forked from cbdb-project/cbdb-online-main-server
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAbstractPersonSubresourceCreateHandler.php
More file actions
466 lines (394 loc) · 20.6 KB
/
Copy pathAbstractPersonSubresourceCreateHandler.php
File metadata and controls
466 lines (394 loc) · 20.6 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
<?php
namespace App\Services\Mutations;
use App\Models\Operation;
use App\Repositories\OperationRepository;
use App\Repositories\ToolsRepository;
use App\Services\AuditLogService;
use App\Support\CompositePrimaryKey;
use App\Support\VariantEquivalentLookup;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* 人物子資源 create handler 共用基底類
*
* 抽象出 repeated-form create 的共通邏輯:
* - 驗證 composite PK
* - 驗證 person_id 一致性
* - 驗證白名單欄位
* - 檢查目標 PK 是否已存在
* - direct 新增(含 transaction + operation + audit_log)
* - proposal 寫 operation
* - 統一 response shape
*/
abstract class AbstractPersonSubresourceCreateHandler extends AbstractMutationHandler {
use \App\Services\Mutations\Concerns\AppliesVariantReplacement;
use \App\Services\Mutations\Concerns\RecordsAiFillSubmission;
/** 核准提案重放時「自己那一筆」的 operation id(見 handle());null = 非核准重放。 */
protected ?int $approvingOperationId = null;
protected OperationRepository $operationRepository;
protected AuditLogService $auditLogService;
public function __construct(
OperationRepository $operationRepository,
AuditLogService $auditLogService
) {
$this->operationRepository = $operationRepository;
$this->auditLogService = $auditLogService;
}
// ── 子類必須實作 ─────────────────────────────────────────
/** 資源名稱(回傳用),例如 'altnames' */
abstract protected function resourceName(): string;
/** 資料表名稱,例如 'ALTNAME_DATA' */
abstract protected function tableName(): string;
/** 顯示名稱(proposal meta 用),例如 '別名' */
abstract protected function displayName(): string;
/** 可接受的 resource alias 列表(含主名) */
abstract protected function resourceAliases(): array;
/** 允許寫入的欄位白名單(含 key 與非 key 欄位) */
abstract protected function allowedFields(): array;
/** __key_columns(proposal meta 用),通常與 CompositePrimaryKey SCHEMAS 一致 */
abstract protected function keyColumns(): array;
/** person_id 在主鍵中的欄位名,預設 'c_personid' */
protected function personIdColumn(): string {
return 'c_personid';
}
// ── supports ─────────────────────────────────────────────
public function supports(string $resource, string $mode, string $operation): bool {
return in_array($resource, $this->resourceAliases(), true)
&& in_array($mode, ['direct', 'proposal'], true)
&& $operation === 'create';
}
// ── handle ───────────────────────────────────────────────
public function handle(string $resource, string $mode, string $operation, int $personId, array $targetPk, array $changes, array $meta = []): JsonResponse {
// 異體字落地替換的通知一律在此統一掛上:涵蓋成功、409(替換後撞既有 PK)與
// 422(替換後無實際變更)三類回應——使用者必須知道「我輸入的字被正規化了」,
// 尤其是在被擋下來的時候,否則錯誤訊息看起來毫無道理。
$this->resetVariantReplaced();
// 核准提案時以 direct/proposal 重放本 handler,待審的那筆提案正是自己;
// handleProposal() 的簽章不含 $meta(多個子類覆寫它),所以在這裡先收下來。
$this->approvingOperationId = isset($meta['__approving_operation_id'])
? (int) $meta['__approving_operation_id']
: null;
return $this->withVariantNotices(
$this->handleAfterVariantReset($resource, $mode, $operation, $personId, $targetPk, $changes, $meta)
);
}
/** handle() 的原始流程;異體字通知由 handle() 統一掛上。 */
protected function handleAfterVariantReset(string $resource, string $mode, string $operation, int $personId, array $targetPk, array $changes, array $meta = []): JsonResponse {
// 1. 授權
$authorizationError = $mode === 'proposal' ? $this->authorizeProposal() : $this->authorizeDirect();
if ($authorizationError) {
return $authorizationError;
}
// 2. 驗證 PK 格式
try {
CompositePrimaryKey::validateOrFail($targetPk, $this->tableName());
} catch (\Throwable $e) {
return $this->errorResponse('主鍵格式不正確', 422, ['pk' => [$e->getMessage()]]);
}
// 3. person_id 與 PK 一致性
$pkValidationError = $this->validatePersonIdInPk($personId, $targetPk);
if ($pkValidationError) {
return $pkValidationError;
}
// 4. 拒絕白名單外的欄位(changes)
if (!empty($changes)) {
$disallowedFields = array_diff(array_keys($changes), $this->allowedFields());
if (!empty($disallowedFields)) {
return $this->errorResponse('包含不允許的欄位', 422, [
'changes' => ['disallowed_fields: ' . implode(', ', $disallowedFields)],
]);
}
}
// 5. 組出完整 row:以 PK 為基底,合併 changes
$rowData = array_merge($targetPk, $changes ?? []);
// 6. 只保留白名單 + key 欄位
$allowedKeys = array_unique(array_merge($this->allowedFields(), $this->keyColumns()));
$rowData = array_intersect_key($rowData, array_flip($allowedKeys));
// 7. 欄位值驗證(子類可覆寫)
$validationErrors = $this->validateFields($rowData);
if (!empty($validationErrors)) {
return $this->errorResponse('參數校驗失敗', 422, $validationErrors);
}
// 7.5 異體字落地替換(型別驅動;見 Concerns\AppliesVariantReplacement)。
// 必須早於 preprocessCreateData()、extractPkFromRow() 與 findExistingRow():
// 文本型 PK 成員替換後的值才會成為實際寫入的 PK,查重也才看到替換後的值。
$rowData = $this->applyVariantReplacement($rowData);
// 8. 前處理(子類可覆寫,如 -999 → 0 轉換)
$rowData = $this->preprocessCreateData($rowData);
// 8.1 從正規化後的 rowData 提取實際 PK(前處理可能改變 key 值)
$actualPk = $this->extractPkFromRow($rowData);
// 9. 檢查目標 PK 是否已存在(使用正規化後的 PK)。
// 第二個條件是 D7「兩形並存」查重:既有列可能存變體形(D6 不做回溯校正),
// 只比對替換後的值會讓「原樣重送變體形」鑄出第二列語義重複資料——而唯一鍵
// 擋不住(不同字形=不同鍵值)。落地替換上線前這種輸入是乾淨的 409。
$existing = $this->findExistingRow($actualPk)
?: VariantEquivalentLookup::findExistingRow($this->tableName(), $this->keyColumns(), $rowData);
if ($existing) {
return $this->errorResponse('目標主鍵已存在', 409, ['target.pk' => ['conflict']]);
}
$comment = is_string($meta['comment'] ?? null) ? trim($meta['comment']) : '';
// 10. 分派到 direct / proposal
if ($mode === 'proposal') {
return $this->handleProposal($personId, $actualPk, $rowData, $comment);
}
$response = $this->handleDirect($personId, $actualPk, $rowData, $comment);
// AI 智能識別:direct 新增成功後回寫 ai_fill_logs(見 RecordsAiFillSubmission)。
// 提交資料=正規化後完整 row(PK + 白名單欄);僅 aiFillCategory() 非 null 的資源實際回寫。
if ($response->getStatusCode() === 200) {
$this->recordAiFillSubmission($meta, $personId, $rowData);
}
return $response;
}
/**
* 驗證 person_id 與 PK 中的 c_personid 一致性
*/
protected function validatePersonIdInPk(int $personId, array $targetPk): ?JsonResponse {
$pkPersonId = $targetPk[$this->personIdColumn()] ?? null;
if ((string) $pkPersonId !== (string) $personId) {
return $this->errorResponse('person_id 與 target.pk.' . $this->personIdColumn() . ' 不一致', 422, [
'person_id' => ['mismatch'],
]);
}
return null;
}
// ── Direct Create ────────────────────────────────────────
protected function handleDirect(int $personId, array $actualPk, array $rowData, string $comment): JsonResponse {
$operationId = (string) Str::ulid();
/** @var Operation|null $operation */
$operation = null;
$insertedArray = [];
// 填充 c_created_by / c_created_date
$toolsRepo = app(ToolsRepository::class);
$rowData = $toolsRepo->timestamp($rowData, true);
try {
DB::transaction(function () use ($personId, $actualPk, $rowData, $comment, $operationId, &$operation, &$insertedArray) {
// 寫入資料表
$this->performInsert($rowData);
// 讀回新增的資料(使用正規化後的 PK)
$insertedRow = $this->findExistingRow($actualPk);
$insertedArray = $this->auditLogService->normalizeRow($insertedRow);
$resourceId = CompositePrimaryKey::buildStoredResourceId($actualPk);
// 寫 operation
$resourceData = array_merge($insertedArray, ['__operation_id' => $operationId]);
if ($comment !== '') {
$resourceData['__note'] = $comment;
}
$operation = $this->operationRepository->store(
Auth::id(),
$personId,
Operation::TYPE_CREATE,
$this->tableName(),
$resourceId,
$resourceData,
[]
);
// 寫 audit_log(使用正規化後的 PK)
$this->auditLogService->write(
$this->tableName(),
'INSERT',
$actualPk,
null,
$insertedArray,
'user',
(string) Auth::id(),
$operation ? (string) $operation->id : null
);
// 子類在同一交易內的後續處理(例如社會關係/親屬寫互逆鏡像列),保證原子性
$this->afterDirectInsert($personId, $actualPk, $rowData, $insertedArray, $operation);
});
} catch (\InvalidArgumentException $e) {
// 子類/鏡像同步明確拋出的衝突(例如 D7 等價字形 preflight、Altname 的括號正規化
// 撞號)→ 整筆交易已回滾,回 409 而不是漏成 500。對齊
// AbstractPersonSubresourceMutationHandler::handleDirect() 的同名 catch。
return $this->errorResponse($e->getMessage(), 409, ['target.pk' => ['conflict']]);
} catch (\Illuminate\Database\QueryException $e) {
if ($this->isUniqueConstraintViolation($e)) {
return $this->errorResponse('目標主鍵已存在', 409, ['target.pk' => ['conflict']]);
}
throw $e;
} catch (MirrorConflictException $e) {
// #66:建立反向鏡像時,對面對應列已有不同內容 → 整筆交易已回滾(含正向列),回 409 + 衝突明細 + 對面鏡像 PK,
// 供前端彈警告 + 可點連結跳對面 edit-v2 + 提供「強制覆寫」(meta.force) 重送。
return $this->errorResponse($e->getMessage(), 409, [
'mirror_conflict' => [
'table' => $e->mirrorTable,
'pk' => $e->mirrorPk,
'fields' => $e->conflicts,
],
]);
} catch (MirrorIntegrityException $e) {
// #70:鏡像同步資料完整性 fail-closed(無權威反向碼可收斂)→ 整筆已回滾,回結構化 422,
// 而非裸 RuntimeException 漏成 500。防禦性:現行 create 在反向碼為哨兵 0 時就走無條件 insert、不進 sync,
// 故 sync 內「缺權威反向碼」分支於 create 不可達;保留此 catch 以防 sync 日後演進拋出,不致漏成 500。
return $this->errorResponse($e->getMessage(), 422, ['mirror_integrity' => ['fail_closed']]);
} catch (MirrorSuspectedException $e) {
// #70(create 路徑):建立反向鏡像時,對面已有疑似同一關係的漂移列(碼∉合法反向集,非嚴格命中)→ 整筆已回滾,
// 回 409 + 疑似列 PK 清單 + 權威反向碼,供前端彈「對面有 N 條疑似」警告 + 跳對面連結 + 強制收斂(meta.force)。
return $this->errorResponse($e->getMessage(), 409, [
'mirror_suspected' => [
'table' => $e->mirrorTable,
'candidates' => $e->candidates,
'authoritative_code' => $e->authoritativeCode,
'count' => $e->count(),
],
]);
}
return response()->json([
'ok' => true,
'resource' => $this->resourceName(),
'mode' => 'direct',
'operation' => 'create',
'result' => [
'pk' => $actualPk,
'operation_id' => $operation?->id,
'row' => $insertedArray,
],
]);
}
// ── Proposal Create ──────────────────────────────────────
protected function handleProposal(int $personId, array $actualPk, array $rowData, string $comment): JsonResponse {
$resourceId = CompositePrimaryKey::buildStoredResourceId($actualPk);
// 檢查:相同 resource_id 不得已有待審核的新增提案。
// 第二個條件同樣是 D7 查重:帶變體形 resource_id 的舊提案與帶歸一後 resource_id
// 的新提案不會相等 ⇒ 兩筆並存、依序核准就落成兩種字形的兩列。
// 核准重放時要排除「自己那一筆」(meta.__approving_operation_id),否則自擋。
if ($this->operationRepository->hasPendingCreateProposal($this->tableName(), $resourceId)
|| VariantEquivalentLookup::hasEquivalentPendingCreateProposal(
$this->tableName(),
$this->keyColumns(),
$rowData,
$this->approvingOperationId,
null,
$personId // 人物子資源的 resource_id 是查詢字串,改用有索引的 operations.c_personid 收斂
)) {
return $this->errorResponse('相同主鍵已有待審核的新增提案', 409, [
'target.pk' => ['pending_proposal_exists'],
]);
}
$proposalData = array_merge($rowData, [
'__proposal_meta' => [
'action' => 'create',
'resource_type' => $this->resourceName(),
'table' => $this->tableName(),
'display_name' => $this->displayName(),
'submitted_by' => Auth::user()->name ?? Auth::id(),
'submitted_by_id' => Auth::id(),
'submitted_at' => Carbon::now()->format('Y-m-d H:i:s'),
'comment' => $comment,
],
'__review_status' => 'pending',
'__key_columns' => $this->keyColumns(),
]);
// 子類可附加副表/鏡像提案資料(例如社會關係的互逆配對碼),核准時據以建立鏡像列。
$auxiliaryPayload = $this->proposalAuxiliaryPayload();
if ($auxiliaryPayload !== []) {
$proposalData['__proposal_aux'] = $auxiliaryPayload;
}
$operation = $this->operationRepository->store(
Auth::id(),
$personId,
Operation::TYPE_PROPOSAL_CREATE,
$this->tableName(),
$resourceId,
$proposalData,
[]
);
return response()->json([
'ok' => true,
'resource' => $this->resourceName(),
'mode' => 'proposal',
'operation' => 'create',
'result' => [
'pk' => $actualPk,
'status' => 'proposal_created',
'operation_id' => $operation?->id,
],
]);
}
// ── 可覆寫的 helper ──────────────────────────────────────
/** 從 rowData 中提取 PK 欄位(使用正規化後的值) */
protected function extractPkFromRow(array $rowData): array {
$pk = [];
foreach ($this->keyColumns() as $col) {
$pk[$col] = $rowData[$col] ?? null;
}
return $pk;
}
/** 查詢是否已有同 PK 的記錄 */
protected function findExistingRow(array $pk): ?object {
$query = DB::table($this->tableName());
foreach ($this->keyColumns() as $col) {
$query->where($col, $pk[$col] ?? null);
}
return $query->first();
}
/** 執行資料表新增 */
protected function performInsert(array $rowData): void {
DB::table($this->tableName())->insert($rowData);
}
/**
* direct 新增成功後、仍在同一交易內的後續處理鉤子(預設無動作)。
* 子類可覆寫以在原子交易內寫入互逆鏡像列(例如 AssociationCreateHandler 寫 ASSOC_DATA 反向關係)。
*/
protected function afterDirectInsert(int $personId, array $actualPk, array $rowData, array $insertedArray, ?Operation $operation): void {
}
/**
* proposal 新增時附加的副表/鏡像提案資料(預設空)。
* 子類可覆寫以把互逆配對碼寫入 __proposal_aux,核准時據以建立鏡像列。
*/
protected function proposalAuxiliaryPayload(): array {
return [];
}
/** 欄位值驗證(預設無驗證,子類可覆寫) */
protected function validateFields(array $data): array {
return [];
}
/** 前處理新增資料(預設無處理,子類可覆寫) */
protected function preprocessCreateData(array $data): array {
return $data;
}
/**
* 將指定欄位中的 -999 轉換為 '0'
*/
protected function normalizeSentinelValues(array $data, array $fields): array {
foreach ($fields as $field) {
if (array_key_exists($field, $data) && ($data[$field] === -999 || $data[$field] === '-999')) {
$data[$field] = '0';
}
}
return $data;
}
/**
* 把「legacy 哨兵 0=Unknown 語義」的非 PK 碼/FK 欄在 CREATE 時規範化為 '0':null / '' / -999 / **缺鍵** 皆落 '0'。
*
* 與 update 版(AbstractPersonSubresourceMutationHandler::normalizeEmptyCodeFields)**刻意不同**:update 的
* 「缺鍵=該欄不變」故只處理已送的空值;CREATE 的「缺鍵=使用者未填=legacy 表單恆送 0」故缺鍵也須落 0,
* 否則 nullable 欄會留 null、與 legacy(表單空→0)分歧,達不到 create==legacy 完全幂等(codex #71)。
* 限 legacy 哨兵 0=Unknown 的碼/FK 欄(如 c_source);真正 nullable、空應留 null 的欄勿用。
*
* @param array $fields 需完全規範化(含缺鍵補 0)的碼/FK 欄名
*/
protected function normalizeEmptyCodeFields(array $data, array $fields): array {
foreach ($fields as $field) {
$v = $data[$field] ?? null; // 缺鍵視為 null(CREATE:未填=哨兵 0)
if ($v === null || $v === '' || (int) $v === -999) {
$data[$field] = '0';
}
}
return $data;
}
/**
* 判斷 QueryException 是否為唯一性約束衝突
*/
private function isUniqueConstraintViolation(\Illuminate\Database\QueryException $e): bool {
$code = (int) ($e->errorInfo[1] ?? 0);
if (in_array($code, [1062, 19], true)) {
return true;
}
$msg = $e->getMessage();
return str_contains($msg, 'UNIQUE') || str_contains($msg, 'Duplicate entry');
}
}