policy = $policy ?? new TangDetectiveProgress(); } public function read(int $userId): array { $this->policy->defaultState($userId); try { $row = Db::name(self::TABLE)->where('user_id', $userId)->find(); return $row ? $this->hydrate($userId, $row) : $this->policy->defaultState($userId); } catch (\Throwable $exception) { $this->storageFailure('read', $userId, $exception); } } /** $request is exclusively the output of TangDetectiveProgress::decodeRequest. */ public function save(int $userId, array $request): array { $this->policy->defaultState($userId); $transactionOpen = false; try { Db::startTrans(); $transactionOpen = true; $row = Db::name(self::TABLE)->where('user_id', $userId)->lock(true)->find(); $current = $row ? $this->hydrate($userId, $row) : $this->policy->defaultState($userId); $change = $this->policy->transition( $current, $request, (string) ($row['last_request_id'] ?? ''), (string) ($row['last_request_hash'] ?? '') ); if (!$change['idempotent']) { $state = $change['state']; $values = [ 'schema_version' => $state['schema_version'], 'content_version' => $state['content_version'], 'revision' => $state['revision'], 'story_generation' => $state['story_generation'], 'progress_json' => $this->policy->encode($state['progress']), 'last_request_id' => $request['request_id'], 'last_request_hash' => $change['request_hash'], 'update_time' => time(), ]; if ($row) { $updated = Db::name(self::TABLE) ->where('user_id', $userId) ->where('revision', $current['revision']) ->where('story_generation', $current['story_generation']) ->update($values); if ($updated !== 1) { throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档已变化,请重新读取'); } } else { // UNIQUE(user_id) arbitrates concurrent first saves. Never upsert/overwrite. $inserted = Db::name(self::TABLE)->insert($values + [ 'user_id' => $userId, 'create_time' => time(), ]); if ((int) $inserted !== 1) { throw new \UnexpectedValueException('Tang progress insert was not confirmed'); } } } Db::commit(); $transactionOpen = false; return $change['state'] + ['idempotent' => $change['idempotent']]; } catch (\Throwable $exception) { if ($transactionOpen) { try { Db::rollback(); } catch (\Throwable $rollbackException) { $this->storageFailure('rollback', $userId, $rollbackException); } } if ($exception instanceof TangDetectiveProgressException) { throw $exception; } if ($this->isConcurrentWriteFailure($exception)) { throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '另一处正在保存,请重新读取存档'); } $this->storageFailure('save', $userId, $exception); } } private function hydrate(int $userId, array $row): array { if ((int) $row['user_id'] !== $userId || (int) $row['schema_version'] !== 1 || $row['content_version'] !== 'season-01' || (int) $row['revision'] < 1 || (int) $row['story_generation'] < 0) { throw new \UnexpectedValueException('Stored Tang progress metadata is invalid'); } try { $progress = $this->policy->normalizeProgress( json_decode((string) $row['progress_json'], false, 12, JSON_THROW_ON_ERROR) ); } catch (\Throwable $exception) { // Corrupt persisted data is not a client validation error. Do not echo its content. throw new \UnexpectedValueException('Stored Tang progress is invalid'); } $state = $this->policy->defaultState($userId); $state['revision'] = (int) $row['revision']; $state['story_generation'] = (int) $row['story_generation']; $state['progress'] = $progress; return $state; } private function isConcurrentWriteFailure(\Throwable $exception): bool { if ($exception instanceof \PDOException) { return in_array((int) ($exception->errorInfo[1] ?? 0), [1062, 1205, 1213], true) || (string) $exception->getCode() === '40001'; } if ($exception instanceof \think\db\exception\PDOException) { $details = $exception->getData()['PDO Error Info'] ?? []; return in_array((int) ($details['Driver Error Code'] ?? 0), [1062, 1205, 1213], true) || ($details['SQLSTATE'] ?? '') === '40001'; } return false; } private function storageFailure(string $operation, int $userId, \Throwable $exception): void { try { // Deliberately omit exception message/trace: ORM errors may contain SQL or payloads. Log::warning('tang_detective_storage_failure', [ 'operation' => $operation, 'user_id' => $userId, 'error_code' => 'STORAGE_UNAVAILABLE', 'exception_class' => get_class($exception), ]); } catch (\Throwable $loggingException) { // An unavailable log sink must not reveal the original storage exception. } throw new TangDetectiveProgressException('STORAGE_UNAVAILABLE', '云端存档暂时不可用,本机进度仍可保留'); } }