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

402 lines
47 KiB
Markdown
Raw Permalink 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。数据库选型已按用户确认更新。
>
> 状态:目标设计,尚未创建数据库、Prisma Schema 或业务迁移;线上集合、数据量和异常分布待 S0 盘点。
>
> 更新日期:2026-10-12。
>
> 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。
## 1. 范围与依据
首期迁移现有微信小程序后端,统一使用 PostgreSQL 主数据库、NestJS API 和七牛文件存储。小程序仍在 `doodle-mini` 维护,数据库模型与迁移只放在 `doodle-store/apps/server/prisma`。Web 与管理后台通过 API 访问,不直接连接数据库。
设计优先级:用户本次确认的 PostgreSQL 选型 → 当前迁移方案 → 实际云函数及调用代码 → 历史设计。当前代码有 7 个核心集合;新增 3 张辅助表用于文件、调度和审计。
| 阅读依据 | 与建模相关的实际行为 |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| [旧云数据库设计](../../../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{ favorites : "题型逻辑关联"
worksheets ||--o{ download_logs : "题型逻辑关联"
assets o|--o{ users : "头像外键"
assets o|--o{ worksheets : "预览外键"
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`。对象存储删除通过业务清理重试完成,不由数据库级联触发。
- 页面 JSON 引用、任务/审计中的资源 ID 是逻辑关联。审计操作者不建用户外键,避免注销删除审计记录;用户注销时移除其中的身份关联与个人信息。
## 3. 现有业务表
### 3.1 users — 用户
| 数据库列 / 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) | 是 | 用户昵称 |
| 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 `(mini_app_id, openid)`;普通索引 unionid;CHECK `total_downloads >= 0`、`auth_version >= 0`。UnionID 不设唯一约束,避免尚未实施跨应用绑定时阻止两个 AppID 的独立账户;区别于旧预案中的全局唯一 UnionID。
`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 判断。删除被题型或子分类引用的分类返回冲突。
### 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() | 任意业务修改,包括计数和状态;与内容更新时间区分 |
约束:`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 暴露。
### 3.4 favorites — 当前收藏关系
| 数据库列 / 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() | 收藏时间 |
UNIQUE `(user_id, worksheet_id)`;列表索引 `(user_id, created_at DESC, id DESC)`。这是当前关系表,取消即删除,不是收藏事件日志。
旧重复关系选择最早 createdAt 的记录作为保留项,同时间按 id 精确排序;被合并的原 ID 写入报告。迁移不借去重自动改写历史 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() | 原历史时间 / 新服务收到事件的时间 |
UNIQUE `(user_id, export_id)` 使用 PostgreSQL 默认 NULL 语义,允许同用户多条 export_id=NULL;**不使用 NULLS NOT DISTINCT**。空串先转 NULL,避免旧记录冲突。新版 API 必填 exportId,NULL 只允许历史导入。重复非空 exportId 若指向不同题型,隔离处理,不能任意挑一条。
索引 `(user_id, created_at DESC, id DESC)`、`(worksheet_id, created_at DESC, id DESC)`。不同 exportId 的同题型多次保存都是有效事件;禁止 UNIQUE `(user_id, worksheet_id)` 或按秒级时间窗口去重。
### 3.6 page_configs — 页面配置权威源
| 数据库列 / 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 — 运行配置
| 数据库列 / 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、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 在响应时生成,不能写回数据库或页面配置。
上传和对象存储操作不放在长数据库事务中;先完成上传并登记资产,再短事务绑定业务记录。未绑定文件由清理流程回收,清理前检查 users、worksheets、categories 和 page_configs 的所有引用。旧桥复制云文件后也必须遵循同一路径。
### 4.2 job_runs — 调度与执行结果
| 数据库列 / 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 — 管理审计
| 数据库列 / 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 或不必要个人资料。
## 5. 一致性与事务规则
### 5.1 登录与注销
登录用可信 `(miniAppId, openid)` 查找用户,沿用老 ID。首次并发登录以唯一约束兜底,冲突后读取已创建用户。首期 JWT 使用 userId + authVersion;每次鉴权核对用户存在、active 和版本,role 以当前服务端数据为准。
涉及用户的写事务先锁用户行并核对状态,再锁题型行;多个题型按 ID 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。
清除资料:昵称与头像引用置 NULL,保留用户、收藏和历史;解除引用后清理用户私有文件。注销:锁用户 → 对所有收藏涉及的现存题型按 ID 加锁并减少 likes → 删除收藏、下载日志 → 解除头像及其他私有资产关联,标记清理 → 清理身份审计关联 → 删除用户,全部数据库修改一事务。用户不存在时旧 token 立即无效。注销后再次微信登录创建全新 userId,不复用已删除账户,不恢复旧记录。
历史下载计数是题型累计成功保存次数,注销删除个人日志时不回减 downloads。likes 表示当前收藏数量,注销需要扣除当前关系的贡献;若旧计数小于应扣数量,扣减保护为 0,并在同事务记录系统审计异常,不能得到负值,也不能因此阻止注销。
对象清理在事务提交后重试,失败状态可查询。数据库备份有滚动保留期限,恢复时须重新落实备份之后的注销删除;不能用恢复或旧导入重新激活已注销关系。具体备份保留期和清理任务在部署文档落实。
### 5.2 收藏
添加:锁用户、锁题型并校验 active → `INSERT ... ON CONFLICT (user_id, worksheet_id) DO NOTHING RETURNING id` → 仅实际插入时 likes+1、更新 updatedAt → 提交。重复添加返回现有关系,不加数。
取消:锁用户、锁现存题型 → `DELETE ... RETURNING id` → 仅实际删除且题型存在时减少 likes → 提交。允许取消已下架或历史缺失题型的收藏。旧数据导致 likes 已为 0 时维持 0 并记录异常。重复取消不减数。
### 5.3 成功保存与 exportId
新版只在相册保存成功后生成/复用 exportId 上报。事务先锁用户、查已有 `(userId, exportId)`:同题型返回既有记录,题型不同返回冲突。尚不存在时锁题型并校验 active,插入日志,真实 downloads+1,更新 updatedAt 后提交;唯一键同时防并发重试。已有事件在题型随后下架时仍可幂等返回。
`ON CONFLICT DO NOTHING RETURNING` 未插入时不能加计数,需在后续查询读取已有结果并核对题型。死锁或序列化冲突采用有界重试,始终复用相同 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.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 | 增补可信 miniAppId,空 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. 重复 `(AppID, 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 同步描述可表达的约束。业务验证不能代替数据库唯一键。
- `apps/server/prisma/schema.prisma`、`migrations/` 与迁移说明同版本提交;本文是实施输入,本次不生成看似已执行的 Schema/迁移文件,也不连接生产库。
- 新增列采用可兼容上一 API 版本的步骤,数据回填和收紧 NOT NULL/外键分开验证;应用回滚继续使用新主库。正式环境不使用 db push 随意覆盖结构。
- 原始导出、隔离记录、文件映射、导入报告和恢复校验随迁移批次归档。数据库备份使用 PostgreSQL 工具链,并实际演练恢复;备份目标在服务器之外。
- 首期不分表、不分区、不引入 Redis、队列或全文搜索。限制连接池与导入并发,按 2 核 4 GB 实际联调结果配置,禁止所有应用默认建立无限连接。
## 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;日期与配置版本完整保留 |
| 备份恢复 / 新写入后的反向转换 | 最新增改删及注销均落实;不复活已取消收藏或已注销用户 |
S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字段长度与类型、计数异常、文件归属及清单、旧调度时区/执行记录。上述未知项不改变 PostgreSQL 决策;发现不兼容数据时更新转换报告和设计,再实施 DDL。