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

This commit is contained in:
R524809
2026-10-15 10:09:34 +08:00
parent 63a6993276
commit e6ded00a4c
41 changed files with 2597 additions and 180 deletions
+6
View File
@@ -0,0 +1,6 @@
# 项目编码约定
- 使用 4 个空格缩进,不使用 Tab;遵循项目 EditorConfig 与 Prettier 配置。
- 新增或修改代码时添加适当的中文注释。公共类、服务方法和接入接口说明职责与使用前提;复杂逻辑说明设计原因、处理顺序、边界条件及异常行为。
- 配置校验、鉴权与权限、事务与幂等、第三方服务接入、日志脱敏等关键逻辑应有注释。临时占位实现要说明当前行为和后续替换位置。
- 注释保持简洁,避免逐行复述代码或给显而易见的操作添加无意义注释;修改逻辑时同步更新相关注释。
+3 -1
View File
@@ -6,7 +6,7 @@
当前完成基础工程和本地数据库环境,具体业务尚未接入: 当前完成基础工程和本地数据库环境,具体业务尚未接入:
- `apps/server`:可运行的 NestJS 服务,只有进程健康检查。 - `apps/server`:可运行的 NestJS 服务,已接入日志、中间件、Swagger 与 JWT 基础设施;当前公开业务路由仍只有进程健康检查。
- `packages/contracts`:基础成功响应与健康检查类型,不包含业务字段或私有实现。 - `packages/contracts`:基础成功响应与健康检查类型,不包含业务字段或私有实现。
- `apps/admin`、`apps/web`:仅预留目录与说明,尚未选择或安装前端框架。 - `apps/admin`、`apps/web`:仅预留目录与说明,尚未选择或安装前端框架。
- 本地 PostgreSQL 18.6 Docker 开发环境已启动,10 张表及初始配置已创建;Prisma、微信登录、七牛接口、定时任务、业务接口与生产部署尚未接入。 - 本地 PostgreSQL 18.6 Docker 开发环境已启动,10 张表及初始配置已创建;Prisma、微信登录、七牛接口、定时任务、业务接口与生产部署尚未接入。
@@ -27,6 +27,8 @@ pnpm dev
可在 `apps/server/.env` 中配置 `PORT`、`HOST`,变量示例见 `apps/server/.env.example`。未配置时使用安全的本地默认值。不要提交真实 `.env` 或密钥。 可在 `apps/server/.env` 中配置 `PORT`、`HOST`,变量示例见 `apps/server/.env.example`。未配置时使用安全的本地默认值。不要提交真实 `.env` 或密钥。
JWT_SECRET 必须配置为至少 32 字节的独立密钥,本机开发密钥已生成。Swagger 默认开发开启:`http://127.0.0.1:3000/api-docs`,生产未显式配置时关闭;其他设置与账户查询接入边界见 [Server 基础设置](docs/v3.5/Server基础设置.md)。
```bash ```bash
pnpm check # 格式、代码检查、类型检查、构建与测试 pnpm check # 格式、代码检查、类型检查、构建与测试
pnpm build # 按工作区依赖顺序构建 pnpm build # 按工作区依赖顺序构建
+13
View File
@@ -1,6 +1,19 @@
NODE_ENV=development NODE_ENV=development
HOST=127.0.0.1 HOST=127.0.0.1
PORT=3000 PORT=3000
LOG_LEVEL=info
API_BODY_LIMIT_BYTES=1048576
# 开发默认开启,生产默认关闭;可显式设置 true/false。
SWAGGER_ENABLED=true
# 只允许明确配置的 Web origin;小程序请求无需 CORS origin。
CORS_ORIGINS=
# 仅在部署明确确认 Nginx 来源时设置 IP/CIDR,默认不信任转发头。
TRUSTED_PROXY_CIDRS=
# 必填;至少 32 字节,开发/测试/生产分别生成,不使用共享默认值。
JWT_SECRET=
JWT_ISSUER=doodle-store
JWT_AUDIENCE=doodle-mini
JWT_EXPIRES_IN_SECONDS=86400
# 后续 Prisma 接入使用;用户名/密码/端口须与根 .env 一致,密码需 URL 编码。 # 后续 Prisma 接入使用;用户名/密码/端口须与根 .env 一致,密码需 URL 编码。
# 当前骨架尚未读取 DATABASE_URL;不要提交实际连接凭证。 # 当前骨架尚未读取 DATABASE_URL;不要提交实际连接凭证。
DATABASE_URL=postgresql://doodle_dev:change-me-local-only@127.0.0.1:5434/doodle_dev?schema=public DATABASE_URL=postgresql://doodle_dev:change-me-local-only@127.0.0.1:5434/doodle_dev?schema=public
+6
View File
@@ -14,13 +14,19 @@
"@doodle/contracts": "workspace:*", "@doodle/contracts": "workspace:*",
"@nestjs/common": "11.2.7", "@nestjs/common": "11.2.7",
"@nestjs/core": "11.2.7", "@nestjs/core": "11.2.7",
"@nestjs/jwt": "11.0.2",
"@nestjs/platform-express": "11.2.7", "@nestjs/platform-express": "11.2.7",
"@nestjs/swagger": "11.4.7",
"class-transformer": "0.5.1",
"class-validator": "0.15.1",
"helmet": "8.3.0",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.2" "rxjs": "7.8.2"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "11.0.24", "@nestjs/cli": "11.0.24",
"@nestjs/testing": "11.2.7", "@nestjs/testing": "11.2.7",
"@types/express": "5.0.6",
"@types/node": "24.19.2", "@types/node": "24.19.2",
"typescript": "5.9.3" "typescript": "5.9.3"
} }
+2 -2
View File
@@ -1,7 +1,7 @@
# 数据库结构与初始化 SQL # 数据库结构与初始化 SQL
已提供 [v3.5 初始化 SQL](./sql/README.md),包含建库、10 张表的基线结构和配置初始化,已通过本地临时库验证。当前未安装 Prisma、未生成 Prisma Schema,也未在应用中连接数据库;doodle_dev 已于 2026-10-12 正式创建 10 张表及初始配置。本地 PostgreSQL 数据库由 [infra](../../../infra/README.md) 的 Docker 配置提供。 已提供 [v3.5 初始化 SQL](./sql/README.md),包含建库、13 张表的最新基线结构、配置初始化和 004~008 增量升级。2026-10-14 已核对设计文档的 135 个字段,最新基线、完整旧库升级链及本地 doodle_dev 的字段、约束、索引和注释一致。开发库已执行至 008;原用户、题型、收藏及配置的保留规则通过临时库验证。本地 PostgreSQL 数据库由 [infra](../../../infra/README.md) 的 Docker 配置提供。
数据库已确认使用 PostgreSQL + Prisma;具体兼容版本在实施时验证并锁定,保留原用户和题型的字符串 ID。目标设计见 [v3.5 数据库设计文档](../../../docs/v3.5/数据库设计文档.md),当前尚未创建 Schema 或实施数据库迁移。 数据库已确认使用 PostgreSQL + Prisma;具体兼容版本在实施时验证并锁定,保留原用户和题型的字符串 ID。目标设计见 [v3.5 数据库设计文档](../../../docs/v3.5/数据库设计文档.md)。当前手工 SQL 基线和增量升级已执行;Prisma 尚未安装,尚未创建 Schema、登记 Prisma 迁移历史或接入应用数据库查询。
本目录及未来 Prisma Client 只能由服务端使用,不能作为公共契约包被 admin、web 或小程序导入。 本目录及未来 Prisma Client 只能由服务端使用,不能作为公共契约包被 admin、web 或小程序导入。
+118 -8
View File
@@ -8,7 +8,6 @@ SET LOCAL search_path = public, pg_catalog;
CREATE TABLE public.users ( CREATE TABLE public.users (
id varchar(128) COLLATE "C" PRIMARY KEY, id varchar(128) COLLATE "C" PRIMARY KEY,
mini_app_id varchar(64) COLLATE "C" NOT NULL,
openid varchar(128) COLLATE "C" NOT NULL, openid varchar(128) COLLATE "C" NOT NULL,
unionid varchar(128) COLLATE "C", unionid varchar(128) COLLATE "C",
nick_name varchar(100), nick_name varchar(100),
@@ -20,7 +19,7 @@ CREATE TABLE public.users (
created_at timestamptz(3) NOT NULL DEFAULT now(), created_at timestamptz(3) NOT NULL DEFAULT now(),
last_active_at timestamptz(3) NOT NULL DEFAULT now(), last_active_at timestamptz(3) NOT NULL DEFAULT now(),
updated_at timestamptz(3) NOT NULL DEFAULT now(), updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT users_mini_app_openid_key UNIQUE (mini_app_id, openid), CONSTRAINT users_openid_key UNIQUE (openid),
CONSTRAINT users_role_check CHECK (role IN ('user', 'admin')), CONSTRAINT users_role_check CHECK (role IN ('user', 'admin')),
CONSTRAINT users_status_check CHECK (status IN ('active', 'disabled')), CONSTRAINT users_status_check CHECK (status IN ('active', 'disabled')),
CONSTRAINT users_total_downloads_check CHECK (total_downloads >= 0), CONSTRAINT users_total_downloads_check CHECK (total_downloads >= 0),
@@ -54,7 +53,7 @@ CREATE TABLE public.assets (
REFERENCES public.users (id) ON UPDATE RESTRICT ON DELETE SET NULL, REFERENCES public.users (id) ON UPDATE RESTRICT ON DELETE SET NULL,
CONSTRAINT assets_provider_check CHECK (provider IN ('qiniu', 'external')), CONSTRAINT assets_provider_check CHECK (provider IN ('qiniu', 'external')),
CONSTRAINT assets_purpose_check CHECK ( CONSTRAINT assets_purpose_check CHECK (
purpose IN ('avatar', 'preview', 'category_icon', 'font', 'share', 'page_config', 'other') purpose IN ('avatar', 'preview', 'category_icon', 'font', 'share', 'page_config', 'worksheet_source', 'other')
), ),
CONSTRAINT assets_visibility_check CHECK (visibility IN ('public', 'private')), CONSTRAINT assets_visibility_check CHECK (visibility IN ('public', 'private')),
CONSTRAINT assets_avatar_visibility_check CHECK (purpose <> 'avatar' OR visibility = 'private'), CONSTRAINT assets_avatar_visibility_check CHECK (purpose <> 'avatar' OR visibility = 'private'),
@@ -120,6 +119,9 @@ CREATE TABLE public.worksheets (
category_id varchar(128) COLLATE "C" NOT NULL, category_id varchar(128) COLLATE "C" NOT NULL,
subcategory varchar(100), subcategory varchar(100),
path varchar(1024) NOT NULL, path varchar(1024) NOT NULL,
generation_mode varchar(16) NOT NULL DEFAULT 'procedural',
renderer_key varchar(64) COLLATE "C",
render_config jsonb NOT NULL DEFAULT '{}'::jsonb,
age_min smallint NOT NULL, age_min smallint NOT NULL,
age_max smallint NOT NULL, age_max smallint NOT NULL,
grade smallint NOT NULL DEFAULT 0, grade smallint NOT NULL DEFAULT 0,
@@ -134,6 +136,7 @@ CREATE TABLE public.worksheets (
downloads_seed bigint NOT NULL DEFAULT 0, downloads_seed bigint NOT NULL DEFAULT 0,
likes_seed bigint NOT NULL DEFAULT 0, likes_seed bigint NOT NULL DEFAULT 0,
status varchar(16) NOT NULL DEFAULT 'draft', status varchar(16) NOT NULL DEFAULT 'draft',
first_published_at timestamptz(3),
created_at timestamptz(3) NOT NULL DEFAULT now(), created_at timestamptz(3) NOT NULL DEFAULT now(),
content_updated_at timestamptz(3), content_updated_at timestamptz(3),
updated_at timestamptz(3) NOT NULL DEFAULT now(), updated_at timestamptz(3) NOT NULL DEFAULT now(),
@@ -141,6 +144,11 @@ CREATE TABLE public.worksheets (
REFERENCES public.categories (id) ON UPDATE RESTRICT ON DELETE RESTRICT, REFERENCES public.categories (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheets_preview_asset_id_fkey FOREIGN KEY (preview_asset_id) CONSTRAINT worksheets_preview_asset_id_fkey FOREIGN KEY (preview_asset_id)
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT, REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheets_generation_mode_check CHECK (generation_mode IN ('procedural', 'resource')),
CONSTRAINT worksheets_renderer_key_check CHECK (
renderer_key IS NULL OR length(btrim(renderer_key)) > 0
),
CONSTRAINT worksheets_render_config_object_check CHECK (jsonb_typeof(render_config) = 'object'),
CONSTRAINT worksheets_age_check CHECK (age_min >= 0 AND age_max >= age_min), CONSTRAINT worksheets_age_check CHECK (age_min >= 0 AND age_max >= age_min),
CONSTRAINT worksheets_difficulty_check CHECK (difficulty BETWEEN 1 AND 4), CONSTRAINT worksheets_difficulty_check CHECK (difficulty BETWEEN 1 AND 4),
CONSTRAINT worksheets_status_check CHECK (status IN ('draft', 'active', 'hidden')), CONSTRAINT worksheets_status_check CHECK (status IN ('draft', 'active', 'hidden')),
@@ -156,16 +164,92 @@ CREATE INDEX worksheets_status_sort_idx
ON public.worksheets (status, sort_order, updated_at DESC, id); ON public.worksheets (status, sort_order, updated_at DESC, id);
CREATE INDEX worksheets_status_age_idx ON public.worksheets (status, age_min, age_max); CREATE INDEX worksheets_status_age_idx ON public.worksheets (status, age_min, age_max);
-- 年级与内容分类独立;同一 worksheet 可适用于多个(含不连续)年级。
-- 不从年龄或旧 grade 自动生成关系,避免把推算值当作已核实的适用范围。
CREATE TABLE public.worksheet_grades (
worksheet_id varchar(128) COLLATE "C" NOT NULL,
grade smallint NOT NULL,
CONSTRAINT worksheet_grades_pkey PRIMARY KEY (worksheet_id, grade),
CONSTRAINT worksheet_grades_worksheet_id_fkey FOREIGN KEY (worksheet_id)
REFERENCES public.worksheets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheet_grades_grade_check CHECK (grade BETWEEN -4 AND 6)
);
-- 联合主键支持查询功能的适用年级;反向索引用于年级菜单及筛选。
CREATE INDEX worksheet_grades_grade_worksheet_idx
ON public.worksheet_grades (grade, worksheet_id);
-- 资源条目代表一份完整可选择内容,不等于单个文件,也不是独立模板引擎。
CREATE TABLE public.worksheet_resources (
id varchar(128) COLLATE "C" PRIMARY KEY,
worksheet_id varchar(128) COLLATE "C" NOT NULL,
title varchar(100) NOT NULL,
preview_asset_id varchar(128) COLLATE "C" NOT NULL,
config_override jsonb,
status varchar(16) NOT NULL DEFAULT 'draft',
sort_order integer NOT NULL DEFAULT 0,
downloads bigint NOT NULL DEFAULT 0,
first_published_at timestamptz(3),
created_at timestamptz(3) NOT NULL DEFAULT now(),
updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT worksheet_resources_worksheet_id_fkey FOREIGN KEY (worksheet_id)
REFERENCES public.worksheets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheet_resources_preview_asset_id_fkey FOREIGN KEY (preview_asset_id)
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheet_resources_downloads_check CHECK (downloads >= 0),
CONSTRAINT worksheet_resources_status_check CHECK (status IN ('draft', 'active', 'hidden')),
CONSTRAINT worksheet_resources_config_object_check CHECK (
config_override IS NULL OR jsonb_typeof(config_override) = 'object'
)
);
CREATE INDEX worksheet_resources_worksheet_status_sort_idx
ON public.worksheet_resources (worksheet_id, status, sort_order, id);
CREATE INDEX worksheet_resources_preview_asset_idx ON public.worksheet_resources (preview_asset_id);
-- 首期仅支持线框和参考图;允许多个同用途文件,排序并列时按 id 确定顺序。
-- 文件格式由 assets 描述;是否打印、如何排列由 renderer 决定。
CREATE TABLE public.worksheet_resource_assets (
id varchar(128) COLLATE "C" PRIMARY KEY,
worksheet_resource_id varchar(128) COLLATE "C" NOT NULL,
asset_id varchar(128) COLLATE "C" NOT NULL,
role varchar(16) NOT NULL,
sort_order integer NOT NULL DEFAULT 0,
CONSTRAINT worksheet_resource_assets_resource_id_fkey FOREIGN KEY (worksheet_resource_id)
REFERENCES public.worksheet_resources (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheet_resource_assets_asset_id_fkey FOREIGN KEY (asset_id)
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheet_resource_assets_role_check CHECK (role IN ('outline', 'reference')),
CONSTRAINT worksheet_resource_assets_resource_role_asset_key
UNIQUE (worksheet_resource_id, role, asset_id)
);
CREATE INDEX worksheet_resource_assets_resource_role_sort_idx
ON public.worksheet_resource_assets (worksheet_resource_id, role, sort_order, id);
CREATE INDEX worksheet_resource_assets_asset_idx ON public.worksheet_resource_assets (asset_id);
CREATE TABLE public.favorites ( CREATE TABLE public.favorites (
id varchar(128) COLLATE "C" PRIMARY KEY, id varchar(128) COLLATE "C" PRIMARY KEY,
user_id varchar(128) COLLATE "C" NOT NULL, user_id varchar(128) COLLATE "C" NOT NULL,
worksheet_id varchar(128) COLLATE "C" NOT NULL, worksheet_id varchar(128) COLLATE "C" NOT NULL,
worksheet_resource_id varchar(128) COLLATE "C",
created_at timestamptz(3) NOT NULL DEFAULT now(), created_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT favorites_user_id_fkey FOREIGN KEY (user_id) CONSTRAINT favorites_user_id_fkey FOREIGN KEY (user_id)
REFERENCES public.users (id) ON UPDATE RESTRICT ON DELETE RESTRICT, REFERENCES public.users (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT favorites_user_worksheet_key UNIQUE (user_id, worksheet_id) CONSTRAINT favorites_resource_id_check CHECK (
worksheet_resource_id IS NULL OR length(btrim(worksheet_resource_id)) > 0
)
); );
-- NULL 表示功能收藏;非 NULL 表示完整内容收藏,不另加 type 字段。
-- 部分唯一索引分别去重,允许同用户收藏一个功能及其中多个资源。
CREATE UNIQUE INDEX favorites_user_worksheet_key
ON public.favorites (user_id, worksheet_id)
WHERE worksheet_resource_id IS NULL;
CREATE UNIQUE INDEX favorites_user_resource_key
ON public.favorites (user_id, worksheet_resource_id)
WHERE worksheet_resource_id IS NOT NULL;
CREATE INDEX favorites_user_created_at_idx CREATE INDEX favorites_user_created_at_idx
ON public.favorites (user_id, created_at DESC, id DESC); ON public.favorites (user_id, created_at DESC, id DESC);
@@ -173,6 +257,7 @@ CREATE TABLE public.download_logs (
id varchar(128) COLLATE "C" PRIMARY KEY, id varchar(128) COLLATE "C" PRIMARY KEY,
user_id varchar(128) COLLATE "C" NOT NULL, user_id varchar(128) COLLATE "C" NOT NULL,
worksheet_id varchar(128) COLLATE "C" NOT NULL, worksheet_id varchar(128) COLLATE "C" NOT NULL,
worksheet_resource_id varchar(128) COLLATE "C",
export_id varchar(128) COLLATE "C", export_id varchar(128) COLLATE "C",
source varchar(16) NOT NULL DEFAULT 'mini', source varchar(16) NOT NULL DEFAULT 'mini',
params jsonb, params jsonb,
@@ -270,19 +355,17 @@ CREATE INDEX admin_audit_logs_actor_created_at_idx
ON public.admin_audit_logs (actor_user_id, created_at DESC); ON public.admin_audit_logs (actor_user_id, created_at DESC);
CREATE INDEX admin_audit_logs_request_id_idx ON public.admin_audit_logs (request_id); CREATE INDEX admin_audit_logs_request_id_idx ON public.admin_audit_logs (request_id);
COMMENT ON TABLE public.users IS '微信用户;沿用 CloudBase 字符串 ID,按 AppID + OpenID 唯一'; COMMENT ON TABLE public.users IS '微信用户;沿用 CloudBase 字符串 ID,按 OpenID 唯一;AppID 由服务端配置';
COMMENT ON COLUMN public.users.total_downloads IS '旧字段迁移兼容值;个人下载数量以 download_logs 查询为准'; COMMENT ON COLUMN public.users.total_downloads IS '旧字段迁移兼容值;个人下载数量以 download_logs 查询为准';
COMMENT ON COLUMN public.users.auth_version IS '应用凭证版本;服务端鉴权校验,不由 SQL 自动递增'; COMMENT ON COLUMN public.users.auth_version IS '应用凭证版本;服务端鉴权校验,不由 SQL 自动递增';
COMMENT ON TABLE public.assets IS '七牛文件引用与旧资源迁移映射;不保存临时签名 URL'; COMMENT ON TABLE public.assets IS '七牛文件引用与旧资源迁移映射;不保存临时签名 URL';
COMMENT ON COLUMN public.assets.object_ref_hash IS '规范序列化 [provider,bucket,objectKey] 的 SHA-256;服务端生成并核对原引用'; COMMENT ON COLUMN public.assets.object_ref_hash IS '规范序列化 [provider,bucket,objectKey] 的 SHA-256;服务端生成并核对原引用';
COMMENT ON COLUMN public.assets.checksum IS '文件内容 SHA-256,不是七牛 ETag;非唯一'; COMMENT ON COLUMN public.assets.checksum IS '文件内容 SHA-256,不是七牛 ETag;非唯一';
COMMENT ON TABLE public.categories IS '分类树;多层循环需由服务端事务检查';
COMMENT ON TABLE public.worksheets IS '题型内容与真实/种子计数;资源通过 assets 引用'; COMMENT ON TABLE public.worksheets IS '题型内容与真实/种子计数;资源通过 assets 引用';
COMMENT ON COLUMN public.worksheets.content_updated_at IS '仅内容修改时更新;不随计数变化;历史缺失保留 NULL'; COMMENT ON COLUMN public.worksheets.content_updated_at IS '仅内容修改时更新;不随计数变化;历史缺失保留 NULL';
COMMENT ON COLUMN public.worksheets.updated_at IS '由服务显式更新;包含计数、状态、内容变更'; COMMENT ON COLUMN public.worksheets.updated_at IS '由服务显式更新;包含计数、状态、内容变更';
COMMENT ON COLUMN public.worksheets.tags IS '字符串数组;数据库限制顶层 array,服务端校验元素'; COMMENT ON COLUMN public.worksheets.tags IS '字符串数组;数据库限制顶层 array,服务端校验元素';
COMMENT ON TABLE public.favorites IS '当前收藏关系;取消收藏删除关系并由业务事务扣减 likes';
COMMENT ON COLUMN public.favorites.worksheet_id IS '题型逻辑关联,不建外键,允许保留缺失题型的历史收藏';
COMMENT ON TABLE public.download_logs IS '成功保存历史;同用户 exportId 唯一,日志与计数由业务事务同步'; COMMENT ON TABLE public.download_logs IS '成功保存历史;同用户 exportId 唯一,日志与计数由业务事务同步';
COMMENT ON COLUMN public.download_logs.worksheet_id IS '题型逻辑关联,不建外键,历史查询使用 LEFT JOIN'; COMMENT ON COLUMN public.download_logs.worksheet_id IS '题型逻辑关联,不建外键,历史查询使用 LEFT JOIN';
COMMENT ON COLUMN public.download_logs.export_id IS '历史 import 可为 NULL;mini/legacy 必须提供非空事件 ID'; COMMENT ON COLUMN public.download_logs.export_id IS '历史 import 可为 NULL;mini/legacy 必须提供非空事件 ID';
@@ -291,4 +374,31 @@ COMMENT ON TABLE public.settings IS '运行配置;value 保留原文档业务
COMMENT ON TABLE public.job_runs IS '任务逻辑去重、租约与执行结果;内容更新仍需业务行锁'; COMMENT ON TABLE public.job_runs IS '任务逻辑去重、租约与执行结果;内容更新仍需业务行锁';
COMMENT ON TABLE public.admin_audit_logs IS '管理变更审计;操作者为逻辑关联,注销需主动去关联'; COMMENT ON TABLE public.admin_audit_logs IS '管理变更审计;操作者为逻辑关联,注销需主动去关联';
COMMENT ON COLUMN public.worksheets.generation_mode IS 'procedural 算法生成;resource 完整内容资源驱动;旧数据默认 procedural,导入逐项核对';
COMMENT ON COLUMN public.worksheets.renderer_key IS '客户端绘制实现标识;多题型可共用;NULL 仅供旧版 ID/path 映射兼容,新功能发布必须明确';
COMMENT ON COLUMN public.worksheets.render_config IS '共用绘制参数;仅允许已定义参数,不存执行代码或密钥';
COMMENT ON TABLE public.worksheet_resources IS 'worksheet 下可选择的完整内容;独立预览及轻量配置覆盖';
COMMENT ON COLUMN public.worksheet_resources.preview_asset_id IS '内容列表预览图;与绘制参考图用途独立,允许引用同一资产';
COMMENT ON COLUMN public.worksheet_resources.config_override IS '可选参数覆盖;应用按 renderer 白名单校验,不支持任意深度合并';
COMMENT ON TABLE public.worksheet_resource_assets IS '完整内容的文件输入;outline 线框,reference 对照图;不保存文件级配置';
COMMENT ON COLUMN public.worksheet_resource_assets.role IS '业务用途,不是文件格式;用途对应的文件数量、类型和是否打印由 renderer 校验';
COMMENT ON COLUMN public.worksheet_resource_assets.sort_order IS '同用途文件的处理顺序,并列按 id 排序;不等于通用图层 z-index';
COMMENT ON COLUMN public.download_logs.worksheet_resource_id IS '可空资源条目逻辑关联;历史和算法生成可空;新写入校验属于同一 worksheet';
COMMENT ON TABLE public.worksheet_grades IS '功能适用年级的唯一权威关系;与内容分类独立,不从年龄自动推算';
COMMENT ON COLUMN public.worksheet_grades.grade IS '-4 托班、-3 小班、-2 中班、-1 大班、0 幼小衔接、1至6 小学年级;无关系表示未配置,不表示全年级';
COMMENT ON COLUMN public.worksheets.grade IS '旧版单年级兼容值;可能由年龄推算;保留原值,新年级筛选使用 worksheet_grades';
COMMENT ON TABLE public.categories IS '内容分类树,与适用年级独立;多层循环需由服务端事务检查';
COMMENT ON COLUMN public.worksheets.first_published_at IS '首次公开发布时间;新功能由发布事务写入,重发/新增资源不重置;旧时间未知保持 NULL';
COMMENT ON COLUMN public.worksheets.is_new IS '旧版新品兼容标记;新功能新品由首次发布时间及时间窗口派生,不作为新业务权威值';
COMMENT ON COLUMN public.worksheets.is_hot IS '功能级人工运营推荐标记,不是下载排行榜计算结果,不自动传递给资源';
COMMENT ON COLUMN public.worksheets.downloads IS '功能累计成功保存次数;保留旧累计值,资源下载也计入,不等于资源计数总和';
COMMENT ON COLUMN public.worksheet_resources.first_published_at IS '内容首次公开发布时间;用于资源新品判断,草稿/重发不重置,未知旧时间保持 NULL';
COMMENT ON COLUMN public.worksheet_resources.downloads IS '内容累计成功保存次数;仅有可信资源归属的新事件递增,不分摊功能旧计数,不因个人日志删除回减';
COMMENT ON TABLE public.favorites IS '功能和完整内容的当前收藏;取消即删除;仅功能收藏增减 worksheets.likes';
COMMENT ON COLUMN public.favorites.worksheet_id IS '稳定功能逻辑关联;内容收藏的功能归属由服务端查询确定,历史缺失时保留';
COMMENT ON COLUMN public.favorites.worksheet_resource_id IS 'NULL 是功能收藏,非 NULL 是完整内容收藏;逻辑关联,缺失/下架不清空,不退化为功能收藏';
COMMIT; COMMIT;
@@ -0,0 +1,16 @@
-- 已执行旧版 002 的数据库使用本增量 SQL;新建库仅执行最新 002/003。
-- 首期只有一个小程序,AppID 由服务端配置,OpenID 直接唯一。
-- 先创建更严格的唯一约束;若存在跨应用重复 OpenID,则事务回滚,保留原数据。
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL search_path = public, pg_catalog;
ALTER TABLE public.users
ADD CONSTRAINT users_openid_key UNIQUE (openid);
ALTER TABLE public.users
DROP CONSTRAINT users_mini_app_openid_key;
ALTER TABLE public.users
DROP COLUMN mini_app_id;
COMMENT ON TABLE public.users IS '微信用户;沿用 CloudBase 字符串 ID,按 OpenID 唯一;AppID 由服务端配置';
COMMIT;
@@ -0,0 +1,84 @@
-- 已执行旧版 002 的库在完成 004 后执行;最新空库基线不执行 004/005。
-- 保留旧 ID/path、计数、配置和历史;不伪造资源条目或自动推断绘制方法。
BEGIN;
SET LOCAL TIME ZONE 'UTC';
SET LOCAL lock_timeout = '5s';
SET LOCAL search_path = public, pg_catalog;
ALTER TABLE public.worksheets
ADD COLUMN generation_mode varchar(16) NOT NULL DEFAULT 'procedural',
ADD COLUMN renderer_key varchar(64) COLLATE "C",
ADD COLUMN render_config jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD CONSTRAINT worksheets_generation_mode_check CHECK (generation_mode IN ('procedural', 'resource')),
ADD CONSTRAINT worksheets_renderer_key_check CHECK (
renderer_key IS NULL OR length(btrim(renderer_key)) > 0
),
ADD CONSTRAINT worksheets_render_config_object_check CHECK (jsonb_typeof(render_config) = 'object');
ALTER TABLE public.assets DROP CONSTRAINT assets_purpose_check;
ALTER TABLE public.assets ADD CONSTRAINT assets_purpose_check CHECK (
purpose IN ('avatar', 'preview', 'category_icon', 'font', 'share', 'page_config', 'worksheet_source', 'other')
);
-- 资源条目代表一份完整可选择内容,不等于单个文件,也不是独立模板引擎。
CREATE TABLE public.worksheet_resources (
id varchar(128) COLLATE "C" PRIMARY KEY,
worksheet_id varchar(128) COLLATE "C" NOT NULL,
title varchar(100) NOT NULL,
preview_asset_id varchar(128) COLLATE "C" NOT NULL,
config_override jsonb,
status varchar(16) NOT NULL DEFAULT 'draft',
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz(3) NOT NULL DEFAULT now(),
updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT worksheet_resources_worksheet_id_fkey FOREIGN KEY (worksheet_id)
REFERENCES public.worksheets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheet_resources_preview_asset_id_fkey FOREIGN KEY (preview_asset_id)
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheet_resources_status_check CHECK (status IN ('draft', 'active', 'hidden')),
CONSTRAINT worksheet_resources_config_object_check CHECK (
config_override IS NULL OR jsonb_typeof(config_override) = 'object'
)
);
CREATE INDEX worksheet_resources_worksheet_status_sort_idx
ON public.worksheet_resources (worksheet_id, status, sort_order, id);
CREATE INDEX worksheet_resources_preview_asset_idx ON public.worksheet_resources (preview_asset_id);
-- 首期仅支持线框和参考图;允许多个同用途文件,排序并列时按 id 确定顺序。
-- 文件格式由 assets 描述;是否打印、如何排列由 renderer 决定。
CREATE TABLE public.worksheet_resource_assets (
id varchar(128) COLLATE "C" PRIMARY KEY,
worksheet_resource_id varchar(128) COLLATE "C" NOT NULL,
asset_id varchar(128) COLLATE "C" NOT NULL,
role varchar(16) NOT NULL,
sort_order integer NOT NULL DEFAULT 0,
CONSTRAINT worksheet_resource_assets_resource_id_fkey FOREIGN KEY (worksheet_resource_id)
REFERENCES public.worksheet_resources (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheet_resource_assets_asset_id_fkey FOREIGN KEY (asset_id)
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheet_resource_assets_role_check CHECK (role IN ('outline', 'reference')),
CONSTRAINT worksheet_resource_assets_resource_role_asset_key
UNIQUE (worksheet_resource_id, role, asset_id)
);
CREATE INDEX worksheet_resource_assets_resource_role_sort_idx
ON public.worksheet_resource_assets (worksheet_resource_id, role, sort_order, id);
CREATE INDEX worksheet_resource_assets_asset_idx ON public.worksheet_resource_assets (asset_id);
-- 与 worksheet_id 一样保留逻辑关联,允许缺失内容的历史仍可读取。
ALTER TABLE public.download_logs
ADD COLUMN worksheet_resource_id varchar(128) COLLATE "C";
COMMENT ON COLUMN public.worksheets.generation_mode IS 'procedural 算法生成;resource 完整内容资源驱动;旧数据默认 procedural,导入逐项核对';
COMMENT ON COLUMN public.worksheets.renderer_key IS '客户端绘制实现标识;多题型可共用;NULL 仅供旧版 ID/path 映射兼容,新功能发布必须明确';
COMMENT ON COLUMN public.worksheets.render_config IS '共用绘制参数;仅允许已定义参数,不存执行代码或密钥';
COMMENT ON TABLE public.worksheet_resources IS 'worksheet 下可选择的完整内容;独立预览及轻量配置覆盖';
COMMENT ON COLUMN public.worksheet_resources.preview_asset_id IS '内容列表预览图;与绘制参考图用途独立,允许引用同一资产';
COMMENT ON COLUMN public.worksheet_resources.config_override IS '可选参数覆盖;应用按 renderer 白名单校验,不支持任意深度合并';
COMMENT ON TABLE public.worksheet_resource_assets IS '完整内容的文件输入;outline 线框,reference 对照图;不保存文件级配置';
COMMENT ON COLUMN public.worksheet_resource_assets.role IS '业务用途,不是文件格式;用途对应的文件数量、类型和是否打印由 renderer 校验';
COMMENT ON COLUMN public.worksheet_resource_assets.sort_order IS '同用途文件的处理顺序,并列按 id 排序;不等于通用图层 z-index';
COMMENT ON COLUMN public.download_logs.worksheet_resource_id IS '可空资源条目逻辑关联;历史和算法生成可空;新写入校验属于同一 worksheet';
COMMIT;
@@ -0,0 +1,28 @@
-- 已有库完成 004/005 后执行;最新空库基线已经包含此结构,不执行本文件。
-- 保留旧 worksheets.grade,不自动回填;经审核的年级映射由导入/管理业务显式写入。
BEGIN;
SET LOCAL TIME ZONE 'UTC';
SET LOCAL lock_timeout = '5s';
SET LOCAL search_path = public, pg_catalog;
-- 年级与内容分类独立;同一 worksheet 可适用于多个(含不连续)年级。
-- 不从年龄或旧 grade 自动生成关系,避免把推算值当作已核实的适用范围。
CREATE TABLE public.worksheet_grades (
worksheet_id varchar(128) COLLATE "C" NOT NULL,
grade smallint NOT NULL,
CONSTRAINT worksheet_grades_pkey PRIMARY KEY (worksheet_id, grade),
CONSTRAINT worksheet_grades_worksheet_id_fkey FOREIGN KEY (worksheet_id)
REFERENCES public.worksheets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT worksheet_grades_grade_check CHECK (grade BETWEEN -4 AND 6)
);
-- 联合主键支持查询功能的适用年级;反向索引用于年级菜单及筛选。
CREATE INDEX worksheet_grades_grade_worksheet_idx
ON public.worksheet_grades (grade, worksheet_id);
COMMENT ON TABLE public.worksheet_grades IS '功能适用年级的唯一权威关系;与内容分类独立,不从年龄自动推算';
COMMENT ON COLUMN public.worksheet_grades.grade IS '-4 托班、-3 小班、-2 中班、-1 大班、0 幼小衔接、1至6 小学年级;无关系表示未配置,不表示全年级';
COMMENT ON COLUMN public.worksheets.grade IS '旧版单年级兼容值;可能由年龄推算;保留原值,新年级筛选使用 worksheet_grades';
COMMENT ON TABLE public.categories IS '内容分类树,与适用年级独立;多层循环需由服务端事务检查';
COMMIT;
@@ -0,0 +1,22 @@
-- 已有库完成 004/005/006 后执行;最新空库基线包含此结构,不执行本文件。
-- 不用创建时间/升级时间冒充首次发布,不将功能历史计数分摊到资源。
BEGIN;
SET LOCAL TIME ZONE 'UTC';
SET LOCAL lock_timeout = '5s';
SET LOCAL search_path = public, pg_catalog;
ALTER TABLE public.worksheets
ADD COLUMN first_published_at timestamptz(3);
ALTER TABLE public.worksheet_resources
ADD COLUMN first_published_at timestamptz(3),
ADD COLUMN downloads bigint NOT NULL DEFAULT 0,
ADD CONSTRAINT worksheet_resources_downloads_check CHECK (downloads >= 0);
COMMENT ON COLUMN public.worksheets.first_published_at IS '首次公开发布时间;新功能由发布事务写入,重发/新增资源不重置;旧时间未知保持 NULL';
COMMENT ON COLUMN public.worksheets.is_new IS '旧版新品兼容标记;新功能新品由首次发布时间及时间窗口派生,不作为新业务权威值';
COMMENT ON COLUMN public.worksheets.is_hot IS '功能级人工运营推荐标记,不是下载排行榜计算结果,不自动传递给资源';
COMMENT ON COLUMN public.worksheets.downloads IS '功能累计成功保存次数;保留旧累计值,资源下载也计入,不等于资源计数总和';
COMMENT ON COLUMN public.worksheet_resources.first_published_at IS '内容首次公开发布时间;用于资源新品判断,草稿/重发不重置,未知旧时间保持 NULL';
COMMENT ON COLUMN public.worksheet_resources.downloads IS '内容累计成功保存次数;仅有可信资源归属的新事件递增,不分摊功能旧计数,不因个人日志删除回减';
COMMIT;
@@ -0,0 +1,29 @@
-- 已有库完成 004/005/006/007 后执行;最新空库基线包含此结构,不执行本文件。
-- 旧收藏全部保持功能收藏(新列 NULL),不猜测具体底图,不改旧 likes。
-- 删除旧唯一约束并创建两种部分唯一索引均在同一事务内完成。
BEGIN;
SET LOCAL TIME ZONE 'UTC';
SET LOCAL lock_timeout = '5s';
SET LOCAL search_path = public, pg_catalog;
ALTER TABLE public.favorites
ADD COLUMN worksheet_resource_id varchar(128) COLLATE "C",
ADD CONSTRAINT favorites_resource_id_check CHECK (
worksheet_resource_id IS NULL OR length(btrim(worksheet_resource_id)) > 0
);
ALTER TABLE public.favorites DROP CONSTRAINT favorites_user_worksheet_key;
-- NULL 表示功能收藏;非 NULL 表示完整内容收藏,不另加 type 字段。
-- 部分唯一索引分别去重,允许同用户收藏一个功能及其中多个资源。
CREATE UNIQUE INDEX favorites_user_worksheet_key
ON public.favorites (user_id, worksheet_id)
WHERE worksheet_resource_id IS NULL;
CREATE UNIQUE INDEX favorites_user_resource_key
ON public.favorites (user_id, worksheet_resource_id)
WHERE worksheet_resource_id IS NOT NULL;
COMMENT ON TABLE public.favorites IS '功能和完整内容的当前收藏;取消即删除;仅功能收藏增减 worksheets.likes';
COMMENT ON COLUMN public.favorites.worksheet_id IS '稳定功能逻辑关联;内容收藏的功能归属由服务端查询确定,历史缺失时保留';
COMMENT ON COLUMN public.favorites.worksheet_resource_id IS 'NULL 是功能收藏,非 NULL 是完整内容收藏;逻辑关联,缺失/下架不清空,不退化为功能收藏';
COMMIT;
+141 -8
View File
@@ -4,17 +4,35 @@
2026-10-12 已在本地 PostgreSQL 18.6 临时数据库执行 001/002/003,并重复执行 003 验证不覆盖 enabled=false;10 张表与 19 项关键约束检查通过,历史 NULL 事件、标识大小写及资产所有者删除行为符合设计。验证库已删除;随后已在用户授权下正式初始化 doodle_dev,10 张表与两条配置记录核对通过。 2026-10-12 已在本地 PostgreSQL 18.6 临时数据库执行 001/002/003,并重复执行 003 验证不覆盖 enabled=false;10 张表与 19 项关键约束检查通过,历史 NULL 事件、标识大小写及资产所有者删除行为符合设计。验证库已删除;随后已在用户授权下正式初始化 doodle_dev,10 张表与两条配置记录核对通过。
| 文件 | 作用 | 执行位置 | | 文件 | 作用 | 执行位置 |
| ------------------------------------------------------------ | ---------------------------------------------- | ---------------------------------------------------------- | | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------- |
| [001-create-database.sql](./001-create-database.sql) | 创建 UTF-8 / C locale / UTC 的 doodle_dev | 连接 postgres 管理数据库,有 CREATEDB 权限;不能包在事务里 | | [001-create-database.sql](./001-create-database.sql) | 创建 UTF-8 / C locale / UTC 的 doodle_dev | 连接 postgres 管理数据库,有 CREATEDB 权限;不能包在事务里 |
| [002-create-tables.sql](./002-create-tables.sql) | 创建 10 张表、约束、索引与注释 | 连接目标空数据库;单事务执行 | | [002-create-tables.sql](./002-create-tables.sql) | 创建 13 张表、约束、索引与注释 | 连接目标空数据库;单事务执行 |
| [003-initialize-settings.sql](./003-initialize-settings.sql) | 初始化 current 页面配置及 homeAutoRefresh 开关 | 连接已建表数据库;可重复执行且不覆盖旧值 | | [003-initialize-settings.sql](./003-initialize-settings.sql) | 初始化 current 页面配置及 homeAutoRefresh 开关 | 连接已建表数据库;可重复执行且不覆盖旧值 |
| [004-remove-users-mini-app-id.sql](./004-remove-users-mini-app-id.sql) | 单小程序身份简化,OpenID 唯一 | 仅旧版基线库,成功后不重复执行 |
| [005-add-worksheet-resources.sql](./005-add-worksheet-resources.sql) | 增加绘制配置、多文件内容及下载内容标识 | 仅未执行此升级的既有库,单事务执行 |
| [006-add-worksheet-grades.sql](./006-add-worksheet-grades.sql) | 增加功能适用年级关系 | 已完成 004/005 的既有库;不自动回填旧 grade |
| [007-add-publication-and-resource-downloads.sql](./007-add-publication-and-resource-downloads.sql) | 功能/资源首次发布时间及资源下载量 | 已完成 004/005/006 的既有库;旧时间不推算、旧计数不分摊 |
| [008-add-resource-favorites.sql](./008-add-resource-favorites.sql) | 统一收藏功能与具体内容、两种部分唯一索引 | 已完成 004~007 的既有库,旧收藏保持功能目标 |
Docker 已在首次启动时创建 doodle_dev,**当前本地跳过 001**。其他环境显式修改 001 的数据库名并用对应角色执行;本次不自动创建云端数据库、生产账号或测试库,不在 SQL 中硬编码密码。生产角色和权限在部署时落实。 Docker 已在首次启动时创建 doodle_dev,**当前本地跳过 001**。其他环境显式修改 001 的数据库名并用对应角色执行;本次不自动创建云端数据库、生产账号或测试库,不在 SQL 中硬编码密码。生产角色和权限在部署时落实。
## 本地执行 ## 最新执行路径
从 `doodle-store` 根目录执行,使用根 `.env` 指定的本地数据库: 当前本地 doodle_dev 已执行至 008,使用最新 13 张表;不重跑 002 或任何已成功的增量 SQL。
| 数据库当前状态 | 升级至最新的执行顺序 |
| ---------------------- | ------------------------------------------------------ |
| 空库 | 002 → 003;未建库时先执行 001,Docker 已建库则跳过 001 |
| 最初 10 张表的旧基线库 | 004 → 005 → 006 → 007 → 008 |
| 已执行部分增量的库 | 按编号顺序执行尚未应用的后续文件 |
| 已执行 008 的库 | 无需再次执行 DDL |
004~008 是历史结构的升级文件,不能用于已执行最新 002 的空库基线。下文各阶段记录解释该次变更,不表示只完成其中某一步就已到最新结构。Prisma 迁移历史尚未接入,执行前依据实际结构及执行记录确认当前版本。
## 空库初始化示例
从 `doodle-store` 根目录执行,以下仅适用于尚未建表的目标库。根 `.env` 当前指向已初始化的 doodle_dev,不在现有库再次运行 002。
```bash ```bash
pnpm infra:up pnpm infra:up
@@ -36,7 +54,7 @@ SELECT id, version FROM public.page_configs;
SELECT id, value FROM public.settings; SELECT id, value FROM public.settings;
``` ```
预期业务表共 10 张,页面配置有 current,设置有 homeAutoRefresh。2026-10-12 已在本地 doodle_dev 执行 002/003 并核对通过,当前已有 10 张表;不要再次执行 002。独立临时库验证与正式本地初始化分别记录,未连接云服务器。 最新基线预期业务表共 13 张,页面配置有 current,设置有 homeAutoRefresh。2026-10-12 已在本地 doodle_dev 执行 002/003 并核对通过,当时已有 10 张表;不要再次执行 002。独立临时库验证与正式本地初始化分别记录,未连接云服务器。
## 实施边界 ## 实施边界
@@ -47,3 +65,118 @@ SELECT id, value FROM public.settings;
- tags 元素类型、分类多层循环、任务租约/锁、JWT 失效与管理员权限由后续业务实现;SQL 不代表业务 API 已完成。 - tags 元素类型、分类多层循环、任务租约/锁、JWT 失效与管理员权限由后续业务实现;SQL 不代表业务 API 已完成。
- updatedAt 和 contentUpdatedAt 由服务显式维护,不添加把两者混为一谈的更新时间触发器。 - updatedAt 和 contentUpdatedAt 由服务显式维护,不添加把两者混为一谈的更新时间触发器。
- Prisma 尚未安装,Schema 和迁移历史尚未接入。后续接入时将本 SQL 纳入正式基线;已有表的库须采用基线登记流程,不能直接再次执行同一 DDL。 - Prisma 尚未安装,Schema 和迁移历史尚未接入。后续接入时将本 SQL 纳入正式基线;已有表的库须采用基线登记流程,不能直接再次执行同一 DDL。
## 单小程序身份简化(2026-10-14)
最新 002 已移除 users.mini_app_id,改为 users.openid 唯一,AppID 由服务端配置。
已执行旧版 002 的库使用 `004-remove-users-mini-app-id.sql` 增量升级;不要重跑 002。
新建库执行最新 002/003 即可,不执行 004。004 会先建立 OpenID 唯一约束,
如存在重复则全部回滚,不自动合并或删除用户;成功执行后不可重复执行。
2026-10-14 已在本地 doodle_dev 执行 004 并核对:mini_app_id 已移除,users_openid_key 为 UNIQUE (openid);执行时用户表为空。
## Worksheet 多资源扩展(2026-10-14)
设计原因见 [Worksheet 数据库设计说明](../../../../docs/v3.5/Worksheet数据库设计说明.md)。
最新 002 新建 13 张表;worksheet 增加 generation_mode、renderer_key、render_config,新增
worksheet_resources、worksheet_resource_assets,assets 支持 worksheet_source 用途,
download_logs 增加可空 worksheet_resource_id。文件关联首期只有 outline/reference,无文件级配置。
- 新空库:执行最新 002 → 003;不要执行 004/005/006/007/008,它们的结果已经包含在基线里。
- 2026-10-12 旧基线库:先执行 004(若尚未执行),再执行 005。
- 已执行 004 的本地库:只执行 005;不要重跑 002 或 004。
- 005 为单事务增量升级,成功后不可重复执行;不使用 IF NOT EXISTS 隐藏结构差异。
```bash
docker compose --env-file .env -f infra/compose.yaml exec -T postgres \
sh -c 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< apps/server/prisma/sql/005-add-worksheet-resources.sql
```
旧 worksheet 的 ID/path、计数、预览及历史不变;新列默认 procedural、NULL renderer_key 和 {}。
导入时按实际功能核对,NULL renderer_key 仅供已登记的旧版映射兼容;新功能发布必须明确实现。
不把旧题型预览自动转为线框或参考资源,资源新表默认空。下载资源 ID 为逻辑关联,服务端需校验归属。
2026-10-14 验证记录:在两个隔离临时库分别执行最新 002/003 与旧版 002/003 + 005,
列定义、约束、索引及注释一致(忽略新增列的物理顺序)。两条路径均通过多线框、共享资产、
双用途引用、独立预览、JSON/状态/唯一性/外键/删除限制、旧数据保留与历史逻辑关联检查。
临时库均已删除;本地 doodle_dev 已执行 005,核对为 12 张表,current/homeAutoRefresh 保留,
两张资源新表为空,未导入测试内容或连接生产库。
## 多适用年级(2026-10-14)
categories 结构保持不变,新增 worksheet_grades(worksheet_id、grade),联合主键防重复,
反向索引供年级菜单筛选。固定编码 -4~6,0 为幼小衔接;不新增年级字典或菜单表。
旧 worksheets.grade 保留原值,关系表作为新适用年级筛选的唯一权威来源。
最新空库执行 002/003,跳过 004/005/006/007/008;已执行 005 的库仅执行 006。
更早的库先按版本补齐未执行的增量文件,不重复执行成功的 SQL。006 单事务、非重复执行脚本,
不自动从旧 grade 或年龄回填关系;经审核的 worksheetId → grades 映射另行导入。
无关系表示未配置,不表示全年级;年级筛选上线前完成对应题型的映射审核。
```bash
docker compose --env-file .env -f infra/compose.yaml exec -T postgres \
sh -c 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< apps/server/prisma/sql/006-add-worksheet-grades.sql
```
2026-10-14 验证:最新 002/003 与旧版基线 + 006 在两个隔离库中生成相同的列、约束、
索引及注释;多/不连续年级、合法编码边界、联合主键、外键、删除限制、旧值保留、
不自动回填及分类/年级组合查询不重复检查通过。临时库已删除。
本地 doodle_dev 已执行 006,共 13 张表;worksheet_grades 为空,初始配置保留。
## 首次发布与资源下载统计(2026-10-14)
最新空库只执行 002/003;已执行 006 的既有库执行 007,不重跑旧迁移。
007 在功能及内容上增加可空 first_published_at(无默认值),内容增加非负 downloads(默认 0)。
旧 is_new 保留为兼容值,is_hot 保留为功能运营推荐;不新增资源新品/热门布尔值、收藏计数或 seed。
未知旧发布时间保持 NULL,首次发布/新品派生及下载幂等事务由后续服务端业务实现。
文件访问、日志数量、创建时间和升级时间都不能冒充真实历史依据。
```bash
docker compose --env-file .env -f infra/compose.yaml exec -T postgres \
sh -c 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< apps/server/prisma/sql/007-add-publication-and-resource-downloads.sql
```
新结构仍为 13 张表。正常资源下载的日志及两层 downloads 由同一事务维护,不使用触发器自动加数。
功能原累计值不覆盖或分摊,资源默认 0;旧单独保存的 is_new 不变,不能据此伪造时间。
2026-10-14 验证:最新 002/003 与旧版基线 + 007 在两个临时库生成相同列、约束、索引及注释。
旧标记/计数/未知发布时间保留、资源默认 0、非负约束和独立发布时间检查通过;
SQL 事务示例通过重复事件不加数、任一步失败全回滚、删除日志不扣累计的验证。
这些验证不代表真实下载 API 已实现。临时库已清理;本地 doodle_dev 已执行 007,仍为 13 张表,
新时间列可空且无默认值,资源 downloads 默认 0、NOT NULL 且非负,原初始配置保留。
## 统一功能与内容收藏(2026-10-14)
最新空库执行 002/003,跳过 004~008;已执行 007 的既有库仅执行 008,不重复已成功 SQL。
008 新增 favorites.worksheet_resource_id,旧记录为 NULL;撤销旧全局唯一约束,
创建功能和内容两个部分唯一索引。不新增资源收藏表/type 字段或资源 likes,仍为 13 张表。
旧收藏 ID、时间、归属和功能 likes 不变。新内容 ID 为逻辑关联,归属由服务校验,缺失不 SET NULL。
```bash
docker compose --env-file .env -f infra/compose.yaml exec -T postgres \
sh -c 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< apps/server/prisma/sql/008-add-resource-favorites.sql
```
后续收藏 INSERT 的 ON CONFLICT 需带对应部分索引谓词,Prisma 接入时保留原生索引。
旧查询/数量/取消加 IS NULL;新资源关系不增减功能 likes,注销清理两类但仅扣功能贡献。
本次仅修改数据库与接口契约,资源收藏 API 及小程序页面尚待实现。
2026-10-14 验证:最新基线与旧版基线 + 008 的列、约束、索引、注释一致;
旧收藏保留、功能和同功能多资源共存、两种部分唯一索引/ON CONFLICT、用户隔离、
旧功能取消范围、资源取消不扣 likes、下架/失联目标保留及注销 SQL 示例通过。
临时验证库已删除;本地 doodle_dev 已执行 008,仍为 13 张表,两种部分索引生效,
原配置保留,未导入测试收藏。验证不代表业务 API 或小程序资源收藏页面已经实现。
## 最终对齐核查(2026-10-14)
- 设计文档与最新 002 的 13 张表、135 个字段名称/类型/可空/默认值一致。
- 实测 001 建隔离库;最新 002/003、原始旧基线 + 004~008 全链路、本地 doodle_dev 的列、约束、索引和注释一致,忽略物理列顺序。
- 完整升级保留旧身份、功能、收藏、历史及计数;003 重复执行不覆盖关闭开关、页面内容及版本。
- 最新基线下的资源、年级、发布/下载、两种收藏约束与 SQL 事务示例回归通过,标识列 C 排序规则核对通过。
- 所有临时核查库已清理;开发库只读比对,无样本写入。本次只修正文档说明,无需新增 DDL 或再次升级开发库。
业务归属/权限、首次发布、计数和审计事务、任务调度等仍需服务端接入;SQL 验证不代替业务 API 验收。
+31
View File
@@ -0,0 +1,31 @@
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { configureApplication } from './app.setup';
import { getServerConfig, type ServerConfig } from './config/server-config';
import { AppLogger } from './logging/app-logger';
/** 创建并配置应用,但不监听端口;启动入口和测试均通过此工厂组装服务。 */
export async function createApplication(
config: ServerConfig = getServerConfig(),
logger = new AppLogger(config.logLevel),
): Promise<NestExpressApplication> {
const app = await NestFactory.create<NestExpressApplication>(
AppModule.forRoot(config),
{
// 解析器由 configureApplication 显式安装,确保大小限制和注册顺序一致。
bodyParser: false,
logger,
// 将初始化错误交给调用方处理,不在 Nest 内部直接退出整个进程。
abortOnError: false,
},
);
try {
configureApplication(app, config, logger);
return app;
} catch (error) {
// 配置阶段失败也关闭已经建立的资源,避免测试或重复启动遗留句柄。
await app.close();
throw error;
}
}
+13 -2
View File
@@ -1,7 +1,18 @@
import { Module } from '@nestjs/common'; import { Module, type DynamicModule } from '@nestjs/common';
import { HealthController } from './health/health.controller'; import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module';
import { ServerConfigModule, type ServerConfig } from './config/server-config';
@Module({ @Module({
imports: [AuthModule],
controllers: [HealthController], controllers: [HealthController],
}) })
export class AppModule {} export class AppModule {
/** 注入调用方已经校验的配置;同一模块结构用于生产启动和隔离测试。 */
static forRoot(config: ServerConfig): DynamicModule {
return {
module: AppModule,
imports: [ServerConfigModule.forRoot(config)],
};
}
}
+104 -3
View File
@@ -1,6 +1,107 @@
import type { INestApplication } from '@nestjs/common'; import { randomUUID } from 'node:crypto';
import { ValidationPipe } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { NextFunction, Response } from 'express';
import helmet from 'helmet';
import { API_PREFIX, type ServerConfig } from './config/server-config';
import { AppLogger } from './logging/app-logger';
import type { HttpRequest } from './common/http-request';
import { ApiErrorFilter } from './common/api-error.filter';
export function configureApplication(app: INestApplication): void { /** 在业务路由初始化前安装 HTTP 基础设施;生产和测试复用相同处理顺序。 */
app.setGlobalPrefix('api/v1'); export function configureApplication(
app: NestExpressApplication,
config: ServerConfig,
logger: AppLogger,
): void {
app.useLogger(logger);
const express = app.getHttpAdapter().getInstance();
express.disable('x-powered-by');
// 仅信任部署明确声明的代理地址,默认不采信客户端伪造的转发头。
if (config.trustedProxyCidrs.length)
express.set('trust proxy', config.trustedProxyCidrs);
// 解析 body 前安装,格式错误和超限响应也携带 requestId。
app.use((request: HttpRequest, response: Response, next: NextFunction) => {
const suppliedId = request.headers['x-request-id'];
// 合法上游 ID 可以贯穿调用链;其余输入替换为服务端生成的标识。
const requestId =
typeof suppliedId === 'string' &&
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
? suppliedId
: `req_${randomUUID()}`;
const startedAt = performance.now();
request.requestId = requestId;
response.setHeader('X-Request-Id', requestId);
// 响应完成后记录状态与耗时;只记录 path,排除 URL 查询中的登录 code/token。
response.once('finish', () =>
logger.log('http_request_completed', {
request_id: requestId,
method: request.method,
path: request.path,
status_code: response.statusCode,
duration_ms: Math.round(performance.now() - startedAt),
}),
);
next();
});
// 开启 Swagger 时兼容其页面脚本;关闭文档时恢复 Helmet 默认 CSP。
app.use(
helmet(config.swaggerEnabled ? { contentSecurityPolicy: false } : {}),
);
// 小程序/服务端请求通常不带 Origin;浏览器请求按精确白名单开放。
// 使用 Bearer token,不启用 Cookie 凭证;CORS 不代替服务端鉴权。
app.enableCors({
origin: (origin, callback) =>
callback(null, !origin || config.corsOrigins.includes(origin)),
credentials: false,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Id'],
exposedHeaders: ['X-Request-Id'],
});
// 应用工厂已禁用 Nest 默认解析器,此处统一限制 JSON 和表单请求大小。
// 表单不解析嵌套对象,上传文件另由后续 assets 模块处理。
app.useBodyParser('json', { limit: config.bodyLimitBytes });
app.useBodyParser('urlencoded', {
limit: config.bodyLimitBytes,
extended: false,
});
// DTO 拒绝未声明字段;不隐式转换类型,也不将原始参数值放进校验错误。
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
validationError: { target: false, value: false },
transformOptions: { enableImplicitConversion: false },
}),
);
// 统一错误格式;成功响应继续由控制器遵循公共契约,避免重复包装。
app.useGlobalFilters(new ApiErrorFilter(logger));
app.setGlobalPrefix(API_PREFIX);
app.enableShutdownHooks(); app.enableShutdownHooks();
// 文档只包含当前实际注册的路由。它直接挂载在 Express 上,不经过 JWT 守卫。
if (config.swaggerEnabled) {
const options = new DocumentBuilder()
.setTitle('Doodle Store API')
.setDescription('统一业务 API;当前仅健康检查,业务接口逐步接入')
.setVersion('0.1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
'access-token',
)
// 默认声明 Bearer 鉴权,公开操作在各自 Swagger 元数据中显式覆盖。
.addSecurityRequirements('access-token')
.build();
SwaggerModule.setup(
'api-docs',
app,
SwaggerModule.createDocument(app, options),
{
// 刷新页面后不保留 token,避免共享浏览器残留授权凭证。
swaggerOptions: { persistAuthorization: false },
jsonDocumentUrl: 'api-docs-json',
},
);
}
} }
@@ -0,0 +1,66 @@
import {
ForbiddenException,
Inject,
Injectable,
UnauthorizedException,
type CanActivate,
type ExecutionContext,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { HttpRequest } from '../common/http-request';
import { ACCOUNT_READER, type AccountReader } from './account-reader';
import { IS_PUBLIC, REQUIRED_ROLES } from './auth.decorators';
import { JwtTokenService } from './jwt-token.service';
@Injectable()
/** 全局访问守卫:JWT 验签 → 当前账户核对 → 服务端角色检查。 */
export class AccessTokenGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly tokens: JwtTokenService,
@Inject(ACCOUNT_READER) private readonly accounts: AccountReader,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 方法级声明优先于控制器级声明;只有显式 @Public() 的路由跳过鉴权。
const targets = [context.getHandler(), context.getClass()];
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, targets))
return true;
const request = context.switchToHttp().getRequest<HttpRequest>();
// 身份只从 Authorization Bearer 读取,不接受 query/body 中的 token 或 userId。
const authorization = request.headers.authorization;
const match =
typeof authorization === 'string'
? /^Bearer ([^\s]+)$/i.exec(authorization)
: null;
const token = match?.[1];
// 先限制 token 长度,再进行签名解析,避免无意义的超长输入消耗资源。
if (!token || token.length > 8192) throw new UnauthorizedException();
const claims = await this.tokens.verifyAccessToken(token);
// 验签成功不等于账户仍有效;注销、禁用、凭证版本变更必须立即阻止旧 token。
// 账户读取异常向上抛出,不退回到只检查 JWT 的宽松模式。
const account = await this.accounts.findById(claims.sub);
if (
!account ||
account.id !== claims.sub ||
account.status !== 'active' ||
account.authVersion !== claims.authVersion
) {
throw new UnauthorizedException();
}
// 后续控制器共享已核对身份,角色取自当前账户,而非客户端或 token 自报值。
request.auth = {
userId: account.id,
authVersion: account.authVersion,
role: account.role,
};
// 未声明角色时只要求有效登录;有角色限制时不匹配返回 403。
const roles = this.reflector.getAllAndOverride<Array<'user' | 'admin'>>(
REQUIRED_ROLES,
targets,
);
if (roles?.length && !roles.includes(account.role))
throw new ForbiddenException();
return true;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
/** 用户模块通过此注入标识替换账户查询实现,JWT 模块不直接依赖数据库模型。 */
export const ACCOUNT_READER = Symbol('ACCOUNT_READER');
/** 鉴权所需的最小账户快照,不包含昵称、头像或微信身份信息。 */
export interface Account {
id: string;
authVersion: number;
role: 'user' | 'admin';
status: 'active' | 'disabled';
}
/** 查询当前账户;不存在返回 null,数据库不可用应抛错,不能当作有效账户。 */
export interface AccountReader {
findById(userId: string): Promise<Account | null>;
}
@Injectable()
/** Prisma 账户查询接入前的占位实现:返回 503,确保受保护接口不会被提前放行。 */
export class UnconfiguredAccountReader implements AccountReader {
async findById(): Promise<Account | null> {
// 后续 Prisma 用户模块替换此 provider;不能仅凭 JWT 放行账户数据接口。
throw new ServiceUnavailableException();
}
}
+19
View File
@@ -0,0 +1,19 @@
import {
createParamDecorator,
SetMetadata,
type ExecutionContext,
} from '@nestjs/common';
import type { HttpRequest } from '../common/http-request';
export const IS_PUBLIC = Symbol('IS_PUBLIC');
export const REQUIRED_ROLES = Symbol('REQUIRED_ROLES');
/** 显式标记公开控制器或操作;仅跳过身份检查,不改变其他中间件及校验行为。 */
export const Public = () => SetMetadata(IS_PUBLIC, true);
/** 声明允许访问的账户角色,由全局守卫使用当前账户资料判断。 */
export const Roles = (...roles: Array<'user' | 'admin'>) =>
SetMetadata(REQUIRED_ROLES, roles);
/** 从守卫写入的请求上下文取身份;公开路由未鉴权时该值可能为 undefined。 */
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext) =>
context.switchToHttp().getRequest<HttpRequest>().auth,
);
+21
View File
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { ACCOUNT_READER, UnconfiguredAccountReader } from './account-reader';
import { AccessTokenGuard } from './access-token.guard';
import { JwtTokenService } from './jwt-token.service';
@Module({
// 签名和验证参数由 JwtTokenService 统一提供,避免不同调用点使用不同策略。
imports: [JwtModule.register({})],
providers: [
JwtTokenService,
// 用户模块接入 Prisma 后替换此 provider;当前有效 token 也不能绕过账户检查。
{ provide: ACCOUNT_READER, useClass: UnconfiguredAccountReader },
// 注册为全局守卫,新路由默认受保护,无需逐个添加 @UseGuards。
{ provide: APP_GUARD, useClass: AccessTokenGuard },
],
exports: [JwtTokenService, ACCOUNT_READER],
})
/** JWT 与鉴权基础设施;本模块尚不提供微信登录或公开 token 签发接口。 */
export class AuthModule {}
+78
View File
@@ -0,0 +1,78 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
/** 最小身份载荷;authVersion 用于与账户当前版本比较,不在 token 中固化角色。 */
export interface AccessTokenClaims {
sub: string;
authVersion: number;
iat: number;
exp: number;
}
@Injectable()
/** 统一签发和验证访问凭证,固定算法、签发方、受众和有效期。 */
export class JwtTokenService {
constructor(
private readonly jwt: JwtService,
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
) {}
/** 仅供可信登录业务调用;调用方须先验证微信身份并取得真实账户。 */
async signAccessToken(
userId: string,
authVersion: number,
): Promise<string> {
if (
!userId ||
userId.length > 128 ||
!Number.isSafeInteger(authVersion) ||
authVersion < 0
) {
throw new Error('Invalid access token identity');
}
return this.jwt.signAsync(
{ sub: userId, authVersion },
{
secret: this.config.jwtSecret,
algorithm: 'HS256',
issuer: this.config.jwtIssuer,
audience: this.config.jwtAudience,
expiresIn: this.config.jwtExpiresInSeconds,
},
);
}
/** 验签及校验载荷;当前账户状态与权限仍由全局守卫继续核对。 */
async verifyAccessToken(token: string): Promise<AccessTokenClaims> {
try {
const claims = await this.jwt.verifyAsync<AccessTokenClaims>(
token,
{
secret: this.config.jwtSecret,
// 不根据 token 头自由选择算法,并限制凭证用途和签发方。
algorithms: ['HS256'],
issuer: this.config.jwtIssuer,
audience: this.config.jwtAudience,
},
);
// 签名正确仍需检查字段类型及必填时间,拒绝无期限或畸形身份载荷。
if (
typeof claims.sub !== 'string' ||
!claims.sub ||
claims.sub.length > 128 ||
!Number.isSafeInteger(claims.authVersion) ||
claims.authVersion < 0 ||
!Number.isSafeInteger(claims.iat) ||
!Number.isSafeInteger(claims.exp) ||
claims.exp <= claims.iat
) {
throw new Error('Invalid access token claims');
}
return claims;
} catch {
// 过期、伪造或载荷错误统一返回 401,不向客户端暴露验签细节。
throw new UnauthorizedException();
}
}
}
@@ -0,0 +1,69 @@
import {
ArgumentsHost,
Catch,
HttpException,
type ExceptionFilter,
} from '@nestjs/common';
import type { Response } from 'express';
import type { HttpRequest } from './http-request';
import { AppLogger } from '../logging/app-logger';
// 对外只提供稳定的安全提示;异常原始 message 可能包含请求值、路径或密钥。
const messages: Record<number, string> = {
400: '请求参数或格式不正确',
401: '请重新登录',
403: '没有访问权限',
404: '接口不存在',
409: '请求与当前数据冲突',
413: '请求体超过大小限制',
415: '不支持的请求格式',
429: '请求过于频繁',
503: '服务暂时不可用',
};
@Catch()
/** 将 Nest 和解析器异常转为统一 HTTP 错误,使用 requestId 关联服务端日志。 */
export class ApiErrorFilter implements ExceptionFilter {
constructor(private readonly logger: AppLogger) {}
catch(exception: unknown, host: ArgumentsHost): void {
const request = host.switchToHttp().getRequest<HttpRequest>();
const response = host.switchToHttp().getResponse<Response>();
// 未识别异常默认视为服务端错误;已知 HTTP 异常保留原状态码。
let status = 500;
if (exception instanceof HttpException) status = exception.getStatus();
else if (
exception &&
typeof exception === 'object' &&
'type' in exception
) {
// body-parser 在进入控制器前抛出的错误不一定是 Nest HttpException。
if (exception.type === 'entity.too.large') status = 413;
else if (exception.type === 'entity.parse.failed') status = 400;
else if (
exception.type === 'encoding.unsupported' ||
exception.type === 'charset.unsupported'
)
status = 415;
}
// 请求完成中间件记录全部状态;服务端错误另记诊断事件,不记录原请求及堆栈。
if (status >= 500) {
this.logger.error('http_request_failed', {
request_id: request.requestId,
method: request.method,
path: request.path,
status_code: status,
error_type:
exception instanceof Error
? exception.name
: 'UnknownError',
});
}
// HTTP 状态与响应 code 保持一致,客户端可用 requestId 反馈问题。
response.status(status).json({
code: status,
message: messages[status] ?? '服务器暂时无法处理请求',
requestId: request.requestId,
});
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { Request } from 'express';
/** 守卫核对账户后写入的身份,后续业务不能用客户端提交的 userId 替代它。 */
export interface AuthenticatedUser {
userId: string;
authVersion: number;
role: 'user' | 'admin';
}
/** 生命周期内共享的请求上下文;未经过中间件或鉴权时字段可能尚未赋值。 */
export type HttpRequest = Request & {
requestId?: string;
auth?: AuthenticatedUser;
};
@@ -3,6 +3,7 @@ export interface RuntimeOptions {
port: number; port: number;
} }
/** 解析监听地址与端口;默认仅本机可访问,部署时显式配置外部监听地址。 */
export function getRuntimeOptions( export function getRuntimeOptions(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): RuntimeOptions { ): RuntimeOptions {
@@ -10,6 +11,7 @@ export function getRuntimeOptions(
const port = Number(rawPort); const port = Number(rawPort);
const host = (env.HOST ?? '127.0.0.1').trim(); const host = (env.HOST ?? '127.0.0.1').trim();
// 先检查原始字符串,拒绝 Number 会接受的空串、科学计数法等输入。
if ( if (
!/^\d+$/.test(rawPort) || !/^\d+$/.test(rawPort) ||
!Number.isInteger(port) || !Number.isInteger(port) ||
+154
View File
@@ -0,0 +1,154 @@
import { DynamicModule, Global, Module } from '@nestjs/common';
import {
getRuntimeOptions,
type RuntimeOptions,
} from '../common/runtime-options';
/** 注入已经校验的配置,业务模块无需直接读取 process.env。 */
export const SERVER_CONFIG = Symbol('SERVER_CONFIG');
/** API 契约的主版本前缀,与服务端发布版本、小程序版本分别维护。 */
export const API_PREFIX = 'api/v1';
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
/** 规范化的启动配置;仅在服务端使用,包含不得下发或记录的 JWT 密钥。 */
export interface ServerConfig extends RuntimeOptions {
nodeEnv: 'development' | 'test' | 'production';
logLevel: LogLevel;
bodyLimitBytes: number;
swaggerEnabled: boolean;
corsOrigins: string[];
trustedProxyCidrs: string[];
jwtSecret: string;
jwtIssuer: string;
jwtAudience: string;
jwtExpiresInSeconds: number;
}
/** 按十进制整数字符串解析并检查范围,防止空串、科学计数法等被 Number 宽松接受。 */
function integer(
env: NodeJS.ProcessEnv,
key: string,
fallback: number,
min: number,
max: number,
): number {
const raw = env[key] ?? String(fallback);
const value = Number(raw);
if (
!/^\d+$/.test(raw) ||
!Number.isSafeInteger(value) ||
value < min ||
value > max
) {
throw new Error(`${key} must be an integer between ${min} and ${max}`);
}
return value;
}
/** 启动前一次性校验环境变量;错误消息只包含配置项要求,不回显实际配置值。 */
export function getServerConfig(
env: NodeJS.ProcessEnv = process.env,
): ServerConfig {
const nodeEnv = env.NODE_ENV ?? 'development';
if (!['development', 'test', 'production'].includes(nodeEnv)) {
throw new Error('NODE_ENV must be development, test or production');
}
const logLevel = env.LOG_LEVEL ?? 'info';
if (!['error', 'warn', 'info', 'debug'].includes(logLevel)) {
throw new Error('LOG_LEVEL must be error, warn, info or debug');
}
// 未显式配置时生产关闭文档,避免沿用开发环境的公开文档入口。
const rawSwagger =
env.SWAGGER_ENABLED ?? (nodeEnv === 'production' ? 'false' : 'true');
if (!['true', 'false'].includes(rawSwagger)) {
throw new Error('SWAGGER_ENABLED must be true or false');
}
// 密钥没有默认值;按字节检查长度,并拒绝示例中的占位密码。
const jwtSecret = env.JWT_SECRET ?? '';
if (
Buffer.byteLength(jwtSecret.trim()) < 32 ||
jwtSecret.includes('change-me')
) {
throw new Error(
'JWT_SECRET must be a configured secret of at least 32 bytes',
);
}
const jwtIssuer = (env.JWT_ISSUER ?? 'doodle-store').trim();
const jwtAudience = (env.JWT_AUDIENCE ?? 'doodle-mini').trim();
if (!jwtIssuer || !jwtAudience) {
throw new Error('JWT_ISSUER and JWT_AUDIENCE must not be empty');
}
// 只接受可直接比较的完整 origin;路径、凭证、通配符不属于白名单项。
const corsOrigins = (env.CORS_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
for (const origin of corsOrigins) {
let url: URL;
try {
url = new URL(origin);
} catch {
throw new Error('CORS_ORIGINS must contain HTTP(S) origins');
}
if (
!['http:', 'https:'].includes(url.protocol) ||
url.origin !== origin
) {
throw new Error(
'CORS_ORIGINS must contain exact HTTP(S) origins without paths',
);
}
}
const trustedProxyCidrs = (env.TRUSTED_PROXY_CIDRS ?? '')
.split(',')
.map((cidr) => cidr.trim())
.filter(Boolean);
// Express 在安装 trust proxy 时校验具体网段;不接受 true 或 hop count。
if (
trustedProxyCidrs.some(
(value) => ['true', 'false'].includes(value) || /^\d+$/.test(value),
)
) {
throw new Error(
'TRUSTED_PROXY_CIDRS must contain explicit proxy addresses or CIDRs',
);
}
return {
...getRuntimeOptions(env),
nodeEnv: nodeEnv as ServerConfig['nodeEnv'],
logLevel: logLevel as LogLevel,
bodyLimitBytes: integer(
env,
'API_BODY_LIMIT_BYTES',
1_048_576,
1024,
10 * 1024 * 1024,
),
swaggerEnabled: rawSwagger === 'true',
corsOrigins,
trustedProxyCidrs,
jwtSecret,
jwtIssuer,
jwtAudience,
jwtExpiresInSeconds: integer(
env,
'JWT_EXPIRES_IN_SECONDS',
86_400,
60,
604_800,
),
};
}
@Global()
@Module({})
/** 将同一份配置提供给所有模块;测试可传入独立配置,避免修改进程环境。 */
export class ServerConfigModule {
static forRoot(config: ServerConfig): DynamicModule {
return {
module: ServerConfigModule,
providers: [{ provide: SERVER_CONFIG, useValue: config }],
exports: [SERVER_CONFIG],
};
}
}
@@ -1,9 +1,19 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import type { ApiSuccess, HealthStatus } from '@doodle/contracts'; import type { ApiSuccess, HealthStatus } from '@doodle/contracts';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/auth.decorators';
@Controller('health') @Controller('health')
@ApiTags('health')
export class HealthController { export class HealthController {
@Get() @Get()
@Public()
@ApiOperation({
summary: 'HTTP 进程存活检查,不检测数据库或七牛',
security: [],
})
@ApiResponse({ status: 200, description: '服务进程可响应' })
/** 仅确认 HTTP 进程能响应;数据库、七牛及业务可用性应另行检查。 */
getHealth(): ApiSuccess<HealthStatus> { getHealth(): ApiSuccess<HealthStatus> {
return { return {
code: 0, code: 0,
+90
View File
@@ -0,0 +1,90 @@
import type { LoggerService } from '@nestjs/common';
import type { LogLevel } from '../config/server-config';
// 数值越大越详细;只输出不超过当前配置级别的事件。
const levels: Record<LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
// 按结构化字段名递归脱敏;调用方仍不得将 token/query 等拼入普通消息字符串。
const sensitiveKeys =
/authorization|cookie|password|secret|token|api[_-]?key|session[_-]?key|openid|unionid|^code$|body|query/i;
/** 将日志上下文转为可安全序列化的值,处理密钥、bigint 和循环引用。 */
function sanitize(value: unknown, seen = new WeakSet<object>()): unknown {
if (typeof value === 'bigint') return value.toString();
// Error 的 message/stack 可能带有原始解析内容或连接凭证,只保留异常类型。
if (value instanceof Error) return { name: value.name };
if (value && typeof value === 'object') {
if (seen.has(value)) return '[Circular]';
seen.add(value);
if (Array.isArray(value))
return value.map((item) => sanitize(item, seen));
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
sensitiveKeys.test(key) ? '[REDACTED]' : sanitize(child, seen),
]),
);
}
return value;
}
/** Nest 日志适配器:每条事件输出单行 JSON,便于按 request_id、状态及耗时检索。 */
export class AppLogger implements LoggerService {
constructor(
private readonly minimumLevel: LogLevel,
// 生产默认输出到标准流;测试注入 sink 收集日志,无需修改 console。
private readonly sink: (line: string, level: LogLevel) => void = (
line,
level,
) => {
if (level === 'error') console.error(line);
else console.log(line);
},
) {}
log(message: unknown, context?: unknown): void {
this.write('info', message, context);
}
warn(message: unknown, context?: unknown): void {
this.write('warn', message, context);
}
debug(message: unknown, context?: unknown): void {
this.write('debug', message, context);
}
// Nest 的 verbose 归入本项目 debug 级别,避免额外增加一套配置值。
verbose(message: unknown, context?: unknown): void {
this.debug(message, context);
}
fatal(message: unknown, context?: unknown): void {
this.write('error', message, context);
}
error(message: unknown, stackOrContext?: unknown, context?: unknown): void {
// 不默认输出异常 stack/message;可包含登录 code、连接密码或原始解析内容。
this.write(
'error',
message,
context ??
(typeof stackOrContext === 'object'
? stackOrContext
: undefined),
);
}
/** 在唯一出口统一过滤级别、脱敏和序列化,保证 Nest 与业务日志格式一致。 */
private write(level: LogLevel, message: unknown, context?: unknown): void {
if (levels[level] > levels[this.minimumLevel]) return;
this.sink(
JSON.stringify({
timestamp: new Date().toISOString(),
level,
message: sanitize(message),
context: sanitize(context),
}),
level,
);
}
}
+28 -19
View File
@@ -1,13 +1,13 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { Logger } from '@nestjs/common'; import { resolve } from 'node:path';
import { NestFactory } from '@nestjs/core'; import { createApplication } from './app.factory';
import { AppModule } from './app.module'; import { API_PREFIX, getServerConfig } from './config/server-config';
import { configureApplication } from './app.setup'; import { AppLogger } from './logging/app-logger';
import { getRuntimeOptions } from './common/runtime-options';
async function bootstrap(): Promise<void> { export async function bootstrap(): Promise<void> {
try { try {
process.loadEnvFile(); // 仅读取服务端 .env,避免读到父目录 Compose 的 .env。
process.loadEnvFile(resolve(__dirname, '../.env'));
} catch (error) { } catch (error) {
if (!( if (!(
error instanceof Error && error instanceof Error &&
@@ -18,17 +18,26 @@ async function bootstrap(): Promise<void> {
} }
} }
const { port, host } = getRuntimeOptions(); const config = getServerConfig();
const app = await NestFactory.create(AppModule); const logger = new AppLogger(config.logLevel);
configureApplication(app); const app = await createApplication(config, logger);
await app.listen(port, host); try {
Logger.log( await app.listen(config.port, config.host);
`Listening on http://${host}:${port}/api/v1/health`, logger.log('server_started', {
'Bootstrap', host: config.host,
); port: config.port,
api_prefix: API_PREFIX,
swagger_enabled: config.swaggerEnabled,
});
} catch (error) {
await app.close();
throw error;
}
} }
void bootstrap().catch((error: unknown) => { if (require.main === module) {
Logger.error(error, undefined, 'Bootstrap'); void bootstrap().catch((error: unknown) => {
process.exit(1); new AppLogger('error').error('server_startup_failed', { error });
}); process.exitCode = 1;
});
}
+1 -1
View File
@@ -2,4 +2,4 @@
后续按迁移方案逐步加入 auth、users、worksheets、categories、favorites、downloads、content、assets 和 legacy 模块。 后续按迁移方案逐步加入 auth、users、worksheets、categories、favorites、downloads、content、assets 和 legacy 模块。
当前没有创建任何业务 Controller、Service、数据库模型或伪接口。健康检查独立放在 `src/health`,不代表业务迁移已完成。 当前 `src/auth` 已提供 JWT 签发/验签、全局守卫、角色校验及账户读取接入点;真实微信登录、Prisma 账户读取和其他业务模块尚未实现。健康检查独立放在 `src/health`,不代表业务迁移已完成。基础设置见 [Server 文档](../../../../docs/v3.5/Server基础设置.md)。
+306
View File
@@ -0,0 +1,306 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Body, Controller, Get, Post } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import type { OpenAPIObject } from '@nestjs/swagger';
import { IsString, MaxLength } from 'class-validator';
import { AppModule } from '../src/app.module';
import { configureApplication } from '../src/app.setup';
import { createApplication } from '../src/app.factory';
import { AppLogger } from '../src/logging/app-logger';
import { ACCOUNT_READER, type Account } from '../src/auth/account-reader';
import { CurrentUser, Public, Roles } from '../src/auth/auth.decorators';
import { JwtTokenService } from '../src/auth/jwt-token.service';
import type { AuthenticatedUser } from '../src/common/http-request';
import { testConfig } from './test-config';
class EchoDto {
@IsString()
@MaxLength(100)
message!: string;
}
// 只在测试模块注册,生产不增加探测身份或签发 token 的路由。
@Controller('probe')
class ProbeController {
@Get('me')
me(@CurrentUser() user: AuthenticatedUser) {
return user;
}
@Get('admin')
@Roles('admin')
admin() {
return { ok: true };
}
@Public()
@Post('echo')
echo(@Body() body: EchoDto) {
return body;
}
@Public()
@Get('failure')
failure() {
throw new Error('private-internal-error');
}
}
test('HTTP 中间件、校验、日志、Swagger 与 JWT 全局守卫', async (context) => {
const logs: string[] = [];
const logger = new AppLogger('info', (line) => logs.push(line));
const config = {
...testConfig,
bodyLimitBytes: 1024,
corsOrigins: ['http://localhost:3001'],
};
// 用可变账户快照模拟禁用、角色和凭证失效,不依赖真实用户或开发数据库。
const accounts = new Map<string, Account>([
[
'UserA',
{ id: 'UserA', authVersion: 1, role: 'user', status: 'active' },
],
[
'AdminA',
{ id: 'AdminA', authVersion: 1, role: 'admin', status: 'active' },
],
]);
const module = await Test.createTestingModule({
imports: [AppModule.forRoot(config)],
controllers: [ProbeController],
})
.overrideProvider(ACCOUNT_READER)
.useValue({ findById: async (id: string) => accounts.get(id) ?? null })
.compile();
const app = module.createNestApplication<NestExpressApplication>({
bodyParser: false,
logger,
});
context.after(() => app.close());
configureApplication(app, config, logger);
await app.listen(0, '127.0.0.1');
const origin = await app.getUrl();
const tokens = app.get(JwtTokenService);
const jwt = app.get(JwtService);
const userToken = await tokens.signAccessToken('UserA', 1);
const request = (path: string, options?: RequestInit) =>
fetch(`${origin}${path}`, {
...options,
signal: AbortSignal.timeout(5000),
});
const health = await request('/api/v1/health?code=private-login-code', {
headers: {
'x-request-id': 'request_test_123',
Origin: 'http://localhost:3001',
},
});
assert.equal(health.status, 200);
assert.equal(health.headers.get('x-request-id'), 'request_test_123');
assert.equal(
health.headers.get('access-control-allow-origin'),
'http://localhost:3001',
);
assert.equal(health.headers.get('x-content-type-options'), 'nosniff');
assert.equal(health.headers.get('x-powered-by'), null);
const deniedOrigin = await request('/api/v1/health', {
headers: { Origin: 'https://untrusted.example' },
});
assert.equal(deniedOrigin.headers.get('access-control-allow-origin'), null);
const missingToken = await request('/api/v1/probe/me');
assert.equal(missingToken.status, 401);
const errorBody = (await missingToken.json()) as {
code: number;
requestId: string;
};
assert.equal(errorBody.code, 401);
assert.equal(errorBody.requestId, missingToken.headers.get('x-request-id'));
const authenticated = await request('/api/v1/probe/me', {
headers: { Authorization: `Bearer ${userToken}` },
});
assert.equal(authenticated.status, 200);
assert.deepEqual(await authenticated.json(), {
userId: 'UserA',
authVersion: 1,
role: 'user',
});
assert.equal(
(
await request('/api/v1/probe/admin', {
headers: { Authorization: `Bearer ${userToken}` },
})
).status,
403,
);
assert.equal(
(
await request('/api/v1/probe/admin', {
headers: {
Authorization: `Bearer ${await tokens.signAccessToken('AdminA', 1)}`,
},
})
).status,
200,
);
// 用同一 JWT 库构造异常凭证,验证算法、签发方、受众和字段校验均会生效。
const sign = (claims: object, overrides: object = {}) =>
jwt.signAsync(claims, {
secret: config.jwtSecret,
algorithm: 'HS256',
issuer: config.jwtIssuer,
audience: config.jwtAudience,
expiresIn: 60,
...overrides,
});
for (const token of [
'invalid-token',
await tokens.signAccessToken('UserA', 0),
await tokens.signAccessToken('DeletedUser', 1),
await sign({ sub: 'UserA', authVersion: 1 }, { expiresIn: -1 }),
await sign({ sub: 'UserA', authVersion: 1 }, { audience: 'other' }),
await sign({ sub: 'UserA', authVersion: 1 }, { issuer: 'other' }),
await sign({ sub: 'UserA', authVersion: 1 }, { algorithm: 'HS384' }),
await sign(
{ sub: 'UserA', authVersion: 1 },
{ secret: 'wrong-secret' },
),
await sign({ sub: 'UserA', authVersion: '1' }),
await jwt.signAsync(
{ sub: 'UserA', authVersion: 1 },
{
secret: config.jwtSecret,
issuer: config.jwtIssuer,
audience: config.jwtAudience,
},
),
])
assert.equal(
(
await request('/api/v1/probe/me', {
headers: { Authorization: `Bearer ${token}` },
})
).status,
401,
);
// 即使签名正确,token 自报 admin 也不能覆盖服务端账户中的 user 角色。
const forgedRole = await sign({
sub: 'UserA',
authVersion: 1,
role: 'admin',
});
assert.equal(
(
await request('/api/v1/probe/admin', {
headers: { Authorization: `Bearer ${forgedRole}` },
})
).status,
403,
);
accounts.get('UserA')!.status = 'disabled';
assert.equal(
(
await request('/api/v1/probe/me', {
headers: { Authorization: `Bearer ${userToken}` },
})
).status,
401,
);
const echo = (body: string, contentType = 'application/json') =>
request('/api/v1/probe/echo', {
method: 'POST',
headers: { 'Content-Type': contentType },
body,
});
assert.equal((await echo('{"message":"ok"}')).status, 201);
assert.equal(
(await echo('message=ok', 'application/x-www-form-urlencoded')).status,
201,
);
assert.equal((await echo('{"message":12}')).status, 400);
assert.equal((await echo('{"message":"ok","extra":true}')).status, 400);
const malformed = await echo('{broken-json');
assert.equal(malformed.status, 400);
assert.ok(malformed.headers.get('x-request-id'));
assert.equal(
(await echo(JSON.stringify({ message: 'x'.repeat(2000) }))).status,
413,
);
const failure = await request('/api/v1/probe/failure');
assert.equal(failure.status, 500);
assert.ok(!(await failure.text()).includes('private-internal-error'));
assert.equal((await request('/api-docs')).status, 200);
const openapi = (await (
await request('/api-docs-json')
).json()) as OpenAPIObject;
assert.ok(openapi.paths['/api/v1/health']);
assert.deepEqual(openapi.paths['/api/v1/health']?.get?.security, []);
assert.equal(
(
openapi.components?.securitySchemes?.['access-token'] as {
scheme: string;
}
).scheme,
'bearer',
);
// 检查完整请求链日志,确认解析失败仍有日志,且查询参数、异常原文和 token 未泄漏。
const requestLogs = logs.filter(
(line) => JSON.parse(line).message === 'http_request_completed',
);
assert.ok(
requestLogs.some(
(line) => JSON.parse(line).context.status_code === 413,
),
);
assert.ok(!logs.join('\n').includes('private-login-code'));
assert.ok(!logs.join('\n').includes('private-internal-error'));
assert.ok(!logs.join('\n').includes(userToken));
});
test('生产默认不暴露 Swagger;账户查询未接入时不放行有效 JWT', async (context) => {
const config = {
...testConfig,
nodeEnv: 'production' as const,
swaggerEnabled: false,
};
const module = await Test.createTestingModule({
imports: [AppModule.forRoot(config)],
controllers: [ProbeController],
}).compile();
const logger = new AppLogger('error', () => {});
const app = module.createNestApplication<NestExpressApplication>({
bodyParser: false,
logger,
});
context.after(() => app.close());
configureApplication(app, config, logger);
await app.listen(0, '127.0.0.1');
const origin = await app.getUrl();
for (const path of ['/api-docs', '/api-docs-json'])
assert.equal((await fetch(`${origin}${path}`)).status, 404);
const token = await app.get(JwtTokenService).signAccessToken('UserA', 1);
assert.equal(
(
await fetch(`${origin}/api/v1/probe/me`, {
headers: { Authorization: `Bearer ${token}` },
})
).status,
503,
);
});
test('导入应用工厂不会监听固定端口', async (context) => {
const app = await createApplication(
testConfig,
new AppLogger('error', () => {}),
);
context.after(() => app.close());
assert.equal(app.getHttpServer().listening, false);
});
+7 -8
View File
@@ -1,19 +1,18 @@
import 'reflect-metadata'; import 'reflect-metadata';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test'; import { test } from 'node:test';
import { Test } from '@nestjs/testing'; import { createApplication } from '../src/app.factory';
import { AppModule } from '../src/app.module'; import { AppLogger } from '../src/logging/app-logger';
import { configureApplication } from '../src/app.setup'; import { testConfig } from './test-config';
test('服务可以启动并通过带版本前缀的 HTTP 健康检查', async (context) => { test('服务可以启动并通过带版本前缀的 HTTP 健康检查', async (context) => {
const testingModule = await Test.createTestingModule({ const app = await createApplication(
imports: [AppModule], testConfig,
}).compile(); new AppLogger('error', () => {}),
const app = testingModule.createNestApplication({ logger: false }); );
context.after(async () => { context.after(async () => {
await app.close(); await app.close();
}); });
configureApplication(app);
await app.listen(0, '127.0.0.1'); await app.listen(0, '127.0.0.1');
const origin = await app.getUrl(); const origin = await app.getUrl();
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { AppLogger } from '../src/logging/app-logger';
test('日志为单行 JSON,递归脱敏并安全处理循环对象与 bigint', () => {
const output: string[] = [];
const logger = new AppLogger('info', (line) => output.push(line));
const nested: Record<string, unknown> = {
password: 'private-password',
amount: 9n,
};
nested.self = nested;
logger.log('test_event', {
authorization: 'Bearer private-token',
nested,
body: { code: 'login-secret' },
});
logger.debug('hidden');
assert.equal(output.length, 1);
const log = JSON.parse(output[0]!);
assert.equal(log.context.authorization, '[REDACTED]');
assert.equal(log.context.nested.password, '[REDACTED]');
assert.equal(log.context.nested.self, '[Circular]');
assert.equal(log.context.nested.amount, '9');
assert.equal(log.context.body, '[REDACTED]');
assert.ok(!output[0]!.includes('private-'));
assert.ok(!output[0]!.includes('login-secret'));
});
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getServerConfig } from '../src/config/server-config';
const env = { JWT_SECRET: 'test-configuration-secret-12345678901234567890' };
test('启动配置必须提供有效 JWT 密钥,不使用共享默认密钥', () => {
for (const secret of [
undefined,
'',
'short',
'change-me-123456789012345678901234567890',
]) {
assert.throws(
() =>
getServerConfig(
secret === undefined ? {} : { JWT_SECRET: secret },
),
/JWT_SECRET/,
);
}
});
test('Swagger 开发默认开启,生产默认关闭,支持显式开关', () => {
assert.equal(getServerConfig(env).swaggerEnabled, true);
assert.equal(
getServerConfig({ ...env, NODE_ENV: 'production' }).swaggerEnabled,
false,
);
assert.equal(
getServerConfig({ ...env, SWAGGER_ENABLED: 'false' }).swaggerEnabled,
false,
);
});
test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域配置', () => {
for (const entry of [
{ API_BODY_LIMIT_BYTES: '0' },
{ API_BODY_LIMIT_BYTES: '1e6' },
{ JWT_EXPIRES_IN_SECONDS: '0' },
{ SWAGGER_ENABLED: 'yes' },
{ LOG_LEVEL: 'all' },
{ NODE_ENV: 'staging' },
{ JWT_ISSUER: ' ' },
{ CORS_ORIGINS: '*' },
{ CORS_ORIGINS: 'https://example.com/path' },
{ TRUSTED_PROXY_CIDRS: 'true' },
])
assert.throws(() => getServerConfig({ ...env, ...entry }));
assert.deepEqual(
getServerConfig({
...env,
CORS_ORIGINS: 'https://example.com,http://localhost:3001',
}).corsOrigins,
['https://example.com', 'http://localhost:3001'],
);
});
+7
View File
@@ -0,0 +1,7 @@
import { getServerConfig } from '../src/config/server-config';
/** 测试独立配置,不读取本地 .env;固定测试密钥不得用于开发或生产。 */
export const testConfig = getServerConfig({
NODE_ENV: 'test',
JWT_SECRET: 'test-only-secret-123456789012345678901234567890',
});
+67
View File
@@ -0,0 +1,67 @@
# Doodle Store Server 基础设置
> 更新日期:2026-10-13。已实现基础配置、日志、中间件、Swagger 与 JWT 守卫,业务登录和 Prisma 账户查询待接入。
参考 `atlas-seller-kit/apps/api` 的应用工厂、结构化日志、requestId、请求体限制、默认鉴权与错误处理。保持本项目 NestJS 11 和 `/api/v1` 契约,不复制卖家工作台的多租户、Redis、对象存储或业务模块。
## 1. 启动与环境变量
从 `doodle-store` 根目录执行 `pnpm dev`。服务只读取 `apps/server/.env`,不向上搜索根目录的 Compose `.env`;部署环境变量优先于 `.env`。应用工厂不自行监听端口,main 负责读取配置和启动,测试使用临时端口。
新环境先复制 `apps/server/.env.example` 为 `apps/server/.env`,设置 JWT_SECRET。生成随机密钥的示例命令:
```bash
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
```
真实密钥仅写入受控环境配置,不提交仓库。当前本机已生成独立开发密钥;测试使用测试专用密钥,生产必须另行生成。
| 配置 | 默认 / 要求 |
| ------------------------- | ----------------------------------------------------------------- |
| NODE_ENV | development;允许 development/test/production |
| HOST / PORT | 127.0.0.1 / 3000;端口须为 1~65535 整数 |
| LOG_LEVEL | info;允许 error/warn/info/debug |
| API_BODY_LIMIT_BYTES | 1048576,即 1 MiB;允许 1 KiB~10 MiB |
| SWAGGER_ENABLED | 未设置时开发/测试 true,生产 false;生产不要照搬开发示例中的 true |
| CORS_ORIGINS | 空列表;逗号分隔的完整 HTTP(S) origin,不接受 * 或路径 |
| TRUSTED_PROXY_CIDRS | 空列表;按部署显式设置代理 IP/CIDR,不使用 true 或跳数 |
| JWT_SECRET | 必填,至少 32 字节;拒绝空值、过短或 change-me 占位值 |
| JWT_ISSUER / JWT_AUDIENCE | doodle-store / doodle-mini |
| JWT_EXPIRES_IN_SECONDS | 86400,即 24 小时;允许 60~604800 秒 |
DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接数据库,也不读取七牛凭证。健康检查成功只证明 HTTP 进程可用。
## 2. 请求处理
- 全部控制器使用 `/api/v1` 前缀,公开健康检查为 `GET /api/v1/health`。
- 创建 Nest 应用时设置 `bodyParser: false`,再显式安装 JSON 和 urlencoded 解析器;共同使用字节限制,urlencoded 使用 extended=false。
- requestId 在解析请求体之前生成;符合 `[A-Za-z0-9_-]{8,128}` 的 X-Request-Id 可沿用,否则生成 req_UUID。所有响应携带 X-Request-Id。
- 请求完成记录 method、path、状态、耗时及 requestId,不记录 query、请求体、Authorization、Cookie。JSON 日志递归脱敏密码、token、微信身份及其他敏感字段。
- Helmet 安全头与隐藏 X-Powered-By。Swagger 开启时放宽文档所需 CSP;Swagger 关闭时使用默认 CSP。
- CORS 使用精确 origin 白名单,无 Cookie 凭证模式;允许 Bearer Authorization,并向浏览器暴露 X-Request-Id。无 origin 的小程序/服务端请求可用,CORS 不代替身份校验。
- DTO 使用 ValidationPipe:transform、whitelist、forbidNonWhitelisted;不隐式把数字字符串转换成数字,不在错误里返回原始对象或值。
- 错误统一为 `{code: HTTP状态码, message: 安全提示, requestId}`,保留 HTTP 状态;JSON 格式错误 400、超限 413、未登录 401、无权限 403、未预期错误 500。成功响应沿用 `{code:0,message,data}`,不重复包装健康检查。
## 3. Swagger
开发默认地址:`http://127.0.0.1:3000/api-docs`;OpenAPI JSON:`/api-docs-json`。Swagger 反映实际注册路由,不展示计划中尚未实现的业务接口。
使用 access-token Bearer JWT 安全定义,健康检查声明为公开。Swagger 不持久保存浏览器 token。生产默认关闭;如部署时显式开启,应通过部署入口限制访问。Swagger 是直接注册的文档路由,不由控制器 JWT 守卫保护。
## 4. JWT 与账户检查
`JwtTokenService` 仅供可信服务端业务签发 token,没有公开的测试登录或任意用户 token 签发接口。使用 HS256,签发 sub=userId、authVersion、iat、exp,并验证 algorithm、issuer、audience、有效期和身份字段类型。
`AccessTokenGuard` 为全局守卫,路由默认需要登录。公开接口使用 `@Public()`,业务代码用 `@CurrentUser()` 获取已核对的身份;管理接口使用 `@Roles('admin')`,角色从当前账户取得,不读取客户端或 token 自报角色。
验签后通过 ACCOUNT_READER 查询账户,要求用户存在、active、userId 一致且 authVersion 匹配。已注销、禁用或版本失效返回 401;不满足角色返回 403。数据库查询失败不降级为“JWT 有效即可通过”。
**当前 ACCOUNT_READER 是拒绝放行的接入占位实现**:有效 token 请求受保护接口时返回 503。后续 Prisma 用户模块必须替换为真实数据库读取;微信 code2Session、账户登录、注销和业务 API 尚未实现。测试中的账户读取替身和 probe 路由只在测试模块注册,不会进入正式服务。
## 5. 验证
使用 `pnpm check` 验证格式、ESLint、类型、构建与测试。集成测试覆盖健康检查、前缀、Swagger 开关与 Bearer 定义、CORS、安全头、requestId、JSON/urlencoded、超限和 DTO 校验、日志脱敏、JWT 伪造/过期/错误签名及 claims、账户禁用/缺失/版本失效、服务端角色校验和未接入账户查询时的拒绝放行。
2026-10-13:`pnpm check` 全部通过,12 项测试通过。另使用构建后的 `dist/main.js` 启动真实进程,核对服务端 `.env` 加载、健康检查 HTTP 200、OpenAPI JSON HTTP 200 和 X-Request-Id;检查进程已关闭,未修改开发数据库或云服务器。
基于 [NestJS Swagger 文档](https://docs.nestjs.com/openapi/introduction) 的运行时文档生成方式及 [ValidationPipe 文档](https://docs.nestjs.com/techniques/validation) 的 DTO 校验机制实现;依赖锁定兼容当前 NestJS 11 的具体版本。
+135
View File
@@ -0,0 +1,135 @@
# Worksheet 数据库设计说明
更新日期:2026-10-14。配套 [数据库设计文档](./数据库设计文档.md) 和 [SQL 执行说明](../../apps/server/prisma/sql/README.md)。本文说明已经确认的模型及边界,不表示小程序或业务 API 已完成改造。
## 1. 需求与选择
现有小程序的 worksheet 对应一个可打印功能,多次生成可以得到不同结果。未来涂色、剪纸等功能复用同一种绘制方法,但需要选择不同底图;一份内容还可能包含多个 SVG 或图片,例如“小猫涂色”需要线框与彩色参考图,并有用于内容列表的效果预览。
只在 worksheets 放单个 asset_id 无法表达多份内容及每份内容的多个输入。固定 outline_asset_id/reference_asset_id 能满足一对文件,却无法容纳多个同用途文件。完整 template 模型又容易提前引入独立布局、组合引擎、版本编辑等尚无明确需求的能力。
因此采用 worksheet → 完整内容资源 → 文件关联 → assets。资源条目只管理标题、预览、发布状态和少量参数差异;文件关联只管理用途和顺序,不建设通用模板系统。四张表不要求四套后台页面,一个内容编辑表单即可维护全部关系。
## 2. 各层职责
| 层级 | 表 | 职责 |
| -------- | ------------------------- | ------------------------------------------------------------------------ |
| 功能 | worksheets | 功能身份、分类、收藏和统计;生成方式、绘制实现、共用参数、入口封面 |
| 内容 | worksheet_resources | 用户可选择的完整内容,例如“小猫涂色”;标题、内容预览、状态及轻量参数覆盖 |
| 文件输入 | worksheet_resource_assets | 内容需要哪些文件;outline/reference 用途及同用途处理顺序 |
| 文件 | assets | 七牛或已核实 HTTPS 文件的稳定引用、格式、大小、校验摘要及状态 |
```mermaid
erDiagram
worksheets ||--o{ worksheet_resources : "一个功能有多份内容"
worksheet_resources ||--o{ worksheet_resource_assets : "一份内容有多个文件"
assets ||--o{ worksheet_resource_assets : "文件可重复利用"
assets ||--o{ worksheet_resources : "内容预览"
assets ||--o{ worksheets : "功能封面"
```
基础 SVG、图片及较大的路径 JSON 放七牛,数据库不存临时签名 URL。参数 JSON 只保存小型、经过校验的配置,不存绘制代码。
## 3. worksheet ID 与 renderer 的分工
现有 [数学注册表](../../../doodle-mini/miniprogram/mathPages/mathDraw/registry.ts) 已将 addition-5、addition-10 等业务 ID 映射到同一 addition 实现及不同模式,说明功能身份与绘制实现并非一一对应。
| 标识或配置 | 作用 |
| --------------------- | --------------------------------------------------------------------- |
| worksheet.id | 稳定业务入口,用于路由、收藏、分享、统计及资源归属 |
| renderer_key | 从客户端已注册实现中选择绘制方法;多个 worksheet 可共用,不设唯一约束 |
| generation_mode | procedural 算法生成;resource 由完整内容资源驱动,不是“是否随机” |
| render_config | 同一功能共用参数和默认模式 |
| worksheet_resource.id | 用户本次选中的具体内容 |
| config_override | 内容与共用参数的少量差异,可空 |
调用顺序:通过 worksheetId 获取配置 → 选择 renderer → 根据需要选择内容 → 解析文件引用 → 绘制。仍由客户端执行绘制;增加新 renderer 需要发布客户端代码,数据库配置不能凭空增加算法。
保留原 path?id=worksheetId、旧 ID 和分享路径。renderer_key 允许 NULL 仅为了兼容已登记的旧映射;新功能发布必须设置已支持标识。未知标识或缺少必须文件时返回明确错误,不默认绘制为另一个功能。
## 4. 预览与多文件示例
```text
worksheet:动物涂色
renderer_key:coloring
generation_mode:resource
render_config:统一纸张尺寸、边距
preview_asset_id:功能入口封面
worksheet_resource:小猫
preview_asset_id:小猫列表效果图
config_override:无
文件关联:
outline / 0:小猫线框 SVG
outline / 1:补充线框 SVG(绘制实现支持时)
reference / 0:小猫彩色参考 PNG
```
- 功能封面用于首页和分类入口,保持原 previewImg 契约。
- 内容预览用于内容选择列表,每条内容各有 previewImg;通过分页接口返回,不把所有预览塞进 worksheet 详情。
- reference 用于绘制页面的对照展示;outline 是实际线框输入。参考图是否打印由 renderer 决定,不因关联就自动加入打印结果。
- 三种用途可以引用同一资产,也可以使用独立文件;资产去重不等于业务内容合并。
- 关联文件默认共同服务一份内容,不是随机候选池;随机选“小猫或恐龙”应选择内容条目。素材池抽取是另一种明确规则。
assets.purpose 描述文件管理用途,新增 worksheet_source;role 描述文件在该内容中的用法,仅支持 outline/reference。参考图可复用 preview 文件,SVG/PNG/JSON 格式由文件元数据和 renderer 校验,不作为 role。
## 5. 轻量配置、发布和权限
参数顺序是 renderer 默认值 → worksheet 共用参数 → 内容覆盖 → 允许的用户参数。首期采用按 renderer 定义的顶层参数覆盖,仅允许边距、缩放方式等明确字段;不支持任意 JSON 深度合并、脚本或文件级配置。
同用途文件按 sort_order、id 稳定排序;顺序不是通用图层 z-index。数量、排布及格式要求由 renderer 定义,不在数据库中新增绘制需求规则表。
管理员维护内容及关联;普通用户只能读取已发布功能下的已发布内容。发布需校验 renderer 支持、必须文件及其数量、资源 ready 状态、格式、参数范围和访问策略。SQL 外键只保证引用存在,不能替代发布状态或文件真实存在的检查。私有资源按策略生成访问地址,不暴露七牛密钥。
首期仅上下架,不硬删除功能或内容。关联采用 RESTRICT,删除共享资产前核对所有封面、内容预览、文件关联及其他引用;软删除同样须由服务检查。发布/修改/关联及管理审计使用同一事务,不在事务内等待七牛上传。内容及文件关联修改由服务同时维护资源 updated_at、父功能 content_updated_at/updated_at,不增加通用更新时间触发器。
## 6. 收藏、下载与旧数据
favorites 统一保存功能和具体内容收藏:worksheet_resource_id 为 NULL 表示功能,非 NULL 表示“小猫涂色”等完整内容,不直接收藏单个 asset。两种部分唯一索引分别去重;同用户可同时收藏功能及其中多个资源,不自动联动,不新增 resource_favorites 表。旧查询/数量/取消必须限定 NULL,内容查询和取消显式按资源目标。仅功能收藏增减 worksheets.likes,内容收藏不新增资源 likes。下载增加可空 worksheet_resource_id,记录选择的完整内容,而不是只记录其中一个文件。资源驱动的新下载要求资源 ID,校验归属及发布状态;算法生成和旧历史允许为空。相同 exportId 的重试同时核对功能和内容 ID,保持事件一致性。
下载同时维护功能与有明确归属的内容累计计数,只有新日志成功插入时同事务各加一次,失败全部回滚,重复 exportId 不加数。功能旧累计值不分摊资源,资源计数不与功能计数相加;个人注销删日志不回减任一层累计量。不增加资源收藏计数或 seed。资源 ID 是逻辑关联,缺失或隐藏的历史内容不导致整条记录丢失,也不泄露未发布详情。这个 ID 只表示选择过什么内容,不保证精确复现原打印结果;未来确需复现时另行设计选中素材、参数、随机种子及不可变版本快照。
增量升级保留旧 worksheet ID/path、预览、计数和历史,添加 procedural 默认值、NULL renderer_key、空共用配置;新内容表为空。导入按实际实现核对模式与映射,不从题型 ID 猜 renderer,不把旧预览图自动当作线框。新空库用最新 002/003;旧库依次使用未执行的 004/005/006/007/008,不重跑建表基线。Prisma 迁移历史接入时需登记对应结构版本。
## 7. 覆盖范围与后续扩展
| 场景 | 当前模型 |
| ------------------------------------- | ------------------------------------------------------ |
| 线框配参考图、独立列表预览 | 直接支持 |
| 多个同用途 SVG、资源文件共享 | 数据模型支持;客户端 renderer 须实现处理规则 |
| 同一绘制实现用于不同功能和内容 | 直接支持 |
| 部分内容调整少量绘制参数 | config_override 白名单覆盖 |
| 多页内容、每页不同文件布局 | 尚未表达页归属;真实需要时增加页结构 |
| 背景、装饰图层、单文件位置/透明度配置 | 首期不开放;同步扩展角色及 renderer 后再设计文件级配置 |
| 素材池随机组合 | 首期不把关联解释为候选池,后续明确选取协议 |
| 下载历史精确重现、版本编辑及回滚 | 当前不承诺;后续设计不可变内容版本与生成快照 |
当前只落实已经确定的身份、完整内容、文件用途、独立预览和少量配置差异,保留扩展点,但不预先建设模板引擎、通用图层系统或版本平台。
## 8. 内容分类与适用年级
categories 保持内容分类;worksheet_grades 用两列联合主键表达功能适用于哪些年级,支持多个及不连续年级。Web 年级菜单再按分类筛选,是两个维度的组合查询,不复制分类树。年级名称和排序暂用固定配置,不新增年级字典表。
旧 worksheets.grade 保留兼容,但其值可能由年龄区间推算,不能成为适用范围的唯一依据。006 仅建空关系表,不自动回填。经管理员审核后写入完整 grades,新筛选以关系表为准;空关系表示未配置,不表示全年级,0 表示幼小衔接。使用 EXISTS 查询防止多选年级导致重复结果。
资源条目暂继承功能适用年级;若同功能下内容难度跨年级明显不同,当前模型不提供资源级年级过滤,须先明确产品规则再扩展。年级与年龄独立维护,不根据年龄自动生成关系。详见数据库设计文档 4.6 节。
## 9. 功能与资源的新鲜度、热度和下载
功能早已固定不代表内容停止更新。功能和内容各增加可空 first_published_at,分别表达首次公开发布时间。新 isNew 按统一新品时间窗口派生,不新增资源布尔新品字段;草稿创建时间、参数更新时间、新增资源或重新上架时间不能替代首次发布时间。未知旧时间不回填为 now()/created_at,也不在重发时伪装新品。
worksheet.is_new 暂保留兼容:新接口以时间派生为准,旧接口仅在历史发布时间未知时可临时沿用原标记。worksheet.is_hot 定义为功能级人工运营推荐,与下载排行榜独立,不自动赋给资源。资源首期按真实 downloads 排名,不新增人工热门标记;若运营明确需要资源推荐,再扩展。
007 给内容条目增加 downloads,默认 0、非负,与 worksheet 总下载量分别维护。成功保存完整“小猫”内容一次:新增一条 download_logs,功能 +1,小猫 +1;关联两张 SVG 不代表下载两次。算法生成或旧事件无可信内容 ID,只更新功能;不能从资源文件访问次数推断打印成功。
事务按用户 → 功能 → 内容加锁,检查归属和发布状态,只有首次插入日志才加数;同一 exportId 的功能/内容 ID 不一致报冲突。失败回滚两层计数。正常下载仅更新业务时间,不更新内容/首次发布时间。功能历史基数和 seed 保留,不平均分配资源、不要求功能总量等于资源之和;删除个人日志不扣累计下载。
当前表结构支持这套规则,具体发布时间设置、isNew 派生、计数事务及兼容 DTO 后续由服务端实现;本次没有改变小程序现有显示逻辑。
## 10. 收藏具体底图的兼容调整
008 给统一 favorites 增加可空内容 ID,并将原 userId+worksheetId 全局唯一约束改为两个部分唯一索引。旧收藏保留原 ID、时间、归属且新列为 NULL,不猜测具体底图。新资源收藏的 worksheetId 从服务端读取,关系插入时重新核对父功能、内容发布状态及归属。
列表默认仅功能,资源/all 通过显式 target 选择;返回目标类型及资源独立预览。取消精确定位收藏记录或资源 ID,旧按功能取消不得删除内容收藏。资源缺失或下架只使详情不可用,内容 ID 不清空,仍可取消。注销清理两种收藏,仅功能关系贡献影响功能 likes。
数据库不强制资源外键以保留历史,发布内容不允许重新归属另一 worksheet。需要收藏某次算法生成结果、保存用户涂色进度或精确结果快照时另行设计;当前资源收藏只表示选中的完整底图内容。
+307 -106
View File
@@ -2,21 +2,21 @@
> 数据库:PostgreSQL;ORM:Prisma。数据库选型已按用户确认更新。 > 数据库:PostgreSQL;ORM:Prisma。数据库选型已按用户确认更新。
> >
> 状态:目标设计,本地开发数据库已配置;10 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。 > 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。
> >
> 更新日期:2026-10-12。 > 更新日期:2026-10-14。
> >
> 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。 > 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。
已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md),建库、10 张表及配置初始化已在 PostgreSQL 18.6 临时库执行验证,19 项约束检查通过;临时库已清理,开发库随后已正式执行基线 SQL,创建 10 张表和两条配置记录;未创建 Prisma Schema 或导入线上数据。 已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md)。2026-10-12 验证并创建最初 10 张表及两条配置;2026-10-14 执行 004/005/006/007/008 后,本地共有 13 张表。最新空库基线与对应增量升级结构已在临时库对照通过,多资源、多年级、资源计数及两种收藏约束、旧数据保留验证通过,临时库已清理;尚未创建 Prisma Schema 或导入线上数据。
本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 10 张表已在本地创建,业务 API 尚未实现。 本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 13 张表已在本地创建,业务 API 尚未实现。
## 1. 范围与依据 ## 1. 范围与依据
首期迁移现有微信小程序后端,统一使用 PostgreSQL 主数据库、NestJS API 和七牛文件存储。小程序仍在 `doodle-mini` 维护,数据库模型与迁移只放在 `doodle-store/apps/server/prisma`。Web 与管理后台通过 API 访问,不直接连接数据库。 首期迁移现有微信小程序后端,统一使用 PostgreSQL 主数据库、NestJS API 和七牛文件存储。小程序仍在 `doodle-mini` 维护,数据库模型与迁移只放在 `doodle-store/apps/server/prisma`。Web 与管理后台通过 API 访问,不直接连接数据库。
设计优先级:用户本次确认的 PostgreSQL 选型 → 当前迁移方案 → 实际云函数及调用代码 → 历史设计。当前代码有 7 个核心集合;新增 3 张辅助表用于文件、调度和审计。 设计优先级:用户本次确认的 PostgreSQL 选型 → 当前迁移方案 → 实际云函数及调用代码 → 历史设计。当前代码有 7 个核心集合;新增 3 张辅助表用于文件、调度和审计,另新增 2 张内容资源表支持多素材绘制及 1 张年级关系表支持跨年级筛选。
| 阅读依据 | 与建模相关的实际行为 | | 阅读依据 | 与建模相关的实际行为 |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
@@ -59,10 +59,17 @@ erDiagram
users o|--o{ assets : "所有者外键" users o|--o{ assets : "所有者外键"
categories o|--o{ categories : "父分类外键" categories o|--o{ categories : "父分类外键"
categories ||--o{ worksheets : "分类外键" categories ||--o{ worksheets : "分类外键"
worksheets ||--o{ favorites : "题型逻辑关联" worksheets o|--o{ favorites : "题型逻辑关联,可历史缺失"
worksheets ||--o{ download_logs : "题型逻辑关联" worksheets o|--o{ download_logs : "题型逻辑关联,可历史缺失"
assets o|--o{ users : "头像外键" assets o|--o{ users : "头像外键"
assets o|--o{ worksheets : "预览外键" assets ||--o{ worksheets : "功能封面外键"
worksheets ||--o{ worksheet_grades : "适用年级外键"
worksheets ||--o{ worksheet_resources : "内容外键"
assets ||--o{ worksheet_resources : "内容预览外键"
worksheet_resources ||--o{ worksheet_resource_assets : "文件关联外键"
assets ||--o{ worksheet_resource_assets : "文件外键"
worksheet_resources o|--o{ favorites : "内容收藏逻辑关联"
worksheet_resources o|--o{ download_logs : "内容逻辑关联"
page_configs ||--o{ job_runs : "执行结果逻辑关联" page_configs ||--o{ job_runs : "执行结果逻辑关联"
``` ```
@@ -71,6 +78,8 @@ erDiagram
- `worksheets.category_id` 与 `categories.parent_id` 有外键且删除 RESTRICT;分类循环由服务在串行化分类树修改的事务中检查。`subcategory` 仍是业务字符串,不强制认作分类主键。 - `worksheets.category_id` 与 `categories.parent_id` 有外键且删除 RESTRICT;分类循环由服务在串行化分类树修改的事务中检查。`subcategory` 仍是业务字符串,不强制认作分类主键。
- 题型首期仅通过 draft/active/hidden 管理,不提供硬删除 API。历史缺失题型不创建伪题型补齐;收藏/历史中非 active 题型不返回未发布详情,允许返回 `worksheet: null` 并取消收藏。 - 题型首期仅通过 draft/active/hidden 管理,不提供硬删除 API。历史缺失题型不创建伪题型补齐;收藏/历史中非 active 题型不返回未发布详情,允许返回 `worksheet: null` 并取消收藏。
- 用户头像、题型预览使用 assets 外键,删除 RESTRICT;资产所有者外键可 `ON DELETE SET NULL`。对象存储删除通过业务清理重试完成,不由数据库级联触发。 - 用户头像、题型预览使用 assets 外键,删除 RESTRICT;资产所有者外键可 `ON DELETE SET NULL`。对象存储删除通过业务清理重试完成,不由数据库级联触发。
- 内容资源的 worksheet、预览资产及文件关联均使用真实外键,删除 RESTRICT;首期只上下架,不提供硬删除。清理文件必须检查功能封面、内容预览、文件关联、头像及配置引用,不能只检查 assets.purpose。
- 收藏和下载 worksheet_resource_id 可空且为逻辑关联;新写入需校验资源属于同一 worksheet,并且功能及内容均 active。历史缺失内容仍保留记录,详情使用 LEFT JOIN 并过滤发布状态。
- 页面 JSON 引用、任务/审计中的资源 ID 是逻辑关联。审计操作者不建用户外键,避免注销删除审计记录;用户注销时移除其中的身份关联与个人信息。 - 页面 JSON 引用、任务/审计中的资源 ID 是逻辑关联。审计操作者不建用户外键,避免注销删除审计记录;用户注销时移除其中的身份关联与个人信息。
## 3. 现有业务表 ## 3. 现有业务表
@@ -80,7 +89,6 @@ erDiagram
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| -------------------------------- | -------------- | ----------- | -------------------------------------------------------------------------------- | | -------------------------------- | -------------- | ----------- | -------------------------------------------------------------------------------- |
| id | varchar(128) | 否 | 原 `users._id`;新账户独立生成 ID | | id | varchar(128) | 否 | 原 `users._id`;新账户独立生成 ID |
| mini_app_id / miniAppId | varchar(64) | 否 | 当前微信 AppID;旧数据根据来源环境填入,不由客户端指定 |
| openid | varchar(128) | 否 | 微信服务端返回的可信标识 | | openid | varchar(128) | 否 | 微信服务端返回的可信标识 |
| unionid | varchar(128) | 是 | 空串转 NULL;仅保存身份线索,首期不自动合并账户 | | unionid | varchar(128) | 是 | 空串转 NULL;仅保存身份线索,首期不自动合并账户 |
| nick_name / nickName | varchar(100) | 是 | 用户昵称 | | nick_name / nickName | varchar(100) | 是 | 用户昵称 |
@@ -93,11 +101,11 @@ erDiagram
| last_active_at / lastActiveAt | timestamptz(3) | 否 / now() | 保留活跃时间;登录更新 | | last_active_at / lastActiveAt | timestamptz(3) | 否 / now() | 保留活跃时间;登录更新 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 新系统资料/权限等变更时间;旧数据缺失按导入规则补齐 | | updated_at / updatedAt | timestamptz(3) | 否 / now() | 新系统资料/权限等变更时间;旧数据缺失按导入规则补齐 |
约束与索引:主键 id;UNIQUE `(mini_app_id, openid)`;普通索引 unionid;CHECK `total_downloads >= 0`、`auth_version >= 0`。UnionID 不设唯一约束,避免尚未实施跨应用绑定时阻止两个 AppID 的独立账户;区别于旧预案中的全局唯一 UnionID。 约束与索引:主键 id;UNIQUE `(openid)`;普通索引 unionid;CHECK `total_downloads >= 0`、`auth_version >= 0`。UnionID 不设唯一约束,不用于自动合并账户。首期只接入一个小程序,AppID/AppSecret 由服务端配置,用户表不重复存储 AppID;未来多应用接入时单独设计登录身份表。
`GET /me/downloads/count` 与资料页数量以 `download_logs` 的当前用户记录数为准,不读取旧 totalDownloads。若兼容 DTO 需要该字段,由适配层返回查询结果;原字段只用于迁移对账,不形成第二套计数。 `GET /me/downloads/count` 与资料页数量以 `download_logs` 的当前用户记录数为准,不读取旧 totalDownloads。若兼容 DTO 需要该字段,由适配层返回查询结果;原字段只用于迁移对账,不形成第二套计数。
### 3.2 categories — 分类 ### 3.2 categories — 内容分类
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| --------------------------- | -------------- | ----------- | ------------------------------------------------------------------ | | --------------------------- | -------------- | ----------- | ------------------------------------------------------------------ |
@@ -113,73 +121,112 @@ erDiagram
主键 id;索引 `(parent_id, sort_order, id)`;CHECK `parent_id IS NULL OR parent_id <> id`。父分类必须存在,导入按树层级排序;多层循环不能靠上述 CHECK 判断。删除被题型或子分类引用的分类返回冲突。 主键 id;索引 `(parent_id, sort_order, id)`;CHECK `parent_id IS NULL OR parent_id <> id`。父分类必须存在,导入按树层级排序;多层循环不能靠上述 CHECK 判断。删除被题型或子分类引用的分类返回冲突。
分类负责数学、语文、手工等内容方向,年级是独立适用维度,不把“一年级数学”“二年级数学”复制为分类,也不把年级放入 categories.parent_id。Web 的“年级 → 分类”菜单是组合查询,不代表数据库父子关系。年级名称及展示顺序先使用固定配置,不新增 grades/菜单表。categories 表结构保持不变。
### 3.3 worksheets — 题型 ### 3.3 worksheets — 题型
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------- | -------------- | ----------- | ----------------------------------------------------------------------- | | ------------------------------------- | -------------- | --------------- | -------------------------------------------------------------------------------------------- |
| id | varchar(128) | 否 | 原 `_id` / 本地 worksheet ID | | id | varchar(128) | 否 | 原 `_id` / 本地 worksheet ID |
| title | varchar(100) | 否 | 保留标题;旧文档的 ≤8 字是展示建议 | | title | varchar(100) | 否 | 保留标题;旧文档的 ≤8 字是展示建议 |
| subtitle | varchar(500) | 否 | 保留副标题;不使用旧预案 desc 字段 | | subtitle | varchar(500) | 否 | 保留副标题;不使用旧预案 desc 字段 |
| category_id / categoryId | varchar(128) | 否 | 原 category;API 仍输出 category | | category_id / categoryId | varchar(128) | 否 | 原 category;API 仍输出 category |
| subcategory | varchar(100) | 是 | 子类字符串,旧空串可转 NULL;兼容 DTO 可还原空串 | | subcategory | varchar(100) | 是 | 子类字符串,旧空串可转 NULL;兼容 DTO 可还原空串 |
| path | varchar(1024) | 否 | 完整小程序页面路径及查询参数,保留跳转/分享语义;不使用 legacyPage 替代 | | path | varchar(1024) | 否 | 完整小程序页面路径及查询参数,保留跳转/分享语义;不使用 legacyPage 替代 |
| age_min / ageMin | smallint | 否 | 最小年龄 | | generation_mode / generationMode | varchar(16) | 否 / procedural | CHECK:procedural 算法生成、resource 完整内容资源驱动 |
| age_max / ageMax | smallint | 否 | 最大年龄 | | renderer_key / rendererKey | varchar(64) | 是 | 客户端注册的绘制实现标识;多个 worksheet 可共用;旧功能兼容期可空 |
| grade | smallint | 否 / 0 | 保留负值,如 -4 托班、0 幼小衔接;不使用 unsigned | | render_config / renderConfig | jsonb | 否 / {} | 共用绘制配置;顶层 object,服务端校验允许的参数 |
| difficulty | smallint | 否 | CHECK 1~4 | | age_min / ageMin | smallint | 否 | 最小年龄 |
| preview_asset_id / previewAssetId | varchar(128) | 否 | 七牛资产或经核实的已有 HTTPS 资源;API 唯一预览字段仍为 previewImg | | age_max / ageMax | smallint | 否 | 最大年龄 |
| tags | jsonb | 否 / [] | 字符串数组;数据库检查顶层 array,应用校验每个元素为字符串 | | grade | smallint | 否 / 0 | 旧版单年级兼容值,保留原值;新年级筛选以 worksheet_grades 为准 |
| is_new / isNew | boolean | 否 / false | 新品标记 | | difficulty | smallint | 否 | CHECK 1~4 |
| is_hot / isHot | boolean | 否 / false | 热门标记 | | preview_asset_id / previewAssetId | varchar(128) | 否 | 七牛资产或经核实的已有 HTTPS 资源;功能入口封面;API 仍为 previewImg,内容预览由资源列表提供 |
| sort_order / sortOrder | integer | 否 / 0 | 题型列表排序 | | tags | jsonb | 否 / [] | 字符串数组;数据库检查顶层 array,应用校验每个元素为字符串 |
| downloads | bigint | 否 / 0 | 原始下载计数;迁入原值,不按历史日志条数重算 | | is_new / isNew | boolean | 否 / false | 旧版新品兼容标记;新业务按 first_published_at 和新品窗口派生 |
| likes | bigint | 否 / 0 | 原始收藏计数;旧异常先报告,新业务保持事务一致性 | | is_hot / isHot | boolean | 否 / false | 功能级人工运营推荐,独立于统计排行榜 |
| downloads_seed / downloadsSeed | bigint | 否 / 0 | 运营展示种子;旧 API downloads_seed 由兼容层映射 | | sort_order / sortOrder | integer | 否 / 0 | 题型列表排序 |
| likes_seed / likesSeed | bigint | 否 / 0 | 运营展示种子 | | downloads | bigint | 否 / 0 | 原始下载计数;迁入原值,不按历史日志条数重算 |
| status | varchar(16) | 否 / draft | CHECK:draft、active、hidden | | likes | bigint | 否 / 0 | 原始收藏计数;旧异常先报告,新业务保持事务一致性 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 首次创建,不随重新发布修改 | | downloads_seed / downloadsSeed | bigint | 否 / 0 | 运营展示种子;旧 API downloads_seed 由兼容层映射 |
| content_updated_at / contentUpdatedAt | timestamptz(3) | 是 | 原值保留;缺失时展示回退 createdAt,不伪造一次内容更新 | | likes_seed / likesSeed | bigint | 否 / 0 | 运营展示种子 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 任意业务修改,包括计数和状态;与内容更新时间区分 | | status | varchar(16) | 否 / draft | CHECK:draft、active、hidden |
| created_at / createdAt | timestamptz(3) | 否 / now() | 首次创建,不随重新发布修改 |
| first_published_at / firstPublishedAt | timestamptz(3) | 是 / 无默认 | 首次公开发布时间;未知旧值保留 NULL,重发及新增资源不重置 |
| content_updated_at / contentUpdatedAt | timestamptz(3) | 是 | 原值保留;缺失时展示回退 createdAt,不伪造一次内容更新 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 任意业务修改,包括计数和状态;与内容更新时间区分 |
约束:`age_min >= 0 AND age_max >= age_min`;`difficulty BETWEEN 1 AND 4`;四种计数均非负;`jsonb_typeof(tags) = 'array'`。非法旧数据先隔离报告,不能直接归零掩盖问题。 约束:`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 索引;后续用真实查询计划决定。 首期索引:`(category_id, status, sort_order, updated_at DESC, id)`、`(status, sort_order, updated_at DESC, id)`、`(status, age_min, age_max)`。榜单对 active 候选集按真实值 + seed 排序,先不建全部榜单表达式索引或 tags GIN 索引;后续用真实查询计划决定。
`template`、`generator`、`generatorConfig`、`layoutConfig`、`userConfigurable` 尚非当前云函数核心字段,不设首期必填列。如果导出中存在,保存原始快照并明确转换;服务端生成正式实施时再设计私有配置,禁止通过公共题型 DTO 暴露。 新增绘制配置遵循 [Worksheet 数据库设计说明](./Worksheet数据库设计说明.md)。worksheet.id 是稳定业务标识,renderer_key 选择客户端已注册实现,render_config 指定参数,资源条目 ID 选择具体内容。不增加含义不明的 type,也不新增独立模板/绘制引擎表。
### 3.4 favorites — 当前收藏关系 SQL 检查 generation_mode 枚举、renderer_key 非空白(允许 SQL NULL)及 render_config 顶层 object。renderer_key 不唯一;旧记录保持原 ID/path,默认 procedural、NULL renderer_key、空配置。导入时逐项核对生成方式并依据真实代码映射,禁止按 ID 命名猜测。NULL 仅允许已登记的旧 ID/path 兼容映射;新功能发布必须设置已支持的 renderer_key。未知实现提示暂不可用或升级,不能默认生成其他内容。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | 资源驱动功能通过 worksheet_resources 提供分页内容列表;算法生成可没有资源条目。公共 DTO 只公开经过白名单校验的客户端参数,不能包含代码、服务端密钥或任意签名 URL。旧导出中的 template/generator 等字段先保存原始快照并明确转换,不自动解释为新 render_config。
| -------------------------- | -------------- | ----------- | ------------------------- |
| 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)`。这是当前关系表,取消即删除,不是收藏事件日志。 ### 3.4 favorites — 功能与内容当前收藏关系
旧重复关系选择最早 createdAt 的记录作为保留项,同时间按 id 精确排序;被合并的原 ID 写入报告。迁移不借去重自动改写历史 likes。 | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------------- | -------------- | ----------- | ------------------------------------------------- |
| id | varchar(128) | 否 | 原 `_id`;新关系生成新 ID |
| user_id / userId | varchar(128) | 否 | users.id 外键 |
| worksheet_id / worksheetId | varchar(128) | 否 | 稳定功能 ID,逻辑关联;内容收藏时由服务端确定归属 |
| worksheet_resource_id / worksheetResourceId | varchar(128) | 是 | NULL 收藏功能,非 NULL 收藏具体内容条目,逻辑关联 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 收藏时间 |
无需 type 或 resource_favorites 表。一条完整内容即使含多张 SVG/参考图,也只产生一条内容收藏,不以 asset_id 为收藏对象。用户可以同时收藏一个功能和它的多份内容,两者不自动转换或联动。
旧 `(user_id, worksheet_id)` 唯一约束替换为两种部分唯一索引:
```sql
CREATE UNIQUE INDEX favorites_user_worksheet_key
ON public.favorites (user_id, worksheet_id)
WHERE worksheet_resource_id IS NULL;
CREATE UNIQUE INDEX favorites_user_resource_key
ON public.favorites (user_id, worksheet_resource_id)
WHERE worksheet_resource_id IS NOT NULL;
```
列表索引保留 `(user_id, created_at DESC, id DESC)`。CHECK:非 NULL 内容 ID 不允许空白;NULL 明确是功能目标,不能用空字符串代表资源。部分唯一索引通过原生 SQL 管理,不能在 Prisma 接入时替换成不带条件的 @@unique 或恢复旧约束。这是当前关系表,取消即删除,不是事件日志。
内容 ID 不建外键以保留失联/下架收藏,查询 LEFT JOIN;服务端添加时校验内容存在、所属功能及两层 active。worksheet_id 从内容查得;客户端若同时传入不同 worksheetId,拒绝请求。已发布或被历史/收藏引用的内容不得重挂另一功能,避免稳定归属改变。资源缺失/隐藏时可返回 resource:null;父功能下架时也不返回未发布功能或资源详情。内容 ID 原值必须保留,不能 SET NULL 转成功能收藏;用户仍可取消。
默认旧列表/数量/单张和批量功能检查仅查 `worksheet_resource_id IS NULL`,旧客户端/CloudBase 桥不能读写或误删内容收藏。新列表与数量显式选择 target=worksheet/resource/all,默认 worksheet;返回 favoriteId、targetType(响应派生)、worksheetId、worksheetResourceId 及对应详情,以便客户端显示资源的独立预览并准确跳转。功能收藏量 likes 仅计功能收藏;新个人全部收藏数量才计两类,不混用旧数量契约。
008 只扩列及调整索引,所有旧记录新列为 NULL,原 ID/时间/worksheetId/likes 不变,不推断原收藏的资源。旧功能重复按最早 createdAt、同时间 id 归并;若未来导入明确内容收藏,按 userId+worksheetResourceId 去重并核对归属。迁移不借去重自动改写历史 likes。
### 3.5 download_logs — 成功保存历史 ### 3.5 download_logs — 成功保存历史
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| -------------------------- | -------------- | ----------- | ----------------------------------------------------- | | ------------------------------------------- | -------------- | ----------- | ------------------------------------------------------------ |
| id | varchar(128) | 否 | 原 `_id` 或新日志 ID | | id | varchar(128) | 否 | 原 `_id` 或新日志 ID |
| user_id / userId | varchar(128) | 否 | users.id 外键 | | user_id / userId | varchar(128) | 否 | users.id 外键 |
| worksheet_id / worksheetId | varchar(128) | 否 | 原题型 ID,逻辑关联 | | worksheet_id / worksheetId | varchar(128) | 否 | 原题型 ID,逻辑关联 |
| export_id / exportId | varchar(128) | 是 | 同一次成功保存的事件 ID;历史缺失为 SQL NULL | | worksheet_resource_id / worksheetResourceId | varchar(128) | 是 | 内容条目逻辑关联;旧历史、算法生成可空;资源驱动新下载需提供 |
| source | varchar(16) | 否 / mini | CHECK:mini、legacy、import;新版上报、旧桥、历史迁入 | | export_id / exportId | varchar(128) | 是 | 同一次成功保存的事件 ID;历史缺失为 SQL NULL |
| params | jsonb | 是 | 保留历史可选参数快照;当前 add 不写,不新增收集要求 | | source | varchar(16) | 否 / mini | CHECK:mini、legacy、import;新版上报、旧桥、历史迁入 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 原历史时间 / 新服务收到事件的时间 | | 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 若指向不同题型,隔离处理,不能任意挑一条。 UNIQUE `(user_id, export_id)` 使用 PostgreSQL 默认 NULL 语义,允许同用户多条 export_id=NULL;**不使用 NULLS NOT DISTINCT**。空串先转 NULL,避免旧记录冲突。新版 API 必填 exportId,NULL 只允许历史导入。重复非空 exportId 若指向不同题型,隔离处理,不能任意挑一条。
同一 exportId 重试必须核对 worksheetId、worksheetResourceId 一致,不得把另一资源下载吞掉。资源 ID 不设唯一约束,同一内容可多次保存。历史内容缺失、隐藏或功能已下架时不返回未发布详情。
SQL CHECK 限定 NULL exportId 只能出现在 source=import 的历史记录中;其余来源必须提供非空白事件 ID,旧桥负责生成 legacy 事件 ID。 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)` 或按秒级时间窗口去重。 索引 `(user_id, created_at DESC, id DESC)`、`(worksheet_id, created_at DESC, id DESC)`。不同 exportId 的同题型多次保存都是有效事件;禁止 UNIQUE `(user_id, worksheet_id)` 或按秒级时间窗口去重。
### 3.6 page_configs — 页面配置权威源 ### 3.6 page_configs — 页面配置权威源
用途:保存页面选取哪些内容、如何组成栏目以及展示顺序,目前主要承接小程序首页。它迁自已有 CloudBase 集合,不是新增的通用页面搭建系统。worksheets/worksheet_resources 保存功能及内容本身,categories 管分类,page_configs 保存页面编排和组装后的展示快照。
例如首页推荐“动物涂色”和“加减法”,热门栏目选取收藏榜前几项,“练笔入门”专题按运营指定顺序展示,这些结果分别写入 home.featured、home.hot、home.sections。当前 pageContentBuild 负责手动构建,homeAutoRefresh 自动更新 featured/hot,并保留其他手动栏目;客户端通过预拉取或接口一次读取并缓存。home.hot 是展示栏目,不等于 worksheets.is_hot 人工运营标记。
“权威源”指页面编排以数据库配置为准,七牛上的可选静态配置文件是发布副本。快照中的标题、预览等来源于业务表,内容变化后需要刷新快照,不将快照当成题型原始数据。version 标识配置更新,published_version/published_at 标识静态副本发布进度;这里的发布时间与功能/资源 first_published_at 无关。
首期保留首页推荐和专题编排。未来 Web 的“年级 → 分类”普通列表直接组合查询 worksheet_grades 与 categories,无需为每种筛选组合保存页面配置。category/age 列主要保留旧分支兼容,可为空;不把所有页面数据都装入本表。新服务端页面配置 API 尚待实现。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------ | -------------- | ----------- | ------------------------------------------------------------------------- | | ------------------------------------ | -------------- | ----------- | ------------------------------------------------------------------------- |
| id | varchar(128) | 否 | 首期沿用 current;主键,不额外限制只能一行,其他旧文档需盘点 | | id | varchar(128) | 否 | 首期沿用 current;主键,不额外限制只能一行,其他旧文档需盘点 |
@@ -213,6 +260,12 @@ SQL CHECK 限定 NULL exportId 只能出现在 source=import 的历史记录中
### 3.7 settings — 运行配置 ### 3.7 settings — 运行配置
用途:保存运行时可调整的业务开关和参数,例如是否启用首页自动刷新;由服务端读取并执行规则,调整已支持的参数通常无需重新发布代码。它保存“是否执行、按什么规则执行”,page_configs 保存最终展示结果,job_runs 保存执行情况。
现有小程序已经使用 CloudBase settings 集合:[首页内容管理页](../../../doodle-mini/miniprogram/supportPages/homeContentManage/homeContentManage.ts) 读取 homeAutoRefresh,调用 [homeTimerControl](../../../doodle-mini/cloudfunctions/homeTimerControl/index.js) 修改 enabled,[homeAutoRefresh](../../../doodle-mini/cloudfunctions/homeAutoRefresh/index.js) 执行前检查开关。当前尚未接入新 PostgreSQL 表;迁移后管理页面通过受管理员权限保护的 API 读写,客户端不再直连数据库。
首期只承接 homeAutoRefresh 等已核实的现有配置;刷新计划、新品时间窗口等确需动态管理时再增加。value 虽然是 JSON,每个配置仍须定义明确格式、默认值和校验规则;version 用于识别修改并支持并发更新,updated_at 记录最近修改。固定规则继续放代码,数据库连接、微信/七牛/JWT 密钥放受控环境变量,不放入本表,也不将运行配置整体公开给客户端。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ---------------------- | -------------- | ----------- | ------------------------------------------------------- | | ---------------------- | -------------- | ----------- | ------------------------------------------------------- |
| id | varchar(128) | 否 | 原固定 ID,例如 homeAutoRefresh | | id | varchar(128) | 否 | 原固定 ID,例如 homeAutoRefresh |
@@ -228,26 +281,26 @@ SQL CHECK 限定 NULL exportId 只能出现在 source=import 的历史记录中
### 4.1 assets — 文件引用与迁移映射 ### 4.1 assets — 文件引用与迁移映射
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------- | -------------- | ------------ | ---------------------------------------------------------------------- | | ------------------------------- | -------------- | ------------ | ---------------------------------------------------------------------------------------- |
| id | varchar(128) | 否 | 稳定资产 ID | | id | varchar(128) | 否 | 稳定资产 ID |
| source_ref / sourceRef | text | 是 | 原 cloud:// fileID 或已核实的稳定 HTTPS URL;禁止签名 URL | | source_ref / sourceRef | text | 是 | 原 cloud:// fileID 或已核实的稳定 HTTPS URL;禁止签名 URL |
| source_ref_hash / sourceRefHash | char(64) | 是 | source_ref 的 SHA-256 十六进制,用于定长唯一索引 | | source_ref_hash / sourceRefHash | char(64) | 是 | source_ref 的 SHA-256 十六进制,用于定长唯一索引 |
| provider | varchar(16) | 否 / qiniu | CHECK:qiniu、external;后者用于已有可用 HTTPS 资源 | | provider | varchar(16) | 否 / qiniu | CHECK:qiniu、external;后者用于已有可用 HTTPS 资源 |
| bucket | varchar(128) | 是 | 七牛空间名;qiniu 就绪时必填 | | bucket | varchar(128) | 是 | 七牛空间名;qiniu 就绪时必填 |
| object_key / objectKey | text | 是 | 七牛对象 key,区分大小写,使用版本化路径 | | object_key / objectKey | text | 是 | 七牛对象 key,区分大小写,使用版本化路径 |
| object_ref_hash / objectRefHash | char(64) | 是 | 对 provider、bucket、objectKey 的规范数组序列化结果计算 SHA-256 | | object_ref_hash / objectRefHash | char(64) | 是 | 对 provider、bucket、objectKey 的规范数组序列化结果计算 SHA-256 |
| external_url / externalUrl | text | 是 | external 时的受控稳定 HTTPS 地址,不支持任意客户端远程抓取 | | external_url / externalUrl | text | 是 | external 时的受控稳定 HTTPS 地址,不支持任意客户端远程抓取 |
| purpose | varchar(32) | 否 | CHECK:avatar、preview、category_icon、font、share、page_config、other | | purpose | varchar(32) | 否 | CHECK:avatar、preview、category_icon、font、share、page_config、worksheet_source、other |
| owner_user_id / ownerUserId | varchar(128) | 是 | 用户头像归属;公共运营资产可空 | | owner_user_id / ownerUserId | varchar(128) | 是 | 用户头像归属;公共运营资产可空 |
| visibility | varchar(16) | 否 | CHECK:public、private;头像使用 private | | visibility | varchar(16) | 否 | CHECK:public、private;头像使用 private |
| mime_type / mimeType | varchar(128) | 是 | 识别的媒体类型 | | mime_type / mimeType | varchar(128) | 是 | 识别的媒体类型 |
| size_bytes / sizeBytes | bigint | 是 | 文件大小,非负 | | size_bytes / sizeBytes | bigint | 是 | 文件大小,非负 |
| checksum | char(64) | 是 | 文件内容 SHA-256;不能直接以对象 ETag 当 SHA-256 | | checksum | char(64) | 是 | 文件内容 SHA-256;不能直接以对象 ETag 当 SHA-256 |
| status | varchar(16) | 否 / pending | CHECK:pending、ready、failed、deleting、deleted | | status | varchar(16) | 否 / pending | CHECK:pending、ready、failed、deleting、deleted |
| last_error / lastError | text | 是 | 经清理的迁移/删除失败原因 | | last_error / lastError | text | 是 | 经清理的迁移/删除失败原因 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 创建映射或上传时间 | | created_at / createdAt | timestamptz(3) | 否 / now() | 创建映射或上传时间 |
| updated_at / updatedAt | 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 不唯一,同内容可有不同归属和访问策略。 UNIQUE source_ref_hash、UNIQUE object_ref_hash(可空);索引 `(owner_user_id, purpose, status)`、`(status, updated_at)`。hash 命中时仍核对完整源/对象引用,避免错误归并;checksum 不唯一,同内容可有不同归属和访问策略。
@@ -255,10 +308,16 @@ qiniu ready 必须具备 bucket、objectKey、objectRefHash、mimeType、sizeByt
SQL 检查 ready 必填字段、external 的 HTTPS 前缀、avatar 必须 private,以及摘要/hash 为 64 位小写十六进制。摘要计算和规范化由服务端完成;HTTPS 前缀检查不代替域名白名单或远程文件校验。 SQL 检查 ready 必填字段、external 的 HTTPS 前缀、avatar 必须 private,以及摘要/hash 为 64 位小写十六进制。摘要计算和规范化由服务端完成;HTTPS 前缀检查不代替域名白名单或远程文件校验。
上传和对象存储操作不放在长数据库事务中;先完成上传并登记资产,再短事务绑定业务记录。未绑定文件由清理流程回收,清理前检查 users、worksheets、categories 和 page_configs 的所有引用。旧桥复制云文件后也必须遵循同一路径。 上传和对象存储操作不放在长数据库事务中;先完成上传并登记资产,再短事务绑定业务记录。未绑定文件由清理流程回收,清理前检查 users、worksheets、worksheet_resources、worksheet_resource_assets、categories 和 page_configs 的所有引用。旧桥复制云文件后也必须遵循同一路径。
### 4.2 job_runs — 调度与执行结果 ### 4.2 job_runs — 调度与执行结果
用途:记录后台任务的某次逻辑执行是否发生、是否成功、耗时、结果、失败原因及重试次数,目前主要为首页自动刷新和可选静态配置发布准备。这是新服务端辅助表,原小程序没有对应的业务集合。
例如首页刷新的一次执行按任务名和 run_key 定位,记录开始/结束时间、succeeded 或 failed、更新后的页面配置版本及推荐条数;失败时记录清理后的错误摘要。settings 决定开关与计划,服务端调度器触发任务,job_runs 记录执行过程,page_configs 保存成功产出的配置。
唯一执行标识配合服务端锁与状态检查,防止同次任务重复执行;执行状态、attempt 和租约为失败重试、服务重启后的遗漏补跑提供依据。表本身不会触发任务,唯一约束也不保证业务只执行一次,定时触发、领取、重试及补跑均由后续服务端代码实现。首期保留轻量执行记录,不因此引入队列、独立调度平台或通用任务管理后台;查询、普通收藏和下载不写本表。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ---------------------------- | -------------- | ------------ | ----------------------------------------------------------------- | | ---------------------------- | -------------- | ------------ | ----------------------------------------------------------------- |
| id | varchar(128) | 否 | 执行记录 ID | | id | varchar(128) | 否 | 执行记录 ID |
@@ -283,6 +342,14 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre
### 4.3 admin_audit_logs — 管理审计 ### 4.3 admin_audit_logs — 管理审计
用途:追溯重要业务修改“谁在什么时候修改了什么、修改前后是什么、为什么修改”。例如管理员将“小猫涂色”下架,或将功能 downloads 从 100 修订为 500,记录操作者、对象、前后值、原因、时间及 requestId。普通应用日志排查程序运行问题,审计记录保留已经提交的关键业务变更;本表是新服务端辅助表,不是小程序原有集合。
必要性:它不是迁移现有功能的硬性前置条件;如果只有单人管理且没有关键修订,也可推迟。当前计划支持计数修订、内容上下架和权限管理,因此保留轻量审计更合适。成本不仅是一张表,还包括相关服务方法的审计写入,首期不追求覆盖所有操作,也不开发独立审计查询后台。
首期范围:人工修订真实计数/seed;功能和资源发布、上下架及关键内容维护(含适用年级/分类调整);首页配置及重要运行开关修改;管理员授权、降权和账户禁用。正常页面查询、用户收藏、下载和每次定时刷新不写管理审计;普通运行错误写应用日志,定时执行过程写 job_runs。已有一致性规则明确要求的系统计数异常仍可记录 actor_type=system,不能把全部普通用户操作转成审计事件。
纳入范围的成功管理修改和审计同事务提交,避免数据修改成功却缺少记录。审计不是自动撤销或完整版本历史;恢复修改仍需业务逻辑和校验,不增加通用回滚平台。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ---------------------------- | -------------- | ----------- | ------------------------------------------------- | | ---------------------------- | -------------- | ----------- | ------------------------------------------------- |
| id | varchar(128) | 否 | 审计 ID | | id | varchar(128) | 否 | 审计 ID |
@@ -297,44 +364,152 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre
| reason | text | 是 | 管理修订说明;显式统计修订必须提供 | | reason | text | 是 | 管理修订说明;显式统计修订必须提供 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 操作提交时间 | | created_at / createdAt | timestamptz(3) | 否 / now() | 操作提交时间 |
索引 `(resource_type, resource_id, created_at DESC)`、`(actor_user_id, created_at DESC)`、request_id。已提交管理变更与审计在同一事务;dryRun 不写业务表,可记录校验日志;失败请求写应用错误日志,不制造成功审计。快照不得包含 token、AppSecret、session_key、登录 code、签名 URL 或不必要个人资料。 索引 `(resource_type, resource_id, created_at DESC)`、`(actor_user_id, created_at DESC)`、request_id。纳入审计范围的已提交管理变更与审计在同一事务;dryRun 不写业务表,可记录校验日志;失败请求写应用错误日志,不制造成功审计。快照不得包含 token、AppSecret、session_key、登录 code、签名 URL 或不必要个人资料。
### 4.4 worksheet_resources — 完整可选择内容
一条记录表示“小猫涂色”等完整内容,既不是单个七牛文件,也不是通用模板。
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------- | -------------- | ----------- | ------------------------------------------- |
| id | varchar(128) | 否 | 内容条目 ID,分享、选择及下载记录使用 |
| worksheet_id / worksheetId | varchar(128) | 否 | worksheets.id 外键 |
| title | varchar(100) | 否 | 内容标题 |
| preview_asset_id / previewAssetId | varchar(128) | 否 | assets.id 外键;内容列表预览图 |
| config_override / configOverride | jsonb | 是 | 少量允许参数的覆盖;SQL NULL 表示无覆盖 |
| status | varchar(16) | 否 / draft | CHECK:draft、active、hidden |
| sort_order / sortOrder | integer | 否 / 0 | 内容展示顺序 |
| downloads | bigint | 否 / 0 | 内容累计成功保存次数,不含功能旧计数或 seed |
| first_published_at / firstPublishedAt | timestamptz(3) | 是 / 无默认 | 内容首次公开发布时间,草稿不写,重发不重置 |
| created_at / createdAt | timestamptz(3) | 否 / now() | 创建时间 |
| updated_at / updatedAt | timestamptz(3) | 否 / now() | 业务修改时间(含计数),由服务维护 |
CHECK:downloads >= 0;config_override 为 SQL NULL 或顶层 object,不接受 JSON null、数组或标量。索引 `(worksheet_id, status, sort_order, id)`、`(preview_asset_id)`。预览必须有资产引用;草稿可引用 pending,发布须通过 ready/类型/访问策略校验。
共用默认值 → worksheet.render_config → 内容 config_override → 允许的用户参数,按该 renderer 定义的参数规则处理;首期采用明确的顶层参数覆盖,不做任意深度合并。内容不能覆盖 renderer_key、generation_mode 或资产引用。公共列表只返回功能和内容均 active 的项目。
### 4.5 worksheet_resource_assets — 内容使用的文件
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| ------------------------------------------- | ------------ | ----------- | ------------------------------------------ |
| id | varchar(128) | 否 | 文件关联 ID |
| worksheet_resource_id / worksheetResourceId | varchar(128) | 否 | worksheet_resources.id 外键 |
| asset_id / assetId | varchar(128) | 否 | assets.id 外键 |
| role | varchar(16) | 否 | 首期 CHECK:outline 线框、reference 对照图 |
| sort_order / sortOrder | integer | 否 / 0 | 同用途文件的处理顺序,并列按 id |
UNIQUE `(worksheet_resource_id, role, asset_id)`,防止同内容同用途重复引用。同一资产可被不同内容使用,也可同时充当不同用途。索引 `(worksheet_resource_id, role, sort_order, id)` 和 `(asset_id)`;支持多个同用途文件,无单文件限制。
不设文件级 config,不将 sort_order 当通用 z-index。布局、数量限制、SVG/图片/JSON 类型要求、是否展示或打印由 renderer 决定。首期不开放 background/overlay 等用途,实际需要时同步扩展 SQL 与客户端协议。基础素材新增 assets.purpose=worksheet_source;参考图可复用 preview 资产。purpose 表达资产管理用途,role 表达内容里的使用方式,两者不同。
线框与参考图属于一份内容,默认共同服务这份内容;不把文件关联列表理解为随机候选池。内容列表预览、绘制参考图、功能封面彼此独立,可引用同一资产。发布时服务端验证所需文件数量、ready 状态、类型和访问策略;隐藏/删除资产前验证仍被引用,不靠外键检查软删除状态。
### 4.6 worksheet_grades — 功能适用年级
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| -------------------------- | ------------ | ------------- | ----------------------------------------------------------------------- |
| worksheet_id / worksheetId | varchar(128) | 否 | worksheets.id 外键 |
| grade | smallint | 否 / 无默认值 | 固定编码:-4 托班、-3 小班、-2 中班、-1 大班、0 幼小衔接、1~6 小学年级 |
联合主键 `(worksheet_id, grade)` 防止重复;CHECK `grade BETWEEN -4 AND 6`;反向索引 `(grade, worksheet_id)`。外键更新/删除 RESTRICT,延续题型只上下架的策略。允许同一题型关联多个、不连续年级;不生成全年级默认关系。扩展年级体系时同步修改 SQL、客户端字典及接口校验。
无关联表示适用年级尚未配置,不表示全年级:普通无年级筛选列表仍可显示 active 题型,指定年级时只返回有该关系的 active 题型。grade=0 明确表示幼小衔接,不表示未知或通用。真正全年级适用的内容应显式关联适用年级。
新服务端支持 grade(单选)或 grades(多选,任一年级匹配)及 categoryId 组合筛选。用 EXISTS 半连接避免跨年级查询重复题型,分页和总数使用同一条件;年级筛选不能绕过发布状态。分类父级是否含子级需遵循分类接口规则,不用分类父子结构解释年级。
```sql
-- 示例:一年级数学;正式 API 使用参数化查询,不拼接用户输入。
SELECT w.id, w.title
FROM public.worksheets AS w
WHERE w.status = 'active'
AND w.category_id = 'math'
AND EXISTS (
SELECT 1
FROM public.worksheet_grades AS wg
WHERE wg.worksheet_id = w.id AND wg.grade = 1
)
ORDER BY w.sort_order, w.updated_at DESC, w.id;
```
年级范围由管理员依据内容核实,不能从 age_min/age_max 自动推算。改年级时锁定 worksheet 行,在一个事务内替换关系、更新内容/业务时间并写管理审计。worksheet_grades 是唯一权威来源;旧 worksheets.grade 冻结为兼容原值,不与新关系双向同步,不由客户端分别更新两套字段。新建题型的旧列默认 0 仅为旧结构兼容,不能据此自动添加幼小衔接关系。新 DTO 返回 grades 数组,旧 DTO 如需 grade 则保留兼容字段,不宣称其等于完整适用范围;旧管理请求带单值 grade 时需经明确适配流程,不能无提示替换已维护的多个年级。
006 仅创建空关系表,不批量 INSERT SELECT worksheets.grade。旧值是候选:部分由年龄中心推算、0 也可能是兜底,需审核每个题型的真实范围,生成显式 worksheetId → grades 映射;越界值先报告,不裁剪或归零。审核后与题型导入同批写关系并对账,使用联合主键实现幂等。默认缺少关系的旧功能可继续出现在无年级筛选列表,按年级页面上线前须完成相关内容映射。
资源条目首期继承所属 worksheet 的适用年级,不增加资源年级表。若一个功能里混有不同年级内容,当前关系只能筛选功能,不能保证每个子资源适用于所选年级;此类内容按年级页面上线前需明确划分功能或另行设计资源级筛选。
## 5. 一致性与事务规则 ## 5. 一致性与事务规则
### 5.1 登录与注销 ### 5.1 登录与注销
登录用可信 `(miniAppId, openid)` 查找用户,沿用老 ID。首次并发登录以唯一约束兜底,冲突后读取已创建用户。首期 JWT 使用 userId + authVersion;每次鉴权核对用户存在、active 和版本,role 以当前服务端数据为准。 登录使用服务端配置的单一小程序 AppID,按微信返回的可信 `openid` 查找用户,沿用老 ID。首次并发登录以唯一约束兜底,冲突后读取已创建用户。首期 JWT 使用 userId + authVersion;每次鉴权核对用户存在、active 和版本,role 以当前服务端数据为准。
涉及用户的写事务先锁用户行并核对状态,再锁题型行;多个题型按 ID 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。 `auth_version` 是账户级登录凭证版本,默认 1。JWT 签发时写入相同的 `authVersion`,每次访问受保护接口时与数据库当前值比较;不一致返回 401,要求重新登录。需要强制全部设备退出或撤销已签发凭证时,由业务服务在事务中递增版本;普通登录和资料更新不递增,避免其他设备无故退出。它撤销的是该用户所有旧版本 token,不用于单设备退出。账户禁用及角色调整仍以数据库当前 status/role 校验,版本不能替代权限检查。现有守卫已实现版本比较,真实 AccountReader 与主动递增业务尚待接入。
清除资料:昵称与头像引用置 NULL,保留用户、收藏和历史;解除引用后清理用户私有文件。注销:锁用户 → 对所有收藏涉及的现存题型按 ID 加锁并减少 likes → 删除收藏、下载日志 → 解除头像及其他私有资产关联,标记清理 → 清理身份审计关联 → 删除用户,全部数据库修改一事务。用户不存在时旧 token 立即无效。注销后再次微信登录创建全新 userId,不复用已删除账户,不恢复旧记录。 涉及用户的写事务先锁用户行并核对状态,再锁题型行、需要时锁内容行;多个题型及同层内容按 ID 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。
历史下载计数是题型累计成功保存次数,注销删除个人日志时不回减 downloads。likes 表示当前收藏数量,注销需要扣除当前关系的贡献;若旧计数小于应扣数量,扣减保护为 0,并在同事务记录系统审计异常,不能得到负值,也不能因此阻止注销。 清除资料:昵称与头像引用置 NULL,保留用户、收藏和历史;解除引用后清理用户私有文件。注销:锁用户 → 只对功能收藏(worksheet_resource_id IS NULL)涉及的现存题型按 ID 加锁并减少 likes → 删除功能及内容两种收藏、下载日志 → 解除头像及其他私有资产关联,标记清理 → 清理身份审计关联 → 删除用户,全部数据库修改一事务。用户不存在时旧 token 立即无效。注销后再次微信登录创建全新 userId,不复用已删除账户,不恢复旧记录。
功能和资源下载计数都是累计成功保存次数,注销删除个人日志时不回减任一层 downloads。likes 仅表示当前功能收藏数量,注销需要扣除当前关系的贡献;若旧计数小于应扣数量,扣减保护为 0,并在同事务记录系统审计异常,不能得到负值,也不能因此阻止注销。
对象清理在事务提交后重试,失败状态可查询。数据库备份有滚动保留期限,恢复时须重新落实备份之后的注销删除;不能用恢复或旧导入重新激活已注销关系。具体备份保留期和清理任务在部署文档落实。 对象清理在事务提交后重试,失败状态可查询。数据库备份有滚动保留期限,恢复时须重新落实备份之后的注销删除;不能用恢复或旧导入重新激活已注销关系。具体备份保留期和清理任务在部署文档落实。
### 5.2 收藏 ### 5.2 功能和内容收藏
添加:锁用户、锁题型并校验 active → `INSERT ... ON CONFLICT (user_id, worksheet_id) DO NOTHING RETURNING id` → 仅实际插入时 likes+1、更新 updatedAt → 提交。重复添加返回现有关系,不加数。 功能添加:锁用户 → 锁题型并校验 active → 按功能部分唯一索引插入,只有实际插入时 likes+1、更新功能 updatedAt;重复添加返回已有关系,不加数。
取消:锁用户、锁现存题型 → `DELETE ... RETURNING id` → 仅实际删除且题型存在时减少 likes → 提交。允许取消已下架或历史缺失题型的收藏。旧数据导致 likes 已为 0 时维持 0 并记录异常。重复取消不减数。 ```sql
-- 部分唯一索引推断必须带对应谓词,旧版无 WHERE 的 ON CONFLICT 需改造。
INSERT INTO public.favorites (id, user_id, worksheet_id)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, worksheet_id)
WHERE worksheet_resource_id IS NULL
DO NOTHING RETURNING id;
```
内容添加:读取稳定资源归属 → 锁用户 → 锁父功能 → 锁内容并重新核对归属及两层 active → 按内容部分唯一索引插入;不增减功能 likes,不自动收藏功能,也不新增资源 likes。用户写入只操作当前身份,不接受客户端 userId。
```sql
INSERT INTO public.favorites (id, user_id, worksheet_id, worksheet_resource_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, worksheet_resource_id)
WHERE worksheet_resource_id IS NOT NULL
DO NOTHING RETURNING id;
```
取消以当前 userId+favoriteId 或显式目标定位;旧按 worksheetId 取消时必须加 IS NULL,内容取消按 worksheetResourceId 精确定位,不能只用 worksheetId 删除全部记录。功能关系 DELETE 真正发生且题型仍存在才减少 likes;内容取消不修改统计。缺失或下架目标不阻止取消,重复取消不扣数。旧 likes 已为 0 时保护为 0 并记录异常;所有真实计数变更与关系删除在同事务。
沿用 `PUT/DELETE /me/favorites/:worksheetId` 为功能收藏契约。新增 `PUT/DELETE /me/favorites/resources/:worksheetResourceId` 为内容收藏契约,不依赖查询参数猜测目标;统一列表/数量 target 显式筛选。功能 check 接口只检查功能关系,内容 check 使用资源 ID 列表及独立目标声明。实际路由及 DTO 在服务端开发时落实,本次没有实现这些 API。
### 5.3 成功保存与 exportId ### 5.3 成功保存与 exportId
新版只在相册保存成功后生成/复用 exportId 上报。事务先锁用户、查已有 `(userId, exportId)`:同题型返回既有记录,题型不同返回冲突。尚不存在时锁题型并校验 active,插入日志,真实 downloads+1,更新 updatedAt 后提交;唯一键同时防并发重试。已有事件在题型随后下架时仍可幂等返回。 新版只在相册保存成功后生成/复用 exportId 上报。事务先锁用户、查已有 `(userId, exportId)`:worksheetId 和 worksheetResourceId(含 NULL)均相同才返回既有记录,任一不同返回冲突。尚不存在时依次锁题型、资源条目,校验 active 及资源归属。资源驱动新事件必须有内容 ID;算法生成和旧协议缺少可信内容归属时可空。只有日志 INSERT 真正成功后才递增 worksheets.downloads;有可信内容 ID 时,同事务递增对应 worksheet_resources.downloads,并维护两者 updated_at。任何一步失败全部回滚;唯一键防并发重试。已有事件在功能或内容随后下架时仍可幂等返回,不再次加数。下载计数不更新内容时间和首次发布时间。
`ON CONFLICT DO NOTHING RETURNING` 未插入时不能加计数,需在后续查询读取已有结果并核对题型。死锁或序列化冲突采用有界重试,始终复用相同 exportId,禁止事务失败后单独补加计数。 `ON CONFLICT DO NOTHING RETURNING` 未插入时不能加计数,需在后续查询读取已有结果并核对题型和内容 ID。死锁或序列化冲突采用有界重试,始终复用相同 exportId,禁止事务失败后单独补加计数。
旧桥未收到 exportId 时为本次接收请求生成带 legacy 前缀的 ID,内部转发重试复用;不同旧请求生成不同 ID,承认无法精确识别独立旧请求间的同一次导出。历史导入的 NULL exportId 保持 NULL,不给每条旧日志伪造客户端事件 ID。 旧桥未收到 exportId 时为本次接收请求生成带 legacy 前缀的 ID,内部转发重试复用;不同旧请求生成不同 ID,承认无法精确识别独立旧请求间的同一次导出。历史导入的 NULL exportId 保持 NULL,不给每条旧日志伪造客户端事件 ID。
### 5.4 题型发布与计数 ### 5.4 题型发布与计数
常规发布只更新内容列、contentUpdatedAt 和 updatedAt;createdAt、真实计数与 seed 保留。新建题型真实计数为 0,运营种子单独写入。修改年龄、标签等内容需更新内容时间;纯计数或上下架只更新 updatedAt。 常规发布只更新内容列、contentUpdatedAt 和 updatedAt;createdAt、真实计数与 seed 保留。新建题型和内容真实计数为 0,运营种子只保留在功能层,不能分摊到资源。修改年龄、标签等内容需更新内容时间;纯计数或上下架只更新 updatedAt。
真实计数/seed 修订使用明确管理动作,锁题型行、记录前后值和 reason、同事务写审计。批量操作每项独立事务,保留逐项结果;dryRun 做同样校验且不写入。不将整批部分失败伪装为完全成功。 真实计数/seed 修订使用明确管理动作,锁题型行、记录前后值和 reason、同事务写审计。批量操作每项独立事务,保留逐项结果;dryRun 做同样校验且不写入。不将整批部分失败伪装为完全成功。
展示 likes/downloads = 原始值 + 对应 seed;管理 rawStats 不加 seed。保持现有首页 Top5、分龄 Top3 下载 + Top3 收藏再全量下载补齐的种子参与规则。分龄严格采用 `ageMin == 下限 AND ageMax >= 上限`;分龄并列沿用 updatedAt DESC,再以 id 稳定排序。首页并列新增稳定 id 排序,并在固定样本验收中记录差异。 展示 likes/downloads = 原始值 + 对应 seed;管理 rawStats 不加 seed。保持现有首页 Top5、分龄 Top3 下载 + Top3 收藏再全量下载补齐的种子参与规则。分龄严格采用 `ageMin == 下限 AND ageMax >= 上限`;分龄并列沿用 updatedAt DESC,再以 id 稳定排序。首页并列新增稳定 id 排序,并在固定样本验收中记录差异。
### 5.4.1 新品、运营推荐与两层下载统计
worksheets 表示功能,worksheet_resources 表示可选内容。新增“小猫”只影响该内容的新鲜度,不重置“动物涂色”功能的首次发布时间。is_hot 保留为功能级人工运营推荐;功能热门排名与资源热门排名分别按各自统计规则计算,不以该布尔值作为排行榜缓存。首期资源不新增 is_new/is_hot、likes 或 seed;需要资源级人工推荐时再单独设计。
新接口的 isNew 从各自 first_published_at 派生:功能/内容可公开,首次发布时间非 NULL、不晚于当前时间且位于配置的新品窗口内才为 true。窗口由服务端统一配置,不在 SQL 里定死天数;SQL 不添加随当前时间变化的 CHECK 或自动过期触发器。未知发布时间为 NULL 时,新接口不标新品;旧功能兼容 DTO 在未知时间时可暂用原 is_new,作为迁移例外逐步停用,不据此生成发布时间。首次发布时间已有可信值时,兼容 DTO 也使用派生结果;资源新 DTO 始终派生。
新功能真实首次公开发布时,在发布事务写入 first_published_at;资源首次发布必须父功能 active,内容也在同一提交后可访问。草稿创建/修改不写,新增资源、配置修改、上下架后重发不重置首次时间。旧 active/hidden 数据发布时间未知时仍保留 NULL,不能在重新上架时用当前时间伪造新品;只能从可信历史证据补录。数据库允许 NULL 以容纳旧数据,真实发布流程和一次写入语义由服务保证。
功能累计 downloads = 原有累计基数 + 迁移后有效功能下载事件;资源累计 downloads = 可信资源基数(如实际存在)+ 有明确资源归属的有效事件。两者是同一事件不同粒度的投影,不相加。保留旧功能 downloads,不按日志数重算、不平均分配、不要求等于全部资源计数之和;007 新增资源计数从 0 起,不推断历史归属,若今后确有可信资源统计可审核导入。
功能展示仍保留原始计数 + seed 的历史契约;资源展示仅真实 downloads,分页榜单按 downloads DESC、id 稳定排序,不新增 seed 或排名布尔值。小项目先使用现有内容列表索引,在实际查询计划需要时再加发布时间或下载榜单索引。资源排行只显示功能和内容均 active 的条目。
管理修订资源 downloads 必须锁父功能再锁内容、记录理由和前后值,不自动修订功能总量;修订功能总量同样不分摊资源,因为历史基数不同。真实下载正常写入必须同时维护对应两层计数;首次发布、参数和内容编辑及统计修订的时间字段分别维护。当前仅落实数据库与契约,原小程序 isNew/isHot 和业务事务尚待 API/客户端改造。
### 5.5 首页配置与自动任务 ### 5.5 首页配置与自动任务
首次初始化 current 行和 homeAutoRefresh 设置行。手动构建、状态引起的重建、自动刷新都使用同一内容服务:按固定顺序锁 homeAutoRefresh 设置行 → 锁 current 行 → 基于最新快照校验/合并 → version+1 → 写配置、任务结果和必要审计 → 提交。管理开关也锁同一设置行,确保事务边界明确。 首次初始化 current 行和 homeAutoRefresh 设置行。手动构建、状态引起的重建、自动刷新都使用同一内容服务:按固定顺序锁 homeAutoRefresh 设置行 → 锁 current 行 → 基于最新快照校验/合并 → version+1 → 写配置、任务结果和必要审计 → 提交。管理开关也锁同一设置行,确保事务边界明确。
@@ -350,7 +525,7 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre
| 旧集合 / 字段 | PostgreSQL 落点 | 处理 | | 旧集合 / 字段 | PostgreSQL 落点 | 处理 |
| -------------------------------------- | ------------------------------ | ---------------------------------------------------- | | -------------------------------------- | ------------------------------ | ---------------------------------------------------- |
| 7 个核心集合 `_id` | 各表 id | 精确保留大小写和原值 | | 7 个核心集合 `_id` | 各表 id | 精确保留大小写和原值 |
| users.openid / unionid | openid / unionid | 增补可信 miniAppId,空 UnionID 转 NULL | | users.openid / unionid | openid / unionid | 确认来自配置的小程序,空 UnionID 转 NULL |
| users.avatarUrl | users.avatar_asset_id + assets | 复制/核实文件,保留 sourceRef 映射;无头像为 NULL | | users.avatarUrl | users.avatar_asset_id + assets | 复制/核实文件,保留 sourceRef 映射;无头像为 NULL |
| users.totalDownloads | total_downloads | 保留原值,统计接口不以它为准 | | users.totalDownloads | total_downloads | 保留原值,统计接口不以它为准 |
| categories.icon | icon / icon_asset_id | 区分字符图标和文件;文件走资产映射 | | categories.icon | icon / icon_asset_id | 区分字符图标和文件;文件走资产映射 |
@@ -368,9 +543,9 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre
1. 冻结/导出按迁移方案进行,保存全量分页、原主键清单、计数、时间范围及摘要。对 BSON 日期/ISO 日期规范化;非法日期隔离,不用当前时间偷偷代替。 1. 冻结/导出按迁移方案进行,保存全量分页、原主键清单、计数、时间范围及摘要。对 BSON 日期/ISO 日期规范化;非法日期隔离,不用当前时间偷偷代替。
2. 检查字段长度、类型、状态、年龄、难度、计数、分类树及用户唯一性。旧缺失 createdAt/updatedAt 仅对允许补齐的新增审计字段使用固定批次时间;业务原始日期无法确定则明确隔离/评审。 2. 检查字段长度、类型、状态、年龄、难度、计数、分类树及用户唯一性。旧缺失 createdAt/updatedAt 仅对允许补齐的新增审计字段使用固定批次时间;业务原始日期无法确定则明确隔离/评审。
3. 建表并初始化配置锁行;先导用户及分类(父节点优先),再导资产并确认 ready,绑定头像/分类图标,再导题型、收藏、下载、页面配置和设置。用户头像先 NULL 后绑定,解决用户与资产双向引用。 3. 建表并初始化配置锁行;先导用户及分类(父节点优先),再导资产并确认 ready,绑定头像/分类图标,再导题型及经审核的适用年级关系。批次包含内容资源时,依次导内容条目及文件关联,再导收藏、下载、页面配置和设置。旧集合没有具体内容时不伪造资源或文件关联;用户头像先 NULL 后绑定,解决用户与资产双向引用。
4. 收藏/下载缺失用户时隔离归档,不新建已注销用户;缺失题型时保留日志原 ID,LEFT JOIN 返回空详情。题型缺失分类需隔离修复,不伪造分类。 4. 收藏/下载缺失用户时隔离归档,不新建已注销用户;缺失题型时保留日志原 ID,LEFT JOIN 返回空详情。题型缺失分类需隔离修复,不伪造分类。
5. 重复 `(AppID, OpenID)` 不自动合并用户,先报告全部身份与关系;重复收藏按确定规则合并;重复 exportId 同题型按最早记录归并,不同题型隔离。每次合并记录原 ID 和保留 ID。 5. 重复 `openid` 不自动合并用户,先报告全部身份与关系;重复收藏按确定规则合并;重复 exportId 同题型按最早记录归并,不同题型隔离。每次合并记录原 ID 和保留 ID。
6. 保留题型原始计数与 seed,报告其与收藏/日志数的基线差异,不在导入过程中“纠正”为条数。事务一致性从切换后的新业务开始建立。 6. 保留题型原始计数与 seed,报告其与收藏/日志数的基线差异,不在导入过程中“纠正”为条数。事务一致性从切换后的新业务开始建立。
7. dryRun 完成后按主键 upsert;导入期间不触发业务加数、自动刷新或内容上新。试导必须使用隔离数据库;最终导入目标不得混入测试写入。 7. dryRun 完成后按主键 upsert;导入期间不触发业务加数、自动刷新或内容上新。试导必须使用隔离数据库;最终导入目标不得混入测试写入。
8. 最终切换后新数据库成为唯一写入源,禁止再次用旧快照 upsert 覆盖;反向恢复必须包括新增、修改和删除,不只追加记录。 8. 最终切换后新数据库成为唯一写入源,禁止再次用旧快照 upsert 覆盖;反向恢复必须包括新增、修改和删除,不只追加记录。
@@ -389,21 +564,47 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre
## 8. 数据库验收清单 ## 8. 数据库验收清单
| 场景 | 验收结果 | | 场景 | 验收结果 |
| ---------------------------------------- | --------------------------------------------------------------------------- | | ---------------------------------------- | ------------------------------------------------------------------------------ |
| 老用户登录 / 同 OpenID 不同 AppID | 老 userId 不变;跨 AppID 不自动合并;大小写不同 ID 不冲突 | | 功能/资源收藏共存与重复 | 同功能多个资源及功能可同时收藏,分别唯一;仅功能关系改变 likes |
| 并发第一次登录 | 唯一用户,不产生两套关系 | | 旧接口/失联目标/注销 | 旧查删只操作功能;内容缺失不退化目标,可取消;注销删除两类但只扣功能 likes |
| 重复/并发收藏、取消、事务中途失败 | 只随真实关系增删计数;失败后关系和计数都回滚 | | 年级单选/多选及分类组合 | 同功能多、不连续年级;只返回 active,跨年级结果不重复,分页总量一致 |
| 同 userId + exportId 并发重报 | 仅一条日志、downloads 仅 +1;不同题型冲突可识别 | | 未配置年级 / 旧 grade 兼容 | 无关系不当全年级,0 是幼小衔接;保留旧值但不自动生成可信关系 |
| 同题型不同 exportId / 多条 NULL 历史事件 | 有效事件均保留,不错误合并 | | 功能/资源新品、重发 | 首次发布时间独立;草稿不标新品;新增资源不使旧功能变新;未知旧时间不伪造 |
| 重新发布、状态变更、管理员修订 | 累计值不意外清零;三种时间用途正确;修订有同事务审计 | | 资源下载与幂等/回滚 | 有资源归属时两层同事务递增一次;失败全部回滚;旧基数不分摊,注销不回减 |
| 清资料 / 注销与写入竞争 | 清资料保留关系;注销删除个人关系、旧 token 失效且不新增孤儿;文件失败可重试 | | 资源型功能 / 多文件内容 | 同内容可关联多个 outline 和 reference;列表有独立预览,引用及 JSON 约束有效 |
| 原用户缺失 / 原题型缺失 | 用户孤儿隔离;题型缺失历史保留,列表可读、无未发布详情泄露 | | 旧功能兼容 / renderer 未支持 | 原 ID/path 不变,明确旧映射可用;新功能缺实现不发布,未知实现不错误回退 |
| 页面分支、seed 和分龄榜单 | 固定样本字段、顺序、筛选、去重、补齐与旧行为一致,新增并列规则显式记录 | | 下载资源归属 / 内容下架 | 新下载校验资源属于题型及发布状态;旧历史可空或缺失,不泄露未发布内容 |
| 自动/手动同时刷新、开关修改、任务重启 | 不覆盖手动栏目、不重复成功运行;旧任务停用;上海业务日正确 | | 老用户登录 / OpenID 唯一性 | 老 userId 不变;同 OpenID 不重复建用户;大小写不同 ID 不冲突 |
| 文件和嵌套资源引用 | 七牛对象实际存在、摘要可核对;DB 无临时签名 URL;正式资源不依赖 cloud:// | | 并发第一次登录 | 唯一用户,不产生两套关系 |
| 重复试导与最终导入对账 | 原 ID 稳定;计数不被触发加数;正常/合并/隔离可解释;无测试数据混入 | | 重复/并发收藏、取消、事务中途失败 | 只随真实关系增删计数;失败后关系和计数都回滚 |
| bigint、nullable JSON 与时区 | DTO 可序列化;NULL 不变 0 或 false;日期与配置版本完整保留 | | 同 userId + exportId 并发重报 | 仅一条日志、downloads 仅 +1;不同题型冲突可识别 |
| 备份恢复 / 新写入后的反向转换 | 最新增改删及注销均落实;不复活已取消收藏或已注销用户 | | 同题型不同 exportId / 多条 NULL 历史事件 | 有效事件均保留,不错误合并 |
| 重新发布、状态变更、管理员修订 | 累计值不意外清零;创建、首次发布、内容及业务更新时间用途正确;修订有同事务审计 |
| 清资料 / 注销与写入竞争 | 清资料保留关系;注销删除个人关系、旧 token 失效且不新增孤儿;文件失败可重试 |
| 原用户缺失 / 原题型缺失 | 用户孤儿隔离;题型缺失历史保留,列表可读、无未发布详情泄露 |
| 页面分支、seed 和分龄榜单 | 固定样本字段、顺序、筛选、去重、补齐与旧行为一致,新增并列规则显式记录 |
| 自动/手动同时刷新、开关修改、任务重启 | 不覆盖手动栏目、不重复成功运行;旧任务停用;上海业务日正确 |
| 文件和嵌套资源引用 | 七牛对象实际存在、摘要可核对;DB 无临时签名 URL;正式资源不依赖 cloud:// |
| 重复试导与最终导入对账 | 原 ID 稳定;计数不被触发加数;正常/合并/隔离可解释;无测试数据混入 |
| bigint、nullable JSON 与时区 | DTO 可序列化;NULL 不变 0 或 false;日期与配置版本完整保留 |
| 备份恢复 / 新写入后的反向转换 | 最新增改删及注销均落实;不复活已取消收藏或已注销用户 |
S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字段长度与类型、计数异常、文件归属及清单、旧调度时区/执行记录。上述未知项不改变 PostgreSQL 决策;发现不兼容数据时更新转换报告和设计,再实施 DDL。 S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字段长度与类型、计数异常、文件归属及清单、旧调度时区/执行记录。上述未知项不改变 PostgreSQL 决策;发现不兼容数据时更新转换报告和设计,再实施 DDL。
## 9. SQL 与实际结构最终核查
核查日期:2026-10-14。当前已确认范围为 13 张表、135 个字段;本次核查未新增表或业务字段。
| 核查项目 | 结果 |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| 本文字段表与最新 002 | 13 张表的字段名称、类型、可空性和默认值逐项一致 |
| 最新空库基线与完整旧库升级链 | 001 建隔离库;最新 002/003 与原始旧基线执行 004~008 后的列、约束、索引和注释一致,忽略新增列的物理顺序 |
| 本地 doodle_dev 与最新基线 | 列、约束、索引和注释一致,已执行至 008 |
| 数据及初始化保留 | 完整升级保留旧用户、功能、收藏、下载、计数和标记;重复 003 不覆盖 enabled=false、页面内容或递增版本 |
| 关键 SQL 回归 | 多文件资源、共享资产、JSON/枚举/外键约束、多适用年级、两种收藏部分唯一索引、取消范围及 SQL 下载幂等/回滚示例通过 |
| 编码与标识 | PostgreSQL 18.6、UTF-8、UTC;ID、微信身份、事件和对象引用等标识列使用 C 排序规则 |
临时核查数据库全部清理;开发库只读比对,未写入样本。SQL 无结构差异需修复;已同步过时的 Prisma 目录说明、统一执行路径,修正关系图中的必填封面/可缺失历史关联及资产清理引用范围。
数据库约束通过不等于业务迁移完成。资源归属、当前发布状态/管理员权限、参数白名单、首次发布时间只写一次、动态新品判断、计数与审计事务、任务锁和租约仍由后续服务端实现;Prisma Schema 和迁移历史尚未接入。旧 grade/is_new/total_downloads 等字段继续按前述兼容规则保留,不作为新业务权威来源。
+160
View File
@@ -0,0 +1,160 @@
# Doodle Mini 云服务端迁移任务清单
更新日期:2026-10-13。依据当前代码、数据库设计及《Doodle Mini 现有功能迁移到云服务端方案》整理。
目标:把现有业务后端、数据、文件和定时任务迁出 CloudBase,由 doodle-store 提供统一 API、PostgreSQL 主数据库和七牛资源存储;doodle-mini 保留现有生成、绘制、保存、分享及广告能力。
标记说明:已勾选表示已有实现及记录的验证;未勾选表示待完成。Server 能启动、数据库已建表,不代表业务迁移已经完成。清单不承诺具体工期,外部权限、备案和微信审核单独记录。
## 一、当前已完成
- [x] 建立独立 doodle-store pnpm 工作区、NestJS 11 Server、公共 contracts 包及构建检查脚本;小程序保持独立项目。
- [x] 确认 PostgreSQL + Prisma 方向;完成 v3.5 数据库、环境及 Server 基础设置文档。
- [x] 启动本地 PostgreSQL 18.6 Docker 开发环境,地址 127.0.0.1:5434,数据库 doodle_dev。
- [x] 生成并执行 10 张表的建表 SQL、索引、约束及两条初始配置;临时库 19 项约束检查通过。
- [x] 接入 Server JSON 日志、脱敏、requestId、bodyParser、/api/v1 前缀、CORS、Helmet、DTO 校验、统一错误处理及 Swagger。
- [x] 接入 JWT 签发/验签、默认鉴权守卫、公开接口和角色声明;保留账户读取接入点。账户读取当前为拒绝放行的占位实现,真实登录未完成。
- [x] Server 完整检查及 12 项基础/集成测试通过,构建入口真实启动检查通过;代码统一 4 空格并补充关键中文注释。
## 二、实施原则与范围
- 公开内容无需登录;用户资料、收藏、下载历史仅访问本人数据;管理接口只允许当前有效管理员。
- 云服务器首期只常驻生产库;本地负责日常开发和测试,真实微信联调时按需创建同实例独立 staging 库及测试 API,不测试写生产库。
- 图片、头像、字体及预览直接使用七牛,开发和生产资源隔离;不引入本地 MinIO、Redis、队列或微服务。
- 本次迁移必须保留管理 API 和现有小程序管理能力。完整 Web 网站和完整独立管理后台页面不是切换前置条件。
- 管理后台“仅管理员能登录”作为鉴权验收要求;后台登录方式尚待确认,若选账号密码需补凭证设计。
- 暂不纳入会员、支付、孩子档案、学习包、批量 PDF、全量 Canvas 服务端重写或完整 SEO 平台。
## 三、S0:线上盘点与条件核实
- [ ] S0-01:确认服务器现有服务、CPU/内存/磁盘可用量,记录生产部署边界。
- [ ] S0-02:确认 API/资源域名、备案、DNS、HTTPS、小程序 request/upload/download 域名及后台配置权限。
- [ ] S0-03:取得微信 AppID/AppSecret、CloudBase 导出/文件权限和七牛空间/区域/域名配置;密钥仅写受控环境。
- [ ] S0-04:全量分页导出并备份 users、categories、worksheets、favorites、download_logs、page_configs、settings;盘点是否有设计之外的线上集合和字段。
- [ ] S0-05:整理所有旧文件引用,覆盖头像、预览、字体、配置嵌套对象、内置兜底和云存储目录;核实已有 HTTPS 文件归属。
- [ ] S0-06:建立固定样本,记录老用户关联、原始/seed 计数、推荐顺序、页面配置、旧定时任务实际时区与执行记录。
阶段验收:有完整原始备份、集合及文件清单、异常报告模板和明确的外部实施条件。
## 四、S1:Prisma 与首条业务链路
- [ ] S1-01:安装并锁定 Prisma 兼容版本,建立覆盖 10 张表的 schema,保持原字符串主键、C 排序规则及现有约束。
- [ ] S1-02:将已执行的 SQL 登记为迁移基线,补齐数据库迁移流程;不得在已有表的开发库重复执行初始 DDL。
- [ ] S1-03:实现 PrismaService、数据库连接/关闭和独立就绪检查;配置真实开发连接串及受控连接池。
- [ ] S1-04:实现真实 AccountReader,查询用户状态、authVersion 和 role;替换当前有效 token 仍返回 503 的占位实现。
- [ ] S1-05:落实公开/本人/管理接口分类,服务层强制校验数据归属;管理员角色不自动开放全体用户私有数据。
- [ ] S1-06:实现 POST /auth/wechat/login:微信 code2Session、按可信 OpenID 查找并沿用老 userId、并发首次登录唯一性、JWT 签发及活跃时间维护。
- [ ] S1-07:实现最小题型公开读取,以及收藏、成功下载记录的事务纵向样板;验证重复请求只产生一次关系或一次计数。
- [ ] S1-08:完善公开 DTO、分页/总量、错误码与 requestId 契约;新接口 id 与旧端 _id/path/previewImg 兼容转换明确记录。
阶段验收:数据库接入真实可用,老用户 ID 延续,公开内容可读,用户写入不能越权,收藏/下载并发重试不重复计数。
## 五、S2:补齐服务端现有功能
### 5.1 用户、收藏与下载
- [ ] S2-U01:实现 GET /me、PATCH /me/profile、DELETE /me/profile;清资料保留收藏和历史。
- [ ] S2-U02:实现 DELETE /me 注销:事务清理收藏及相关 likes、下载历史、用户资料和身份;旧凭证失效,私有文件可重试清理。
- [ ] S2-U03:补齐收藏添加/取消/分页列表/数量/单张检查/批量检查;关系与 likes 同事务,仅真实增删影响计数。
- [ ] S2-U04:补齐下载记录添加/分页列表/数量;要求新版 exportId,日志与 downloads 同事务;历史 NULL 事件保留。
- [ ] S2-U05:处理失联历史:缺用户记录隔离,缺题型历史保留原 worksheetId 且详情可为空;不向用户暴露 draft/hidden 内容。
### 5.2 公开内容
- [ ] S2-P01:实现题型列表/详情及分类列表;公开接口强制只返回 active,不允许客户端参数绕过状态限制。
- [ ] S2-P02:实现分龄推荐,保留 5 个年龄段、现有年龄匹配、Top3 下载+Top3 收藏、去重和不足补齐规则。
- [ ] S2-P03:区分真实计数与 seed 展示值;推荐保留种子参与规则,排序并列明确稳定规则并与固定样本核对。
- [ ] S2-P04:提供公共首页配置接口,保留 home/category/age/version/updatedAt 及 previewImg 字段形状。
### 5.3 管理权限与操作
- [ ] S2-A01:确定管理后台登录方式及凭证方案;配置首个管理员,默认用户不能注册或自升管理员。
- [ ] S2-A02:落实后台独立登录入口或明确的管理员授权流程:身份验证后校验 active+admin,非管理员不能获得后台登录态;现有小程序管理入口同样受保护。
- [ ] S2-A03:全部管理 API 服务端检查管理员身份;用户降权/禁用后立即失去权限,不能只靠前端隐藏按钮。
- [ ] S2-A04:实现题型发布/重新发布/上下架/管理列表和分类状态数量;普通重发不清零真实计数,保留 createdAt/contentUpdatedAt/updatedAt 语义。
- [ ] S2-A05:实现分类创建/更新,保留主键和父子关系,校验父分类及循环引用。
- [ ] S2-A06:实现批量年龄/年级/tags/seed/统计修订,保留 dryRun、逐项结果和错误;真实/seed 修订记录操作者、原因及前后值。
- [ ] S2-A07:管理修改与审计同事务;核实 worksheetsStatsUpdate 的旧调用需要,不向普通用户开放通用加数接口。
### 5.4 七牛、首页与任务
- [ ] S2-F01:接入七牛环境配置,落实公共预览/字体与私有头像访问策略,开发/生产上传和清理范围隔离。
- [ ] S2-F02:实现资源上传 API,校验身份、用途、类型、大小和归属;普通用户仅上传本人头像,预览上传仅管理员。
- [ ] S2-F03:实现 assets 元数据、对象 key、文件校验及稳定引用;响应生成访问地址,不把临时签名 URL 写入数据库。
- [ ] S2-F04:实现头像/预览/分类图标/字体及配置嵌套资源的 DTO 解析;私有 URL 有效期与客户端刷新可用。
- [ ] S2-C01:实现首页手动构建与配置读取,保留 featuredIds/hotIds/sections、page 分支及旧参数兜底语义。
- [ ] S2-C02:实现首页自动 Top5 下载/收藏榜,只覆盖对应推荐数组,保留手动栏目。
- [ ] S2-C03:实现定时启停、明确 Asia/Shanghai 时区及计划时刻、job_runs 执行结果/失败记录、当日遗漏补跑。
- [ ] S2-C04:手动和自动刷新使用一致锁序及配置版本检查,避免并发覆盖;资产上传不放入长数据库事务。
- [ ] S2-C05:如果兼容期仍需静态配置文件,实现按版本发布、失败重试及上一有效文件保留,DB 为唯一权威源。
阶段验收:17 个现有云函数均有迁移落点或明确停用说明;用户、公开内容、管理、文件、首页和任务行为与原版对照通过。
## 六、S3:小程序适配、数据试迁与旧版兼容
### 6.1 小程序 HTTP 接入
- [ ] S3-M01:在 doodle-mini 建立环境 API 地址及业务服务层,保留小程序独立项目和原路由/分享路径。
- [ ] S3-M02:改造 wx.login+HTTP 登录、token 管理和 401 并发登录合并,登录成功最多重试一次;写请求复用业务幂等键。
- [ ] S3-M03:替换分类、分龄、首页、收藏、下载、资料与设置中的正式云调用;不能只改 cloud-adapter。
- [ ] S3-M04:替换现有管理页面的云函数调用和 settings 数据库直读,保持发布、上下架、批量同步及首页运营入口。
- [ ] S3-M05:替换头像/预览上传、云字体解析、内置 cloud:// 兜底和首页预拉取地址;修正 HTTP 下载临时文件路径契约。
- [ ] S3-M06:升级资料/页面配置缓存名称或版本,清理旧 cloud:// 缓存;断网继续使用可用生成/保存与内容兜底。
- [ ] S3-M07:实现保存成功后的同 exportId 补报队列,绑定原 userId;网络失败不误报相册保存失败、不重复扣本地配额或埋点。
- [ ] S3-M08:核对收藏成功埋点、export_success、广告、分享和相册权限;微信平台功能继续按现有方式使用。
### 6.2 数据与文件迁移
- [ ] S3-D01:实现全量分页导出、日期/空值/字段规范化、可重复主键导入、dryRun 和目标环境保护,正式切换后禁止旧快照覆盖新写入。
- [ ] S3-D02:处理重复用户身份、重复收藏、重复非空 exportId、失联关联、非法字段及未知字段,逐项记录归并或隔离依据。
- [ ] S3-D03:建立旧 fileID → assetId/objectKey → 大小/类型/checksum/状态清单;下载、上传后校验内容,不只替换 URL。
- [ ] S3-D04:按依赖顺序导入并保留原 userId、worksheetId、日期、原始/seed 计数、页面结构和设置;不按日志数重算旧累计计数。
- [ ] S3-D05:执行试导和对账,满足原始数=正常导入数+合并重复数+隔离数;抽查老用户、无资料用户、缺题型记录及各年龄段。
- [ ] S3-D06:实现并演练反向转换,覆盖切换后新增、修改、取消收藏和注销删除,避免恢复时复活旧关系。
### 6.3 旧版兼容桥
- [ ] S3-L01:为旧云函数建立 cloud/readonly/server 模式;兼容登录、资料、收藏、下载和公共内容的两种旧响应格式。
- [ ] S3-L02:实现桥接签名、可信 AppID/OpenID、时间窗、重放防护与服务端权限检查;旧客户端自报身份不可采信。
- [ ] S3-L03:内部转发重试复用业务幂等键;旧下载缺 exportId 时每次接收生成 legacy ID,不按秒级时间窗口错误去重。
- [ ] S3-L04:处理旧版 CloudBase 上传:校验旧文件环境、路径和所有者,复制至七牛后提交新库;不把永久 cloud:// 写进新库。
- [ ] S3-L05:记录旧桥与旧文件调用量,明确管理员使用新版;不承诺旧调试管理页的数据库直读完全兼容。
### 6.4 部署与联调验收
- [ ] S3-O01:准备生产 Compose、固定镜像、PostgreSQL 持久卷及数据库账号权限;数据库不开放公网端口。
- [ ] S3-O02:配置 Nginx/HTTPS/证书续期、生产密钥和日志轮转;开发/生产配置隔离,生产 Swagger 默认关闭。
- [ ] S3-O03:按需部署独立测试 API 和 staging 库,完成新 HTTP 与旧桥共同访问隔离测试库的联调。
- [ ] S3-O04:实现数据库每日异机备份、恢复步骤及恢复演练;若需更低 RPO,再评估 PostgreSQL WAL/PITR,不直接照搬 MySQL binlog。
- [ ] S3-O05:配置服务/数据库就绪、错误、磁盘、备份失败与定时任务失败监测及通知。
- [ ] S3-T01:运行权限、数据归属、登录错误、分页、并发收藏/下载、事务失败、账户注销及管理员操作的业务自动测试。
- [ ] S3-T02:固定样本核对分类/分龄/首页分支、榜单 seed、字段、排序、去重、补齐和兜底;移除旧 100 条隐形上限。
- [ ] S3-T03:微信体验版 iOS/Android 真机验收登录、头像/字体、生成、保存、收藏/历史、注销、管理、广告及分享;关闭域名校验绕过。
- [ ] S3-T04:验证 token 过期、离线、保存后补报、API/任务重启、重复事件及恢复后的新增/删除数据。
阶段验收:数据/文件审计通过,新旧协议只调用同一业务实现;测试版本功能及恢复演练通过,生产切换前置条件齐备。
## 七、S4:正式切换
- [ ] S4-01:准备低峰维护窗口、正式小程序审核/发布版本、切换模式与版本记录,明确写入冻结和回滚责任。
- [ ] S4-02:冻结 CloudBase 全部业务及管理写入,停止旧任务;导出最终快照和新增文件,避免测试数据混入生产。
- [ ] S4-03:最终生产导入及关联/计数/文件核对通过后,将兼容桥切到 server,新 PostgreSQL 成为唯一写入源。
- [ ] S4-04:更新预拉取及资源配置,开启新任务,发布新版小程序,核对老用户、收藏、保存记录、字体/头像和管理功能。
- [ ] S4-05:记录生产切换时间、Server/数据库/小程序版本、审计报告及故障处置;新库已有写入时优先回滚 API,继续使用新主库。
阶段验收:新旧客户端都只写新库;生产数据、文件和功能可核对,旧库保持只读,可按演练步骤恢复。
## 八、S5:观察与 CloudBase 退场
- [ ] S5-01:建议先观察至少 14 天,持续核对错误、任务、真实计数、旧桥和旧文件访问;天数本身不是删除旧资源的证明。
- [ ] S5-02:确认旧调用归零或明确剩余旧版的升级要求,完成旧数据/文件受控可恢复归档。
- [ ] S5-03:移除正式小程序 CloudBase 初始化、旧调用和云配置;确认新端不再有 cloud://、旧上传或云字体运行依赖。
- [ ] S5-04:满足退场条件后停用兼容函数、旧存储和套餐,保存最终审计与退出记录。
阶段验收:业务稳定运行于新后端;必要旧数据有恢复副本,CloudBase 无剩余运行依赖。
## 九、下一步建议
优先推进 S0 线上盘点和 S1 Prisma/真实登录/题型读取/收藏下载事务的纵向样板。基础设施已齐备,当前首要技术阻塞是未接入真实数据库账户查询和业务模块。七牛资源准备可同步推进。
管理员登录方式、跨端账户绑定、完整后台页面和完整 Web 分别管理:管理员授权能力及现有管理 API 属于本次;完整后台/Web 页面和 Web 用户登录不作为现有小程序迁移的切换前置条件。
+9 -5
View File
@@ -230,7 +230,7 @@ apps/server/ # 以下路径相对于 doodle-store
1. 小程序调用 `wx.login()` 获取一次性 code。 1. 小程序调用 `wx.login()` 获取一次性 code。
2. 服务器用配置的 AppID/AppSecret 调用微信 `code2Session`,取得可信 OpenID,UnionID 可为空。 2. 服务器用配置的 AppID/AppSecret 调用微信 `code2Session`,取得可信 OpenID,UnionID 可为空。
3. 按当前 AppID + OpenID 查找迁入用户,沿用原 `users._id`;确实不存在时才新建。 3. 使用配置的单一小程序 AppID 验证登录,按可信 OpenID 查找迁入用户,沿用原 `users._id`;确实不存在时才新建。
4. 签发有期限的应用登录凭证。首期可用 JWT,建议初始有效期 24 小时;过期后通过微信静默登录续用,暂不增加独立刷新令牌体系。 4. 签发有期限的应用登录凭证。首期可用 JWT,建议初始有效期 24 小时;过期后通过微信静默登录续用,暂不增加独立刷新令牌体系。
5. 每次用户请求核对账户有效状态;角色从服务端配置/数据取得。注销立即使旧凭证失效。 5. 每次用户请求核对账户有效状态;角色从服务端配置/数据取得。注销立即使旧凭证失效。
@@ -247,7 +247,7 @@ Web 用户不会凭现有小程序 OpenID 自动变成同一账户。跨端身
- 数据库对 `(userId, exportId)` 建唯一键,下载记录和 `worksheets.downloads + 1` 在同一事务内完成。重试只返回已有结果,不再次加数。 - 数据库对 `(userId, exportId)` 建唯一键,下载记录和 `worksheets.downloads + 1` 在同一事务内完成。重试只返回已有结果,不再次加数。
- API 上报失败不能把已保存的图片判定为保存失败。新版可用小型本地补报队列重试同一个 exportId;补报不再次递增本地配额或发送 export_success。队列绑定原 userId,换账户不代报,注销时清除。 - API 上报失败不能把已保存的图片判定为保存失败。新版可用小型本地补报队列重试同一个 exportId;补报不再次递增本地配额或发送 export_success。队列绑定原 userId,换账户不代报,注销时清除。
- 服务端接收的是客户端成功回报,无法独立证明图片已进入手机相册;本次不把该回报设计成支付凭据。 - 服务端接收的是客户端成功回报,无法独立证明图片已进入手机相册;本次不把该回报设计成支付凭据。
- 收藏唯一键为 `(userId, worksheetId)`;确实新增/删除关系时才更新 likes。重复请求不增减;注销删除收藏时同步处理受影响计数。 - 收藏统一保存功能和内容目标,按资源 ID 是否为空分别去重。只有功能收藏的真实增删才更新 likes;内容收藏不增减功能计数。旧接口限定资源 ID 为 NULL,重复请求不增减;注销清理两种关系但只扣功能贡献。
- 展示计数与真实计数分开:展示可沿用真实值 + `likes_seed`/`downloads_seed`,分析和管理原始查询不加 seed。迁移期间不改变已有推荐的种子参与规则。 - 展示计数与真实计数分开:展示可沿用真实值 + `likes_seed`/`downloads_seed`,分析和管理原始查询不加 seed。迁移期间不改变已有推荐的种子参与规则。
- 常规重新发布题型不能将真实计数重置为 0。显式统计修订保留管理员入口,但记录操作者及前后值。 - 常规重新发布题型不能将真实计数重置为 0。显式统计修订保留管理员入口,但记录操作者及前后值。
@@ -273,17 +273,19 @@ Web 用户不会凭现有小程序 OpenID 自动变成同一账户。跨端身
| `page_configs` | `page_configs` | `current` ID、JSON 结构、版本、时间保留;所有嵌套文件引用转换 | | `page_configs` | `page_configs` | `current` ID、JSON 结构、版本、时间保留;所有嵌套文件引用转换 |
| `settings` | `settings` | 包括 `homeAutoRefresh.enabled`,先盘点全部线上配置 | | `settings` | `settings` | 包括 `homeAutoRefresh.enabled`,先盘点全部线上配置 |
新增必要辅助表:文件迁移/引用表 `assets`、任务执行记录 `job_runs`、管理审计记录 `admin_audit_logs`。它们用于迁移与运行,不代表已有业务集合。 新增必要辅助表:文件迁移/引用表 `assets`、任务执行记录 `job_runs`、管理审计记录 `admin_audit_logs`。它们用于迁移与运行,不代表已有业务集合。另新增 worksheet_resources 和 worksheet_resource_assets,用于一份内容关联多份线框/参考素材,再新增 worksheet_grades 支持多适用年级,共 13 张表。categories 继续管理内容分类,年级菜单组合查询 worksheet_grades;旧 grade 仅保留兼容,需审核后写入年级映射,不按年龄自动回填。功能与内容分别记录首次发布时间及下载量;新 isNew 按时间派生,功能 isHot 保留为运营推荐,旧累计下载不分摊资源。worksheet 增加生成方式、renderer_key 和共用配置;现有 ID/path/previewImg 保持兼容,下载和收藏增加可空的内容条目 ID;旧收藏保持功能目标,新内容收藏不影响功能 likes。详见 [Worksheet 数据库设计说明](./v3.5/Worksheet数据库设计说明.md)。
主键采用足够长度的字符串,保留大小写语义,不重新生成所有 ID。业务字段用结构化列,tags/页面配置/扩展配置用 JSON;导入前保留完整原始导出,未识别字段进入迁移报告,不能默默丢弃。 主键采用足够长度的字符串,保留大小写语义,不重新生成所有 ID。业务字段用结构化列,tags/页面配置/扩展配置用 JSON;导入前保留完整原始导出,未识别字段进入迁移报告,不能默默丢弃。
主要索引:用户 `(miniAppId, openid)` 唯一;收藏 `(userId, worksheetId)` 唯一及 `(userId, createdAt)`;下载 `(userId, exportId)` 唯一及用户/题型时间索引;题型分类/状态/排序索引。空 exportId 规范为 NULL,历史多条无 ID 下载不得互相冲突。 主要索引:用户 `openid` 唯一(首期仅接入一个小程序,AppID 由服务端配置);收藏以内容 ID 是否为空区分功能/内容,分别采用 `(userId, worksheetId) WHERE worksheetResourceId IS NULL` 与 `(userId, worksheetResourceId) WHERE worksheetResourceId IS NOT NULL` 部分唯一索引,保留 `(userId, createdAt)` 列表索引;下载 `(userId, exportId)` 唯一及用户/题型时间索引;题型分类/状态/排序索引。空 exportId 规范为 NULL,历史多条无 ID 下载不得互相冲突。
资源收藏使用统一 favorites;新增 `PUT/DELETE /me/favorites/resources/:worksheetResourceId`,旧按 worksheetId 的路由及 CloudBase 桥只操作资源 ID 为 NULL 的功能关系。列表/数量显式 target=worksheet/resource/all,默认 worksheet;资源收藏的独立预览及跳转由新版客户端接入。注销删除两种收藏,仅功能收藏扣减 likes。
### 6.2 导入与审计规则 ### 6.2 导入与审计规则
1. 完整分页导出实际集合,记录每个集合总数、主键清单、时间范围和校验摘要;不可只导出首批 100 条。 1. 完整分页导出实际集合,记录每个集合总数、主键清单、时间范围和校验摘要;不可只导出首批 100 条。
2. 规范 BSON/JSON 日期为同一 UTC 时间,数据库和 API 时间一致;业务日与任务时区明确使用 Asia/Shanghai。空值与 0 区分,禁止因默认值覆盖已有数据。 2. 规范 BSON/JSON 日期为同一 UTC 时间,数据库和 API 时间一致;业务日与任务时区明确使用 Asia/Shanghai。空值与 0 区分,禁止因默认值覆盖已有数据。
3. 先导分类和用户,再导题型、收藏、下载、配置及设置。脚本可重复执行、按主键 upsert,并支持 dryRun;生产切换后不允许再次用旧导出覆盖新写入。 3. 先导分类和用户,再导题型、收藏、下载、配置及设置。题型导入后,根据审核过的 worksheetId → grades 映射写入 worksheet_grades;不从旧 grade 或年龄自动生成适用范围。脚本可重复执行、按主键 upsert,并支持 dryRun;生产切换后不允许再次用旧导出覆盖新写入。
4. 检测重复 OpenID、重复收藏、重复 exportId、失联关联、非法状态/年龄、缺失图片和未知字段。异常输出逐项报告,不以“跳过错误”当成功。 4. 检测重复 OpenID、重复收藏、重复 exportId、失联关联、非法状态/年龄、缺失图片和未知字段。异常输出逐项报告,不以“跳过错误”当成功。
5. 原用户已经注销而留下的收藏/下载记录应隔离归档,不能重建已注销用户。题型缺失时保留历史 worksheetId 和记录,列表 LEFT JOIN 返回空详情,不能整个历史接口报错。 5. 原用户已经注销而留下的收藏/下载记录应隔离归档,不能重建已注销用户。题型缺失时保留历史 worksheetId 和记录,列表 LEFT JOIN 返回空详情,不能整个历史接口报错。
6. 收藏去重、异常隔离等转换明确写进审计报告:原始数 = 正常导入数 + 合并重复数 + 隔离数。不得无说明要求转换后条数与原始条数相同。 6. 收藏去重、异常隔离等转换明确写进审计报告:原始数 = 正常导入数 + 合并重复数 + 隔离数。不得无说明要求转换后条数与原始条数相同。
@@ -471,3 +473,5 @@ AI 主要承担接口/模型初稿、SQL 转换、数据脚本、小程序调用
- [pnpm 工作区](https://pnpm.io/workspaces):Monorepo、内部包引用及工作区锁文件。 - [pnpm 工作区](https://pnpm.io/workspaces):Monorepo、内部包引用及工作区锁文件。
- [pnpm 筛选命令](https://pnpm.io/filtering):按应用、依赖关系执行开发与检查任务。 - [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/*` 及上述管理页面。 - `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/*` 及上述管理页面。
资源收藏是新增能力,原 CloudBase 收藏函数不能直接承接。若确需回到旧数据库,应先明确新增内容及收藏关系的存储和旧查询隔离方案并演练,不能把资源收藏压成功能收藏;优先回滚 API 版本并继续使用新主库。
+1 -1
View File
@@ -45,7 +45,7 @@ POSTGRES_DB、POSTGRES_USER、POSTGRES_PASSWORD 只在空数据卷首次初始
postgresql://doodle_dev:<URL编码后的本地密码>@127.0.0.1:5434/doodle_dev?schema=public postgresql://doodle_dev:<URL编码后的本地密码>@127.0.0.1:5434/doodle_dev?schema=public
``` ```
根 `.env` 仅供 Compose;`apps/server/.env` 仅供服务端,当前 NestJS 骨架尚未读取 DATABASE_URL、接入 Prisma 或七牛。doodle_dev 已执行基线 SQL,创建 10 张表及初始配置;后续 Prisma schema/migrations 根据 [v3.5 数据库设计](../docs/v3.5/数据库设计文档.md) 实施。开发角色为此本地集群的初始化管理员,生产运行角色权限在部署时单独设置,不复制本地账号密码到云服务器。 根 `.env` 仅供 Compose;`apps/server/.env` 仅供服务端,当前 NestJS 骨架尚未读取 DATABASE_URL、接入 Prisma 或七牛。doodle_dev 已执行基线 SQL,原创建 10 张表及初始配置,2026-10-14 执行 004/005/006/007/008 后共有 13 张表;后续 Prisma schema/migrations 根据 [v3.5 数据库设计](../docs/v3.5/数据库设计文档.md) 实施。开发角色为此本地集群的初始化管理员,生产运行角色权限在部署时单独设置,不复制本地账号密码到云服务器。
自动测试需要真实数据库时,使用同一**本地** PostgreSQL 实例内独立 `doodle_test` 数据库及连接串,测试初始化流程另行实施。测试不得对 doodle_dev 或生产库执行清空数据操作;目前尚未创建测试库。 自动测试需要真实数据库时,使用同一**本地** PostgreSQL 实例内独立 `doodle_test` 数据库及连接串,测试初始化流程另行实施。测试不得对 doodle_dev 或生产库执行清空数据操作;目前尚未创建测试库。
+319 -15
View File
@@ -34,13 +34,28 @@ importers:
version: link:../../packages/contracts version: link:../../packages/contracts
'@nestjs/common': '@nestjs/common':
specifier: 11.2.7 specifier: 11.2.7
version: 11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) version: 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
'@nestjs/core': '@nestjs/core':
specifier: 11.2.7 specifier: 11.2.7
version: 11.2.7(@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt':
specifier: 11.0.2
version: 11.0.2(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: 11.2.7 specifier: 11.2.7
version: 11.2.7(@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(supports-color@8.1.1) version: 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(supports-color@8.1.1)
'@nestjs/swagger':
specifier: 11.4.7
version: 11.4.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
class-transformer:
specifier: 0.5.1
version: 0.5.1
class-validator:
specifier: 0.15.1
version: 0.15.1
helmet:
specifier: 8.3.0
version: 8.3.0
reflect-metadata: reflect-metadata:
specifier: 0.2.2 specifier: 0.2.2
version: 0.2.2 version: 0.2.2
@@ -53,7 +68,10 @@ importers:
version: 11.0.24(@types/node@24.19.2)(prettier@3.9.10) version: 11.0.24(@types/node@24.19.2)(prettier@3.9.10)
'@nestjs/testing': '@nestjs/testing':
specifier: 11.2.7 specifier: 11.2.7
version: 11.2.7(@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(@nestjs/platform-express@11.2.7) version: 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(@nestjs/platform-express@11.2.7)
'@types/express':
specifier: 5.0.6
version: 5.0.6
'@types/node': '@types/node':
specifier: 24.19.2 specifier: 24.19.2
version: 24.19.2 version: 24.19.2
@@ -352,6 +370,9 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
'@microsoft/tsdoc@0.16.0':
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
'@nestjs/cli@11.0.24': '@nestjs/cli@11.0.24':
resolution: {integrity: sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==} resolution: {integrity: sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==}
engines: {node: '>= 20.11'} engines: {node: '>= 20.11'}
@@ -396,6 +417,24 @@ packages:
'@nestjs/websockets': '@nestjs/websockets':
optional: true optional: true
'@nestjs/jwt@11.0.2':
resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==}
peerDependencies:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/mapped-types@2.1.1':
resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
class-transformer: ^0.4.0 || ^0.5.0
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/platform-express@11.2.7': '@nestjs/platform-express@11.2.7':
resolution: {integrity: sha512-+F0hV/4yJM+D/Qwyo5XmKMe6YGEb+ssT2jFWrgdFe1obumXCUeC1GNob/kNeXyzAXm7dcxZVUtmcoqaF6z36jA==} resolution: {integrity: sha512-+F0hV/4yJM+D/Qwyo5XmKMe6YGEb+ssT2jFWrgdFe1obumXCUeC1GNob/kNeXyzAXm7dcxZVUtmcoqaF6z36jA==}
peerDependencies: peerDependencies:
@@ -411,6 +450,23 @@ packages:
prettier: prettier:
optional: true optional: true
'@nestjs/swagger@11.4.7':
resolution: {integrity: sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==}
peerDependencies:
'@fastify/static': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/common': ^11.0.1
'@nestjs/core': ^11.0.1
class-transformer: '*'
class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
'@fastify/static':
optional: true
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/testing@11.2.7': '@nestjs/testing@11.2.7':
resolution: {integrity: sha512-CoWN8pIjXPVhkbaT0JI1E7X/QOnf9A8cdrzuhU+9mzRS4296/kds6BT+4tH7kOH484tH3GWkkB0+K7dA/FVGKg==} resolution: {integrity: sha512-CoWN8pIjXPVhkbaT0JI1E7X/QOnf9A8cdrzuhU+9mzRS4296/kds6BT+4tH7kOH484tH3GWkkB0+K7dA/FVGKg==}
peerDependencies: peerDependencies:
@@ -424,6 +480,9 @@ packages:
'@nestjs/platform-express': '@nestjs/platform-express':
optional: true optional: true
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@tokenizer/inflate@0.4.1': '@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -431,6 +490,12 @@ packages:
'@tokenizer/token@0.3.0': '@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -443,12 +508,42 @@ packages:
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/express-serve-static-core@5.1.3':
resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==}
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@24.19.2': '@types/node@24.19.2':
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@typescript-eslint/eslint-plugin@8.71.1': '@typescript-eslint/eslint-plugin@8.71.1':
resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -665,6 +760,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -712,6 +810,12 @@ packages:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
class-transformer@0.5.1:
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
class-validator@0.15.1:
resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==}
cli-cursor@3.1.0: cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -817,6 +921,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -1046,6 +1153,10 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
helmet@8.3.0:
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
engines: {node: '>=18.0.0'}
hookified@1.15.1: hookified@1.15.1:
resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==}
@@ -1130,6 +1241,10 @@ packages:
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true hasBin: true
js-yaml@5.3.0:
resolution: {integrity: sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==}
hasBin: true
json-parse-even-better-errors@2.3.1: json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
@@ -1153,6 +1268,16 @@ packages:
jsonfile@6.2.1: jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
keyv@5.6.0: keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
@@ -1160,6 +1285,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
libphonenumber-js@1.13.15:
resolution: {integrity: sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==}
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -1175,6 +1303,27 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.18.1: lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@@ -1531,6 +1680,9 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
swagger-ui-dist@5.32.13:
resolution: {integrity: sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==}
symbol-observable@4.0.0: symbol-observable@4.0.0:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
@@ -1671,6 +1823,10 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
validator@13.15.35:
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
engines: {node: '>= 0.10'}
vary@1.1.2: vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2028,6 +2184,8 @@ snapshots:
'@lukeed/csprng@1.1.0': {} '@lukeed/csprng@1.1.0': {}
'@microsoft/tsdoc@0.16.0': {}
'@nestjs/cli@11.0.24(@types/node@24.19.2)(prettier@3.9.10)': '@nestjs/cli@11.0.24(@types/node@24.19.2)(prettier@3.9.10)':
dependencies: dependencies:
'@angular-devkit/core': 19.2.27(chokidar@4.0.3) '@angular-devkit/core': 19.2.27(chokidar@4.0.3)
@@ -2064,7 +2222,7 @@ snapshots:
- uglify-js - uglify-js
- webpack-cli - webpack-cli
'@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)': '@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)':
dependencies: dependencies:
file-type: 21.3.4(supports-color@8.1.1) file-type: 21.3.4(supports-color@8.1.1)
iterare: 1.2.1 iterare: 1.2.1
@@ -2073,12 +2231,15 @@ snapshots:
rxjs: 7.8.2 rxjs: 7.8.2
tslib: 2.8.1 tslib: 2.8.1
uid: 2.0.2 uid: 2.0.2
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@nestjs/core@11.2.7(@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)': '@nestjs/core@11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies: dependencies:
'@nestjs/common': 11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
fast-safe-stringify: 2.1.1 fast-safe-stringify: 2.1.1
iterare: 1.2.1 iterare: 1.2.1
path-to-regexp: 8.4.2 path-to-regexp: 8.4.2
@@ -2087,12 +2248,26 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
uid: 2.0.2 uid: 2.0.2
optionalDependencies: optionalDependencies:
'@nestjs/platform-express': 11.2.7(@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(supports-color@8.1.1) '@nestjs/platform-express': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(supports-color@8.1.1)
'@nestjs/platform-express@11.2.7(@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(supports-color@8.1.1)': '@nestjs/jwt@11.0.2(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))':
dependencies: dependencies:
'@nestjs/common': 11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
'@nestjs/core': 11.2.7(@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/jsonwebtoken': 9.0.10
jsonwebtoken: 9.0.3
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
reflect-metadata: 0.2.2
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
'@nestjs/platform-express@11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(supports-color@8.1.1)':
dependencies:
'@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
'@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cors: 2.8.6 cors: 2.8.6
express: 5.2.1(supports-color@8.1.1) express: 5.2.1(supports-color@8.1.1)
multer: 2.4.0 multer: 2.4.0
@@ -2114,13 +2289,30 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - chokidar
'@nestjs/testing@11.2.7(@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(@nestjs/platform-express@11.2.7)': '@nestjs/swagger@11.4.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies: dependencies:
'@nestjs/common': 11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) '@microsoft/tsdoc': 0.16.0
'@nestjs/core': 11.2.7(@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
'@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types': 2.1.1(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
js-yaml: 5.3.0
lodash: 4.18.1
path-to-regexp: 8.4.2
reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.13
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
'@nestjs/testing@11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(@nestjs/platform-express@11.2.7)':
dependencies:
'@nestjs/common': 11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
'@nestjs/core': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
'@nestjs/platform-express': 11.2.7(@nestjs/common@11.2.7(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(supports-color@8.1.1) '@nestjs/platform-express': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(supports-color@8.1.1)
'@scarf/scarf@1.4.0': {}
'@tokenizer/inflate@0.4.1(supports-color@8.1.1)': '@tokenizer/inflate@0.4.1(supports-color@8.1.1)':
dependencies: dependencies:
@@ -2131,6 +2323,15 @@ snapshots:
'@tokenizer/token@0.3.0': {} '@tokenizer/token@0.3.0': {}
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 24.19.2
'@types/connect@3.4.38':
dependencies:
'@types/node': 24.19.2
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
dependencies: dependencies:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
@@ -2145,12 +2346,49 @@ snapshots:
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/express-serve-static-core@5.1.3':
dependencies:
'@types/node': 24.19.2
'@types/qs': 6.15.1
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express@5.0.6':
dependencies:
'@types/body-parser': 1.19.6
'@types/express-serve-static-core': 5.1.3
'@types/serve-static': 2.2.0
'@types/http-errors@2.0.5': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 24.19.2
'@types/ms@2.1.0': {}
'@types/node@24.19.2': '@types/node@24.19.2':
dependencies: dependencies:
undici-types: 7.24.6 undici-types: 7.24.6
'@types/qs@6.15.1': {}
'@types/range-parser@1.2.7': {}
'@types/send@1.2.1':
dependencies:
'@types/node': 24.19.2
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 24.19.2
'@types/validator@13.15.10': {}
'@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -2436,6 +2674,8 @@ snapshots:
node-releases: 2.0.58 node-releases: 2.0.58
update-browserslist-db: 1.3.4(browserslist@4.29.3) update-browserslist-db: 1.3.4(browserslist@4.29.3)
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
buffer@5.7.1: buffer@5.7.1:
@@ -2484,6 +2724,14 @@ snapshots:
chrome-trace-event@1.0.4: {} chrome-trace-event@1.0.4: {}
class-transformer@0.5.1: {}
class-validator@0.15.1:
dependencies:
'@types/validator': 13.15.10
libphonenumber-js: 1.13.15
validator: 13.15.35
cli-cursor@3.1.0: cli-cursor@3.1.0:
dependencies: dependencies:
restore-cursor: 3.1.0 restore-cursor: 3.1.0
@@ -2569,6 +2817,10 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {} ee-first@1.1.1: {}
electron-to-chromium@1.5.452: {} electron-to-chromium@1.5.452: {}
@@ -2842,6 +3094,8 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
helmet@8.3.0: {}
hookified@1.15.1: {} hookified@1.15.1: {}
hookified@2.2.0: {} hookified@2.2.0: {}
@@ -2907,6 +3161,10 @@ snapshots:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
js-yaml@5.3.0:
dependencies:
argparse: 2.0.1
json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@2.3.1: {}
json-schema-traverse@0.4.1: {} json-schema-traverse@0.4.1: {}
@@ -2925,6 +3183,30 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.8.5
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
keyv@5.6.0: keyv@5.6.0:
dependencies: dependencies:
'@keyv/serialize': 1.1.1 '@keyv/serialize': 1.1.1
@@ -2934,6 +3216,8 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
libphonenumber-js@1.13.15: {}
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
load-esm@1.0.3: {} load-esm@1.0.3: {}
@@ -2944,6 +3228,20 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.once@4.1.1: {}
lodash@4.18.1: {} lodash@4.18.1: {}
log-symbols@4.1.0: log-symbols@4.1.0:
@@ -3293,6 +3591,10 @@ snapshots:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
swagger-ui-dist@5.32.13:
dependencies:
'@scarf/scarf': 1.4.0
symbol-observable@4.0.0: {} symbol-observable@4.0.0: {}
tapable@2.3.3: {} tapable@2.3.3: {}
@@ -3396,6 +3698,8 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
validator@13.15.35: {}
vary@1.1.2: {} vary@1.1.2: {}
watchpack@2.5.2: watchpack@2.5.2:
+2 -1
View File
@@ -5,6 +5,7 @@ packages:
engineStrict: true engineStrict: true
strictPeerDependencies: true strictPeerDependencies: true
# NestJS 的安装后提示脚本不是运行必需项,不执行依赖安装脚本。 # NestJS 安装提示和 Swagger 间接依赖的遥测脚本不是运行必需项。
allowBuilds: allowBuilds:
'@nestjs/core': false '@nestjs/core': false
'@scarf/scarf': false