Files
doodle-store/docs/v3.5/数据库设计文档.md
T
2026-10-15 10:09:34 +08:00

611 lines
79 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Doodle Store v3.5 数据库设计
> 数据库:PostgreSQL;ORM:Prisma。数据库选型已按用户确认更新。
>
> 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。
>
> 更新日期:2026-10-14。
>
> 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。
已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md)。2026-10-12 验证并创建最初 10 张表及两条配置;2026-10-14 执行 004/005/006/007/008 后,本地共有 13 张表。最新空库基线与对应增量升级结构已在临时库对照通过,多资源、多年级、资源计数及两种收藏约束、旧数据保留验证通过,临时库已清理;尚未创建 Prisma Schema 或导入线上数据。
本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 13 张表已在本地创建,业务 API 尚未实现。
## 1. 范围与依据
首期迁移现有微信小程序后端,统一使用 PostgreSQL 主数据库、NestJS API 和七牛文件存储。小程序仍在 `doodle-mini` 维护,数据库模型与迁移只放在 `doodle-store/apps/server/prisma`。Web 与管理后台通过 API 访问,不直接连接数据库。
设计优先级:用户本次确认的 PostgreSQL 选型 → 当前迁移方案 → 实际云函数及调用代码 → 历史设计。当前代码有 7 个核心集合;新增 3 张辅助表用于文件、调度和审计,另新增 2 张内容资源表支持多素材绘制及 1 张年级关系表支持跨年级筛选。
| 阅读依据 | 与建模相关的实际行为 |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| [旧云数据库设计](../../../doodle-mini/docs/小程序云开发方案和数据库设计.md)、[旧后端预案](../../../doodle-mini/docs/后端部署方案.md) | 历史模型参考;其中 MySQL Schema、`favorites_log` 和生成器必填字段不直接沿用 |
| [userLogin](../../../doodle-mini/cloudfunctions/userLogin/index.js)、[userSettings](../../../doodle-mini/cloudfunctions/userSettings/index.js) | OpenID 登录、UnionID 可空;用户包含 `totalDownloads`;清资料与注销是不同操作 |
| [userFavorites](../../../doodle-mini/cloudfunctions/userFavorites/index.js)、[userDownloadLogs](../../../doodle-mini/cloudfunctions/userDownloadLogs/index.js) | 实际收藏集合为 `favorites`;下载支持 `exportId`;缺失题型时详情为 null;当前关系写入与计数未纳入同一事务 |
| [题型发布](../../../doodle-mini/cloudfunctions/worksheetsPublish/index.js)、[批量修改](../../../doodle-mini/cloudfunctions/worksheetsBatchPatch/index.js)、[题型查询](../../../doodle-mini/cloudfunctions/worksheetsQuery/index.js) | `path`、`grade`、三种状态、真实/seed 计数、内容更新时间均需保留;重发题型可能覆盖计数,迁移后修正 |
| [页面构建](../../../doodle-mini/cloudfunctions/pageContentBuild/index.js)、[自动刷新](../../../doodle-mini/cloudfunctions/homeAutoRefresh/index.js)、[开关](../../../doodle-mini/cloudfunctions/homeTimerControl/index.js) | `page_configs/current` 为权威配置;`settings/homeAutoRefresh` 为开关;静态文件是派生产物 |
| [分龄推荐](../../../doodle-mini/cloudfunctions/ageRecommendedQuery/index.js) | 5 个年龄段;筛选、seed、去重、榜单及不足补齐规则应保留 |
| [保存流程](../../../doodle-mini/miniprogram/utils/downloadPrint.ts)、[设置页面](../../../doodle-mini/miniprogram/supportPages/settings/settings.ts) | 实际保存成功后才上报;页面承诺注销清除收藏和下载记录,但旧云函数只删除用户 |
首期不建反馈、学习路线、会员、支付、孩子档案、学习包或服务端生成任务表。`feedback`、`learning_plans` 等若在线上盘点中存在,应先归档并明确处理范围,不能当作空集合丢弃。年龄目标和周计划目前主要来自小程序配置,动态推荐不需要新建推荐关系表。
## 2. 全局约定
### 2.1 命名、主键与类型
| 项目 | 约定 |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| 表名 / 数据库列名 | 表名沿用集合名、列名 snake_case;Prisma 字段使用 camelCase,通过 `@map` / `@@map` 映射 |
| 原主键 | 7 张现有表 `_id → id` 原值保留,使用 `varchar(128)`;不强制转换 UUID,不批量重发 ID |
| 新主键 | 新用户、关系、日志及辅助记录可由服务端生成 UUID 字符串,仍存 `varchar(128)`;配置/设置保留固定业务 ID |
| 大小写 | ID、OpenID、exportId、对象 key 必须精确匹配;这些列使用确定性、区分大小写的排序规则,迁移 SQL 明确指定 `COLLATE "C"`;外键列保持一致 |
| 字符编码 | 数据库 UTF-8;文本字段按 Unicode 字符处理;导入先检查实际长度,超长项报告,禁止静默截断 |
| 时间 | `timestamptz(3)`,连接会话设 UTC;API 输出 ISO 8601 UTC;业务日与任务计划使用 `Asia/Shanghai` |
| JSON | `jsonb`;Prisma 使用 `Json` / `Json?` 和 `@db.JsonB`;需要查询、唯一约束或计数的字段使用结构化列 |
| 空值 | 可选文本空串按字段规则转 SQL NULL;0 与 NULL 严格区分;JSON nullable 字段区分 SQL NULL 与 JSON null |
| 整数 | 年龄/难度/年级用 smallint,排序用 integer,累计计数与版本用 bigint;禁止浮点计数 |
| bigint 对外 | 首期计数/版本 DTO 保持 number;转换前验证处于 JS 安全整数范围,超出返回明确错误;Prisma BigInt 不直接 JSON 序列化 |
| 默认值 | 新建记录使用默认值;迁入记录显式写入原值,不能用默认值覆盖已有 0、false 或时间 |
本文字段表中“可空”指 SQL NULL;无另行说明的字段均 NOT NULL。表长度是目标限制,正式 DDL 前用全量导出核对;超过限制时调整设计或隔离,不直接裁剪。
### 2.2 关系与删除策略
```mermaid
erDiagram
users ||--o{ favorites : "用户外键"
users ||--o{ download_logs : "用户外键"
users o|--o{ assets : "所有者外键"
categories o|--o{ categories : "父分类外键"
categories ||--o{ worksheets : "分类外键"
worksheets o|--o{ favorites : "题型逻辑关联,可历史缺失"
worksheets o|--o{ download_logs : "题型逻辑关联,可历史缺失"
assets o|--o{ users : "头像外键"
assets ||--o{ worksheets : "功能封面外键"
worksheets ||--o{ worksheet_grades : "适用年级外键"
worksheets ||--o{ worksheet_resources : "内容外键"
assets ||--o{ worksheet_resources : "内容预览外键"
worksheet_resources ||--o{ worksheet_resource_assets : "文件关联外键"
assets ||--o{ worksheet_resource_assets : "文件外键"
worksheet_resources o|--o{ favorites : "内容收藏逻辑关联"
worksheet_resources o|--o{ download_logs : "内容逻辑关联"
page_configs ||--o{ job_runs : "执行结果逻辑关联"
```
- 收藏和下载的 `user_id` 有真实外键,`ON DELETE RESTRICT`;注销服务先处理关系与计数,再删除用户,避免级联删除跳过业务逻辑。
- 收藏和下载的 `worksheet_id` **保留原 ID,但不建题型外键**,以支持历史缺失题型;读取使用 LEFT JOIN。新写入由服务校验题型存在且 active,使用行锁保证状态检查与写入一致。
- `worksheets.category_id` 与 `categories.parent_id` 有外键且删除 RESTRICT;分类循环由服务在串行化分类树修改的事务中检查。`subcategory` 仍是业务字符串,不强制认作分类主键。
- 题型首期仅通过 draft/active/hidden 管理,不提供硬删除 API。历史缺失题型不创建伪题型补齐;收藏/历史中非 active 题型不返回未发布详情,允许返回 `worksheet: null` 并取消收藏。
- 用户头像、题型预览使用 assets 外键,删除 RESTRICT;资产所有者外键可 `ON DELETE SET NULL`。对象存储删除通过业务清理重试完成,不由数据库级联触发。
- 内容资源的 worksheet、预览资产及文件关联均使用真实外键,删除 RESTRICT;首期只上下架,不提供硬删除。清理文件必须检查功能封面、内容预览、文件关联、头像及配置引用,不能只检查 assets.purpose。
- 收藏和下载 worksheet_resource_id 可空且为逻辑关联;新写入需校验资源属于同一 worksheet,并且功能及内容均 active。历史缺失内容仍保留记录,详情使用 LEFT JOIN 并过滤发布状态。
- 页面 JSON 引用、任务/审计中的资源 ID 是逻辑关联。审计操作者不建用户外键,避免注销删除审计记录;用户注销时移除其中的身份关联与个人信息。
## 3. 现有业务表
### 3.1 users — 用户
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| -------------------------------- | -------------- | ----------- | -------------------------------------------------------------------------------- |
| id | varchar(128) | 否 | 原 `users._id`;新账户独立生成 ID |
| openid | varchar(128) | 否 | 微信服务端返回的可信标识 |
| unionid | varchar(128) | 是 | 空串转 NULL;仅保存身份线索,首期不自动合并账户 |
| nick_name / nickName | varchar(100) | 是 | 用户昵称 |
| avatar_asset_id / avatarAssetId | varchar(128) | 是 | assets.id;API 动态生成 avatarUrl,不保存临时签名 URL |
| total_downloads / totalDownloads | bigint | 否 / 0 | 保留旧字段,作为历史兼容值;当前云函数创建后没有随下载维护,不能充当权威累计统计 |
| role | varchar(16) | 否 / user | CHECK:user、admin;迁入默认 user,管理员由可信名单显式配置 |
| status | varchar(16) | 否 / active | CHECK:active、disabled;禁用账户不接受写入 |
| auth_version / authVersion | integer | 否 / 1 | 非负凭证版本;主动失效时递增,JWT 带版本且请求核对当前值 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 保留首次创建时间 |
| last_active_at / lastActiveAt | timestamptz(3) | 否 / now() | 保留活跃时间;登录更新 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 新系统资料/权限等变更时间;旧数据缺失按导入规则补齐 |
约束与索引:主键 id;UNIQUE `(openid)`;普通索引 unionid;CHECK `total_downloads >= 0`、`auth_version >= 0`。UnionID 不设唯一约束,不用于自动合并账户。首期只接入一个小程序,AppID/AppSecret 由服务端配置,用户表不重复存储 AppID;未来多应用接入时单独设计登录身份表。
`GET /me/downloads/count` 与资料页数量以 `download_logs` 的当前用户记录数为准,不读取旧 totalDownloads。若兼容 DTO 需要该字段,由适配层返回查询结果;原字段只用于迁移对账,不形成第二套计数。
### 3.2 categories — 内容分类
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| --------------------------- | -------------- | ----------- | ------------------------------------------------------------------ |
| id | varchar(128) | 否 | 原分类 ID,如 math、chinese;不重命名 |
| name | varchar(100) | 否 | 分类名 |
| icon | varchar(1024) | 否 | 保留图标名、字符等非文件内容;若是文件则转为稳定资产引用,接口解析 |
| icon_asset_id / iconAssetId | varchar(128) | 是 | 文件类型图标的 assets 外键;存在时对外 icon 由此解析 |
| color | varchar(32) | 否 | 保留颜色值 |
| sort_order / sortOrder | integer | 否 / 0 | 保留排序权重 |
| parent_id / parentId | varchar(128) | 是 | 自关联,根分类为 NULL,空串规范化 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 旧代码不写该字段,缺失用导入批次时间并报告 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 新系统更新维护 |
主键 id;索引 `(parent_id, sort_order, id)`;CHECK `parent_id IS NULL OR parent_id <> id`。父分类必须存在,导入按树层级排序;多层循环不能靠上述 CHECK 判断。删除被题型或子分类引用的分类返回冲突。
分类负责数学、语文、手工等内容方向,年级是独立适用维度,不把“一年级数学”“二年级数学”复制为分类,也不把年级放入 categories.parent_id。Web 的“年级 → 分类”菜单是组合查询,不代表数据库父子关系。年级名称及展示顺序先使用固定配置,不新增 grades/菜单表。categories 表结构保持不变。
### 3.3 worksheets — 题型
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------- | -------------- | --------------- | -------------------------------------------------------------------------------------------- |
| id | varchar(128) | 否 | 原 `_id` / 本地 worksheet ID |
| title | varchar(100) | 否 | 保留标题;旧文档的 ≤8 字是展示建议 |
| subtitle | varchar(500) | 否 | 保留副标题;不使用旧预案 desc 字段 |
| category_id / categoryId | varchar(128) | 否 | 原 category;API 仍输出 category |
| subcategory | varchar(100) | 是 | 子类字符串,旧空串可转 NULL;兼容 DTO 可还原空串 |
| path | varchar(1024) | 否 | 完整小程序页面路径及查询参数,保留跳转/分享语义;不使用 legacyPage 替代 |
| generation_mode / generationMode | varchar(16) | 否 / procedural | CHECK:procedural 算法生成、resource 完整内容资源驱动 |
| renderer_key / rendererKey | varchar(64) | 是 | 客户端注册的绘制实现标识;多个 worksheet 可共用;旧功能兼容期可空 |
| render_config / renderConfig | jsonb | 否 / {} | 共用绘制配置;顶层 object,服务端校验允许的参数 |
| age_min / ageMin | smallint | 否 | 最小年龄 |
| age_max / ageMax | smallint | 否 | 最大年龄 |
| grade | smallint | 否 / 0 | 旧版单年级兼容值,保留原值;新年级筛选以 worksheet_grades 为准 |
| difficulty | smallint | 否 | CHECK 1~4 |
| preview_asset_id / previewAssetId | varchar(128) | 否 | 七牛资产或经核实的已有 HTTPS 资源;功能入口封面;API 仍为 previewImg,内容预览由资源列表提供 |
| tags | jsonb | 否 / [] | 字符串数组;数据库检查顶层 array,应用校验每个元素为字符串 |
| is_new / isNew | boolean | 否 / false | 旧版新品兼容标记;新业务按 first_published_at 和新品窗口派生 |
| is_hot / isHot | boolean | 否 / false | 功能级人工运营推荐,独立于统计排行榜 |
| sort_order / sortOrder | integer | 否 / 0 | 题型列表排序 |
| downloads | bigint | 否 / 0 | 原始下载计数;迁入原值,不按历史日志条数重算 |
| likes | bigint | 否 / 0 | 原始收藏计数;旧异常先报告,新业务保持事务一致性 |
| downloads_seed / downloadsSeed | bigint | 否 / 0 | 运营展示种子;旧 API downloads_seed 由兼容层映射 |
| likes_seed / likesSeed | bigint | 否 / 0 | 运营展示种子 |
| status | varchar(16) | 否 / draft | CHECK:draft、active、hidden |
| created_at / createdAt | timestamptz(3) | 否 / now() | 首次创建,不随重新发布修改 |
| first_published_at / firstPublishedAt | timestamptz(3) | 是 / 无默认 | 首次公开发布时间;未知旧值保留 NULL,重发及新增资源不重置 |
| content_updated_at / contentUpdatedAt | timestamptz(3) | 是 | 原值保留;缺失时展示回退 createdAt,不伪造一次内容更新 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 任意业务修改,包括计数和状态;与内容更新时间区分 |
约束:`age_min >= 0 AND age_max >= age_min`;`difficulty BETWEEN 1 AND 4`;四种计数均非负;`jsonb_typeof(tags) = 'array'`。非法旧数据先隔离报告,不能直接归零掩盖问题。
首期索引:`(category_id, status, sort_order, updated_at DESC, id)`、`(status, sort_order, updated_at DESC, id)`、`(status, age_min, age_max)`。榜单对 active 候选集按真实值 + seed 排序,先不建全部榜单表达式索引或 tags GIN 索引;后续用真实查询计划决定。
新增绘制配置遵循 [Worksheet 数据库设计说明](./Worksheet数据库设计说明.md)。worksheet.id 是稳定业务标识,renderer_key 选择客户端已注册实现,render_config 指定参数,资源条目 ID 选择具体内容。不增加含义不明的 type,也不新增独立模板/绘制引擎表。
SQL 检查 generation_mode 枚举、renderer_key 非空白(允许 SQL NULL)及 render_config 顶层 object。renderer_key 不唯一;旧记录保持原 ID/path,默认 procedural、NULL renderer_key、空配置。导入时逐项核对生成方式并依据真实代码映射,禁止按 ID 命名猜测。NULL 仅允许已登记的旧 ID/path 兼容映射;新功能发布必须设置已支持的 renderer_key。未知实现提示暂不可用或升级,不能默认生成其他内容。
资源驱动功能通过 worksheet_resources 提供分页内容列表;算法生成可没有资源条目。公共 DTO 只公开经过白名单校验的客户端参数,不能包含代码、服务端密钥或任意签名 URL。旧导出中的 template/generator 等字段先保存原始快照并明确转换,不自动解释为新 render_config。
### 3.4 favorites — 功能与内容当前收藏关系
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------------- | -------------- | ----------- | ------------------------------------------------- |
| id | varchar(128) | 否 | 原 `_id`;新关系生成新 ID |
| user_id / userId | varchar(128) | 否 | users.id 外键 |
| worksheet_id / worksheetId | varchar(128) | 否 | 稳定功能 ID,逻辑关联;内容收藏时由服务端确定归属 |
| worksheet_resource_id / worksheetResourceId | varchar(128) | 是 | NULL 收藏功能,非 NULL 收藏具体内容条目,逻辑关联 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 收藏时间 |
无需 type 或 resource_favorites 表。一条完整内容即使含多张 SVG/参考图,也只产生一条内容收藏,不以 asset_id 为收藏对象。用户可以同时收藏一个功能和它的多份内容,两者不自动转换或联动。
旧 `(user_id, worksheet_id)` 唯一约束替换为两种部分唯一索引:
```sql
CREATE UNIQUE INDEX favorites_user_worksheet_key
ON public.favorites (user_id, worksheet_id)
WHERE worksheet_resource_id IS NULL;
CREATE UNIQUE INDEX favorites_user_resource_key
ON public.favorites (user_id, worksheet_resource_id)
WHERE worksheet_resource_id IS NOT NULL;
```
列表索引保留 `(user_id, created_at DESC, id DESC)`。CHECK:非 NULL 内容 ID 不允许空白;NULL 明确是功能目标,不能用空字符串代表资源。部分唯一索引通过原生 SQL 管理,不能在 Prisma 接入时替换成不带条件的 @@unique 或恢复旧约束。这是当前关系表,取消即删除,不是事件日志。
内容 ID 不建外键以保留失联/下架收藏,查询 LEFT JOIN;服务端添加时校验内容存在、所属功能及两层 active。worksheet_id 从内容查得;客户端若同时传入不同 worksheetId,拒绝请求。已发布或被历史/收藏引用的内容不得重挂另一功能,避免稳定归属改变。资源缺失/隐藏时可返回 resource:null;父功能下架时也不返回未发布功能或资源详情。内容 ID 原值必须保留,不能 SET NULL 转成功能收藏;用户仍可取消。
默认旧列表/数量/单张和批量功能检查仅查 `worksheet_resource_id IS NULL`,旧客户端/CloudBase 桥不能读写或误删内容收藏。新列表与数量显式选择 target=worksheet/resource/all,默认 worksheet;返回 favoriteId、targetType(响应派生)、worksheetId、worksheetResourceId 及对应详情,以便客户端显示资源的独立预览并准确跳转。功能收藏量 likes 仅计功能收藏;新个人全部收藏数量才计两类,不混用旧数量契约。
008 只扩列及调整索引,所有旧记录新列为 NULL,原 ID/时间/worksheetId/likes 不变,不推断原收藏的资源。旧功能重复按最早 createdAt、同时间 id 归并;若未来导入明确内容收藏,按 userId+worksheetResourceId 去重并核对归属。迁移不借去重自动改写历史 likes。
### 3.5 download_logs — 成功保存历史
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------------- | -------------- | ----------- | ------------------------------------------------------------ |
| id | varchar(128) | 否 | 原 `_id` 或新日志 ID |
| user_id / userId | varchar(128) | 否 | users.id 外键 |
| worksheet_id / worksheetId | varchar(128) | 否 | 原题型 ID,逻辑关联 |
| worksheet_resource_id / worksheetResourceId | varchar(128) | 是 | 内容条目逻辑关联;旧历史、算法生成可空;资源驱动新下载需提供 |
| export_id / exportId | varchar(128) | 是 | 同一次成功保存的事件 ID;历史缺失为 SQL NULL |
| source | varchar(16) | 否 / mini | CHECK:mini、legacy、import;新版上报、旧桥、历史迁入 |
| params | jsonb | 是 | 保留历史可选参数快照;当前 add 不写,不新增收集要求 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 原历史时间 / 新服务收到事件的时间 |
UNIQUE `(user_id, export_id)` 使用 PostgreSQL 默认 NULL 语义,允许同用户多条 export_id=NULL;**不使用 NULLS NOT DISTINCT**。空串先转 NULL,避免旧记录冲突。新版 API 必填 exportId,NULL 只允许历史导入。重复非空 exportId 若指向不同题型,隔离处理,不能任意挑一条。
同一 exportId 重试必须核对 worksheetId、worksheetResourceId 一致,不得把另一资源下载吞掉。资源 ID 不设唯一约束,同一内容可多次保存。历史内容缺失、隐藏或功能已下架时不返回未发布详情。
SQL CHECK 限定 NULL exportId 只能出现在 source=import 的历史记录中;其余来源必须提供非空白事件 ID,旧桥负责生成 legacy 事件 ID。
索引 `(user_id, created_at DESC, id DESC)`、`(worksheet_id, created_at DESC, id DESC)`。不同 exportId 的同题型多次保存都是有效事件;禁止 UNIQUE `(user_id, worksheet_id)` 或按秒级时间窗口去重。
### 3.6 page_configs — 页面配置权威源
用途:保存页面选取哪些内容、如何组成栏目以及展示顺序,目前主要承接小程序首页。它迁自已有 CloudBase 集合,不是新增的通用页面搭建系统。worksheets/worksheet_resources 保存功能及内容本身,categories 管分类,page_configs 保存页面编排和组装后的展示快照。
例如首页推荐“动物涂色”和“加减法”,热门栏目选取收藏榜前几项,“练笔入门”专题按运营指定顺序展示,这些结果分别写入 home.featured、home.hot、home.sections。当前 pageContentBuild 负责手动构建,homeAutoRefresh 自动更新 featured/hot,并保留其他手动栏目;客户端通过预拉取或接口一次读取并缓存。home.hot 是展示栏目,不等于 worksheets.is_hot 人工运营标记。
“权威源”指页面编排以数据库配置为准,七牛上的可选静态配置文件是发布副本。快照中的标题、预览等来源于业务表,内容变化后需要刷新快照,不将快照当成题型原始数据。version 标识配置更新,published_version/published_at 标识静态副本发布进度;这里的发布时间与功能/资源 first_published_at 无关。
首期保留首页推荐和专题编排。未来 Web 的“年级 → 分类”普通列表直接组合查询 worksheet_grades 与 categories,无需为每种筛选组合保存页面配置。category/age 列主要保留旧分支兼容,可为空;不把所有页面数据都装入本表。新服务端页面配置 API 尚待实现。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------ | -------------- | ----------- | ------------------------------------------------------------------------- |
| id | varchar(128) | 否 | 首期沿用 current;主键,不额外限制只能一行,其他旧文档需盘点 |
| home | jsonb | 是 | 首页快照,保持 featured / hot / sections / categoryTabs / ageBands 等结构 |
| category | jsonb | 是 | 保留旧分支兼容,当前 home/all 构建会清为 NULL |
| age | jsonb | 是 | 保留旧分支兼容,目前通常 NULL |
| version | bigint | 否 / 0 | 保留旧版本,每次成功提交配置 +1,CHECK 非负 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 原 ISO 字符串转换;API 还原 ISO 字符串 |
| published_version / publishedVersion | bigint | 是 | 可选静态配置文件最后成功发布的版本,缺失为 NULL |
| published_at / publishedAt | timestamptz(3) | 是 | 可选静态文件发布成功时间 |
首期只需主键。JSON 字段非空时顶层必须是 object。数据库快照中的文件字段转为 `{ "assetId": "..." }` 对象,公共 DTO 再解析为原 `previewImg` / icon 等字符串;普通路径、图标字符和文本不转换。所有嵌套路径在迁移映射清单中记录。
```json
{
"featured": [
{
"id": "原题型ID",
"title": "练习纸",
"previewImg": { "assetId": "预览资产ID" },
"path": "/原页面路径?原参数",
"available": true
}
],
"hot": [],
"sections": []
}
```
示例仅展示内部引用方式,导入必须保留完整原结构,不按示例删减字段。API 返回 home/category/age/version/updatedAt;不向普通客户端公开内部资产元数据或发布状态。
### 3.7 settings — 运行配置
用途:保存运行时可调整的业务开关和参数,例如是否启用首页自动刷新;由服务端读取并执行规则,调整已支持的参数通常无需重新发布代码。它保存“是否执行、按什么规则执行”,page_configs 保存最终展示结果,job_runs 保存执行情况。
现有小程序已经使用 CloudBase settings 集合:[首页内容管理页](../../../doodle-mini/miniprogram/supportPages/homeContentManage/homeContentManage.ts) 读取 homeAutoRefresh,调用 [homeTimerControl](../../../doodle-mini/cloudfunctions/homeTimerControl/index.js) 修改 enabled,[homeAutoRefresh](../../../doodle-mini/cloudfunctions/homeAutoRefresh/index.js) 执行前检查开关。当前尚未接入新 PostgreSQL 表;迁移后管理页面通过受管理员权限保护的 API 读写,客户端不再直连数据库。
首期只承接 homeAutoRefresh 等已核实的现有配置;刷新计划、新品时间窗口等确需动态管理时再增加。value 虽然是 JSON,每个配置仍须定义明确格式、默认值和校验规则;version 用于识别修改并支持并发更新,updated_at 记录最近修改。固定规则继续放代码,数据库连接、微信/七牛/JWT 密钥放受控环境变量,不放入本表,也不将运行配置整体公开给客户端。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ---------------------- | -------------- | ----------- | ------------------------------------------------------- |
| id | varchar(128) | 否 | 原固定 ID,例如 homeAutoRefresh |
| value | jsonb | 否 | 原文档除 `_id` 和 updatedAt 外的业务字段;必须为 object |
| version | bigint | 否 / 0 | 新增配置并发版本,CHECK 非负 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 原值优先,缺失采用批次时间 |
仅主键。`homeAutoRefresh` 迁入示例为 `{ "enabled": false }`,不把 false 覆盖成默认 true。缺文档时业务默认 enabled=true;S1 初始化时创建该设置行,不在写事务内临时缺行加锁。
新计划可在 value 中加入 `timezone: "Asia/Shanghai"` 和 schedule;具体执行时刻必须先核对旧控制台时区和执行记录。`GET/PATCH /admin/home/auto-refresh` 由服务映射为原 enabled 形状,未知线上设置逐项盘点,不仅迁移这一行。
## 4. 新增辅助表
### 4.1 assets — 文件引用与迁移映射
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------- | -------------- | ------------ | ---------------------------------------------------------------------------------------- |
| id | varchar(128) | 否 | 稳定资产 ID |
| source_ref / sourceRef | text | 是 | 原 cloud:// fileID 或已核实的稳定 HTTPS URL;禁止签名 URL |
| source_ref_hash / sourceRefHash | char(64) | 是 | source_ref 的 SHA-256 十六进制,用于定长唯一索引 |
| provider | varchar(16) | 否 / qiniu | CHECK:qiniu、external;后者用于已有可用 HTTPS 资源 |
| bucket | varchar(128) | 是 | 七牛空间名;qiniu 就绪时必填 |
| object_key / objectKey | text | 是 | 七牛对象 key,区分大小写,使用版本化路径 |
| object_ref_hash / objectRefHash | char(64) | 是 | 对 provider、bucket、objectKey 的规范数组序列化结果计算 SHA-256 |
| external_url / externalUrl | text | 是 | external 时的受控稳定 HTTPS 地址,不支持任意客户端远程抓取 |
| purpose | varchar(32) | 否 | CHECK:avatar、preview、category_icon、font、share、page_config、worksheet_source、other |
| owner_user_id / ownerUserId | varchar(128) | 是 | 用户头像归属;公共运营资产可空 |
| visibility | varchar(16) | 否 | CHECK:public、private;头像使用 private |
| mime_type / mimeType | varchar(128) | 是 | 识别的媒体类型 |
| size_bytes / sizeBytes | bigint | 是 | 文件大小,非负 |
| checksum | char(64) | 是 | 文件内容 SHA-256;不能直接以对象 ETag 当 SHA-256 |
| status | varchar(16) | 否 / pending | CHECK:pending、ready、failed、deleting、deleted |
| last_error / lastError | text | 是 | 经清理的迁移/删除失败原因 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 创建映射或上传时间 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 状态变化时间 |
UNIQUE source_ref_hash、UNIQUE object_ref_hash(可空);索引 `(owner_user_id, purpose, status)`、`(status, updated_at)`。hash 命中时仍核对完整源/对象引用,避免错误归并;checksum 不唯一,同内容可有不同归属和访问策略。
qiniu ready 必须具备 bucket、objectKey、objectRefHash、mimeType、sizeBytes 和 checksum;external ready 必须有经核实的 externalUrl,无法取得内容摘要时在报告注明。源文件下载、上传与校验完成后才 ready,再写业务外键。DB 存引用,访问域名从服务配置取;私有签名 URL 在响应时生成,不能写回数据库或页面配置。
SQL 检查 ready 必填字段、external 的 HTTPS 前缀、avatar 必须 private,以及摘要/hash 为 64 位小写十六进制。摘要计算和规范化由服务端完成;HTTPS 前缀检查不代替域名白名单或远程文件校验。
上传和对象存储操作不放在长数据库事务中;先完成上传并登记资产,再短事务绑定业务记录。未绑定文件由清理流程回收,清理前检查 users、worksheets、worksheet_resources、worksheet_resource_assets、categories 和 page_configs 的所有引用。旧桥复制云文件后也必须遵循同一路径。
### 4.2 job_runs — 调度与执行结果
用途:记录后台任务的某次逻辑执行是否发生、是否成功、耗时、结果、失败原因及重试次数,目前主要为首页自动刷新和可选静态配置发布准备。这是新服务端辅助表,原小程序没有对应的业务集合。
例如首页刷新的一次执行按任务名和 run_key 定位,记录开始/结束时间、succeeded 或 failed、更新后的页面配置版本及推荐条数;失败时记录清理后的错误摘要。settings 决定开关与计划,服务端调度器触发任务,job_runs 记录执行过程,page_configs 保存成功产出的配置。
唯一执行标识配合服务端锁与状态检查,防止同次任务重复执行;执行状态、attempt 和租约为失败重试、服务重启后的遗漏补跑提供依据。表本身不会触发任务,唯一约束也不保证业务只执行一次,定时触发、领取、重试及补跑均由后续服务端代码实现。首期保留轻量执行记录,不因此引入队列、独立调度平台或通用任务管理后台;查询、普通收藏和下载不写本表。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ---------------------------- | -------------- | ------------ | ----------------------------------------------------------------- |
| id | varchar(128) | 否 | 执行记录 ID |
| job_name / jobName | varchar(64) | 否 | 如 home_auto_refresh、page_config_publish |
| run_key / runKey | varchar(128) | 否 | 每个逻辑运行唯一:定时按上海业务日;手动按请求 ID;发布按目标版本 |
| trigger_type / triggerType | varchar(16) | 否 | CHECK:scheduled、manual、recovery |
| status | varchar(16) | 否 / pending | CHECK:pending、running、succeeded、failed、skipped |
| scheduled_at / scheduledAt | timestamptz(3) | 是 | 计划执行时刻,存 UTC |
| started_at / startedAt | timestamptz(3) | 是 | 实际开始时间 |
| finished_at / finishedAt | timestamptz(3) | 是 | 完成/失败/跳过时间 |
| attempt | integer | 否 / 0 | 领取尝试次数,非负 |
| lock_token / lockToken | varchar(128) | 是 | 每次领取新 token,避免过期执行者提交 |
| locked_until / lockedUntil | timestamptz(3) | 是 | 租约截止时间 |
| result | jsonb | 是 | 版本、数量、跳过理由等结构化摘要 |
| error_message / errorMessage | text | 是 | 清理敏感信息后的错误摘要 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 建立记录时间 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 最近状态修改 |
UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, created_at DESC)`。一日任务逻辑运行仅一条记录,失败恢复沿用同一记录并增加 attempt,不靠插入新记录绕过唯一键。
记录唯一键解决逻辑重复,不能代替内容写锁。领取使用条件更新或行锁;提交结果必须核对 lockToken 和租约。失败不标 succeeded;禁用记录 skipped;重启后补跑当日已到计划时间、未成功的任务,关闭开关时不补跑。数据库事务内只写数据,不等待上传。
### 4.3 admin_audit_logs — 管理审计
用途:追溯重要业务修改“谁在什么时候修改了什么、修改前后是什么、为什么修改”。例如管理员将“小猫涂色”下架,或将功能 downloads 从 100 修订为 500,记录操作者、对象、前后值、原因、时间及 requestId。普通应用日志排查程序运行问题,审计记录保留已经提交的关键业务变更;本表是新服务端辅助表,不是小程序原有集合。
必要性:它不是迁移现有功能的硬性前置条件;如果只有单人管理且没有关键修订,也可推迟。当前计划支持计数修订、内容上下架和权限管理,因此保留轻量审计更合适。成本不仅是一张表,还包括相关服务方法的审计写入,首期不追求覆盖所有操作,也不开发独立审计查询后台。
首期范围:人工修订真实计数/seed;功能和资源发布、上下架及关键内容维护(含适用年级/分类调整);首页配置及重要运行开关修改;管理员授权、降权和账户禁用。正常页面查询、用户收藏、下载和每次定时刷新不写管理审计;普通运行错误写应用日志,定时执行过程写 job_runs。已有一致性规则明确要求的系统计数异常仍可记录 actor_type=system,不能把全部普通用户操作转成审计事件。
纳入范围的成功管理修改和审计同事务提交,避免数据修改成功却缺少记录。审计不是自动撤销或完整版本历史;恢复修改仍需业务逻辑和校验,不增加通用回滚平台。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ---------------------------- | -------------- | ----------- | ------------------------------------------------- |
| id | varchar(128) | 否 | 审计 ID |
| actor_user_id / actorUserId | varchar(128) | 是 | 操作时的管理员 ID,逻辑关联;注销时去关联 |
| actor_type / actorType | varchar(16) | 否 | CHECK:admin、system、migration |
| action | varchar(64) | 否 | 发布、状态变更、计数修订、分类更新、开关等动作 |
| resource_type / resourceType | varchar(64) | 否 | worksheets、categories、page_configs、settings 等 |
| resource_id / resourceId | varchar(128) | 是 | 目标主键;批量操作按每个实际修改项分别记录 |
| request_id / requestId | varchar(128) | 否 | 用于聚合一次批量操作与排障 |
| before_data / beforeData | jsonb | 是 | 变更前的必要字段,尤其真实/seed 计数 |
| after_data / afterData | jsonb | 是 | 变更后字段 |
| reason | text | 是 | 管理修订说明;显式统计修订必须提供 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 操作提交时间 |
索引 `(resource_type, resource_id, created_at DESC)`、`(actor_user_id, created_at DESC)`、request_id。纳入审计范围的已提交管理变更与审计在同一事务;dryRun 不写业务表,可记录校验日志;失败请求写应用错误日志,不制造成功审计。快照不得包含 token、AppSecret、session_key、登录 code、签名 URL 或不必要个人资料。
### 4.4 worksheet_resources — 完整可选择内容
一条记录表示“小猫涂色”等完整内容,既不是单个七牛文件,也不是通用模板。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------- | -------------- | ----------- | ------------------------------------------- |
| id | varchar(128) | 否 | 内容条目 ID,分享、选择及下载记录使用 |
| worksheet_id / worksheetId | varchar(128) | 否 | worksheets.id 外键 |
| title | varchar(100) | 否 | 内容标题 |
| preview_asset_id / previewAssetId | varchar(128) | 否 | assets.id 外键;内容列表预览图 |
| config_override / configOverride | jsonb | 是 | 少量允许参数的覆盖;SQL NULL 表示无覆盖 |
| status | varchar(16) | 否 / draft | CHECK:draft、active、hidden |
| sort_order / sortOrder | integer | 否 / 0 | 内容展示顺序 |
| downloads | bigint | 否 / 0 | 内容累计成功保存次数,不含功能旧计数或 seed |
| first_published_at / firstPublishedAt | timestamptz(3) | 是 / 无默认 | 内容首次公开发布时间,草稿不写,重发不重置 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 创建时间 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 业务修改时间(含计数),由服务维护 |
CHECK:downloads >= 0;config_override 为 SQL NULL 或顶层 object,不接受 JSON null、数组或标量。索引 `(worksheet_id, status, sort_order, id)`、`(preview_asset_id)`。预览必须有资产引用;草稿可引用 pending,发布须通过 ready/类型/访问策略校验。
共用默认值 → worksheet.render_config → 内容 config_override → 允许的用户参数,按该 renderer 定义的参数规则处理;首期采用明确的顶层参数覆盖,不做任意深度合并。内容不能覆盖 renderer_key、generation_mode 或资产引用。公共列表只返回功能和内容均 active 的项目。
### 4.5 worksheet_resource_assets — 内容使用的文件
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------------- | ------------ | ----------- | ------------------------------------------ |
| id | varchar(128) | 否 | 文件关联 ID |
| worksheet_resource_id / worksheetResourceId | varchar(128) | 否 | worksheet_resources.id 外键 |
| asset_id / assetId | varchar(128) | 否 | assets.id 外键 |
| role | varchar(16) | 否 | 首期 CHECK:outline 线框、reference 对照图 |
| sort_order / sortOrder | integer | 否 / 0 | 同用途文件的处理顺序,并列按 id |
UNIQUE `(worksheet_resource_id, role, asset_id)`,防止同内容同用途重复引用。同一资产可被不同内容使用,也可同时充当不同用途。索引 `(worksheet_resource_id, role, sort_order, id)` 和 `(asset_id)`;支持多个同用途文件,无单文件限制。
不设文件级 config,不将 sort_order 当通用 z-index。布局、数量限制、SVG/图片/JSON 类型要求、是否展示或打印由 renderer 决定。首期不开放 background/overlay 等用途,实际需要时同步扩展 SQL 与客户端协议。基础素材新增 assets.purpose=worksheet_source;参考图可复用 preview 资产。purpose 表达资产管理用途,role 表达内容里的使用方式,两者不同。
线框与参考图属于一份内容,默认共同服务这份内容;不把文件关联列表理解为随机候选池。内容列表预览、绘制参考图、功能封面彼此独立,可引用同一资产。发布时服务端验证所需文件数量、ready 状态、类型和访问策略;隐藏/删除资产前验证仍被引用,不靠外键检查软删除状态。
### 4.6 worksheet_grades — 功能适用年级
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| -------------------------- | ------------ | ------------- | ----------------------------------------------------------------------- |
| worksheet_id / worksheetId | varchar(128) | 否 | worksheets.id 外键 |
| grade | smallint | 否 / 无默认值 | 固定编码:-4 托班、-3 小班、-2 中班、-1 大班、0 幼小衔接、1~6 小学年级 |
联合主键 `(worksheet_id, grade)` 防止重复;CHECK `grade BETWEEN -4 AND 6`;反向索引 `(grade, worksheet_id)`。外键更新/删除 RESTRICT,延续题型只上下架的策略。允许同一题型关联多个、不连续年级;不生成全年级默认关系。扩展年级体系时同步修改 SQL、客户端字典及接口校验。
无关联表示适用年级尚未配置,不表示全年级:普通无年级筛选列表仍可显示 active 题型,指定年级时只返回有该关系的 active 题型。grade=0 明确表示幼小衔接,不表示未知或通用。真正全年级适用的内容应显式关联适用年级。
新服务端支持 grade(单选)或 grades(多选,任一年级匹配)及 categoryId 组合筛选。用 EXISTS 半连接避免跨年级查询重复题型,分页和总数使用同一条件;年级筛选不能绕过发布状态。分类父级是否含子级需遵循分类接口规则,不用分类父子结构解释年级。
```sql
-- 示例:一年级数学;正式 API 使用参数化查询,不拼接用户输入。
SELECT w.id, w.title
FROM public.worksheets AS w
WHERE w.status = 'active'
AND w.category_id = 'math'
AND EXISTS (
SELECT 1
FROM public.worksheet_grades AS wg
WHERE wg.worksheet_id = w.id AND wg.grade = 1
)
ORDER BY w.sort_order, w.updated_at DESC, w.id;
```
年级范围由管理员依据内容核实,不能从 age_min/age_max 自动推算。改年级时锁定 worksheet 行,在一个事务内替换关系、更新内容/业务时间并写管理审计。worksheet_grades 是唯一权威来源;旧 worksheets.grade 冻结为兼容原值,不与新关系双向同步,不由客户端分别更新两套字段。新建题型的旧列默认 0 仅为旧结构兼容,不能据此自动添加幼小衔接关系。新 DTO 返回 grades 数组,旧 DTO 如需 grade 则保留兼容字段,不宣称其等于完整适用范围;旧管理请求带单值 grade 时需经明确适配流程,不能无提示替换已维护的多个年级。
006 仅创建空关系表,不批量 INSERT SELECT worksheets.grade。旧值是候选:部分由年龄中心推算、0 也可能是兜底,需审核每个题型的真实范围,生成显式 worksheetId → grades 映射;越界值先报告,不裁剪或归零。审核后与题型导入同批写关系并对账,使用联合主键实现幂等。默认缺少关系的旧功能可继续出现在无年级筛选列表,按年级页面上线前须完成相关内容映射。
资源条目首期继承所属 worksheet 的适用年级,不增加资源年级表。若一个功能里混有不同年级内容,当前关系只能筛选功能,不能保证每个子资源适用于所选年级;此类内容按年级页面上线前需明确划分功能或另行设计资源级筛选。
## 5. 一致性与事务规则
### 5.1 登录与注销
登录使用服务端配置的单一小程序 AppID,按微信返回的可信 `openid` 查找用户,沿用老 ID。首次并发登录以唯一约束兜底,冲突后读取已创建用户。首期 JWT 使用 userId + authVersion;每次鉴权核对用户存在、active 和版本,role 以当前服务端数据为准。
`auth_version` 是账户级登录凭证版本,默认 1。JWT 签发时写入相同的 `authVersion`,每次访问受保护接口时与数据库当前值比较;不一致返回 401,要求重新登录。需要强制全部设备退出或撤销已签发凭证时,由业务服务在事务中递增版本;普通登录和资料更新不递增,避免其他设备无故退出。它撤销的是该用户所有旧版本 token,不用于单设备退出。账户禁用及角色调整仍以数据库当前 status/role 校验,版本不能替代权限检查。现有守卫已实现版本比较,真实 AccountReader 与主动递增业务尚待接入。
涉及用户的写事务先锁用户行并核对状态,再锁题型行、需要时锁内容行;多个题型及同层内容按 ID 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。
清除资料:昵称与头像引用置 NULL,保留用户、收藏和历史;解除引用后清理用户私有文件。注销:锁用户 → 只对功能收藏(worksheet_resource_id IS NULL)涉及的现存题型按 ID 加锁并减少 likes → 删除功能及内容两种收藏、下载日志 → 解除头像及其他私有资产关联,标记清理 → 清理身份审计关联 → 删除用户,全部数据库修改一事务。用户不存在时旧 token 立即无效。注销后再次微信登录创建全新 userId,不复用已删除账户,不恢复旧记录。
功能和资源下载计数都是累计成功保存次数,注销删除个人日志时不回减任一层 downloads。likes 仅表示当前功能收藏数量,注销需要扣除当前关系的贡献;若旧计数小于应扣数量,扣减保护为 0,并在同事务记录系统审计异常,不能得到负值,也不能因此阻止注销。
对象清理在事务提交后重试,失败状态可查询。数据库备份有滚动保留期限,恢复时须重新落实备份之后的注销删除;不能用恢复或旧导入重新激活已注销关系。具体备份保留期和清理任务在部署文档落实。
### 5.2 功能和内容收藏
功能添加:锁用户 → 锁题型并校验 active → 按功能部分唯一索引插入,只有实际插入时 likes+1、更新功能 updatedAt;重复添加返回已有关系,不加数。
```sql
-- 部分唯一索引推断必须带对应谓词,旧版无 WHERE 的 ON CONFLICT 需改造。
INSERT INTO public.favorites (id, user_id, worksheet_id)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, worksheet_id)
WHERE worksheet_resource_id IS NULL
DO NOTHING RETURNING id;
```
内容添加:读取稳定资源归属 → 锁用户 → 锁父功能 → 锁内容并重新核对归属及两层 active → 按内容部分唯一索引插入;不增减功能 likes,不自动收藏功能,也不新增资源 likes。用户写入只操作当前身份,不接受客户端 userId。
```sql
INSERT INTO public.favorites (id, user_id, worksheet_id, worksheet_resource_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, worksheet_resource_id)
WHERE worksheet_resource_id IS NOT NULL
DO NOTHING RETURNING id;
```
取消以当前 userId+favoriteId 或显式目标定位;旧按 worksheetId 取消时必须加 IS NULL,内容取消按 worksheetResourceId 精确定位,不能只用 worksheetId 删除全部记录。功能关系 DELETE 真正发生且题型仍存在才减少 likes;内容取消不修改统计。缺失或下架目标不阻止取消,重复取消不扣数。旧 likes 已为 0 时保护为 0 并记录异常;所有真实计数变更与关系删除在同事务。
沿用 `PUT/DELETE /me/favorites/:worksheetId` 为功能收藏契约。新增 `PUT/DELETE /me/favorites/resources/:worksheetResourceId` 为内容收藏契约,不依赖查询参数猜测目标;统一列表/数量 target 显式筛选。功能 check 接口只检查功能关系,内容 check 使用资源 ID 列表及独立目标声明。实际路由及 DTO 在服务端开发时落实,本次没有实现这些 API。
### 5.3 成功保存与 exportId
新版只在相册保存成功后生成/复用 exportId 上报。事务先锁用户、查已有 `(userId, exportId)`:worksheetId 和 worksheetResourceId(含 NULL)均相同才返回既有记录,任一不同返回冲突。尚不存在时依次锁题型、资源条目,校验 active 及资源归属。资源驱动新事件必须有内容 ID;算法生成和旧协议缺少可信内容归属时可空。只有日志 INSERT 真正成功后才递增 worksheets.downloads;有可信内容 ID 时,同事务递增对应 worksheet_resources.downloads,并维护两者 updated_at。任何一步失败全部回滚;唯一键防并发重试。已有事件在功能或内容随后下架时仍可幂等返回,不再次加数。下载计数不更新内容时间和首次发布时间。
`ON CONFLICT DO NOTHING RETURNING` 未插入时不能加计数,需在后续查询读取已有结果并核对题型和内容 ID。死锁或序列化冲突采用有界重试,始终复用相同 exportId,禁止事务失败后单独补加计数。
旧桥未收到 exportId 时为本次接收请求生成带 legacy 前缀的 ID,内部转发重试复用;不同旧请求生成不同 ID,承认无法精确识别独立旧请求间的同一次导出。历史导入的 NULL exportId 保持 NULL,不给每条旧日志伪造客户端事件 ID。
### 5.4 题型发布与计数
常规发布只更新内容列、contentUpdatedAt 和 updatedAt;createdAt、真实计数与 seed 保留。新建题型和内容真实计数为 0,运营种子只保留在功能层,不能分摊到资源。修改年龄、标签等内容需更新内容时间;纯计数或上下架只更新 updatedAt。
真实计数/seed 修订使用明确管理动作,锁题型行、记录前后值和 reason、同事务写审计。批量操作每项独立事务,保留逐项结果;dryRun 做同样校验且不写入。不将整批部分失败伪装为完全成功。
展示 likes/downloads = 原始值 + 对应 seed;管理 rawStats 不加 seed。保持现有首页 Top5、分龄 Top3 下载 + Top3 收藏再全量下载补齐的种子参与规则。分龄严格采用 `ageMin == 下限 AND ageMax >= 上限`;分龄并列沿用 updatedAt DESC,再以 id 稳定排序。首页并列新增稳定 id 排序,并在固定样本验收中记录差异。
### 5.4.1 新品、运营推荐与两层下载统计
worksheets 表示功能,worksheet_resources 表示可选内容。新增“小猫”只影响该内容的新鲜度,不重置“动物涂色”功能的首次发布时间。is_hot 保留为功能级人工运营推荐;功能热门排名与资源热门排名分别按各自统计规则计算,不以该布尔值作为排行榜缓存。首期资源不新增 is_new/is_hot、likes 或 seed;需要资源级人工推荐时再单独设计。
新接口的 isNew 从各自 first_published_at 派生:功能/内容可公开,首次发布时间非 NULL、不晚于当前时间且位于配置的新品窗口内才为 true。窗口由服务端统一配置,不在 SQL 里定死天数;SQL 不添加随当前时间变化的 CHECK 或自动过期触发器。未知发布时间为 NULL 时,新接口不标新品;旧功能兼容 DTO 在未知时间时可暂用原 is_new,作为迁移例外逐步停用,不据此生成发布时间。首次发布时间已有可信值时,兼容 DTO 也使用派生结果;资源新 DTO 始终派生。
新功能真实首次公开发布时,在发布事务写入 first_published_at;资源首次发布必须父功能 active,内容也在同一提交后可访问。草稿创建/修改不写,新增资源、配置修改、上下架后重发不重置首次时间。旧 active/hidden 数据发布时间未知时仍保留 NULL,不能在重新上架时用当前时间伪造新品;只能从可信历史证据补录。数据库允许 NULL 以容纳旧数据,真实发布流程和一次写入语义由服务保证。
功能累计 downloads = 原有累计基数 + 迁移后有效功能下载事件;资源累计 downloads = 可信资源基数(如实际存在)+ 有明确资源归属的有效事件。两者是同一事件不同粒度的投影,不相加。保留旧功能 downloads,不按日志数重算、不平均分配、不要求等于全部资源计数之和;007 新增资源计数从 0 起,不推断历史归属,若今后确有可信资源统计可审核导入。
功能展示仍保留原始计数 + seed 的历史契约;资源展示仅真实 downloads,分页榜单按 downloads DESC、id 稳定排序,不新增 seed 或排名布尔值。小项目先使用现有内容列表索引,在实际查询计划需要时再加发布时间或下载榜单索引。资源排行只显示功能和内容均 active 的条目。
管理修订资源 downloads 必须锁父功能再锁内容、记录理由和前后值,不自动修订功能总量;修订功能总量同样不分摊资源,因为历史基数不同。真实下载正常写入必须同时维护对应两层计数;首次发布、参数和内容编辑及统计修订的时间字段分别维护。当前仅落实数据库与契约,原小程序 isNew/isHot 和业务事务尚待 API/客户端改造。
### 5.5 首页配置与自动任务
首次初始化 current 行和 homeAutoRefresh 设置行。手动构建、状态引起的重建、自动刷新都使用同一内容服务:按固定顺序锁 homeAutoRefresh 设置行 → 锁 current 行 → 基于最新快照校验/合并 → version+1 → 写配置、任务结果和必要审计 → 提交。管理开关也锁同一设置行,确保事务边界明确。
首期题型量小,可在短事务中完成查询与 JSON 构建;若构建移出事务,提交前比较 expectedVersion,冲突必须重新读取重建,不能覆盖新配置。自动刷新只覆盖 featured/hot,保留 sections 等手动配置。home/all 构建清 category/age,category 分支只重建 category;原调用中空数组被当作“沿用现有配置”的行为由兼容层保留。
公共 API 直接读取 DB 配置。若兼容期仍发布静态 JSON,提交后用 `page_config_publish` 执行记录重试,文件采用按版本不可变对象 key;publishedVersion 只在对应版本上传成功后前移,防止旧发布覆盖新版本。签名 URL 不进入公共静态配置,私有头像也不进入公共页面 JSON。静态发布失败不回滚已经提交的业务配置,保留上一可用文件并明确发布状态。
## 6. CloudBase 导入与审计
### 6.1 字段转换
| 旧集合 / 字段 | PostgreSQL 落点 | 处理 |
| -------------------------------------- | ------------------------------ | ---------------------------------------------------- |
| 7 个核心集合 `_id` | 各表 id | 精确保留大小写和原值 |
| users.openid / unionid | openid / unionid | 确认来自配置的小程序,空 UnionID 转 NULL |
| users.avatarUrl | users.avatar_asset_id + assets | 复制/核实文件,保留 sourceRef 映射;无头像为 NULL |
| users.totalDownloads | total_downloads | 保留原值,统计接口不以它为准 |
| categories.icon | icon / icon_asset_id | 区分字符图标和文件;文件走资产映射 |
| worksheets.category / previewImg | category_id / preview_asset_id | 分类校验;预览转换为稳定资产引用 |
| worksheets.likes_seed / downloads_seed | likes_seed / downloads_seed | 原值保存;缺失才补 0 |
| favorites | favorites | 使用真实集合名,不从旧文档虚构 favorites_log |
| download_logs.exportId | export_id | 有值保留、空串或缺失转 NULL;params 若存在保留 |
| page_configs.home/category/age | 相应 jsonb | 深层遍历全部文件引用;保留字段、数组顺序和 null 语义 |
| page_configs.updatedAt | updated_at | 旧 ISO 时间转为 timestamptz |
| settings.enabled 等业务字段 | settings.value | 保留所有配置,例 enabled=false 原样迁入 |
完整原始导出与逐项转换报告存受控迁移归档,不把整个含身份信息的原文档塞入公开扩展 JSON。未知字段逐项列出保留位置或明确处理结果,不能默默忽略。
### 6.2 顺序与异常
1. 冻结/导出按迁移方案进行,保存全量分页、原主键清单、计数、时间范围及摘要。对 BSON 日期/ISO 日期规范化;非法日期隔离,不用当前时间偷偷代替。
2. 检查字段长度、类型、状态、年龄、难度、计数、分类树及用户唯一性。旧缺失 createdAt/updatedAt 仅对允许补齐的新增审计字段使用固定批次时间;业务原始日期无法确定则明确隔离/评审。
3. 建表并初始化配置锁行;先导用户及分类(父节点优先),再导资产并确认 ready,绑定头像/分类图标,再导题型及经审核的适用年级关系。批次包含内容资源时,依次导内容条目及文件关联,再导收藏、下载、页面配置和设置。旧集合没有具体内容时不伪造资源或文件关联;用户头像先 NULL 后绑定,解决用户与资产双向引用。
4. 收藏/下载缺失用户时隔离归档,不新建已注销用户;缺失题型时保留日志原 ID,LEFT JOIN 返回空详情。题型缺失分类需隔离修复,不伪造分类。
5. 重复 `openid` 不自动合并用户,先报告全部身份与关系;重复收藏按确定规则合并;重复 exportId 同题型按最早记录归并,不同题型隔离。每次合并记录原 ID 和保留 ID。
6. 保留题型原始计数与 seed,报告其与收藏/日志数的基线差异,不在导入过程中“纠正”为条数。事务一致性从切换后的新业务开始建立。
7. dryRun 完成后按主键 upsert;导入期间不触发业务加数、自动刷新或内容上新。试导必须使用隔离数据库;最终导入目标不得混入测试写入。
8. 最终切换后新数据库成为唯一写入源,禁止再次用旧快照 upsert 覆盖;反向恢复必须包括新增、修改和删除,不只追加记录。
每集合满足 `原始数 = 正常导入数 + 合并重复数 + 隔离数`,并提供主键/文件映射与异常原因。辅助表单独对账,不与旧业务总数混算。NULL exportId 的多条历史下载都应正常保留。
## 7. Prisma 与数据库迁移落地
- datasource 使用 `provider = "postgresql"`。具体 PostgreSQL 主版本、Prisma 版本、Node 运行时组合在 S1 样板中验证并锁定,不照抄旧 MySQL Schema 或旧连接参数。
- PostgreSQL 类型映射:字符串 `@db.VarChar(n)`、计数 BigInt `@db.BigInt`、日期 DateTime `@db.Timestamptz(3)`、JSON `@db.JsonB`。SQL 的 now() 负责新建时间;更新由服务显式维护,内容时间不依赖自动 updatedAt 推断。
- PostgreSQL CHECK、标识符列排序规则、DESC 索引和本文事务 SQL 以版本化迁移 SQL 为最终落点;Prisma Schema 同步描述可表达的约束。业务验证不能代替数据库唯一键。
- 当前可执行基线 SQL 位于 `apps/server/prisma/sql`,尚未纳入 Prisma 迁移历史。后续 `schema.prisma`、`migrations/` 与迁移说明同版本提交;已有表的数据库先登记基线,不能重复执行初始建表 DDL。本次不连接生产库。
- 新增列采用可兼容上一 API 版本的步骤,数据回填和收紧 NOT NULL/外键分开验证;应用回滚继续使用新主库。正式环境不使用 db push 随意覆盖结构。
- 原始导出、隔离记录、文件映射、导入报告和恢复校验随迁移批次归档。数据库备份使用 PostgreSQL 工具链,并实际演练恢复;备份目标在服务器之外。
- 首期不分表、不分区、不引入 Redis、队列或全文搜索。限制连接池与导入并发,按 2 核 4 GB 实际联调结果配置,禁止所有应用默认建立无限连接。
## 8. 数据库验收清单
| 场景 | 验收结果 |
| ---------------------------------------- | ------------------------------------------------------------------------------ |
| 功能/资源收藏共存与重复 | 同功能多个资源及功能可同时收藏,分别唯一;仅功能关系改变 likes |
| 旧接口/失联目标/注销 | 旧查删只操作功能;内容缺失不退化目标,可取消;注销删除两类但只扣功能 likes |
| 年级单选/多选及分类组合 | 同功能多、不连续年级;只返回 active,跨年级结果不重复,分页总量一致 |
| 未配置年级 / 旧 grade 兼容 | 无关系不当全年级,0 是幼小衔接;保留旧值但不自动生成可信关系 |
| 功能/资源新品、重发 | 首次发布时间独立;草稿不标新品;新增资源不使旧功能变新;未知旧时间不伪造 |
| 资源下载与幂等/回滚 | 有资源归属时两层同事务递增一次;失败全部回滚;旧基数不分摊,注销不回减 |
| 资源型功能 / 多文件内容 | 同内容可关联多个 outline 和 reference;列表有独立预览,引用及 JSON 约束有效 |
| 旧功能兼容 / renderer 未支持 | 原 ID/path 不变,明确旧映射可用;新功能缺实现不发布,未知实现不错误回退 |
| 下载资源归属 / 内容下架 | 新下载校验资源属于题型及发布状态;旧历史可空或缺失,不泄露未发布内容 |
| 老用户登录 / OpenID 唯一性 | 老 userId 不变;同 OpenID 不重复建用户;大小写不同 ID 不冲突 |
| 并发第一次登录 | 唯一用户,不产生两套关系 |
| 重复/并发收藏、取消、事务中途失败 | 只随真实关系增删计数;失败后关系和计数都回滚 |
| 同 userId + exportId 并发重报 | 仅一条日志、downloads 仅 +1;不同题型冲突可识别 |
| 同题型不同 exportId / 多条 NULL 历史事件 | 有效事件均保留,不错误合并 |
| 重新发布、状态变更、管理员修订 | 累计值不意外清零;创建、首次发布、内容及业务更新时间用途正确;修订有同事务审计 |
| 清资料 / 注销与写入竞争 | 清资料保留关系;注销删除个人关系、旧 token 失效且不新增孤儿;文件失败可重试 |
| 原用户缺失 / 原题型缺失 | 用户孤儿隔离;题型缺失历史保留,列表可读、无未发布详情泄露 |
| 页面分支、seed 和分龄榜单 | 固定样本字段、顺序、筛选、去重、补齐与旧行为一致,新增并列规则显式记录 |
| 自动/手动同时刷新、开关修改、任务重启 | 不覆盖手动栏目、不重复成功运行;旧任务停用;上海业务日正确 |
| 文件和嵌套资源引用 | 七牛对象实际存在、摘要可核对;DB 无临时签名 URL;正式资源不依赖 cloud:// |
| 重复试导与最终导入对账 | 原 ID 稳定;计数不被触发加数;正常/合并/隔离可解释;无测试数据混入 |
| bigint、nullable JSON 与时区 | DTO 可序列化;NULL 不变 0 或 false;日期与配置版本完整保留 |
| 备份恢复 / 新写入后的反向转换 | 最新增改删及注销均落实;不复活已取消收藏或已注销用户 |
S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字段长度与类型、计数异常、文件归属及清单、旧调度时区/执行记录。上述未知项不改变 PostgreSQL 决策;发现不兼容数据时更新转换报告和设计,再实施 DDL。
## 9. SQL 与实际结构最终核查
核查日期:2026-10-14。当前已确认范围为 13 张表、135 个字段;本次核查未新增表或业务字段。
| 核查项目 | 结果 |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| 本文字段表与最新 002 | 13 张表的字段名称、类型、可空性和默认值逐项一致 |
| 最新空库基线与完整旧库升级链 | 001 建隔离库;最新 002/003 与原始旧基线执行 004~008 后的列、约束、索引和注释一致,忽略新增列的物理顺序 |
| 本地 doodle_dev 与最新基线 | 列、约束、索引和注释一致,已执行至 008 |
| 数据及初始化保留 | 完整升级保留旧用户、功能、收藏、下载、计数和标记;重复 003 不覆盖 enabled=false、页面内容或递增版本 |
| 关键 SQL 回归 | 多文件资源、共享资产、JSON/枚举/外键约束、多适用年级、两种收藏部分唯一索引、取消范围及 SQL 下载幂等/回滚示例通过 |
| 编码与标识 | PostgreSQL 18.6、UTF-8、UTC;ID、微信身份、事件和对象引用等标识列使用 C 排序规则 |
临时核查数据库全部清理;开发库只读比对,未写入样本。SQL 无结构差异需修复;已同步过时的 Prisma 目录说明、统一执行路径,修正关系图中的必填封面/可缺失历史关联及资产清理引用范围。
数据库约束通过不等于业务迁移完成。资源归属、当前发布状态/管理员权限、参数白名单、首次发布时间只写一次、动态新品判断、计数与审计事务、任务锁和租约仍由后续服务端实现;Prisma Schema 和迁移历史尚未接入。旧 grade/is_new/total_downloads 等字段继续按前述兼容规则保留,不作为新业务权威来源。