diff --git a/.editorconfig b/.editorconfig index 86a63dc..9c61627 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,6 +4,6 @@ root = true charset = utf-8 end_of_line = lf indent_style = space -indent_size = 2 +indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e2cb704 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# 仅用于本地 Docker Compose,命令从 doodle-store 根目录执行。 +POSTGRES_PORT=5434 +POSTGRES_DB=doodle_dev +POSTGRES_USER=doodle_dev +# 本地开发专用;复制为 .env 后替换,真实密码不要提交仓库。 +POSTGRES_PASSWORD=change-me-local-only diff --git a/.prettierrc.json b/.prettierrc.json index a20502b..04f5f99 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,4 +1,6 @@ { - "singleQuote": true, - "trailingComma": "all" + "tabWidth": 4, + "useTabs": false, + "singleQuote": true, + "trailingComma": "all" } diff --git a/README.md b/README.md index 3034fee..85233c1 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,17 @@ ## 当前范围 -本次仅初始化工程,不开发具体业务: +当前完成基础工程和本地数据库环境,具体业务尚未接入: - `apps/server`:可运行的 NestJS 服务,只有进程健康检查。 - `packages/contracts`:基础成功响应与健康检查类型,不包含业务字段或私有实现。 - `apps/admin`、`apps/web`:仅预留目录与说明,尚未选择或安装前端框架。 -- 数据库、Prisma、微信登录、七牛、定时任务、业务接口与生产部署尚未接入。 +- 本地 PostgreSQL 18.6 Docker 开发环境已启动,10 张表及初始配置已创建;Prisma、微信登录、七牛接口、定时任务、业务接口与生产部署尚未接入。 ## 本地使用 +先按 [本地数据库说明](infra/README.md) 配置根 `.env`,执行 `pnpm infra:up`。默认数据库地址为 `127.0.0.1:5434`,开发库为 `doodle_dev`。图片等资源直接使用七牛,本地仅启动 PostgreSQL。生产与按需测试的安排见 [v3.5 开发与部署环境方案](docs/v3.5/开发与部署环境方案.md)。 + 使用 Node.js **24.20.0** 与 pnpm **11.24.0**。如使用 fnm,可在本目录执行 `fnm use --install-if-missing`。 ```bash @@ -42,6 +44,7 @@ apps/ packages/ contracts/ 可公开的接口类型 deploy/ 后续部署说明,当前未部署 +infra/ 本地 PostgreSQL Docker 配置 docs/ 工程初始化与接入边界说明 ``` diff --git a/apps/server/.env.example b/apps/server/.env.example index 275ac90..6a1af3d 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -1,3 +1,6 @@ NODE_ENV=development HOST=127.0.0.1 PORT=3000 +# 后续 Prisma 接入使用;用户名/密码/端口须与根 .env 一致,密码需 URL 编码。 +# 当前骨架尚未读取 DATABASE_URL;不要提交实际连接凭证。 +DATABASE_URL=postgresql://doodle_dev:change-me-local-only@127.0.0.1:5434/doodle_dev?schema=public diff --git a/apps/server/nest-cli.json b/apps/server/nest-cli.json index 2f068a1..e11effa 100644 --- a/apps/server/nest-cli.json +++ b/apps/server/nest-cli.json @@ -1,9 +1,9 @@ { - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true, - "tsConfigPath": "tsconfig.build.json" - } + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "tsConfigPath": "tsconfig.build.json" + } } diff --git a/apps/server/package.json b/apps/server/package.json index e4e6e73..0116e43 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,27 +1,27 @@ { - "name": "@doodle/server", - "version": "0.1.0", - "private": true, - "type": "commonjs", - "scripts": { - "dev": "nest start --watch", - "build": "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" - }, - "dependencies": { - "@doodle/contracts": "workspace:*", - "@nestjs/common": "11.2.7", - "@nestjs/core": "11.2.7", - "@nestjs/platform-express": "11.2.7", - "reflect-metadata": "0.2.2", - "rxjs": "7.8.2" - }, - "devDependencies": { - "@nestjs/cli": "11.0.24", - "@nestjs/testing": "11.2.7", - "@types/node": "24.19.2", - "typescript": "5.9.3" - } + "name": "@doodle/server", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "scripts": { + "dev": "nest start --watch", + "build": "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" + }, + "dependencies": { + "@doodle/contracts": "workspace:*", + "@nestjs/common": "11.2.7", + "@nestjs/core": "11.2.7", + "@nestjs/platform-express": "11.2.7", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.2" + }, + "devDependencies": { + "@nestjs/cli": "11.0.24", + "@nestjs/testing": "11.2.7", + "@types/node": "24.19.2", + "typescript": "5.9.3" + } } diff --git a/apps/server/prisma/README.md b/apps/server/prisma/README.md index 2a90e98..19a6adc 100644 --- a/apps/server/prisma/README.md +++ b/apps/server/prisma/README.md @@ -1,6 +1,6 @@ -# 数据库结构预留目录 +# 数据库结构与初始化 SQL -后续用于 Prisma 数据模型与版本化数据库迁移。当前未安装 Prisma、未定义表结构,也未连接或创建数据库。 +已提供 [v3.5 初始化 SQL](./sql/README.md),包含建库、10 张表的基线结构和配置初始化,已通过本地临时库验证。当前未安装 Prisma、未生成 Prisma Schema,也未在应用中连接数据库;doodle_dev 已于 2026-10-12 正式创建 10 张表及初始配置。本地 PostgreSQL 数据库由 [infra](../../../infra/README.md) 的 Docker 配置提供。 数据库已确认使用 PostgreSQL + Prisma;具体兼容版本在实施时验证并锁定,保留原用户和题型的字符串 ID。目标设计见 [v3.5 数据库设计文档](../../../docs/v3.5/数据库设计文档.md),当前尚未创建 Schema 或实施数据库迁移。 diff --git a/apps/server/prisma/sql/001-create-database.sql b/apps/server/prisma/sql/001-create-database.sql new file mode 100644 index 0000000..ad08e86 --- /dev/null +++ b/apps/server/prisma/sql/001-create-database.sql @@ -0,0 +1,14 @@ +-- PostgreSQL 18;连接 postgres 管理库,以有 CREATEDB 权限的角色执行。 +-- Docker 首次启动已创建 doodle_dev 时跳过本文件,不删除或重建已有库。 +-- 如需其他环境,显式修改数据库名;首期不自动创建云端测试/生产库。 +-- CREATE DATABASE 不能在事务中执行。 +CREATE DATABASE doodle_dev + WITH TEMPLATE = template0 + ENCODING = 'UTF8' + LOCALE_PROVIDER = libc + LC_COLLATE = 'C' + LC_CTYPE = 'C'; + +ALTER DATABASE doodle_dev SET timezone TO 'UTC'; + +COMMENT ON DATABASE doodle_dev IS 'Doodle 本地开发数据库;生产与测试使用独立数据库及凭证'; diff --git a/apps/server/prisma/sql/002-create-tables.sql b/apps/server/prisma/sql/002-create-tables.sql new file mode 100644 index 0000000..e88d888 --- /dev/null +++ b/apps/server/prisma/sql/002-create-tables.sql @@ -0,0 +1,294 @@ +-- v3.5 PostgreSQL 基线结构。连接目标空数据库后执行。 +-- 单事务创建;失败全部回滚;不使用 IF NOT EXISTS 掩盖既有结构差异。 +-- 主键由服务端生成或使用旧 CloudBase _id,SQL 不重新生成旧 ID。 +BEGIN; +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, + mini_app_id varchar(64) COLLATE "C" NOT NULL, + openid varchar(128) COLLATE "C" NOT NULL, + unionid varchar(128) COLLATE "C", + nick_name varchar(100), + 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_mini_app_openid_key UNIQUE (mini_app_id, 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', '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, + 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', + 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_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); + +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, + created_at timestamptz(3) NOT NULL DEFAULT now(), + CONSTRAINT favorites_user_id_fkey FOREIGN KEY (user_id) + REFERENCES public.users (id) ON UPDATE RESTRICT ON DELETE RESTRICT, + CONSTRAINT favorites_user_worksheet_key UNIQUE (user_id, worksheet_id) +); + +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, + 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,按 AppID + OpenID 唯一'; +COMMENT ON COLUMN public.users.total_downloads IS '旧字段迁移兼容值;个人下载数量以 download_logs 查询为准'; +COMMENT ON COLUMN public.users.auth_version IS '应用凭证版本;服务端鉴权校验,不由 SQL 自动递增'; +COMMENT ON TABLE public.assets IS '七牛文件引用与旧资源迁移映射;不保存临时签名 URL'; +COMMENT ON COLUMN public.assets.object_ref_hash IS '规范序列化 [provider,bucket,objectKey] 的 SHA-256;服务端生成并核对原引用'; +COMMENT ON COLUMN public.assets.checksum IS '文件内容 SHA-256,不是七牛 ETag;非唯一'; +COMMENT ON TABLE public.categories IS '分类树;多层循环需由服务端事务检查'; +COMMENT ON TABLE public.worksheets IS '题型内容与真实/种子计数;资源通过 assets 引用'; +COMMENT ON COLUMN public.worksheets.content_updated_at IS '仅内容修改时更新;不随计数变化;历史缺失保留 NULL'; +COMMENT ON COLUMN public.worksheets.updated_at IS '由服务显式更新;包含计数、状态、内容变更'; +COMMENT ON COLUMN public.worksheets.tags IS '字符串数组;数据库限制顶层 array,服务端校验元素'; +COMMENT ON TABLE public.favorites IS '当前收藏关系;取消收藏删除关系并由业务事务扣减 likes'; +COMMENT ON COLUMN public.favorites.worksheet_id IS '题型逻辑关联,不建外键,允许保留缺失题型的历史收藏'; +COMMENT ON TABLE public.download_logs IS '成功保存历史;同用户 exportId 唯一,日志与计数由业务事务同步'; +COMMENT ON COLUMN public.download_logs.worksheet_id IS '题型逻辑关联,不建外键,历史查询使用 LEFT JOIN'; +COMMENT ON COLUMN public.download_logs.export_id IS '历史 import 可为 NULL;mini/legacy 必须提供非空事件 ID'; +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 '管理变更审计;操作者为逻辑关联,注销需主动去关联'; + +COMMIT; diff --git a/apps/server/prisma/sql/003-initialize-settings.sql b/apps/server/prisma/sql/003-initialize-settings.sql new file mode 100644 index 0000000..c020bf0 --- /dev/null +++ b/apps/server/prisma/sql/003-initialize-settings.sql @@ -0,0 +1,14 @@ +-- 初始配置锁行;可重复执行,绝不覆盖已经导入的页面、版本或 enabled=false。 +-- 不插入假用户、假题型或生产管理员;不启动任何调度器。 +BEGIN; +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/sql/README.md b/apps/server/prisma/sql/README.md new file mode 100644 index 0000000..b7131f1 --- /dev/null +++ b/apps/server/prisma/sql/README.md @@ -0,0 +1,49 @@ +# v3.5 PostgreSQL 初始化 SQL + +基于 [数据库设计](../../../../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 张表与两条配置记录核对通过。 + +| 文件 | 作用 | 执行位置 | +| ------------------------------------------------------------ | ---------------------------------------------- | ---------------------------------------------------------- | +| [001-create-database.sql](./001-create-database.sql) | 创建 UTF-8 / C locale / UTC 的 doodle_dev | 连接 postgres 管理数据库,有 CREATEDB 权限;不能包在事务里 | +| [002-create-tables.sql](./002-create-tables.sql) | 创建 10 张表、约束、索引与注释 | 连接目标空数据库;单事务执行 | +| [003-initialize-settings.sql](./003-initialize-settings.sql) | 初始化 current 页面配置及 homeAutoRefresh 开关 | 连接已建表数据库;可重复执行且不覆盖旧值 | + +Docker 已在首次启动时创建 doodle_dev,**当前本地跳过 001**。其他环境显式修改 001 的数据库名并用对应角色执行;本次不自动创建云端数据库、生产账号或测试库,不在 SQL 中硬编码密码。生产角色和权限在部署时落实。 + +## 本地执行 + +从 `doodle-store` 根目录执行,使用根 `.env` 指定的本地数据库: + +```bash +pnpm infra:up + +docker compose --env-file .env -f infra/compose.yaml exec -T postgres \ + sh -c 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < apps/server/prisma/sql/002-create-tables.sql + +docker compose --env-file .env -f infra/compose.yaml exec -T postgres \ + sh -c 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < apps/server/prisma/sql/003-initialize-settings.sql +``` + +002 用显式 CREATE TABLE 而非 IF NOT EXISTS;已有同名表时返回错误并回滚,不覆盖已有结构或数据。它是空库基线,不是可反复执行的增量迁移;后续结构修改写新迁移文件。003 的重复执行不会覆盖导入内容、已递增版本或 enabled=false。 + +```sql +SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename; +SELECT id, version FROM public.page_configs; +SELECT id, value FROM public.settings; +``` + +预期业务表共 10 张,页面配置有 current,设置有 homeAutoRefresh。2026-10-12 已在本地 doodle_dev 执行 002/003 并核对通过,当前已有 10 张表;不要再次执行 002。独立临时库验证与正式本地初始化分别记录,未连接云服务器。 + +## 实施边界 + +- 字符串主键保留旧 ID,应用生成新 ID;所有身份/对象标识使用 C 排序规则。 +- 收藏/下载的用户外键限制直接删除;注销需先清关系、处理计数,再删用户。题型仅逻辑关联,保留缺失题型历史。 +- 资产 ready 必填元数据、avatar 必须 private;资产归属、用途匹配、签名地址、远程 URL 白名单和文件存在性仍由服务端校验。 +- 下载 NULL exportId 只允许 import 来源;非空事件按 userId + exportId 去重。计数不会通过触发器自动更新,由后续业务事务实现。 +- tags 元素类型、分类多层循环、任务租约/锁、JWT 失效与管理员权限由后续业务实现;SQL 不代表业务 API 已完成。 +- updatedAt 和 contentUpdatedAt 由服务显式维护,不添加把两者混为一谈的更新时间触发器。 +- Prisma 尚未安装,Schema 和迁移历史尚未接入。后续接入时将本 SQL 纳入正式基线;已有表的库须采用基线登记流程,不能直接再次执行同一 DDL。 diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 61bfc42..98dcf62 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -2,6 +2,6 @@ import { Module } from '@nestjs/common'; import { HealthController } from './health/health.controller'; @Module({ - controllers: [HealthController], + controllers: [HealthController], }) export class AppModule {} diff --git a/apps/server/src/app.setup.ts b/apps/server/src/app.setup.ts index 424a688..09d610a 100644 --- a/apps/server/src/app.setup.ts +++ b/apps/server/src/app.setup.ts @@ -1,6 +1,6 @@ import type { INestApplication } from '@nestjs/common'; export function configureApplication(app: INestApplication): void { - app.setGlobalPrefix('api/v1'); - app.enableShutdownHooks(); + app.setGlobalPrefix('api/v1'); + app.enableShutdownHooks(); } diff --git a/apps/server/src/common/runtime-options.ts b/apps/server/src/common/runtime-options.ts index 0fded4a..1e569ff 100644 --- a/apps/server/src/common/runtime-options.ts +++ b/apps/server/src/common/runtime-options.ts @@ -1,27 +1,27 @@ export interface RuntimeOptions { - host: string; - port: number; + host: string; + port: number; } export function getRuntimeOptions( - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = process.env, ): RuntimeOptions { - const rawPort = env.PORT ?? '3000'; - const port = Number(rawPort); - const host = (env.HOST ?? '127.0.0.1').trim(); + const rawPort = env.PORT ?? '3000'; + const port = Number(rawPort); + const host = (env.HOST ?? '127.0.0.1').trim(); - if ( - !/^\d+$/.test(rawPort) || - !Number.isInteger(port) || - port < 1 || - port > 65535 - ) { - throw new Error('PORT must be an integer between 1 and 65535'); - } + if ( + !/^\d+$/.test(rawPort) || + !Number.isInteger(port) || + port < 1 || + port > 65535 + ) { + throw new Error('PORT must be an integer between 1 and 65535'); + } - if (!host) { - throw new Error('HOST must not be empty'); - } + if (!host) { + throw new Error('HOST must not be empty'); + } - return { host, port }; + return { host, port }; } diff --git a/apps/server/src/health/health.controller.ts b/apps/server/src/health/health.controller.ts index 561995b..1c33259 100644 --- a/apps/server/src/health/health.controller.ts +++ b/apps/server/src/health/health.controller.ts @@ -3,15 +3,15 @@ import type { ApiSuccess, HealthStatus } from '@doodle/contracts'; @Controller('health') export class HealthController { - @Get() - getHealth(): ApiSuccess { - return { - code: 0, - message: 'ok', - data: { - status: 'ok', - service: 'doodle-store-server', - }, - }; - } + @Get() + getHealth(): ApiSuccess { + return { + code: 0, + message: 'ok', + data: { + status: 'ok', + service: 'doodle-store-server', + }, + }; + } } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 9faf253..5f1d2c7 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -6,26 +6,29 @@ import { configureApplication } from './app.setup'; import { getRuntimeOptions } from './common/runtime-options'; async function bootstrap(): Promise { - try { - process.loadEnvFile(); - } catch (error) { - if (!( - error instanceof Error && - 'code' in error && - error.code === 'ENOENT' - )) { - throw error; + try { + process.loadEnvFile(); + } catch (error) { + if (!( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + )) { + throw error; + } } - } - const { port, host } = getRuntimeOptions(); - const app = await NestFactory.create(AppModule); - configureApplication(app); - await app.listen(port, host); - Logger.log(`Listening on http://${host}:${port}/api/v1/health`, 'Bootstrap'); + const { port, host } = getRuntimeOptions(); + const app = await NestFactory.create(AppModule); + configureApplication(app); + await app.listen(port, host); + Logger.log( + `Listening on http://${host}:${port}/api/v1/health`, + 'Bootstrap', + ); } void bootstrap().catch((error: unknown) => { - Logger.error(error, undefined, 'Bootstrap'); - process.exit(1); + Logger.error(error, undefined, 'Bootstrap'); + process.exit(1); }); diff --git a/apps/server/test/health.test.ts b/apps/server/test/health.test.ts index 495caa6..0c92360 100644 --- a/apps/server/test/health.test.ts +++ b/apps/server/test/health.test.ts @@ -6,32 +6,32 @@ import { AppModule } from '../src/app.module'; import { configureApplication } from '../src/app.setup'; test('服务可以启动并通过带版本前缀的 HTTP 健康检查', async (context) => { - const testingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - const app = testingModule.createNestApplication({ logger: false }); - context.after(async () => { - await app.close(); - }); - configureApplication(app); - await app.listen(0, '127.0.0.1'); - const origin = await app.getUrl(); + const testingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + const app = testingModule.createNestApplication({ logger: false }); + context.after(async () => { + await app.close(); + }); + configureApplication(app); + await app.listen(0, '127.0.0.1'); + const origin = await app.getUrl(); - const response = await fetch(`${origin}/api/v1/health`, { - signal: AbortSignal.timeout(5000), - }); - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { - code: 0, - message: 'ok', - data: { - status: 'ok', - service: 'doodle-store-server', - }, - }); + const response = await fetch(`${origin}/api/v1/health`, { + signal: AbortSignal.timeout(5000), + }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + code: 0, + message: 'ok', + data: { + status: 'ok', + service: 'doodle-store-server', + }, + }); - const unversioned = await fetch(`${origin}/health`, { - signal: AbortSignal.timeout(5000), - }); - assert.equal(unversioned.status, 404); + const unversioned = await fetch(`${origin}/health`, { + signal: AbortSignal.timeout(5000), + }); + assert.equal(unversioned.status, 404); }); diff --git a/apps/server/test/runtime-options.test.ts b/apps/server/test/runtime-options.test.ts index 1481d7f..0fc6c66 100644 --- a/apps/server/test/runtime-options.test.ts +++ b/apps/server/test/runtime-options.test.ts @@ -3,25 +3,25 @@ import { test } from 'node:test'; import { getRuntimeOptions } from '../src/common/runtime-options'; test('使用本地默认 host 和 port', () => { - assert.deepEqual(getRuntimeOptions({}), { - host: '127.0.0.1', - port: 3000, - }); + assert.deepEqual(getRuntimeOptions({}), { + host: '127.0.0.1', + port: 3000, + }); }); test('读取显式 host 和 port', () => { - assert.deepEqual(getRuntimeOptions({ HOST: '0.0.0.0', PORT: '4000' }), { - host: '0.0.0.0', - port: 4000, - }); + assert.deepEqual(getRuntimeOptions({ HOST: '0.0.0.0', PORT: '4000' }), { + host: '0.0.0.0', + port: 4000, + }); }); test('拒绝非法端口', () => { - for (const port of ['', '0', '65536', '-1', '3000abc', '3.5', '1e3']) { - assert.throws(() => getRuntimeOptions({ PORT: port }), /PORT/); - } + for (const port of ['', '0', '65536', '-1', '3000abc', '3.5', '1e3']) { + assert.throws(() => getRuntimeOptions({ PORT: port }), /PORT/); + } }); test('拒绝空 host', () => { - assert.throws(() => getRuntimeOptions({ HOST: ' ' }), /HOST/); + assert.throws(() => getRuntimeOptions({ HOST: ' ' }), /HOST/); }); diff --git a/apps/server/tsconfig.build.json b/apps/server/tsconfig.build.json index 2b44eee..8076f75 100644 --- a/apps/server/tsconfig.build.json +++ b/apps/server/tsconfig.build.json @@ -1,9 +1,9 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist" - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "dist-test", "test"] + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "dist-test", "test"] } diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 1e3976f..c8aff72 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -1,16 +1,16 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "module": "Node16", - "moduleResolution": "Node16", - "lib": ["ES2022"], - "types": ["node"], - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "esModuleInterop": true, - "sourceMap": true, - "outDir": "dist" - }, - "include": ["src/**/*.ts", "test/**/*.ts"], - "exclude": ["node_modules", "dist", "dist-test"] + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "types": ["node"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "sourceMap": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist", "dist-test"] } diff --git a/apps/server/tsconfig.test.json b/apps/server/tsconfig.test.json index 03bb522..8ff48c5 100644 --- a/apps/server/tsconfig.test.json +++ b/apps/server/tsconfig.test.json @@ -1,8 +1,8 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": ".", - "outDir": "dist-test", - "sourceMap": false - } + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist-test", + "sourceMap": false + } } diff --git a/deploy/README.md b/deploy/README.md index a5c71fa..1abcd31 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,6 +1,8 @@ # 部署预留目录 -本次未部署服务、启动数据库或修改云服务器。 +本次未向云服务器部署服务或启动生产数据库,也未修改云服务器。 + +本地数据库配置位于 [infra](../infra/README.md),与云服务器部署分开维护。云端首期只常驻 PostgreSQL 生产库,真实联调按需创建同实例独立测试库;图片等资源直接使用七牛。详见 [v3.5 开发与部署环境方案](../docs/v3.5/开发与部署环境方案.md)。 后续加入 Docker Compose、Nginx、数据库备份与恢复配置,按应用独立部署;数据库不开放公网端口,真实环境变量不提交仓库。 diff --git a/docs/v3.5/开发与部署环境方案.md b/docs/v3.5/开发与部署环境方案.md new file mode 100644 index 0000000..b55716c --- /dev/null +++ b/docs/v3.5/开发与部署环境方案.md @@ -0,0 +1,70 @@ +# Doodle Store v3.5 开发与部署环境 + +> 更新日期:2026-10-12。 +> +> 已确认:PostgreSQL 本地 Docker 开发环境;图片等资源直接使用七牛。 + +本地验证结果:PostgreSQL 18.6 已启动并通过健康检查,UTF-8 / UTC,密码 TCP 登录成功;NULL exportId 唯一键和大小写匹配行为通过临时表事务验证,检查后已回滚。随后已在用户授权下正式初始化 doodle_dev,public 下业务表共 10 张,current 配置和 homeAutoRefresh 开关已创建。Prisma 与七牛业务接入尚未实施。 + +> 首期建议:云服务器只常驻生产数据库,不长期部署第二套测试系统;真实微信联调和迁移演练按需开独立测试环境。本次未修改云服务器或配置七牛账户。 + +## 1. 首期安排 + +当前项目用户少、后端以 CRUD 为主,已有服务器为 2 核 4 GB。采用本地开发 + 单套线上生产即可起步;上线前仍在隔离数据库完成迁移验证,不拿生产库做自动测试或试导。 + +| 场景 | 应用 | 数据库 | 资源 | +| ------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------- | +| 日常开发 | 本机 NestJS watch 模式 | 本机 Docker PostgreSQL / doodle_dev | 七牛开发资源空间或 dev 前缀 | +| 数据库自动测试 | 本机测试进程 | 本机同实例内独立 doodle_test,后续按需创建 | 测试替身;需要真实上传验证时使用七牛开发资源 | +| 微信体验版联调 / 迁移演练 | 按需部署测试 API,独立 HTTPS 域名及环境变量 | 先在本机试导;真实云端联调时同一云端 PostgreSQL 实例内独立 doodle_staging | 七牛测试资源,禁止覆盖生产对象 | +| 正式用户 | 云服务器生产 API | 云服务器 PostgreSQL / doodle_prod | 七牛生产资源 | + +**目前无需在云端常驻生产和测试两个数据库,更无需两个 PostgreSQL 容器。** 当云端联调确有需求时,同一 PostgreSQL 实例创建独立 staging 库和独立登录角色即可;测试 API 用另一端口/域名连接 staging,验收后停止测试进程并按保留规则归档临时库。单独数据库主要提供数据与权限隔离,仍共享实例 CPU/内存;试导控制并发,不与生产高峰争抢资源。 + +微信体验版与正式版不是两个独立 AppID,后端地址须根据客户端版本环境配置。独立测试 API 不接生产库、不使用生产登录 token 签名密钥,生产配置也不回退到测试地址。测试角色不能访问生产库,需通过 PostgreSQL 库级 CONNECT 权限及 schema/对象权限验证;不只依赖连接串约定。 + +以下情况出现后再考虑长期 staging:连续有真实微信联调需求;频繁修改数据库;新支付、会员或服务端生成流程;多人并行开发;数据规模导致一次迁移演练需要长时间。首期按实际需求启用,避免预先复制完整部署。 + +## 2. 本地 Docker + +参考项目 `/Users/joey-xd/sites/seller-store/atlas-seller-kit/infra/compose.yaml` 使用 PostgreSQL 容器、应用宿主机运行和命名卷持久化。本项目复用这种方式,数据库独立,未修改参考项目。 + +- Compose 文件:[infra/compose.yaml](../../infra/compose.yaml)。 +- 固定镜像:`postgres:18.6-alpine`,与参考项目保持一致;生产部署前验证并固定同一兼容主版本。 +- 默认地址:`127.0.0.1:5434`;数据库和用户均为 `doodle_dev`。 +- 数据卷挂载 `/var/lib/postgresql`,适配 PostgreSQL 18 的版本子目录布局。 +- 使用 UTF-8、C locale 和 UTC;业务任务仍明确采用 Asia/Shanghai。 +- 根 `.env` 保存 Compose 配置,服务端连接串保存在 `apps/server/.env`;真实文件均被 Git 忽略。 +- 本地已完成 10 张表及初始配置的创建;Prisma 与业务接入后续按 [数据库设计文档](./数据库设计文档.md) 实施。 + +使用步骤及 SQL 客户端连接见 [infra/README](../../infra/README.md)。本地故障和试导不会写入生产库;数据导入脚本后续应增加目标环境识别、dryRun 和生产写入保护。 + +## 3. 七牛直接使用与最小隔离 + +图片、头像、预览、字体及其他对象文件直接使用七牛;本地 PostgreSQL 只保存 assets 元数据与对象引用。开发无需仿真对象存储,不将图片存数据库或容器数据卷。上传 API 与七牛凭证尚待业务模块实施,本次不创建空间或上传文件。 + +尽量复用同一七牛账户与现有资源域名。访问策略保持公共预览/字体与私有头像区分,空间是否复用取决于现有空间访问属性。生产与开发至少使用不同对象 key 前缀,例如: + +```text +prod/previews/<资产ID>/preview.png +prod/avatars/<用户ID>/<资产ID>.png +dev/previews/<资产ID>/preview.png +dev/avatars/<测试用户ID>/<资产ID>.png +staging/... +``` + +前缀由服务端环境配置生成,客户端不能指定 prod;资源 key 不因重试或重新发布覆盖已有文件。开发可以读取既有公共生产预览/字体,开发上传及删除只作用于 dev/staging;测试私有头像使用测试文件。清理任务同样限制环境和前缀,不能跨环境扫描后删除未在本地数据库引用的生产对象。 + +**前缀主要防操作混用,不是七牛账户 AK/SK 的权限边界。** 个人首期开发可采用前缀隔离、服务端限制和受控密钥文件;涉及多人开发或自动删除时,应使用能实际限制开发权限的独立凭证/空间。若现有账号权限无法按所需范围限制,则创建独立开发空间及相应权限,不能把改空间名当作凭证权限已经隔离。 + +七牛配置只放服务端环境变量,包含访问凭证、公共/私有空间、资源域名和环境前缀;具体变量在 assets 模块接入时落实。数据库不保存过期签名 URL;私有资源由 API 按需生成地址。普通小程序用户不能获取账户 AK/SK。 + +## 4. 上线前最低流程 + +1. 在本机完成业务检查、数据库事务验证和全量 dryRun/试导,对账正常、合并、隔离及文件映射。 +2. 需要微信真实 HTTPS 联调时,临时部署独立测试 API 与 staging 库,体验版用测试数据完成真机验收;测试数据库可用脱敏快照,原始导出只在受控迁移位置处理。 +3. 正式迁移前备份生产及旧数据,停止旧任务和写入;按迁移方案最终导入、核对后切换到唯一生产库。 +4. 正式 API、定时任务与兼容桥只写 doodle_prod;测试任务关闭,测试上传不进入生产资源前缀。 +5. 生产数据库每日备份到服务器之外并演练恢复;生产更新保留上一可运行 API 版本。测试库不充当生产备份。 + +上述安排保持一套长期生产系统,临时测试仍有明确数据边界,后续需求增加时再扩展部署。切换和回滚详细步骤继续以 [云服务端迁移方案](../云服务端迁移方案.md) 为准。 diff --git a/docs/v3.5/数据库设计文档.md b/docs/v3.5/数据库设计文档.md index 48a7e8f..894a301 100644 --- a/docs/v3.5/数据库设计文档.md +++ b/docs/v3.5/数据库设计文档.md @@ -2,12 +2,16 @@ > 数据库:PostgreSQL;ORM:Prisma。数据库选型已按用户确认更新。 > -> 状态:目标设计,尚未创建数据库、Prisma Schema 或业务迁移;线上集合、数据量和异常分布待 S0 盘点。 +> 状态:目标设计,本地开发数据库已配置;10 张业务表及初始配置已在本地创建;尚未创建 Prisma Schema 或接入 Prisma 迁移历史;线上集合、数据量和异常分布待 S0 盘点。 > > 更新日期:2026-10-12。 > > 配套方案:[云服务端迁移方案](../云服务端迁移方案.md)。本文细化该方案的数据结构与一致性规则。 +已生成 [数据库初始化 SQL](../../apps/server/prisma/sql/README.md),建库、10 张表及配置初始化已在 PostgreSQL 18.6 临时库执行验证,19 项约束检查通过;临时库已清理,开发库随后已正式执行基线 SQL,创建 10 张表和两条配置记录;未创建 Prisma Schema 或导入线上数据。 + +本地开发使用 Docker PostgreSQL,图片等资源直接使用七牛;云服务器首期只常驻生产库,隔离测试库在真实联调时按需创建,可复用同一 PostgreSQL 实例。详见 [开发与部署环境方案](./开发与部署环境方案.md) 与 [本地数据库说明](../../infra/README.md)。本地数据库已配置,本文 10 张表已在本地创建,业务 API 尚未实现。 + ## 1. 范围与依据 首期迁移现有微信小程序后端,统一使用 PostgreSQL 主数据库、NestJS API 和七牛文件存储。小程序仍在 `doodle-mini` 维护,数据库模型与迁移只放在 `doodle-store/apps/server/prisma`。Web 与管理后台通过 API 访问,不直接连接数据库。 @@ -170,6 +174,8 @@ UNIQUE `(user_id, worksheet_id)`;列表索引 `(user_id, created_at DESC, id D UNIQUE `(user_id, export_id)` 使用 PostgreSQL 默认 NULL 语义,允许同用户多条 export_id=NULL;**不使用 NULLS NOT DISTINCT**。空串先转 NULL,避免旧记录冲突。新版 API 必填 exportId,NULL 只允许历史导入。重复非空 exportId 若指向不同题型,隔离处理,不能任意挑一条。 +SQL CHECK 限定 NULL exportId 只能出现在 source=import 的历史记录中;其余来源必须提供非空白事件 ID,旧桥负责生成 legacy 事件 ID。 + 索引 `(user_id, created_at DESC, id DESC)`、`(worksheet_id, created_at DESC, id DESC)`。不同 exportId 的同题型多次保存都是有效事件;禁止 UNIQUE `(user_id, worksheet_id)` 或按秒级时间窗口去重。 ### 3.6 page_configs — 页面配置权威源 @@ -189,17 +195,17 @@ UNIQUE `(user_id, export_id)` 使用 PostgreSQL 默认 NULL 语义,允许同 ```json { - "featured": [ - { - "id": "原题型ID", - "title": "练习纸", - "previewImg": { "assetId": "预览资产ID" }, - "path": "/原页面路径?原参数", - "available": true - } - ], - "hot": [], - "sections": [] + "featured": [ + { + "id": "原题型ID", + "title": "练习纸", + "previewImg": { "assetId": "预览资产ID" }, + "path": "/原页面路径?原参数", + "available": true + } + ], + "hot": [], + "sections": [] } ``` @@ -247,6 +253,8 @@ UNIQUE source_ref_hash、UNIQUE object_ref_hash(可空);索引 `(owner_use qiniu ready 必须具备 bucket、objectKey、objectRefHash、mimeType、sizeBytes 和 checksum;external ready 必须有经核实的 externalUrl,无法取得内容摘要时在报告注明。源文件下载、上传与校验完成后才 ready,再写业务外键。DB 存引用,访问域名从服务配置取;私有签名 URL 在响应时生成,不能写回数据库或页面配置。 +SQL 检查 ready 必填字段、external 的 HTTPS 前缀、avatar 必须 private,以及摘要/hash 为 64 位小写十六进制。摘要计算和规范化由服务端完成;HTTPS 前缀检查不代替域名白名单或远程文件校验。 + 上传和对象存储操作不放在长数据库事务中;先完成上传并登记资产,再短事务绑定业务记录。未绑定文件由清理流程回收,清理前检查 users、worksheets、categories 和 page_configs 的所有引用。旧桥复制云文件后也必须遵循同一路径。 ### 4.2 job_runs — 调度与执行结果 @@ -374,7 +382,7 @@ UNIQUE `(job_name, run_key)`;索引 `(status, locked_until)`、`(job_name, cre - datasource 使用 `provider = "postgresql"`。具体 PostgreSQL 主版本、Prisma 版本、Node 运行时组合在 S1 样板中验证并锁定,不照抄旧 MySQL Schema 或旧连接参数。 - PostgreSQL 类型映射:字符串 `@db.VarChar(n)`、计数 BigInt `@db.BigInt`、日期 DateTime `@db.Timestamptz(3)`、JSON `@db.JsonB`。SQL 的 now() 负责新建时间;更新由服务显式维护,内容时间不依赖自动 updatedAt 推断。 - PostgreSQL CHECK、标识符列排序规则、DESC 索引和本文事务 SQL 以版本化迁移 SQL 为最终落点;Prisma Schema 同步描述可表达的约束。业务验证不能代替数据库唯一键。 -- `apps/server/prisma/schema.prisma`、`migrations/` 与迁移说明同版本提交;本文是实施输入,本次不生成看似已执行的 Schema/迁移文件,也不连接生产库。 +- 当前可执行基线 SQL 位于 `apps/server/prisma/sql`,尚未纳入 Prisma 迁移历史。后续 `schema.prisma`、`migrations/` 与迁移说明同版本提交;已有表的数据库先登记基线,不能重复执行初始建表 DDL。本次不连接生产库。 - 新增列采用可兼容上一 API 版本的步骤,数据回填和收紧 NOT NULL/外键分开验证;应用回滚继续使用新主库。正式环境不使用 db push 随意覆盖结构。 - 原始导出、隔离记录、文件映射、导入报告和恢复校验随迁移批次归档。数据库备份使用 PostgreSQL 工具链,并实际演练恢复;备份目标在服务器之外。 - 首期不分表、不分区、不引入 Redis、队列或全文搜索。限制连接池与导入并发,按 2 核 4 GB 实际联调结果配置,禁止所有应用默认建立无限连接。 diff --git a/docs/云服务端迁移方案.md b/docs/云服务端迁移方案.md index 3fce178..0d7629d 100644 --- a/docs/云服务端迁移方案.md +++ b/docs/云服务端迁移方案.md @@ -118,8 +118,8 @@ doodle-store/ ```yaml packages: - - 'apps/*' - - 'packages/*' + - 'apps/*' + - 'packages/*' ``` - 每个应用与共享包有独立 `package.json`,声明自身依赖;根项目设为 `private: true`,主要负责工作区脚本和公共开发工具,不集中安装所有应用的运行依赖。 @@ -213,10 +213,10 @@ apps/server/ # 以下路径相对于 doodle-store ```json { - "code": 0, - "message": "ok", - "data": {}, - "requestId": "request-id" + "code": 0, + "message": "ok", + "data": {}, + "requestId": "request-id" } ``` @@ -255,6 +255,8 @@ Web 用户不会凭现有小程序 OpenID 自动变成同一账户。跨端身 ## 6. 数据迁移 +环境安排见 [v3.5 开发与部署环境方案](./v3.5/开发与部署环境方案.md):本机 Docker PostgreSQL 用于开发,图片等直接使用七牛;云服务器首期仅常驻生产库。试导先在本机隔离库进行,真实微信联调时按需创建同实例独立 staging 库及测试 API,不长期部署两套数据库服务,测试不写生产库。 + 数据库详细设计见 [v3.5 数据库设计文档](./v3.5/数据库设计文档.md),使用 PostgreSQL + Prisma,覆盖以下集合及辅助表。 ### 6.1 集合映射与主键 diff --git a/eslint.config.mjs b/eslint.config.mjs index 0aae96b..fb9e85b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,21 +3,21 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { - ignores: [ - '**/node_modules/**', - '**/.pnpm-store/**', - '**/dist/**', - '**/dist-test/**', - '**/coverage/**', - ], - }, - js.configs.recommended, - ...tseslint.configs.recommended, - { - files: ['apps/server/**/*.ts', '**/*.mjs'], - languageOptions: { - globals: globals.node, + { + ignores: [ + '**/node_modules/**', + '**/.pnpm-store/**', + '**/dist/**', + '**/dist-test/**', + '**/coverage/**', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['apps/server/**/*.ts', '**/*.mjs'], + languageOptions: { + globals: globals.node, + }, }, - }, ); diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..9663504 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,54 @@ +# 本地 PostgreSQL 开发环境 + +参考 `atlas-seller-kit/infra` 的 Docker 数据库 + 宿主机应用模式,只运行 PostgreSQL。图片、头像、字体等直接使用七牛,不启动 Redis、MinIO 或本地文件服务器。 + +## 启动 + +从 `doodle-store` 根目录执行: + +```bash +cp .env.example .env +# 编辑 .env,替换 POSTGRES_PASSWORD 为本地密码。 +pnpm infra:up +pnpm infra:status +``` + +已有 `.env` 时保留现有文件,不重复复制。`infra:up` 显式加载根目录 `.env` 并等待健康检查,不依赖 Compose 文件所在目录寻找环境变量。镜像固定为参考项目使用的 `postgres:18.6-alpine`,升级时显式修改标签,不使用 latest。 + +| 连接项 | 默认值 | +| -------------- | ---------------------------------- | +| 主机 | 127.0.0.1 | +| 宿主机端口 | 5434(避免与参考项目的 5433 冲突) | +| 数据库 | doodle_dev | +| 用户 | doodle_dev | +| 密码 | 根目录 `.env` 中 POSTGRES_PASSWORD | +| 编码 / 时区 | UTF-8 / UTC | +| Compose 项目名 | doodle-store-dev | + +端口只绑定本机回环地址;数据库客户端可用上述配置连接。postgres-data 是此 Compose 项目的独立命名卷,停止容器保留数据,不影响参考项目。 + +```bash +pnpm infra:logs +pnpm infra:psql +pnpm infra:down +``` + +`infra:psql` 在容器内通过本地 socket 打开 SQL 控制台;宿主机数据库客户端通过 TCP 连接并使用密码。`infra:down` 不删除命名卷。没有提供自动删除数据的 reset 命令。 + +POSTGRES_DB、POSTGRES_USER、POSTGRES_PASSWORD 只在空数据卷首次初始化时创建数据库/角色。修改 `.env` 不会自动修改已有数据库密码,也不会把已有卷迁移到新 PostgreSQL 主版本;已有库应通过 SQL 管理角色,升级先备份并演练恢复。 + +## 服务端连接 + +服务端在宿主机通过 `pnpm dev` 启动,连接串放 `apps/server/.env`。参考该目录 `.env.example`,将用户名、密码、端口和库名与根 `.env` 对齐;密码有特殊字符时先做 URL 编码。 + +```text +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 张表及初始配置;后续 Prisma schema/migrations 根据 [v3.5 数据库设计](../docs/v3.5/数据库设计文档.md) 实施。开发角色为此本地集群的初始化管理员,生产运行角色权限在部署时单独设置,不复制本地账号密码到云服务器。 + +自动测试需要真实数据库时,使用同一**本地** PostgreSQL 实例内独立 `doodle_test` 数据库及连接串,测试初始化流程另行实施。测试不得对 doodle_dev 或生产库执行清空数据操作;目前尚未创建测试库。 + +云端首期仅常驻生产库,测试联调按需隔离。详细安排与七牛资源隔离见 [开发与部署环境方案](../docs/v3.5/开发与部署环境方案.md)。 + +建表 SQL 已生成并通过临时库验证,开发库已于 2026-10-12 完成初始化,其他空库执行步骤见 [SQL 执行说明](../apps/server/prisma/sql/README.md)。Docker 启动只创建空数据库,不会自动执行这些业务表脚本。 diff --git a/infra/compose.yaml b/infra/compose.yaml new file mode 100644 index 0000000..5f81ee1 --- /dev/null +++ b/infra/compose.yaml @@ -0,0 +1,31 @@ +name: doodle-store-dev + +services: + postgres: + image: postgres:18.6-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-doodle_dev} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?请先复制根目录 .env.example 为 .env 并设置本地密码} + POSTGRES_DB: ${POSTGRES_DB:-doodle_dev} + POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C' + TZ: UTC + PGTZ: UTC + command: ['postgres', '-c', 'timezone=UTC'] + ports: + - '127.0.0.1:${POSTGRES_PORT:-5434}:5432' + volumes: + # PostgreSQL 18 的数据目录位于版本子目录,挂载父目录以持久化整个集群。 + - postgres-data:/var/lib/postgresql + healthcheck: + test: + [ + 'CMD-SHELL', + 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"', + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + +volumes: + postgres-data: diff --git a/package.json b/package.json index 914a467..cae8659 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,35 @@ { - "name": "doodle-store", - "version": "0.1.0", - "private": true, - "description": "Doodle 服务端、管理后台与 Web 工作区", - "packageManager": "pnpm@11.24.0", - "engines": { - "node": ">=24.20.0 <25", - "pnpm": "11.24.0" - }, - "scripts": { - "dev": "pnpm --filter @doodle/contracts build && pnpm --filter @doodle/server dev", - "build": "pnpm -r --if-present run build", - "start": "pnpm --filter @doodle/server start", - "typecheck": "pnpm --filter @doodle/contracts build && pnpm -r --if-present run typecheck", - "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" - }, - "devDependencies": { - "@eslint/js": "10.0.1", - "eslint": "10.12.0", - "globals": "17.13.0", - "prettier": "3.9.10", - "typescript": "5.9.3", - "typescript-eslint": "8.71.1" - } + "name": "doodle-store", + "version": "0.1.0", + "private": true, + "description": "Doodle 服务端、管理后台与 Web 工作区", + "packageManager": "pnpm@11.24.0", + "engines": { + "node": ">=24.20.0 <25", + "pnpm": "11.24.0" + }, + "scripts": { + "infra:up": "docker compose --env-file .env -f infra/compose.yaml up -d --wait", + "infra:down": "docker compose --env-file .env -f infra/compose.yaml down", + "infra:status": "docker compose --env-file .env -f infra/compose.yaml ps", + "infra:logs": "docker compose --env-file .env -f infra/compose.yaml logs --tail=100 postgres", + "infra:psql": "docker compose --env-file .env -f infra/compose.yaml exec postgres sh -c 'exec psql -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\"'", + "dev": "pnpm --filter @doodle/contracts build && pnpm --filter @doodle/server dev", + "build": "pnpm -r --if-present run build", + "start": "pnpm --filter @doodle/server start", + "typecheck": "pnpm --filter @doodle/contracts build && pnpm -r --if-present run typecheck", + "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" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "eslint": "10.12.0", + "globals": "17.13.0", + "prettier": "3.9.10", + "typescript": "5.9.3", + "typescript-eslint": "8.71.1" + } } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index af51e54..d0c3ba4 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,24 +1,24 @@ { - "name": "@doodle/contracts", - "version": "0.1.0", - "private": true, - "type": "commonjs", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "name": "@doodle/contracts", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "typescript": "5.9.3" } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc --noEmit -p tsconfig.json" - }, - "devDependencies": { - "typescript": "5.9.3" - } } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f6b4d94..7610dcf 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,12 +1,12 @@ /** 可公开的成功响应类型;不定义服务端内部错误或数据库实体。 */ export interface ApiSuccess { - code: 0; - message: string; - data: T; + code: 0; + message: string; + data: T; } /** 只描述 HTTP 进程存活,不代表外部服务已接入或健康。 */ export interface HealthStatus { - status: 'ok'; - service: 'doodle-store-server'; + status: 'ok'; + service: 'doodle-store-server'; } diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index 8d41b64..4eea839 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -1,14 +1,14 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "module": "Node16", - "moduleResolution": "Node16", - "lib": ["ES2022"], - "types": [], - "declaration": true, - "rootDir": "src", - "outDir": "dist" - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "types": [], + "declaration": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4373ce6..9719d12 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,10 @@ packages: - - 'apps/*' - - 'packages/*' + - 'apps/*' + - 'packages/*' engineStrict: true strictPeerDependencies: true # NestJS 的安装后提示脚本不是运行必需项,不执行依赖安装脚本。 allowBuilds: - '@nestjs/core': false + '@nestjs/core': false diff --git a/tsconfig.base.json b/tsconfig.base.json index 2851843..59caab9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,10 +1,10 @@ { - "compilerOptions": { - "target": "ES2022", - "strict": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true - } + "compilerOptions": { + "target": "ES2022", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + } }