From 8827110f2fa2119b43116c4deb88b94b4f943522 Mon Sep 17 00:00:00 2001 From: R524809 Date: Fri, 16 Oct 2026 17:32:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E3=80=81worksheet=20=E6=8E=A5=E5=8F=A3=E5=BC=80=E5=8F=91?= =?UTF-8?q?=EF=BC=8C=E8=B5=84=E6=BA=90=E8=BF=81=E7=A7=BB=E5=88=B0=E4=B8=83?= =?UTF-8?q?=E7=89=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .prettierignore | 3 + README.md | 31 +- apps/server/.env.example | 14 +- apps/server/package.json | 21 +- apps/server/prisma.config.ts | 18 + apps/server/prisma/README.md | 29 +- .../migrations/0_v35_baseline/migration.sql | 419 +++++ .../migration.sql | 9 + .../prisma/migrations/migration_lock.toml | 1 + apps/server/prisma/schema.prisma | 293 ++++ apps/server/prisma/sql/002-create-tables.sql | 5 + .../sql/009-add-category-publication.sql | 9 + apps/server/prisma/sql/README.md | 10 +- apps/server/scripts/dev.mjs | 69 + apps/server/scripts/import-categories.mjs | 102 ++ apps/server/scripts/import-worksheets.mjs | 98 ++ apps/server/scripts/test-auth-db.mjs | 84 + apps/server/src/app.module.ts | 4 +- apps/server/src/app.setup.ts | 9 +- apps/server/src/auth/access-token.guard.ts | 21 +- apps/server/src/auth/account-policy.ts | 17 + apps/server/src/auth/account-reader.ts | 13 +- apps/server/src/auth/account.repository.ts | 152 ++ apps/server/src/auth/auth.module.ts | 22 +- .../src/auth/current-user.controller.ts | 25 + apps/server/src/auth/jwt-token.service.ts | 30 +- apps/server/src/auth/login-rate-limiter.ts | 51 + apps/server/src/auth/login.controller.ts | 35 + apps/server/src/auth/login.dto.ts | 23 + apps/server/src/auth/login.service.ts | 37 + apps/server/src/auth/wechat-client.ts | 85 + apps/server/src/categories/category-import.ts | 81 + apps/server/src/categories/category-input.ts | 121 ++ .../src/categories/category.controller.ts | 122 ++ apps/server/src/categories/category.dto.ts | 55 + apps/server/src/categories/category.module.ts | 15 + .../server/src/categories/category.service.ts | 430 +++++ apps/server/src/common/api-error.filter.ts | 15 + apps/server/src/common/api-exception.ts | 13 + apps/server/src/common/runtime-options.ts | 2 +- apps/server/src/config/server-config.ts | 76 +- apps/server/src/content/content-policy.ts | 78 + apps/server/src/content/content.controller.ts | 140 ++ apps/server/src/content/content.dto.ts | 100 ++ apps/server/src/content/preview-url.ts | 53 + .../server/src/content/publication.service.ts | 183 ++ .../src/content/resource-preview.service.ts | 116 ++ apps/server/src/database/database.module.ts | 11 + apps/server/src/database/prisma.service.ts | 35 + .../src/database/readiness.controller.ts | 25 + apps/server/src/imports/worksheet-import.ts | 484 ++++++ apps/server/src/main.ts | 13 + apps/server/src/modules/README.md | 10 +- .../src/worksheets/worksheet-query.dto.ts | 207 +++ .../src/worksheets/worksheet.controller.ts | 62 + .../server/src/worksheets/worksheet.module.ts | 22 + .../src/worksheets/worksheet.service.ts | 260 +++ apps/server/test/application.test.ts | 2 +- apps/server/test/auth-database.test.ts | 402 +++++ apps/server/test/category-database.test.ts | 731 ++++++++ apps/server/test/category-input.test.ts | 84 + .../test/content-publication-database.test.ts | 725 ++++++++ apps/server/test/login-rate-limiter.test.ts | 34 + apps/server/test/runtime-options.test.ts | 2 +- apps/server/test/server-config.test.ts | 111 +- apps/server/test/test-config.ts | 4 + apps/server/test/wechat-client.test.ts | 108 ++ apps/server/test/worksheet-database.test.ts | 523 ++++++ .../test/worksheet-import-database.test.ts | 194 +++ apps/server/test/worksheet-import.fixture.ts | 25 + apps/server/test/worksheet-import.test.ts | 62 + apps/server/test/worksheet-query.test.ts | 49 + docs/v3.5/Server基础设置.md | 24 +- docs/v3.5/Worksheet数据库设计说明.md | 4 + docs/v3.5/categories-import-2026-10-15.json | 23 + docs/v3.5/categories-reimport-2026-10-15.json | 39 + docs/v3.5/worksheets-dry-run-2026-10-16.json | 451 +++++ docs/v3.5/worksheets-import-2026-10-16.json | 451 +++++ docs/v3.5/worksheets-reimport-2026-10-16.json | 451 +++++ .../worksheets-verification-2026-10-16.json | 33 + docs/v3.5/云存储图片迁移七牛调研.md | 105 ++ docs/v3.5/内容发布与可见性.md | 58 + docs/v3.5/分类接口与数据导入.md | 99 ++ docs/v3.5/数据库设计文档.md | 25 +- docs/v3.5/服务端登录接入说明.md | 100 ++ docs/v3.5/用户鉴权与登录设计.md | 410 +++++ docs/v3.5/迁移任务清单.md | 38 +- docs/v3.5/题型数据导入说明.md | 58 + docs/v3.5/题型查询接口说明.md | 127 ++ eslint.config.mjs | 1 + infra/README.md | 12 +- package.json | 9 +- packages/asset-sync/.env.example | 17 + packages/asset-sync/.gitignore | 1 + packages/asset-sync/README.md | 107 ++ packages/asset-sync/package.json | 14 + packages/asset-sync/src/cli.mjs | 104 ++ packages/asset-sync/src/config.mjs | 126 ++ packages/asset-sync/src/files.mjs | 160 ++ packages/asset-sync/src/qiniu-client.mjs | 87 + packages/asset-sync/src/sync.mjs | 152 ++ packages/asset-sync/test/sync.test.mjs | 391 +++++ packages/contracts/README.md | 2 +- packages/contracts/src/index.ts | 162 ++ pnpm-lock.yaml | 1530 ++++++++++++++++- pnpm-workspace.yaml | 4 + 107 files changed, 12775 insertions(+), 125 deletions(-) create mode 100644 apps/server/prisma.config.ts create mode 100644 apps/server/prisma/migrations/0_v35_baseline/migration.sql create mode 100644 apps/server/prisma/migrations/20261016000000_category_publication/migration.sql create mode 100644 apps/server/prisma/migrations/migration_lock.toml create mode 100644 apps/server/prisma/schema.prisma create mode 100644 apps/server/prisma/sql/009-add-category-publication.sql create mode 100644 apps/server/scripts/dev.mjs create mode 100644 apps/server/scripts/import-categories.mjs create mode 100644 apps/server/scripts/import-worksheets.mjs create mode 100644 apps/server/scripts/test-auth-db.mjs create mode 100644 apps/server/src/auth/account-policy.ts create mode 100644 apps/server/src/auth/account.repository.ts create mode 100644 apps/server/src/auth/current-user.controller.ts create mode 100644 apps/server/src/auth/login-rate-limiter.ts create mode 100644 apps/server/src/auth/login.controller.ts create mode 100644 apps/server/src/auth/login.dto.ts create mode 100644 apps/server/src/auth/login.service.ts create mode 100644 apps/server/src/auth/wechat-client.ts create mode 100644 apps/server/src/categories/category-import.ts create mode 100644 apps/server/src/categories/category-input.ts create mode 100644 apps/server/src/categories/category.controller.ts create mode 100644 apps/server/src/categories/category.dto.ts create mode 100644 apps/server/src/categories/category.module.ts create mode 100644 apps/server/src/categories/category.service.ts create mode 100644 apps/server/src/common/api-exception.ts create mode 100644 apps/server/src/content/content-policy.ts create mode 100644 apps/server/src/content/content.controller.ts create mode 100644 apps/server/src/content/content.dto.ts create mode 100644 apps/server/src/content/preview-url.ts create mode 100644 apps/server/src/content/publication.service.ts create mode 100644 apps/server/src/content/resource-preview.service.ts create mode 100644 apps/server/src/database/database.module.ts create mode 100644 apps/server/src/database/prisma.service.ts create mode 100644 apps/server/src/database/readiness.controller.ts create mode 100644 apps/server/src/imports/worksheet-import.ts create mode 100644 apps/server/src/worksheets/worksheet-query.dto.ts create mode 100644 apps/server/src/worksheets/worksheet.controller.ts create mode 100644 apps/server/src/worksheets/worksheet.module.ts create mode 100644 apps/server/src/worksheets/worksheet.service.ts create mode 100644 apps/server/test/auth-database.test.ts create mode 100644 apps/server/test/category-database.test.ts create mode 100644 apps/server/test/category-input.test.ts create mode 100644 apps/server/test/content-publication-database.test.ts create mode 100644 apps/server/test/login-rate-limiter.test.ts create mode 100644 apps/server/test/wechat-client.test.ts create mode 100644 apps/server/test/worksheet-database.test.ts create mode 100644 apps/server/test/worksheet-import-database.test.ts create mode 100644 apps/server/test/worksheet-import.fixture.ts create mode 100644 apps/server/test/worksheet-import.test.ts create mode 100644 apps/server/test/worksheet-query.test.ts create mode 100644 docs/v3.5/categories-import-2026-10-15.json create mode 100644 docs/v3.5/categories-reimport-2026-10-15.json create mode 100644 docs/v3.5/worksheets-dry-run-2026-10-16.json create mode 100644 docs/v3.5/worksheets-import-2026-10-16.json create mode 100644 docs/v3.5/worksheets-reimport-2026-10-16.json create mode 100644 docs/v3.5/worksheets-verification-2026-10-16.json create mode 100644 docs/v3.5/云存储图片迁移七牛调研.md create mode 100644 docs/v3.5/内容发布与可见性.md create mode 100644 docs/v3.5/分类接口与数据导入.md create mode 100644 docs/v3.5/服务端登录接入说明.md create mode 100644 docs/v3.5/用户鉴权与登录设计.md create mode 100644 docs/v3.5/题型数据导入说明.md create mode 100644 docs/v3.5/题型查询接口说明.md create mode 100644 packages/asset-sync/.env.example create mode 100644 packages/asset-sync/.gitignore create mode 100644 packages/asset-sync/README.md create mode 100644 packages/asset-sync/package.json create mode 100644 packages/asset-sync/src/cli.mjs create mode 100644 packages/asset-sync/src/config.mjs create mode 100644 packages/asset-sync/src/files.mjs create mode 100644 packages/asset-sync/src/qiniu-client.mjs create mode 100644 packages/asset-sync/src/sync.mjs create mode 100644 packages/asset-sync/test/sync.test.mjs diff --git a/.gitignore b/.gitignore index 4360024..241f571 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist-test/ coverage/ *.tsbuildinfo *.log +/apps/server/src/generated/ .DS_Store .env .env.* diff --git a/.prettierignore b/.prettierignore index b8bcd44..5562223 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,6 @@ dist dist-test coverage pnpm-lock.yaml +apps/server/src/generated + +packages/asset-sync/reports diff --git a/README.md b/README.md index 4193d25..48dea4e 100644 --- a/README.md +++ b/README.md @@ -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 引用。 diff --git a/apps/server/.env.example b/apps/server/.env.example index b80bbfc..c76489c 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -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= diff --git a/apps/server/package.json b/apps/server/package.json index f8edade..61179e1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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" } } diff --git a/apps/server/prisma.config.ts b/apps/server/prisma.config.ts new file mode 100644 index 0000000..c40c602 --- /dev/null +++ b/apps/server/prisma.config.ts @@ -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 } } + : {}), +}); diff --git a/apps/server/prisma/README.md b/apps/server/prisma/README.md index db512c8..91e754e 100644 --- a/apps/server/prisma/README.md +++ b/apps/server/prisma/README.md @@ -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)。 diff --git a/apps/server/prisma/migrations/0_v35_baseline/migration.sql b/apps/server/prisma/migrations/0_v35_baseline/migration.sql new file mode 100644 index 0000000..59c60c2 --- /dev/null +++ b/apps/server/prisma/migrations/0_v35_baseline/migration.sql @@ -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; diff --git a/apps/server/prisma/migrations/20261016000000_category_publication/migration.sql b/apps/server/prisma/migrations/20261016000000_category_publication/migration.sql new file mode 100644 index 0000000..76dc3bf --- /dev/null +++ b/apps/server/prisma/migrations/20261016000000_category_publication/migration.sql @@ -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; diff --git a/apps/server/prisma/migrations/migration_lock.toml b/apps/server/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/apps/server/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma new file mode 100644 index 0000000..24413cd --- /dev/null +++ b/apps/server/prisma/schema.prisma @@ -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") +} diff --git a/apps/server/prisma/sql/002-create-tables.sql b/apps/server/prisma/sql/002-create-tables.sql index 86f123a..391e54a 100644 --- a/apps/server/prisma/sql/002-create-tables.sql +++ b/apps/server/prisma/sql/002-create-tables.sql @@ -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 ( diff --git a/apps/server/prisma/sql/009-add-category-publication.sql b/apps/server/prisma/sql/009-add-category-publication.sql new file mode 100644 index 0000000..76dc3bf --- /dev/null +++ b/apps/server/prisma/sql/009-add-category-publication.sql @@ -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; diff --git a/apps/server/prisma/sql/README.md b/apps/server/prisma/sql/README.md index 917205d..865bc39 100644 --- a/apps/server/prisma/sql/README.md +++ b/apps/server/prisma/sql/README.md @@ -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 个字段。新后端公开接口同时检查分类祖先状态,管理员状态修改使数据库页面快照失效。 diff --git a/apps/server/scripts/dev.mjs b/apps/server/scripts/dev.mjs new file mode 100644 index 0000000..b458308 --- /dev/null +++ b/apps/server/scripts/dev.mjs @@ -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); +}); diff --git a/apps/server/scripts/import-categories.mjs b/apps/server/scripts/import-categories.mjs new file mode 100644 index 0000000..b260db7 --- /dev/null +++ b/apps/server/scripts/import-categories.mjs @@ -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(); +} diff --git a/apps/server/scripts/import-worksheets.mjs b/apps/server/scripts/import-worksheets.mjs new file mode 100644 index 0000000..0eb92d0 --- /dev/null +++ b/apps/server/scripts/import-worksheets.mjs @@ -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(); +} diff --git a/apps/server/scripts/test-auth-db.mjs b/apps/server/scripts/test-auth-db.mjs new file mode 100644 index 0000000..da99ac6 --- /dev/null +++ b/apps/server/scripts/test-auth-db.mjs @@ -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(); +} diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index e60171c..2d6253b 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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 { diff --git a/apps/server/src/app.setup.ts b/apps/server/src/app.setup.ts index 04bacf7..757b367 100644 --- a/apps/server/src/app.setup.ts +++ b/apps/server/src/app.setup.ts @@ -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' }, diff --git a/apps/server/src/auth/access-token.guard.ts b/apps/server/src/auth/access-token.guard.ts index ba8f1aa..6d2a3d4 100644 --- a/apps/server/src/auth/access-token.guard.ts +++ b/apps/server/src/auth/access-token.guard.ts @@ -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, diff --git a/apps/server/src/auth/account-policy.ts b/apps/server/src/auth/account-policy.ts new file mode 100644 index 0000000..cdb4620 --- /dev/null +++ b/apps/server/src/auth/account-policy.ts @@ -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; +} diff --git a/apps/server/src/auth/account-reader.ts b/apps/server/src/auth/account-reader.ts index 3fd56e0..0009293 100644 --- a/apps/server/src/auth/account-reader.ts +++ b/apps/server/src/auth/account-reader.ts @@ -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; } - -@Injectable() -/** Prisma 账户查询接入前的占位实现:返回 503,确保受保护接口不会被提前放行。 */ -export class UnconfiguredAccountReader implements AccountReader { - async findById(): Promise { - // 后续 Prisma 用户模块替换此 provider;不能仅凭 JWT 放行账户数据接口。 - throw new ServiceUnavailableException(); - } -} diff --git a/apps/server/src/auth/account.repository.ts b/apps/server/src/auth/account.repository.ts new file mode 100644 index 0000000..81c2871 --- /dev/null +++ b/apps/server/src/auth/account.repository.ts @@ -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 { + 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>` + 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 { + 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(), + }; + } +} diff --git a/apps/server/src/auth/auth.module.ts b/apps/server/src/auth/auth.module.ts index 1984f9e..b7d7afb 100644 --- a/apps/server/src/auth/auth.module.ts +++ b/apps/server/src/auth/auth.module.ts @@ -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 {} diff --git a/apps/server/src/auth/current-user.controller.ts b/apps/server/src/auth/current-user.controller.ts new file mode 100644 index 0000000..fbd7816 --- /dev/null +++ b/apps/server/src/auth/current-user.controller.ts @@ -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> { + return { + code: 0, + message: 'ok', + data: await this.accounts.getProfile(user), + }; + } +} diff --git a/apps/server/src/auth/jwt-token.service.ts b/apps/server/src/auth/jwt-token.service.ts index 6183faf..898a66c 100644 --- a/apps/server/src/auth/jwt-token.service.ts +++ b/apps/server/src/auth/jwt-token.service.ts @@ -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> { + 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 { 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', + ); } } } diff --git a/apps/server/src/auth/login-rate-limiter.ts b/apps/server/src/auth/login-rate-limiter.ts new file mode 100644 index 0000000..015066a --- /dev/null +++ b/apps/server/src/auth/login-rate-limiter.ts @@ -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; + } +} diff --git a/apps/server/src/auth/login.controller.ts b/apps/server/src/auth/login.controller.ts new file mode 100644 index 0000000..ba180e6 --- /dev/null +++ b/apps/server/src/auth/login.controller.ts @@ -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> { + return { + code: 0, + message: 'ok', + data: await this.login.login(input, request.ip ?? 'unknown'), + }; + } +} diff --git a/apps/server/src/auth/login.dto.ts b/apps/server/src/auth/login.dto.ts new file mode 100644 index 0000000..bf26295 --- /dev/null +++ b/apps/server/src/auth/login.dto.ts @@ -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; +} diff --git a/apps/server/src/auth/login.service.ts b/apps/server/src/auth/login.service.ts new file mode 100644 index 0000000..bb5e036 --- /dev/null +++ b/apps/server/src/auth/login.service.ts @@ -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 { + 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, + }; + } +} diff --git a/apps/server/src/auth/wechat-client.ts b/apps/server/src/auth/wechat-client.ts new file mode 100644 index 0000000..add5d8a --- /dev/null +++ b/apps/server/src/auth/wechat-client.ts @@ -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 { + 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; + 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) + ); + } +} diff --git a/apps/server/src/categories/category-import.ts b/apps/server/src/categories/category-import.ts new file mode 100644 index 0000000..15fad39 --- /dev/null +++ b/apps/server/src/categories/category-import.ts @@ -0,0 +1,81 @@ +import { BadRequestException } from '@nestjs/common'; +import { + categoryKeys, + categoryObject, + normalizeCategory, + requireCategoryKeys, + type CategoryFields, +} from './category-input'; + +export interface CategoryImportRow extends Omit { + 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(); + 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 } : {}), + }; + }); +} diff --git a/apps/server/src/categories/category-input.ts b/apps/server/src/categories/category-input.ts new file mode 100644 index 0000000..f7a8f26 --- /dev/null +++ b/apps/server/src/categories/category-input.ts @@ -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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new BadRequestException('分类必须为对象'); + return value as Record; +} + +export function requireCategoryKeys( + value: Record, + 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, +): 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(); + const order: string[] = []; + for (const row of rows) { + let id: string | null = row.id; + const visiting = new Set(); + 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; +} diff --git a/apps/server/src/categories/category.controller.ts b/apps/server/src/categories/category.controller.ts new file mode 100644 index 0000000..59979af --- /dev/null +++ b/apps/server/src/categories/category.controller.ts @@ -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> { + return { code: 0, message: 'ok', data: await this.categories.list() }; + } + + @Public() + @Get(':id') + @ApiOperation({ summary: '分类详情', security: [] }) + async get(@Param('id') id: string): Promise> { + 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> { + return { + code: 0, + message: 'ok', + data: await this.categories.listForAdmin(), + }; + } + + @Get(':id') + @ApiOperation({ summary: '分类管理预览(管理员)' }) + async get(@Param('id') id: string): Promise> { + 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> { + 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> { + 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>> { + return { + code: 0, + message: 'ok', + data: await this.categories.remove(id, actor, request.requestId!), + }; + } +} diff --git a/apps/server/src/categories/category.dto.ts b/apps/server/src/categories/category.dto.ts new file mode 100644 index 0000000..baf84ff --- /dev/null +++ b/apps/server/src/categories/category.dto.ts @@ -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), +) {} diff --git a/apps/server/src/categories/category.module.ts b/apps/server/src/categories/category.module.ts new file mode 100644 index 0000000..ead36d2 --- /dev/null +++ b/apps/server/src/categories/category.module.ts @@ -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 {} diff --git a/apps/server/src/categories/category.service.ts b/apps/server/src/categories/category.service.ts new file mode 100644 index 0000000..0f28a75 --- /dev/null +++ b/apps/server/src/categories/category.service.ts @@ -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 { + return this.publicRows(); + } + + private async publicRows(id?: string): Promise { + const filter = + id === undefined ? Prisma.sql`TRUE` : Prisma.sql`id = ${id}`; + return this.run(async () => { + const rows = await this.database.client.$queryRaw` + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + // 此锁允许公开 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 { + 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(operation: () => Promise): Promise { + 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'); + } + } +} diff --git a/apps/server/src/common/api-error.filter.ts b/apps/server/src/common/api-error.filter.ts index fa6b2ec..3c2733d 100644 --- a/apps/server/src/common/api-error.filter.ts +++ b/apps/server/src/common/api-error.filter.ts @@ -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 = { @@ -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 } + : {}), }); } } diff --git a/apps/server/src/common/api-exception.ts b/apps/server/src/common/api-exception.ts new file mode 100644 index 0000000..9f4d480 --- /dev/null +++ b/apps/server/src/common/api-exception.ts @@ -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); + } +} diff --git a/apps/server/src/common/runtime-options.ts b/apps/server/src/common/runtime-options.ts index 51a9f2f..d629482 100644 --- a/apps/server/src/common/runtime-options.ts +++ b/apps/server/src/common/runtime-options.ts @@ -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(); diff --git a/apps/server/src/config/server-config.ts b/apps/server/src/config/server-config.ts index d05becf..398608a 100644 --- a/apps/server/src/config/server-config.ts +++ b/apps/server/src/config/server-config.ts @@ -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', diff --git a/apps/server/src/content/content-policy.ts b/apps/server/src/content/content-policy.ts new file mode 100644 index 0000000..7992fae --- /dev/null +++ b/apps/server/src/content/content-policy.ts @@ -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 { + 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 { + 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( + operation: () => Promise, +): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new ApiException(503, 'DATABASE_UNAVAILABLE'); + } +} diff --git a/apps/server/src/content/content.controller.ts b/apps/server/src/content/content.controller.ts new file mode 100644 index 0000000..bd799ea --- /dev/null +++ b/apps/server/src/content/content.controller.ts @@ -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> { + return { + code: 0, + message: 'ok', + data: await this.worksheets.listForAdmin(query), + }; + } + + @Get(':id') + @ApiOperation({ summary: '题型管理预览(管理员,包含草稿及有效可见性)' }) + async get(@Param('id') id: string): Promise> { + 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> { + 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> { + return { + code: 0, + message: 'ok', + data: await this.resources.list(query), + }; + } + + @Get(':id') + @ApiOperation({ summary: '资源管理预览(管理员,包含草稿及有效可见性)' }) + async get( + @Param('id') id: string, + ): Promise> { + 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> { + return { + code: 0, + message: 'ok', + data: await this.publication.update( + 'resource', + id, + input, + actor, + request.requestId!, + ), + }; + } +} diff --git a/apps/server/src/content/content.dto.ts b/apps/server/src/content/content.dto.ts new file mode 100644 index 0000000..744665e --- /dev/null +++ b/apps/server/src/content/content.dto.ts @@ -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), + }; +} diff --git a/apps/server/src/content/preview-url.ts b/apps/server/src/content/preview-url.ts new file mode 100644 index 0000000..92e6d92 --- /dev/null +++ b/apps/server/src/content/preview-url.ts @@ -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; +} diff --git a/apps/server/src/content/publication.service.ts b/apps/server/src/content/publication.service.ts new file mode 100644 index 0000000..8294dcd --- /dev/null +++ b/apps/server/src/content/publication.service.ts @@ -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 { + 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).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(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(), + }; + } +} diff --git a/apps/server/src/content/resource-preview.service.ts b/apps/server/src/content/resource-preview.service.ts new file mode 100644 index 0000000..df9029f --- /dev/null +++ b/apps/server/src/content/resource-preview.service.ts @@ -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 { + 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 { + 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(), + }; + } +} diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts new file mode 100644 index 0000000..fe51e5c --- /dev/null +++ b/apps/server/src/database/database.module.ts @@ -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 {} diff --git a/apps/server/src/database/prisma.service.ts b/apps/server/src/database/prisma.service.ts new file mode 100644 index 0000000..9d6893c --- /dev/null +++ b/apps/server/src/database/prisma.service.ts @@ -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 { + try { + await this.client.$queryRaw`SELECT 1`; + } catch { + throw new ApiException(503, 'DATABASE_UNAVAILABLE'); + } + } + + async onModuleDestroy(): Promise { + await this.client.$disconnect(); + } +} diff --git a/apps/server/src/database/readiness.controller.ts b/apps/server/src/database/readiness.controller.ts new file mode 100644 index 0000000..40e3a15 --- /dev/null +++ b/apps/server/src/database/readiness.controller.ts @@ -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' }, + }; + } +} diff --git a/apps/server/src/imports/worksheet-import.ts b/apps/server/src/imports/worksheet-import.ts new file mode 100644 index 0000000..e4ecc5d --- /dev/null +++ b/apps/server/src/imports/worksheet-import.ts @@ -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(); + 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; + 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; + 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'); + } + } +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 8ca3d3a..38d0446 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -23,12 +23,25 @@ export async function bootstrap(): Promise { 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; diff --git a/apps/server/src/modules/README.md b/apps/server/src/modules/README.md index c296075..b669b15 100644 --- a/apps/server/src/modules/README.md +++ b/apps/server/src/modules/README.md @@ -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)。 diff --git a/apps/server/src/worksheets/worksheet-query.dto.ts b/apps/server/src/worksheets/worksheet-query.dto.ts new file mode 100644 index 0000000..5855cc1 --- /dev/null +++ b/apps/server/src/worksheets/worksheet-query.dto.ts @@ -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'), + }; +} diff --git a/apps/server/src/worksheets/worksheet.controller.ts b/apps/server/src/worksheets/worksheet.controller.ts new file mode 100644 index 0000000..4fe630a --- /dev/null +++ b/apps/server/src/worksheets/worksheet.controller.ts @@ -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> { + 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> { + return { code: 0, message: 'ok', data: await this.worksheets.get(id) }; + } +} diff --git a/apps/server/src/worksheets/worksheet.module.ts b/apps/server/src/worksheets/worksheet.module.ts new file mode 100644 index 0000000..b7b3cbc --- /dev/null +++ b/apps/server/src/worksheets/worksheet.module.ts @@ -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 {} diff --git a/apps/server/src/worksheets/worksheet.service.ts b/apps/server/src/worksheets/worksheet.service.ts new file mode 100644 index 0000000..122aa2f --- /dev/null +++ b/apps/server/src/worksheets/worksheet.service.ts @@ -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 { + const query = parseWorksheetQuery(input); + return this.readPage(query, visibleWorksheet, (row) => + this.toPublic(row), + ); + } + + /** 仅管理控制器调用;公开 DTO 与管理 DTO 分离,不能由 query 提升权限。 */ + async listForAdmin( + input: AdminWorksheetQueryDto, + ): Promise { + 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( + 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>` + 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(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 { + return this.toPublic(await this.readOne(id, visibleWorksheet)); + } + + /** 管理预览只增加发布状态和有效可见性,不泄漏源文件或任意渲染配置。 */ + async getForAdmin(id: string): Promise { + return this.toAdmin(await this.readOne(id, Prisma.sql`TRUE`)); + } + + private async readOne( + id: string, + visibility: Prisma.Sql, + ): Promise { + 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, + }; + } +} diff --git a/apps/server/test/application.test.ts b/apps/server/test/application.test.ts index 67aca8c..2f6c493 100644 --- a/apps/server/test/application.test.ts +++ b/apps/server/test/application.test.ts @@ -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, diff --git a/apps/server/test/auth-database.test.ts b/apps/server/test/auth-database.test.ts new file mode 100644 index 0000000..6fc47ce --- /dev/null +++ b/apps/server/test/auth-database.test.ts @@ -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({ + 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) + .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)); + }, + ); + }, +); diff --git a/apps/server/test/category-database.test.ts b/apps/server/test/category-database.test.ts new file mode 100644 index 0000000..c5e7e4a --- /dev/null +++ b/apps/server/test/category-database.test.ts @@ -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({ + 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; + 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).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).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', + ), + ); + }, + ); + }, +); diff --git a/apps/server/test/category-input.test.ts b/apps/server/test/category-input.test.ts new file mode 100644 index 0000000..93f127d --- /dev/null +++ b/apps/server/test/category-input.test.ts @@ -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); +}); diff --git a/apps/server/test/content-publication-database.test.ts b/apps/server/test/content-publication-database.test.ts new file mode 100644 index 0000000..cc8ae97 --- /dev/null +++ b/apps/server/test/content-publication-database.test.ts @@ -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({ + 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(path: string, token?: string): Promise { + 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( + path: string, + status: string, + ): Promise { + 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>('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( + '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( + '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( + 'admin/worksheet-resources/pub-resource', + admin, + ) + ).visible, + true, + ); + await patch('admin/worksheets/pub-live/status', 'hidden'); + assert.equal( + ( + await data( + '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( + '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( + 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 }, + ); + }, + ); + }, +); diff --git a/apps/server/test/login-rate-limiter.test.ts b/apps/server/test/login-rate-limiter.test.ts new file mode 100644 index 0000000..8eb6adf --- /dev/null +++ b/apps/server/test/login-rate-limiter.test.ts @@ -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'); +}); diff --git a/apps/server/test/runtime-options.test.ts b/apps/server/test/runtime-options.test.ts index 0fc6c66..cc52ee4 100644 --- a/apps/server/test/runtime-options.test.ts +++ b/apps/server/test/runtime-options.test.ts @@ -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, }); }); diff --git a/apps/server/test/server-config.test.ts b/apps/server/test/server-config.test.ts index fab0492..f03a23c 100644 --- a/apps/server/test/server-config.test.ts +++ b/apps/server/test/server-config.test.ts @@ -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/, + ); +}); diff --git a/apps/server/test/test-config.ts b/apps/server/test/test-config.ts index b6002eb..156115c 100644 --- a/apps/server/test/test-config.ts +++ b/apps/server/test/test-config.ts @@ -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', }); diff --git a/apps/server/test/wechat-client.test.ts b/apps/server/test/wechat-client.test.ts new file mode 100644 index 0000000..a619ed6 --- /dev/null +++ b/apps/server/test/wechat-client.test.ts @@ -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', + ); +}); diff --git a/apps/server/test/worksheet-database.test.ts b/apps/server/test/worksheet-database.test.ts new file mode 100644 index 0000000..31190bd --- /dev/null +++ b/apps/server/test/worksheet-database.test.ts @@ -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({ + 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.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 => { + const result = await response( + `worksheets${query ? `?${query}` : ''}`, + ); + assert.equal(result.status, 200); + const body = (await result.json()) as ApiSuccess; + assert.equal(body.code, 0); + return body.data; + }; + const ids = (data: WorksheetPage) => data.items.map((row) => row.id); + const detail = async (id: string): Promise => { + const result = await response(`worksheets/${id}`); + assert.equal(result.status, 200); + return ((await result.json()) as ApiSuccess).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), + ); + }); + }, +); diff --git a/apps/server/test/worksheet-import-database.test.ts b/apps/server/test/worksheet-import-database.test.ts new file mode 100644 index 0000000..15cb6f9 --- /dev/null +++ b/apps/server/test/worksheet-import-database.test.ts @@ -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, + ); + }, + ); + }, +); diff --git a/apps/server/test/worksheet-import.fixture.ts b/apps/server/test/worksheet-import.fixture.ts new file mode 100644 index 0000000..8ccce95 --- /dev/null +++ b/apps/server/test/worksheet-import.fixture.ts @@ -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, +}; diff --git a/apps/server/test/worksheet-import.test.ts b/apps/server/test/worksheet-import.test.ts new file mode 100644 index 0000000..2cdcc42 --- /dev/null +++ b/apps/server/test/worksheet-import.test.ts @@ -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)); +}); diff --git a/apps/server/test/worksheet-query.test.ts b/apps/server/test/worksheet-query.test.ts new file mode 100644 index 0000000..d584e25 --- /dev/null +++ b/apps/server/test/worksheet-query.test.ts @@ -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)); +}); diff --git a/docs/v3.5/Server基础设置.md b/docs/v3.5/Server基础设置.md index 5a8224d..62a728c 100644 --- a/docs/v3.5/Server基础设置.md +++ b/docs/v3.5/Server基础设置.md @@ -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 的具体版本。 diff --git a/docs/v3.5/Worksheet数据库设计说明.md b/docs/v3.5/Worksheet数据库设计说明.md index 61949d5..66b0c1f 100644 --- a/docs/v3.5/Worksheet数据库设计说明.md +++ b/docs/v3.5/Worksheet数据库设计说明.md @@ -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)。 diff --git a/docs/v3.5/categories-import-2026-10-15.json b/docs/v3.5/categories-import-2026-10-15.json new file mode 100644 index 0000000..afbf19b --- /dev/null +++ b/docs/v3.5/categories-import-2026-10-15.json @@ -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" } + ] +} diff --git a/docs/v3.5/categories-reimport-2026-10-15.json b/docs/v3.5/categories-reimport-2026-10-15.json new file mode 100644 index 0000000..bbe431f --- /dev/null +++ b/docs/v3.5/categories-reimport-2026-10-15.json @@ -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" + } + ] +} diff --git a/docs/v3.5/worksheets-dry-run-2026-10-16.json b/docs/v3.5/worksheets-dry-run-2026-10-16.json new file mode 100644 index 0000000..09a187c --- /dev/null +++ b/docs/v3.5/worksheets-dry-run-2026-10-16.json @@ -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" + } + ] +} diff --git a/docs/v3.5/worksheets-import-2026-10-16.json b/docs/v3.5/worksheets-import-2026-10-16.json new file mode 100644 index 0000000..7c66b82 --- /dev/null +++ b/docs/v3.5/worksheets-import-2026-10-16.json @@ -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" + } + ] +} diff --git a/docs/v3.5/worksheets-reimport-2026-10-16.json b/docs/v3.5/worksheets-reimport-2026-10-16.json new file mode 100644 index 0000000..7127d81 --- /dev/null +++ b/docs/v3.5/worksheets-reimport-2026-10-16.json @@ -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" + } + ] +} diff --git a/docs/v3.5/worksheets-verification-2026-10-16.json b/docs/v3.5/worksheets-verification-2026-10-16.json new file mode 100644 index 0000000..a1646a4 --- /dev/null +++ b/docs/v3.5/worksheets-verification-2026-10-16.json @@ -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 +} diff --git a/docs/v3.5/云存储图片迁移七牛调研.md b/docs/v3.5/云存储图片迁移七牛调研.md new file mode 100644 index 0000000..20ddc65 --- /dev/null +++ b/docs/v3.5/云存储图片迁移七牛调研.md @@ -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 `,下载目录使用 `--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//.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 重建和旧上传/定时任务切换仍需分别落实。 diff --git a/docs/v3.5/内容发布与可见性.md b/docs/v3.5/内容发布与可见性.md new file mode 100644 index 0000000..f054c8e --- /dev/null +++ b/docs/v3.5/内容发布与可见性.md @@ -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 已注册全部新路由。 diff --git a/docs/v3.5/分类接口与数据导入.md b/docs/v3.5/分类接口与数据导入.md new file mode 100644 index 0000000..747793c --- /dev/null +++ b/docs/v3.5/分类接口与数据导入.md @@ -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 `,以服务端当前账户的 `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)。 diff --git a/docs/v3.5/数据库设计文档.md b/docs/v3.5/数据库设计文档.md index 4e7a32f..4912583 100644 --- a/docs/v3.5/数据库设计文档.md +++ b/docs/v3.5/数据库设计文档.md @@ -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 等字段继续按前述兼容规则保留,不作为新业务权威来源。 diff --git a/docs/v3.5/服务端登录接入说明.md b/docs/v3.5/服务端登录接入说明.md new file mode 100644 index 0000000..dafc391 --- /dev/null +++ b/docs/v3.5/服务端登录接入说明.md @@ -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 `。登录、本人接口的成功与失败响应均为 `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 张业务表及两条初始配置保留,开发库没有写入测试用户,所有临时测试/参考库已删除。真实密钥未进入可提交文件。 diff --git a/docs/v3.5/用户鉴权与登录设计.md b/docs/v3.5/用户鉴权与登录设计.md new file mode 100644 index 0000000..003c903 --- /dev/null +++ b/docs/v3.5/用户鉴权与登录设计.md @@ -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 `;不信任缓存的 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。 diff --git a/docs/v3.5/迁移任务清单.md b/docs/v3.5/迁移任务清单.md index e65768e..994bfa6 100644 --- a/docs/v3.5/迁移任务清单.md +++ b/docs/v3.5/迁移任务清单.md @@ -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)通过。 diff --git a/docs/v3.5/题型数据导入说明.md b/docs/v3.5/题型数据导入说明.md new file mode 100644 index 0000000..6c609b9 --- /dev/null +++ b/docs/v3.5/题型数据导入说明.md @@ -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)。七牛文件迁移与题型管理写接口仍待实施。 diff --git a/docs/v3.5/题型查询接口说明.md b/docs/v3.5/题型查询接口说明.md new file mode 100644 index 0000000..363d88c --- /dev/null +++ b/docs/v3.5/题型查询接口说明.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 参数格式,不改数据库或小程序云函数。旧分龄推荐接口尚未迁移,其匹配和榜单规则不属于本次普通列表变更。 diff --git a/eslint.config.mjs b/eslint.config.mjs index fb9e85b..9aea832 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,7 @@ export default tseslint.config( '**/dist/**', '**/dist-test/**', '**/coverage/**', + '**/src/generated/**', ], }, js.configs.recommended, diff --git a/infra/README.md b/infra/README.md index cf7ed09..547bd73 100644 --- a/infra/README.md +++ b/infra/README.md @@ -45,10 +45,18 @@ POSTGRES_DB、POSTGRES_USER、POSTGRES_PASSWORD 只在空数据卷首次初始 postgresql://doodle_dev:@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/基线及服务端登录接口;现有库只登记迁移记录,不重复执行建表。 diff --git a/package.json b/package.json index cae8659..6647337 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/asset-sync/.env.example b/packages/asset-sync/.env.example new file mode 100644 index 0000000..37485fe --- /dev/null +++ b/packages/asset-sync/.env.example @@ -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 diff --git a/packages/asset-sync/.gitignore b/packages/asset-sync/.gitignore new file mode 100644 index 0000000..a9a1bd3 --- /dev/null +++ b/packages/asset-sync/.gitignore @@ -0,0 +1 @@ +reports/ diff --git a/packages/asset-sync/README.md b/packages/asset-sync/README.md new file mode 100644 index 0000000..61719d7 --- /dev/null +++ b/packages/asset-sync/README.md @@ -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)。 diff --git a/packages/asset-sync/package.json b/packages/asset-sync/package.json new file mode 100644 index 0000000..670e93f --- /dev/null +++ b/packages/asset-sync/package.json @@ -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" + } +} diff --git a/packages/asset-sync/src/cli.mjs b/packages/asset-sync/src/cli.mjs new file mode 100644 index 0000000..5382ddd --- /dev/null +++ b/packages/asset-sync/src/cli.mjs @@ -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; +}); diff --git a/packages/asset-sync/src/config.mjs b/packages/asset-sync/src/config.mjs new file mode 100644 index 0000000..ad389f5 --- /dev/null +++ b/packages/asset-sync/src/config.mjs @@ -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'); + } +} diff --git a/packages/asset-sync/src/files.mjs b/packages/asset-sync/src/files.mjs new file mode 100644 index 0000000..2421348 --- /dev/null +++ b/packages/asset-sync/src/files.mjs @@ -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*)?]/.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 }; +} diff --git a/packages/asset-sync/src/qiniu-client.mjs b/packages/asset-sync/src/qiniu-client.mjs new file mode 100644 index 0000000..c0cbed6 --- /dev/null +++ b/packages/asset-sync/src/qiniu-client.mjs @@ -0,0 +1,87 @@ +import qiniu from 'qiniu'; +import { SyncError } from './config.mjs'; + +const networkCodes = new Set([ + 'ECONNRESET', + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ENOTFOUND', + 'EAI_AGAIN', + 'UND_ERR_CONNECT_TIMEOUT', +]); + +/** 仅以状态码和有限网络错误识别重试;不保留 SDK 返回体或异常信息。 */ +async function sdkCall(operation, allowed = []) { + let result; + try { + result = await operation(); + } catch (error) { + const status = + Number(error?.statusCode ?? error?.resp?.statusCode) || 0; + if (allowed.includes(status)) return { status, data: null }; + throw new SyncError( + 'QINIU_REQUEST_FAILED', + '七牛请求失败', + status, + status === 429 || + (status >= 500 && status <= 599) || + networkCodes.has(error?.code), + ); + } + const status = result?.resp?.statusCode ?? 0; + if (status < 200 || status >= 300) { + if (allowed.includes(status)) return { status, data: result.data }; + throw new SyncError( + 'QINIU_REQUEST_FAILED', + '七牛请求失败', + status, + status === 429 || (status >= 500 && status <= 599), + ); + } + return { status, data: result.data }; +} + +/** 七牛官方 SDK 适配:强制 HTTPS、逐对象上传凭证、仅新增;不创建或修改空间权限。 */ +export function createQiniuClient(config, sdk = qiniu) { + const sdkConfig = new sdk.conf.Config({ useHttpsDomain: true }); + if (config.region) + sdkConfig.regionsProvider = sdk.httpc.Region.fromRegionId( + config.region, + ); + const mac = new sdk.auth.digest.Mac(config.ak, config.sk); + const manager = new sdk.rs.BucketManager(mac, sdkConfig); + const uploader = new sdk.form_up.FormUploader(sdkConfig); + return { + async bucketInfo(bucket) { + return (await sdkCall(() => manager.getBucketInfo(bucket))).data; + }, + async stat(bucket, key) { + const result = await sdkCall( + () => manager.stat(bucket, key), + [612], + ); + return result.status === 612 ? null : result.data; + }, + async upload(file, buffer) { + const policy = new sdk.rs.PutPolicy({ + scope: `${file.bucket}:${file.key}`, + insertOnly: 1, + expires: 3600, + fsizeLimit: file.size, + }); + const token = policy.uploadToken(mac); + const extra = new sdk.form_up.PutExtra(); + extra.fname = file.relativePath.split('/').at(-1); + extra.mimeType = file.mimeType; + extra.checkCrc = true; + const result = await sdkCall(() => + uploader.put(token, file.key, buffer, extra), + ); + if ( + result.data?.key !== file.key || + result.data?.hash !== file.etag + ) + throw new SyncError('UPLOAD_RESPONSE_MISMATCH'); + }, + }; +} diff --git a/packages/asset-sync/src/sync.mjs b/packages/asset-sync/src/sync.mjs new file mode 100644 index 0000000..fc6a58f --- /dev/null +++ b/packages/asset-sync/src/sync.mjs @@ -0,0 +1,152 @@ +import { readSource } from './files.mjs'; +import { SyncError, validateApplyConfig } from './config.mjs'; + +/** SDK 错误不透传;本地源码可用固定的错误码定位失败环节。 */ +export function safeFailure(error) { + return error instanceof SyncError + ? { code: error.code, statusCode: error.statusCode || null } + : { code: 'LOCAL_OPERATION_FAILED', statusCode: null }; +} + +/** 只重试瞬时网络/限流/服务异常;同名冲突、授权失败不做盲目覆盖或重试。 */ +export async function retry( + operation, + retries, + sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +) { + for (let attempt = 0; ; attempt++) { + try { + return await operation(); + } catch (error) { + if ( + !(error instanceof SyncError) || + !error.retryable || + attempt >= retries + ) + throw error; + await sleep(Math.min(200 * 2 ** attempt, 3000)); + } + } +} + +function matches(remote, file) { + return ( + remote && + remote.hash === file.etag && + Number.isSafeInteger(remote.fsize) && + remote.fsize === file.size + ); +} + +/** 默认只预演;正式同步先检查所有空间权限,之后有限并发逐项校验,失败不影响报告落盘。 */ +export async function syncPlan( + config, + plan, + { apply = false, client, progress = () => {}, sleep } = {}, +) { + const report = { + schemaVersion: 1, + mode: apply ? 'apply' : 'dry-run', + startedAt: new Date().toISOString(), + sourceDir: plan.sourceDir, + files: plan.files.map((file) => ({ + ...file, + status: apply ? 'not_attempted' : 'planned', + })), + }; + const run = (operation) => retry(operation, config.retries, sleep); + if (apply) { + try { + validateApplyConfig(config, plan.files); + const destinations = new Set(plan.files.map((file) => file.bucket)); + // 包含头像在内的资源共用公开空间,执行前核对实际访问权限。 + for (const bucket of destinations) { + const info = await run(() => client.bucketInfo(bucket)); + if (info.private !== 0) + throw new SyncError('BUCKET_VISIBILITY_MISMATCH'); + } + let cursor = 0; + const worker = async () => { + for (;;) { + const index = cursor++; + const file = report.files[index]; + if (!file) return; + try { + const source = await readSource( + { ...config, sourceDir: plan.sourceDir }, + file.relativePath, + ); + if ( + source.sha256 !== file.sha256 || + source.size !== file.size + ) + throw new SyncError('SOURCE_CHANGED'); + const remote = await run(() => + client.stat(file.bucket, file.key), + ); + if (remote) { + if (!matches(remote, file)) + throw new SyncError('REMOTE_CONFLICT'); + file.status = 'skipped'; + } else { + try { + await run(() => + client.upload(file, source.buffer), + ); + } catch (error) { + // 614 表示另一任务已创建对象;按摘要复核,不覆盖同名文件。 + if ( + !(error instanceof SyncError) || + error.statusCode !== 614 + ) + throw error; + } + const uploaded = await run(() => + client.stat(file.bucket, file.key), + ); + if (!matches(uploaded, file)) + throw new SyncError('VERIFY_FAILED'); + file.status = 'uploaded'; + } + file.verifiedBy = 'qiniu-etag-and-size'; + } catch (error) { + file.status = + error instanceof SyncError && + error.code === 'REMOTE_CONFLICT' + ? 'conflict' + : 'failed'; + file.error = safeFailure(error); + } + progress({ + completed: report.files.filter( + (entry) => entry.status !== 'not_attempted', + ).length, + total: report.files.length, + status: file.status, + }); + } + }; + await Promise.all( + Array.from( + { length: Math.min(config.concurrency, plan.files.length) }, + worker, + ), + ); + } catch (error) { + report.error = safeFailure(error); + } + } + report.finishedAt = new Date().toISOString(); + report.summary = { + total: report.files.length, + bytes: report.files.reduce((sum, file) => sum + file.size, 0), + }; + for (const file of report.files) + report.summary[file.status] = (report.summary[file.status] ?? 0) + 1; + report.ok = + !report.error && + !report.summary.failed && + !report.summary.conflict && + !report.summary.not_attempted; + return report; +} diff --git a/packages/asset-sync/test/sync.test.mjs b/packages/asset-sync/test/sync.test.mjs new file mode 100644 index 0000000..7f6a4fd --- /dev/null +++ b/packages/asset-sync/test/sync.test.mjs @@ -0,0 +1,391 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + mkdtemp, + mkdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; +import qiniu from 'qiniu'; +import { + loadConfig, + normalizeRelativePath, + packageRoot, + SyncError, +} from '../src/config.mjs'; +import { buildPlan, qiniuEtag } from '../src/files.mjs'; +import { safeFailure, syncPlan } from '../src/sync.mjs'; +import { createQiniuClient } from '../src/qiniu-client.mjs'; + +const jpeg = Buffer.from([ + 255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 0, 1, 0, 0, 0, 0, +]); +async function fixture(context) { + const dir = await mkdtemp(join(tmpdir(), 'doodle-sync-')); + context.after(() => rm(dir, { recursive: true, force: true })); + const source = join(dir, 'source'); + await mkdir(join(source, 'assets/previews/math'), { recursive: true }); + await mkdir(join(source, 'user-avatars')); + await mkdir(join(source, 'content')); + await writeFile(join(source, 'assets/previews/math/中文 图.jpg'), jpeg); + await writeFile( + join(source, 'user-avatars/u.png'), + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]), + ); + await writeFile(join(source, 'content/page-config.json'), '{"home":null}'); + await writeFile(join(source, '.DS_Store'), 'ignored'); + const envFile = join(dir, '.env'); + await writeFile( + envFile, + `SOURCE_DIR=./source\nQINIU_AK=test-ak-never-log\nQINIU_SK=test-sk-never-log\nQINIU_BUCKET=test-public\nQINIU_PUBLIC_DOMAIN=https://cdn.example.test\n`, + ); + const config = await loadConfig({ env: {}, envFile }); + const plan = await buildPlan(config); + return { dir, source, envFile, config, plan }; +} +function fakeClient() { + const objects = new Map(); + const calls = []; + return { + calls, + objects, + async bucketInfo(bucket) { + calls.push(['info', bucket]); + return { private: bucket === 'test-private' ? 1 : 0 }; + }, + async stat(bucket, key) { + calls.push(['stat', bucket, key]); + return objects.get(`${bucket}/${key}`) ?? null; + }, + async upload(file, buffer) { + calls.push(['upload', file.key]); + const id = `${file.bucket}/${file.key}`; + if (objects.has(id)) + throw new SyncError('QINIU_REQUEST_FAILED', 'conflict', 614); + objects.set(id, { hash: qiniuEtag(buffer), fsize: buffer.length }); + }, + }; +} +const noSleep = async () => {}; + +test('配置:相对源路径按 env 文件定位,环境变量覆盖,拒绝非法前缀/域名/数值', async (context) => { + const fixtureData = await fixture(context); + assert.equal(fixtureData.config.sourceDir, fixtureData.source); + assert.equal(fixtureData.config.prefix, 'doodle/'); + const override = await loadConfig({ + env: { QINIU_KEY_PREFIX: '' }, + envFile: fixtureData.envFile, + }); + assert.equal(override.prefix, ''); + for (const env of [ + { SYNC_CONCURRENCY: '0' }, + { SYNC_RETRIES: '-1' }, + { QINIU_PUBLIC_DOMAIN: 'https://user:secret@host.test' }, + { QINIU_KEY_PREFIX: '../escape' }, + { QINIU_KEY_PREFIX: '/absolute' }, + ]) + await assert.rejects( + loadConfig({ env, envFile: fixtureData.envFile }), + SyncError, + ); + for (const path of ['../x', 'a/../../b', '/x', 'a\\b', 'a//b', 'x\0']) + assert.throws(() => normalizeRelativePath(path), SyncError); +}); + +test('ETag:固定向量覆盖空文件、普通内容与 4 MiB 分块边界', () => { + assert.equal(qiniuEtag(Buffer.alloc(0)), 'Fto5o-5ea0sNMlW_75VgGJCv2AcJ'); + assert.equal( + qiniuEtag(Buffer.from('hello')), + 'Fqr0xh3cxeii2r7eDztILNmuqUNN', + ); + // 固定多块期望值由独立 SHA-1 实现生成,避免只用同一函数自我验证。 + assert.equal( + qiniuEtag(Buffer.alloc(4194304)), + 'FivMvS848VwT631aif2dhfWV4jvD', + ); + assert.equal( + qiniuEtag(Buffer.alloc(4194305)), + 'lhCFgki5yzon0rjN9uJusf6qtsF6', + ); +}); + +test('预演:不调用七牛,不包含 AK/SK,保留目录/摘要头像与预览共用公开空间', async (context) => { + const { config, plan } = await fixture(context); + const report = await syncPlan(config, plan, { + client: new Proxy( + {}, + { + get() { + throw new Error('禁止网络'); + }, + }, + ), + }); + assert.equal(report.ok, true); + assert.equal(report.summary.planned, 3); + const avatar = report.files.find((file) => + file.relativePath.startsWith('user-avatars/'), + ); + assert.equal(avatar.visibility, 'public'); + assert.equal(avatar.bucket, 'test-public'); + assert.equal( + avatar.publicUrl, + 'https://cdn.example.test/doodle/user-avatars/u.png', + ); + assert.ok(report.files.every((file) => file.bucket === avatar.bucket)); + const preview = report.files.find((file) => + file.relativePath.startsWith('assets/'), + ); + assert.equal(preview.key, 'doodle/assets/previews/math/中文 图.jpg'); + assert.match(preview.publicUrl, /%E4%B8%AD%E6%96%87%20%E5%9B%BE/); + assert.equal(preview.mimeType, 'image/jpeg'); + assert.equal(preview.sha256.length, 64); + assert.ok(!JSON.stringify(report).includes(config.ak)); + assert.ok(!JSON.stringify(report).includes(config.sk)); + assert.equal( + (await buildPlan(config, ['assets/previews'])).files.length, + 1, + ); +}); + +test('同步:上传后逐对象校验,重跑跳过,相同 key 的不同内容绝不覆盖', async (context) => { + const { config, plan } = await fixture(context); + const client = fakeClient(); + const first = await syncPlan(config, plan, { apply: true, client }); + assert.equal(first.summary.uploaded, 3); + assert.equal(first.ok, true); + assert.equal(client.objects.size, 3); + const second = await syncPlan(config, plan, { apply: true, client }); + assert.equal(second.summary.skipped, 3); + const item = plan.files[0]; + client.objects.set(`${item.bucket}/${item.key}`, { + hash: 'different', + fsize: item.size, + }); + const third = await syncPlan(config, plan, { apply: true, client }); + assert.equal(third.summary.conflict, 1); + assert.equal(third.summary.skipped, 2); + assert.equal(third.ok, false); + assert.equal( + client.objects.get(`${item.bucket}/${item.key}`).hash, + 'different', + ); + assert.equal(client.calls.filter(([op]) => op === 'upload').length, 3); +}); + +test('全批预检查:缺少凭据/空间、目标空间实际私有时不上传任何文件', async (context) => { + const { config, plan } = await fixture(context); + for (const changed of [ + { ...config, ak: '' }, + { ...config, publicBucket: '' }, + ]) { + const client = fakeClient(); + const changedPlan = await buildPlan(changed); + const result = await syncPlan(changed, changedPlan, { + apply: true, + client, + }); + assert.equal(result.ok, false); + assert.equal(client.objects.size, 0); + } + const missing = { + ...plan, + files: plan.files.map((file) => + file.relativePath.startsWith('user-avatars/') + ? { ...file, bucket: null } + : file, + ), + }; + const client = fakeClient(); + assert.equal( + (await syncPlan(config, missing, { apply: true, client })).error.code, + 'BUCKET_REQUIRED', + ); + client.bucketInfo = async () => ({ private: 1 }); + assert.equal( + (await syncPlan(config, plan, { apply: true, client })).error.code, + 'BUCKET_VISIBILITY_MISMATCH', + ); + assert.equal(client.objects.size, 0); +}); + +test('瞬时错误重试:上传成功但响应丢失时按 614 和远端摘要恢复,不覆盖', async (context) => { + const { config, plan } = await fixture(context); + const client = fakeClient(); + const normalUpload = client.upload; + let lost = false; + client.upload = async (file, buffer) => { + await normalUpload(file, buffer); + if (!lost) { + lost = true; + throw new SyncError('QINIU_REQUEST_FAILED', 'lost', 0, true); + } + }; + const result = await syncPlan(config, plan, { + apply: true, + client, + sleep: noSleep, + }); + assert.equal(result.ok, true); + assert.equal(result.summary.uploaded, 3); + assert.equal(client.objects.size, 3); +}); + +test('源文件变化、远端摘要错误会失败;普通异常和 SDK 凭据不会写进报告', async (context) => { + const { config, plan, source } = await fixture(context); + await writeFile( + join(source, plan.files[0].relativePath), + Buffer.concat([jpeg, Buffer.from('changed')]), + ); + const client = fakeClient(); + const result = await syncPlan(config, plan, { apply: true, client }); + assert.equal(result.summary.failed, 1); + assert.equal(result.files[0].error.code, 'SOURCE_CHANGED'); + const other = fakeClient(); + other.stat = async () => null; + const verify = await syncPlan( + config, + { ...plan, files: [plan.files[1]] }, + { apply: true, client: other }, + ); + assert.equal(verify.files[0].error.code, 'VERIFY_FAILED'); + assert.deepEqual(safeFailure(new Error(`${config.ak} ${config.sk}`)), { + code: 'LOCAL_OPERATION_FAILED', + statusCode: null, + }); +}); + +test('扫描:拒绝软链接、超限和损坏图片,保留空普通文件', async (context) => { + const { config, source } = await fixture(context); + await assert.rejects(buildPlan({ ...config, maxFileBytes: 1 }), SyncError); + await writeFile(join(source, 'empty.txt'), ''); + assert.ok( + (await buildPlan(config)).files.some( + (file) => file.relativePath === 'empty.txt' && file.size === 0, + ), + ); + await writeFile( + join(source, 'assets/previews/math/bad.jpg'), + 'denied', + ); + await assert.rejects(buildPlan(config), { code: 'INVALID_IMAGE' }); + await rm(join(source, 'assets/previews/math/bad.jpg')); + await symlink(join(source, 'content'), join(source, 'link')); + await assert.rejects(buildPlan(config), { code: 'SYMLINK_NOT_ALLOWED' }); +}); + +test('官方 SDK 适配:HTTPS、明确 key 的 insertOnly token、MIME/CRC 和安全错误映射', async (context) => { + const { config, plan } = await fixture(context); + const file = plan.files[0]; + let token, policy, extraValue; + const sdk = { + ...qiniu, + rs: { + ...qiniu.rs, + BucketManager: class { + constructor(_mac, conf) { + assert.equal(conf.useHttpsDomain, true); + } + async getBucketInfo() { + return { resp: { statusCode: 200 }, data: { private: 0 } }; + } + async stat() { + return { + resp: { statusCode: 612 }, + data: { error: 'ignored' }, + }; + } + }, + }, + form_up: { + ...qiniu.form_up, + FormUploader: class { + async put(value, key, _buffer, extra) { + token = value; + extraValue = extra; + return { + resp: { statusCode: 200 }, + data: { key, hash: file.etag }, + }; + } + }, + }, + }; + const client = createQiniuClient(config, sdk); + assert.equal(await client.stat(file.bucket, file.key), null); + assert.equal((await client.bucketInfo(file.bucket)).private, 0); + await client.upload(file, jpeg); + policy = JSON.parse( + Buffer.from(token.split(':')[2], 'base64url').toString(), + ); + assert.equal(policy.insertOnly, 1); + assert.equal(policy.scope, `${file.bucket}:${file.key}`); + assert.equal(extraValue.mimeType, file.mimeType); + assert.equal(extraValue.checkCrc, true); + sdk.rs.BucketManager = class { + async stat() { + throw Object.assign(new Error(config.sk), { + resp: { statusCode: 401 }, + }); + } + }; + await assert.rejects( + createQiniuClient(config, sdk).stat(file.bucket, file.key), + (error) => + error.statusCode === 401 && + !error.retryable && + !error.message.includes(config.sk), + ); +}); + +test('CLI:默认只预演,报告独占且权限 0600;禁止报告写入源目录', async (context) => { + const { dir, envFile, source } = await fixture(context); + const reportPath = join(dir, 'report.json'); + const cli = resolve(packageRoot, 'src/cli.mjs'); + const result = execFileSync( + process.execPath, + [cli, '--env', envFile, '--report', reportPath], + { encoding: 'utf8', env: { PATH: process.env.PATH } }, + ); + assert.equal(JSON.parse(result).mode, 'dry-run'); + assert.equal( + JSON.parse(await readFile(reportPath, 'utf8')).summary.planned, + 3, + ); + assert.equal((await stat(reportPath)).mode & 0o777, 0o600); + const deniedReport = join(dir, 'no-credentials.json'); + assert.throws(() => + execFileSync( + process.execPath, + [cli, '--env', envFile, '--apply', '--report', deniedReport], + { + stdio: 'pipe', + env: { PATH: process.env.PATH, QINIU_AK: '', QINIU_SK: '' }, + }, + ), + ); + const denied = JSON.parse(await readFile(deniedReport, 'utf8')); + assert.equal(denied.error.code, 'CREDENTIALS_REQUIRED'); + assert.equal(denied.summary.not_attempted, 3); + + assert.throws(() => + execFileSync( + process.execPath, + [cli, '--env', envFile, '--report', reportPath], + { stdio: 'pipe' }, + ), + ); + assert.throws(() => + execFileSync( + process.execPath, + [cli, '--env', envFile, '--report', join(source, 'report.json')], + { stdio: 'pipe' }, + ), + ); +}); diff --git a/packages/contracts/README.md b/packages/contracts/README.md index ecc9598..350f090 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -1,6 +1,6 @@ # 公共接口契约 -仅包含可公开的基础响应和健康检查类型。服务端通过 `workspace:*` 引用,使用 `import type` 避免引入无必要的运行时逻辑。 +包含可公开的基础响应、健康检查、微信登录请求/结果、本人摘要、分类查询/创建/更新、题型查询/公开 DTO/分页及有限错误原因类型。题型计数为含 seed 的十进制展示字符串,previewImg 可空。服务端通过 `workspace:*` 引用,使用 `import type` 避免引入无必要的运行时逻辑。 后续可以加入公开请求/响应类型及枚举,但禁止包含 Prisma Client、数据库模型、密钥、管理权限实现或私有生成器。 diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7610dcf..2c3244e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,3 +10,165 @@ export interface HealthStatus { status: 'ok'; service: 'doodle-store-server'; } + +/** 安全、有限的失败原因;HTTP code 仍承担错误大类,不暴露内部异常。 */ +export type ApiErrorReason = + | 'AUTH_REQUIRED' + | 'AUTH_INVALID' + | 'AUTH_EXPIRED' + | 'AUTH_REVOKED' + | 'ACCOUNT_NOT_FOUND' + | 'ACCOUNT_DISABLED' + | 'WECHAT_CODE_INVALID' + | 'WECHAT_LOGIN_BLOCKED' + | 'RATE_LIMITED' + | 'AUTH_UPSTREAM_UNAVAILABLE' + | 'DATABASE_UNAVAILABLE'; + +export interface ApiError { + code: number; + message: string; + requestId?: string; + reason?: ApiErrorReason; +} + +export type WechatLoginMode = 'start' | 'resume'; +export interface WechatLoginRequest { + code: string; + mode: WechatLoginMode; +} + +/** 显式白名单摘要,不含微信身份或凭证版本。头像签名由后续资产模块补齐。 */ +export interface UserSummary { + id: string; + nickName: string | null; + avatarUrl: string | null; + avatarUrlExpiresAt: string | null; + createdAt: string; +} + +export interface WechatLoginResult { + accessToken: string; + tokenType: 'Bearer'; + expiresIn: number; + expiresAt: string; + user: UserSummary; +} + +export interface CurrentUserProfile extends UserSummary { + role: 'user' | 'admin'; +} + +export type ContentStatus = 'draft' | 'active' | 'hidden'; + +/** 分类 ID 沿用云数据库原值;列表为扁平结构,parentId=null 表示根分类。 */ +export interface Category { + status: ContentStatus; + id: string; + name: string; + icon: string; + color: string; + sortOrder: number; + parentId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateCategoryRequest { + status?: ContentStatus; + id: string; + name: string; + icon: string; + color: string; + sortOrder?: number; + parentId?: string | null; +} + +/** PATCH 不允许修改 ID;省略父级保持原值,null 或空串移到根级。 */ +export type UpdateCategoryRequest = Partial>; + +export type WorksheetAgeBand = '3-4' | '4-5' | '5-6' | '6-7' | '7-8'; +export type WorksheetSort = 'default' | 'latest' | 'hot'; + +/** 筛选条件可组合;grade 与 grades、age 与 ageBand 分别互斥。 */ +export interface WorksheetQuery { + page?: number; + pageSize?: number; + categoryId?: string; + grade?: number; + grades?: number[]; + ageBand?: WorksheetAgeBand; + age?: number; + tag?: string; + sort?: WorksheetSort; + isHot?: boolean; + isNew?: boolean; +} + +/** 公开题型白名单;64 位展示计数使用十进制字符串,避免 JavaScript 精度丢失。 */ +export interface PublicWorksheet { + id: string; + title: string; + subtitle: string; + category: string; + subcategory: string | null; + path: string; + ageMin: number; + ageMax: number; + grades: number[]; + difficulty: number; + previewImg: string | null; + tags: string[]; + isNew: boolean; + isHot: boolean; + sortOrder: number; + downloads: string; + likes: string; + createdAt: string; + updatedAt: string; + contentUpdatedAt: string | null; + firstPublishedAt: string | null; +} + +export interface WorksheetPage { + items: PublicWorksheet[]; + total: number; + page: number; + pageSize: number; + hasNext: boolean; +} + +/** 管理预览沿用公开字段白名单,额外标明自身状态和整条所属关系是否可见。 */ +export interface AdminWorksheet extends PublicWorksheet { + status: ContentStatus; + visible: boolean; +} +export interface AdminWorksheetPage extends Omit { + items: AdminWorksheet[]; +} +export interface UpdateContentStatusRequest { + status: ContentStatus; +} +export interface ContentPublication { + id: string; + status: ContentStatus; + firstPublishedAt: string | null; + updatedAt: string; +} +/** 资源管理预览不返回配置覆盖、源文件地址、私有对象或签名 URL。 */ +export interface AdminWorksheetResource extends ContentPublication { + worksheetId: string; + title: string; + previewImg: string | null; + sortOrder: number; + downloads: string; + createdAt: string; + visible: boolean; +} +export interface AdminResourcePage { + items: AdminWorksheetResource[]; + total: number; + page: number; + pageSize: number; + hasNext: boolean; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3b5561..f100301 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: devDependencies: '@eslint/js': specifier: 10.0.1 - version: 10.0.1(eslint@10.12.0(supports-color@8.1.1)) + version: 10.0.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1)) eslint: specifier: 10.12.0 - version: 10.12.0(supports-color@8.1.1) + version: 10.12.0(jiti@2.7.0)(supports-color@8.1.1) globals: specifier: 17.13.0 version: 17.13.0 @@ -25,7 +25,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: 8.71.1 - version: 8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + version: 8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) apps/server: dependencies: @@ -47,6 +47,12 @@ importers: '@nestjs/swagger': specifier: 11.4.7 version: 11.4.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + '@prisma/adapter-pg': + specifier: 7.10.0 + version: 7.10.0 + '@prisma/client': + specifier: 7.10.0 + version: 7.10.0(prisma@7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3) class-transformer: specifier: 0.5.1 version: 0.5.1 @@ -56,6 +62,9 @@ importers: helmet: specifier: 8.3.0 version: 8.3.0 + pg: + specifier: 8.23.1 + version: 8.23.1 reflect-metadata: specifier: 0.2.2 version: 0.2.2 @@ -75,10 +84,22 @@ importers: '@types/node': specifier: 24.19.2 version: 24.19.2 + '@types/pg': + specifier: 8.23.1 + version: 8.23.1 + prisma: + specifier: 7.10.0 + version: 7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) typescript: specifier: 5.9.3 version: 5.9.3 + packages/asset-sync: + dependencies: + qiniu: + specifier: 7.15.2 + version: 7.15.2 + packages/contracts: devDependencies: typescript: @@ -139,6 +160,20 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@electric-sql/pglite-socket@0.1.3': + resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3': + resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==} + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': + resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==} + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -480,9 +515,149 @@ packages: '@nestjs/platform-express': optional: true + '@prisma/adapter-pg@7.10.0': + resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} + + '@prisma/client-runtime-utils@7.10.0': + resolution: {integrity: sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==} + + '@prisma/client@7.10.0': + resolution: {integrity: sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.10.0': + resolution: {integrity: sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA==} + + '@prisma/debug@7.10.0': + resolution: {integrity: sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/dev@0.24.17': + resolution: {integrity: sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==} + + '@prisma/driver-adapter-utils@7.10.0': + resolution: {integrity: sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==} + + '@prisma/engines-version@7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3': + resolution: {integrity: sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw==} + + '@prisma/engines@7.10.0': + resolution: {integrity: sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ==} + + '@prisma/fetch-engine@7.10.0': + resolution: {integrity: sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g==} + + '@prisma/get-platform@7.10.0': + resolution: {integrity: sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.11': + resolution: {integrity: sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==} + engines: {bun: '>=1.2.0', node: '>=22.0.0'} + + '@prisma/studio-core@0.33.0': + resolution: {integrity: sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -496,6 +671,39 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/d3-array@3.0.3': + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + + '@types/d3-color@3.1.0': + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + + '@types/d3-delaunay@6.0.1': + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + + '@types/d3-format@3.0.1': + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-interpolate@3.0.1': + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.2': + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time-format@2.1.0': + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + + '@types/d3-time@3.0.0': + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -514,6 +722,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -523,18 +734,27 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/lodash@4.17.26': + resolution: {integrity: sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} '@types/node@24.19.2': resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -603,6 +823,41 @@ packages: resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@visx/curve@4.0.1-alpha.0': + resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} + + '@visx/event@4.0.1-alpha.0': + resolution: {integrity: sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==} + + '@visx/grid@4.0.1-alpha.0': + resolution: {integrity: sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/group@4.0.1-alpha.0': + resolution: {integrity: sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/point@4.0.1-alpha.0': + resolution: {integrity: sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==} + + '@visx/responsive@4.0.1-alpha.0': + resolution: {integrity: sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/scale@4.0.1-alpha.0': + resolution: {integrity: sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==} + + '@visx/shape@4.0.1-alpha.0': + resolution: {integrity: sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/vendor@4.0.0-alpha.0': + resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -674,6 +929,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -717,6 +976,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} @@ -726,6 +988,10 @@ packages: array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -741,9 +1007,18 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + before@0.0.1: + resolution: {integrity: sha512-1J5SWbkoVJH9DTALN8igB4p+nPKZzPrJ/HomqBDLpfUvDXCdjdBmBUcH5McZfur0lftVssVU6BZug5NYh87zTw==} + + better-result@2.10.0: + resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + block-stream2@2.1.0: + resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -777,6 +1052,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cacheable@2.5.0: resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} @@ -806,6 +1089,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -816,6 +1103,9 @@ packages: class-validator@0.15.1: resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -857,6 +1147,12 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -877,6 +1173,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + copy-to@2.0.1: + resolution: {integrity: sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -890,10 +1189,66 @@ packages: typescript: optional: true + crc32@0.2.2: + resolution: {integrity: sha512-PFZEGbDUeoNbL2GHIEpJRQGheXReDody/9axKTxhXtQqIL443wnNigtVZO9iuCIMPApKZRv7k2xr8euXHqNxQQ==} + engines: {node: '>= 0.4.0'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -906,17 +1261,50 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-user-agent@1.0.0: + resolution: {integrity: sha512-bDF7bg6OSNcSwFWPu4zYKpVkJZQYVrAANMYB8bc9Szem1D0yKdm4sa/rOCs2aC9+2GMqQ7KnwtZRvDhmLF0dXw==} + engines: {node: '>= 0.10.0'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + digest-header@1.1.0: + resolution: {integrity: sha512-glXVh42vz40yZb9Cq2oMOt70FIoWiv+vxNvdKdU8CwjLad25qHM3trLxhl9bVjdr6WaslIXhWpn0NO8T/67Qjg==} + engines: {node: '>= 8.0.0'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -927,20 +1315,41 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + electron-to-chromium@1.5.452: resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.26.0: resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} engines: {node: '>=10.13.0'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1037,6 +1446,20 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + exsolve@1.1.3: + resolution: {integrity: sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1046,6 +1469,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -1072,6 +1498,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} + engines: {node: '>=20'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1082,6 +1512,10 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fork-ts-checker-webpack-plugin@9.1.0: resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} engines: {node: '>=14.21.3'} @@ -1089,6 +1523,9 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 + formstream@1.5.2: + resolution: {integrity: sha512-NASf0lgxC1AyKNXQIrXTEYkiX99LhCEXTkiGObXAkpBui86a4u8FjH1o2bGb3PpqI3kafC+yw4zWeK6l6VHTgg==} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1107,14 +1544,24 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1137,6 +1584,12 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammex@3.1.13: + resolution: {integrity: sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1167,6 +1620,13 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -1193,6 +1653,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -1200,6 +1664,10 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1219,6 +1687,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -1234,6 +1705,10 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1331,10 +1806,17 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.3: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} + lru.min@1.1.5: + resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -1377,6 +1859,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -1395,6 +1882,13 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mockdate@3.0.5: + resolution: {integrity: sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1406,6 +1900,17 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1422,6 +1927,10 @@ packages: node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + node-hex@1.0.1: + resolution: {integrity: sha512-iwpZdvW6Umz12ICmu9IYPRxg0tOLGmU3Tq2tKetejCj3oZd7b2nUXwP3a7QA5M9glWy8wlPS1G3RwM/CdsUbdQ==} + engines: {node: '>=8.0.0'} + node-releases@2.0.58: resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} engines: {node: '>=18'} @@ -1434,6 +1943,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1453,6 +1965,16 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + os-name@1.0.3: + resolution: {integrity: sha512-f5estLO2KN8vgtTRaILIgEGBoBrMnZ3JQ7W9TMZCnOIGwHe8TRGSpcagnWDo+Dfhd/z08k9Xe75hvciJJ8Qaew==} + engines: {node: '>=0.10.0'} + hasBin: true + + osx-release@1.1.0: + resolution: {integrity: sha512-ixCMMwnVxyHFQLQnINhmIpWqXIfS2YOXchwQrk+OFzmo6nDjQ0E4KXAyyUh0T0MZgV4bUhkRrAbVqlE4yLVq4A==} + engines: {node: '>=0.10.0'} + hasBin: true + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -1492,6 +2014,49 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} + + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.1: + resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1503,10 +2068,37 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1516,18 +2108,44 @@ packages: engines: {node: '>=14'} hasBin: true + prisma@7.10.0: + resolution: {integrity: sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proxy-addr@2.0.8: resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qified@0.10.1: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} + qiniu@7.15.2: + resolution: {integrity: sha512-j3GkmPP4P5o0c2cphqo6TwetCID8ZCMubcPaZlFS6iXng0mvV4BRxfMb9eROItTvyRL/XPWe2+T6ZLUC5Hs1BA==} + engines: {node: '>= 6'} + qs@6.16.0: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} @@ -1540,6 +2158,18 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc9@3.1.0: + resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1548,9 +2178,16 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1563,6 +2200,17 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -1576,9 +2224,16 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.2: + resolution: {integrity: sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==} + hasBin: true + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + schema-utils@3.3.0: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} @@ -1587,6 +2242,10 @@ packages: resolution: {integrity: sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==} engines: {node: '>= 10.13.0'} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1596,6 +2255,9 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -1645,10 +2307,25 @@ packages: resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} engines: {node: '>= 8'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -1739,6 +2416,16 @@ packages: engines: {node: '>=10'} hasBin: true + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -1768,6 +2455,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1787,6 +2477,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1803,6 +2498,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unescape@1.0.1: + resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==} + engines: {node: '>=0.10.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -1820,9 +2519,30 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + urllib@2.44.1: + resolution: {integrity: sha512-vreOVvFizoiIz5NK9IYMgUknkriHHBVccn2VFfJhgKz6O2qwm0SgjFk4OpXFRDXpdrTx8EzM1DB0/pejrqXwPA==} + engines: {node: '>= 0.10.0'} + peerDependencies: + proxy-agent: ^5.0.0 + peerDependenciesMeta: + proxy-agent: + optional: true + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utility@1.18.0: + resolution: {integrity: sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==} + engines: {node: '>= 0.12.0'} + + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validator@13.15.35: resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} engines: {node: '>= 0.10'} @@ -1861,6 +2581,10 @@ packages: engines: {node: '>= 8'} hasBin: true + win-release@1.1.1: + resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==} + engines: {node: '>=0.10.0'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -1872,6 +2596,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -1884,6 +2612,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + snapshots: '@angular-devkit/core@19.2.24(chokidar@4.0.3)': @@ -1965,9 +2696,19 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.12.0(supports-color@8.1.1))': + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: - eslint: 10.12.0(supports-color@8.1.1) + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': {} + + '@eslint-community/eslint-utils@4.10.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))': + dependencies: + eslint: 10.12.0(jiti@2.7.0)(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1988,9 +2729,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.12.0(supports-color@8.1.1))': + '@eslint/js@10.0.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))': optionalDependencies: - eslint: 10.12.0(supports-color@8.1.1) + eslint: 10.12.0(jiti@2.7.0)(supports-color@8.1.1) '@eslint/object-schema@3.0.5': {} @@ -2312,8 +3053,170 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.2.7(@nestjs/common@11.2.7(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.7)(supports-color@8.1.1) + '@prisma/adapter-pg@7.10.0': + dependencies: + '@prisma/driver-adapter-utils': 7.10.0 + '@types/pg': 8.23.1 + pg: 8.23.1 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.10.0': {} + + '@prisma/client@7.10.0(prisma@7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.10.0 + optionalDependencies: + prisma: 7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.10.0': + dependencies: + c12: 3.3.4 + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.10.0': {} + + '@prisma/debug@7.2.0': {} + + '@prisma/dev@0.24.17(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) + '@electric-sql/pglite-tools': 0.3.3(@electric-sql/pglite@0.4.3) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.11 + find-my-way: 9.7.0 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.4.2(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.10.0': + dependencies: + '@prisma/debug': 7.10.0 + + '@prisma/engines-version@7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3': {} + + '@prisma/engines@7.10.0': + dependencies: + '@prisma/debug': 7.10.0 + '@prisma/engines-version': 7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3 + '@prisma/fetch-engine': 7.10.0 + '@prisma/get-platform': 7.10.0 + + '@prisma/fetch-engine@7.10.0': + dependencies: + '@prisma/debug': 7.10.0 + '@prisma/engines-version': 7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3 + '@prisma/get-platform': 7.10.0 + + '@prisma/get-platform@7.10.0': + dependencies: + '@prisma/debug': 7.10.0 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.11': + dependencies: + ajv: 8.20.0 + better-result: 2.10.0 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.33.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/react': 19.3.0 + '@visx/curve': 4.0.1-alpha.0 + '@visx/event': 4.0.1-alpha.0 + '@visx/grid': 4.0.1-alpha.0(react@19.3.0) + '@visx/group': 4.0.1-alpha.0(react@19.3.0) + '@visx/responsive': 4.0.1-alpha.0(react@19.3.0) + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.3.0) + d3-array: 3.2.4 + d3-shape: 3.2.0 + elkjs: 0.11.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + transitivePeerDependencies: + - '@types/react-dom' + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.3.0)(react@19.3.0)': + dependencies: + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-slot@1.2.3(@types/react@19.3.0)(react@19.3.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-toggle@1.1.10(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.3.0)(react@19.3.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.3.0)(react@19.3.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.3.0)(react@19.3.0)': + dependencies: + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + '@scarf/scarf@1.4.0': {} + '@standard-schema/spec@1.1.0': {} + '@tokenizer/inflate@0.4.1(supports-color@8.1.1)': dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -2332,6 +3235,36 @@ snapshots: dependencies: '@types/node': 24.19.2 + '@types/d3-array@3.0.3': {} + + '@types/d3-color@3.1.0': {} + + '@types/d3-delaunay@6.0.1': {} + + '@types/d3-format@3.0.1': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-interpolate@3.0.1': + dependencies: + '@types/d3-color': 3.1.0 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.2': + dependencies: + '@types/d3-time': 3.0.0 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@2.1.0': {} + + '@types/d3-time@3.0.0': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -2359,6 +3292,8 @@ snapshots: '@types/express-serve-static-core': 5.1.3 '@types/serve-static': 2.2.0 + '@types/geojson@7946.0.16': {} + '@types/http-errors@2.0.5': {} '@types/json-schema@7.0.15': {} @@ -2368,16 +3303,28 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 24.19.2 + '@types/lodash@4.17.26': {} + '@types/ms@2.1.0': {} '@types/node@24.19.2': dependencies: undici-types: 7.24.6 + '@types/pg@8.23.1': + dependencies: + '@types/node': 24.19.2 + pg-protocol: 1.16.1 + pg-types: 2.2.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + '@types/send@1.2.1': dependencies: '@types/node': 24.19.2 @@ -2389,15 +3336,15 @@ snapshots: '@types/validator@13.15.10': {} - '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.71.1 - '@typescript-eslint/type-utils': 8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.71.1 - eslint: 10.12.0(supports-color@8.1.1) + eslint: 10.12.0(jiti@2.7.0)(supports-color@8.1.1) ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -2405,14 +3352,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/parser@8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.71.1 '@typescript-eslint/types': 8.71.1 '@typescript-eslint/typescript-estree': 8.71.1(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.12.0(supports-color@8.1.1) + eslint: 10.12.0(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2435,13 +3382,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.71.1 '@typescript-eslint/typescript-estree': 8.71.1(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) debug: 4.4.3(supports-color@8.1.1) - eslint: 10.12.0(supports-color@8.1.1) + eslint: 10.12.0(jiti@2.7.0)(supports-color@8.1.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -2464,13 +3411,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/utils@8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1)) '@typescript-eslint/scope-manager': 8.71.1 '@typescript-eslint/types': 8.71.1 '@typescript-eslint/typescript-estree': 8.71.1(supports-color@8.1.1)(typescript@5.9.3) - eslint: 10.12.0(supports-color@8.1.1) + eslint: 10.12.0(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2480,6 +3427,83 @@ snapshots: '@typescript-eslint/types': 8.71.1 eslint-visitor-keys: 5.0.1 + '@visx/curve@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/event@4.0.1-alpha.0': + dependencies: + '@types/react': 19.3.0 + '@visx/point': 4.0.1-alpha.0 + + '@visx/grid@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.3.0) + '@visx/point': 4.0.1-alpha.0 + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.3.0) + classnames: 2.5.1 + react: 19.3.0 + + '@visx/group@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + classnames: 2.5.1 + react: 19.3.0 + + '@visx/point@4.0.1-alpha.0': {} + + '@visx/responsive@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/lodash': 4.17.26 + '@types/react': 19.3.0 + lodash: 4.18.1 + react: 19.3.0 + + '@visx/scale@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/shape@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/lodash': 4.17.26 + '@types/react': 19.3.0 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.3.0) + '@visx/scale': 4.0.1-alpha.0 + '@visx/vendor': 4.0.0-alpha.0 + classnames: 2.5.1 + lodash: 4.18.1 + react: 19.3.0 + + '@visx/vendor@4.0.0-alpha.0': + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.1.1 + '@types/d3-scale': 4.0.2 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -2575,6 +3599,10 @@ snapshots: acorn@8.19.0: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -2623,12 +3651,16 @@ snapshots: ansis@4.2.0: {} + any-promise@1.3.0: {} + append-field@1.0.0: {} argparse@2.0.1: {} array-timsort@1.0.3: {} + aws-ssl-profiles@1.1.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -2637,12 +3669,20 @@ snapshots: baseline-browser-mapping@2.11.28: {} + before@0.0.1: {} + + better-result@2.10.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 + block-stream2@2.1.0: + dependencies: + readable-stream: 3.6.2 + body-parser@2.3.0(supports-color@8.1.1): dependencies: bytes: 3.1.2 @@ -2689,6 +3729,21 @@ snapshots: bytes@3.1.2: {} + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.3 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.3 + rc9: 3.1.0 + cacheable@2.5.0: dependencies: '@cacheable/memory': 2.2.0 @@ -2722,6 +3777,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + chrome-trace-event@1.0.4: {} class-transformer@0.5.1: {} @@ -2732,6 +3791,8 @@ snapshots: libphonenumber-js: 1.13.15 validator: 13.15.35 + classnames@2.5.1: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -2765,6 +3826,10 @@ snapshots: concat-map@0.0.1: {} + confbox@0.2.4: {} + + confbox@0.3.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -2775,6 +3840,8 @@ snapshots: cookie@0.7.2: {} + copy-to@2.0.1: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -2789,12 +3856,62 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crc32@0.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + + d3-array@3.2.1: + dependencies: + internmap: 2.0.3 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-delaunay@6.0.2: + dependencies: + delaunator: 5.1.0 + + d3-format@3.1.0: {} + + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -2803,14 +3920,36 @@ snapshots: deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} + default-user-agent@1.0.0: + dependencies: + os-name: 1.0.3 + defaults@1.0.4: dependencies: clone: 1.0.4 + defu@6.1.7: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + denque@2.1.0: {} + depd@2.0.0: {} + destr@2.0.5: {} + + destroy@1.2.0: {} + + digest-header@1.1.0: {} + + dotenv@17.4.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2823,17 +3962,34 @@ snapshots: ee-first@1.1.1: {} + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.452: {} + elkjs@0.11.1: {} + emoji-regex@8.0.0: {} + empathic@2.0.0: {} + + encodeurl@1.0.2: {} + encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.26.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + env-paths@3.0.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -2870,9 +4026,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.12.0(supports-color@8.1.1): + eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5(supports-color@8.1.1) '@eslint/config-helpers': 0.7.0 @@ -2902,6 +4058,8 @@ snapshots: minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -2964,12 +4122,28 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.1.3: {} + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-safe-stringify@2.1.1: {} fast-uri@3.1.8: {} @@ -3002,6 +4176,12 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way@9.7.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.2 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3015,6 +4195,11 @@ snapshots: flatted@3.4.4: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.2): dependencies: '@babel/code-frame': 7.29.7 @@ -3032,6 +4217,13 @@ snapshots: typescript: 5.9.3 webpack: 5.106.2 + formstream@1.5.2: + dependencies: + destroy: 1.2.0 + mime: 2.6.0 + node-hex: 1.0.1 + pause-stream: 0.0.11 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -3046,6 +4238,10 @@ snapshots: function-bind@1.1.2: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3059,11 +4255,15 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-port-please@3.2.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + giget@3.3.1: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -3082,6 +4282,10 @@ snapshots: graceful-fs@4.2.11: {} + grammex@3.1.13: {} + + graphmatch@1.1.1: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -3108,6 +4312,14 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -3127,10 +4339,14 @@ snapshots: inherits@2.0.4: {} + internmap@2.0.3: {} + ipaddr.js@1.9.1: {} is-arrayish@0.2.1: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -3143,6 +4359,8 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + is-unicode-supported@0.1.0: {} isexe@2.0.0: {} @@ -3155,6 +4373,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jiti@2.7.0: {} + js-tokens@4.0.0: {} js-yaml@4.3.2: @@ -3249,8 +4469,12 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + lru-cache@11.5.3: {} + lru.min@1.1.5: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -3281,6 +4505,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@2.6.0: {} + mimic-fn@2.1.0: {} minimatch@10.2.6: @@ -3295,6 +4521,12 @@ snapshots: minipass@7.1.3: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mockdate@3.0.5: {} + ms@2.1.3: {} multer@2.4.0: @@ -3305,6 +4537,28 @@ snapshots: mute-stream@2.0.0: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.5 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.5 + natural-compare@1.4.0: {} negotiator@1.1.0: @@ -3319,12 +4573,16 @@ snapshots: dependencies: lodash: 4.18.1 + node-hex@1.0.1: {} + node-releases@2.0.58: {} object-assign@4.1.1: {} object-inspect@1.13.4: {} + ohash@2.0.12: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -3358,6 +4616,15 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + os-name@1.0.3: + dependencies: + osx-release: 1.1.0 + win-release: 1.1.1 + + osx-release@1.1.0: + dependencies: + minimist: 1.2.8 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -3392,29 +4659,140 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + + pause-stream@0.0.11: + dependencies: + through: 2.3.8 + + perfect-debounce@2.1.0: {} + + pg-cloudflare@1.4.1: + optional: true + + pg-connection-string@2.14.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.1): + dependencies: + pg: 8.23.1 + + pg-protocol@1.16.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.1: + dependencies: + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.23.1) + pg-protocol: 1.16.1 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.4: {} picomatch@4.0.7: {} + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.3 + pathe: 2.0.3 + pluralize@8.0.0: {} + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + prelude-ls@1.2.1: {} prettier@3.9.10: {} + prisma@7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.10.0 + '@prisma/dev': 0.24.17(typescript@5.9.3) + '@prisma/engines': 7.10.0 + '@prisma/studio-core': 0.33.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} + pure-rand@6.1.0: {} + qified@0.10.1: dependencies: hookified: 2.2.0 + qiniu@7.15.2: + dependencies: + agentkeepalive: 4.6.0 + before: 0.0.1 + block-stream2: 2.1.0 + crc32: 0.2.2 + destroy: 1.2.0 + encodeurl: 1.0.2 + formstream: 1.5.2 + mime: 2.6.0 + mkdirp: 0.5.6 + mockdate: 3.0.5 + tunnel-agent: 0.6.0 + typescript: 4.9.5 + urllib: 2.44.1 + transitivePeerDependencies: + - proxy-agent + qs@6.16.0: dependencies: es-define-property: 1.0.1 @@ -3429,6 +4807,18 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + rc9@3.1.0: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react@19.3.0: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -3437,8 +4827,12 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.1.1: {} + reflect-metadata@0.2.2: {} + remeda@2.33.4: {} + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -3448,6 +4842,12 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + ret@0.5.0: {} + + retry@0.12.0: {} + + robust-predicates@3.0.3: {} + router@2.2.0(supports-color@8.1.1): dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -3468,8 +4868,14 @@ snapshots: safe-buffer@5.2.1: {} + safe-regex2@5.1.2: + dependencies: + ret: 0.5.0 + safer-buffer@2.1.2: {} + scheduler@0.28.0: {} + schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 @@ -3483,6 +4889,8 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) + semver@5.7.2: {} + semver@7.8.5: {} send@1.2.1(supports-color@8.1.1): @@ -3501,6 +4909,8 @@ snapshots: transitivePeerDependencies: - supports-color + seq-queue@0.0.5: {} + serve-static@2.2.1(supports-color@8.1.1): dependencies: encodeurl: 2.0.0 @@ -3559,8 +4969,16 @@ snapshots: source-map@0.7.4: {} + split2@4.2.0: {} + + sqlstring@2.3.3: {} + + statuses@1.5.0: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + streamsearch@1.1.0: {} string-width@4.2.3: @@ -3614,6 +5032,16 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + through@2.3.8: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -3646,6 +5074,10 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3661,17 +5093,19 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 - typescript-eslint@8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3): + typescript-eslint@8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/parser': 8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.71.1(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - eslint: 10.12.0(supports-color@8.1.1) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + eslint: 10.12.0(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color + typescript@4.9.5: {} + typescript@5.9.3: {} uid@2.0.2: @@ -3682,6 +5116,10 @@ snapshots: undici-types@7.24.6: {} + unescape@1.0.1: + dependencies: + extend-shallow: 2.0.1 + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -3696,8 +5134,35 @@ snapshots: dependencies: punycode: 2.3.1 + urllib@2.44.1: + dependencies: + any-promise: 1.3.0 + content-type: 1.0.5 + default-user-agent: 1.0.0 + digest-header: 1.1.0 + ee-first: 1.1.1 + formstream: 1.5.2 + humanize-ms: 1.2.1 + iconv-lite: 0.6.3 + pump: 3.0.4 + qs: 6.16.0 + statuses: 1.5.0 + utility: 1.18.0 + util-deprecate@1.0.2: {} + utility@1.18.0: + dependencies: + copy-to: 2.0.1 + escape-html: 1.0.3 + mkdirp: 0.5.6 + mz: 2.7.0 + unescape: 1.0.1 + + valibot@1.4.2(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + validator@13.15.35: {} vary@1.1.2: {} @@ -3758,6 +5223,10 @@ snapshots: dependencies: isexe: 2.0.0 + win-release@1.1.1: + dependencies: + semver: 5.7.2 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: @@ -3768,8 +5237,15 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + yargs-parser@21.1.1: {} yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.13 + graphmatch: 1.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d32cc38..53e77f0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,3 +9,7 @@ strictPeerDependencies: true allowBuilds: '@nestjs/core': false '@scarf/scarf': false + + # 引擎包允许准备官方引擎;客户端由项目脚本统一生成。 + '@prisma/engines': true + prisma: false