docs: 基本完成初版数据库设计

This commit is contained in:
R524809
2026-10-15 10:09:34 +08:00
parent 63a6993276
commit e6ded00a4c
41 changed files with 2597 additions and 180 deletions
+307 -106
View File
@@ -2,21 +2,21 @@
> 数据库:PostgreSQL;ORM:Prisma。数据库选型已按用户确认更新。
>
> 状态:目标设计,本地开发数据库已配置;10 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。
> 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。
>
> 更新日期:2026-10-12。
> 更新日期:2026-10-14。
>
> 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。
已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md),建库、10 张表及配置初始化已在 PostgreSQL 18.6 临时库执行验证,19 项约束检查通过;临时库已清理,开发库随后已正式执行基线 SQL,创建 10 张表和两条配置记录;未创建 Prisma Schema 或导入线上数据。
已生成 [数据库初始化 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)。本地数据库已配置,本文 10 张表已在本地创建,业务 API 尚未实现。
本地开发使用 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 张辅助表用于文件、调度和审计。
设计优先级:用户本次确认的 PostgreSQL 选型 → 当前迁移方案 → 实际云函数及调用代码 → 历史设计。当前代码有 7 个核心集合;新增 3 张辅助表用于文件、调度和审计,另新增 2 张内容资源表支持多素材绘制及 1 张年级关系表支持跨年级筛选。
| 阅读依据 | 与建模相关的实际行为 |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
@@ -59,10 +59,17 @@ erDiagram
users o|--o{ assets : "所有者外键"
categories o|--o{ categories : "父分类外键"
categories ||--o{ worksheets : "分类外键"
worksheets ||--o{ favorites : "题型逻辑关联"
worksheets ||--o{ download_logs : "题型逻辑关联"
worksheets o|--o{ favorites : "题型逻辑关联,可历史缺失"
worksheets o|--o{ download_logs : "题型逻辑关联,可历史缺失"
assets o|--o{ users : "头像外键"
assets o|--o{ worksheets : "预览外键"
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 : "执行结果逻辑关联"
```
@@ -71,6 +78,8 @@ erDiagram
- `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. 现有业务表
@@ -80,7 +89,6 @@ erDiagram
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| -------------------------------- | -------------- | ----------- | -------------------------------------------------------------------------------- |
| id | varchar(128) | 否 | 原 `users._id`;新账户独立生成 ID |
| mini_app_id / miniAppId | varchar(64) | 否 | 当前微信 AppID;旧数据根据来源环境填入,不由客户端指定 |
| openid | varchar(128) | 否 | 微信服务端返回的可信标识 |
| unionid | varchar(128) | 是 | 空串转 NULL;仅保存身份线索,首期不自动合并账户 |
| nick_name / nickName | varchar(100) | 是 | 用户昵称 |
@@ -93,11 +101,11 @@ erDiagram
| last_active_at / lastActiveAt | timestamptz(3) | 否 / now() | 保留活跃时间;登录更新 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 新系统资料/权限等变更时间;旧数据缺失按导入规则补齐 |
约束与索引:主键 id;UNIQUE `(mini_app_id, openid)`;普通索引 unionid;CHECK `total_downloads >= 0`、`auth_version >= 0`。UnionID 不设唯一约束,避免尚未实施跨应用绑定时阻止两个 AppID 的独立账户;区别于旧预案中的全局唯一 UnionID。
约束与索引:主键 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 — 分类
### 3.2 categories — 内容分类
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| --------------------------- | -------------- | ----------- | ------------------------------------------------------------------ |
@@ -113,73 +121,112 @@ erDiagram
主键 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 替代 |
| age_min / ageMin | smallint | 否 | 最小年龄 |
| age_max / ageMax | smallint | 否 | 最大年龄 |
| grade | smallint | 否 / 0 | 保留负值,如 -4 托班、0 幼小衔接;不使用 unsigned |
| difficulty | smallint | 否 | CHECK 1~4 |
| preview_asset_id / previewAssetId | varchar(128) | 否 | 七牛资产或经核实的已有 HTTPS 资源;API 唯一预览字段仍为 previewImg |
| tags | jsonb | 否 / [] | 字符串数组;数据库检查顶层 array,应用校验每个元素为字符串 |
| is_new / isNew | boolean | 否 / false | 新品标记 |
| 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() | 首次创建,不随重新发布修改 |
| content_updated_at / contentUpdatedAt | timestamptz(3) | 是 | 原值保留;缺失时展示回退 createdAt,不伪造一次内容更新 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 任意业务修改,包括计数和状态;与内容更新时间区分 |
| 数据库列 / 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 索引;后续用真实查询计划决定。
`template`、`generator`、`generatorConfig`、`layoutConfig`、`userConfigurable` 尚非当前云函数核心字段,不设首期必填列。如果导出中存在,保存原始快照并明确转换;服务端生成正式实施时再设计私有配置,禁止通过公共题型 DTO 暴露。
新增绘制配置遵循 [Worksheet 数据库设计说明](./Worksheet数据库设计说明.md)。worksheet.id 是稳定业务标识,renderer_key 选择客户端已注册实现,render_config 指定参数,资源条目 ID 选择具体内容。不增加含义不明的 type,也不新增独立模板/绘制引擎表。
### 3.4 favorites — 当前收藏关系
SQL 检查 generation_mode 枚举、renderer_key 非空白(允许 SQL NULL)及 render_config 顶层 object。renderer_key 不唯一;旧记录保持原 ID/path,默认 procedural、NULL renderer_key、空配置。导入时逐项核对生成方式并依据真实代码映射,禁止按 ID 命名猜测。NULL 仅允许已登记的旧 ID/path 兼容映射;新功能发布必须设置已支持的 renderer_key。未知实现提示暂不可用或升级,不能默认生成其他内容。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| -------------------------- | -------------- | ----------- | ------------------------- |
| id | varchar(128) | 否 | 原 `_id`;新关系生成新 ID |
| user_id / userId | varchar(128) | 否 | users.id 外键 |
| worksheet_id / worksheetId | varchar(128) | 否 | 保留原题型 ID,逻辑关联 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 收藏时间 |
资源驱动功能通过 worksheet_resources 提供分页内容列表;算法生成可没有资源条目。公共 DTO 只公开经过白名单校验的客户端参数,不能包含代码、服务端密钥或任意签名 URL。旧导出中的 template/generator 等字段先保存原始快照并明确转换,不自动解释为新 render_config。
UNIQUE `(user_id, worksheet_id)`;列表索引 `(user_id, created_at DESC, id DESC)`。这是当前关系表,取消即删除,不是收藏事件日志。
### 3.4 favorites — 功能与内容当前收藏关系
旧重复关系选择最早 createdAt 的记录作为保留项,同时间按 id 精确排序;被合并的原 ID 写入报告。迁移不借去重自动改写历史 likes。
| 数据库列 / 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,逻辑关联 |
| 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() | 原历史时间 / 新服务收到事件的时间 |
| 数据库列 / 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;主键,不额外限制只能一行,其他旧文档需盘点 |
@@ -213,6 +260,12 @@ SQL CHECK 限定 NULL exportId 只能出现在 source=import 的历史记录中
### 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 |
@@ -228,26 +281,26 @@ SQL CHECK 限定 NULL exportId 只能出现在 source=import 的历史记录中
### 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、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() | 状态变化时间 |
| 数据库列 / 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 不唯一,同内容可有不同归属和访问策略。
@@ -255,10 +308,16 @@ qiniu ready 必须具备 bucket、objectKey、objectRefHash、mimeType、sizeByt
SQL 检查 ready 必填字段、external 的 HTTPS 前缀、avatar 必须 private,以及摘要/hash 为 64 位小写十六进制。摘要计算和规范化由服务端完成;HTTPS 前缀检查不代替域名白名单或远程文件校验。
上传和对象存储操作不放在长数据库事务中;先完成上传并登记资产,再短事务绑定业务记录。未绑定文件由清理流程回收,清理前检查 users、worksheets、categories 和 page_configs 的所有引用。旧桥复制云文件后也必须遵循同一路径。
上传和对象存储操作不放在长数据库事务中;先完成上传并登记资产,再短事务绑定业务记录。未绑定文件由清理流程回收,清理前检查 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 |
@@ -283,6 +342,14 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre
### 4.3 admin_audit_logs — 管理审计
用途:追溯重要业务修改“谁在什么时候修改了什么、修改前后是什么、为什么修改”。例如管理员将“小猫涂色”下架,或将功能 downloads 从 100 修订为 500,记录操作者、对象、前后值、原因、时间及 requestId。普通应用日志排查程序运行问题,审计记录保留已经提交的关键业务变更;本表是新服务端辅助表,不是小程序原有集合。
必要性:它不是迁移现有功能的硬性前置条件;如果只有单人管理且没有关键修订,也可推迟。当前计划支持计数修订、内容上下架和权限管理,因此保留轻量审计更合适。成本不仅是一张表,还包括相关服务方法的审计写入,首期不追求覆盖所有操作,也不开发独立审计查询后台。
首期范围:人工修订真实计数/seed;功能和资源发布、上下架及关键内容维护(含适用年级/分类调整);首页配置及重要运行开关修改;管理员授权、降权和账户禁用。正常页面查询、用户收藏、下载和每次定时刷新不写管理审计;普通运行错误写应用日志,定时执行过程写 job_runs。已有一致性规则明确要求的系统计数异常仍可记录 actor_type=system,不能把全部普通用户操作转成审计事件。
纳入范围的成功管理修改和审计同事务提交,避免数据修改成功却缺少记录。审计不是自动撤销或完整版本历史;恢复修改仍需业务逻辑和校验,不增加通用回滚平台。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ---------------------------- | -------------- | ----------- | ------------------------------------------------- |
| id | varchar(128) | 否 | 审计 ID |
@@ -297,44 +364,152 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre
| 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 或不必要个人资料。
索引 `(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 登录与注销
登录用可信 `(miniAppId, openid)` 查找用户,沿用老 ID。首次并发登录以唯一约束兜底,冲突后读取已创建用户。首期 JWT 使用 userId + authVersion;每次鉴权核对用户存在、active 和版本,role 以当前服务端数据为准。
登录使用服务端配置的单一小程序 AppID,按微信返回的可信 `openid` 查找用户,沿用老 ID。首次并发登录以唯一约束兜底,冲突后读取已创建用户。首期 JWT 使用 userId + authVersion;每次鉴权核对用户存在、active 和版本,role 以当前服务端数据为准。
涉及用户的写事务先锁用户行并核对状态,再锁题型行;多个题型按 ID 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。
`auth_version` 是账户级登录凭证版本,默认 1。JWT 签发时写入相同的 `authVersion`,每次访问受保护接口时与数据库当前值比较;不一致返回 401,要求重新登录。需要强制全部设备退出或撤销已签发凭证时,由业务服务在事务中递增版本;普通登录和资料更新不递增,避免其他设备无故退出。它撤销的是该用户所有旧版本 token,不用于单设备退出。账户禁用及角色调整仍以数据库当前 status/role 校验,版本不能替代权限检查。现有守卫已实现版本比较,真实 AccountReader 与主动递增业务尚待接入。
清除资料:昵称与头像引用置 NULL,保留用户、收藏和历史;解除引用后清理用户私有文件。注销:锁用户 → 对所有收藏涉及的现存题型按 ID 加锁并减少 likes → 删除收藏、下载日志 → 解除头像及其他私有资产关联,标记清理 → 清理身份审计关联 → 删除用户,全部数据库修改一事务。用户不存在时旧 token 立即无效。注销后再次微信登录创建全新 userId,不复用已删除账户,不恢复旧记录。
涉及用户的写事务先锁用户行并核对状态,再锁题型行、需要时锁内容行;多个题型及同层内容按 ID 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。
历史下载计数是题型累计成功保存次数,注销删除个人日志时不回减 downloads。likes 表示当前收藏数量,注销需要扣除当前关系的贡献;若旧计数小于应扣数量,扣减保护为 0,并在同事务记录系统审计异常,不能得到负值,也不能因此阻止注销。
清除资料:昵称与头像引用置 NULL,保留用户、收藏和历史;解除引用后清理用户私有文件。注销:锁用户 → 只对功能收藏(worksheet_resource_id IS NULL)涉及的现存题型按 ID 加锁并减少 likes → 删除功能及内容两种收藏、下载日志 → 解除头像及其他私有资产关联,标记清理 → 清理身份审计关联 → 删除用户,全部数据库修改一事务。用户不存在时旧 token 立即无效。注销后再次微信登录创建全新 userId,不复用已删除账户,不恢复旧记录。
功能和资源下载计数都是累计成功保存次数,注销删除个人日志时不回减任一层 downloads。likes 仅表示当前功能收藏数量,注销需要扣除当前关系的贡献;若旧计数小于应扣数量,扣减保护为 0,并在同事务记录系统审计异常,不能得到负值,也不能因此阻止注销。
对象清理在事务提交后重试,失败状态可查询。数据库备份有滚动保留期限,恢复时须重新落实备份之后的注销删除;不能用恢复或旧导入重新激活已注销关系。具体备份保留期和清理任务在部署文档落实。
### 5.2 收藏
### 5.2 功能和内容收藏
添加:锁用户、锁题型并校验 active → `INSERT ... ON CONFLICT (user_id, worksheet_id) DO NOTHING RETURNING id` → 仅实际插入时 likes+1、更新 updatedAt → 提交。重复添加返回现有关系,不加数。
功能添加:锁用户 → 锁题型并校验 active → 按功能部分唯一索引插入,只有实际插入时 likes+1、更新功能 updatedAt;重复添加返回已有关系,不加数。
取消:锁用户、锁现存题型 → `DELETE ... RETURNING id` → 仅实际删除且题型存在时减少 likes → 提交。允许取消已下架或历史缺失题型的收藏。旧数据导致 likes 已为 0 时维持 0 并记录异常。重复取消不减数。
```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)`:同题型返回既有记录,题型不同返回冲突。尚不存在时锁题型并校验 active,插入日志,真实 downloads+1,更新 updatedAt 后提交;唯一键同时防并发重试。已有事件在题型随后下架时仍可幂等返回。
新版只在相册保存成功后生成/复用 exportId 上报。事务先锁用户、查已有 `(userId, exportId)`:worksheetId 和 worksheetResourceId(含 NULL)均相同才返回既有记录,任一不同返回冲突。尚不存在时依次锁题型、资源条目,校验 active 及资源归属。资源驱动新事件必须有内容 ID;算法生成和旧协议缺少可信内容归属时可空。只有日志 INSERT 真正成功后才递增 worksheets.downloads;有可信内容 ID 时,同事务递增对应 worksheet_resources.downloads,并维护两者 updated_at。任何一步失败全部回滚;唯一键防并发重试。已有事件在功能或内容随后下架时仍可幂等返回,不再次加数。下载计数不更新内容时间和首次发布时间。
`ON CONFLICT DO NOTHING RETURNING` 未插入时不能加计数,需在后续查询读取已有结果并核对题型。死锁或序列化冲突采用有界重试,始终复用相同 exportId,禁止事务失败后单独补加计数。
`ON CONFLICT DO NOTHING RETURNING` 未插入时不能加计数,需在后续查询读取已有结果并核对题型和内容 ID。死锁或序列化冲突采用有界重试,始终复用相同 exportId,禁止事务失败后单独补加计数。
旧桥未收到 exportId 时为本次接收请求生成带 legacy 前缀的 ID,内部转发重试复用;不同旧请求生成不同 ID,承认无法精确识别独立旧请求间的同一次导出。历史导入的 NULL exportId 保持 NULL,不给每条旧日志伪造客户端事件 ID。
### 5.4 题型发布与计数
常规发布只更新内容列、contentUpdatedAt 和 updatedAt;createdAt、真实计数与 seed 保留。新建题型真实计数为 0,运营种子单独写入。修改年龄、标签等内容需更新内容时间;纯计数或上下架只更新 updatedAt。
常规发布只更新内容列、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 → 写配置、任务结果和必要审计 → 提交。管理开关也锁同一设置行,确保事务边界明确。
@@ -350,7 +525,7 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre
| 旧集合 / 字段 | PostgreSQL 落点 | 处理 |
| -------------------------------------- | ------------------------------ | ---------------------------------------------------- |
| 7 个核心集合 `_id` | 各表 id | 精确保留大小写和原值 |
| users.openid / unionid | openid / unionid | 增补可信 miniAppId,空 UnionID 转 NULL |
| 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 | 区分字符图标和文件;文件走资产映射 |
@@ -368,9 +543,9 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre
1. 冻结/导出按迁移方案进行,保存全量分页、原主键清单、计数、时间范围及摘要。对 BSON 日期/ISO 日期规范化;非法日期隔离,不用当前时间偷偷代替。
2. 检查字段长度、类型、状态、年龄、难度、计数、分类树及用户唯一性。旧缺失 createdAt/updatedAt 仅对允许补齐的新增审计字段使用固定批次时间;业务原始日期无法确定则明确隔离/评审。
3. 建表并初始化配置锁行;先导用户及分类(父节点优先),再导资产并确认 ready,绑定头像/分类图标,再导题型、收藏、下载、页面配置和设置。用户头像先 NULL 后绑定,解决用户与资产双向引用。
3. 建表并初始化配置锁行;先导用户及分类(父节点优先),再导资产并确认 ready,绑定头像/分类图标,再导题型及经审核的适用年级关系。批次包含内容资源时,依次导内容条目及文件关联,再导收藏、下载、页面配置和设置。旧集合没有具体内容时不伪造资源或文件关联;用户头像先 NULL 后绑定,解决用户与资产双向引用。
4. 收藏/下载缺失用户时隔离归档,不新建已注销用户;缺失题型时保留日志原 ID,LEFT JOIN 返回空详情。题型缺失分类需隔离修复,不伪造分类。
5. 重复 `(AppID, OpenID)` 不自动合并用户,先报告全部身份与关系;重复收藏按确定规则合并;重复 exportId 同题型按最早记录归并,不同题型隔离。每次合并记录原 ID 和保留 ID。
5. 重复 `openid` 不自动合并用户,先报告全部身份与关系;重复收藏按确定规则合并;重复 exportId 同题型按最早记录归并,不同题型隔离。每次合并记录原 ID 和保留 ID。
6. 保留题型原始计数与 seed,报告其与收藏/日志数的基线差异,不在导入过程中“纠正”为条数。事务一致性从切换后的新业务开始建立。
7. dryRun 完成后按主键 upsert;导入期间不触发业务加数、自动刷新或内容上新。试导必须使用隔离数据库;最终导入目标不得混入测试写入。
8. 最终切换后新数据库成为唯一写入源,禁止再次用旧快照 upsert 覆盖;反向恢复必须包括新增、修改和删除,不只追加记录。
@@ -389,21 +564,47 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre
## 8. 数据库验收清单
| 场景 | 验收结果 |
| ---------------------------------------- | --------------------------------------------------------------------------- |
| 老用户登录 / 同 OpenID 不同 AppID | 老 userId 不变;跨 AppID 不自动合并;大小写不同 ID 不冲突 |
| 并发第一次登录 | 唯一用户,不产生两套关系 |
| 重复/并发收藏、取消、事务中途失败 | 只随真实关系增删计数;失败后关系和计数都回滚 |
| 同 userId + exportId 并发重报 | 仅一条日志、downloads 仅 +1;不同题型冲突可识别 |
| 同题型不同 exportId / 多条 NULL 历史事件 | 有效事件均保留,不错误合并 |
| 重新发布、状态变更、管理员修订 | 累计值不意外清零;三种时间用途正确;修订有同事务审计 |
| 清资料 / 注销与写入竞争 | 清资料保留关系;注销删除个人关系、旧 token 失效且不新增孤儿;文件失败可重试 |
| 原用户缺失 / 原题型缺失 | 用户孤儿隔离;题型缺失历史保留,列表可读、无未发布详情泄露 |
| 页面分支、seed 和分龄榜单 | 固定样本字段、顺序、筛选、去重、补齐与旧行为一致,新增并列规则显式记录 |
| 自动/手动同时刷新、开关修改、任务重启 | 不覆盖手动栏目、不重复成功运行;旧任务停用;上海业务日正确 |
| 文件和嵌套资源引用 | 七牛对象实际存在、摘要可核对;DB 无临时签名 URL;正式资源不依赖 cloud:// |
| 重复试导与最终导入对账 | 原 ID 稳定;计数不被触发加数;正常/合并/隔离可解释;无测试数据混入 |
| bigint、nullable JSON 与时区 | DTO 可序列化;NULL 不变 0 或 false;日期与配置版本完整保留 |
| 备份恢复 / 新写入后的反向转换 | 最新增改删及注销均落实;不复活已取消收藏或已注销用户 |
| 场景 | 验收结果 |
| ---------------------------------------- | ------------------------------------------------------------------------------ |
| 功能/资源收藏共存与重复 | 同功能多个资源及功能可同时收藏,分别唯一;仅功能关系改变 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 等字段继续按前述兼容规则保留,不作为新业务权威来源。