table = $table; } public function where(string $key, $value): self { $this->where[$key] = $value; return $this; } public function whereNull(string $key): self { return $this->where($key, null); } public function lock(bool $value): self { $this->locked = $value; return $this; } public function find() { self::$events[] = ['table' => $this->table, 'where' => $this->where, 'locked' => $this->locked]; if (self::$enforceLocks && (!$this->locked || \think\facade\Db::$depth < 1)) { throw new \RuntimeException('write read must lock inside caller transaction'); } if ($this->table === 'diagnosis') return ($this->where['id'] ?? 0) === 7 ? (object) ['id' => 7] : null; foreach (TrackingNote::$rows as $row) { $matches = true; foreach ($this->where as $key => $value) { if (($row[$key] ?? null) !== $value) $matches = false; } if ($matches) return new FixtureRow($row); } return null; } } class FixtureRow { public function __construct(private array $data) {} public function __get(string $key) { return $this->data[$key] ?? null; } public function __isset(string $key): bool { return isset($this->data[$key]); } public function __set(string $key, $value): void { $this->data[$key] = $value; } public function save(): void { TrackingNote::$rows[$this->data['id']] = $this->data; } } class Diagnosis { public static function where(string $key, $value): FixtureQuery { return (new FixtureQuery('diagnosis'))->where($key, $value); } } class TrackingNote { public static array $rows = []; public static function where(string $key, $value): FixtureQuery { return (new FixtureQuery('tracking_note'))->where($key, $value); } public static function create(array $data): FixtureRow { $data['id'] = count(self::$rows) + 1; $data['delete_time'] = null; self::$rows[$data['id']] = $data; return new FixtureRow($data); } } } namespace { use app\adminapi\logic\tcm\TrackingNoteLogic; use app\common\model\tcm\TrackingNote; use app\common\model\tcm\FixtureQuery; use think\facade\Db; require dirname(__DIR__) . '/app/common/logic/BaseLogic.php'; $sourceFlag = array_search('--source', $argv, true); require $sourceFlag === false ? dirname(__DIR__) . '/app/adminapi/logic/tcm/TrackingNoteLogic.php' : $argv[$sourceFlag + 1]; date_default_timezone_set('Asia/Shanghai'); function check(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); } function rejects(callable $fn, string $message): void { try { $fn(); } catch (DomainException $expected) { return; } throw new RuntimeException($message); } $today = (new DateTimeImmutable('now', new DateTimeZone('Asia/Shanghai')))->format('Y-m-d'); check(TrackingNoteLogic::addOrAppend(['diagnosis_id' => 7, 'admin_id' => 9, 'content' => '今日合成备注', 'note_date' => '2000-01-01']), 'legacy call remains accepted'); $legacy = reset(TrackingNote::$rows); check($legacy['note_date'] === $today, 'legacy entry point still ignores submitted historical date'); if (in_array('--observe', $argv, true)) { echo json_encode(['legacyTodayOnly' => $legacy['note_date'] === $today, 'explicitHistoricalDateSupported' => method_exists(TrackingNoteLogic::class, 'appendForDate')], JSON_UNESCAPED_UNICODE) . PHP_EOL; exit(0); } check(method_exists(TrackingNoteLogic::class, 'appendForDate'), 'explicit historical API exists'); TrackingNote::$rows = []; FixtureQuery::$events = []; FixtureQuery::$enforceLocks = true; $ownTransactions = Db::$calls; $id = Db::transaction(static fn() => TrackingNoteLogic::appendForDate(7, ' 2024-02-29 ', ' 第一条合成历史备注 ', 9)); check(Db::$calls === $ownTransactions + 1, 'new method does not open or commit a nested transaction'); check($id === 1, 'actual created id returned'); check(TrackingNote::$rows[$id]['note_date'] === '2024-02-29', 'valid leap-day date normalized explicitly'); check(preg_match('/^\[\d{2}:\d{2}\] 第一条合成历史备注$/u', TrackingNote::$rows[$id]['content']) === 1, 'trimmed content has insertion-time prefix'); $again = Db::transaction(static fn() => TrackingNoteLogic::appendForDate(7, '2024-02-29', '第二条合成历史备注', 10)); check($again === $id && count(TrackingNote::$rows) === 1, 'same day appends instead of replacing or creating second note'); check(substr_count(TrackingNote::$rows[$id]['content'], "\n") === 1, 'two original lines preserved'); check(str_contains(TrackingNote::$rows[$id]['content'], '第一条合成历史备注'), 'first content retained'); check(TrackingNote::$rows[$id]['admin_id'] === 9, 'original creator retained; caller audit owns latest actor'); check(array_column(FixtureQuery::$events, 'table') === ['diagnosis', 'tracking_note', 'diagnosis', 'tracking_note'], 'diagnosis lock precedes exact-day row lock for both insert/update'); check(FixtureQuery::$events[1]['where'] === ['diagnosis_id' => 7, 'note_date' => '2024-02-29'], 'date and diagnosis scoped lookup includes deleted rows explicitly'); $before = TrackingNote::$rows; try { Db::transaction(static function (): void { TrackingNoteLogic::appendForDate(7, '2024-02-29', '必须随调用者回滚', 9); throw new RuntimeException('synthetic audit insert failure'); }); } catch (RuntimeException $error) { check($error->getMessage() === 'synthetic audit insert failure', 'expected synthetic rollback'); } check(TrackingNote::$rows === $before, 'caller rollback restores note alongside task/audit transaction'); $tomorrow = (new DateTimeImmutable('tomorrow', new DateTimeZone('Asia/Shanghai')))->format('Y-m-d'); foreach (['', '2025-02-29', '2024-02-30', '2024-2-01', '2024/02/01', '0000-01-01', '2024-01-01 08:00:00', $tomorrow] as $invalid) { rejects(static fn() => TrackingNoteLogic::appendForDate(7, $invalid, '合成备注', 9), 'invalid date rejected: ' . $invalid); } rejects(static fn() => TrackingNoteLogic::appendForDate(0, '2024-01-01', '内容', 9), 'missing diagnosis rejected'); rejects(static fn() => TrackingNoteLogic::appendForDate(7, '2024-01-01', '内容', 0), 'missing actor rejected'); rejects(static fn() => TrackingNoteLogic::appendForDate(7, '2024-01-01', ' ', 9), 'blank content rejected'); rejects(static fn() => Db::transaction(static fn() => TrackingNoteLogic::appendForDate(999, '2024-01-01', '内容', 9)), 'missing diagnosis row rejected'); TrackingNote::$rows[$id]['delete_time'] = 123; rejects(static fn() => Db::transaction(static fn() => TrackingNoteLogic::appendForDate(7, '2024-02-29', '不能复活已删除备注', 9)), 'deleted note cannot be resurrected'); check(TrackingNote::$rows[$id]['delete_time'] === 123, 'deleted state retained'); TrackingNote::$rows = []; FixtureQuery::$events = []; check(TrackingNoteLogic::addOrAppend(['diagnosis_id' => 7, 'content' => '旧接口仍可追加']), 'legacy zero-actor semantics preserved'); check(reset(TrackingNote::$rows)['note_date'] === $today, 'legacy remains today only'); check(FixtureQuery::$events[0]['table'] === 'diagnosis' && FixtureQuery::$events[0]['locked'], 'legacy shares serialization lock with new service'); check(!TrackingNoteLogic::addOrAppend(['diagnosis_id' => 0, 'content' => '无效']), 'legacy invalid ID returns false'); check(!TrackingNoteLogic::addOrAppend(['diagnosis_id' => 7, 'content' => '']), 'legacy blank content returns false'); echo "FollowupAudioTrackingNoteBehaviorTest: historical date/leap-day validation, caller transaction rollback, lock order, append-only identity, deleted guard, legacy today semantics: PASS\n"; }