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
+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。