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/ coverage/
*.tsbuildinfo *.tsbuildinfo
*.log *.log
/apps/server/src/generated/
.DS_Store .DS_Store
.env .env
.env.* .env.*
+3
View File
@@ -4,3 +4,6 @@ dist
dist-test dist-test
coverage coverage
pnpm-lock.yaml pnpm-lock.yaml
apps/server/src/generated
packages/asset-sync/reports
+25 -6
View File
@@ -4,12 +4,13 @@
## 当前范围 ## 当前范围
当前完成基础工程和本地数据库环境,具体业务尚未接入: 当前已完成基础工程、本地数据库、小程序登录鉴权及分类服务:
- `apps/server`:可运行的 NestJS 服务,已接入日志、中间件、Swagger 与 JWT 基础设施;当前公开业务路由仍只有进程健康检查。 - `apps/server`:可运行的 NestJS 服务,已接入日志、中间件、Swagger、微信静默登录、JWT 与真实账户检查,提供本人摘要、分类查询/管理、题型列表/详情与组合筛选和数据库就绪接口。
- `packages/contracts`:基础成功响应与健康检查类型,不包含业务字段或私有实现。 - `packages/contracts`:公开响应、登录请求/结果、本人摘要、分类与安全错误类型,不包含数据库模型或私有实现。
- `packages/asset-sync`:本地云存储备份同步七牛的独立 Node.js 工具,支持预演、校验、重试,头像与其他资源共用公开空间。
- `apps/admin`、`apps/web`:仅预留目录与说明,尚未选择或安装前端框架。 - `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 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` 或密钥。 可在 `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 ```bash
pnpm check # 格式、代码检查、类型检查、构建与测试 pnpm check # 格式、代码检查、类型检查、构建与测试
pnpm test:db # 独立临时数据库中的登录/鉴权/分类测试,结束后清理
pnpm build # 按工作区依赖顺序构建 pnpm build # 按工作区依赖顺序构建
pnpm start # 运行已构建的服务端 pnpm start # 运行已构建的服务端
pnpm format # 格式化源码与配置 pnpm format # 格式化源码与配置
@@ -44,6 +50,7 @@ apps/
admin/ 后续管理后台,当前仅占位 admin/ 后续管理后台,当前仅占位
web/ 后续用户网站,当前仅占位 web/ 后续用户网站,当前仅占位
packages/ packages/
asset-sync/ 云存储备份同步七牛的独立脚本
contracts/ 可公开的接口类型 contracts/ 可公开的接口类型
deploy/ 后续部署说明,当前未部署 deploy/ 后续部署说明,当前未部署
infra/ 本地 PostgreSQL Docker 配置 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)。 现有功能迁移的权威方案见 [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 NODE_ENV=development
HOST=127.0.0.1 HOST=127.0.0.1
PORT=3000 PORT=8800
LOG_LEVEL=info LOG_LEVEL=info
API_BODY_LIMIT_BYTES=1048576 API_BODY_LIMIT_BYTES=1048576
# 开发默认开启,生产默认关闭;可显式设置 true/false。 # 开发默认开启,生产默认关闭;可显式设置 true/false。
@@ -14,6 +14,14 @@ JWT_SECRET=
JWT_ISSUER=doodle-store JWT_ISSUER=doodle-store
JWT_AUDIENCE=doodle-mini JWT_AUDIENCE=doodle-mini
JWT_EXPIRES_IN_SECONDS=86400 JWT_EXPIRES_IN_SECONDS=86400
# 后续 Prisma 接入使用;用户名/密码/端口须与根 .env 一致,密码需 URL 编码。 # 微信静默登录已接入,两项均必填;小程序 AppID 与对应 AppSecret。
# 当前骨架尚未读取 DATABASE_URL;不要提交实际连接凭证。 # 仅服务端持有 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 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, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
"dev": "nest start --watch", "dev": "pnpm prisma:generate && node scripts/dev.mjs",
"build": "nest build", "build": "pnpm prisma:generate && nest build",
"start": "node dist/main.js", "start": "node dist/main.js",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "pnpm prisma:generate && tsc --noEmit -p tsconfig.json",
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js" "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": { "dependencies": {
"@doodle/contracts": "workspace:*", "@doodle/contracts": "workspace:*",
@@ -17,9 +23,12 @@
"@nestjs/jwt": "11.0.2", "@nestjs/jwt": "11.0.2",
"@nestjs/platform-express": "11.2.7", "@nestjs/platform-express": "11.2.7",
"@nestjs/swagger": "11.4.7", "@nestjs/swagger": "11.4.7",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"class-validator": "0.15.1", "class-validator": "0.15.1",
"helmet": "8.3.0", "helmet": "8.3.0",
"pg": "8.23.1",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.2" "rxjs": "7.8.2"
}, },
@@ -28,6 +37,8 @@
"@nestjs/testing": "11.2.7", "@nestjs/testing": "11.2.7",
"@types/express": "5.0.6", "@types/express": "5.0.6",
"@types/node": "24.19.2", "@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; REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT;
CREATE TABLE public.categories ( CREATE TABLE public.categories (
status varchar(16) NOT NULL DEFAULT 'draft',
id varchar(128) COLLATE "C" PRIMARY KEY, id varchar(128) COLLATE "C" PRIMARY KEY,
name varchar(100) NOT NULL, name varchar(100) NOT NULL,
icon varchar(1024) 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, REFERENCES public.categories (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
CONSTRAINT categories_icon_asset_id_fkey FOREIGN KEY (icon_asset_id) CONSTRAINT categories_icon_asset_id_fkey FOREIGN KEY (icon_asset_id)
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT, 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) 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 INDEX categories_parent_sort_idx ON public.categories (parent_id, sort_order, id);
CREATE TABLE public.worksheets ( 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 # 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 或数据库客户端执行。 基于 [数据库设计](../../../../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 张表与两条配置记录核对通过。 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 | | 已执行 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 去重。计数不会通过触发器自动更新,由后续业务事务实现。 - 下载 NULL exportId 只允许 import 来源;非空事件按 userId + exportId 去重。计数不会通过触发器自动更新,由后续业务事务实现。
- tags 元素类型、分类多层循环、任务租约/锁、JWT 失效与管理员权限由后续业务实现;SQL 不代表业务 API 已完成。 - tags 元素类型、分类多层循环、任务租约/锁、JWT 失效与管理员权限由后续业务实现;SQL 不代表业务 API 已完成。
- updatedAt 和 contentUpdatedAt 由服务显式维护,不添加把两者混为一谈的更新时间触发器。 - updatedAt 和 contentUpdatedAt 由服务显式维护,不添加把两者混为一谈的更新时间触发器。
- Prisma 尚未安装,Schema 和迁移历史尚未接入。后续接入时将本 SQL 纳入正式基线;已有表的库须采用基线登记流程,不能直接再次执行同一 DDL。 - Prisma Schema 和迁移历史已接入,本 SQL 已纳入正式基线;已有表的库须先核对再登记基线,不能直接再次执行同一 DDL。
## 单小程序身份简化(2026-10-14) ## 单小程序身份简化(2026-10-14)
@@ -180,3 +182,7 @@ docker compose --env-file .env -f infra/compose.yaml exec -T postgres \
- 所有临时核查库已清理;开发库只读比对,无样本写入。本次只修正文档说明,无需新增 DDL 或再次升级开发库。 - 所有临时核查库已清理;开发库只读比对,无样本写入。本次只修正文档说明,无需新增 DDL 或再次升级开发库。
业务归属/权限、首次发布、计数和审计事务、任务调度等仍需服务端接入;SQL 验证不代替业务 API 验收。 业务归属/权限、首次发布、计数和审计事务、任务调度等仍需服务端接入;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 { Module, type DynamicModule } from '@nestjs/common';
import { HealthController } from './health/health.controller'; import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module'; import { 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'; import { ServerConfigModule, type ServerConfig } from './config/server-config';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule, CategoryModule, WorksheetModule],
controllers: [HealthController], controllers: [HealthController],
}) })
export class AppModule { export class AppModule {
+7 -2
View File
@@ -33,6 +33,9 @@ export function configureApplication(
const startedAt = performance.now(); const startedAt = performance.now();
request.requestId = requestId; request.requestId = requestId;
response.setHeader('X-Request-Id', 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。 // 响应完成后记录状态与耗时;只记录 path,排除 URL 查询中的登录 code/token。
response.once('finish', () => response.once('finish', () =>
logger.log('http_request_completed', { logger.log('http_request_completed', {
@@ -57,7 +60,7 @@ export function configureApplication(
credentials: false, credentials: false,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Id'], allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Id'],
exposedHeaders: ['X-Request-Id'], exposedHeaders: ['X-Request-Id', 'Retry-After'],
}); });
// 应用工厂已禁用 Nest 默认解析器,此处统一限制 JSON 和表单请求大小。 // 应用工厂已禁用 Nest 默认解析器,此处统一限制 JSON 和表单请求大小。
// 表单不解析嵌套对象,上传文件另由后续 assets 模块处理。 // 表单不解析嵌套对象,上传文件另由后续 assets 模块处理。
@@ -84,7 +87,9 @@ export function configureApplication(
if (config.swaggerEnabled) { if (config.swaggerEnabled) {
const options = new DocumentBuilder() const options = new DocumentBuilder()
.setTitle('Doodle Store API') .setTitle('Doodle Store API')
.setDescription('统一业务 API;当前仅健康检查,业务接口逐步接入') .setDescription(
'统一业务 API;已接入微信静默登录、本人资料、账户鉴权与分类查询/管理',
)
.setVersion('0.1.0') .setVersion('0.1.0')
.addBearerAuth( .addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
+10 -11
View File
@@ -2,7 +2,6 @@ import {
ForbiddenException, ForbiddenException,
Inject, Inject,
Injectable, Injectable,
UnauthorizedException,
type CanActivate, type CanActivate,
type ExecutionContext, type ExecutionContext,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -11,6 +10,8 @@ import type { HttpRequest } from '../common/http-request';
import { ACCOUNT_READER, type AccountReader } from './account-reader'; import { ACCOUNT_READER, type AccountReader } from './account-reader';
import { IS_PUBLIC, REQUIRED_ROLES } from './auth.decorators'; import { IS_PUBLIC, REQUIRED_ROLES } from './auth.decorators';
import { JwtTokenService } from './jwt-token.service'; import { JwtTokenService } from './jwt-token.service';
import { ApiException } from '../common/api-exception';
import { requireCurrentAccount } from './account-policy';
@Injectable() @Injectable()
/** 全局访问守卫:JWT 验签 → 当前账户核对 → 服务端角色检查。 */ /** 全局访问守卫:JWT 验签 → 当前账户核对 → 服务端角色检查。 */
@@ -35,19 +36,17 @@ export class AccessTokenGuard implements CanActivate {
: null; : null;
const token = match?.[1]; const token = match?.[1];
// 先限制 token 长度,再进行签名解析,避免无意义的超长输入消耗资源。 // 先限制 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); const claims = await this.tokens.verifyAccessToken(token);
// 验签成功不等于账户仍有效;注销、禁用、凭证版本变更必须立即阻止旧 token。 // 验签成功不等于账户仍有效;注销、禁用、凭证版本变更必须立即阻止旧 token。
// 账户读取异常向上抛出,不退回到只检查 JWT 的宽松模式。 // 账户读取异常向上抛出,不退回到只检查 JWT 的宽松模式。
const account = await this.accounts.findById(claims.sub); const account = requireCurrentAccount(
if ( await this.accounts.findById(claims.sub),
!account || claims.sub,
account.id !== claims.sub || claims.authVersion,
account.status !== 'active' || );
account.authVersion !== claims.authVersion
) {
throw new UnauthorizedException();
}
// 后续控制器共享已核对身份,角色取自当前账户,而非客户端或 token 自报值。 // 后续控制器共享已核对身份,角色取自当前账户,而非客户端或 token 自报值。
request.auth = { request.auth = {
userId: account.id, 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 const ACCOUNT_READER = Symbol('ACCOUNT_READER');
/** 鉴权所需的最小账户快照,不包含昵称、头像或微信身份信息。 */ /** 鉴权所需的最小账户快照,不包含昵称、头像或微信身份信息。 */
export interface Account { export interface Account {
@@ -13,12 +11,3 @@ export interface Account {
export interface AccountReader { export interface AccountReader {
findById(userId: string): Promise<Account | null>; 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 { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt'; 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 { AccessTokenGuard } from './access-token.guard';
import { JwtTokenService } from './jwt-token.service'; 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({ @Module({
// 签名和验证参数由 JwtTokenService 统一提供,避免不同调用点使用不同策略。 // 签名和验证参数由 JwtTokenService 统一提供,避免不同调用点使用不同策略。
imports: [JwtModule.register({})], imports: [JwtModule.register({}), DatabaseModule],
controllers: [LoginController, CurrentUserController],
providers: [ providers: [
JwtTokenService, JwtTokenService,
// 用户模块接入 Prisma 后替换此 provider;当前有效 token 也不能绕过账户检查。 AccountRepository,
{ provide: ACCOUNT_READER, useClass: UnconfiguredAccountReader }, { provide: ACCOUNT_READER, useExisting: AccountRepository },
{ provide: WECHAT_FETCH, useValue: fetch },
WechatClient,
LoginRateLimiter,
LoginService,
// 注册为全局守卫,新路由默认受保护,无需逐个添加 @UseGuards。 // 注册为全局守卫,新路由默认受保护,无需逐个添加 @UseGuards。
{ provide: APP_GUARD, useClass: AccessTokenGuard }, { provide: APP_GUARD, useClass: AccessTokenGuard },
], ],
exports: [JwtTokenService, ACCOUNT_READER], exports: [JwtTokenService, ACCOUNT_READER],
}) })
/** JWT 与鉴权基础设施;本模块尚不提供微信登录或公开 token 签发接口。 */ /** 微信静默登录、本人读取与全局鉴权;身份始终来自可信微信兑换或本站凭证。 */
export class AuthModule {} 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 { JwtService } from '@nestjs/jwt';
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config'; import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
import type { WechatLoginResult } from '@doodle/contracts';
import { ApiException } from '../common/api-exception';
/** 最小身份载荷;authVersion 用于与账户当前版本比较,不在 token 中固化角色。 */ /** 最小身份载荷;authVersion 用于与账户当前版本比较,不在 token 中固化角色。 */
export interface AccessTokenClaims { 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> { async verifyAccessToken(token: string): Promise<AccessTokenClaims> {
try { try {
@@ -70,9 +87,14 @@ export class JwtTokenService {
throw new Error('Invalid access token claims'); throw new Error('Invalid access token claims');
} }
return claims; return claims;
} catch { } catch (error) {
// 过期、伪造或载荷错误统一返回 401,不向客户端暴露验签细节。 // 只公开客户端恢复需要的有限原因,不泄漏验签细节、token 或原始异常。
throw new UnauthorizedException(); 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 { Response } from 'express';
import type { HttpRequest } from './http-request'; import type { HttpRequest } from './http-request';
import { AppLogger } from '../logging/app-logger'; import { AppLogger } from '../logging/app-logger';
import { ApiException } from './api-exception';
// 对外只提供稳定的安全提示;异常原始 message 可能包含请求值、路径或密钥。 // 对外只提供稳定的安全提示;异常原始 message 可能包含请求值、路径或密钥。
const messages: Record<number, string> = { const messages: Record<number, string> = {
@@ -57,13 +58,27 @@ export class ApiErrorFilter implements ExceptionFilter {
exception instanceof Error exception instanceof Error
? exception.name ? exception.name
: 'UnknownError', : 'UnknownError',
...(exception instanceof ApiException
? { reason: exception.reason }
: {}),
}); });
} }
// HTTP 状态与响应 code 保持一致,客户端可用 requestId 反馈问题。 // HTTP 状态与响应 code 保持一致,客户端可用 requestId 反馈问题。
if (
exception instanceof ApiException &&
exception.retryAfterSeconds !== undefined
)
response.setHeader(
'Retry-After',
String(exception.retryAfterSeconds),
);
response.status(status).json({ response.status(status).json({
code: status, code: status,
message: messages[status] ?? '服务器暂时无法处理请求', message: messages[status] ?? '服务器暂时无法处理请求',
requestId: request.requestId, 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( export function getRuntimeOptions(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): RuntimeOptions { ): RuntimeOptions {
const rawPort = env.PORT ?? '3000'; const rawPort = env.PORT ?? '8800';
const port = Number(rawPort); const port = Number(rawPort);
const host = (env.HOST ?? '127.0.0.1').trim(); const host = (env.HOST ?? '127.0.0.1').trim();
+75 -1
View File
@@ -10,7 +10,7 @@ export const SERVER_CONFIG = Symbol('SERVER_CONFIG');
export const API_PREFIX = 'api/v1'; export const API_PREFIX = 'api/v1';
export type LogLevel = 'error' | 'warn' | 'info' | 'debug'; export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
/** 规范化的启动配置;仅在服务端使用,包含不得下发或记录的 JWT 密钥。 */ /** 规范化的启动配置;仅在服务端使用,JWT 和微信密钥不得下发或记录。 */
export interface ServerConfig extends RuntimeOptions { export interface ServerConfig extends RuntimeOptions {
nodeEnv: 'development' | 'test' | 'production'; nodeEnv: 'development' | 'test' | 'production';
logLevel: LogLevel; logLevel: LogLevel;
@@ -22,6 +22,13 @@ export interface ServerConfig extends RuntimeOptions {
jwtIssuer: string; jwtIssuer: string;
jwtAudience: string; jwtAudience: string;
jwtExpiresInSeconds: number; jwtExpiresInSeconds: number;
/** 微信凭据仅服务端持有,登录接入后两项必须完整。 */
wechatMiniAppId: string;
wechatMiniAppSecret: string;
databaseUrl: string;
/** 新品依据首次发布时间判断;公共资源域名缺失时不返回七牛对象地址。 */
worksheetNewWindowDays: number;
qiniuPublicDomain: string | null;
} }
/** 按十进制整数字符串解析并检查范围,防止空串、科学计数法等被 Number 宽松接受。 */ /** 按十进制整数字符串解析并检查范围,防止空串、科学计数法等被 Number 宽松接受。 */
@@ -78,6 +85,41 @@ export function getServerConfig(
if (!jwtIssuer || !jwtAudience) { if (!jwtIssuer || !jwtAudience) {
throw new Error('JWT_ISSUER and JWT_AUDIENCE must not be empty'); 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;路径、凭证、通配符不属于白名单项。 // 只接受可直接比较的完整 origin;路径、凭证、通配符不属于白名单项。
const corsOrigins = (env.CORS_ORIGINS ?? '') const corsOrigins = (env.CORS_ORIGINS ?? '')
.split(',') .split(',')
@@ -113,6 +155,27 @@ export function getServerConfig(
'TRUSTED_PROXY_CIDRS must contain explicit proxy addresses or CIDRs', '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 { return {
...getRuntimeOptions(env), ...getRuntimeOptions(env),
nodeEnv: nodeEnv as ServerConfig['nodeEnv'], nodeEnv: nodeEnv as ServerConfig['nodeEnv'],
@@ -130,6 +193,17 @@ export function getServerConfig(
jwtSecret, jwtSecret,
jwtIssuer, jwtIssuer,
jwtAudience, jwtAudience,
wechatMiniAppId,
wechatMiniAppSecret,
databaseUrl,
worksheetNewWindowDays: integer(
env,
'WORKSHEET_NEW_WINDOW_DAYS',
30,
1,
365,
),
qiniuPublicDomain,
jwtExpiresInSeconds: integer( jwtExpiresInSeconds: integer(
env, env,
'JWT_EXPIRES_IN_SECONDS', '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); const app = await createApplication(config, logger);
try { try {
await app.listen(config.port, config.host); 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', { logger.log('server_started', {
host: config.host, host: config.host,
port: config.port, port: config.port,
api_prefix: API_PREFIX, api_prefix: API_PREFIX,
swagger_enabled: config.swaggerEnabled, 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) { } catch (error) {
await app.close(); await app.close();
throw error; 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)); assert.ok(!logs.join('\n').includes(userToken));
}); });
test('生产默认不暴露 Swagger;账户查询未接入时不放行有效 JWT', async (context) => { test('生产默认不暴露 Swagger;真实账户数据库不可用时不放行有效 JWT', async (context) => {
const config = { const config = {
...testConfig, ...testConfig,
nodeEnv: 'production' as const, 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', () => { test('使用本地默认 host 和 port', () => {
assert.deepEqual(getRuntimeOptions({}), { assert.deepEqual(getRuntimeOptions({}), {
host: '127.0.0.1', 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 { test } from 'node:test';
import { getServerConfig } from '../src/config/server-config'; 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 密钥,不使用共享默认密钥', () => { test('启动配置必须提供有效 JWT 密钥,不使用共享默认密钥', () => {
for (const secret of [ 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 有效期、日志级别、布尔开关和跨域配置', () => { test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域配置', () => {
for (const entry of [ for (const entry of [
{ API_BODY_LIMIT_BYTES: '0' }, { API_BODY_LIMIT_BYTES: '0' },
@@ -55,3 +137,30 @@ test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域
['https://example.com', 'http://localhost:3001'], ['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({ export const testConfig = getServerConfig({
NODE_ENV: 'test', NODE_ENV: 'test',
JWT_SECRET: 'test-only-secret-123456789012345678901234567890', 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 基础设置 # 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、对象存储或业务模块。 参考 `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 负责读取配置和启动,测试使用临时端口。 从 `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。生成随机密钥的示例命令: 新环境先复制 `apps/server/.env.example` 为 `apps/server/.env`,设置 JWT_SECRET。生成随机密钥的示例命令:
```bash ```bash
@@ -19,7 +23,7 @@ node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
| 配置 | 默认 / 要求 | | 配置 | 默认 / 要求 |
| ------------------------- | ----------------------------------------------------------------- | | ------------------------- | ----------------------------------------------------------------- |
| NODE_ENV | development;允许 development/test/production | | 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 | | LOG_LEVEL | info;允许 error/warn/info/debug |
| API_BODY_LIMIT_BYTES | 1048576,即 1 MiB;允许 1 KiB~10 MiB | | API_BODY_LIMIT_BYTES | 1048576,即 1 MiB;允许 1 KiB~10 MiB |
| SWAGGER_ENABLED | 未设置时开发/测试 true,生产 false;生产不要照搬开发示例中的 true | | 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_SECRET | 必填,至少 32 字节;拒绝空值、过短或 change-me 占位值 |
| JWT_ISSUER / JWT_AUDIENCE | doodle-store / doodle-mini | | JWT_ISSUER / JWT_AUDIENCE | doodle-store / doodle-mini |
| JWT_EXPIRES_IN_SECONDS | 86400,即 24 小时;允许 60~604800 秒 | | 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. 请求处理 ## 2. 请求处理
@@ -40,11 +48,11 @@ DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接
- Helmet 安全头与隐藏 X-Powered-By。Swagger 开启时放宽文档所需 CSP;Swagger 关闭时使用默认 CSP。 - Helmet 安全头与隐藏 X-Powered-By。Swagger 开启时放宽文档所需 CSP;Swagger 关闭时使用默认 CSP。
- CORS 使用精确 origin 白名单,无 Cookie 凭证模式;允许 Bearer Authorization,并向浏览器暴露 X-Request-Id。无 origin 的小程序/服务端请求可用,CORS 不代替身份校验。 - CORS 使用精确 origin 白名单,无 Cookie 凭证模式;允许 Bearer Authorization,并向浏览器暴露 X-Request-Id。无 origin 的小程序/服务端请求可用,CORS 不代替身份校验。
- DTO 使用 ValidationPipe:transform、whitelist、forbidNonWhitelisted;不隐式把数字字符串转换成数字,不在错误里返回原始对象或值。 - 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 ## 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 守卫保护。 使用 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 查询账户,要求用户存在、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. 验证 ## 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-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 的具体版本。 基于 [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。 列表默认仅功能,资源/all 通过显式 target 选择;返回目标类型及资源独立预览。取消精确定位收藏记录或资源 ID,旧按功能取消不得删除内容收藏。资源缺失或下架只使详情不可用,内容 ID 不清空,仍可取消。注销清理两种收藏,仅功能关系贡献影响功能 likes。
数据库不强制资源外键以保留历史,发布内容不允许重新归属另一 worksheet。需要收藏某次算法生成结果、保存用户涂色进度或精确结果快照时另行设计;当前资源收藏只表示选中的完整底图内容。 数据库不强制资源外键以保留历史,发布内容不允许重新归属另一 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。数据库选型已按用户确认更新。 > 数据库:PostgreSQL;ORM:Prisma。数据库选型已按用户确认更新。
> >
> 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。 > 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;已接入 Prisma Schema 和迁移基线;线上集合、数据量和异常分布待 S0 盘点。
> >
> 更新日期:2026-10-14。 > 更新日期:2026-10-15(登录与 Prisma 实施状态更新,业务表结构不变)。
> >
> 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。 > 配套方案:[云服务端迁移方案](../云服务端迁移方案.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. 范围与依据 ## 1. 范围与依据
@@ -109,6 +111,7 @@ erDiagram
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 | | 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
| --------------------------- | -------------- | ----------- | ------------------------------------------------------------------ | | --------------------------- | -------------- | ----------- | ------------------------------------------------------------------ |
| status | varchar(16) | 否 / draft | CHECK:draft、active、hidden;公开可见还要求全部上级 active |
| id | varchar(128) | 否 | 原分类 ID,如 math、chinese;不重命名 | | id | varchar(128) | 否 | 原分类 ID,如 math、chinese;不重命名 |
| name | varchar(100) | 否 | 分类名 | | name | varchar(100) | 否 | 分类名 |
| icon | varchar(1024) | 否 | 保留图标名、字符等非文件内容;若是文件则转为稳定资产引用,接口解析 | | icon | varchar(1024) | 否 | 保留图标名、字符等非文件内容;若是文件则转为稳定资产引用,接口解析 |
@@ -121,7 +124,9 @@ erDiagram
主键 id;索引 `(parent_id, sort_order, id)`;CHECK `parent_id IS NULL OR parent_id <> id`。父分类必须存在,导入按树层级排序;多层循环不能靠上述 CHECK 判断。删除被题型或子分类引用的分类返回冲突。 主键 id;索引 `(parent_id, sort_order, id)`;CHECK `parent_id IS NULL OR parent_id <> id`。父分类必须存在,导入按树层级排序;多层循环不能靠上述 CHECK 判断。删除被题型或子分类引用的分类返回冲突。
分类负责数学、语文、手工等内容方向,年级是独立适用维度,不把“一年级数学”“二年级数学”复制为分类,也不把年级放入 categories.parent_id。Web 的“年级 → 分类”菜单是组合查询,不代表数据库父子关系。年级名称及展示顺序先使用固定配置,不新增 grades/菜单表。categories 表结构保持不变。 分类负责数学、语文、手工等内容方向,年级是独立适用维度,不把“一年级数学”“二年级数学”复制为分类,也不把年级放入 categories.parent_id。Web 的“年级 → 分类”菜单是组合查询,不代表数据库父子关系。年级名称及展示顺序先使用固定配置,不新增 grades/菜单表。分类增加发布状态,不增加年级菜单字段。
分类、题型和资源统一使用三态发布规则;上级未上线时下级也不可公开读取,隐藏上级不改写下级自身状态。管理预览及快照失效规则见 [内容发布与可见性](./内容发布与可见性.md)。
### 3.3 worksheets — 题型 ### 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 以当前服务端数据为准。 登录使用服务端配置的单一小程序 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 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。 涉及用户的写事务先锁用户行并核对状态,再锁题型行、需要时锁内容行;多个题型及同层内容按 ID 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。
@@ -494,7 +499,7 @@ DO NOTHING RETURNING id;
真实计数/seed 修订使用明确管理动作,锁题型行、记录前后值和 reason、同事务写审计。批量操作每项独立事务,保留逐项结果;dryRun 做同样校验且不写入。不将整批部分失败伪装为完全成功。 真实计数/seed 修订使用明确管理动作,锁题型行、记录前后值和 reason、同事务写审计。批量操作每项独立事务,保留逐项结果;dryRun 做同样校验且不写入。不将整批部分失败伪装为完全成功。
展示 likes/downloads = 原始值 + 对应 seed;管理 rawStats 不加 seed。保持现有首页 Top5、分龄 Top3 下载 + Top3 收藏再全量下载补齐的种子参与规则。分龄严格采用 `ageMin == 下限 AND ageMax >= 上限`;分龄并列沿用 updatedAt DESC,再以 id 稳定排序。首页并列新增稳定 id 排序,并在固定样本验收中记录差异。 展示 likes/downloads = 原始值 + 对应 seed;管理 rawStats 不加 seed。保持现有首页 Top5、分龄 Top3 下载 + Top3 收藏再全量下载补齐的种子参与规则。旧分龄推荐沿用 `ageMin == 下限 AND ageMax >= 上限`(尚未迁移);2026-10-16 用户确认普通 `/worksheets?ageBand=...` 筛选改为 `ageMin <= 下限 AND ageMax >= 上限`,只返回完整覆盖所选年龄段的题型;分龄并列沿用 updatedAt DESC,再以 id 稳定排序。首页并列新增稳定 id 排序,并在固定样本验收中记录差异。
### 5.4.1 新品、运营推荐与两层下载统计 ### 5.4.1 新品、运营推荐与两层下载统计
@@ -516,7 +521,7 @@ worksheets 表示功能,worksheet_resources 表示可选内容。新增“小
首期题型量小,可在短事务中完成查询与 JSON 构建;若构建移出事务,提交前比较 expectedVersion,冲突必须重新读取重建,不能覆盖新配置。自动刷新只覆盖 featured/hot,保留 sections 等手动配置。home/all 构建清 category/age,category 分支只重建 category;原调用中空数组被当作“沿用现有配置”的行为由兼容层保留。 首期题型量小,可在短事务中完成查询与 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 导入与审计 ## 6. CloudBase 导入与审计
@@ -594,7 +599,7 @@ S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字
## 9. SQL 与实际结构最终核查 ## 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 目录说明、统一执行路径,修正关系图中的必填封面/可缺失历史关联及资产清理引用范围。 临时核查数据库全部清理;开发库只读比对,未写入样本。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 云服务端迁移任务清单 # Doodle Mini 云服务端迁移任务清单
更新日期:2026-10-13。依据当前代码、数据库设计及《Doodle Mini 现有功能迁移到云服务端方案》整理。 更新日期:2026-10-15。依据当前代码、数据库设计及《Doodle Mini 现有功能迁移到云服务端方案》整理。
目标:把现有业务后端、数据、文件和定时任务迁出 CloudBase,由 doodle-store 提供统一 API、PostgreSQL 主数据库和七牛资源存储;doodle-mini 保留现有生成、绘制、保存、分享及广告能力。 目标:把现有业务后端、数据、文件和定时任务迁出 CloudBase,由 doodle-store 提供统一 API、PostgreSQL 主数据库和七牛资源存储;doodle-mini 保留现有生成、绘制、保存、分享及广告能力。
登录与鉴权实施细节见 [用户鉴权与登录设计](./用户鉴权与登录设计.md)(2026-10-15 设计稿);服务端登录和真实账户检查已实现,注销与小程序适配仍待实施;具体边界见 [服务端登录接入说明](./服务端登录接入说明.md)。
标记说明:已勾选表示已有实现及记录的验证;未勾选表示待完成。Server 能启动、数据库已建表,不代表业务迁移已经完成。清单不承诺具体工期,外部权限、备案和微信审核单独记录。 标记说明:已勾选表示已有实现及记录的验证;未勾选表示待完成。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 包及构建检查脚本;小程序保持独立项目。 - [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] 启动本地 PostgreSQL 18.6 Docker 开发环境,地址 127.0.0.1:5434,数据库 doodle_dev。
- [x] 生成并执行 10 张表的建表 SQL、索引、约束及两条初始配置;临时库 19 项约束检查通过。 - [x] 生成并执行 10 张表的建表 SQL、索引、约束及两条初始配置;临时库 19 项约束检查通过。
- [x] 接入 Server JSON 日志、脱敏、requestId、bodyParser、/api/v1 前缀、CORS、Helmet、DTO 校验、统一错误处理及 Swagger。 - [x] 接入 Server JSON 日志、脱敏、requestId、bodyParser、/api/v1 前缀、CORS、Helmet、DTO 校验、统一错误处理及 Swagger。
- [x] 接入 JWT 签发/验签、默认鉴权守卫、公开接口和角色声明;保留账户读取接入点。账户读取当前为拒绝放行的占位实现,真实登录未完成。 - [x] 接入 JWT 签发/验签、默认鉴权守卫、公开接口和角色声明;已接入 Prisma 账户读取、微信静默登录和安全认证错误原因。
- [x] Server 完整检查及 12 项基础/集成测试通过,构建入口真实启动检查通过;代码统一 4 空格并补充关键中文注释。 - [x] 2026-10-15 Server 完整检查、20 项常规测试及独立数据库 8 项检查通过,构建入口和真实数据库连接验证通过;微信真实 code 联调仍待小程序接入。
## 二、实施原则与范围 ## 二、实施原则与范围
@@ -38,12 +44,12 @@
## 四、S1:Prisma 与首条业务链路 ## 四、S1:Prisma 与首条业务链路
- [ ] S1-01:安装并锁定 Prisma 兼容版本,建立覆盖 10 张表的 schema,保持原字符串主键、C 排序规则及现有约束。 - [x] S1-01:锁定 Prisma 7.10.0 与 PostgreSQL adapter,Schema 覆盖 13 张表;SQL 基线保留 C 排序规则、CHECK 及部分唯一索引。
- [ ] S1-02:将已执行的 SQL 登记为迁移基线,补齐数据库迁移流程;不得在已有表的开发库重复执行初始 DDL。 - [x] S1-02:将已执行的 SQL 登记为迁移基线,补齐数据库迁移流程;不得在已有表的开发库重复执行初始 DDL。
- [ ] S1-03:实现 PrismaService、数据库连接/关闭和独立就绪检查;配置真实开发连接串及受控连接池。 - [x] S1-03:实现 PrismaService、数据库连接/关闭和独立就绪检查;配置真实开发连接串及受控连接池。
- [ ] S1-04:实现真实 AccountReader,查询用户状态、authVersion 和 role;替换当前有效 token 仍返回 503 的占位实现。 - [x] S1-04:实现真实 AccountReader,查询用户状态、authVersion 和 role;替换当前有效 token 仍返回 503 的占位实现。
- [ ] S1-05:落实公开/本人/管理接口分类,服务层强制校验数据归属;管理员角色不自动开放全体用户私有数据。 - [ ] 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-07:实现最小题型公开读取,以及收藏、成功下载记录的事务纵向样板;验证重复请求只产生一次关系或一次计数。
- [ ] S1-08:完善公开 DTO、分页/总量、错误码与 requestId 契约;新接口 id 与旧端 _id/path/previewImg 兼容转换明确记录。 - [ ] S1-08:完善公开 DTO、分页/总量、错误码与 requestId 契约;新接口 id 与旧端 _id/path/previewImg 兼容转换明确记录。
@@ -53,7 +59,7 @@
### 5.1 用户、收藏与下载 ### 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-U02:实现 DELETE /me 注销:事务清理收藏及相关 likes、下载历史、用户资料和身份;旧凭证失效,私有文件可重试清理。
- [ ] S2-U03:补齐收藏添加/取消/分页列表/数量/单张检查/批量检查;关系与 likes 同事务,仅真实增删影响计数。 - [ ] S2-U03:补齐收藏添加/取消/分页列表/数量/单张检查/批量检查;关系与 likes 同事务,仅真实增删影响计数。
- [ ] S2-U04:补齐下载记录添加/分页列表/数量;要求新版 exportId,日志与 downloads 同事务;历史 NULL 事件保留。 - [ ] S2-U04:补齐下载记录添加/分页列表/数量;要求新版 exportId,日志与 downloads 同事务;历史 NULL 事件保留。
@@ -61,7 +67,7 @@
### 5.2 公开内容 ### 5.2 公开内容
- [ ] S2-P01:实现题型列表/详情及分类列表;公开接口强制只返回 active,不允许客户端参数绕过状态限制。 - [x] S2-P01:分类与题型公开列表/详情已实现;分类及题型统一要求自身与祖先上线,支持组合筛选、分页和安全预览解析,未就绪预览按用户确认返回 null。分类新增 status,管理员可独立预览与上下架。
- [ ] S2-P02:实现分龄推荐,保留 5 个年龄段、现有年龄匹配、Top3 下载+Top3 收藏、去重和不足补齐规则。 - [ ] S2-P02:实现分龄推荐,保留 5 个年龄段、现有年龄匹配、Top3 下载+Top3 收藏、去重和不足补齐规则。
- [ ] S2-P03:区分真实计数与 seed 展示值;推荐保留种子参与规则,排序并列明确稳定规则并与固定样本核对。 - [ ] S2-P03:区分真实计数与 seed 展示值;推荐保留种子参与规则,排序并列明确稳定规则并与固定样本核对。
- [ ] S2-P04:提供公共首页配置接口,保留 home/category/age/version/updatedAt 及 previewImg 字段形状。 - [ ] S2-P04:提供公共首页配置接口,保留 home/category/age/version/updatedAt 及 previewImg 字段形状。
@@ -71,10 +77,10 @@
- [ ] S2-A01:确定管理后台登录方式及凭证方案;配置首个管理员,默认用户不能注册或自升管理员。 - [ ] S2-A01:确定管理后台登录方式及凭证方案;配置首个管理员,默认用户不能注册或自升管理员。
- [ ] S2-A02:落实后台独立登录入口或明确的管理员授权流程:身份验证后校验 active+admin,非管理员不能获得后台登录态;现有小程序管理入口同样受保护。 - [ ] S2-A02:落实后台独立登录入口或明确的管理员授权流程:身份验证后校验 active+admin,非管理员不能获得后台登录态;现有小程序管理入口同样受保护。
- [ ] S2-A03:全部管理 API 服务端检查管理员身份;用户降权/禁用后立即失去权限,不能只靠前端隐藏按钮。 - [ ] S2-A03:全部管理 API 服务端检查管理员身份;用户降权/禁用后立即失去权限,不能只靠前端隐藏按钮。
- [ ] S2-A04:实现题型发布/重新发布/上下架/管理列表和分类状态数量;普通重发不清零真实计数,保留 createdAt/contentUpdatedAt/updatedAt 语义。 - [ ] S2-A04:题型/资源管理列表、详情预览与状态上下架已实现;题型配置发布/重新构建及分类状态数量待实现;普通重发不清零真实计数,保留 createdAt/contentUpdatedAt/updatedAt 语义。
- [ ] S2-A05:实现分类创建/更新,保留主键和父子关系,校验父分类及循环引用。 - [x] S2-A05:实现管理员分类创建/更新/删除,保留主键和父子关系,事务锁串行校验父分类及循环引用;删除有子分类或题型引用时返回冲突,审计同事务。当前创建/更新支持文字/Emoji 图标,文件资产另见 S2-F04。
- [ ] S2-A06:实现批量年龄/年级/tags/seed/统计修订,保留 dryRun、逐项结果和错误;真实/seed 修订记录操作者、原因及前后值。 - [ ] S2-A06:实现批量年龄/年级/tags/seed/统计修订,保留 dryRun、逐项结果和错误;真实/seed 修订记录操作者、原因及前后值。
- [ ] S2-A07:管理修改与审计同事务;核实 worksheetsStatsUpdate 的旧调用需要,不向普通用户开放通用加数接口。 - [ ] S2-A07:分类管理修改/迁移与审计已同事务;其他业务及 worksheetsStatsUpdate 旧调用核实待完成,不向普通用户开放通用加数接口。
### 5.4 七牛、首页与任务 ### 5.4 七牛、首页与任务
@@ -155,6 +161,10 @@
## 九、下一步建议 ## 九、下一步建议
优先推进 S0 线上盘点和 S1 Prisma/真实登录/题型读取/收藏下载事务的纵向样板。基础设施已齐备,当前首要技术阻塞是未接入真实数据库账户查询和业务模块。七牛资源准备可同步推进。 Prisma、真实账户读取及服务端微信登录已接入;继续推进 S0 线上盘点、S1 题型读取及收藏下载事务样板,以及小程序体验版真实 code 联调。七牛资源准备可同步推进。
管理员登录方式、跨端账户绑定、完整后台页面和完整 Web 分别管理:管理员授权能力及现有管理 API 属于本次;完整后台/Web 页面和 Web 用户登录不作为现有小程序迁移的切换前置条件。 管理员登录方式、跨端账户绑定、完整后台页面和完整 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/**',
'**/dist-test/**', '**/dist-test/**',
'**/coverage/**', '**/coverage/**',
'**/src/generated/**',
], ],
}, },
js.configs.recommended, 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 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)。 云端首期仅常驻生产库,测试联调按需隔离。详细安排与七牛资源隔离见 [开发与部署环境方案](../docs/v3.5/开发与部署环境方案.md)。
建表 SQL 已生成并通过临时库验证,开发库已于 2026-10-12 完成初始化,其他空库执行步骤见 [SQL 执行说明](../apps/server/prisma/sql/README.md)。Docker 启动只创建空数据库,不会自动执行这些业务表脚本。 建表 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", "lint": "eslint . --max-warnings 0",
"format": "prettier . --write", "format": "prettier . --write",
"format:check": "prettier . --check", "format:check": "prettier . --check",
"test": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server 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" "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": { "devDependencies": {
"@eslint/js": "10.0.1", "@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