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
+13
View File
@@ -1,6 +1,19 @@
NODE_ENV=development
HOST=127.0.0.1
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 编码。
# 当前骨架尚未读取 DATABASE_URL;不要提交实际连接凭证。
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:*",
"@nestjs/common": "11.2.7",
"@nestjs/core": "11.2.7",
"@nestjs/jwt": "11.0.2",
"@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",
"rxjs": "7.8.2"
},
"devDependencies": {
"@nestjs/cli": "11.0.24",
"@nestjs/testing": "11.2.7",
"@types/express": "5.0.6",
"@types/node": "24.19.2",
"typescript": "5.9.3"
}
+2 -2
View File
@@ -1,7 +1,7 @@
# 数据库结构与初始化 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 或小程序导入。
+118 -8
View File
@@ -8,7 +8,6 @@ SET LOCAL search_path = public, pg_catalog;
CREATE TABLE public.users (
id varchar(128) COLLATE "C" PRIMARY KEY,
mini_app_id varchar(64) COLLATE "C" NOT NULL,
openid varchar(128) COLLATE "C" NOT NULL,
unionid varchar(128) COLLATE "C",
nick_name varchar(100),
@@ -20,7 +19,7 @@ CREATE TABLE public.users (
created_at timestamptz(3) NOT NULL DEFAULT now(),
last_active_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_status_check CHECK (status IN ('active', 'disabled')),
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,
CONSTRAINT assets_provider_check CHECK (provider IN ('qiniu', 'external')),
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_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,
subcategory varchar(100),
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_max smallint NOT NULL,
grade smallint NOT NULL DEFAULT 0,
@@ -134,6 +136,7 @@ CREATE TABLE public.worksheets (
downloads_seed bigint NOT NULL DEFAULT 0,
likes_seed bigint NOT NULL DEFAULT 0,
status varchar(16) NOT NULL DEFAULT 'draft',
first_published_at timestamptz(3),
created_at timestamptz(3) NOT NULL DEFAULT now(),
content_updated_at timestamptz(3),
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,
CONSTRAINT worksheets_preview_asset_id_fkey FOREIGN KEY (preview_asset_id)
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_difficulty_check CHECK (difficulty BETWEEN 1 AND 4),
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);
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 (
id varchar(128) COLLATE "C" PRIMARY KEY,
user_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(),
CONSTRAINT favorites_user_id_fkey FOREIGN KEY (user_id)
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
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,
user_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",
source varchar(16) NOT NULL DEFAULT 'mini',
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);
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.auth_version IS '应用凭证版本;服务端鉴权校验,不由 SQL 自动递增';
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.checksum IS '文件内容 SHA-256,不是七牛 ETag;非唯一';
COMMENT ON TABLE public.categories IS '分类树;多层循环需由服务端事务检查';
COMMENT ON TABLE public.worksheets IS '题型内容与真实/种子计数;资源通过 assets 引用';
COMMENT ON COLUMN public.worksheets.content_updated_at IS '仅内容修改时更新;不随计数变化;历史缺失保留 NULL';
COMMENT ON COLUMN public.worksheets.updated_at IS '由服务显式更新;包含计数、状态、内容变更';
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 COLUMN public.download_logs.worksheet_id IS '题型逻辑关联,不建外键,历史查询使用 LEFT JOIN';
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.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;
@@ -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 张表与两条配置记录核对通过。
| 文件 | 作用 | 执行位置 |
| ------------------------------------------------------------ | ---------------------------------------------- | ---------------------------------------------------------- |
| [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 张表、约束、索引与注释 | 连接目标空数据库;单事务执行 |
| [003-initialize-settings.sql](./003-initialize-settings.sql) | 初始化 current 页面配置及 homeAutoRefresh 开关 | 连接已建表数据库;可重复执行且不覆盖旧值 |
| 文件 | 作用 | 执行位置 |
| -------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------- |
| [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) | 创建 13 张表、约束、索引与注释 | 连接目标空数据库;单事务执行 |
| [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 中硬编码密码。生产角色和权限在部署时落实。
## 本地执行
## 最新执行路径
从 `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
pnpm infra:up
@@ -36,7 +54,7 @@ SELECT id, version FROM public.page_configs;
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 已完成。
- updatedAt 和 contentUpdatedAt 由服务显式维护,不添加把两者混为一谈的更新时间触发器。
- 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 { AuthModule } from './auth/auth.module';
import { ServerConfigModule, type ServerConfig } from './config/server-config';
@Module({
imports: [AuthModule],
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 {
app.setGlobalPrefix('api/v1');
/** 在业务路由初始化前安装 HTTP 基础设施;生产和测试复用相同处理顺序。 */
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();
// 文档只包含当前实际注册的路由。它直接挂载在 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;
}
/** 解析监听地址与端口;默认仅本机可访问,部署时显式配置外部监听地址。 */
export function getRuntimeOptions(
env: NodeJS.ProcessEnv = process.env,
): RuntimeOptions {
@@ -10,6 +11,7 @@ export function getRuntimeOptions(
const port = Number(rawPort);
const host = (env.HOST ?? '127.0.0.1').trim();
// 先检查原始字符串,拒绝 Number 会接受的空串、科学计数法等输入。
if (
!/^\d+$/.test(rawPort) ||
!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 type { ApiSuccess, HealthStatus } from '@doodle/contracts';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/auth.decorators';
@Controller('health')
@ApiTags('health')
export class HealthController {
@Get()
@Public()
@ApiOperation({
summary: 'HTTP 进程存活检查,不检测数据库或七牛',
security: [],
})
@ApiResponse({ status: 200, description: '服务进程可响应' })
/** 仅确认 HTTP 进程能响应;数据库、七牛及业务可用性应另行检查。 */
getHealth(): ApiSuccess<HealthStatus> {
return {
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 { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { configureApplication } from './app.setup';
import { getRuntimeOptions } from './common/runtime-options';
import { resolve } from 'node:path';
import { createApplication } from './app.factory';
import { API_PREFIX, getServerConfig } from './config/server-config';
import { AppLogger } from './logging/app-logger';
async function bootstrap(): Promise<void> {
export async function bootstrap(): Promise<void> {
try {
process.loadEnvFile();
// 仅读取服务端 .env,避免读到父目录 Compose 的 .env。
process.loadEnvFile(resolve(__dirname, '../.env'));
} catch (error) {
if (!(
error instanceof Error &&
@@ -18,17 +18,26 @@ async function bootstrap(): Promise<void> {
}
}
const { port, host } = getRuntimeOptions();
const app = await NestFactory.create(AppModule);
configureApplication(app);
await app.listen(port, host);
Logger.log(
`Listening on http://${host}:${port}/api/v1/health`,
'Bootstrap',
);
const config = getServerConfig();
const logger = new AppLogger(config.logLevel);
const app = await createApplication(config, logger);
try {
await app.listen(config.port, config.host);
logger.log('server_started', {
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) => {
Logger.error(error, undefined, 'Bootstrap');
process.exit(1);
});
if (require.main === module) {
void bootstrap().catch((error: unknown) => {
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 模块。
当前没有创建任何业务 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 assert from 'node:assert/strict';
import { test } from 'node:test';
import { Test } from '@nestjs/testing';
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 { testConfig } from './test-config';
test('服务可以启动并通过带版本前缀的 HTTP 健康检查', async (context) => {
const testingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = testingModule.createNestApplication({ logger: false });
const app = await createApplication(
testConfig,
new AppLogger('error', () => {}),
);
context.after(async () => {
await app.close();
});
configureApplication(app);
await app.listen(0, '127.0.0.1');
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',
});