feat: 完成分类、worksheet 接口开发,资源迁移到七牛
This commit is contained in:
@@ -5,6 +5,7 @@ dist-test/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
*.log
|
||||
/apps/server/src/generated/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -4,3 +4,6 @@ dist
|
||||
dist-test
|
||||
coverage
|
||||
pnpm-lock.yaml
|
||||
apps/server/src/generated
|
||||
|
||||
packages/asset-sync/reports
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
## 当前范围
|
||||
|
||||
当前完成基础工程和本地数据库环境,具体业务尚未接入:
|
||||
当前已完成基础工程、本地数据库、小程序登录鉴权及分类服务:
|
||||
|
||||
- `apps/server`:可运行的 NestJS 服务,已接入日志、中间件、Swagger 与 JWT 基础设施;当前公开业务路由仍只有进程健康检查。
|
||||
- `packages/contracts`:基础成功响应与健康检查类型,不包含业务字段或私有实现。
|
||||
- `apps/server`:可运行的 NestJS 服务,已接入日志、中间件、Swagger、微信静默登录、JWT 与真实账户检查,提供本人摘要、分类查询/管理、题型列表/详情与组合筛选和数据库就绪接口。
|
||||
- `packages/contracts`:公开响应、登录请求/结果、本人摘要、分类与安全错误类型,不包含数据库模型或私有实现。
|
||||
- `packages/asset-sync`:本地云存储备份同步七牛的独立 Node.js 工具,支持预演、校验、重试,头像与其他资源共用公开空间。
|
||||
- `apps/admin`、`apps/web`:仅预留目录与说明,尚未选择或安装前端框架。
|
||||
- 本地 PostgreSQL 18.6 Docker 开发环境已启动,10 张表及初始配置已创建;Prisma、微信登录、七牛接口、定时任务、业务接口与生产部署尚未接入。
|
||||
- 本地 PostgreSQL 18.6 Docker 开发环境已启动,13 张表及初始配置已创建,并于 2026-10-15 与最新 SQL 复核一致;Prisma 与微信登录已接入,七牛、其他业务及生产部署待实施。
|
||||
|
||||
## 本地使用
|
||||
|
||||
@@ -23,14 +24,19 @@ pnpm build
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
默认地址为 `http://127.0.0.1:3000/api/v1/health`。该接口只证明服务进程可以处理 HTTP 请求,不代表数据库、文件存储或业务系统已经可用。
|
||||
默认地址为 `http://127.0.0.1:8800/api/v1/health`。该接口只证明服务进程可以处理 HTTP 请求,不代表数据库、文件存储或业务系统已经可用。
|
||||
|
||||
`pnpm dev` 在服务监听成功后显示完整 Swagger 地址(默认 `http://127.0.0.1:8800/api-docs`),并使用系统默认浏览器自动打开一次。热更新不会重复打开;关闭 Swagger 时不打开。链接按实际 HOST/PORT 生成,建议使用输出的 IP 地址,避免 `localhost` 解析到其他项目。
|
||||
|
||||
可在 `apps/server/.env` 中配置 `PORT`、`HOST`,变量示例见 `apps/server/.env.example`。未配置时使用安全的本地默认值。不要提交真实 `.env` 或密钥。
|
||||
|
||||
JWT_SECRET 必须配置为至少 32 字节的独立密钥,本机开发密钥已生成。Swagger 默认开发开启:`http://127.0.0.1:3000/api-docs`,生产未显式配置时关闭;其他设置与账户查询接入边界见 [Server 基础设置](docs/v3.5/Server基础设置.md)。
|
||||
JWT_SECRET 必须配置为至少 32 字节的独立密钥,本机开发密钥已生成。Swagger 默认开发开启:`http://127.0.0.1:8800/api-docs`,生产未显式配置时关闭;其他设置与账户查询接入边界见 [Server 基础设置](docs/v3.5/Server基础设置.md)。
|
||||
|
||||
微信小程序配置在 `apps/server/.env` 中填写 `WECHAT_MINI_APP_ID` 和 `WECHAT_MINI_APP_SECRET`。两项均为启动必填;DATABASE_URL 同样必须配置。真实 AppSecret 仅由服务端持有,不提交仓库。
|
||||
|
||||
```bash
|
||||
pnpm check # 格式、代码检查、类型检查、构建与测试
|
||||
pnpm test:db # 独立临时数据库中的登录/鉴权/分类测试,结束后清理
|
||||
pnpm build # 按工作区依赖顺序构建
|
||||
pnpm start # 运行已构建的服务端
|
||||
pnpm format # 格式化源码与配置
|
||||
@@ -44,6 +50,7 @@ apps/
|
||||
admin/ 后续管理后台,当前仅占位
|
||||
web/ 后续用户网站,当前仅占位
|
||||
packages/
|
||||
asset-sync/ 云存储备份同步七牛的独立脚本
|
||||
contracts/ 可公开的接口类型
|
||||
deploy/ 后续部署说明,当前未部署
|
||||
infra/ 本地 PostgreSQL Docker 配置
|
||||
@@ -54,4 +61,16 @@ docs/ 工程初始化与接入边界说明
|
||||
|
||||
数据库模型、密钥和未来私有生成/绘制逻辑只能由服务端持有,不能放进公共契约包供前端导入。小程序不直接依赖本仓库源码或本地工作区包。
|
||||
|
||||
登录请求、恢复错误码、数据库基线及联调边界见 [服务端登录接入说明](docs/v3.5/服务端登录接入说明.md)。
|
||||
|
||||
分类公开/管理 API、本地导入工具及本批 6 条分类的导入结果见 [分类接口与数据导入](docs/v3.5/分类接口与数据导入.md)。
|
||||
|
||||
51 条旧题型数据已导入本地库,预览文件仍待迁移;导入工具、异常年级和计数对账见 [题型数据导入说明](docs/v3.5/题型数据导入说明.md)。
|
||||
|
||||
题型查询支持分类、审核年级、年龄段、Tag、最新/热门、运营标记和分页,见 [题型查询接口说明](docs/v3.5/题型查询接口说明.md)。
|
||||
|
||||
现有功能迁移的权威方案见 [docs/云服务端迁移方案.md](docs/云服务端迁移方案.md),小程序产品规划与历史设计仍保留在 `doodle-mini`。本仓库的初始化说明见 [docs/初始化说明.md](docs/初始化说明.md)。
|
||||
|
||||
## 云存储备份同步七牛
|
||||
|
||||
独立脚本项目位于 [packages/asset-sync](packages/asset-sync/README.md),配置入口为该目录的 `.env`,已填写本地 cloud_backup 源目录。`pnpm assets:sync` 默认只预演;填写七牛 AK/SK 与 bucket 后,使用 `pnpm assets:sync --apply --only assets/previews` 同步题型预览。支持保留相对路径、同内容跳过、冲突保护、有限重试和报告;头像与其他资源共用 QINIU_BUCKET 公开空间。该工具不修改业务数据库或自动替换旧 cloud 引用。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
NODE_ENV=development
|
||||
HOST=127.0.0.1
|
||||
PORT=3000
|
||||
PORT=8800
|
||||
LOG_LEVEL=info
|
||||
API_BODY_LIMIT_BYTES=1048576
|
||||
# 开发默认开启,生产默认关闭;可显式设置 true/false。
|
||||
@@ -14,6 +14,14 @@ JWT_SECRET=
|
||||
JWT_ISSUER=doodle-store
|
||||
JWT_AUDIENCE=doodle-mini
|
||||
JWT_EXPIRES_IN_SECONDS=86400
|
||||
# 后续 Prisma 接入使用;用户名/密码/端口须与根 .env 一致,密码需 URL 编码。
|
||||
# 当前骨架尚未读取 DATABASE_URL;不要提交实际连接凭证。
|
||||
# 微信静默登录已接入,两项均必填;小程序 AppID 与对应 AppSecret。
|
||||
# 仅服务端持有 AppSecret,不提交实际密钥,也不使用 JWT_SECRET 代替。
|
||||
WECHAT_MINI_APP_ID=
|
||||
WECHAT_MINI_APP_SECRET=
|
||||
# Prisma 必填;用户名/密码/端口须与根 .env 一致,密码需 URL 编码。
|
||||
# 仅连接 public schema;不要提交实际连接凭证。
|
||||
DATABASE_URL=postgresql://doodle_dev:change-me-local-only@127.0.0.1:5434/doodle_dev?schema=public
|
||||
# 新品按真实首次发布时间计算;旧数据发布时间未知时不标新品。
|
||||
WORKSHEET_NEW_WINDOW_DAYS=30
|
||||
# 可选公共资源 HTTPS 域名(不含路径);只解析 ready/public 资产,不授予上传权限。
|
||||
QINIU_PUBLIC_DOMAIN=
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"dev": "pnpm prisma:generate && node scripts/dev.mjs",
|
||||
"build": "pnpm prisma:generate && nest build",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
|
||||
"typecheck": "pnpm prisma:generate && tsc --noEmit -p tsconfig.json",
|
||||
"test": "pnpm prisma:generate && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"db:status": "prisma migrate status",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"test:db": "pnpm prisma:generate && node scripts/test-auth-db.mjs",
|
||||
"categories:import": "node scripts/import-categories.mjs",
|
||||
"worksheets:import": "node scripts/import-worksheets.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@doodle/contracts": "workspace:*",
|
||||
@@ -17,9 +23,12 @@
|
||||
"@nestjs/jwt": "11.0.2",
|
||||
"@nestjs/platform-express": "11.2.7",
|
||||
"@nestjs/swagger": "11.4.7",
|
||||
"@prisma/adapter-pg": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.15.1",
|
||||
"helmet": "8.3.0",
|
||||
"pg": "8.23.1",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
@@ -28,6 +37,8 @@
|
||||
"@nestjs/testing": "11.2.7",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/node": "24.19.2",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.9.3",
|
||||
"prisma": "7.10.0",
|
||||
"@types/pg": "8.23.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
: {}),
|
||||
});
|
||||
@@ -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"
|
||||
@@ -0,0 +1,293 @@
|
||||
// 由现有 v3.5 PostgreSQL 结构反向映射。
|
||||
// CHECK、C 排序规则与部分唯一索引以 migrations 中保留的原始 SQL 为准。
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
previewFeatures = ["partialIndexes"]
|
||||
importFileExtension = "js"
|
||||
moduleFormat = "cjs"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model AdminAuditLog {
|
||||
id String @id @db.VarChar(128)
|
||||
actorUserId String? @map("actor_user_id") @db.VarChar(128)
|
||||
actorType String @map("actor_type") @db.VarChar(16)
|
||||
action String @db.VarChar(64)
|
||||
resourceType String @map("resource_type") @db.VarChar(64)
|
||||
resourceId String? @map("resource_id") @db.VarChar(128)
|
||||
requestId String @map("request_id") @db.VarChar(128)
|
||||
beforeData Json? @map("before_data")
|
||||
afterData Json? @map("after_data")
|
||||
reason String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
@@index([actorUserId, createdAt(sort: Desc)], map: "admin_audit_logs_actor_created_at_idx")
|
||||
@@index([requestId])
|
||||
@@index([resourceType, resourceId, createdAt(sort: Desc)], map: "admin_audit_logs_resource_created_at_idx")
|
||||
@@map("admin_audit_logs")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model Asset {
|
||||
id String @id @db.VarChar(128)
|
||||
sourceRef String? @map("source_ref")
|
||||
sourceRefHash String? @unique @map("source_ref_hash") @db.Char(64)
|
||||
provider String @default("qiniu") @db.VarChar(16)
|
||||
bucket String? @db.VarChar(128)
|
||||
objectKey String? @map("object_key")
|
||||
objectRefHash String? @unique @map("object_ref_hash") @db.Char(64)
|
||||
externalUrl String? @map("external_url")
|
||||
purpose String @db.VarChar(32)
|
||||
ownerUserId String? @map("owner_user_id") @db.VarChar(128)
|
||||
visibility String @db.VarChar(16)
|
||||
mimeType String? @map("mime_type") @db.VarChar(128)
|
||||
sizeBytes BigInt? @map("size_bytes")
|
||||
checksum String? @db.Char(64)
|
||||
status String @default("pending") @db.VarChar(16)
|
||||
lastError String? @map("last_error")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
usersAssetsOwnerUserIdTousers User? @relation("assets_owner_user_idTousers", fields: [ownerUserId], references: [id], onUpdate: Restrict)
|
||||
categories Category[]
|
||||
usersUsersAvatarAssetIdToassets User[] @relation("users_avatar_asset_idToassets")
|
||||
worksheetResourceAssets WorksheetResourceAsset[]
|
||||
worksheetResources WorksheetResource[]
|
||||
worksheets Worksheet[]
|
||||
|
||||
@@index([ownerUserId, purpose, status], map: "assets_owner_purpose_status_idx")
|
||||
@@index([status, updatedAt])
|
||||
@@map("assets")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model Category {
|
||||
status String @default("draft") @db.VarChar(16)
|
||||
id String @id @db.VarChar(128)
|
||||
name String @db.VarChar(100)
|
||||
icon String @db.VarChar(1024)
|
||||
iconAssetId String? @map("icon_asset_id") @db.VarChar(128)
|
||||
color String @db.VarChar(32)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
parentId String? @map("parent_id") @db.VarChar(128)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
assets Asset? @relation(fields: [iconAssetId], references: [id], onDelete: Restrict, onUpdate: Restrict)
|
||||
categories Category? @relation("categoriesTocategories", fields: [parentId], references: [id], onDelete: Restrict, onUpdate: Restrict)
|
||||
otherCategories Category[] @relation("categoriesTocategories")
|
||||
worksheets Worksheet[]
|
||||
|
||||
@@index([parentId, sortOrder, id], map: "categories_parent_sort_idx")
|
||||
@@index([status, parentId, sortOrder, id], map: "categories_status_parent_sort_idx")
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model DownloadLog {
|
||||
id String @id @db.VarChar(128)
|
||||
userId String @map("user_id") @db.VarChar(128)
|
||||
worksheetId String @map("worksheet_id") @db.VarChar(128)
|
||||
exportId String? @map("export_id") @db.VarChar(128)
|
||||
source String @default("mini") @db.VarChar(16)
|
||||
params Json?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
worksheetResourceId String? @map("worksheet_resource_id") @db.VarChar(128)
|
||||
users User @relation(fields: [userId], references: [id], onUpdate: Restrict)
|
||||
|
||||
@@unique([userId, exportId], map: "download_logs_user_export_key")
|
||||
@@index([userId, createdAt(sort: Desc), id(sort: Desc)], map: "download_logs_user_created_at_idx")
|
||||
@@index([worksheetId, createdAt(sort: Desc), id(sort: Desc)], map: "download_logs_worksheet_created_at_idx")
|
||||
@@map("download_logs")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model Favorite {
|
||||
id String @id @db.VarChar(128)
|
||||
userId String @map("user_id") @db.VarChar(128)
|
||||
worksheetId String @map("worksheet_id") @db.VarChar(128)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
worksheetResourceId String? @map("worksheet_resource_id") @db.VarChar(128)
|
||||
users User @relation(fields: [userId], references: [id], onUpdate: Restrict)
|
||||
|
||||
@@unique([userId, worksheetResourceId], map: "favorites_user_resource_key", where: raw("(worksheet_resource_id IS NOT NULL)"))
|
||||
@@unique([userId, worksheetId], map: "favorites_user_worksheet_key", where: raw("(worksheet_resource_id IS NULL)"))
|
||||
@@index([userId, createdAt(sort: Desc), id(sort: Desc)], map: "favorites_user_created_at_idx")
|
||||
@@map("favorites")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model JobRun {
|
||||
id String @id @db.VarChar(128)
|
||||
jobName String @map("job_name") @db.VarChar(64)
|
||||
runKey String @map("run_key") @db.VarChar(128)
|
||||
triggerType String @map("trigger_type") @db.VarChar(16)
|
||||
status String @default("pending") @db.VarChar(16)
|
||||
scheduledAt DateTime? @map("scheduled_at") @db.Timestamptz(3)
|
||||
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
|
||||
attempt Int @default(0)
|
||||
lockToken String? @map("lock_token") @db.VarChar(128)
|
||||
lockedUntil DateTime? @map("locked_until") @db.Timestamptz(3)
|
||||
result Json?
|
||||
errorMessage String? @map("error_message")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
@@unique([jobName, runKey], map: "job_runs_job_run_key")
|
||||
@@index([jobName, createdAt(sort: Desc)], map: "job_runs_job_created_at_idx")
|
||||
@@index([status, lockedUntil])
|
||||
@@map("job_runs")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model PageConfig {
|
||||
id String @id @db.VarChar(128)
|
||||
home Json?
|
||||
category Json?
|
||||
age Json?
|
||||
version BigInt @default(0)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
publishedVersion BigInt? @map("published_version")
|
||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
|
||||
|
||||
@@map("page_configs")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model Setting {
|
||||
id String @id @db.VarChar(128)
|
||||
value Json
|
||||
version BigInt @default(0)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model User {
|
||||
id String @id @db.VarChar(128)
|
||||
openid String @unique @db.VarChar(128)
|
||||
unionid String? @db.VarChar(128)
|
||||
nickName String? @map("nick_name") @db.VarChar(100)
|
||||
avatarAssetId String? @map("avatar_asset_id") @db.VarChar(128)
|
||||
totalDownloads BigInt @default(0) @map("total_downloads")
|
||||
role String @default("user") @db.VarChar(16)
|
||||
status String @default("active") @db.VarChar(16)
|
||||
authVersion Int @default(1) @map("auth_version")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
lastActiveAt DateTime @default(now()) @map("last_active_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
assetsAssetsOwnerUserIdTousers Asset[] @relation("assets_owner_user_idTousers")
|
||||
downloadLogs DownloadLog[]
|
||||
favorites Favorite[]
|
||||
assetsUsersAvatarAssetIdToassets Asset? @relation("users_avatar_asset_idToassets", fields: [avatarAssetId], references: [id], onDelete: Restrict, onUpdate: Restrict)
|
||||
|
||||
@@index([unionid])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model WorksheetGrade {
|
||||
worksheetId String @map("worksheet_id") @db.VarChar(128)
|
||||
grade Int @db.SmallInt
|
||||
worksheets Worksheet @relation(fields: [worksheetId], references: [id], onUpdate: Restrict)
|
||||
|
||||
@@id([worksheetId, grade])
|
||||
@@index([grade, worksheetId], map: "worksheet_grades_grade_worksheet_idx")
|
||||
@@map("worksheet_grades")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model WorksheetResourceAsset {
|
||||
id String @id @db.VarChar(128)
|
||||
worksheetResourceId String @map("worksheet_resource_id") @db.VarChar(128)
|
||||
assetId String @map("asset_id") @db.VarChar(128)
|
||||
role String @db.VarChar(16)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
assets Asset @relation(fields: [assetId], references: [id], onUpdate: Restrict)
|
||||
worksheetResources WorksheetResource @relation(fields: [worksheetResourceId], references: [id], onUpdate: Restrict, map: "worksheet_resource_assets_resource_id_fkey")
|
||||
|
||||
@@unique([worksheetResourceId, role, assetId], map: "worksheet_resource_assets_resource_role_asset_key")
|
||||
@@index([assetId], map: "worksheet_resource_assets_asset_idx")
|
||||
@@index([worksheetResourceId, role, sortOrder, id], map: "worksheet_resource_assets_resource_role_sort_idx")
|
||||
@@map("worksheet_resource_assets")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model WorksheetResource {
|
||||
id String @id @db.VarChar(128)
|
||||
worksheetId String @map("worksheet_id") @db.VarChar(128)
|
||||
title String @db.VarChar(100)
|
||||
previewAssetId String @map("preview_asset_id") @db.VarChar(128)
|
||||
configOverride Json? @map("config_override")
|
||||
status String @default("draft") @db.VarChar(16)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
firstPublishedAt DateTime? @map("first_published_at") @db.Timestamptz(3)
|
||||
downloads BigInt @default(0)
|
||||
worksheetResourceAssets WorksheetResourceAsset[]
|
||||
assets Asset @relation(fields: [previewAssetId], references: [id], onUpdate: Restrict)
|
||||
worksheets Worksheet @relation(fields: [worksheetId], references: [id], onUpdate: Restrict)
|
||||
|
||||
@@index([previewAssetId], map: "worksheet_resources_preview_asset_idx")
|
||||
@@index([worksheetId, status, sortOrder, id], map: "worksheet_resources_worksheet_status_sort_idx")
|
||||
@@map("worksheet_resources")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model Worksheet {
|
||||
id String @id @db.VarChar(128)
|
||||
title String @db.VarChar(100)
|
||||
subtitle String @db.VarChar(500)
|
||||
categoryId String @map("category_id") @db.VarChar(128)
|
||||
subcategory String? @db.VarChar(100)
|
||||
path String @db.VarChar(1024)
|
||||
ageMin Int @map("age_min") @db.SmallInt
|
||||
ageMax Int @map("age_max") @db.SmallInt
|
||||
grade Int @default(0) @db.SmallInt
|
||||
difficulty Int @db.SmallInt
|
||||
previewAssetId String @map("preview_asset_id") @db.VarChar(128)
|
||||
tags Json @default("[]")
|
||||
isNew Boolean @default(false) @map("is_new")
|
||||
isHot Boolean @default(false) @map("is_hot")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
downloads BigInt @default(0)
|
||||
likes BigInt @default(0)
|
||||
downloadsSeed BigInt @default(0) @map("downloads_seed")
|
||||
likesSeed BigInt @default(0) @map("likes_seed")
|
||||
status String @default("draft") @db.VarChar(16)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
contentUpdatedAt DateTime? @map("content_updated_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
generationMode String @default("procedural") @map("generation_mode") @db.VarChar(16)
|
||||
rendererKey String? @map("renderer_key") @db.VarChar(64)
|
||||
renderConfig Json @default("{}") @map("render_config")
|
||||
firstPublishedAt DateTime? @map("first_published_at") @db.Timestamptz(3)
|
||||
worksheetGrades WorksheetGrade[]
|
||||
worksheetResources WorksheetResource[]
|
||||
categories Category @relation(fields: [categoryId], references: [id], onUpdate: Restrict)
|
||||
assets Asset @relation(fields: [previewAssetId], references: [id], onUpdate: Restrict)
|
||||
|
||||
@@index([categoryId, status, sortOrder, updatedAt(sort: Desc), id], map: "worksheets_category_status_sort_idx")
|
||||
@@index([status, ageMin, ageMax], map: "worksheets_status_age_idx")
|
||||
@@index([status, sortOrder, updatedAt(sort: Desc), id], map: "worksheets_status_sort_idx")
|
||||
@@map("worksheets")
|
||||
}
|
||||
@@ -94,6 +94,7 @@ ALTER TABLE public.users
|
||||
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT;
|
||||
|
||||
CREATE TABLE public.categories (
|
||||
status varchar(16) NOT NULL DEFAULT 'draft',
|
||||
id varchar(128) COLLATE "C" PRIMARY KEY,
|
||||
name varchar(100) NOT NULL,
|
||||
icon varchar(1024) NOT NULL,
|
||||
@@ -107,9 +108,13 @@ CREATE TABLE public.categories (
|
||||
REFERENCES public.categories (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||
CONSTRAINT categories_icon_asset_id_fkey FOREIGN KEY (icon_asset_id)
|
||||
REFERENCES public.assets (id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||
CONSTRAINT categories_status_check CHECK (status IN ('draft', 'active', 'hidden')),
|
||||
CONSTRAINT categories_parent_not_self_check CHECK (parent_id IS NULL OR parent_id <> id)
|
||||
);
|
||||
|
||||
CREATE INDEX categories_status_parent_sort_idx ON public.categories (status, parent_id, sort_order, id);
|
||||
COMMENT ON COLUMN public.categories.status IS '发布状态:draft 开发测试,active 上线,hidden 下架;公开访问还需所有上级分类 active';
|
||||
|
||||
CREATE INDEX categories_parent_sort_idx ON public.categories (parent_id, sort_order, id);
|
||||
|
||||
CREATE TABLE public.worksheets (
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 既有分类保留当前公开行为;以后新建分类默认草稿。不要重跑已成功的增量迁移。
|
||||
BEGIN;
|
||||
ALTER TABLE public.categories ADD COLUMN status varchar(16) NOT NULL DEFAULT 'draft';
|
||||
UPDATE public.categories SET status = 'active';
|
||||
ALTER TABLE public.categories ADD CONSTRAINT categories_status_check
|
||||
CHECK (status IN ('draft', 'active', 'hidden'));
|
||||
CREATE INDEX categories_status_parent_sort_idx ON public.categories (status, parent_id, sort_order, id);
|
||||
COMMENT ON COLUMN public.categories.status IS '发布状态:draft 开发测试,active 上线,hidden 下架;公开访问还需所有上级分类 active';
|
||||
COMMIT;
|
||||
@@ -1,5 +1,7 @@
|
||||
# v3.5 PostgreSQL 初始化 SQL
|
||||
|
||||
> 2026-10-15:已接入 Prisma Schema 与 `0_v35_baseline`,本地现有库仅登记基线,没有重建业务表。下文保留原手工 SQL 的执行路径和历史核查;新的空库优先使用 Prisma migrate deploy,后续升级新增迁移。详见 [Prisma 说明](../README.md)。
|
||||
|
||||
基于 [数据库设计](../../../../docs/v3.5/数据库设计文档.md),面向 PostgreSQL 18。缩进统一 4 个空格;文件是普通 PostgreSQL SQL,可使用 psql 或数据库客户端执行。
|
||||
|
||||
2026-10-12 已在本地 PostgreSQL 18.6 临时数据库执行 001/002/003,并重复执行 003 验证不覆盖 enabled=false;10 张表与 19 项关键约束检查通过,历史 NULL 事件、标识大小写及资产所有者删除行为符合设计。验证库已删除;随后已在用户授权下正式初始化 doodle_dev,10 张表与两条配置记录核对通过。
|
||||
@@ -28,7 +30,7 @@ Docker 已在首次启动时创建 doodle_dev,**当前本地跳过 001**。其
|
||||
| 已执行部分增量的库 | 按编号顺序执行尚未应用的后续文件 |
|
||||
| 已执行 008 的库 | 无需再次执行 DDL |
|
||||
|
||||
004~008 是历史结构的升级文件,不能用于已执行最新 002 的空库基线。下文各阶段记录解释该次变更,不表示只完成其中某一步就已到最新结构。Prisma 迁移历史尚未接入,执行前依据实际结构及执行记录确认当前版本。
|
||||
004~008 是历史结构的升级文件,不能用于已执行最新 002 的空库基线。下文各阶段记录解释该次变更,不表示只完成其中某一步就已到最新结构。当前已接入 Prisma 迁移基线,旧手工库在登记前依据实际结构及执行记录确认当前版本。
|
||||
|
||||
## 空库初始化示例
|
||||
|
||||
@@ -64,7 +66,7 @@ SELECT id, value FROM public.settings;
|
||||
- 下载 NULL exportId 只允许 import 来源;非空事件按 userId + exportId 去重。计数不会通过触发器自动更新,由后续业务事务实现。
|
||||
- tags 元素类型、分类多层循环、任务租约/锁、JWT 失效与管理员权限由后续业务实现;SQL 不代表业务 API 已完成。
|
||||
- updatedAt 和 contentUpdatedAt 由服务显式维护,不添加把两者混为一谈的更新时间触发器。
|
||||
- Prisma 尚未安装,Schema 和迁移历史尚未接入。后续接入时将本 SQL 纳入正式基线;已有表的库须采用基线登记流程,不能直接再次执行同一 DDL。
|
||||
- Prisma Schema 和迁移历史已接入,本 SQL 已纳入正式基线;已有表的库须先核对再登记基线,不能直接再次执行同一 DDL。
|
||||
|
||||
## 单小程序身份简化(2026-10-14)
|
||||
|
||||
@@ -180,3 +182,7 @@ docker compose --env-file .env -f infra/compose.yaml exec -T postgres \
|
||||
- 所有临时核查库已清理;开发库只读比对,无样本写入。本次只修正文档说明,无需新增 DDL 或再次升级开发库。
|
||||
|
||||
业务归属/权限、首次发布、计数和审计事务、任务调度等仍需服务端接入;SQL 验证不代替业务 API 验收。
|
||||
|
||||
## 分类发布状态(2026-10-16)
|
||||
|
||||
最新 002/003 包含分类 status,空库不再执行 009。原 004~008 及 Prisma 旧基线不改写;已有结构新增 009(或使用对应 Prisma 迁移,二者不能重复)。009 新增 status 默认 draft、三态 CHECK 和状态/父级索引,同时将既有分类置为 active 保持兼容。当前为 13 张表、136 个字段。新后端公开接口同时检查分类祖先状态,管理员状态修改使数据库页面快照失效。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module, type DynamicModule } from '@nestjs/common';
|
||||
import { HealthController } from './health/health.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CategoryModule } from './categories/category.module';
|
||||
import { WorksheetModule } from './worksheets/worksheet.module';
|
||||
import { ServerConfigModule, type ServerConfig } from './config/server-config';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, CategoryModule, WorksheetModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {
|
||||
|
||||
@@ -33,6 +33,9 @@ export function configureApplication(
|
||||
const startedAt = performance.now();
|
||||
request.requestId = requestId;
|
||||
response.setHeader('X-Request-Id', requestId);
|
||||
// 登录和本人接口的成功/失败均禁止缓存,避免凭证或个人数据进入公共缓存。
|
||||
if (/^\/api\/v1\/(auth|me)(?:\/|$)/.test(request.path))
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
// 响应完成后记录状态与耗时;只记录 path,排除 URL 查询中的登录 code/token。
|
||||
response.once('finish', () =>
|
||||
logger.log('http_request_completed', {
|
||||
@@ -57,7 +60,7 @@ export function configureApplication(
|
||||
credentials: false,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Id'],
|
||||
exposedHeaders: ['X-Request-Id'],
|
||||
exposedHeaders: ['X-Request-Id', 'Retry-After'],
|
||||
});
|
||||
// 应用工厂已禁用 Nest 默认解析器,此处统一限制 JSON 和表单请求大小。
|
||||
// 表单不解析嵌套对象,上传文件另由后续 assets 模块处理。
|
||||
@@ -84,7 +87,9 @@ export function configureApplication(
|
||||
if (config.swaggerEnabled) {
|
||||
const options = new DocumentBuilder()
|
||||
.setTitle('Doodle Store API')
|
||||
.setDescription('统一业务 API;当前仅健康检查,业务接口逐步接入')
|
||||
.setDescription(
|
||||
'统一业务 API;已接入微信静默登录、本人资料、账户鉴权与分类查询/管理',
|
||||
)
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth(
|
||||
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
} from '@nestjs/common';
|
||||
@@ -11,6 +10,8 @@ import type { HttpRequest } from '../common/http-request';
|
||||
import { ACCOUNT_READER, type AccountReader } from './account-reader';
|
||||
import { IS_PUBLIC, REQUIRED_ROLES } from './auth.decorators';
|
||||
import { JwtTokenService } from './jwt-token.service';
|
||||
import { ApiException } from '../common/api-exception';
|
||||
import { requireCurrentAccount } from './account-policy';
|
||||
|
||||
@Injectable()
|
||||
/** 全局访问守卫:JWT 验签 → 当前账户核对 → 服务端角色检查。 */
|
||||
@@ -35,19 +36,17 @@ export class AccessTokenGuard implements CanActivate {
|
||||
: null;
|
||||
const token = match?.[1];
|
||||
// 先限制 token 长度,再进行签名解析,避免无意义的超长输入消耗资源。
|
||||
if (!token || token.length > 8192) throw new UnauthorizedException();
|
||||
if (!authorization) throw new ApiException(401, 'AUTH_REQUIRED');
|
||||
if (!token || token.length > 8192)
|
||||
throw new ApiException(401, 'AUTH_INVALID');
|
||||
const claims = await this.tokens.verifyAccessToken(token);
|
||||
// 验签成功不等于账户仍有效;注销、禁用、凭证版本变更必须立即阻止旧 token。
|
||||
// 账户读取异常向上抛出,不退回到只检查 JWT 的宽松模式。
|
||||
const account = await this.accounts.findById(claims.sub);
|
||||
if (
|
||||
!account ||
|
||||
account.id !== claims.sub ||
|
||||
account.status !== 'active' ||
|
||||
account.authVersion !== claims.authVersion
|
||||
) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const account = requireCurrentAccount(
|
||||
await this.accounts.findById(claims.sub),
|
||||
claims.sub,
|
||||
claims.authVersion,
|
||||
);
|
||||
// 后续控制器共享已核对身份,角色取自当前账户,而非客户端或 token 自报值。
|
||||
request.auth = {
|
||||
userId: account.id,
|
||||
|
||||
@@ -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,6 +1,4 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
|
||||
/** 用户模块通过此注入标识替换账户查询实现,JWT 模块不直接依赖数据库模型。 */
|
||||
/** 账户读取注入标识,JWT 守卫不直接依赖数据库模型。 */
|
||||
export const ACCOUNT_READER = Symbol('ACCOUNT_READER');
|
||||
/** 鉴权所需的最小账户快照,不包含昵称、头像或微信身份信息。 */
|
||||
export interface Account {
|
||||
@@ -13,12 +11,3 @@ export interface Account {
|
||||
export interface AccountReader {
|
||||
findById(userId: string): Promise<Account | null>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** Prisma 账户查询接入前的占位实现:返回 503,确保受保护接口不会被提前放行。 */
|
||||
export class UnconfiguredAccountReader implements AccountReader {
|
||||
async findById(): Promise<Account | null> {
|
||||
// 后续 Prisma 用户模块替换此 provider;不能仅凭 JWT 放行账户数据接口。
|
||||
throw new ServiceUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ACCOUNT_READER, UnconfiguredAccountReader } from './account-reader';
|
||||
import { ACCOUNT_READER } from './account-reader';
|
||||
import { AccessTokenGuard } from './access-token.guard';
|
||||
import { JwtTokenService } from './jwt-token.service';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { AccountRepository } from './account.repository';
|
||||
import { WECHAT_FETCH, WechatClient } from './wechat-client';
|
||||
import { LoginRateLimiter } from './login-rate-limiter';
|
||||
import { LoginService } from './login.service';
|
||||
import { LoginController } from './login.controller';
|
||||
import { CurrentUserController } from './current-user.controller';
|
||||
|
||||
@Module({
|
||||
// 签名和验证参数由 JwtTokenService 统一提供,避免不同调用点使用不同策略。
|
||||
imports: [JwtModule.register({})],
|
||||
imports: [JwtModule.register({}), DatabaseModule],
|
||||
controllers: [LoginController, CurrentUserController],
|
||||
providers: [
|
||||
JwtTokenService,
|
||||
// 用户模块接入 Prisma 后替换此 provider;当前有效 token 也不能绕过账户检查。
|
||||
{ provide: ACCOUNT_READER, useClass: UnconfiguredAccountReader },
|
||||
AccountRepository,
|
||||
{ provide: ACCOUNT_READER, useExisting: AccountRepository },
|
||||
{ provide: WECHAT_FETCH, useValue: fetch },
|
||||
WechatClient,
|
||||
LoginRateLimiter,
|
||||
LoginService,
|
||||
// 注册为全局守卫,新路由默认受保护,无需逐个添加 @UseGuards。
|
||||
{ provide: APP_GUARD, useClass: AccessTokenGuard },
|
||||
],
|
||||
exports: [JwtTokenService, ACCOUNT_READER],
|
||||
})
|
||||
/** JWT 与鉴权基础设施;本模块尚不提供微信登录或公开 token 签发接口。 */
|
||||
/** 微信静默登录、本人读取与全局鉴权;身份始终来自可信微信兑换或本站凭证。 */
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { ApiSuccess, CurrentUserProfile } from '@doodle/contracts';
|
||||
import { CurrentUser } from './auth.decorators';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
import { AccountRepository } from './account.repository';
|
||||
|
||||
@Controller('me')
|
||||
@ApiTags('users')
|
||||
export class CurrentUserController {
|
||||
constructor(private readonly accounts: AccountRepository) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取当前有效账户的本人资料' })
|
||||
/** 仅从守卫写入的身份读取本人,拒绝由 query/body 指定其他账户。 */
|
||||
async getMe(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<ApiSuccess<CurrentUserProfile>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.accounts.getProfile(user),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
import type { WechatLoginResult } from '@doodle/contracts';
|
||||
import { ApiException } from '../common/api-exception';
|
||||
|
||||
/** 最小身份载荷;authVersion 用于与账户当前版本比较,不在 token 中固化角色。 */
|
||||
export interface AccessTokenClaims {
|
||||
@@ -43,6 +45,21 @@ export class JwtTokenService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 响应期限从已签发的实际 claims 取得,避免两个时钟计算出不一致的有效期。 */
|
||||
async issueAccessToken(
|
||||
userId: string,
|
||||
authVersion: number,
|
||||
): Promise<Omit<WechatLoginResult, 'user'>> {
|
||||
const accessToken = await this.signAccessToken(userId, authVersion);
|
||||
const claims = await this.verifyAccessToken(accessToken);
|
||||
return {
|
||||
accessToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: claims.exp - claims.iat,
|
||||
expiresAt: new Date(claims.exp * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 验签及校验载荷;当前账户状态与权限仍由全局守卫继续核对。 */
|
||||
async verifyAccessToken(token: string): Promise<AccessTokenClaims> {
|
||||
try {
|
||||
@@ -70,9 +87,14 @@ export class JwtTokenService {
|
||||
throw new Error('Invalid access token claims');
|
||||
}
|
||||
return claims;
|
||||
} catch {
|
||||
// 过期、伪造或载荷错误统一返回 401,不向客户端暴露验签细节。
|
||||
throw new UnauthorizedException();
|
||||
} catch (error) {
|
||||
// 只公开客户端恢复需要的有限原因,不泄漏验签细节、token 或原始异常。
|
||||
throw new ApiException(
|
||||
401,
|
||||
error instanceof Error && error.name === 'TokenExpiredError'
|
||||
? 'AUTH_EXPIRED'
|
||||
: 'AUTH_INVALID',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ApiException } from '../common/api-exception';
|
||||
|
||||
@Injectable()
|
||||
/** 单实例固定窗口限流;有界存储,容量耗尽拒绝新来源而不驱逐仍有效的窗口。 */
|
||||
export class LoginRateLimiter {
|
||||
private readonly windows = new Map<
|
||||
string,
|
||||
{ count: number; resetAt: number }
|
||||
>();
|
||||
private readonly windowMs = 60_000;
|
||||
private readonly maximumKeys = 10_000;
|
||||
|
||||
checkIp(ip: string): void {
|
||||
this.consume(`ip:${ip}`, 30);
|
||||
}
|
||||
|
||||
checkIdentity(openid: string): void {
|
||||
// 限流键不保存原始微信身份;同一身份最多每分钟恢复 10 次。
|
||||
this.consume(
|
||||
`identity:${createHash('sha256').update(openid).digest('hex')}`,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
private consume(key: string, limit: number): void {
|
||||
const now = Date.now();
|
||||
let entry = this.windows.get(key);
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
if (this.windows.size >= this.maximumKeys) {
|
||||
for (const [candidate, value] of this.windows)
|
||||
if (value.resetAt <= now) this.windows.delete(candidate);
|
||||
if (
|
||||
this.windows.size >= this.maximumKeys &&
|
||||
!this.windows.has(key)
|
||||
)
|
||||
throw new ApiException(429, 'RATE_LIMITED', 60);
|
||||
}
|
||||
entry = { count: 0, resetAt: now + this.windowMs };
|
||||
this.windows.set(key, entry);
|
||||
}
|
||||
if (entry.count >= limit)
|
||||
throw new ApiException(
|
||||
429,
|
||||
'RATE_LIMITED',
|
||||
Math.max(1, Math.ceil((entry.resetAt - now) / 1000)),
|
||||
);
|
||||
entry.count += 1;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
import { ApiException } from '../common/api-exception';
|
||||
|
||||
/** 只有微信服务端成功验证后才构造,不能从公开 DTO 直接转换。 */
|
||||
export interface WechatIdentity {
|
||||
openid: string;
|
||||
unionid: string | null;
|
||||
}
|
||||
|
||||
/** HTTP 边界便于测试替换;正式实现始终只请求固定微信 HTTPS 地址。 */
|
||||
export const WECHAT_FETCH = Symbol('WECHAT_FETCH');
|
||||
export type WechatFetch = typeof fetch;
|
||||
|
||||
@Injectable()
|
||||
export class WechatClient {
|
||||
constructor(
|
||||
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
|
||||
@Inject(WECHAT_FETCH) private readonly http: WechatFetch,
|
||||
) {}
|
||||
|
||||
/** 一次 code 只兑换一次,不自动重试;超时后客户端需重新取得 code。 */
|
||||
async exchangeCode(code: string): Promise<WechatIdentity> {
|
||||
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||
url.search = new URLSearchParams({
|
||||
appid: this.config.wechatMiniAppId,
|
||||
secret: this.config.wechatMiniAppSecret,
|
||||
js_code: code,
|
||||
grant_type: 'authorization_code',
|
||||
}).toString();
|
||||
try {
|
||||
const response = await this.http(url, {
|
||||
method: 'GET',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!response.ok) throw new Error('Wechat HTTP failure');
|
||||
const payload: unknown = await response.json();
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
Array.isArray(payload)
|
||||
)
|
||||
throw new Error('Invalid Wechat response');
|
||||
const data = payload as Record<string, unknown>;
|
||||
if (data.errcode !== undefined && data.errcode !== 0) {
|
||||
if (data.errcode === 40029 || data.errcode === 40163)
|
||||
throw new ApiException(400, 'WECHAT_CODE_INVALID');
|
||||
if (data.errcode === 40226)
|
||||
throw new ApiException(403, 'WECHAT_LOGIN_BLOCKED');
|
||||
if (data.errcode === 45011)
|
||||
throw new ApiException(429, 'RATE_LIMITED', 60);
|
||||
throw new ApiException(503, 'AUTH_UPSTREAM_UNAVAILABLE');
|
||||
}
|
||||
if (
|
||||
!this.isIdentifier(data.openid) ||
|
||||
(data.unionid != null &&
|
||||
data.unionid !== '' &&
|
||||
!this.isIdentifier(data.unionid))
|
||||
)
|
||||
throw new Error('Invalid Wechat identity');
|
||||
// session_key 仅用于微信数据加解密;本链路不使用、不保存、不下发。
|
||||
return {
|
||||
openid: data.openid,
|
||||
unionid:
|
||||
typeof data.unionid === 'string' && data.unionid
|
||||
? data.unionid
|
||||
: null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ApiException) throw error;
|
||||
// 网络异常可能包含带 AppSecret 的 URL,禁止记录或传播异常原文。
|
||||
throw new ApiException(503, 'AUTH_UPSTREAM_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
|
||||
private isIdentifier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= 128 &&
|
||||
!/\s/.test(value)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
categoryKeys,
|
||||
categoryObject,
|
||||
normalizeCategory,
|
||||
requireCategoryKeys,
|
||||
type CategoryFields,
|
||||
} from './category-input';
|
||||
|
||||
export interface CategoryImportRow extends Omit<CategoryFields, 'status'> {
|
||||
status?: CategoryFields['status'];
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/** 兼容 CloudBase JSON Lines 和 JSON 数组;整批校验后才能进入写入事务。 */
|
||||
export function parseCategoryExport(source: string): CategoryImportRow[] {
|
||||
let values: unknown[];
|
||||
try {
|
||||
const content = source.replace(/^\uFEFF/, '').trim();
|
||||
if (!content) throw new Error('empty');
|
||||
const parsed: unknown = content.startsWith('[')
|
||||
? JSON.parse(content)
|
||||
: content
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => JSON.parse(line));
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.length === 0 ||
|
||||
parsed.length > 10_000
|
||||
)
|
||||
throw new Error('size');
|
||||
values = parsed;
|
||||
} catch {
|
||||
throw new BadRequestException(
|
||||
'分类文件须为非空 JSON 数组或 JSON Lines,最多 10000 条',
|
||||
);
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
return values.map((value) => {
|
||||
const row = categoryObject(value);
|
||||
requireCategoryKeys(row, [
|
||||
...categoryKeys,
|
||||
'_id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
]);
|
||||
if (row._id !== undefined && row.id !== undefined && row._id !== row.id)
|
||||
throw new BadRequestException('id 与 _id 不一致');
|
||||
const fields = normalizeCategory({ ...row, id: row._id ?? row.id });
|
||||
if (ids.has(fields.id)) throw new BadRequestException('分类 ID 重复');
|
||||
ids.add(fields.id);
|
||||
const time = (key: string): Date | undefined => {
|
||||
const raw = row[key];
|
||||
if (raw === undefined) return undefined;
|
||||
// 明确时区,避免导入机器时区改变历史时间。拒绝 CloudBase 未识别的日期对象。
|
||||
if (
|
||||
typeof raw !== 'string' ||
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
||||
raw,
|
||||
) ||
|
||||
!Number.isFinite(Date.parse(raw))
|
||||
)
|
||||
throw new BadRequestException('分类时间须为带时区的 ISO 时间');
|
||||
return new Date(raw);
|
||||
};
|
||||
const createdAt = time('createdAt');
|
||||
const updatedAt = time('updatedAt');
|
||||
if (createdAt && updatedAt && updatedAt < createdAt)
|
||||
throw new BadRequestException('分类更新时间不能早于创建时间');
|
||||
// 旧导出没有状态:新记录为草稿,重导时由服务保留数据库当前状态。
|
||||
const { status, ...rest } = fields;
|
||||
return {
|
||||
...rest,
|
||||
...(row.status === undefined ? {} : { status }),
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { ContentStatus } from '@doodle/contracts';
|
||||
import { contentStatus } from '../content/content-policy';
|
||||
|
||||
export interface CategoryFields {
|
||||
status: ContentStatus;
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
sortOrder: number;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export const categoryKeys = [
|
||||
'id',
|
||||
'status',
|
||||
'name',
|
||||
'icon',
|
||||
'color',
|
||||
'sortOrder',
|
||||
'parentId',
|
||||
];
|
||||
|
||||
/** API 与导入共用严格校验;不将数字/布尔值隐式转成名称或 ID。 */
|
||||
export function categoryObject(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new BadRequestException('分类必须为对象');
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function requireCategoryKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowed: string[],
|
||||
): void {
|
||||
if (Object.keys(value).some((key) => !allowed.includes(key)))
|
||||
throw new BadRequestException('分类包含未知字段');
|
||||
}
|
||||
|
||||
export function categoryId(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
[...value].length > 128 ||
|
||||
!/^[^\s/]+$/u.test(value) ||
|
||||
[...value].some(
|
||||
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
|
||||
)
|
||||
)
|
||||
throw new BadRequestException('分类 ID 不合法');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 文件图标需等资产迁移/签名模块完成,当前只接受文字、Emoji 和图标名。 */
|
||||
export function normalizeCategory(
|
||||
input: Record<string, unknown>,
|
||||
): CategoryFields {
|
||||
const text = (key: string, max: number): string => {
|
||||
const value = input[key];
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.trim() ||
|
||||
[...value].length > max ||
|
||||
value.includes('\0')
|
||||
)
|
||||
throw new BadRequestException(`分类 ${key} 不合法`);
|
||||
return value.trim();
|
||||
};
|
||||
const icon = text('icon', 1024);
|
||||
if (/^(?:https?:|cloud:|data:|file:|\/)/i.test(icon))
|
||||
throw new BadRequestException('文件图标需通过资产模块绑定');
|
||||
const sortOrder = input.sortOrder === undefined ? 0 : input.sortOrder;
|
||||
if (
|
||||
typeof sortOrder !== 'number' ||
|
||||
!Number.isInteger(sortOrder) ||
|
||||
sortOrder < -2147483648 ||
|
||||
sortOrder > 2147483647
|
||||
)
|
||||
throw new BadRequestException('分类排序必须为 PostgreSQL integer');
|
||||
const parent = input.parentId;
|
||||
return {
|
||||
status: contentStatus(
|
||||
input.status === undefined ? 'draft' : input.status,
|
||||
),
|
||||
id: categoryId(input.id),
|
||||
name: text('name', 100),
|
||||
icon,
|
||||
color: text('color', 32),
|
||||
sortOrder,
|
||||
parentId:
|
||||
parent === undefined || parent === null || parent === ''
|
||||
? null
|
||||
: categoryId(parent),
|
||||
};
|
||||
}
|
||||
|
||||
/** 检查合并后的整棵树;迭代遍历避免深层导入触发调用栈溢出,并返回父先子后顺序。 */
|
||||
export function categoryTreeOrder(
|
||||
rows: Array<{ id: string; parentId: string | null }>,
|
||||
): string[] {
|
||||
const parents = new Map(rows.map((row) => [row.id, row.parentId]));
|
||||
const done = new Set<string>();
|
||||
const order: string[] = [];
|
||||
for (const row of rows) {
|
||||
let id: string | null = row.id;
|
||||
const visiting = new Set<string>();
|
||||
const path: string[] = [];
|
||||
while (id !== null && !done.has(id)) {
|
||||
if (!parents.has(id)) throw new BadRequestException('父分类不存在');
|
||||
if (visiting.has(id))
|
||||
throw new BadRequestException('分类不能形成循环引用');
|
||||
visiting.add(id);
|
||||
path.push(id);
|
||||
id = parents.get(id)!;
|
||||
}
|
||||
for (const item of path.reverse()) {
|
||||
done.add(item);
|
||||
order.push(item);
|
||||
}
|
||||
}
|
||||
return order;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { ApiSuccess, Category } from '@doodle/contracts';
|
||||
import { CurrentUser, Public, Roles } from '../auth/auth.decorators';
|
||||
import type { AuthenticatedUser, HttpRequest } from '../common/http-request';
|
||||
import { CategoryService } from './category.service';
|
||||
import { CreateCategoryDto, UpdateCategoryDto } from './category.dto';
|
||||
|
||||
@Controller('categories')
|
||||
@ApiTags('categories')
|
||||
/** 分类查询供小程序和未来 Web 共用,不要求静默登录先完成。 */
|
||||
export class CategoryController {
|
||||
constructor(private readonly categories: CategoryService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '已上线分类,所有上级须已上线;按 sortOrder、id 升序',
|
||||
security: [],
|
||||
})
|
||||
async list(): Promise<ApiSuccess<Category[]>> {
|
||||
return { code: 0, message: 'ok', data: await this.categories.list() };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '分类详情', security: [] })
|
||||
async get(@Param('id') id: string): Promise<ApiSuccess<Category>> {
|
||||
return { code: 0, message: 'ok', data: await this.categories.get(id) };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/categories')
|
||||
@ApiTags('admin/categories')
|
||||
@Roles('admin')
|
||||
/** 分类新增、修改、删除统一要求管理员;权限取服务端当前账户,不相信客户端自报角色。 */
|
||||
export class AdminCategoryController {
|
||||
constructor(private readonly categories: CategoryService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '全部状态分类(管理员,包含草稿和下架)' })
|
||||
async list(): Promise<ApiSuccess<Category[]>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.categories.listForAdmin(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '分类管理预览(管理员)' })
|
||||
async get(@Param('id') id: string): Promise<ApiSuccess<Category>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.categories.getForAdmin(id),
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建分类(管理员)' })
|
||||
async create(
|
||||
@Body() input: CreateCategoryDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: HttpRequest,
|
||||
): Promise<ApiSuccess<Category>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.categories.create(
|
||||
input,
|
||||
actor,
|
||||
request.requestId!,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '局部更新分类(管理员,ID 不可修改)' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() input: UpdateCategoryDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: HttpRequest,
|
||||
): Promise<ApiSuccess<Category>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.categories.update(
|
||||
id,
|
||||
input,
|
||||
actor,
|
||||
request.requestId!,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({
|
||||
summary: '删除分类(管理员,有子分类或题型引用时返回 409)',
|
||||
})
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: HttpRequest,
|
||||
): Promise<ApiSuccess<Pick<Category, 'id'>>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.categories.remove(id, actor, request.requestId!),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
ApiProperty,
|
||||
ApiPropertyOptional,
|
||||
OmitType,
|
||||
PartialType,
|
||||
} from '@nestjs/swagger';
|
||||
import { Allow } from 'class-validator';
|
||||
import type { ContentStatus, CreateCategoryRequest } from '@doodle/contracts';
|
||||
|
||||
/** 白名单和文档在此声明,字段及父子树校验统一由 category-input 完成,导入使用同一规则。 */
|
||||
export class CreateCategoryDto implements CreateCategoryRequest {
|
||||
@ApiPropertyOptional({
|
||||
enum: ['draft', 'active', 'hidden'],
|
||||
default: 'draft',
|
||||
})
|
||||
@Allow()
|
||||
status?: ContentStatus;
|
||||
|
||||
@ApiProperty({ maxLength: 128, example: 'math' })
|
||||
@Allow()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ maxLength: 100, example: '数感启蒙' })
|
||||
@Allow()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
maxLength: 1024,
|
||||
example: '📐',
|
||||
description: '当前支持文字、Emoji 或图标名',
|
||||
})
|
||||
@Allow()
|
||||
icon!: string;
|
||||
|
||||
@ApiProperty({ maxLength: 32, example: '#CDB4DB' })
|
||||
@Allow()
|
||||
color!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: 'integer', default: 0 })
|
||||
@Allow()
|
||||
sortOrder?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'null 或空串表示根分类',
|
||||
})
|
||||
@Allow()
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
/** ID 是稳定引用,更新路径只允许修改展示与父级字段。 */
|
||||
export class UpdateCategoryDto extends PartialType(
|
||||
OmitType(CreateCategoryDto, ['id'] as const),
|
||||
) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import {
|
||||
AdminCategoryController,
|
||||
CategoryController,
|
||||
} from './category.controller';
|
||||
import { CategoryService } from './category.service';
|
||||
|
||||
/** 小程序与 Web 共享的分类模块。 */
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [CategoryController, AdminCategoryController],
|
||||
providers: [CategoryService],
|
||||
})
|
||||
export class CategoryModule {}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { Category } from '@doodle/contracts';
|
||||
import {
|
||||
Prisma,
|
||||
type Category as StoredCategory,
|
||||
} from '../generated/prisma/client';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { ApiException } from '../common/api-exception';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
import {
|
||||
visibleCategoryIds,
|
||||
lockContentAdmin,
|
||||
invalidateContentSnapshots,
|
||||
} from '../content/content-policy';
|
||||
import { contentStatus } from '../content/content-policy';
|
||||
import {
|
||||
categoryId,
|
||||
categoryKeys,
|
||||
categoryObject,
|
||||
categoryTreeOrder,
|
||||
normalizeCategory,
|
||||
requireCategoryKeys,
|
||||
type CategoryFields,
|
||||
} from './category-input';
|
||||
import { parseCategoryExport } from './category-import';
|
||||
|
||||
export interface CategoryImportReport {
|
||||
dryRun: boolean;
|
||||
batchAt: string;
|
||||
total: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
unchanged: number;
|
||||
missingCreatedAt: number;
|
||||
missingUpdatedAt: number;
|
||||
changes: Array<{ id: string; action: 'insert' | 'update' | 'unchanged' }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** 分类读写与导入共用规则;分类树写操作按表锁串行化,防止并发改父级形成循环。 */
|
||||
export class CategoryService {
|
||||
constructor(private readonly database: PrismaService) {}
|
||||
|
||||
/** 公开分类必须从 active 根分类可达;管理读取另外注册受鉴权保护的路由。 */
|
||||
async list(): Promise<Category[]> {
|
||||
return this.publicRows();
|
||||
}
|
||||
|
||||
private async publicRows(id?: string): Promise<Category[]> {
|
||||
const filter =
|
||||
id === undefined ? Prisma.sql`TRUE` : Prisma.sql`id = ${id}`;
|
||||
return this.run(async () => {
|
||||
const rows = await this.database.client.$queryRaw<StoredCategory[]>`
|
||||
SELECT id, name, icon, icon_asset_id AS "iconAssetId", color,
|
||||
sort_order AS "sortOrder", parent_id AS "parentId", status,
|
||||
created_at AS "createdAt", updated_at AS "updatedAt"
|
||||
FROM public.categories WHERE id IN (${visibleCategoryIds}) AND (${filter}) ORDER BY sort_order, id
|
||||
`;
|
||||
return rows.map((row) => this.toCategory(row));
|
||||
});
|
||||
}
|
||||
|
||||
/** 未上线、祖先未上线与不存在统一 404,不能通过已知 ID 绕过列表过滤。 */
|
||||
async get(id: string): Promise<Category> {
|
||||
categoryId(id);
|
||||
const rows = await this.publicRows(id);
|
||||
const row = rows.find((item) => item.id === id);
|
||||
if (!row) throw new NotFoundException();
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 仅管理路由调用;包含草稿、下架及上级分类隐藏的记录。 */
|
||||
async listForAdmin(): Promise<Category[]> {
|
||||
return this.run(async () =>
|
||||
(
|
||||
await this.database.client.category.findMany({
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
).map((row) => this.toCategory(row)),
|
||||
);
|
||||
}
|
||||
|
||||
async getForAdmin(id: string): Promise<Category> {
|
||||
categoryId(id);
|
||||
return this.run(async () => {
|
||||
const row = await this.database.client.category.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!row) throw new NotFoundException();
|
||||
return this.toCategory(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** 守卫后在事务内锁住账户并复核权限,成功修改与审计一起提交。 */
|
||||
async create(
|
||||
input: unknown,
|
||||
actor: AuthenticatedUser,
|
||||
requestId: string,
|
||||
): Promise<Category> {
|
||||
const data = categoryObject(input);
|
||||
requireCategoryKeys(data, categoryKeys);
|
||||
const fields = normalizeCategory(data);
|
||||
return this.run(() =>
|
||||
this.database.client.$transaction(
|
||||
async (tx) => {
|
||||
await lockContentAdmin(tx, actor);
|
||||
await this.lockTree(tx);
|
||||
const rows = await tx.category.findMany();
|
||||
if (rows.some((row) => row.id === fields.id))
|
||||
throw new ConflictException();
|
||||
categoryTreeOrder([...rows, fields]);
|
||||
const after = await tx.category.create({ data: fields });
|
||||
if (fields.status === 'active')
|
||||
await invalidateContentSnapshots(tx);
|
||||
await this.audit(
|
||||
tx,
|
||||
'create',
|
||||
null,
|
||||
after,
|
||||
requestId,
|
||||
actor.userId,
|
||||
);
|
||||
return this.toCategory(after);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** PATCH 省略字段不变;拒绝空修改及 ID 改名,重复提交相同值不刷新时间/审计。 */
|
||||
async update(
|
||||
id: string,
|
||||
input: unknown,
|
||||
actor: AuthenticatedUser,
|
||||
requestId: string,
|
||||
): Promise<Category> {
|
||||
categoryId(id);
|
||||
const patch = categoryObject(input);
|
||||
requireCategoryKeys(
|
||||
patch,
|
||||
categoryKeys.filter((key) => key !== 'id'),
|
||||
);
|
||||
if (!Object.keys(patch).length) throw new BadRequestException();
|
||||
return this.run(() =>
|
||||
this.database.client.$transaction(
|
||||
async (tx) => {
|
||||
await lockContentAdmin(tx, actor);
|
||||
await this.lockTree(tx);
|
||||
const rows = await tx.category.findMany();
|
||||
const before = rows.find((row) => row.id === id);
|
||||
if (!before) throw new NotFoundException();
|
||||
const fields = normalizeCategory({ ...before, ...patch });
|
||||
categoryTreeOrder(
|
||||
rows.map((row) => (row.id === id ? fields : row)),
|
||||
);
|
||||
if (this.sameFields(before, fields))
|
||||
return this.toCategory(before);
|
||||
if (before.iconAssetId && fields.icon !== before.icon)
|
||||
throw new ConflictException(
|
||||
'文件图标需通过资产模块修改',
|
||||
);
|
||||
const after = await tx.category.update({
|
||||
where: { id },
|
||||
data: { ...fields, updatedAt: new Date() },
|
||||
});
|
||||
if (
|
||||
before.status !== after.status ||
|
||||
before.parentId !== after.parentId
|
||||
)
|
||||
await invalidateContentSnapshots(tx);
|
||||
await this.audit(
|
||||
tx,
|
||||
'update',
|
||||
before,
|
||||
after,
|
||||
requestId,
|
||||
actor.userId,
|
||||
);
|
||||
return this.toCategory(after);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 只删除无引用的分类;权限、分类树修改和删除审计同事务,不级联删除内容。 */
|
||||
async remove(
|
||||
id: string,
|
||||
actor: AuthenticatedUser,
|
||||
requestId: string,
|
||||
): Promise<Pick<Category, 'id'>> {
|
||||
categoryId(id);
|
||||
return this.run(() =>
|
||||
this.database.client.$transaction(
|
||||
async (tx) => {
|
||||
await lockContentAdmin(tx, actor);
|
||||
await this.lockTree(tx);
|
||||
const before = await tx.category.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!before) throw new NotFoundException();
|
||||
const child = await tx.category.findFirst({
|
||||
where: { parentId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (child) throw new ConflictException('分类仍有子分类');
|
||||
const worksheet = await tx.worksheet.findFirst({
|
||||
where: { categoryId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (worksheet)
|
||||
throw new ConflictException('分类仍被题型引用');
|
||||
// 外键是并发题型写入的最后保护;检查后新建的引用同样不能被静默删除。
|
||||
await tx.category.delete({ where: { id } });
|
||||
await invalidateContentSnapshots(tx);
|
||||
await this.audit(
|
||||
tx,
|
||||
'delete',
|
||||
before,
|
||||
null,
|
||||
requestId,
|
||||
actor.userId,
|
||||
);
|
||||
return { id };
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** CLI 专用入口,不注册 HTTP 路由;dry-run 无任何写入,正式导入整批原子提交。 */
|
||||
async importExport(
|
||||
source: string,
|
||||
dryRun: boolean,
|
||||
requestId: string,
|
||||
reason: string,
|
||||
): Promise<CategoryImportReport> {
|
||||
const input = parseCategoryExport(source);
|
||||
const batch = new Date();
|
||||
return this.run(() =>
|
||||
this.database.client.$transaction(
|
||||
async (tx) => {
|
||||
await this.lockTree(tx);
|
||||
const existing = await tx.category.findMany();
|
||||
const beforeById = new Map(
|
||||
existing.map((row) => [row.id, row]),
|
||||
);
|
||||
const merged = new Map(
|
||||
existing.map((row) => [
|
||||
row.id,
|
||||
{ id: row.id, parentId: row.parentId },
|
||||
]),
|
||||
);
|
||||
for (const row of input) merged.set(row.id, row);
|
||||
const byId = new Map(input.map((row) => [row.id, row]));
|
||||
const order = categoryTreeOrder([...merged.values()]);
|
||||
const report: CategoryImportReport = {
|
||||
dryRun,
|
||||
batchAt: batch.toISOString(),
|
||||
total: input.length,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
missingCreatedAt: input.filter((row) => !row.createdAt)
|
||||
.length,
|
||||
missingUpdatedAt: input.filter((row) => !row.updatedAt)
|
||||
.length,
|
||||
changes: [],
|
||||
};
|
||||
for (const id of order) {
|
||||
const row = byId.get(id);
|
||||
if (!row) continue;
|
||||
const before = beforeById.get(id);
|
||||
const fields = normalizeCategory({
|
||||
...row,
|
||||
status: row.status ?? before?.status ?? 'draft',
|
||||
});
|
||||
if (before && this.sameFields(before, fields)) {
|
||||
report.unchanged++;
|
||||
report.changes.push({ id, action: 'unchanged' });
|
||||
continue;
|
||||
}
|
||||
if (before?.iconAssetId)
|
||||
throw new ConflictException(
|
||||
'不能用文字导入覆盖文件图标',
|
||||
);
|
||||
// 重导永远保留现存 createdAt;无源时间时只对真正变更的行使用本批时间。
|
||||
const createdAt =
|
||||
before?.createdAt ?? row.createdAt ?? batch;
|
||||
const updatedAt = new Date(
|
||||
Math.max(
|
||||
(row.updatedAt ?? batch).getTime(),
|
||||
before?.updatedAt.getTime() ?? 0,
|
||||
),
|
||||
);
|
||||
if (updatedAt < createdAt)
|
||||
throw new BadRequestException('分类时间顺序不合法');
|
||||
const action = before ? 'update' : 'insert';
|
||||
report[before ? 'updated' : 'inserted']++;
|
||||
report.changes.push({ id, action });
|
||||
if (!dryRun) {
|
||||
const after = before
|
||||
? await tx.category.update({
|
||||
where: { id },
|
||||
data: { ...fields, updatedAt },
|
||||
})
|
||||
: await tx.category.create({
|
||||
data: { ...fields, createdAt, updatedAt },
|
||||
});
|
||||
if (
|
||||
before?.status !== after.status ||
|
||||
before?.parentId !== after.parentId
|
||||
)
|
||||
await invalidateContentSnapshots(tx);
|
||||
await this.audit(
|
||||
tx,
|
||||
action,
|
||||
before ?? null,
|
||||
after,
|
||||
requestId,
|
||||
null,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
return report;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async lockTree(tx: Prisma.TransactionClient): Promise<void> {
|
||||
// 此锁允许公开 SELECT;API 与导入都必须先持锁再读取整树,避免写偏差。
|
||||
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
|
||||
}
|
||||
|
||||
private sameFields(row: StoredCategory, input: CategoryFields): boolean {
|
||||
return categoryKeys.every(
|
||||
(key) =>
|
||||
row[key as keyof CategoryFields] ===
|
||||
input[key as keyof CategoryFields],
|
||||
);
|
||||
}
|
||||
|
||||
private toCategory(row: StoredCategory): Category {
|
||||
// 资产图标未接入签名前拒绝返回原始引用,不泄漏对象 key 或 cloud:// 地址。
|
||||
if (
|
||||
row.iconAssetId ||
|
||||
/^(?:https?:|cloud:|data:|file:|\/)/i.test(row.icon)
|
||||
)
|
||||
throw new HttpException('分类图标资产尚未接入', 503);
|
||||
return {
|
||||
status: contentStatus(row.status),
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
icon: row.icon,
|
||||
color: row.color,
|
||||
sortOrder: row.sortOrder,
|
||||
parentId: row.parentId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async audit(
|
||||
tx: Prisma.TransactionClient,
|
||||
action: string,
|
||||
before: StoredCategory | null,
|
||||
after: StoredCategory | null,
|
||||
requestId: string,
|
||||
actorUserId: string | null,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
await tx.adminAuditLog.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
actorUserId,
|
||||
actorType: actorUserId ? 'admin' : 'migration',
|
||||
action: `category.${action}`,
|
||||
resourceType: 'categories',
|
||||
resourceId: (after ?? before)!.id,
|
||||
requestId,
|
||||
...(before ? { beforeData: this.auditSnapshot(before) } : {}),
|
||||
...(after ? { afterData: this.auditSnapshot(after) } : {}),
|
||||
...(reason ? { reason } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private auditSnapshot(row: StoredCategory): Prisma.InputJsonObject {
|
||||
// 删除文件图标分类也能审计;保留稳定资产 ID,不记录旧地址或签名 URL。
|
||||
const fileIcon =
|
||||
row.iconAssetId ||
|
||||
/^(?:https?:|cloud:|data:|file:|\/)/i.test(row.icon);
|
||||
return {
|
||||
status: contentStatus(row.status),
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
icon: fileIcon ? null : row.icon,
|
||||
iconAssetId: row.iconAssetId,
|
||||
color: row.color,
|
||||
sortOrder: row.sortOrder,
|
||||
parentId: row.parentId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async run<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
['P2002', 'P2003'].includes(error.code)
|
||||
)
|
||||
throw new ConflictException();
|
||||
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { Response } from 'express';
|
||||
import type { HttpRequest } from './http-request';
|
||||
import { AppLogger } from '../logging/app-logger';
|
||||
import { ApiException } from './api-exception';
|
||||
|
||||
// 对外只提供稳定的安全提示;异常原始 message 可能包含请求值、路径或密钥。
|
||||
const messages: Record<number, string> = {
|
||||
@@ -57,13 +58,27 @@ export class ApiErrorFilter implements ExceptionFilter {
|
||||
exception instanceof Error
|
||||
? exception.name
|
||||
: 'UnknownError',
|
||||
...(exception instanceof ApiException
|
||||
? { reason: exception.reason }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
// HTTP 状态与响应 code 保持一致,客户端可用 requestId 反馈问题。
|
||||
if (
|
||||
exception instanceof ApiException &&
|
||||
exception.retryAfterSeconds !== undefined
|
||||
)
|
||||
response.setHeader(
|
||||
'Retry-After',
|
||||
String(exception.retryAfterSeconds),
|
||||
);
|
||||
response.status(status).json({
|
||||
code: status,
|
||||
message: messages[status] ?? '服务器暂时无法处理请求',
|
||||
requestId: request.requestId,
|
||||
...(exception instanceof ApiException
|
||||
? { reason: exception.reason }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export interface RuntimeOptions {
|
||||
export function getRuntimeOptions(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): RuntimeOptions {
|
||||
const rawPort = env.PORT ?? '3000';
|
||||
const rawPort = env.PORT ?? '8800';
|
||||
const port = Number(rawPort);
|
||||
const host = (env.HOST ?? '127.0.0.1').trim();
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export const SERVER_CONFIG = Symbol('SERVER_CONFIG');
|
||||
export const API_PREFIX = 'api/v1';
|
||||
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
|
||||
|
||||
/** 规范化的启动配置;仅在服务端使用,包含不得下发或记录的 JWT 密钥。 */
|
||||
/** 规范化的启动配置;仅在服务端使用,JWT 和微信密钥不得下发或记录。 */
|
||||
export interface ServerConfig extends RuntimeOptions {
|
||||
nodeEnv: 'development' | 'test' | 'production';
|
||||
logLevel: LogLevel;
|
||||
@@ -22,6 +22,13 @@ export interface ServerConfig extends RuntimeOptions {
|
||||
jwtIssuer: string;
|
||||
jwtAudience: string;
|
||||
jwtExpiresInSeconds: number;
|
||||
/** 微信凭据仅服务端持有,登录接入后两项必须完整。 */
|
||||
wechatMiniAppId: string;
|
||||
wechatMiniAppSecret: string;
|
||||
databaseUrl: string;
|
||||
/** 新品依据首次发布时间判断;公共资源域名缺失时不返回七牛对象地址。 */
|
||||
worksheetNewWindowDays: number;
|
||||
qiniuPublicDomain: string | null;
|
||||
}
|
||||
|
||||
/** 按十进制整数字符串解析并检查范围,防止空串、科学计数法等被 Number 宽松接受。 */
|
||||
@@ -78,6 +85,41 @@ export function getServerConfig(
|
||||
if (!jwtIssuer || !jwtAudience) {
|
||||
throw new Error('JWT_ISSUER and JWT_AUDIENCE must not be empty');
|
||||
}
|
||||
const wechatMiniAppId = (env.WECHAT_MINI_APP_ID ?? '').trim();
|
||||
const wechatMiniAppSecret = (env.WECHAT_MINI_APP_SECRET ?? '').trim();
|
||||
// 已接入真实登录;缺失凭据必须启动失败,错误不回显配置值。
|
||||
if (!wechatMiniAppId || !wechatMiniAppSecret) {
|
||||
throw new Error(
|
||||
'WECHAT_MINI_APP_ID and WECHAT_MINI_APP_SECRET are required',
|
||||
);
|
||||
}
|
||||
if (/\s/.test(wechatMiniAppId) || /\s/.test(wechatMiniAppSecret)) {
|
||||
throw new Error(
|
||||
'WECHAT_MINI_APP_ID and WECHAT_MINI_APP_SECRET must not contain whitespace',
|
||||
);
|
||||
}
|
||||
const databaseUrl = env.DATABASE_URL ?? '';
|
||||
try {
|
||||
const url = new URL(databaseUrl);
|
||||
if (
|
||||
!['postgresql:', 'postgres:'].includes(url.protocol) ||
|
||||
!url.hostname ||
|
||||
url.pathname.length < 2 ||
|
||||
url.hash ||
|
||||
/\s/.test(databaseUrl)
|
||||
)
|
||||
throw new Error();
|
||||
// 本阶段连接 public 中的既有 SQL 表,拒绝悄悄连接到其他 Prisma schema。
|
||||
if (
|
||||
url.searchParams.has('schema') &&
|
||||
url.searchParams.get('schema') !== 'public'
|
||||
)
|
||||
throw new Error();
|
||||
} catch {
|
||||
throw new Error(
|
||||
'DATABASE_URL must be a PostgreSQL connection URL using the public schema',
|
||||
);
|
||||
}
|
||||
// 只接受可直接比较的完整 origin;路径、凭证、通配符不属于白名单项。
|
||||
const corsOrigins = (env.CORS_ORIGINS ?? '')
|
||||
.split(',')
|
||||
@@ -113,6 +155,27 @@ export function getServerConfig(
|
||||
'TRUSTED_PROXY_CIDRS must contain explicit proxy addresses or CIDRs',
|
||||
);
|
||||
}
|
||||
const rawPublicDomain = (env.QINIU_PUBLIC_DOMAIN ?? '').trim();
|
||||
let qiniuPublicDomain: string | null = null;
|
||||
if (rawPublicDomain) {
|
||||
try {
|
||||
const url = new URL(rawPublicDomain);
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.pathname !== '/' ||
|
||||
url.search ||
|
||||
url.hash
|
||||
)
|
||||
throw new Error();
|
||||
qiniuPublicDomain = url.origin;
|
||||
} catch {
|
||||
throw new Error(
|
||||
'QINIU_PUBLIC_DOMAIN must be an HTTPS origin without credentials, paths or query',
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...getRuntimeOptions(env),
|
||||
nodeEnv: nodeEnv as ServerConfig['nodeEnv'],
|
||||
@@ -130,6 +193,17 @@ export function getServerConfig(
|
||||
jwtSecret,
|
||||
jwtIssuer,
|
||||
jwtAudience,
|
||||
wechatMiniAppId,
|
||||
wechatMiniAppSecret,
|
||||
databaseUrl,
|
||||
worksheetNewWindowDays: integer(
|
||||
env,
|
||||
'WORKSHEET_NEW_WINDOW_DAYS',
|
||||
30,
|
||||
1,
|
||||
365,
|
||||
),
|
||||
qiniuPublicDomain,
|
||||
jwtExpiresInSeconds: integer(
|
||||
env,
|
||||
'JWT_EXPIRES_IN_SECONDS',
|
||||
|
||||
@@ -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!,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,25 @@ export async function bootstrap(): Promise<void> {
|
||||
const app = await createApplication(config, logger);
|
||||
try {
|
||||
await app.listen(config.port, config.host);
|
||||
// 用实际监听地址生成文档链接;通配监听地址替换为同协议族的本机地址。
|
||||
const origin = new URL(await app.getUrl());
|
||||
if (origin.hostname === '0.0.0.0') origin.hostname = '127.0.0.1';
|
||||
if (origin.hostname === '[::]') origin.hostname = '[::1]';
|
||||
const swaggerUrl = config.swaggerEnabled
|
||||
? new URL('/api-docs', origin).href
|
||||
: null;
|
||||
logger.log('server_started', {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
api_prefix: API_PREFIX,
|
||||
swagger_enabled: config.swaggerEnabled,
|
||||
swagger_url: swaggerUrl,
|
||||
});
|
||||
// 开发启动器在监听成功后接收通知;独立于日志级别,生产启动不发此通知。
|
||||
if (process.env.DOODLE_DEV_LAUNCHER === '1')
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ event: 'doodle-dev-ready', swaggerUrl })}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
await app.close();
|
||||
throw error;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,7 @@ test('HTTP 中间件、校验、日志、Swagger 与 JWT 全局守卫', async (c
|
||||
assert.ok(!logs.join('\n').includes(userToken));
|
||||
});
|
||||
|
||||
test('生产默认不暴露 Swagger;账户查询未接入时不放行有效 JWT', async (context) => {
|
||||
test('生产默认不暴露 Swagger;真实账户数据库不可用时不放行有效 JWT', async (context) => {
|
||||
const config = {
|
||||
...testConfig,
|
||||
nodeEnv: 'production' as const,
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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');
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { getRuntimeOptions } from '../src/common/runtime-options';
|
||||
test('使用本地默认 host 和 port', () => {
|
||||
assert.deepEqual(getRuntimeOptions({}), {
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
port: 8800,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { getServerConfig } from '../src/config/server-config';
|
||||
|
||||
const env = { JWT_SECRET: 'test-configuration-secret-12345678901234567890' };
|
||||
const env = {
|
||||
JWT_SECRET: 'test-configuration-secret-12345678901234567890',
|
||||
DATABASE_URL:
|
||||
'postgresql://test:test@127.0.0.1:1/doodle_test?schema=public',
|
||||
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
|
||||
};
|
||||
|
||||
test('启动配置必须提供有效 JWT 密钥,不使用共享默认密钥', () => {
|
||||
for (const secret of [
|
||||
@@ -33,6 +39,82 @@ test('Swagger 开发默认开启,生产默认关闭,支持显式开关', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('真实登录接入后微信配置必须完整,缺省或留空都拒绝启动', () => {
|
||||
for (const entry of [
|
||||
{ WECHAT_MINI_APP_ID: undefined, WECHAT_MINI_APP_SECRET: undefined },
|
||||
{ WECHAT_MINI_APP_ID: '', WECHAT_MINI_APP_SECRET: '' },
|
||||
{ WECHAT_MINI_APP_ID: ' ', WECHAT_MINI_APP_SECRET: '\t' },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => getServerConfig({ ...env, ...entry }),
|
||||
/WECHAT_MINI_APP/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('成对读取微信配置并去除首尾空白', () => {
|
||||
const config = getServerConfig({
|
||||
...env,
|
||||
WECHAT_MINI_APP_ID: ' wx0123456789abcdef ',
|
||||
WECHAT_MINI_APP_SECRET: ' test-only-wechat-secret ',
|
||||
});
|
||||
assert.equal(config.wechatMiniAppId, 'wx0123456789abcdef');
|
||||
assert.equal(config.wechatMiniAppSecret, 'test-only-wechat-secret');
|
||||
});
|
||||
|
||||
test('拒绝不完整或含内部空白的微信配置,错误不回显配置值', () => {
|
||||
for (const entry of [
|
||||
{
|
||||
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: undefined,
|
||||
},
|
||||
{
|
||||
WECHAT_MINI_APP_ID: undefined,
|
||||
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
|
||||
},
|
||||
{
|
||||
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: ' ',
|
||||
},
|
||||
{
|
||||
WECHAT_MINI_APP_ID: 'wx0123 456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
|
||||
},
|
||||
{
|
||||
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: 'test-only\nwechat-secret',
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => getServerConfig({ ...env, ...entry }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /WECHAT_MINI_APP/);
|
||||
for (const value of Object.values(entry)) {
|
||||
if (value?.trim())
|
||||
assert.ok(!error.message.includes(value));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('数据库连接必须为 PostgreSQL public schema,校验不回显含密码的 URL', () => {
|
||||
for (const databaseUrl of [
|
||||
'',
|
||||
'mysql://user:private-password@localhost/db',
|
||||
'postgresql:///db',
|
||||
'postgresql://localhost',
|
||||
'postgresql://localhost/db?schema=other',
|
||||
])
|
||||
assert.throws(
|
||||
() => getServerConfig({ ...env, DATABASE_URL: databaseUrl }),
|
||||
/DATABASE_URL/,
|
||||
);
|
||||
assert.equal(getServerConfig(env).databaseUrl, env.DATABASE_URL);
|
||||
});
|
||||
|
||||
test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域配置', () => {
|
||||
for (const entry of [
|
||||
{ API_BODY_LIMIT_BYTES: '0' },
|
||||
@@ -55,3 +137,30 @@ test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域
|
||||
['https://example.com', 'http://localhost:3001'],
|
||||
);
|
||||
});
|
||||
|
||||
test('题型新品窗口与公共资源域名校验,缺省域名不构造七牛地址', () => {
|
||||
assert.equal(getServerConfig(env).worksheetNewWindowDays, 30);
|
||||
assert.equal(getServerConfig(env).qiniuPublicDomain, null);
|
||||
assert.equal(
|
||||
getServerConfig({
|
||||
...env,
|
||||
QINIU_PUBLIC_DOMAIN: 'https://assets.example.test/',
|
||||
}).qiniuPublicDomain,
|
||||
'https://assets.example.test',
|
||||
);
|
||||
for (const value of ['0', '366', '1e2', ''])
|
||||
assert.throws(() =>
|
||||
getServerConfig({ ...env, WORKSHEET_NEW_WINDOW_DAYS: value }),
|
||||
);
|
||||
for (const value of [
|
||||
'http://assets.example.test',
|
||||
'https://user:secret@example.test',
|
||||
'https://example.test/path',
|
||||
'https://example.test?sign=private',
|
||||
'https://example.test#hash',
|
||||
])
|
||||
assert.throws(
|
||||
() => getServerConfig({ ...env, QINIU_PUBLIC_DOMAIN: value }),
|
||||
/QINIU_PUBLIC_DOMAIN/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,4 +4,8 @@ import { getServerConfig } from '../src/config/server-config';
|
||||
export const testConfig = getServerConfig({
|
||||
NODE_ENV: 'test',
|
||||
JWT_SECRET: 'test-only-secret-123456789012345678901234567890',
|
||||
DATABASE_URL:
|
||||
'postgresql://test:test@127.0.0.1:1/doodle_test?schema=public',
|
||||
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
|
||||
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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
@@ -1,6 +1,8 @@
|
||||
# Doodle Store Server 基础设置
|
||||
|
||||
> 更新日期:2026-10-13。已实现基础配置、日志、中间件、Swagger 与 JWT 守卫,业务登录和 Prisma 账户查询待接入。
|
||||
> 更新日期:2026-10-15。已实现基础配置、日志、中间件、Swagger、微信登录、Prisma 账户查询与 JWT 守卫。
|
||||
|
||||
后续业务接入见 [用户鉴权与登录设计](./用户鉴权与登录设计.md);服务端登录模式和 reason 已接入,小程序恢复及 Web Cookie 会话仍是目标设计。当前接口与验证说明见 [服务端登录接入说明](./服务端登录接入说明.md)。
|
||||
|
||||
参考 `atlas-seller-kit/apps/api` 的应用工厂、结构化日志、requestId、请求体限制、默认鉴权与错误处理。保持本项目 NestJS 11 和 `/api/v1` 契约,不复制卖家工作台的多租户、Redis、对象存储或业务模块。
|
||||
|
||||
@@ -8,6 +10,8 @@
|
||||
|
||||
从 `doodle-store` 根目录执行 `pnpm dev`。服务只读取 `apps/server/.env`,不向上搜索根目录的 Compose `.env`;部署环境变量优先于 `.env`。应用工厂不自行监听端口,main 负责读取配置和启动,测试使用临时端口。
|
||||
|
||||
开发命令通过 `apps/server/scripts/dev.mjs` 启动 Nest watch,在收到实际监听成功的通知后,输出 `Swagger: <完整地址>` 并使用系统默认浏览器打开一次。热更新不重复打开;`SWAGGER_ENABLED=false` 时只提示文档已关闭。链接使用实际监听 IP/端口,通配地址转换为本机地址。通知独立于日志级别;打开浏览器失败只提示手动访问,不终止服务。`pnpm start` 保留服务端启动行为,不自动打开浏览器,`server_started` 结构化日志增加 `swagger_url`。
|
||||
|
||||
新环境先复制 `apps/server/.env.example` 为 `apps/server/.env`,设置 JWT_SECRET。生成随机密钥的示例命令:
|
||||
|
||||
```bash
|
||||
@@ -19,7 +23,7 @@ node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
|
||||
| 配置 | 默认 / 要求 |
|
||||
| ------------------------- | ----------------------------------------------------------------- |
|
||||
| NODE_ENV | development;允许 development/test/production |
|
||||
| HOST / PORT | 127.0.0.1 / 3000;端口须为 1~65535 整数 |
|
||||
| HOST / PORT | 127.0.0.1 / 8800;端口须为 1~65535 整数 |
|
||||
| LOG_LEVEL | info;允许 error/warn/info/debug |
|
||||
| API_BODY_LIMIT_BYTES | 1048576,即 1 MiB;允许 1 KiB~10 MiB |
|
||||
| SWAGGER_ENABLED | 未设置时开发/测试 true,生产 false;生产不要照搬开发示例中的 true |
|
||||
@@ -28,8 +32,12 @@ node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
|
||||
| JWT_SECRET | 必填,至少 32 字节;拒绝空值、过短或 change-me 占位值 |
|
||||
| JWT_ISSUER / JWT_AUDIENCE | doodle-store / doodle-mini |
|
||||
| JWT_EXPIRES_IN_SECONDS | 86400,即 24 小时;允许 60~604800 秒 |
|
||||
| WECHAT_MINI_APP_ID | 小程序 AppID;必填,必须与 AppSecret 成对提供 |
|
||||
| WECHAT_MINI_APP_SECRET | 必填;无默认密钥,仅服务端持有 |
|
||||
|
||||
DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接数据库,也不读取七牛凭证。健康检查成功只证明 HTTP 进程可用。
|
||||
微信配置写入 `apps/server/.env`,不写根目录 Compose `.env`。服务端通过 `ServerConfig.wechatMiniAppId/wechatMiniAppSecret` 读取;两项去除首尾空白后校验非空且不含内部空白,错误不回显实际值。登录时使用 code2Session 验证身份,启动校验不代表已验证微信凭据真实有效。AppSecret 与 JWT_SECRET 是独立密钥,不得混用、下发或输出到日志。
|
||||
|
||||
DATABASE_URL 现在必填,必须为 PostgreSQL URL 且使用 public schema;Prisma 单应用连接池最多 5 条连接,连接超时 2 秒、语句超时 5 秒,应用关闭时释放。GET /health 只证明 HTTP 进程可用,GET /health/ready 检查数据库连通性;七牛待接入。
|
||||
|
||||
## 2. 请求处理
|
||||
|
||||
@@ -40,11 +48,11 @@ DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接
|
||||
- Helmet 安全头与隐藏 X-Powered-By。Swagger 开启时放宽文档所需 CSP;Swagger 关闭时使用默认 CSP。
|
||||
- CORS 使用精确 origin 白名单,无 Cookie 凭证模式;允许 Bearer Authorization,并向浏览器暴露 X-Request-Id。无 origin 的小程序/服务端请求可用,CORS 不代替身份校验。
|
||||
- DTO 使用 ValidationPipe:transform、whitelist、forbidNonWhitelisted;不隐式把数字字符串转换成数字,不在错误里返回原始对象或值。
|
||||
- 错误统一为 `{code: HTTP状态码, message: 安全提示, requestId}`,保留 HTTP 状态;JSON 格式错误 400、超限 413、未登录 401、无权限 403、未预期错误 500。成功响应沿用 `{code:0,message,data}`,不重复包装健康检查。
|
||||
- 错误统一为 `{code: HTTP状态码, message: 安全提示, requestId}`,保留 HTTP 状态;JSON 格式错误 400、超限 413、未登录 401、无权限 403、未预期错误 500。可信业务错误新增有限 reason,限流错误携带 Retry-After;成功响应沿用 `{code:0,message,data}`,不重复包装健康检查。
|
||||
|
||||
## 3. Swagger
|
||||
|
||||
开发默认地址:`http://127.0.0.1:3000/api-docs`;OpenAPI JSON:`/api-docs-json`。Swagger 反映实际注册路由,不展示计划中尚未实现的业务接口。
|
||||
开发默认地址:`http://127.0.0.1:8800/api-docs`;OpenAPI JSON:`/api-docs-json`。Swagger 反映实际注册路由,不展示计划中尚未实现的业务接口。
|
||||
|
||||
使用 access-token Bearer JWT 安全定义,健康检查声明为公开。Swagger 不持久保存浏览器 token。生产默认关闭;如部署时显式开启,应通过部署入口限制访问。Swagger 是直接注册的文档路由,不由控制器 JWT 守卫保护。
|
||||
|
||||
@@ -56,12 +64,14 @@ DATABASE_URL 仍保留供后续 Prisma 接入,当前 HTTP 基础层不连接
|
||||
|
||||
验签后通过 ACCOUNT_READER 查询账户,要求用户存在、active、userId 一致且 authVersion 匹配。已注销、禁用或版本失效返回 401;不满足角色返回 403。数据库查询失败不降级为“JWT 有效即可通过”。
|
||||
|
||||
**当前 ACCOUNT_READER 是拒绝放行的接入占位实现**:有效 token 请求受保护接口时返回 503。后续 Prisma 用户模块必须替换为真实数据库读取;微信 code2Session、账户登录、注销和业务 API 尚未实现。测试中的账户读取替身和 probe 路由只在测试模块注册,不会进入正式服务。
|
||||
ACCOUNT_READER 现已接入真实 Prisma 查询;数据库失败返回 503/DATABASE_UNAVAILABLE,不降级放行。POST /auth/wechat/login 接入微信 code2Session、start/resume、唯一冲突处理及行锁;GET /me 返回本人摘要。资料修改、注销及其他业务 API 待实现。测试中的账户读取替身、微信 HTTP 替身和 probe 路由只在测试中使用。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
使用 `pnpm check` 验证格式、ESLint、类型、构建与测试。集成测试覆盖健康检查、前缀、Swagger 开关与 Bearer 定义、CORS、安全头、requestId、JSON/urlencoded、超限和 DTO 校验、日志脱敏、JWT 伪造/过期/错误签名及 claims、账户禁用/缺失/版本失效、服务端角色校验和未接入账户查询时的拒绝放行。
|
||||
使用 `pnpm check` 验证格式、ESLint、类型、构建与测试。集成测试覆盖健康检查、前缀、Swagger 开关与 Bearer 定义、CORS、安全头、requestId、JSON/urlencoded、超限和 DTO 校验、日志脱敏、JWT 伪造/过期/错误签名及 claims、账户禁用/缺失/版本失效、服务端角色校验和数据库不可用时的拒绝放行。
|
||||
|
||||
2026-10-13:`pnpm check` 全部通过,12 项测试通过。另使用构建后的 `dist/main.js` 启动真实进程,核对服务端 `.env` 加载、健康检查 HTTP 200、OpenAPI JSON HTTP 200 和 X-Request-Id;检查进程已关闭,未修改开发数据库或云服务器。
|
||||
|
||||
2026-10-15:接入微信静默登录与真实账户读取后,`pnpm check` 的 20 项常规测试和独立 `pnpm test:db` 的 8 项检查通过;构建产物真实启动/数据库连接通过。详细边界与记录见 [服务端登录接入说明](./服务端登录接入说明.md)。
|
||||
|
||||
基于 [NestJS Swagger 文档](https://docs.nestjs.com/openapi/introduction) 的运行时文档生成方式及 [ValidationPipe 文档](https://docs.nestjs.com/techniques/validation) 的 DTO 校验机制实现;依赖锁定兼容当前 NestJS 11 的具体版本。
|
||||
|
||||
@@ -133,3 +133,7 @@ worksheet.is_new 暂保留兼容:新接口以时间派生为准,旧接口仅
|
||||
列表默认仅功能,资源/all 通过显式 target 选择;返回目标类型及资源独立预览。取消精确定位收藏记录或资源 ID,旧按功能取消不得删除内容收藏。资源缺失或下架只使详情不可用,内容 ID 不清空,仍可取消。注销清理两种收藏,仅功能关系贡献影响功能 likes。
|
||||
|
||||
数据库不强制资源外键以保留历史,发布内容不允许重新归属另一 worksheet。需要收藏某次算法生成结果、保存用户涂色进度或精确结果快照时另行设计;当前资源收藏只表示选中的完整底图内容。
|
||||
|
||||
## 发布可见性补充(2026-10-16)
|
||||
|
||||
沿用 worksheets 与 worksheet_resources 的 draft/active/hidden,不新增布尔上线字段。categories 增加同名 status,新建默认草稿;上级未上线会阻止下级公开读取,不覆盖下级状态。管理预览、上下架、首发时间及快照失效已实现,资源公开读取/生成/下载仍待后续接口接入相同策略。详见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"source": "database_export-_oMVCNYtUGVT.json",
|
||||
"sourceSha256": "02a5a2d26c1801e7e77e8dc2449aebd5b74e11d49a358ed957f3cd11ca290d11",
|
||||
"requestId": "categories-import-1a32b65c-194b-442b-b97c-c3759aa521be",
|
||||
"dryRun": false,
|
||||
"batchAt": "2026-10-15T05:35:43.575Z",
|
||||
"total": 6,
|
||||
"inserted": 6,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"missingCreatedAt": 6,
|
||||
"missingUpdatedAt": 6,
|
||||
"recoveredFromDatabase": true,
|
||||
"note": "导入事务成功,初次保存报告的相对路径无效;此报告依据已提交的分类时间、同批审计及源文件哈希补记。",
|
||||
"changes": [
|
||||
{ "id": "math", "action": "insert" },
|
||||
{ "id": "puzzle", "action": "insert" },
|
||||
{ "id": "pinyin", "action": "insert" },
|
||||
{ "id": "chinese", "action": "insert" },
|
||||
{ "id": "english", "action": "insert" },
|
||||
{ "id": "craft", "action": "insert" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"source": "database_export-_oMVCNYtUGVT.json",
|
||||
"sourceSha256": "02a5a2d26c1801e7e77e8dc2449aebd5b74e11d49a358ed957f3cd11ca290d11",
|
||||
"requestId": "categories-import-d11d1569-947b-4000-9fea-4e86acc45e44",
|
||||
"dryRun": false,
|
||||
"batchAt": "2026-10-15T05:38:17.482Z",
|
||||
"total": 6,
|
||||
"inserted": 0,
|
||||
"updated": 0,
|
||||
"unchanged": 6,
|
||||
"missingCreatedAt": 6,
|
||||
"missingUpdatedAt": 6,
|
||||
"changes": [
|
||||
{
|
||||
"id": "math",
|
||||
"action": "unchanged"
|
||||
},
|
||||
{
|
||||
"id": "puzzle",
|
||||
"action": "unchanged"
|
||||
},
|
||||
{
|
||||
"id": "pinyin",
|
||||
"action": "unchanged"
|
||||
},
|
||||
{
|
||||
"id": "chinese",
|
||||
"action": "unchanged"
|
||||
},
|
||||
{
|
||||
"id": "english",
|
||||
"action": "unchanged"
|
||||
},
|
||||
{
|
||||
"id": "craft",
|
||||
"action": "unchanged"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
{
|
||||
"source": "worksheets.json",
|
||||
"sourceSha256": "184e357f25c3c32de76d5f417315fa831e9022d24c8723d92e7bad2e77707150",
|
||||
"requestId": "worksheets-import-ce45ffac-2272-424e-acfd-1a93a6dd4379",
|
||||
"dryRun": true,
|
||||
"mode": "metadata-only",
|
||||
"batchAt": "2026-10-16T05:59:12.184Z",
|
||||
"total": 51,
|
||||
"inserted": 51,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"isolated": 0,
|
||||
"assetsInserted": 51,
|
||||
"assetsReused": 0,
|
||||
"pendingPreviews": 51,
|
||||
"readyPreviews": 0,
|
||||
"readyForPublicUse": false,
|
||||
"gradeRelationsInserted": 0,
|
||||
"warnings": [
|
||||
{
|
||||
"id": "break-ten",
|
||||
"field": "grade",
|
||||
"reason": "旧兼容值超出新年级范围;原样保留,不写 worksheet_grades,须人工审核",
|
||||
"originalValue": 23
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"field": "downloads_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"field": "likes_seed",
|
||||
"reason": "源字段缺失,使用数据库约定默认值 0"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"downloads": "4203",
|
||||
"likes": "217",
|
||||
"downloadsSeed": "629",
|
||||
"likesSeed": "666"
|
||||
},
|
||||
"changes": [
|
||||
{
|
||||
"id": "letter-tracing-daily-checkin",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ed9bd61247311b9e31222b98d768e50cbecd45be290eea2c09f869687bcc47a3",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-single",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_292ec2303d74360769ca5ff9e3c2651bc27862c770c007320ba2d2d3e4a23424",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-upper-lower",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_f8edb05d6280ea86cc9b16bc097612621dd8105e8c2fd47b9dc704c80f7331b1",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-case-pairing",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_924b82c9ff669c42a7567a89554dcbc0fd2c03e37746f9e802715bc056b8ee32",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-two-column",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_fa04443ab3c32112e76453578024a507bfa34ded181eb43e81d9b37d6a129db5",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-three",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ec7592fc601ff5304b2c1fecea748f921e8937990303732b27fa83943c40c55b",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "letter-tracing-half",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_aa75a233bec6e5a7bf928fae6ffc66cade0d997774c3b0d2d316ee60aaa6e6b6",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-find",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_1a26a050bd297bd83e9682f1c378f78b6f9ee877ecc48b289225c0b51737645e",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "counting-matching",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_cc9d75451a0ee4da7a04e664bd80d8ab9eda02f6fdb3cdd46eef8d0a071e4c54",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-coloring",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_3d9edf7690ffd34ab68ce3d2a60833fcba04b9c02c09c70fa1209f49a1ea66cd",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "addition-5",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_940163cc8631cf219c6776fb16b642782a9cec276faf36c6e82fd9fd88256abe",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "missing-number",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_204ad59ba9d1af25e066b5ff11fb46740a7d442f6e3676b95ddc7b8b0163f041",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "compare",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_3db2d8a91fd6c7ac0b1dca93d66c416059338e699ccc71c096283d869abf09d2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "counting-select",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_c977fd235d232c10e81b9a27f008c8f9d06ebe288ad87172b1f4ede94e113553",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-decompose",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_897d397176b357eacb05d1e0252a1dd8e6ba720f5e16ac0154714648f631a9fc",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-decompose-20",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_f281beff18b2d16a2752f1b7a89b3aec66d7ab4e7620c2879926d619045a7a53",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-sort",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ab742f76c9a602ddc62fea8cafa01aa63d11dacceaccb87e09447601eb6a86d1",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "number-object-match",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_377bd7c09c52eb09275b06ee9502badb8877af8fb26f64db8fbb89074adb234d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "make-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_aa0e070da59aab8cdd4375815e0ff15cbe110846836d81bcc9bc193f3a9f8100",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "break-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_28204937f027aa9c1021538876302094d1537af88ff7184db8a510f8b46fbc92",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "flat-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_bf0d27f22c0ecf5b3c5881a14d82e175463e7beb0e607d331466380ed3292013",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "borrow-ten",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_6da16aac3c0777b40fcd29199dcce4723d290846c8c2334ccd25561456325a3e",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "one-digit-addition",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_79de07b8e1e2b0aeedcc69c1e194b053c448bc470d7a258acd51108961f41671",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-addition",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_1e14f4accf739b96848de4f641f11ddd87ba726e372faae4f6718682d32ef680",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-subtraction",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_45fd3e7f901b2482fad78b4a6204ee86f163a5329d19ee8ae5b07ccb1faff6ee",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "practice-mixed",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_4d7e439d13e3ff1e6dc9777d9c7963517620ccc26e26e4a8d18c977187ca33cd",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "multiplication-table",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_f611cfaaaf53b070ee40cb80ccba2b9f2594535c78ee9230e9b1145af7bf7d60",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "color-shape-match",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_739a548debba7949768438bd7f4424c772595316609442d40b72db63e60d3715",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "shape-symbol",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_0cccc724e9b6cd7d291aca127adda619889c4575ca290edd8350ed5e70822bb2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "position-coloring",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_cca47918d46442771054bd34f96f5325b6eb3817fb82812e4cc8ece1b7373fa6",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "color-pattern",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_e0d545a5003655ca755efc2dfb39d4ece749ce71d7757b6e5fa213cfc0117f0f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "match-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_9031059317b1f8717659484b2634edacf25f94712e84bc13415302af7bd6d78c",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "line-recognition",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_a617caadf17a862293016607d7eeb57e21ce0d0bf8f59f4e05a58ac6f843be4b",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-reasoning",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_b63cba1e2ca40ea06b20291708c91e3be43b1d0e01a74cdf54ac66c66317a29f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "code-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_364bd44e33e8595da9305168176f7266474f2fe9e121c548ba5268e297969f40",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "dot-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_ef7a884a30627fdd9349e81b4cb7662fc5048b937e13a5c00ad02122f887f82a",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-drawing-5x5",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_16d270e7e3ad234805b1a6b3af5507d56f6c8363a79985ebe7d365d8fc88001d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "grid-drawing-3x3",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_9b4f5d0cd417cc12807d92b9dd674e08054685514e9188d87d43dee41b46796c",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-coloring-grid",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_184aa7580b91f411717bbe6e3f4c580ceed9e604da3b75031ce29c74250caaa9",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-coloring-find",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_0fec84f97f2a04b734c5839a2b396206800375fd1d47782bb14b84f2715af334",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_2270e1d234135860d31689591b7b9220fe9fc84c37207d50c39a611daf94f52f",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_6a32f38f23e609a289f2f4da36a3eeafad9e424d2b8cf4a654c61519c9ad8e79",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-daily",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_47413e9d6c96556f94fdcfa5dd1517d304575170998b706d73af516e27f29257",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-tracing-v2",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_1dab372e03f70063d6a2269d34c61d9c922b15f6dd2f67315e004255f5d765eb",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pinyin-dictation-v2",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_57195cf48980b0028475b986d1ca3aea32b98ae63e4a8541d6657cf6a653fcd3",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-sheet",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_2303428a8e71f057a9bdd87817d300fce5e64b5b4c813e53cfd13de2ab46e2b2",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "hanzi-daily-checkin",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_bb81b0f638bfb8a37bd48d6d41cef3528918413f1e6aa1e4cfca68769fc07c70",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "pen-control-mix",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_9306467297d403ec07f422e61227c2300faf5ef6e8e232d9eb1737b0a263309d",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "word-test-sheet",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_34e9636918372c0dd5fbfcb2f017f4ac700f40dd5553560bd9233320f6eeb3a9",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "clock-reading",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_dd2205e72b203854845ce228ac67a0fb4960bed4ea194ca207edbcf8365da979",
|
||||
"previewStatus": "pending"
|
||||
},
|
||||
{
|
||||
"id": "clock-connect",
|
||||
"action": "insert",
|
||||
"previewAssetId": "preview_7d8efbec45918618d2a8d36007342343844f9190ef6b0266f11cd6069616ccf8",
|
||||
"previewStatus": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 重建和旧上传/定时任务切换仍需分别落实。
|
||||
@@ -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 已注册全部新路由。
|
||||
@@ -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
@@ -2,15 +2,17 @@
|
||||
|
||||
> 数据库:PostgreSQL;ORM:Prisma。数据库选型已按用户确认更新。
|
||||
>
|
||||
> 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。
|
||||
> 状态:目标设计,本地开发数据库已配置;13 张业务表及初始配置已在本地创建;已接入 Prisma Schema 和迁移基线;线上集合、数据量和异常分布待 S0 盘点。
|
||||
>
|
||||
> 更新日期:2026-10-14。
|
||||
> 更新日期:2026-10-15(登录与 Prisma 实施状态更新,业务表结构不变)。
|
||||
>
|
||||
> 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。
|
||||
|
||||
已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md)。2026-10-12 验证并创建最初 10 张表及两条配置;2026-10-14 执行 004/005/006/007/008 后,本地共有 13 张表。最新空库基线与对应增量升级结构已在临时库对照通过,多资源、多年级、资源计数及两种收藏约束、旧数据保留验证通过,临时库已清理;尚未创建 Prisma Schema 或导入线上数据。
|
||||
登录与鉴权的后续实施设计见 [用户鉴权与登录设计](./用户鉴权与登录设计.md):优先小程序,细化微信登录、JWT、401 恢复及注销,并说明未来 Web 会话与跨端身份的演进;当前无需调整本文 13 张表。
|
||||
|
||||
本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 13 张表已在本地创建,业务 API 尚未实现。
|
||||
已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md)。2026-10-12 验证并创建最初 10 张表及两条配置;2026-10-14 执行 004/005/006/007/008 后,本地共有 13 张表。最新空库基线与对应增量升级结构已在临时库对照通过,多资源、多年级、资源计数及两种收藏约束、旧数据保留验证通过,临时库已清理;2026-10-15 已接入 Prisma Schema 和迁移基线,尚未导入线上数据。
|
||||
|
||||
本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 13 张表已在本地创建,服务端微信登录、本人读取和账户鉴权已实现,其他业务 API 待接入。
|
||||
|
||||
## 1. 范围与依据
|
||||
|
||||
@@ -109,6 +111,7 @@ erDiagram
|
||||
|
||||
| 数据库列 / Prisma 字段 | 类型 | 可空 / 默认 | 说明 |
|
||||
| --------------------------- | -------------- | ----------- | ------------------------------------------------------------------ |
|
||||
| status | varchar(16) | 否 / draft | CHECK:draft、active、hidden;公开可见还要求全部上级 active |
|
||||
| id | varchar(128) | 否 | 原分类 ID,如 math、chinese;不重命名 |
|
||||
| name | varchar(100) | 否 | 分类名 |
|
||||
| icon | varchar(1024) | 否 | 保留图标名、字符等非文件内容;若是文件则转为稳定资产引用,接口解析 |
|
||||
@@ -121,7 +124,9 @@ erDiagram
|
||||
|
||||
主键 id;索引 `(parent_id, sort_order, id)`;CHECK `parent_id IS NULL OR parent_id <> id`。父分类必须存在,导入按树层级排序;多层循环不能靠上述 CHECK 判断。删除被题型或子分类引用的分类返回冲突。
|
||||
|
||||
分类负责数学、语文、手工等内容方向,年级是独立适用维度,不把“一年级数学”“二年级数学”复制为分类,也不把年级放入 categories.parent_id。Web 的“年级 → 分类”菜单是组合查询,不代表数据库父子关系。年级名称及展示顺序先使用固定配置,不新增 grades/菜单表。categories 表结构保持不变。
|
||||
分类负责数学、语文、手工等内容方向,年级是独立适用维度,不把“一年级数学”“二年级数学”复制为分类,也不把年级放入 categories.parent_id。Web 的“年级 → 分类”菜单是组合查询,不代表数据库父子关系。年级名称及展示顺序先使用固定配置,不新增 grades/菜单表。分类增加发布状态,不增加年级菜单字段。
|
||||
|
||||
分类、题型和资源统一使用三态发布规则;上级未上线时下级也不可公开读取,隐藏上级不改写下级自身状态。管理预览及快照失效规则见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||
|
||||
### 3.3 worksheets — 题型
|
||||
|
||||
@@ -443,7 +448,7 @@ ORDER BY w.sort_order, w.updated_at DESC, w.id;
|
||||
|
||||
登录使用服务端配置的单一小程序 AppID,按微信返回的可信 `openid` 查找用户,沿用老 ID。首次并发登录以唯一约束兜底,冲突后读取已创建用户。首期 JWT 使用 userId + authVersion;每次鉴权核对用户存在、active 和版本,role 以当前服务端数据为准。
|
||||
|
||||
`auth_version` 是账户级登录凭证版本,默认 1。JWT 签发时写入相同的 `authVersion`,每次访问受保护接口时与数据库当前值比较;不一致返回 401,要求重新登录。需要强制全部设备退出或撤销已签发凭证时,由业务服务在事务中递增版本;普通登录和资料更新不递增,避免其他设备无故退出。它撤销的是该用户所有旧版本 token,不用于单设备退出。账户禁用及角色调整仍以数据库当前 status/role 校验,版本不能替代权限检查。现有守卫已实现版本比较,真实 AccountReader 与主动递增业务尚待接入。
|
||||
`auth_version` 是账户级登录凭证版本,默认 1。JWT 签发时写入相同的 `authVersion`,每次访问受保护接口时与数据库当前值比较;不一致返回 401,要求重新登录。需要强制全部设备退出或撤销已签发凭证时,由业务服务在事务中递增版本;普通登录和资料更新不递增,避免其他设备无故退出。它撤销的是该用户所有旧版本 token,不用于单设备退出。账户禁用及角色调整仍以数据库当前 status/role 校验,版本不能替代权限检查。现有守卫与真实 AccountReader 已接入版本比较,主动递增版本的业务服务仍待接入。
|
||||
|
||||
涉及用户的写事务先锁用户行并核对状态,再锁题型行、需要时锁内容行;多个题型及同层内容按 ID 固定顺序加锁。收藏、下载、资料修改和注销遵守相同锁顺序,避免注销与新写入交叉。
|
||||
|
||||
@@ -494,7 +499,7 @@ DO NOTHING RETURNING id;
|
||||
|
||||
真实计数/seed 修订使用明确管理动作,锁题型行、记录前后值和 reason、同事务写审计。批量操作每项独立事务,保留逐项结果;dryRun 做同样校验且不写入。不将整批部分失败伪装为完全成功。
|
||||
|
||||
展示 likes/downloads = 原始值 + 对应 seed;管理 rawStats 不加 seed。保持现有首页 Top5、分龄 Top3 下载 + Top3 收藏再全量下载补齐的种子参与规则。分龄严格采用 `ageMin == 下限 AND ageMax >= 上限`;分龄并列沿用 updatedAt DESC,再以 id 稳定排序。首页并列新增稳定 id 排序,并在固定样本验收中记录差异。
|
||||
展示 likes/downloads = 原始值 + 对应 seed;管理 rawStats 不加 seed。保持现有首页 Top5、分龄 Top3 下载 + Top3 收藏再全量下载补齐的种子参与规则。旧分龄推荐沿用 `ageMin == 下限 AND ageMax >= 上限`(尚未迁移);2026-10-16 用户确认普通 `/worksheets?ageBand=...` 筛选改为 `ageMin <= 下限 AND ageMax >= 上限`,只返回完整覆盖所选年龄段的题型;分龄并列沿用 updatedAt DESC,再以 id 稳定排序。首页并列新增稳定 id 排序,并在固定样本验收中记录差异。
|
||||
|
||||
### 5.4.1 新品、运营推荐与两层下载统计
|
||||
|
||||
@@ -516,7 +521,7 @@ worksheets 表示功能,worksheet_resources 表示可选内容。新增“小
|
||||
|
||||
首期题型量小,可在短事务中完成查询与 JSON 构建;若构建移出事务,提交前比较 expectedVersion,冲突必须重新读取重建,不能覆盖新配置。自动刷新只覆盖 featured/hot,保留 sections 等手动配置。home/all 构建清 category/age,category 分支只重建 category;原调用中空数组被当作“沿用现有配置”的行为由兼容层保留。
|
||||
|
||||
公共 API 直接读取 DB 配置。若兼容期仍发布静态 JSON,提交后用 `page_config_publish` 执行记录重试,文件采用按版本不可变对象 key;publishedVersion 只在对应版本上传成功后前移,防止旧发布覆盖新版本。签名 URL 不进入公共静态配置,私有头像也不进入公共页面 JSON。静态发布失败不回滚已经提交的业务配置,保留上一可用文件并明确发布状态。
|
||||
公共 API 直接读取 DB 配置。若兼容期仍发布静态 JSON,提交后用 `page_config_publish` 执行记录重试,文件采用按版本不可变对象 key;publishedVersion 只在对应版本上传成功后前移,防止旧发布覆盖新版本。签名 URL 不进入公共静态配置,私有头像也不进入公共页面 JSON。静态发布失败不回滚已经提交的业务配置,保留上一可用文件并明确发布状态。内容下架撤销属于例外:必须立即取消旧展示快照的可用标记,不能以“上一可用文件”继续交付下架内容;新后端当前会清空数据库快照,外部分发及客户端缓存撤销在切换前补齐。
|
||||
|
||||
## 6. CloudBase 导入与审计
|
||||
|
||||
@@ -594,7 +599,7 @@ S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字
|
||||
|
||||
## 9. SQL 与实际结构最终核查
|
||||
|
||||
核查日期:2026-10-14。当前已确认范围为 13 张表、135 个字段;本次核查未新增表或业务字段。
|
||||
核查日期:2026-10-14。当时已确认范围为 13 张表、135 个字段;该次核查未新增表或业务字段。2026-10-16 新增 categories.status 后为 136 个字段,升级及发布规则见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||
|
||||
| 核查项目 | 结果 |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -607,4 +612,4 @@ S0 仍需确认:全部线上集合及配置文档、来源 AppID、历史字
|
||||
|
||||
临时核查数据库全部清理;开发库只读比对,未写入样本。SQL 无结构差异需修复;已同步过时的 Prisma 目录说明、统一执行路径,修正关系图中的必填封面/可缺失历史关联及资产清理引用范围。
|
||||
|
||||
数据库约束通过不等于业务迁移完成。资源归属、当前发布状态/管理员权限、参数白名单、首次发布时间只写一次、动态新品判断、计数与审计事务、任务锁和租约仍由后续服务端实现;Prisma Schema 和迁移历史尚未接入。旧 grade/is_new/total_downloads 等字段继续按前述兼容规则保留,不作为新业务权威来源。
|
||||
数据库约束通过不等于业务迁移完成。资源归属、当前发布状态/管理员权限、参数白名单、首次发布时间只写一次、动态新品判断、计数与审计事务、任务锁和租约仍由后续服务端实现;Prisma Schema 和迁移基线已接入。旧 grade/is_new/total_downloads 等字段继续按前述兼容规则保留,不作为新业务权威来源。
|
||||
|
||||
@@ -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 张业务表及两条初始配置保留,开发库没有写入测试用户,所有临时测试/参考库已删除。真实密钥未进入可提交文件。
|
||||
@@ -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
@@ -1,11 +1,17 @@
|
||||
# Doodle Mini 云服务端迁移任务清单
|
||||
|
||||
更新日期:2026-10-13。依据当前代码、数据库设计及《Doodle Mini 现有功能迁移到云服务端方案》整理。
|
||||
更新日期:2026-10-15。依据当前代码、数据库设计及《Doodle Mini 现有功能迁移到云服务端方案》整理。
|
||||
|
||||
目标:把现有业务后端、数据、文件和定时任务迁出 CloudBase,由 doodle-store 提供统一 API、PostgreSQL 主数据库和七牛资源存储;doodle-mini 保留现有生成、绘制、保存、分享及广告能力。
|
||||
|
||||
登录与鉴权实施细节见 [用户鉴权与登录设计](./用户鉴权与登录设计.md)(2026-10-15 设计稿);服务端登录和真实账户检查已实现,注销与小程序适配仍待实施;具体边界见 [服务端登录接入说明](./服务端登录接入说明.md)。
|
||||
|
||||
标记说明:已勾选表示已有实现及记录的验证;未勾选表示待完成。Server 能启动、数据库已建表,不代表业务迁移已经完成。清单不承诺具体工期,外部权限、备案和微信审核单独记录。
|
||||
|
||||
2026-10-16 补充:51 条题型已按原 ID/计数/时间导入本地,51 条预览资产仅登记 pending,尚未迁移文件或新增题型 HTTP API。旧 grade=23 原样保留并报告,未自动生成适用年级关系。此次本地元数据暂存不代表 S2-P01、S2-F03 或 S3-D03~D05 全部完成,详见 [题型数据导入说明](./题型数据导入说明.md)。
|
||||
|
||||
2026-10-16 查询补充:题型公开列表/详情与分类、审核年级、年龄段、Tag、最新/热门、运营/新品标记筛选已实现;预览未就绪按用户确认返回 null,年级关系仍待审核,见 [题型查询接口说明](./题型查询接口说明.md)。这不代表图片迁移、分龄推荐混排或题型管理写接口已经完成。
|
||||
|
||||
## 一、当前已完成
|
||||
|
||||
- [x] 建立独立 doodle-store pnpm 工作区、NestJS 11 Server、公共 contracts 包及构建检查脚本;小程序保持独立项目。
|
||||
@@ -13,8 +19,8 @@
|
||||
- [x] 启动本地 PostgreSQL 18.6 Docker 开发环境,地址 127.0.0.1:5434,数据库 doodle_dev。
|
||||
- [x] 生成并执行 10 张表的建表 SQL、索引、约束及两条初始配置;临时库 19 项约束检查通过。
|
||||
- [x] 接入 Server JSON 日志、脱敏、requestId、bodyParser、/api/v1 前缀、CORS、Helmet、DTO 校验、统一错误处理及 Swagger。
|
||||
- [x] 接入 JWT 签发/验签、默认鉴权守卫、公开接口和角色声明;保留账户读取接入点。账户读取当前为拒绝放行的占位实现,真实登录未完成。
|
||||
- [x] Server 完整检查及 12 项基础/集成测试通过,构建入口真实启动检查通过;代码统一 4 空格并补充关键中文注释。
|
||||
- [x] 接入 JWT 签发/验签、默认鉴权守卫、公开接口和角色声明;已接入 Prisma 账户读取、微信静默登录和安全认证错误原因。
|
||||
- [x] 2026-10-15 Server 完整检查、20 项常规测试及独立数据库 8 项检查通过,构建入口和真实数据库连接验证通过;微信真实 code 联调仍待小程序接入。
|
||||
|
||||
## 二、实施原则与范围
|
||||
|
||||
@@ -38,12 +44,12 @@
|
||||
|
||||
## 四、S1:Prisma 与首条业务链路
|
||||
|
||||
- [ ] S1-01:安装并锁定 Prisma 兼容版本,建立覆盖 10 张表的 schema,保持原字符串主键、C 排序规则及现有约束。
|
||||
- [ ] S1-02:将已执行的 SQL 登记为迁移基线,补齐数据库迁移流程;不得在已有表的开发库重复执行初始 DDL。
|
||||
- [ ] S1-03:实现 PrismaService、数据库连接/关闭和独立就绪检查;配置真实开发连接串及受控连接池。
|
||||
- [ ] S1-04:实现真实 AccountReader,查询用户状态、authVersion 和 role;替换当前有效 token 仍返回 503 的占位实现。
|
||||
- [x] S1-01:锁定 Prisma 7.10.0 与 PostgreSQL adapter,Schema 覆盖 13 张表;SQL 基线保留 C 排序规则、CHECK 及部分唯一索引。
|
||||
- [x] S1-02:将已执行的 SQL 登记为迁移基线,补齐数据库迁移流程;不得在已有表的开发库重复执行初始 DDL。
|
||||
- [x] S1-03:实现 PrismaService、数据库连接/关闭和独立就绪检查;配置真实开发连接串及受控连接池。
|
||||
- [x] S1-04:实现真实 AccountReader,查询用户状态、authVersion 和 role;替换当前有效 token 仍返回 503 的占位实现。
|
||||
- [ ] S1-05:落实公开/本人/管理接口分类,服务层强制校验数据归属;管理员角色不自动开放全体用户私有数据。
|
||||
- [ ] S1-06:实现 POST /auth/wechat/login:微信 code2Session、按可信 OpenID 查找并沿用老 userId、并发首次登录唯一性、JWT 签发及活跃时间维护。
|
||||
- [x] S1-06:实现 POST /auth/wechat/login:code2Session、start/resume、老 userId 延续、并发首次登录、JWT 及活跃时间;真实数据库自动测试通过,真实微信 code 联调仍在 S3。
|
||||
- [ ] S1-07:实现最小题型公开读取,以及收藏、成功下载记录的事务纵向样板;验证重复请求只产生一次关系或一次计数。
|
||||
- [ ] S1-08:完善公开 DTO、分页/总量、错误码与 requestId 契约;新接口 id 与旧端 _id/path/previewImg 兼容转换明确记录。
|
||||
|
||||
@@ -53,7 +59,7 @@
|
||||
|
||||
### 5.1 用户、收藏与下载
|
||||
|
||||
- [ ] S2-U01:实现 GET /me、PATCH /me/profile、DELETE /me/profile;清资料保留收藏和历史。
|
||||
- [ ] S2-U01:GET /me 已实现;PATCH /me/profile、DELETE /me/profile 待实现,清资料保留收藏和历史。
|
||||
- [ ] S2-U02:实现 DELETE /me 注销:事务清理收藏及相关 likes、下载历史、用户资料和身份;旧凭证失效,私有文件可重试清理。
|
||||
- [ ] S2-U03:补齐收藏添加/取消/分页列表/数量/单张检查/批量检查;关系与 likes 同事务,仅真实增删影响计数。
|
||||
- [ ] S2-U04:补齐下载记录添加/分页列表/数量;要求新版 exportId,日志与 downloads 同事务;历史 NULL 事件保留。
|
||||
@@ -61,7 +67,7 @@
|
||||
|
||||
### 5.2 公开内容
|
||||
|
||||
- [ ] S2-P01:实现题型列表/详情及分类列表;公开接口强制只返回 active,不允许客户端参数绕过状态限制。
|
||||
- [x] S2-P01:分类与题型公开列表/详情已实现;分类及题型统一要求自身与祖先上线,支持组合筛选、分页和安全预览解析,未就绪预览按用户确认返回 null。分类新增 status,管理员可独立预览与上下架。
|
||||
- [ ] S2-P02:实现分龄推荐,保留 5 个年龄段、现有年龄匹配、Top3 下载+Top3 收藏、去重和不足补齐规则。
|
||||
- [ ] S2-P03:区分真实计数与 seed 展示值;推荐保留种子参与规则,排序并列明确稳定规则并与固定样本核对。
|
||||
- [ ] S2-P04:提供公共首页配置接口,保留 home/category/age/version/updatedAt 及 previewImg 字段形状。
|
||||
@@ -71,10 +77,10 @@
|
||||
- [ ] S2-A01:确定管理后台登录方式及凭证方案;配置首个管理员,默认用户不能注册或自升管理员。
|
||||
- [ ] S2-A02:落实后台独立登录入口或明确的管理员授权流程:身份验证后校验 active+admin,非管理员不能获得后台登录态;现有小程序管理入口同样受保护。
|
||||
- [ ] S2-A03:全部管理 API 服务端检查管理员身份;用户降权/禁用后立即失去权限,不能只靠前端隐藏按钮。
|
||||
- [ ] S2-A04:实现题型发布/重新发布/上下架/管理列表和分类状态数量;普通重发不清零真实计数,保留 createdAt/contentUpdatedAt/updatedAt 语义。
|
||||
- [ ] S2-A05:实现分类创建/更新,保留主键和父子关系,校验父分类及循环引用。
|
||||
- [ ] S2-A04:题型/资源管理列表、详情预览与状态上下架已实现;题型配置发布/重新构建及分类状态数量待实现;普通重发不清零真实计数,保留 createdAt/contentUpdatedAt/updatedAt 语义。
|
||||
- [x] S2-A05:实现管理员分类创建/更新/删除,保留主键和父子关系,事务锁串行校验父分类及循环引用;删除有子分类或题型引用时返回冲突,审计同事务。当前创建/更新支持文字/Emoji 图标,文件资产另见 S2-F04。
|
||||
- [ ] S2-A06:实现批量年龄/年级/tags/seed/统计修订,保留 dryRun、逐项结果和错误;真实/seed 修订记录操作者、原因及前后值。
|
||||
- [ ] S2-A07:管理修改与审计同事务;核实 worksheetsStatsUpdate 的旧调用需要,不向普通用户开放通用加数接口。
|
||||
- [ ] S2-A07:分类管理修改/迁移与审计已同事务;其他业务及 worksheetsStatsUpdate 旧调用核实待完成,不向普通用户开放通用加数接口。
|
||||
|
||||
### 5.4 七牛、首页与任务
|
||||
|
||||
@@ -155,6 +161,10 @@
|
||||
|
||||
## 九、下一步建议
|
||||
|
||||
优先推进 S0 线上盘点和 S1 Prisma/真实登录/题型读取/收藏下载事务的纵向样板。基础设施已齐备,当前首要技术阻塞是未接入真实数据库账户查询和业务模块。七牛资源准备可同步推进。
|
||||
Prisma、真实账户读取及服务端微信登录已接入;继续推进 S0 线上盘点、S1 题型读取及收藏下载事务样板,以及小程序体验版真实 code 联调。七牛资源准备可同步推进。
|
||||
|
||||
管理员登录方式、跨端账户绑定、完整后台页面和完整 Web 分别管理:管理员授权能力及现有管理 API 属于本次;完整后台/Web 页面和 Web 用户登录不作为现有小程序迁移的切换前置条件。
|
||||
|
||||
2026-10-16 发布控制:分类 status 增量迁移及本地升级完成;全部公开题型查询检查所属分类链,新增管理员题型/资源预览和状态修改,审计/首发时间/数据库快照失效原子提交。小程序仍使用旧云服务,其请求迁移、本地兜底取消及预拉取缓存撤销属于切换前置条件,不能将新后端验收视为现网隐藏已生效。见 [内容发布与可见性](./内容发布与可见性.md)。
|
||||
|
||||
2026-10-16 小程序分类页接入:全局 HTTP 配置支持 develop 本地/线上切换,trial/release 仅线上;后端缺省与本地实际端口改为 8800。分类页已读取公开分类、完整题型分页和点击详情,移除本地静态兜底,保留榜单与混合排序并安全比较字符串计数。首页预拉取、登录、收藏、下载及分享直达生成页仍待切换;9 项小程序测试(含真实本地 API)通过。
|
||||
|
||||
@@ -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)。七牛文件迁移与题型管理写接口仍待实施。
|
||||
@@ -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 参数格式,不改数据库或小程序云函数。旧分龄推荐接口尚未迁移,其匹配和榜单规则不属于本次普通列表变更。
|
||||
@@ -10,6 +10,7 @@ export default tseslint.config(
|
||||
'**/dist/**',
|
||||
'**/dist-test/**',
|
||||
'**/coverage/**',
|
||||
'**/src/generated/**',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
|
||||
+10
-2
@@ -45,10 +45,18 @@ POSTGRES_DB、POSTGRES_USER、POSTGRES_PASSWORD 只在空数据卷首次初始
|
||||
postgresql://doodle_dev:<URL编码后的本地密码>@127.0.0.1:5434/doodle_dev?schema=public
|
||||
```
|
||||
|
||||
根 `.env` 仅供 Compose;`apps/server/.env` 仅供服务端,当前 NestJS 骨架尚未读取 DATABASE_URL、接入 Prisma 或七牛。doodle_dev 已执行基线 SQL,原创建 10 张表及初始配置,2026-10-14 执行 004/005/006/007/008 后共有 13 张表;后续 Prisma schema/migrations 根据 [v3.5 数据库设计](../docs/v3.5/数据库设计文档.md) 实施。开发角色为此本地集群的初始化管理员,生产运行角色权限在部署时单独设置,不复制本地账号密码到云服务器。
|
||||
根 `.env` 仅供 Compose;`apps/server/.env` 仅供服务端,服务端已读取 DATABASE_URL 并接入 Prisma,七牛仍待实施。doodle_dev 已执行基线 SQL,原创建 10 张表及初始配置,2026-10-14 执行 004/005/006/007/008 后共有 13 张表;Prisma schema/migrations 已接入,根据 [v3.5 数据库设计](../docs/v3.5/数据库设计文档.md) 实施。开发角色为此本地集群的初始化管理员,生产运行角色权限在部署时单独设置,不复制本地账号密码到云服务器。
|
||||
|
||||
自动测试需要真实数据库时,使用同一**本地** PostgreSQL 实例内独立 `doodle_test` 数据库及连接串,测试初始化流程另行实施。测试不得对 doodle_dev 或生产库执行清空数据操作;目前尚未创建测试库。
|
||||
自动测试需要真实数据库时,使用同一**本地** PostgreSQL 实例内独立 `doodle_test` 数据库及连接串,当前 `pnpm test:db` 使用脚本创建的随机命名短期库,结束后自动删除,不创建常驻测试库。测试不得对 doodle_dev 或生产库执行清空数据操作。
|
||||
|
||||
云端首期仅常驻生产库,测试联调按需隔离。详细安排与七牛资源隔离见 [开发与部署环境方案](../docs/v3.5/开发与部署环境方案.md)。
|
||||
|
||||
建表 SQL 已生成并通过临时库验证,开发库已于 2026-10-12 完成初始化,其他空库执行步骤见 [SQL 执行说明](../apps/server/prisma/sql/README.md)。Docker 启动只创建空数据库,不会自动执行这些业务表脚本。
|
||||
|
||||
## 本地状态复核(2026-10-15)
|
||||
|
||||
- `doodle_dev` 已存在且容器健康,连接地址为 `127.0.0.1:5434`,PostgreSQL 18.6、UTF-8、C 排序规则、UTC。
|
||||
- 在独立临时参考库执行最新 002/003,对照开发库的表、字段定义、约束、索引及注释,全部一致;忽略字段物理顺序。共 13 张表、135 个字段、41 个索引;无需重新执行建表或增量 SQL。
|
||||
- `page_configs/current` 与 `settings/homeAutoRefresh` 均已初始化,版本为 0,自动刷新 enabled=true;其余 11 张表为空,尚未导入线上数据。
|
||||
- 已将服务端 `.env` 中 DATABASE_URL 的密码与现有本地数据库凭据对齐,验证宿主机端口可达及 TCP 密码连接成功;未输出密钥,保留其他服务端配置。
|
||||
- 开发库只读核对,未重建或清空;临时参考库已删除。随后已接入 Prisma Schema/基线及服务端登录接口;现有库只登记迁移记录,不重复执行建表。
|
||||
|
||||
+7
-2
@@ -21,8 +21,13 @@
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"test": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server test",
|
||||
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test"
|
||||
"test": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server test && pnpm --filter @doodle/asset-sync test",
|
||||
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test",
|
||||
"test:db": "pnpm --filter @doodle/contracts build && pnpm --filter @doodle/server test:db",
|
||||
"categories:import": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server categories:import",
|
||||
"worksheets:import": "pnpm --filter @doodle/server... run build && pnpm --filter @doodle/server worksheets:import",
|
||||
"assets:sync": "pnpm --filter @doodle/asset-sync sync",
|
||||
"assets:test": "pnpm --filter @doodle/asset-sync test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
reports/
|
||||
@@ -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)。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user