forked from cbdb-project/cbdb-online-main-server
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathOperationsProposalController.php
More file actions
1379 lines (1164 loc) · 61.7 KB
/
Copy pathOperationsProposalController.php
File metadata and controls
1379 lines (1164 loc) · 61.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
namespace App\Http\Controllers;
use App\Models\Operation;
use App\Repositories\BiogMainRepository;
use App\Repositories\OperationRepository;
use App\Services\AuditLogService;
use App\Services\CharVariantMapService;
use App\Services\NameSearchIndexService;
use App\Support\VariantEquivalentLookup;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
class OperationsProposalController extends Controller {
protected $operationRepository;
protected $nameSearchIndexService;
protected $biogMainRepository;
protected array $tableColumnCache = [];
/**
* 段一/段二/段三:以 v2 mutation handler 重放核准的人物提案(表名 → API resource)。
*
* 這些表的提案原本落到通用行覆寫(applyCreate/Update/DeleteProposal)或 legacy repository
* 委派(applyOffice/PossessionCreate/PossessionUpdate/EventProposal),繞過聚合的
* 派生/護欄/audit/索引同步,或與 direct 編輯是兩份獨立實作。
* 改為重建 {resource, 'direct', operation, personId, targetPk, changes} 重放 direct handler,
* 使核准與直接編輯逐位一致(見 docs/ENTITY_AGGREGATE_ARCHITECTURE.md §4.5)。
*
* 段二(postings/possessions/events)額外把 $auxiliaryPayload(地址副表意圖,
* c_addr/c_addr_id/c_addr_cleared,見 applyViaMutationHandler)併入 changes——這些欄位
* 從不屬於主表白名單,只存在 __proposal_aux,handler 的 handle() 本就會從 changes 抽出它們
* (對齊 PostingMutationHandler/PossessionMutationHandler/EventMutationHandler/
* *CreateHandler 既有的 direct 地址副表同步邏輯)。
*
* 段三(BIOG_MAIN,人物主檔)三種操作各按 direct 語義重放,不照抄子資源形狀:
* - update → BiogMainMutationHandler:核准時把提案 delta 套用到「當下」資料列並重跑
* BasicInformationRequest 驗證(含「名(中)/拼音名原值非空即不可清空」護欄,取代先前
* 控制器層的 NO_CLEAR_COLUMNS_ON_APPLY——語義等價且 direct/proposal 同一份)。
* - delete → BiogMainDeleteHandler:人物「刪除」是軟刪除(c_name_chn='<待删除>' 的 UPDATE)。
* 先前通用 applyDeleteProposal() 會對 BIOG_MAIN 做**物理 DELETE**——與 direct 語義相反,
* 且在入邊 FK 尚為 CASCADE 期間會靜默連鎖刪除 25 張子表資料(見
* docs/CASCADE_TO_RESTRICT_MIGRATION_NOTES.md §11.1)。現行無任何提交端會產生
* BIOG_MAIN 的 TYPE_PROPOSAL_DELETE,此路由是防禦性封洞。
* - create → BiogMainCreateHandler:帶 c_personid 驗證(非 0、不得已存在、不得過大)與
* 欄位白名單,取代先前的盲 Eloquent create(僅 legacy 提交路由理論可達)。
*
* 不含:委派檔 KIN_DATA/ASSOC_DATA(兩人互為鏡像的親屬/社會關係,核准時的鏡像衝突語義
* 仍在 legacy 那側、需先裁定域邏輯才能收斂,見 docs/PERSON_PROPOSAL_PATHS.md §5.1);
* code 表提案走 CodesController 自己的核准路徑、不經此。
* auth 無礙:canReviewProposals() 與 canWriteDirectly() 為同一謂詞,能到核准端點者必過 authorizeDirect()。
*/
protected const HANDLER_ROUTED_RESOURCES = [
'BIOG_MAIN' => 'basicinformation',
'ALTNAME_DATA' => 'altnames',
'BIOG_ADDR_DATA' => 'addresses',
'ENTRY_DATA' => 'entries',
'STATUS_DATA' => 'statuses',
'BIOG_TEXT_DATA' => 'texts',
'BIOG_SOURCE_DATA' => 'sources',
'BIOG_INST_DATA' => 'social_institutions',
'POSTED_TO_OFFICE_DATA' => 'postings',
'POSSESSION_DATA' => 'possessions',
'EVENTS_DATA' => 'events',
];
public function __construct(
OperationRepository $operationRepository,
NameSearchIndexService $nameSearchIndexService,
BiogMainRepository $biogMainRepository
) {
$this->operationRepository = $operationRepository;
$this->nameSearchIndexService = $nameSearchIndexService;
$this->biogMainRepository = $biogMainRepository;
}
public function approve(Request $request, Operation $operation) {
$this->ensureCanReview($operation);
$payload = $this->decodeResourceData($operation);
$original = $this->decodeResourceOriginal($operation);
$table = $operation->resource;
$keyColumns = $payload['__key_columns'] ?? [];
$opType = (int) $operation->op_type;
// 實體聚合提案(§4.5):resource 為聚合 API 名、跨多表,不走下方「單表列」機制。
// 以 mode=direct 重放對應 EntityAggregate handler(validate→guardWrite→service),
// direct 與 proposal 天然對等。
if ($payload['__entity_aggregate'] ?? false) {
return $this->approveEntityAggregateProposal($request, $operation, $payload);
}
if (empty($keyColumns)) {
flash('審核失敗:提案缺少主鍵資訊。', 'error');
return redirect()->back();
}
$data = $this->normalizeRowForTable($table, $this->sanitizePayload($payload, $table));
$original = $this->normalizeRowForTable($table, $original);
$auxiliaryPayload = $this->extractAuxiliaryPayload($payload, $table);
$comment = trim((string) $request->input('review_comment', ''));
// 核准=一次實際寫入:稽核欄一律蓋核准當下,署名採雙人名「審核人 (Proposed by: 提案人)」。
// override 覆蓋套用期間所有經 ToolsRepository::timestamp() 的寫入(含 handler 重放與鏡像同步)。
$proposerName = is_array($payload['__proposal_meta'] ?? null)
? ($payload['__proposal_meta']['submitted_by'] ?? null)
: null;
\App\Support\AuditActor::override(
\App\Support\AuditActor::approvalName(is_scalar($proposerName) ? (string) $proposerName : null)
);
$this->lastAppliedOperationId = null;
try {
DB::transaction(function () use ($opType, $table, $data, $keyColumns, $original, $operation, $comment, $auxiliaryPayload) {
[$appliedRow, $usedDirectWorkflow] = $this->applyProposal(
$operation,
$table,
$data,
$keyColumns,
$original,
$auxiliaryPayload
);
if (!$usedDirectWorkflow) {
$finalOperation = $this->logFinalOperation($operation, $appliedRow, $original, $opType);
$this->writeAuditLogForApproval($operation, $appliedRow, $original, $opType);
// 社會關係/親屬刪除核准:在 final delete operation 建立後同步刪除反向鏡像列,
// 使鏡像 audit 掛同一 operation id(與 direct delete 一致,避免單向孤兒且審計鏈完整)。
if ($opType === Operation::TYPE_PROPOSAL_DELETE && $table === 'ASSOC_DATA') {
app(\App\Repositories\BiogMainRepository::class)->syncAssocMirrorOnDelete($appliedRow, $finalOperation);
}
if ($opType === Operation::TYPE_PROPOSAL_DELETE && $table === 'KIN_DATA') {
// 核准為非互動路徑,沿用「刪除全部對應反向列」語義($force=true):取得 #81 §6 廣集孤兒修正,
// 不在此拋多筆確認閘(是否於核准路徑加偵測閘由 #82 統一評估)。
app(\App\Repositories\BiogMainRepository::class)->syncKinMirrorOnDelete($appliedRow, $finalOperation, null, true);
}
}
$this->updateProposalStatus(
$operation,
'approved',
$comment,
$opType === Operation::TYPE_PROPOSAL_CREATE ? $appliedRow : null,
$keyColumns,
$opType === Operation::TYPE_PROPOSAL_CREATE,
$this->lastAppliedOperationId
);
});
} catch (ValidationException $e) {
$messages = $e->validator->errors()->all();
$detail = implode(';', $messages);
Log::warning('提案核准失敗(驗證錯誤)', [
'operation_id' => $operation->id,
'table' => $table,
'errors' => $messages,
]);
flash('審核失敗:'.$detail, 'error');
return redirect()->back();
} catch (\App\Services\Mutations\MirrorConflictException|\App\Services\Mutations\MirrorSuspectedException $e) {
// #77:核准社會關係/親屬更新提案時,偵測到對面互逆鏡像列已被獨立改動(內容分歧/關係碼漂移)或資料完整性問題。
// 整筆交易已回滾、提案未核准——避免靜默覆寫對方資料。回友善中文提示(不外洩底層 SQL),引導審核者先至對面確認。
Log::warning('提案核准中止:對面鏡像分歧/疑似', [
'operation_id' => $operation->id,
'table' => $table,
'exception' => get_class($e),
]);
flash('審核未通過:偵測到對應的反向關係列已被獨立修改(內容或關係碼不一致)。為避免覆寫對方資料,已中止此次核准——請先至對應人物頁確認/修正反向關係後再核准。', 'error');
return redirect()->back();
} catch (\App\Services\Mutations\MirrorIntegrityException $e) {
// fail-closed:鏡像同步所需的配對碼/權威反向碼缺失,若繼續核准會造成單邊刪除或假成功。
Log::warning('提案核准中止:鏡像資料完整性 fail-closed', [
'operation_id' => $operation->id,
'table' => $table,
'exception' => get_class($e),
'message' => $e->getMessage(),
]);
flash('審核未通過:對應的反向關係資料完整性異常,為避免產生單邊刪除或不一致鏡像,已中止此次核准。請先檢查關係碼與對應人物資料後再重試。', 'error');
return redirect()->back();
} catch (\Illuminate\Database\QueryException $e) {
// #77:DB 層錯誤(如核准 create 提案時對面已存在等價鏡像導致主鍵衝突)→ 整筆已回滾。
// 回友善中文提示,**不外洩原始 SQL/錯誤字串**給審核者(完整訊息只進 log)。
Log::error('提案核准失敗(資料庫錯誤)', [
'operation_id' => $operation->id,
'table' => $table,
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile().':'.$e->getLine(),
]);
flash('審核失敗:資料庫操作發生衝突或錯誤(可能對應記錄已存在或已被變更),本次未核准。請重新整理後確認資料狀態,或聯絡管理員。', 'error');
return redirect()->back();
} catch (\Throwable $e) {
Log::error('提案核准失敗', [
'operation_id' => $operation->id,
'table' => $table,
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile().':'.$e->getLine(),
]);
flash('審核失敗:'.$e->getMessage(), 'error');
return redirect()->back();
} finally {
\App\Support\AuditActor::clear();
}
flash('提案已核准並套用至資料表 @ '.Carbon::now(), 'success');
return redirect()->back();
}
/**
* 核准實體聚合提案(§4.5):以 mode=direct 重放對應 EntityAggregate handler。
* handler 自身在交易內 validate→guardWrite→service(寫 operation+audit+配套表),
* 本處只負責重放、標記提案已核准,並把 handler 的友善錯誤(422/404/409)轉為 flash。
*/
protected function approveEntityAggregateProposal(Request $request, Operation $operation, array $payload) {
$resource = (string) ($payload['__entity_resource'] ?? '');
$entityOperation = (string) ($payload['__entity_operation'] ?? '');
$entityPk = $payload['__entity_pk'] ?? null;
$changes = is_array($payload['changes'] ?? null) ? $payload['changes'] : [];
$personId = (int) ($operation->c_personid ?? 0);
$comment = trim((string) $request->input('review_comment', ''));
$definition = app(\App\Services\Mutations\EntityAggregate\EntityAggregateDefinitionRegistry::class)
->forResource($resource);
if ($definition === null || !in_array($entityOperation, ['create', 'update', 'delete'], true)) {
flash('審核失敗:無法識別的實體聚合提案。', 'error');
return redirect()->back();
}
$handler = app(\App\Services\Mutations\MutationHandlerRegistry::class)
->resolve($resource, 'direct', $entityOperation);
if ($handler === null) {
flash('審核失敗:找不到對應的實體寫入 handler。', 'error');
return redirect()->back();
}
$pkField = $definition->pkField();
$targetPk = $entityPk !== null ? [$pkField => $entityPk] : [];
// 同單表核准:稽核署名採雙人名「審核人 (Proposed by: 提案人)」。
$proposerName = is_array($payload['__proposal_meta'] ?? null)
? ($payload['__proposal_meta']['submitted_by'] ?? null)
: null;
\App\Support\AuditActor::override(
\App\Support\AuditActor::approvalName(is_scalar($proposerName) ? (string) $proposerName : null)
);
try {
DB::transaction(function () use (
$handler,
$resource,
$entityOperation,
$personId,
$targetPk,
$changes,
$operation,
$comment,
$pkField
) {
$response = $handler->handle($resource, 'direct', $entityOperation, $personId, $targetPk, $changes, []);
$status = $response->getStatusCode();
$body = json_decode($response->getContent(), true);
if ($status < 200 || $status >= 300) {
$message = is_array($body) ? (string) ($body['message'] ?? '提案套用失敗') : '提案套用失敗';
throw new \RuntimeException($message);
}
// create:把 handler 配發的新主鍵記回提案(resource_id 指向已建立的實體)。
$appliedPk = is_array($body['result']['pk'] ?? null) ? $body['result']['pk'] : null;
$this->updateProposalStatus(
$operation,
'approved',
$comment,
$entityOperation === 'create' ? $appliedPk : null,
$entityOperation === 'create' ? [$pkField] : [],
$entityOperation === 'create'
);
});
} catch (ValidationException $e) {
$detail = implode(';', $e->validator->errors()->all());
Log::warning('實體聚合提案核准失敗(驗證錯誤)', ['operation_id' => $operation->id, 'resource' => $resource, 'errors' => $detail]);
flash('審核失敗:'.$detail, 'error');
return redirect()->back();
} catch (\Throwable $e) {
Log::error('實體聚合提案核准失敗', [
'operation_id' => $operation->id,
'resource' => $resource,
'exception' => get_class($e),
'message' => $e->getMessage(),
]);
flash('審核失敗:'.$e->getMessage(), 'error');
return redirect()->back();
} finally {
\App\Support\AuditActor::clear();
}
flash('提案已核准並套用 @ '.Carbon::now(), 'success');
return redirect()->back();
}
public function reject(Request $request, Operation $operation) {
$this->ensureCanReview($operation);
$comment = trim((string) $request->input('review_comment', ''));
$this->updateProposalStatus($operation, 'rejected', $comment);
flash('提案已退回 @ '.Carbon::now(), 'info');
return redirect()->back();
}
protected function ensureCanReview(Operation $operation): void {
if (!Auth::check() || !Auth::user()->canReviewProposals()) {
abort(403, '無權審核提案。');
}
$opType = (int) $operation->op_type;
if (!in_array($opType, [Operation::TYPE_PROPOSAL_CREATE, Operation::TYPE_PROPOSAL_UPDATE, Operation::TYPE_PROPOSAL_DELETE], true)) {
abort(404);
}
}
protected function decodeResourceData(Operation $operation): array {
$payload = json_decode($operation->resource_data, true);
return is_array($payload) ? $payload : [];
}
protected function decodeResourceOriginal(Operation $operation): array {
$original = json_decode($operation->resource_original, true);
return is_array($original) ? $original : [];
}
protected function sanitizePayload(array $payload, ?string $table = null): array {
$sanitized = [];
$columns = $this->getTableColumnMap($table);
foreach ($payload as $key => $value) {
if (is_string($key) && strpos($key, '__') === 0) {
continue;
}
if ($columns !== null && is_string($key) && !isset($columns[$key])) {
continue;
}
$sanitized[$key] = $value;
}
return $sanitized;
}
protected function extractAuxiliaryPayload(array $payload, string $table): array {
$auxiliary = [];
$storedAuxiliary = $payload['__proposal_aux'] ?? null;
if (is_array($storedAuxiliary)) {
$auxiliary = $storedAuxiliary;
}
$columns = $this->getTableColumnMap($table);
if ($columns === null) {
return $auxiliary;
}
foreach ($payload as $key => $value) {
if (!is_string($key) || strpos($key, '__') === 0) {
continue;
}
if (!isset($columns[$key])) {
$auxiliary[$key] = $value;
}
}
return $auxiliary;
}
protected function getTableColumnMap(?string $table): ?array {
if ($table === null || $table === '' || !Schema::hasTable($table)) {
return null;
}
if (!array_key_exists($table, $this->tableColumnCache)) {
$this->tableColumnCache[$table] = array_flip(Schema::getColumnListing($table));
}
return $this->tableColumnCache[$table];
}
protected function applyProposal(
Operation $operation,
string $table,
array $data,
array $keyColumns,
array $original,
array $auxiliaryPayload
): array {
// 段一:已遷移的人物子資源 CREATE/UPDATE/DELETE 一律由 v2 direct handler 重放,使核准與直接編輯
// 逐位一致(派生/護欄/audit/索引同步)。usedDirectWorkflow=true —— handler 自寫 operation + audit,
// approve() 不再補記。
//
// 「同主鍵已有待審核提案則拒」去重護欄與重放的關係:AbstractPersonSubresourceCreateHandler 的護欄在
// handleProposal() 內、direct 路徑不經過,故不受影響;SourceMutationHandler 的護欄在 mode 分派之前、
// direct 亦會跑,故以 meta.__approving_operation_id 排除「正在核准的自己」(見 applyViaMutationHandler)。
if (isset(self::HANDLER_ROUTED_RESOURCES[$table])) {
return [$this->applyViaMutationHandler($operation, $table, $data, $keyColumns, $original, $auxiliaryPayload), true];
}
if ((int) $operation->op_type === Operation::TYPE_PROPOSAL_DELETE) {
return [$this->applyDeleteProposal($table, $keyColumns, $original), false];
}
if ($table === 'KIN_DATA') {
return [$this->applyKinshipProposal($operation, $data, $original, $auxiliaryPayload), true];
}
if ($table === 'ASSOC_DATA') {
return [$this->applyAssocProposal($operation, $data, $original, $auxiliaryPayload), true];
}
if ((int) $operation->op_type === Operation::TYPE_PROPOSAL_CREATE) {
return [$this->applyCreateProposal($table, $data, $keyColumns), false];
}
return [$this->applyUpdateProposal($table, $data, $keyColumns, $original), false];
}
/**
* 段一核准重放:把提案還原成一次 direct mutation,交回同一個 v2 handler 落庫。
*
* 存的 resource_data 是「合併後的行快照」(update 時 data = original ∪ updateData),據此還原意圖:
* - create:targetPk = data 的鍵欄;changes = data 全量(**含**鍵欄,不剔除)。鍵欄同時留在
* changes 對 AbstractPersonSubresourceCreateHandler 系handler 是 no-op(其
* allowedFields() 本含鍵欄,且 handle() 固定 merge(targetPk, changes) 組回整列);
* 但對 bespoke 的 PostingCreateHandler 是必要的——c_office_id 既是 POSTED_TO_OFFICE_DATA
* 複合鍵之一,也是該 handler 直接從 changes 讀取(未經 targetPk 合併)的必填欄位,
* 剔除會導致「缺少 c_office_id」(段二踩坑,見下方 changes 賦值處註解)。
* - update:targetPk = original 的鍵欄;changes = data 相對 original 有差異的欄位(含被改動的鍵欄,
* handler 內以 buildNewPk 處理改鍵)。因 data=original∪updateData,差集恰為使用者變更。
* - delete:targetPk = original 的鍵欄;changes = []。
*
* $auxiliaryPayload(postings/possessions/events 專用):地址副表意圖(c_addr/c_addr_id/
* c_addr_cleared)從不屬於主表欄位白名單,提案送出時只存進 __proposal_aux(見
* PostingMutationHandler::proposalAuxiliaryPayload() 等),故 data/original 的差集抓不到它,
* 需顯式併入 changes——handler 的 handle() 本就會從 changes 抽出這些鍵(對齊其 direct 路徑)。
* 只挑 ADDRESS_AUX_KEYS 這幾個已知鍵合併,不整包塞入:__proposal_aux 舊資料可能還帶著
* legacy applyOfficeProposal() 時代寫入的 _id/_postingid/_officeid(僅供已刪除的 legacy
* 委派方法定位記錄用),這些鍵不是 handler 認得的欄位,整包合併會被白名單擋下(422/
* RuntimeException)。其餘 7 張已收斂的表無此類欄,過濾後恆為 [],合併為 no-op。
*/
private const ADDRESS_AUX_KEYS = ['c_addr', 'c_addr_id', 'c_addr_cleared'];
/**
* 系統代管的稽核欄。提案 payload 是「快照」語義、可能含這四欄(legacy 提案入口存整列,update
* 提案 data=original∪changes 也天然含),但 handler 的 changes 是「使用者意圖」語義、白名單
* 刻意不含稽核欄——重放前必須剔除,否則核准直接 422(disallowed_fields)。剔除後由
* ToolsRepository::timestamp() 以核准當下+雙人名署名重新蓋章(見 AuditActor)。
*/
private const AUDIT_COLUMNS = ['c_created_by', 'c_created_date', 'c_modified_by', 'c_modified_date'];
/**
* 本次核准經 handler 重放實際落庫的 direct operation id。audit_log 掛在該 id 而非提案列 id,
* 核准後寫回提案 payload(__applied_operation_id)供 operations 列表認領 audit(「比較」按鈕)。
* kinship/assoc bespoke 路徑(BiogMainRepository 內部自建 operation)目前未回報 id,維持 null。
*/
private ?string $lastAppliedOperationId = null;
protected function applyViaMutationHandler(
Operation $operation,
string $table,
array $data,
array $keyColumns,
array $original,
array $auxiliaryPayload = []
): array {
$resource = self::HANDLER_ROUTED_RESOURCES[$table];
$opType = (int) $operation->op_type;
$addressAux = array_intersect_key($auxiliaryPayload, array_flip(self::ADDRESS_AUX_KEYS));
// 這些子資源以 row 內 c_personid 為權威人物(handler 會校驗 target.pk.c_personid 與 person_id 一致);
// operation->c_personid 對舊/測試資料可能為 0,故不可優先。用 null 合併避免 `0 ?? x` 的陷阱。
$personId = (int) ($data['c_personid'] ?? $original['c_personid'] ?? ($operation->c_personid ?: 0));
// update/delete 皆以 original 定位目標列;缺 original 無從定位——沿用通用路徑的清晰契約
// (比 handler 內「主鍵格式不正確」更有指向性)。create 無 original,不適用。
if ($original === [] && $opType !== Operation::TYPE_PROPOSAL_CREATE) {
throw new \RuntimeException(
$opType === Operation::TYPE_PROPOSAL_DELETE ? '缺少原始資料,無法刪除。' : '缺少原始資料,無法更新。'
);
}
if ($opType === Operation::TYPE_PROPOSAL_CREATE) {
$handlerOperation = 'create';
$targetPk = $this->pickColumns($data, $keyColumns);
$changes = array_merge($data, $addressAux);
} elseif ($opType === Operation::TYPE_PROPOSAL_DELETE) {
$handlerOperation = 'delete';
$targetPk = $this->pickColumns($original, $keyColumns);
$changes = [];
} else {
$handlerOperation = 'update';
$targetPk = $this->pickColumns($original, $keyColumns);
// 稽核欄雖多半在 diff 中因兩快照相等而抵銷,但只要序列化格式有絲毫差異就會漏進來,
// 與 create 同樣會被白名單擋下——一併剔除(下方統一處理)。
$changes = array_merge($this->diffChangedColumns($original, $data), $addressAux);
}
// 快照 → 意圖的翻譯:剔除系統代管稽核欄(見 AUDIT_COLUMNS 註解)。
$changes = array_diff_key($changes, array_flip(self::AUDIT_COLUMNS));
/** @var \App\Services\Mutations\MutationHandlerRegistry $registry */
$registry = app(\App\Services\Mutations\MutationHandlerRegistry::class);
$handler = $registry->resolve($resource, 'direct', $handlerOperation);
if ($handler === null) {
throw new \RuntimeException("找不到 {$resource}/{$handlerOperation} 的 mutation handler,無法套用提案。");
}
// __approving_operation_id:讓 handler 的「同主鍵已有待審核提案則拒」護欄排除正在核准的這一筆
// (否則核准 create 時會被自己擋下)。目前僅 SourceMutationHandler 於 direct 路徑跑該護欄。
$meta = ['__approving_operation_id' => $operation->id];
$response = $handler->handle($resource, 'direct', $handlerOperation, $personId, $targetPk, $changes, $meta);
$status = $response->getStatusCode();
$body = json_decode($response->getContent(), true);
if ($status < 200 || $status >= 300) {
$message = is_array($body) ? (string) ($body['message'] ?? '提案套用失敗') : '提案套用失敗';
// handler 的欄位級錯誤(如 BIOG_MAIN 的「名不能為空」)對審核者有指向性,攤平附在訊息後。
$fieldErrors = is_array($body['errors'] ?? null)
? implode(';', array_map(
static fn ($messages) => implode(';', array_map('strval', (array) $messages)),
$body['errors']
))
: '';
if ($fieldErrors !== '') {
$message .= ':'.$fieldErrors;
}
// 交回外層交易回滾;訊息不外洩底層細節(approve() 已有 ValidationException/QueryException 友善提示)。
throw new \RuntimeException("提案套用失敗({$resource}/{$handlerOperation}):{$message}");
}
$this->lastAppliedOperationId = isset($body['result']['operation_id']) && $body['result']['operation_id'] !== null
? (string) $body['result']['operation_id']
: null;
if ($opType === Operation::TYPE_PROPOSAL_DELETE) {
// appliedRow 於刪除後僅供日誌,回傳刪除前的原始列即可(下游 updateProposalStatus 只在 create 用到)。
return $original;
}
// 讀回套用後的資料列:以 handler 回報的新主鍵定位(改鍵時 result.pk 為新鍵)。
$newPk = is_array($body) && isset($body['result']['pk']) && is_array($body['result']['pk'])
? $this->pickColumns($body['result']['pk'], $keyColumns)
: $targetPk;
return $this->fetchAppliedRow($table, $newPk) ?? array_merge($original, $data);
}
/** 從 $row 取出 $columns 指定的欄位(缺欄跳過)。 */
protected function pickColumns(array $row, array $columns): array {
$out = [];
foreach ($columns as $column) {
if (array_key_exists($column, $row)) {
$out[$column] = $row[$column];
}
}
return $out;
}
/** $data 相對 $original 有差異(stringwise)的欄位;作為 direct update 的 changes。 */
protected function diffChangedColumns(array $original, array $data): array {
$changes = [];
foreach ($data as $column => $value) {
if (!array_key_exists($column, $original) || (string) $original[$column] !== (string) $value) {
$changes[$column] = $value;
}
}
return $changes;
}
protected function applyKinshipProposal(
Operation $operation,
array $data,
array $original,
array $auxiliaryPayload
): array {
$personId = (int) ($operation->c_personid ?? $data['c_personid'] ?? $original['c_personid'] ?? 0);
$requestPayload = array_merge($data, $auxiliaryPayload);
$request = Request::create('/', 'POST', $requestPayload);
if ((int) $operation->op_type === Operation::TYPE_PROPOSAL_CREATE) {
// #82:核准 CREATE 啟用鏡像衝突/疑似偵測(對齊 v2 direct create)——對面分歧/碼漂移則拋例外中止核准,不盲插衝突鏡像。
return $this->biogMainRepository->kinshipStoreById($request, $personId, true);
}
if (empty($original)) {
throw new \RuntimeException('缺少原始資料,無法更新。');
}
$result = $this->biogMainRepository->kinshipUpdateById(
$request,
$personId,
$this->buildLegacyKinshipId($original),
true // #77:核准時啟用鏡像衝突/疑似偵測——對面鏡像已分歧/碼漂移則拋例外中止核准(不靜默覆寫)
);
$mirrorStatus = (int) ($result['err'] ?? 1);
unset($result['err']);
if ($mirrorStatus === 0) {
throw new \RuntimeException('對應的親屬資料更新失敗,請從對應的親屬人物修改。');
}
if ($mirrorStatus > 1) {
throw new \RuntimeException('對應的親屬資料有多筆重複,請從對應的親屬人物修改。');
}
return $result;
}
protected function applyAssocProposal(
Operation $operation,
array $data,
array $original,
array $auxiliaryPayload
): array {
$personId = (int) ($operation->c_personid ?? $data['c_personid'] ?? $original['c_personid'] ?? 0);
$request = Request::create('/', 'POST', array_merge($data, $auxiliaryPayload));
if ((int) $operation->op_type === Operation::TYPE_PROPOSAL_CREATE) {
// #82:核准 CREATE 啟用鏡像衝突/疑似偵測(對齊 v2 direct create)。
$result = $this->biogMainRepository->assocStoreById($request, $personId, true);
return $this->fetchAppliedRow('ASSOC_DATA', [
'c_personid' => $result['c_personid'] ?? $personId,
'c_assoc_code' => $result['c_assoc_code'] ?? null,
'c_assoc_id' => $result['c_assoc_id'] ?? null,
'c_kin_code' => $result['c_kin_code'] ?? null,
'c_kin_id' => $result['c_kin_id'] ?? null,
'c_assoc_kin_code' => $result['c_assoc_kin_code'] ?? null,
'c_assoc_kin_id' => $result['c_assoc_kin_id'] ?? null,
'c_text_title' => $result['c_text_title'] ?? '',
'c_assoc_first_year' => $result['c_assoc_first_year'] ?? '-9999',
]) ?? $result;
}
if (empty($original)) {
throw new \RuntimeException('缺少原始資料,無法更新。');
}
$result = $this->biogMainRepository->assocUpdateById(
$request,
$this->buildLegacyAssocId($original),
$personId,
true // #77:核准時啟用鏡像衝突/疑似偵測——對面鏡像已分歧/碼漂移則拋例外中止核准(不靜默覆寫)
);
if ($result === []) {
throw new \RuntimeException('資料不存在或已被刪除,無法更新。');
}
return $this->fetchAppliedRow('ASSOC_DATA', [
'c_personid' => $personId,
'c_assoc_code' => $result['c_assoc_code'] ?? $original['c_assoc_code'] ?? null,
'c_assoc_id' => $result['c_assoc_id'] ?? $original['c_assoc_id'] ?? null,
'c_kin_code' => $result['c_kin_code'] ?? $original['c_kin_code'] ?? null,
'c_kin_id' => $result['c_kin_id'] ?? $original['c_kin_id'] ?? null,
'c_assoc_kin_code' => $result['c_assoc_kin_code'] ?? $original['c_assoc_kin_code'] ?? null,
'c_assoc_kin_id' => $result['c_assoc_kin_id'] ?? $original['c_assoc_kin_id'] ?? null,
'c_text_title' => $result['c_text_title'] ?? $original['c_text_title'] ?? '',
'c_assoc_first_year' => $result['c_assoc_first_year'] ?? $original['c_assoc_first_year'] ?? '-9999',
]) ?? array_merge($original, $result);
}
protected function buildLegacyKinshipId(array $original): string {
foreach (['c_personid', 'c_kin_id', 'c_kin_code'] as $column) {
if (!array_key_exists($column, $original)) {
throw new \RuntimeException("缺少 {$column},無法更新親屬提案。");
}
}
return implode('-', [
$original['c_personid'],
$original['c_kin_id'],
$original['c_kin_code'],
]);
}
protected function buildLegacyAssocId(array $original): string {
$required = ['c_personid', 'c_assoc_code', 'c_assoc_id', 'c_kin_code', 'c_kin_id', 'c_assoc_kin_code', 'c_assoc_kin_id'];
foreach ($required as $column) {
if (!array_key_exists($column, $original)) {
throw new \RuntimeException("缺少 {$column},無法更新社會關係提案。");
}
}
$assocFirstYear = (string) ($original['c_assoc_first_year'] ?? '-9999');
return implode('-', [
$original['c_personid'],
$original['c_assoc_code'],
$original['c_assoc_id'],
$original['c_kin_code'],
$original['c_kin_id'],
$original['c_assoc_kin_code'],
$original['c_assoc_kin_id'],
$this->biogMainRepository->unionPKDef($original['c_text_title'] ?? ''),
str_replace('-', '(minus)', $assocFirstYear),
]);
}
protected function fetchAppliedRow(string $table, array $conditions): ?array {
$conditions = array_filter($conditions, static fn ($value) => $value !== null);
if ($conditions === []) {
return null;
}
$query = DB::table($table);
foreach ($conditions as $column => $value) {
$query->where($column, $value);
}
$row = $query->first();
return $row ? $this->convertRowToArray($row) : null;
}
/**
* 提案核准時對 payload 做異體字落地替換(型別驅動,範圍由 VariantReplaceScope 決定)。
*
* payload 到此**已由 `sanitizePayload()` 收斂成純欄位**(`__` 前綴的內部鍵與非欄位鍵都
* 已被剃掉),所以這裡處理的就是資料欄。`replaceRow()` 的型別閘門與
* `EXCLUDED_COLUMNS_ANY_TABLE`(稽核欄)是第二層保險——**不要**因為看到這段而以為
* 可以把呼叫點往上搬到 `sanitizePayload()` 之前。char_variant_map 整表排除,不受影響。
*
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
protected function replaceVariantsForApproval(string $table, array $data): array {
return CharVariantMapService::replaceRow($data, $table)['data'];
}
protected function applyCreateProposal(string $table, array $data, array $keyColumns): array {
// 異體字落地替換:**掛在方法最上方、buildKeyConditions() 之前**,不是「insert 之前」。
// 下方 :753 的重複檢查也用 $data 組主鍵條件,若替換晚於它,就會出現「查重用替換前
// 的值、落庫用替換後的值」的錯位(§1.3 明文禁止的形狀)。
//
// 這是**雙保險**:提案建立端(S2/S3/S5)存進 payload 的已是替換後值,本步是對
// 歷史遺留 payload(那些在落地替換上線前送出的提案)補網;依 D8 重複套用是幂等的。
$data = $this->replaceVariantsForApproval($table, $data);
$data = $this->assignAutoKeyIfNeeded($table, $keyColumns, $data);
$data = $this->enforceAuditFieldsForCreate($table, $data);
if (!$this->hasKeyValues($keyColumns, $data, $this->optionalKeyColumnsForTable($table))) {
throw new \RuntimeException('缺少主鍵欄位,無法新增資料。');
}
$existing = DB::table($table)->where($this->buildKeyConditions($keyColumns, $data))->first();
if ($existing) {
throw new \RuntimeException('資料已存在,無法再次新增。');
}
// D7「兩形並存」:上面是替換後值的**精確**比對,擋不住「歸一後相同但字形不同」的
// 既有列(那是兩個不同的鍵值,唯一鍵也不會衝突)。今天走這條分支的表其文本型 PK
// 成員都在排除清單內,所以 findExistingRow() 會在 $inScope === [] 直接 return null
// =零額外查詢;掛著是為了日後任何文本型 PK 的表落到這條分支時不會靜默分裂。
if (VariantEquivalentLookup::findExistingRow($table, $keyColumns, $data) !== null) {
throw new \RuntimeException('已存在異體字歸一後相同的紀錄(字形不同),無法再次新增。');
}
// char_variant_map 走這條通用核准路徑(不在 HANDLER_ROUTED_RESOURCES):落庫前必須
// 驗結構。提交端的守衛(AbstractCodeTableMutationHandler)擋不到**歷史待審提案**,
// 也擋不到任何不經該 handler 建立的提案;成環的對照一旦落庫,dropCycleEdges() 會把
// 環上兩條邊一起丟掉、只留 Log::error,該組字的落地替換在全站靜默停止。
if (strtolower($table) === 'char_variant_map') {
CharVariantMapService::assertWritable($data);
}
DB::table($table)->insert($data);
// 落庫後重置快取,否則新對照在該 process 的剩餘生命週期內不生效。
if (strtolower($table) === 'char_variant_map') {
CharVariantMapService::reset();
}
$row = DB::table($table)->where($this->buildKeyConditions($keyColumns, $data))->first();
if (!$row) {
throw new \RuntimeException('新增後讀取資料失敗。');
}
// 特殊處理:ALTNAME_DATA 需要手動調用索引服務
if ($table === 'ALTNAME_DATA') {
$this->indexAltnameAfterCreate($data);
}
return $this->convertRowToArray($row);
}
protected function applyUpdateProposal(string $table, array $data, array $keyColumns, array $original): array {
if (empty($original)) {
throw new \RuntimeException('缺少原始資料,無法更新。');
}
// 異體字落地替換:同樣掛在最上方。$conditions 用 $original(既有列的實際值)定位,
// 不受替換影響;而 buildUpdatePayload() 與改鍵碰撞偵測都讀 $data,必須看到替換後的值。
$data = $this->replaceVariantsForApproval($table, $data);
$data = $this->enforceAuditFieldsForUpdate($table, $data, $original);
$conditions = $this->buildKeyConditions($keyColumns, $original);
$current = DB::table($table)->where($conditions)->first();
if (!$current) {
throw new \RuntimeException('資料不存在或已被刪除,無法更新。');
}
$updatePayload = $this->buildUpdatePayload($data, $keyColumns, $original);
// 改鍵碰撞偵測(#117,對齊 direct 路徑):updatePayload 含任一主鍵欄即為改鍵;若變更後的新主鍵
// 已被另一列佔用,擋下並回明確錯誤,避免 UPDATE 撞 DB 複合主鍵約束冒成未處理的 500。
$reKeyedColumns = array_intersect($keyColumns, array_keys($updatePayload));
if (!empty($reKeyedColumns)) {
$newKeyRow = $this->resolveReadbackKeyRow($keyColumns, $original, $updatePayload);
if (DB::table($table)->where($this->buildKeyConditions($keyColumns, $newKeyRow))->exists()) {
throw new \RuntimeException('變更後的主鍵與現有記錄重複,無法核准此改鍵提案。');
}
// D7:同上,精確比對擋不住等價字形。排除自己(改鍵前那一列)交給 lookup 內部。
$selfPk = array_intersect_key($original, array_flip($keyColumns));
if (VariantEquivalentLookup::findExistingRow($table, $keyColumns, $newKeyRow, [$selfPk]) !== null) {
throw new \RuntimeException('變更後的主鍵與現有記錄異體字歸一後相同,無法核准此改鍵提案。');
}
}
// char_variant_map 走的是這條通用核准路徑(不在 HANDLER_ROUTED_RESOURCES):
// 落庫前必須驗結構(單一 codepoint、不成環),否則成環的對照核准後會讓
// CharVariantMapService::dropCycleEdges() 把環上兩條邊一起丟掉、只留 Log::error,
// 該組字的落地替換在全站靜默停止(提案階段的守衛見 AbstractCodeTableMutationHandler)。
if (strtolower($table) === 'char_variant_map' && !empty($updatePayload)) {
CharVariantMapService::assertWritable($updatePayload, isset($original['id']) ? (int) $original['id'] : null);
}
if (!empty($updatePayload)) {
DB::table($table)->where($conditions)->update($updatePayload);
}
// 落庫後重置對照表快取,否則核准後的對照在該 process 的剩餘生命週期內不生效。
if (strtolower($table) === 'char_variant_map') {
CharVariantMapService::reset();
}
$readKeyRow = $this->resolveReadbackKeyRow($keyColumns, $original, $updatePayload);
$readConditions = $this->buildKeyConditions($keyColumns, $readKeyRow);
$row = DB::table($table)->where($readConditions)->first();
if (!$row) {
throw new \RuntimeException('更新後讀取資料失敗。');
}
// 特殊處理:ALTNAME_DATA 需要手動調用索引服務
if ($table === 'ALTNAME_DATA') {
$this->indexAltnameAfterUpdate($original, $data);
}
return $this->convertRowToArray($row);
}
/**
* 套用刪除提案:以 __key_columns + original 的 PK 值定位目標列並刪除。
* POSTED_TO_OFFICE_DATA/POSSESSION_DATA 已收斂至 HANDLER_ROUTED_RESOURCES(段二),
* 副表連帶刪除改由 PostingDeleteHandler/PossessionDeleteHandler 委派既有 repository
* 方法處理,此處不再需要特例。
* 回傳被刪除前的原始列(供 logFinalOperation/audit 使用);目標列不存在則回傳空陣列。
*/
protected function applyDeleteProposal(string $table, array $keyColumns, array $original): array {
if (empty($original)) {
throw new \RuntimeException('缺少原始資料,無法刪除。');
}
if (empty($keyColumns)) {
throw new \RuntimeException('提案缺少主鍵資訊,無法刪除。');
}
$conditions = $this->buildKeyConditions($keyColumns, $original);
$row = DB::table($table)->where($conditions)->first();
if (!$row) {
// 刻意**不替換** $original(delete 的定位器就該是歷史快照當時的字形)。但自本階段
// 起,文本型 PK 成員(ASSOC_DATA.c_text_title、ALTNAME_DATA.c_alt_name_chn)**真的會**
// 被核准改寫,於是「待審的 delete 提案指向舊字形、目標列已被另一筆核准改名」從理論
// 變成常態。若直接當成冪等成功,approve() 會照樣寫一筆 DELETE 稽核並標記 approved,
// 而資料列還在——稽核鏈記錄了一件沒發生的事。所以先用歸一後的 PK 再探一次。
$normalizedOriginal = CharVariantMapService::replaceRow($original, $table)['data'];
$normalizedConditions = $this->buildKeyConditions($keyColumns, $normalizedOriginal);
if ($normalizedConditions !== $conditions) {
$row = DB::table($table)->where($normalizedConditions)->first();
if ($row) {
$conditions = $normalizedConditions;
}
}
}
if (!$row) {
// 兩種字形都找不到:維持既有的冪等成功語義(目標列可能已被正常刪除),
// 但留下痕跡——否則「提案已過期」與「真的刪掉了」在稽核上長得一模一樣。
Log::warning('核准刪除提案時目標列已不存在(含異體字歸一後再探一次),視為冪等成功', [
'table' => $table,
'key_columns' => $keyColumns,
]);
return $original;
}
$deletedRow = $this->convertRowToArray($row);
DB::table($table)->where($conditions)->delete();
// 注意:社會關係反向鏡像刪除移至 approve() 於 logFinalOperation 之後執行,
// 以便鏡像 audit 掛 final delete operation id(見 approve())。
if ($table === 'ALTNAME_DATA') {
$this->indexAltnameAfterDelete($deletedRow);
}
return $deletedRow;
}
/**
* ALTNAME_DATA 核准刪除後移除全文索引。
*/
protected function indexAltnameAfterDelete(array $row): void {
if (!Schema::hasTable('CBDB__NAME_FTS')) {
return;
}
$name = $row['c_alt_name_chn'] ?? null;
$personId = $row['c_personid'] ?? null;
if (empty($name) || $personId === null) {
return;
}
$this->nameSearchIndexService->removeAltname(
$personId,
$row['c_alt_name_type_code'] ?? null,
$name
);
}
protected function buildUpdatePayload(array $data, array $keyColumns, array $original): array {
$updatePayload = array_diff_key($data, array_flip($keyColumns));
foreach ($keyColumns as $column) {
if (!array_key_exists($column, $original) || !array_key_exists($column, $data)) {
continue;
}
if (!$this->keyValuesMatch($data[$column], $original[$column])) {
$updatePayload[$column] = $data[$column];
}
}
return $updatePayload;
}
protected function resolveReadbackKeyRow(array $keyColumns, array $original, array $updatePayload): array {
$row = $original;
foreach ($keyColumns as $column) {
if (array_key_exists($column, $updatePayload)) {
$row[$column] = $updatePayload[$column];
}
}
return $row;
}
protected function keyValuesMatch($left, $right): bool {
if ($left === $right) {
return true;
}
if (is_numeric($left) && is_numeric($right)) {