feat: 完成分类、worksheet 接口开发,资源迁移到七牛

This commit is contained in:
R524809
2026-10-16 17:32:23 +08:00
parent e6ded00a4c
commit 8827110f2f
107 changed files with 12775 additions and 125 deletions
+1
View File
@@ -5,6 +5,7 @@ dist-test/
coverage/
*.tsbuildinfo
*.log
/apps/server/src/generated/
.DS_Store
.env
.env.*
+3
View File
@@ -4,3 +4,6 @@ dist
dist-test
coverage
pnpm-lock.yaml
apps/server/src/generated
packages/asset-sync/reports
+25 -6
View File
@@ -4,12 +4,13 @@
## 当前范围
当前完成基础工程和本地数据库环境,具体业务尚未接入:
当前已完成基础工程、本地数据库、小程序登录鉴权及分类服务:
- `apps/server`:可运行的 NestJS 服务,已接入日志、中间件、Swagger 与 JWT 基础设施;当前公开业务路由仍只有进程健康检查。
- `packages/contracts`:基础成功响应与健康检查类型,不包含业务字段或私有实现。
- `apps/server`:可运行的 NestJS 服务,已接入日志、中间件、Swagger、微信静默登录、JWT 与真实账户检查,提供本人摘要、分类查询/管理、题型列表/详情与组合筛选和数据库就绪接口。
- `packages/contracts`:公开响应、登录请求/结果、本人摘要、分类与安全错误类型,不包含数据库模型或私有实现。
- `packages/asset-sync`:本地云存储备份同步七牛的独立 Node.js 工具,支持预演、校验、重试,头像与其他资源共用公开空间。
- `apps/admin`、`apps/web`:仅预留目录与说明,尚未选择或安装前端框架。
- 本地 PostgreSQL 18.6 Docker 开发环境已启动,10 张表及初始配置已创建;Prisma、微信登录、七牛接口、定时任务、业务接口与生产部署尚未接入。
- 本地 PostgreSQL 18.6 Docker 开发环境已启动,13 张表及初始配置已创建,并于 2026-10-15 与最新 SQL 复核一致;Prisma 与微信登录已接入,七牛、其他业务及生产部署待实施。
## 本地使用
@@ -23,14 +24,19 @@ pnpm build
pnpm dev
```
默认地址为 `http://127.0.0.1:3000/api/v1/health`。该接口只证明服务进程可以处理 HTTP 请求,不代表数据库、文件存储或业务系统已经可用。
默认地址为 `http://127.0.0.1:8800/api/v1/health`。该接口只证明服务进程可以处理 HTTP 请求,不代表数据库、文件存储或业务系统已经可用。
`pnpm dev` 在服务监听成功后显示完整 Swagger 地址(默认 `http://127.0.0.1:8800/api-docs`),并使用系统默认浏览器自动打开一次。热更新不会重复打开;关闭 Swagger 时不打开。链接按实际 HOST/PORT 生成,建议使用输出的 IP 地址,避免 `localhost` 解析到其他项目。
可在 `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)。
JWT_SECRET 必须配置为至少 32 字节的独立密钥,本机开发密钥已生成。Swagger 默认开发开启:`http://127.0.0.1:8800/api-docs`,生产未显式配置时关闭;其他设置与账户查询接入边界见 [Server 基础设置](docs/v3.5/Server基础设置.md)。
微信小程序配置在 `apps/server/.env` 中填写 `WECHAT_MINI_APP_ID` 和 `WECHAT_MINI_APP_SECRET`。两项均为启动必填;DATABASE_URL 同样必须配置。真实 AppSecret 仅由服务端持有,不提交仓库。
```bash
pnpm check # 格式、代码检查、类型检查、构建与测试
pnpm test:db # 独立临时数据库中的登录/鉴权/分类测试,结束后清理
pnpm build # 按工作区依赖顺序构建
pnpm start # 运行已构建的服务端
pnpm format # 格式化源码与配置
@@ -44,6 +50,7 @@ apps/
admin/ 后续管理后台,当前仅占位
web/ 后续用户网站,当前仅占位
packages/
asset-sync/ 云存储备份同步七牛的独立脚本
contracts/ 可公开的接口类型
deploy/ 后续部署说明,当前未部署
infra/ 本地 PostgreSQL Docker 配置
@@ -54,4 +61,16 @@ docs/ 工程初始化与接入边界说明
数据库模型、密钥和未来私有生成/绘制逻辑只能由服务端持有,不能放进公共契约包供前端导入。小程序不直接依赖本仓库源码或本地工作区包。
登录请求、恢复错误码、数据库基线及联调边界见 [服务端登录接入说明](docs/v3.5/服务端登录接入说明.md)。
分类公开/管理 API、本地导入工具及本批 6 条分类的导入结果见 [分类接口与数据导入](docs/v3.5/分类接口与数据导入.md)。
51 条旧题型数据已导入本地库,预览文件仍待迁移;导入工具、异常年级和计数对账见 [题型数据导入说明](docs/v3.5/题型数据导入说明.md)。
题型查询支持分类、审核年级、年龄段、Tag、最新/热门、运营标记和分页,见 [题型查询接口说明](docs/v3.5/题型查询接口说明.md)。
现有功能迁移的权威方案见 [docs/云服务端迁移方案.md](docs/云服务端迁移方案.md),小程序产品规划与历史设计仍保留在 `doodle-mini`。本仓库的初始化说明见 [docs/初始化说明.md](docs/初始化说明.md)。
## 云存储备份同步七牛
独立脚本项目位于 [packages/asset-sync](packages/asset-sync/README.md),配置入口为该目录的 `.env`,已填写本地 cloud_backup 源目录。`pnpm assets:sync` 默认只预演;填写七牛 AK/SK 与 bucket 后,使用 `pnpm assets:sync --apply --only assets/previews` 同步题型预览。支持保留相对路径、同内容跳过、冲突保护、有限重试和报告;头像与其他资源共用 QINIU_BUCKET 公开空间。该工具不修改业务数据库或自动替换旧 cloud 引用。
+11 -3
View File
@@ -1,6 +1,6 @@
NODE_ENV=development
HOST=127.0.0.1
PORT=3000
PORT=8800
LOG_LEVEL=info
API_BODY_LIMIT_BYTES=1048576
# 开发默认开启,生产默认关闭;可显式设置 true/false。
@@ -14,6 +14,14 @@ JWT_SECRET=
JWT_ISSUER=doodle-store
JWT_AUDIENCE=doodle-mini
JWT_EXPIRES_IN_SECONDS=86400
# 后续 Prisma 接入使用;用户名/密码/端口须与根 .env 一致,密码需 URL 编码。
# 当前骨架尚未读取 DATABASE_URL;不要提交实际连接凭证。
# 微信静默登录已接入,两项均必填;小程序 AppID 与对应 AppSecret。
# 仅服务端持有 AppSecret,不提交实际密钥,也不使用 JWT_SECRET 代替。
WECHAT_MINI_APP_ID=
WECHAT_MINI_APP_SECRET=
# Prisma 必填;用户名/密码/端口须与根 .env 一致,密码需 URL 编码。
# 仅连接 public schema;不要提交实际连接凭证。
DATABASE_URL=postgresql://doodle_dev:change-me-local-only@127.0.0.1:5434/doodle_dev?schema=public
# 新品按真实首次发布时间计算;旧数据发布时间未知时不标新品。
WORKSHEET_NEW_WINDOW_DAYS=30
# 可选公共资源 HTTPS 域名(不含路径);只解析 ready/public 资产,不授予上传权限。
QINIU_PUBLIC_DOMAIN=
+16 -5
View File
@@ -4,11 +4,17 @@
"private": true,
"type": "commonjs",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"dev": "pnpm prisma:generate && node scripts/dev.mjs",
"build": "pnpm prisma:generate && nest build",
"start": "node dist/main.js",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
"typecheck": "pnpm prisma:generate && tsc --noEmit -p tsconfig.json",
"test": "pnpm prisma:generate && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js",
"prisma:generate": "prisma generate",
"db:status": "prisma migrate status",
"db:deploy": "prisma migrate deploy",
"test:db": "pnpm prisma:generate && node scripts/test-auth-db.mjs",
"categories:import": "node scripts/import-categories.mjs",
"worksheets:import": "node scripts/import-worksheets.mjs"
},
"dependencies": {
"@doodle/contracts": "workspace:*",
@@ -17,9 +23,12 @@
"@nestjs/jwt": "11.0.2",
"@nestjs/platform-express": "11.2.7",
"@nestjs/swagger": "11.4.7",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"class-transformer": "0.5.1",
"class-validator": "0.15.1",
"helmet": "8.3.0",
"pg": "8.23.1",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2"
},
@@ -28,6 +37,8 @@
"@nestjs/testing": "11.2.7",
"@types/express": "5.0.6",
"@types/node": "24.19.2",
"typescript": "5.9.3"
"typescript": "5.9.3",
"prisma": "7.10.0",
"@types/pg": "8.23.1"
}
}
+18
View File
@@ -0,0 +1,18 @@
import { resolve } from 'node:path';
import { defineConfig } from 'prisma/config';
// CLI 与应用仅读取服务端 .env;生成客户端不要求存在真实连接串。
try {
process.loadEnvFile(resolve(__dirname, '.env'));
} catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
throw error;
}
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations' },
...(process.env.DATABASE_URL
? { datasource: { url: process.env.DATABASE_URL } }
: {}),
});
+25 -4
View File
@@ -1,7 +1,28 @@
# 数据库结构与初始化 SQL
# 数据库结构与 Prisma 迁移
已提供 [v3.5 初始化 SQL](./sql/README.md),包含建库、13 张表的最新基线结构、配置初始化和 004~008 增量升级。2026-10-14 已核对设计文档的 135 个字段,最新基线、完整旧库升级链及本地 doodle_dev 的字段、约束、索引和注释一致。开发库已执行至 008;原用户、题型、收藏及配置的保留规则通过临时库验证。本地 PostgreSQL 数据库由 [infra](../../../infra/README.md) 的 Docker 配置提供。
2026-10-15 已接入 Prisma 7.10.0、PostgreSQL adapter 和服务端连接池,Schema 反向映射现有 13 张表、135 个字段。Prisma 模型使用 PascalCase,标量字段 camelCase,通过 map 保留数据库表名和列名;原字符串 ID、字段类型、可空性与默认值不变。客户端仅服务端使用,不向公共契约包或前端导出。
数据库已确认使用 PostgreSQL + Prisma;具体兼容版本在实施时验证并锁定,保留原用户和题型的字符串 ID。目标设计见 [v3.5 数据库设计文档](../../../docs/v3.5/数据库设计文档.md)。当前手工 SQL 基线和增量升级已执行;Prisma 尚未安装,尚未创建 Schema、登记 Prisma 迁移历史或接入应用数据库查询。
## 基线与已有数据
本目录及未来 Prisma Client 只能由服务端使用,不能作为公共契约包被 admin、web 或小程序导入。
`migrations/0_v35_baseline/migration.sql` 来自已验证的最新 SQL 002/003,合为一个事务,保留 CHECK、C 排序规则、原外键删除规则、两种收藏部分唯一索引及初始配置。CHECK、排序规则及注释不能仅从 Prisma Schema 重建;部分唯一索引由 introspection 加入 partialIndexes 预览声明,实际约束仍以 SQL 为准。
- 新空库:配置正确的 DATABASE_URL,再从项目根运行 `pnpm --filter @doodle/server db:deploy`。
- 已按 002/003 或完整 004~008 链升级的库:先逐项核对实际结构、配置和数据,再执行 `pnpm --filter @doodle/server exec prisma migrate resolve --applied 0_v35_baseline`;不重跑建表。
- 检查状态:`pnpm --filter @doodle/server db:status`。
- 后续变更:新增迁移,保持已应用基线不变。不要对现有开发库使用 db push/reset,也不要在最新基线之上重复执行历史 004~008。
本地开发库已在完整结构核对后登记基线,新增 `_prisma_migrations` 技术表,仍为 13 张业务表;已有两条初始配置保留。后续 Prisma 迁移只通过正式新增 migration 变更结构,原 SQL 目录作为历史及设计参考保留。
## 连接、生成与测试
CLI 由 `prisma.config.ts` 加载服务端 `.env`;应用从已验证的 ServerConfig 读取连接串,不搜索根 Compose `.env`。用户密码等信息不得写入 Schema、迁移或日志。
`pnpm --filter @doodle/server prisma:generate` 生成忽略提交的 `src/generated/prisma`;dev/build/typecheck/test 脚本均会先生成。使用 CommonJS 模式匹配现有 NestJS 工程,不引入前端数据库客户端。
`pnpm test:db` 自动创建随机命名的本机临时库,执行正式迁移,验证微信登录和真实账户鉴权,随后删除临时库;不会清空或写入开发库业务数据。默认 `pnpm check` 不需要真实数据库。
范围见 [服务端登录接入说明](../../../docs/v3.5/服务端登录接入说明.md),完整模型见 [数据库设计](../../../docs/v3.5/数据库设计文档.md),旧 SQL 执行档案见 [sql/README](./sql/README.md)。
## 分类发布状态(2026-10-16)
保持已应用的 0_v35_baseline 不变,新增 20261016000000_category_publication。当前 Schema 为 13 张业务表、136 个字段。既有分类迁移为 active,后续创建默认 draft,增加状态 CHECK 与索引;题型和资源复用现有状态列。本地已 deploy 并核对全部业务数据未改变(分类新增状态除外)。详见 [内容发布与可见性](../../../docs/v3.5/内容发布与可见性.md)。
@@ -0,0 +1,419 @@
-- v3.5 已有结构的 Prisma 基线;保留原始 SQL 的 C 排序规则、CHECK 和部分唯一索引。
-- 已初始化数据库先核对结构再 migrate resolve --applied,不重建已有业务表。
BEGIN;
-- v3.5 PostgreSQL 基线结构。连接目标空数据库后执行。
-- 单事务创建;失败全部回滚;不使用 IF NOT EXISTS 掩盖既有结构差异。
-- 主键由服务端生成或使用旧 CloudBase _id,SQL 不重新生成旧 ID。
SET LOCAL TIME ZONE 'UTC';
SET LOCAL lock_timeout = '5s';
SET LOCAL search_path = public, pg_catalog;
CREATE TABLE public.users (
id varchar(128) COLLATE "C" PRIMARY KEY,
openid varchar(128) COLLATE "C" NOT NULL,
unionid varchar(128) COLLATE "C",
nick_name varchar(100),
avatar_asset_id varchar(128) COLLATE "C",
total_downloads bigint NOT NULL DEFAULT 0,
role varchar(16) NOT NULL DEFAULT 'user',
status varchar(16) NOT NULL DEFAULT 'active',
auth_version integer NOT NULL DEFAULT 1,
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_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),
CONSTRAINT users_auth_version_check CHECK (auth_version >= 0)
);
CREATE INDEX users_unionid_idx ON public.users (unionid);
CREATE TABLE public.assets (
id varchar(128) COLLATE "C" PRIMARY KEY,
source_ref text COLLATE "C",
source_ref_hash char(64) COLLATE "C",
provider varchar(16) NOT NULL DEFAULT 'qiniu',
bucket varchar(128) COLLATE "C",
object_key text COLLATE "C",
object_ref_hash char(64) COLLATE "C",
external_url text COLLATE "C",
purpose varchar(32) NOT NULL,
owner_user_id varchar(128) COLLATE "C",
visibility varchar(16) NOT NULL,
mime_type varchar(128),
size_bytes bigint,
checksum char(64) COLLATE "C",
status varchar(16) NOT NULL DEFAULT 'pending',
last_error text,
created_at timestamptz(3) NOT NULL DEFAULT now(),
updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT assets_source_ref_hash_key UNIQUE (source_ref_hash),
CONSTRAINT assets_object_ref_hash_key UNIQUE (object_ref_hash),
CONSTRAINT assets_owner_user_id_fkey FOREIGN KEY (owner_user_id)
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', 'worksheet_source', 'other')
),
CONSTRAINT assets_visibility_check CHECK (visibility IN ('public', 'private')),
CONSTRAINT assets_avatar_visibility_check CHECK (purpose <> 'avatar' OR visibility = 'private'),
CONSTRAINT assets_status_check CHECK (status IN ('pending', 'ready', 'failed', 'deleting', 'deleted')),
CONSTRAINT assets_size_bytes_check CHECK (size_bytes IS NULL OR size_bytes >= 0),
CONSTRAINT assets_source_ref_hash_check CHECK (
source_ref_hash IS NULL OR source_ref_hash ~ '^[0-9a-f]{64}$'
),
CONSTRAINT assets_object_ref_hash_check CHECK (
object_ref_hash IS NULL OR object_ref_hash ~ '^[0-9a-f]{64}$'
),
CONSTRAINT assets_checksum_check CHECK (checksum IS NULL OR checksum ~ '^[0-9a-f]{64}$'),
CONSTRAINT assets_ready_reference_check CHECK (
status <> 'ready'
OR (
provider = 'qiniu'
AND bucket IS NOT NULL AND length(bucket) > 0
AND object_key IS NOT NULL AND length(object_key) > 0
AND object_ref_hash IS NOT NULL
AND mime_type IS NOT NULL AND length(mime_type) > 0
AND size_bytes IS NOT NULL
AND checksum IS NOT NULL
)
OR (
provider = 'external'
AND external_url IS NOT NULL
AND external_url ~ '^https://[^/[:space:]]+'
)
)
);
CREATE INDEX assets_owner_purpose_status_idx ON public.assets (owner_user_id, purpose, status);
CREATE INDEX assets_status_updated_at_idx ON public.assets (status, updated_at);
-- users 与 assets 双向关联:先建立两张表,再补头像外键。
ALTER TABLE public.users
ADD CONSTRAINT users_avatar_asset_id_fkey FOREIGN KEY (avatar_asset_id)
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT;
CREATE TABLE public.categories (
id varchar(128) COLLATE "C" PRIMARY KEY,
name varchar(100) NOT NULL,
icon varchar(1024) NOT NULL,
icon_asset_id varchar(128) COLLATE "C",
color varchar(32) NOT NULL,
sort_order integer NOT NULL DEFAULT 0,
parent_id varchar(128) COLLATE "C",
created_at timestamptz(3) NOT NULL DEFAULT now(),
updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT categories_parent_id_fkey FOREIGN KEY (parent_id)
REFERENCES public.categories (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT categories_icon_asset_id_fkey FOREIGN KEY (icon_asset_id)
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT categories_parent_not_self_check CHECK (parent_id IS NULL OR parent_id <> id)
);
CREATE INDEX categories_parent_sort_idx ON public.categories (parent_id, sort_order, id);
CREATE TABLE public.worksheets (
id varchar(128) COLLATE "C" PRIMARY KEY,
title varchar(100) NOT NULL,
subtitle varchar(500) NOT NULL,
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,
difficulty smallint NOT NULL,
preview_asset_id varchar(128) COLLATE "C" NOT NULL,
tags jsonb NOT NULL DEFAULT '[]'::jsonb,
is_new boolean NOT NULL DEFAULT false,
is_hot boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0,
downloads bigint NOT NULL DEFAULT 0,
likes bigint NOT NULL DEFAULT 0,
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(),
CONSTRAINT worksheets_category_id_fkey FOREIGN KEY (category_id)
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')),
CONSTRAINT worksheets_counts_check CHECK (
downloads >= 0 AND likes >= 0 AND downloads_seed >= 0 AND likes_seed >= 0
),
CONSTRAINT worksheets_tags_array_check CHECK (jsonb_typeof(tags) = 'array')
);
CREATE INDEX worksheets_category_status_sort_idx
ON public.worksheets (category_id, status, sort_order, updated_at DESC, id);
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_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);
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,
created_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT download_logs_user_id_fkey FOREIGN KEY (user_id)
REFERENCES public.users (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
-- 默认 NULLS DISTINCT 语义,历史 NULL export_id 可保存多条。
CONSTRAINT download_logs_user_export_key UNIQUE (user_id, export_id),
CONSTRAINT download_logs_source_check CHECK (source IN ('mini', 'legacy', 'import')),
CONSTRAINT download_logs_export_id_check CHECK (
(source = 'import' AND export_id IS NULL)
OR (export_id IS NOT NULL AND length(btrim(export_id)) > 0)
)
);
CREATE INDEX download_logs_user_created_at_idx
ON public.download_logs (user_id, created_at DESC, id DESC);
CREATE INDEX download_logs_worksheet_created_at_idx
ON public.download_logs (worksheet_id, created_at DESC, id DESC);
CREATE TABLE public.page_configs (
id varchar(128) COLLATE "C" PRIMARY KEY,
home jsonb,
category jsonb,
age jsonb,
version bigint NOT NULL DEFAULT 0,
updated_at timestamptz(3) NOT NULL DEFAULT now(),
published_version bigint,
published_at timestamptz(3),
CONSTRAINT page_configs_version_check CHECK (version >= 0),
CONSTRAINT page_configs_published_version_check CHECK (
published_version IS NULL OR (published_version >= 0 AND published_version <= version)
),
CONSTRAINT page_configs_home_object_check CHECK (home IS NULL OR jsonb_typeof(home) = 'object'),
CONSTRAINT page_configs_category_object_check CHECK (
category IS NULL OR jsonb_typeof(category) = 'object'
),
CONSTRAINT page_configs_age_object_check CHECK (age IS NULL OR jsonb_typeof(age) = 'object')
);
CREATE TABLE public.settings (
id varchar(128) COLLATE "C" PRIMARY KEY,
value jsonb NOT NULL,
version bigint NOT NULL DEFAULT 0,
updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT settings_value_object_check CHECK (jsonb_typeof(value) = 'object'),
CONSTRAINT settings_version_check CHECK (version >= 0)
);
CREATE TABLE public.job_runs (
id varchar(128) COLLATE "C" PRIMARY KEY,
job_name varchar(64) COLLATE "C" NOT NULL,
run_key varchar(128) COLLATE "C" NOT NULL,
trigger_type varchar(16) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'pending',
scheduled_at timestamptz(3),
started_at timestamptz(3),
finished_at timestamptz(3),
attempt integer NOT NULL DEFAULT 0,
lock_token varchar(128) COLLATE "C",
locked_until timestamptz(3),
result jsonb,
error_message text,
created_at timestamptz(3) NOT NULL DEFAULT now(),
updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT job_runs_job_run_key UNIQUE (job_name, run_key),
CONSTRAINT job_runs_trigger_type_check CHECK (trigger_type IN ('scheduled', 'manual', 'recovery')),
CONSTRAINT job_runs_status_check CHECK (
status IN ('pending', 'running', 'succeeded', 'failed', 'skipped')
),
CONSTRAINT job_runs_attempt_check CHECK (attempt >= 0)
);
CREATE INDEX job_runs_status_locked_until_idx ON public.job_runs (status, locked_until);
CREATE INDEX job_runs_job_created_at_idx ON public.job_runs (job_name, created_at DESC);
CREATE TABLE public.admin_audit_logs (
id varchar(128) COLLATE "C" PRIMARY KEY,
actor_user_id varchar(128) COLLATE "C",
actor_type varchar(16) NOT NULL,
action varchar(64) NOT NULL,
resource_type varchar(64) NOT NULL,
resource_id varchar(128) COLLATE "C",
request_id varchar(128) COLLATE "C" NOT NULL,
before_data jsonb,
after_data jsonb,
reason text,
created_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT admin_audit_logs_actor_type_check CHECK (actor_type IN ('admin', 'system', 'migration'))
);
CREATE INDEX admin_audit_logs_resource_created_at_idx
ON public.admin_audit_logs (resource_type, resource_id, created_at DESC);
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,按 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.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.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';
COMMENT ON TABLE public.page_configs IS '页面配置权威源;JSON 内持久化 assetId,DTO 解析资源地址';
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 是完整内容收藏;逻辑关联,缺失/下架不清空,不退化为功能收藏';
-- 初始配置锁行;可重复执行,绝不覆盖已经导入的页面、版本或 enabled=false。
-- 不插入假用户、假题型或生产管理员;不启动任何调度器。
SET LOCAL TIME ZONE 'UTC';
INSERT INTO public.page_configs (id, home, category, age, version)
VALUES ('current', NULL, NULL, NULL, 0)
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.settings (id, value, version)
VALUES ('homeAutoRefresh', '{"enabled": true}'::jsonb, 0)
ON CONFLICT (id) DO NOTHING;
COMMIT;
@@ -0,0 +1,9 @@
-- 既有分类保留当前公开行为;以后新建分类默认草稿。不要重跑已成功的增量迁移。
BEGIN;
ALTER TABLE public.categories ADD COLUMN status varchar(16) NOT NULL DEFAULT 'draft';
UPDATE public.categories SET status = 'active';
ALTER TABLE public.categories ADD CONSTRAINT categories_status_check
CHECK (status IN ('draft', 'active', 'hidden'));
CREATE INDEX categories_status_parent_sort_idx ON public.categories (status, parent_id, sort_order, id);
COMMENT ON COLUMN public.categories.status IS '发布状态:draft 开发测试,active 上线,hidden 下架;公开访问还需所有上级分类 active';
COMMIT;
@@ -0,0 +1 @@
provider = "postgresql"
+293
View File
@@ -0,0 +1,293 @@
// 由现有 v3.5 PostgreSQL 结构反向映射。
// CHECK、C 排序规则与部分唯一索引以 migrations 中保留的原始 SQL 为准。
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
previewFeatures = ["partialIndexes"]
importFileExtension = "js"
moduleFormat = "cjs"
}
datasource db {
provider = "postgresql"
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model AdminAuditLog {
id String @id @db.VarChar(128)
actorUserId String? @map("actor_user_id") @db.VarChar(128)
actorType String @map("actor_type") @db.VarChar(16)
action String @db.VarChar(64)
resourceType String @map("resource_type") @db.VarChar(64)
resourceId String? @map("resource_id") @db.VarChar(128)
requestId String @map("request_id") @db.VarChar(128)
beforeData Json? @map("before_data")
afterData Json? @map("after_data")
reason String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@index([actorUserId, createdAt(sort: Desc)], map: "admin_audit_logs_actor_created_at_idx")
@@index([requestId])
@@index([resourceType, resourceId, createdAt(sort: Desc)], map: "admin_audit_logs_resource_created_at_idx")
@@map("admin_audit_logs")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model Asset {
id String @id @db.VarChar(128)
sourceRef String? @map("source_ref")
sourceRefHash String? @unique @map("source_ref_hash") @db.Char(64)
provider String @default("qiniu") @db.VarChar(16)
bucket String? @db.VarChar(128)
objectKey String? @map("object_key")
objectRefHash String? @unique @map("object_ref_hash") @db.Char(64)
externalUrl String? @map("external_url")
purpose String @db.VarChar(32)
ownerUserId String? @map("owner_user_id") @db.VarChar(128)
visibility String @db.VarChar(16)
mimeType String? @map("mime_type") @db.VarChar(128)
sizeBytes BigInt? @map("size_bytes")
checksum String? @db.Char(64)
status String @default("pending") @db.VarChar(16)
lastError String? @map("last_error")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
usersAssetsOwnerUserIdTousers User? @relation("assets_owner_user_idTousers", fields: [ownerUserId], references: [id], onUpdate: Restrict)
categories Category[]
usersUsersAvatarAssetIdToassets User[] @relation("users_avatar_asset_idToassets")
worksheetResourceAssets WorksheetResourceAsset[]
worksheetResources WorksheetResource[]
worksheets Worksheet[]
@@index([ownerUserId, purpose, status], map: "assets_owner_purpose_status_idx")
@@index([status, updatedAt])
@@map("assets")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model Category {
status String @default("draft") @db.VarChar(16)
id String @id @db.VarChar(128)
name String @db.VarChar(100)
icon String @db.VarChar(1024)
iconAssetId String? @map("icon_asset_id") @db.VarChar(128)
color String @db.VarChar(32)
sortOrder Int @default(0) @map("sort_order")
parentId String? @map("parent_id") @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
assets Asset? @relation(fields: [iconAssetId], references: [id], onDelete: Restrict, onUpdate: Restrict)
categories Category? @relation("categoriesTocategories", fields: [parentId], references: [id], onDelete: Restrict, onUpdate: Restrict)
otherCategories Category[] @relation("categoriesTocategories")
worksheets Worksheet[]
@@index([parentId, sortOrder, id], map: "categories_parent_sort_idx")
@@index([status, parentId, sortOrder, id], map: "categories_status_parent_sort_idx")
@@map("categories")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model DownloadLog {
id String @id @db.VarChar(128)
userId String @map("user_id") @db.VarChar(128)
worksheetId String @map("worksheet_id") @db.VarChar(128)
exportId String? @map("export_id") @db.VarChar(128)
source String @default("mini") @db.VarChar(16)
params Json?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
worksheetResourceId String? @map("worksheet_resource_id") @db.VarChar(128)
users User @relation(fields: [userId], references: [id], onUpdate: Restrict)
@@unique([userId, exportId], map: "download_logs_user_export_key")
@@index([userId, createdAt(sort: Desc), id(sort: Desc)], map: "download_logs_user_created_at_idx")
@@index([worksheetId, createdAt(sort: Desc), id(sort: Desc)], map: "download_logs_worksheet_created_at_idx")
@@map("download_logs")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model Favorite {
id String @id @db.VarChar(128)
userId String @map("user_id") @db.VarChar(128)
worksheetId String @map("worksheet_id") @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
worksheetResourceId String? @map("worksheet_resource_id") @db.VarChar(128)
users User @relation(fields: [userId], references: [id], onUpdate: Restrict)
@@unique([userId, worksheetResourceId], map: "favorites_user_resource_key", where: raw("(worksheet_resource_id IS NOT NULL)"))
@@unique([userId, worksheetId], map: "favorites_user_worksheet_key", where: raw("(worksheet_resource_id IS NULL)"))
@@index([userId, createdAt(sort: Desc), id(sort: Desc)], map: "favorites_user_created_at_idx")
@@map("favorites")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model JobRun {
id String @id @db.VarChar(128)
jobName String @map("job_name") @db.VarChar(64)
runKey String @map("run_key") @db.VarChar(128)
triggerType String @map("trigger_type") @db.VarChar(16)
status String @default("pending") @db.VarChar(16)
scheduledAt DateTime? @map("scheduled_at") @db.Timestamptz(3)
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
attempt Int @default(0)
lockToken String? @map("lock_token") @db.VarChar(128)
lockedUntil DateTime? @map("locked_until") @db.Timestamptz(3)
result Json?
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
@@unique([jobName, runKey], map: "job_runs_job_run_key")
@@index([jobName, createdAt(sort: Desc)], map: "job_runs_job_created_at_idx")
@@index([status, lockedUntil])
@@map("job_runs")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model PageConfig {
id String @id @db.VarChar(128)
home Json?
category Json?
age Json?
version BigInt @default(0)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
publishedVersion BigInt? @map("published_version")
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
@@map("page_configs")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model Setting {
id String @id @db.VarChar(128)
value Json
version BigInt @default(0)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
@@map("settings")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model User {
id String @id @db.VarChar(128)
openid String @unique @db.VarChar(128)
unionid String? @db.VarChar(128)
nickName String? @map("nick_name") @db.VarChar(100)
avatarAssetId String? @map("avatar_asset_id") @db.VarChar(128)
totalDownloads BigInt @default(0) @map("total_downloads")
role String @default("user") @db.VarChar(16)
status String @default("active") @db.VarChar(16)
authVersion Int @default(1) @map("auth_version")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
lastActiveAt DateTime @default(now()) @map("last_active_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
assetsAssetsOwnerUserIdTousers Asset[] @relation("assets_owner_user_idTousers")
downloadLogs DownloadLog[]
favorites Favorite[]
assetsUsersAvatarAssetIdToassets Asset? @relation("users_avatar_asset_idToassets", fields: [avatarAssetId], references: [id], onDelete: Restrict, onUpdate: Restrict)
@@index([unionid])
@@map("users")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model WorksheetGrade {
worksheetId String @map("worksheet_id") @db.VarChar(128)
grade Int @db.SmallInt
worksheets Worksheet @relation(fields: [worksheetId], references: [id], onUpdate: Restrict)
@@id([worksheetId, grade])
@@index([grade, worksheetId], map: "worksheet_grades_grade_worksheet_idx")
@@map("worksheet_grades")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model WorksheetResourceAsset {
id String @id @db.VarChar(128)
worksheetResourceId String @map("worksheet_resource_id") @db.VarChar(128)
assetId String @map("asset_id") @db.VarChar(128)
role String @db.VarChar(16)
sortOrder Int @default(0) @map("sort_order")
assets Asset @relation(fields: [assetId], references: [id], onUpdate: Restrict)
worksheetResources WorksheetResource @relation(fields: [worksheetResourceId], references: [id], onUpdate: Restrict, map: "worksheet_resource_assets_resource_id_fkey")
@@unique([worksheetResourceId, role, assetId], map: "worksheet_resource_assets_resource_role_asset_key")
@@index([assetId], map: "worksheet_resource_assets_asset_idx")
@@index([worksheetResourceId, role, sortOrder, id], map: "worksheet_resource_assets_resource_role_sort_idx")
@@map("worksheet_resource_assets")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model WorksheetResource {
id String @id @db.VarChar(128)
worksheetId String @map("worksheet_id") @db.VarChar(128)
title String @db.VarChar(100)
previewAssetId String @map("preview_asset_id") @db.VarChar(128)
configOverride Json? @map("config_override")
status String @default("draft") @db.VarChar(16)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
firstPublishedAt DateTime? @map("first_published_at") @db.Timestamptz(3)
downloads BigInt @default(0)
worksheetResourceAssets WorksheetResourceAsset[]
assets Asset @relation(fields: [previewAssetId], references: [id], onUpdate: Restrict)
worksheets Worksheet @relation(fields: [worksheetId], references: [id], onUpdate: Restrict)
@@index([previewAssetId], map: "worksheet_resources_preview_asset_idx")
@@index([worksheetId, status, sortOrder, id], map: "worksheet_resources_worksheet_status_sort_idx")
@@map("worksheet_resources")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model Worksheet {
id String @id @db.VarChar(128)
title String @db.VarChar(100)
subtitle String @db.VarChar(500)
categoryId String @map("category_id") @db.VarChar(128)
subcategory String? @db.VarChar(100)
path String @db.VarChar(1024)
ageMin Int @map("age_min") @db.SmallInt
ageMax Int @map("age_max") @db.SmallInt
grade Int @default(0) @db.SmallInt
difficulty Int @db.SmallInt
previewAssetId String @map("preview_asset_id") @db.VarChar(128)
tags Json @default("[]")
isNew Boolean @default(false) @map("is_new")
isHot Boolean @default(false) @map("is_hot")
sortOrder Int @default(0) @map("sort_order")
downloads BigInt @default(0)
likes BigInt @default(0)
downloadsSeed BigInt @default(0) @map("downloads_seed")
likesSeed BigInt @default(0) @map("likes_seed")
status String @default("draft") @db.VarChar(16)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
contentUpdatedAt DateTime? @map("content_updated_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
generationMode String @default("procedural") @map("generation_mode") @db.VarChar(16)
rendererKey String? @map("renderer_key") @db.VarChar(64)
renderConfig Json @default("{}") @map("render_config")
firstPublishedAt DateTime? @map("first_published_at") @db.Timestamptz(3)
worksheetGrades WorksheetGrade[]
worksheetResources WorksheetResource[]
categories Category @relation(fields: [categoryId], references: [id], onUpdate: Restrict)
assets Asset @relation(fields: [previewAssetId], references: [id], onUpdate: Restrict)
@@index([categoryId, status, sortOrder, updatedAt(sort: Desc), id], map: "worksheets_category_status_sort_idx")
@@index([status, ageMin, ageMax], map: "worksheets_status_age_idx")
@@index([status, sortOrder, updatedAt(sort: Desc), id], map: "worksheets_status_sort_idx")
@@map("worksheets")
}
@@ -94,6 +94,7 @@ ALTER TABLE public.users
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT;
CREATE TABLE public.categories (
status varchar(16) NOT NULL DEFAULT 'draft',
id varchar(128) COLLATE "C" PRIMARY KEY,
name varchar(100) NOT NULL,
icon varchar(1024) NOT NULL,
@@ -107,9 +108,13 @@ CREATE TABLE public.categories (
REFERENCES public.categories (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT categories_icon_asset_id_fkey FOREIGN KEY (icon_asset_id)
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT categories_status_check CHECK (status IN ('draft', 'active', 'hidden')),
CONSTRAINT categories_parent_not_self_check CHECK (parent_id IS NULL OR parent_id <> id)
);
CREATE INDEX categories_status_parent_sort_idx ON public.categories (status, parent_id, sort_order, id);
COMMENT ON COLUMN public.categories.status IS '发布状态:draft 开发测试,active 上线,hidden 下架;公开访问还需所有上级分类 active';
CREATE INDEX categories_parent_sort_idx ON public.categories (parent_id, sort_order, id);
CREATE TABLE public.worksheets (
@@ -0,0 +1,9 @@
-- 既有分类保留当前公开行为;以后新建分类默认草稿。不要重跑已成功的增量迁移。
BEGIN;
ALTER TABLE public.categories ADD COLUMN status varchar(16) NOT NULL DEFAULT 'draft';
UPDATE public.categories SET status = 'active';
ALTER TABLE public.categories ADD CONSTRAINT categories_status_check
CHECK (status IN ('draft', 'active', 'hidden'));
CREATE INDEX categories_status_parent_sort_idx ON public.categories (status, parent_id, sort_order, id);
COMMENT ON COLUMN public.categories.status IS '发布状态:draft 开发测试,active 上线,hidden 下架;公开访问还需所有上级分类 active';
COMMIT;
+8 -2
View File
@@ -1,5 +1,7 @@
# v3.5 PostgreSQL 初始化 SQL
> 2026-10-15:已接入 Prisma Schema 与 `0_v35_baseline`,本地现有库仅登记基线,没有重建业务表。下文保留原手工 SQL 的执行路径和历史核查;新的空库优先使用 Prisma migrate deploy,后续升级新增迁移。详见 [Prisma 说明](../README.md)。
基于 [数据库设计](../../../../docs/v3.5/数据库设计文档.md),面向 PostgreSQL 18。缩进统一 4 个空格;文件是普通 PostgreSQL SQL,可使用 psql 或数据库客户端执行。
2026-10-12 已在本地 PostgreSQL 18.6 临时数据库执行 001/002/003,并重复执行 003 验证不覆盖 enabled=false;10 张表与 19 项关键约束检查通过,历史 NULL 事件、标识大小写及资产所有者删除行为符合设计。验证库已删除;随后已在用户授权下正式初始化 doodle_dev,10 张表与两条配置记录核对通过。
@@ -28,7 +30,7 @@ Docker 已在首次启动时创建 doodle_dev,**当前本地跳过 001**。其
| 已执行部分增量的库 | 按编号顺序执行尚未应用的后续文件 |
| 已执行 008 的库 | 无需再次执行 DDL |
004~008 是历史结构的升级文件,不能用于已执行最新 002 的空库基线。下文各阶段记录解释该次变更,不表示只完成其中某一步就已到最新结构。Prisma 迁移历史尚未接入,执行前依据实际结构及执行记录确认当前版本。
004~008 是历史结构的升级文件,不能用于已执行最新 002 的空库基线。下文各阶段记录解释该次变更,不表示只完成其中某一步就已到最新结构。当前已接入 Prisma 迁移基线,旧手工库在登记前依据实际结构及执行记录确认当前版本。
## 空库初始化示例
@@ -64,7 +66,7 @@ SELECT id, value FROM public.settings;
- 下载 NULL exportId 只允许 import 来源;非空事件按 userId + exportId 去重。计数不会通过触发器自动更新,由后续业务事务实现。
- tags 元素类型、分类多层循环、任务租约/锁、JWT 失效与管理员权限由后续业务实现;SQL 不代表业务 API 已完成。
- updatedAt 和 contentUpdatedAt 由服务显式维护,不添加把两者混为一谈的更新时间触发器。
- Prisma 尚未安装,Schema 和迁移历史尚未接入。后续接入时将本 SQL 纳入正式基线;已有表的库须采用基线登记流程,不能直接再次执行同一 DDL。
- Prisma Schema 和迁移历史已接入,本 SQL 已纳入正式基线;已有表的库须先核对再登记基线,不能直接再次执行同一 DDL。
## 单小程序身份简化(2026-10-14)
@@ -180,3 +182,7 @@ docker compose --env-file .env -f infra/compose.yaml exec -T postgres \
- 所有临时核查库已清理;开发库只读比对,无样本写入。本次只修正文档说明,无需新增 DDL 或再次升级开发库。
业务归属/权限、首次发布、计数和审计事务、任务调度等仍需服务端接入;SQL 验证不代替业务 API 验收。
## 分类发布状态(2026-10-16)
最新 002/003 包含分类 status,空库不再执行 009。原 004~008 及 Prisma 旧基线不改写;已有结构新增 009(或使用对应 Prisma 迁移,二者不能重复)。009 新增 status 默认 draft、三态 CHECK 和状态/父级索引,同时将既有分类置为 active 保持兼容。当前为 13 张表、136 个字段。新后端公开接口同时检查分类祖先状态,管理员状态修改使数据库页面快照失效。
+69
View File
@@ -0,0 +1,69 @@
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { createInterface } from 'node:readline';
const require = createRequire(import.meta.url);
const child = spawn(
process.execPath,
[require.resolve('@nestjs/cli/bin/nest.js'), 'start', '--watch'],
{
env: { ...process.env, DOODLE_DEV_LAUNCHER: '1' },
stdio: ['inherit', 'pipe', 'inherit'],
},
);
let opened = false;
/** 使用系统默认浏览器,不经 shell 拼接 URL;打开失败不影响开发服务继续运行。 */
function openBrowser(url) {
const command =
process.platform === 'darwin'
? 'open'
: process.platform === 'win32'
? 'explorer.exe'
: 'xdg-open';
const browser = spawn(command, [url], { stdio: 'ignore', detached: true });
const warn = () =>
process.stderr.write(`无法自动打开浏览器,请手动访问:${url}\n`);
browser.once('error', warn);
browser.once('exit', (code) => {
if (code !== 0 && code !== null) warn();
});
browser.unref();
}
// 只在本次启动器收到子服务的成功通知后打开;热更新重启不会反复弹出页面。
const lines = createInterface({ input: child.stdout });
lines.on('line', (line) => {
let event;
try {
event = JSON.parse(line);
} catch {
/* Nest 编译提示不是 JSON,原样转发。 */
}
if (event?.event !== 'doodle-dev-ready') {
process.stdout.write(`${line}\n`);
return;
}
if (!event.swaggerUrl) {
process.stdout.write('\nSwagger 已关闭(SWAGGER_ENABLED=false)。\n\n');
return;
}
process.stdout.write(`\nSwagger: ${event.swaggerUrl}\n\n`);
if (!opened) {
opened = true;
openBrowser(event.swaggerUrl);
}
});
// 退出启动器时将信号传给 Nest,保留其对子服务的正常关闭和端口释放流程。
for (const signal of ['SIGINT', 'SIGTERM'])
process.on(signal, () => child.kill(signal));
child.once('error', () => {
process.stderr.write('开发服务启动失败,请检查本地 Node.js 与项目依赖。\n');
process.exitCode = 1;
});
child.once('exit', (code, signal) => {
lines.close();
process.exitCode =
code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1);
});
+102
View File
@@ -0,0 +1,102 @@
import 'reflect-metadata';
import { createHash, randomUUID } from 'node:crypto';
import {
closeSync,
openSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { basename, resolve } from 'node:path';
import { createRequire } from 'node:module';
import { parseArgs, parseEnv } from 'node:util';
const require = createRequire(import.meta.url);
const { PrismaService } = require('../dist/database/prisma.service.js');
const { CategoryService } = require('../dist/categories/category.service.js');
const { HttpException } = require('@nestjs/common');
let database;
let reportFd;
let reportPath;
let reportWritten = false;
let committed = false;
try {
const { values } = parseArgs({
options: {
file: { type: 'string' },
apply: { type: 'boolean', default: false },
report: { type: 'string' },
},
});
if (!values.file)
throw new Error(
'请用 --file 指定分类 JSON / JSON Lines;默认只校验,--apply 才写入',
);
const file = resolve(values.file);
if (statSync(file).size > 10 * 1024 * 1024)
throw new Error('分类文件不能超过 10 MiB');
const source = readFileSync(file, 'utf8');
const sourceSha256 = createHash('sha256').update(source).digest('hex');
const local = parseEnv(
readFileSync(new URL('../.env', import.meta.url), 'utf8'),
);
// 当前工具只执行本地数据迁移;不能因为继承环境变量而意外连接远程/生产数据库。
const url = new URL(process.env.DATABASE_URL ?? local.DATABASE_URL);
if (
!['127.0.0.1', 'localhost'].includes(url.hostname) ||
url.pathname !== '/doodle_dev'
)
throw new Error('分类导入工具只允许连接本机 doodle_dev');
// 在任何数据库修改前独占创建报告文件,提前发现路径、权限或重名问题。
if (values.report) {
reportPath = resolve(values.report);
reportFd = openSync(reportPath, 'wx');
}
database = new PrismaService({ databaseUrl: url.toString() });
const service = new CategoryService(database);
const requestId = `categories-import-${randomUUID()}`;
const result = await service.importExport(
source,
!values.apply,
requestId,
`CloudBase category export sha256=${sourceSha256}; missing timestamps use batch time`,
);
committed = !result.dryRun;
const report = {
source: basename(file),
sourceSha256,
requestId,
...result,
};
const output = `${JSON.stringify(report, null, 4)}\n`;
process.stdout.write(output);
if (reportFd !== undefined) {
writeFileSync(reportFd, output);
reportWritten = true;
}
} catch (error) {
// 业务校验可说明原因;数据库异常、URL、凭据和堆栈均不输出。
const message =
error instanceof HttpException
? error.message
: error instanceof Error &&
!/(postgres|password|connection|prisma|\/\/)/i.test(
error.message,
)
? error.message
: '分类导入失败,请检查本地数据库配置和文件';
process.stderr.write(`${message}\n`);
if (committed)
process.stderr.write(
'数据库事务已提交;报告保存失败,具体批次见上方输出及数据库审计。\n',
);
process.exitCode = 1;
} finally {
if (reportFd !== undefined) {
closeSync(reportFd);
if (!reportWritten) unlinkSync(reportPath);
}
await database?.onModuleDestroy();
}
+98
View File
@@ -0,0 +1,98 @@
import 'reflect-metadata';
import { createHash, randomUUID } from 'node:crypto';
import {
closeSync,
openSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { basename, resolve } from 'node:path';
import { createRequire } from 'node:module';
import { parseArgs, parseEnv } from 'node:util';
const require = createRequire(import.meta.url);
const { PrismaService } = require('../dist/database/prisma.service.js');
const { WorksheetImporter } = require('../dist/imports/worksheet-import.js');
const { HttpException } = require('@nestjs/common');
let database;
let reportFd;
let reportPath;
let reportWritten = false;
let committed = false;
try {
const { values } = parseArgs({
options: {
file: { type: 'string' },
apply: { type: 'boolean', default: false },
'metadata-only': { type: 'boolean', default: false },
report: { type: 'string' },
},
});
if (!values.file)
throw new Error('请用 --file 指定题型导出;默认预演,--apply 才写库');
const file = resolve(values.file);
if (statSync(file).size > 10 * 1024 * 1024)
throw new Error('题型文件不能超过 10 MiB');
const source = readFileSync(file, 'utf8');
const sourceSha256 = createHash('sha256').update(source).digest('hex');
const local = parseEnv(
readFileSync(new URL('../.env', import.meta.url), 'utf8'),
);
const url = new URL(process.env.DATABASE_URL ?? local.DATABASE_URL);
// 元数据暂存仅限本地开发库,不能带着未就绪的预览图导入到生产。
if (
!['127.0.0.1', 'localhost'].includes(url.hostname) ||
url.pathname !== '/doodle_dev'
)
throw new Error('题型导入工具只允许本机 doodle_dev');
if (values.report) {
reportPath = resolve(values.report);
reportFd = openSync(reportPath, 'wx');
}
database = new PrismaService({ databaseUrl: url.toString() });
const requestId = `worksheets-import-${randomUUID()}`;
const result = await new WorksheetImporter(database).importExport(source, {
dryRun: !values.apply,
metadataOnly: values['metadata-only'],
requestId,
sourceSha256,
});
committed = !result.dryRun;
const report = {
source: basename(file),
sourceSha256,
requestId,
...result,
};
const output = `${JSON.stringify(report, null, 4)}\n`;
process.stdout.write(output);
if (reportFd !== undefined) {
writeFileSync(reportFd, output);
reportWritten = true;
}
} catch (error) {
const message =
error instanceof HttpException
? error.message
: error instanceof Error &&
!/(postgres|password|connection|prisma|\/\/)/i.test(
error.message,
)
? error.message
: '题型导入失败,请检查本地数据库配置和文件';
process.stderr.write(`${message}\n`);
if (committed)
process.stderr.write(
'数据库事务已提交;报告保存失败,批次见上方输出及审计。\n',
);
process.exitCode = 1;
} finally {
if (reportFd !== undefined) {
closeSync(reportFd);
if (!reportWritten) unlinkSync(reportPath);
}
await database?.onModuleDestroy();
}
+84
View File
@@ -0,0 +1,84 @@
import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { parseEnv } from 'node:util';
import { spawnSync } from 'node:child_process';
import pg from 'pg';
// 只使用本机开发库凭据创建独立短期库;绝不在 doodle_dev 中清空或写入测试用户。
const serverRoot = fileURLToPath(new URL('../', import.meta.url));
const local = parseEnv(
readFileSync(new URL('../.env', import.meta.url), 'utf8'),
);
const url = new URL(local.DATABASE_URL);
if (
!['127.0.0.1', 'localhost'].includes(url.hostname) ||
url.pathname !== '/doodle_dev'
)
throw new Error('数据库集成测试只允许本机 doodle_dev 实例创建隔离库');
const database = `doodle_auth_test_${randomUUID().replaceAll('-', '')}`;
const admin = new pg.Pool({
connectionString: url.toString(),
max: 1,
connectionTimeoutMillis: 3000,
});
const testUrl = new URL(url);
testUrl.pathname = `/${database}`;
const env = {
...process.env,
DATABASE_URL: testUrl.toString(),
TEST_DATABASE_URL: testUrl.toString(),
};
let created = false;
function run(args) {
const result = spawnSync('pnpm', args, {
cwd: serverRoot,
env,
encoding: 'utf8',
timeout: 120_000,
});
for (const output of [result.stdout, result.stderr])
if (output)
process.stdout.write(
output
.replaceAll(testUrl.toString(), '[test-database-url]')
.replaceAll(decodeURIComponent(url.password), '[redacted]'),
);
if (result.status !== 0) throw new Error('数据库测试子任务失败');
}
try {
await admin.query(
`CREATE DATABASE "${database}" WITH TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'C'`,
);
created = true;
run(['exec', 'prisma', 'migrate', 'deploy']);
run(['exec', 'tsc', '-p', 'tsconfig.test.json']);
run(['exec', 'node', '--test', 'dist-test/test/auth-database.test.js']);
run(['exec', 'node', '--test', 'dist-test/test/category-database.test.js']);
run([
'exec',
'node',
'--test',
'dist-test/test/worksheet-import-database.test.js',
]);
run([
'exec',
'node',
'--test',
'dist-test/test/worksheet-database.test.js',
]);
run([
'exec',
'node',
'--test',
'dist-test/test/content-publication-database.test.js',
]);
} finally {
if (created) {
await admin.query(`DROP DATABASE "${database}"`);
console.log('临时数据库已删除,开发库业务数据未改动。');
}
await admin.end();
}
+3 -1
View File
@@ -1,10 +1,12 @@
import { Module, type DynamicModule } from '@nestjs/common';
import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module';
import { CategoryModule } from './categories/category.module';
import { WorksheetModule } from './worksheets/worksheet.module';
import { ServerConfigModule, type ServerConfig } from './config/server-config';
@Module({
imports: [AuthModule],
imports: [AuthModule, CategoryModule, WorksheetModule],
controllers: [HealthController],
})
export class AppModule {
+7 -2
View File
@@ -33,6 +33,9 @@ export function configureApplication(
const startedAt = performance.now();
request.requestId = requestId;
response.setHeader('X-Request-Id', requestId);
// 登录和本人接口的成功/失败均禁止缓存,避免凭证或个人数据进入公共缓存。
if (/^\/api\/v1\/(auth|me)(?:\/|$)/.test(request.path))
response.setHeader('Cache-Control', 'no-store');
// 响应完成后记录状态与耗时;只记录 path,排除 URL 查询中的登录 code/token。
response.once('finish', () =>
logger.log('http_request_completed', {
@@ -57,7 +60,7 @@ export function configureApplication(
credentials: false,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Id'],
exposedHeaders: ['X-Request-Id'],
exposedHeaders: ['X-Request-Id', 'Retry-After'],
});
// 应用工厂已禁用 Nest 默认解析器,此处统一限制 JSON 和表单请求大小。
// 表单不解析嵌套对象,上传文件另由后续 assets 模块处理。
@@ -84,7 +87,9 @@ export function configureApplication(
if (config.swaggerEnabled) {
const options = new DocumentBuilder()
.setTitle('Doodle Store API')
.setDescription('统一业务 API;当前仅健康检查,业务接口逐步接入')
.setDescription(
'统一业务 API;已接入微信静默登录、本人资料、账户鉴权与分类查询/管理',
)
.setVersion('0.1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
+10 -11
View File
@@ -2,7 +2,6 @@ import {
ForbiddenException,
Inject,
Injectable,
UnauthorizedException,
type CanActivate,
type ExecutionContext,
} from '@nestjs/common';
@@ -11,6 +10,8 @@ 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';
import { ApiException } from '../common/api-exception';
import { requireCurrentAccount } from './account-policy';
@Injectable()
/** 全局访问守卫:JWT 验签 → 当前账户核对 → 服务端角色检查。 */
@@ -35,19 +36,17 @@ export class AccessTokenGuard implements CanActivate {
: null;
const token = match?.[1];
// 先限制 token 长度,再进行签名解析,避免无意义的超长输入消耗资源。
if (!token || token.length > 8192) throw new UnauthorizedException();
if (!authorization) throw new ApiException(401, 'AUTH_REQUIRED');
if (!token || token.length > 8192)
throw new ApiException(401, 'AUTH_INVALID');
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();
}
const account = requireCurrentAccount(
await this.accounts.findById(claims.sub),
claims.sub,
claims.authVersion,
);
// 后续控制器共享已核对身份,角色取自当前账户,而非客户端或 token 自报值。
request.auth = {
userId: account.id,
+17
View File
@@ -0,0 +1,17 @@
import type { Account } from './account-reader';
import { ApiException } from '../common/api-exception';
/** 请求身份必须与当前账户一致;读后再变更的事务需要再次调用本校验。 */
export function requireCurrentAccount(
account: Account | null,
userId: string,
authVersion: number,
): Account {
if (!account || account.id !== userId)
throw new ApiException(401, 'ACCOUNT_NOT_FOUND');
if (account.status !== 'active')
throw new ApiException(401, 'ACCOUNT_DISABLED');
if (account.authVersion !== authVersion)
throw new ApiException(401, 'AUTH_REVOKED');
return account;
}
+1 -12
View File
@@ -1,6 +1,4 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
/** 用户模块通过此注入标识替换账户查询实现,JWT 模块不直接依赖数据库模型。 */
/** 账户读取注入标识,JWT 守卫不直接依赖数据库模型。 */
export const ACCOUNT_READER = Symbol('ACCOUNT_READER');
/** 鉴权所需的最小账户快照,不包含昵称、头像或微信身份信息。 */
export interface Account {
@@ -13,12 +11,3 @@ export interface Account {
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();
}
}
+152
View File
@@ -0,0 +1,152 @@
import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import type {
CurrentUserProfile,
UserSummary,
WechatLoginMode,
} from '@doodle/contracts';
import type { Prisma } from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import { ApiException } from '../common/api-exception';
import type { Account, AccountReader } from './account-reader';
import type { WechatIdentity } from './wechat-client';
import { requireCurrentAccount } from './account-policy';
import type { AuthenticatedUser } from '../common/http-request';
const accountSelect = {
id: true,
status: true,
role: true,
authVersion: true,
} as const;
const profileSelect = {
...accountSelect,
unionid: true,
nickName: true,
createdAt: true,
} as const;
type StoredAccount = Prisma.UserGetPayload<{ select: typeof accountSelect }>;
type StoredProfile = Prisma.UserGetPayload<{ select: typeof profileSelect }>;
@Injectable()
/** OpenID 唯一约束保证只建一人;账户行锁串行化登录与状态/资料变更。 */
export class AccountRepository implements AccountReader {
private readonly logger = new Logger(AccountRepository.name);
constructor(private readonly database: PrismaService) {}
async findById(userId: string): Promise<Account | null> {
try {
const user = await this.database.client.user.findUnique({
where: { id: userId },
select: accountSelect,
});
return user ? this.toAccount(user) : null;
} catch {
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
/** 先由调用方完成微信兑换;事务内没有外部 HTTP,普通登录不撤销其他设备凭证。 */
async login(
identity: WechatIdentity,
mode: WechatLoginMode,
): Promise<{ account: Account; user: UserSummary }> {
try {
return await this.database.client.$transaction(
async (tx) => {
if (mode === 'start') {
// 显式 DO NOTHING,不让并发首次登录覆盖既有账户的角色、禁用状态或资料。
await tx.$executeRaw`
INSERT INTO public.users (id, openid, unionid)
VALUES (${randomUUID()}, ${identity.openid}, ${identity.unionid})
ON CONFLICT (openid) DO NOTHING
`;
}
const rows = await tx.$queryRaw<Array<{ id: string }>>`
SELECT id FROM public.users WHERE openid = ${identity.openid} FOR UPDATE
`;
const row = rows[0];
if (!row) throw new ApiException(401, 'ACCOUNT_NOT_FOUND');
const stored = await tx.user.findUniqueOrThrow({
where: { id: row.id },
select: profileSelect,
});
const account = this.toAccount(stored);
if (account.status !== 'active')
throw new ApiException(401, 'ACCOUNT_DISABLED');
const now = new Date();
const addUnionId =
!stored.unionid && identity.unionid !== null;
if (
stored.unionid &&
identity.unionid &&
stored.unionid !== identity.unionid
)
this.logger.warn('wechat_unionid_mismatch');
await tx.user.update({
where: { id: row.id },
data: {
lastActiveAt: now,
...(addUnionId
? { unionid: identity.unionid, updatedAt: now }
: {}),
},
select: { id: true },
});
return { account, user: this.toSummary(stored) };
},
{ maxWait: 5000, timeout: 10_000 },
);
} catch (error) {
if (error instanceof ApiException) throw error;
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
/** 再读一次当前账户,防止守卫通过后资料查询之间的注销/版本变化被忽略。 */
async getProfile(identity: AuthenticatedUser): Promise<CurrentUserProfile> {
try {
const user = await this.database.client.user.findUnique({
where: { id: identity.userId },
select: profileSelect,
});
const account = requireCurrentAccount(
user ? this.toAccount(user) : null,
identity.userId,
identity.authVersion,
);
return { ...this.toSummary(user!), role: account.role };
} catch (error) {
if (error instanceof ApiException) throw error;
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
private toAccount(user: StoredAccount): Account {
if (
!['user', 'admin'].includes(user.role) ||
!['active', 'disabled'].includes(user.status) ||
!Number.isSafeInteger(user.authVersion) ||
user.authVersion < 0
)
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
return {
id: user.id,
authVersion: user.authVersion,
role: user.role as Account['role'],
status: user.status as Account['status'],
};
}
private toSummary(user: StoredProfile): UserSummary {
// 七牛签名尚未接入,明确返回空头像,不下发对象 key、私有地址或任意外链。
return {
id: user.id,
nickName: user.nickName,
avatarUrl: null,
avatarUrlExpiresAt: null,
createdAt: user.createdAt.toISOString(),
};
}
}
+17 -5
View File
@@ -1,21 +1,33 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { ACCOUNT_READER, UnconfiguredAccountReader } from './account-reader';
import { ACCOUNT_READER } from './account-reader';
import { AccessTokenGuard } from './access-token.guard';
import { JwtTokenService } from './jwt-token.service';
import { DatabaseModule } from '../database/database.module';
import { AccountRepository } from './account.repository';
import { WECHAT_FETCH, WechatClient } from './wechat-client';
import { LoginRateLimiter } from './login-rate-limiter';
import { LoginService } from './login.service';
import { LoginController } from './login.controller';
import { CurrentUserController } from './current-user.controller';
@Module({
// 签名和验证参数由 JwtTokenService 统一提供,避免不同调用点使用不同策略。
imports: [JwtModule.register({})],
imports: [JwtModule.register({}), DatabaseModule],
controllers: [LoginController, CurrentUserController],
providers: [
JwtTokenService,
// 用户模块接入 Prisma 后替换此 provider;当前有效 token 也不能绕过账户检查。
{ provide: ACCOUNT_READER, useClass: UnconfiguredAccountReader },
AccountRepository,
{ provide: ACCOUNT_READER, useExisting: AccountRepository },
{ provide: WECHAT_FETCH, useValue: fetch },
WechatClient,
LoginRateLimiter,
LoginService,
// 注册为全局守卫,新路由默认受保护,无需逐个添加 @UseGuards。
{ provide: APP_GUARD, useClass: AccessTokenGuard },
],
exports: [JwtTokenService, ACCOUNT_READER],
})
/** JWT 与鉴权基础设施;本模块尚不提供微信登录或公开 token 签发接口。 */
/** 微信静默登录、本人读取与全局鉴权;身份始终来自可信微信兑换或本站凭证。 */
export class AuthModule {}
@@ -0,0 +1,25 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ApiSuccess, CurrentUserProfile } from '@doodle/contracts';
import { CurrentUser } from './auth.decorators';
import type { AuthenticatedUser } from '../common/http-request';
import { AccountRepository } from './account.repository';
@Controller('me')
@ApiTags('users')
export class CurrentUserController {
constructor(private readonly accounts: AccountRepository) {}
@Get()
@ApiOperation({ summary: '获取当前有效账户的本人资料' })
/** 仅从守卫写入的身份读取本人,拒绝由 query/body 指定其他账户。 */
async getMe(
@CurrentUser() user: AuthenticatedUser,
): Promise<ApiSuccess<CurrentUserProfile>> {
return {
code: 0,
message: 'ok',
data: await this.accounts.getProfile(user),
};
}
}
+26 -4
View File
@@ -1,6 +1,8 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
import type { WechatLoginResult } from '@doodle/contracts';
import { ApiException } from '../common/api-exception';
/** 最小身份载荷;authVersion 用于与账户当前版本比较,不在 token 中固化角色。 */
export interface AccessTokenClaims {
@@ -43,6 +45,21 @@ export class JwtTokenService {
);
}
/** 响应期限从已签发的实际 claims 取得,避免两个时钟计算出不一致的有效期。 */
async issueAccessToken(
userId: string,
authVersion: number,
): Promise<Omit<WechatLoginResult, 'user'>> {
const accessToken = await this.signAccessToken(userId, authVersion);
const claims = await this.verifyAccessToken(accessToken);
return {
accessToken,
tokenType: 'Bearer',
expiresIn: claims.exp - claims.iat,
expiresAt: new Date(claims.exp * 1000).toISOString(),
};
}
/** 验签及校验载荷;当前账户状态与权限仍由全局守卫继续核对。 */
async verifyAccessToken(token: string): Promise<AccessTokenClaims> {
try {
@@ -70,9 +87,14 @@ export class JwtTokenService {
throw new Error('Invalid access token claims');
}
return claims;
} catch {
// 过期、伪造或载荷错误统一返回 401,不向客户端暴露验签细节。
throw new UnauthorizedException();
} catch (error) {
// 只公开客户端恢复需要的有限原因,不泄漏验签细节、token 或原始异常。
throw new ApiException(
401,
error instanceof Error && error.name === 'TokenExpiredError'
? 'AUTH_EXPIRED'
: 'AUTH_INVALID',
);
}
}
}
@@ -0,0 +1,51 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { ApiException } from '../common/api-exception';
@Injectable()
/** 单实例固定窗口限流;有界存储,容量耗尽拒绝新来源而不驱逐仍有效的窗口。 */
export class LoginRateLimiter {
private readonly windows = new Map<
string,
{ count: number; resetAt: number }
>();
private readonly windowMs = 60_000;
private readonly maximumKeys = 10_000;
checkIp(ip: string): void {
this.consume(`ip:${ip}`, 30);
}
checkIdentity(openid: string): void {
// 限流键不保存原始微信身份;同一身份最多每分钟恢复 10 次。
this.consume(
`identity:${createHash('sha256').update(openid).digest('hex')}`,
10,
);
}
private consume(key: string, limit: number): void {
const now = Date.now();
let entry = this.windows.get(key);
if (!entry || entry.resetAt <= now) {
if (this.windows.size >= this.maximumKeys) {
for (const [candidate, value] of this.windows)
if (value.resetAt <= now) this.windows.delete(candidate);
if (
this.windows.size >= this.maximumKeys &&
!this.windows.has(key)
)
throw new ApiException(429, 'RATE_LIMITED', 60);
}
entry = { count: 0, resetAt: now + this.windowMs };
this.windows.set(key, entry);
}
if (entry.count >= limit)
throw new ApiException(
429,
'RATE_LIMITED',
Math.max(1, Math.ceil((entry.resetAt - now) / 1000)),
);
entry.count += 1;
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Body, Controller, HttpCode, Post, Req } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import type { ApiSuccess, WechatLoginResult } from '@doodle/contracts';
import { Public } from './auth.decorators';
import { WechatLoginDto } from './login.dto';
import { LoginService } from './login.service';
import type { HttpRequest } from '../common/http-request';
@Controller('auth/wechat')
@ApiTags('auth')
export class LoginController {
constructor(private readonly login: LoginService) {}
@Public()
@Post('login')
@HttpCode(200)
@ApiOperation({ summary: '小程序微信静默登录', security: [] })
@ApiResponse({
status: 200,
description: '返回本站 JWT 和用户摘要,不返回微信身份或密钥',
})
@ApiResponse({ status: 401, description: '账户不存在、已禁用' })
@ApiResponse({ status: 429, description: '登录过于频繁;参见 Retry-After' })
/** IP 只取 Express 已按可信代理配置解析的来源,不使用客户端自报转发头。 */
async signIn(
@Body() input: WechatLoginDto,
@Req() request: HttpRequest,
): Promise<ApiSuccess<WechatLoginResult>> {
return {
code: 0,
message: 'ok',
data: await this.login.login(input, request.ip ?? 'unknown'),
};
}
}
+23
View File
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsString, Matches, MaxLength, MinLength } from 'class-validator';
import type { WechatLoginMode, WechatLoginRequest } from '@doodle/contracts';
/** 公开接口只接受微信临时 code 和创建意图,拒绝客户端自报账户和应用身份。 */
export class WechatLoginDto implements WechatLoginRequest {
@ApiProperty({
description: '本次 wx.login 返回的一次性 code',
maxLength: 512,
})
@IsString()
@MinLength(1)
@MaxLength(512)
@Matches(/^\S+$/)
code!: string;
@ApiProperty({
enum: ['start', 'resume'],
description: 'start 允许首次建号,resume 只恢复已有账户',
})
@IsIn(['start', 'resume'])
mode!: WechatLoginMode;
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import type { WechatLoginRequest, WechatLoginResult } from '@doodle/contracts';
import { WechatClient } from './wechat-client';
import { AccountRepository } from './account.repository';
import { JwtTokenService } from './jwt-token.service';
import { LoginRateLimiter } from './login-rate-limiter';
@Injectable()
export class LoginService {
constructor(
private readonly wechat: WechatClient,
private readonly accounts: AccountRepository,
private readonly tokens: JwtTokenService,
private readonly limiter: LoginRateLimiter,
) {}
/** 外部身份验证后再开启账户事务;签发发生在提交后,不对客户端提供任意身份签发能力。 */
async login(
input: WechatLoginRequest,
ip: string,
): Promise<WechatLoginResult> {
this.limiter.checkIp(ip);
const identity = await this.wechat.exchangeCode(input.code);
this.limiter.checkIdentity(identity.openid);
const { account, user } = await this.accounts.login(
identity,
input.mode,
);
return {
...(await this.tokens.issueAccessToken(
account.id,
account.authVersion,
)),
user,
};
}
}
+85
View File
@@ -0,0 +1,85 @@
import { Inject, Injectable } from '@nestjs/common';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
import { ApiException } from '../common/api-exception';
/** 只有微信服务端成功验证后才构造,不能从公开 DTO 直接转换。 */
export interface WechatIdentity {
openid: string;
unionid: string | null;
}
/** HTTP 边界便于测试替换;正式实现始终只请求固定微信 HTTPS 地址。 */
export const WECHAT_FETCH = Symbol('WECHAT_FETCH');
export type WechatFetch = typeof fetch;
@Injectable()
export class WechatClient {
constructor(
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
@Inject(WECHAT_FETCH) private readonly http: WechatFetch,
) {}
/** 一次 code 只兑换一次,不自动重试;超时后客户端需重新取得 code。 */
async exchangeCode(code: string): Promise<WechatIdentity> {
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
url.search = new URLSearchParams({
appid: this.config.wechatMiniAppId,
secret: this.config.wechatMiniAppSecret,
js_code: code,
grant_type: 'authorization_code',
}).toString();
try {
const response = await this.http(url, {
method: 'GET',
redirect: 'error',
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error('Wechat HTTP failure');
const payload: unknown = await response.json();
if (
!payload ||
typeof payload !== 'object' ||
Array.isArray(payload)
)
throw new Error('Invalid Wechat response');
const data = payload as Record<string, unknown>;
if (data.errcode !== undefined && data.errcode !== 0) {
if (data.errcode === 40029 || data.errcode === 40163)
throw new ApiException(400, 'WECHAT_CODE_INVALID');
if (data.errcode === 40226)
throw new ApiException(403, 'WECHAT_LOGIN_BLOCKED');
if (data.errcode === 45011)
throw new ApiException(429, 'RATE_LIMITED', 60);
throw new ApiException(503, 'AUTH_UPSTREAM_UNAVAILABLE');
}
if (
!this.isIdentifier(data.openid) ||
(data.unionid != null &&
data.unionid !== '' &&
!this.isIdentifier(data.unionid))
)
throw new Error('Invalid Wechat identity');
// session_key 仅用于微信数据加解密;本链路不使用、不保存、不下发。
return {
openid: data.openid,
unionid:
typeof data.unionid === 'string' && data.unionid
? data.unionid
: null,
};
} catch (error) {
if (error instanceof ApiException) throw error;
// 网络异常可能包含带 AppSecret 的 URL,禁止记录或传播异常原文。
throw new ApiException(503, 'AUTH_UPSTREAM_UNAVAILABLE');
}
}
private isIdentifier(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= 128 &&
!/\s/.test(value)
);
}
}
@@ -0,0 +1,81 @@
import { BadRequestException } from '@nestjs/common';
import {
categoryKeys,
categoryObject,
normalizeCategory,
requireCategoryKeys,
type CategoryFields,
} from './category-input';
export interface CategoryImportRow extends Omit<CategoryFields, 'status'> {
status?: CategoryFields['status'];
createdAt?: Date;
updatedAt?: Date;
}
/** 兼容 CloudBase JSON Lines 和 JSON 数组;整批校验后才能进入写入事务。 */
export function parseCategoryExport(source: string): CategoryImportRow[] {
let values: unknown[];
try {
const content = source.replace(/^\uFEFF/, '').trim();
if (!content) throw new Error('empty');
const parsed: unknown = content.startsWith('[')
? JSON.parse(content)
: content
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
if (
!Array.isArray(parsed) ||
parsed.length === 0 ||
parsed.length > 10_000
)
throw new Error('size');
values = parsed;
} catch {
throw new BadRequestException(
'分类文件须为非空 JSON 数组或 JSON Lines,最多 10000 条',
);
}
const ids = new Set<string>();
return values.map((value) => {
const row = categoryObject(value);
requireCategoryKeys(row, [
...categoryKeys,
'_id',
'createdAt',
'updatedAt',
]);
if (row._id !== undefined && row.id !== undefined && row._id !== row.id)
throw new BadRequestException('id 与 _id 不一致');
const fields = normalizeCategory({ ...row, id: row._id ?? row.id });
if (ids.has(fields.id)) throw new BadRequestException('分类 ID 重复');
ids.add(fields.id);
const time = (key: string): Date | undefined => {
const raw = row[key];
if (raw === undefined) return undefined;
// 明确时区,避免导入机器时区改变历史时间。拒绝 CloudBase 未识别的日期对象。
if (
typeof raw !== 'string' ||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(
raw,
) ||
!Number.isFinite(Date.parse(raw))
)
throw new BadRequestException('分类时间须为带时区的 ISO 时间');
return new Date(raw);
};
const createdAt = time('createdAt');
const updatedAt = time('updatedAt');
if (createdAt && updatedAt && updatedAt < createdAt)
throw new BadRequestException('分类更新时间不能早于创建时间');
// 旧导出没有状态:新记录为草稿,重导时由服务保留数据库当前状态。
const { status, ...rest } = fields;
return {
...rest,
...(row.status === undefined ? {} : { status }),
...(createdAt ? { createdAt } : {}),
...(updatedAt ? { updatedAt } : {}),
};
});
}
@@ -0,0 +1,121 @@
import { BadRequestException } from '@nestjs/common';
import type { ContentStatus } from '@doodle/contracts';
import { contentStatus } from '../content/content-policy';
export interface CategoryFields {
status: ContentStatus;
id: string;
name: string;
icon: string;
color: string;
sortOrder: number;
parentId: string | null;
}
export const categoryKeys = [
'id',
'status',
'name',
'icon',
'color',
'sortOrder',
'parentId',
];
/** API 与导入共用严格校验;不将数字/布尔值隐式转成名称或 ID。 */
export function categoryObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new BadRequestException('分类必须为对象');
return value as Record<string, unknown>;
}
export function requireCategoryKeys(
value: Record<string, unknown>,
allowed: string[],
): void {
if (Object.keys(value).some((key) => !allowed.includes(key)))
throw new BadRequestException('分类包含未知字段');
}
export function categoryId(value: unknown): string {
if (
typeof value !== 'string' ||
[...value].length > 128 ||
!/^[^\s/]+$/u.test(value) ||
[...value].some(
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
)
)
throw new BadRequestException('分类 ID 不合法');
return value;
}
/** 文件图标需等资产迁移/签名模块完成,当前只接受文字、Emoji 和图标名。 */
export function normalizeCategory(
input: Record<string, unknown>,
): CategoryFields {
const text = (key: string, max: number): string => {
const value = input[key];
if (
typeof value !== 'string' ||
!value.trim() ||
[...value].length > max ||
value.includes('\0')
)
throw new BadRequestException(`分类 ${key} 不合法`);
return value.trim();
};
const icon = text('icon', 1024);
if (/^(?:https?:|cloud:|data:|file:|\/)/i.test(icon))
throw new BadRequestException('文件图标需通过资产模块绑定');
const sortOrder = input.sortOrder === undefined ? 0 : input.sortOrder;
if (
typeof sortOrder !== 'number' ||
!Number.isInteger(sortOrder) ||
sortOrder < -2147483648 ||
sortOrder > 2147483647
)
throw new BadRequestException('分类排序必须为 PostgreSQL integer');
const parent = input.parentId;
return {
status: contentStatus(
input.status === undefined ? 'draft' : input.status,
),
id: categoryId(input.id),
name: text('name', 100),
icon,
color: text('color', 32),
sortOrder,
parentId:
parent === undefined || parent === null || parent === ''
? null
: categoryId(parent),
};
}
/** 检查合并后的整棵树;迭代遍历避免深层导入触发调用栈溢出,并返回父先子后顺序。 */
export function categoryTreeOrder(
rows: Array<{ id: string; parentId: string | null }>,
): string[] {
const parents = new Map(rows.map((row) => [row.id, row.parentId]));
const done = new Set<string>();
const order: string[] = [];
for (const row of rows) {
let id: string | null = row.id;
const visiting = new Set<string>();
const path: string[] = [];
while (id !== null && !done.has(id)) {
if (!parents.has(id)) throw new BadRequestException('父分类不存在');
if (visiting.has(id))
throw new BadRequestException('分类不能形成循环引用');
visiting.add(id);
path.push(id);
id = parents.get(id)!;
}
for (const item of path.reverse()) {
done.add(item);
order.push(item);
}
}
return order;
}
@@ -0,0 +1,122 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Req,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ApiSuccess, Category } from '@doodle/contracts';
import { CurrentUser, Public, Roles } from '../auth/auth.decorators';
import type { AuthenticatedUser, HttpRequest } from '../common/http-request';
import { CategoryService } from './category.service';
import { CreateCategoryDto, UpdateCategoryDto } from './category.dto';
@Controller('categories')
@ApiTags('categories')
/** 分类查询供小程序和未来 Web 共用,不要求静默登录先完成。 */
export class CategoryController {
constructor(private readonly categories: CategoryService) {}
@Public()
@Get()
@ApiOperation({
summary: '已上线分类,所有上级须已上线;按 sortOrder、id 升序',
security: [],
})
async list(): Promise<ApiSuccess<Category[]>> {
return { code: 0, message: 'ok', data: await this.categories.list() };
}
@Public()
@Get(':id')
@ApiOperation({ summary: '分类详情', security: [] })
async get(@Param('id') id: string): Promise<ApiSuccess<Category>> {
return { code: 0, message: 'ok', data: await this.categories.get(id) };
}
}
@Controller('admin/categories')
@ApiTags('admin/categories')
@Roles('admin')
/** 分类新增、修改、删除统一要求管理员;权限取服务端当前账户,不相信客户端自报角色。 */
export class AdminCategoryController {
constructor(private readonly categories: CategoryService) {}
@Get()
@ApiOperation({ summary: '全部状态分类(管理员,包含草稿和下架)' })
async list(): Promise<ApiSuccess<Category[]>> {
return {
code: 0,
message: 'ok',
data: await this.categories.listForAdmin(),
};
}
@Get(':id')
@ApiOperation({ summary: '分类管理预览(管理员)' })
async get(@Param('id') id: string): Promise<ApiSuccess<Category>> {
return {
code: 0,
message: 'ok',
data: await this.categories.getForAdmin(id),
};
}
@Post()
@ApiOperation({ summary: '创建分类(管理员)' })
async create(
@Body() input: CreateCategoryDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: HttpRequest,
): Promise<ApiSuccess<Category>> {
return {
code: 0,
message: 'ok',
data: await this.categories.create(
input,
actor,
request.requestId!,
),
};
}
@Patch(':id')
@ApiOperation({ summary: '局部更新分类(管理员,ID 不可修改)' })
async update(
@Param('id') id: string,
@Body() input: UpdateCategoryDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: HttpRequest,
): Promise<ApiSuccess<Category>> {
return {
code: 0,
message: 'ok',
data: await this.categories.update(
id,
input,
actor,
request.requestId!,
),
};
}
@Delete(':id')
@ApiOperation({
summary: '删除分类(管理员,有子分类或题型引用时返回 409)',
})
async remove(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: HttpRequest,
): Promise<ApiSuccess<Pick<Category, 'id'>>> {
return {
code: 0,
message: 'ok',
data: await this.categories.remove(id, actor, request.requestId!),
};
}
}
@@ -0,0 +1,55 @@
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Allow } from 'class-validator';
import type { ContentStatus, CreateCategoryRequest } from '@doodle/contracts';
/** 白名单和文档在此声明,字段及父子树校验统一由 category-input 完成,导入使用同一规则。 */
export class CreateCategoryDto implements CreateCategoryRequest {
@ApiPropertyOptional({
enum: ['draft', 'active', 'hidden'],
default: 'draft',
})
@Allow()
status?: ContentStatus;
@ApiProperty({ maxLength: 128, example: 'math' })
@Allow()
id!: string;
@ApiProperty({ maxLength: 100, example: '数感启蒙' })
@Allow()
name!: string;
@ApiProperty({
maxLength: 1024,
example: '📐',
description: '当前支持文字、Emoji 或图标名',
})
@Allow()
icon!: string;
@ApiProperty({ maxLength: 32, example: '#CDB4DB' })
@Allow()
color!: string;
@ApiPropertyOptional({ type: 'integer', default: 0 })
@Allow()
sortOrder?: number;
@ApiPropertyOptional({
type: String,
nullable: true,
description: 'null 或空串表示根分类',
})
@Allow()
parentId?: string | null;
}
/** ID 是稳定引用,更新路径只允许修改展示与父级字段。 */
export class UpdateCategoryDto extends PartialType(
OmitType(CreateCategoryDto, ['id'] as const),
) {}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import {
AdminCategoryController,
CategoryController,
} from './category.controller';
import { CategoryService } from './category.service';
/** 小程序与 Web 共享的分类模块。 */
@Module({
imports: [DatabaseModule],
controllers: [CategoryController, AdminCategoryController],
providers: [CategoryService],
})
export class CategoryModule {}
@@ -0,0 +1,430 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
HttpException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Category } from '@doodle/contracts';
import {
Prisma,
type Category as StoredCategory,
} from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import { ApiException } from '../common/api-exception';
import type { AuthenticatedUser } from '../common/http-request';
import {
visibleCategoryIds,
lockContentAdmin,
invalidateContentSnapshots,
} from '../content/content-policy';
import { contentStatus } from '../content/content-policy';
import {
categoryId,
categoryKeys,
categoryObject,
categoryTreeOrder,
normalizeCategory,
requireCategoryKeys,
type CategoryFields,
} from './category-input';
import { parseCategoryExport } from './category-import';
export interface CategoryImportReport {
dryRun: boolean;
batchAt: string;
total: number;
inserted: number;
updated: number;
unchanged: number;
missingCreatedAt: number;
missingUpdatedAt: number;
changes: Array<{ id: string; action: 'insert' | 'update' | 'unchanged' }>;
}
@Injectable()
/** 分类读写与导入共用规则;分类树写操作按表锁串行化,防止并发改父级形成循环。 */
export class CategoryService {
constructor(private readonly database: PrismaService) {}
/** 公开分类必须从 active 根分类可达;管理读取另外注册受鉴权保护的路由。 */
async list(): Promise<Category[]> {
return this.publicRows();
}
private async publicRows(id?: string): Promise<Category[]> {
const filter =
id === undefined ? Prisma.sql`TRUE` : Prisma.sql`id = ${id}`;
return this.run(async () => {
const rows = await this.database.client.$queryRaw<StoredCategory[]>`
SELECT id, name, icon, icon_asset_id AS "iconAssetId", color,
sort_order AS "sortOrder", parent_id AS "parentId", status,
created_at AS "createdAt", updated_at AS "updatedAt"
FROM public.categories WHERE id IN (${visibleCategoryIds}) AND (${filter}) ORDER BY sort_order, id
`;
return rows.map((row) => this.toCategory(row));
});
}
/** 未上线、祖先未上线与不存在统一 404,不能通过已知 ID 绕过列表过滤。 */
async get(id: string): Promise<Category> {
categoryId(id);
const rows = await this.publicRows(id);
const row = rows.find((item) => item.id === id);
if (!row) throw new NotFoundException();
return row;
}
/** 仅管理路由调用;包含草稿、下架及上级分类隐藏的记录。 */
async listForAdmin(): Promise<Category[]> {
return this.run(async () =>
(
await this.database.client.category.findMany({
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
})
).map((row) => this.toCategory(row)),
);
}
async getForAdmin(id: string): Promise<Category> {
categoryId(id);
return this.run(async () => {
const row = await this.database.client.category.findUnique({
where: { id },
});
if (!row) throw new NotFoundException();
return this.toCategory(row);
});
}
/** 守卫后在事务内锁住账户并复核权限,成功修改与审计一起提交。 */
async create(
input: unknown,
actor: AuthenticatedUser,
requestId: string,
): Promise<Category> {
const data = categoryObject(input);
requireCategoryKeys(data, categoryKeys);
const fields = normalizeCategory(data);
return this.run(() =>
this.database.client.$transaction(
async (tx) => {
await lockContentAdmin(tx, actor);
await this.lockTree(tx);
const rows = await tx.category.findMany();
if (rows.some((row) => row.id === fields.id))
throw new ConflictException();
categoryTreeOrder([...rows, fields]);
const after = await tx.category.create({ data: fields });
if (fields.status === 'active')
await invalidateContentSnapshots(tx);
await this.audit(
tx,
'create',
null,
after,
requestId,
actor.userId,
);
return this.toCategory(after);
},
{ timeout: 10_000 },
),
);
}
/** PATCH 省略字段不变;拒绝空修改及 ID 改名,重复提交相同值不刷新时间/审计。 */
async update(
id: string,
input: unknown,
actor: AuthenticatedUser,
requestId: string,
): Promise<Category> {
categoryId(id);
const patch = categoryObject(input);
requireCategoryKeys(
patch,
categoryKeys.filter((key) => key !== 'id'),
);
if (!Object.keys(patch).length) throw new BadRequestException();
return this.run(() =>
this.database.client.$transaction(
async (tx) => {
await lockContentAdmin(tx, actor);
await this.lockTree(tx);
const rows = await tx.category.findMany();
const before = rows.find((row) => row.id === id);
if (!before) throw new NotFoundException();
const fields = normalizeCategory({ ...before, ...patch });
categoryTreeOrder(
rows.map((row) => (row.id === id ? fields : row)),
);
if (this.sameFields(before, fields))
return this.toCategory(before);
if (before.iconAssetId && fields.icon !== before.icon)
throw new ConflictException(
'文件图标需通过资产模块修改',
);
const after = await tx.category.update({
where: { id },
data: { ...fields, updatedAt: new Date() },
});
if (
before.status !== after.status ||
before.parentId !== after.parentId
)
await invalidateContentSnapshots(tx);
await this.audit(
tx,
'update',
before,
after,
requestId,
actor.userId,
);
return this.toCategory(after);
},
{ timeout: 10_000 },
),
);
}
/** 只删除无引用的分类;权限、分类树修改和删除审计同事务,不级联删除内容。 */
async remove(
id: string,
actor: AuthenticatedUser,
requestId: string,
): Promise<Pick<Category, 'id'>> {
categoryId(id);
return this.run(() =>
this.database.client.$transaction(
async (tx) => {
await lockContentAdmin(tx, actor);
await this.lockTree(tx);
const before = await tx.category.findUnique({
where: { id },
});
if (!before) throw new NotFoundException();
const child = await tx.category.findFirst({
where: { parentId: id },
select: { id: true },
});
if (child) throw new ConflictException('分类仍有子分类');
const worksheet = await tx.worksheet.findFirst({
where: { categoryId: id },
select: { id: true },
});
if (worksheet)
throw new ConflictException('分类仍被题型引用');
// 外键是并发题型写入的最后保护;检查后新建的引用同样不能被静默删除。
await tx.category.delete({ where: { id } });
await invalidateContentSnapshots(tx);
await this.audit(
tx,
'delete',
before,
null,
requestId,
actor.userId,
);
return { id };
},
{ timeout: 10_000 },
),
);
}
/** CLI 专用入口,不注册 HTTP 路由;dry-run 无任何写入,正式导入整批原子提交。 */
async importExport(
source: string,
dryRun: boolean,
requestId: string,
reason: string,
): Promise<CategoryImportReport> {
const input = parseCategoryExport(source);
const batch = new Date();
return this.run(() =>
this.database.client.$transaction(
async (tx) => {
await this.lockTree(tx);
const existing = await tx.category.findMany();
const beforeById = new Map(
existing.map((row) => [row.id, row]),
);
const merged = new Map(
existing.map((row) => [
row.id,
{ id: row.id, parentId: row.parentId },
]),
);
for (const row of input) merged.set(row.id, row);
const byId = new Map(input.map((row) => [row.id, row]));
const order = categoryTreeOrder([...merged.values()]);
const report: CategoryImportReport = {
dryRun,
batchAt: batch.toISOString(),
total: input.length,
inserted: 0,
updated: 0,
unchanged: 0,
missingCreatedAt: input.filter((row) => !row.createdAt)
.length,
missingUpdatedAt: input.filter((row) => !row.updatedAt)
.length,
changes: [],
};
for (const id of order) {
const row = byId.get(id);
if (!row) continue;
const before = beforeById.get(id);
const fields = normalizeCategory({
...row,
status: row.status ?? before?.status ?? 'draft',
});
if (before && this.sameFields(before, fields)) {
report.unchanged++;
report.changes.push({ id, action: 'unchanged' });
continue;
}
if (before?.iconAssetId)
throw new ConflictException(
'不能用文字导入覆盖文件图标',
);
// 重导永远保留现存 createdAt;无源时间时只对真正变更的行使用本批时间。
const createdAt =
before?.createdAt ?? row.createdAt ?? batch;
const updatedAt = new Date(
Math.max(
(row.updatedAt ?? batch).getTime(),
before?.updatedAt.getTime() ?? 0,
),
);
if (updatedAt < createdAt)
throw new BadRequestException('分类时间顺序不合法');
const action = before ? 'update' : 'insert';
report[before ? 'updated' : 'inserted']++;
report.changes.push({ id, action });
if (!dryRun) {
const after = before
? await tx.category.update({
where: { id },
data: { ...fields, updatedAt },
})
: await tx.category.create({
data: { ...fields, createdAt, updatedAt },
});
if (
before?.status !== after.status ||
before?.parentId !== after.parentId
)
await invalidateContentSnapshots(tx);
await this.audit(
tx,
action,
before ?? null,
after,
requestId,
null,
reason,
);
}
}
return report;
},
{ timeout: 30_000 },
),
);
}
private async lockTree(tx: Prisma.TransactionClient): Promise<void> {
// 此锁允许公开 SELECT;API 与导入都必须先持锁再读取整树,避免写偏差。
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
}
private sameFields(row: StoredCategory, input: CategoryFields): boolean {
return categoryKeys.every(
(key) =>
row[key as keyof CategoryFields] ===
input[key as keyof CategoryFields],
);
}
private toCategory(row: StoredCategory): Category {
// 资产图标未接入签名前拒绝返回原始引用,不泄漏对象 key 或 cloud:// 地址。
if (
row.iconAssetId ||
/^(?:https?:|cloud:|data:|file:|\/)/i.test(row.icon)
)
throw new HttpException('分类图标资产尚未接入', 503);
return {
status: contentStatus(row.status),
id: row.id,
name: row.name,
icon: row.icon,
color: row.color,
sortOrder: row.sortOrder,
parentId: row.parentId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private async audit(
tx: Prisma.TransactionClient,
action: string,
before: StoredCategory | null,
after: StoredCategory | null,
requestId: string,
actorUserId: string | null,
reason?: string,
): Promise<void> {
await tx.adminAuditLog.create({
data: {
id: randomUUID(),
actorUserId,
actorType: actorUserId ? 'admin' : 'migration',
action: `category.${action}`,
resourceType: 'categories',
resourceId: (after ?? before)!.id,
requestId,
...(before ? { beforeData: this.auditSnapshot(before) } : {}),
...(after ? { afterData: this.auditSnapshot(after) } : {}),
...(reason ? { reason } : {}),
},
});
}
private auditSnapshot(row: StoredCategory): Prisma.InputJsonObject {
// 删除文件图标分类也能审计;保留稳定资产 ID,不记录旧地址或签名 URL。
const fileIcon =
row.iconAssetId ||
/^(?:https?:|cloud:|data:|file:|\/)/i.test(row.icon);
return {
status: contentStatus(row.status),
id: row.id,
name: row.name,
icon: fileIcon ? null : row.icon,
iconAssetId: row.iconAssetId,
color: row.color,
sortOrder: row.sortOrder,
parentId: row.parentId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private async run<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
if (error instanceof HttpException) throw error;
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
['P2002', 'P2003'].includes(error.code)
)
throw new ConflictException();
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
}
@@ -7,6 +7,7 @@ import {
import type { Response } from 'express';
import type { HttpRequest } from './http-request';
import { AppLogger } from '../logging/app-logger';
import { ApiException } from './api-exception';
// 对外只提供稳定的安全提示;异常原始 message 可能包含请求值、路径或密钥。
const messages: Record<number, string> = {
@@ -57,13 +58,27 @@ export class ApiErrorFilter implements ExceptionFilter {
exception instanceof Error
? exception.name
: 'UnknownError',
...(exception instanceof ApiException
? { reason: exception.reason }
: {}),
});
}
// HTTP 状态与响应 code 保持一致,客户端可用 requestId 反馈问题。
if (
exception instanceof ApiException &&
exception.retryAfterSeconds !== undefined
)
response.setHeader(
'Retry-After',
String(exception.retryAfterSeconds),
);
response.status(status).json({
code: status,
message: messages[status] ?? '服务器暂时无法处理请求',
requestId: request.requestId,
...(exception instanceof ApiException
? { reason: exception.reason }
: {}),
});
}
}
+13
View File
@@ -0,0 +1,13 @@
import { HttpException } from '@nestjs/common';
import type { ApiErrorReason } from '@doodle/contracts';
/** 仅由可信业务创建,过滤器只输出已登记原因,不透传第三方异常原文。 */
export class ApiException extends HttpException {
constructor(
status: number,
readonly reason: ApiErrorReason,
readonly retryAfterSeconds?: number,
) {
super({ reason }, status);
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ export interface RuntimeOptions {
export function getRuntimeOptions(
env: NodeJS.ProcessEnv = process.env,
): RuntimeOptions {
const rawPort = env.PORT ?? '3000';
const rawPort = env.PORT ?? '8800';
const port = Number(rawPort);
const host = (env.HOST ?? '127.0.0.1').trim();
+75 -1
View File
@@ -10,7 +10,7 @@ export const SERVER_CONFIG = Symbol('SERVER_CONFIG');
export const API_PREFIX = 'api/v1';
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
/** 规范化的启动配置;仅在服务端使用,包含不得下发或记录的 JWT 密钥。 */
/** 规范化的启动配置;仅在服务端使用,JWT 和微信密钥不得下发或记录。 */
export interface ServerConfig extends RuntimeOptions {
nodeEnv: 'development' | 'test' | 'production';
logLevel: LogLevel;
@@ -22,6 +22,13 @@ export interface ServerConfig extends RuntimeOptions {
jwtIssuer: string;
jwtAudience: string;
jwtExpiresInSeconds: number;
/** 微信凭据仅服务端持有,登录接入后两项必须完整。 */
wechatMiniAppId: string;
wechatMiniAppSecret: string;
databaseUrl: string;
/** 新品依据首次发布时间判断;公共资源域名缺失时不返回七牛对象地址。 */
worksheetNewWindowDays: number;
qiniuPublicDomain: string | null;
}
/** 按十进制整数字符串解析并检查范围,防止空串、科学计数法等被 Number 宽松接受。 */
@@ -78,6 +85,41 @@ export function getServerConfig(
if (!jwtIssuer || !jwtAudience) {
throw new Error('JWT_ISSUER and JWT_AUDIENCE must not be empty');
}
const wechatMiniAppId = (env.WECHAT_MINI_APP_ID ?? '').trim();
const wechatMiniAppSecret = (env.WECHAT_MINI_APP_SECRET ?? '').trim();
// 已接入真实登录;缺失凭据必须启动失败,错误不回显配置值。
if (!wechatMiniAppId || !wechatMiniAppSecret) {
throw new Error(
'WECHAT_MINI_APP_ID and WECHAT_MINI_APP_SECRET are required',
);
}
if (/\s/.test(wechatMiniAppId) || /\s/.test(wechatMiniAppSecret)) {
throw new Error(
'WECHAT_MINI_APP_ID and WECHAT_MINI_APP_SECRET must not contain whitespace',
);
}
const databaseUrl = env.DATABASE_URL ?? '';
try {
const url = new URL(databaseUrl);
if (
!['postgresql:', 'postgres:'].includes(url.protocol) ||
!url.hostname ||
url.pathname.length < 2 ||
url.hash ||
/\s/.test(databaseUrl)
)
throw new Error();
// 本阶段连接 public 中的既有 SQL 表,拒绝悄悄连接到其他 Prisma schema。
if (
url.searchParams.has('schema') &&
url.searchParams.get('schema') !== 'public'
)
throw new Error();
} catch {
throw new Error(
'DATABASE_URL must be a PostgreSQL connection URL using the public schema',
);
}
// 只接受可直接比较的完整 origin;路径、凭证、通配符不属于白名单项。
const corsOrigins = (env.CORS_ORIGINS ?? '')
.split(',')
@@ -113,6 +155,27 @@ export function getServerConfig(
'TRUSTED_PROXY_CIDRS must contain explicit proxy addresses or CIDRs',
);
}
const rawPublicDomain = (env.QINIU_PUBLIC_DOMAIN ?? '').trim();
let qiniuPublicDomain: string | null = null;
if (rawPublicDomain) {
try {
const url = new URL(rawPublicDomain);
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.pathname !== '/' ||
url.search ||
url.hash
)
throw new Error();
qiniuPublicDomain = url.origin;
} catch {
throw new Error(
'QINIU_PUBLIC_DOMAIN must be an HTTPS origin without credentials, paths or query',
);
}
}
return {
...getRuntimeOptions(env),
nodeEnv: nodeEnv as ServerConfig['nodeEnv'],
@@ -130,6 +193,17 @@ export function getServerConfig(
jwtSecret,
jwtIssuer,
jwtAudience,
wechatMiniAppId,
wechatMiniAppSecret,
databaseUrl,
worksheetNewWindowDays: integer(
env,
'WORKSHEET_NEW_WINDOW_DAYS',
30,
1,
365,
),
qiniuPublicDomain,
jwtExpiresInSeconds: integer(
env,
'JWT_EXPIRES_IN_SECONDS',
+78
View File
@@ -0,0 +1,78 @@
import {
BadRequestException,
ForbiddenException,
HttpException,
} from '@nestjs/common';
import type { ContentStatus } from '@doodle/contracts';
import { Prisma } from '../generated/prisma/client';
import { requireCurrentAccount } from '../auth/account-policy';
import type { AuthenticatedUser } from '../common/http-request';
import { ApiException } from '../common/api-exception';
/** 只从 active 根节点展开;草稿祖先、失联或成环的分支均不对外可见。 */
export const visibleCategoryIds = Prisma.sql`WITH RECURSIVE visible_categories AS (
SELECT id FROM public.categories WHERE parent_id IS NULL AND status = 'active'
UNION
SELECT c.id FROM public.categories c JOIN visible_categories p ON c.parent_id = p.id
WHERE c.status = 'active'
) SELECT id FROM visible_categories`;
/** 题型列表、详情和管理预览共用规则,不能只过滤左侧分类菜单。SQL 中题型别名固定为 w。 */
export const visibleWorksheet = Prisma.sql`w.status = 'active' AND w.category_id IN (${visibleCategoryIds})`;
/** 发布状态显式校验;不接受布尔值、NULL 或客户端自行添加的测试状态。 */
export function contentStatus(value: unknown): ContentStatus {
if (value !== 'draft' && value !== 'active' && value !== 'hidden')
throw new BadRequestException('发布状态不合法');
return value;
}
/** 写事务重新核对当前账户并持锁,避免守卫之后被禁用或降权仍然提交。 */
export async function lockContentAdmin(
tx: Prisma.TransactionClient,
actor: AuthenticatedUser,
): Promise<void> {
const rows = await tx.$queryRaw<
Array<{
id: string;
status: 'active' | 'disabled';
role: 'user' | 'admin';
authVersion: number;
}>
>`SELECT id, status, role, auth_version AS "authVersion" FROM public.users WHERE id = ${actor.userId} FOR UPDATE`;
const account = requireCurrentAccount(
rows[0] ?? null,
actor.userId,
actor.authVersion,
);
if (account.role !== 'admin') throw new ForbiddenException();
}
/** 先撤销数据库展示快照与发布标记;外部已分发的静态文件须由后续发布链路撤销/重建。 */
export async function invalidateContentSnapshots(
tx: Prisma.TransactionClient,
): Promise<void> {
await tx.pageConfig.updateMany({
data: {
home: Prisma.DbNull,
category: Prisma.DbNull,
age: Prisma.DbNull,
version: { increment: 1 },
publishedVersion: null,
publishedAt: null,
updatedAt: new Date(),
},
});
}
/** 保留业务 HTTP 错误;连接/约束诊断不原样输出给调用者。 */
export async function runContentQuery<T>(
operation: () => Promise<T>,
): Promise<T> {
try {
return await operation();
} catch (error) {
if (error instanceof HttpException) throw error;
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
@@ -0,0 +1,140 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Query,
Req,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type {
AdminResourcePage,
AdminWorksheet,
AdminWorksheetPage,
AdminWorksheetResource,
ApiSuccess,
ContentPublication,
} from '@doodle/contracts';
import { CurrentUser, Roles } from '../auth/auth.decorators';
import type { AuthenticatedUser, HttpRequest } from '../common/http-request';
import { WorksheetService } from '../worksheets/worksheet.service';
import {
AdminResourceQueryDto,
AdminWorksheetQueryDto,
UpdateContentStatusDto,
} from './content.dto';
import { PublicationService } from './publication.service';
import { ResourcePreviewService } from './resource-preview.service';
@Controller('admin/worksheets')
@ApiTags('admin/worksheets')
@Roles('admin')
/** 管理预览使用本站 JWT 与当前管理员身份,不接受客户端自报测试身份。 */
export class AdminWorksheetController {
constructor(
private readonly worksheets: WorksheetService,
private readonly publication: PublicationService,
) {}
@Get()
@ApiOperation({ summary: '全部状态题型(管理员,支持状态及原有筛选)' })
async list(
@Query() query: AdminWorksheetQueryDto,
): Promise<ApiSuccess<AdminWorksheetPage>> {
return {
code: 0,
message: 'ok',
data: await this.worksheets.listForAdmin(query),
};
}
@Get(':id')
@ApiOperation({ summary: '题型管理预览(管理员,包含草稿及有效可见性)' })
async get(@Param('id') id: string): Promise<ApiSuccess<AdminWorksheet>> {
return {
code: 0,
message: 'ok',
data: await this.worksheets.getForAdmin(id),
};
}
@Patch(':id/status')
@ApiOperation({
summary: '题型发布、下架或转草稿(管理员);发布要求分类已上线',
})
async update(
@Param('id') id: string,
@Body() input: UpdateContentStatusDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: HttpRequest,
): Promise<ApiSuccess<ContentPublication>> {
return {
code: 0,
message: 'ok',
data: await this.publication.update(
'worksheet',
id,
input,
actor,
request.requestId!,
),
};
}
}
@Controller('admin/worksheet-resources')
@ApiTags('admin/worksheet-resources')
@Roles('admin')
/** 资源公开列表尚未开放;管理预览和上下架先落实同一发布规则。 */
export class AdminResourceController {
constructor(
private readonly resources: ResourcePreviewService,
private readonly publication: PublicationService,
) {}
@Get()
@ApiOperation({
summary: '分页查询资源(管理员,支持 worksheetId/status)',
})
async list(
@Query() query: AdminResourceQueryDto,
): Promise<ApiSuccess<AdminResourcePage>> {
return {
code: 0,
message: 'ok',
data: await this.resources.list(query),
};
}
@Get(':id')
@ApiOperation({ summary: '资源管理预览(管理员,包含草稿及有效可见性)' })
async get(
@Param('id') id: string,
): Promise<ApiSuccess<AdminWorksheetResource>> {
return { code: 0, message: 'ok', data: await this.resources.get(id) };
}
@Patch(':id/status')
@ApiOperation({
summary: '资源发布、下架或转草稿(管理员);发布要求题型及分类已上线',
})
async update(
@Param('id') id: string,
@Body() input: UpdateContentStatusDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: HttpRequest,
): Promise<ApiSuccess<ContentPublication>> {
return {
code: 0,
message: 'ok',
data: await this.publication.update(
'resource',
id,
input,
actor,
request.requestId!,
),
};
}
}
+100
View File
@@ -0,0 +1,100 @@
import { BadRequestException } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Allow } from 'class-validator';
import type {
ContentStatus,
UpdateContentStatusRequest,
} from '@doodle/contracts';
import {
WorksheetQueryDto,
parseWorksheetQuery,
} from '../worksheets/worksheet-query.dto';
import { contentStatus } from './content-policy';
/** 管理列表才接受 status;公开接口不复用此 DTO,避免开放草稿查询开关。 */
export class AdminWorksheetQueryDto extends WorksheetQueryDto {
@Allow()
@ApiPropertyOptional({
enum: ['draft', 'active', 'hidden'],
description: '省略查询全部状态',
})
declare status?: string;
}
/** 首期只提供状态写入,不开放未经白名单校验的渲染配置编辑。 */
export class UpdateContentStatusDto implements UpdateContentStatusRequest {
@Allow()
@ApiProperty({ enum: ['draft', 'active', 'hidden'] })
declare status: ContentStatus;
}
/** 资源管理列表仅支持分页、所属题型和状态,不隐式套用题型年龄/年级条件。 */
export class AdminResourceQueryDto {
@Allow()
@ApiPropertyOptional({
type: 'integer',
default: 1,
minimum: 1,
maximum: 1000000,
})
declare page?: string;
@Allow()
@ApiPropertyOptional({
type: 'integer',
default: 20,
minimum: 1,
maximum: 100,
})
declare pageSize?: string;
@Allow()
@ApiPropertyOptional({ maxLength: 128 })
declare worksheetId?: string;
@Allow()
@ApiPropertyOptional({ enum: ['draft', 'active', 'hidden'] })
declare status?: string;
}
/** 服务层同样检查字段白名单,防止非 HTTP 调用绕过状态参数限制。 */
export function parseAdminWorksheetQuery(input: AdminWorksheetQueryDto) {
const { status, ...query } = input;
return {
query: parseWorksheetQuery(query),
status: status === undefined ? undefined : contentStatus(status),
};
}
/** 严格解析资源分页和过滤;拒绝重复/未知字段,避免管理查询静默扩大范围。 */
export function parseAdminResourceQuery(input: AdminResourceQueryDto) {
if (
Object.keys(input).some(
(key) =>
!['page', 'pageSize', 'worksheetId', 'status'].includes(key),
)
)
throw new BadRequestException();
const { page, pageSize } = parseWorksheetQuery({
...(input.page === undefined ? {} : { page: input.page }),
...(input.pageSize === undefined ? {} : { pageSize: input.pageSize }),
});
const worksheetId = input.worksheetId;
if (
worksheetId !== undefined &&
(typeof worksheetId !== 'string' ||
!worksheetId.trim() ||
[...worksheetId].length > 128 ||
worksheetId.includes('\0'))
)
throw new BadRequestException();
return {
page,
pageSize,
worksheetId,
status:
input.status === undefined
? undefined
: contentStatus(input.status),
};
}
+53
View File
@@ -0,0 +1,53 @@
/** 仅解析已就绪的公开预览图;管理预览同样不绕过资产访问规则。 */
export interface PreviewAssetRow {
assetStatus: string;
assetVisibility: string;
assetPurpose: string;
assetOwnerId: string | null;
assetProvider: string;
assetKey: string | null;
assetExternalUrl: string | null;
assetMimeType: string | null;
}
/** 公开与管理共用安全的预览 URL 解析;尚未迁移、私有或签名地址返回空值。 */
export function previewUrl(
row: PreviewAssetRow,
qiniuPublicDomain: string | null,
): string | null {
// 先查 ready 和公共用途;未迁移/失败/私有文件明确留空,不泄漏旧 cloud:// 或内部 key。
if (
row.assetStatus !== 'ready' ||
row.assetVisibility !== 'public' ||
row.assetPurpose !== 'preview' ||
row.assetOwnerId !== null ||
!row.assetMimeType?.startsWith('image/')
)
return null;
if (row.assetProvider === 'external' && row.assetExternalUrl) {
try {
const url = new URL(row.assetExternalUrl);
return url.protocol === 'https:' &&
!url.username &&
!url.password &&
!url.search &&
!url.hash
? url.href
: null;
} catch {
return null;
}
}
if (row.assetProvider === 'qiniu' && row.assetKey && qiniuPublicDomain) {
const parts = row.assetKey.split('/');
if (
row.assetKey.startsWith('/') ||
row.assetKey.includes('\\') ||
/[\r\n]/.test(row.assetKey) ||
parts.some((part) => part === '.' || part === '..')
)
return null;
return `${qiniuPublicDomain}/${parts.map((part) => encodeURIComponent(part)).join('/')}`;
}
return null;
}
@@ -0,0 +1,183 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { ContentPublication } from '@doodle/contracts';
import { Prisma } from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import type { AuthenticatedUser } from '../common/http-request';
import {
contentStatus,
invalidateContentSnapshots,
lockContentAdmin,
runContentQuery,
visibleCategoryIds,
visibleWorksheet,
} from './content-policy';
interface PublicationRow {
id: string;
status: string;
firstPublishedAt: Date | null;
updatedAt: Date;
}
@Injectable()
/** 状态、首次发布、页面快照失效与审计在同一事务提交;不进行外部上传。 */
export class PublicationService {
constructor(private readonly database: PrismaService) {}
/** 仅管理员路由调用;table 只取服务端常量,不从请求拼接 SQL 标识符。 */
async update(
target: 'worksheet' | 'resource',
id: string,
input: unknown,
actor: AuthenticatedUser,
requestId: string,
): Promise<ContentPublication> {
if (!id || [...id].length > 128 || id.includes('\0'))
throw new NotFoundException();
if (
!input ||
typeof input !== 'object' ||
Array.isArray(input) ||
Object.keys(input).some((key) => key !== 'status')
)
throw new BadRequestException();
const status = contentStatus((input as Record<string, unknown>).status);
const table =
target === 'worksheet'
? Prisma.sql`public.worksheets`
: Prisma.sql`public.worksheet_resources`;
return runContentQuery(() =>
this.database.client.$transaction(
async (tx) => {
await lockContentAdmin(tx, actor);
// 与分类变更及导入采用相同锁顺序;资源发布总是先锁父功能,再锁资源。
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
if (target === 'resource') {
const parents = await tx.$queryRaw<
Array<{ worksheetId: string }>
>`
SELECT worksheet_id AS "worksheetId" FROM public.worksheet_resources WHERE id = ${id}
`;
if (!parents[0]) throw new NotFoundException();
await tx.$queryRaw`SELECT id FROM public.worksheets WHERE id = ${parents[0].worksheetId} FOR UPDATE`;
}
const rows = await tx.$queryRaw<
PublicationRow[]
>(Prisma.sql`
SELECT id, status, first_published_at AS "firstPublishedAt", updated_at AS "updatedAt"
FROM ${table} WHERE id = ${id} FOR UPDATE
`);
const before = rows[0];
if (!before) throw new NotFoundException();
if (before.status === status)
return this.toPublication(before);
if (status === 'active') {
const available =
target === 'worksheet'
? await tx.$queryRaw<
Array<{ id: string }>
>`SELECT id FROM public.worksheets WHERE id = ${id} AND category_id IN (${visibleCategoryIds})`
: await tx.$queryRaw<
Array<{ id: string }>
>`SELECT r.id FROM public.worksheet_resources r JOIN public.worksheets w ON w.id = r.worksheet_id WHERE r.id = ${id} AND (${visibleWorksheet})`;
if (!available.length)
throw new ConflictException(
'上级尚未上线,不能发布',
);
}
const now = new Date();
// 旧 active/hidden 的未知时间保持 NULL;首次从草稿发布才登记,不因重新上架重置。
// 有下架审计的旧记录即使被改回草稿,也不能把未知历史伪装成本次首发。
const history =
before.status === 'draft' &&
status === 'active' &&
!before.firstPublishedAt
? await tx.adminAuditLog.findMany({
where: {
resourceType:
target === 'worksheet'
? 'worksheets'
: 'worksheet_resources',
resourceId: id,
},
orderBy: [
{ createdAt: 'asc' },
{ id: 'asc' },
],
select: { beforeData: true, afterData: true },
})
: [];
const previousPublication =
history.some(
(entry) =>
this.snapshotStatus(entry.beforeData) ===
'active' ||
this.snapshotStatus(entry.afterData) ===
'active',
) ||
['active', 'hidden'].includes(
this.snapshotStatus(history[0]?.beforeData) ??
this.snapshotStatus(history[0]?.afterData) ??
'',
);
const firstPublishedAt =
before.firstPublishedAt ??
(before.status === 'draft' &&
status === 'active' &&
!previousPublication
? now
: null);
const after = (
await tx.$queryRaw<PublicationRow[]>(Prisma.sql`
UPDATE ${table} SET status = ${status}, first_published_at = ${firstPublishedAt}, updated_at = ${now}
WHERE id = ${id} RETURNING id, status, first_published_at AS "firstPublishedAt", updated_at AS "updatedAt"
`)
)[0]!;
await invalidateContentSnapshots(tx);
await tx.adminAuditLog.create({
data: {
id: randomUUID(),
actorType: 'admin',
actorUserId: actor.userId,
action: `${target}.status`,
resourceType:
target === 'worksheet'
? 'worksheets'
: 'worksheet_resources',
resourceId: id,
requestId,
beforeData: { ...this.toPublication(before) },
afterData: { ...this.toPublication(after) },
},
});
return this.toPublication(after);
},
{ timeout: 10_000 },
),
);
}
/** 首条历史即为下架/上线时,不将旧未知发布时间伪装为本次首发。 */
private snapshotStatus(
value: Prisma.JsonValue | undefined,
): string | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value))
return undefined;
return typeof value.status === 'string' ? value.status : undefined;
}
private toPublication(row: PublicationRow): ContentPublication {
return {
id: row.id,
status: contentStatus(row.status),
firstPublishedAt: row.firstPublishedAt?.toISOString() ?? null,
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,116 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import type {
AdminResourcePage,
AdminWorksheetResource,
} from '@doodle/contracts';
import { Prisma } from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
import {
contentStatus,
runContentQuery,
visibleWorksheet,
} from './content-policy';
import {
parseAdminResourceQuery,
type AdminResourceQueryDto,
} from './content.dto';
import { previewUrl, type PreviewAssetRow } from './preview-url';
interface ResourceRow extends PreviewAssetRow {
id: string;
worksheetId: string;
title: string;
status: string;
sortOrder: number;
downloads: bigint;
firstPublishedAt: Date | null;
createdAt: Date;
updatedAt: Date;
visible: boolean;
}
@Injectable()
/** 管理员可检查草稿资源;预览仍只解析公开资产,不直接返回参考图和参数覆盖。 */
export class ResourcePreviewService {
constructor(
private readonly database: PrismaService,
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
) {}
async list(input: AdminResourceQueryDto): Promise<AdminResourcePage> {
const { page, pageSize, worksheetId, status } =
parseAdminResourceQuery(input);
const parts = [Prisma.sql`TRUE`];
if (worksheetId !== undefined)
parts.push(Prisma.sql`r.worksheet_id = ${worksheetId}`);
if (status !== undefined) parts.push(Prisma.sql`r.status = ${status}`);
const where = Prisma.join(parts, ' AND ');
return runContentQuery(() =>
this.database.client.$transaction(
async (tx) => {
const counts = await tx.$queryRaw<
Array<{ total: bigint }>
>`SELECT count(*) AS total FROM public.worksheet_resources r WHERE ${where}`;
const total = Number(counts[0]!.total);
if (!Number.isSafeInteger(total))
throw new Error('资源总数超出安全范围');
const rows = await tx.$queryRaw<
ResourceRow[]
>`SELECT ${this.columns()}
FROM public.worksheet_resources r JOIN public.worksheets w ON w.id = r.worksheet_id
JOIN public.assets a ON a.id = r.preview_asset_id WHERE ${where}
ORDER BY r.sort_order, r.id LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`;
return {
items: rows.map((row) => this.convert(row)),
total,
page,
pageSize,
hasNext: page * pageSize < total,
};
},
{ isolationLevel: 'RepeatableRead', timeout: 10_000 },
),
);
}
async get(id: string): Promise<AdminWorksheetResource> {
if (!id || [...id].length > 128 || id.includes('\0'))
throw new NotFoundException();
return runContentQuery(async () => {
const rows = await this.database.client.$queryRaw<
ResourceRow[]
>`SELECT ${this.columns()}
FROM public.worksheet_resources r JOIN public.worksheets w ON w.id = r.worksheet_id
JOIN public.assets a ON a.id = r.preview_asset_id WHERE r.id = ${id}`;
if (!rows[0]) throw new NotFoundException();
return this.convert(rows[0]);
});
}
private columns(): Prisma.Sql {
return Prisma.sql`r.id, r.worksheet_id AS "worksheetId", r.title, r.status,
r.sort_order AS "sortOrder", r.downloads, r.first_published_at AS "firstPublishedAt",
r.created_at AS "createdAt", r.updated_at AS "updatedAt",
(r.status = 'active' AND (${visibleWorksheet})) AS visible,
a.status AS "assetStatus", a.visibility AS "assetVisibility", a.purpose AS "assetPurpose",
a.owner_user_id AS "assetOwnerId", a.provider AS "assetProvider", a.object_key AS "assetKey",
a.external_url AS "assetExternalUrl", a.mime_type AS "assetMimeType"`;
}
private convert(row: ResourceRow): AdminWorksheetResource {
return {
id: row.id,
worksheetId: row.worksheetId,
title: row.title,
status: contentStatus(row.status),
previewImg: previewUrl(row, this.config.qiniuPublicDomain),
sortOrder: row.sortOrder,
downloads: row.downloads.toString(),
visible: row.visible,
firstPublishedAt: row.firstPublishedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { ReadinessController } from './readiness.controller';
/** 数据库模块只由服务端导入,不对前端契约包公开。 */
@Module({
controllers: [ReadinessController],
providers: [PrismaService],
exports: [PrismaService],
})
export class DatabaseModule {}
@@ -0,0 +1,35 @@
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
import { ApiException } from '../common/api-exception';
@Injectable()
/** 单应用单连接池,按需连接;关闭应用时释放,HTTP 存活检查不依赖数据库。 */
export class PrismaService implements OnModuleDestroy {
readonly client: PrismaClient;
constructor(@Inject(SERVER_CONFIG) config: ServerConfig) {
const adapter = new PrismaPg({
connectionString: config.databaseUrl,
max: 5,
connectionTimeoutMillis: 2000,
idleTimeoutMillis: 10_000,
statement_timeout: 5000,
});
this.client = new PrismaClient({ adapter, log: [] });
}
/** 仅检查数据库连通性;不向公开健康响应返回连接信息或 SQL 异常。 */
async checkReady(): Promise<void> {
try {
await this.client.$queryRaw`SELECT 1`;
} catch {
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
async onModuleDestroy(): Promise<void> {
await this.client.$disconnect();
}
}
@@ -0,0 +1,25 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/auth.decorators';
import { PrismaService } from './prisma.service';
@Controller('health')
@ApiTags('health')
export class ReadinessController {
constructor(private readonly database: PrismaService) {}
@Public()
@Get('ready')
@ApiOperation({
summary: '数据库连通性检查,不代表微信或七牛可用',
security: [],
})
async ready() {
await this.database.checkReady();
return {
code: 0,
message: 'ok',
data: { status: 'ok', database: 'ok' },
};
}
}
+484
View File
@@ -0,0 +1,484 @@
import { createHash, randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
HttpException,
} from '@nestjs/common';
import { isISO8601 } from 'class-validator';
import type { Prisma, Worksheet } from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import { ApiException } from '../common/api-exception';
type WorksheetData = Omit<
Worksheet,
'previewAssetId' | 'tags' | 'renderConfig'
> & {
tags: string[];
renderConfig: Prisma.InputJsonObject;
};
export interface WorksheetImportRow {
data: WorksheetData;
previewSourceRef: string;
}
export interface WorksheetImportWarning {
id: string;
field: string;
reason: string;
originalValue?: number;
}
export interface ParsedWorksheets {
rows: WorksheetImportRow[];
warnings: WorksheetImportWarning[];
}
const allowedFields = [
'_id',
'title',
'subtitle',
'category',
'subcategory',
'path',
'ageMin',
'ageMax',
'grade',
'difficulty',
'previewImg',
'tags',
'isNew',
'isHot',
'sortOrder',
'downloads',
'likes',
'downloads_seed',
'likes_seed',
'status',
'createdAt',
'updatedAt',
'contentUpdatedAt',
];
const counterFields = [
'downloads',
'likes',
'downloadsSeed',
'likesSeed',
] as const;
/** 所有导入校验在写入前完成;原数据只作为数据解析,不执行内容或下载任意地址。 */
export function parseWorksheetExport(source: string): ParsedWorksheets {
let values: unknown;
try {
const text = source.replace(/^\uFEFF/, '').trim();
values = text.startsWith('[')
? JSON.parse(text)
: text
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
} catch {
throw new BadRequestException('题型文件须为 JSON 数组或 JSON Lines');
}
if (!Array.isArray(values) || !values.length || values.length > 10_000)
throw new BadRequestException('题型数量须在 1~10000 之间');
const ids = new Set<string>();
const warnings: WorksheetImportWarning[] = [];
const rows = values.map((value: unknown): WorksheetImportRow => {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new BadRequestException('题型必须为对象');
const row = value as Record<string, unknown>;
const unknown = Object.keys(row).filter(
(key) => !allowedFields.includes(key),
);
if (unknown.length)
throw new BadRequestException(
`题型包含未识别字段:${unknown.join(', ')}`,
);
const text = (key: string, max: number, empty = false): string => {
const input = row[key];
if (
typeof input !== 'string' ||
(!empty && !input.trim()) ||
[...input].length > max ||
input.includes('\0')
)
throw new BadRequestException(`题型 ${key} 不合法`);
return input;
};
const integer = (key: string, min: number, max: number): number => {
const input = row[key];
if (
typeof input !== 'number' ||
!Number.isSafeInteger(input) ||
input < min ||
input > max
)
throw new BadRequestException(
`题型 ${key} 须为 ${min}~${max} 的整数`,
);
return input;
};
const counter = (key: string, optional = false): bigint => {
const input = row[key];
if (input === undefined && optional) return 0n;
if (
typeof input !== 'number' ||
!Number.isSafeInteger(input) ||
input < 0
)
throw new BadRequestException(
`题型 ${key} 须为非负安全整数,禁止丢失精度`,
);
return BigInt(input);
};
const bool = (key: string): boolean => {
if (typeof row[key] !== 'boolean')
throw new BadRequestException(`题型 ${key} 须为布尔值`);
return row[key];
};
const date = (key: string, optional = false): Date | null => {
const input = row[key];
if ((input === undefined || input === null) && optional)
return null;
let raw: unknown = input;
if (input && typeof input === 'object' && !Array.isArray(input)) {
const wrapped = input as Record<string, unknown>;
if (Object.keys(wrapped).length !== 1 || !('$date' in wrapped))
throw new BadRequestException(`题型 ${key} 日期对象不合法`);
raw = wrapped.$date;
}
if (
typeof raw !== 'string' ||
!isISO8601(raw, { strict: true, strictSeparator: true }) ||
!/T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(
raw,
) ||
!Number.isFinite(Date.parse(raw))
)
throw new BadRequestException(
`题型 ${key} 须为带时区的 ISO / $date 时间`,
);
return new Date(raw);
};
const id = text('_id', 128);
if (id !== id.trim() || /[\s/]/.test(id) || ids.has(id))
throw new BadRequestException('题型 ID 不合法或重复');
ids.add(id);
const categoryId = text('category', 128);
const path = text('path', 1024);
if (!path.startsWith('/') || path.startsWith('//'))
throw new BadRequestException('题型须保留小程序页面路径');
const previewSourceRef = text('previewImg', 4096);
// 当前迁移器只接收旧云存储 fileID,后续 HTTPS 需先核实来源,不能任意抓取。
if (!/^cloud:\/\/[^\s/?#]+\/[^\s?#]+$/.test(previewSourceRef))
throw new BadRequestException('题型预览须为稳定 cloud:// fileID');
const ageMin = integer('ageMin', 0, 32767);
const ageMax = integer('ageMax', ageMin, 32767);
const grade = integer('grade', -32768, 32767);
if (grade < -4 || grade > 6)
warnings.push({
id,
field: 'grade',
reason: '旧兼容值超出新年级范围;原样保留,不写 worksheet_grades,须人工审核',
originalValue: grade,
});
for (const key of ['downloads_seed', 'likes_seed'])
if (row[key] === undefined)
warnings.push({
id,
field: key,
reason: '源字段缺失,使用数据库约定默认值 0',
});
if (
!Array.isArray(row.tags) ||
row.tags.some(
(tag) => typeof tag !== 'string' || tag.includes('\0'),
)
)
throw new BadRequestException('题型 tags 须为字符串数组');
const status = text('status', 16);
if (!['draft', 'active', 'hidden'].includes(status))
throw new BadRequestException('题型状态不合法');
const createdAt = date('createdAt')!;
const updatedAt = date('updatedAt')!;
const contentUpdatedAt = date('contentUpdatedAt', true);
if (
updatedAt < createdAt ||
(contentUpdatedAt &&
(contentUpdatedAt < createdAt || contentUpdatedAt > updatedAt))
)
throw new BadRequestException('题型日期先后顺序不合法');
const subcategory =
row.subcategory === undefined ||
row.subcategory === null ||
row.subcategory === ''
? null
: text('subcategory', 100);
return {
previewSourceRef,
data: {
id,
title: text('title', 100),
subtitle: text('subtitle', 500, true),
categoryId,
subcategory,
path,
ageMin,
ageMax,
grade,
difficulty: integer('difficulty', 1, 4),
tags: row.tags as string[],
isNew: bool('isNew'),
isHot: bool('isHot'),
sortOrder: integer('sortOrder', -2147483648, 2147483647),
downloads: counter('downloads'),
likes: counter('likes'),
downloadsSeed: counter('downloads_seed', true),
likesSeed: counter('likes_seed', true),
status,
createdAt,
updatedAt,
contentUpdatedAt,
// 老功能只保留原路由;不猜测 renderer、首次发布时间、年级或完整内容资源。
generationMode: 'procedural',
rendererKey: null,
renderConfig: {},
firstPublishedAt: null,
},
};
});
return { rows, warnings };
}
/** 规范化类型后比较;JSON 对象键序不影响重复导入,计数以十进制文本对账。 */
function snapshot(value: unknown): Prisma.InputJsonValue {
if (value instanceof Date) return value.toISOString();
if (typeof value === 'bigint') return value.toString();
if (Array.isArray(value))
return value.map((item) => (item === null ? null : snapshot(item)));
if (value && typeof value === 'object')
return Object.fromEntries(
Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, child]) => [
key,
child === null ? null : snapshot(child),
]),
);
return value as string | number | boolean;
}
export interface WorksheetImportOptions {
dryRun: boolean;
metadataOnly: boolean;
requestId: string;
sourceSha256: string;
}
/** 仅供本地迁移 CLI 调用,不注册公开 HTTP;相同记录幂等,冲突旧快照禁止覆盖新写入。 */
export class WorksheetImporter {
constructor(private readonly database: PrismaService) {}
async importExport(source: string, options: WorksheetImportOptions) {
const parsed = parseWorksheetExport(source);
if (
createHash('sha256').update(source).digest('hex') !==
options.sourceSha256
)
throw new BadRequestException('源文件摘要不匹配');
try {
return await this.database.client.$transaction(
async (tx) => {
// 与分类管理保持锁序;同时阻止导入期间其他资产/题型写入改变核对结果。
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
await tx.$executeRaw`LOCK TABLE public.assets, public.worksheets IN SHARE ROW EXCLUSIVE MODE`;
const categories = new Set(
(
await tx.category.findMany({ select: { id: true } })
).map((row) => row.id),
);
const missingCategories = [
...new Set(
parsed.rows.map((row) => row.data.categoryId),
),
].filter((id) => !categories.has(id));
if (missingCategories.length)
throw new BadRequestException(
`缺少分类:${missingCategories.join(', ')}`,
);
const sourceRefs = [
...new Set(
parsed.rows.map((row) => row.previewSourceRef),
),
];
const hashes = new Map(
sourceRefs.map((ref) => [
ref,
createHash('sha256').update(ref).digest('hex'),
]),
);
const assets = await tx.asset.findMany({
where: { sourceRefHash: { in: [...hashes.values()] } },
});
const assetsByHash = new Map(
assets.map((asset) => [asset.sourceRefHash!, asset]),
);
const existing = new Map(
(
await tx.worksheet.findMany({
where: {
id: {
in: parsed.rows.map(
(row) => row.data.id,
),
},
},
})
).map((row) => [row.id, row]),
);
const report = {
dryRun: options.dryRun,
mode: options.metadataOnly
? 'metadata-only'
: 'ready-previews',
batchAt: new Date().toISOString(),
total: parsed.rows.length,
inserted: 0,
updated: 0,
unchanged: 0,
isolated: 0,
assetsInserted: 0,
assetsReused: 0,
pendingPreviews: 0,
readyPreviews: 0,
readyForPublicUse: !options.metadataOnly,
gradeRelationsInserted: 0,
warnings: parsed.warnings,
totals: Object.fromEntries(
counterFields.map((key) => [
key,
parsed.rows
.reduce(
(sum, row) => sum + row.data[key],
0n,
)
.toString(),
]),
),
changes: [] as Array<{
id: string;
action: 'insert' | 'unchanged';
previewAssetId: string;
previewStatus: string;
}>,
};
for (const row of parsed.rows) {
const hash = hashes.get(row.previewSourceRef)!;
const mapped = assetsByHash.get(hash);
if (
mapped &&
(mapped.sourceRef !== row.previewSourceRef ||
mapped.purpose !== 'preview' ||
mapped.visibility !== 'public' ||
mapped.ownerUserId !== null ||
['failed', 'deleting', 'deleted'].includes(
mapped.status,
))
)
throw new ConflictException(
`题型 ${row.data.id} 的预览资产映射冲突`,
);
if (!options.metadataOnly && mapped?.status !== 'ready')
throw new ConflictException(
'预览尚未迁移;本地暂存须显式使用 --metadata-only,不能视为可发布导入',
);
const assetId = mapped?.id ?? `preview_${hash}`;
if (
!mapped &&
(await tx.asset.findUnique({
where: { id: assetId },
select: { id: true },
}))
)
throw new ConflictException('预览资产 ID 冲突');
const status = mapped?.status ?? 'pending';
if (status === 'ready') report.readyPreviews++;
else {
report.pendingPreviews++;
report.readyForPublicUse = false;
}
const data = { ...row.data, previewAssetId: assetId };
const before = existing.get(row.data.id);
if (
before &&
JSON.stringify(snapshot(before)) !==
JSON.stringify(snapshot(data))
)
throw new ConflictException(
`题型 ${row.data.id} 已存在且内容不同;禁止用旧导出覆盖当前数据`,
);
// 同一源文件可共享资产,只为首次遇到的新引用计一次新增。
if (mapped) report.assetsReused++;
else {
report.assetsInserted++;
const pending = {
id: assetId,
sourceRef: row.previewSourceRef,
sourceRefHash: hash,
purpose: 'preview',
visibility: 'public',
status: 'pending',
provider: 'qiniu',
};
if (!options.dryRun)
await tx.asset.create({ data: pending });
assetsByHash.set(hash, {
...pending,
bucket: null,
objectKey: null,
objectRefHash: null,
externalUrl: null,
ownerUserId: null,
mimeType: null,
sizeBytes: null,
checksum: null,
lastError: null,
createdAt: new Date(),
updatedAt: new Date(),
});
}
if (before) {
report.unchanged++;
} else {
report.inserted++;
if (!options.dryRun) {
await tx.worksheet.create({ data });
await tx.adminAuditLog.create({
data: {
id: randomUUID(),
actorType: 'migration',
action: 'worksheet.import',
resourceType: 'worksheets',
resourceId: data.id,
requestId: options.requestId,
afterData: snapshot(data),
reason: `CloudBase sha256=${options.sourceSha256}; mode=${report.mode}; preview=${status}; legacy grade retained, new grades unreviewed`,
},
});
}
}
report.changes.push({
id: data.id,
action: before ? 'unchanged' : 'insert',
previewAssetId: assetId,
previewStatus: status,
});
}
return report;
},
{ timeout: 30_000 },
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
}
}
}
+13
View File
@@ -23,12 +23,25 @@ export async function bootstrap(): Promise<void> {
const app = await createApplication(config, logger);
try {
await app.listen(config.port, config.host);
// 用实际监听地址生成文档链接;通配监听地址替换为同协议族的本机地址。
const origin = new URL(await app.getUrl());
if (origin.hostname === '0.0.0.0') origin.hostname = '127.0.0.1';
if (origin.hostname === '[::]') origin.hostname = '[::1]';
const swaggerUrl = config.swaggerEnabled
? new URL('/api-docs', origin).href
: null;
logger.log('server_started', {
host: config.host,
port: config.port,
api_prefix: API_PREFIX,
swagger_enabled: config.swaggerEnabled,
swagger_url: swaggerUrl,
});
// 开发启动器在监听成功后接收通知;独立于日志级别,生产启动不发此通知。
if (process.env.DOODLE_DEV_LAUNCHER === '1')
process.stdout.write(
`${JSON.stringify({ event: 'doodle-dev-ready', swaggerUrl })}\n`,
);
} catch (error) {
await app.close();
throw error;
+8 -2
View File
@@ -1,5 +1,11 @@
# 业务模块预留目录
后续按迁移方案逐步加入 auth、users、worksheets、categories、favorites、downloads、content、assets 和 legacy 模块。
后续按迁移方案逐步加入 users、favorites、downloads、content、assets 和 legacy 模块;auth 继续扩展现有 `src/auth`,分类服务位于 `src/categories`、题型查询位于 `src/worksheets`,不另建第二套。
当前 `src/auth` 已提供 JWT 签发/验签、全局守卫、角色校验及账户读取接入点;真实微信登录、Prisma 账户读取和其他业务模块尚未实现。健康检查独立放在 `src/health`,不代表业务迁移已完成。基础设置见 [Server 文档](../../../../docs/v3.5/Server基础设置.md)。
当前 `src/auth` 已提供微信静默登录、GET /me、JWT、全局守卫、角色及真实账户检查;`src/database` 提供 Prisma 连接和数据库就绪检查。资料修改、注销和其他业务模块尚未实现;接口与测试边界见 [登录接入说明](../../../../docs/v3.5/服务端登录接入说明.md)。
`src/categories` 已提供公开列表/详情、管理员创建/局部更新/删除、引用保护、事务审计和 CLI 共用的幂等导入逻辑;文件图标待资产模块接入,详见 [分类接入说明](../../../../docs/v3.5/分类接口与数据导入.md)。
`src/imports/worksheet-import.ts` 仅供本地 CLI 导入旧题型:严格转换、pending 预览暂存、保留原始计数/日期、冲突保护和事务审计,详见 [题型数据导入说明](../../../../docs/v3.5/题型数据导入说明.md)。
`src/worksheets` 提供公开列表/详情、分类/审核年级/分龄/Tag 组合筛选、最新/热门排序和分页。始终只读 active;预览单独检查 ready/公开用途/图片类型,未就绪返回 null,不暴露旧云地址或内部生成配置。详见 [题型查询说明](../../../../docs/v3.5/题型查询接口说明.md)。
@@ -0,0 +1,207 @@
import { BadRequestException } from '@nestjs/common';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Allow } from 'class-validator';
import type { WorksheetAgeBand, WorksheetSort } from '@doodle/contracts';
export const worksheetAgeBands = {
'3-4': [3, 4],
'4-5': [4, 5],
'5-6': [5, 6],
'6-7': [6, 7],
'7-8': [7, 8],
} as const;
/** HTTP query 先做字段白名单,随后显式解析;不把 false、空串或重复参数隐式转换。 */
export class WorksheetQueryDto {
@Allow()
@ApiPropertyOptional({
type: 'integer',
default: 1,
minimum: 1,
maximum: 1000000,
})
declare page?: string;
@Allow()
@ApiPropertyOptional({
type: 'integer',
default: 20,
minimum: 1,
maximum: 100,
})
declare pageSize?: string;
@Allow()
@ApiPropertyOptional({
example: 'math',
description: '精确分类 ID,不自动包含子分类',
})
declare categoryId?: string;
@Allow()
@ApiPropertyOptional({
type: 'integer',
minimum: -4,
maximum: 6,
description: '经审核的适用年级,不能与 grades 同传',
})
declare grade?: string;
@Allow()
@ApiPropertyOptional({
example: '-1,0,1',
description: '多个年级,逗号分隔,任一匹配;不重复返回题型',
})
declare grades?: string;
@Allow()
@ApiPropertyOptional({
enum: Object.keys(worksheetAgeBands),
description:
'题型适用范围完整覆盖所选年龄段:ageMin ≤ 下限,ageMax ≥ 上限',
})
declare ageBand?: string;
@Allow()
@ApiPropertyOptional({
type: 'integer',
minimum: 0,
maximum: 32767,
description: '单个年龄须被题型年龄范围覆盖,不能与 ageBand 同传',
})
declare age?: string;
@Allow()
@ApiPropertyOptional({
example: '描红',
maxLength: 100,
description: 'Tag 精确匹配,区分大小写',
})
declare tag?: string;
@Allow()
@ApiPropertyOptional({
enum: ['default', 'latest', 'hot'],
default: 'default',
})
declare sort?: string;
@Allow()
@ApiPropertyOptional({
type: Boolean,
description: '筛选人工运营热门标记,可与热度排序分别使用',
})
declare isHot?: string;
@Allow()
@ApiPropertyOptional({
type: Boolean,
description: '按真实首次发布时间及新品窗口筛选,未知发布时间不标新品',
})
declare isNew?: string;
}
export interface ParsedWorksheetQuery {
page: number;
pageSize: number;
categoryId: string | undefined;
grades: number[] | undefined;
ageBand: WorksheetAgeBand | undefined;
age: number | undefined;
tag: string | undefined;
sort: WorksheetSort;
isHot: boolean | undefined;
isNew: boolean | undefined;
}
/** service 也调用同一解析入口,确保手动调用不会绕过组合和取值校验。 */
export function parseWorksheetQuery(
input: WorksheetQueryDto,
): ParsedWorksheetQuery {
const allowed = [
'page',
'pageSize',
'categoryId',
'grade',
'grades',
'ageBand',
'age',
'tag',
'sort',
'isHot',
'isNew',
];
if (Object.keys(input).some((key) => !allowed.includes(key)))
throw new BadRequestException('未知题型查询参数');
const text = (
value: unknown,
name: string,
max: number,
): string | undefined => {
if (value === undefined) return undefined;
if (
typeof value !== 'string' ||
!value.trim() ||
[...value].length > max ||
value.includes('\0')
)
throw new BadRequestException(`${name} 不合法`);
return value.trim();
};
const integer = (
value: unknown,
name: string,
min: number,
max: number,
): number | undefined => {
const raw = text(value, name, 16);
if (raw === undefined) return undefined;
if (!/^(?:0|-?[1-9]\d*)$/.test(raw))
throw new BadRequestException(`${name} 须为整数`);
const result = Number(raw);
if (!Number.isSafeInteger(result) || result < min || result > max)
throw new BadRequestException(`${name} 越界`);
return result;
};
const boolean = (value: unknown, name: string): boolean | undefined => {
const raw = text(value, name, 5);
if (raw === undefined) return undefined;
if (raw !== 'true' && raw !== 'false')
throw new BadRequestException(`${name} 须为 true 或 false`);
return raw === 'true';
};
const grade = integer(input.grade, 'grade', -4, 6);
const gradeList = text(input.grades, 'grades', 100);
if (grade !== undefined && gradeList !== undefined)
throw new BadRequestException('grade 和 grades 不能同时传入');
let grades = grade === undefined ? undefined : [grade];
if (gradeList !== undefined) {
const values = gradeList.split(',');
if (values.length > 11)
throw new BadRequestException('grades 最多 11 项');
grades = [
...new Set(values.map((value) => integer(value, 'grades', -4, 6)!)),
];
}
const ageBand = text(input.ageBand, 'ageBand', 3);
if (ageBand !== undefined && !Object.hasOwn(worksheetAgeBands, ageBand))
throw new BadRequestException('年龄段不合法');
const age = integer(input.age, 'age', 0, 32767);
if (age !== undefined && ageBand !== undefined)
throw new BadRequestException('age 与 ageBand 不能同时传入');
const sort = text(input.sort, 'sort', 10) ?? 'default';
if (!['default', 'latest', 'hot'].includes(sort))
throw new BadRequestException('排序方式不合法');
return {
page: integer(input.page, 'page', 1, 1000000) ?? 1,
pageSize: integer(input.pageSize, 'pageSize', 1, 100) ?? 20,
categoryId: text(input.categoryId, 'categoryId', 128),
grades,
ageBand: ageBand as WorksheetAgeBand | undefined,
age,
tag: text(input.tag, 'tag', 100),
sort: sort as WorksheetSort,
isHot: boolean(input.isHot, 'isHot'),
isNew: boolean(input.isNew, 'isNew'),
};
}
@@ -0,0 +1,62 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import type {
ApiSuccess,
PublicWorksheet,
WorksheetPage,
} from '@doodle/contracts';
import { Public } from '../auth/auth.decorators';
import { WorksheetQueryDto } from './worksheet-query.dto';
import { WorksheetService } from './worksheet.service';
@Controller('worksheets')
@ApiTags('worksheets')
@Public()
/** 小程序与未来 Web 共用的公开只读接口,不提供管理状态或原始统计的查询开关。 */
export class WorksheetController {
constructor(private readonly worksheets: WorksheetService) {}
@Get()
@ApiOperation({
summary: '分页查询全部/分类/年级/年龄段/Tag,支持最新和热门排序',
security: [],
description:
'条件可组合,题型及全部上级分类必须 active;sort=hot 按下载展示值降序;isHot 为运营标记筛选。预览未就绪返回 null。',
})
@ApiOkResponse({
description:
'{code:0,message:"ok",data:{items,total,page,pageSize,hasNext}};downloads/likes 为含 seed 的十进制字符串。',
})
async list(
@Query() query: WorksheetQueryDto,
): Promise<ApiSuccess<WorksheetPage>> {
return {
code: 0,
message: 'ok',
data: await this.worksheets.list(query),
};
}
@Get(':id')
@ApiOperation({
summary: '公开题型详情;不存在或未发布返回 404',
security: [],
})
@ApiParam({
name: 'id',
example: 'number-find',
description: '使用题型列表 data.items 中的 id,例如 number-find',
})
@ApiNotFoundResponse({
description: '题型不存在,或题型及所属分类链尚未全部上线',
})
async get(@Param('id') id: string): Promise<ApiSuccess<PublicWorksheet>> {
return { code: 0, message: 'ok', data: await this.worksheets.get(id) };
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import {
AdminWorksheetController,
AdminResourceController,
} from '../content/content.controller';
import { PublicationService } from '../content/publication.service';
import { ResourcePreviewService } from '../content/resource-preview.service';
import { WorksheetController } from './worksheet.controller';
import { WorksheetService } from './worksheet.service';
/** 公开题型查询与独立鉴权的管理预览、上下架;不注册文件上传或资源公开查询。 */
@Module({
imports: [DatabaseModule],
controllers: [
WorksheetController,
AdminWorksheetController,
AdminResourceController,
],
providers: [WorksheetService, PublicationService, ResourcePreviewService],
})
export class WorksheetModule {}
@@ -0,0 +1,260 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import type {
AdminWorksheet,
AdminWorksheetPage,
PublicWorksheet,
WorksheetPage,
} from '@doodle/contracts';
import {
visibleWorksheet,
runContentQuery,
contentStatus,
} from '../content/content-policy';
import { previewUrl } from '../content/preview-url';
import {
parseAdminWorksheetQuery,
type AdminWorksheetQueryDto,
} from '../content/content.dto';
import { Prisma } from '../generated/prisma/client';
import { PrismaService } from '../database/prisma.service';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
import {
parseWorksheetQuery,
worksheetAgeBands,
type ParsedWorksheetQuery,
type WorksheetQueryDto,
} from './worksheet-query.dto';
interface WorksheetRow {
status: string;
visible: boolean;
id: string;
title: string;
subtitle: string;
category: string;
subcategory: string | null;
path: string;
ageMin: number;
ageMax: number;
grades: number[];
difficulty: number;
tags: string[];
isNew: boolean;
isHot: boolean;
sortOrder: number;
downloads: bigint;
likes: bigint;
downloadsSeed: bigint;
likesSeed: bigint;
createdAt: Date;
updatedAt: Date;
contentUpdatedAt: Date | null;
firstPublishedAt: Date | null;
assetStatus: string;
assetVisibility: string;
assetPurpose: string;
assetOwnerId: string | null;
assetProvider: string;
assetKey: string | null;
assetExternalUrl: string | null;
assetMimeType: string | null;
}
@Injectable()
/** 公开查询按整条所属关系限制可见性;管理预览由独立的管理员路由提供。 */
export class WorksheetService {
constructor(
private readonly database: PrismaService,
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
) {}
/** 公开查询不提供状态开关,固定要求题型及全部上级分类上线。 */
async list(input: WorksheetQueryDto): Promise<WorksheetPage> {
const query = parseWorksheetQuery(input);
return this.readPage(query, visibleWorksheet, (row) =>
this.toPublic(row),
);
}
/** 仅管理控制器调用;公开 DTO 与管理 DTO 分离,不能由 query 提升权限。 */
async listForAdmin(
input: AdminWorksheetQueryDto,
): Promise<AdminWorksheetPage> {
const { query, status } = parseAdminWorksheetQuery(input);
return this.readPage(
query,
status === undefined
? Prisma.sql`TRUE`
: Prisma.sql`w.status = ${status}`,
(row) => this.toAdmin(row),
);
}
/** 总量、数据和派生可见性在同一个数据库快照中读取。 */
private async readPage<T extends PublicWorksheet>(
query: ParsedWorksheetQuery,
visibility: Prisma.Sql,
convert: (row: WorksheetRow) => T,
) {
const isNew = this.isNewExpression(new Date());
const where = this.conditions(query, isNew, visibility);
return runContentQuery(() =>
this.database.client.$transaction(
async (tx) => {
const counts = await tx.$queryRaw<Array<{ total: bigint }>>`
SELECT COUNT(*) AS total FROM public.worksheets w WHERE ${where}
`;
const total = Number(counts[0]!.total);
if (!Number.isSafeInteger(total))
throw new Error('题型总数超出安全范围');
const rows = await tx.$queryRaw<WorksheetRow[]>(Prisma.sql`
SELECT ${this.columns(isNew)}
FROM public.worksheets w JOIN public.assets a ON a.id = w.preview_asset_id
WHERE ${where} ORDER BY ${this.order(query.sort)}
LIMIT ${query.pageSize} OFFSET ${(query.page - 1) * query.pageSize}
`);
return {
items: rows.map(convert),
total,
page: query.page,
pageSize: query.pageSize,
hasNext: query.page * query.pageSize < total,
};
},
{ isolationLevel: 'RepeatableRead', timeout: 10_000 },
),
);
}
/** 未发布、祖先未发布与不存在统一 404。 */
async get(id: string): Promise<PublicWorksheet> {
return this.toPublic(await this.readOne(id, visibleWorksheet));
}
/** 管理预览只增加发布状态和有效可见性,不泄漏源文件或任意渲染配置。 */
async getForAdmin(id: string): Promise<AdminWorksheet> {
return this.toAdmin(await this.readOne(id, Prisma.sql`TRUE`));
}
private async readOne(
id: string,
visibility: Prisma.Sql,
): Promise<WorksheetRow> {
if (!id || [...id].length > 128 || id.includes('\0'))
throw new NotFoundException();
return runContentQuery(async () => {
const rows = await this.database.client.$queryRaw<
WorksheetRow[]
>(Prisma.sql`
SELECT ${this.columns(this.isNewExpression(new Date()))}
FROM public.worksheets w JOIN public.assets a ON a.id = w.preview_asset_id
WHERE w.id = ${id} AND (${visibility})
`);
if (!rows[0]) throw new NotFoundException();
return rows[0];
});
}
private toAdmin(row: WorksheetRow): AdminWorksheet {
return {
...this.toPublic(row),
status: contentStatus(row.status),
visible: row.visible,
};
}
private isNewExpression(now: Date): Prisma.Sql {
const cutoff = new Date(
now.getTime() - this.config.worksheetNewWindowDays * 86400000,
);
// NULL 首发时间和未来时间均为 false;旧 is_new 不能代替真实发布时间。
return Prisma.sql`COALESCE(w.first_published_at BETWEEN ${cutoff} AND ${now}, FALSE)`;
}
private conditions(
query: ParsedWorksheetQuery,
isNew: Prisma.Sql,
visibility: Prisma.Sql,
): Prisma.Sql {
const parts = [Prisma.sql`(${visibility})`];
if (query.categoryId !== undefined)
parts.push(Prisma.sql`w.category_id = ${query.categoryId}`);
if (query.grades !== undefined)
parts.push(Prisma.sql`EXISTS (
SELECT 1 FROM public.worksheet_grades g WHERE g.worksheet_id = w.id
AND g.grade IN (${Prisma.join(query.grades)})
)`);
if (query.ageBand !== undefined) {
const [min, max] = worksheetAgeBands[query.ageBand];
// 适用范围须完整覆盖所选年龄段;只相交不算命中,不做推荐榜补齐或去重混排。
parts.push(Prisma.sql`w.age_min <= ${min} AND w.age_max >= ${max}`);
}
if (query.age !== undefined)
parts.push(
Prisma.sql`w.age_min <= ${query.age} AND w.age_max >= ${query.age}`,
);
if (query.tag !== undefined)
parts.push(
Prisma.sql`w.tags @> ${JSON.stringify([query.tag])}::jsonb`,
);
if (query.isHot !== undefined)
parts.push(Prisma.sql`w.is_hot = ${query.isHot}`);
if (query.isNew !== undefined)
parts.push(Prisma.sql`${isNew} = ${query.isNew}`);
return Prisma.join(parts, ' AND ');
}
private order(sort: ParsedWorksheetQuery['sort']): Prisma.Sql {
if (sort === 'latest')
return Prisma.sql`COALESCE(w.first_published_at, w.created_at) DESC, w.id ASC`;
// 转 numeric 避免两个 bigint 相加溢出;不在 JavaScript 中排序或先截断候选集。
if (sort === 'hot')
return Prisma.sql`
(w.downloads::numeric + w.downloads_seed::numeric) DESC,
(w.likes::numeric + w.likes_seed::numeric) DESC, w.updated_at DESC, w.id ASC
`;
return Prisma.sql`w.sort_order ASC, w.updated_at DESC, w.id ASC`;
}
private columns(isNew: Prisma.Sql): Prisma.Sql {
// 不 SELECT w.*,不把旧 grade、seed、内部资产或生成器配置混入公开响应。
return Prisma.sql`
w.status, (${visibleWorksheet}) AS visible,
w.id, w.title, w.subtitle, w.category_id AS category, w.subcategory, w.path,
w.age_min AS "ageMin", w.age_max AS "ageMax", w.difficulty, w.tags,
array_to_json(ARRAY(SELECT grade FROM public.worksheet_grades g WHERE g.worksheet_id = w.id ORDER BY grade)) AS grades,
${isNew} AS "isNew", w.is_hot AS "isHot", w.sort_order AS "sortOrder",
w.downloads, w.likes, w.downloads_seed AS "downloadsSeed", w.likes_seed AS "likesSeed",
w.created_at AS "createdAt", w.updated_at AS "updatedAt",
w.content_updated_at AS "contentUpdatedAt", w.first_published_at AS "firstPublishedAt",
a.status AS "assetStatus", a.visibility AS "assetVisibility", a.purpose AS "assetPurpose",
a.owner_user_id AS "assetOwnerId", a.provider AS "assetProvider", a.object_key AS "assetKey", a.external_url AS "assetExternalUrl", a.mime_type AS "assetMimeType"
`;
}
private toPublic(row: WorksheetRow): PublicWorksheet {
return {
id: row.id,
title: row.title,
subtitle: row.subtitle,
category: row.category,
subcategory: row.subcategory,
path: row.path,
ageMin: row.ageMin,
ageMax: row.ageMax,
grades: row.grades,
difficulty: row.difficulty,
previewImg: previewUrl(row, this.config.qiniuPublicDomain),
tags: row.tags,
isNew: row.isNew,
isHot: row.isHot,
sortOrder: row.sortOrder,
downloads: (row.downloads + row.downloadsSeed).toString(),
likes: (row.likes + row.likesSeed).toString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
contentUpdatedAt: row.contentUpdatedAt?.toISOString() ?? null,
firstPublishedAt: row.firstPublishedAt?.toISOString() ?? null,
};
}
}
+1 -1
View File
@@ -264,7 +264,7 @@ test('HTTP 中间件、校验、日志、Swagger 与 JWT 全局守卫', async (c
assert.ok(!logs.join('\n').includes(userToken));
});
test('生产默认不暴露 Swagger;账户查询未接入时不放行有效 JWT', async (context) => {
test('生产默认不暴露 Swagger;真实账户数据库不可用时不放行有效 JWT', async (context) => {
const config = {
...testConfig,
nodeEnv: 'production' as const,
+402
View File
@@ -0,0 +1,402 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Controller, Get } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { ApiSuccess, WechatLoginResult } from '@doodle/contracts';
import { AppModule } from '../src/app.module';
import { configureApplication } from '../src/app.setup';
import { AppLogger } from '../src/logging/app-logger';
import { PrismaService } from '../src/database/prisma.service';
import { WECHAT_FETCH, type WechatFetch } from '../src/auth/wechat-client';
import { Roles } from '../src/auth/auth.decorators';
import { JwtTokenService } from '../src/auth/jwt-token.service';
import { AccountRepository } from '../src/auth/account.repository';
import { testConfig } from './test-config';
@Controller('test-admin')
class AdminProbe {
@Get()
@Roles('admin')
get() {
return { ok: true };
}
}
const databaseUrl = process.env.TEST_DATABASE_URL;
test(
'真实 PostgreSQL:微信登录、并发、本人隔离及账户状态鉴权',
{ skip: !databaseUrl },
async (context) => {
// 即使手工运行测试,也只允许本机脚本创建的短期库,禁止误用开发/生产数据库。
const parsed = new URL(databaseUrl!);
assert.ok(['127.0.0.1', 'localhost'].includes(parsed.hostname));
assert.match(parsed.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
const config = { ...testConfig, databaseUrl: databaseUrl! };
const logs: string[] = [];
const logger = new AppLogger('info', (line) => logs.push(line));
const requests: string[] = [];
const http: WechatFetch = async (input) => {
const code = new URL(String(input)).searchParams.get('js_code')!;
requests.push(code);
if (code === 'bad-code')
return Response.json({
errcode: 40029,
errmsg: 'private-upstream-message',
});
if (code === 'risk-code') return Response.json({ errcode: 40226 });
if (code === 'quota-code') return Response.json({ errcode: 45011 });
if (code === 'network-code')
throw new Error('private-network-secret');
return Response.json({
openid: code.startsWith('concurrent-')
? 'ConcurrentOpenID'
: code,
session_key: 'private-session-key',
...(code === 'LegacyOpenID' ? { unionid: 'FreshUnion' } : {}),
});
};
const module = await Test.createTestingModule({
imports: [AppModule.forRoot(config)],
controllers: [AdminProbe],
})
.overrideProvider(WECHAT_FETCH)
.useValue(http)
.compile();
const app = module.createNestApplication<NestExpressApplication>({
logger,
bodyParser: false,
});
context.after(() => app.close());
configureApplication(app, config, logger);
await app.listen(0, '127.0.0.1');
const origin = await app.getUrl();
const prisma = app.get(PrismaService).client;
const tokens = app.get(JwtTokenService);
const repository = app.get(AccountRepository);
const request = (path: string, token?: string) =>
fetch(`${origin}/api/v1/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
signal: AbortSignal.timeout(10_000),
});
const login = (code: string, mode = 'start', extra: object = {}) =>
fetch(`${origin}/api/v1/auth/wechat/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Forwarded-For': 'untrusted-client-ip',
},
body: JSON.stringify({ code, mode, ...extra }),
signal: AbortSignal.timeout(15_000),
});
const reason = async (
response: Response,
status: number,
expected: string,
) => {
assert.equal(response.status, status);
const body = (await response.json()) as {
reason: string;
requestId: string;
};
assert.equal(body.reason, expected);
assert.ok(body.requestId);
};
const loginData = async (response: Response) => {
assert.equal(response.status, 200);
assert.equal(response.headers.get('cache-control'), 'no-store');
return ((await response.json()) as ApiSuccess<WechatLoginResult>)
.data;
};
await context.test('初始配置和只读就绪检查可用', async () => {
assert.equal((await request('health/ready')).status, 200);
assert.equal(await prisma.pageConfig.count(), 1);
assert.equal(await prisma.setting.count(), 1);
assert.equal(await prisma.user.count(), 0);
await reason(await request('me'), 401, 'AUTH_REQUIRED');
});
await context.test(
'首次静默登录默认普通用户,续期不改变版本,响应只含白名单',
async () => {
const data = await loginData(await login('FirstOpenID'));
const claims = await tokens.verifyAccessToken(data.accessToken);
assert.equal(data.user.id, claims.sub);
assert.equal(data.expiresIn, 86400);
assert.equal(
data.expiresAt,
new Date(claims.exp * 1000).toISOString(),
);
assert.deepEqual(
Object.keys(data.user).sort(),
[
'id',
'nickName',
'avatarUrl',
'avatarUrlExpiresAt',
'createdAt',
].sort(),
);
const stored = await prisma.user.findUniqueOrThrow({
where: { id: data.user.id },
});
assert.equal(stored.role, 'user');
assert.equal(stored.status, 'active');
assert.equal(stored.authVersion, 1);
const second = await loginData(
await login('FirstOpenID', 'resume'),
);
assert.equal(second.user.id, data.user.id);
const me = await request(
'me?userId=somebody-else',
data.accessToken,
);
assert.equal(me.status, 200);
assert.equal(me.headers.get('cache-control'), 'no-store');
assert.equal(
((await me.json()) as { data: { id: string } }).data.id,
data.user.id,
);
assert.equal(
(await request('test-admin', data.accessToken)).status,
403,
);
},
);
await context.test(
'老 ID、资料、版本及时间保留,UnionID 只补空不覆盖',
async () => {
const createdAt = new Date('2025-01-01T00:00:00Z');
const updatedAt = new Date('2025-01-02T00:00:00Z');
await prisma.user.create({
data: {
id: 'legacy_cloud_id',
openid: 'LegacyOpenID',
nickName: '老用户',
authVersion: 3,
createdAt,
updatedAt,
},
});
const data = await loginData(
await login('LegacyOpenID', 'resume'),
);
assert.equal(data.user.id, 'legacy_cloud_id');
assert.equal(data.user.nickName, '老用户');
const stored = await prisma.user.findUniqueOrThrow({
where: { id: 'legacy_cloud_id' },
});
assert.equal(stored.unionid, 'FreshUnion');
assert.equal(stored.authVersion, 3);
assert.equal(
stored.createdAt.toISOString(),
createdAt.toISOString(),
);
await repository.login(
{ openid: 'LegacyOpenID', unionid: 'ConflictingUnion' },
'resume',
);
await repository.login(
{ openid: 'LegacyOpenID', unionid: null },
'resume',
);
const after = await prisma.user.findUniqueOrThrow({
where: { id: 'legacy_cloud_id' },
});
assert.equal(after.unionid, 'FreshUnion');
assert.equal(
after.updatedAt.toISOString(),
stored.updatedAt.toISOString(),
);
assert.ok(after.lastActiveAt > createdAt);
},
);
await context.test(
'并发首次登录只生成一个账户,OpenID 区分大小写',
async () => {
const responses = await Promise.all(
Array.from({ length: 8 }, (_, i) =>
login(`concurrent-${i}`),
),
);
const users = await Promise.all(
responses.map(async (r) => (await loginData(r)).user.id),
);
assert.equal(new Set(users).size, 1);
assert.equal(
await prisma.user.count({
where: { openid: 'ConcurrentOpenID' },
}),
1,
);
const lower = await repository.login(
{ openid: 'concurrentopenid', unionid: null },
'start',
);
assert.notEqual(lower.account.id, users[0]);
},
);
await context.test(
'resume 不建号,禁用不能被 start 重置,撤销/降权/删除即时生效',
async () => {
await reason(
await login('MissingOpenID', 'resume'),
401,
'ACCOUNT_NOT_FOUND',
);
assert.equal(
await prisma.user.count({
where: { openid: 'MissingOpenID' },
}),
0,
);
const data = await loginData(await login('LifecycleOpenID'));
const id = data.user.id;
await prisma.user.update({
where: { id },
data: { role: 'admin' },
});
assert.equal(
(await request('test-admin', data.accessToken)).status,
200,
);
await prisma.user.update({
where: { id },
data: { role: 'user' },
});
assert.equal(
(await request('test-admin', data.accessToken)).status,
403,
);
await prisma.user.update({
where: { id },
data: { authVersion: { increment: 1 } },
});
await reason(
await request('me', data.accessToken),
401,
'AUTH_REVOKED',
);
const renewed = await loginData(
await login('LifecycleOpenID', 'resume'),
);
await prisma.user.update({
where: { id },
data: { status: 'disabled' },
});
await reason(
await request('me', renewed.accessToken),
401,
'ACCOUNT_DISABLED',
);
await reason(
await login('LifecycleOpenID'),
401,
'ACCOUNT_DISABLED',
);
await prisma.user.delete({ where: { id } });
await reason(
await request('me', renewed.accessToken),
401,
'ACCOUNT_NOT_FOUND',
);
await reason(
await login('LifecycleOpenID', 'resume'),
401,
'ACCOUNT_NOT_FOUND',
);
const fresh = await loginData(await login('LifecycleOpenID'));
assert.notEqual(fresh.user.id, id);
},
);
await context.test(
'拒绝额外身份参数和坏 code,错误不变成成功响应',
async () => {
const before = requests.length;
for (const input of [
{ openid: 'forged' },
{ appid: 'forged' },
{ userId: 'forged' },
{ role: 'admin' },
])
assert.equal(
(await login('valid', 'start', input)).status,
400,
);
assert.equal((await login(' ', 'start')).status, 400);
assert.equal((await login('valid', 'unknown')).status, 400);
assert.equal(requests.length, before);
await reason(
await login('bad-code'),
400,
'WECHAT_CODE_INVALID',
);
await reason(
await login('risk-code'),
403,
'WECHAT_LOGIN_BLOCKED',
);
const quota = await login('quota-code');
assert.equal(quota.headers.get('retry-after'), '60');
await reason(quota, 429, 'RATE_LIMITED');
await reason(
await login('network-code'),
503,
'AUTH_UPSTREAM_UNAVAILABLE',
);
},
);
await context.test(
'认证失败原因及日志不泄露凭据,伪造转发头不能绕过限流',
async () => {
await reason(
await request('me', 'invalid-token'),
401,
'AUTH_INVALID',
);
const jwt = app.get(JwtTokenService);
const expired = await app
.get((await import('@nestjs/jwt')).JwtService)
.signAsync(
{ sub: 'legacy_cloud_id', authVersion: 3 },
{
secret: config.jwtSecret,
issuer: config.jwtIssuer,
audience: config.jwtAudience,
expiresIn: -1,
},
);
await reason(await request('me', expired), 401, 'AUTH_EXPIRED');
const token = await jwt.signAccessToken('legacy_cloud_id', 3);
assert.equal((await request('me', token)).status, 200);
let limited = false;
for (let i = 0; i < 35; i++) {
const result = await login(`rate-${i}`, 'resume');
if (result.status === 429) {
assert.ok(result.headers.get('retry-after'));
limited = true;
break;
}
}
assert.ok(limited);
const allLogs = logs.join('\n');
for (const secret of [
config.wechatMiniAppSecret,
'private-session-key',
'private-upstream-message',
'private-network-secret',
token,
'LegacyOpenID',
])
assert.ok(!allLogs.includes(secret));
},
);
},
);
+731
View File
@@ -0,0 +1,731 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Test } from '@nestjs/testing';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { ApiSuccess, Category } from '@doodle/contracts';
import { AppModule } from '../src/app.module';
import { configureApplication } from '../src/app.setup';
import { AppLogger } from '../src/logging/app-logger';
import { PrismaService } from '../src/database/prisma.service';
import { JwtTokenService } from '../src/auth/jwt-token.service';
import { CategoryService } from '../src/categories/category.service';
import { testConfig } from './test-config';
const databaseUrl = process.env.TEST_DATABASE_URL;
const item = {
status: 'active' as const,
id: 'math',
name: '数感启蒙',
icon: '📐',
color: '#CDB4DB',
sortOrder: 0,
parentId: null,
};
test(
'真实 PostgreSQL:分类接口、并发树校验、审计及幂等导入',
{ skip: !databaseUrl },
async (context) => {
const parsed = new URL(databaseUrl!);
assert.ok(['127.0.0.1', 'localhost'].includes(parsed.hostname));
assert.match(parsed.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
const config = { ...testConfig, databaseUrl: databaseUrl! };
const module = await Test.createTestingModule({
imports: [AppModule.forRoot(config)],
}).compile();
const logger = new AppLogger('error', () => {});
const app = module.createNestApplication<NestExpressApplication>({
logger,
bodyParser: false,
});
context.after(() => app.close());
configureApplication(app, config, logger);
await app.listen(0, '127.0.0.1');
const origin = await app.getUrl();
const prisma = app.get(PrismaService).client;
const service = app.get(CategoryService);
const tokens = app.get(JwtTokenService);
await prisma.user.createMany({
data: [
{
id: 'category-admin',
openid: 'category-admin',
role: 'admin',
},
{
id: 'category-admin-two',
openid: 'category-admin-two',
role: 'admin',
},
{ id: 'category-user', openid: 'category-user' },
],
});
const admin = await tokens.signAccessToken('category-admin', 1);
const secondAdmin = await tokens.signAccessToken(
'category-admin-two',
1,
);
const user = await tokens.signAccessToken('category-user', 1);
const call = (
path: string,
method = 'GET',
body?: unknown,
token?: string,
) =>
fetch(`${origin}/api/v1/${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Request-Id': 'category-test-request',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(15_000),
});
const data = async (response: Response, status = 200) => {
assert.equal(response.status, status);
const body = (await response.json()) as ApiSuccess<Category>;
assert.equal(body.code, 0);
return body.data;
};
await context.test(
'公开列表无需登录,写入限定管理员,校验失败不写库',
async () => {
const initial = await call('categories');
assert.equal(initial.status, 200);
assert.deepEqual(
((await initial.json()) as ApiSuccess<Category[]>).data,
[],
);
for (const [path, method, body] of [
['admin/categories', 'POST', item],
['admin/categories/math', 'PATCH', { name: '无权限修改' }],
['admin/categories/math', 'DELETE', undefined],
] as const) {
assert.equal((await call(path, method, body)).status, 401);
assert.equal(
(await call(path, method, body, user)).status,
403,
);
}
for (const input of [
{ ...item, name: null },
{ ...item, sortOrder: '0' },
{ ...item, unexpected: true },
{ ...item, parentId: 'missing' },
])
assert.equal(
(await call('admin/categories', 'POST', input, admin))
.status,
400,
);
assert.equal(await prisma.category.count(), 0);
assert.equal(
await prisma.adminAuditLog.count({
where: { resourceType: 'categories' },
}),
0,
);
},
);
await context.test(
'创建、公开详情、稳定排序、局部更新及同事务审计',
async () => {
const created = await data(
await call('admin/categories', 'POST', item, admin),
201,
);
assert.equal(created.id, 'math');
assert.equal(created.icon, '📐');
assert.equal(created.parentId, null);
assert.deepEqual(
Object.keys(created).sort(),
[
'status',
'id',
'name',
'icon',
'color',
'sortOrder',
'parentId',
'createdAt',
'updatedAt',
].sort(),
);
assert.equal(
(await call('admin/categories', 'POST', item, admin))
.status,
409,
);
assert.equal((await call('categories/missing')).status, 404);
assert.equal(
(await data(await call('categories/math'))).name,
item.name,
);
await data(
await call(
'admin/categories',
'POST',
{ ...item, id: 'Math' },
admin,
),
201,
);
await data(
await call(
'admin/categories',
'POST',
{
...item,
id: 'child',
parentId: 'math',
sortOrder: 2,
},
admin,
),
201,
);
const list = await call('categories');
assert.deepEqual(
((await list.json()) as ApiSuccess<Category[]>).data.map(
(row) => row.id,
),
['Math', 'math', 'child'],
);
const updated = await data(
await call(
'admin/categories/math',
'PATCH',
{ name: '数学启蒙' },
admin,
),
);
assert.equal(updated.name, '数学启蒙');
assert.equal(updated.icon, item.icon);
assert.equal(updated.createdAt, created.createdAt);
const audit = await prisma.adminAuditLog.findFirstOrThrow({
where: {
resourceType: 'categories',
resourceId: 'math',
action: 'category.update',
},
});
assert.equal(audit.actorUserId, 'category-admin');
assert.equal(audit.requestId, 'category-test-request');
assert.equal(
(audit.beforeData as { name: string }).name,
item.name,
);
assert.equal(
(audit.afterData as { name: string }).name,
updated.name,
);
const count = await prisma.adminAuditLog.count();
const unchanged = await data(
await call(
'admin/categories/math',
'PATCH',
{ name: updated.name },
admin,
),
);
assert.equal(unchanged.updatedAt, updated.updatedAt);
assert.equal(await prisma.adminAuditLog.count(), count);
for (const patch of [
{},
{ id: 'new-id' },
{ icon: null },
{ parentId: 'child' },
{ parentId: 'math' },
])
assert.equal(
(
await call(
'admin/categories/math',
'PATCH',
patch,
admin,
)
).status,
400,
);
await data(
await call(
'admin/categories/child',
'PATCH',
{ parentId: '' },
admin,
),
);
assert.equal(
(await data(await call('categories/child'))).parentId,
null,
);
},
);
await context.test(
'不同管理员并发互设父级只能成功一个,事务内再次检查账户',
async () => {
const results = await Promise.all([
call(
'admin/categories/math',
'PATCH',
{ parentId: 'Math' },
admin,
),
call(
'admin/categories/Math',
'PATCH',
{ parentId: 'math' },
secondAdmin,
),
]);
assert.deepEqual(
results.map((r) => r.status).sort(),
[200, 400],
);
await prisma.user.update({
where: { id: 'category-admin' },
data: { role: 'user' },
});
await assert.rejects(
service.create(
{ ...item, id: 'forged' },
{
userId: 'category-admin',
authVersion: 1,
role: 'admin',
},
'recheck',
),
);
const staleActor = {
userId: 'category-admin',
authVersion: 1,
role: 'admin' as const,
};
await assert.rejects(
service.update(
'math',
{ name: '无权限修改' },
staleActor,
'recheck',
),
{ status: 403 },
);
await assert.rejects(
service.remove('math', staleActor, 'recheck'),
{ status: 403 },
);
assert.equal(
await prisma.category.count({ where: { id: 'forged' } }),
0,
);
await prisma.user.update({
where: { id: 'category-admin' },
data: { role: 'admin' },
});
},
);
await context.test(
'管理员删除无引用分类,审计保留删除前快照;重复删除返回 404',
async () => {
const id = 'delete-leaf';
const before = await data(
await call(
'admin/categories',
'POST',
{ ...item, id },
admin,
),
201,
);
const response = await call(
`admin/categories/${id}`,
'DELETE',
undefined,
admin,
);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
code: 0,
message: 'ok',
data: { id },
});
assert.equal((await call(`categories/${id}`)).status, 404);
const audit = await prisma.adminAuditLog.findFirstOrThrow({
where: { resourceId: id, action: 'category.delete' },
});
assert.equal(audit.actorUserId, 'category-admin');
assert.equal(audit.actorType, 'admin');
assert.equal(audit.requestId, 'category-test-request');
assert.equal(
(audit.beforeData as { name: string }).name,
before.name,
);
assert.equal(audit.afterData, null);
assert.equal(
(
await call(
`admin/categories/${id}`,
'DELETE',
undefined,
admin,
)
).status,
404,
);
assert.equal(
await prisma.adminAuditLog.count({
where: { resourceId: id, action: 'category.delete' },
}),
1,
);
},
);
await context.test(
'有子分类或任何状态题型引用时返回 409,不删除关联内容或记录成功审计',
async () => {
await prisma.category.createMany({
data: [
{ ...item, id: 'delete-parent' },
{ ...item, id: 'delete-used' },
],
});
await prisma.category.create({
data: {
...item,
id: 'delete-child',
parentId: 'delete-parent',
},
});
await prisma.asset.create({
data: {
id: 'delete-test-preview',
purpose: 'preview',
visibility: 'public',
},
});
await prisma.worksheet.create({
data: {
id: 'delete-test-worksheet',
title: '删除保护测试',
subtitle: '',
categoryId: 'delete-used',
path: '/test',
ageMin: 3,
ageMax: 6,
difficulty: 1,
previewAssetId: 'delete-test-preview',
rendererKey: 'test',
},
});
assert.equal(
(
await call(
'admin/categories/delete-parent',
'DELETE',
undefined,
admin,
)
).status,
409,
);
for (const status of ['draft', 'active', 'hidden']) {
await prisma.worksheet.update({
where: { id: 'delete-test-worksheet' },
data: { status },
});
assert.equal(
(
await call(
'admin/categories/delete-used',
'DELETE',
undefined,
admin,
)
).status,
409,
);
}
assert.equal(
await prisma.category.count({
where: {
id: {
in: [
'delete-parent',
'delete-child',
'delete-used',
],
},
},
}),
3,
);
assert.equal(
await prisma.worksheet.count({
where: { id: 'delete-test-worksheet' },
}),
1,
);
assert.equal(
await prisma.adminAuditLog.count({
where: {
resourceId: {
in: ['delete-parent', 'delete-used'],
},
action: 'category.delete',
},
}),
0,
);
},
);
await context.test(
'审计失败会回滚删除,并发删除只成功一次',
async () => {
await prisma.category.createMany({
data: [
{ ...item, id: 'rollback-delete' },
{ ...item, id: 'concurrent-delete' },
],
});
// 在隔离测试库制造审计失败,验证删行与审计确实处在同一事务。
await prisma.$executeRaw`ALTER TABLE public.admin_audit_logs ADD CONSTRAINT category_delete_audit_test CHECK (action <> 'category.delete' OR resource_id <> 'rollback-delete')`;
try {
assert.equal(
(
await call(
'admin/categories/rollback-delete',
'DELETE',
undefined,
admin,
)
).status,
503,
);
assert.equal(
await prisma.category.count({
where: { id: 'rollback-delete' },
}),
1,
);
assert.equal(
await prisma.adminAuditLog.count({
where: {
resourceId: 'rollback-delete',
action: 'category.delete',
},
}),
0,
);
} finally {
await prisma.$executeRaw`ALTER TABLE public.admin_audit_logs DROP CONSTRAINT category_delete_audit_test`;
}
const responses = await Promise.all([
call(
'admin/categories/concurrent-delete',
'DELETE',
undefined,
admin,
),
call(
'admin/categories/concurrent-delete',
'DELETE',
undefined,
secondAdmin,
),
]);
assert.deepEqual(
responses.map((response) => response.status).sort(),
[200, 404],
);
assert.equal(
await prisma.adminAuditLog.count({
where: {
resourceId: 'concurrent-delete',
action: 'category.delete',
},
}),
1,
);
},
);
await context.test(
'禁用、降权、凭证撤销后,新增/修改/删除都立即失去权限',
async () => {
const actions = [
[
'admin/categories',
'POST',
{ ...item, id: 'forbidden-create' },
],
['admin/categories/math', 'PATCH', { name: '无权限修改' }],
['admin/categories/math', 'DELETE', undefined],
] as const;
const check = async (status: number) => {
for (const [path, method, body] of actions)
assert.equal(
(await call(path, method, body, secondAdmin))
.status,
status,
);
};
await prisma.user.update({
where: { id: 'category-admin-two' },
data: { status: 'disabled' },
});
await check(401);
await prisma.user.update({
where: { id: 'category-admin-two' },
data: { status: 'active', role: 'user' },
});
await check(403);
await prisma.user.update({
where: { id: 'category-admin-two' },
data: { role: 'admin', authVersion: { increment: 1 } },
});
await check(401);
assert.equal(
await prisma.category.count({
where: { id: 'forbidden-create' },
}),
0,
);
assert.ok(
await prisma.category.findUnique({ where: { id: 'math' } }),
);
},
);
await context.test(
'子先父后导入、预演不写库、缺时报告、重导不刷新时间/审计',
async () => {
const rows = [
{
...item,
_id: 'import-child',
id: 'import-child',
parentId: 'import-root',
},
{ ...item, id: 'import-root' },
];
const source = rows
.map((row) => JSON.stringify(row))
.join('\n');
const count = await prisma.category.count();
const audits = await prisma.adminAuditLog.count();
const preview = await service.importExport(
source,
true,
'dry-run',
'test',
);
assert.equal(preview.inserted, 2);
assert.equal(preview.missingCreatedAt, 2);
assert.equal(await prisma.category.count(), count);
assert.equal(await prisma.adminAuditLog.count(), audits);
const applied = await service.importExport(
source,
false,
'import-test',
'sha256=test',
);
assert.equal(applied.inserted, 2);
const before = await prisma.category.findUniqueOrThrow({
where: { id: 'import-root' },
});
assert.equal(before.createdAt.toISOString(), applied.batchAt);
const repeated = await service.importExport(
source,
false,
'repeat',
'test',
);
assert.equal(repeated.unchanged, 2);
assert.equal(repeated.inserted + repeated.updated, 0);
assert.deepEqual(
await prisma.category.findUniqueOrThrow({
where: { id: 'import-root' },
}),
before,
);
assert.equal(await prisma.adminAuditLog.count(), audits + 2);
const updated = await service.importExport(
JSON.stringify([
{ ...item, id: 'import-root', name: '新名称' },
]),
false,
'import-update',
'test',
);
assert.equal(updated.updated, 1);
assert.equal(
(
await prisma.category.findUniqueOrThrow({
where: { id: 'import-root' },
})
).createdAt.toISOString(),
before.createdAt.toISOString(),
);
},
);
await context.test(
'导入后半批失败会回滚已写行及审计,且校验现有树中的循环',
async () => {
const count = await prisma.category.count();
const audits = await prisma.adminAuditLog.count();
await assert.rejects(
service.importExport(
JSON.stringify([
{ ...item, id: 'rollback-first' },
{
...item,
id: 'rollback-second',
createdAt: '2099-01-01T00:00:00Z',
},
]),
false,
'rollback',
'test',
),
);
assert.equal(await prisma.category.count(), count);
assert.equal(await prisma.adminAuditLog.count(), audits);
await assert.rejects(
service.importExport(
JSON.stringify([
{
...item,
id: 'import-root',
parentId: 'import-child',
},
]),
false,
'cycle',
'test',
),
);
await assert.rejects(
service.importExport(
JSON.stringify([
{ ...item, id: 'orphan', parentId: 'not-found' },
]),
false,
'orphan',
'test',
),
);
},
);
},
);
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { categoryTreeOrder } from '../src/categories/category-input';
import { parseCategoryExport } from '../src/categories/category-import';
const root = {
_id: 'math',
name: '数感启蒙',
icon: '📐',
color: '#CDB4DB',
sortOrder: 0,
parentId: null,
};
test('分类导入:兼容 JSON Lines/数组,保留原 ID、Emoji 和带时区时间', () => {
const child = {
...root,
_id: 'Math',
parentId: 'math',
createdAt: '2025-01-01T08:00:00+08:00',
};
const lines = `\uFEFF${JSON.stringify(child)}\n\n${JSON.stringify(root)}\n`;
const rows = parseCategoryExport(lines);
assert.deepEqual(
rows.map((row) => row.id),
['Math', 'math'],
);
assert.equal(rows[0]?.createdAt?.toISOString(), '2025-01-01T00:00:00.000Z');
assert.equal(rows[1]?.createdAt, undefined);
assert.equal(rows[1]?.icon, '📐');
assert.deepEqual(categoryTreeOrder(rows), ['math', 'Math']);
assert.equal(
parseCategoryExport(JSON.stringify([{ ...root, parentId: '' }]))[0]
?.parentId,
null,
);
});
test('分类导入:拒绝重复、未知字段、类型转换、越界及未迁移文件图标', () => {
const bad = [
[{ ...root, id: 'other' }],
[root, root],
[{ ...root, role: 'admin' }],
[{ ...root, name: 42 }],
[{ ...root, icon: 'https://private.example/icon.png' }],
[{ ...root, icon: 'cloud://secret/icon' }],
[{ ...root, _id: ' math' }],
[{ ...root, sortOrder: '2' }],
[{ ...root, sortOrder: 0.5 }],
[{ ...root, sortOrder: 2147483648 }],
[{ ...root, parentId: false }],
[{ ...root, createdAt: '2025-01-01T00:00:00' }],
[{ ...root, createdAt: { $date: '2025-01-01' } }],
[
{
...root,
createdAt: '2025-01-02T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
},
],
];
for (const value of bad)
assert.throws(() => parseCategoryExport(JSON.stringify(value)));
for (const value of ['', '{}', '[]', '{broken'])
assert.throws(() => parseCategoryExport(value));
});
test('分类树:缺失父级、自引用和多层循环均拒绝,深层树不递归', () => {
for (const rows of [
[{ id: 'a', parentId: 'missing' }],
[{ id: 'a', parentId: 'a' }],
[
{ id: 'a', parentId: 'b' },
{ id: 'b', parentId: 'c' },
{ id: 'c', parentId: 'a' },
],
])
assert.throws(() => categoryTreeOrder(rows));
const deep = Array.from({ length: 10_000 }, (_, i) => ({
id: String(i),
parentId: i ? String(i - 1) : null,
})).reverse();
assert.equal(categoryTreeOrder(deep).length, 10_000);
});
@@ -0,0 +1,725 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Test } from '@nestjs/testing';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type {
AdminWorksheet,
AdminWorksheetResource,
ContentPublication,
} from '@doodle/contracts';
import { AppModule } from '../src/app.module';
import { configureApplication } from '../src/app.setup';
import { AppLogger } from '../src/logging/app-logger';
import { PrismaService } from '../src/database/prisma.service';
import { JwtTokenService } from '../src/auth/jwt-token.service';
import { PublicationService } from '../src/content/publication.service';
import { CategoryService } from '../src/categories/category.service';
import { testConfig } from './test-config';
const databaseUrl = process.env.TEST_DATABASE_URL;
test(
'真实 PostgreSQL:内容发布可见性、管理员预览、审计、缓存失效及并发',
{ skip: !databaseUrl },
async (context) => {
const url = new URL(databaseUrl!);
assert.ok(['localhost', '127.0.0.1'].includes(url.hostname));
assert.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
const config = { ...testConfig, databaseUrl: databaseUrl! };
const module = await Test.createTestingModule({
imports: [AppModule.forRoot(config)],
}).compile();
const logger = new AppLogger('error', () => {});
const app = module.createNestApplication<NestExpressApplication>({
logger,
bodyParser: false,
});
context.after(() => app.close());
configureApplication(app, config, logger);
await app.listen(0, '127.0.0.1');
const origin = await app.getUrl();
const prisma = app.get(PrismaService).client;
const tokens = app.get(JwtTokenService);
await prisma.user.createMany({
data: [
{
id: 'publication-admin',
openid: 'publication-admin',
role: 'admin',
},
{ id: 'publication-user', openid: 'publication-user' },
],
});
const admin = await tokens.signAccessToken('publication-admin', 1);
const user = await tokens.signAccessToken('publication-user', 1);
const actor = {
userId: 'publication-admin',
authVersion: 1,
role: 'admin' as const,
};
const call = (
path: string,
method = 'GET',
body?: unknown,
token?: string,
) =>
fetch(`${origin}/api/v1/${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Request-Id': 'publication-test',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(15_000),
});
async function data<T>(path: string, token?: string): Promise<T> {
const response = await call(path, 'GET', undefined, token);
assert.equal(
response.status,
200,
`${path}: ${await (response.status === 200 ? Promise.resolve('') : response.text())}`,
);
return ((await response.json()) as { data: T }).data;
}
async function patch<T = ContentPublication>(
path: string,
status: string,
): Promise<T> {
const response = await call(path, 'PATCH', { status }, admin);
assert.equal(response.status, 200);
return ((await response.json()) as { data: T }).data;
}
const category = { name: '发布样本', icon: '📄', color: '#fff' };
await prisma.category.create({
data: { ...category, id: 'pub-root', status: 'active' },
});
await prisma.category.create({
data: {
...category,
id: 'pub-child',
parentId: 'pub-root',
status: 'active',
},
});
await prisma.category.create({
data: {
...category,
id: 'pub-grandchild',
parentId: 'pub-child',
status: 'active',
},
});
await prisma.category.create({
data: { ...category, id: 'pub-draft-category' },
});
await prisma.asset.create({
data: {
id: 'pub-asset',
purpose: 'preview',
visibility: 'public',
sourceRef: 'cloud://never-public/source',
},
});
const old = new Date('2024-01-01T00:00:00Z');
for (const [id, status, categoryId] of [
['pub-live', 'active', 'pub-grandchild'],
['pub-draft', 'draft', 'pub-grandchild'],
['pub-unready', 'draft', 'pub-draft-category'],
['pub-old', 'hidden', 'pub-grandchild'],
['pub-old-hidden', 'hidden', 'pub-grandchild'],
['pub-never-published', 'draft', 'pub-grandchild'],
['pub-rollback', 'draft', 'pub-grandchild'],
]) {
await prisma.worksheet.create({
data: {
id: id!,
title: id!,
subtitle: '',
categoryId: categoryId!,
status: status!,
path: '/test',
ageMin: 3,
ageMax: 6,
difficulty: 1,
previewAssetId: 'pub-asset',
downloads: 1000000n,
tags: ['publication'],
createdAt: old,
},
});
}
await prisma.worksheetResource.createMany({
data: [
{
id: 'pub-resource',
worksheetId: 'pub-live',
title: '资源草稿',
previewAssetId: 'pub-asset',
},
{
id: 'pub-resource-unready',
worksheetId: 'pub-draft',
title: '上级未发布',
previewAssetId: 'pub-asset',
},
],
});
await context.test(
'新分类默认草稿,公开列表/详情隐藏;管理读取仅限当前管理员',
async () => {
const response = await call(
'admin/categories',
'POST',
{ ...category, id: 'pub-new' },
admin,
);
assert.equal(response.status, 201);
assert.equal(
((await response.json()) as { data: { status: string } })
.data.status,
'draft',
);
assert.equal((await call('categories/pub-new')).status, 404);
assert.equal(
(
await prisma.category.findUniqueOrThrow({
where: { id: 'pub-draft-category' },
})
).status,
'draft',
);
const rows = await data<Array<{ id: string }>>('categories');
assert.ok(!rows.some((row) => row.id === 'pub-new'));
for (const path of [
'admin/categories',
'admin/categories/pub-new',
'admin/worksheets?status=draft',
'admin/worksheets/pub-draft',
'admin/worksheet-resources',
'admin/worksheet-resources/pub-resource',
]) {
assert.equal((await call(path)).status, 401);
assert.equal(
(await call(path, 'GET', undefined, user)).status,
403,
);
assert.equal(
(await call(path, 'GET', undefined, admin)).status,
200,
);
}
const preview = await data<AdminWorksheet>(
'admin/worksheets/pub-draft',
admin,
);
assert.equal(preview.status, 'draft');
assert.equal(preview.visible, false);
assert.equal(preview.previewImg, null);
for (const field of [
'renderConfig',
'assetKey',
'sourceRef',
'downloadsSeed',
])
assert.ok(!(field in preview));
const drafts = await data<{ items: AdminWorksheet[] }>(
'admin/worksheets?status=draft&tag=publication',
admin,
);
assert.ok(drafts.items.some((row) => row.id === 'pub-draft'));
assert.ok(drafts.items.every((row) => row.status === 'draft'));
for (const bad of [null, 'testing', true, 'ACTIVE']) {
assert.equal(
(
await call(
'admin/categories/pub-new',
'PATCH',
{ status: bad },
admin,
)
).status,
400,
);
assert.equal(
(
await call(
'admin/worksheets/pub-draft/status',
'PATCH',
{ status: bad },
admin,
)
).status,
400,
);
}
assert.equal(
(
await call(
'admin/worksheet-resources?status=testing',
'GET',
undefined,
admin,
)
).status,
400,
);
assert.equal(
(
await call(
'admin/worksheet-resources?grade=1',
'GET',
undefined,
admin,
)
).status,
400,
);
},
);
await context.test(
'多层祖先下架影响全部/分类/热门/筛选/详情,恢复不改写下属状态',
async () => {
assert.equal((await call('worksheets/pub-live')).status, 200);
await patch('admin/categories/pub-root', 'hidden');
for (const id of ['pub-root', 'pub-child', 'pub-grandchild'])
assert.equal((await call(`categories/${id}`)).status, 404);
for (const query of [
'tag=publication',
'categoryId=pub-grandchild',
'tag=publication&sort=hot',
'tag=publication&sort=latest',
'tag=publication&age=4',
'tag=publication&ageBand=3-4',
]) {
const result = await data<{
total: number;
items: unknown[];
}>(`worksheets?${query}`);
assert.equal(result.total, 0);
assert.deepEqual(result.items, []);
}
assert.equal((await call('worksheets/pub-live')).status, 404);
assert.equal(
(
await data<AdminWorksheet>(
'admin/worksheets/pub-live',
admin,
)
).visible,
false,
);
assert.equal(
(
await prisma.worksheet.findUniqueOrThrow({
where: { id: 'pub-live' },
})
).status,
'active',
);
await patch('admin/categories/pub-root', 'active');
assert.equal((await call('worksheets/pub-live')).status, 200);
assert.equal((await call('worksheets/pub-draft')).status, 404);
assert.equal(
(await call('worksheets?status=draft')).status,
400,
);
assert.equal(
(await call('worksheets?preview=true')).status,
400,
);
assert.equal(
(await call('categories/pub-draft-category?preview=true'))
.status,
404,
);
},
);
await context.test(
'状态修改鉴权、上级发布前置条件与资源有效可见性',
async () => {
for (const path of [
'admin/worksheets/pub-draft/status',
'admin/worksheet-resources/pub-resource/status',
]) {
assert.equal(
(await call(path, 'PATCH', { status: 'active' }))
.status,
401,
);
assert.equal(
(await call(path, 'PATCH', { status: 'active' }, user))
.status,
403,
);
}
assert.equal(
(
await call(
'admin/worksheets/pub-unready/status',
'PATCH',
{ status: 'active' },
admin,
)
).status,
409,
);
assert.equal(
(
await call(
'admin/worksheet-resources/pub-resource-unready/status',
'PATCH',
{ status: 'active' },
admin,
)
).status,
409,
);
const published = await patch(
'admin/worksheet-resources/pub-resource/status',
'active',
);
assert.ok(published.firstPublishedAt);
assert.equal(
(
await data<AdminWorksheetResource>(
'admin/worksheet-resources/pub-resource',
admin,
)
).visible,
true,
);
await patch('admin/worksheets/pub-live/status', 'hidden');
assert.equal(
(
await data<AdminWorksheetResource>(
'admin/worksheet-resources/pub-resource',
admin,
)
).visible,
false,
);
assert.equal(
(
await prisma.worksheetResource.findUniqueOrThrow({
where: { id: 'pub-resource' },
})
).status,
'active',
);
await patch('admin/worksheets/pub-live/status', 'active');
assert.equal(
(
await data<AdminWorksheetResource>(
'admin/worksheet-resources/pub-resource',
admin,
)
).visible,
true,
);
const resources = await data<{
total: number;
items: AdminWorksheetResource[];
}>(
'admin/worksheet-resources?worksheetId=pub-live&status=active&pageSize=1',
admin,
);
assert.equal(resources.total, 1);
assert.equal(resources.items[0]?.id, 'pub-resource');
},
);
await context.test(
'首次发布登记、重发保持时间、未知旧首发不伪造,并发重复仅一份审计',
async () => {
const paths = [
'admin/worksheets/pub-draft/status',
'admin/worksheet-resources/pub-resource/status',
];
const first = await patch(paths[0]!, 'active');
assert.ok(first.firstPublishedAt);
for (const path of paths) {
const before = await data<ContentPublication>(
path.replace('/status', ''),
admin,
);
await patch(path, 'hidden');
const after = await patch(path, 'active');
assert.equal(
after.firstPublishedAt,
before.firstPublishedAt,
);
}
assert.equal(
(await patch('admin/worksheets/pub-old/status', 'active'))
.firstPublishedAt,
null,
);
await patch('admin/worksheets/pub-old-hidden/status', 'draft');
assert.equal(
(
await patch(
'admin/worksheets/pub-old-hidden/status',
'active',
)
).firstPublishedAt,
null,
);
await patch('admin/worksheets/pub-old/status', 'draft');
assert.equal(
(await patch('admin/worksheets/pub-old/status', 'active'))
.firstPublishedAt,
null,
);
await patch(
'admin/worksheets/pub-never-published/status',
'hidden',
);
await patch(
'admin/worksheets/pub-never-published/status',
'draft',
);
assert.ok(
(
await patch(
'admin/worksheets/pub-never-published/status',
'active',
)
).firstPublishedAt,
);
const audits = await prisma.adminAuditLog.count({
where: {
resourceId: 'pub-draft',
action: 'worksheet.status',
},
});
const results = await Promise.all([
patch(paths[0]!, 'hidden'),
patch(paths[0]!, 'hidden'),
]);
assert.deepEqual(results[0], results[1]);
assert.equal(
await prisma.adminAuditLog.count({
where: {
resourceId: 'pub-draft',
action: 'worksheet.status',
},
}),
audits + 1,
);
const stored = await prisma.worksheet.findUniqueOrThrow({
where: { id: 'pub-draft' },
});
await patch(paths[0]!, 'hidden');
assert.equal(
(
await prisma.worksheet.findUniqueOrThrow({
where: { id: 'pub-draft' },
})
).updatedAt.toISOString(),
stored.updatedAt.toISOString(),
);
},
);
await context.test(
'状态变更同事务撤销页面快照;无变更不增加版本',
async () => {
await prisma.pageConfig.create({
data: {
id: 'publication-snapshot',
home: { featured: [{ id: 'pub-live' }] },
category: { test: true },
age: { test: true },
version: 9n,
publishedVersion: 9n,
publishedAt: new Date(),
},
});
await patch('admin/categories/pub-root', 'hidden');
const snapshot = await prisma.pageConfig.findUniqueOrThrow({
where: { id: 'publication-snapshot' },
});
assert.equal(snapshot.version, 10n);
for (const key of [
'home',
'category',
'age',
'publishedVersion',
'publishedAt',
] as const)
assert.equal(snapshot[key], null);
await patch('admin/categories/pub-root', 'hidden');
assert.deepEqual(
await prisma.pageConfig.findUniqueOrThrow({
where: { id: 'publication-snapshot' },
}),
snapshot,
);
await patch('admin/categories/pub-root', 'active');
},
);
await context.test(
'旧分类导出省略 status 时重导保留下架,新导入分类默认草稿',
async () => {
const service = app.get(CategoryService);
await patch('admin/categories/pub-child', 'hidden');
const before = await prisma.category.findUniqueOrThrow({
where: { id: 'pub-child' },
});
const report = await service.importExport(
JSON.stringify([
{
...category,
id: 'pub-child',
parentId: 'pub-root',
sortOrder: 0,
},
]),
false,
'publication-import',
'test',
);
assert.equal(report.unchanged, 1);
assert.deepEqual(
await prisma.category.findUniqueOrThrow({
where: { id: 'pub-child' },
}),
before,
);
await service.importExport(
JSON.stringify([{ ...category, id: 'pub-import-draft' }]),
false,
'publication-import',
'test',
);
assert.equal(
(
await prisma.category.findUniqueOrThrow({
where: { id: 'pub-import-draft' },
})
).status,
'draft',
);
await patch('admin/categories/pub-child', 'active');
},
);
await context.test(
'审计失败回滚状态、首发时间与快照;服务层复核禁用、降权和版本失效',
async () => {
const snapshots = await prisma.pageConfig.findMany();
await prisma.$executeRawUnsafe(
`CREATE FUNCTION fail_publication_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.resource_id = 'pub-rollback' THEN RAISE EXCEPTION 'test rollback'; END IF; RETURN NEW; END $$`,
);
await prisma.$executeRawUnsafe(
'CREATE TRIGGER fail_publication_audit BEFORE INSERT ON public.admin_audit_logs FOR EACH ROW EXECUTE FUNCTION fail_publication_audit()',
);
try {
assert.equal(
(
await call(
'admin/worksheets/pub-rollback/status',
'PATCH',
{ status: 'active' },
admin,
)
).status,
503,
);
const row = await prisma.worksheet.findUniqueOrThrow({
where: { id: 'pub-rollback' },
});
assert.equal(row.status, 'draft');
assert.equal(row.firstPublishedAt, null);
assert.deepEqual(
await prisma.pageConfig.findMany(),
snapshots,
);
assert.equal(
await prisma.adminAuditLog.count({
where: { resourceId: 'pub-rollback' },
}),
0,
);
} finally {
await prisma.$executeRawUnsafe(
'DROP TRIGGER fail_publication_audit ON public.admin_audit_logs',
);
await prisma.$executeRawUnsafe(
'DROP FUNCTION fail_publication_audit()',
);
}
const service = app.get(PublicationService);
await prisma.user.update({
where: { id: actor.userId },
data: { role: 'user' },
});
await assert.rejects(
service.update(
'worksheet',
'pub-rollback',
{ status: 'active' },
actor,
'stale',
),
{ status: 403 },
);
assert.equal(
(
await call(
'admin/worksheets/pub-live',
'GET',
undefined,
admin,
)
).status,
403,
);
await prisma.user.update({
where: { id: actor.userId },
data: { role: 'admin', status: 'disabled' },
});
await assert.rejects(
service.update(
'worksheet',
'pub-rollback',
{ status: 'active' },
actor,
'stale',
),
{ status: 401 },
);
await prisma.user.update({
where: { id: actor.userId },
data: { status: 'active', authVersion: 2 },
});
assert.equal(
(
await call(
'admin/worksheet-resources',
'GET',
undefined,
admin,
)
).status,
401,
);
await assert.rejects(
service.update(
'resource',
'pub-resource',
{ status: 'hidden' },
actor,
'stale',
),
{ status: 401 },
);
},
);
},
);
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { LoginRateLimiter } from '../src/auth/login-rate-limiter';
import { ApiException } from '../src/common/api-exception';
test('登录分别限制 IP 与可信身份,窗口过期后恢复且返回重试期限', (context) => {
context.mock.method(Date, 'now', () => 1000);
const limiter = new LoginRateLimiter();
for (let i = 0; i < 30; i++) limiter.checkIp('127.0.0.1');
assert.throws(
() => limiter.checkIp('127.0.0.1'),
(error: unknown) =>
error instanceof ApiException &&
error.reason === 'RATE_LIMITED' &&
error.retryAfterSeconds === 60,
);
limiter.checkIp('127.0.0.2');
for (let i = 0; i < 10; i++) limiter.checkIdentity('openid-a');
assert.throws(() => limiter.checkIdentity('openid-a'), ApiException);
limiter.checkIdentity('openid-b');
context.mock.method(Date, 'now', () => 61_000);
limiter.checkIp('127.0.0.1');
limiter.checkIdentity('openid-a');
});
test('大量来源不会无限增长内存,过期后可以回收容量', (context) => {
context.mock.method(Date, 'now', () => 0);
const limiter = new LoginRateLimiter();
for (let i = 0; i < 10_000; i++) limiter.checkIp(`ip-${i}`);
assert.throws(() => limiter.checkIp('new-ip'), ApiException);
limiter.checkIp('ip-0');
context.mock.method(Date, 'now', () => 60_000);
limiter.checkIp('new-ip');
});
+1 -1
View File
@@ -5,7 +5,7 @@ import { getRuntimeOptions } from '../src/common/runtime-options';
test('使用本地默认 host 和 port', () => {
assert.deepEqual(getRuntimeOptions({}), {
host: '127.0.0.1',
port: 3000,
port: 8800,
});
});
+110 -1
View File
@@ -2,7 +2,13 @@ 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' };
const env = {
JWT_SECRET: 'test-configuration-secret-12345678901234567890',
DATABASE_URL:
'postgresql://test:test@127.0.0.1:1/doodle_test?schema=public',
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
};
test('启动配置必须提供有效 JWT 密钥,不使用共享默认密钥', () => {
for (const secret of [
@@ -33,6 +39,82 @@ test('Swagger 开发默认开启,生产默认关闭,支持显式开关', ()
);
});
test('真实登录接入后微信配置必须完整,缺省或留空都拒绝启动', () => {
for (const entry of [
{ WECHAT_MINI_APP_ID: undefined, WECHAT_MINI_APP_SECRET: undefined },
{ WECHAT_MINI_APP_ID: '', WECHAT_MINI_APP_SECRET: '' },
{ WECHAT_MINI_APP_ID: ' ', WECHAT_MINI_APP_SECRET: '\t' },
]) {
assert.throws(
() => getServerConfig({ ...env, ...entry }),
/WECHAT_MINI_APP/,
);
}
});
test('成对读取微信配置并去除首尾空白', () => {
const config = getServerConfig({
...env,
WECHAT_MINI_APP_ID: ' wx0123456789abcdef ',
WECHAT_MINI_APP_SECRET: ' test-only-wechat-secret ',
});
assert.equal(config.wechatMiniAppId, 'wx0123456789abcdef');
assert.equal(config.wechatMiniAppSecret, 'test-only-wechat-secret');
});
test('拒绝不完整或含内部空白的微信配置,错误不回显配置值', () => {
for (const entry of [
{
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
WECHAT_MINI_APP_SECRET: undefined,
},
{
WECHAT_MINI_APP_ID: undefined,
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
},
{
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
WECHAT_MINI_APP_SECRET: ' ',
},
{
WECHAT_MINI_APP_ID: 'wx0123 456789abcdef',
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
},
{
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
WECHAT_MINI_APP_SECRET: 'test-only\nwechat-secret',
},
]) {
assert.throws(
() => getServerConfig({ ...env, ...entry }),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /WECHAT_MINI_APP/);
for (const value of Object.values(entry)) {
if (value?.trim())
assert.ok(!error.message.includes(value));
}
return true;
},
);
}
});
test('数据库连接必须为 PostgreSQL public schema,校验不回显含密码的 URL', () => {
for (const databaseUrl of [
'',
'mysql://user:private-password@localhost/db',
'postgresql:///db',
'postgresql://localhost',
'postgresql://localhost/db?schema=other',
])
assert.throws(
() => getServerConfig({ ...env, DATABASE_URL: databaseUrl }),
/DATABASE_URL/,
);
assert.equal(getServerConfig(env).databaseUrl, env.DATABASE_URL);
});
test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域配置', () => {
for (const entry of [
{ API_BODY_LIMIT_BYTES: '0' },
@@ -55,3 +137,30 @@ test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域
['https://example.com', 'http://localhost:3001'],
);
});
test('题型新品窗口与公共资源域名校验,缺省域名不构造七牛地址', () => {
assert.equal(getServerConfig(env).worksheetNewWindowDays, 30);
assert.equal(getServerConfig(env).qiniuPublicDomain, null);
assert.equal(
getServerConfig({
...env,
QINIU_PUBLIC_DOMAIN: 'https://assets.example.test/',
}).qiniuPublicDomain,
'https://assets.example.test',
);
for (const value of ['0', '366', '1e2', ''])
assert.throws(() =>
getServerConfig({ ...env, WORKSHEET_NEW_WINDOW_DAYS: value }),
);
for (const value of [
'http://assets.example.test',
'https://user:secret@example.test',
'https://example.test/path',
'https://example.test?sign=private',
'https://example.test#hash',
])
assert.throws(
() => getServerConfig({ ...env, QINIU_PUBLIC_DOMAIN: value }),
/QINIU_PUBLIC_DOMAIN/,
);
});
+4
View File
@@ -4,4 +4,8 @@ import { getServerConfig } from '../src/config/server-config';
export const testConfig = getServerConfig({
NODE_ENV: 'test',
JWT_SECRET: 'test-only-secret-123456789012345678901234567890',
DATABASE_URL:
'postgresql://test:test@127.0.0.1:1/doodle_test?schema=public',
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
});
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { WechatClient, type WechatFetch } from '../src/auth/wechat-client';
import { ApiException } from '../src/common/api-exception';
import { testConfig } from './test-config';
const responding =
(payload: unknown, status = 200): WechatFetch =>
async () =>
new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
test('微信兑换只向固定地址发送服务端凭据,忽略 session_key,不重试一次性 code', async () => {
let calls = 0;
const http: WechatFetch = async (input, init) => {
calls += 1;
const url = new URL(String(input));
assert.equal(url.origin, 'https://api.weixin.qq.com');
assert.equal(url.pathname, '/sns/jscode2session');
assert.equal(url.searchParams.get('appid'), testConfig.wechatMiniAppId);
assert.equal(
url.searchParams.get('secret'),
testConfig.wechatMiniAppSecret,
);
assert.equal(url.searchParams.get('js_code'), 'once-code');
assert.equal(init?.redirect, 'error');
assert.ok(init?.signal);
return responding({
openid: 'OpenIdA',
unionid: 'UnionA',
session_key: 'private-session-key',
})(input, init);
};
assert.deepEqual(
await new WechatClient(testConfig, http).exchangeCode('once-code'),
{ openid: 'OpenIdA', unionid: 'UnionA' },
);
assert.equal(calls, 1);
for (const unionid of [undefined, null, '']) {
const result = await new WechatClient(
testConfig,
responding({ openid: 'A', unionid, errcode: 0 }),
).exchangeCode('code');
assert.equal(result.unionid, null);
}
});
test('微信错误、畸形响应和网络失败映射为有限安全错误,不传播原始消息', async () => {
for (const [payload, status, reason] of [
[{ errcode: 40029 }, 400, 'WECHAT_CODE_INVALID'],
[{ errcode: 40163 }, 400, 'WECHAT_CODE_INVALID'],
[{ errcode: 40226 }, 403, 'WECHAT_LOGIN_BLOCKED'],
[{ errcode: 45011 }, 429, 'RATE_LIMITED'],
[{ errcode: -1 }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[
{ errcode: 40013, errmsg: 'private-secret' },
503,
'AUTH_UPSTREAM_UNAVAILABLE',
],
[{ openid: '' }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[{ openid: 'A', unionid: 123 }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[{ openid: 'x'.repeat(129) }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[{ openid: ' A' }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[null, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[[], 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
] as const) {
await assert.rejects(
new WechatClient(testConfig, responding(payload)).exchangeCode(
'code',
),
(error: unknown) => {
assert.ok(error instanceof ApiException);
assert.equal(error.getStatus(), status);
assert.equal(error.reason, reason);
assert.ok(
!JSON.stringify(error.getResponse()).includes(
'private-secret',
),
);
return true;
},
);
}
let calls = 0;
const failed: WechatFetch = async () => {
calls += 1;
throw new Error('https://api.weixin.qq.com?secret=private-secret');
};
await assert.rejects(
new WechatClient(testConfig, failed).exchangeCode('code'),
(error: unknown) =>
error instanceof ApiException &&
error.reason === 'AUTH_UPSTREAM_UNAVAILABLE',
);
assert.equal(calls, 1);
for (const http of [
responding({}, 502),
(async () => new Response('not-json')) as WechatFetch,
])
await assert.rejects(
new WechatClient(testConfig, http).exchangeCode('code'),
(error: unknown) =>
error instanceof ApiException &&
error.reason === 'AUTH_UPSTREAM_UNAVAILABLE',
);
});
+523
View File
@@ -0,0 +1,523 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Test } from '@nestjs/testing';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type {
ApiSuccess,
PublicWorksheet,
WorksheetPage,
} from '@doodle/contracts';
import type { Prisma } from '../src/generated/prisma/client';
import { AppModule } from '../src/app.module';
import { configureApplication } from '../src/app.setup';
import { AppLogger } from '../src/logging/app-logger';
import { PrismaService } from '../src/database/prisma.service';
import { testConfig } from './test-config';
const databaseUrl = process.env.TEST_DATABASE_URL;
test(
'真实 PostgreSQL:公开题型组合筛选、分页、最新/热门和数据隔离',
{ skip: !databaseUrl },
async (context) => {
const url = new URL(databaseUrl!);
assert.ok(['127.0.0.1', 'localhost'].includes(url.hostname));
assert.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
const config = {
...testConfig,
databaseUrl: databaseUrl!,
qiniuPublicDomain: 'https://assets.example.test',
};
const module = await Test.createTestingModule({
imports: [AppModule.forRoot(config)],
}).compile();
const logger = new AppLogger('error', () => {});
const app = module.createNestApplication<NestExpressApplication>({
logger,
bodyParser: false,
});
context.after(() => app.close());
configureApplication(app, config, logger);
await app.listen(0, '127.0.0.1');
const origin = await app.getUrl();
const prisma = app.get(PrismaService).client;
const baseActive = await prisma.worksheet.count({
where: { status: 'active' },
});
await prisma.category.createMany({
data: [
{
status: 'active',
id: 'query-cat',
name: '查询样本',
icon: '📐',
color: '#fff',
},
{
status: 'active',
id: 'query-pages',
name: '分页样本',
icon: '📐',
color: '#fff',
},
],
});
await prisma.category.create({
data: {
status: 'active',
id: 'query-child',
name: '子分类',
icon: '📐',
color: '#fff',
parentId: 'query-cat',
},
});
await prisma.asset.createMany({
data: [
{
id: 'query-pending',
purpose: 'preview',
visibility: 'public',
sourceRef: 'cloud://source/never-expose.jpg',
},
{
id: 'query-external',
purpose: 'preview',
visibility: 'public',
provider: 'external',
status: 'ready',
mimeType: 'image/jpeg',
externalUrl: 'https://external.example.test/preview.jpg',
},
{
id: 'query-private',
purpose: 'preview',
visibility: 'private',
provider: 'external',
status: 'ready',
mimeType: 'image/jpeg',
externalUrl: 'https://private.example.test/secret.jpg',
},
{
id: 'query-signed',
purpose: 'preview',
visibility: 'public',
provider: 'external',
status: 'ready',
mimeType: 'image/jpeg',
externalUrl:
'https://external.example.test/preview.jpg?sign=secret',
},
{
id: 'query-qiniu',
purpose: 'preview',
visibility: 'public',
provider: 'qiniu',
status: 'ready',
mimeType: 'image/jpeg',
bucket: 'test-public',
objectKey: 'previews/中文 图片.jpg',
objectRefHash: 'a'.repeat(64),
checksum: 'b'.repeat(64),
sizeBytes: 123n,
},
{
id: 'query-not-image',
purpose: 'preview',
visibility: 'public',
provider: 'external',
status: 'ready',
mimeType: 'text/html',
externalUrl: 'https://external.example.test/error.html',
},
],
});
const old = new Date('2025-01-01T00:00:00Z');
const now = new Date();
const dayAgo = new Date(now.getTime() - 86400000);
const fortyDaysAgo = new Date(now.getTime() - 40 * 86400000);
const future = new Date(now.getTime() + 86400000);
const create = (
id: string,
extra: Partial<Prisma.WorksheetUncheckedCreateInput> = {},
) =>
prisma.worksheet.create({
data: {
id,
title: id,
subtitle: '',
categoryId: 'query-cat',
path: `/test?id=${id}`,
ageMin: 3,
ageMax: 8,
difficulty: 1,
previewAssetId: 'query-pending',
tags: ['数学'],
status: 'active',
createdAt: old,
updatedAt: old,
renderConfig: { privateGenerator: 'never-expose-config' },
...extra,
},
});
await create('query-a', {
grade: 23,
isNew: true,
isHot: true,
sortOrder: 1,
downloads: 10n,
downloadsSeed: 90n,
likes: 1n,
updatedAt: now,
tags: ['数学', '描红', 'chronology'],
});
await create('query-b', {
grade: 0,
sortOrder: 1,
firstPublishedAt: dayAgo,
ageMax: 4,
downloads: 40n,
likes: 400n,
previewAssetId: 'query-private',
tags: ['数学', 'Math', 'chronology'],
});
await create('query-c', {
isHot: true,
sortOrder: 2,
firstPublishedAt: fortyDaysAgo,
ageMin: 4,
downloads: 50n,
downloadsSeed: 60n,
previewAssetId: 'query-external',
tags: ['描红', 'chronology'],
});
await create('query-future', {
firstPublishedAt: future,
previewAssetId: 'query-signed',
});
await create('query-qiniu-item', {
previewAssetId: 'query-qiniu',
tags: ['image'],
});
await create('query-html-item', {
previewAssetId: 'query-not-image',
tags: ['image'],
});
await create('query-hidden', {
status: 'hidden',
downloads: 1000000n,
firstPublishedAt: now,
});
await create('query-draft', { status: 'draft' });
await create('query-child-item', { categoryId: 'query-child' });
await create('query-huge-one', {
tags: ['precision'],
downloads: 9223372036854775807n,
});
await create('query-huge-two', {
tags: ['precision'],
downloads: 9223372036854775806n,
downloadsSeed: 100n,
});
await create('query-tie-a', { tags: ['tie'] });
await create('query-tie-b', { tags: ['tie'] });
await prisma.worksheetGrade.createMany({
data: [
{ worksheetId: 'query-b', grade: 0 },
{ worksheetId: 'query-b', grade: 1 },
{ worksheetId: 'query-c', grade: 1 },
{ worksheetId: 'query-c', grade: 2 },
{ worksheetId: 'query-hidden', grade: 1 },
],
});
const response = (path: string) =>
fetch(`${origin}/api/v1/${path}`, {
headers: { Authorization: 'Bearer invalid-public-token' },
signal: AbortSignal.timeout(10000),
});
const list = async (query = ''): Promise<WorksheetPage> => {
const result = await response(
`worksheets${query ? `?${query}` : ''}`,
);
assert.equal(result.status, 200);
const body = (await result.json()) as ApiSuccess<WorksheetPage>;
assert.equal(body.code, 0);
return body.data;
};
const ids = (data: WorksheetPage) => data.items.map((row) => row.id);
const detail = async (id: string): Promise<PublicWorksheet> => {
const result = await response(`worksheets/${id}`);
assert.equal(result.status, 200);
return ((await result.json()) as ApiSuccess<PublicWorksheet>).data;
};
await context.test(
'无需登录可查全部,固定 active,分类不自动包含子级,详情使用公开白名单',
async () => {
assert.equal((await list()).total, baseActive + 11);
const filtered = await list(
'categoryId=query-cat&pageSize=100',
);
assert.equal(filtered.total, 10);
assert.equal(filtered.items.length, 10);
assert.ok(!ids(filtered).includes('query-child-item'));
assert.equal((await list('categoryId=not-found')).total, 0);
for (const id of ['query-hidden', 'query-draft', 'missing'])
assert.equal(
(await response(`worksheets/${id}`)).status,
404,
);
const row = await detail('query-a');
assert.equal(row.downloads, '100');
assert.equal(row.previewImg, null);
assert.deepEqual(row.grades, []);
assert.equal(row.isNew, false);
for (const field of [
'renderConfig',
'rendererKey',
'generationMode',
'previewAssetId',
'sourceRef',
'grade',
'downloadsSeed',
'assetKey',
'assetOwnerId',
])
assert.ok(!(field in row));
assert.ok(!JSON.stringify(filtered).includes('never-expose'));
},
);
await context.test(
'单/多年级使用审核关系,0 不丢失,重复年级与多重命中不产生重复行',
async () => {
assert.deepEqual(
ids(await list('categoryId=query-cat&grade=0')),
['query-b'],
);
const result = await list('categoryId=query-cat&grades=0,1,1');
assert.equal(result.total, 2);
assert.deepEqual(
new Set(ids(result)),
new Set(['query-b', 'query-c']),
);
assert.equal(
(await list('categoryId=query-cat&grade=-4')).total,
0,
);
assert.deepEqual((await detail('query-c')).grades, [1, 2]);
},
);
await context.test(
'年龄段要求完整覆盖且含边界,单年龄范围匹配;Tag 精确匹配并可组合其他条件',
async () => {
const band = await list(
'categoryId=query-cat&ageBand=3-4&pageSize=100',
);
assert.ok(
ids(band).includes('query-a') &&
ids(band).includes('query-b'),
);
assert.ok(!ids(band).includes('query-c'));
// 3~8 岁覆盖中间及最高年龄段;3~4 岁与 4~5 岁仅相交,不应命中。
for (const ageBand of ['4-5', '5-6', '7-8']) {
const covered = await list(
`categoryId=query-cat&ageBand=${ageBand}&pageSize=100`,
);
assert.ok(ids(covered).includes('query-a'));
assert.ok(ids(covered).includes('query-c'));
assert.ok(!ids(covered).includes('query-b'));
assert.equal(covered.total, covered.items.length);
}
assert.ok(
ids(
await list('categoryId=query-cat&age=4&pageSize=100'),
).includes('query-c'),
);
assert.deepEqual(
ids(
await list(
'categoryId=query-cat&grade=1&ageBand=3-4&tag=数学',
),
),
['query-b'],
);
assert.deepEqual(
ids(await list('categoryId=query-cat&tag=Math')),
['query-b'],
);
assert.equal(
(await list('categoryId=query-cat&tag=math')).total,
0,
);
assert.equal(
(
await list(
`categoryId=query-cat&tag=${encodeURIComponent("' OR TRUE --")}`,
)
).total,
0,
);
},
);
await context.test(
'最新按首发/创建时间,热门含 seed,运营推荐与真实新品筛选分开',
async () => {
assert.deepEqual(
ids(
await list(
'categoryId=query-cat&tag=chronology&sort=latest',
),
),
['query-b', 'query-c', 'query-a'],
);
assert.deepEqual(
ids(
await list(
'categoryId=query-cat&tag=chronology&sort=hot',
),
),
['query-c', 'query-a', 'query-b'],
);
assert.deepEqual(
ids(await list('categoryId=query-cat&isHot=true')).sort(),
['query-a', 'query-c'],
);
assert.deepEqual(
ids(await list('categoryId=query-cat&isNew=true')),
['query-b'],
);
const oldItems = ids(
await list('categoryId=query-cat&isNew=false&pageSize=100'),
);
assert.ok(
oldItems.includes('query-a') &&
oldItems.includes('query-future'),
);
assert.ok(!oldItems.includes('query-b'));
const precise = await list(
'categoryId=query-cat&tag=precision&sort=hot',
);
assert.deepEqual(ids(precise), [
'query-huge-two',
'query-huge-one',
]);
assert.equal(
precise.items[0]?.downloads,
'9223372036854775906',
);
},
);
await context.test(
'预览只解析 ready/public/image,无效、私有、签名或非图片不泄漏;七牛 key 正确编码',
async () => {
assert.equal((await detail('query-b')).previewImg, null);
assert.equal((await detail('query-future')).previewImg, null);
assert.equal(
(await detail('query-html-item')).previewImg,
null,
);
assert.equal(
(await detail('query-c')).previewImg,
'https://external.example.test/preview.jpg',
);
assert.equal(
(await detail('query-qiniu-item')).previewImg,
'https://assets.example.test/previews/%E4%B8%AD%E6%96%87%20%E5%9B%BE%E7%89%87.jpg',
);
},
);
await context.test(
'稳定分页可取超过 100 条,总数与筛选一致,越页为空;非法/管理参数被拒绝',
async () => {
const first = await list(
'categoryId=query-cat&tag=tie&pageSize=1',
);
const second = await list(
'categoryId=query-cat&tag=tie&pageSize=1&page=2',
);
assert.deepEqual(ids(first), ['query-tie-a']);
assert.deepEqual(ids(second), ['query-tie-b']);
assert.equal(first.hasNext, true);
assert.equal(second.hasNext, false);
await prisma.worksheet.createMany({
data: Array.from({ length: 121 }, (_, i) => ({
id: `query-page-${String(i).padStart(3, '0')}`,
title: '分页题型',
subtitle: '',
categoryId: 'query-pages',
path: '/test',
ageMin: 3,
ageMax: 5,
difficulty: 1,
previewAssetId: 'query-pending',
status: 'active',
createdAt: old,
updatedAt: old,
})),
});
const pageOne = await list(
'categoryId=query-pages&pageSize=100',
);
const pageTwo = await list(
'categoryId=query-pages&pageSize=100&page=2',
);
assert.equal(pageOne.total, 121);
assert.equal(pageOne.items.length, 100);
assert.equal(pageTwo.items.length, 21);
assert.equal(
new Set([...ids(pageOne), ...ids(pageTwo)]).size,
121,
);
assert.equal(pageTwo.hasNext, false);
const beyond = await list('categoryId=query-pages&page=9');
assert.equal(beyond.total, 121);
assert.deepEqual(beyond.items, []);
for (const query of [
'status=hidden',
'rawStats=true',
'grade=23',
'pageSize=101',
'ageBand=3-5',
'page=1&page=2',
'isHot=1',
'grades=1,,2',
])
assert.equal(
(await response(`worksheets?${query}`)).status,
400,
);
},
);
await context.test('Swagger 公共声明与查询参数已注册', async () => {
const result = await fetch(`${origin}/api-docs-json`);
const doc = (await result.json()) as {
paths: Record<
string,
{
get: {
security: unknown[];
parameters: Array<{ name: string }>;
};
}
>;
};
const route = doc.paths['/api/v1/worksheets']!.get;
assert.deepEqual(route.security, []);
for (const name of [
'categoryId',
'grade',
'grades',
'ageBand',
'tag',
'sort',
'isHot',
'isNew',
'page',
'pageSize',
])
assert.ok(
route.parameters.some((param) => param.name === name),
);
});
},
);
@@ -0,0 +1,194 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { test } from 'node:test';
import { PrismaService } from '../src/database/prisma.service';
import { WorksheetImporter } from '../src/imports/worksheet-import';
import { testConfig } from './test-config';
import { worksheetFixture as fixture } from './worksheet-import.fixture';
const databaseUrl = process.env.TEST_DATABASE_URL;
test(
'真实 PostgreSQL:题型元数据导入、待迁移资产、对账、幂等与整批回滚',
{ skip: !databaseUrl },
async (context) => {
const url = new URL(databaseUrl!);
assert.ok(['127.0.0.1', 'localhost'].includes(url.hostname));
assert.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
const db = new PrismaService({
...testConfig,
databaseUrl: databaseUrl!,
});
context.after(() => db.onModuleDestroy());
const prisma = db.client;
const importer = new WorksheetImporter(db);
await prisma.category.create({
data: {
status: 'active',
id: fixture.category,
name: '导入验证',
icon: '📐',
color: '#fff',
},
});
const run = (rows: unknown[], dryRun = false, metadataOnly = true) => {
const source = rows.map((row) => JSON.stringify(row)).join('\n');
return importer.importExport(source, {
dryRun,
metadataOnly,
requestId: randomUUID(),
sourceSha256: createHash('sha256').update(source).digest('hex'),
});
};
const counts = async () => ({
assets: await prisma.asset.count(),
worksheets: await prisma.worksheet.count(),
audits: await prisma.adminAuditLog.count(),
});
await context.test(
'默认拒绝未就绪预览;预演及无效分类不写任何数据',
async () => {
const before = await counts();
await assert.rejects(run([fixture], false, false), {
status: 409,
});
await assert.rejects(
run([{ ...fixture, category: 'missing-category' }]),
{ status: 400 },
);
const preview = await run([fixture], true);
assert.equal(preview.inserted, 1);
assert.equal(preview.pendingPreviews, 1);
assert.equal(preview.readyForPublicUse, false);
assert.deepEqual(await counts(), before);
},
);
await context.test(
'同批保存原 ID、原始计数、时间、旧年级及 pending 资产,不伪造资源或年级关系',
async () => {
const report = await run([{ ...fixture, grade: 23 }]);
assert.equal(report.inserted, 1);
assert.equal(report.assetsInserted, 1);
assert.equal(report.gradeRelationsInserted, 0);
assert.equal(report.warnings[0]?.originalValue, 23);
const row = await prisma.worksheet.findUniqueOrThrow({
where: { id: fixture._id },
});
assert.equal(row.grade, 23);
assert.equal(row.downloads, 23n);
assert.equal(row.downloadsSeed, 12n);
assert.equal(
row.createdAt.toISOString(),
fixture.createdAt.$date,
);
assert.equal(
row.updatedAt.toISOString(),
fixture.updatedAt.$date,
);
assert.equal(row.firstPublishedAt, null);
assert.equal(row.contentUpdatedAt, null);
assert.equal(row.status, 'active');
const asset = await prisma.asset.findUniqueOrThrow({
where: { id: row.previewAssetId },
});
assert.equal(asset.sourceRef, fixture.previewImg);
assert.equal(
asset.sourceRefHash,
createHash('sha256')
.update(fixture.previewImg)
.digest('hex'),
);
assert.equal(asset.status, 'pending');
assert.equal(asset.objectKey, null);
assert.equal(asset.bucket, null);
assert.equal(asset.mimeType, null);
assert.equal(
await prisma.worksheetGrade.count({
where: { worksheetId: fixture._id },
}),
0,
);
assert.equal(
await prisma.worksheetResource.count({
where: { worksheetId: fixture._id },
}),
0,
);
const audit = await prisma.adminAuditLog.findFirstOrThrow({
where: {
resourceId: fixture._id,
action: 'worksheet.import',
},
});
assert.equal(audit.actorType, 'migration');
assert.equal(
(audit.afterData as { downloads: string }).downloads,
'23',
);
},
);
await context.test(
'重导不新增或更新时间,共享预览复用资产;不同旧快照禁止覆盖',
async () => {
const before = await counts();
const stored = await prisma.worksheet.findUniqueOrThrow({
where: { id: fixture._id },
});
const repeated = await run([{ ...fixture, grade: 23 }]);
assert.equal(repeated.unchanged, 1);
assert.equal(repeated.assetsInserted, 0);
assert.deepEqual(await counts(), before);
assert.deepEqual(
await prisma.worksheet.findUniqueOrThrow({
where: { id: fixture._id },
}),
stored,
);
const shared = await run([
{ ...fixture, _id: 'worksheet-import-shared' },
]);
assert.equal(shared.assetsInserted, 0);
assert.equal(shared.assetsReused, 1);
assert.equal(await prisma.asset.count(), before.assets);
await assert.rejects(
run([{ ...fixture, grade: 23, downloads: 24 }]),
{ status: 409 },
);
assert.equal(
(
await prisma.worksheet.findUniqueOrThrow({
where: { id: fixture._id },
})
).downloads,
23n,
);
},
);
await context.test(
'后半批冲突会回滚前面新增题型、资产及审计',
async () => {
const before = await counts();
await assert.rejects(
run([
{
...fixture,
_id: 'worksheet-import-rollback',
previewImg:
'cloud://fixture-env/assets/previews/rollback.jpg',
},
{ ...fixture, grade: 23, title: '禁止覆盖' },
]),
{ status: 409 },
);
assert.deepEqual(await counts(), before);
assert.equal(
await prisma.worksheet.count({
where: { id: 'worksheet-import-rollback' },
}),
0,
);
},
);
},
);
@@ -0,0 +1,25 @@
/** 最小旧格式样本,不依赖附件路径或线上文件。 */
export const worksheetFixture = {
_id: 'worksheet-import-one',
title: '导入测试',
subtitle: '',
category: 'worksheet-import-test',
subcategory: '',
path: '/mathPages/mathDraw/mathDraw?id=worksheet-import-one',
ageMin: 3,
ageMax: 6,
grade: 0,
difficulty: 1,
previewImg: 'cloud://fixture-env/assets/previews/one.jpg',
tags: ['数学'],
isNew: false,
isHot: true,
sortOrder: 5,
downloads: 23,
likes: 2,
status: 'active',
createdAt: { $date: '2026-04-24T05:26:16.507Z' },
updatedAt: { $date: '2026-09-07T05:43:03.511Z' },
downloads_seed: 12,
likes_seed: 14,
};
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { parseWorksheetExport } from '../src/imports/worksheet-import';
import { worksheetFixture as fixture } from './worksheet-import.fixture';
test('题型导入:JSON Lines、BSON 日期、计数和缺省字段准确保留', () => {
const row = {
...fixture,
grade: 23,
downloads_seed: undefined,
contentUpdatedAt: { $date: '2026-06-01T08:00:00+08:00' },
};
const result = parseWorksheetExport(`\uFEFF${JSON.stringify(row)}\n`);
const stored = result.rows[0]!.data;
assert.equal(stored.downloads, 23n);
assert.equal(stored.downloadsSeed, 0n);
assert.equal(stored.likesSeed, 14n);
assert.equal(stored.grade, 23);
assert.equal(stored.subcategory, null);
assert.equal(stored.createdAt.toISOString(), fixture.createdAt.$date);
assert.equal(
stored.contentUpdatedAt?.toISOString(),
'2026-06-01T00:00:00.000Z',
);
assert.equal(stored.firstPublishedAt, null);
assert.equal(stored.rendererKey, null);
assert.equal(result.warnings.length, 2);
assert.equal(
parseWorksheetExport(JSON.stringify([fixture])).rows[0]?.data.path,
fixture.path,
);
});
test('题型导入:拒绝未知字段、重复 ID、非法日期、类型及计数精度丢失', () => {
for (const patch of [
{ unknown: true },
{ title: 42 },
{ category: null },
{ difficulty: 0 },
{ ageMax: 1 },
{ tags: ['a', 1] },
{ isNew: 'false' },
{ downloads: -1 },
{ downloads: Number.MAX_SAFE_INTEGER + 1 },
{ grade: 32768 },
{ status: 'deleted' },
{ previewImg: 'https://example.com/a.jpg' },
{ createdAt: { $date: '2026-02-30T00:00:00Z' } },
{ createdAt: { $date: '2026-01-01' } },
{ createdAt: null },
{ createdAt: { $date: fixture.createdAt.$date, injected: true } },
{ updatedAt: { $date: '2025-01-01T00:00:00Z' } },
])
assert.throws(() =>
parseWorksheetExport(JSON.stringify([{ ...fixture, ...patch }])),
);
assert.throws(() =>
parseWorksheetExport(JSON.stringify([fixture, fixture])),
);
for (const value of ['', '[]', '{}', '{broken'])
assert.throws(() => parseWorksheetExport(value));
});
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
parseWorksheetQuery,
WorksheetQueryDto,
} from '../src/worksheets/worksheet-query.dto';
test('题型查询参数:零年级、多个年级去重、false 与组合筛选可准确解析', () => {
const query = parseWorksheetQuery({
grade: '0',
ageBand: '3-4',
tag: ' 数学 ',
isHot: 'false',
});
assert.deepEqual(query.grades, [0]);
assert.equal(query.isHot, false);
assert.equal(query.page, 1);
assert.equal(query.pageSize, 20);
assert.equal(query.tag, '数学');
assert.deepEqual(
parseWorksheetQuery({ grades: '-4,0,6,0' }).grades,
[-4, 0, 6],
);
assert.equal(parseWorksheetQuery({ age: '0' }).age, 0);
});
test('题型查询参数:非法值、重复参数和越权状态字段不能被静默忽略', () => {
const bad: unknown[] = [
{ page: '0' },
{ page: '1e2' },
{ pageSize: '101' },
{ page: ['1', '2'] },
{ grade: '23' },
{ grade: '0', grades: '1' },
{ grades: '0,,1' },
{ grades: '' },
{ ageBand: '3-5' },
{ age: '4', ageBand: '3-4' },
{ isHot: '0' },
{ isNew: 'False' },
{ sort: 'random' },
{ tag: ['a', 'b'] },
{ tag: '' },
{ status: 'draft' },
{ rawStats: 'true' },
];
for (const query of bad)
assert.throws(() => parseWorksheetQuery(query as WorksheetQueryDto));
});
+17 -7
View File
@@ -1,6 +1,8 @@
# Doodle Store Server 基础设置
> 更新日期:2026-10-13。已实现基础配置、日志、中间件、Swagger 与 JWT 守卫,业务登录和 Prisma 账户查询待接入。
> 更新日期:2026-10-15。已实现基础配置、日志、中间件、Swagger、微信登录、Prisma 账户查询与 JWT 守卫。
后续业务接入见 [用户鉴权与登录设计](./用户鉴权与登录设计.md);服务端登录模式和 reason 已接入,小程序恢复及 Web Cookie 会话仍是目标设计。当前接口与验证说明见 [服务端登录接入说明](./服务端登录接入说明.md)。
参考 `atlas-seller-kit/apps/api` 的应用工厂、结构化日志、requestId、请求体限制、默认鉴权与错误处理。保持本项目 NestJS 11 和 `/api/v1` 契约,不复制卖家工作台的多租户、Redis、对象存储或业务模块。
@@ -8,6 +10,8 @@
从 `doodle-store` 根目录执行 `pnpm dev`。服务只读取 `apps/server/.env`,不向上搜索根目录的 Compose `.env`;部署环境变量优先于 `.env`。应用工厂不自行监听端口,main 负责读取配置和启动,测试使用临时端口。
开发命令通过 `apps/server/scripts/dev.mjs` 启动 Nest watch,在收到实际监听成功的通知后,输出 `Swagger: <完整地址>` 并使用系统默认浏览器打开一次。热更新不重复打开;`SWAGGER_ENABLED=false` 时只提示文档已关闭。链接使用实际监听 IP/端口,通配地址转换为本机地址。通知独立于日志级别;打开浏览器失败只提示手动访问,不终止服务。`pnpm start` 保留服务端启动行为,不自动打开浏览器,`server_started` 结构化日志增加 `swagger_url`。
新环境先复制 `apps/server/.env.example` 为 `apps/server/.env`,设置 JWT_SECRET。生成随机密钥的示例命令:
```bash
@@ -19,7 +23,7 @@ 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 整数 |
| HOST / PORT | 127.0.0.1 / 8800;端口须为 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 |
@@ -28,8 +32,12 @@ node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
| JWT_SECRET | 必填,至少 32 字节;拒绝空值、过短或 change-me 占位值 |
| JWT_ISSUER / JWT_AUDIENCE | doodle-store / doodle-mini |
| JWT_EXPIRES_IN_SECONDS | 86400,即 24 小时;允许 60~604800 秒 |
| WECHAT_MINI_APP_ID | 小程序 AppID;必填,必须与 AppSecret 成对提供 |
| WECHAT_MINI_APP_SECRET | 必填;无默认密钥,仅服务端持有 |
DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接数据库,也不读取七牛凭证。健康检查成功只证明 HTTP 进程可用。
微信配置写入 `apps/server/.env`,不写根目录 Compose `.env`。服务端通过 `ServerConfig.wechatMiniAppId/wechatMiniAppSecret` 读取;两项去除首尾空白后校验非空且不含内部空白,错误不回显实际值。登录时使用 code2Session 验证身份,启动校验不代表已验证微信凭据真实有效。AppSecret 与 JWT_SECRET 是独立密钥,不得混用、下发或输出到日志。
DATABASE_URL 现在必填,必须为 PostgreSQL URL 且使用 public schema;Prisma 单应用连接池最多 5 条连接,连接超时 2 秒、语句超时 5 秒,应用关闭时释放。GET /health 只证明 HTTP 进程可用,GET /health/ready 检查数据库连通性;七牛待接入。
## 2. 请求处理
@@ -40,11 +48,11 @@ DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接
- 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}`,不重复包装健康检查。
- 错误统一为 `{code: HTTP状态码, message: 安全提示, requestId}`,保留 HTTP 状态;JSON 格式错误 400、超限 413、未登录 401、无权限 403、未预期错误 500。可信业务错误新增有限 reason,限流错误携带 Retry-After;成功响应沿用 `{code:0,message,data}`,不重复包装健康检查。
## 3. Swagger
开发默认地址:`http://127.0.0.1:3000/api-docs`;OpenAPI JSON:`/api-docs-json`。Swagger 反映实际注册路由,不展示计划中尚未实现的业务接口。
开发默认地址:`http://127.0.0.1:8800/api-docs`;OpenAPI JSON:`/api-docs-json`。Swagger 反映实际注册路由,不展示计划中尚未实现的业务接口。
使用 access-token Bearer JWT 安全定义,健康检查声明为公开。Swagger 不持久保存浏览器 token。生产默认关闭;如部署时显式开启,应通过部署入口限制访问。Swagger 是直接注册的文档路由,不由控制器 JWT 守卫保护。
@@ -56,12 +64,14 @@ DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接
验签后通过 ACCOUNT_READER 查询账户,要求用户存在、active、userId 一致且 authVersion 匹配。已注销、禁用或版本失效返回 401;不满足角色返回 403。数据库查询失败不降级为“JWT 有效即可通过”。
**当前 ACCOUNT_READER 是拒绝放行的接入占位实现**:有效 token 请求受保护接口时返回 503。后续 Prisma 用户模块必须替换为真实数据库读取;微信 code2Session、账户登录、注销和业务 API 尚未实现。测试中的账户读取替身和 probe 路由只在测试模块注册,不会进入正式服务。
ACCOUNT_READER 现已接入真实 Prisma 查询;数据库失败返回 503/DATABASE_UNAVAILABLE,不降级放行。POST /auth/wechat/login 接入微信 code2Session、start/resume、唯一冲突处理及行锁;GET /me 返回本人摘要。资料修改、注销及其他业务 API 待实现。测试中的账户读取替身、微信 HTTP 替身和 probe 路由只在测试中使用。
## 5. 验证
使用 `pnpm check` 验证格式、ESLint、类型、构建与测试。集成测试覆盖健康检查、前缀、Swagger 开关与 Bearer 定义、CORS、安全头、requestId、JSON/urlencoded、超限和 DTO 校验、日志脱敏、JWT 伪造/过期/错误签名及 claims、账户禁用/缺失/版本失效、服务端角色校验和未接入账户查询时的拒绝放行。
使用 `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;检查进程已关闭,未修改开发数据库或云服务器。
2026-10-15:接入微信静默登录与真实账户读取后,`pnpm check` 的 20 项常规测试和独立 `pnpm test:db` 的 8 项检查通过;构建产物真实启动/数据库连接通过。详细边界与记录见 [服务端登录接入说明](./服务端登录接入说明.md)。
基于 [NestJS Swagger 文档](https://docs.nestjs.com/openapi/introduction) 的运行时文档生成方式及 [ValidationPipe 文档](https://docs.nestjs.com/techniques/validation) 的 DTO 校验机制实现;依赖锁定兼容当前 NestJS 11 的具体版本。
@@ -133,3 +133,7 @@ worksheet.is_new 暂保留兼容:新接口以时间派生为准,旧接口仅
列表默认仅功能,资源/all 通过显式 target 选择;返回目标类型及资源独立预览。取消精确定位收藏记录或资源 ID,旧按功能取消不得删除内容收藏。资源缺失或下架只使详情不可用,内容 ID 不清空,仍可取消。注销清理两种收藏,仅功能关系贡献影响功能 likes。
数据库不强制资源外键以保留历史,发布内容不允许重新归属另一 worksheet。需要收藏某次算法生成结果、保存用户涂色进度或精确结果快照时另行设计;当前资源收藏只表示选中的完整底图内容。
## 发布可见性补充(2026-10-16)
沿用 worksheets 与 worksheet_resources 的 draft/active/hidden,不新增布尔上线字段。categories 增加同名 status,新建默认草稿;上级未上线会阻止下级公开读取,不覆盖下级状态。管理预览、上下架、首发时间及快照失效已实现,资源公开读取/生成/下载仍待后续接口接入相同策略。详见 [内容发布与可见性](./内容发布与可见性.md)。
@@ -0,0 +1,23 @@
{
"source": "database_export-_oMVCNYtUGVT.json",
"sourceSha256": "02a5a2d26c1801e7e77e8dc2449aebd5b74e11d49a358ed957f3cd11ca290d11",
"requestId": "categories-import-1a32b65c-194b-442b-b97c-c3759aa521be",
"dryRun": false,
"batchAt": "2026-10-15T05:35:43.575Z",
"total": 6,
"inserted": 6,
"updated": 0,
"unchanged": 0,
"missingCreatedAt": 6,
"missingUpdatedAt": 6,
"recoveredFromDatabase": true,
"note": "导入事务成功,初次保存报告的相对路径无效;此报告依据已提交的分类时间、同批审计及源文件哈希补记。",
"changes": [
{ "id": "math", "action": "insert" },
{ "id": "puzzle", "action": "insert" },
{ "id": "pinyin", "action": "insert" },
{ "id": "chinese", "action": "insert" },
{ "id": "english", "action": "insert" },
{ "id": "craft", "action": "insert" }
]
}
@@ -0,0 +1,39 @@
{
"source": "database_export-_oMVCNYtUGVT.json",
"sourceSha256": "02a5a2d26c1801e7e77e8dc2449aebd5b74e11d49a358ed957f3cd11ca290d11",
"requestId": "categories-import-d11d1569-947b-4000-9fea-4e86acc45e44",
"dryRun": false,
"batchAt": "2026-10-15T05:38:17.482Z",
"total": 6,
"inserted": 0,
"updated": 0,
"unchanged": 6,
"missingCreatedAt": 6,
"missingUpdatedAt": 6,
"changes": [
{
"id": "math",
"action": "unchanged"
},
{
"id": "puzzle",
"action": "unchanged"
},
{
"id": "pinyin",
"action": "unchanged"
},
{
"id": "chinese",
"action": "unchanged"
},
{
"id": "english",
"action": "unchanged"
},
{
"id": "craft",
"action": "unchanged"
}
]
}
@@ -0,0 +1,451 @@
{
"source": "worksheets.json",
"sourceSha256": "184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150",
"requestId": "worksheets-import-ce45ffac-2272-424e-acfd-1a93a6dd4379",
"dryRun": true,
"mode": "metadata-only",
"batchAt": "2026-10-16T05:59:12.184Z",
"total": 51,
"inserted": 51,
"updated": 0,
"unchanged": 0,
"isolated": 0,
"assetsInserted": 51,
"assetsReused": 0,
"pendingPreviews": 51,
"readyPreviews": 0,
"readyForPublicUse": false,
"gradeRelationsInserted": 0,
"warnings": [
{
"id": "break-ten",
"field": "grade",
"reason": "旧兼容值超出新年级范围;原样保留,不写 worksheet_grades,须人工审核",
"originalValue": 23
},
{
"id": "pinyin-tracing",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-tracing",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-daily",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-daily",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-tracing-v2",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-tracing-v2",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation-v2",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation-v2",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-sheet",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-sheet",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-daily-checkin",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-daily-checkin",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pen-control-mix",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pen-control-mix",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "word-test-sheet",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "word-test-sheet",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-reading",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-reading",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-connect",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-connect",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
}
],
"totals": {
"downloads": "4203",
"likes": "217",
"downloadsSeed": "629",
"likesSeed": "666"
},
"changes": [
{
"id": "letter-tracing-daily-checkin",
"action": "insert",
"previewAssetId": "preview_ed9bd61247311b9e31222b98d768e50cbecd45be290eea2c09f869687bcc47a3",
"previewStatus": "pending"
},
{
"id": "letter-tracing-single",
"action": "insert",
"previewAssetId": "preview_292ec2303d74360769ca5ff9e3c2651bc27862c770c007320ba2d2d3e4a23424",
"previewStatus": "pending"
},
{
"id": "letter-tracing-upper-lower",
"action": "insert",
"previewAssetId": "preview_f8edb05d6280ea86cc9b16bc097612621dd8105e8c2fd47b9dc704c80f7331b1",
"previewStatus": "pending"
},
{
"id": "letter-tracing-case-pairing",
"action": "insert",
"previewAssetId": "preview_924b82c9ff669c42a7567a89554dcbc0fd2c03e37746f9e802715bc056b8ee32",
"previewStatus": "pending"
},
{
"id": "letter-tracing-two-column",
"action": "insert",
"previewAssetId": "preview_fa04443ab3c32112e76453578024a507bfa34ded181eb43e81d9b37d6a129db5",
"previewStatus": "pending"
},
{
"id": "letter-tracing-three",
"action": "insert",
"previewAssetId": "preview_ec7592fc601ff5304b2c1fecea748f921e8937990303732b27fa83943c40c55b",
"previewStatus": "pending"
},
{
"id": "letter-tracing-half",
"action": "insert",
"previewAssetId": "preview_aa75a233bec6e5a7bf928fae6ffc66cade0d997774c3b0d2d316ee60aaa6e6b6",
"previewStatus": "pending"
},
{
"id": "number-find",
"action": "insert",
"previewAssetId": "preview_1a26a050bd297bd83e9682f1c378f78b6f9ee877ecc48b289225c0b51737645e",
"previewStatus": "pending"
},
{
"id": "counting-matching",
"action": "insert",
"previewAssetId": "preview_cc9d75451a0ee4da7a04e664bd80d8ab9eda02f6fdb3cdd46eef8d0a071e4c54",
"previewStatus": "pending"
},
{
"id": "number-coloring",
"action": "insert",
"previewAssetId": "preview_3d9edf7690ffd34ab68ce3d2a60833fcba04b9c02c09c70fa1209f49a1ea66cd",
"previewStatus": "pending"
},
{
"id": "addition-5",
"action": "insert",
"previewAssetId": "preview_940163cc8631cf219c6776fb16b642782a9cec276faf36c6e82fd9fd88256abe",
"previewStatus": "pending"
},
{
"id": "missing-number",
"action": "insert",
"previewAssetId": "preview_204ad59ba9d1af25e066b5ff11fb46740a7d442f6e3676b95ddc7b8b0163f041",
"previewStatus": "pending"
},
{
"id": "compare",
"action": "insert",
"previewAssetId": "preview_3db2d8a91fd6c7ac0b1dca93d66c416059338e699ccc71c096283d869abf09d2",
"previewStatus": "pending"
},
{
"id": "counting-select",
"action": "insert",
"previewAssetId": "preview_c977fd235d232c10e81b9a27f008c8f9d06ebe288ad87172b1f4ede94e113553",
"previewStatus": "pending"
},
{
"id": "number-decompose",
"action": "insert",
"previewAssetId": "preview_897d397176b357eacb05d1e0252a1dd8e6ba720f5e16ac0154714648f631a9fc",
"previewStatus": "pending"
},
{
"id": "number-decompose-20",
"action": "insert",
"previewAssetId": "preview_f281beff18b2d16a2752f1b7a89b3aec66d7ab4e7620c2879926d619045a7a53",
"previewStatus": "pending"
},
{
"id": "number-sort",
"action": "insert",
"previewAssetId": "preview_ab742f76c9a602ddc62fea8cafa01aa63d11dacceaccb87e09447601eb6a86d1",
"previewStatus": "pending"
},
{
"id": "number-object-match",
"action": "insert",
"previewAssetId": "preview_377bd7c09c52eb09275b06ee9502badb8877af8fb26f64db8fbb89074adb234d",
"previewStatus": "pending"
},
{
"id": "make-ten",
"action": "insert",
"previewAssetId": "preview_aa0e070da59aab8cdd4375815e0ff15cbe110846836d81bcc9bc193f3a9f8100",
"previewStatus": "pending"
},
{
"id": "break-ten",
"action": "insert",
"previewAssetId": "preview_28204937f027aa9c1021538876302094d1537af88ff7184db8a510f8b46fbc92",
"previewStatus": "pending"
},
{
"id": "flat-ten",
"action": "insert",
"previewAssetId": "preview_bf0d27f22c0ecf5b3c5881a14d82e175463e7beb0e607d331466380ed3292013",
"previewStatus": "pending"
},
{
"id": "borrow-ten",
"action": "insert",
"previewAssetId": "preview_6da16aac3c0777b40fcd29199dcce4723d290846c8c2334ccd25561456325a3e",
"previewStatus": "pending"
},
{
"id": "one-digit-addition",
"action": "insert",
"previewAssetId": "preview_79de07b8e1e2b0aeedcc69c1e194b053c448bc470d7a258acd51108961f41671",
"previewStatus": "pending"
},
{
"id": "practice-addition",
"action": "insert",
"previewAssetId": "preview_1e14f4accf739b96848de4f641f11ddd87ba726e372faae4f6718682d32ef680",
"previewStatus": "pending"
},
{
"id": "practice-subtraction",
"action": "insert",
"previewAssetId": "preview_45fd3e7f901b2482fad78b4a6204ee86f163a5329d19ee8ae5b07ccb1faff6ee",
"previewStatus": "pending"
},
{
"id": "practice-mixed",
"action": "insert",
"previewAssetId": "preview_4d7e439d13e3ff1e6dc9777d9c7963517620ccc26e26e4a8d18c977187ca33cd",
"previewStatus": "pending"
},
{
"id": "multiplication-table",
"action": "insert",
"previewAssetId": "preview_f611cfaaaf53b070ee40cb80ccba2b9f2594535c78ee9230e9b1145af7bf7d60",
"previewStatus": "pending"
},
{
"id": "color-shape-match",
"action": "insert",
"previewAssetId": "preview_739a548debba7949768438bd7f4424c772595316609442d40b72db63e60d3715",
"previewStatus": "pending"
},
{
"id": "shape-symbol",
"action": "insert",
"previewAssetId": "preview_0cccc724e9b6cd7d291aca127adda619889c4575ca290edd8350ed5e70822bb2",
"previewStatus": "pending"
},
{
"id": "position-coloring",
"action": "insert",
"previewAssetId": "preview_cca47918d46442771054bd34f96f5325b6eb3817fb82812e4cc8ece1b7373fa6",
"previewStatus": "pending"
},
{
"id": "color-pattern",
"action": "insert",
"previewAssetId": "preview_e0d545a5003655ca755efc2dfb39d4ece749ce71d7757b6e5fa213cfc0117f0f",
"previewStatus": "pending"
},
{
"id": "match-connect",
"action": "insert",
"previewAssetId": "preview_9031059317b1f8717659484b2634edacf25f94712e84bc13415302af7bd6d78c",
"previewStatus": "pending"
},
{
"id": "line-recognition",
"action": "insert",
"previewAssetId": "preview_a617caadf17a862293016607d7eeb57e21ce0d0bf8f59f4e05a58ac6f843be4b",
"previewStatus": "pending"
},
{
"id": "grid-reasoning",
"action": "insert",
"previewAssetId": "preview_b63cba1e2ca40ea06b20291708c91e3be43b1d0e01a74cdf54ac66c66317a29f",
"previewStatus": "pending"
},
{
"id": "code-connect",
"action": "insert",
"previewAssetId": "preview_364bd44e33e8595da9305168176f7266474f2fe9e121c548ba5268e297969f40",
"previewStatus": "pending"
},
{
"id": "dot-connect",
"action": "insert",
"previewAssetId": "preview_ef7a884a30627fdd9349e81b4cb7662fc5048b937e13a5c00ad02122f887f82a",
"previewStatus": "pending"
},
{
"id": "grid-drawing-5x5",
"action": "insert",
"previewAssetId": "preview_16d270e7e3ad234805b1a6b3af5507d56f6c8363a79985ebe7d365d8fc88001d",
"previewStatus": "pending"
},
{
"id": "grid-drawing-3x3",
"action": "insert",
"previewAssetId": "preview_9b4f5d0cd417cc12807d92b9dd674e08054685514e9188d87d43dee41b46796c",
"previewStatus": "pending"
},
{
"id": "word-coloring-grid",
"action": "insert",
"previewAssetId": "preview_184aa7580b91f411717bbe6e3f4c580ceed9e604da3b75031ce29c74250caaa9",
"previewStatus": "pending"
},
{
"id": "word-coloring-find",
"action": "insert",
"previewAssetId": "preview_0fec84f97f2a04b734c5839a2b396206800375fd1d47782bb14b84f2715af334",
"previewStatus": "pending"
},
{
"id": "pinyin-tracing",
"action": "insert",
"previewAssetId": "preview_2270e1d234135860d31689591b7b9220fe9fc84c37207d50c39a611daf94f52f",
"previewStatus": "pending"
},
{
"id": "pinyin-dictation",
"action": "insert",
"previewAssetId": "preview_6a32f38f23e609a289f2f4da36a3eeafad9e424d2b8cf4a654c61519c9ad8e79",
"previewStatus": "pending"
},
{
"id": "pinyin-daily",
"action": "insert",
"previewAssetId": "preview_47413e9d6c96556f94fdcfa5dd1517d304575170998b706d73af516e27f29257",
"previewStatus": "pending"
},
{
"id": "pinyin-tracing-v2",
"action": "insert",
"previewAssetId": "preview_1dab372e03f70063d6a2269d34c61d9c922b15f6dd2f67315e004255f5d765eb",
"previewStatus": "pending"
},
{
"id": "pinyin-dictation-v2",
"action": "insert",
"previewAssetId": "preview_57195cf48980b0028475b986d1ca3aea32b98ae63e4a8541d6657cf6a653fcd3",
"previewStatus": "pending"
},
{
"id": "hanzi-sheet",
"action": "insert",
"previewAssetId": "preview_2303428a8e71f057a9bdd87817d300fce5e64b5b4c813e53cfd13de2ab46e2b2",
"previewStatus": "pending"
},
{
"id": "hanzi-daily-checkin",
"action": "insert",
"previewAssetId": "preview_bb81b0f638bfb8a37bd48d6d41cef3528918413f1e6aa1e4cfca68769fc07c70",
"previewStatus": "pending"
},
{
"id": "pen-control-mix",
"action": "insert",
"previewAssetId": "preview_9306467297d403ec07f422e61227c2300faf5ef6e8e232d9eb1737b0a263309d",
"previewStatus": "pending"
},
{
"id": "word-test-sheet",
"action": "insert",
"previewAssetId": "preview_34e9636918372c0dd5fbfcb2f017f4ac700f40dd5553560bd9233320f6eeb3a9",
"previewStatus": "pending"
},
{
"id": "clock-reading",
"action": "insert",
"previewAssetId": "preview_dd2205e72b203854845ce228ac67a0fb4960bed4ea194ca207edbcf8365da979",
"previewStatus": "pending"
},
{
"id": "clock-connect",
"action": "insert",
"previewAssetId": "preview_7d8efbec45918618d2a8d36007342343844f9190ef6b0266f11cd6069616ccf8",
"previewStatus": "pending"
}
]
}
+451
View File
@@ -0,0 +1,451 @@
{
"source": "worksheets.json",
"sourceSha256": "184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150",
"requestId": "worksheets-import-d33a9942-819e-487c-b650-c5b7904b0faf",
"dryRun": false,
"mode": "metadata-only",
"batchAt": "2026-10-16T05:59:22.266Z",
"total": 51,
"inserted": 51,
"updated": 0,
"unchanged": 0,
"isolated": 0,
"assetsInserted": 51,
"assetsReused": 0,
"pendingPreviews": 51,
"readyPreviews": 0,
"readyForPublicUse": false,
"gradeRelationsInserted": 0,
"warnings": [
{
"id": "break-ten",
"field": "grade",
"reason": "旧兼容值超出新年级范围;原样保留,不写 worksheet_grades,须人工审核",
"originalValue": 23
},
{
"id": "pinyin-tracing",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-tracing",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-daily",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-daily",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-tracing-v2",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-tracing-v2",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation-v2",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation-v2",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-sheet",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-sheet",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-daily-checkin",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-daily-checkin",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pen-control-mix",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pen-control-mix",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "word-test-sheet",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "word-test-sheet",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-reading",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-reading",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-connect",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-connect",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
}
],
"totals": {
"downloads": "4203",
"likes": "217",
"downloadsSeed": "629",
"likesSeed": "666"
},
"changes": [
{
"id": "letter-tracing-daily-checkin",
"action": "insert",
"previewAssetId": "preview_ed9bd61247311b9e31222b98d768e50cbecd45be290eea2c09f869687bcc47a3",
"previewStatus": "pending"
},
{
"id": "letter-tracing-single",
"action": "insert",
"previewAssetId": "preview_292ec2303d74360769ca5ff9e3c2651bc27862c770c007320ba2d2d3e4a23424",
"previewStatus": "pending"
},
{
"id": "letter-tracing-upper-lower",
"action": "insert",
"previewAssetId": "preview_f8edb05d6280ea86cc9b16bc097612621dd8105e8c2fd47b9dc704c80f7331b1",
"previewStatus": "pending"
},
{
"id": "letter-tracing-case-pairing",
"action": "insert",
"previewAssetId": "preview_924b82c9ff669c42a7567a89554dcbc0fd2c03e37746f9e802715bc056b8ee32",
"previewStatus": "pending"
},
{
"id": "letter-tracing-two-column",
"action": "insert",
"previewAssetId": "preview_fa04443ab3c32112e76453578024a507bfa34ded181eb43e81d9b37d6a129db5",
"previewStatus": "pending"
},
{
"id": "letter-tracing-three",
"action": "insert",
"previewAssetId": "preview_ec7592fc601ff5304b2c1fecea748f921e8937990303732b27fa83943c40c55b",
"previewStatus": "pending"
},
{
"id": "letter-tracing-half",
"action": "insert",
"previewAssetId": "preview_aa75a233bec6e5a7bf928fae6ffc66cade0d997774c3b0d2d316ee60aaa6e6b6",
"previewStatus": "pending"
},
{
"id": "number-find",
"action": "insert",
"previewAssetId": "preview_1a26a050bd297bd83e9682f1c378f78b6f9ee877ecc48b289225c0b51737645e",
"previewStatus": "pending"
},
{
"id": "counting-matching",
"action": "insert",
"previewAssetId": "preview_cc9d75451a0ee4da7a04e664bd80d8ab9eda02f6fdb3cdd46eef8d0a071e4c54",
"previewStatus": "pending"
},
{
"id": "number-coloring",
"action": "insert",
"previewAssetId": "preview_3d9edf7690ffd34ab68ce3d2a60833fcba04b9c02c09c70fa1209f49a1ea66cd",
"previewStatus": "pending"
},
{
"id": "addition-5",
"action": "insert",
"previewAssetId": "preview_940163cc8631cf219c6776fb16b642782a9cec276faf36c6e82fd9fd88256abe",
"previewStatus": "pending"
},
{
"id": "missing-number",
"action": "insert",
"previewAssetId": "preview_204ad59ba9d1af25e066b5ff11fb46740a7d442f6e3676b95ddc7b8b0163f041",
"previewStatus": "pending"
},
{
"id": "compare",
"action": "insert",
"previewAssetId": "preview_3db2d8a91fd6c7ac0b1dca93d66c416059338e699ccc71c096283d869abf09d2",
"previewStatus": "pending"
},
{
"id": "counting-select",
"action": "insert",
"previewAssetId": "preview_c977fd235d232c10e81b9a27f008c8f9d06ebe288ad87172b1f4ede94e113553",
"previewStatus": "pending"
},
{
"id": "number-decompose",
"action": "insert",
"previewAssetId": "preview_897d397176b357eacb05d1e0252a1dd8e6ba720f5e16ac0154714648f631a9fc",
"previewStatus": "pending"
},
{
"id": "number-decompose-20",
"action": "insert",
"previewAssetId": "preview_f281beff18b2d16a2752f1b7a89b3aec66d7ab4e7620c2879926d619045a7a53",
"previewStatus": "pending"
},
{
"id": "number-sort",
"action": "insert",
"previewAssetId": "preview_ab742f76c9a602ddc62fea8cafa01aa63d11dacceaccb87e09447601eb6a86d1",
"previewStatus": "pending"
},
{
"id": "number-object-match",
"action": "insert",
"previewAssetId": "preview_377bd7c09c52eb09275b06ee9502badb8877af8fb26f64db8fbb89074adb234d",
"previewStatus": "pending"
},
{
"id": "make-ten",
"action": "insert",
"previewAssetId": "preview_aa0e070da59aab8cdd4375815e0ff15cbe110846836d81bcc9bc193f3a9f8100",
"previewStatus": "pending"
},
{
"id": "break-ten",
"action": "insert",
"previewAssetId": "preview_28204937f027aa9c1021538876302094d1537af88ff7184db8a510f8b46fbc92",
"previewStatus": "pending"
},
{
"id": "flat-ten",
"action": "insert",
"previewAssetId": "preview_bf0d27f22c0ecf5b3c5881a14d82e175463e7beb0e607d331466380ed3292013",
"previewStatus": "pending"
},
{
"id": "borrow-ten",
"action": "insert",
"previewAssetId": "preview_6da16aac3c0777b40fcd29199dcce4723d290846c8c2334ccd25561456325a3e",
"previewStatus": "pending"
},
{
"id": "one-digit-addition",
"action": "insert",
"previewAssetId": "preview_79de07b8e1e2b0aeedcc69c1e194b053c448bc470d7a258acd51108961f41671",
"previewStatus": "pending"
},
{
"id": "practice-addition",
"action": "insert",
"previewAssetId": "preview_1e14f4accf739b96848de4f641f11ddd87ba726e372faae4f6718682d32ef680",
"previewStatus": "pending"
},
{
"id": "practice-subtraction",
"action": "insert",
"previewAssetId": "preview_45fd3e7f901b2482fad78b4a6204ee86f163a5329d19ee8ae5b07ccb1faff6ee",
"previewStatus": "pending"
},
{
"id": "practice-mixed",
"action": "insert",
"previewAssetId": "preview_4d7e439d13e3ff1e6dc9777d9c7963517620ccc26e26e4a8d18c977187ca33cd",
"previewStatus": "pending"
},
{
"id": "multiplication-table",
"action": "insert",
"previewAssetId": "preview_f611cfaaaf53b070ee40cb80ccba2b9f2594535c78ee9230e9b1145af7bf7d60",
"previewStatus": "pending"
},
{
"id": "color-shape-match",
"action": "insert",
"previewAssetId": "preview_739a548debba7949768438bd7f4424c772595316609442d40b72db63e60d3715",
"previewStatus": "pending"
},
{
"id": "shape-symbol",
"action": "insert",
"previewAssetId": "preview_0cccc724e9b6cd7d291aca127adda619889c4575ca290edd8350ed5e70822bb2",
"previewStatus": "pending"
},
{
"id": "position-coloring",
"action": "insert",
"previewAssetId": "preview_cca47918d46442771054bd34f96f5325b6eb3817fb82812e4cc8ece1b7373fa6",
"previewStatus": "pending"
},
{
"id": "color-pattern",
"action": "insert",
"previewAssetId": "preview_e0d545a5003655ca755efc2dfb39d4ece749ce71d7757b6e5fa213cfc0117f0f",
"previewStatus": "pending"
},
{
"id": "match-connect",
"action": "insert",
"previewAssetId": "preview_9031059317b1f8717659484b2634edacf25f94712e84bc13415302af7bd6d78c",
"previewStatus": "pending"
},
{
"id": "line-recognition",
"action": "insert",
"previewAssetId": "preview_a617caadf17a862293016607d7eeb57e21ce0d0bf8f59f4e05a58ac6f843be4b",
"previewStatus": "pending"
},
{
"id": "grid-reasoning",
"action": "insert",
"previewAssetId": "preview_b63cba1e2ca40ea06b20291708c91e3be43b1d0e01a74cdf54ac66c66317a29f",
"previewStatus": "pending"
},
{
"id": "code-connect",
"action": "insert",
"previewAssetId": "preview_364bd44e33e8595da9305168176f7266474f2fe9e121c548ba5268e297969f40",
"previewStatus": "pending"
},
{
"id": "dot-connect",
"action": "insert",
"previewAssetId": "preview_ef7a884a30627fdd9349e81b4cb7662fc5048b937e13a5c00ad02122f887f82a",
"previewStatus": "pending"
},
{
"id": "grid-drawing-5x5",
"action": "insert",
"previewAssetId": "preview_16d270e7e3ad234805b1a6b3af5507d56f6c8363a79985ebe7d365d8fc88001d",
"previewStatus": "pending"
},
{
"id": "grid-drawing-3x3",
"action": "insert",
"previewAssetId": "preview_9b4f5d0cd417cc12807d92b9dd674e08054685514e9188d87d43dee41b46796c",
"previewStatus": "pending"
},
{
"id": "word-coloring-grid",
"action": "insert",
"previewAssetId": "preview_184aa7580b91f411717bbe6e3f4c580ceed9e604da3b75031ce29c74250caaa9",
"previewStatus": "pending"
},
{
"id": "word-coloring-find",
"action": "insert",
"previewAssetId": "preview_0fec84f97f2a04b734c5839a2b396206800375fd1d47782bb14b84f2715af334",
"previewStatus": "pending"
},
{
"id": "pinyin-tracing",
"action": "insert",
"previewAssetId": "preview_2270e1d234135860d31689591b7b9220fe9fc84c37207d50c39a611daf94f52f",
"previewStatus": "pending"
},
{
"id": "pinyin-dictation",
"action": "insert",
"previewAssetId": "preview_6a32f38f23e609a289f2f4da36a3eeafad9e424d2b8cf4a654c61519c9ad8e79",
"previewStatus": "pending"
},
{
"id": "pinyin-daily",
"action": "insert",
"previewAssetId": "preview_47413e9d6c96556f94fdcfa5dd1517d304575170998b706d73af516e27f29257",
"previewStatus": "pending"
},
{
"id": "pinyin-tracing-v2",
"action": "insert",
"previewAssetId": "preview_1dab372e03f70063d6a2269d34c61d9c922b15f6dd2f67315e004255f5d765eb",
"previewStatus": "pending"
},
{
"id": "pinyin-dictation-v2",
"action": "insert",
"previewAssetId": "preview_57195cf48980b0028475b986d1ca3aea32b98ae63e4a8541d6657cf6a653fcd3",
"previewStatus": "pending"
},
{
"id": "hanzi-sheet",
"action": "insert",
"previewAssetId": "preview_2303428a8e71f057a9bdd87817d300fce5e64b5b4c813e53cfd13de2ab46e2b2",
"previewStatus": "pending"
},
{
"id": "hanzi-daily-checkin",
"action": "insert",
"previewAssetId": "preview_bb81b0f638bfb8a37bd48d6d41cef3528918413f1e6aa1e4cfca68769fc07c70",
"previewStatus": "pending"
},
{
"id": "pen-control-mix",
"action": "insert",
"previewAssetId": "preview_9306467297d403ec07f422e61227c2300faf5ef6e8e232d9eb1737b0a263309d",
"previewStatus": "pending"
},
{
"id": "word-test-sheet",
"action": "insert",
"previewAssetId": "preview_34e9636918372c0dd5fbfcb2f017f4ac700f40dd5553560bd9233320f6eeb3a9",
"previewStatus": "pending"
},
{
"id": "clock-reading",
"action": "insert",
"previewAssetId": "preview_dd2205e72b203854845ce228ac67a0fb4960bed4ea194ca207edbcf8365da979",
"previewStatus": "pending"
},
{
"id": "clock-connect",
"action": "insert",
"previewAssetId": "preview_7d8efbec45918618d2a8d36007342343844f9190ef6b0266f11cd6069616ccf8",
"previewStatus": "pending"
}
]
}
@@ -0,0 +1,451 @@
{
"source": "worksheets.json",
"sourceSha256": "184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150",
"requestId": "worksheets-import-44653144-d109-422c-83c5-d52d4cce08fc",
"dryRun": false,
"mode": "metadata-only",
"batchAt": "2026-10-16T06:00:05.491Z",
"total": 51,
"inserted": 0,
"updated": 0,
"unchanged": 51,
"isolated": 0,
"assetsInserted": 0,
"assetsReused": 51,
"pendingPreviews": 51,
"readyPreviews": 0,
"readyForPublicUse": false,
"gradeRelationsInserted": 0,
"warnings": [
{
"id": "break-ten",
"field": "grade",
"reason": "旧兼容值超出新年级范围;原样保留,不写 worksheet_grades,须人工审核",
"originalValue": 23
},
{
"id": "pinyin-tracing",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-tracing",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-daily",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-daily",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-tracing-v2",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-tracing-v2",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation-v2",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pinyin-dictation-v2",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-sheet",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-sheet",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-daily-checkin",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "hanzi-daily-checkin",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pen-control-mix",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "pen-control-mix",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "word-test-sheet",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "word-test-sheet",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-reading",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-reading",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-connect",
"field": "downloads_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
},
{
"id": "clock-connect",
"field": "likes_seed",
"reason": "源字段缺失,使用数据库约定默认值 0"
}
],
"totals": {
"downloads": "4203",
"likes": "217",
"downloadsSeed": "629",
"likesSeed": "666"
},
"changes": [
{
"id": "letter-tracing-daily-checkin",
"action": "unchanged",
"previewAssetId": "preview_ed9bd61247311b9e31222b98d768e50cbecd45be290eea2c09f869687bcc47a3",
"previewStatus": "pending"
},
{
"id": "letter-tracing-single",
"action": "unchanged",
"previewAssetId": "preview_292ec2303d74360769ca5ff9e3c2651bc27862c770c007320ba2d2d3e4a23424",
"previewStatus": "pending"
},
{
"id": "letter-tracing-upper-lower",
"action": "unchanged",
"previewAssetId": "preview_f8edb05d6280ea86cc9b16bc097612621dd8105e8c2fd47b9dc704c80f7331b1",
"previewStatus": "pending"
},
{
"id": "letter-tracing-case-pairing",
"action": "unchanged",
"previewAssetId": "preview_924b82c9ff669c42a7567a89554dcbc0fd2c03e37746f9e802715bc056b8ee32",
"previewStatus": "pending"
},
{
"id": "letter-tracing-two-column",
"action": "unchanged",
"previewAssetId": "preview_fa04443ab3c32112e76453578024a507bfa34ded181eb43e81d9b37d6a129db5",
"previewStatus": "pending"
},
{
"id": "letter-tracing-three",
"action": "unchanged",
"previewAssetId": "preview_ec7592fc601ff5304b2c1fecea748f921e8937990303732b27fa83943c40c55b",
"previewStatus": "pending"
},
{
"id": "letter-tracing-half",
"action": "unchanged",
"previewAssetId": "preview_aa75a233bec6e5a7bf928fae6ffc66cade0d997774c3b0d2d316ee60aaa6e6b6",
"previewStatus": "pending"
},
{
"id": "number-find",
"action": "unchanged",
"previewAssetId": "preview_1a26a050bd297bd83e9682f1c378f78b6f9ee877ecc48b289225c0b51737645e",
"previewStatus": "pending"
},
{
"id": "counting-matching",
"action": "unchanged",
"previewAssetId": "preview_cc9d75451a0ee4da7a04e664bd80d8ab9eda02f6fdb3cdd46eef8d0a071e4c54",
"previewStatus": "pending"
},
{
"id": "number-coloring",
"action": "unchanged",
"previewAssetId": "preview_3d9edf7690ffd34ab68ce3d2a60833fcba04b9c02c09c70fa1209f49a1ea66cd",
"previewStatus": "pending"
},
{
"id": "addition-5",
"action": "unchanged",
"previewAssetId": "preview_940163cc8631cf219c6776fb16b642782a9cec276faf36c6e82fd9fd88256abe",
"previewStatus": "pending"
},
{
"id": "missing-number",
"action": "unchanged",
"previewAssetId": "preview_204ad59ba9d1af25e066b5ff11fb46740a7d442f6e3676b95ddc7b8b0163f041",
"previewStatus": "pending"
},
{
"id": "compare",
"action": "unchanged",
"previewAssetId": "preview_3db2d8a91fd6c7ac0b1dca93d66c416059338e699ccc71c096283d869abf09d2",
"previewStatus": "pending"
},
{
"id": "counting-select",
"action": "unchanged",
"previewAssetId": "preview_c977fd235d232c10e81b9a27f008c8f9d06ebe288ad87172b1f4ede94e113553",
"previewStatus": "pending"
},
{
"id": "number-decompose",
"action": "unchanged",
"previewAssetId": "preview_897d397176b357eacb05d1e0252a1dd8e6ba720f5e16ac0154714648f631a9fc",
"previewStatus": "pending"
},
{
"id": "number-decompose-20",
"action": "unchanged",
"previewAssetId": "preview_f281beff18b2d16a2752f1b7a89b3aec66d7ab4e7620c2879926d619045a7a53",
"previewStatus": "pending"
},
{
"id": "number-sort",
"action": "unchanged",
"previewAssetId": "preview_ab742f76c9a602ddc62fea8cafa01aa63d11dacceaccb87e09447601eb6a86d1",
"previewStatus": "pending"
},
{
"id": "number-object-match",
"action": "unchanged",
"previewAssetId": "preview_377bd7c09c52eb09275b06ee9502badb8877af8fb26f64db8fbb89074adb234d",
"previewStatus": "pending"
},
{
"id": "make-ten",
"action": "unchanged",
"previewAssetId": "preview_aa0e070da59aab8cdd4375815e0ff15cbe110846836d81bcc9bc193f3a9f8100",
"previewStatus": "pending"
},
{
"id": "break-ten",
"action": "unchanged",
"previewAssetId": "preview_28204937f027aa9c1021538876302094d1537af88ff7184db8a510f8b46fbc92",
"previewStatus": "pending"
},
{
"id": "flat-ten",
"action": "unchanged",
"previewAssetId": "preview_bf0d27f22c0ecf5b3c5881a14d82e175463e7beb0e607d331466380ed3292013",
"previewStatus": "pending"
},
{
"id": "borrow-ten",
"action": "unchanged",
"previewAssetId": "preview_6da16aac3c0777b40fcd29199dcce4723d290846c8c2334ccd25561456325a3e",
"previewStatus": "pending"
},
{
"id": "one-digit-addition",
"action": "unchanged",
"previewAssetId": "preview_79de07b8e1e2b0aeedcc69c1e194b053c448bc470d7a258acd51108961f41671",
"previewStatus": "pending"
},
{
"id": "practice-addition",
"action": "unchanged",
"previewAssetId": "preview_1e14f4accf739b96848de4f641f11ddd87ba726e372faae4f6718682d32ef680",
"previewStatus": "pending"
},
{
"id": "practice-subtraction",
"action": "unchanged",
"previewAssetId": "preview_45fd3e7f901b2482fad78b4a6204ee86f163a5329d19ee8ae5b07ccb1faff6ee",
"previewStatus": "pending"
},
{
"id": "practice-mixed",
"action": "unchanged",
"previewAssetId": "preview_4d7e439d13e3ff1e6dc9777d9c7963517620ccc26e26e4a8d18c977187ca33cd",
"previewStatus": "pending"
},
{
"id": "multiplication-table",
"action": "unchanged",
"previewAssetId": "preview_f611cfaaaf53b070ee40cb80ccba2b9f2594535c78ee9230e9b1145af7bf7d60",
"previewStatus": "pending"
},
{
"id": "color-shape-match",
"action": "unchanged",
"previewAssetId": "preview_739a548debba7949768438bd7f4424c772595316609442d40b72db63e60d3715",
"previewStatus": "pending"
},
{
"id": "shape-symbol",
"action": "unchanged",
"previewAssetId": "preview_0cccc724e9b6cd7d291aca127adda619889c4575ca290edd8350ed5e70822bb2",
"previewStatus": "pending"
},
{
"id": "position-coloring",
"action": "unchanged",
"previewAssetId": "preview_cca47918d46442771054bd34f96f5325b6eb3817fb82812e4cc8ece1b7373fa6",
"previewStatus": "pending"
},
{
"id": "color-pattern",
"action": "unchanged",
"previewAssetId": "preview_e0d545a5003655ca755efc2dfb39d4ece749ce71d7757b6e5fa213cfc0117f0f",
"previewStatus": "pending"
},
{
"id": "match-connect",
"action": "unchanged",
"previewAssetId": "preview_9031059317b1f8717659484b2634edacf25f94712e84bc13415302af7bd6d78c",
"previewStatus": "pending"
},
{
"id": "line-recognition",
"action": "unchanged",
"previewAssetId": "preview_a617caadf17a862293016607d7eeb57e21ce0d0bf8f59f4e05a58ac6f843be4b",
"previewStatus": "pending"
},
{
"id": "grid-reasoning",
"action": "unchanged",
"previewAssetId": "preview_b63cba1e2ca40ea06b20291708c91e3be43b1d0e01a74cdf54ac66c66317a29f",
"previewStatus": "pending"
},
{
"id": "code-connect",
"action": "unchanged",
"previewAssetId": "preview_364bd44e33e8595da9305168176f7266474f2fe9e121c548ba5268e297969f40",
"previewStatus": "pending"
},
{
"id": "dot-connect",
"action": "unchanged",
"previewAssetId": "preview_ef7a884a30627fdd9349e81b4cb7662fc5048b937e13a5c00ad02122f887f82a",
"previewStatus": "pending"
},
{
"id": "grid-drawing-5x5",
"action": "unchanged",
"previewAssetId": "preview_16d270e7e3ad234805b1a6b3af5507d56f6c8363a79985ebe7d365d8fc88001d",
"previewStatus": "pending"
},
{
"id": "grid-drawing-3x3",
"action": "unchanged",
"previewAssetId": "preview_9b4f5d0cd417cc12807d92b9dd674e08054685514e9188d87d43dee41b46796c",
"previewStatus": "pending"
},
{
"id": "word-coloring-grid",
"action": "unchanged",
"previewAssetId": "preview_184aa7580b91f411717bbe6e3f4c580ceed9e604da3b75031ce29c74250caaa9",
"previewStatus": "pending"
},
{
"id": "word-coloring-find",
"action": "unchanged",
"previewAssetId": "preview_0fec84f97f2a04b734c5839a2b396206800375fd1d47782bb14b84f2715af334",
"previewStatus": "pending"
},
{
"id": "pinyin-tracing",
"action": "unchanged",
"previewAssetId": "preview_2270e1d234135860d31689591b7b9220fe9fc84c37207d50c39a611daf94f52f",
"previewStatus": "pending"
},
{
"id": "pinyin-dictation",
"action": "unchanged",
"previewAssetId": "preview_6a32f38f23e609a289f2f4da36a3eeafad9e424d2b8cf4a654c61519c9ad8e79",
"previewStatus": "pending"
},
{
"id": "pinyin-daily",
"action": "unchanged",
"previewAssetId": "preview_47413e9d6c96556f94fdcfa5dd1517d304575170998b706d73af516e27f29257",
"previewStatus": "pending"
},
{
"id": "pinyin-tracing-v2",
"action": "unchanged",
"previewAssetId": "preview_1dab372e03f70063d6a2269d34c61d9c922b15f6dd2f67315e004255f5d765eb",
"previewStatus": "pending"
},
{
"id": "pinyin-dictation-v2",
"action": "unchanged",
"previewAssetId": "preview_57195cf48980b0028475b986d1ca3aea32b98ae63e4a8541d6657cf6a653fcd3",
"previewStatus": "pending"
},
{
"id": "hanzi-sheet",
"action": "unchanged",
"previewAssetId": "preview_2303428a8e71f057a9bdd87817d300fce5e64b5b4c813e53cfd13de2ab46e2b2",
"previewStatus": "pending"
},
{
"id": "hanzi-daily-checkin",
"action": "unchanged",
"previewAssetId": "preview_bb81b0f638bfb8a37bd48d6d41cef3528918413f1e6aa1e4cfca68769fc07c70",
"previewStatus": "pending"
},
{
"id": "pen-control-mix",
"action": "unchanged",
"previewAssetId": "preview_9306467297d403ec07f422e61227c2300faf5ef6e8e232d9eb1737b0a263309d",
"previewStatus": "pending"
},
{
"id": "word-test-sheet",
"action": "unchanged",
"previewAssetId": "preview_34e9636918372c0dd5fbfcb2f017f4ac700f40dd5553560bd9233320f6eeb3a9",
"previewStatus": "pending"
},
{
"id": "clock-reading",
"action": "unchanged",
"previewAssetId": "preview_dd2205e72b203854845ce228ac67a0fb4960bed4ea194ca207edbcf8365da979",
"previewStatus": "pending"
},
{
"id": "clock-connect",
"action": "unchanged",
"previewAssetId": "preview_7d8efbec45918618d2a8d36007342343844f9190ef6b0266f11cd6069616ccf8",
"previewStatus": "pending"
}
]
}
@@ -0,0 +1,33 @@
{
"verifiedAt": "2026-10-16T06:00:05.547Z",
"sourceSha256": "184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150",
"sourceRows": 51,
"databaseRows": 51,
"allSourceFieldsMatched": true,
"categories": 6,
"pendingPreviewAssets": 51,
"migrationAudits": 51,
"gradeRelations": 0,
"resourceRows": 0,
"gradeWarnings": [
{
"id": "break-ten",
"legacyGrade": 23,
"resolution": "保留旧兼容列,未生成新年级关系"
}
],
"counterTotals": {
"downloads": "4203",
"likes": "217",
"downloadsSeed": "629",
"likesSeed": "666"
},
"reimport": {
"inserted": 0,
"updated": 0,
"unchanged": 51,
"assetsInserted": 0,
"timestampsAndAuditsUnchanged": true
},
"readyForPublicUse": false
}
@@ -0,0 +1,105 @@
# 小程序云存储图片迁移七牛调研
调研日期:2026-10-16。范围:官方能力核实及本项目实施方案;未执行云端文件下载、上传、权限调整或删除。
## 结论
可以迁移。针对已经导入的 51 个题型预览,建议采用 **原云开发授权下载 → 本地备份与校验 → 七牛上传 → 回读校验 → 更新现有 assets 为 ready**。这是一套文件迁移任务,保持题型 ID、preview_asset_id 及原计数/业务时间不变,不必重新导入题型。
七牛直接抓取 HTTPS URL 也可作为备选,但需要先解决链接授权、失效与源站访问限制。当前只有 51 张预览,优先选择便于留存原文件、校验和重试的本地中转方式。这一优先级是结合项目规模作出的工程判断。
## 官方能力核实
| 环节 | 已核实能力 | 对本项目的意义 |
| -------------- | ------------------------------------------- | -------------------------------------------- |
| 云开发文件获取 | SDK 可按 fileID 下载,或换取 HTTPS 下载链接 | 使用现有 source_ref,不从文件名猜下载地址 |
| 云开发目录备份 | CLI 支持下载文件和目录 | 可先备份 assets/previews 目录 |
| 七牛上传 | Node.js SDK 支持服务端上传及 stat 查询 | 能集成现有 Node.js/Prisma 工程并自动回填映射 |
| 七牛抓取 | fetch 可抓取指定 URL 到指定 bucket/key | 可减少本地转发,但不直接识别 cloud:// |
上述能力依据 [CloudBase 文件管理](https://docs.cloudbase.net/storage/sdk)、[CloudBase CLI 云存储](https://docs.cloudbase.net/cli-v1/storage)、[七牛 Node.js SDK](https://developer.qiniu.com/kodo/sdk/nodejs) 和 [七牛第三方资源抓取](https://developer.qiniu.com/kodo/api/fetch)。
## 获取源图片的两条路径
### 路径 A:CLI / 管理 SDK 下载原文件
CloudBase 官方 CLI 提供 `tcb storage download <cloudPath> <localPath>`,下载目录使用 `--dir`。以下是选择并确认原云开发环境后的目录下载形式,仅供实施时参考,尚未在本项目账号执行:
```bash
# 先确认安装版本支持的参数,并用有权限的账号登录、明确原环境
tcb storage download --help
# 在明确原环境的配置/参数下,备份预览目录
tcb storage download assets/previews ./cloud-backup/previews --dir
```
目录下载可能包括不在当前 51 条题型中的旧文件;上传和数据库绑定仍按这 51 条资产清单选择。若要完整备份,后续另行盘点头像、字体和页面配置等目录,不将未引用文件直接混入本批次。[官方目录下载说明](https://docs.cloudbase.net/cli-v1/storage)
自动化也可选 `@cloudbase/manager-node` 的文件/目录下载能力。官方管理 SDK 使用 Tencent Cloud SecretId/SecretKey 和 envId 初始化;实际必须确认账号能够管理这个小程序原环境,不能把已有 WECHAT_MINI_APP_SECRET 当作腾讯云管理密钥。原小程序云环境与目标本地 PostgreSQL 是两套系统,目标选用 PostgreSQL 不意味着源端要改用 CloudBase 的 PG 存储接口。[管理 SDK 存储文档](https://docs.cloudbase.net/api-reference/manager/node/storage)
官方管理 SDK 还说明目录不存在时可能不下载且不抛错,因此必须按清单核对实际下载的 51 个文件,不能只看命令退出成功。SDK 版本的参数形式存在差异,实施时以锁定版本为准。[目录下载说明](https://docs.cloudbase.net/api-reference/manager/node/storage)
如果账号看不到原环境,先核实绑定和管理权限,不创建替代环境或调整原环境为公开读来绕过授权。具体账号关系仍需实际控制台确认。[CloudBase 账号与环境关联](https://docs.cloudbase.net/faq/account)
### 路径 B:原环境兑换下载链接
在已授权的原云开发运行环境中,使用相应 SDK 的 getTempFileURL,再由本地任务下载。官方存储文档说明每批最多 50 个链接,因此本批 51 个 fileID 可分成 50 + 1,逐项检查成功状态;授权链接在使用前获取,过期重新兑换。[官方临时链接说明](https://docs.cloudbase.net/storage/sdk)
不要把所有 HTTPS 地址都认作临时地址:公有读文件可获得稳定链接,私有读文件通常返回有时限的授权链接,有效期随接口和配置而异。带 sign/t 等授权参数的地址应按临时访问凭证处理,不写入长期资产字段,不放入迁移报告或日志。资产身份继续使用原 fileID/source_ref_hash,而非某次生成的 URL。[官方权限与链接说明](https://docs.cloudbase.net/storage/sdk)
若需要临时管理云函数,只允许管理员/受控任务调用,限定原环境及本次文件清单,完成后撤除;本次不部署该函数。wx.cloud、wx-server-sdk、CloudBase 管理 SDK 的运行位置和凭据不同,实施时明确选择一种,先验证 1 张原图,不能照搬另一 SDK 的认证示例。
## 迁入七牛的选择
### 推荐:备份后由 Node.js 上传
利用七牛服务端 SDK 上传本地文件,并用资源查询取得目标信息;大批量纯文件同步也可用官方 qshell 的 qupload,但本项目还需自动维护资产和数据库审计,选择与现有工程集成的任务更合适。[七牛 SDK](https://developer.qiniu.com/kodo/sdk/nodejs)、[qshell 工具](https://developer.qiniu.com/kodo/1302/qshell)
建议实施流程:
1. 从本地 assets 筛选 purpose=preview、status=pending,导出 assetId → source_ref → 本地备份路径清单,记录原环境和源导出摘要。
2. 下载原文件,检查返回的是可解码图片,记录实际 MIME、字节数和 SHA-256;不把错误 HTML 页面或仅改扩展名的内容当作图片。
3. 将对象上传到开发环境专属空间/路径。对象 key 使用资产 ID 和内容版本,例如 `dev/previews/<assetId>/<sha256>.jpg`;扩展名以实际文件类型为准。这是本项目命名建议。
4. 核对返回 key、大小和内容。不同算法的 hash 不直接比较;本批只有 51 张,可全部从七牛原文件回读并计算同一 SHA-256,与备份逐一比对。
5. 文件操作全部完成后,每个资产使用短事务锁行并复核原 source_ref/status,再写 bucket、object_key、object_ref_hash、mime_type、size_bytes、checksum、status=ready 和迁移审计。
6. 保留资产 ID、原 source_ref/source_ref_hash 和题型 preview_asset_id,题型原始 createdAt/updatedAt、downloads/likes/seed 不因换存储而重写。
7. ready 资产重新执行时核对目标后跳过;失败记录可重试状态及原因,保留原备份。同名异内容不能直接覆盖。上传成功但数据库提交失败时,按既定 key 查询和校验,避免重试生成另一对象。
七牛资源查询能返回目标 hash、fsize、mimeType 等信息;完整内容回读、SHA-256 和事务顺序是本项目提出的验收设计,不是已完成的迁移结果。[七牛 SDK 资源查询](https://developer.qiniu.com/kodo/sdk/nodejs)
### 备选:七牛 fetch / batchfetch
流程为 `fileID → 新鲜有效的 HTTPS 下载链接 → 七牛 fetch → 校验 → 回填资产`。七牛官方 fetch 每次处理一个文件,可指定目标 key;受源站 IP、UA 等访问限制影响。批量可用 qshell batchfetch,仍需逐项核实结果,不能仅按任务提交成功把资产改成 ready。[fetch API](https://developer.qiniu.com/kodo/api/fetch)、[qshell](https://developer.qiniu.com/kodo/1302/qshell)
该路线适合已有可靠链接清单、文件较多的后续场景。签名过期就重新取得链接,不把旧签名清单作为长期重试数据。抓取后也要验证大小/类型/内容,403、404 及失败行单独记录。是否能从七牛网络访问当前源站,需要用本账号实际测试,官方能力支持不代表该环境已经验证成功。
七牛还提供 URL/第三方存储迁移工具。直接对接 S3/COS 需要真实源存储权限和相应端点;仅凭 cloud:// fileID 或小程序 AppSecret,不足以推定可以使用底层存储迁移通道。[七牛文件迁移工具](https://developer.qiniu.com/kodo/6284/file-backup-migration?portal_modal=1)
## 预览和头像存储与绑定
| 资源 | 本项目建议 | 绑定方式 |
| ------------------ | ------------------------------------------ | ------------------------------ |
| 题型预览、公共字体 | 公共资源空间,业务 API 生成稳定 HTTPS 地址 | 保留现有预览资产 ID |
| 用户头像 | 与预览共用公开空间,使用稳定 HTTPS 地址 | 迁移用户后按原 userId 核实归属 |
七牛私有空间下载需要授权 URL,公开空间可使用域名与对象 key 生成地址;生产资源域名应使用已配置的自定义域名,避免依赖测试域名。[七牛下载方式](https://developer.qiniu.com/kodo/kb/1321/how-to-acquire-the-outside-storage-file-links)
当前按用户确认,头像与预览等资源统一使用 QINIU_BUCKET 公开空间;开发/生产隔离方案仍需在接入时确定。仅在文件名里加入 private 或 avatars,并不等于完成访问控制。头像必须依据 users 导出中实际引用和身份关联迁移,不从文件名猜当前用户头像,也不批量绑定已经失联/注销用户的文件。
## 实施前需要的配置
- 源端:原 EnvId,以及能读取该环境文件的 CLI 登录/管理权限,或原环境受控 SDK 运行权限。
- 七牛:AccessKey/SecretKey、目标 Bucket、区域,以及配置 HTTPS 的资源域名;当前头像与其他资源共用公开空间。
- 本地:受控备份目录、文件清单和迁移报告目录。密钥仅放受控环境,不写进文档、命令历史或普通日志。
建议第一批只执行 51 张预览:先验证 1 张,再迁移全部;验收 `51 个引用 = 成功校验数 + 明确失败数`,全部成功后为 51 个 ready。失败不应混成成功,不改题型计数或审核外的年级数据。
后续再盘点头像、字体、分类文件图标、首页 JSON 嵌套图片、分享图及小程序硬编码/缓存中的 cloud://。旧版本仍可能使用旧引用,完成新版切换和兼容期核对前保留源文件。新页面从 API 获取七牛地址,按实际使用的微信网络接口配置合法域名,并通过真机验证。
当前建议的下一步是:确认原云开发下载权限与七牛开发空间配置,然后实现“下载备份、上传校验、资产回填”脚本及资源地址解析服务。上述调研不代表配置已齐备,也尚未修改现有 51 条 pending 资产。
## 本地同步工具已实现(2026-10-16)
原文件已由用户下载到 doodle-assets/cloud_backup。现已新增 [packages/asset-sync](../../packages/asset-sync/README.md) 独立脚本项目,可配置源目录、七牛 AK/SK、统一公开空间与对象前缀。默认预演,显式 --apply 才上传;支持 ETag/大小校验、同内容跳过、同名异内容冲突、有限重试和 JSON 报告。
本地已预演全部 62 个文件及题型预览 51 个文件,未配置真实凭据、未上传七牛,也未更新数据库。此工具只完成“已有备份 → 七牛文件同步”,首次推荐同步预览的命令为 `pnpm assets:sync --apply --only assets/previews`。后续资产回填、用户头像绑定、首页 JSON 重建和旧上传/定时任务切换仍需分别落实。
+58
View File
@@ -0,0 +1,58 @@
# 内容发布与可见性
2026-10-16。三张表统一使用 `status = draft / active / hidden`,不增加重复的 isPublished 布尔字段。draft 用于开发、编辑及测试,active 为上线,hidden 为下架。新增分类、题型、资源默认 draft。
## 公开规则
- 分类:自身及所有祖先均为 active。公开列表只返回从 active 根节点可达的分类;不存在、未公开、祖先未公开的详情统一 404。
- 题型:自身为 active,所属分类及全部祖先可见。全部、分类、年级、年龄、Tag、最新、热门、计数、分页和详情使用同一服务端规则。公开路由不能用 status、preview 或测试模式参数读取草稿;即使调用者是管理员,公开接口也不放宽。
- 资源:自身为 active,所属题型及其分类链均可见。管理预览提供派生 visible;资源公开读取/生成/下载接口尚未实现,后续必须复用相同规则再开放。
上级下架只改变有效可见性,不批量改写下级 status。恢复上级不会使原有草稿或下架内容自动上线。分类目录不根据题型数量自动隐藏;允许已上线的空分类。
## 管理接口
统一前缀 `/api/v1`,均要求有效本站 JWT 和服务端当前 active + admin 账户。体验版、客户端开关或自报角色不能替代鉴权。
| 方法 | 路径 | 用途 |
| ----- | ------------------------------------- | ----------------------------------------------------- |
| GET | /admin/categories | 全部状态的扁平分类列表 |
| GET | /admin/categories/:id | 分类管理详情 |
| POST | /admin/categories | 创建分类,省略 status 为 draft |
| PATCH | /admin/categories/:id | 原有字段及 status 的局部修改 |
| GET | /admin/worksheets | 原有组合筛选及分页,额外支持 status;省略查询全部状态 |
| GET | /admin/worksheets/:id | 题型卡片数据预览,含 status 和 visible |
| PATCH | /admin/worksheets/:id/status | 题型状态修改 |
| GET | /admin/worksheet-resources | 支持 worksheetId/status/page/pageSize |
| GET | /admin/worksheet-resources/:id | 资源卡片数据预览,含 status 和 visible |
| PATCH | /admin/worksheet-resources/:id/status | 资源状态修改 |
状态修改请求示例:`{"status":"hidden"}`。不接受其他字段或测试专用状态;非法值 400,不存在 404,题型/资源发布时上级未上线返回 409。管理预览沿用公开字段白名单,不返回任意渲染配置、私有文件、源文件地址或签名 URL;尚未提供服务端绘制测试图的接口。
所有写事务重新锁定、检查管理员账户,再按分类 → 父题型 → 资源顺序加锁;状态、首次发布时间、快照失效与审计同事务提交。相同状态重复提交不刷新时间、重复审计或增加快照版本。下架不清零统计,不删除收藏/下载历史,不重写内容更新时间。
第一次从未发布草稿转 active 登记 firstPublishedAt;重新上架保留首次发布时间。旧 active/hidden 的未知首次发布时间保持 NULL,历史审计用于防止改回草稿后伪装新品。上线内容直接编辑不提供旧版与草稿版并存的保证;版本管理或独立测试环境另行实施。
## 快照与小程序切换边界
分类状态/父级变化、分类删除、分类上线创建,以及题型/资源状态变化,会在同一事务清空服务端 `page_configs` 的 home/category/age,递增 version,清空 publishedVersion/publishedAt。重新构建页面配置时必须按上述可见性过滤;空配置不得降级返回旧快照。
这只撤销新后端数据库中的展示快照。当前新后端还没有首页构建、静态配置分发或 CDN 清理接口,不能据此声称微信云端/七牛已分发的旧 JSON 被撤回。已下载或缓存的公开文件也不具备内容保密能力。
`doodle-mini` 分类页已在 2026-10-16 接入新后端:通过全局环境配置读取公开分类及完整题型分页,已移除写死目录和静态题型兜底;失败展示重试,点击时重新读取公开详情,404 阻止跳转并刷新。开发入口使用 http://127.0.0.1:8800/api/v1,体验版/正式版仅使用待填写的 HTTPS 线上地址。
首页预拉取、登录、收藏、下载及其他页面仍使用旧云服务。新后端状态不会自动改写旧云数据或撤回旧静态文件;首页快照重建、缓存版本验证、其他入口/分享直达生成页的可用性检查尚待迁移。分类页接入不能视为全站上下架切换已完成。联调方式见 [小程序本地后端接口联调](../../../doodle-mini/docs/v3.5/本地后端接口联调.md)。
## 数据库升级及现有数据
新增 Prisma 迁移 `20261016000000_category_publication`;已有已登记基线的库执行 `pnpm --filter @doodle/server db:deploy`。已应用的 `0_v35_baseline` 不变。手工 SQL 升级使用 009,不能与同一 Prisma 增量重复执行;新空库可通过完整 Prisma 历史或最新 002/003 初始化。
增量迁移给既有分类设为 active,保留此前公开行为;新建默认 draft。分类旧导出省略 status 时,新导入记录为 draft,已有记录保留当前状态;显式 status 才修改,防止旧导出重新上架内容。题型旧导出与当前数据冲突时仍拒绝覆盖。
本地 doodle_dev 已升级:6 个分类维持 active,51 个题型及全部其他业务数据指纹与升级前一致,未写入测试用户或测试内容。哪些旧条目仍在开发由维护者确认后通过管理接口设为 draft/hidden,不按名称自动判断。
## 验证
`pnpm check` 检查格式、静态检查、类型与普通测试;`pnpm test:db` 使用独立临时 PostgreSQL 数据库并在完成后删除。发布回归覆盖:默认草稿、管理员与普通用户隔离、多层祖先下架、全部查询路径与详情防绕过、资源所属关系、发布前置检查、首次/重复发布、未知历史时间、并发幂等、同事务缓存失效、旧导出状态保留、审计失败回滚及事务内账户复核。
验证结果:`pnpm check` 通过(28 项普通测试,数据库测试由单独命令执行);`pnpm test:db` 共 39 项通过,临时数据库已清理。本地运行服务公开分类 6 条、题型 51 条,新增管理查询匿名均返回 401,Swagger 已注册全部新路由。
+99
View File
@@ -0,0 +1,99 @@
# 分类接口与数据导入
分类服务基于 v3.5 `categories` 表实现,供小程序及后续 Web 共用。保留原分类 ID 和父子关系,年级不属于分类树。当前服务端已实现,`doodle-mini` 分类页已通过统一环境配置切换至新后端,其他页面仍按迁移进度保留旧云服务。
## 接口
前缀为 `/api/v1`,成功响应统一为 `{ code: 0, message: "ok", data: ... }`。
| 方法 | 路径 | 权限 | 结果 |
| ------ | ----------------------- | ------------------- | --------------------------------------------------------------------------- |
| GET | `/categories` | 公开,无需登录 | 自身及全部祖先已上线分类的扁平数组,依次按 `sortOrder ASC, id ASC` 稳定排序 |
| GET | `/categories/:id` | 公开,无需登录 | 单个已上线分类;不存在或所属链未上线返回 404 |
| POST | `/admin/categories` | 当前 active + admin | 创建分类,返回 201;ID 重复返回 409 |
| PATCH | `/admin/categories/:id` | 当前 active + admin | 局部更新,返回 200;不存在返回 404 |
| DELETE | `/admin/categories/:id` | 当前 active + admin | 删除无引用分类,返回 200;不存在 404,有引用 409 |
列表没有分页、筛选或自动展开后代的参数,不沿用 CloudBase 单次查询的 100 条限制。分类表没有发布状态,不套用题型的 active 过滤规则。
公开分类结构示例:
```json
{
"id": "math",
"name": "数感启蒙",
"icon": "📐",
"color": "#CDB4DB",
"sortOrder": 0,
"parentId": null,
"status": "active",
"createdAt": "2026-10-15T05:35:43.575Z",
"updatedAt": "2026-10-15T05:35:43.575Z"
}
```
新版返回 `id`,不是旧云函数的 `_id`。小程序切换时应在接口适配层转换;不要据此修改数据库主键。公开响应不含 `iconAssetId`、对象存储 key 或内部关联资料。公共 TypeScript 契约位于 `packages/contracts/src/index.ts`,开发 Swagger 可查看实际注册接口。
创建请求包含必填 `id/name/icon/color`,可选 `status`(默认 draft)、`sortOrder`(默认 0)和 `parentId`(默认 null)。PATCH 只接受 `name/icon/color/sortOrder/parentId/status`,省略字段保持原值;不允许空对象、修改 ID、未知字段或将数字字符串隐式转换为排序值。`parentId` 为 null 或空串时规范化为根分类。
ID 区分大小写,最长 128 个字符,不含空白、控制字符或斜杠;名称/图标/颜色长度分别不超过 100/1024/32,排序必须为 PostgreSQL integer。名称、图标和颜色会去除首尾空白。
父分类必须存在,禁止自引用及多层循环。分类写入先锁定并复核管理员账户,再串行锁定分类树、读取最新状态并校验;导入也使用同一个分类表锁。公开读取不被该表锁阻塞。成功管理修改与前后快照审计在同一事务提交,重复提交相同值不刷新更新时间或新增审计。当前未开放 HTTP 导入接口。
新增、修改、删除均使用 `Authorization: Bearer <accessToken>`,以服务端当前账户的 `active + admin` 为准。未登录返回 401,普通用户或已降权管理员返回 403;禁用账户或凭证撤销返回 401。事务内再次复核账户,不能用 token 自报角色或旧身份绕过限制。
删除不接收请求体,成功响应为 `{ code: 0, message: "ok", data: { id: "分类ID" } }`。存在子分类或任何状态(draft/active/hidden)的题型引用时返回 409,须先调整这些引用;不级联删除子分类、题型或图标资产。删除记录保留 beforeData,afterData 为 NULL;审计失败则删除回滚。同一分类并发删除仅一次成功,其余返回 404。
本批图标均为文字/Emoji。文件图标的资产迁移、访问地址解析尚待七牛模块接入,当前创建/导入拒绝文件 URL;遇到已有文件资产图标时不下发原始地址。文件资产能力由迁移清单 S2-F04 跟进。
## 本地导入
在 `doodle-store` 根目录执行,文件参数使用绝对路径:
```bash
# 构建后执行预演,不写分类或审计
pnpm categories:import --file "/绝对路径/categories.json"
# 明确写入;整批成功或整批回滚
pnpm categories:import --file "/绝对路径/categories.json" --apply
# 可选报告路径也使用绝对路径;报告文件必须尚不存在
pnpm categories:import --file "/绝对路径/categories.json" --apply --report "/绝对路径/import-report.json"
```
工具读取 `apps/server/.env` 的 DATABASE_URL,允许显式环境覆盖,但仅允许本机 `doodle_dev`。若服务端已经构建,可用 `pnpm --filter @doodle/server categories:import ...` 避免重复构建。pnpm 的工作目录会切换到 server 包,使用绝对文件/报告路径可避免歧义。报告路径会在写库前检查并独占创建;不覆盖已有报告。
导入支持 JSON 数组与 CloudBase JSON Lines(本次附件格式),最多 10000 行、10 MiB;只接受已知分类字段以及 `_id/id/createdAt/updatedAt`。未知字段、重复 ID、冲突的 `_id/id`、类型错误、未知日期格式或无效父子树会整批拒绝。日期可选,存在时须为带时区的 ISO 字符串;尚不支持 CloudBase 特殊日期对象。
导入按合并后的完整分类树检查并按父先子后写入,不要求源文件按层级排序。新增行保留源 ID;已有行按业务字段比较,真正变化时才更新,并保留已有 `createdAt`,不会删除附件中没有的分类。文件缺时间时,新记录的两个时间使用同一导入批次时间;有变化的已有行只补更新时间。完全相同的重复导入不改变数据、时间或审计。
每条实际变化写入 `actor_type=migration` 审计,包含批次 requestId、源文件 SHA-256、前后快照及时间补齐说明。报告列出新增、更新、未变化及缺时间字段的数量;正式导入前应查看预演结果。
## 2026-10-15 本地导入结果
源文件:`database_export-_oMVCNYtUGVT.json`;SHA-256:`02a5a2d26c1801e7e77e8dc2449aebd5b74e11d49a358ed957f3cd11ca290d11`。
已向本地 `doodle_dev` 新增 6 条分类及 6 条迁移审计,未修改其他业务表。6 行均缺少原始创建/更新时间,统一使用 `2026-10-15T05:35:43.575Z`(北京时间 13:35:43.575),这是本次导入时间,并非历史创建时间。
| ID | 名称 | 图标 | sortOrder | parentId |
| ------- | -------- | ---- | --------- | -------- |
| math | 数感启蒙 | 📐 | 0 | null |
| puzzle | 益智游戏 | 🧩 | 1 | null |
| pinyin | 汉语拼音 | ǎ | 2 | null |
| chinese | 识字练字 | ✏️ | 3 | null |
| english | 英语启蒙 | 🔤 | 4 | null |
| craft | 创意手工 | 🌈 | 5 | null |
批次及时间补齐记录见 [首次导入报告](categories-import-2026-10-15.json)。原附件未改动。首次报告因相对路径无效未能保存,现已按数据库已提交记录补记,并修正了工具的报告路径预检查。
再次正式执行同一附件的结果为新增 0、更新 0、未变化 6,见 [重复导入复核报告](categories-reimport-2026-10-15.json)。实际 HTTP 列表/6 个详情已逐字段与附件核对一致,创建及更新时间保持首次批次值,审计仍为 6 条。开发库未新增任何测试用户;匿名读取成功,匿名管理写入返回 401。
## 验证
`pnpm check` 覆盖格式、静态检查、类型检查、构建及常规测试;`pnpm test:db` 在独立临时数据库验证登录鉴权和分类业务,结束后删除临时库,不向开发库写入测试账户。
分类验证涵盖公开读取、增删改的管理员权限及禁用/降权/撤销、非法字段、ID 大小写、稳定排序、局部更新、审计快照、无变化更新、事务内权限复核、两个管理员并发互设父级、子分类及各状态题型引用保护、删除审计失败回滚、并发/重复删除、子先父后导入、dry-run、缺失时间补齐、重复导入、保留创建时间、半批失败回滚及跨现有数据的循环校验。
## 发布控制(2026-10-16)
增加 GET /admin/categories 及 /admin/categories/:id,可读取所有状态;公开接口不接受草稿预览开关。新增分类默认 draft,旧分类增量升级保留 active。旧导出省略 status 时重导保留当前状态,新导入默认 draft;显式状态才更改。状态或父级修改同事务撤销新后端数据库页面快照并记录审计。详见 [内容发布与可见性](./内容发布与可见性.md)。
+15 -10
View File
@@ -2,15 +2,17 @@
> 数据库:PostgreSQL;ORM:Prisma。数据库选型已按用户确认更新。
>
> 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。
> 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;已接入 Prisma Schema 和迁移基线;线上集合、数据量和异常分布待 S0 盘点。
>
> 更新日期:2026-10-14。
> 更新日期:2026-10-15(登录与 Prisma 实施状态更新,业务表结构不变)。
>
> 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。
已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md)。2026-10-12 验证并创建最初 10 张表及两条配置;2026-10-14 执行 004/005/006/007/008 后,本地共有 13 张表。最新空库基线与对应增量升级结构已在临时库对照通过,多资源、多年级、资源计数及两种收藏约束、旧数据保留验证通过,临时库已清理;尚未创建 Prisma Schema 或导入线上数据。
登录与鉴权的后续实施设计见 [用户鉴权与登录设计](./用户鉴权与登录设计.md):优先小程序,细化微信登录、JWT、401 恢复及注销,并说明未来 Web 会话与跨端身份的演进;当前无需调整本文 13 张表。
本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 13 张表已在本地创建,业务 API 尚未实现。
已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md)。2026-10-12 验证并创建最初 10 张表及两条配置;2026-10-14 执行 004/005/006/007/008 后,本地共有 13 张表。最新空库基线与对应增量升级结构已在临时库对照通过,多资源、多年级、资源计数及两种收藏约束、旧数据保留验证通过,临时库已清理;2026-10-15 已接入 Prisma Schema 和迁移基线,尚未导入线上数据。
本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 13 张表已在本地创建,服务端微信登录、本人读取和账户鉴权已实现,其他业务 API 待接入。
## 1. 范围与依据
@@ -109,6 +111,7 @@ erDiagram
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| --------------------------- | -------------- | ----------- | ------------------------------------------------------------------ |
| status | varchar(16) | 否 / draft | CHECK:draft、active、hidden;公开可见还要求全部上级 active |
| id | varchar(128) | 否 | 原分类 ID,如 math、chinese;不重命名 |
| name | varchar(100) | 否 | 分类名 |
| icon | varchar(1024) | 否 | 保留图标名、字符等非文件内容;若是文件则转为稳定资产引用,接口解析 |
@@ -121,7 +124,9 @@ erDiagram
主键 id;索引 `(parent_id, sort_order, id)`;CHECK `parent_id IS NULL OR parent_id <> id`。父分类必须存在,导入按树层级排序;多层循环不能靠上述 CHECK 判断。删除被题型或子分类引用的分类返回冲突。
分类负责数学、语文、手工等内容方向,年级是独立适用维度,不把“一年级数学”“二年级数学”复制为分类,也不把年级放入 categories.parent_id。Web 的“年级 → 分类”菜单是组合查询,不代表数据库父子关系。年级名称及展示顺序先使用固定配置,不新增 grades/菜单表。categories 表结构保持不变。
分类负责数学、语文、手工等内容方向,年级是独立适用维度,不把“一年级数学”“二年级数学”复制为分类,也不把年级放入 categories.parent_id。Web 的“年级 → 分类”菜单是组合查询,不代表数据库父子关系。年级名称及展示顺序先使用固定配置,不新增 grades/菜单表。分类增加发布状态,不增加年级菜单字段。
分类、题型和资源统一使用三态发布规则;上级未上线时下级也不可公开读取,隐藏上级不改写下级自身状态。管理预览及快照失效规则见 [内容发布与可见性](./内容发布与可见性.md)。
### 3.3 worksheets — 题型
@@ -443,7 +448,7 @@ ORDER BY w.sort_order, w.updated_at DESC, w.id;
登录使用服务端配置的单一小程序 AppID,按微信返回的可信 `openid` 查找用户,沿用老 ID。首次并发登录以唯一约束兜底,冲突后读取已创建用户。首期 JWT 使用 userId + authVersion;每次鉴权核对用户存在、active 和版本,role 以当前服务端数据为准。
`auth_version` 是账户级登录凭证版本,默认 1。JWT 签发时写入相同的 `authVersion`,每次访问受保护接口时与数据库当前值比较;不一致返回 401,要求重新登录。需要强制全部设备退出或撤销已签发凭证时,由业务服务在事务中递增版本;普通登录和资料更新不递增,避免其他设备无故退出。它撤销的是该用户所有旧版本 token,不用于单设备退出。账户禁用及角色调整仍以数据库当前 status/role 校验,版本不能替代权限检查。现有守卫已实现版本比较,真实 AccountReader 与主动递增业务尚待接入。
`auth_version` 是账户级登录凭证版本,默认 1。JWT 签发时写入相同的 `authVersion`,每次访问受保护接口时与数据库当前值比较;不一致返回 401,要求重新登录。需要强制全部设备退出或撤销已签发凭证时,由业务服务在事务中递增版本;普通登录和资料更新不递增,避免其他设备无故退出。它撤销的是该用户所有旧版本 token,不用于单设备退出。账户禁用及角色调整仍以数据库当前 status/role 校验,版本不能替代权限检查。现有守卫与真实 AccountReader 已接入版本比较,主动递增版本的业务服务仍待接入。
涉及用户的写事务先锁用户行并核对状态,再锁题型行、需要时锁内容行;多个题型及同层内容按 ID 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。
@@ -494,7 +499,7 @@ DO NOTHING RETURNING id;
真实计数/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 >= 上限`(尚未迁移);2026-10-16 用户确认普通 `/worksheets?ageBand=...` 筛选改为 `ageMin <= 下限 AND ageMax >= 上限`,只返回完整覆盖所选年龄段的题型;分龄并列沿用 updatedAt DESC,再以 id 稳定排序。首页并列新增稳定 id 排序,并在固定样本验收中记录差异。
### 5.4.1 新品、运营推荐与两层下载统计
@@ -516,7 +521,7 @@ worksheets 表示功能,worksheet_resources 表示可选内容。新增“小
首期题型量小,可在短事务中完成查询与 JSON 构建;若构建移出事务,提交前比较 expectedVersion,冲突必须重新读取重建,不能覆盖新配置。自动刷新只覆盖 featured/hot,保留 sections 等手动配置。home/all 构建清 category/age,category 分支只重建 category;原调用中空数组被当作“沿用现有配置”的行为由兼容层保留。
公共 API 直接读取 DB 配置。若兼容期仍发布静态 JSON,提交后用 `page_config_publish` 执行记录重试,文件采用按版本不可变对象 key;publishedVersion 只在对应版本上传成功后前移,防止旧发布覆盖新版本。签名 URL 不进入公共静态配置,私有头像也不进入公共页面 JSON。静态发布失败不回滚已经提交的业务配置,保留上一可用文件并明确发布状态。
公共 API 直接读取 DB 配置。若兼容期仍发布静态 JSON,提交后用 `page_config_publish` 执行记录重试,文件采用按版本不可变对象 key;publishedVersion 只在对应版本上传成功后前移,防止旧发布覆盖新版本。签名 URL 不进入公共静态配置,私有头像也不进入公共页面 JSON。静态发布失败不回滚已经提交的业务配置,保留上一可用文件并明确发布状态。内容下架撤销属于例外:必须立即取消旧展示快照的可用标记,不能以“上一可用文件”继续交付下架内容;新后端当前会清空数据库快照,外部分发及客户端缓存撤销在切换前补齐。
## 6. CloudBase 导入与审计
@@ -594,7 +599,7 @@ S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字
## 9. SQL 与实际结构最终核查
核查日期:2026-10-14。当前已确认范围为 13 张表、135 个字段;本次核查未新增表或业务字段。
核查日期:2026-10-14。当时已确认范围为 13 张表、135 个字段;该次核查未新增表或业务字段。2026-10-16 新增 categories.status 后为 136 个字段,升级及发布规则见 [内容发布与可见性](./内容发布与可见性.md)。
| 核查项目 | 结果 |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
@@ -607,4 +612,4 @@ S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字
临时核查数据库全部清理;开发库只读比对,未写入样本。SQL 无结构差异需修复;已同步过时的 Prisma 目录说明、统一执行路径,修正关系图中的必填封面/可缺失历史关联及资产清理引用范围。
数据库约束通过不等于业务迁移完成。资源归属、当前发布状态/管理员权限、参数白名单、首次发布时间只写一次、动态新品判断、计数与审计事务、任务锁和租约仍由后续服务端实现;Prisma Schema 和迁移历史尚未接入。旧 grade/is_new/total_downloads 等字段继续按前述兼容规则保留,不作为新业务权威来源。
数据库约束通过不等于业务迁移完成。资源归属、当前发布状态/管理员权限、参数白名单、首次发布时间只写一次、动态新品判断、计数与审计事务、任务锁和租约仍由后续服务端实现;Prisma Schema 和迁移基线已接入。旧 grade/is_new/total_downloads 等字段继续按前述兼容规则保留,不作为新业务权威来源。
+100
View File
@@ -0,0 +1,100 @@
# 小程序静默登录与服务端鉴权接入
> 日期:2026-10-15。范围:doodle-store 服务端;小程序 HTTP 适配、资料修改/注销、七牛头像签名和 Web 登录另行实现。
设计依据:[用户鉴权与登录设计](./用户鉴权与登录设计.md)、[数据库设计](./数据库设计文档.md)。
## 1. 本次接口
统一前缀为 `/api/v1`,开发文档为 `/api-docs`。
| 接口 | 身份要求 | 行为 |
| ------------------------- | --------------------------------- | -------------------------------------------------- |
| `POST /auth/wechat/login` | 无本站 token,必须有真实微信 code | 验证微信身份、创建或恢复账户,签发默认 24 小时 JWT |
| `GET /me` | 本站 Bearer JWT | 查询当前账户的本人摘要和当前 role |
| `GET /health` | 公开 | HTTP 进程存活,不检查外部服务 |
| `GET /health/ready` | 公开 | 检查 PostgreSQL 连通性,不代表微信或七牛可用 |
小程序通过 `wx.login` 获得 code 后提交:
```http
POST /api/v1/auth/wechat/login
Content-Type: application/json
{
"code": "本次 wx.login 返回的 code",
"mode": "start"
}
```
- `start` 允许首次建号,已有用户仍沿用原 ID;默认 role=user、status=active、authVersion=1。
- `resume` 只恢复已有用户,账户不存在时返回 401/ACCOUNT_NOT_FOUND,不自动重建。
- 两个模式都先向微信验证 code,且都拒绝 disabled 用户;不能使用 start 重置禁用状态、角色或资料。
- 请求不接受 AppID、AppSecret、OpenID、UnionID、userId 或角色等额外字段;应用凭据固定取服务端配置。
成功结果为 `{code:0,message:"ok",data:{accessToken,tokenType,expiresIn,expiresAt,user}}`。user 只含 `id/nickName/avatarUrl/avatarUrlExpiresAt/createdAt`;`GET /me` 另含当前 role,不输出微信身份或凭证版本。
`expiresIn/expiresAt` 从实际 JWT claims 取得;后续请求使用 `Authorization: Bearer <accessToken>`。登录、本人接口的成功与失败响应均为 `Cache-Control: no-store`。
当前七牛签名未接入,头像 URL 和期限返回 NULL;数据库已有头像引用保留,不改成公开对象地址。资料修改、清资料、注销及账户版本主动递增服务不包含在本次接口中。
## 2. 鉴权与恢复
受保护接口默认执行:固定算法/issuer/audience 的 JWT 验证 → 查询当前 users → 核对 active 和 authVersion → 检查服务端 role。账户不存在、被禁用或版本失效时旧 token 立即被拒绝;角色变化无需等待 token 到期。
`GET /me` 的 userId 只取守卫上下文,不允许指定其他账户;即使 query 中出现 userId,也不会用于身份选择。管理 API 仍需 `@Roles('admin')` 和对应业务归属/写事务校验;本次没有新增公开管理员登录或任意用户 token 签发接口。
错误保留 HTTP 状态与 `{code,message,requestId}`,仅可信业务异常增加有限 reason:
| HTTP | reason | 小程序下一步 |
| ---- | ------------------------------------------- | ------------------------------------------------- |
| 401 | AUTH_REQUIRED / AUTH_INVALID / AUTH_EXPIRED | 按设计共用一次静默恢复;原业务最多重发一次 |
| 401 | AUTH_REVOKED | 停止后台恢复,用户主动继续时再验证微信 |
| 401 | ACCOUNT_NOT_FOUND | 清旧状态,停止后台建号;主动开始时才使用 start |
| 401 | ACCOUNT_DISABLED | 停止恢复;重新登录不解除禁用 |
| 400 | WECHAT_CODE_INVALID | 当前 code 不能重用;受总重试次数限制地获取新 code |
| 403 | WECHAT_LOGIN_BLOCKED | 微信风控拦截,停止自动重试 |
| 429 | RATE_LIMITED | 遵守 Retry-After,不能立即循环 |
| 503 | AUTH_UPSTREAM_UNAVAILABLE | 微信网络/配置/响应异常,退避;不传播微信 errmsg |
| 503 | DATABASE_UNAVAILABLE | 数据库暂不可用,不降级为只验 JWT 放行 |
微信调用固定 HTTPS 地址,5 秒超时,禁止重定向,不自动重试一次性 code。每分钟单 IP 最多 30 次登录兑换、可信 OpenID 最多 10 次;限流使用有界内存,进程重启会重置,当前用于单实例。来源 IP 按已配置的可信代理解析,不接受任意客户端自报的 X-Forwarded-For。
不记录 code、OpenID/UnionID、AppSecret、session_key、token 或原始微信错误。session_key 没有当前业务用途,因此不落库、不缓存、不下发。
## 3. 配置与启动
服务端只加载 `apps/server/.env`,以下配置现在都必须有效存在:
- `DATABASE_URL`:本地 PostgreSQL 连接串,使用 public schema。
- `WECHAT_MINI_APP_ID` 与 `WECHAT_MINI_APP_SECRET`:对应当前小程序,不能与 JWT_SECRET 混用。
- `JWT_SECRET`:至少 32 字节的独立密钥;issuer/audience/期限继续沿用 Server 基础设置。
从 doodle-store 根目录运行 `pnpm dev`。Prisma 客户端在 dev/build/typecheck/test 前生成;实际查询按需建立连接,HTTP 健康检查不因数据库故障被误判为进程退出。应用关闭时释放连接池。
真实 code 必须由相同 AppID 的小程序现取,不能用文档占位值或在公网开放测试后门。微信成功登录还需要合法请求域名、HTTPS 与真机/体验版联调;自动测试替换微信 HTTP 边界,不代表已经验证真实微信账号登录。
## 4. 数据库与迁移
Prisma Schema 映射现有 13 张表,客户端仅服务端可用。`0_v35_baseline` 保留原始 SQL 的 CHECK、C 排序规则、部分唯一索引和初始配置;不能用 Prisma 自动生成的简化 DDL 覆盖这些约束。
已有数据库在完整核对后,使用 `pnpm --filter @doodle/server exec prisma migrate resolve --applied 0_v35_baseline` 登记基线;该操作新增 Prisma 迁移记录,不重建业务表。空库使用 `pnpm --filter @doodle/server exec prisma migrate deploy`,会执行基线及初始配置。
后续升级新增迁移文件,已登记的基线保持不变。`sql/004`~`008` 是旧库升级档案,不在最新基线之上重跑;日常开发不使用 db push 或 reset 改写已建好的库。
登录先在事务外验证微信,再以 OpenID 唯一约束和用户行锁处理账户。并发 start 使用 INSERT ON CONFLICT DO NOTHING,不覆盖既有状态;resume 缺用户不写入。普通登录仅维护活跃时间;UnionID 只补空,不清空、不自动合并;非空冲突记录不含身份值的警告。
## 5. 验证方式
- `pnpm check`:格式、ESLint、类型、构建及无需真实数据库的测试;数据库专项测试在未提供隔离连接时跳过。
- `pnpm test:db`:从本地开发连接取得建库权限,创建随机命名的独立短期库,执行正式迁移,运行 HTTP + 真实 Prisma/PostgreSQL 测试,结束后删除短期库。
数据库专项脚本仅允许本机 doodle_dev 实例,测试代码还验证随机短期库名称;不会清空开发库,也不在开发库写入假用户。覆盖老 ID 延续、并发首次登录、大小写语义、resume 不建号、UnionID 保留、禁用/降权/撤销/删除、本人读取、参数注入、上游错误、限流及日志脱敏。
微信接口依据:[code2Session](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-login/api_code2session.html)。Prisma 接入参考:[CommonJS 客户端生成](https://www.prisma.io/docs/orm/v7/prisma-schema/overview/generators)、[既有数据库基线](https://www.prisma.io/docs/orm/v7/prisma-migrate/workflows/baselining)。
## 6. 本次验证记录
2026-10-15:`pnpm check` 通过,20 项常规测试通过;数据库专项在常规运行时按设计跳过,已通过独立 `pnpm test:db` 执行,8 项检查全部通过。真实微信调用使用 HTTP 替身验证固定地址、凭据来源、错误映射和一次性 code 不重试,尚未进行小程序真实 code 联调。
构建产物使用本机真实配置启动,HTTP 存活和数据库就绪均 200,本人接口无凭证 401,空 code 登录请求 400;检查服务已关闭。本地基线已登记,Prisma migrate diff 无差异;13 张业务表及两条初始配置保留,开发库没有写入测试用户,所有临时测试/参考库已删除。真实密钥未进入可提交文件。
+410
View File
@@ -0,0 +1,410 @@
# Doodle Store v3.5 用户鉴权与登录设计
> 更新日期:2026-10-15。
>
> 状态:方案及分阶段实施设计。服务端微信登录、真实账户查询、GET /me、JWT reason、限流已接入;小程序 HTTP 接入、资料生命周期及 Web 会话待实现。当前落点见 [服务端登录接入说明](./服务端登录接入说明.md)。
>
> 依据:[数据库设计](./数据库设计文档.md)、[云服务端迁移方案](../云服务端迁移方案.md)、[Server 基础设置](./Server基础设置.md)及下述实际代码。第一阶段优先完成小程序;Web 在此基础上演进。
## 1. 设计结论与阶段边界
首期采用 **微信身份验证 → Doodle 用户 → 24 小时 JWT → 每次请求核对当前账户**。小程序继续静默登录,浏览和本地生成不等待登录;收藏、历史、资料、上传等远端用户操作需要有效凭证。
| 问题 | 本次建议 |
| ------------------ | ------------------------------------------------------------------------------------------ |
| 用户是谁 | `users.id` 是唯一业务账户标识;迁入用户保留原 `_id`,收藏及下载继续关联它 |
| 如何证明微信身份 | 小程序 `wx.login` 取得一次性 code,服务端用固定 AppID/AppSecret 调用 `code2Session` |
| 后续 API 如何认人 | `Authorization: Bearer <Doodle JWT>`;不信任缓存的 user、OpenID、客户端 UUID 或自报角色 |
| 登录有效期 | 沿用现有默认 24 小时;到期重新执行微信静默登录,不增加 refresh token |
| 如何撤销权限 | 每次查询当前 `status`、`role`、`authVersion`;敏感写事务中再次检查 |
| 是否增加数据库表 | 小程序阶段不增加,现有 13 张表足够;会话表和登录身份表按后续触发条件引入 |
| 昵称、头像、手机号 | 昵称和头像由用户主动填写;登录不要求填写,不要求绑定手机号 |
| Web 如何共用账户 | 优先考虑通过现有小程序扫码确认登录,直接进入同一 `users.id`;网站微信 OAuth 是独立接入选项 |
| Web 如何保存登录态 | 后续使用服务端会话及 HttpOnly Cookie,与小程序共用账户检查和业务权限规则 |
这里的 24 小时是 Doodle 凭证期限,与微信 `session_key` 的期限、本地资料缓存期限各自独立。`authVersion` 撤销的是旧凭证,不会禁止持有人再次通过微信证明身份;需要禁止登录时必须禁用账户。
暂不建设密码注册、短信验证码、复杂 RBAC、设备管理、第三方登录平台或统一 OAuth 服务。未来 Web 框架尚未选择,本方案不依赖 Next.js 或特定部署商。
## 2. 现状与需要补齐的部分
### 2.1 doodle-mini 实际实现
| 代码 | 已有行为 | 迁移时的处理 |
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| [app.ts](../../../doodle-mini/miniprogram/app.ts) | `onLaunch` 非阻塞调用 `getUser()`;仍初始化 CloudBase | 保留非阻塞启动,替换身份及业务请求;CloudBase 随兼容阶段退场 |
| [utils/auth.ts](../../../doodle-mini/miniprogram/utils/auth.ts) | 内存 → 7 天 Storage → `userLogin`;已有 `loginPromise` 合并部分并发 | 用户展示缓存与凭证分开;首次登录、续期、401 共用同一个登录 Promise |
| [userLogin](../../../doodle-mini/cloudfunctions/userLogin/index.js) | 从 `getWXContext()` 获取身份;按 OpenID 查用户;新增或更新活跃时间;支持更新资料 | 改成服务端兑换 code;唯一约束保证并发;登录与资料更新拆成明确接口 |
| [utils/http.ts](../../../doodle-mini/miniprogram/utils/http.ts) | 基础 `wx.request` 包装 | 补齐 API 地址、PATCH、Bearer、结构化错误、401 恢复;上传和下载另做适配 |
| [个人中心](../../../doodle-mini/miniprogram/pages/profile/profile.ts) | 已实现主动选择头像、填写昵称、上传 CloudBase,再更新资料 | 保留交互;头像改为上传七牛资产后绑定 assetId |
| [收藏](../../../doodle-mini/miniprogram/utils/favorites.ts)、[下载](../../../doodle-mini/miniprogram/utils/downloadLogs.ts) | 调用前 `getUser()`;仍调用云函数;部分失败返回空列表或 0 | 改为受保护 API;错误不能假装成真实空数据 |
| [设置页面](../../../doodle-mini/miniprogram/supportPages/settings/settings.ts)、[userSettings](../../../doodle-mini/cloudfunctions/userSettings/index.js) | 清资料或注销后 `clearUser()`;旧注销只删 users | 清资料不清凭证;注销完整事务清理,阻止后台自动重建账户 |
需要纠正的几个历史描述:
- 当前 user 对象包含 OpenID/UnionID,云函数还通过展开数据库记录返回资料;新 DTO 必须显式列字段,不直接返回数据库模型。
- 7 天缓存只控制重新调用登录的时机,内存命中还会跳过 Storage 过期检查;它不提供独立 API 的身份验证。
- 旧登录先查再新增,没有原子冲突处理,不能把它描述为天然并发安全的 upsert。
- 头像昵称已在个人中心实现;旧登录文档中的“后续实现”已过时。
- `envVersion === 'develop'` 只是入口显示条件。[homeTimerControl](../../../doodle-mini/cloudfunctions/homeTimerControl/index.js) 等入口代码未见管理员身份检查;线上调用权限仍需盘点,迁移后的每个管理 API 都必须自行校验。
### 2.2 doodle-store 已有基础
[src/auth](../../apps/server/src/auth/auth.module.ts) 已有 JWT 签发/验签、全局 `AccessTokenGuard`、`@Public()`、`@Roles()`、`@CurrentUser()`。JWT 固定 HS256,检查 issuer、audience、过期时间及 claims;角色取自账户读取结果。
`ACCOUNT_READER` 已替换为真实 Prisma 读取;数据库故障仍返回 503。微信登录、本人摘要和有限 reason 已接入;账户资料修改、注销及主动版本递增服务仍待实现。小程序恢复逻辑和 Web 内容继续作为后续目标设计。
## 3. 身份、凭证与权限分层
```mermaid
flowchart LR
WX[微信 code] --> Verify[服务端验证微信身份]
Verify --> Account[users.id · Doodle 账户]
Account --> Mini[小程序 Bearer JWT]
Account -.后续.-> Web[Web Cookie 会话]
Mini --> Check[当前账户状态与版本]
Web --> Check
Check --> Permission[角色及资源归属检查]
Permission --> Business[收藏 · 下载 · 资料 · 管理]
```
| 标识 | 用途 | 不承担的职责 |
| ------------- | -------------------------------------- | ---------------------------------------- |
| `users.id` | 全端稳定业务主键,业务关联及审计使用 | 不作为秘密或登录凭证 |
| 小程序 OpenID | 当前配置的小程序内识别微信身份 | 不假设与网站、公众号的 OpenID 相同 |
| UnionID | 同一微信开放平台账户下的关联线索,可空 | 首期不唯一、不自动合并账户 |
| `session_key` | 微信数据加解密需要的服务端密钥 | 不作为 Doodle Bearer,不下发客户端 |
| Doodle JWT | 小程序向本站 API 出示的访问凭证 | 不固化角色,不包含微信身份和用户资料 |
| 客户端 UUID | 现有埋点或本地安装标识 | 不用来认证、授权、找回账户或证明设备归属 |
服务层只接收经过守卫构造的可信用户上下文。公开 DTO 和客户端请求不能写入 `role/status/authVersion/openid/unionid`;即使请求者是管理员,资料更新接口也不接受这些字段。
## 4. 小程序登录流程
### 4.1 接口契约
所有路径省略 `/api/v1`。`POST /auth/wechat/login` 显式公开,仍受参数校验和限流保护。
```json
{
"code": "本次 wx.login 返回的临时凭证",
"mode": "resume"
}
```
`code` 必须为非空、有长度上限的字符串;不接受客户端 AppID、AppSecret、OpenID、userId 或角色。`mode` 必填,只有以下两种值:
- `resume`:只恢复现有账户,不创建用户。已有登录记录的启动、token 到期及 401 恢复都用它;身份对应账户已删除时返回 `ACCOUNT_NOT_FOUND`。
- `start`:允许首次建号。全新安装、没有任何账户历史时可以非阻塞静默调用;本机曾注销或发现账户被删除时,必须由用户再次点击“开始使用账户”后调用。
`mode` 是客户端的创建意图,不是安全凭证。两个模式都先验证微信身份、都拒绝 disabled 账户;`start` 不允许重置现有账户、解除禁用或选择目标 userId。
成功返回:
```json
{
"code": 0,
"message": "ok",
"data": {
"accessToken": "Doodle JWT",
"tokenType": "Bearer",
"expiresIn": 86400,
"expiresAt": "2026-10-16T02:00:00.000Z",
"user": {
"id": "沿用旧用户 ID 或新生成的 ID",
"nickName": null,
"avatarUrl": null,
"avatarUrlExpiresAt": null,
"createdAt": "2026-10-15T02:00:00.000Z"
}
}
}
```
不返回 `openid/unionid/session_key/authVersion` 或完整 users 记录。`avatarUrl` 非空且为私有签名地址时同时返回期限。统计走已有数量接口,不在每次登录中计数;小程序适配层将 `id` 转为现有页面需要的 `_id`,移除 UserInfo 中的微信身份字段。管理入口若需要角色,用受保护的 `GET /me` 获取当前 `role`,仅用于显示。
登录及本人资料响应设置 `Cache-Control: no-store`,不进入 CDN/公共响应缓存。JWT 示例中的期限取自实际签发结果,不能另算出与 `exp` 不一致的期限。
### 4.2 服务端步骤
1. 校验参数及入口限流,用服务端固定的小程序配置调用微信 `code2Session`;外部网络调用不放在数据库事务或行锁内。
2. 验证 HTTP/响应格式、微信错误码和非空 OpenID;成功时 `errcode` 可以不出现,出现则必须是成功值。UnionID 空串规范为 NULL。当前没有加解密用途,`session_key` 不落库、不缓存、不记录日志。
3. 开启短事务,按可信 OpenID 查询用户。存在则锁定用户并核对 active;不存在且 mode=resume 则结束并返回 `ACCOUNT_NOT_FOUND`;mode=start 才插入新账户。
4. 新账户由服务端生成 ID,默认 `role=user/status=active/authVersion=1`。用 `UNIQUE(openid)` 和明确的冲突处理兜底,并发失败时重新读取实际账户;不能覆盖其角色、状态、资料或版本。PostgreSQL 唯一冲突会中止事务,须用 `ON CONFLICT` 或回滚后的新事务重读,不能直接在失败事务里继续查询。
5. 已有账户更新 `last_active_at`;本次 UnionID 缺失不清空旧值,有值且旧值为空才补齐;两个非空值不一致时记录脱敏异常供核查,不覆盖、不合并。实际资料变化才维护相应 `updated_at`,不把普通登录当成权限或资料修改。
6. 提交后按本次确认的 `id/authVersion` 签发 JWT,显式构造 DTO。若提交后恰好发生禁用、注销或版本递增,旧快照 token 会在下一次账户核对时失效,不能跳过检查以迁就它。
被禁用的账户不能通过再次登录变成新用户。旧用户是否存在取决于已完成的数据导入;切换前必须迁入旧 users,不能先对空生产库开放 start 模式,再补导产生重复账户。
### 4.3 微信接口调用约束
微信小程序 code 有效期五分钟且只能使用一次;每次登录尝试现取现用。提交后超时也可能已经被微信消费,不使用同一个 code 盲目重试;客户端需要恢复时重新调用 `wx.login`,并受统一重试次数限制。[微信 wx.login](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.login.html)、[小程序登录说明](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html)。
| 配置/场景 | 建议行为 |
| ------------------------ | --------------------------------------------------------------------------------------------------------- |
| AppID/AppSecret | 仅服务端配置;新增 `WECHAT_MINI_APP_ID/WECHAT_MINI_APP_SECRET` 的启动校验,禁止读取客户端配置作为身份来源 |
| 外部 HTTP | 固定微信 HTTPS 地址,校验 TLS,建议单次超时 5 秒;不跟随到任意地址,不记录带 secret/code 的 URL |
| 微信 40029 或已使用 code | 返回 400 + `WECHAT_CODE_INVALID`;一次恢复周期内最多额外获取一次新 code |
| 微信 40226 | 返回 403 + `WECHAT_LOGIN_BLOCKED`;停止自动登录,不能伪装成普通 code 过期 |
| 微信 45011 / 本站限流 | 返回 429 + `RATE_LIMITED`;遵守 `Retry-After`,不立即循环 |
| 微信繁忙、超时、响应异常 | 返回 503 + `AUTH_UPSTREAM_UNAVAILABLE`;退避,允许公开功能继续使用 |
| AppID/AppSecret 配置失效 | 返回安全的服务不可用提示并告警;不引导客户端不断获取 code |
| 本站数据库失败 | 返回 503;不能改用客户端身份或只验 JWT 放行 |
具体错误映射以[微信 code2Session](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-login/api_code2session.html)为准,不把微信原始 errmsg/响应直接下发。建议起步使用入口 IP 限流,例如单 IP 每分钟 30 次并保留小突发;共享网络需实测调参,不能只靠它。获得可信 OpenID 后补身份维度节流,成功登录不因客户端重试重复建号。首期单实例可用有界内存计数,重启会重置;多实例或严格配额时再选共享存储。
微信 `wx.checkSession` 检查微信的 `session_key`,不能检查 Doodle JWT。当前不使用微信加解密能力,不将它加入每次 API 请求流程。[微信 wx.checkSession](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.checkSession.html)。
## 5. 凭证、账户检查与业务授权
### 5.1 JWT
沿用现有 HS256、`iss=doodle-store`、`aud=doodle-mini`、`sub=userId`、`authVersion/iat/exp`,默认 `JWT_EXPIRES_IN_SECONDS=86400`。JWT 是可读取的签名载荷,里面不放用户资料、微信身份、手机号或秘密。
不提供任意 userId 换 token、前端自行续签、仅凭旧 token 换新 token 的接口。普通登录、更新资料、清资料不递增 `auth_version`,避免其他设备无故失效。
每次受保护请求顺序固定:
1. 从 Authorization 读取 Bearer,验证签名、算法、issuer、audience、期限及字段。
2. 真实 AccountReader 只查询当前 `id/status/role/authVersion`,核对用户存在、active、版本相同;数据库不可用则拒绝服务。
3. 将结果写入 `request.auth`,执行路由角色检查。
4. 业务服务检查目标归属和发布状态;写事务锁用户行后重新核对状态、版本,以及需要的管理员角色,再按数据库规定锁题型/内容。
第一阶段不缓存账户状态,确保禁用、降权、注销可在后续请求立即生效。读请求已通过检查后发生的并发状态变更,不宣称能撤回已发出的响应;关键写入与禁用/注销事务通过同一账户行锁串行化。管理事务也要检查操作者,不能只在控制器检查一次角色。
### 5.2 权限矩阵
| 接口/操作 | 未登录 | active 普通用户 | active 管理员 |
| ---------------------------------------------- | -------------- | -------------------------------- | -------------------------------------- |
| 首页、分类、active 功能及 active 内容列表/详情 | 允许 | 允许 | 允许,公共接口仍只看发布内容 |
| 登录 | 验证微信后允许 | 允许 | 允许,不因登录自动授权 |
| `/me`、收藏、下载历史及数量 | 拒绝 | 仅本人 | 仅本人 |
| 上传头像、修改/清除资料、注销 | 拒绝 | 仅本人 | 仅本人 |
| 管理查询、发布、上下架、配置、统计修订 | 拒绝 | 拒绝 | 明确管理路由允许,并按范围审计 |
| 读取其他用户私有头像、历史或收藏 | 拒绝 | 拒绝 | 默认拒绝;admin 不是全量私有数据通行证 |
| 未来服务端生成/私有文件 | 按专门规则 | 登录后仍核对配额、归属、资源状态 | 仍按对应业务规则 |
公开接口用 `@Public()` 明确声明,且不返回本人收藏状态;需要个人状态时单独请求 `/me/...`。现有 Public 装饰器会完全跳过守卫,不将它当作“有 token 就自动解析身份”的可选鉴权模式。
资产接口先验证 purpose、大小、格式、owner 和状态;头像只允许绑定本人拥有、用途为 avatar 且 ready 的私有资产。客户端传来的 assetId、worksheetId、resourceId 都必须做业务校验,不能仅因用户已登录便接受。
### 5.3 管理权限与版本撤销
首个管理员通过受控初始化脚本将已验证的现有账户升为 admin,不能按“第一个登录者”授权,也不能依赖 develop 环境。授权、降权、禁用、重新启用采用专用服务/脚本,在同一事务锁操作者与目标用户,按 ID 排序获取同层锁,写入审计;初始化脚本使用 system 操作者。
建议上述角色/状态变更都递增目标 `auth_version`,防止恢复 active 或重新授权后,早先的 token 再次恢复权限。仅撤销全部凭证时同样递增版本。避免整数溢出,变更失败不绕回旧版本。
首期没有独立 sessionId,因此不承诺单设备远端退出。小程序暂不增加普通“退出登录”按钮;本地清除缓存不等于服务端撤销凭证。若后续需要单设备退出、设备列表或管理员独立登录期限,再引入会话记录。未来后台的专用登录态还必须验证 `active+admin`,普通用户不能获得后台会话。
## 6. 小程序状态管理与 401 恢复
### 6.1 状态与缓存
`auth.ts` 对页面保留简单入口,但改变其内部含义:
| 方法 | 目标职责 |
| ----------------------- | ------------------------------------------------------------------------- |
| `getCachedUser()` | 只用于首屏展示,不能表示已经通过服务端认证 |
| `ensureAuthenticated()` | 获取尚未到期的凭证,或共用登录 Promise 完成微信登录 |
| `getUser()` | 兼容现有调用:先确保凭证,再返回当前用户摘要;不再仅凭 7 天缓存认定已登录 |
| `refreshUser()` | 已登录时 `GET /me` 刷新资料,必要时走一次正常认证恢复;不先清凭证 |
| `updateUserProfile()` | 请求受保护资料接口,成功后同步本地摘要和 globalData |
| `clearUser()` | 仅作为内部清理工具;明确清理原因,不能顺手触发重新注册 |
建议用带版本、环境及 AppID 命名空间的单个 Storage 对象保存 `{accessToken, expiresAt, user}`,避免多个 key 写入一半;读取时校验结构,旧 `user_info` 只可辅助展示,升级后移除。资料缓存不保存微信身份;角色缓存最多影响按钮显示。
凭证先内存、再 Storage;在到期前约 60 秒按需重新登录,`expiresIn` 从收到响应时估算本机剩余期,不依赖设备绝对时间完全准确。最终有效性始终由服务端判断;客户端若解析 JWT 只为时间提示,不能据此授权。上传/请求失败日志不得输出完整 token 或登录响应。
另存轻量的账户使用状态:是否曾建立账户、是否主动注销/暂停恢复。注销成功后即使 user 缓存被删除,也保留“需用户主动重新开始”的标记;否则下一次 `onLaunch` 会把它误认作全新安装。卸载清数据后该标记可能消失,此时重新验证微信可以建新账户;本方案不提供跨卸载的禁止再注册策略。
### 6.2 自动恢复规则
1. 公共请求不附带凭证、不启动登录。应用启动可非阻塞预热登录;失败不影响首页和已有本地生成/保存。
2. 受保护请求获取当前 token,记录发送时的 token 代次和业务所属 userId。登录请求直接使用底层 HTTP,不经过需登录的包装,避免递归。
3. 收到可恢复的 401 时,若已有较新 token,直接使用新 token 重试;不能让旧请求迟到的 401 清掉新凭证。否则共用唯一登录 Promise,以 mode=resume 恢复。
4. 一次原始业务请求只允许一次认证恢复和一次重发;恢复登录若遇到明确无效 code,可以额外获取一次新 code,之后停止。第二次业务 401、403、429、5xx 或网络错误不触发新的自动登录链。
5. 恢复后必须仍是原 userId,才能重发原业务写入。账户改变则中止、清理相关视图,由用户重新操作;下载补报队列绑定原 userId,不能代报到新账户。
6. GET、按目标定义的 PUT/DELETE 收藏可安全重发;下载使用原 exportId,资料更新复用同一内容。管理发布、通用 POST、资产上传、注销等不可通用重放,只由业务层按幂等键或专门流程恢复。超时不是“未提交”,不对未知结果的写入盲目重试。
7. 清理/注销时增加本地状态代次,使尚未结束的登录、资料及列表响应不能重新写回旧状态;等待中的业务请求同时取消或忽略,Promise 结束后只清理自身对应实例。
短时间登录失败保留退避状态,例如 5 秒、15 秒、60 秒并设置上限;页面并发请求共用同一次失败结果,不各自重新登录。网络恢复或用户点击重试可重新尝试;公开功能不受阻。
### 6.3 错误契约
保留 `{code:HTTP状态码, message, requestId}`,增加可选的枚举 `reason`;先更新服务端错误过滤器和契约,再接小程序。只允许预定义安全错误,不透传数据库异常或微信原始消息。
| HTTP / reason | 小程序行为 |
| --------------------------------------------- | --------------------------------------------------------------- |
| 401 `AUTH_REQUIRED/AUTH_EXPIRED/AUTH_INVALID` | 有账户恢复意图时合并静默恢复一次;匿名首次用户按 start 流程处理 |
| 401 `AUTH_REVOKED` | 清凭证并停止后台恢复;用户主动继续时重新验证微信,不能循环续签 |
| 401 `ACCOUNT_NOT_FOUND` | 清旧账户数据,停止自动建号;公开功能可用,用户主动开始才 start |
| 401 `ACCOUNT_DISABLED` | 清凭证并停止自动恢复;提示账户不可用,再次验证也不解除禁用 |
| 403 权限不足或微信登录被拦截 | 提示无权限/无法登录,不清正常资料后反复尝试 |
| 400/409 | 提示参数/业务冲突;不通过重登处理 |
| 429 | 遵守重试期限;请求层透传安全的 `Retry-After` 信息 |
| 503/网络错误 | 保留可用显示缓存,标明暂不可用;不返回伪造的空列表或零统计 |
认证错误原因只在已验签后的必要账户检查中细分,不能做成按客户端任意 userId 查询账户存在/状态的接口。未知 reason 保守停止自动恢复。
`wx.uploadFile` 也要附带凭证并解析 HTTP 状态;先确保登录再上传,不将 multipart 上传纳入通用自动重发。下载签名 URL 本身是短期访问能力,不把本站 Bearer 附给七牛或任意外部域名;私有 URL 到期后重新向受保护 API 获取地址。
## 7. 资料、清除、注销与重新进入
### 7.1 资料接口
| 接口 | 行为 |
| -------------------- | --------------------------------------------------------------------------------- |
| `GET /me` | 返回本人公开 DTO 及显示需要的当前 role;数量按独立接口查询;不输出 OpenID/UnionID |
| `PATCH /me/profile` | 仅允许 `nickName/avatarAssetId`;字段缺省是不修改,NULL 是清除;拒绝未知字段 |
| `DELETE /me/profile` | 清昵称及头像引用,保留账户、凭证、收藏、历史;前端更新摘要,不清登录态 |
| `DELETE /me` | 当前凭证 + 新微信 code + 用户确认,验证同一身份后按数据库规则完整注销 |
头像流程:`chooseAvatar` 得到临时本地文件 → 带 Bearer 上传 `POST /assets` → 得到本人 avatar assetId → PATCH 绑定。服务端校验归属,不能接受任意头像 URL、文件路径或他人 assetId。替换/清除后按既有文件清理机制处理旧私有对象,失败重试,不在数据库事务里请求对象存储。
昵称按数据库上限 100 个 Unicode 字符校验,首尾空白规范化,不把它作为唯一账号名。个人中心可分别保存昵称和头像,不强迫二者齐全;微信填写组件提供的临时路径不是永久头像地址。[微信头像昵称填写](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html)。
### 7.2 注销的完整边界
注销请求体建议为 `{code, confirm:true}`。前端在明确确认后现取 code;后端调用微信校验得到可信 OpenID,必须对应当前认证账户,不调用会自动建号的登录方法。code 不能替换 JWT 身份,也不能指定要删谁。
数据库步骤沿用已定规则:锁用户并重查版本/状态 → 按 ID 锁涉及的题型,仅扣功能收藏贡献 → 删除功能和内容两类收藏及下载记录 → 解除头像/私有资产关联并标记清理 → 去除管理审计中的身份及个人信息关联 → 删除用户,同一事务提交。之后所有旧 JWT 因查不到用户而失效。
客户端收到成功后清凭证、资料、本人列表和下载补报队列,阻止在途结果回写,记录暂停建号状态,回到可匿名浏览的首页。其他设备的 resume 登录查不到用户时也停止自动建号。用户主动重新开始时才创建全新 userId,旧收藏和历史不恢复。
若注销响应丢失,客户端进入“确认结果中”状态,暂停自动 start、原写请求及补报。可用原 token 查询 `GET /me`:`ACCOUNT_NOT_FOUND` 说明原账户已不存在,应执行本地清理;仍存在则让用户明确重试并获取新 code;网络失败则保持未知,不能当作成功或重新建号。注销接口不进入通用 401 重试器。
本方案通过 mode=resume 和客户端意图管理阻止无意重新注册,不承诺删除后永远禁止该微信再建号;跨设备的主动 start 仍可创建新账户。若未来要求注销冷静期或全局禁止再注册,需要另设计受控的身份保留策略,不能只靠已删除的 users 行实现。
## 8. 旧版本兼容与上线
旧版没有 `wx.login` code 或本站 JWT,兼容云函数仍从 `cloud.getWXContext()` 获取可信身份,经已有迁移方案规定的服务间签名入口访问同一业务服务。
- 兼容桥只允许已登记的小程序 AppID、函数/action 白名单、时间窗及防重放校验;客户端 event 中的身份一律不可信。
- 内部桥不伪装成普通 Public 业务路由。即使声明跳过 JWT,也必须由独立桥接守卫验签,再执行真实账户状态和服务层权限检查;签名密钥不能用于普通 JWT 签发。
- 旧登录 action 可以按既定兼容语义首次建号;其他收藏、下载、资料、设置 action 只查现有账户,不因账户已删而自动创建。桥接 action 和旧响应形状按白名单转换,不返回完整数据库模型。
- 旧版主动调用登录仍可能在注销后新建号,这是已发布代码的兼容限制;新用户 ID 不恢复旧数据。完整的客户端暂停建号体验只由新版保证,旧版下线期限按迁移观察确定。
- 历史资料响应如确有兼容字段需要,可在受控桥内返回已核实的必要字段;新版 DTO 不延续 OpenID/UnionID 暴露。
- 兼容桥仍需检查 admin,不能把“来自云函数”当成管理授权;旧管理页面要求使用新版。签名防重放记录必须原子且有清理期限,具体存储在兼容桥实施时落地。
上线顺序:先完成受控试迁与旧身份对账,再部署 API/兼容桥,在切换窗口统一写入源,最后发布新版。开发、体验、正式 API/缓存命名空间隔离;不得把不可信 envVersion 当成后端权限或测试库选择依据。体验版如仍对应同一 AppID,必须核实数据隔离和资源隔离策略。
## 9. 未来 Web:共用账户,独立管理会话
### 9.1 推荐首条路径:现有小程序扫码确认
Web 面向现有微信用户提供“使用涂鸦丫小程序登录”:网页展示带短期 challenge 的小程序码 → 微信扫码进入小程序登录确认页 → 小程序用当前有效 Doodle 身份明确确认 → 原网页兑换自己的 Web 会话。
这是项目自行实现的登录确认协议,不是直接调用网站应用微信 OAuth。它能直接使用已经验证的小程序 userId,不需要用两个 OpenID 猜测是否同一人;未来即使 UnionID 为空也能共用收藏和历史。微信官方提供小程序码进入指定页面的能力,登录 challenge、安全校验和浏览器会话由本站实现。[获取小程序码](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/qr-code.html)。
未来实现必须包含:
1. 网页创建挑战时建立 HttpOnly 的预登录浏览器绑定,服务端保存绑定摘要;二维码只携带随机定位符,不包含 token、userId 或浏览器兑换秘密。
2. challenge 建议 2 分钟过期,随机数至少 128 位,区分 user-web/admin 用途。小程序码图案本身可以长期存在,是否过期以本站 challenge 为准;过期扫码必须拒绝。
3. 确认页展示“将登录涂鸦丫网页”、使用的账户及双方可核对的短码,要求主动确认。扫码/打开页面本身不能完成登录,防止误扫和远程诱导登录。
4. 只有持有有效小程序凭证者可确认;只有创建挑战的浏览器绑定可查询/兑换结果。扫码定位符不能作为领取网页会话的凭证。
5. 状态原子流转 `pending → confirmed → consumed`,另有 rejected/expired;一次挑战只能成功兑换一次。兑换时重新核对用户状态和确认时的 authVersion,禁用/注销/撤销后不能再兑换。
6. 登录后轮换预登录标识,创建全新的服务端会话;轮询只取状态,不返回 Bearer;会话通过 `Set-Cookie` 交给原浏览器,不能经 URL、二维码或小程序转传。
此路径需要新增短期挑战存储,Web 上线时优先用 PostgreSQL 表和到期清理,不为低频轮询先引入 Redis/WebSocket。小程序码生成还需要微信服务端调用凭证管理,这与用户登录 JWT 不同,届时单独接入。
它主要解决电脑 Web 登录,不能覆盖所有手机浏览器或非微信用户。移动 Web、独立账号和非微信人群有实际需求后,再增加相应入口。
### 9.2 另一条路径:网站应用微信 OAuth
如果希望网页直接使用微信网站登录,可接入已审核的网站应用及其 AppID/AppSecret,使用授权码回调、服务端换身份和随机 state 验证。小程序 `wx.login` code 与网站授权 code 不能混用,微信 OAuth 的 access_token 也不是 Doodle API token。[网站应用微信登录开发指南](https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html)。
新增应用身份前,才引入 `auth_identities`:建议包含 `id/user_id/provider/app_id/subject/union_scope/union_id/created_at`,其中 `UNIQUE(provider, app_id, subject)`,subject 对应该应用可信 OpenID;`union_scope` 标明可信的开放平台账户范围,不能仅比较无作用域字符串。
届时的结构迁移顺序:
1. 将现有 `users.openid` 按服务端已核实的唯一小程序 AppID 回填成身份记录,逐项校验,保留原 userId。
2. 小程序和新增应用统一通过身份表找账户,稳定后再将 users 的旧身份列改为可空/过渡字段并停止作为权威源;最终移除。不能让只有网站身份的新账户仍被迫填写小程序 OpenID。
3. 已绑定身份直接进入原账户。新身份即使拿到相同 UnionID,也只作为绑定候选;按本项目规则要求用户显式验证并绑定,不自动合并已有账户。
4. 绑定必须有当前账户登录态 + 新身份的独立有效证明,不能由客户端上报 UnionID。身份已属于其他 userId 时返回冲突,不自动搬移收藏/历史;账号合并另立流程。
5. 注销时事务删除所有登录身份及会话。多个身份的唯一冲突和并发绑定必须依赖数据库约束,不能先查一次就认定安全。
UnionID 在同一微信开放平台账户范围内关联用户,且可能缺失;不是所有微信产品的全局账号 ID。[UnionID 机制](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html)。当前不恢复刚移除的 users.mini_app_id,也不提前改动已完成的 13 表基线。
### 9.3 Web 会话与浏览器安全
推荐 Web 与 API 通过同源 `/api` 反向代理访问,由 API 直接管理不透明会话 Cookie:`Secure; HttpOnly; SameSite=Lax; Path=/`,使用 `__Host-` 前缀且不设 Domain。不把长效凭证放在 localStorage、页面脚本变量、URL 或日志中。[OWASP 会话管理指南](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)。
Web 阶段新增 `auth_sessions`,保存随机会话秘密的摘要,而不是原始秘密;建议字段为 `id/token_hash/user_id/client_type/auth_version/created_at/last_seen_at/idle_expires_at/absolute_expires_at/revoked_at`。每次会话请求核对期限、撤销状态、账户状态和版本,角色仍取当前 users;注销清理会话,单浏览器退出只撤销当前会话。
普通 Web 初始可取 7 天空闲期限、30 天绝对期限;后台单独取 30 分钟空闲期限、8 小时绝对期限,并重新核对 active+admin。这些是未来上线时需要实测确认的起步值,不修改当前小程序 JWT 配置。
Cookie 接入时需要明确调整现有 Bearer-only 守卫:先按路由允许的凭证类型解析为统一用户上下文,再共用账户/版本/角色及归属检查。业务 API 可允许小程序 Bearer 或 Web session,专用后台登录/会话只接受 admin 用途;存在相互冲突的双凭证时拒绝,不在 Bearer 失效后悄悄改用另一身份 Cookie。小程序 audience 继续限定,不直接把 `doodle-mini` JWT 复制到网站。
Cookie 写请求必须有 CSRF 防护,建议 Origin 校验 + 与会话绑定的 CSRF token;OAuth state 和扫码 challenge 还需独立绑定原浏览器。SameSite、CORS、HttpOnly 都不能单独替代这些检查。只读 GET 不执行写操作;公开页面不夹带个人响应缓存。登录提升权限后更换会话标识,明确退出时撤销服务端会话。
当前 CORS 是 `credentials:false`。同源代理下不必为 Cookie 开放跨源;若未来必须跨源,需精确 Origin 白名单、credentials、Cookie SameSite/第三方 Cookie 限制及 CSRF 一并重新设计,不能只翻一个开关。前端框架和域名拓扑到 Web 开发时确定。
## 10. 数据库影响、落点与实施顺序
### 10.1 本次数据库无需变更
| 需求 | 当前结构是否足够 | 何时新增结构 |
| -------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------- |
| 单小程序登录、本人资料与权限 | 足够,users 的 OpenID 唯一约束、role/status/auth_version 已具备 | 当前无需 DDL |
| 全账户撤销、禁用及降权 | 足够,变更版本并实时读账户 | 当前无需 DDL |
| Web 小程序扫码登录 | users 足够,需挑战和浏览器会话存储 | Web 上线时增加 challenge/session 表 |
| 单设备远端退出、设备管理 | 当前不足 | 真实需求出现时引入会话标识及服务端会话检查 |
| 网站 OAuth、手机号、邮箱等多身份 | 当前 users.openid NOT NULL 不足 | 引入身份表并迁移 users 身份约束,不提前塞空串或伪 OpenID |
新表将来通过新的增量迁移接入,不重跑已有基线;身份/会话表进入正式数据库设计后,再实现相应能力。
### 10.2 代码落点
- `doodle-store/apps/server/src/auth`:扩展现有模块,负责登录控制器、微信客户端、JWT、账户读取、限流及认证错误;不再在 modules 下重复建第二套 auth。
- `doodle-store/apps/server/src/modules/users`:本人资料、资产绑定、清资料、注销及账户状态/角色变更,复用事务规则。
- `doodle-store/packages/contracts`:公开请求/响应、有限错误原因和用户 DTO;不包含 Prisma 模型、微信密钥或私有身份记录。doodle-mini 按 HTTP 契约接入,不跨仓库导源码。
- `doodle-mini/miniprogram/utils/auth.ts` 与业务 HTTP 层:缓存/凭证/状态机、并发合并、401 恢复;页面调用业务方法。
- 小程序个人中心、设置、收藏/历史、上传及现有管理入口:分别适配;不把迁移理解为只替换一个函数名。
### 10.3 建议推进顺序
1. Prisma schema/既有 SQL 基线登记、真实 AccountReader;将已有守卫测试接到真实账户查询。
2. 微信客户端与登录 API、resume/start 语义、显式 DTO、错误 reason、限流与日志脱敏。
3. 小程序统一认证请求层、缓存升级、并发/迟到响应处理;先打通体验版 `wx.login → API → GET /me`。
4. 本人资料、头像、清资料和注销;接入收藏/下载及幂等恢复,再补管理接口的事务内权限核对。
5. 兼容桥、迁入老身份样本、真机/离线/过期/注销验证,按迁移任务清单切换。
6. Web 实际启动时实现扫码挑战、Cookie 会话和对应数据库增量;新增网站身份时才迁移身份表。
真实微信联调前需核实 AppID/AppSecret、开放平台绑定情况、HTTPS 及 request/upload/download 合法域名、体验版数据隔离。现在无需把密钥写入设计文档,也不以这些待核实项阻塞方案评审。
## 11. 实施验收清单
以下是分阶段验收要求;已实施部分及自动验证范围见配套接入说明,不能将服务端测试视为小程序真机/Web 验收。
| 场景 | 预期结果 |
| ----------------------------------------- | --------------------------------------------------------------------------------------- |
| 老用户有效 code 登录 | userId 与旧 `_id` 相同,收藏/历史不断联 |
| 同 OpenID 并发首次 start | 只有一个 users,唯一冲突处理不覆盖权限/资料 |
| resume 查不到用户 | 不建号,返回 ACCOUNT_NOT_FOUND;需主动 start |
| 客户端伪造 AppID/OpenID/userId/role | DTO 拒绝或不作为可信身份,不影响目标账户 |
| code 无效/重复/过期、微信风控/超时/限流 | 有限且正确的错误映射,不创建用户、不无限重试 |
| JWT 伪造、过期、错误 audience/issuer/算法 | 拒绝;不接受无期限载荷或 URL token |
| token 有效但数据库不可用 | 503,不只验签放行 |
| 账户禁用、降权、撤销后重新启用 | 旧凭证仍无效;重新验证也不能绕过 disabled |
| 多个请求同时 401、旧 401 迟到 | 只发生一轮恢复;不会清除新凭证,每个业务最多重发一次 |
| 清缓存/注销与登录、列表请求并发 | 旧响应不复活状态;注销后后台 resume 不重新建号 |
| 重新登录返回不同 userId | 原写请求不重发,原下载队列不代报 |
| 保存成功后上报超时、重试 | 原 exportId 重用,不重复计数、不重复扣本地配额 |
| 清资料 | 收藏/历史及有效凭证保留,头像私有对象进入清理流程 |
| 注销成功或响应丢失 | 事务完整,旧 token 失效;未知结果可确认,不自动重放或重新注册 |
| 任意用户改他人头像、读他人历史 | 拒绝;管理员默认也不能读取全量私有数据 |
| 小程序本地改角色或显示管理按钮 | 管理 API 仍拒绝,管理写事务重新核对操作者 |
| 日志/接口/缓存检查 | 无 AppSecret、session_key、code、完整 token、原始微信身份泄漏 |
| iOS/Android 体验版、网络断开/恢复 | 正常静默登录;离线公开内容和本地生成可用,远端错误不显示成空数据 |
| Web 后续验收 | 挑战过期/重放/误扫/跨浏览器兑换拒绝;Cookie/CSRF/退出/账户撤销有效;与小程序同一 userId |
## 12. 参考与待确定事项
代码和数据库规则以本文引用的仓库文件为准。微信资料于 2026-10-15 查阅;未来接入时复核平台准入、配额及接口变化。
- [旧小程序登录方案](../../../doodle-mini/docs/用户登录与信息授权方案.md):仅作为云开发历史背景,实际行为以代码为准。
- [迁移任务清单](./迁移任务清单.md):真实开发、数据迁移与切换验收入口。
- [微信 code2Session](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-login/api_code2session.html)、[UnionID](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html):身份来源与作用域。
- [微信网站应用登录](https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html)、[OWASP 会话管理](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html):未来网站登录和浏览器会话的参考,不代表本站已接入。
本次建议默认按“小程序 24 小时 JWT、暂不增加表、Web 优先现有小程序扫码确认”推进。Web 的移动端覆盖、网站应用准入及实际域名拓扑留到 Web 启动时确定;若改选独立手机号/邮箱登录,需要补充验证、凭证、找回和解绑方案,不能直接复用昵称或客户端 UUID。
+24 -14
View File
@@ -1,11 +1,17 @@
# Doodle Mini 云服务端迁移任务清单
更新日期:2026-10-13。依据当前代码、数据库设计及《Doodle Mini 现有功能迁移到云服务端方案》整理。
更新日期:2026-10-15。依据当前代码、数据库设计及《Doodle Mini 现有功能迁移到云服务端方案》整理。
目标:把现有业务后端、数据、文件和定时任务迁出 CloudBase,由 doodle-store 提供统一 API、PostgreSQL 主数据库和七牛资源存储;doodle-mini 保留现有生成、绘制、保存、分享及广告能力。
登录与鉴权实施细节见 [用户鉴权与登录设计](./用户鉴权与登录设计.md)(2026-10-15 设计稿);服务端登录和真实账户检查已实现,注销与小程序适配仍待实施;具体边界见 [服务端登录接入说明](./服务端登录接入说明.md)。
标记说明:已勾选表示已有实现及记录的验证;未勾选表示待完成。Server 能启动、数据库已建表,不代表业务迁移已经完成。清单不承诺具体工期,外部权限、备案和微信审核单独记录。
2026-10-16 补充:51 条题型已按原 ID/计数/时间导入本地,51 条预览资产仅登记 pending,尚未迁移文件或新增题型 HTTP API。旧 grade=23 原样保留并报告,未自动生成适用年级关系。此次本地元数据暂存不代表 S2-P01、S2-F03 或 S3-D03~D05 全部完成,详见 [题型数据导入说明](./题型数据导入说明.md)。
2026-10-16 查询补充:题型公开列表/详情与分类、审核年级、年龄段、Tag、最新/热门、运营/新品标记筛选已实现;预览未就绪按用户确认返回 null,年级关系仍待审核,见 [题型查询接口说明](./题型查询接口说明.md)。这不代表图片迁移、分龄推荐混排或题型管理写接口已经完成。
## 一、当前已完成
- [x] 建立独立 doodle-store pnpm 工作区、NestJS 11 Server、公共 contracts 包及构建检查脚本;小程序保持独立项目。
@@ -13,8 +19,8 @@
- [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 空格并补充关键中文注释。
- [x] 接入 JWT 签发/验签、默认鉴权守卫、公开接口和角色声明;已接入 Prisma 账户读取、微信静默登录和安全认证错误原因。
- [x] 2026-10-15 Server 完整检查、20 项常规测试及独立数据库 8 项检查通过,构建入口和真实数据库连接验证通过;微信真实 code 联调仍待小程序接入。
## 二、实施原则与范围
@@ -38,12 +44,12 @@
## 四、S1:Prisma 与首条业务链路
- [ ] S1-01:安装并锁定 Prisma 兼容版本,建立覆盖 10 张表的 schema,保持原字符串主键、C 排序规则及现有约束。
- [ ] S1-02:将已执行的 SQL 登记为迁移基线,补齐数据库迁移流程;不得在已有表的开发库重复执行初始 DDL。
- [ ] S1-03:实现 PrismaService、数据库连接/关闭和独立就绪检查;配置真实开发连接串及受控连接池。
- [ ] S1-04:实现真实 AccountReader,查询用户状态、authVersion 和 role;替换当前有效 token 仍返回 503 的占位实现。
- [x] S1-01:锁定 Prisma 7.10.0 与 PostgreSQL adapter,Schema 覆盖 13 张表;SQL 基线保留 C 排序规则、CHECK 及部分唯一索引。
- [x] S1-02:将已执行的 SQL 登记为迁移基线,补齐数据库迁移流程;不得在已有表的开发库重复执行初始 DDL。
- [x] S1-03:实现 PrismaService、数据库连接/关闭和独立就绪检查;配置真实开发连接串及受控连接池。
- [x] S1-04:实现真实 AccountReader,查询用户状态、authVersion 和 role;替换当前有效 token 仍返回 503 的占位实现。
- [ ] S1-05:落实公开/本人/管理接口分类,服务层强制校验数据归属;管理员角色不自动开放全体用户私有数据。
- [ ] S1-06:实现 POST /auth/wechat/login:微信 code2Session、按可信 OpenID 查找并沿用老 userId、并发首次登录唯一性、JWT 签发及活跃时间维护。
- [x] S1-06:实现 POST /auth/wechat/login:code2Session、start/resume、老 userId 延续、并发首次登录、JWT 及活跃时间;真实数据库自动测试通过,真实微信 code 联调仍在 S3。
- [ ] S1-07:实现最小题型公开读取,以及收藏、成功下载记录的事务纵向样板;验证重复请求只产生一次关系或一次计数。
- [ ] S1-08:完善公开 DTO、分页/总量、错误码与 requestId 契约;新接口 id 与旧端 _id/path/previewImg 兼容转换明确记录。
@@ -53,7 +59,7 @@
### 5.1 用户、收藏与下载
- [ ] S2-U01:实现 GET /me、PATCH /me/profile、DELETE /me/profile;清资料保留收藏和历史。
- [ ] S2-U01:GET /me 已实现;PATCH /me/profile、DELETE /me/profile 待实现,清资料保留收藏和历史。
- [ ] S2-U02:实现 DELETE /me 注销:事务清理收藏及相关 likes、下载历史、用户资料和身份;旧凭证失效,私有文件可重试清理。
- [ ] S2-U03:补齐收藏添加/取消/分页列表/数量/单张检查/批量检查;关系与 likes 同事务,仅真实增删影响计数。
- [ ] S2-U04:补齐下载记录添加/分页列表/数量;要求新版 exportId,日志与 downloads 同事务;历史 NULL 事件保留。
@@ -61,7 +67,7 @@
### 5.2 公开内容
- [ ] S2-P01:实现题型列表/详情及分类列表;公开接口强制只返回 active,不允许客户端参数绕过状态限制。
- [x] S2-P01:分类与题型公开列表/详情已实现;分类及题型统一要求自身与祖先上线,支持组合筛选、分页和安全预览解析,未就绪预览按用户确认返回 null。分类新增 status,管理员可独立预览与上下架。
- [ ] S2-P02:实现分龄推荐,保留 5 个年龄段、现有年龄匹配、Top3 下载+Top3 收藏、去重和不足补齐规则。
- [ ] S2-P03:区分真实计数与 seed 展示值;推荐保留种子参与规则,排序并列明确稳定规则并与固定样本核对。
- [ ] S2-P04:提供公共首页配置接口,保留 home/category/age/version/updatedAt 及 previewImg 字段形状。
@@ -71,10 +77,10 @@
- [ ] S2-A01:确定管理后台登录方式及凭证方案;配置首个管理员,默认用户不能注册或自升管理员。
- [ ] S2-A02:落实后台独立登录入口或明确的管理员授权流程:身份验证后校验 active+admin,非管理员不能获得后台登录态;现有小程序管理入口同样受保护。
- [ ] S2-A03:全部管理 API 服务端检查管理员身份;用户降权/禁用后立即失去权限,不能只靠前端隐藏按钮。
- [ ] S2-A04:实现题型发布/重新发布/上下架/管理列表和分类状态数量;普通重发不清零真实计数,保留 createdAt/contentUpdatedAt/updatedAt 语义。
- [ ] S2-A05:实现分类创建/更新,保留主键和父子关系,校验父分类及循环引用。
- [ ] S2-A04:题型/资源管理列表、详情预览与状态上下架已实现;题型配置发布/重新构建及分类状态数量待实现;普通重发不清零真实计数,保留 createdAt/contentUpdatedAt/updatedAt 语义。
- [x] S2-A05:实现管理员分类创建/更新/删除,保留主键和父子关系,事务锁串行校验父分类及循环引用;删除有子分类或题型引用时返回冲突,审计同事务。当前创建/更新支持文字/Emoji 图标,文件资产另见 S2-F04。
- [ ] S2-A06:实现批量年龄/年级/tags/seed/统计修订,保留 dryRun、逐项结果和错误;真实/seed 修订记录操作者、原因及前后值。
- [ ] S2-A07:管理修改与审计同事务;核实 worksheetsStatsUpdate 的旧调用需要,不向普通用户开放通用加数接口。
- [ ] S2-A07:分类管理修改/迁移与审计已同事务;其他业务及 worksheetsStatsUpdate 旧调用核实待完成,不向普通用户开放通用加数接口。
### 5.4 七牛、首页与任务
@@ -155,6 +161,10 @@
## 九、下一步建议
优先推进 S0 线上盘点和 S1 Prisma/真实登录/题型读取/收藏下载事务的纵向样板。基础设施已齐备,当前首要技术阻塞是未接入真实数据库账户查询和业务模块。七牛资源准备可同步推进。
Prisma、真实账户读取及服务端微信登录已接入;继续推进 S0 线上盘点、S1 题型读取及收藏下载事务样板,以及小程序体验版真实 code 联调。七牛资源准备可同步推进。
管理员登录方式、跨端账户绑定、完整后台页面和完整 Web 分别管理:管理员授权能力及现有管理 API 属于本次;完整后台/Web 页面和 Web 用户登录不作为现有小程序迁移的切换前置条件。
2026-10-16 发布控制:分类 status 增量迁移及本地升级完成;全部公开题型查询检查所属分类链,新增管理员题型/资源预览和状态修改,审计/首发时间/数据库快照失效原子提交。小程序仍使用旧云服务,其请求迁移、本地兜底取消及预拉取缓存撤销属于切换前置条件,不能将新后端验收视为现网隐藏已生效。见 [内容发布与可见性](./内容发布与可见性.md)。
2026-10-16 小程序分类页接入:全局 HTTP 配置支持 develop 本地/线上切换,trial/release 仅线上;后端缺省与本地实际端口改为 8800。分类页已读取公开分类、完整题型分页和点击详情,移除本地静态兜底,保留榜单与混合排序并安全比较字符串计数。首页预拉取、登录、收藏、下载及分享直达生成页仍待切换;9 项小程序测试(含真实本地 API)通过。
+58
View File
@@ -0,0 +1,58 @@
# 题型数据导入说明
## 2026-10-16 本地导入
已将用户提供的 `worksheets.json` 导入本地 `doodle_dev`。源文件为 CloudBase JSON Lines,共 51 条,无重复 ID、未知字段或日期错误,5 个引用分类均已存在。原附件未改动,SHA-256 为 `184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150`。
| 项目 | 结果 |
| ---------------------------- | -------------------------------------------------- |
| 题型 | 新增 51,更新 0,隔离 0 |
| 原始发布状态 | 51 条 active,原样保留 |
| 分类分布 | math 22、puzzle 11、english 7、chinese 6、pinyin 5 |
| 预览资产 | 新增 51 条,全部 pending |
| 迁移审计 | 新增 51 条,与题型及资产同事务提交 |
| 重复导入 | 新增 0、更新 0、未变化 51,资产/时间/审计均未改变 |
| 新适用年级关系及完整内容资源 | 均未生成,不从旧值或预览图推断 |
导入批次时间为 `2026-10-16T05:59:22.266Z`,北京时间 13:59:22.266;题型自身的 createdAt/updatedAt 使用附件原值,不使用批次时间覆盖。2 条 contentUpdatedAt 原样保留,其余为 NULL。
计数对账:downloads **4203**,likes **217**,downloads_seed **629**,likes_seed **666**。11 条题型未提供两种 seed,按数据库约定默认 0,并在报告逐项列出,不重算或修正原下载、收藏计数。
`break-ten`(破十法练习)的旧 grade 为 **23**,超出新年级体系 -4~6。该值原样保留在兼容列 `worksheets.grade`;不拆成 2、3,不裁剪或归零,也不写入 `worksheet_grades`。其余旧 grade 同样未自动成为审核后的适用年级关系,后续应提供经审核的 worksheetId → grades 映射。
## 文件迁移边界
本次是**本地元数据暂存**,不是文件迁移或生产发布。51 张预览图均为旧 `cloud://` 引用,未提供本地图片,项目也尚未接入七牛配置。因此先登记稳定资产 ID 与 source_ref/source_ref_hash,status 为 pending;bucket、object_key、external_url、mime_type、size_bytes、checksum 均为 NULL,没有虚构七牛地址或文件校验结果。
为保留原始状态,题型仍记录源 active;这不表示预览图已经可访问,报告明确 `readyForPublicUse=false`。后续公共题型接口/首页构建必须校验预览资产 ready 和访问策略,不能仅检查 active 就把这批待迁移数据当作完整可用内容。正式迁移/发布仍遵循数据库设计中先完成并核验资产的顺序。
老功能保留原 ID、完整 path、标题、标签及运营标记。generation_mode 使用旧功能兼容默认 procedural,renderer_key 为 NULL,render_config 为 {};不从 ID 猜 renderer,不把封面图当作线框,不伪造 first_published_at、内容资源或文件关联。
## 工具
在 doodle-store 根目录执行,文件和报告路径使用绝对路径:
```bash
# 本地元数据预演,不写数据库
pnpm worksheets:import --file "/绝对路径/worksheets.json" --metadata-only
# 确认预演后写入;可选报告文件必须尚不存在
pnpm worksheets:import --file "/绝对路径/worksheets.json" --metadata-only --apply --report "/绝对路径/import-report.json"
```
根命令先构建依赖和服务端;已构建时可执行 `pnpm --filter @doodle/server worksheets:import ...`。工具只允许本机 `doodle_dev`,仅读取服务端 `.env`,不连接生产。默认是 dry-run;未显式使用 `--metadata-only` 时,任何未就绪预览都会阻止导入。
输入支持 JSON Lines/数组、ISO 字符串和 CloudBase `{ "$date": "..." }` 时间,最多 10000 条、10 MiB。未知字段、重复 ID、缺分类、非法时间、年龄/难度/类型错误、负计数或数字精度风险均阻止整批写入。
题型、资产映射和迁移审计原子提交;任何后续行冲突也回滚前面已写数据。共享预览按源引用 SHA-256 复用资产;相同数据重复执行不改变原时间或增加审计。已有同 ID 数据若不同则报冲突,不用旧快照覆盖新写入,不删除附件外的数据。报告文件在写库前独占创建,避免路径/权限或重名问题发生在提交之后。
## 报告与验证
- [预演报告](worksheets-dry-run-2026-10-16.json)
- [正式导入报告](worksheets-import-2026-10-16.json)
- [重复导入报告](worksheets-reimport-2026-10-16.json)
- [独立逐字段对账报告](worksheets-verification-2026-10-16.json)
已通过 `pnpm check` 及 `pnpm test:db`。独立临时数据库验证默认拒绝 pending、dry-run 不写库、缺分类拒绝、原 ID/计数/日期/兼容年级保留、重复导入、共享预览复用、旧快照覆盖保护与半批失败回滚;测试库已清理。正式写库后,独立于解析器逐字段核对全部 51 条题型及资产来源,并实际重复执行确认时间及审计不变。
导入本身不提供 HTTP API。2026-10-16 已另行完成题型公开查询,返回文字/元数据并对未就绪预览返回 null,详见 [题型查询接口说明](./题型查询接口说明.md)。七牛文件迁移与题型管理写接口仍待实施。
+127
View File
@@ -0,0 +1,127 @@
# 题型查询接口
更新日期:2026-10-16。实现范围:公开题型列表、详情和组合筛选,供小程序及未来 Web 共用。题型管理预览及状态写入已在独立管理员接口补齐;尚未实现题型配置编辑、分龄推荐 Top3 混排或首页构建,doodle-mini 分类页现已通过全局环境配置接入新接口。
## 接口与示例
统一前缀为 `/api/v1`,无需登录,固定要求自身 `status=active` 且所属分类及全部祖先已上线。客户端不能传 status、rawStats 等参数读取草稿、隐藏内容或管理统计;未识别参数返回 400。
| 用途 | 请求 |
| -------------------- | --------------------------------------------------------------------------- |
| 全部题型(分页) | `GET /worksheets?page=1&pageSize=20` |
| 本批全部 51 条 | `GET /worksheets?pageSize=100` |
| 按分类 | `GET /worksheets?categoryId=math` |
| 按单个年级 | `GET /worksheets?grade=1` |
| 按多个年级,任一匹配 | `GET /worksheets?grades=-1,0,1` |
| 按年龄段 | `GET /worksheets?ageBand=3-4` |
| 按单个年龄 | `GET /worksheets?age=5` |
| 按 Tag | `GET /worksheets?tag=描红` |
| 最新排序 | `GET /worksheets?sort=latest` |
| 热门排序 | `GET /worksheets?sort=hot` |
| 人工运营热门标记 | `GET /worksheets?isHot=true` |
| 新品标记 | `GET /worksheets?isNew=true` |
| 组合筛选 | `GET /worksheets?categoryId=math&ageBand=3-4&tag=数学&sort=hot&pageSize=20` |
| 详情 | `GET /worksheets/number-find` |
中文 Tag 由 HTTP 客户端正常 URL 编码。查询参数与实际路由已加入开发 Swagger:`http://127.0.0.1:8800/api-docs`。
## 筛选与排序规则
- 不同维度的条件按 AND 组合;多年级内部任一匹配。分类按原 ID 精确匹配,不自动包含子分类。
- 年级只使用审核后的 `worksheet_grades`,不读取旧 `worksheets.grade` 兜底。支持 -4~6,0 表示幼小衔接。`grade` 和逗号分隔的 `grades` 互斥;多重匹配不重复返回题型。
- `ageBand` 支持 3-4、4-5、5-6、6-7、7-8;题型适用范围须完整覆盖所选年龄段,即 ageMin ≤ 下限且 ageMax ≥ 上限。例如 3~8 岁题型可命中 3-4、4-5、5-6、6-7、7-8;3~5 岁题型不能命中 5-6,仅边界相交不足以匹配。公开列表与管理员列表共用此规则。
- `age` 是单年龄范围查询,要求 ageMin ≤ age ≤ ageMax,与 ageBand 互斥。年龄范围为非负 smallint。
- Tag 为字符串数组成员的精确匹配,区分大小写,不进行标题搜索、模糊匹配或推荐补齐。
- 默认排序:sortOrder 升序 → updatedAt 降序 → id 升序。
- 最新排序:firstPublishedAt 降序;旧数据未知时按 createdAt 降序 → id 升序。不使用会被计数变化刷新的 updatedAt 充当发布时间,也不改写原日期。
- 热门排序:downloads + downloadsSeed 降序 → likes + likesSeed 降序 → updatedAt 降序 → id 升序。数据库在完整筛选集排序后分页,不先取 100 条再排序,统计相加不会发生 bigint 溢出。
- `isHot` 独立筛选人工运营标记,不等同于下载热度排序,支持 true/false。
- `isNew` 依据真实 firstPublishedAt 及 `WORKSHEET_NEW_WINDOW_DAYS` 判断,默认 30 天;时间未知、未来或窗口外均为 false,旧 is_new 不作为新接口判断依据。最新排序与“仅新品”是两种查询,可分别使用。
## 分页及公开响应
page 默认 1,范围 1~1000000;pageSize 默认 20,范围 1~100。超过 100 条可继续翻页,没有旧云函数固定截断 100 条的问题。超过最后一页仍返回正确 total、空 items 和 hasNext=false。
```json
{
"code": 0,
"message": "ok",
"data": {
"items": [
{
"id": "number-find",
"title": "找数字,涂一涂",
"subtitle": "找出目标数字并涂色",
"category": "math",
"subcategory": "math-draw",
"path": "/mathPages/mathDraw/mathDraw?id=number-find",
"ageMin": 3,
"ageMax": 5,
"grades": [],
"difficulty": 1,
"previewImg": null,
"tags": ["数学", "数字认知", "涂色"],
"isNew": false,
"isHot": false,
"sortOrder": 101,
"downloads": "335",
"likes": "24",
"createdAt": "2026-04-29T05:06:22.720Z",
"updatedAt": "2026-10-15T07:17:05.132Z",
"contentUpdatedAt": null,
"firstPublishedAt": null
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"hasNext": false
}
}
```
上述为响应结构示例,非默认第一页结果。downloads/likes 均为**含对应 seed 的展示值**,采用十进制字符串,避免 64 位计数在 JSON/JavaScript 中丢失精度。数据库原值不变;前端不要再次叠加 seed,不应把这些字段当作原始管理统计。
详情响应同样为 `{code:0,message:"ok",data:题型对象}`。不存在和未发布的题型均返回 404。公开白名单不含旧 grade、生成器配置、rendererKey、内部资产 ID、原 cloud:// 引用或对象存储 key。
列表与 total 在同一 Repeatable Read 事务、同一条件和时间点下读取,避免查询期间并发修改导致当次分页总量与内容使用不同快照。跨请求翻页仍可能受到新的业务写入影响,不承诺长时间固定快照。
## 预览图与配置
已按用户确认采用“返回题型文字/元数据,未就绪预览留空”的方式。预览返回 URL 前,必须同时满足资产 ready、public、purpose=preview、无私有用户归属、图片 MIME。
- 旧源引用、pending/failed/deleting/deleted、私有文件或非图片均返回 previewImg=null。
- 七牛公共资产:配置 `QINIU_PUBLIC_DOMAIN` 为 HTTPS origin,按对象 key 逐段编码;缺域名不猜地址、不返回 key。
- 经核实的 external 资产:仅输出无凭据、无查询参数或 fragment 的稳定 HTTPS URL;不输出带签名的临时地址。
`QINIU_PUBLIC_DOMAIN` 只用于只读地址解析,不代表上传、私有签名或文件迁移已经接入。新配置均在 `apps/server/.env.example`;实际密钥不属于查询接口范围。
## 当前 51 条数据的结果
2026-10-16 只读联调并逐项对照原附件:
| 查询 | total |
| ---------------------------------------- | ----- |
| 全部 | 51 |
| math | 22 |
| english | 7 |
| pinyin | 5 |
| chinese | 6 |
| puzzle | 11 |
| ageBand=3-4 | 10 |
| tag=字母 | 7 |
| categoryId=math & ageBand=3-4 & tag=数学 | 5 |
热门前 3:word-coloring-grid(774)、number-find(335)、number-decompose(325);最新前 3:clock-connect、clock-reading、word-test-sheet。
当前 51 张预览全部 pending,因此 previewImg=null;所有真实首次发布时间未知,isNew=false;审核后的年级关系尚为空,所以带 grade/grades 的查询暂时没有结果。这些条件不会用旧 grade/is_new 或临时图片地址自动补齐。年级筛选功能已经用独立样本验证,后续审核并写入关系即可命中。
已通过 `pnpm check`、`pnpm test:db`,临时库覆盖公开状态隔离、组合筛选、多年级去重、Tag 精确匹配及注入文本、false/0/重复参数校验、121 条完整分页、稳定并列顺序、bigint 展示/排序、真实新品、预览访问策略和 Swagger 参数声明。开发库实测只读,未修改题型、年级、资产或审计。
## 发布控制补充(2026-10-16)
公开列表、total、分页与详情统一过滤整条所属关系,不能通过热门、分类之外的查询或已知 ID 绕过。分类下架后其题型不再公开,恢复分类不改写题型自身状态。管理员通过 /admin/worksheets 查询草稿或下架内容;/admin/worksheet-resources 提供资源管理预览。状态更新、首发时间、页面快照失效与审计同事务。接口与客户端切换边界见 [内容发布与可见性](./内容发布与可见性.md)。
## 年龄段匹配调整(2026-10-16)
用户确认将普通题型查询从“最小年龄等于年龄段下限”改为“适用范围完整覆盖所选年龄段”。本次仅修改 ageBand 匹配语义,不修改 grade/grades、age/ageBand 参数格式,不改数据库或小程序云函数。旧分龄推荐接口尚未迁移,其匹配和榜单规则不属于本次普通列表变更。
+1
View File
@@ -10,6 +10,7 @@ export default tseslint.config(
'**/dist/**',
'**/dist-test/**',
'**/coverage/**',
'**/src/generated/**',
],
},
js.configs.recommended,
+10 -2
View File
@@ -45,10 +45,18 @@ POSTGRES_DB、POSTGRES_USER、POSTGRES_PASSWORD 只在空数据卷首次初始
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 张表及初始配置,2026-10-14 执行 004/005/006/007/008 后共有 13 张表;后续 Prisma schema/migrations 根据 [v3.5 数据库设计](../docs/v3.5/数据库设计文档.md) 实施。开发角色为此本地集群的初始化管理员,生产运行角色权限在部署时单独设置,不复制本地账号密码到云服务器。
根 `.env` 仅供 Compose;`apps/server/.env` 仅供服务端,服务端已读取 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` 数据库及连接串,当前 `pnpm test:db` 使用脚本创建的随机命名短期库,结束后自动删除,不创建常驻测试库。测试不得对 doodle_dev 或生产库执行清空数据操作。
云端首期仅常驻生产库,测试联调按需隔离。详细安排与七牛资源隔离见 [开发与部署环境方案](../docs/v3.5/开发与部署环境方案.md)。
建表 SQL 已生成并通过临时库验证,开发库已于 2026-10-12 完成初始化,其他空库执行步骤见 [SQL 执行说明](../apps/server/prisma/sql/README.md)。Docker 启动只创建空数据库,不会自动执行这些业务表脚本。
## 本地状态复核(2026-10-15)
- `doodle_dev` 已存在且容器健康,连接地址为 `127.0.0.1:5434`,PostgreSQL 18.6、UTF-8、C 排序规则、UTC。
- 在独立临时参考库执行最新 002/003,对照开发库的表、字段定义、约束、索引及注释,全部一致;忽略字段物理顺序。共 13 张表、135 个字段、41 个索引;无需重新执行建表或增量 SQL。
- `page_configs/current` 与 `settings/homeAutoRefresh` 均已初始化,版本为 0,自动刷新 enabled=true;其余 11 张表为空,尚未导入线上数据。
- 已将服务端 `.env` 中 DATABASE_URL 的密码与现有本地数据库凭据对齐,验证宿主机端口可达及 TCP 密码连接成功;未输出密钥,保留其他服务端配置。
- 开发库只读核对,未重建或清空;临时参考库已删除。随后已接入 Prisma Schema/基线及服务端登录接口;现有库只登记迁移记录,不重复执行建表。
+7 -2
View File
@@ -21,8 +21,13 @@
"lint": "eslint . --max-warnings 0",
"format": "prettier . --write",
"format:check": "prettier . --check",
"test": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server test",
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test"
"test": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server test && pnpm --filter @doodle/asset-sync test",
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test",
"test:db": "pnpm --filter @doodle/contracts build && pnpm --filter @doodle/server test:db",
"categories:import": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server categories:import",
"worksheets:import": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server worksheets:import",
"assets:sync": "pnpm --filter @doodle/asset-sync sync",
"assets:test": "pnpm --filter @doodle/asset-sync test"
},
"devDependencies": {
"@eslint/js": "10.0.1",
+17
View File
@@ -0,0 +1,17 @@
# 相对路径以 packages/asset-sync 为基准,也可填写绝对路径。
SOURCE_DIR=../../../doodle-assets/cloud_backup
# 七牛服务端凭据,仅在 --apply 时要求;不能使用微信 AppSecret。
QINIU_AK=
QINIU_SK=
# 所有资源共用的公开空间,包含 user-avatars 头像。
QINIU_BUCKET=
# 自动查询区域;需要固定时填 z0、z1、z2、na0、as0 等官方区域 ID。
QINIU_REGION=
# 对象 key = 前缀 + 备份相对路径;留空表示保留原始路径。
QINIU_KEY_PREFIX=doodle/
# 可选:用于报告中的公共 URL,只填 HTTPS origin,不含路径;包含头像 URL。
QINIU_PUBLIC_DOMAIN=
SYNC_CONCURRENCY=3
SYNC_RETRIES=2
# 内存上传上限,默认 64 MiB;每个并发任务最多读取一份文件。
SYNC_MAX_FILE_BYTES=67108864
+1
View File
@@ -0,0 +1 @@
reports/
+107
View File
@@ -0,0 +1,107 @@
# 本地备份同步七牛
独立 Node.js 脚本项目,使用官方 qiniu SDK 7.15.2。入口命令在 doodle-store 根目录执行。只复制本地文件到已存在的七牛空间,不创建空间、不删除/覆盖远端对象、不修改数据库或业务 URL。
## 配置
已生成本机 `packages/asset-sync/.env`,源目录配置为:
```dotenv
SOURCE_DIR=/Users/joey-xd/sites/doodle/doodle-assets/cloud_backup
QINIU_AK=
QINIU_SK=
QINIU_BUCKET=
QINIU_REGION=
QINIU_KEY_PREFIX=doodle/
QINIU_PUBLIC_DOMAIN=
SYNC_CONCURRENCY=3
SYNC_RETRIES=2
SYNC_MAX_FILE_BYTES=67108864
```
填写本地 `.env` 即可,不要将 AK/SK 写到小程序、Git、命令参数或聊天中。该文件被 Git 忽略,并以 0600 权限保存。新机器可从 `.env.example` 复制;相对 SOURCE_DIR 以配置文件目录为基准,运行目录不影响源路径。进程环境变量优先于文件配置。
| 配置 | 说明 |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| SOURCE_DIR | 本地备份根目录,工具只读 |
| QINIU_AK / QINIU_SK | 七牛服务端 AccessKey / SecretKey,仅正式同步要求 |
| QINIU_BUCKET | 所有资源共用的公开空间:图片、头像、字体、JSON 等 |
| QINIU_REGION | 可留空自动查询;固定区域时使用七牛 region ID,如 z0、z1、z2、na0、as0 |
| QINIU_KEY_PREFIX | 对象前缀;默认 doodle/,空串为原相对路径 |
| QINIU_PUBLIC_DOMAIN | 可选 HTTPS origin,例如 https://cdn.example.com;不含 /doodle 等路径,仅用于生成报告中的公共 URL,不证明域名已经绑定该空间 |
| SYNC_CONCURRENCY | 并发文件数,1~10,默认 3 |
| SYNC_RETRIES | 瞬时错误额外重试次数,0~5,默认 2 |
| SYNC_MAX_FILE_BYTES | 每个文件读取内存的大小限制,默认 64 MiB |
空间必须已存在且有权查询和上传。执行前读取空间设置,核对目标空间实际 private=0,工具不会自动调整 ACL。头像与其他资源使用同一个 QINIU_BUCKET,无需配置私有空间。
## 使用
```bash
# 查看用法
pnpm assets:sync --help
# 默认预演全部文件,不调用七牛接口,也不要求 AK/SK
pnpm assets:sync
# 只预演题型预览图
pnpm assets:sync --only assets/previews
# 填写 .env 后,先同步 51 张预览
pnpm assets:sync --apply --only assets/previews
# 可选择多个目录
pnpm assets:sync --apply --only assets/fonts --only assets/materials
# 全部同步,含头像,均使用 QINIU_BUCKET 公开空间
pnpm assets:sync --apply
# 自定义配置文件/报告;报告文件不能已存在,也不能位于源备份内
pnpm assets:sync --env /绝对路径/.env.sync --report /绝对路径/report.json
```
`--only` 按目录前缀或精确文件路径筛选,不是 glob;`--dry-run` 可显式声明预演,与 `--apply` 互斥。通过默认预演确认清单后,再添加 --apply 上传。文件缺失、损坏 JSON、超限文件、软链接及无法识别的预览/头像图片会停止预检。
## 对象路径和同步行为
默认映射:
```text
assets/previews/math/number-find.jpg
→ 公共 bucket : doodle/assets/previews/math/number-find.jpg
user-avatars/<文件名>.png
→ 同一公共 bucket : doodle/user-avatars/<文件名>.png
```
头像保留 `user-avatars/` 相对路径,与预览等资源共用公开空间。配置 QINIU_PUBLIC_DOMAIN 后,报告也会生成头像的公开地址。工具不根据文件名推断用户身份,用户归属仍需在后续绑定时核实。
1. 扫描源目录,忽略隐藏文件,记录实际 MIME、字节数、SHA-256 与七牛 ETag;不修改源文件。
2. 正式执行时重新读取并核对本地摘要,使用该次读取的 Buffer 上传,避免核对后源文件被修改。
3. 远端不存在时,用指定 bucket:key 的 insertOnly=1 凭证上传,仅允许新增,启用 SDK CRC 校验和 HTTPS。
4. 远端同 key 且 ETag/大小一致:跳过并标记已核对。不同内容:报告 conflict,保留原远端文件;需要新版本时选择新的前缀/key。
5. 上传完成再次 stat,对比远端 ETag/大小;不是仅凭“请求成功”就记完成。
6. 瞬时网络错误、429、5xx 有限重试,鉴权错误和冲突不盲目重试。上传已成功但响应丢失时,重试遇到 614 后回查摘要恢复。
7. 任一文件失败或冲突时命令退出码为 1,其余文件结果仍写入报告。中断后重新运行同一命令,会重新检查远端并跳过已完成对象;未提供分片级断点续传。
校验方式是七牛内容 ETag 和大小,不等同于从 CDN 下载全部文件后比较 SHA-256。SHA-256 是本地备份摘要,报告明确记录 verifiedBy=qiniu-etag-and-size。不会自动刷新 CDN 或替换同名对象。
## 报告与后续接入
默认报告写 `packages/asset-sync/reports/<时间>-<随机ID>.json`,目录被 Git 和格式化工具忽略,文件权限 0600;含源相对路径、目标 bucket/key、MIME、大小、摘要、状态和有限错误码。不包含 AK、SK、上传凭证、签名链接或 SDK 原始异常。中断时报告可能未写完,以重新运行后的远端校验为准。
主要状态:planned(预演)、uploaded(上传并核对)、skipped(已有同内容)、conflict(同名异内容)、failed、not_attempted(全批配置/权限预检未通过)。常见全批错误:CREDENTIALS_REQUIRED、BUCKET_REQUIRED、BUCKET_VISIBILITY_MISMATCH。上传用户需有 bucketInfo、stat、upload 所需权限。
这次只做文件同步。同步成功后仍需按报告映射更新新后端 assets(保持现有 ID 并回填 bucket/key/MIME/摘要、status=ready)、字体配置、用户头像绑定及首页 JSON 内的 cloud 引用。content/page-config.json 当前会原样复制,不会自动转换嵌套链接;原云上传和定时任务也不会自动切换到七牛。
## 验证与当前预演
```bash
pnpm assets:test
pnpm check
```
测试使用临时本地文件和模拟七牛接口,覆盖配置、ETag 边界、头像同空间映射、预演无网络、首次上传、重复跳过、冲突保护、重试、响应丢失恢复、源文件变化、校验失败、凭据脱敏及 CLI 报告权限。没有使用真实 AK/SK 或执行真实上传。
本地备份预演:62 个文件,共 3,568,778 字节;其中题型预览 51、头像 7、字体 1、素材 1、首页配置 1、demo 1。该数量为本地预演结果,不代表已上传。
实现依据:[七牛官方 Node.js SDK](https://developer.qiniu.com/kodo/sdk/nodejs)、[七牛上传策略](https://developer.qiniu.com/kodo/manual/put-policy)。
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@doodle/asset-sync",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "本地云存储备份同步七牛工具",
"scripts": {
"sync": "node src/cli.mjs",
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"qiniu": "7.15.2"
}
}
+104
View File
@@ -0,0 +1,104 @@
import { randomUUID } from 'node:crypto';
import { mkdir, open, realpath } from 'node:fs/promises';
import {
basename,
dirname,
isAbsolute,
relative,
resolve,
sep,
} from 'node:path';
import { parseArgs } from 'node:util';
import { loadConfig, packageRoot, SyncError } from './config.mjs';
import { buildPlan } from './files.mjs';
import { safeFailure, syncPlan } from './sync.mjs';
/** 报告以独占、仅当前用户可读写方式创建;拒绝写入源备份目录,避免污染下次清单。 */
async function reserveReport(path, sourceDir) {
const check = (target) => {
const inside = relative(sourceDir, target);
if (
!inside ||
(!isAbsolute(inside) &&
inside !== '..' &&
!inside.startsWith(`..${sep}`))
)
throw new SyncError('REPORT_INSIDE_SOURCE');
};
check(path);
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
const canonicalPath = resolve(
await realpath(dirname(path)),
basename(path),
);
check(canonicalPath);
return open(canonicalPath, 'wx', 0o600);
}
async function main() {
const { values } = parseArgs({
options: {
apply: { type: 'boolean' },
'dry-run': { type: 'boolean' },
only: { type: 'string', multiple: true },
report: { type: 'string' },
env: { type: 'string' },
help: { type: 'boolean' },
},
});
if (values.help) {
console.log(
'资源同步七牛:默认预演,不修改云端或数据库。\n\npnpm assets:sync [--only assets/previews] [--report /绝对路径/report.json]\npnpm assets:sync --apply [--only assets/previews]\n\n配置:packages/asset-sync/.env;可用 --env 指定其他配置文件。AK/SK 只通过配置/环境变量提供。',
);
return;
}
if (values.apply && values['dry-run'])
throw new SyncError('CONFLICTING_OPTIONS');
const config = await loadConfig(
values.env ? { envFile: resolve(values.env) } : {},
);
const plan = await buildPlan(config, values.only ?? []);
const reportPath = values.report
? resolve(values.report)
: resolve(packageRoot, 'reports', `${Date.now()}-${randomUUID()}.json`);
const output = await reserveReport(reportPath, plan.sourceDir);
try {
// 预演不加载 SDK,不要求七牛凭据,也不调用任何远程接口。
const client = values.apply
? (await import('./qiniu-client.mjs')).createQiniuClient(config)
: undefined;
const report = await syncPlan(config, plan, {
apply: Boolean(values.apply),
client,
progress: (value) => console.log(JSON.stringify(value)),
});
await output.writeFile(`${JSON.stringify(report, null, 4)}\n`, 'utf8');
console.log(
JSON.stringify({
mode: report.mode,
ok: report.ok,
summary: report.summary,
...(report.error ? { error: report.error } : {}),
report: reportPath,
}),
);
if (!report.ok) process.exitCode = 1;
} finally {
await output.close();
}
}
main().catch((error) => {
// 不打印异常对象或 stack;配置文件及 SDK 异常可能包含凭据或签名地址。
console.error(
JSON.stringify({
ok: false,
error: safeFailure(error),
message:
error instanceof SyncError
? error.message
: '同步未完成,请检查配置、源目录和报告路径',
}),
);
process.exitCode = 1;
});
+126
View File
@@ -0,0 +1,126 @@
import { readFile } from 'node:fs/promises';
import { dirname, isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseEnv } from 'node:util';
export const packageRoot = fileURLToPath(new URL('../', import.meta.url));
/** 有限错误码与固定提示;禁止把 SDK 原始异常、签名或配置值写入日志。 */
export class SyncError extends Error {
constructor(code, message = code, statusCode = 0, retryable = false) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.retryable = retryable;
}
}
/** 本地文件配置与环境变量合并,环境变量优先;相对源路径以配置文件所在目录为准。 */
export async function loadConfig({
env = process.env,
envFile = resolve(packageRoot, '.env'),
} = {}) {
let values = {};
try {
values = parseEnv(await readFile(envFile, 'utf8'));
} catch (error) {
if (error.code !== 'ENOENT')
throw new SyncError('CONFIG_READ_FAILED', '无法读取配置文件');
}
const merged = { ...values, ...env };
const number = (key, fallback, min, max) => {
const raw = merged[key] ?? String(fallback);
if (
!/^\d+$/.test(raw) ||
!Number.isSafeInteger(Number(raw)) ||
Number(raw) < min ||
Number(raw) > max
)
throw new SyncError('INVALID_CONFIG', `${key} 配置不合法`);
return Number(raw);
};
const source = merged.SOURCE_DIR ?? '../../../doodle-assets/cloud_backup';
if (!source.trim())
throw new SyncError('INVALID_CONFIG', 'SOURCE_DIR 不能为空');
const prefix = normalizeRelativePath(
merged.QINIU_KEY_PREFIX ?? 'doodle/',
true,
);
const bucket = (key) => {
const value = (merged[key] ?? '').trim();
if (value && !/^[a-z0-9][a-z0-9-]{1,62}$/.test(value))
throw new SyncError('INVALID_CONFIG', `${key} 配置不合法`);
return value;
};
const domain = (merged.QINIU_PUBLIC_DOMAIN ?? '').trim();
if (domain) {
try {
const url = new URL(domain);
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.pathname !== '/' ||
url.search ||
url.hash
)
throw new Error();
} catch {
throw new SyncError(
'INVALID_CONFIG',
'QINIU_PUBLIC_DOMAIN 必须为 HTTPS origin',
);
}
}
const region = (merged.QINIU_REGION ?? '').trim();
if (region && !/^[a-z0-9][a-z0-9-]{0,30}$/.test(region))
throw new SyncError('INVALID_CONFIG', 'QINIU_REGION 配置不合法');
return {
sourceDir: isAbsolute(source)
? resolve(source)
: resolve(dirname(envFile), source),
ak: (merged.QINIU_AK ?? '').trim(),
sk: (merged.QINIU_SK ?? '').trim(),
publicBucket: bucket('QINIU_BUCKET'),
region,
prefix: prefix ? `${prefix}/` : '',
publicDomain: domain.replace(/\/$/, ''),
concurrency: number('SYNC_CONCURRENCY', 3, 1, 10),
retries: number('SYNC_RETRIES', 2, 0, 5),
maxFileBytes: number('SYNC_MAX_FILE_BYTES', 67108864, 1, 1073741824),
};
}
/** 文件 key 与筛选前缀使用 POSIX 相对路径,拒绝越界、反斜线或空路径段。 */
export function normalizeRelativePath(value, allowEmpty = false) {
if (
typeof value !== 'string' ||
value.includes('\\') ||
[...value].some(
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
)
)
throw new SyncError('INVALID_PATH', '相对路径配置不合法');
const text = value.replace(/\/+$/, '');
if (!text && allowEmpty && !value.startsWith('/')) return '';
if (
!text ||
text.startsWith('/') ||
text.split('/').some((part) => !part || part === '.' || part === '..')
)
throw new SyncError('INVALID_PATH', '路径必须位于备份目录内');
return text;
}
/** 执行前一次检查全部目标;头像与其他资源共用 QINIU_BUCKET 公开空间。 */
export function validateApplyConfig(config, files) {
if (!config.ak || !config.sk || /\s/.test(config.ak + config.sk))
throw new SyncError(
'CREDENTIALS_REQUIRED',
'请配置 QINIU_AK 和 QINIU_SK',
);
for (const file of files) {
if (!file.bucket)
throw new SyncError('BUCKET_REQUIRED', '请配置 QINIU_BUCKET');
}
}
+160
View File
@@ -0,0 +1,160 @@
import { createHash } from 'node:crypto';
import { constants } from 'node:fs';
import { lstat, open, readdir, realpath } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import { normalizeRelativePath, SyncError } from './config.mjs';
/** 七牛 ETag:4 MiB 分块 SHA-1,单块前缀 0x16,多块摘要前缀 0x96。 */
export function qiniuEtag(buffer) {
const blockSize = 4 * 1024 * 1024;
const sha1 = (data) => createHash('sha1').update(data).digest();
if (buffer.length <= blockSize)
return Buffer.concat([Buffer.from([0x16]), sha1(buffer)]).toString(
'base64url',
);
const blocks = [];
for (let offset = 0; offset < buffer.length; offset += blockSize)
blocks.push(sha1(buffer.subarray(offset, offset + blockSize)));
return Buffer.concat([
Buffer.from([0x96]),
sha1(Buffer.concat(blocks)),
]).toString('base64url');
}
/** 只读普通文件,不跟随文件或父目录软链接;限制单文件内存占用。 */
export async function readSource(config, relativePath) {
relativePath = normalizeRelativePath(relativePath);
let parent = config.sourceDir;
for (const part of relativePath.split('/').slice(0, -1)) {
parent = join(parent, part);
if ((await lstat(parent)).isSymbolicLink())
throw new SyncError('SYMLINK_NOT_ALLOWED', '源目录包含软链接');
}
const path = join(config.sourceDir, relativePath);
const actual = await realpath(path);
if (!actual.startsWith(`${config.sourceDir}${sep}`))
throw new SyncError('SOURCE_OUTSIDE_ROOT');
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stat = await handle.stat();
if (!stat.isFile() || stat.size > config.maxFileBytes)
throw new SyncError(
'FILE_TOO_LARGE_OR_INVALID',
'文件超限或不是普通文件',
);
const buffer = await handle.readFile();
if (buffer.length > config.maxFileBytes)
throw new SyncError('FILE_TOO_LARGE_OR_INVALID');
return {
buffer,
size: buffer.length,
sha256: createHash('sha256').update(buffer).digest('hex'),
etag: qiniuEtag(buffer),
};
} finally {
await handle.close();
}
}
function mimeType(path, buffer) {
const head = buffer.subarray(0, 16);
if (
head
.subarray(0, 8)
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
)
return 'image/png';
if (head[0] === 255 && head[1] === 216 && head[2] === 255)
return 'image/jpeg';
if (
head.toString('ascii', 0, 4) === 'RIFF' &&
head.toString('ascii', 8, 12) === 'WEBP'
)
return 'image/webp';
if (/^GIF8[79]a/.test(head.toString('ascii'))) return 'image/gif';
if (head.toString('ascii', 0, 4) === 'wOF2') return 'font/woff2';
if (head.toString('ascii', 0, 4) === 'wOFF') return 'font/woff';
if (head.length >= 4 && head.readUInt32BE(0) === 0x00010000)
return 'font/ttf';
if (head.toString('ascii', 0, 4) === 'OTTO') return 'font/otf';
if (path.toLowerCase().endsWith('.json')) {
try {
JSON.parse(buffer.toString('utf8'));
return 'application/json';
} catch {
throw new SyncError('INVALID_JSON', 'JSON 文件损坏');
}
}
if (
path.toLowerCase().endsWith('.svg') &&
/^\s*(?:<\?xml[^>]*>\s*)?<svg[\s>]/.test(buffer.toString('utf8'))
)
return 'image/svg+xml';
return 'application/octet-stream';
}
/** 预演同样读取文件并生成摘要;头像与其他资源统一上传到公开空间。 */
export async function buildPlan(config, only = []) {
const root = await lstat(config.sourceDir);
if (!root.isDirectory() || root.isSymbolicLink())
throw new SyncError('INVALID_SOURCE_DIR', '备份路径必须为真实目录');
config = { ...config, sourceDir: await realpath(config.sourceDir) };
const filters = only.map((value) => normalizeRelativePath(value));
const selected = (path) =>
!filters.length ||
filters.some(
(prefix) => path === prefix || path.startsWith(`${prefix}/`),
);
const paths = [];
async function walk(dir) {
for (const entry of (await readdir(dir, { withFileTypes: true })).sort(
(a, b) => a.name.localeCompare(b.name, 'en'),
)) {
if (entry.name.startsWith('.')) continue;
const path = join(dir, entry.name);
const rel = relative(config.sourceDir, path).split(sep).join('/');
if (entry.isSymbolicLink())
throw new SyncError(
'SYMLINK_NOT_ALLOWED',
'备份目录包含软链接,请移除后重试',
);
if (entry.isDirectory()) await walk(path);
else if (entry.isFile() && selected(rel))
paths.push(normalizeRelativePath(rel));
else if (!entry.isFile())
throw new SyncError('UNSUPPORTED_FILE', '备份包含非普通文件');
}
}
await walk(config.sourceDir);
if (!paths.length) throw new SyncError('NO_FILES', '没有找到匹配文件');
const files = [];
for (const path of paths) {
const { buffer, ...hashes } = await readSource(config, path);
const visibility = 'public';
const key = `${config.prefix}${path}`;
if (Buffer.byteLength(key) > 750)
throw new SyncError('KEY_TOO_LONG', '七牛对象路径超过 750 字节');
const mime = mimeType(path, buffer);
if (
(path.startsWith('assets/previews/') ||
path.startsWith('user-avatars/')) &&
!mime.startsWith('image/')
)
throw new SyncError(
'INVALID_IMAGE',
'预览或头像文件不是支持的图片格式',
);
files.push({
relativePath: path,
key,
bucket: config.publicBucket || null,
visibility,
mimeType: mime,
...hashes,
publicUrl: config.publicDomain
? `${config.publicDomain}/${key.split('/').map(encodeURIComponent).join('/')}`
: null,
});
}
return { sourceDir: config.sourceDir, files };
}

Some files were not shown because too many files have changed in this diff Show More