feat: 完成分类、worksheet 接口开发,资源迁移到七牛
This commit is contained in:
+17
-7
@@ -1,6 +1,8 @@
|
||||
# Doodle Store Server 基础设置
|
||||
|
||||
> 更新日期:2026-10-13。已实现基础配置、日志、中间件、Swagger 与 JWT 守卫,业务登录和 Prisma 账户查询待接入。
|
||||
> 更新日期:2026-10-15。已实现基础配置、日志、中间件、Swagger、微信登录、Prisma 账户查询与 JWT 守卫。
|
||||
|
||||
后续业务接入见 [用户鉴权与登录设计](./用户鉴权与登录设计.md);服务端登录模式和 reason 已接入,小程序恢复及 Web Cookie 会话仍是目标设计。当前接口与验证说明见 [服务端登录接入说明](./服务端登录接入说明.md)。
|
||||
|
||||
参考 `atlas-seller-kit/apps/api` 的应用工厂、结构化日志、requestId、请求体限制、默认鉴权与错误处理。保持本项目 NestJS 11 和 `/api/v1` 契约,不复制卖家工作台的多租户、Redis、对象存储或业务模块。
|
||||
|
||||
@@ -8,6 +10,8 @@
|
||||
|
||||
从 `doodle-store` 根目录执行 `pnpm dev`。服务只读取 `apps/server/.env`,不向上搜索根目录的 Compose `.env`;部署环境变量优先于 `.env`。应用工厂不自行监听端口,main 负责读取配置和启动,测试使用临时端口。
|
||||
|
||||
开发命令通过 `apps/server/scripts/dev.mjs` 启动 Nest watch,在收到实际监听成功的通知后,输出 `Swagger: <完整地址>` 并使用系统默认浏览器打开一次。热更新不重复打开;`SWAGGER_ENABLED=false` 时只提示文档已关闭。链接使用实际监听 IP/端口,通配地址转换为本机地址。通知独立于日志级别;打开浏览器失败只提示手动访问,不终止服务。`pnpm start` 保留服务端启动行为,不自动打开浏览器,`server_started` 结构化日志增加 `swagger_url`。
|
||||
|
||||
新环境先复制 `apps/server/.env.example` 为 `apps/server/.env`,设置 JWT_SECRET。生成随机密钥的示例命令:
|
||||
|
||||
```bash
|
||||
@@ -19,7 +23,7 @@ node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
|
||||
| 配置 | 默认 / 要求 |
|
||||
| ------------------------- | ----------------------------------------------------------------- |
|
||||
| NODE_ENV | development;允许 development/test/production |
|
||||
| HOST / PORT | 127.0.0.1 / 3000;端口须为 1~65535 整数 |
|
||||
| HOST / PORT | 127.0.0.1 / 8800;端口须为 1~65535 整数 |
|
||||
| LOG_LEVEL | info;允许 error/warn/info/debug |
|
||||
| API_BODY_LIMIT_BYTES | 1048576,即 1 MiB;允许 1 KiB~10 MiB |
|
||||
| SWAGGER_ENABLED | 未设置时开发/测试 true,生产 false;生产不要照搬开发示例中的 true |
|
||||
@@ -28,8 +32,12 @@ node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
|
||||
| JWT_SECRET | 必填,至少 32 字节;拒绝空值、过短或 change-me 占位值 |
|
||||
| JWT_ISSUER / JWT_AUDIENCE | doodle-store / doodle-mini |
|
||||
| JWT_EXPIRES_IN_SECONDS | 86400,即 24 小时;允许 60~604800 秒 |
|
||||
| WECHAT_MINI_APP_ID | 小程序 AppID;必填,必须与 AppSecret 成对提供 |
|
||||
| WECHAT_MINI_APP_SECRET | 必填;无默认密钥,仅服务端持有 |
|
||||
|
||||
DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接数据库,也不读取七牛凭证。健康检查成功只证明 HTTP 进程可用。
|
||||
微信配置写入 `apps/server/.env`,不写根目录 Compose `.env`。服务端通过 `ServerConfig.wechatMiniAppId/wechatMiniAppSecret` 读取;两项去除首尾空白后校验非空且不含内部空白,错误不回显实际值。登录时使用 code2Session 验证身份,启动校验不代表已验证微信凭据真实有效。AppSecret 与 JWT_SECRET 是独立密钥,不得混用、下发或输出到日志。
|
||||
|
||||
DATABASE_URL 现在必填,必须为 PostgreSQL URL 且使用 public schema;Prisma 单应用连接池最多 5 条连接,连接超时 2 秒、语句超时 5 秒,应用关闭时释放。GET /health 只证明 HTTP 进程可用,GET /health/ready 检查数据库连通性;七牛待接入。
|
||||
|
||||
## 2. 请求处理
|
||||
|
||||
@@ -40,11 +48,11 @@ DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接
|
||||
- Helmet 安全头与隐藏 X-Powered-By。Swagger 开启时放宽文档所需 CSP;Swagger 关闭时使用默认 CSP。
|
||||
- CORS 使用精确 origin 白名单,无 Cookie 凭证模式;允许 Bearer Authorization,并向浏览器暴露 X-Request-Id。无 origin 的小程序/服务端请求可用,CORS 不代替身份校验。
|
||||
- DTO 使用 ValidationPipe:transform、whitelist、forbidNonWhitelisted;不隐式把数字字符串转换成数字,不在错误里返回原始对象或值。
|
||||
- 错误统一为 `{code: HTTP状态码, message: 安全提示, requestId}`,保留 HTTP 状态;JSON 格式错误 400、超限 413、未登录 401、无权限 403、未预期错误 500。成功响应沿用 `{code:0,message,data}`,不重复包装健康检查。
|
||||
- 错误统一为 `{code: HTTP状态码, message: 安全提示, requestId}`,保留 HTTP 状态;JSON 格式错误 400、超限 413、未登录 401、无权限 403、未预期错误 500。可信业务错误新增有限 reason,限流错误携带 Retry-After;成功响应沿用 `{code:0,message,data}`,不重复包装健康检查。
|
||||
|
||||
## 3. Swagger
|
||||
|
||||
开发默认地址:`http://127.0.0.1:3000/api-docs`;OpenAPI JSON:`/api-docs-json`。Swagger 反映实际注册路由,不展示计划中尚未实现的业务接口。
|
||||
开发默认地址:`http://127.0.0.1:8800/api-docs`;OpenAPI JSON:`/api-docs-json`。Swagger 反映实际注册路由,不展示计划中尚未实现的业务接口。
|
||||
|
||||
使用 access-token Bearer JWT 安全定义,健康检查声明为公开。Swagger 不持久保存浏览器 token。生产默认关闭;如部署时显式开启,应通过部署入口限制访问。Swagger 是直接注册的文档路由,不由控制器 JWT 守卫保护。
|
||||
|
||||
@@ -56,12 +64,14 @@ DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接
|
||||
|
||||
验签后通过 ACCOUNT_READER 查询账户,要求用户存在、active、userId 一致且 authVersion 匹配。已注销、禁用或版本失效返回 401;不满足角色返回 403。数据库查询失败不降级为“JWT 有效即可通过”。
|
||||
|
||||
**当前 ACCOUNT_READER 是拒绝放行的接入占位实现**:有效 token 请求受保护接口时返回 503。后续 Prisma 用户模块必须替换为真实数据库读取;微信 code2Session、账户登录、注销和业务 API 尚未实现。测试中的账户读取替身和 probe 路由只在测试模块注册,不会进入正式服务。
|
||||
ACCOUNT_READER 现已接入真实 Prisma 查询;数据库失败返回 503/DATABASE_UNAVAILABLE,不降级放行。POST /auth/wechat/login 接入微信 code2Session、start/resume、唯一冲突处理及行锁;GET /me 返回本人摘要。资料修改、注销及其他业务 API 待实现。测试中的账户读取替身、微信 HTTP 替身和 probe 路由只在测试中使用。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
使用 `pnpm check` 验证格式、ESLint、类型、构建与测试。集成测试覆盖健康检查、前缀、Swagger 开关与 Bearer 定义、CORS、安全头、requestId、JSON/urlencoded、超限和 DTO 校验、日志脱敏、JWT 伪造/过期/错误签名及 claims、账户禁用/缺失/版本失效、服务端角色校验和未接入账户查询时的拒绝放行。
|
||||
使用 `pnpm check` 验证格式、ESLint、类型、构建与测试。集成测试覆盖健康检查、前缀、Swagger 开关与 Bearer 定义、CORS、安全头、requestId、JSON/urlencoded、超限和 DTO 校验、日志脱敏、JWT 伪造/过期/错误签名及 claims、账户禁用/缺失/版本失效、服务端角色校验和数据库不可用时的拒绝放行。
|
||||
|
||||
2026-10-13:`pnpm check` 全部通过,12 项测试通过。另使用构建后的 `dist/main.js` 启动真实进程,核对服务端 `.env` 加载、健康检查 HTTP 200、OpenAPI JSON HTTP 200 和 X-Request-Id;检查进程已关闭,未修改开发数据库或云服务器。
|
||||
|
||||
2026-10-15:接入微信静默登录与真实账户读取后,`pnpm check` 的 20 项常规测试和独立 `pnpm test:db` 的 8 项检查通过;构建产物真实启动/数据库连接通过。详细边界与记录见 [服务端登录接入说明](./服务端登录接入说明.md)。
|
||||
|
||||
基于 [NestJS Swagger 文档](https://docs.nestjs.com/openapi/introduction) 的运行时文档生成方式及 [ValidationPipe 文档](https://docs.nestjs.com/techniques/validation) 的 DTO 校验机制实现;依赖锁定兼容当前 NestJS 11 的具体版本。
|
||||
|
||||
@@ -133,3 +133,7 @@ worksheet.is_new 暂保留兼容:新接口以时间派生为准,旧接口仅
|
||||
列表默认仅功能,资源/all 通过显式 target 选择;返回目标类型及资源独立预览。取消精确定位收藏记录或资源 ID,旧按功能取消不得删除内容收藏。资源缺失或下架只使详情不可用,内容 ID 不清空,仍可取消。注销清理两种收藏,仅功能关系贡献影响功能 likes。
|
||||
|
||||
数据库不强制资源外键以保留历史,发布内容不允许重新归属另一 worksheet。需要收藏某次算法生成结果、保存用户涂色进度或精确结果快照时另行设计;当前资源收藏只表示选中的完整底图内容。
|
||||
|
||||
## 发布可见性补充(2026-10-16)
|
||||
|
||||
沿用 worksheets 与 worksheet_resources 的 draft/active/hidden,不新增布尔上线字段。categories 增加同名 status,新建默认草稿;上级未上线会阻止下级公开读取,不覆盖下级状态。管理预览、上下架、首发时间及快照失效已实现,资源公开读取/生成/下载仍待后续接口接入相同策略。详见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"source": "database_export-_oMVCNYtUGVT.json",
|
||||
"sourceSha256": "02a5a2d26c1801e7e77e8dc2449aebd5b74e11d49a358ed957f3cd11ca290d11",
|
||||
"requestId": "categories-import-1a32b65c-194b-442b-b97c-c3759aa521be",
|
||||
"dryRun": false,
|
||||
"batchAt": "2026-10-15T05:35:43.575Z",
|
||||
"total": 6,
|
||||
"inserted": 6,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"missingCreatedAt": 6,
|
||||
"missingUpdatedAt": 6,
|
||||
"recoveredFromDatabase": true,
|
||||
"note": "导入事务成功,初次保存报告的相对路径无效;此报告依据已提交的分类时间、同批审计及源文件哈希补记。",
|
||||
"changes": [
|
||||
{ "id": "math", "action": "insert" },
|
||||
{ "id": "puzzle", "action": "insert" },
|
||||
{ "id": "pinyin", "action": "insert" },
|
||||
{ "id": "chinese", "action": "insert" },
|
||||
{ "id": "english", "action": "insert" },
|
||||
{ "id": "craft", "action": "insert" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"source": "database_export-_oMVCNYtUGVT.json",
|
||||
"sourceSha256": "02a5a2d26c1801e7e77e8dc2449aebd5b74e11d49a358ed957f3cd11ca290d11",
|
||||
"requestId": "categories-import-d11d1569-947b-4000-9fea-4e86acc45e44",
|
||||
"dryRun": false,
|
||||
"batchAt": "2026-10-15T05:38:17.482Z",
|
||||
"total": 6,
|
||||
"inserted": 0,
|
||||
"updated": 0,
|
||||
"unchanged": 6,
|
||||
"missingCreatedAt": 6,
|
||||
"missingUpdatedAt": 6,
|
||||
"changes": [
|
||||
{
|
||||
"id": "math",
|
||||
"action": "unchanged"
|
||||
},
|
||||
{
|
||||
"id": "puzzle",
|
||||
"action": "unchanged"
|
||||
},
|
||||
{
|
||||
"id": "pinyin",
|
||||
"action": "unchanged"
|
||||
},
|
||||
{
|
||||
"id": "chinese",
|
||||
"action": "unchanged"
|
||||
},
|
||||
{
|
||||
"id": "english",
|
||||
"action": "unchanged"
|
||||
},
|
||||
{
|
||||
"id": "craft",
|
||||
"action": "unchanged"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
{
|
||||
"source": "worksheets.json",
|
||||
"sourceSha256": "184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150",
|
||||
"requestId": "worksheets-import-ce45ffac-2272-424e-acfd-1a93a6dd4379",
|
||||
"dryRun": true,
|
||||
"mode": "metadata-only",
|
||||
"batchAt": "2026-10-16T05:59:12.184Z",
|
||||
"total": 51,
|
||||
"inserted": 51,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"isolated": 0,
|
||||
"assetsInserted": 51,
|
||||
"assetsReused": 0,
|
||||
"pendingPreviews": 51,
|
||||
"readyPreviews": 0,
|
||||
"readyForPublicUse": false,
|
||||
"gradeRelationsInserted": 0,
|
||||
"warnings": [
|
||||
{
|
||||
"id": "break-ten",
|
||||
"field": "grade",
|
||||
"reason": "旧兼容值超出新年级范围;原样保留,不写 worksheet_grades,须人工审核",
|
||||
"originalValue": 23
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"downloads": "4203",
|
||||
"likes": "217",
|
||||
"downloadsSeed": "629",
|
||||
"likesSeed": "666"
|
||||
},
|
||||
"changes": [
|
||||
{
|
||||
"id": "letter-tracing-daily-checkin",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ed9bd61247311b9e31222b98d768e50cbecd45be290eea2c09f869687bcc47a3",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-single",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_292ec2303d74360769ca5ff9e3c2651bc27862c770c007320ba2d2d3e4a23424",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-upper-lower",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_f8edb05d6280ea86cc9b16bc097612621dd8105e8c2fd47b9dc704c80f7331b1",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-case-pairing",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_924b82c9ff669c42a7567a89554dcbc0fd2c03e37746f9e802715bc056b8ee32",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-two-column",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_fa04443ab3c32112e76453578024a507bfa34ded181eb43e81d9b37d6a129db5",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-three",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ec7592fc601ff5304b2c1fecea748f921e8937990303732b27fa83943c40c55b",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-half",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_aa75a233bec6e5a7bf928fae6ffc66cade0d997774c3b0d2d316ee60aaa6e6b6",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-find",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_1a26a050bd297bd83e9682f1c378f78b6f9ee877ecc48b289225c0b51737645e",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "counting-matching",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_cc9d75451a0ee4da7a04e664bd80d8ab9eda02f6fdb3cdd46eef8d0a071e4c54",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-coloring",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_3d9edf7690ffd34ab68ce3d2a60833fcba04b9c02c09c70fa1209f49a1ea66cd",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "addition-5",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_940163cc8631cf219c6776fb16b642782a9cec276faf36c6e82fd9fd88256abe",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "missing-number",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_204ad59ba9d1af25e066b5ff11fb46740a7d442f6e3676b95ddc7b8b0163f041",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "compare",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_3db2d8a91fd6c7ac0b1dca93d66c416059338e699ccc71c096283d869abf09d2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "counting-select",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_c977fd235d232c10e81b9a27f008c8f9d06ebe288ad87172b1f4ede94e113553",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-decompose",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_897d397176b357eacb05d1e0252a1dd8e6ba720f5e16ac0154714648f631a9fc",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-decompose-20",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_f281beff18b2d16a2752f1b7a89b3aec66d7ab4e7620c2879926d619045a7a53",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-sort",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ab742f76c9a602ddc62fea8cafa01aa63d11dacceaccb87e09447601eb6a86d1",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-object-match",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_377bd7c09c52eb09275b06ee9502badb8877af8fb26f64db8fbb89074adb234d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "make-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_aa0e070da59aab8cdd4375815e0ff15cbe110846836d81bcc9bc193f3a9f8100",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "break-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_28204937f027aa9c1021538876302094d1537af88ff7184db8a510f8b46fbc92",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "flat-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_bf0d27f22c0ecf5b3c5881a14d82e175463e7beb0e607d331466380ed3292013",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "borrow-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_6da16aac3c0777b40fcd29199dcce4723d290846c8c2334ccd25561456325a3e",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "one-digit-addition",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_79de07b8e1e2b0aeedcc69c1e194b053c448bc470d7a258acd51108961f41671",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-addition",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_1e14f4accf739b96848de4f641f11ddd87ba726e372faae4f6718682d32ef680",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-subtraction",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_45fd3e7f901b2482fad78b4a6204ee86f163a5329d19ee8ae5b07ccb1faff6ee",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-mixed",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_4d7e439d13e3ff1e6dc9777d9c7963517620ccc26e26e4a8d18c977187ca33cd",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "multiplication-table",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_f611cfaaaf53b070ee40cb80ccba2b9f2594535c78ee9230e9b1145af7bf7d60",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "color-shape-match",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_739a548debba7949768438bd7f4424c772595316609442d40b72db63e60d3715",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "shape-symbol",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_0cccc724e9b6cd7d291aca127adda619889c4575ca290edd8350ed5e70822bb2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "position-coloring",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_cca47918d46442771054bd34f96f5325b6eb3817fb82812e4cc8ece1b7373fa6",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "color-pattern",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_e0d545a5003655ca755efc2dfb39d4ece749ce71d7757b6e5fa213cfc0117f0f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "match-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_9031059317b1f8717659484b2634edacf25f94712e84bc13415302af7bd6d78c",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "line-recognition",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_a617caadf17a862293016607d7eeb57e21ce0d0bf8f59f4e05a58ac6f843be4b",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-reasoning",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_b63cba1e2ca40ea06b20291708c91e3be43b1d0e01a74cdf54ac66c66317a29f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "code-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_364bd44e33e8595da9305168176f7266474f2fe9e121c548ba5268e297969f40",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "dot-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ef7a884a30627fdd9349e81b4cb7662fc5048b937e13a5c00ad02122f887f82a",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-drawing-5x5",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_16d270e7e3ad234805b1a6b3af5507d56f6c8363a79985ebe7d365d8fc88001d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-drawing-3x3",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_9b4f5d0cd417cc12807d92b9dd674e08054685514e9188d87d43dee41b46796c",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-coloring-grid",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_184aa7580b91f411717bbe6e3f4c580ceed9e604da3b75031ce29c74250caaa9",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-coloring-find",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_0fec84f97f2a04b734c5839a2b396206800375fd1d47782bb14b84f2715af334",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_2270e1d234135860d31689591b7b9220fe9fc84c37207d50c39a611daf94f52f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_6a32f38f23e609a289f2f4da36a3eeafad9e424d2b8cf4a654c61519c9ad8e79",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_47413e9d6c96556f94fdcfa5dd1517d304575170998b706d73af516e27f29257",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_1dab372e03f70063d6a2269d34c61d9c922b15f6dd2f67315e004255f5d765eb",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_57195cf48980b0028475b986d1ca3aea32b98ae63e4a8541d6657cf6a653fcd3",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_2303428a8e71f057a9bdd87817d300fce5e64b5b4c813e53cfd13de2ab46e2b2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_bb81b0f638bfb8a37bd48d6d41cef3528918413f1e6aa1e4cfca68769fc07c70",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_9306467297d403ec07f422e61227c2300faf5ef6e8e232d9eb1737b0a263309d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_34e9636918372c0dd5fbfcb2f017f4ac700f40dd5553560bd9233320f6eeb3a9",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_dd2205e72b203854845ce228ac67a0fb4960bed4ea194ca207edbcf8365da979",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_7d8efbec45918618d2a8d36007342343844f9190ef6b0266f11cd6069616ccf8",
|
||||
"previewStatus": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
{
|
||||
"source": "worksheets.json",
|
||||
"sourceSha256": "184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150",
|
||||
"requestId": "worksheets-import-d33a9942-819e-487c-b650-c5b7904b0faf",
|
||||
"dryRun": false,
|
||||
"mode": "metadata-only",
|
||||
"batchAt": "2026-10-16T05:59:22.266Z",
|
||||
"total": 51,
|
||||
"inserted": 51,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"isolated": 0,
|
||||
"assetsInserted": 51,
|
||||
"assetsReused": 0,
|
||||
"pendingPreviews": 51,
|
||||
"readyPreviews": 0,
|
||||
"readyForPublicUse": false,
|
||||
"gradeRelationsInserted": 0,
|
||||
"warnings": [
|
||||
{
|
||||
"id": "break-ten",
|
||||
"field": "grade",
|
||||
"reason": "旧兼容值超出新年级范围;原样保留,不写 worksheet_grades,须人工审核",
|
||||
"originalValue": 23
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"downloads": "4203",
|
||||
"likes": "217",
|
||||
"downloadsSeed": "629",
|
||||
"likesSeed": "666"
|
||||
},
|
||||
"changes": [
|
||||
{
|
||||
"id": "letter-tracing-daily-checkin",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ed9bd61247311b9e31222b98d768e50cbecd45be290eea2c09f869687bcc47a3",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-single",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_292ec2303d74360769ca5ff9e3c2651bc27862c770c007320ba2d2d3e4a23424",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-upper-lower",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_f8edb05d6280ea86cc9b16bc097612621dd8105e8c2fd47b9dc704c80f7331b1",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-case-pairing",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_924b82c9ff669c42a7567a89554dcbc0fd2c03e37746f9e802715bc056b8ee32",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-two-column",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_fa04443ab3c32112e76453578024a507bfa34ded181eb43e81d9b37d6a129db5",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-three",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ec7592fc601ff5304b2c1fecea748f921e8937990303732b27fa83943c40c55b",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-half",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_aa75a233bec6e5a7bf928fae6ffc66cade0d997774c3b0d2d316ee60aaa6e6b6",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-find",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_1a26a050bd297bd83e9682f1c378f78b6f9ee877ecc48b289225c0b51737645e",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "counting-matching",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_cc9d75451a0ee4da7a04e664bd80d8ab9eda02f6fdb3cdd46eef8d0a071e4c54",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-coloring",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_3d9edf7690ffd34ab68ce3d2a60833fcba04b9c02c09c70fa1209f49a1ea66cd",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "addition-5",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_940163cc8631cf219c6776fb16b642782a9cec276faf36c6e82fd9fd88256abe",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "missing-number",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_204ad59ba9d1af25e066b5ff11fb46740a7d442f6e3676b95ddc7b8b0163f041",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "compare",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_3db2d8a91fd6c7ac0b1dca93d66c416059338e699ccc71c096283d869abf09d2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "counting-select",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_c977fd235d232c10e81b9a27f008c8f9d06ebe288ad87172b1f4ede94e113553",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-decompose",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_897d397176b357eacb05d1e0252a1dd8e6ba720f5e16ac0154714648f631a9fc",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-decompose-20",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_f281beff18b2d16a2752f1b7a89b3aec66d7ab4e7620c2879926d619045a7a53",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-sort",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ab742f76c9a602ddc62fea8cafa01aa63d11dacceaccb87e09447601eb6a86d1",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-object-match",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_377bd7c09c52eb09275b06ee9502badb8877af8fb26f64db8fbb89074adb234d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "make-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_aa0e070da59aab8cdd4375815e0ff15cbe110846836d81bcc9bc193f3a9f8100",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "break-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_28204937f027aa9c1021538876302094d1537af88ff7184db8a510f8b46fbc92",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "flat-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_bf0d27f22c0ecf5b3c5881a14d82e175463e7beb0e607d331466380ed3292013",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "borrow-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_6da16aac3c0777b40fcd29199dcce4723d290846c8c2334ccd25561456325a3e",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "one-digit-addition",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_79de07b8e1e2b0aeedcc69c1e194b053c448bc470d7a258acd51108961f41671",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-addition",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_1e14f4accf739b96848de4f641f11ddd87ba726e372faae4f6718682d32ef680",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-subtraction",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_45fd3e7f901b2482fad78b4a6204ee86f163a5329d19ee8ae5b07ccb1faff6ee",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-mixed",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_4d7e439d13e3ff1e6dc9777d9c7963517620ccc26e26e4a8d18c977187ca33cd",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "multiplication-table",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_f611cfaaaf53b070ee40cb80ccba2b9f2594535c78ee9230e9b1145af7bf7d60",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "color-shape-match",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_739a548debba7949768438bd7f4424c772595316609442d40b72db63e60d3715",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "shape-symbol",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_0cccc724e9b6cd7d291aca127adda619889c4575ca290edd8350ed5e70822bb2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "position-coloring",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_cca47918d46442771054bd34f96f5325b6eb3817fb82812e4cc8ece1b7373fa6",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "color-pattern",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_e0d545a5003655ca755efc2dfb39d4ece749ce71d7757b6e5fa213cfc0117f0f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "match-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_9031059317b1f8717659484b2634edacf25f94712e84bc13415302af7bd6d78c",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "line-recognition",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_a617caadf17a862293016607d7eeb57e21ce0d0bf8f59f4e05a58ac6f843be4b",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-reasoning",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_b63cba1e2ca40ea06b20291708c91e3be43b1d0e01a74cdf54ac66c66317a29f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "code-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_364bd44e33e8595da9305168176f7266474f2fe9e121c548ba5268e297969f40",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "dot-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ef7a884a30627fdd9349e81b4cb7662fc5048b937e13a5c00ad02122f887f82a",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-drawing-5x5",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_16d270e7e3ad234805b1a6b3af5507d56f6c8363a79985ebe7d365d8fc88001d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-drawing-3x3",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_9b4f5d0cd417cc12807d92b9dd674e08054685514e9188d87d43dee41b46796c",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-coloring-grid",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_184aa7580b91f411717bbe6e3f4c580ceed9e604da3b75031ce29c74250caaa9",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-coloring-find",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_0fec84f97f2a04b734c5839a2b396206800375fd1d47782bb14b84f2715af334",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_2270e1d234135860d31689591b7b9220fe9fc84c37207d50c39a611daf94f52f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_6a32f38f23e609a289f2f4da36a3eeafad9e424d2b8cf4a654c61519c9ad8e79",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_47413e9d6c96556f94fdcfa5dd1517d304575170998b706d73af516e27f29257",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_1dab372e03f70063d6a2269d34c61d9c922b15f6dd2f67315e004255f5d765eb",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_57195cf48980b0028475b986d1ca3aea32b98ae63e4a8541d6657cf6a653fcd3",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_2303428a8e71f057a9bdd87817d300fce5e64b5b4c813e53cfd13de2ab46e2b2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_bb81b0f638bfb8a37bd48d6d41cef3528918413f1e6aa1e4cfca68769fc07c70",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_9306467297d403ec07f422e61227c2300faf5ef6e8e232d9eb1737b0a263309d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_34e9636918372c0dd5fbfcb2f017f4ac700f40dd5553560bd9233320f6eeb3a9",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_dd2205e72b203854845ce228ac67a0fb4960bed4ea194ca207edbcf8365da979",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_7d8efbec45918618d2a8d36007342343844f9190ef6b0266f11cd6069616ccf8",
|
||||
"previewStatus": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
{
|
||||
"source": "worksheets.json",
|
||||
"sourceSha256": "184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150",
|
||||
"requestId": "worksheets-import-44653144-d109-422c-83c5-d52d4cce08fc",
|
||||
"dryRun": false,
|
||||
"mode": "metadata-only",
|
||||
"batchAt": "2026-10-16T06:00:05.491Z",
|
||||
"total": 51,
|
||||
"inserted": 0,
|
||||
"updated": 0,
|
||||
"unchanged": 51,
|
||||
"isolated": 0,
|
||||
"assetsInserted": 0,
|
||||
"assetsReused": 51,
|
||||
"pendingPreviews": 51,
|
||||
"readyPreviews": 0,
|
||||
"readyForPublicUse": false,
|
||||
"gradeRelationsInserted": 0,
|
||||
"warnings": [
|
||||
{
|
||||
"id": "break-ten",
|
||||
"field": "grade",
|
||||
"reason": "旧兼容值超出新年级范围;原样保留,不写 worksheet_grades,须人工审核",
|
||||
"originalValue": 23
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"downloads": "4203",
|
||||
"likes": "217",
|
||||
"downloadsSeed": "629",
|
||||
"likesSeed": "666"
|
||||
},
|
||||
"changes": [
|
||||
{
|
||||
"id": "letter-tracing-daily-checkin",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_ed9bd61247311b9e31222b98d768e50cbecd45be290eea2c09f869687bcc47a3",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-single",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_292ec2303d74360769ca5ff9e3c2651bc27862c770c007320ba2d2d3e4a23424",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-upper-lower",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_f8edb05d6280ea86cc9b16bc097612621dd8105e8c2fd47b9dc704c80f7331b1",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-case-pairing",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_924b82c9ff669c42a7567a89554dcbc0fd2c03e37746f9e802715bc056b8ee32",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-two-column",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_fa04443ab3c32112e76453578024a507bfa34ded181eb43e81d9b37d6a129db5",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-three",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_ec7592fc601ff5304b2c1fecea748f921e8937990303732b27fa83943c40c55b",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-half",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_aa75a233bec6e5a7bf928fae6ffc66cade0d997774c3b0d2d316ee60aaa6e6b6",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-find",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_1a26a050bd297bd83e9682f1c378f78b6f9ee877ecc48b289225c0b51737645e",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "counting-matching",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_cc9d75451a0ee4da7a04e664bd80d8ab9eda02f6fdb3cdd46eef8d0a071e4c54",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-coloring",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_3d9edf7690ffd34ab68ce3d2a60833fcba04b9c02c09c70fa1209f49a1ea66cd",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "addition-5",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_940163cc8631cf219c6776fb16b642782a9cec276faf36c6e82fd9fd88256abe",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "missing-number",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_204ad59ba9d1af25e066b5ff11fb46740a7d442f6e3676b95ddc7b8b0163f041",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "compare",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_3db2d8a91fd6c7ac0b1dca93d66c416059338e699ccc71c096283d869abf09d2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "counting-select",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_c977fd235d232c10e81b9a27f008c8f9d06ebe288ad87172b1f4ede94e113553",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-decompose",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_897d397176b357eacb05d1e0252a1dd8e6ba720f5e16ac0154714648f631a9fc",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-decompose-20",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_f281beff18b2d16a2752f1b7a89b3aec66d7ab4e7620c2879926d619045a7a53",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-sort",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_ab742f76c9a602ddc62fea8cafa01aa63d11dacceaccb87e09447601eb6a86d1",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-object-match",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_377bd7c09c52eb09275b06ee9502badb8877af8fb26f64db8fbb89074adb234d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "make-ten",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_aa0e070da59aab8cdd4375815e0ff15cbe110846836d81bcc9bc193f3a9f8100",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "break-ten",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_28204937f027aa9c1021538876302094d1537af88ff7184db8a510f8b46fbc92",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "flat-ten",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_bf0d27f22c0ecf5b3c5881a14d82e175463e7beb0e607d331466380ed3292013",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "borrow-ten",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_6da16aac3c0777b40fcd29199dcce4723d290846c8c2334ccd25561456325a3e",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "one-digit-addition",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_79de07b8e1e2b0aeedcc69c1e194b053c448bc470d7a258acd51108961f41671",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-addition",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_1e14f4accf739b96848de4f641f11ddd87ba726e372faae4f6718682d32ef680",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-subtraction",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_45fd3e7f901b2482fad78b4a6204ee86f163a5329d19ee8ae5b07ccb1faff6ee",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-mixed",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_4d7e439d13e3ff1e6dc9777d9c7963517620ccc26e26e4a8d18c977187ca33cd",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "multiplication-table",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_f611cfaaaf53b070ee40cb80ccba2b9f2594535c78ee9230e9b1145af7bf7d60",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "color-shape-match",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_739a548debba7949768438bd7f4424c772595316609442d40b72db63e60d3715",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "shape-symbol",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_0cccc724e9b6cd7d291aca127adda619889c4575ca290edd8350ed5e70822bb2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "position-coloring",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_cca47918d46442771054bd34f96f5325b6eb3817fb82812e4cc8ece1b7373fa6",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "color-pattern",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_e0d545a5003655ca755efc2dfb39d4ece749ce71d7757b6e5fa213cfc0117f0f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "match-connect",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_9031059317b1f8717659484b2634edacf25f94712e84bc13415302af7bd6d78c",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "line-recognition",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_a617caadf17a862293016607d7eeb57e21ce0d0bf8f59f4e05a58ac6f843be4b",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-reasoning",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_b63cba1e2ca40ea06b20291708c91e3be43b1d0e01a74cdf54ac66c66317a29f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "code-connect",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_364bd44e33e8595da9305168176f7266474f2fe9e121c548ba5268e297969f40",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "dot-connect",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_ef7a884a30627fdd9349e81b4cb7662fc5048b937e13a5c00ad02122f887f82a",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-drawing-5x5",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_16d270e7e3ad234805b1a6b3af5507d56f6c8363a79985ebe7d365d8fc88001d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-drawing-3x3",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_9b4f5d0cd417cc12807d92b9dd674e08054685514e9188d87d43dee41b46796c",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-coloring-grid",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_184aa7580b91f411717bbe6e3f4c580ceed9e604da3b75031ce29c74250caaa9",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-coloring-find",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_0fec84f97f2a04b734c5839a2b396206800375fd1d47782bb14b84f2715af334",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_2270e1d234135860d31689591b7b9220fe9fc84c37207d50c39a611daf94f52f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_6a32f38f23e609a289f2f4da36a3eeafad9e424d2b8cf4a654c61519c9ad8e79",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_47413e9d6c96556f94fdcfa5dd1517d304575170998b706d73af516e27f29257",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_1dab372e03f70063d6a2269d34c61d9c922b15f6dd2f67315e004255f5d765eb",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_57195cf48980b0028475b986d1ca3aea32b98ae63e4a8541d6657cf6a653fcd3",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_2303428a8e71f057a9bdd87817d300fce5e64b5b4c813e53cfd13de2ab46e2b2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_bb81b0f638bfb8a37bd48d6d41cef3528918413f1e6aa1e4cfca68769fc07c70",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_9306467297d403ec07f422e61227c2300faf5ef6e8e232d9eb1737b0a263309d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_34e9636918372c0dd5fbfcb2f017f4ac700f40dd5553560bd9233320f6eeb3a9",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_dd2205e72b203854845ce228ac67a0fb4960bed4ea194ca207edbcf8365da979",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"action": "unchanged",
|
||||
"previewAssetId": "preview_7d8efbec45918618d2a8d36007342343844f9190ef6b0266f11cd6069616ccf8",
|
||||
"previewStatus": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"verifiedAt": "2026-10-16T06:00:05.547Z",
|
||||
"sourceSha256": "184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150",
|
||||
"sourceRows": 51,
|
||||
"databaseRows": 51,
|
||||
"allSourceFieldsMatched": true,
|
||||
"categories": 6,
|
||||
"pendingPreviewAssets": 51,
|
||||
"migrationAudits": 51,
|
||||
"gradeRelations": 0,
|
||||
"resourceRows": 0,
|
||||
"gradeWarnings": [
|
||||
{
|
||||
"id": "break-ten",
|
||||
"legacyGrade": 23,
|
||||
"resolution": "保留旧兼容列,未生成新年级关系"
|
||||
}
|
||||
],
|
||||
"counterTotals": {
|
||||
"downloads": "4203",
|
||||
"likes": "217",
|
||||
"downloadsSeed": "629",
|
||||
"likesSeed": "666"
|
||||
},
|
||||
"reimport": {
|
||||
"inserted": 0,
|
||||
"updated": 0,
|
||||
"unchanged": 51,
|
||||
"assetsInserted": 0,
|
||||
"timestampsAndAuditsUnchanged": true
|
||||
},
|
||||
"readyForPublicUse": false
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# 小程序云存储图片迁移七牛调研
|
||||
|
||||
调研日期:2026-10-16。范围:官方能力核实及本项目实施方案;未执行云端文件下载、上传、权限调整或删除。
|
||||
|
||||
## 结论
|
||||
|
||||
可以迁移。针对已经导入的 51 个题型预览,建议采用 **原云开发授权下载 → 本地备份与校验 → 七牛上传 → 回读校验 → 更新现有 assets 为 ready**。这是一套文件迁移任务,保持题型 ID、preview_asset_id 及原计数/业务时间不变,不必重新导入题型。
|
||||
|
||||
七牛直接抓取 HTTPS URL 也可作为备选,但需要先解决链接授权、失效与源站访问限制。当前只有 51 张预览,优先选择便于留存原文件、校验和重试的本地中转方式。这一优先级是结合项目规模作出的工程判断。
|
||||
|
||||
## 官方能力核实
|
||||
|
||||
| 环节 | 已核实能力 | 对本项目的意义 |
|
||||
| -------------- | ------------------------------------------- | -------------------------------------------- |
|
||||
| 云开发文件获取 | SDK 可按 fileID 下载,或换取 HTTPS 下载链接 | 使用现有 source_ref,不从文件名猜下载地址 |
|
||||
| 云开发目录备份 | CLI 支持下载文件和目录 | 可先备份 assets/previews 目录 |
|
||||
| 七牛上传 | Node.js SDK 支持服务端上传及 stat 查询 | 能集成现有 Node.js/Prisma 工程并自动回填映射 |
|
||||
| 七牛抓取 | fetch 可抓取指定 URL 到指定 bucket/key | 可减少本地转发,但不直接识别 cloud:// |
|
||||
|
||||
上述能力依据 [CloudBase 文件管理](https://docs.cloudbase.net/storage/sdk)、[CloudBase CLI 云存储](https://docs.cloudbase.net/cli-v1/storage)、[七牛 Node.js SDK](https://developer.qiniu.com/kodo/sdk/nodejs) 和 [七牛第三方资源抓取](https://developer.qiniu.com/kodo/api/fetch)。
|
||||
|
||||
## 获取源图片的两条路径
|
||||
|
||||
### 路径 A:CLI / 管理 SDK 下载原文件
|
||||
|
||||
CloudBase 官方 CLI 提供 `tcb storage download <cloudPath> <localPath>`,下载目录使用 `--dir`。以下是选择并确认原云开发环境后的目录下载形式,仅供实施时参考,尚未在本项目账号执行:
|
||||
|
||||
```bash
|
||||
# 先确认安装版本支持的参数,并用有权限的账号登录、明确原环境
|
||||
tcb storage download --help
|
||||
|
||||
# 在明确原环境的配置/参数下,备份预览目录
|
||||
tcb storage download assets/previews ./cloud-backup/previews --dir
|
||||
```
|
||||
|
||||
目录下载可能包括不在当前 51 条题型中的旧文件;上传和数据库绑定仍按这 51 条资产清单选择。若要完整备份,后续另行盘点头像、字体和页面配置等目录,不将未引用文件直接混入本批次。[官方目录下载说明](https://docs.cloudbase.net/cli-v1/storage)
|
||||
|
||||
自动化也可选 `@cloudbase/manager-node` 的文件/目录下载能力。官方管理 SDK 使用 Tencent Cloud SecretId/SecretKey 和 envId 初始化;实际必须确认账号能够管理这个小程序原环境,不能把已有 WECHAT_MINI_APP_SECRET 当作腾讯云管理密钥。原小程序云环境与目标本地 PostgreSQL 是两套系统,目标选用 PostgreSQL 不意味着源端要改用 CloudBase 的 PG 存储接口。[管理 SDK 存储文档](https://docs.cloudbase.net/api-reference/manager/node/storage)
|
||||
|
||||
官方管理 SDK 还说明目录不存在时可能不下载且不抛错,因此必须按清单核对实际下载的 51 个文件,不能只看命令退出成功。SDK 版本的参数形式存在差异,实施时以锁定版本为准。[目录下载说明](https://docs.cloudbase.net/api-reference/manager/node/storage)
|
||||
|
||||
如果账号看不到原环境,先核实绑定和管理权限,不创建替代环境或调整原环境为公开读来绕过授权。具体账号关系仍需实际控制台确认。[CloudBase 账号与环境关联](https://docs.cloudbase.net/faq/account)
|
||||
|
||||
### 路径 B:原环境兑换下载链接
|
||||
|
||||
在已授权的原云开发运行环境中,使用相应 SDK 的 getTempFileURL,再由本地任务下载。官方存储文档说明每批最多 50 个链接,因此本批 51 个 fileID 可分成 50 + 1,逐项检查成功状态;授权链接在使用前获取,过期重新兑换。[官方临时链接说明](https://docs.cloudbase.net/storage/sdk)
|
||||
|
||||
不要把所有 HTTPS 地址都认作临时地址:公有读文件可获得稳定链接,私有读文件通常返回有时限的授权链接,有效期随接口和配置而异。带 sign/t 等授权参数的地址应按临时访问凭证处理,不写入长期资产字段,不放入迁移报告或日志。资产身份继续使用原 fileID/source_ref_hash,而非某次生成的 URL。[官方权限与链接说明](https://docs.cloudbase.net/storage/sdk)
|
||||
|
||||
若需要临时管理云函数,只允许管理员/受控任务调用,限定原环境及本次文件清单,完成后撤除;本次不部署该函数。wx.cloud、wx-server-sdk、CloudBase 管理 SDK 的运行位置和凭据不同,实施时明确选择一种,先验证 1 张原图,不能照搬另一 SDK 的认证示例。
|
||||
|
||||
## 迁入七牛的选择
|
||||
|
||||
### 推荐:备份后由 Node.js 上传
|
||||
|
||||
利用七牛服务端 SDK 上传本地文件,并用资源查询取得目标信息;大批量纯文件同步也可用官方 qshell 的 qupload,但本项目还需自动维护资产和数据库审计,选择与现有工程集成的任务更合适。[七牛 SDK](https://developer.qiniu.com/kodo/sdk/nodejs)、[qshell 工具](https://developer.qiniu.com/kodo/1302/qshell)
|
||||
|
||||
建议实施流程:
|
||||
|
||||
1. 从本地 assets 筛选 purpose=preview、status=pending,导出 assetId → source_ref → 本地备份路径清单,记录原环境和源导出摘要。
|
||||
2. 下载原文件,检查返回的是可解码图片,记录实际 MIME、字节数和 SHA-256;不把错误 HTML 页面或仅改扩展名的内容当作图片。
|
||||
3. 将对象上传到开发环境专属空间/路径。对象 key 使用资产 ID 和内容版本,例如 `dev/previews/<assetId>/<sha256>.jpg`;扩展名以实际文件类型为准。这是本项目命名建议。
|
||||
4. 核对返回 key、大小和内容。不同算法的 hash 不直接比较;本批只有 51 张,可全部从七牛原文件回读并计算同一 SHA-256,与备份逐一比对。
|
||||
5. 文件操作全部完成后,每个资产使用短事务锁行并复核原 source_ref/status,再写 bucket、object_key、object_ref_hash、mime_type、size_bytes、checksum、status=ready 和迁移审计。
|
||||
6. 保留资产 ID、原 source_ref/source_ref_hash 和题型 preview_asset_id,题型原始 createdAt/updatedAt、downloads/likes/seed 不因换存储而重写。
|
||||
7. ready 资产重新执行时核对目标后跳过;失败记录可重试状态及原因,保留原备份。同名异内容不能直接覆盖。上传成功但数据库提交失败时,按既定 key 查询和校验,避免重试生成另一对象。
|
||||
|
||||
七牛资源查询能返回目标 hash、fsize、mimeType 等信息;完整内容回读、SHA-256 和事务顺序是本项目提出的验收设计,不是已完成的迁移结果。[七牛 SDK 资源查询](https://developer.qiniu.com/kodo/sdk/nodejs)
|
||||
|
||||
### 备选:七牛 fetch / batchfetch
|
||||
|
||||
流程为 `fileID → 新鲜有效的 HTTPS 下载链接 → 七牛 fetch → 校验 → 回填资产`。七牛官方 fetch 每次处理一个文件,可指定目标 key;受源站 IP、UA 等访问限制影响。批量可用 qshell batchfetch,仍需逐项核实结果,不能仅按任务提交成功把资产改成 ready。[fetch API](https://developer.qiniu.com/kodo/api/fetch)、[qshell](https://developer.qiniu.com/kodo/1302/qshell)
|
||||
|
||||
该路线适合已有可靠链接清单、文件较多的后续场景。签名过期就重新取得链接,不把旧签名清单作为长期重试数据。抓取后也要验证大小/类型/内容,403、404 及失败行单独记录。是否能从七牛网络访问当前源站,需要用本账号实际测试,官方能力支持不代表该环境已经验证成功。
|
||||
|
||||
七牛还提供 URL/第三方存储迁移工具。直接对接 S3/COS 需要真实源存储权限和相应端点;仅凭 cloud:// fileID 或小程序 AppSecret,不足以推定可以使用底层存储迁移通道。[七牛文件迁移工具](https://developer.qiniu.com/kodo/6284/file-backup-migration?portal_modal=1)
|
||||
|
||||
## 预览和头像存储与绑定
|
||||
|
||||
| 资源 | 本项目建议 | 绑定方式 |
|
||||
| ------------------ | ------------------------------------------ | ------------------------------ |
|
||||
| 题型预览、公共字体 | 公共资源空间,业务 API 生成稳定 HTTPS 地址 | 保留现有预览资产 ID |
|
||||
| 用户头像 | 与预览共用公开空间,使用稳定 HTTPS 地址 | 迁移用户后按原 userId 核实归属 |
|
||||
|
||||
七牛私有空间下载需要授权 URL,公开空间可使用域名与对象 key 生成地址;生产资源域名应使用已配置的自定义域名,避免依赖测试域名。[七牛下载方式](https://developer.qiniu.com/kodo/kb/1321/how-to-acquire-the-outside-storage-file-links)
|
||||
|
||||
当前按用户确认,头像与预览等资源统一使用 QINIU_BUCKET 公开空间;开发/生产隔离方案仍需在接入时确定。仅在文件名里加入 private 或 avatars,并不等于完成访问控制。头像必须依据 users 导出中实际引用和身份关联迁移,不从文件名猜当前用户头像,也不批量绑定已经失联/注销用户的文件。
|
||||
|
||||
## 实施前需要的配置
|
||||
|
||||
- 源端:原 EnvId,以及能读取该环境文件的 CLI 登录/管理权限,或原环境受控 SDK 运行权限。
|
||||
- 七牛:AccessKey/SecretKey、目标 Bucket、区域,以及配置 HTTPS 的资源域名;当前头像与其他资源共用公开空间。
|
||||
- 本地:受控备份目录、文件清单和迁移报告目录。密钥仅放受控环境,不写进文档、命令历史或普通日志。
|
||||
|
||||
建议第一批只执行 51 张预览:先验证 1 张,再迁移全部;验收 `51 个引用 = 成功校验数 + 明确失败数`,全部成功后为 51 个 ready。失败不应混成成功,不改题型计数或审核外的年级数据。
|
||||
|
||||
后续再盘点头像、字体、分类文件图标、首页 JSON 嵌套图片、分享图及小程序硬编码/缓存中的 cloud://。旧版本仍可能使用旧引用,完成新版切换和兼容期核对前保留源文件。新页面从 API 获取七牛地址,按实际使用的微信网络接口配置合法域名,并通过真机验证。
|
||||
|
||||
当前建议的下一步是:确认原云开发下载权限与七牛开发空间配置,然后实现“下载备份、上传校验、资产回填”脚本及资源地址解析服务。上述调研不代表配置已齐备,也尚未修改现有 51 条 pending 资产。
|
||||
|
||||
## 本地同步工具已实现(2026-10-16)
|
||||
|
||||
原文件已由用户下载到 doodle-assets/cloud_backup。现已新增 [packages/asset-sync](../../packages/asset-sync/README.md) 独立脚本项目,可配置源目录、七牛 AK/SK、统一公开空间与对象前缀。默认预演,显式 --apply 才上传;支持 ETag/大小校验、同内容跳过、同名异内容冲突、有限重试和 JSON 报告。
|
||||
|
||||
本地已预演全部 62 个文件及题型预览 51 个文件,未配置真实凭据、未上传七牛,也未更新数据库。此工具只完成“已有备份 → 七牛文件同步”,首次推荐同步预览的命令为 `pnpm assets:sync --apply --only assets/previews`。后续资产回填、用户头像绑定、首页 JSON 重建和旧上传/定时任务切换仍需分别落实。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 内容发布与可见性
|
||||
|
||||
2026-10-16。三张表统一使用 `status = draft / active / hidden`,不增加重复的 isPublished 布尔字段。draft 用于开发、编辑及测试,active 为上线,hidden 为下架。新增分类、题型、资源默认 draft。
|
||||
|
||||
## 公开规则
|
||||
|
||||
- 分类:自身及所有祖先均为 active。公开列表只返回从 active 根节点可达的分类;不存在、未公开、祖先未公开的详情统一 404。
|
||||
- 题型:自身为 active,所属分类及全部祖先可见。全部、分类、年级、年龄、Tag、最新、热门、计数、分页和详情使用同一服务端规则。公开路由不能用 status、preview 或测试模式参数读取草稿;即使调用者是管理员,公开接口也不放宽。
|
||||
- 资源:自身为 active,所属题型及其分类链均可见。管理预览提供派生 visible;资源公开读取/生成/下载接口尚未实现,后续必须复用相同规则再开放。
|
||||
|
||||
上级下架只改变有效可见性,不批量改写下级 status。恢复上级不会使原有草稿或下架内容自动上线。分类目录不根据题型数量自动隐藏;允许已上线的空分类。
|
||||
|
||||
## 管理接口
|
||||
|
||||
统一前缀 `/api/v1`,均要求有效本站 JWT 和服务端当前 active + admin 账户。体验版、客户端开关或自报角色不能替代鉴权。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| ----- | ------------------------------------- | ----------------------------------------------------- |
|
||||
| GET | /admin/categories | 全部状态的扁平分类列表 |
|
||||
| GET | /admin/categories/:id | 分类管理详情 |
|
||||
| POST | /admin/categories | 创建分类,省略 status 为 draft |
|
||||
| PATCH | /admin/categories/:id | 原有字段及 status 的局部修改 |
|
||||
| GET | /admin/worksheets | 原有组合筛选及分页,额外支持 status;省略查询全部状态 |
|
||||
| GET | /admin/worksheets/:id | 题型卡片数据预览,含 status 和 visible |
|
||||
| PATCH | /admin/worksheets/:id/status | 题型状态修改 |
|
||||
| GET | /admin/worksheet-resources | 支持 worksheetId/status/page/pageSize |
|
||||
| GET | /admin/worksheet-resources/:id | 资源卡片数据预览,含 status 和 visible |
|
||||
| PATCH | /admin/worksheet-resources/:id/status | 资源状态修改 |
|
||||
|
||||
状态修改请求示例:`{"status":"hidden"}`。不接受其他字段或测试专用状态;非法值 400,不存在 404,题型/资源发布时上级未上线返回 409。管理预览沿用公开字段白名单,不返回任意渲染配置、私有文件、源文件地址或签名 URL;尚未提供服务端绘制测试图的接口。
|
||||
|
||||
所有写事务重新锁定、检查管理员账户,再按分类 → 父题型 → 资源顺序加锁;状态、首次发布时间、快照失效与审计同事务提交。相同状态重复提交不刷新时间、重复审计或增加快照版本。下架不清零统计,不删除收藏/下载历史,不重写内容更新时间。
|
||||
|
||||
第一次从未发布草稿转 active 登记 firstPublishedAt;重新上架保留首次发布时间。旧 active/hidden 的未知首次发布时间保持 NULL,历史审计用于防止改回草稿后伪装新品。上线内容直接编辑不提供旧版与草稿版并存的保证;版本管理或独立测试环境另行实施。
|
||||
|
||||
## 快照与小程序切换边界
|
||||
|
||||
分类状态/父级变化、分类删除、分类上线创建,以及题型/资源状态变化,会在同一事务清空服务端 `page_configs` 的 home/category/age,递增 version,清空 publishedVersion/publishedAt。重新构建页面配置时必须按上述可见性过滤;空配置不得降级返回旧快照。
|
||||
|
||||
这只撤销新后端数据库中的展示快照。当前新后端还没有首页构建、静态配置分发或 CDN 清理接口,不能据此声称微信云端/七牛已分发的旧 JSON 被撤回。已下载或缓存的公开文件也不具备内容保密能力。
|
||||
|
||||
`doodle-mini` 分类页已在 2026-10-16 接入新后端:通过全局环境配置读取公开分类及完整题型分页,已移除写死目录和静态题型兜底;失败展示重试,点击时重新读取公开详情,404 阻止跳转并刷新。开发入口使用 http://127.0.0.1:8800/api/v1,体验版/正式版仅使用待填写的 HTTPS 线上地址。
|
||||
|
||||
首页预拉取、登录、收藏、下载及其他页面仍使用旧云服务。新后端状态不会自动改写旧云数据或撤回旧静态文件;首页快照重建、缓存版本验证、其他入口/分享直达生成页的可用性检查尚待迁移。分类页接入不能视为全站上下架切换已完成。联调方式见 [小程序本地后端接口联调](../../../doodle-mini/docs/v3.5/本地后端接口联调.md)。
|
||||
|
||||
## 数据库升级及现有数据
|
||||
|
||||
新增 Prisma 迁移 `20261016000000_category_publication`;已有已登记基线的库执行 `pnpm --filter @doodle/server db:deploy`。已应用的 `0_v35_baseline` 不变。手工 SQL 升级使用 009,不能与同一 Prisma 增量重复执行;新空库可通过完整 Prisma 历史或最新 002/003 初始化。
|
||||
|
||||
增量迁移给既有分类设为 active,保留此前公开行为;新建默认 draft。分类旧导出省略 status 时,新导入记录为 draft,已有记录保留当前状态;显式 status 才修改,防止旧导出重新上架内容。题型旧导出与当前数据冲突时仍拒绝覆盖。
|
||||
|
||||
本地 doodle_dev 已升级:6 个分类维持 active,51 个题型及全部其他业务数据指纹与升级前一致,未写入测试用户或测试内容。哪些旧条目仍在开发由维护者确认后通过管理接口设为 draft/hidden,不按名称自动判断。
|
||||
|
||||
## 验证
|
||||
|
||||
`pnpm check` 检查格式、静态检查、类型与普通测试;`pnpm test:db` 使用独立临时 PostgreSQL 数据库并在完成后删除。发布回归覆盖:默认草稿、管理员与普通用户隔离、多层祖先下架、全部查询路径与详情防绕过、资源所属关系、发布前置检查、首次/重复发布、未知历史时间、并发幂等、同事务缓存失效、旧导出状态保留、审计失败回滚及事务内账户复核。
|
||||
|
||||
验证结果:`pnpm check` 通过(28 项普通测试,数据库测试由单独命令执行);`pnpm test:db` 共 39 项通过,临时数据库已清理。本地运行服务公开分类 6 条、题型 51 条,新增管理查询匿名均返回 401,Swagger 已注册全部新路由。
|
||||
@@ -0,0 +1,99 @@
|
||||
# 分类接口与数据导入
|
||||
|
||||
分类服务基于 v3.5 `categories` 表实现,供小程序及后续 Web 共用。保留原分类 ID 和父子关系,年级不属于分类树。当前服务端已实现,`doodle-mini` 分类页已通过统一环境配置切换至新后端,其他页面仍按迁移进度保留旧云服务。
|
||||
|
||||
## 接口
|
||||
|
||||
前缀为 `/api/v1`,成功响应统一为 `{ code: 0, message: "ok", data: ... }`。
|
||||
|
||||
| 方法 | 路径 | 权限 | 结果 |
|
||||
| ------ | ----------------------- | ------------------- | --------------------------------------------------------------------------- |
|
||||
| GET | `/categories` | 公开,无需登录 | 自身及全部祖先已上线分类的扁平数组,依次按 `sortOrder ASC, id ASC` 稳定排序 |
|
||||
| GET | `/categories/:id` | 公开,无需登录 | 单个已上线分类;不存在或所属链未上线返回 404 |
|
||||
| POST | `/admin/categories` | 当前 active + admin | 创建分类,返回 201;ID 重复返回 409 |
|
||||
| PATCH | `/admin/categories/:id` | 当前 active + admin | 局部更新,返回 200;不存在返回 404 |
|
||||
| DELETE | `/admin/categories/:id` | 当前 active + admin | 删除无引用分类,返回 200;不存在 404,有引用 409 |
|
||||
|
||||
列表没有分页、筛选或自动展开后代的参数,不沿用 CloudBase 单次查询的 100 条限制。分类表没有发布状态,不套用题型的 active 过滤规则。
|
||||
|
||||
公开分类结构示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "math",
|
||||
"name": "数感启蒙",
|
||||
"icon": "📐",
|
||||
"color": "#CDB4DB",
|
||||
"sortOrder": 0,
|
||||
"parentId": null,
|
||||
"status": "active",
|
||||
"createdAt": "2026-10-15T05:35:43.575Z",
|
||||
"updatedAt": "2026-10-15T05:35:43.575Z"
|
||||
}
|
||||
```
|
||||
|
||||
新版返回 `id`,不是旧云函数的 `_id`。小程序切换时应在接口适配层转换;不要据此修改数据库主键。公开响应不含 `iconAssetId`、对象存储 key 或内部关联资料。公共 TypeScript 契约位于 `packages/contracts/src/index.ts`,开发 Swagger 可查看实际注册接口。
|
||||
|
||||
创建请求包含必填 `id/name/icon/color`,可选 `status`(默认 draft)、`sortOrder`(默认 0)和 `parentId`(默认 null)。PATCH 只接受 `name/icon/color/sortOrder/parentId/status`,省略字段保持原值;不允许空对象、修改 ID、未知字段或将数字字符串隐式转换为排序值。`parentId` 为 null 或空串时规范化为根分类。
|
||||
|
||||
ID 区分大小写,最长 128 个字符,不含空白、控制字符或斜杠;名称/图标/颜色长度分别不超过 100/1024/32,排序必须为 PostgreSQL integer。名称、图标和颜色会去除首尾空白。
|
||||
|
||||
父分类必须存在,禁止自引用及多层循环。分类写入先锁定并复核管理员账户,再串行锁定分类树、读取最新状态并校验;导入也使用同一个分类表锁。公开读取不被该表锁阻塞。成功管理修改与前后快照审计在同一事务提交,重复提交相同值不刷新更新时间或新增审计。当前未开放 HTTP 导入接口。
|
||||
|
||||
新增、修改、删除均使用 `Authorization: Bearer <accessToken>`,以服务端当前账户的 `active + admin` 为准。未登录返回 401,普通用户或已降权管理员返回 403;禁用账户或凭证撤销返回 401。事务内再次复核账户,不能用 token 自报角色或旧身份绕过限制。
|
||||
|
||||
删除不接收请求体,成功响应为 `{ code: 0, message: "ok", data: { id: "分类ID" } }`。存在子分类或任何状态(draft/active/hidden)的题型引用时返回 409,须先调整这些引用;不级联删除子分类、题型或图标资产。删除记录保留 beforeData,afterData 为 NULL;审计失败则删除回滚。同一分类并发删除仅一次成功,其余返回 404。
|
||||
|
||||
本批图标均为文字/Emoji。文件图标的资产迁移、访问地址解析尚待七牛模块接入,当前创建/导入拒绝文件 URL;遇到已有文件资产图标时不下发原始地址。文件资产能力由迁移清单 S2-F04 跟进。
|
||||
|
||||
## 本地导入
|
||||
|
||||
在 `doodle-store` 根目录执行,文件参数使用绝对路径:
|
||||
|
||||
```bash
|
||||
# 构建后执行预演,不写分类或审计
|
||||
pnpm categories:import --file "/绝对路径/categories.json"
|
||||
|
||||
# 明确写入;整批成功或整批回滚
|
||||
pnpm categories:import --file "/绝对路径/categories.json" --apply
|
||||
|
||||
# 可选报告路径也使用绝对路径;报告文件必须尚不存在
|
||||
pnpm categories:import --file "/绝对路径/categories.json" --apply --report "/绝对路径/import-report.json"
|
||||
```
|
||||
|
||||
工具读取 `apps/server/.env` 的 DATABASE_URL,允许显式环境覆盖,但仅允许本机 `doodle_dev`。若服务端已经构建,可用 `pnpm --filter @doodle/server categories:import ...` 避免重复构建。pnpm 的工作目录会切换到 server 包,使用绝对文件/报告路径可避免歧义。报告路径会在写库前检查并独占创建;不覆盖已有报告。
|
||||
|
||||
导入支持 JSON 数组与 CloudBase JSON Lines(本次附件格式),最多 10000 行、10 MiB;只接受已知分类字段以及 `_id/id/createdAt/updatedAt`。未知字段、重复 ID、冲突的 `_id/id`、类型错误、未知日期格式或无效父子树会整批拒绝。日期可选,存在时须为带时区的 ISO 字符串;尚不支持 CloudBase 特殊日期对象。
|
||||
|
||||
导入按合并后的完整分类树检查并按父先子后写入,不要求源文件按层级排序。新增行保留源 ID;已有行按业务字段比较,真正变化时才更新,并保留已有 `createdAt`,不会删除附件中没有的分类。文件缺时间时,新记录的两个时间使用同一导入批次时间;有变化的已有行只补更新时间。完全相同的重复导入不改变数据、时间或审计。
|
||||
|
||||
每条实际变化写入 `actor_type=migration` 审计,包含批次 requestId、源文件 SHA-256、前后快照及时间补齐说明。报告列出新增、更新、未变化及缺时间字段的数量;正式导入前应查看预演结果。
|
||||
|
||||
## 2026-10-15 本地导入结果
|
||||
|
||||
源文件:`database_export-_oMVCNYtUGVT.json`;SHA-256:`02a5a2d26c1801e7e77e8dc2449aebd5b74e11d49a358ed957f3cd11ca290d11`。
|
||||
|
||||
已向本地 `doodle_dev` 新增 6 条分类及 6 条迁移审计,未修改其他业务表。6 行均缺少原始创建/更新时间,统一使用 `2026-10-15T05:35:43.575Z`(北京时间 13:35:43.575),这是本次导入时间,并非历史创建时间。
|
||||
|
||||
| ID | 名称 | 图标 | sortOrder | parentId |
|
||||
| ------- | -------- | ---- | --------- | -------- |
|
||||
| math | 数感启蒙 | 📐 | 0 | null |
|
||||
| puzzle | 益智游戏 | 🧩 | 1 | null |
|
||||
| pinyin | 汉语拼音 | ǎ | 2 | null |
|
||||
| chinese | 识字练字 | ✏️ | 3 | null |
|
||||
| english | 英语启蒙 | 🔤 | 4 | null |
|
||||
| craft | 创意手工 | 🌈 | 5 | null |
|
||||
|
||||
批次及时间补齐记录见 [首次导入报告](categories-import-2026-10-15.json)。原附件未改动。首次报告因相对路径无效未能保存,现已按数据库已提交记录补记,并修正了工具的报告路径预检查。
|
||||
|
||||
再次正式执行同一附件的结果为新增 0、更新 0、未变化 6,见 [重复导入复核报告](categories-reimport-2026-10-15.json)。实际 HTTP 列表/6 个详情已逐字段与附件核对一致,创建及更新时间保持首次批次值,审计仍为 6 条。开发库未新增任何测试用户;匿名读取成功,匿名管理写入返回 401。
|
||||
|
||||
## 验证
|
||||
|
||||
`pnpm check` 覆盖格式、静态检查、类型检查、构建及常规测试;`pnpm test:db` 在独立临时数据库验证登录鉴权和分类业务,结束后删除临时库,不向开发库写入测试账户。
|
||||
|
||||
分类验证涵盖公开读取、增删改的管理员权限及禁用/降权/撤销、非法字段、ID 大小写、稳定排序、局部更新、审计快照、无变化更新、事务内权限复核、两个管理员并发互设父级、子分类及各状态题型引用保护、删除审计失败回滚、并发/重复删除、子先父后导入、dry-run、缺失时间补齐、重复导入、保留创建时间、半批失败回滚及跨现有数据的循环校验。
|
||||
|
||||
## 发布控制(2026-10-16)
|
||||
|
||||
增加 GET /admin/categories 及 /admin/categories/:id,可读取所有状态;公开接口不接受草稿预览开关。新增分类默认 draft,旧分类增量升级保留 active。旧导出省略 status 时重导保留当前状态,新导入默认 draft;显式状态才更改。状态或父级修改同事务撤销新后端数据库页面快照并记录审计。详见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||
+15
-10
@@ -2,15 +2,17 @@
|
||||
|
||||
> 数据库:PostgreSQL;ORM:Prisma。数据库选型已按用户确认更新。
|
||||
>
|
||||
> 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。
|
||||
> 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;已接入 Prisma Schema 和迁移基线;线上集合、数据量和异常分布待 S0 盘点。
|
||||
>
|
||||
> 更新日期:2026-10-14。
|
||||
> 更新日期:2026-10-15(登录与 Prisma 实施状态更新,业务表结构不变)。
|
||||
>
|
||||
> 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。
|
||||
|
||||
已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md)。2026-10-12 验证并创建最初 10 张表及两条配置;2026-10-14 执行 004/005/006/007/008 后,本地共有 13 张表。最新空库基线与对应增量升级结构已在临时库对照通过,多资源、多年级、资源计数及两种收藏约束、旧数据保留验证通过,临时库已清理;尚未创建 Prisma Schema 或导入线上数据。
|
||||
登录与鉴权的后续实施设计见 [用户鉴权与登录设计](./用户鉴权与登录设计.md):优先小程序,细化微信登录、JWT、401 恢复及注销,并说明未来 Web 会话与跨端身份的演进;当前无需调整本文 13 张表。
|
||||
|
||||
本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 13 张表已在本地创建,业务 API 尚未实现。
|
||||
已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md)。2026-10-12 验证并创建最初 10 张表及两条配置;2026-10-14 执行 004/005/006/007/008 后,本地共有 13 张表。最新空库基线与对应增量升级结构已在临时库对照通过,多资源、多年级、资源计数及两种收藏约束、旧数据保留验证通过,临时库已清理;2026-10-15 已接入 Prisma Schema 和迁移基线,尚未导入线上数据。
|
||||
|
||||
本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 13 张表已在本地创建,服务端微信登录、本人读取和账户鉴权已实现,其他业务 API 待接入。
|
||||
|
||||
## 1. 范围与依据
|
||||
|
||||
@@ -109,6 +111,7 @@ erDiagram
|
||||
|
||||
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
|
||||
| --------------------------- | -------------- | ----------- | ------------------------------------------------------------------ |
|
||||
| status | varchar(16) | 否 / draft | CHECK:draft、active、hidden;公开可见还要求全部上级 active |
|
||||
| id | varchar(128) | 否 | 原分类 ID,如 math、chinese;不重命名 |
|
||||
| name | varchar(100) | 否 | 分类名 |
|
||||
| icon | varchar(1024) | 否 | 保留图标名、字符等非文件内容;若是文件则转为稳定资产引用,接口解析 |
|
||||
@@ -121,7 +124,9 @@ erDiagram
|
||||
|
||||
主键 id;索引 `(parent_id, sort_order, id)`;CHECK `parent_id IS NULL OR parent_id <> id`。父分类必须存在,导入按树层级排序;多层循环不能靠上述 CHECK 判断。删除被题型或子分类引用的分类返回冲突。
|
||||
|
||||
分类负责数学、语文、手工等内容方向,年级是独立适用维度,不把“一年级数学”“二年级数学”复制为分类,也不把年级放入 categories.parent_id。Web 的“年级 → 分类”菜单是组合查询,不代表数据库父子关系。年级名称及展示顺序先使用固定配置,不新增 grades/菜单表。categories 表结构保持不变。
|
||||
分类负责数学、语文、手工等内容方向,年级是独立适用维度,不把“一年级数学”“二年级数学”复制为分类,也不把年级放入 categories.parent_id。Web 的“年级 → 分类”菜单是组合查询,不代表数据库父子关系。年级名称及展示顺序先使用固定配置,不新增 grades/菜单表。分类增加发布状态,不增加年级菜单字段。
|
||||
|
||||
分类、题型和资源统一使用三态发布规则;上级未上线时下级也不可公开读取,隐藏上级不改写下级自身状态。管理预览及快照失效规则见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||
|
||||
### 3.3 worksheets — 题型
|
||||
|
||||
@@ -443,7 +448,7 @@ ORDER BY w.sort_order, w.updated_at DESC, w.id;
|
||||
|
||||
登录使用服务端配置的单一小程序 AppID,按微信返回的可信 `openid` 查找用户,沿用老 ID。首次并发登录以唯一约束兜底,冲突后读取已创建用户。首期 JWT 使用 userId + authVersion;每次鉴权核对用户存在、active 和版本,role 以当前服务端数据为准。
|
||||
|
||||
`auth_version` 是账户级登录凭证版本,默认 1。JWT 签发时写入相同的 `authVersion`,每次访问受保护接口时与数据库当前值比较;不一致返回 401,要求重新登录。需要强制全部设备退出或撤销已签发凭证时,由业务服务在事务中递增版本;普通登录和资料更新不递增,避免其他设备无故退出。它撤销的是该用户所有旧版本 token,不用于单设备退出。账户禁用及角色调整仍以数据库当前 status/role 校验,版本不能替代权限检查。现有守卫已实现版本比较,真实 AccountReader 与主动递增业务尚待接入。
|
||||
`auth_version` 是账户级登录凭证版本,默认 1。JWT 签发时写入相同的 `authVersion`,每次访问受保护接口时与数据库当前值比较;不一致返回 401,要求重新登录。需要强制全部设备退出或撤销已签发凭证时,由业务服务在事务中递增版本;普通登录和资料更新不递增,避免其他设备无故退出。它撤销的是该用户所有旧版本 token,不用于单设备退出。账户禁用及角色调整仍以数据库当前 status/role 校验,版本不能替代权限检查。现有守卫与真实 AccountReader 已接入版本比较,主动递增版本的业务服务仍待接入。
|
||||
|
||||
涉及用户的写事务先锁用户行并核对状态,再锁题型行、需要时锁内容行;多个题型及同层内容按 ID 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。
|
||||
|
||||
@@ -494,7 +499,7 @@ DO NOTHING RETURNING id;
|
||||
|
||||
真实计数/seed 修订使用明确管理动作,锁题型行、记录前后值和 reason、同事务写审计。批量操作每项独立事务,保留逐项结果;dryRun 做同样校验且不写入。不将整批部分失败伪装为完全成功。
|
||||
|
||||
展示 likes/downloads = 原始值 + 对应 seed;管理 rawStats 不加 seed。保持现有首页 Top5、分龄 Top3 下载 + Top3 收藏再全量下载补齐的种子参与规则。分龄严格采用 `ageMin == 下限 AND ageMax >= 上限`;分龄并列沿用 updatedAt DESC,再以 id 稳定排序。首页并列新增稳定 id 排序,并在固定样本验收中记录差异。
|
||||
展示 likes/downloads = 原始值 + 对应 seed;管理 rawStats 不加 seed。保持现有首页 Top5、分龄 Top3 下载 + Top3 收藏再全量下载补齐的种子参与规则。旧分龄推荐沿用 `ageMin == 下限 AND ageMax >= 上限`(尚未迁移);2026-10-16 用户确认普通 `/worksheets?ageBand=...` 筛选改为 `ageMin <= 下限 AND ageMax >= 上限`,只返回完整覆盖所选年龄段的题型;分龄并列沿用 updatedAt DESC,再以 id 稳定排序。首页并列新增稳定 id 排序,并在固定样本验收中记录差异。
|
||||
|
||||
### 5.4.1 新品、运营推荐与两层下载统计
|
||||
|
||||
@@ -516,7 +521,7 @@ worksheets 表示功能,worksheet_resources 表示可选内容。新增“小
|
||||
|
||||
首期题型量小,可在短事务中完成查询与 JSON 构建;若构建移出事务,提交前比较 expectedVersion,冲突必须重新读取重建,不能覆盖新配置。自动刷新只覆盖 featured/hot,保留 sections 等手动配置。home/all 构建清 category/age,category 分支只重建 category;原调用中空数组被当作“沿用现有配置”的行为由兼容层保留。
|
||||
|
||||
公共 API 直接读取 DB 配置。若兼容期仍发布静态 JSON,提交后用 `page_config_publish` 执行记录重试,文件采用按版本不可变对象 key;publishedVersion 只在对应版本上传成功后前移,防止旧发布覆盖新版本。签名 URL 不进入公共静态配置,私有头像也不进入公共页面 JSON。静态发布失败不回滚已经提交的业务配置,保留上一可用文件并明确发布状态。
|
||||
公共 API 直接读取 DB 配置。若兼容期仍发布静态 JSON,提交后用 `page_config_publish` 执行记录重试,文件采用按版本不可变对象 key;publishedVersion 只在对应版本上传成功后前移,防止旧发布覆盖新版本。签名 URL 不进入公共静态配置,私有头像也不进入公共页面 JSON。静态发布失败不回滚已经提交的业务配置,保留上一可用文件并明确发布状态。内容下架撤销属于例外:必须立即取消旧展示快照的可用标记,不能以“上一可用文件”继续交付下架内容;新后端当前会清空数据库快照,外部分发及客户端缓存撤销在切换前补齐。
|
||||
|
||||
## 6. CloudBase 导入与审计
|
||||
|
||||
@@ -594,7 +599,7 @@ S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字
|
||||
|
||||
## 9. SQL 与实际结构最终核查
|
||||
|
||||
核查日期:2026-10-14。当前已确认范围为 13 张表、135 个字段;本次核查未新增表或业务字段。
|
||||
核查日期:2026-10-14。当时已确认范围为 13 张表、135 个字段;该次核查未新增表或业务字段。2026-10-16 新增 categories.status 后为 136 个字段,升级及发布规则见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||
|
||||
| 核查项目 | 结果 |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -607,4 +612,4 @@ S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字
|
||||
|
||||
临时核查数据库全部清理;开发库只读比对,未写入样本。SQL 无结构差异需修复;已同步过时的 Prisma 目录说明、统一执行路径,修正关系图中的必填封面/可缺失历史关联及资产清理引用范围。
|
||||
|
||||
数据库约束通过不等于业务迁移完成。资源归属、当前发布状态/管理员权限、参数白名单、首次发布时间只写一次、动态新品判断、计数与审计事务、任务锁和租约仍由后续服务端实现;Prisma Schema 和迁移历史尚未接入。旧 grade/is_new/total_downloads 等字段继续按前述兼容规则保留,不作为新业务权威来源。
|
||||
数据库约束通过不等于业务迁移完成。资源归属、当前发布状态/管理员权限、参数白名单、首次发布时间只写一次、动态新品判断、计数与审计事务、任务锁和租约仍由后续服务端实现;Prisma Schema 和迁移基线已接入。旧 grade/is_new/total_downloads 等字段继续按前述兼容规则保留,不作为新业务权威来源。
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 小程序静默登录与服务端鉴权接入
|
||||
|
||||
> 日期:2026-10-15。范围:doodle-store 服务端;小程序 HTTP 适配、资料修改/注销、七牛头像签名和 Web 登录另行实现。
|
||||
|
||||
设计依据:[用户鉴权与登录设计](./用户鉴权与登录设计.md)、[数据库设计](./数据库设计文档.md)。
|
||||
|
||||
## 1. 本次接口
|
||||
|
||||
统一前缀为 `/api/v1`,开发文档为 `/api-docs`。
|
||||
|
||||
| 接口 | 身份要求 | 行为 |
|
||||
| ------------------------- | --------------------------------- | -------------------------------------------------- |
|
||||
| `POST /auth/wechat/login` | 无本站 token,必须有真实微信 code | 验证微信身份、创建或恢复账户,签发默认 24 小时 JWT |
|
||||
| `GET /me` | 本站 Bearer JWT | 查询当前账户的本人摘要和当前 role |
|
||||
| `GET /health` | 公开 | HTTP 进程存活,不检查外部服务 |
|
||||
| `GET /health/ready` | 公开 | 检查 PostgreSQL 连通性,不代表微信或七牛可用 |
|
||||
|
||||
小程序通过 `wx.login` 获得 code 后提交:
|
||||
|
||||
```http
|
||||
POST /api/v1/auth/wechat/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"code": "本次 wx.login 返回的 code",
|
||||
"mode": "start"
|
||||
}
|
||||
```
|
||||
|
||||
- `start` 允许首次建号,已有用户仍沿用原 ID;默认 role=user、status=active、authVersion=1。
|
||||
- `resume` 只恢复已有用户,账户不存在时返回 401/ACCOUNT_NOT_FOUND,不自动重建。
|
||||
- 两个模式都先向微信验证 code,且都拒绝 disabled 用户;不能使用 start 重置禁用状态、角色或资料。
|
||||
- 请求不接受 AppID、AppSecret、OpenID、UnionID、userId 或角色等额外字段;应用凭据固定取服务端配置。
|
||||
|
||||
成功结果为 `{code:0,message:"ok",data:{accessToken,tokenType,expiresIn,expiresAt,user}}`。user 只含 `id/nickName/avatarUrl/avatarUrlExpiresAt/createdAt`;`GET /me` 另含当前 role,不输出微信身份或凭证版本。
|
||||
|
||||
`expiresIn/expiresAt` 从实际 JWT claims 取得;后续请求使用 `Authorization: Bearer <accessToken>`。登录、本人接口的成功与失败响应均为 `Cache-Control: no-store`。
|
||||
|
||||
当前七牛签名未接入,头像 URL 和期限返回 NULL;数据库已有头像引用保留,不改成公开对象地址。资料修改、清资料、注销及账户版本主动递增服务不包含在本次接口中。
|
||||
|
||||
## 2. 鉴权与恢复
|
||||
|
||||
受保护接口默认执行:固定算法/issuer/audience 的 JWT 验证 → 查询当前 users → 核对 active 和 authVersion → 检查服务端 role。账户不存在、被禁用或版本失效时旧 token 立即被拒绝;角色变化无需等待 token 到期。
|
||||
|
||||
`GET /me` 的 userId 只取守卫上下文,不允许指定其他账户;即使 query 中出现 userId,也不会用于身份选择。管理 API 仍需 `@Roles('admin')` 和对应业务归属/写事务校验;本次没有新增公开管理员登录或任意用户 token 签发接口。
|
||||
|
||||
错误保留 HTTP 状态与 `{code,message,requestId}`,仅可信业务异常增加有限 reason:
|
||||
|
||||
| HTTP | reason | 小程序下一步 |
|
||||
| ---- | ------------------------------------------- | ------------------------------------------------- |
|
||||
| 401 | AUTH_REQUIRED / AUTH_INVALID / AUTH_EXPIRED | 按设计共用一次静默恢复;原业务最多重发一次 |
|
||||
| 401 | AUTH_REVOKED | 停止后台恢复,用户主动继续时再验证微信 |
|
||||
| 401 | ACCOUNT_NOT_FOUND | 清旧状态,停止后台建号;主动开始时才使用 start |
|
||||
| 401 | ACCOUNT_DISABLED | 停止恢复;重新登录不解除禁用 |
|
||||
| 400 | WECHAT_CODE_INVALID | 当前 code 不能重用;受总重试次数限制地获取新 code |
|
||||
| 403 | WECHAT_LOGIN_BLOCKED | 微信风控拦截,停止自动重试 |
|
||||
| 429 | RATE_LIMITED | 遵守 Retry-After,不能立即循环 |
|
||||
| 503 | AUTH_UPSTREAM_UNAVAILABLE | 微信网络/配置/响应异常,退避;不传播微信 errmsg |
|
||||
| 503 | DATABASE_UNAVAILABLE | 数据库暂不可用,不降级为只验 JWT 放行 |
|
||||
|
||||
微信调用固定 HTTPS 地址,5 秒超时,禁止重定向,不自动重试一次性 code。每分钟单 IP 最多 30 次登录兑换、可信 OpenID 最多 10 次;限流使用有界内存,进程重启会重置,当前用于单实例。来源 IP 按已配置的可信代理解析,不接受任意客户端自报的 X-Forwarded-For。
|
||||
|
||||
不记录 code、OpenID/UnionID、AppSecret、session_key、token 或原始微信错误。session_key 没有当前业务用途,因此不落库、不缓存、不下发。
|
||||
|
||||
## 3. 配置与启动
|
||||
|
||||
服务端只加载 `apps/server/.env`,以下配置现在都必须有效存在:
|
||||
|
||||
- `DATABASE_URL`:本地 PostgreSQL 连接串,使用 public schema。
|
||||
- `WECHAT_MINI_APP_ID` 与 `WECHAT_MINI_APP_SECRET`:对应当前小程序,不能与 JWT_SECRET 混用。
|
||||
- `JWT_SECRET`:至少 32 字节的独立密钥;issuer/audience/期限继续沿用 Server 基础设置。
|
||||
|
||||
从 doodle-store 根目录运行 `pnpm dev`。Prisma 客户端在 dev/build/typecheck/test 前生成;实际查询按需建立连接,HTTP 健康检查不因数据库故障被误判为进程退出。应用关闭时释放连接池。
|
||||
|
||||
真实 code 必须由相同 AppID 的小程序现取,不能用文档占位值或在公网开放测试后门。微信成功登录还需要合法请求域名、HTTPS 与真机/体验版联调;自动测试替换微信 HTTP 边界,不代表已经验证真实微信账号登录。
|
||||
|
||||
## 4. 数据库与迁移
|
||||
|
||||
Prisma Schema 映射现有 13 张表,客户端仅服务端可用。`0_v35_baseline` 保留原始 SQL 的 CHECK、C 排序规则、部分唯一索引和初始配置;不能用 Prisma 自动生成的简化 DDL 覆盖这些约束。
|
||||
|
||||
已有数据库在完整核对后,使用 `pnpm --filter @doodle/server exec prisma migrate resolve --applied 0_v35_baseline` 登记基线;该操作新增 Prisma 迁移记录,不重建业务表。空库使用 `pnpm --filter @doodle/server exec prisma migrate deploy`,会执行基线及初始配置。
|
||||
|
||||
后续升级新增迁移文件,已登记的基线保持不变。`sql/004`~`008` 是旧库升级档案,不在最新基线之上重跑;日常开发不使用 db push 或 reset 改写已建好的库。
|
||||
|
||||
登录先在事务外验证微信,再以 OpenID 唯一约束和用户行锁处理账户。并发 start 使用 INSERT ON CONFLICT DO NOTHING,不覆盖既有状态;resume 缺用户不写入。普通登录仅维护活跃时间;UnionID 只补空,不清空、不自动合并;非空冲突记录不含身份值的警告。
|
||||
|
||||
## 5. 验证方式
|
||||
|
||||
- `pnpm check`:格式、ESLint、类型、构建及无需真实数据库的测试;数据库专项测试在未提供隔离连接时跳过。
|
||||
- `pnpm test:db`:从本地开发连接取得建库权限,创建随机命名的独立短期库,执行正式迁移,运行 HTTP + 真实 Prisma/PostgreSQL 测试,结束后删除短期库。
|
||||
|
||||
数据库专项脚本仅允许本机 doodle_dev 实例,测试代码还验证随机短期库名称;不会清空开发库,也不在开发库写入假用户。覆盖老 ID 延续、并发首次登录、大小写语义、resume 不建号、UnionID 保留、禁用/降权/撤销/删除、本人读取、参数注入、上游错误、限流及日志脱敏。
|
||||
|
||||
微信接口依据:[code2Session](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-login/api_code2session.html)。Prisma 接入参考:[CommonJS 客户端生成](https://www.prisma.io/docs/orm/v7/prisma-schema/overview/generators)、[既有数据库基线](https://www.prisma.io/docs/orm/v7/prisma-migrate/workflows/baselining)。
|
||||
|
||||
## 6. 本次验证记录
|
||||
|
||||
2026-10-15:`pnpm check` 通过,20 项常规测试通过;数据库专项在常规运行时按设计跳过,已通过独立 `pnpm test:db` 执行,8 项检查全部通过。真实微信调用使用 HTTP 替身验证固定地址、凭据来源、错误映射和一次性 code 不重试,尚未进行小程序真实 code 联调。
|
||||
|
||||
构建产物使用本机真实配置启动,HTTP 存活和数据库就绪均 200,本人接口无凭证 401,空 code 登录请求 400;检查服务已关闭。本地基线已登记,Prisma migrate diff 无差异;13 张业务表及两条初始配置保留,开发库没有写入测试用户,所有临时测试/参考库已删除。真实密钥未进入可提交文件。
|
||||
@@ -0,0 +1,410 @@
|
||||
# Doodle Store v3.5 用户鉴权与登录设计
|
||||
|
||||
> 更新日期:2026-10-15。
|
||||
>
|
||||
> 状态:方案及分阶段实施设计。服务端微信登录、真实账户查询、GET /me、JWT reason、限流已接入;小程序 HTTP 接入、资料生命周期及 Web 会话待实现。当前落点见 [服务端登录接入说明](./服务端登录接入说明.md)。
|
||||
>
|
||||
> 依据:[数据库设计](./数据库设计文档.md)、[云服务端迁移方案](../云服务端迁移方案.md)、[Server 基础设置](./Server基础设置.md)及下述实际代码。第一阶段优先完成小程序;Web 在此基础上演进。
|
||||
|
||||
## 1. 设计结论与阶段边界
|
||||
|
||||
首期采用 **微信身份验证 → Doodle 用户 → 24 小时 JWT → 每次请求核对当前账户**。小程序继续静默登录,浏览和本地生成不等待登录;收藏、历史、资料、上传等远端用户操作需要有效凭证。
|
||||
|
||||
| 问题 | 本次建议 |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| 用户是谁 | `users.id` 是唯一业务账户标识;迁入用户保留原 `_id`,收藏及下载继续关联它 |
|
||||
| 如何证明微信身份 | 小程序 `wx.login` 取得一次性 code,服务端用固定 AppID/AppSecret 调用 `code2Session` |
|
||||
| 后续 API 如何认人 | `Authorization: Bearer <Doodle JWT>`;不信任缓存的 user、OpenID、客户端 UUID 或自报角色 |
|
||||
| 登录有效期 | 沿用现有默认 24 小时;到期重新执行微信静默登录,不增加 refresh token |
|
||||
| 如何撤销权限 | 每次查询当前 `status`、`role`、`authVersion`;敏感写事务中再次检查 |
|
||||
| 是否增加数据库表 | 小程序阶段不增加,现有 13 张表足够;会话表和登录身份表按后续触发条件引入 |
|
||||
| 昵称、头像、手机号 | 昵称和头像由用户主动填写;登录不要求填写,不要求绑定手机号 |
|
||||
| Web 如何共用账户 | 优先考虑通过现有小程序扫码确认登录,直接进入同一 `users.id`;网站微信 OAuth 是独立接入选项 |
|
||||
| Web 如何保存登录态 | 后续使用服务端会话及 HttpOnly Cookie,与小程序共用账户检查和业务权限规则 |
|
||||
|
||||
这里的 24 小时是 Doodle 凭证期限,与微信 `session_key` 的期限、本地资料缓存期限各自独立。`authVersion` 撤销的是旧凭证,不会禁止持有人再次通过微信证明身份;需要禁止登录时必须禁用账户。
|
||||
|
||||
暂不建设密码注册、短信验证码、复杂 RBAC、设备管理、第三方登录平台或统一 OAuth 服务。未来 Web 框架尚未选择,本方案不依赖 Next.js 或特定部署商。
|
||||
|
||||
## 2. 现状与需要补齐的部分
|
||||
|
||||
### 2.1 doodle-mini 实际实现
|
||||
|
||||
| 代码 | 已有行为 | 迁移时的处理 |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| [app.ts](../../../doodle-mini/miniprogram/app.ts) | `onLaunch` 非阻塞调用 `getUser()`;仍初始化 CloudBase | 保留非阻塞启动,替换身份及业务请求;CloudBase 随兼容阶段退场 |
|
||||
| [utils/auth.ts](../../../doodle-mini/miniprogram/utils/auth.ts) | 内存 → 7 天 Storage → `userLogin`;已有 `loginPromise` 合并部分并发 | 用户展示缓存与凭证分开;首次登录、续期、401 共用同一个登录 Promise |
|
||||
| [userLogin](../../../doodle-mini/cloudfunctions/userLogin/index.js) | 从 `getWXContext()` 获取身份;按 OpenID 查用户;新增或更新活跃时间;支持更新资料 | 改成服务端兑换 code;唯一约束保证并发;登录与资料更新拆成明确接口 |
|
||||
| [utils/http.ts](../../../doodle-mini/miniprogram/utils/http.ts) | 基础 `wx.request` 包装 | 补齐 API 地址、PATCH、Bearer、结构化错误、401 恢复;上传和下载另做适配 |
|
||||
| [个人中心](../../../doodle-mini/miniprogram/pages/profile/profile.ts) | 已实现主动选择头像、填写昵称、上传 CloudBase,再更新资料 | 保留交互;头像改为上传七牛资产后绑定 assetId |
|
||||
| [收藏](../../../doodle-mini/miniprogram/utils/favorites.ts)、[下载](../../../doodle-mini/miniprogram/utils/downloadLogs.ts) | 调用前 `getUser()`;仍调用云函数;部分失败返回空列表或 0 | 改为受保护 API;错误不能假装成真实空数据 |
|
||||
| [设置页面](../../../doodle-mini/miniprogram/supportPages/settings/settings.ts)、[userSettings](../../../doodle-mini/cloudfunctions/userSettings/index.js) | 清资料或注销后 `clearUser()`;旧注销只删 users | 清资料不清凭证;注销完整事务清理,阻止后台自动重建账户 |
|
||||
|
||||
需要纠正的几个历史描述:
|
||||
|
||||
- 当前 user 对象包含 OpenID/UnionID,云函数还通过展开数据库记录返回资料;新 DTO 必须显式列字段,不直接返回数据库模型。
|
||||
- 7 天缓存只控制重新调用登录的时机,内存命中还会跳过 Storage 过期检查;它不提供独立 API 的身份验证。
|
||||
- 旧登录先查再新增,没有原子冲突处理,不能把它描述为天然并发安全的 upsert。
|
||||
- 头像昵称已在个人中心实现;旧登录文档中的“后续实现”已过时。
|
||||
- `envVersion === 'develop'` 只是入口显示条件。[homeTimerControl](../../../doodle-mini/cloudfunctions/homeTimerControl/index.js) 等入口代码未见管理员身份检查;线上调用权限仍需盘点,迁移后的每个管理 API 都必须自行校验。
|
||||
|
||||
### 2.2 doodle-store 已有基础
|
||||
|
||||
[src/auth](../../apps/server/src/auth/auth.module.ts) 已有 JWT 签发/验签、全局 `AccessTokenGuard`、`@Public()`、`@Roles()`、`@CurrentUser()`。JWT 固定 HS256,检查 issuer、audience、过期时间及 claims;角色取自账户读取结果。
|
||||
|
||||
`ACCOUNT_READER` 已替换为真实 Prisma 读取;数据库故障仍返回 503。微信登录、本人摘要和有限 reason 已接入;账户资料修改、注销及主动版本递增服务仍待实现。小程序恢复逻辑和 Web 内容继续作为后续目标设计。
|
||||
|
||||
## 3. 身份、凭证与权限分层
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
WX[微信 code] --> Verify[服务端验证微信身份]
|
||||
Verify --> Account[users.id · Doodle 账户]
|
||||
Account --> Mini[小程序 Bearer JWT]
|
||||
Account -.后续.-> Web[Web Cookie 会话]
|
||||
Mini --> Check[当前账户状态与版本]
|
||||
Web --> Check
|
||||
Check --> Permission[角色及资源归属检查]
|
||||
Permission --> Business[收藏 · 下载 · 资料 · 管理]
|
||||
```
|
||||
|
||||
| 标识 | 用途 | 不承担的职责 |
|
||||
| ------------- | -------------------------------------- | ---------------------------------------- |
|
||||
| `users.id` | 全端稳定业务主键,业务关联及审计使用 | 不作为秘密或登录凭证 |
|
||||
| 小程序 OpenID | 当前配置的小程序内识别微信身份 | 不假设与网站、公众号的 OpenID 相同 |
|
||||
| UnionID | 同一微信开放平台账户下的关联线索,可空 | 首期不唯一、不自动合并账户 |
|
||||
| `session_key` | 微信数据加解密需要的服务端密钥 | 不作为 Doodle Bearer,不下发客户端 |
|
||||
| Doodle JWT | 小程序向本站 API 出示的访问凭证 | 不固化角色,不包含微信身份和用户资料 |
|
||||
| 客户端 UUID | 现有埋点或本地安装标识 | 不用来认证、授权、找回账户或证明设备归属 |
|
||||
|
||||
服务层只接收经过守卫构造的可信用户上下文。公开 DTO 和客户端请求不能写入 `role/status/authVersion/openid/unionid`;即使请求者是管理员,资料更新接口也不接受这些字段。
|
||||
|
||||
## 4. 小程序登录流程
|
||||
|
||||
### 4.1 接口契约
|
||||
|
||||
所有路径省略 `/api/v1`。`POST /auth/wechat/login` 显式公开,仍受参数校验和限流保护。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "本次 wx.login 返回的临时凭证",
|
||||
"mode": "resume"
|
||||
}
|
||||
```
|
||||
|
||||
`code` 必须为非空、有长度上限的字符串;不接受客户端 AppID、AppSecret、OpenID、userId 或角色。`mode` 必填,只有以下两种值:
|
||||
|
||||
- `resume`:只恢复现有账户,不创建用户。已有登录记录的启动、token 到期及 401 恢复都用它;身份对应账户已删除时返回 `ACCOUNT_NOT_FOUND`。
|
||||
- `start`:允许首次建号。全新安装、没有任何账户历史时可以非阻塞静默调用;本机曾注销或发现账户被删除时,必须由用户再次点击“开始使用账户”后调用。
|
||||
|
||||
`mode` 是客户端的创建意图,不是安全凭证。两个模式都先验证微信身份、都拒绝 disabled 账户;`start` 不允许重置现有账户、解除禁用或选择目标 userId。
|
||||
|
||||
成功返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"accessToken": "Doodle JWT",
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": 86400,
|
||||
"expiresAt": "2026-10-16T02:00:00.000Z",
|
||||
"user": {
|
||||
"id": "沿用旧用户 ID 或新生成的 ID",
|
||||
"nickName": null,
|
||||
"avatarUrl": null,
|
||||
"avatarUrlExpiresAt": null,
|
||||
"createdAt": "2026-10-15T02:00:00.000Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不返回 `openid/unionid/session_key/authVersion` 或完整 users 记录。`avatarUrl` 非空且为私有签名地址时同时返回期限。统计走已有数量接口,不在每次登录中计数;小程序适配层将 `id` 转为现有页面需要的 `_id`,移除 UserInfo 中的微信身份字段。管理入口若需要角色,用受保护的 `GET /me` 获取当前 `role`,仅用于显示。
|
||||
|
||||
登录及本人资料响应设置 `Cache-Control: no-store`,不进入 CDN/公共响应缓存。JWT 示例中的期限取自实际签发结果,不能另算出与 `exp` 不一致的期限。
|
||||
|
||||
### 4.2 服务端步骤
|
||||
|
||||
1. 校验参数及入口限流,用服务端固定的小程序配置调用微信 `code2Session`;外部网络调用不放在数据库事务或行锁内。
|
||||
2. 验证 HTTP/响应格式、微信错误码和非空 OpenID;成功时 `errcode` 可以不出现,出现则必须是成功值。UnionID 空串规范为 NULL。当前没有加解密用途,`session_key` 不落库、不缓存、不记录日志。
|
||||
3. 开启短事务,按可信 OpenID 查询用户。存在则锁定用户并核对 active;不存在且 mode=resume 则结束并返回 `ACCOUNT_NOT_FOUND`;mode=start 才插入新账户。
|
||||
4. 新账户由服务端生成 ID,默认 `role=user/status=active/authVersion=1`。用 `UNIQUE(openid)` 和明确的冲突处理兜底,并发失败时重新读取实际账户;不能覆盖其角色、状态、资料或版本。PostgreSQL 唯一冲突会中止事务,须用 `ON CONFLICT` 或回滚后的新事务重读,不能直接在失败事务里继续查询。
|
||||
5. 已有账户更新 `last_active_at`;本次 UnionID 缺失不清空旧值,有值且旧值为空才补齐;两个非空值不一致时记录脱敏异常供核查,不覆盖、不合并。实际资料变化才维护相应 `updated_at`,不把普通登录当成权限或资料修改。
|
||||
6. 提交后按本次确认的 `id/authVersion` 签发 JWT,显式构造 DTO。若提交后恰好发生禁用、注销或版本递增,旧快照 token 会在下一次账户核对时失效,不能跳过检查以迁就它。
|
||||
|
||||
被禁用的账户不能通过再次登录变成新用户。旧用户是否存在取决于已完成的数据导入;切换前必须迁入旧 users,不能先对空生产库开放 start 模式,再补导产生重复账户。
|
||||
|
||||
### 4.3 微信接口调用约束
|
||||
|
||||
微信小程序 code 有效期五分钟且只能使用一次;每次登录尝试现取现用。提交后超时也可能已经被微信消费,不使用同一个 code 盲目重试;客户端需要恢复时重新调用 `wx.login`,并受统一重试次数限制。[微信 wx.login](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.login.html)、[小程序登录说明](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html)。
|
||||
|
||||
| 配置/场景 | 建议行为 |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| AppID/AppSecret | 仅服务端配置;新增 `WECHAT_MINI_APP_ID/WECHAT_MINI_APP_SECRET` 的启动校验,禁止读取客户端配置作为身份来源 |
|
||||
| 外部 HTTP | 固定微信 HTTPS 地址,校验 TLS,建议单次超时 5 秒;不跟随到任意地址,不记录带 secret/code 的 URL |
|
||||
| 微信 40029 或已使用 code | 返回 400 + `WECHAT_CODE_INVALID`;一次恢复周期内最多额外获取一次新 code |
|
||||
| 微信 40226 | 返回 403 + `WECHAT_LOGIN_BLOCKED`;停止自动登录,不能伪装成普通 code 过期 |
|
||||
| 微信 45011 / 本站限流 | 返回 429 + `RATE_LIMITED`;遵守 `Retry-After`,不立即循环 |
|
||||
| 微信繁忙、超时、响应异常 | 返回 503 + `AUTH_UPSTREAM_UNAVAILABLE`;退避,允许公开功能继续使用 |
|
||||
| AppID/AppSecret 配置失效 | 返回安全的服务不可用提示并告警;不引导客户端不断获取 code |
|
||||
| 本站数据库失败 | 返回 503;不能改用客户端身份或只验 JWT 放行 |
|
||||
|
||||
具体错误映射以[微信 code2Session](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-login/api_code2session.html)为准,不把微信原始 errmsg/响应直接下发。建议起步使用入口 IP 限流,例如单 IP 每分钟 30 次并保留小突发;共享网络需实测调参,不能只靠它。获得可信 OpenID 后补身份维度节流,成功登录不因客户端重试重复建号。首期单实例可用有界内存计数,重启会重置;多实例或严格配额时再选共享存储。
|
||||
|
||||
微信 `wx.checkSession` 检查微信的 `session_key`,不能检查 Doodle JWT。当前不使用微信加解密能力,不将它加入每次 API 请求流程。[微信 wx.checkSession](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.checkSession.html)。
|
||||
|
||||
## 5. 凭证、账户检查与业务授权
|
||||
|
||||
### 5.1 JWT
|
||||
|
||||
沿用现有 HS256、`iss=doodle-store`、`aud=doodle-mini`、`sub=userId`、`authVersion/iat/exp`,默认 `JWT_EXPIRES_IN_SECONDS=86400`。JWT 是可读取的签名载荷,里面不放用户资料、微信身份、手机号或秘密。
|
||||
|
||||
不提供任意 userId 换 token、前端自行续签、仅凭旧 token 换新 token 的接口。普通登录、更新资料、清资料不递增 `auth_version`,避免其他设备无故失效。
|
||||
|
||||
每次受保护请求顺序固定:
|
||||
|
||||
1. 从 Authorization 读取 Bearer,验证签名、算法、issuer、audience、期限及字段。
|
||||
2. 真实 AccountReader 只查询当前 `id/status/role/authVersion`,核对用户存在、active、版本相同;数据库不可用则拒绝服务。
|
||||
3. 将结果写入 `request.auth`,执行路由角色检查。
|
||||
4. 业务服务检查目标归属和发布状态;写事务锁用户行后重新核对状态、版本,以及需要的管理员角色,再按数据库规定锁题型/内容。
|
||||
|
||||
第一阶段不缓存账户状态,确保禁用、降权、注销可在后续请求立即生效。读请求已通过检查后发生的并发状态变更,不宣称能撤回已发出的响应;关键写入与禁用/注销事务通过同一账户行锁串行化。管理事务也要检查操作者,不能只在控制器检查一次角色。
|
||||
|
||||
### 5.2 权限矩阵
|
||||
|
||||
| 接口/操作 | 未登录 | active 普通用户 | active 管理员 |
|
||||
| ---------------------------------------------- | -------------- | -------------------------------- | -------------------------------------- |
|
||||
| 首页、分类、active 功能及 active 内容列表/详情 | 允许 | 允许 | 允许,公共接口仍只看发布内容 |
|
||||
| 登录 | 验证微信后允许 | 允许 | 允许,不因登录自动授权 |
|
||||
| `/me`、收藏、下载历史及数量 | 拒绝 | 仅本人 | 仅本人 |
|
||||
| 上传头像、修改/清除资料、注销 | 拒绝 | 仅本人 | 仅本人 |
|
||||
| 管理查询、发布、上下架、配置、统计修订 | 拒绝 | 拒绝 | 明确管理路由允许,并按范围审计 |
|
||||
| 读取其他用户私有头像、历史或收藏 | 拒绝 | 拒绝 | 默认拒绝;admin 不是全量私有数据通行证 |
|
||||
| 未来服务端生成/私有文件 | 按专门规则 | 登录后仍核对配额、归属、资源状态 | 仍按对应业务规则 |
|
||||
|
||||
公开接口用 `@Public()` 明确声明,且不返回本人收藏状态;需要个人状态时单独请求 `/me/...`。现有 Public 装饰器会完全跳过守卫,不将它当作“有 token 就自动解析身份”的可选鉴权模式。
|
||||
|
||||
资产接口先验证 purpose、大小、格式、owner 和状态;头像只允许绑定本人拥有、用途为 avatar 且 ready 的私有资产。客户端传来的 assetId、worksheetId、resourceId 都必须做业务校验,不能仅因用户已登录便接受。
|
||||
|
||||
### 5.3 管理权限与版本撤销
|
||||
|
||||
首个管理员通过受控初始化脚本将已验证的现有账户升为 admin,不能按“第一个登录者”授权,也不能依赖 develop 环境。授权、降权、禁用、重新启用采用专用服务/脚本,在同一事务锁操作者与目标用户,按 ID 排序获取同层锁,写入审计;初始化脚本使用 system 操作者。
|
||||
|
||||
建议上述角色/状态变更都递增目标 `auth_version`,防止恢复 active 或重新授权后,早先的 token 再次恢复权限。仅撤销全部凭证时同样递增版本。避免整数溢出,变更失败不绕回旧版本。
|
||||
|
||||
首期没有独立 sessionId,因此不承诺单设备远端退出。小程序暂不增加普通“退出登录”按钮;本地清除缓存不等于服务端撤销凭证。若后续需要单设备退出、设备列表或管理员独立登录期限,再引入会话记录。未来后台的专用登录态还必须验证 `active+admin`,普通用户不能获得后台会话。
|
||||
|
||||
## 6. 小程序状态管理与 401 恢复
|
||||
|
||||
### 6.1 状态与缓存
|
||||
|
||||
`auth.ts` 对页面保留简单入口,但改变其内部含义:
|
||||
|
||||
| 方法 | 目标职责 |
|
||||
| ----------------------- | ------------------------------------------------------------------------- |
|
||||
| `getCachedUser()` | 只用于首屏展示,不能表示已经通过服务端认证 |
|
||||
| `ensureAuthenticated()` | 获取尚未到期的凭证,或共用登录 Promise 完成微信登录 |
|
||||
| `getUser()` | 兼容现有调用:先确保凭证,再返回当前用户摘要;不再仅凭 7 天缓存认定已登录 |
|
||||
| `refreshUser()` | 已登录时 `GET /me` 刷新资料,必要时走一次正常认证恢复;不先清凭证 |
|
||||
| `updateUserProfile()` | 请求受保护资料接口,成功后同步本地摘要和 globalData |
|
||||
| `clearUser()` | 仅作为内部清理工具;明确清理原因,不能顺手触发重新注册 |
|
||||
|
||||
建议用带版本、环境及 AppID 命名空间的单个 Storage 对象保存 `{accessToken, expiresAt, user}`,避免多个 key 写入一半;读取时校验结构,旧 `user_info` 只可辅助展示,升级后移除。资料缓存不保存微信身份;角色缓存最多影响按钮显示。
|
||||
|
||||
凭证先内存、再 Storage;在到期前约 60 秒按需重新登录,`expiresIn` 从收到响应时估算本机剩余期,不依赖设备绝对时间完全准确。最终有效性始终由服务端判断;客户端若解析 JWT 只为时间提示,不能据此授权。上传/请求失败日志不得输出完整 token 或登录响应。
|
||||
|
||||
另存轻量的账户使用状态:是否曾建立账户、是否主动注销/暂停恢复。注销成功后即使 user 缓存被删除,也保留“需用户主动重新开始”的标记;否则下一次 `onLaunch` 会把它误认作全新安装。卸载清数据后该标记可能消失,此时重新验证微信可以建新账户;本方案不提供跨卸载的禁止再注册策略。
|
||||
|
||||
### 6.2 自动恢复规则
|
||||
|
||||
1. 公共请求不附带凭证、不启动登录。应用启动可非阻塞预热登录;失败不影响首页和已有本地生成/保存。
|
||||
2. 受保护请求获取当前 token,记录发送时的 token 代次和业务所属 userId。登录请求直接使用底层 HTTP,不经过需登录的包装,避免递归。
|
||||
3. 收到可恢复的 401 时,若已有较新 token,直接使用新 token 重试;不能让旧请求迟到的 401 清掉新凭证。否则共用唯一登录 Promise,以 mode=resume 恢复。
|
||||
4. 一次原始业务请求只允许一次认证恢复和一次重发;恢复登录若遇到明确无效 code,可以额外获取一次新 code,之后停止。第二次业务 401、403、429、5xx 或网络错误不触发新的自动登录链。
|
||||
5. 恢复后必须仍是原 userId,才能重发原业务写入。账户改变则中止、清理相关视图,由用户重新操作;下载补报队列绑定原 userId,不能代报到新账户。
|
||||
6. GET、按目标定义的 PUT/DELETE 收藏可安全重发;下载使用原 exportId,资料更新复用同一内容。管理发布、通用 POST、资产上传、注销等不可通用重放,只由业务层按幂等键或专门流程恢复。超时不是“未提交”,不对未知结果的写入盲目重试。
|
||||
7. 清理/注销时增加本地状态代次,使尚未结束的登录、资料及列表响应不能重新写回旧状态;等待中的业务请求同时取消或忽略,Promise 结束后只清理自身对应实例。
|
||||
|
||||
短时间登录失败保留退避状态,例如 5 秒、15 秒、60 秒并设置上限;页面并发请求共用同一次失败结果,不各自重新登录。网络恢复或用户点击重试可重新尝试;公开功能不受阻。
|
||||
|
||||
### 6.3 错误契约
|
||||
|
||||
保留 `{code:HTTP状态码, message, requestId}`,增加可选的枚举 `reason`;先更新服务端错误过滤器和契约,再接小程序。只允许预定义安全错误,不透传数据库异常或微信原始消息。
|
||||
|
||||
| HTTP / reason | 小程序行为 |
|
||||
| --------------------------------------------- | --------------------------------------------------------------- |
|
||||
| 401 `AUTH_REQUIRED/AUTH_EXPIRED/AUTH_INVALID` | 有账户恢复意图时合并静默恢复一次;匿名首次用户按 start 流程处理 |
|
||||
| 401 `AUTH_REVOKED` | 清凭证并停止后台恢复;用户主动继续时重新验证微信,不能循环续签 |
|
||||
| 401 `ACCOUNT_NOT_FOUND` | 清旧账户数据,停止自动建号;公开功能可用,用户主动开始才 start |
|
||||
| 401 `ACCOUNT_DISABLED` | 清凭证并停止自动恢复;提示账户不可用,再次验证也不解除禁用 |
|
||||
| 403 权限不足或微信登录被拦截 | 提示无权限/无法登录,不清正常资料后反复尝试 |
|
||||
| 400/409 | 提示参数/业务冲突;不通过重登处理 |
|
||||
| 429 | 遵守重试期限;请求层透传安全的 `Retry-After` 信息 |
|
||||
| 503/网络错误 | 保留可用显示缓存,标明暂不可用;不返回伪造的空列表或零统计 |
|
||||
|
||||
认证错误原因只在已验签后的必要账户检查中细分,不能做成按客户端任意 userId 查询账户存在/状态的接口。未知 reason 保守停止自动恢复。
|
||||
|
||||
`wx.uploadFile` 也要附带凭证并解析 HTTP 状态;先确保登录再上传,不将 multipart 上传纳入通用自动重发。下载签名 URL 本身是短期访问能力,不把本站 Bearer 附给七牛或任意外部域名;私有 URL 到期后重新向受保护 API 获取地址。
|
||||
|
||||
## 7. 资料、清除、注销与重新进入
|
||||
|
||||
### 7.1 资料接口
|
||||
|
||||
| 接口 | 行为 |
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| `GET /me` | 返回本人公开 DTO 及显示需要的当前 role;数量按独立接口查询;不输出 OpenID/UnionID |
|
||||
| `PATCH /me/profile` | 仅允许 `nickName/avatarAssetId`;字段缺省是不修改,NULL 是清除;拒绝未知字段 |
|
||||
| `DELETE /me/profile` | 清昵称及头像引用,保留账户、凭证、收藏、历史;前端更新摘要,不清登录态 |
|
||||
| `DELETE /me` | 当前凭证 + 新微信 code + 用户确认,验证同一身份后按数据库规则完整注销 |
|
||||
|
||||
头像流程:`chooseAvatar` 得到临时本地文件 → 带 Bearer 上传 `POST /assets` → 得到本人 avatar assetId → PATCH 绑定。服务端校验归属,不能接受任意头像 URL、文件路径或他人 assetId。替换/清除后按既有文件清理机制处理旧私有对象,失败重试,不在数据库事务里请求对象存储。
|
||||
|
||||
昵称按数据库上限 100 个 Unicode 字符校验,首尾空白规范化,不把它作为唯一账号名。个人中心可分别保存昵称和头像,不强迫二者齐全;微信填写组件提供的临时路径不是永久头像地址。[微信头像昵称填写](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html)。
|
||||
|
||||
### 7.2 注销的完整边界
|
||||
|
||||
注销请求体建议为 `{code, confirm:true}`。前端在明确确认后现取 code;后端调用微信校验得到可信 OpenID,必须对应当前认证账户,不调用会自动建号的登录方法。code 不能替换 JWT 身份,也不能指定要删谁。
|
||||
|
||||
数据库步骤沿用已定规则:锁用户并重查版本/状态 → 按 ID 锁涉及的题型,仅扣功能收藏贡献 → 删除功能和内容两类收藏及下载记录 → 解除头像/私有资产关联并标记清理 → 去除管理审计中的身份及个人信息关联 → 删除用户,同一事务提交。之后所有旧 JWT 因查不到用户而失效。
|
||||
|
||||
客户端收到成功后清凭证、资料、本人列表和下载补报队列,阻止在途结果回写,记录暂停建号状态,回到可匿名浏览的首页。其他设备的 resume 登录查不到用户时也停止自动建号。用户主动重新开始时才创建全新 userId,旧收藏和历史不恢复。
|
||||
|
||||
若注销响应丢失,客户端进入“确认结果中”状态,暂停自动 start、原写请求及补报。可用原 token 查询 `GET /me`:`ACCOUNT_NOT_FOUND` 说明原账户已不存在,应执行本地清理;仍存在则让用户明确重试并获取新 code;网络失败则保持未知,不能当作成功或重新建号。注销接口不进入通用 401 重试器。
|
||||
|
||||
本方案通过 mode=resume 和客户端意图管理阻止无意重新注册,不承诺删除后永远禁止该微信再建号;跨设备的主动 start 仍可创建新账户。若未来要求注销冷静期或全局禁止再注册,需要另设计受控的身份保留策略,不能只靠已删除的 users 行实现。
|
||||
|
||||
## 8. 旧版本兼容与上线
|
||||
|
||||
旧版没有 `wx.login` code 或本站 JWT,兼容云函数仍从 `cloud.getWXContext()` 获取可信身份,经已有迁移方案规定的服务间签名入口访问同一业务服务。
|
||||
|
||||
- 兼容桥只允许已登记的小程序 AppID、函数/action 白名单、时间窗及防重放校验;客户端 event 中的身份一律不可信。
|
||||
- 内部桥不伪装成普通 Public 业务路由。即使声明跳过 JWT,也必须由独立桥接守卫验签,再执行真实账户状态和服务层权限检查;签名密钥不能用于普通 JWT 签发。
|
||||
- 旧登录 action 可以按既定兼容语义首次建号;其他收藏、下载、资料、设置 action 只查现有账户,不因账户已删而自动创建。桥接 action 和旧响应形状按白名单转换,不返回完整数据库模型。
|
||||
- 旧版主动调用登录仍可能在注销后新建号,这是已发布代码的兼容限制;新用户 ID 不恢复旧数据。完整的客户端暂停建号体验只由新版保证,旧版下线期限按迁移观察确定。
|
||||
- 历史资料响应如确有兼容字段需要,可在受控桥内返回已核实的必要字段;新版 DTO 不延续 OpenID/UnionID 暴露。
|
||||
- 兼容桥仍需检查 admin,不能把“来自云函数”当成管理授权;旧管理页面要求使用新版。签名防重放记录必须原子且有清理期限,具体存储在兼容桥实施时落地。
|
||||
|
||||
上线顺序:先完成受控试迁与旧身份对账,再部署 API/兼容桥,在切换窗口统一写入源,最后发布新版。开发、体验、正式 API/缓存命名空间隔离;不得把不可信 envVersion 当成后端权限或测试库选择依据。体验版如仍对应同一 AppID,必须核实数据隔离和资源隔离策略。
|
||||
|
||||
## 9. 未来 Web:共用账户,独立管理会话
|
||||
|
||||
### 9.1 推荐首条路径:现有小程序扫码确认
|
||||
|
||||
Web 面向现有微信用户提供“使用涂鸦丫小程序登录”:网页展示带短期 challenge 的小程序码 → 微信扫码进入小程序登录确认页 → 小程序用当前有效 Doodle 身份明确确认 → 原网页兑换自己的 Web 会话。
|
||||
|
||||
这是项目自行实现的登录确认协议,不是直接调用网站应用微信 OAuth。它能直接使用已经验证的小程序 userId,不需要用两个 OpenID 猜测是否同一人;未来即使 UnionID 为空也能共用收藏和历史。微信官方提供小程序码进入指定页面的能力,登录 challenge、安全校验和浏览器会话由本站实现。[获取小程序码](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/qr-code.html)。
|
||||
|
||||
未来实现必须包含:
|
||||
|
||||
1. 网页创建挑战时建立 HttpOnly 的预登录浏览器绑定,服务端保存绑定摘要;二维码只携带随机定位符,不包含 token、userId 或浏览器兑换秘密。
|
||||
2. challenge 建议 2 分钟过期,随机数至少 128 位,区分 user-web/admin 用途。小程序码图案本身可以长期存在,是否过期以本站 challenge 为准;过期扫码必须拒绝。
|
||||
3. 确认页展示“将登录涂鸦丫网页”、使用的账户及双方可核对的短码,要求主动确认。扫码/打开页面本身不能完成登录,防止误扫和远程诱导登录。
|
||||
4. 只有持有有效小程序凭证者可确认;只有创建挑战的浏览器绑定可查询/兑换结果。扫码定位符不能作为领取网页会话的凭证。
|
||||
5. 状态原子流转 `pending → confirmed → consumed`,另有 rejected/expired;一次挑战只能成功兑换一次。兑换时重新核对用户状态和确认时的 authVersion,禁用/注销/撤销后不能再兑换。
|
||||
6. 登录后轮换预登录标识,创建全新的服务端会话;轮询只取状态,不返回 Bearer;会话通过 `Set-Cookie` 交给原浏览器,不能经 URL、二维码或小程序转传。
|
||||
|
||||
此路径需要新增短期挑战存储,Web 上线时优先用 PostgreSQL 表和到期清理,不为低频轮询先引入 Redis/WebSocket。小程序码生成还需要微信服务端调用凭证管理,这与用户登录 JWT 不同,届时单独接入。
|
||||
|
||||
它主要解决电脑 Web 登录,不能覆盖所有手机浏览器或非微信用户。移动 Web、独立账号和非微信人群有实际需求后,再增加相应入口。
|
||||
|
||||
### 9.2 另一条路径:网站应用微信 OAuth
|
||||
|
||||
如果希望网页直接使用微信网站登录,可接入已审核的网站应用及其 AppID/AppSecret,使用授权码回调、服务端换身份和随机 state 验证。小程序 `wx.login` code 与网站授权 code 不能混用,微信 OAuth 的 access_token 也不是 Doodle API token。[网站应用微信登录开发指南](https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html)。
|
||||
|
||||
新增应用身份前,才引入 `auth_identities`:建议包含 `id/user_id/provider/app_id/subject/union_scope/union_id/created_at`,其中 `UNIQUE(provider, app_id, subject)`,subject 对应该应用可信 OpenID;`union_scope` 标明可信的开放平台账户范围,不能仅比较无作用域字符串。
|
||||
|
||||
届时的结构迁移顺序:
|
||||
|
||||
1. 将现有 `users.openid` 按服务端已核实的唯一小程序 AppID 回填成身份记录,逐项校验,保留原 userId。
|
||||
2. 小程序和新增应用统一通过身份表找账户,稳定后再将 users 的旧身份列改为可空/过渡字段并停止作为权威源;最终移除。不能让只有网站身份的新账户仍被迫填写小程序 OpenID。
|
||||
3. 已绑定身份直接进入原账户。新身份即使拿到相同 UnionID,也只作为绑定候选;按本项目规则要求用户显式验证并绑定,不自动合并已有账户。
|
||||
4. 绑定必须有当前账户登录态 + 新身份的独立有效证明,不能由客户端上报 UnionID。身份已属于其他 userId 时返回冲突,不自动搬移收藏/历史;账号合并另立流程。
|
||||
5. 注销时事务删除所有登录身份及会话。多个身份的唯一冲突和并发绑定必须依赖数据库约束,不能先查一次就认定安全。
|
||||
|
||||
UnionID 在同一微信开放平台账户范围内关联用户,且可能缺失;不是所有微信产品的全局账号 ID。[UnionID 机制](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html)。当前不恢复刚移除的 users.mini_app_id,也不提前改动已完成的 13 表基线。
|
||||
|
||||
### 9.3 Web 会话与浏览器安全
|
||||
|
||||
推荐 Web 与 API 通过同源 `/api` 反向代理访问,由 API 直接管理不透明会话 Cookie:`Secure; HttpOnly; SameSite=Lax; Path=/`,使用 `__Host-` 前缀且不设 Domain。不把长效凭证放在 localStorage、页面脚本变量、URL 或日志中。[OWASP 会话管理指南](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)。
|
||||
|
||||
Web 阶段新增 `auth_sessions`,保存随机会话秘密的摘要,而不是原始秘密;建议字段为 `id/token_hash/user_id/client_type/auth_version/created_at/last_seen_at/idle_expires_at/absolute_expires_at/revoked_at`。每次会话请求核对期限、撤销状态、账户状态和版本,角色仍取当前 users;注销清理会话,单浏览器退出只撤销当前会话。
|
||||
|
||||
普通 Web 初始可取 7 天空闲期限、30 天绝对期限;后台单独取 30 分钟空闲期限、8 小时绝对期限,并重新核对 active+admin。这些是未来上线时需要实测确认的起步值,不修改当前小程序 JWT 配置。
|
||||
|
||||
Cookie 接入时需要明确调整现有 Bearer-only 守卫:先按路由允许的凭证类型解析为统一用户上下文,再共用账户/版本/角色及归属检查。业务 API 可允许小程序 Bearer 或 Web session,专用后台登录/会话只接受 admin 用途;存在相互冲突的双凭证时拒绝,不在 Bearer 失效后悄悄改用另一身份 Cookie。小程序 audience 继续限定,不直接把 `doodle-mini` JWT 复制到网站。
|
||||
|
||||
Cookie 写请求必须有 CSRF 防护,建议 Origin 校验 + 与会话绑定的 CSRF token;OAuth state 和扫码 challenge 还需独立绑定原浏览器。SameSite、CORS、HttpOnly 都不能单独替代这些检查。只读 GET 不执行写操作;公开页面不夹带个人响应缓存。登录提升权限后更换会话标识,明确退出时撤销服务端会话。
|
||||
|
||||
当前 CORS 是 `credentials:false`。同源代理下不必为 Cookie 开放跨源;若未来必须跨源,需精确 Origin 白名单、credentials、Cookie SameSite/第三方 Cookie 限制及 CSRF 一并重新设计,不能只翻一个开关。前端框架和域名拓扑到 Web 开发时确定。
|
||||
|
||||
## 10. 数据库影响、落点与实施顺序
|
||||
|
||||
### 10.1 本次数据库无需变更
|
||||
|
||||
| 需求 | 当前结构是否足够 | 何时新增结构 |
|
||||
| -------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| 单小程序登录、本人资料与权限 | 足够,users 的 OpenID 唯一约束、role/status/auth_version 已具备 | 当前无需 DDL |
|
||||
| 全账户撤销、禁用及降权 | 足够,变更版本并实时读账户 | 当前无需 DDL |
|
||||
| Web 小程序扫码登录 | users 足够,需挑战和浏览器会话存储 | Web 上线时增加 challenge/session 表 |
|
||||
| 单设备远端退出、设备管理 | 当前不足 | 真实需求出现时引入会话标识及服务端会话检查 |
|
||||
| 网站 OAuth、手机号、邮箱等多身份 | 当前 users.openid NOT NULL 不足 | 引入身份表并迁移 users 身份约束,不提前塞空串或伪 OpenID |
|
||||
|
||||
新表将来通过新的增量迁移接入,不重跑已有基线;身份/会话表进入正式数据库设计后,再实现相应能力。
|
||||
|
||||
### 10.2 代码落点
|
||||
|
||||
- `doodle-store/apps/server/src/auth`:扩展现有模块,负责登录控制器、微信客户端、JWT、账户读取、限流及认证错误;不再在 modules 下重复建第二套 auth。
|
||||
- `doodle-store/apps/server/src/modules/users`:本人资料、资产绑定、清资料、注销及账户状态/角色变更,复用事务规则。
|
||||
- `doodle-store/packages/contracts`:公开请求/响应、有限错误原因和用户 DTO;不包含 Prisma 模型、微信密钥或私有身份记录。doodle-mini 按 HTTP 契约接入,不跨仓库导源码。
|
||||
- `doodle-mini/miniprogram/utils/auth.ts` 与业务 HTTP 层:缓存/凭证/状态机、并发合并、401 恢复;页面调用业务方法。
|
||||
- 小程序个人中心、设置、收藏/历史、上传及现有管理入口:分别适配;不把迁移理解为只替换一个函数名。
|
||||
|
||||
### 10.3 建议推进顺序
|
||||
|
||||
1. Prisma schema/既有 SQL 基线登记、真实 AccountReader;将已有守卫测试接到真实账户查询。
|
||||
2. 微信客户端与登录 API、resume/start 语义、显式 DTO、错误 reason、限流与日志脱敏。
|
||||
3. 小程序统一认证请求层、缓存升级、并发/迟到响应处理;先打通体验版 `wx.login → API → GET /me`。
|
||||
4. 本人资料、头像、清资料和注销;接入收藏/下载及幂等恢复,再补管理接口的事务内权限核对。
|
||||
5. 兼容桥、迁入老身份样本、真机/离线/过期/注销验证,按迁移任务清单切换。
|
||||
6. Web 实际启动时实现扫码挑战、Cookie 会话和对应数据库增量;新增网站身份时才迁移身份表。
|
||||
|
||||
真实微信联调前需核实 AppID/AppSecret、开放平台绑定情况、HTTPS 及 request/upload/download 合法域名、体验版数据隔离。现在无需把密钥写入设计文档,也不以这些待核实项阻塞方案评审。
|
||||
|
||||
## 11. 实施验收清单
|
||||
|
||||
以下是分阶段验收要求;已实施部分及自动验证范围见配套接入说明,不能将服务端测试视为小程序真机/Web 验收。
|
||||
|
||||
| 场景 | 预期结果 |
|
||||
| ----------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| 老用户有效 code 登录 | userId 与旧 `_id` 相同,收藏/历史不断联 |
|
||||
| 同 OpenID 并发首次 start | 只有一个 users,唯一冲突处理不覆盖权限/资料 |
|
||||
| resume 查不到用户 | 不建号,返回 ACCOUNT_NOT_FOUND;需主动 start |
|
||||
| 客户端伪造 AppID/OpenID/userId/role | DTO 拒绝或不作为可信身份,不影响目标账户 |
|
||||
| code 无效/重复/过期、微信风控/超时/限流 | 有限且正确的错误映射,不创建用户、不无限重试 |
|
||||
| JWT 伪造、过期、错误 audience/issuer/算法 | 拒绝;不接受无期限载荷或 URL token |
|
||||
| token 有效但数据库不可用 | 503,不只验签放行 |
|
||||
| 账户禁用、降权、撤销后重新启用 | 旧凭证仍无效;重新验证也不能绕过 disabled |
|
||||
| 多个请求同时 401、旧 401 迟到 | 只发生一轮恢复;不会清除新凭证,每个业务最多重发一次 |
|
||||
| 清缓存/注销与登录、列表请求并发 | 旧响应不复活状态;注销后后台 resume 不重新建号 |
|
||||
| 重新登录返回不同 userId | 原写请求不重发,原下载队列不代报 |
|
||||
| 保存成功后上报超时、重试 | 原 exportId 重用,不重复计数、不重复扣本地配额 |
|
||||
| 清资料 | 收藏/历史及有效凭证保留,头像私有对象进入清理流程 |
|
||||
| 注销成功或响应丢失 | 事务完整,旧 token 失效;未知结果可确认,不自动重放或重新注册 |
|
||||
| 任意用户改他人头像、读他人历史 | 拒绝;管理员默认也不能读取全量私有数据 |
|
||||
| 小程序本地改角色或显示管理按钮 | 管理 API 仍拒绝,管理写事务重新核对操作者 |
|
||||
| 日志/接口/缓存检查 | 无 AppSecret、session_key、code、完整 token、原始微信身份泄漏 |
|
||||
| iOS/Android 体验版、网络断开/恢复 | 正常静默登录;离线公开内容和本地生成可用,远端错误不显示成空数据 |
|
||||
| Web 后续验收 | 挑战过期/重放/误扫/跨浏览器兑换拒绝;Cookie/CSRF/退出/账户撤销有效;与小程序同一 userId |
|
||||
|
||||
## 12. 参考与待确定事项
|
||||
|
||||
代码和数据库规则以本文引用的仓库文件为准。微信资料于 2026-10-15 查阅;未来接入时复核平台准入、配额及接口变化。
|
||||
|
||||
- [旧小程序登录方案](../../../doodle-mini/docs/用户登录与信息授权方案.md):仅作为云开发历史背景,实际行为以代码为准。
|
||||
- [迁移任务清单](./迁移任务清单.md):真实开发、数据迁移与切换验收入口。
|
||||
- [微信 code2Session](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-login/api_code2session.html)、[UnionID](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html):身份来源与作用域。
|
||||
- [微信网站应用登录](https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html)、[OWASP 会话管理](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html):未来网站登录和浏览器会话的参考,不代表本站已接入。
|
||||
|
||||
本次建议默认按“小程序 24 小时 JWT、暂不增加表、Web 优先现有小程序扫码确认”推进。Web 的移动端覆盖、网站应用准入及实际域名拓扑留到 Web 启动时确定;若改选独立手机号/邮箱登录,需要补充验证、凭证、找回和解绑方案,不能直接复用昵称或客户端 UUID。
|
||||
+24
-14
@@ -1,11 +1,17 @@
|
||||
# Doodle Mini 云服务端迁移任务清单
|
||||
|
||||
更新日期:2026-10-13。依据当前代码、数据库设计及《Doodle Mini 现有功能迁移到云服务端方案》整理。
|
||||
更新日期:2026-10-15。依据当前代码、数据库设计及《Doodle Mini 现有功能迁移到云服务端方案》整理。
|
||||
|
||||
目标:把现有业务后端、数据、文件和定时任务迁出 CloudBase,由 doodle-store 提供统一 API、PostgreSQL 主数据库和七牛资源存储;doodle-mini 保留现有生成、绘制、保存、分享及广告能力。
|
||||
|
||||
登录与鉴权实施细节见 [用户鉴权与登录设计](./用户鉴权与登录设计.md)(2026-10-15 设计稿);服务端登录和真实账户检查已实现,注销与小程序适配仍待实施;具体边界见 [服务端登录接入说明](./服务端登录接入说明.md)。
|
||||
|
||||
标记说明:已勾选表示已有实现及记录的验证;未勾选表示待完成。Server 能启动、数据库已建表,不代表业务迁移已经完成。清单不承诺具体工期,外部权限、备案和微信审核单独记录。
|
||||
|
||||
2026-10-16 补充:51 条题型已按原 ID/计数/时间导入本地,51 条预览资产仅登记 pending,尚未迁移文件或新增题型 HTTP API。旧 grade=23 原样保留并报告,未自动生成适用年级关系。此次本地元数据暂存不代表 S2-P01、S2-F03 或 S3-D03~D05 全部完成,详见 [题型数据导入说明](./题型数据导入说明.md)。
|
||||
|
||||
2026-10-16 查询补充:题型公开列表/详情与分类、审核年级、年龄段、Tag、最新/热门、运营/新品标记筛选已实现;预览未就绪按用户确认返回 null,年级关系仍待审核,见 [题型查询接口说明](./题型查询接口说明.md)。这不代表图片迁移、分龄推荐混排或题型管理写接口已经完成。
|
||||
|
||||
## 一、当前已完成
|
||||
|
||||
- [x] 建立独立 doodle-store pnpm 工作区、NestJS 11 Server、公共 contracts 包及构建检查脚本;小程序保持独立项目。
|
||||
@@ -13,8 +19,8 @@
|
||||
- [x] 启动本地 PostgreSQL 18.6 Docker 开发环境,地址 127.0.0.1:5434,数据库 doodle_dev。
|
||||
- [x] 生成并执行 10 张表的建表 SQL、索引、约束及两条初始配置;临时库 19 项约束检查通过。
|
||||
- [x] 接入 Server JSON 日志、脱敏、requestId、bodyParser、/api/v1 前缀、CORS、Helmet、DTO 校验、统一错误处理及 Swagger。
|
||||
- [x] 接入 JWT 签发/验签、默认鉴权守卫、公开接口和角色声明;保留账户读取接入点。账户读取当前为拒绝放行的占位实现,真实登录未完成。
|
||||
- [x] Server 完整检查及 12 项基础/集成测试通过,构建入口真实启动检查通过;代码统一 4 空格并补充关键中文注释。
|
||||
- [x] 接入 JWT 签发/验签、默认鉴权守卫、公开接口和角色声明;已接入 Prisma 账户读取、微信静默登录和安全认证错误原因。
|
||||
- [x] 2026-10-15 Server 完整检查、20 项常规测试及独立数据库 8 项检查通过,构建入口和真实数据库连接验证通过;微信真实 code 联调仍待小程序接入。
|
||||
|
||||
## 二、实施原则与范围
|
||||
|
||||
@@ -38,12 +44,12 @@
|
||||
|
||||
## 四、S1:Prisma 与首条业务链路
|
||||
|
||||
- [ ] S1-01:安装并锁定 Prisma 兼容版本,建立覆盖 10 张表的 schema,保持原字符串主键、C 排序规则及现有约束。
|
||||
- [ ] S1-02:将已执行的 SQL 登记为迁移基线,补齐数据库迁移流程;不得在已有表的开发库重复执行初始 DDL。
|
||||
- [ ] S1-03:实现 PrismaService、数据库连接/关闭和独立就绪检查;配置真实开发连接串及受控连接池。
|
||||
- [ ] S1-04:实现真实 AccountReader,查询用户状态、authVersion 和 role;替换当前有效 token 仍返回 503 的占位实现。
|
||||
- [x] S1-01:锁定 Prisma 7.10.0 与 PostgreSQL adapter,Schema 覆盖 13 张表;SQL 基线保留 C 排序规则、CHECK 及部分唯一索引。
|
||||
- [x] S1-02:将已执行的 SQL 登记为迁移基线,补齐数据库迁移流程;不得在已有表的开发库重复执行初始 DDL。
|
||||
- [x] S1-03:实现 PrismaService、数据库连接/关闭和独立就绪检查;配置真实开发连接串及受控连接池。
|
||||
- [x] S1-04:实现真实 AccountReader,查询用户状态、authVersion 和 role;替换当前有效 token 仍返回 503 的占位实现。
|
||||
- [ ] S1-05:落实公开/本人/管理接口分类,服务层强制校验数据归属;管理员角色不自动开放全体用户私有数据。
|
||||
- [ ] S1-06:实现 POST /auth/wechat/login:微信 code2Session、按可信 OpenID 查找并沿用老 userId、并发首次登录唯一性、JWT 签发及活跃时间维护。
|
||||
- [x] S1-06:实现 POST /auth/wechat/login:code2Session、start/resume、老 userId 延续、并发首次登录、JWT 及活跃时间;真实数据库自动测试通过,真实微信 code 联调仍在 S3。
|
||||
- [ ] S1-07:实现最小题型公开读取,以及收藏、成功下载记录的事务纵向样板;验证重复请求只产生一次关系或一次计数。
|
||||
- [ ] S1-08:完善公开 DTO、分页/总量、错误码与 requestId 契约;新接口 id 与旧端 _id/path/previewImg 兼容转换明确记录。
|
||||
|
||||
@@ -53,7 +59,7 @@
|
||||
|
||||
### 5.1 用户、收藏与下载
|
||||
|
||||
- [ ] S2-U01:实现 GET /me、PATCH /me/profile、DELETE /me/profile;清资料保留收藏和历史。
|
||||
- [ ] S2-U01:GET /me 已实现;PATCH /me/profile、DELETE /me/profile 待实现,清资料保留收藏和历史。
|
||||
- [ ] S2-U02:实现 DELETE /me 注销:事务清理收藏及相关 likes、下载历史、用户资料和身份;旧凭证失效,私有文件可重试清理。
|
||||
- [ ] S2-U03:补齐收藏添加/取消/分页列表/数量/单张检查/批量检查;关系与 likes 同事务,仅真实增删影响计数。
|
||||
- [ ] S2-U04:补齐下载记录添加/分页列表/数量;要求新版 exportId,日志与 downloads 同事务;历史 NULL 事件保留。
|
||||
@@ -61,7 +67,7 @@
|
||||
|
||||
### 5.2 公开内容
|
||||
|
||||
- [ ] S2-P01:实现题型列表/详情及分类列表;公开接口强制只返回 active,不允许客户端参数绕过状态限制。
|
||||
- [x] S2-P01:分类与题型公开列表/详情已实现;分类及题型统一要求自身与祖先上线,支持组合筛选、分页和安全预览解析,未就绪预览按用户确认返回 null。分类新增 status,管理员可独立预览与上下架。
|
||||
- [ ] S2-P02:实现分龄推荐,保留 5 个年龄段、现有年龄匹配、Top3 下载+Top3 收藏、去重和不足补齐规则。
|
||||
- [ ] S2-P03:区分真实计数与 seed 展示值;推荐保留种子参与规则,排序并列明确稳定规则并与固定样本核对。
|
||||
- [ ] S2-P04:提供公共首页配置接口,保留 home/category/age/version/updatedAt 及 previewImg 字段形状。
|
||||
@@ -71,10 +77,10 @@
|
||||
- [ ] S2-A01:确定管理后台登录方式及凭证方案;配置首个管理员,默认用户不能注册或自升管理员。
|
||||
- [ ] S2-A02:落实后台独立登录入口或明确的管理员授权流程:身份验证后校验 active+admin,非管理员不能获得后台登录态;现有小程序管理入口同样受保护。
|
||||
- [ ] S2-A03:全部管理 API 服务端检查管理员身份;用户降权/禁用后立即失去权限,不能只靠前端隐藏按钮。
|
||||
- [ ] S2-A04:实现题型发布/重新发布/上下架/管理列表和分类状态数量;普通重发不清零真实计数,保留 createdAt/contentUpdatedAt/updatedAt 语义。
|
||||
- [ ] S2-A05:实现分类创建/更新,保留主键和父子关系,校验父分类及循环引用。
|
||||
- [ ] S2-A04:题型/资源管理列表、详情预览与状态上下架已实现;题型配置发布/重新构建及分类状态数量待实现;普通重发不清零真实计数,保留 createdAt/contentUpdatedAt/updatedAt 语义。
|
||||
- [x] S2-A05:实现管理员分类创建/更新/删除,保留主键和父子关系,事务锁串行校验父分类及循环引用;删除有子分类或题型引用时返回冲突,审计同事务。当前创建/更新支持文字/Emoji 图标,文件资产另见 S2-F04。
|
||||
- [ ] S2-A06:实现批量年龄/年级/tags/seed/统计修订,保留 dryRun、逐项结果和错误;真实/seed 修订记录操作者、原因及前后值。
|
||||
- [ ] S2-A07:管理修改与审计同事务;核实 worksheetsStatsUpdate 的旧调用需要,不向普通用户开放通用加数接口。
|
||||
- [ ] S2-A07:分类管理修改/迁移与审计已同事务;其他业务及 worksheetsStatsUpdate 旧调用核实待完成,不向普通用户开放通用加数接口。
|
||||
|
||||
### 5.4 七牛、首页与任务
|
||||
|
||||
@@ -155,6 +161,10 @@
|
||||
|
||||
## 九、下一步建议
|
||||
|
||||
优先推进 S0 线上盘点和 S1 Prisma/真实登录/题型读取/收藏下载事务的纵向样板。基础设施已齐备,当前首要技术阻塞是未接入真实数据库账户查询和业务模块。七牛资源准备可同步推进。
|
||||
Prisma、真实账户读取及服务端微信登录已接入;继续推进 S0 线上盘点、S1 题型读取及收藏下载事务样板,以及小程序体验版真实 code 联调。七牛资源准备可同步推进。
|
||||
|
||||
管理员登录方式、跨端账户绑定、完整后台页面和完整 Web 分别管理:管理员授权能力及现有管理 API 属于本次;完整后台/Web 页面和 Web 用户登录不作为现有小程序迁移的切换前置条件。
|
||||
|
||||
2026-10-16 发布控制:分类 status 增量迁移及本地升级完成;全部公开题型查询检查所属分类链,新增管理员题型/资源预览和状态修改,审计/首发时间/数据库快照失效原子提交。小程序仍使用旧云服务,其请求迁移、本地兜底取消及预拉取缓存撤销属于切换前置条件,不能将新后端验收视为现网隐藏已生效。见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||
|
||||
2026-10-16 小程序分类页接入:全局 HTTP 配置支持 develop 本地/线上切换,trial/release 仅线上;后端缺省与本地实际端口改为 8800。分类页已读取公开分类、完整题型分页和点击详情,移除本地静态兜底,保留榜单与混合排序并安全比较字符串计数。首页预拉取、登录、收藏、下载及分享直达生成页仍待切换;9 项小程序测试(含真实本地 API)通过。
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# 题型数据导入说明
|
||||
|
||||
## 2026-10-16 本地导入
|
||||
|
||||
已将用户提供的 `worksheets.json` 导入本地 `doodle_dev`。源文件为 CloudBase JSON Lines,共 51 条,无重复 ID、未知字段或日期错误,5 个引用分类均已存在。原附件未改动,SHA-256 为 `184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150`。
|
||||
|
||||
| 项目 | 结果 |
|
||||
| ---------------------------- | -------------------------------------------------- |
|
||||
| 题型 | 新增 51,更新 0,隔离 0 |
|
||||
| 原始发布状态 | 51 条 active,原样保留 |
|
||||
| 分类分布 | math 22、puzzle 11、english 7、chinese 6、pinyin 5 |
|
||||
| 预览资产 | 新增 51 条,全部 pending |
|
||||
| 迁移审计 | 新增 51 条,与题型及资产同事务提交 |
|
||||
| 重复导入 | 新增 0、更新 0、未变化 51,资产/时间/审计均未改变 |
|
||||
| 新适用年级关系及完整内容资源 | 均未生成,不从旧值或预览图推断 |
|
||||
|
||||
导入批次时间为 `2026-10-16T05:59:22.266Z`,北京时间 13:59:22.266;题型自身的 createdAt/updatedAt 使用附件原值,不使用批次时间覆盖。2 条 contentUpdatedAt 原样保留,其余为 NULL。
|
||||
|
||||
计数对账:downloads **4203**,likes **217**,downloads_seed **629**,likes_seed **666**。11 条题型未提供两种 seed,按数据库约定默认 0,并在报告逐项列出,不重算或修正原下载、收藏计数。
|
||||
|
||||
`break-ten`(破十法练习)的旧 grade 为 **23**,超出新年级体系 -4~6。该值原样保留在兼容列 `worksheets.grade`;不拆成 2、3,不裁剪或归零,也不写入 `worksheet_grades`。其余旧 grade 同样未自动成为审核后的适用年级关系,后续应提供经审核的 worksheetId → grades 映射。
|
||||
|
||||
## 文件迁移边界
|
||||
|
||||
本次是**本地元数据暂存**,不是文件迁移或生产发布。51 张预览图均为旧 `cloud://` 引用,未提供本地图片,项目也尚未接入七牛配置。因此先登记稳定资产 ID 与 source_ref/source_ref_hash,status 为 pending;bucket、object_key、external_url、mime_type、size_bytes、checksum 均为 NULL,没有虚构七牛地址或文件校验结果。
|
||||
|
||||
为保留原始状态,题型仍记录源 active;这不表示预览图已经可访问,报告明确 `readyForPublicUse=false`。后续公共题型接口/首页构建必须校验预览资产 ready 和访问策略,不能仅检查 active 就把这批待迁移数据当作完整可用内容。正式迁移/发布仍遵循数据库设计中先完成并核验资产的顺序。
|
||||
|
||||
老功能保留原 ID、完整 path、标题、标签及运营标记。generation_mode 使用旧功能兼容默认 procedural,renderer_key 为 NULL,render_config 为 {};不从 ID 猜 renderer,不把封面图当作线框,不伪造 first_published_at、内容资源或文件关联。
|
||||
|
||||
## 工具
|
||||
|
||||
在 doodle-store 根目录执行,文件和报告路径使用绝对路径:
|
||||
|
||||
```bash
|
||||
# 本地元数据预演,不写数据库
|
||||
pnpm worksheets:import --file "/绝对路径/worksheets.json" --metadata-only
|
||||
|
||||
# 确认预演后写入;可选报告文件必须尚不存在
|
||||
pnpm worksheets:import --file "/绝对路径/worksheets.json" --metadata-only --apply --report "/绝对路径/import-report.json"
|
||||
```
|
||||
|
||||
根命令先构建依赖和服务端;已构建时可执行 `pnpm --filter @doodle/server worksheets:import ...`。工具只允许本机 `doodle_dev`,仅读取服务端 `.env`,不连接生产。默认是 dry-run;未显式使用 `--metadata-only` 时,任何未就绪预览都会阻止导入。
|
||||
|
||||
输入支持 JSON Lines/数组、ISO 字符串和 CloudBase `{ "$date": "..." }` 时间,最多 10000 条、10 MiB。未知字段、重复 ID、缺分类、非法时间、年龄/难度/类型错误、负计数或数字精度风险均阻止整批写入。
|
||||
|
||||
题型、资产映射和迁移审计原子提交;任何后续行冲突也回滚前面已写数据。共享预览按源引用 SHA-256 复用资产;相同数据重复执行不改变原时间或增加审计。已有同 ID 数据若不同则报冲突,不用旧快照覆盖新写入,不删除附件外的数据。报告文件在写库前独占创建,避免路径/权限或重名问题发生在提交之后。
|
||||
|
||||
## 报告与验证
|
||||
|
||||
- [预演报告](worksheets-dry-run-2026-10-16.json)
|
||||
- [正式导入报告](worksheets-import-2026-10-16.json)
|
||||
- [重复导入报告](worksheets-reimport-2026-10-16.json)
|
||||
- [独立逐字段对账报告](worksheets-verification-2026-10-16.json)
|
||||
|
||||
已通过 `pnpm check` 及 `pnpm test:db`。独立临时数据库验证默认拒绝 pending、dry-run 不写库、缺分类拒绝、原 ID/计数/日期/兼容年级保留、重复导入、共享预览复用、旧快照覆盖保护与半批失败回滚;测试库已清理。正式写库后,独立于解析器逐字段核对全部 51 条题型及资产来源,并实际重复执行确认时间及审计不变。
|
||||
|
||||
导入本身不提供 HTTP API。2026-10-16 已另行完成题型公开查询,返回文字/元数据并对未就绪预览返回 null,详见 [题型查询接口说明](./题型查询接口说明.md)。七牛文件迁移与题型管理写接口仍待实施。
|
||||
@@ -0,0 +1,127 @@
|
||||
# 题型查询接口
|
||||
|
||||
更新日期:2026-10-16。实现范围:公开题型列表、详情和组合筛选,供小程序及未来 Web 共用。题型管理预览及状态写入已在独立管理员接口补齐;尚未实现题型配置编辑、分龄推荐 Top3 混排或首页构建,doodle-mini 分类页现已通过全局环境配置接入新接口。
|
||||
|
||||
## 接口与示例
|
||||
|
||||
统一前缀为 `/api/v1`,无需登录,固定要求自身 `status=active` 且所属分类及全部祖先已上线。客户端不能传 status、rawStats 等参数读取草稿、隐藏内容或管理统计;未识别参数返回 400。
|
||||
|
||||
| 用途 | 请求 |
|
||||
| -------------------- | --------------------------------------------------------------------------- |
|
||||
| 全部题型(分页) | `GET /worksheets?page=1&pageSize=20` |
|
||||
| 本批全部 51 条 | `GET /worksheets?pageSize=100` |
|
||||
| 按分类 | `GET /worksheets?categoryId=math` |
|
||||
| 按单个年级 | `GET /worksheets?grade=1` |
|
||||
| 按多个年级,任一匹配 | `GET /worksheets?grades=-1,0,1` |
|
||||
| 按年龄段 | `GET /worksheets?ageBand=3-4` |
|
||||
| 按单个年龄 | `GET /worksheets?age=5` |
|
||||
| 按 Tag | `GET /worksheets?tag=描红` |
|
||||
| 最新排序 | `GET /worksheets?sort=latest` |
|
||||
| 热门排序 | `GET /worksheets?sort=hot` |
|
||||
| 人工运营热门标记 | `GET /worksheets?isHot=true` |
|
||||
| 新品标记 | `GET /worksheets?isNew=true` |
|
||||
| 组合筛选 | `GET /worksheets?categoryId=math&ageBand=3-4&tag=数学&sort=hot&pageSize=20` |
|
||||
| 详情 | `GET /worksheets/number-find` |
|
||||
|
||||
中文 Tag 由 HTTP 客户端正常 URL 编码。查询参数与实际路由已加入开发 Swagger:`http://127.0.0.1:8800/api-docs`。
|
||||
|
||||
## 筛选与排序规则
|
||||
|
||||
- 不同维度的条件按 AND 组合;多年级内部任一匹配。分类按原 ID 精确匹配,不自动包含子分类。
|
||||
- 年级只使用审核后的 `worksheet_grades`,不读取旧 `worksheets.grade` 兜底。支持 -4~6,0 表示幼小衔接。`grade` 和逗号分隔的 `grades` 互斥;多重匹配不重复返回题型。
|
||||
- `ageBand` 支持 3-4、4-5、5-6、6-7、7-8;题型适用范围须完整覆盖所选年龄段,即 ageMin ≤ 下限且 ageMax ≥ 上限。例如 3~8 岁题型可命中 3-4、4-5、5-6、6-7、7-8;3~5 岁题型不能命中 5-6,仅边界相交不足以匹配。公开列表与管理员列表共用此规则。
|
||||
- `age` 是单年龄范围查询,要求 ageMin ≤ age ≤ ageMax,与 ageBand 互斥。年龄范围为非负 smallint。
|
||||
- Tag 为字符串数组成员的精确匹配,区分大小写,不进行标题搜索、模糊匹配或推荐补齐。
|
||||
- 默认排序:sortOrder 升序 → updatedAt 降序 → id 升序。
|
||||
- 最新排序:firstPublishedAt 降序;旧数据未知时按 createdAt 降序 → id 升序。不使用会被计数变化刷新的 updatedAt 充当发布时间,也不改写原日期。
|
||||
- 热门排序:downloads + downloadsSeed 降序 → likes + likesSeed 降序 → updatedAt 降序 → id 升序。数据库在完整筛选集排序后分页,不先取 100 条再排序,统计相加不会发生 bigint 溢出。
|
||||
- `isHot` 独立筛选人工运营标记,不等同于下载热度排序,支持 true/false。
|
||||
- `isNew` 依据真实 firstPublishedAt 及 `WORKSHEET_NEW_WINDOW_DAYS` 判断,默认 30 天;时间未知、未来或窗口外均为 false,旧 is_new 不作为新接口判断依据。最新排序与“仅新品”是两种查询,可分别使用。
|
||||
|
||||
## 分页及公开响应
|
||||
|
||||
page 默认 1,范围 1~1000000;pageSize 默认 20,范围 1~100。超过 100 条可继续翻页,没有旧云函数固定截断 100 条的问题。超过最后一页仍返回正确 total、空 items 和 hasNext=false。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "number-find",
|
||||
"title": "找数字,涂一涂",
|
||||
"subtitle": "找出目标数字并涂色",
|
||||
"category": "math",
|
||||
"subcategory": "math-draw",
|
||||
"path": "/mathPages/mathDraw/mathDraw?id=number-find",
|
||||
"ageMin": 3,
|
||||
"ageMax": 5,
|
||||
"grades": [],
|
||||
"difficulty": 1,
|
||||
"previewImg": null,
|
||||
"tags": ["数学", "数字认知", "涂色"],
|
||||
"isNew": false,
|
||||
"isHot": false,
|
||||
"sortOrder": 101,
|
||||
"downloads": "335",
|
||||
"likes": "24",
|
||||
"createdAt": "2026-04-29T05:06:22.720Z",
|
||||
"updatedAt": "2026-10-15T07:17:05.132Z",
|
||||
"contentUpdatedAt": null,
|
||||
"firstPublishedAt": null
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"hasNext": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上述为响应结构示例,非默认第一页结果。downloads/likes 均为**含对应 seed 的展示值**,采用十进制字符串,避免 64 位计数在 JSON/JavaScript 中丢失精度。数据库原值不变;前端不要再次叠加 seed,不应把这些字段当作原始管理统计。
|
||||
|
||||
详情响应同样为 `{code:0,message:"ok",data:题型对象}`。不存在和未发布的题型均返回 404。公开白名单不含旧 grade、生成器配置、rendererKey、内部资产 ID、原 cloud:// 引用或对象存储 key。
|
||||
|
||||
列表与 total 在同一 Repeatable Read 事务、同一条件和时间点下读取,避免查询期间并发修改导致当次分页总量与内容使用不同快照。跨请求翻页仍可能受到新的业务写入影响,不承诺长时间固定快照。
|
||||
|
||||
## 预览图与配置
|
||||
|
||||
已按用户确认采用“返回题型文字/元数据,未就绪预览留空”的方式。预览返回 URL 前,必须同时满足资产 ready、public、purpose=preview、无私有用户归属、图片 MIME。
|
||||
|
||||
- 旧源引用、pending/failed/deleting/deleted、私有文件或非图片均返回 previewImg=null。
|
||||
- 七牛公共资产:配置 `QINIU_PUBLIC_DOMAIN` 为 HTTPS origin,按对象 key 逐段编码;缺域名不猜地址、不返回 key。
|
||||
- 经核实的 external 资产:仅输出无凭据、无查询参数或 fragment 的稳定 HTTPS URL;不输出带签名的临时地址。
|
||||
|
||||
`QINIU_PUBLIC_DOMAIN` 只用于只读地址解析,不代表上传、私有签名或文件迁移已经接入。新配置均在 `apps/server/.env.example`;实际密钥不属于查询接口范围。
|
||||
|
||||
## 当前 51 条数据的结果
|
||||
|
||||
2026-10-16 只读联调并逐项对照原附件:
|
||||
|
||||
| 查询 | total |
|
||||
| ---------------------------------------- | ----- |
|
||||
| 全部 | 51 |
|
||||
| math | 22 |
|
||||
| english | 7 |
|
||||
| pinyin | 5 |
|
||||
| chinese | 6 |
|
||||
| puzzle | 11 |
|
||||
| ageBand=3-4 | 10 |
|
||||
| tag=字母 | 7 |
|
||||
| categoryId=math & ageBand=3-4 & tag=数学 | 5 |
|
||||
|
||||
热门前 3:word-coloring-grid(774)、number-find(335)、number-decompose(325);最新前 3:clock-connect、clock-reading、word-test-sheet。
|
||||
|
||||
当前 51 张预览全部 pending,因此 previewImg=null;所有真实首次发布时间未知,isNew=false;审核后的年级关系尚为空,所以带 grade/grades 的查询暂时没有结果。这些条件不会用旧 grade/is_new 或临时图片地址自动补齐。年级筛选功能已经用独立样本验证,后续审核并写入关系即可命中。
|
||||
|
||||
已通过 `pnpm check`、`pnpm test:db`,临时库覆盖公开状态隔离、组合筛选、多年级去重、Tag 精确匹配及注入文本、false/0/重复参数校验、121 条完整分页、稳定并列顺序、bigint 展示/排序、真实新品、预览访问策略和 Swagger 参数声明。开发库实测只读,未修改题型、年级、资产或审计。
|
||||
|
||||
## 发布控制补充(2026-10-16)
|
||||
|
||||
公开列表、total、分页与详情统一过滤整条所属关系,不能通过热门、分类之外的查询或已知 ID 绕过。分类下架后其题型不再公开,恢复分类不改写题型自身状态。管理员通过 /admin/worksheets 查询草稿或下架内容;/admin/worksheet-resources 提供资源管理预览。状态更新、首发时间、页面快照失效与审计同事务。接口与客户端切换边界见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||
|
||||
## 年龄段匹配调整(2026-10-16)
|
||||
|
||||
用户确认将普通题型查询从“最小年龄等于年龄段下限”改为“适用范围完整覆盖所选年龄段”。本次仅修改 ageBand 匹配语义,不修改 grade/grades、age/ageBand 参数格式,不改数据库或小程序云函数。旧分龄推荐接口尚未迁移,其匹配和榜单规则不属于本次普通列表变更。
|
||||
Reference in New Issue
Block a user