feat: 创建数据库
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+25
-25
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 或实施数据库迁移。
|
||||
|
||||
|
||||
@@ -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 本地开发数据库;生产与测试使用独立数据库及凭证';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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。
|
||||
@@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health/health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import type { ApiSuccess, HealthStatus } from '@doodle/contracts';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
getHealth(): ApiSuccess<HealthStatus> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: {
|
||||
status: 'ok',
|
||||
service: 'doodle-store-server',
|
||||
},
|
||||
};
|
||||
}
|
||||
@Get()
|
||||
getHealth(): ApiSuccess<HealthStatus> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: {
|
||||
status: 'ok',
|
||||
service: 'doodle-store-server',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+20
-17
@@ -6,26 +6,29 @@ import { configureApplication } from './app.setup';
|
||||
import { getRuntimeOptions } from './common/runtime-options';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
+14
-14
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist-test",
|
||||
"sourceMap": false
|
||||
}
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist-test",
|
||||
"sourceMap": false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user