feat: 初始化项目

This commit is contained in:
R524809
2026-10-12 15:22:27 +08:00
commit 3c6fd2c8ca
37 changed files with 4859 additions and 0 deletions
+401
View File
@@ -0,0 +1,401 @@
# 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。
+471
View File
@@ -0,0 +1,471 @@
# Doodle Mini 现有功能迁移到云服务端方案
> 决策:已确认迁移到自有腾讯云服务器;数据库使用 PostgreSQL。
>
> 文档归属:本方案在 `doodle-store/docs` 中维护,作为迁移实施的唯一方案文档;小程序产品规划与历史设计仍保留在 `doodle-mini`。
>
> 工程组织:小程序继续在独立项目 `doodle-mini` 中维护;新项目 `doodle-store` 使用 pnpm workspace 管理服务端、管理后台和 Web,不包含小程序源码。
>
> 实施状态:已完成基础工程初始化;业务功能、数据迁移与上线尚未实施。本文不是迁移完成报告。
>
> 更新日期:2026-10-12。以 `doodle-mini` 当前仓库代码为迁移依据,线上数据量、文件清单及服务器环境需在实施第一步核实。
## 1. 目标与范围
本次将现有小程序的业务后端、数据、文件及定时任务迁出微信 CloudBase,形成 **一套业务 API、一套主数据库、一套文件存储**。后续 Web 直接复用这套后端。
采用轻量模块化单体,不建立微服务、数据库集群或复杂运维平台。AI 辅助接口、脚本、适配和部署配置开发,人工及实际运行验证负责确认行为、数据和切换结果。
### 1.1 本次交付
- 微信静默登录、用户资料、清除资料和注销账户。
- 题型与分类查询、分龄推荐、首页配置、手动及自动推荐刷新。
- 收藏、下载历史、个人数量统计、题型下载/收藏计数。
- 现有题型发布、上下架、分类管理、批量属性同步等管理能力。
- 头像、预览图、云字体和首页预拉取配置的迁移与访问。
- 小程序 HTTP 接入、短期旧版本兼容、数据迁移、部署、备份、验收与回滚。
### 1.2 后续单独实施
- 独立管理后台 `doodle-store/apps/admin` 的完整页面;本次先迁移管理 API,并保留小程序现有管理操作,不以新后台上线作为切换前置条件。
- 完整 Web 网站、SEO 内容页和 Web 登录/账户绑定。
- 将存量 Canvas 题型全部改为服务端生成。
- 创意涂色、批量 PDF、会员、支付、孩子档案和学习包等新功能。
本次迁移后,现有随机出题、Canvas 绘制、相册保存、微信分享、广告解锁及本地下载配额仍按小程序现有方式工作。迁移后端并不会自动隐藏已经随小程序发布的生成代码。未来 Web 的私有生成器另行接入同一后端。
## 2. 已知条件与实施前核实项
| 项目 | 当前已知条件 | 方案处理 |
| ---------- | ----------------------------------------------------- | ----------------------------------------------------------- |
| 云服务器 | 腾讯云,地区可能为华东;2 核、4 GB 内存、60 GB 系统盘 | 作为初期 API 与数据库部署目标;实施前检查现有业务和可用资源 |
| 服务器期限 | 已付费至 2027 年 10 月,计划长期保留 | 不将整台服务器续费全部归于本项目,但记录实际资源占用 |
| 流量 | 每月 500 GB | 用于 API 和经服务器上传的流量;七牛直出文件单独计费 |
| 域名 | 已持有长期域名 | 实施前确认具体 API/资源子域名、备案、DNS 和 HTTPS |
| 文件存储 | 计划使用七牛 | 新文件统一进入七牛;存储、请求和下载费用按实际用量核算 |
| CloudBase | 当前 19.9 元/月 | 过渡期保留;完全退出兼容函数与旧文件依赖后再停止套餐 |
| 后端规模 | 17 个云函数,入口 JS 合计约 1,875 行 | 主要是查询和 CRUD,适合 AI 辅助迁移;行数不等于全部迁移成本 |
实施前应取得:服务器系统/现有服务/可用内存与磁盘/峰值带宽;域名备案和小程序后台配置权限;现有 AppID 对应的 AppSecret;CloudBase 导出与文件访问权限;七牛空间、区域和 HTTPS 资源域名。密钥通过环境变量配置,不写进文档或仓库。
这些信息用于实施,不影响现在确定架构。备案、审核、DNS 和文件传输等待时间与实际开发工时分开记录。
## 3. 目标架构与技术选择
```mermaid
flowchart TD
Mini[新版微信小程序 · doodle-mini] -->|HTTPS| Nginx[Nginx]
Web[后续 Web · doodle-store] -->|HTTPS| Nginx
Admin[后续管理后台 · doodle-store] -->|HTTPS| Nginx
Old[旧版小程序] --> Bridge[过渡期 CloudBase 兼容函数]
Bridge -->|签名请求| Nginx
Nginx --> API[统一业务 API · NestJS]
API --> DB[(PostgreSQL · 唯一主数据库)]
API --> Files[七牛 · 文件存储]
API --> Wx[微信登录接口]
Mini -->|图片和字体访问| Files
Scheduler[应用内定时任务] --> API
Render[后续私有生成模块] -.-> API
```
CloudBase 兼容函数只转发旧协议,不保留独立业务规则;切换后不再写旧数据库。未来 Web 的页面渲染与练习纸绘制使用同一后端数据,生成任务增长后可拆为独立进程,不需要再建一套业务系统。
| 层级 | 首期选择 | 选择理由与限制 |
| ---------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| 服务端 | TypeScript + NestJS,单应用 | 延续仓库技术栈和已有后端预案;按模块组织现有业务 |
| 工程管理 | 独立 `doodle-store` 项目 + pnpm workspace Monorepo | 管理 `apps/server`、后续 `apps/admin` / `apps/web` 和必要共享包;`doodle-mini` 不加入工作区 |
| 数据库 | PostgreSQL + Prisma | 用唯一键和事务保证收藏/下载一致性;版本与 ORM 兼容性在实施时验证 |
| 运行时 | 受维护的 Node.js LTS | 实施时锁定具体版本及依赖,不沿用旧文档的版本号 |
| 文件 | 七牛对象存储,HTTPS 自定义资源域名 | 与数据库分离;数据库保存对象引用,避免保存会过期的签名 URL |
| 部署 | Docker Compose 管理 API 与 PostgreSQL,Nginx 反向代理 | 固定镜像版本、持久化数据库卷;已有 Nginx 可复用,不覆盖其他业务 |
| 定时任务 | 单实例应用内调度 + 数据库执行记录/锁 | 任务少,不引入 Redis/队列;控制重复执行及手动刷新竞争 |
| 日志与监测 | 结构化日志、健康检查、错误与任务失败通知 | 覆盖基本排障,不额外部署大型监控平台 |
2 核 4 GB 可作为起步配置,但承载能力以实际资源检查和联调结果为准。先部署 API 与数据库;服务端绘制的内存/CPU 需求在后续样板中单独测量。数据库不开放公网端口。
### 3.1 两个独立项目的职责
- `doodle-mini`:继续维护现有微信小程序、微信平台适配、原管理页面及过渡期 CloudBase 兼容函数;HTTP 接入改造也在此项目完成。保持现有小程序目录和开发工具配置,不搬入新项目。
- `doodle-store`:独立项目位于 `/Users/joey-xd/sites/doodle/doodle-store`,采用 pnpm workspace Monorepo;`apps/server` 承载统一业务 API,后续 `apps/admin` 和 `apps/web` 分别提供管理后台与用户网站。当前只完成基础工程与健康检查,尚无业务 API。
- 两个项目通过有版本的 HTTPS API 协作,独立管理依赖、版本与发布;不使用跨仓库源码导入、相对路径引用或本地软链接作为正式依赖。
- Monorepo 只用于代码、依赖与开发任务管理,不改变“一套业务 API、一套主数据库”的目标,也不要求使用 NestJS 自身的多应用 Monorepo 模式。
### 3.2 doodle-store 计划目录与工作区
已初始化 pnpm 工作区、`apps/server` 的 NestJS 骨架和 `packages/contracts` 的基础响应/健康检查类型;`apps/admin`、`apps/web`、Prisma、迁移脚本与部署目录目前仅有用途说明。以下为目标结构,业务模块、数据库接入、完整前端和部署配置尚未实现,不为凑齐目录提前开发完整应用。
```text
doodle-store/
apps/
server/ # 首期统一业务 API
src/
prisma/ # 数据模型与版本化迁移,仅服务端访问
scripts/ # 数据/文件导入、审计与恢复
package.json
.env.example # 变量名示例,不包含真实密钥
admin/ # 后续独立管理后台
web/ # 后续用户网站与 SEO 页面
packages/
contracts/ # 必要的公开请求/响应类型及枚举
deploy/ # Compose、Nginx、备份配置
docs/ # 新系统接口与部署文档
pnpm-workspace.yaml
pnpm-lock.yaml
package.json
```
`doodle-store/pnpm-workspace.yaml`:
```yaml
packages:
- 'apps/*'
- 'packages/*'
```
- 每个应用与共享包有独立 `package.json`,声明自身依赖;根项目设为 `private: true`,主要负责工作区脚本和公共开发工具,不集中安装所有应用的运行依赖。
- `doodle-store` 使用单一 `pnpm-lock.yaml`,锁定 `packageManager` 和 Node.js 版本;CI 使用冻结锁文件安装。`doodle-mini` 保持自身依赖和构建流程,不因新项目使用 pnpm 而强制切换。
- 工作区内部包使用 `workspace:*` 引用,不要求发布到 npm;`packages/contracts` 仅用于新项目内部共享公开契约,不包含数据库模型、密钥或私有生成器实现。
- 使用 pnpm 的筛选与递归脚本执行构建、检查、测试;应用构建需包含其依赖共享包的构建步骤。先采用 pnpm 原生能力,不默认引入 Turborepo、Nx、私有包仓库或复杂发布平台。
- TypeScript 基础配置可以复用,但 Node.js、浏览器和 SSR 应用保留各自运行环境配置;UI、SDK 等共享包仅在出现真实复用需求后提取,不建设无边界的通用 `shared` 包。
`doodle-store/apps/server` 内部目标结构(当前仅实现启动配置与健康检查,业务模块待开发):
```text
apps/server/ # 以下路径相对于 doodle-store
src/
health/ # 已实现:仅 HTTP 进程存活检查
modules/ # 以下业务模块尚未实现,当前仅用途说明
auth/ # 微信登录、用户身份、管理权限
users/ # 用户资料与注销
worksheets/ # 题型、上下架和属性同步
categories/ # 分类
favorites/ # 收藏与计数
downloads/ # 成功保存记录、历史与计数
content/ # 首页配置、推荐和发布
assets/ # 文件上传、引用和访问地址
legacy/ # 旧云函数协议适配,过渡后移除
common/ # 校验、错误格式、日志
prisma/ # 数据结构、版本化迁移
scripts/ # 导入、文件迁移、审计与恢复
package.json # 服务端独立依赖和构建脚本
.env.example # 变量名示例,不包含真实密钥
```
### 3.3 跨项目契约、发布与代码边界
- 公共 API 契约以 `doodle-store` 为权威来源,提供版本化接口文档/OpenAPI。小程序不能直接使用另一个仓库的 `workspace:*` 包;首期可将按契约生成或核对的必要类型同步到 `doodle-mini`,记录来源版本,并由小程序服务层继续使用 `wx.request` / `wx.uploadFile`。
- 公开契约变化时,分别核对 `doodle-mini`、`apps/admin`、`apps/web` 的适配与测试结果。新增兼容字段优先;字段删除、更名或语义变化须保留兼容期,不能假设两个项目或全部客户端同时升级。
- `apps/server`、`apps/admin`、`apps/web` 独立构建和部署。后台若采用 SPA,可发布静态文件;Web 是否采用 SSR/预渲染另行选择。共享包变化时检查所有受影响应用,但不要求所有应用一起发布。
- 数据库结构、访问凭证、管理权限判断、未来私有生成/绘制实现仅由服务端持有。管理后台和 Web 通过 API 访问,不直接导入 Prisma Client 或服务端源码;前端包也不得间接依赖这些私有模块。`private: true` 不是防止代码被前端打包的安全边界。
- 服务端和各前端分别管理环境变量,真实密钥不提交仓库、不写入前端可公开配置。生产仅部署目标应用及必要依赖,不在 2 核 4 GB 服务器上长期运行整套开发模式或无关应用。
## 4. 现有功能与云函数迁移清单
以下 API 是目标契约,实施时生成接口文档。统一前缀为 `/api/v1`;表中使用省略前缀的路径。用户接口从可信登录态取得 userId,不接受客户端指定身份。管理接口一律由服务端校验管理员权限。
| 当前云函数 | 当前功能 | 迁移目标 | 关键要求 |
| ------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `userLogin` | 静默登录、更新昵称头像 | `POST /auth/wechat/login`、`GET /me`、`PATCH /me/profile` | 原用户 ID 不变;AppSecret/session_key 不下发 |
| `userSettings` | 清除资料、注销 | `DELETE /me/profile`、`DELETE /me` | 清资料保留收藏/历史;注销落实页面承诺的关联数据删除 |
| `userFavorites` | 添加/取消/列表/数量/单张检查/批量检查 | `PUT/DELETE /me/favorites/:worksheetId`、`GET /me/favorites`、`GET /me/favorites/count`、`POST /me/favorites/check` | 收藏关系唯一;真实增删与题型计数同一事务 |
| `userDownloadLogs` | 添加成功保存记录、列表、数量 | `POST /me/downloads`、`GET /me/downloads`、`GET /me/downloads/count` | 新版必须传 exportId;只在实际保存成功后上报 |
| `worksheetsQuery` | 题型查询、状态筛选、分类状态数量、展示计数 | `GET /worksheets`、`GET /worksheets/:id`、`GET /admin/worksheets` | 普通用户只看 active;管理查询支持全部状态和原始计数 |
| `worksheetsPublish` | 按固定 ID 新增或更新题型 | `PUT /admin/worksheets/:id` | 保留发布能力;普通内容更新不重置累计计数 |
| `worksheetsUpdateStatus` | draft/active/hidden 切换 | `PATCH /admin/worksheets/:id/status` | 状态变更后刷新受影响首页展示 |
| `worksheetsBatchPatch` | 批量年龄、年级、标签、种子及计数同步 | `POST /admin/worksheets/batch-patch` | 保留 dryRun、逐项结果和错误;计数修订单独审计 |
| `worksheetsStatsUpdate` | 直接递增 likes/downloads | 不向普通用户提供通用加数接口;由收藏/下载服务承接 | 当前仓库工具无调用方;旧协议仅在确认必要时适配,不能绕过幂等 |
| `categoriesQuery` | 分类排序查询 | `GET /categories` | 保留 ID、父子关系、图标、颜色和排序 |
| `categoriesCreate` | 创建分类 | `POST /admin/categories` | ID 唯一;校验父分类和循环引用 |
| `categoriesUpdate` | 更新分类 | `PUT /admin/categories/:id` | 不修改已有主键;分类关系可审计 |
| `ageRecommendedQuery` | 5 个年龄段推荐 | `GET /recommendations/age?ageKey=3-4` | 保留筛选、Top3 下载 + Top3 收藏、去重及不足补齐规则 |
| `pageConfigFetch` | 读取 current 页面配置 | `GET /page-config` | 保留 home/category/age、version、updatedAt 和 previewImg |
| `pageContentBuild` | 手动首页配置、栏目与展示重建 | `POST /admin/page-config/build`、`GET /admin/page-config` | 保留 featuredIds/hotIds/sections 与现有 page 分支语义 |
| `homeAutoRefresh` | 自动更新首页下载/收藏榜 | 服务端任务、`POST /admin/home/refresh` | 保留各 Top5 和其他首页配置;启停、时区、重复执行可核对 |
| `homeTimerControl` | 开关自动推荐 | `GET/PATCH /admin/home/auto-refresh` | 数据从新数据库读写,替代管理页数据库直读 |
### 4.1 不依赖云函数名称也必须迁移的部分
本节代码位置均属于 `doodle-mini`;接入改造留在原项目,新业务实现位于 `doodle-store/apps/server`。
| 当前依赖 | 代码位置 | 迁移处理 |
| -------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| 云初始化、静默登录、首页配置加载 | `miniprogram/app.ts` | 改为 API 配置与登录;预拉取、缓存、兜底保持可用 |
| 云调用与资料缓存 | `miniprogram/utils/auth.ts` | `wx.login` + HTTP;资料缓存与有效登录态分开管理 |
| 收藏/下载业务包装 | `miniprogram/utils/favorites.ts`、`downloadLogs.ts` | 替换内部实现,保留页面使用方式和统计触发时机 |
| 分类与分龄直接调用 | `pages/category/category.ts`、`pages/age/age.ts` | 接入业务服务层,保持排序/推荐/路由与字段兼容 |
| 头像上传 | `pages/profile/profile.ts` | 替换 `wx.cloud.uploadFile`,存入七牛 |
| 预览图上传与题型发布 | `base/pageMixin.ts` | API 上传 + 管理发布;开发环境隐藏入口不等于管理授权 |
| 云文件字体地址 | `core/font/fontLoader.ts`、`fontProfiles.ts` | 改成 HTTPS 文件引用;继续使用 `wx.loadFontFace` 并验证字形 |
| 内置云预览地址 | `pages/home/home.data.ts` | 迁移并替换 cloud://;兼顾本地兜底数据 |
| 管理页云函数调用 | `supportPages/categoryManage`、`categoryContentManage`、`homeContentManage`、`worksheetSync`、`settings` | 接入管理/用户 API,逐页核对 |
| 管理页直读 settings | `supportPages/homeContentManage/homeContentManage.ts` | 改为管理 API;仅换云函数封装无法覆盖此处 |
| 旧适配器与 HTTP 工具 | `platform/cloud-adapter.ts`、`utils/http.ts` | 逐调用点收口;前者目前指向 doodle,不能假设全站均经它调用 |
| 小程序后台预拉取 URL | 仓库之外的微信后台配置 | 改为新公共配置地址,关闭调试绕过后验证 |
| 云开发定时触发配置 | `cloudfunctions/homeAutoRefresh/config.json` 与控制台 | 核实实际执行时间/时区,迁入调度,切换时停旧任务 |
`wx.reportEvent` 是微信统计能力,不依赖业务 CloudBase 数据库,无需为了迁移而更换。分享、广告和相册权限也继续使用微信能力。
## 5. API、身份和统计约定
### 5.1 新接口与兼容格式
新 API 使用统一响应,例如:
```json
{
"code": 0,
"message": "ok",
"data": {},
"requestId": "request-id"
}
```
- HTTP 状态码区分参数错误、未登录、无权限、冲突、服务异常;失败不包装成成功的空列表。
- 列表提供分页和总量。当前一次最多取 100 条的实现不能成为数据库迁移后的隐形上限;小程序服务层负责拉完所需内容或采用分页展示。
- 新服务内部使用 `id`;小程序 DTO/兼容桥继续提供旧 `_id`、`path`、`previewImg`。数据库路径字段可命名 `miniProgramPath`,Web 路径后续另做映射。
- 云函数目前存在 `{code:0,data}` 与 `{success:true,data,stats}` 两种协议,由业务服务层/兼容桥转换,不要求所有页面同时改写数据形状。
- `previewImg` 是唯一预览字段;公共 DTO 不包含生成器私有配置、用户身份密钥或未发布内容。
### 5.2 微信登录与用户延续
1. 小程序调用 `wx.login()` 获取一次性 code。
2. 服务器用配置的 AppID/AppSecret 调用微信 `code2Session`,取得可信 OpenID,UnionID 可为空。
3. 按当前 AppID + OpenID 查找迁入用户,沿用原 `users._id`;确实不存在时才新建。
4. 签发有期限的应用登录凭证。首期可用 JWT,建议初始有效期 24 小时;过期后通过微信静默登录续用,暂不增加独立刷新令牌体系。
5. 每次用户请求核对账户有效状态;角色从服务端配置/数据取得。注销立即使旧凭证失效。
客户端缓存的 user 对象、OpenID、UUID 都不能作为可信登录凭证。请求遇到 401 时合并并发登录,成功后最多重试一次;写请求需具备幂等性。当前 7 天资料缓存不能替代 API 身份验证。
Web 用户不会凭现有小程序 OpenID 自动变成同一账户。跨端身份绑定作为后续业务设计,当前保留稳定 userId 和可选 UnionID,不自动合并用户。
管理权限首期采用明确管理员名单或角色字段,默认不授权。发布、上下架、批量修订和定时任务配置必须服务端验证;不以客户端 `envVersion`、按钮隐藏或自报角色为依据。
### 5.3 成功保存与收藏计数
- 保存成功的判定继续是 `wx.saveImageToPhotosAlbum` 成功回调;点击保存、图片生成完成、拿到文件、接口返回 200 都不是成功保存。
- 保存成功后递增本地配额、记录 `export_success`,用同一个 exportId 向 API 写下载记录。
- 数据库对 `(userId, exportId)` 建唯一键,下载记录和 `worksheets.downloads + 1` 在同一事务内完成。重试只返回已有结果,不再次加数。
- API 上报失败不能把已保存的图片判定为保存失败。新版可用小型本地补报队列重试同一个 exportId;补报不再次递增本地配额或发送 export_success。队列绑定原 userId,换账户不代报,注销时清除。
- 服务端接收的是客户端成功回报,无法独立证明图片已进入手机相册;本次不把该回报设计成支付凭据。
- 收藏唯一键为 `(userId, worksheetId)`;确实新增/删除关系时才更新 likes。重复请求不增减;注销删除收藏时同步处理受影响计数。
- 展示计数与真实计数分开:展示可沿用真实值 + `likes_seed`/`downloads_seed`,分析和管理原始查询不加 seed。迁移期间不改变已有推荐的种子参与规则。
- 常规重新发布题型不能将真实计数重置为 0。显式统计修订保留管理员入口,但记录操作者及前后值。
旧版缺少 exportId 的保存请求不能凭题型 ID 或几秒时间窗口去重,这会误删用户真实多次保存。兼容桥按每次收到的旧请求生成事件 ID,内部转发重试复用该 ID;接受无法跨独立旧请求精确去重的限制,并监测其调用量。
## 6. 数据迁移
数据库详细设计见 [v3.5 数据库设计文档](./v3.5/数据库设计文档.md),使用 PostgreSQL + Prisma,覆盖以下集合及辅助表。
### 6.1 集合映射与主键
代码实际使用 7 个核心集合。历史设计文档中的 `favorites_log` 与当前代码不一致,应迁移实际 `favorites`;`feedback`、`learning_plans` 等只出现在设计中的集合,先盘点线上是否存在,不默认当成已上线功能。
| CloudBase 集合 | 新表 | 迁移要求 |
| --------------- | --------------- | ----------------------------------------------------------------------------- |
| `users` | `users` | `_id → id` 原值保留;OpenID、可选 UnionID、资料、创建/活跃时间及旧字段保留 |
| `categories` | `categories` | ID、parentId、图标、颜色、排序保留 |
| `worksheets` | `worksheets` | ID、路由、年龄、grade、难度、tags、状态、预览、真实/seed 计数及各时间字段保留 |
| `favorites` | `favorites` | 用户/题型关联不变;重复关系按审计规则归并 |
| `download_logs` | `download_logs` | ID、用户/题型关联、时间、可选 exportId 保留;历史缺 exportId 可为空 |
| `page_configs` | `page_configs` | `current` ID、JSON 结构、版本、时间保留;所有嵌套文件引用转换 |
| `settings` | `settings` | 包括 `homeAutoRefresh.enabled`,先盘点全部线上配置 |
新增必要辅助表:文件迁移/引用表 `assets`、任务执行记录 `job_runs`、管理审计记录 `admin_audit_logs`。它们用于迁移与运行,不代表已有业务集合。
主键采用足够长度的字符串,保留大小写语义,不重新生成所有 ID。业务字段用结构化列,tags/页面配置/扩展配置用 JSON;导入前保留完整原始导出,未识别字段进入迁移报告,不能默默丢弃。
主要索引:用户 `(miniAppId, openid)` 唯一;收藏 `(userId, worksheetId)` 唯一及 `(userId, createdAt)`;下载 `(userId, exportId)` 唯一及用户/题型时间索引;题型分类/状态/排序索引。空 exportId 规范为 NULL,历史多条无 ID 下载不得互相冲突。
### 6.2 导入与审计规则
1. 完整分页导出实际集合,记录每个集合总数、主键清单、时间范围和校验摘要;不可只导出首批 100 条。
2. 规范 BSON/JSON 日期为同一 UTC 时间,数据库和 API 时间一致;业务日与任务时区明确使用 Asia/Shanghai。空值与 0 区分,禁止因默认值覆盖已有数据。
3. 先导分类和用户,再导题型、收藏、下载、配置及设置。脚本可重复执行、按主键 upsert,并支持 dryRun;生产切换后不允许再次用旧导出覆盖新写入。
4. 检测重复 OpenID、重复收藏、重复 exportId、失联关联、非法状态/年龄、缺失图片和未知字段。异常输出逐项报告,不以“跳过错误”当成功。
5. 原用户已经注销而留下的收藏/下载记录应隔离归档,不能重建已注销用户。题型缺失时保留历史 worksheetId 和记录,列表 LEFT JOIN 返回空详情,不能整个历史接口报错。
6. 收藏去重、异常隔离等转换明确写进审计报告:原始数 = 正常导入数 + 合并重复数 + 隔离数。不得无说明要求转换后条数与原始条数相同。
7. 保留历史真实计数及 seed;旧计数与历史条数可能已有差异,先报告,不直接按日志总数重算下载量。切换后的新事件用事务建立一致性。
验收既对总量,也对用户集合、主键和关联。重点核对若干有收藏/历史的老用户、无资料用户、缺失题型记录和多个年龄段,不只抽查首页。
## 7. 文件、首页预拉取与定时任务
### 7.1 文件迁移
资源清单同时覆盖云数据库、页面配置嵌套对象、字体配置、首页内置兜底、用户头像和云存储目录。现有 HTTPS 资源(如部分 `cdn.joeyone.cn` 字体)先核实归属与可用性,不重复搬运已符合目标的文件。
生成可追溯映射:`原 cloud:// fileID → 新 objectKey/assetId → checksum/大小/类型 → 迁移状态`。确认源文件后下载、上传七牛并验证内容;仅改 URL 不算文件已迁移。
- 对可公开使用的预览图、共享图片和小程序所需字体使用公共资源空间/域名,采用版本化对象 key 和缓存。
- 头像等用户文件使用私有访问策略;数据库存对象引用,API 生成访问地址。小程序缓存需记录有效期并刷新,不能把短期签名 URL 当永久 avatarUrl。
- 未来私有线稿/模板另行存放;当前小程序使用的字体与最终预览不因迁移就自动变成保密素材。
- 首期头像和预览图较小,统一通过 `POST /api/v1/assets` 上传,由 API 校验身份、用途、类型、大小和归属,再写七牛。普通用户仅上传本人头像;预览上传需要管理权限。
- 后续有大文件需求时再加入七牛直传凭证。AK/SK 只在服务端;上传地址、对象 key 与归属由服务端控制,不接受任意远程 URL 抓取。
- 旧版仍会先上传 CloudBase 再调用更新资料/发布接口。兼容期保留所需上传能力,兼容入口校验旧文件环境、允许路径和所有者后复制到七牛再提交新数据库;不把永久 cloud:// 引用写入新数据库。
新版本完成后,正式代码和兜底资源均不再依赖 cloud://。旧版本硬编码字体/预览无法通过接口转发改掉,所以旧文件需要与兼容期一起保留,退场时单独检查访问量。
### 7.2 首页配置与推荐
现有 `app.ts` 顺序为预拉取 → 本地缓存 → 云函数兜底。迁移后由新公共 `GET /api/v1/page-config` 提供配置,微信后台预拉取 URL 同步更新,失败继续使用可用缓存/内置数据,网络恢复后按版本刷新。
- 新 API 保留预拉取解析所需的数据形状与 `previewImg`;升级缓存命名/版本,避免长期沿用 cloud:// 和旧首页字段。
- PostgreSQL 中的 current 配置是权威源。静态配置文件若为缓存或旧版兼容发布,只是派生产物;发布失败可重试,保留上一有效版本,不形成第二套内容数据库。
- 手动栏目设置、各 page 分支及 category/age 为 null 的现有行为通过固定输入样本比对,避免迁移时无意改变运营配置。
- 自动刷新仍只更新下载/收藏 Top5,保留手动维护的其他栏目。分龄推荐保持现有“ageMin 等于目标下限且 ageMax 覆盖上限”的匹配方式,业务算法调整留到后续。
- 排名并列时定义稳定排序并与旧样本核对。统计更新不等于内容上新,保留 createdAt/contentUpdatedAt/updatedAt 各自用途。
### 7.3 定时任务
仓库存在 7 段定时表达式 `0 0 22 * * * *`,不得直接当作新调度器的表达式或凭它认定北京时间执行时刻。先核对 CloudBase 控制台实际时区与最近执行记录,再换为服务端明确时区的计划。
保留 enabled 开关;首次配置缺失默认启用的规则显式记录。自动执行与手动执行共用内容服务,使用锁/事务避免互相覆盖,记录执行时间、结果与失败原因。服务重启后发现当日应执行任务遗漏,可补跑一次;旧任务在正式切换前停用。
## 8. 小程序接入与旧版兼容
### 8.1 新版改造
- 本节改造在独立的 `doodle-mini` 项目中实施,不移动小程序源码到 `doodle-store`;按新项目公开 API 契约接入,记录联调使用的服务端版本。
- 按开发、体验、正式配置 API 地址,不在生产构建中使用 localhost/IP。接口域名、下载资源域名及实际上传域名按微信后台要求配置 HTTPS 与备案。
- 建立业务服务层(auth/worksheet/category/content/favorite/download/asset/admin),页面调用业务方法。当前散落的调用点逐一替换,不依赖修改单个 cloud-adapter 就完成迁移。
- HTTP 包装补齐登录、错误格式、超时和日志;`wx.uploadFile`、`wx.downloadFile` 分别处理。当前 `utils/http.ts` 下载包装读取 `res.data` 不适用于 `wx.downloadFile` 的临时文件路径,使用前修正契约。
- 所有正式入口及分享 path 保留。收藏/下载历史中的原 worksheetId 能打开原题型,不因存储变化重定向到默认题型。
- 网络故障时保留已有生成/保存能力;远程收藏与历史展示明确失败/缓存状态,不将失败误显示为真实空数据。
- 微信事件名和 worksheet_id/export_id 口径延续;收藏成功事件仍在远端成功后记录。
### 8.2 过渡期兼容桥
发布新版并不意味着所有旧版立即消失。兼容桥覆盖旧正式用户的登录、资料、收藏、历史和内容查询;旧管理页存在数据库直读,要求管理员使用新版,不承诺旧调试管理页完全兼容。
1. 原云函数名称保留,按旧 action 白名单转发统一服务端业务方法,并转换回原响应格式。
2. 身份从 `cloud.getWXContext()` 取得,不能从 event 信任 OpenID/角色。
3. 兼容请求以服务间密钥签名,包含函数/action、可信 AppID/OpenID、请求体、时间和唯一请求 ID;服务端验签、校验时效并拒绝重放。
4. 内部转发重试使用新的传输请求 ID,但复用同一业务幂等键。签名密钥不下发小程序;管理 action 仍需验证管理员身份。
5. 切换后兼容桥只读写新数据库,旧数据库停止写入;旧存储仅用于兼容文件读取/上传与迁移。
这一阶段有两个接入协议,但只有一份业务实现和一个写入源。兼容模块带退场期限和调用统计,不长期维护两套后端。
## 9. 实施里程碑与可执行待办
| 阶段 | 工作与交付 | 完成条件 |
| ------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| S0 盘点 | 线上集合/文件清单、资源和域名核实、固定功能样本、迁移报告模板 | 迁移对象、数量和异常可追溯;有原始备份 |
| S1 纵向样板 | doodle-store pnpm 工作区、apps/server 骨架、数据库迁移、微信登录、题型读取、收藏/下载事务 | 工作区安装/构建通过;老用户身份关联正确;重复收藏/保存请求不重复计数 |
| S2 功能迁移 | 补齐其余 API、管理权限、配置/推荐/任务、文件模块 | 17 个云函数各有落点,管理页直读替代完成 |
| S3 接入与试迁 | doodle-mini 服务层改造、跨项目契约核对、文件与数据库试导、兼容桥、体验版部署 | 新旧用户协议共同访问测试数据库;页面/字体/预拉取实测通过 |
| S4 上线切换 | 生产导入、单写切换、正式版本发布、在线核对 | 用户关联与累计数据一致;新写入只在新数据库 |
| S5 退场 | 持续核对兼容/旧文件调用、移除云开发依赖、归档旧数据 | 达到退场门槛后完全退出 CloudBase |
### 执行清单
- [ ] S0:导出实际集合及文件清单;确认 API 域名、备案、服务器和七牛环境。
- [ ] S0:记录老用户/题型/推荐/配置样本及当前原始计数,准备数据审计规则。
- [x] S1:新建独立 `doodle-store` 项目,建立 pnpm workspace、锁文件、版本约束及 `apps/server` 的独立构建/检查脚本;不迁入 `doodle-mini` 源码。2026-10-12 已完成基础初始化,格式/代码/类型检查、5 项基础测试与构建入口 HTTP 检查通过;不代表 S1 业务样板已完成。
- [ ] S1:在 `doodle-store/apps/server` 建立 PostgreSQL/Prisma 数据结构、统一响应、日志与版本化公开接口契约。
- [ ] S1:微信登录沿用原 userId;服务端权限校验;收藏与下载幂等事务。
- [ ] S2:完成全部公开/用户/管理接口及现有推荐行为对照。
- [ ] S2:迁移文件、首页预拉取配置和定时任务,完成手动/自动刷新互斥。
- [ ] S3:在 `doodle-mini` 替换所有正式云调用、管理直读、上传和字体解析,核对旧缓存升级及新 API 契约;记录两个项目的联调版本。
- [ ] S3:验证兼容签名、身份、响应格式、旧文件新增迁移及无 exportId 请求行为。
- [ ] S3:试导后核对集合总量、主键、关联、转换/隔离项及文件校验。
- [ ] S3:完成备份恢复演练、API 检查和微信体验版真机验收。
- [ ] S4:准备写入冻结、切换顺序、回滚记录,再执行生产切换与正式发布。
- [ ] S5:确认兼容/旧资源访问退场门槛,归档后停止旧任务、函数、存储与套餐。
## 10. 正式切换与回滚
### 10.1 推荐切换:短维护窗口,全量最终导入
现有业务较小,优先使用经过演练的短写入冻结,不建设长期双写或增量同步平台。窗口时长由试导耗时决定,不能提前承诺“零停机”或固定分钟数。
1. 先完成试迁和体验版验收,保持现有正式版仍使用 CloudBase。新正式版完成审核准备,但暂不放量。
2. 预部署全部兼容函数,支持统一的 `cloud / readonly / server` 切换模式;在演练中验证。旧云函数对用户创建/活跃时间、收藏、下载、资料和管理写入均受模式约束。
3. 选择低峰窗口,冻结旧端全部业务写入,停止旧推荐任务和管理发布;明确提示维护,读取与本地生成/保存仍可用。窗口内旧版成功保存但日志未上报的限制应在切换记录中注明;新版补报队列在试迁时验证。
4. 在冻结状态导出最终快照,完成新增文件迁移和全量导入审计;此前试迁目标不得保留混入的测试记录。
5. 将兼容桥整体切到 server,新数据库成为唯一写入源;更新公共配置/预拉取来源,开启新定时任务。核对桥和任务状态,旧数据库保持只读。
6. 发布新版小程序;过渡期新版 HTTP 和旧版桥均调用同一业务服务。新版本未推广完成也不得把一部分用户写回旧数据库。
7. 核对登录、收藏、成功保存记录、头像/字体、首页刷新与真实计数,再进入观察期。
若首次盘点发现数据规模或维护窗口不满足全量导入,应重新设计最终同步方式,不能直接在生产并行写两个库。试迁阶段的测试用户和数据不得混入生产。
### 10.2 回滚的两种情况
| 时点 | 回滚方式 |
| ------------------------ | ------------------------------------------------------------- |
| 新库尚未接收真实业务写入 | 停新任务/新入口,将桥恢复 cloud;解除旧库写入冻结,恢复旧任务 |
| 新库已接收真实业务写入 | 优先回滚 API 版本并继续使用新数据库;不可直接把客户端切回旧库 |
如必须回到旧数据库:同时冻结 HTTP 和兼容桥写入,导出新库最新数据并反向转换,将切换后的新增、修改和删除全部同步到旧库,完成关联/计数审计后再恢复旧入口。反向导入按新库权威快照替换相关数据,不能只补新增,否则会复活已取消收藏或已注销用户。
S3 应验证反向转换和恢复步骤。每次切换保存版本、模式、时间点和审计报告;数据库变更采用可兼容旧 API 版本的方式,避免应用回滚时表结构无法使用。
### 10.3 CloudBase 退场门槛
建议先观察至少 14 天,再结合实际旧版调用决定退场;14 天本身不是可以删除云资源的证明。要求:
- 新版正式功能及管理操作已稳定使用新 API。
- 兼容调用和旧 cloud:// 文件访问在约定观察窗口内已归零,或明确接受剩余旧版必须升级才能继续在线使用;不能凭新版发布推断旧版本消失。
- 新数据库、七牛文件及恢复副本均可使用,旧数据已完成可恢复归档。
- 旧任务停止,新版无 CloudBase 运行依赖;客户端 cloud 初始化及项目云配置在最终清理阶段移除。
删除旧资源和停止套餐应在上述条件满足后执行。兼容函数、上传或云字体仍使用旧环境时,不能宣称已完全迁出或已省下全部 19.9 元费用。
## 11. 验收与最低运行要求
### 11.1 自动验证与真实环境验收
| 验证层 | 检查项目 | 通过标准 |
| ----------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| 服务端自动检查 | 登录错误、用户越权、管理权限、分页、重复收藏、并发重复 exportId、事务失败、注销 | 不越权、不重复计数;失败无半条业务写入 |
| 工程与契约检查 | doodle-store 工作区安装/构建、应用依赖边界、公开契约与 doodle-mini 适配 | 服务端可独立构建;客户端不导入私有模块;联调版本及兼容行为可追溯 |
| 行为对照 | 固定输入的分类、分龄、手动配置、自动榜单、展示/真实计数 | 字段、去重、排序、兜底与预期一致;差异显式记录 |
| 迁移审计 | ID、数量、日期、关系、重复/隔离项、旧计数、文件 checksum | 数据量可对账;可用关联完整;无未解释丢失 |
| 小程序体验版/真机 | 老用户登录、首页/分类/年龄、预览生成、实际保存、收藏/历史、资料、注销、管理发布 | 使用有效域名和证书;功能链路与原版一致 |
| 故障与回滚 | token 过期、离线、保存后上报失败、API 重启、备份恢复、切换后新增/删除 | 本地保存结果准确;同事件重试不重复;恢复不遗漏切换后的变化 |
微信开发者工具“跳过域名校验”下通过不算正式网络验收。至少检查 iOS、Android 的上传、图片/字体加载和保存回调。服务端和脚本可以自动测试;相册权限、实际保存、微信分享、广告、预拉取与真机字体效果需实际微信环境验证,不能以静态检查替代。
管理员验收包括:首页手动推荐/栏目、定时开关、分类创建/更新、题型上传/发布/上下架、年龄标签同步及 dryRun。正式用户验收还应覆盖所有分类入口、首页推荐、分龄和收藏历史中的跳转路径。
### 11.2 轻量运行基线
- 每日自动备份数据库到服务器之外,备份访问受控并保留滚动版本;上线前成功恢复一次。七牛文件迁移清单和必要配置随数据备份保留。
- 初始目标可设数据库 RPO 不超过 24 小时、恢复操作 RTO 不超过 60 分钟,以演练结果确认;若需要更小数据损失窗口,再增加 binlog/更频繁备份。每日备份不等于零数据损失。
- HTTPS 自动续期;检查 API/数据库健康、磁盘、错误、备份失败和定时任务失败。日志轮转,不记录 AppSecret、登录 code、令牌、签名 URL 或完整用户敏感信息。
- 版本和数据库变更可追踪;更新前备份,保留上一可运行应用版本。对本项目采用简单可执行步骤,不要求集群或复杂运维平台。
- 发布记录关联 `doodle-store` 服务端版本、数据库迁移版本和 `doodle-mini` 小程序版本;后续后台/Web 各自记录版本。回滚服务端时保持已发布客户端的 API 兼容,不要求两个项目同步回滚。
- 成本按 API 流量、数据库体量、七牛存储/下载/请求和实际维护工时记录,不以“已有服务器”推导零总成本。
## 12. AI 辅助工作量与排期
以下是初步预算,不是已验证的工期承诺。假设线上数据和文件较少、服务器可部署、权限齐备、沿用本方案接口/数据库选择;包含 `doodle-store` 基础 pnpm 工作区与 `apps/server`,以及 `doodle-mini` 接入改造,不包含独立后台/Web 页面、私有包发布平台或存量绘图重写。
| 工作包 | AI 辅助后的初步投入 |
| -------------------------------------------------------------- | ------------------------------------------------ |
| 实际盘点、固定样本、接口与导入字段确认 | 2–3 小时 |
| doodle-store 工作区、服务端骨架、登录/权限、现有业务与管理 API | 8–12 小时 |
| 数据/文件导入、对账与反向转换脚本 | 4–8 小时 |
| 小程序服务层、登录、上传、缓存/字体与页面适配 | 6–10 小时 |
| 部署、兼容桥、必要测试/真机联调与切换演练 | 6–10 小时 |
| 合计 | 26–43 小时;约加 25% 修正余量后按 32–54 小时准备 |
按每天 8 小时集中投入,可先预算约 4–7 个工作日;兼职开发的日历周期更长,外部审核/备案等待另算。S5 还有至少 14 天的退场观察,代码与正式切换完成时间不等于关闭 CloudBase 的时间,观察期也不代表连续全日投入。先完成 S0 + S1 的纵向样板,以实际用时、数据量和发现的问题更新后续预算。
AI 主要承担接口/模型初稿、SQL 转换、数据脚本、小程序调用替换、部署配置和测试用例;必须运行验证的内容是老用户身份、导入结果、文件可用性、实际保存、管理权限及生产切换。无需将常规备份和升级夸大为单独的大型工程。
## 13. 与现有规划的关系及参考
本方案覆盖旧文档“暂缓核心 API/数据库迁移”“只替换 cloud-adapter 即可”的表述。目标改为自有服务器统一后端;过渡期保留 CloudBase 仅用于兼容和恢复。现有产品功能待办、Web 试点和服务端绘图的范围分别管理。
工程组织以本方案为准:不在 `doodle-mini` 中新增服务端工程;独立 `doodle-store` pnpm Monorepo 管理服务端、后续后台和 Web,目前只完成基础初始化,业务迁移仍待实施。本方案迁入 `doodle-store/docs/云服务端迁移方案.md`,不在两个项目中维护重复副本。
实施依据是 `doodle-mini` 当前代码,历史文档中的 Prisma 示例、免费额度、Node 版本和“零成本 CDN”表述不能直接作为部署事实。
- [微信小程序网络与服务器域名要求](https://developers.weixin.qq.com/miniprogram/dev/framework/ability/network.html):HTTPS、备案、request/upload/download 域名与真实环境校验。
- [微信小程序登录流程](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html):code2Session 与身份/会话密钥边界。
- [七牛上传凭证](https://developer.qiniu.com/kodo/1208/upload-token):凭证由服务端生成,限制范围和有效期;直传作为后续可选优化。
- [PostgreSQL 数据库设计(v3.5)](./v3.5/数据库设计文档.md):表结构、约束、事务、导入映射与数据库验收;具体 PostgreSQL / Prisma 版本在实施时验证并锁定。
- [pnpm 工作区](https://pnpm.io/workspaces):Monorepo、内部包引用及工作区锁文件。
- [pnpm 筛选命令](https://pnpm.io/filtering):按应用、依赖关系执行开发与检查任务。
- `doodle-mini` 仓库依据:`cloudfunctions/*/index.js`、`miniprogram/app.ts`、`utils/auth.ts`、`favorites.ts`、`downloadLogs.ts`、`downloadPrint.ts`、`base/pageMixin.ts`、`pages/profile/profile.ts`、`core/font/*` 及上述管理页面。
+43
View File
@@ -0,0 +1,43 @@
# Doodle Store 初始化说明
初始化日期:2026-10-12。
## 工程边界
- 新项目路径:`/Users/joey-xd/sites/doodle/doodle-store`。
- 小程序继续在 `/Users/joey-xd/sites/doodle/doodle-mini` 独立维护,未搬移源码或更改其依赖。
- 本项目采用 pnpm workspace;首期应用是 `apps/server`,后续加入 `apps/admin` 和 `apps/web`。
- 内部共享包使用 `workspace:*`;跨项目使用有版本的 HTTPS API,不直接引用彼此源码。
## 已初始化的能力
- 固定 Node.js 24.20.0、pnpm 11.24.0,使用单一锁文件。
- NestJS 11 / TypeScript 5.9 服务端骨架;选择兼容版本,不直接追随依赖的最新主版本。
- 严格类型检查、ESLint、Prettier 和按依赖顺序执行的构建脚本。
- 可选 `.env` 加载、端口/监听地址校验、关闭信号处理。
- `GET /api/v1/health` 进程存活检查及真实 HTTP 测试。
- admin、web、Prisma、迁移脚本和部署目录的用途说明。
## 初始化验证
- `pnpm install --frozen-lockfile --offline`:锁文件重复安装通过。
- `pnpm check`:格式、代码规范、类型检查、构建与 5 项基础测试通过。
- 使用已构建的 `apps/server/dist/main.js` 启动真实进程,本地请求 `/api/v1/health` 返回 HTTP 200 和预期响应;验证后关闭进程。
- 已初始化独立本地 Git 仓库,尚未提交、配置远程仓库或推送。
## 未开发的内容
尚未实现登录/权限、用户资料、分类、题型、收藏、下载、推荐、管理业务、七牛、定时任务或私有绘图。未连接数据库、迁移数据、发布小程序或部署云服务器。
数据库选型讨论不等于最终变更指令;当前仅预留 Prisma 目录,不安装 ORM 或生成表结构。后续按确认后的数据库选型实施。
## 后续开发要求
1. 按迁移方案先完成登录、题型读取、收藏/下载幂等事务的纵向样板,不一次生成所有业务模块。
2. `apps/server/prisma` 作为数据库结构唯一来源,原用户/题型字符串 ID 必须保留。
3. 公共契约不得包含私有实现;管理权限由服务端判断,浏览器不直接访问数据库。
4. 小程序联调记录服务端与客户端版本,保留旧版本兼容期;不能假设同一时间升级所有用户。
5. 新增共享包后,确保根目录构建/检查先处理依赖包,再处理使用它们的应用。
6. CI 可使用 `pnpm install --frozen-lockfile` 和 `pnpm check`;当前未连接 CI 服务或创建远程仓库。
业务迁移范围与切换步骤以本项目的[云服务端迁移方案](./云服务端迁移方案.md)为准;该文档已从 `doodle-mini` 移入本项目统一维护。本文件只描述初始化状态,不代表 S1 业务样板已完成。