['Driver Error Code' => $this->getCode()]]; } } } namespace think\facade { final class Db { public static array $rows = []; public static array $snapshot = []; public static bool $transaction = false; public static ?int $lockedUser = null; public static int $insertError = 0; public static int $writes = 0; public static function name(string $table): TangQuery { if ($table !== 'tcm_tang_detective_progress') { throw new \RuntimeException('Attempted unrelated business table access'); } return new TangQuery(); } public static function startTrans(): void { if (self::$transaction) { throw new \RuntimeException('Leaked transaction'); } self::$snapshot = self::$rows; self::$transaction = true; } public static function commit(): void { self::$transaction = false; self::$lockedUser = null; } public static function rollback(): void { self::$rows = self::$snapshot; self::commit(); } } final class TangQuery { private array $conditions = []; private bool $locked = false; public function where(string $field, $value): self { $this->conditions[$field] = $value; return $this; } public function lock(bool $locked): self { $this->locked = $locked; return $this; } public function find(): ?array { if (!isset($this->conditions['user_id'])) { throw new \RuntimeException('Query omitted server user identity'); } if ($this->locked) { if (!Db::$transaction) { throw new \RuntimeException('Row lock requires transaction'); } Db::$lockedUser = $this->conditions['user_id']; } return Db::$rows[$this->conditions['user_id']] ?? null; } public function insert(array $values): int { if (!Db::$transaction || Db::$lockedUser !== $values['user_id']) { throw new \RuntimeException('Insert must follow own-user locked lookup'); } if (Db::$insertError) { $code = Db::$insertError; Db::$insertError = 0; throw new \think\db\exception\PDOException('SECRET_SQL_AND_PRIVATE_PAYLOAD', $code); } if (isset(Db::$rows[$values['user_id']])) { throw new \think\db\exception\PDOException('SECRET_DUPLICATE_SQL', 1062); } Db::$rows[$values['user_id']] = $values; ++Db::$writes; return 1; } public function update(array $values): int { $userId = $this->conditions['user_id'] ?? 0; if (!Db::$transaction || Db::$lockedUser !== $userId) { throw new \RuntimeException('Update requires own-user lock'); } $row = Db::$rows[$userId] ?? []; if (!isset($this->conditions['revision'], $this->conditions['story_generation']) || ($row['revision'] ?? null) !== $this->conditions['revision'] || ($row['story_generation'] ?? null) !== $this->conditions['story_generation']) { return 0; } Db::$rows[$userId] = array_merge($row, $values); ++Db::$writes; return 1; } } final class Log { public static array $entries = []; public static function warning(string $message, array $context): void { self::$entries[] = [$message, $context]; } } } namespace app\api\controller { final class TangTestResponse extends \ArrayObject { public array $headers = []; public function header(array $headers): self { $this->headers = $headers; return $this; } } class BaseApiController { protected int $userId; protected object $request; public function __construct(int $userId, object $request) { $this->userId = $userId; $this->request = $request; } protected function data(array $data): TangTestResponse { return new TangTestResponse(['code' => 1, 'data' => $data]); } protected function fail(string $message, array $data = [], int $code = 0, int $show = 0): TangTestResponse { return new TangTestResponse(['code' => $code, 'show' => $show, 'msg' => $message, 'data' => $data]); } } } namespace { require __DIR__ . '/../app/common/service/game/TangDetectiveProgressException.php'; require __DIR__ . '/../app/common/service/game/TangDetectiveProgress.php'; require __DIR__ . '/../app/api/logic/tcm/TangDetectiveLogic.php'; require __DIR__ . '/../app/api/controller/TangDetectiveController.php'; use app\api\controller\TangDetectiveController; use app\api\logic\tcm\TangDetectiveLogic; use app\common\service\game\TangDetectiveProgress; use app\common\service\game\TangDetectiveProgressException; use think\facade\Db; use think\facade\Log; $assertions = 0; $expect = static function (bool $condition, string $message) use (&$assertions): void { if (!$condition) { throw new RuntimeException($message); } ++$assertions; }; $error = static function (string $code, callable $action) use ($expect): void { try { $action(); } catch (TangDetectiveProgressException $exception) { $expect($exception->errorCode() === $code, 'stable error: ' . $code); $expect(!str_contains($exception->getMessage(), 'SECRET'), 'database detail stays private'); return; } throw new RuntimeException('Missing error: ' . $code); }; $requestObject = static function (string $method, string $raw = '', string $type = 'application/json'): object { return new class($method, $raw, $type) { public function __construct(private string $method, private string $raw, private string $type) {} public function isGet(): bool { return $this->method === 'GET'; } public function isPost(): bool { return $this->method === 'POST'; } public function contentType(): string { return $this->type; } public function getContent(): string { return $this->raw; } }; }; $policy = new TangDetectiveProgress(); $logic = new TangDetectiveLogic($policy); $body = [ 'schema_version' => 1, 'content_version' => 'season-01', 'base_revision' => 0, 'story_generation' => 0, 'request_id' => 'own_user_request_001', 'operation' => 'replace', 'progress' => $policy->defaultProgress(), ]; $raw = json_encode($body, JSON_THROW_ON_ERROR); $command = $policy->decodeRequest($raw); $expect($logic->read(11)['revision'] === 0 && Db::$rows === [], 'empty read makes no database writes'); $save = $logic->save(11, $command); $expect($save['revision'] === 1 && $save['user_id'] === 11, 'server identity owns first save'); $expect(!Db::$transaction && Db::$writes === 1, 'first save commits one write'); $save = $logic->save(11, $command); $expect($save['idempotent'] && Db::$writes === 1, 'same request retry performs no update'); $expect($logic->read(12)['revision'] === 0, 'different user cannot read first user state'); $logic->save(12, $command); $expect(count(Db::$rows) === 2, 'same request ID may be used by independent users'); $before = Db::$rows[11]; $stale = $command; $stale['request_id'] = 'stale_request_00001'; $error('PROGRESS_CONFLICT', static fn () => $logic->save(11, $stale)); $expect(Db::$rows[11] === $before && !Db::$transaction, 'stale update rolls back without overwrite'); $next = $command; $next['base_revision'] = 1; $next['request_id'] = 'second_request_0001'; $expect($logic->save(11, $next)['revision'] === 2, 'matching revision updates under lock'); $error('PROGRESS_CONFLICT', static fn () => $logic->save(11, $command)); foreach ([1062, 1205, 1213] as $code) { Db::$insertError = $code; $error('PROGRESS_CONFLICT', static fn () => $logic->save(20, $command)); $expect(!isset(Db::$rows[20]) && !Db::$transaction, 'first-insert race leaves no partial state'); } Db::$insertError = 1146; $error('STORAGE_UNAVAILABLE', static fn () => $logic->save(20, $command)); $expect(!str_contains(json_encode(Log::$entries), 'SECRET'), 'logs exclude SQL and raw exception text'); $error('AUTH_REQUIRED', static fn () => $logic->read(0)); Db::$rows[12]['progress_json'] = '{"healthAnswer":"SECRET_PRIVATE_TEXT"}'; $error('STORAGE_UNAVAILABLE', static fn () => $logic->read(12)); $expect(!str_contains(json_encode(Log::$entries), 'SECRET'), 'corrupt stored text never enters logs'); $controller = new TangDetectiveController(11, $requestObject('GET')); $expect($controller->progress()['data']['user_id'] === 11, 'controller returns only server identity'); $expect(count($controller->catalog()['data']['chapters']) === 15, 'catalog has no storage dependency'); $expect($controller->saveProgress()['data']['error_code'] === 'METHOD_NOT_ALLOWED', 'conventional GET save route cannot write'); $controller = new TangDetectiveController(0, $requestObject('GET')); $expect($controller->catalog()['data']['error_code'] === 'AUTH_REQUIRED', 'catalog cannot bypass own-user requirement'); $controller = new TangDetectiveController(11, $requestObject('POST', $raw, 'text/plain')); $expect($controller->saveProgress()['data']['error_code'] === 'UNSUPPORTED_MEDIA_TYPE', 'save rejects non-JSON media type'); $forged = $body + ['user_id' => 99]; $controller = new TangDetectiveController(11, $requestObject('POST', json_encode($forged))); $expect($controller->saveProgress()['data']['error_code'] === 'INVALID_REQUEST', 'client identity field is rejected'); $controller = new TangDetectiveController(31, $requestObject('POST', $raw)); $result = $controller->saveProgress(); $expect($result['code'] === 1 && $result['data']['user_id'] === 31, 'controller passes only injected identity to persistence'); $expect($result->headers['Cache-Control'] === 'no-store', 'own-user state is never shared-cacheable'); echo "Tang Detective persistence/controller doubles: {$assertions} assertions OK\n"; }