# Doodle Store v3.5 数据库设计 > 数据库:PostgreSQL;ORM:Prisma。数据库选型已按用户确认更新。 > > 状态:目标设计,本地开发数据库已配置;10 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。 > > 更新日期:2026-10-12。 > > 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。 已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md),建库、10 张表及配置初始化已在 PostgreSQL 18.6 临时库执行验证,19 项约束检查通过;临时库已清理,开发库随后已正式执行基线 SQL,创建 10 张表和两条配置记录;未创建 Prisma Schema 或导入线上数据。 本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 10 张表已在本地创建,业务 API 尚未实现。 ## 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 若指向不同题型,隔离处理,不能任意挑一条。 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 — 页面配置权威源 | 数据库列 / 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 在响应时生成,不能写回数据库或页面配置。 SQL 检查 ready 必填字段、external 的 HTTPS 前缀、avatar 必须 private,以及摘要/hash 为 64 位小写十六进制。摘要计算和规范化由服务端完成;HTTPS 前缀检查不代替域名白名单或远程文件校验。 上传和对象存储操作不放在长数据库事务中;先完成上传并登记资产,再短事务绑定业务记录。未绑定文件由清理流程回收,清理前检查 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 同步描述可表达的约束。业务验证不能代替数据库唯一键。 - 当前可执行基线 SQL 位于 `apps/server/prisma/sql`,尚未纳入 Prisma 迁移历史。后续 `schema.prisma`、`migrations/` 与迁移说明同版本提交;已有表的数据库先登记基线,不能重复执行初始建表 DDL。本次不连接生产库。 - 新增列采用可兼容上一 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。