This commit is contained in:
大哥大哥的大哥哥
2026-09-09 14:47:29 +08:00
parent 4d9da40abd
commit 65755c9e96
832 changed files with 412085 additions and 0 deletions
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\api\logic\tcm\TangDetectiveLogic;
use app\common\service\game\TangDetectiveProgress;
use app\common\service\game\TangDetectiveProgressException;
use think\facade\Log;
/** All actions use the existing LoginMiddleware token identity. */
class TangDetectiveController extends BaseApiController
{
public array $notNeedLogin = [];
public function catalog()
{
return $this->respond('GET', static fn (): array => (new TangDetectiveProgress())->catalog());
}
public function progress()
{
return $this->respond('GET', fn (): array => (new TangDetectiveLogic())->read((int) $this->userId));
}
public function saveProgress()
{
return $this->respond('POST', function (): array {
if (strtolower($this->request->contentType()) !== 'application/json') {
throw new TangDetectiveProgressException('UNSUPPORTED_MEDIA_TYPE', '请使用 JSON 提交存档');
}
$policy = new TangDetectiveProgress();
$request = $policy->decodeRequest((string) $this->request->getContent());
return (new TangDetectiveLogic($policy))->save((int) $this->userId, $request);
});
}
private function respond(string $method, callable $action)
{
try {
// Guard methods too: ThinkPHP's conventional controller routes remain enabled.
if (($method === 'GET' && !$this->request->isGet())
|| ($method === 'POST' && !$this->request->isPost())) {
throw new TangDetectiveProgressException('METHOD_NOT_ALLOWED', '请求方式不正确');
}
if ($this->userId <= 0) {
throw new TangDetectiveProgressException('AUTH_REQUIRED', '请先登录');
}
return $this->data($action())->header(['Cache-Control' => 'no-store']);
} catch (TangDetectiveProgressException $exception) {
return $this->fail($exception->getMessage(), ['error_code' => $exception->errorCode()], 0, 0)
->header(['Cache-Control' => 'no-store']);
} catch (\Throwable $exception) {
try {
Log::warning('tang_detective_request_failure', [
'user_id' => $this->userId,
'error_code' => 'STORAGE_UNAVAILABLE',
'exception_class' => get_class($exception),
]);
} catch (\Throwable $loggingException) {
// No raw exception, request body, token or SQL is exposed on failure.
}
return $this->fail('云端存档暂时不可用,本机进度仍可保留', ['error_code' => 'STORAGE_UNAVAILABLE'], 0, 0)
->header(['Cache-Control' => 'no-store']);
}
}
}
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace app\api\logic\tcm;
use app\common\service\game\TangDetectiveProgress;
use app\common\service\game\TangDetectiveProgressException;
use think\facade\Db;
use think\facade\Log;
/** Own-user persistence only. This class never touches the match-three tables. */
final class TangDetectiveLogic
{
private const TABLE = 'tcm_tang_detective_progress';
private TangDetectiveProgress $policy;
public function __construct(?TangDetectiveProgress $policy = null)
{
$this->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', '云端存档暂时不可用,本机进度仍可保留');
}
}
@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace app\common\service\game;
/** ID-only progress policy. No framework, database, filesystem writes or network. */
final class TangDetectiveProgress
{
public const MAX_BODY_BYTES = 32768;
private array $catalog;
private array $chapters = [];
private array $cardIds = [];
public function __construct(?array $catalog = null)
{
$this->catalog = $catalog ?? require dirname(__DIR__, 4) . '/config/tang_detective.php';
foreach ($this->catalog['chapters'] as $chapter) {
$this->chapters[$chapter['chapter_id']] = $chapter;
$this->cardIds[] = $chapter['memory_card_id'];
}
}
public function catalog(): array
{
return $this->catalog;
}
public function defaultProgress(): array
{
return [
'completedHotspots' => (object) [],
'completedChapters' => [],
'lastChapter' => 1,
'collectedMemoryCards' => [],
'comicReaderByChapter' => (object) [],
'lastPageId' => '',
];
}
public function defaultState(int $userId): array
{
if ($userId <= 0) {
throw new TangDetectiveProgressException('AUTH_REQUIRED', '请先登录');
}
return [
'user_id' => $userId,
'schema_version' => 1,
'content_version' => 'season-01',
'revision' => 0,
'story_generation' => 0,
'progress' => $this->defaultProgress(),
];
}
/** Decode raw JSON so objects, lists, integers and booleans stay distinct. */
public function decodeRequest(string $raw): array
{
if (strlen($raw) > self::MAX_BODY_BYTES) {
throw new TangDetectiveProgressException('PAYLOAD_TOO_LARGE', '存档请求超过大小限制');
}
try {
$decoded = json_decode($raw, false, 12, JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
$this->invalid('存档请求不是有效的 JSON 对象');
}
$source = $this->record($decoded, [
'schema_version', 'content_version', 'base_revision', 'story_generation',
'request_id', 'operation', 'progress',
], true);
if ($source['schema_version'] !== 1 || $source['content_version'] !== 'season-01') {
throw new TangDetectiveProgressException('UNSUPPORTED_CONTENT_VERSION', '存档版本不匹配,请更新后再试');
}
$this->boundedInteger($source['base_revision'], 0, 2147483646);
$this->boundedInteger($source['story_generation'], 0, 2147483646);
if (!is_string($source['request_id']) || !preg_match('/\A[A-Za-z0-9_-]{16,64}\z/D', $source['request_id'])) {
$this->invalid('存档请求标识无效');
}
if (!in_array($source['operation'], ['replace', 'reset_story'], true)) {
$this->invalid('存档操作无效');
}
$progress = $this->normalizeProgress($source['progress']);
if ($source['operation'] === 'reset_story') {
$empty = $this->defaultProgress();
$empty['collectedMemoryCards'] = $progress['collectedMemoryCards'];
if ($this->encode($empty) !== $this->encode($progress)) {
$this->invalid('重新开始请求必须清空故事进度');
}
}
return [
'schema_version' => 1,
'content_version' => 'season-01',
'base_revision' => $source['base_revision'],
'story_generation' => $source['story_generation'],
'request_id' => $source['request_id'],
'operation' => $source['operation'],
'progress' => $progress,
];
}
/** Reject unknown fields; canonicalize only equivalent ordering of sets/maps. */
public function normalizeProgress($value): array
{
$source = $this->record($value, [
'completedHotspots', 'completedChapters', 'lastChapter',
'collectedMemoryCards', 'comicReaderByChapter', 'lastPageId',
], true);
$this->boundedInteger($source['lastChapter'], 1, count($this->chapters));
$hotspots = $this->record($source['completedHotspots'], array_keys($this->chapters));
$readers = $this->record($source['comicReaderByChapter'], array_keys($this->chapters));
$finished = $this->idSet($source['completedChapters'], array_keys($this->chapters));
$cards = $this->idSet($source['collectedMemoryCards'], $this->cardIds);
$cleanHotspots = [];
$cleanReaders = [];
foreach ($this->chapters as $id => $chapter) {
$events = $this->eventPrefix($hotspots[$id] ?? [], $chapter['hotspot_ids']);
if (array_key_exists($id, $hotspots)) {
$cleanHotspots[$id] = $events;
}
$isFinished = in_array($id, $finished, true);
if (!array_key_exists($id, $readers)) {
if ($events !== [] || $isFinished) {
$this->invalid('章节存档缺少一致的阅读状态');
}
continue;
}
$reader = $this->record($readers[$id], ['currentPageId', 'completedEventIds', 'chapterFinished'], true);
$readerEvents = $this->eventPrefix($reader['completedEventIds'], $chapter['hotspot_ids']);
if (!is_bool($reader['chapterFinished']) || $readerEvents !== $events
|| $reader['chapterFinished'] !== $isFinished
|| ($isFinished && count($events) !== 4)) {
$this->invalid('章节完成状态与事件进度不一致');
}
$pageIndex = is_string($reader['currentPageId'])
? array_search($reader['currentPageId'], $chapter['page_ids'], true)
: false;
$lastUnlocked = $isFinished ? 7 : min(6, 2 + count($events));
if ($pageIndex === false || $pageIndex > $lastUnlocked) {
$this->invalid('阅读页码无效或尚未解锁');
}
$cleanReaders[$id] = [
'currentPageId' => $reader['currentPageId'],
'completedEventIds' => $readerEvents,
'chapterFinished' => $reader['chapterFinished'],
];
}
if (!is_string($source['lastPageId'])) {
$this->invalid('最后阅读页码无效');
}
$lastId = sprintf('S01-C%02d', $source['lastChapter']);
if ($source['lastPageId'] !== '' && (
!isset($cleanReaders[$lastId])
|| $cleanReaders[$lastId]['currentPageId'] !== $source['lastPageId']
)) {
$this->invalid('最后阅读页码与章节书签不一致');
}
return [
'completedHotspots' => (object) $cleanHotspots,
'completedChapters' => $finished,
'lastChapter' => $source['lastChapter'],
'collectedMemoryCards' => $cards,
'comicReaderByChapter' => (object) $cleanReaders,
'lastPageId' => $source['lastPageId'],
];
}
/** Called under the user's database row lock; request must be decodeRequest's result. */
public function transition(array $current, array $request, string $lastRequestId = '', string $lastHash = ''): array
{
$hash = hash('sha256', $this->encode($request));
if ($lastRequestId !== '' && hash_equals($lastRequestId, $request['request_id'])) {
if (!hash_equals($lastHash, $hash)) {
throw new TangDetectiveProgressException('IDEMPOTENCY_CONFLICT', '同一请求标识不能提交不同存档');
}
return ['state' => $current, 'request_hash' => $hash, 'idempotent' => true];
}
if ($request['base_revision'] !== $current['revision']
|| $request['story_generation'] !== $current['story_generation']) {
throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档已变化,请选择云端或本机进度');
}
if ($current['revision'] >= 2147483646 || $current['story_generation'] >= 2147483646) {
throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档版本超出范围,请联系管理员');
}
$next = $current;
$next['progress'] = $request['progress'];
if ($request['operation'] === 'reset_story') {
$next['progress'] = $this->defaultProgress();
$cards = array_unique(array_merge(
$current['progress']['collectedMemoryCards'],
$request['progress']['collectedMemoryCards']
));
$next['progress']['collectedMemoryCards'] = array_values(array_intersect($this->cardIds, $cards));
++$next['story_generation'];
}
++$next['revision'];
return ['state' => $next, 'request_hash' => $hash, 'idempotent' => false];
}
public function encode(array $value): string
{
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
private function record($value, array $allowed, bool $requireAll = false): array
{
if (!$value instanceof \stdClass) {
$this->invalid('存档字段必须是对象');
}
$fields = get_object_vars($value);
if (array_diff(array_keys($fields), $allowed) !== []
|| ($requireAll && array_diff($allowed, array_keys($fields)) !== [])) {
$this->invalid('存档包含未知字段或缺少必要字段');
}
return $fields;
}
private function idSet($value, array $allowed): array
{
if (!is_array($value) || $value !== array_values($value) || count($value) > count($allowed)) {
$this->invalid('存档标识列表无效');
}
$seen = [];
foreach ($value as $id) {
if (!is_string($id) || !in_array($id, $allowed, true) || isset($seen[$id])) {
$this->invalid('存档包含非法或重复标识');
}
$seen[$id] = true;
}
return array_values(array_filter($allowed, static fn (string $id): bool => isset($seen[$id])));
}
private function eventPrefix($value, array $allowed): array
{
if (!is_array($value) || $value !== array_slice($allowed, 0, count($value))) {
$this->invalid('事件进度必须是本章连续前缀');
}
return $value;
}
private function boundedInteger($value, int $minimum, int $maximum): void
{
if (!is_int($value) || $value < $minimum || $value > $maximum) {
$this->invalid('存档整数参数无效');
}
}
private function invalid(string $message): void
{
throw new TangDetectiveProgressException('INVALID_REQUEST', $message);
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace app\common\service\game;
final class TangDetectiveProgressException extends \DomainException
{
private string $errorCode;
public function __construct(string $errorCode, string $message)
{
parent::__construct($message);
$this->errorCode = $errorCode;
}
public function errorCode(): string
{
return $this->errorCode;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
// ID-only registry verified against the original season-01 source:
// miniprogram/data/memoryCards.js and package-game/utils/comicReaderState.js.
// No story text, answers, patient data, or asset URLs belong in this registry.
$chapters = [];
for ($number = 1; $number <= 15; ++$number) {
$chapterId = sprintf('S01-C%02d', $number);
$chapters[] = [
'chapter_id' => $chapterId,
'chapter_number' => $number,
'hotspot_ids' => array_map(
static fn (int $event): string => sprintf('S01-H%02d', $event),
range(($number - 1) * 4 + 1, $number * 4)
),
'page_ids' => array_map(
static fn (int $page): string => sprintf('%s-P%02d', $chapterId, $page),
range(1, 8)
),
'memory_card_id' => $chapterId . '-MC01',
];
}
return [
'game_id' => 'tang-detective',
'schema_version' => 1,
'content_version' => 'season-01',
'max_body_bytes' => 32768,
'chapters' => $chapters,
];
+135
View File
@@ -0,0 +1,135 @@
# 唐侦探独立存档契约与验证边界
本模块只保存 `season-01` 的章节、事件、卡片 ID 和阅读游标。它复用主小程序登录身份,不读取或写入三消周榜、患者记录或健康回答。机器可读契约见 `tang-detective.openapi.yaml`
## 前端请求与响应
- `GET /api/tang/catalog`:部署目录的 ID 白名单,不创建用户存档。
- `GET /api/tang/progress`:当前登录用户的确认存档;未保存过返回修订与代次均为 `0`
- `POST /api/tang/saveProgress`:仅接受 `application/json`,原始正文最多 `32768` 字节。
- 三个端点均要求现有请求头 `token`。正文不接受 `user_id`、token 或任何身份替代字段。
- 业务响应沿用 `{code, show, msg, data}`,HTTP 200 本身不代表成功。控制器响应设置 `Cache-Control: no-store`
目录 `data` 为:
```json
{
"game_id": "tang-detective",
"schema_version": 1,
"content_version": "season-01",
"max_body_bytes": 32768,
"chapters": [{
"chapter_id": "S01-C01",
"chapter_number": 1,
"hotspot_ids": ["S01-H01", "S01-H02", "S01-H03", "S01-H04"],
"page_ids": ["S01-C01-P01", "S01-C01-P02", "S01-C01-P03", "S01-C01-P04", "S01-C01-P05", "S01-C01-P06", "S01-C01-P07", "S01-C01-P08"],
"memory_card_id": "S01-C01-MC01"
}]
}
```
示例仅列第一章;实际返回固定十五章。ID 依据原项目 `miniprogram/data/season.js``data/memoryCards.js``package-game/pages/chapter/chapterPages.js``package-game/utils/comicReaderState.js` 核对。`season-01` 是本同步契约版本,不代表内容已医学审签或公开发布。
空存档的 `data`
```json
{
"user_id": 11,
"schema_version": 1,
"content_version": "season-01",
"revision": 0,
"story_generation": 0,
"progress": {
"completedHotspots": {},
"completedChapters": [],
"lastChapter": 1,
"collectedMemoryCards": [],
"comicReaderByChapter": {},
"lastPageId": ""
}
}
```
`user_id` 仅为当前用户 ID,供前端隔离本机存档命名空间;它由登录中间件提供,不能由客户端选择。保存成功返回同一结构,并额外提供 `idempotent` 布尔值。
请求示例:
```json
{
"schema_version": 1,
"content_version": "season-01",
"base_revision": 0,
"story_generation": 0,
"request_id": "tang_request_00000001",
"operation": "replace",
"progress": {
"completedHotspots": {"S01-C01": ["S01-H01"]},
"completedChapters": [],
"lastChapter": 1,
"collectedMemoryCards": [],
"comicReaderByChapter": {
"S01-C01": {
"currentPageId": "S01-C01-P04",
"completedEventIds": ["S01-H01"],
"chapterFinished": false
}
},
"lastPageId": "S01-C01-P04"
}
}
```
必须提供全部七个请求字段及六个进度字段。映射使用 `{}`;数组、字符串整数、数字布尔值、未知字段、重复/外章 ID、事件缺口、乱序一律拒绝。事件只能是本章固定四事件的连续前缀。有事件或已完成章节必须存在对应阅读对象;事件镜像相同,`chapterFinished``completedChapters` 成员关系一致。四事件完成只解锁 P07,不自动完成章节;P08 要求明确 `chapterFinished=true`
`lastPageId` 可为空;非空时须等于 `lastChapter` 对应 `currentPageId`。收藏只验证卡 ID,可在重玩清空故事后继续保留;不要求当前轮故事已完成该章。
禁止直接序列化原版 `getProgress()`。原版会保留额外字段,且 `memoryCardSnapshots` 包含正文。本机可保留这些字段,提交前必须按本契约另建投影;不可发送健康选择、答案、文字快照、音频内容、偏好设置、姓名、手机或用户画像。
## 替换、冲突与重置
`replace` 仅在 `base_revision``story_generation` 都匹配时替换整份投影,修订加一。用户在冲突界面明确选择保留本机时,可以在重新读取云端版本后发送新请求 ID;禁止后台擅自把旧投影套到新修订上。
`reset_story` 请求的故事字段必须为空、`lastChapter=1``lastPageId=''`,可以保留已验证收藏 ID。在版本匹配后,服务器保留“当前服务器收藏 ∪ 本次请求收藏”,清空其他故事字段,修订和故事代次各加一。前端本机重置待同步时,暂停普通保存与自动冲突重放;老代次队列不能重新进入新故事。离线新获得的卡 ID 不会因成功重置而丢失。
最后一次成功请求的 ID 和规范化内容摘要存储在同一行。相同 ID、相同内容重试只返回原确认状态;同 ID 不同内容返回 `IDEMPOTENCY_CONFLICT`。后续新提交成功后,更早的重试不在缓存窗口内,版本不匹配时返回 `PROGRESS_CONFLICT`,不会再次执行。重置超时只能重试原请求或读取后协调,不能自动换 ID 再重置。
失败 `data``{"error_code":"..."}`
| error_code | 前端处理 |
|---|---|
| `PROGRESS_CONFLICT` | 暂停队列、读取云端,提示用户选择云端或本机;不覆盖、不自动合并 |
| `IDEMPOTENCY_CONFLICT` | 暂停错误请求;同一请求 ID 不可变更内容 |
| `INVALID_REQUEST` | 投影或关联约束不符合契约;保留本机,停止原样重试 |
| `PAYLOAD_TOO_LARGE` | 正文超过 32 KiB;不可分片绕过,应修复白名单投影 |
| `UNSUPPORTED_CONTENT_VERSION` | 不兼容版本;保留本机,更新后协调 |
| `UNSUPPORTED_MEDIA_TYPE` | 改为 `application/json` |
| `METHOD_NOT_ALLOWED` | 修正 GET/POST 方法 |
| `AUTH_REQUIRED` | 恢复原小程序登录 |
| `STORAGE_UNAVAILABLE` | 保留本机,稍后按同请求重试或读取确认 |
原登录中间件会更早返回 `code=-1`(过期)或 `code=0,data=[]`(缺 token)。此既有格式未改动,前端也要处理,不能假设所有错误都有 `error_code`
网络请求复用主小程序 `token`,采用有限超时(现有 Vue3 封装默认 15 秒);传 JSON 请求头覆盖默认表单编码。单用户保存排队、合并频繁游标变动;只对暂时网络/存储失败进行有限退避重试,并复用原请求 ID、原正文。这个模块未新增集中限流器;对公网部署前仍须按实际网关配置用户级速率限制,不能把前端节流当成服务端限流。
## 数据隔离、迁移与可观测性
仅新增 `zyt_tcm_tang_detective_progress`,逻辑访问名 `tcm_tang_detective_progress`。每用户唯一行;读取不自动建行。写入通过事务与 `SELECT ... FOR UPDATE` 锁定自己的行,更新还检查修订/代次。首次并发插入由 `UNIQUE(user_id)` 仲裁;重复键、死锁或锁等待失败映射为 `PROGRESS_CONFLICT`,不向客户端暴露数据库详情。无 upsert 覆盖路径。
迁移文件:`sql/1.9.20260908/add_tang_detective_progress.sql`。脚本沿用默认 `zyt_` 前缀、InnoDB、`utf8mb4` 与整数时间戳;在执行前核对部署的 `database.prefix`。只追加表,不回填其他业务数据。发布顺序是先审核/执行新增表迁移,再启用接口,最后启用前端同步。回退时先停入口/回退代码,保留新增表和用户存档;不要自动删除存档。
错误日志只记录稳定事件名、操作、当前用户 ID 与异常类,不记录请求体、token、SQL、原始异常消息或正文。未实现外部监控仪表盘或迁移自动执行。进度行关联账号;不复制患者、手机、健康回答。独立保留策略及账号删除时的存档清理须随实际账号生命周期流程审定,本次不修改共享用户删除流程。
## 检查方式和交付边界
不需要 Composer、数据库或网络的契约脚本:
```sh
php server/tests/TangDetectiveProgressContractTest.php
php server/tests/TangDetectivePersistenceContractTest.php
```
第一项覆盖十五章全部连续事件前缀、页锁、未知字段/健康正文拒绝、32 KiB 边界、幂等、版本冲突及重置后旧代次。第二项通过内存替身执行真实持久化/控制器代码,检查身份隔离、行锁调用、仅访问独立表、重复提交不更新、数据库异常脱敏、方法与媒体类型保护。
已使用受限、只读 Node 模块加载器逐章核对原 source 的 15 章、60 事件、120 页和 15 卡 ID,结果通过。该核对不执行 PHP 后端。
截至本次编写环境,`php` 不在 PATH,也未在三个常用可执行路径找到。本轮 PHP 测试状态为 **NOT RUN(未运行)**;已写测试不等于通过。内存替身即使通过,也不证明真实 MySQL 隔离级别、死锁行为、中间件认证、HTTP 路由或目标部署可用。真实数据库并发、登录到请求端点、微信真机和部署验证均未运行。本轮不执行迁移、不启动服务、不安装依赖或访问网络。
+240
View File
@@ -0,0 +1,240 @@
openapi: 3.1.0
info:
title: 唐侦探独立章节存档
version: 1.0.0
description: >-
使用原小程序 token,仅操作登录用户自己的 season-01 ID 存档。
不接收健康回答、正文、快照或客户端用户身份。业务错误沿用 HTTP 200/code 约定。
servers:
- url: /api
security:
- MiniProgramToken: []
paths:
/tang/catalog:
get:
operationId: getTangDetectiveCatalog
summary: 读取部署版本的 ID 白名单
responses:
'200':
description: code=1 为目录,code=0/-1 为错误
content:
application/json:
schema:
oneOf:
- type: object
required: [code, show, msg, data]
properties:
code: {const: 1}
show: {const: 0}
msg: {type: string}
data: {$ref: '#/components/schemas/Catalog'}
- {$ref: '#/components/schemas/Failure'}
/tang/progress:
get:
operationId: getTangDetectiveProgress
summary: 只读当前登录用户存档,无存档返回 revision=0 的默认值
responses:
'200':
$ref: '#/components/responses/ProgressResponse'
/tang/saveProgress:
post:
operationId: saveTangDetectiveProgress
summary: 版本匹配时替换整份 ID 投影或重新开始故事
description: >-
原始请求体最多 32768 字节。最后一次 request_id 的相同规范化内容可幂等重试。
必须匹配 base_revision 和 story_generation;冲突不覆盖、不自动合并。
reset_story 必须提交空故事字段,收藏为当前服务器与该请求的已验证卡 ID 并集。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/SaveRequest'}
responses:
'200':
$ref: '#/components/responses/ProgressResponse'
components:
securitySchemes:
MiniProgramToken:
type: apiKey
in: header
name: token
description: 沿用现有 LoginMiddleware;不是 Authorization,也不放入请求正文。
responses:
ProgressResponse:
description: >-
必须检查 codecode=1 的 data 是本人确认存档。
此控制器的响应含 Cache-Control no-store。
headers:
Cache-Control:
schema: {type: string, const: no-store}
content:
application/json:
schema:
oneOf:
- {$ref: '#/components/schemas/ProgressSuccess'}
- {$ref: '#/components/schemas/Failure'}
schemas:
ChapterId:
type: string
pattern: '^S01-C(0[1-9]|1[0-5])$'
EventId:
type: string
pattern: '^S01-H(0[1-9]|[1-5][0-9]|60)$'
PageId:
type: string
pattern: '^S01-C(0[1-9]|1[0-5])-P0[1-8]$'
CardId:
type: string
pattern: '^S01-C(0[1-9]|1[0-5])-MC01$'
Catalog:
type: object
additionalProperties: false
required: [game_id, schema_version, content_version, max_body_bytes, chapters]
properties:
game_id: {const: tang-detective}
schema_version: {const: 1}
content_version: {const: season-01}
max_body_bytes: {const: 32768}
chapters:
type: array
minItems: 15
maxItems: 15
items:
type: object
additionalProperties: false
required: [chapter_id, chapter_number, hotspot_ids, page_ids, memory_card_id]
properties:
chapter_id: {$ref: '#/components/schemas/ChapterId'}
chapter_number: {type: integer, minimum: 1, maximum: 15}
hotspot_ids:
type: array
minItems: 4
maxItems: 4
uniqueItems: true
items: {$ref: '#/components/schemas/EventId'}
page_ids:
type: array
minItems: 8
maxItems: 8
uniqueItems: true
items: {$ref: '#/components/schemas/PageId'}
memory_card_id: {$ref: '#/components/schemas/CardId'}
Reader:
type: object
additionalProperties: false
required: [currentPageId, completedEventIds, chapterFinished]
properties:
currentPageId: {$ref: '#/components/schemas/PageId'}
completedEventIds:
type: array
maxItems: 4
uniqueItems: true
items: {$ref: '#/components/schemas/EventId'}
chapterFinished: {type: boolean}
description: >-
事件必须是所属章节的连续前缀且与 completedHotspots 相同;
chapterFinished 必须与 completedChapters 成员关系一致,为 true 时须有四事件。
未 finished 仅允许 P01 至 P(03+事件数),最多 P07P08 必须 finished。
Progress:
type: object
additionalProperties: false
required: [completedHotspots, completedChapters, lastChapter, collectedMemoryCards, comicReaderByChapter, lastPageId]
properties:
completedHotspots:
type: object
maxProperties: 15
propertyNames: {$ref: '#/components/schemas/ChapterId'}
additionalProperties:
type: array
maxItems: 4
uniqueItems: true
items: {$ref: '#/components/schemas/EventId'}
completedChapters:
type: array
maxItems: 15
uniqueItems: true
items: {$ref: '#/components/schemas/ChapterId'}
lastChapter: {type: integer, minimum: 1, maximum: 15}
collectedMemoryCards:
type: array
maxItems: 15
uniqueItems: true
items: {$ref: '#/components/schemas/CardId'}
comicReaderByChapter:
type: object
maxProperties: 15
propertyNames: {$ref: '#/components/schemas/ChapterId'}
additionalProperties: {$ref: '#/components/schemas/Reader'}
lastPageId:
oneOf:
- {const: ''}
- {$ref: '#/components/schemas/PageId'}
description: >-
空映射必须是 {},不是 []。有已完成事件或 finished 的章节必须有 reader。
非空 lastPageId 必须等于 lastChapter 对应 reader.currentPageId。
跨字段成员关系、顺序和解锁约束由 TangDetectiveProgress 验证。
SaveRequest:
type: object
additionalProperties: false
required: [schema_version, content_version, base_revision, story_generation, request_id, operation, progress]
properties:
schema_version: {type: integer, const: 1}
content_version: {const: season-01}
base_revision: {type: integer, minimum: 0, maximum: 2147483646}
story_generation: {type: integer, minimum: 0, maximum: 2147483646}
request_id: {type: string, pattern: '^[A-Za-z0-9_-]{16,64}$'}
operation: {enum: [replace, reset_story]}
progress: {$ref: '#/components/schemas/Progress'}
allOf:
- if:
properties:
operation: {const: reset_story}
then:
properties:
progress:
properties:
completedHotspots: {maxProperties: 0}
completedChapters: {maxItems: 0}
lastChapter: {const: 1}
comicReaderByChapter: {maxProperties: 0}
lastPageId: {const: ''}
State:
type: object
additionalProperties: false
required: [user_id, schema_version, content_version, revision, story_generation, progress]
properties:
user_id: {type: integer, minimum: 1}
schema_version: {const: 1}
content_version: {const: season-01}
revision: {type: integer, minimum: 0}
story_generation: {type: integer, minimum: 0}
progress: {$ref: '#/components/schemas/Progress'}
idempotent:
type: boolean
description: 仅保存响应提供;true 表示重复确认最后一次提交,未重复写入。
ProgressSuccess:
type: object
required: [code, show, msg, data]
properties:
code: {const: 1}
show: {const: 0}
msg: {type: string}
data: {$ref: '#/components/schemas/State'}
Failure:
type: object
required: [code, show, msg, data]
properties:
code: {type: integer, enum: [0, -1]}
show: {type: integer, enum: [0, 1]}
msg: {type: string}
data:
oneOf:
- type: object
additionalProperties: false
required: [error_code]
properties:
error_code:
enum: [INVALID_REQUEST, PAYLOAD_TOO_LARGE, UNSUPPORTED_CONTENT_VERSION, PROGRESS_CONFLICT, IDEMPOTENCY_CONFLICT, METHOD_NOT_ALLOWED, UNSUPPORTED_MEDIA_TYPE, AUTH_REQUIRED, STORAGE_UNAVAILABLE]
- type: array
maxItems: 0
description: 原登录中间件可能提前返回空 data;登录过期 code=-1。
@@ -0,0 +1,17 @@
-- 唐侦探 season-01 独立存档。仅新增表;不修改用户、患者或三消业务表。
-- 默认前缀 zyt_;部署时须与 database.prefix 核对。不要对线上库自动执行。
CREATE TABLE IF NOT EXISTS `zyt_tcm_tang_detective_progress` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`user_id` int unsigned NOT NULL COMMENT '统一 token 对应的小程序用户 ID',
`schema_version` smallint unsigned NOT NULL DEFAULT 1,
`content_version` varchar(32) NOT NULL DEFAULT 'season-01',
`revision` int unsigned NOT NULL DEFAULT 1 COMMENT '成功写入递增,旧修订不能覆盖',
`story_generation` int unsigned NOT NULL DEFAULT 0 COMMENT '重新开始故事时递增',
`progress_json` text NOT NULL COMMENT '严格白名单 ID 及游标,不含正文或健康选择',
`last_request_id` varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`last_request_hash` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tang_progress_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='唐侦探用户独立章节存档';
@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
// Pure in-memory doubles exercise the actual persistence/controller classes.
// This is NOT a MySQL locking, middleware authentication, or HTTP integration test.
namespace think\db\exception {
class PDOException extends \RuntimeException
{
public function getData(): array
{
return ['PDO Error Info' => ['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";
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
// Pure PHP: no vendor bootstrap, database, server, network, or filesystem writes.
require __DIR__ . '/../app/common/service/game/TangDetectiveProgressException.php';
require __DIR__ . '/../app/common/service/game/TangDetectiveProgress.php';
use app\common\service\game\TangDetectiveProgress;
use app\common\service\game\TangDetectiveProgressException;
$policy = new TangDetectiveProgress();
$assertions = 0;
$expect = static function (bool $condition, string $message) use (&$assertions): void {
if (!$condition) {
throw new RuntimeException($message);
}
++$assertions;
};
$expectError = static function (string $code, callable $action) use ($expect): void {
try {
$action();
} catch (TangDetectiveProgressException $exception) {
$expect($exception->errorCode() === $code, 'expected stable error: ' . $code);
return;
}
throw new RuntimeException('Expected error was not thrown: ' . $code);
};
$encode = static fn (array $value): string => json_encode($value, JSON_THROW_ON_ERROR);
$command = static function (array $progress, int $revision = 0, int $generation = 0, string $id = 'test_request_00000001', string $operation = 'replace'): array {
return [
'schema_version' => 1, 'content_version' => 'season-01',
'base_revision' => $revision, 'story_generation' => $generation,
'request_id' => $id, 'operation' => $operation, 'progress' => $progress,
];
};
$progressFor = static function (int $chapterNumber, int $events, bool $finished = false) use ($policy): array {
$id = sprintf('S01-C%02d', $chapterNumber);
$catalog = $policy->catalog()['chapters'][$chapterNumber - 1];
$done = array_slice($catalog['hotspot_ids'], 0, $events);
$page = sprintf('%s-P%02d', $id, $finished ? 8 : min(7, 3 + $events));
return [
'completedHotspots' => (object) [$id => $done],
'completedChapters' => $finished ? [$id] : [],
'lastChapter' => $chapterNumber,
'collectedMemoryCards' => [],
'comicReaderByChapter' => (object) [$id => (object) [
'currentPageId' => $page, 'completedEventIds' => $done, 'chapterFinished' => $finished,
]],
'lastPageId' => $page,
];
};
$initial = $policy->defaultState(11);
$expect($initial['user_id'] === 11 && $initial['revision'] === 0, 'own-user empty read is revision zero');
$expect($initial['progress']['completedHotspots'] instanceof stdClass, 'empty maps serialize as objects');
$expectError('AUTH_REQUIRED', static fn () => $policy->defaultState(0));
$expect(count($policy->catalog()['chapters']) === 15, 'catalog contains 15 chapters');
for ($chapter = 1; $chapter <= 15; ++$chapter) {
for ($count = 0; $count <= 4; ++$count) {
$parsed = $policy->decodeRequest($encode($command($progressFor($chapter, $count))));
$id = sprintf('S01-C%02d', $chapter);
$expect(count($parsed['progress']['completedHotspots']->$id) === $count, 'all canonical prefixes accepted');
$expect($parsed['progress']['completedChapters'] === [], 'four events do not finish a chapter automatically');
}
$parsed = $policy->decodeRequest($encode($command($progressFor($chapter, 4, true))));
$expect(count($parsed['progress']['completedChapters']) === 1, 'explicit emotion completion permits memory page');
}
$valid = $command($progressFor(1, 1));
$request = $policy->decodeRequest($encode($valid));
$saved = $policy->transition($initial, $request);
$expect($saved['state']['revision'] === 1 && !$saved['idempotent'], 'first save advances exactly one revision');
$retry = $policy->transition($saved['state'], $request, $request['request_id'], $saved['request_hash']);
$expect($retry['idempotent'] && $retry['state'] === $saved['state'], 'acknowledgement loss retries are idempotent');
$changed = $request;
$changed['progress']['lastPageId'] = '';
$expectError('IDEMPOTENCY_CONFLICT', static fn () => $policy->transition(
$saved['state'], $changed, $request['request_id'], $saved['request_hash']
));
$racing = $request;
$racing['request_id'] = 'other_device_00000001';
$expectError('PROGRESS_CONFLICT', static fn () => $policy->transition($saved['state'], $racing));
$expect($saved['state']['progress']['lastPageId'] === 'S01-C01-P04', 'conflicting copy never changes confirmed state');
$withCard = $progressFor(1, 4, true);
$withCard['collectedMemoryCards'] = ['S01-C01-MC01'];
$current = $policy->transition($initial, $policy->decodeRequest($encode($command($withCard))))['state'];
$resetProgress = $policy->defaultProgress();
$resetProgress['collectedMemoryCards'] = ['S01-C02-MC01'];
$resetRequest = $policy->decodeRequest($encode($command($resetProgress, 1, 0, 'reset_request_000001', 'reset_story')));
$reset = $policy->transition($current, $resetRequest);
$expect($reset['state']['progress']['collectedMemoryCards'] === ['S01-C01-MC01', 'S01-C02-MC01'], 'reset preserves cloud and unsynced local card IDs');
$expect($reset['state']['story_generation'] === 1 && $reset['state']['revision'] === 2, 'reset advances generation and revision');
$expect(get_object_vars($reset['state']['progress']['comicReaderByChapter']) === [], 'reset empties reader bookmarks');
$resetRetry = $policy->transition($reset['state'], $resetRequest, $resetRequest['request_id'], $reset['request_hash']);
$expect($resetRetry['state']['story_generation'] === 1 && $resetRetry['idempotent'], 'reset retry never increments generation twice');
$oldGeneration = $command($withCard, 2, 0, 'old_device_000000001');
$expectError('PROGRESS_CONFLICT', static fn () => $policy->transition($reset['state'], $policy->decodeRequest($encode($oldGeneration))));
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($withCard, 1, 0, 'reset_request_000002', 'reset_story'))));
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('[]'));
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('{invalid'));
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest('null'));
$expectError('PAYLOAD_TOO_LARGE', static fn () => $policy->decodeRequest(str_repeat(' ', 32769)));
$exact = $encode($valid);
$exact .= str_repeat(' ', 32768 - strlen($exact));
$expect($policy->decodeRequest($exact)['request_id'] === $valid['request_id'], 'exactly 32 KiB allowed');
$invalidBodies = [];
$body = $valid;
$body['user_id'] = 99;
$invalidBodies[] = $body;
$body = $valid;
$body['progress']['memoryCardSnapshots'] = (object) ['private' => 'must not be stored'];
$invalidBodies[] = $body;
$body = $valid;
$body['progress']['healthAnswer'] = 'private';
$invalidBodies[] = $body;
$body = $valid;
$body['progress']['lastChapter'] = '1';
$invalidBodies[] = $body;
$body = $valid;
$body['progress']['completedHotspots'] = [];
$invalidBodies[] = $body;
$body = $valid;
$body['base_revision'] = -1;
$invalidBodies[] = $body;
$body = $valid;
$body['request_id'] = 'short';
$invalidBodies[] = $body;
$body = $valid;
$body['progress']['collectedMemoryCards'] = ['S01-C16-MC01'];
$invalidBodies[] = $body;
$body = $valid;
$body['progress']['collectedMemoryCards'] = ['S01-C01-MC01', 'S01-C01-MC01'];
$invalidBodies[] = $body;
foreach ($invalidBodies as $body) {
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($body)));
}
foreach ([['S01-H02'], ['S01-H01', 'S01-H03'], ['S01-H02', 'S01-H01'], ['S01-H01', 'S01-H01'], ['S01-H05']] as $badPrefix) {
$progress = $progressFor(1, 1);
$progress['completedHotspots']->{'S01-C01'} = $badPrefix;
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
}
$progress = $progressFor(1, 3);
$progress['completedChapters'] = ['S01-C01'];
$progress['comicReaderByChapter']->{'S01-C01'}->chapterFinished = true;
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
$progress = $progressFor(1, 4);
$progress['comicReaderByChapter']->{'S01-C01'}->currentPageId = 'S01-C01-P08';
$progress['lastPageId'] = 'S01-C01-P08';
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
$progress = $progressFor(1, 1);
$progress['comicReaderByChapter']->{'S01-C01'}->answer = 'private';
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
$progress = $progressFor(1, 1);
$progress['comicReaderByChapter']->{'S01-C01'}->completedEventIds = [];
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
$progress = $progressFor(1, 1);
$progress['lastPageId'] = 'S01-C02-P01';
$expectError('INVALID_REQUEST', static fn () => $policy->decodeRequest($encode($command($progress))));
$body = $valid;
$body['schema_version'] = '1';
$expectError('UNSUPPORTED_CONTENT_VERSION', static fn () => $policy->decodeRequest($encode($body)));
$body = $valid;
$body['content_version'] = 'season-02';
$expectError('UNSUPPORTED_CONTENT_VERSION', static fn () => $policy->decodeRequest($encode($body)));
echo "Tang Detective progress contract: {$assertions} assertions OK\n";