diff --git a/deploy/README.md b/deploy/README.md index 290499f..0386eb8 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,6 +1,6 @@ # Doodle 云端部署准备 -更新时间:2026-10-19。本文件记录部署方案与待确认条件。已在 1Panel 申请根域及泛域名证书;尚未创建 Doodle 云端应用、应用域名 DNS 记录或镜像仓库,也未改动旧线上云开发链路。 +更新时间:2026-10-20。本文件记录部署方案与待确认条件。已在 1Panel 申请根域及泛域名证书,并安装 PostgreSQL;尚未创建 Doodle 云端应用、应用域名 DNS 记录或镜像仓库,也未改动旧线上云开发链路。 ## 已确认条件 @@ -24,6 +24,20 @@ 现有已发布小程序继续使用原云开发。保留全部旧云函数、预拉取模式、地址、生成/发布与定时链路。开发版跳过旧线上预拉取,体验/正式包保留原有预拉取行为;本次不关闭旧服务。 +## 本次数据库迁移范围(2026-10-20 更新) + +最新来源策略为:本地 `doodle_dev` 提供已整理的分类、题型、内容关联和七牛映射;CloudBase 提供最新用户、收藏、下载记录及题型真实计数。这取代此前“只导内容”和“直接全量复制本地库”的约定。 + +建议先在本机独立迁移库整合,再统一导入 `doodle_prod`,保留 `doodle_dev` 作为内容来源。`worksheets` 的内容/seed 与真实计数分字段合并;用户头像以线上最新绑定核验七牛映射;新后端页面配置和任务状态重新生成或初始化。完整逐表规则及依赖、身份、计数和补导处理见 [生产数据整合与导入方案](../docs/v3.5/生产数据整合与导入方案.md)。 + +保留正式表结构、约束、索引和真实 Prisma 迁移历史;数据库集群账号/密码及服务端运行密钥单独配置。旧 CloudBase 服务持续保留,一次快照导入不代表实时同步。当前只整理方案,尚未执行导出、整合或云端导入。 + +第一阶段的具体命令与试导结果见 [分类与题型首次导入操作手册](../docs/v3.5/分类与题型首次导入操作手册.md),配套 SQL 位于 `deploy/postgres/content/`。文档验证仅在本地隔离库执行,不表示云端已导入。 + +云端空库也可先通过 Adminer 执行 [initialize-doodle-prod.sql](./postgres/initialize-doodle-prod.sql) 初始化结构,无需先解决本机 DBX/SSH 连接。该文件由独立空库的正式迁移结果生成,含真实 Prisma 历史,不含开发业务数据;已验证恢复及 Prisma 接续兼容。按 [首次建表指南](../docs/v3.5/云端数据库首次建表指南.md) 操作,当前尚未在云端执行。 + +PostgreSQL 已由用户选择通过 1Panel 安装管理,2026-10-20 核查为 `postgres:18.6-alpine`,容器名 `1Panel-postgresql-RqP6`。不再启动本目录先前准备的 `compose.database.yaml` 创建第二套数据库实例;下文关于项目管理 PostgreSQL 编排的早期建议以此决策为准。 + ## 建议部署结构 采用“1Panel 管理入口,项目维护应用编排”的组合方式: @@ -121,7 +135,7 @@ API、PostgreSQL 的 Docker Compose、镜像版本、环境变量示例和备份 3. 准备数据库持久化、备份和恢复流程;备份副本需要保存到服务器之外。 4. 核对基础镜像及应用镜像从服务器的拉取能力,再确定仓库分发或首次离线交付。 5. 确认 API 域名并配置 DNS、1Panel 反向代理,绑定已签发的证书 ID 11,验收 HTTPS 与续签部署链路。 -6. 初始化单套云端数据库、执行迁移、受控导入内容;不整库复制开发用户和临时数据。 +6. 按最新方案将本地内容与 CloudBase 最新用户业务数据整合,对账后迁入 1Panel 管理的 `doodle_prod` 并备份。 7. 完成微信服务端出口 IP 与合法域名配置,再更新小程序 production 接口地址供体验版验收。 -当前仅完成 1Panel 泛域名证书申请,未部署 Doodle 云端应用或数据库。公网入口与云端数据库验收完成前,不将开发机的 `127.0.0.1` 地址当作可用的体验版接口。 +当前已完成 1Panel 泛域名证书申请及 PostgreSQL 实例安装,尚未执行双来源数据整合与导入,也未部署 Doodle 云端应用。公网入口与云端数据库验收完成前,不将开发机的 `127.0.0.1` 地址当作可用的体验版接口。 diff --git a/deploy/postgres/content/apply.sql b/deploy/postgres/content/apply.sql new file mode 100644 index 0000000..496ae6f --- /dev/null +++ b/deploy/postgres/content/apply.sql @@ -0,0 +1,162 @@ +-- 由文档中的打包步骤在事务和 COPY 数据段之后拼接;不要单独执行此文件。 +DO $$ +DECLARE + payload jsonb; + actual_migrations jsonb; + actual_columns jsonb; +BEGIN + IF current_database() <> 'doodle_prod' + AND current_database() !~ '^doodle_migration_[0-9]{8}(_[a-z0-9]+)?$' THEN + RAISE EXCEPTION '目标只能是 doodle_prod 或独立的 doodle_migration_YYYYMMDD 库'; + END IF; + IF (SELECT count(*) FROM content_import_encoded) <> 1 THEN + RAISE EXCEPTION '导入包必须只有一份内容快照'; + END IF; + SELECT convert_from(decode(body, 'base64'), 'UTF8')::jsonb + INTO STRICT payload FROM content_import_encoded; + IF payload->>'format' IS DISTINCT FROM 'doodle-content-v1' + OR payload->>'source_database' IS DISTINCT FROM 'doodle_dev' + OR jsonb_typeof(payload->'assets') IS DISTINCT FROM 'array' + OR jsonb_typeof(payload->'categories') IS DISTINCT FROM 'array' + OR jsonb_typeof(payload->'worksheets') IS DISTINCT FROM 'array' + OR payload->'excluded_relations' IS DISTINCT FROM + '{"worksheet_grades":0,"worksheet_resources":0,"worksheet_resource_assets":0}'::jsonb THEN + RAISE EXCEPTION '导入包格式错误,或已有年级/资源关联需要扩展本批范围'; + END IF; + IF jsonb_array_length(payload->'categories') = 0 + OR jsonb_array_length(payload->'worksheets') = 0 THEN + RAISE EXCEPTION '本批分类和题型不能为空'; + END IF; + IF EXISTS (SELECT 1 FROM public._prisma_migrations + WHERE finished_at IS NULL AND rolled_back_at IS NULL) THEN + RAISE EXCEPTION '目标存在未完成迁移'; + END IF; + SELECT COALESCE(jsonb_agg(jsonb_build_object( + 'name', migration_name, 'checksum', checksum + ) ORDER BY migration_name), '[]'::jsonb) + INTO actual_migrations FROM public._prisma_migrations + WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL; + IF actual_migrations IS DISTINCT FROM payload->'migration_history' THEN + RAISE EXCEPTION '源库与目标的迁移名称/校验和不一致'; + END IF; + SELECT jsonb_agg(jsonb_build_object( + 'table', c.relname, 'column', a.attname, + 'type', format_type(a.atttypid, a.atttypmod), 'not_null', a.attnotnull + ) ORDER BY c.relname, a.attname) + INTO actual_columns + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname IN ('assets', 'categories', 'worksheets') + AND a.attnum > 0 AND NOT a.attisdropped; + IF actual_columns IS DISTINCT FROM payload->'column_signature' THEN + RAISE EXCEPTION '源库与目标字段结构不一致,停止以免丢失字段'; + END IF; +END $$; + +CREATE TEMP TABLE content_import_payload ON COMMIT DROP AS +SELECT convert_from(decode(body, 'base64'), 'UTF8')::jsonb AS body +FROM content_import_encoded; +CREATE TEMP TABLE content_import_assets ON COMMIT DROP AS +SELECT a.* FROM content_import_payload p, + LATERAL jsonb_populate_recordset(NULL::public.assets, p.body->'assets') a; +CREATE TEMP TABLE content_import_categories ON COMMIT DROP AS +SELECT c.* FROM content_import_payload p, + LATERAL jsonb_populate_recordset(NULL::public.categories, p.body->'categories') c; +CREATE TEMP TABLE content_import_worksheets ON COMMIT DROP AS +SELECT w.* FROM content_import_payload p, + LATERAL jsonb_populate_recordset(NULL::public.worksheets, p.body->'worksheets') w; + +-- 这一步只初始化内容。真实运营计数之后由 CloudBase 最新快照赋值,不复制开发计数。 +UPDATE content_import_worksheets SET downloads = 0, likes = 0; +LOCK TABLE public.assets, public.categories, public.worksheets IN SHARE ROW EXCLUSIVE MODE; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM public.assets) + OR EXISTS (SELECT 1 FROM public.categories) + OR EXISTS (SELECT 1 FROM public.worksheets) THEN + RAISE EXCEPTION '目标三张表必须为空;本流程不覆盖、清空或重复合并已有内容'; + END IF; + IF EXISTS (SELECT 1 FROM content_import_assets + WHERE provider IS DISTINCT FROM 'qiniu' OR visibility IS DISTINCT FROM 'public' + OR status IS DISTINCT FROM 'ready' + OR purpose NOT IN ('preview', 'category_icon')) THEN + RAISE EXCEPTION '本批只允许就绪的七牛公开预览/分类图标资产'; + END IF; + IF EXISTS (SELECT 1 FROM content_import_assets a + WHERE a.owner_user_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM public.users u WHERE u.id = a.owner_user_id)) THEN + RAISE EXCEPTION '内容资产含未迁移的用户归属,须先处理用户映射;不自动清空 owner'; + END IF; + IF EXISTS (SELECT 1 FROM content_import_worksheets w + WHERE w.generation_mode <> 'procedural') THEN + RAISE EXCEPTION '资源型题型需要同时迁移完整资源关联,本批不能只导题型父表'; + END IF; + IF EXISTS (SELECT 1 FROM content_import_worksheets w + WHERE NOT EXISTS (SELECT 1 FROM content_import_categories c WHERE c.id = w.category_id) + OR NOT EXISTS (SELECT 1 FROM content_import_assets a + WHERE a.id = w.preview_asset_id AND a.purpose = 'preview')) + OR EXISTS (SELECT 1 FROM content_import_categories c + WHERE (c.parent_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM content_import_categories p WHERE p.id = c.parent_id)) + OR (c.icon_asset_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM content_import_assets a WHERE a.id = c.icon_asset_id + AND a.purpose = 'category_icon'))) THEN + RAISE EXCEPTION '分类父级、题型分类或资产引用缺失/用途不匹配'; + END IF; + IF EXISTS (SELECT 1 FROM content_import_assets a + WHERE NOT EXISTS (SELECT 1 FROM content_import_worksheets w WHERE w.preview_asset_id = a.id) + AND NOT EXISTS (SELECT 1 FROM content_import_categories c WHERE c.icon_asset_id = a.id)) THEN + RAISE EXCEPTION '导入包含本批内容未引用的资产'; + END IF; + IF EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id, parent_id, ARRAY[id::text] AS path, false AS cycle + FROM content_import_categories + UNION ALL + SELECT a.id, c.parent_id, a.path || c.id::text, c.id = ANY(a.path) + FROM ancestors a JOIN content_import_categories c ON c.id = a.parent_id + WHERE NOT a.cycle + ) SELECT 1 FROM ancestors WHERE cycle + ) THEN + RAISE EXCEPTION '分类存在循环父子关系'; + END IF; +END $$; + +-- 单条分类 INSERT 包含所有父子行;语句结束时检查外键,无需关闭约束或触发器。 +INSERT INTO public.assets SELECT * FROM content_import_assets; +INSERT INTO public.categories SELECT * FROM content_import_categories; +INSERT INTO public.worksheets SELECT * FROM content_import_worksheets; + +-- 提交前逐字段核对;统计字段与已经归零的临时数据比较。 +DO $$ +BEGIN + IF EXISTS (SELECT * FROM public.assets EXCEPT SELECT * FROM content_import_assets) + OR EXISTS (SELECT * FROM content_import_assets EXCEPT SELECT * FROM public.assets) + OR EXISTS (SELECT * FROM public.categories EXCEPT SELECT * FROM content_import_categories) + OR EXISTS (SELECT * FROM content_import_categories EXCEPT SELECT * FROM public.categories) + OR EXISTS (SELECT * FROM public.worksheets EXCEPT SELECT * FROM content_import_worksheets) + OR EXISTS (SELECT * FROM content_import_worksheets EXCEPT SELECT * FROM public.worksheets) THEN + RAISE EXCEPTION '写入结果与导入包不一致,整批回滚'; + END IF; +END $$; +INSERT INTO public.admin_audit_logs ( + id, actor_type, action, resource_type, request_id, after_data, reason +) +SELECT + 'content-import-' || current_setting('doodle.content_import_sha256'), + 'migration', 'content.import', 'content-batch', + 'content-import-' || current_setting('doodle.content_import_sha256'), + jsonb_build_object( + 'assets', (SELECT count(*) FROM content_import_assets), + 'categories', (SELECT count(*) FROM content_import_categories), + 'worksheets', (SELECT count(*) FROM content_import_worksheets), + 'sourceDatabase', body->>'source_database', + 'exportedAt', body->>'exported_at', + 'sourceSha256', current_setting('doodle.content_import_sha256'), + 'statisticsState', 'pending-cloudbase' + ), + '本地内容初始化;资产与业务 ID 保留,downloads/likes 初始化为 0,待 CloudBase 最新统计覆盖。' +FROM content_import_payload; +COMMIT; +\echo CONTENT_IMPORT_COMMITTED diff --git a/deploy/postgres/content/export.sql b/deploy/postgres/content/export.sql new file mode 100644 index 0000000..4841214 --- /dev/null +++ b/deploy/postgres/content/export.sql @@ -0,0 +1,65 @@ +\set ON_ERROR_STOP on +BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY; +SET LOCAL TIME ZONE 'UTC'; + +-- 只读导出本地内容,防止误把另一环境的数据当作本次内容来源。 +DO $$ +BEGIN + IF current_database() <> 'doodle_dev' THEN + RAISE EXCEPTION '导出只允许读取 doodle_dev'; + END IF; + IF EXISTS ( + SELECT 1 FROM public._prisma_migrations + WHERE finished_at IS NULL AND rolled_back_at IS NULL + ) THEN + RAISE EXCEPTION '源库存在未完成迁移,请先核查'; + END IF; +END $$; + +WITH asset_ids AS ( + SELECT preview_asset_id AS id FROM public.worksheets + UNION + SELECT icon_asset_id FROM public.categories WHERE icon_asset_id IS NOT NULL +) +SELECT jsonb_build_object( + 'format', 'doodle-content-v1', + 'source_database', current_database(), + 'exported_at', transaction_timestamp(), + 'migration_history', ( + SELECT COALESCE(jsonb_agg(jsonb_build_object( + 'name', migration_name, 'checksum', checksum + ) ORDER BY migration_name), '[]'::jsonb) + FROM public._prisma_migrations + WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL + ), + -- 按字段名比较签名,兼容经旧 SQL 初始化、字段物理顺序不同的本地库。 + 'column_signature', ( + SELECT jsonb_agg(jsonb_build_object( + 'table', c.relname, 'column', a.attname, + 'type', format_type(a.atttypid, a.atttypmod), 'not_null', a.attnotnull + ) ORDER BY c.relname, a.attname) + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname IN ('assets', 'categories', 'worksheets') + AND a.attnum > 0 AND NOT a.attisdropped + ), + 'excluded_relations', jsonb_build_object( + 'worksheet_grades', (SELECT count(*) FROM public.worksheet_grades), + 'worksheet_resources', (SELECT count(*) FROM public.worksheet_resources), + 'worksheet_resource_assets', (SELECT count(*) FROM public.worksheet_resource_assets) + ), + 'assets', ( + SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.id), '[]'::jsonb) + FROM public.assets a JOIN asset_ids r ON r.id = a.id + ), + 'categories', ( + SELECT COALESCE(jsonb_agg(to_jsonb(c) ORDER BY c.id), '[]'::jsonb) + FROM public.categories c + ), + 'worksheets', ( + SELECT COALESCE(jsonb_agg(to_jsonb(w) ORDER BY w.id), '[]'::jsonb) + FROM public.worksheets w + ) +); +COMMIT; diff --git a/deploy/postgres/initialize-doodle-prod.sql b/deploy/postgres/initialize-doodle-prod.sql new file mode 100644 index 0000000..397d5a3 --- /dev/null +++ b/deploy/postgres/initialize-doodle-prod.sql @@ -0,0 +1,1185 @@ +-- Doodle 生产空库初始化快照(2026-10-20) +-- 来源:独立空库实际执行仓库三次正式 Prisma 迁移后的完整导出;不包含 doodle_dev 业务数据。 +-- 适用 PostgreSQL 18;仅第一次初始化空 doodle_prod。保留迁移校验和供后续 migrate deploy 接续。 +-- 以目标数据库所有者执行;包含 13 张业务表、Prisma 历史和两条初始配置。 +BEGIN; +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '120s'; +DO $doodle_empty_guard$ +BEGIN + IF current_database() <> 'doodle_prod' AND current_database() !~ '^doodle_schema_check_[a-z0-9]+$' THEN + RAISE EXCEPTION '只允许初始化 doodle_prod 空库;请核对当前数据库'; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg_toast%' + AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S') + ) THEN + RAISE EXCEPTION '目标已有用户表/视图/序列,停止初始化;不清空或覆盖已有结构'; + END IF; +END +$doodle_empty_guard$; +-- +-- PostgreSQL database dump +-- + + +-- Dumped from database version 18.6 +-- Dumped by pg_dump version 18.6 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: public; Type: SCHEMA; Schema: -; Owner: - +-- + + + +-- +-- Name: SCHEMA public; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON SCHEMA public IS 'standard public schema'; + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: _prisma_migrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public._prisma_migrations ( + id character varying(36) NOT NULL, + checksum character varying(64) NOT NULL, + finished_at timestamp with time zone, + migration_name character varying(255) NOT NULL, + logs text, + rolled_back_at timestamp with time zone, + started_at timestamp with time zone DEFAULT now() NOT NULL, + applied_steps_count integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: admin_audit_logs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.admin_audit_logs ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + actor_user_id character varying(128) COLLATE pg_catalog."C", + actor_type character varying(16) NOT NULL, + action character varying(64) NOT NULL, + resource_type character varying(64) NOT NULL, + resource_id character varying(128) COLLATE pg_catalog."C", + request_id character varying(128) NOT NULL COLLATE pg_catalog."C", + before_data jsonb, + after_data jsonb, + reason text, + created_at timestamp(3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT admin_audit_logs_actor_type_check CHECK (((actor_type)::text = ANY ((ARRAY['admin'::character varying, 'system'::character varying, 'migration'::character varying, 'user'::character varying])::text[]))) +); + + +-- +-- Name: TABLE admin_audit_logs; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.admin_audit_logs IS '管理变更审计;操作者为逻辑关联,注销需主动去关联'; + + +-- +-- Name: assets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.assets ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + source_ref text COLLATE pg_catalog."C", + source_ref_hash character(64) COLLATE pg_catalog."C", + provider character varying(16) DEFAULT 'qiniu'::character varying NOT NULL, + bucket character varying(128) COLLATE pg_catalog."C", + object_key text COLLATE pg_catalog."C", + object_ref_hash character(64) COLLATE pg_catalog."C", + external_url text COLLATE pg_catalog."C", + purpose character varying(32) NOT NULL, + owner_user_id character varying(128) COLLATE pg_catalog."C", + visibility character varying(16) NOT NULL, + mime_type character varying(128), + size_bytes bigint, + checksum character(64) COLLATE pg_catalog."C", + status character varying(16) DEFAULT 'pending'::character varying NOT NULL, + last_error text, + created_at timestamp(3) with time zone DEFAULT now() NOT NULL, + updated_at timestamp(3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT assets_checksum_check CHECK (((checksum IS NULL) OR (checksum ~ '^[0-9a-f]{64}$'::text))), + CONSTRAINT assets_object_ref_hash_check CHECK (((object_ref_hash IS NULL) OR (object_ref_hash ~ '^[0-9a-f]{64}$'::text))), + CONSTRAINT assets_provider_check CHECK (((provider)::text = ANY ((ARRAY['qiniu'::character varying, 'external'::character varying])::text[]))), + CONSTRAINT assets_purpose_check CHECK (((purpose)::text = ANY ((ARRAY['avatar'::character varying, 'preview'::character varying, 'category_icon'::character varying, 'font'::character varying, 'share'::character varying, 'page_config'::character varying, 'worksheet_source'::character varying, 'other'::character varying])::text[]))), + CONSTRAINT assets_ready_reference_check CHECK ((((status)::text <> 'ready'::text) OR (((provider)::text = 'qiniu'::text) AND (bucket IS NOT NULL) AND (length((bucket)::text) > 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)::text) > 0) AND (size_bytes IS NOT NULL) AND (checksum IS NOT NULL)) OR (((provider)::text = 'external'::text) AND (external_url IS NOT NULL) AND (external_url ~ '^https://[^/[:space:]]+'::text)))), + 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}$'::text))), + CONSTRAINT assets_status_check CHECK (((status)::text = ANY ((ARRAY['pending'::character varying, 'ready'::character varying, 'failed'::character varying, 'deleting'::character varying, 'deleted'::character varying])::text[]))), + CONSTRAINT assets_visibility_check CHECK (((visibility)::text = ANY ((ARRAY['public'::character varying, 'private'::character varying])::text[]))) +); + + +-- +-- Name: TABLE assets; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.assets IS '七牛文件引用与旧资源迁移映射;不保存临时签名 URL'; + + +-- +-- Name: COLUMN assets.object_ref_hash; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.assets.object_ref_hash IS '规范序列化 [provider,bucket,objectKey] 的 SHA-256;服务端生成并核对原引用'; + + +-- +-- Name: COLUMN assets.checksum; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.assets.checksum IS '文件内容 SHA-256,不是七牛 ETag;非唯一'; + + +-- +-- Name: categories; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.categories ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + name character varying(100) NOT NULL, + icon character varying(1024) NOT NULL, + icon_asset_id character varying(128) COLLATE pg_catalog."C", + color character varying(32) NOT NULL, + sort_order integer DEFAULT 0 NOT NULL, + parent_id character varying(128) COLLATE pg_catalog."C", + created_at timestamp(3) with time zone DEFAULT now() NOT NULL, + updated_at timestamp(3) with time zone DEFAULT now() NOT NULL, + status character varying(16) DEFAULT 'draft'::character varying NOT NULL, + CONSTRAINT categories_parent_not_self_check CHECK (((parent_id IS NULL) OR ((parent_id)::text <> (id)::text))), + CONSTRAINT categories_status_check CHECK (((status)::text = ANY ((ARRAY['draft'::character varying, 'active'::character varying, 'hidden'::character varying])::text[]))) +); + + +-- +-- Name: TABLE categories; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.categories IS '内容分类树,与适用年级独立;多层循环需由服务端事务检查'; + + +-- +-- Name: COLUMN categories.status; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.categories.status IS '发布状态:draft 开发测试,active 上线,hidden 下架;公开访问还需所有上级分类 active'; + + +-- +-- Name: download_logs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.download_logs ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + user_id character varying(128) NOT NULL COLLATE pg_catalog."C", + worksheet_id character varying(128) NOT NULL COLLATE pg_catalog."C", + worksheet_resource_id character varying(128) COLLATE pg_catalog."C", + export_id character varying(128) COLLATE pg_catalog."C", + source character varying(16) DEFAULT 'mini'::character varying NOT NULL, + params jsonb, + created_at timestamp(3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT download_logs_export_id_check CHECK (((((source)::text = 'import'::text) AND (export_id IS NULL)) OR ((export_id IS NOT NULL) AND (length(btrim((export_id)::text)) > 0)))), + CONSTRAINT download_logs_source_check CHECK (((source)::text = ANY ((ARRAY['mini'::character varying, 'legacy'::character varying, 'import'::character varying])::text[]))) +); + + +-- +-- Name: TABLE download_logs; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.download_logs IS '成功保存历史;同用户 exportId 唯一,日志与计数由业务事务同步'; + + +-- +-- Name: COLUMN download_logs.worksheet_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.download_logs.worksheet_id IS '题型逻辑关联,不建外键,历史查询使用 LEFT JOIN'; + + +-- +-- Name: COLUMN download_logs.worksheet_resource_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.download_logs.worksheet_resource_id IS '可空资源条目逻辑关联;历史和算法生成可空;新写入校验属于同一 worksheet'; + + +-- +-- Name: COLUMN download_logs.export_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.download_logs.export_id IS '历史 import 可为 NULL;mini/legacy 必须提供非空事件 ID'; + + +-- +-- Name: favorites; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.favorites ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + user_id character varying(128) NOT NULL COLLATE pg_catalog."C", + worksheet_id character varying(128) NOT NULL COLLATE pg_catalog."C", + worksheet_resource_id character varying(128) COLLATE pg_catalog."C", + created_at timestamp(3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT favorites_resource_id_check CHECK (((worksheet_resource_id IS NULL) OR (length(btrim((worksheet_resource_id)::text)) > 0))) +); + + +-- +-- Name: TABLE favorites; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.favorites IS '功能和完整内容的当前收藏;取消即删除;仅功能收藏增减 worksheets.likes'; + + +-- +-- Name: COLUMN favorites.worksheet_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.favorites.worksheet_id IS '稳定功能逻辑关联;内容收藏的功能归属由服务端查询确定,历史缺失时保留'; + + +-- +-- Name: COLUMN favorites.worksheet_resource_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.favorites.worksheet_resource_id IS 'NULL 是功能收藏,非 NULL 是完整内容收藏;逻辑关联,缺失/下架不清空,不退化为功能收藏'; + + +-- +-- Name: job_runs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.job_runs ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + job_name character varying(64) NOT NULL COLLATE pg_catalog."C", + run_key character varying(128) NOT NULL COLLATE pg_catalog."C", + trigger_type character varying(16) NOT NULL, + status character varying(16) DEFAULT 'pending'::character varying NOT NULL, + scheduled_at timestamp(3) with time zone, + started_at timestamp(3) with time zone, + finished_at timestamp(3) with time zone, + attempt integer DEFAULT 0 NOT NULL, + lock_token character varying(128) COLLATE pg_catalog."C", + locked_until timestamp(3) with time zone, + result jsonb, + error_message text, + created_at timestamp(3) with time zone DEFAULT now() NOT NULL, + updated_at timestamp(3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT job_runs_attempt_check CHECK ((attempt >= 0)), + CONSTRAINT job_runs_status_check CHECK (((status)::text = ANY ((ARRAY['pending'::character varying, 'running'::character varying, 'succeeded'::character varying, 'failed'::character varying, 'skipped'::character varying])::text[]))), + CONSTRAINT job_runs_trigger_type_check CHECK (((trigger_type)::text = ANY ((ARRAY['scheduled'::character varying, 'manual'::character varying, 'recovery'::character varying])::text[]))) +); + + +-- +-- Name: TABLE job_runs; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.job_runs IS '任务逻辑去重、租约与执行结果;内容更新仍需业务行锁'; + + +-- +-- Name: page_configs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.page_configs ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + home jsonb, + category jsonb, + age jsonb, + version bigint DEFAULT 0 NOT NULL, + updated_at timestamp(3) with time zone DEFAULT now() NOT NULL, + published_version bigint, + published_at timestamp(3) with time zone, + CONSTRAINT page_configs_age_object_check CHECK (((age IS NULL) OR (jsonb_typeof(age) = 'object'::text))), + CONSTRAINT page_configs_category_object_check CHECK (((category IS NULL) OR (jsonb_typeof(category) = 'object'::text))), + CONSTRAINT page_configs_home_object_check CHECK (((home IS NULL) OR (jsonb_typeof(home) = 'object'::text))), + CONSTRAINT page_configs_published_version_check CHECK (((published_version IS NULL) OR ((published_version >= 0) AND (published_version <= version)))), + CONSTRAINT page_configs_version_check CHECK ((version >= 0)) +); + + +-- +-- Name: TABLE page_configs; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.page_configs IS '页面配置权威源;JSON 内持久化 assetId,DTO 解析资源地址'; + + +-- +-- Name: settings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.settings ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + value jsonb NOT NULL, + version bigint DEFAULT 0 NOT NULL, + updated_at timestamp(3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT settings_value_object_check CHECK ((jsonb_typeof(value) = 'object'::text)), + CONSTRAINT settings_version_check CHECK ((version >= 0)) +); + + +-- +-- Name: TABLE settings; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.settings IS '运行配置;value 保留原文档业务字段'; + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.users ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + openid character varying(128) NOT NULL COLLATE pg_catalog."C", + unionid character varying(128) COLLATE pg_catalog."C", + nick_name character varying(100), + avatar_asset_id character varying(128) COLLATE pg_catalog."C", + total_downloads bigint DEFAULT 0 NOT NULL, + role character varying(16) DEFAULT 'user'::character varying NOT NULL, + status character varying(16) DEFAULT 'active'::character varying NOT NULL, + auth_version integer DEFAULT 1 NOT NULL, + created_at timestamp(3) with time zone DEFAULT now() NOT NULL, + last_active_at timestamp(3) with time zone DEFAULT now() NOT NULL, + updated_at timestamp(3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT users_auth_version_check CHECK ((auth_version >= 0)), + CONSTRAINT users_role_check CHECK (((role)::text = ANY ((ARRAY['user'::character varying, 'admin'::character varying])::text[]))), + CONSTRAINT users_status_check CHECK (((status)::text = ANY ((ARRAY['active'::character varying, 'disabled'::character varying])::text[]))), + CONSTRAINT users_total_downloads_check CHECK ((total_downloads >= 0)) +); + + +-- +-- Name: TABLE users; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.users IS '微信用户;沿用 CloudBase 字符串 ID,按 OpenID 唯一;AppID 由服务端配置'; + + +-- +-- Name: COLUMN users.total_downloads; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.users.total_downloads IS '旧字段迁移兼容值;个人下载数量以 download_logs 查询为准'; + + +-- +-- Name: COLUMN users.auth_version; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.users.auth_version IS '应用凭证版本;服务端鉴权校验,不由 SQL 自动递增'; + + +-- +-- Name: worksheet_grades; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.worksheet_grades ( + worksheet_id character varying(128) NOT NULL COLLATE pg_catalog."C", + grade smallint NOT NULL, + CONSTRAINT worksheet_grades_grade_check CHECK (((grade >= '-4'::integer) AND (grade <= 6))) +); + + +-- +-- Name: TABLE worksheet_grades; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.worksheet_grades IS '功能适用年级的唯一权威关系;与内容分类独立,不从年龄自动推算'; + + +-- +-- Name: COLUMN worksheet_grades.grade; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheet_grades.grade IS '-4 托班、-3 小班、-2 中班、-1 大班、0 幼小衔接、1至6 小学年级;无关系表示未配置,不表示全年级'; + + +-- +-- Name: worksheet_resource_assets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.worksheet_resource_assets ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + worksheet_resource_id character varying(128) NOT NULL COLLATE pg_catalog."C", + asset_id character varying(128) NOT NULL COLLATE pg_catalog."C", + role character varying(16) NOT NULL, + sort_order integer DEFAULT 0 NOT NULL, + CONSTRAINT worksheet_resource_assets_role_check CHECK (((role)::text = ANY ((ARRAY['outline'::character varying, 'reference'::character varying])::text[]))) +); + + +-- +-- Name: TABLE worksheet_resource_assets; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.worksheet_resource_assets IS '完整内容的文件输入;outline 线框,reference 对照图;不保存文件级配置'; + + +-- +-- Name: COLUMN worksheet_resource_assets.role; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheet_resource_assets.role IS '业务用途,不是文件格式;用途对应的文件数量、类型和是否打印由 renderer 校验'; + + +-- +-- Name: COLUMN worksheet_resource_assets.sort_order; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheet_resource_assets.sort_order IS '同用途文件的处理顺序,并列按 id 排序;不等于通用图层 z-index'; + + +-- +-- Name: worksheet_resources; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.worksheet_resources ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + worksheet_id character varying(128) NOT NULL COLLATE pg_catalog."C", + title character varying(100) NOT NULL, + preview_asset_id character varying(128) NOT NULL COLLATE pg_catalog."C", + config_override jsonb, + status character varying(16) DEFAULT 'draft'::character varying NOT NULL, + sort_order integer DEFAULT 0 NOT NULL, + downloads bigint DEFAULT 0 NOT NULL, + first_published_at timestamp(3) with time zone, + created_at timestamp(3) with time zone DEFAULT now() NOT NULL, + updated_at timestamp(3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT worksheet_resources_config_object_check CHECK (((config_override IS NULL) OR (jsonb_typeof(config_override) = 'object'::text))), + CONSTRAINT worksheet_resources_downloads_check CHECK ((downloads >= 0)), + CONSTRAINT worksheet_resources_status_check CHECK (((status)::text = ANY ((ARRAY['draft'::character varying, 'active'::character varying, 'hidden'::character varying])::text[]))) +); + + +-- +-- Name: TABLE worksheet_resources; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.worksheet_resources IS 'worksheet 下可选择的完整内容;独立预览及轻量配置覆盖'; + + +-- +-- Name: COLUMN worksheet_resources.preview_asset_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheet_resources.preview_asset_id IS '内容列表预览图;与绘制参考图用途独立,允许引用同一资产'; + + +-- +-- Name: COLUMN worksheet_resources.config_override; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheet_resources.config_override IS '可选参数覆盖;应用按 renderer 白名单校验,不支持任意深度合并'; + + +-- +-- Name: COLUMN worksheet_resources.downloads; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheet_resources.downloads IS '内容累计成功保存次数;仅有可信资源归属的新事件递增,不分摊功能旧计数,不因个人日志删除回减'; + + +-- +-- Name: COLUMN worksheet_resources.first_published_at; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheet_resources.first_published_at IS '内容首次公开发布时间;用于资源新品判断,草稿/重发不重置,未知旧时间保持 NULL'; + + +-- +-- Name: worksheets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.worksheets ( + id character varying(128) NOT NULL COLLATE pg_catalog."C", + title character varying(100) NOT NULL, + subtitle character varying(500) NOT NULL, + category_id character varying(128) NOT NULL COLLATE pg_catalog."C", + subcategory character varying(100), + path character varying(1024) NOT NULL, + generation_mode character varying(16) DEFAULT 'procedural'::character varying NOT NULL, + renderer_key character varying(64) COLLATE pg_catalog."C", + render_config jsonb DEFAULT '{}'::jsonb NOT NULL, + age_min smallint NOT NULL, + age_max smallint NOT NULL, + grade smallint DEFAULT 0 NOT NULL, + difficulty smallint NOT NULL, + preview_asset_id character varying(128) NOT NULL COLLATE pg_catalog."C", + tags jsonb DEFAULT '[]'::jsonb NOT NULL, + is_new boolean DEFAULT false NOT NULL, + is_hot boolean DEFAULT false NOT NULL, + sort_order integer DEFAULT 0 NOT NULL, + downloads bigint DEFAULT 0 NOT NULL, + likes bigint DEFAULT 0 NOT NULL, + downloads_seed bigint DEFAULT 0 NOT NULL, + likes_seed bigint DEFAULT 0 NOT NULL, + status character varying(16) DEFAULT 'draft'::character varying NOT NULL, + first_published_at timestamp(3) with time zone, + created_at timestamp(3) with time zone DEFAULT now() NOT NULL, + content_updated_at timestamp(3) with time zone, + updated_at timestamp(3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT worksheets_age_check CHECK (((age_min >= 0) AND (age_max >= age_min))), + CONSTRAINT worksheets_counts_check CHECK (((downloads >= 0) AND (likes >= 0) AND (downloads_seed >= 0) AND (likes_seed >= 0))), + CONSTRAINT worksheets_difficulty_check CHECK (((difficulty >= 1) AND (difficulty <= 4))), + CONSTRAINT worksheets_generation_mode_check CHECK (((generation_mode)::text = ANY ((ARRAY['procedural'::character varying, 'resource'::character varying])::text[]))), + CONSTRAINT worksheets_render_config_object_check CHECK ((jsonb_typeof(render_config) = 'object'::text)), + CONSTRAINT worksheets_renderer_key_check CHECK (((renderer_key IS NULL) OR (length(btrim((renderer_key)::text)) > 0))), + CONSTRAINT worksheets_status_check CHECK (((status)::text = ANY ((ARRAY['draft'::character varying, 'active'::character varying, 'hidden'::character varying])::text[]))), + CONSTRAINT worksheets_tags_array_check CHECK ((jsonb_typeof(tags) = 'array'::text)) +); + + +-- +-- Name: TABLE worksheets; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.worksheets IS '题型内容与真实/种子计数;资源通过 assets 引用'; + + +-- +-- Name: COLUMN worksheets.generation_mode; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.generation_mode IS 'procedural 算法生成;resource 完整内容资源驱动;旧数据默认 procedural,导入逐项核对'; + + +-- +-- Name: COLUMN worksheets.renderer_key; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.renderer_key IS '客户端绘制实现标识;多题型可共用;NULL 仅供旧版 ID/path 映射兼容,新功能发布必须明确'; + + +-- +-- Name: COLUMN worksheets.render_config; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.render_config IS '共用绘制参数;仅允许已定义参数,不存执行代码或密钥'; + + +-- +-- Name: COLUMN worksheets.grade; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.grade IS '旧版单年级兼容值;可能由年龄推算;保留原值,新年级筛选使用 worksheet_grades'; + + +-- +-- Name: COLUMN worksheets.tags; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.tags IS '字符串数组;数据库限制顶层 array,服务端校验元素'; + + +-- +-- Name: COLUMN worksheets.is_new; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.is_new IS '旧版新品兼容标记;新功能新品由首次发布时间及时间窗口派生,不作为新业务权威值'; + + +-- +-- Name: COLUMN worksheets.is_hot; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.is_hot IS '功能级人工运营推荐标记,不是下载排行榜计算结果,不自动传递给资源'; + + +-- +-- Name: COLUMN worksheets.downloads; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.downloads IS '功能累计成功保存次数;保留旧累计值,资源下载也计入,不等于资源计数总和'; + + +-- +-- Name: COLUMN worksheets.first_published_at; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.first_published_at IS '首次公开发布时间;新功能由发布事务写入,重发/新增资源不重置;旧时间未知保持 NULL'; + + +-- +-- Name: COLUMN worksheets.content_updated_at; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.content_updated_at IS '仅内容修改时更新;不随计数变化;历史缺失保留 NULL'; + + +-- +-- Name: COLUMN worksheets.updated_at; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.worksheets.updated_at IS '由服务显式更新;包含计数、状态、内容变更'; + + +-- +-- Data for Name: _prisma_migrations; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public._prisma_migrations (id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count) VALUES ('6f0befd4-3285-40fd-94e3-cb0318b0f99a', 'fcf767720f78a156fff6a04a52c4a78ba85b7a6682ba5714e05f133b943da03e', '2026-10-20 07:24:00.506413+00', '0_v35_baseline', NULL, NULL, '2026-10-20 07:24:00.44403+00', 1); +INSERT INTO public._prisma_migrations (id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count) VALUES ('dd0264b5-8640-4c87-802c-1de176a5a894', 'efb27c3d6ffc8b65c91c358c95549bb5bc74e035478dfa59da372c1771d74658', '2026-10-20 07:24:00.510581+00', '20261016000000_category_publication', NULL, NULL, '2026-10-20 07:24:00.506902+00', 1); +INSERT INTO public._prisma_migrations (id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count) VALUES ('2b2a3931-3463-4a24-8cba-65d7675c9833', '22d2859a798a3b4502f27da521d7b4d9dd5438c5bf4fc18e5d52011201605158', '2026-10-20 07:24:00.513999+00', '20261019000000_member_assets', NULL, NULL, '2026-10-20 07:24:00.511081+00', 1); + + +-- +-- Data for Name: admin_audit_logs; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: assets; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: categories; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: download_logs; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: favorites; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: job_runs; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: page_configs; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.page_configs (id, home, category, age, version, updated_at, published_version, published_at) VALUES ('current', NULL, NULL, NULL, 0, '2026-10-20 07:24:00.446+00', NULL, NULL); + + +-- +-- Data for Name: settings; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.settings (id, value, version, updated_at) VALUES ('homeAutoRefresh', '{"enabled": true}', 0, '2026-10-20 07:24:00.446+00'); + + +-- +-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: worksheet_grades; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: worksheet_resource_assets; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: worksheet_resources; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: worksheets; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Name: _prisma_migrations _prisma_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public._prisma_migrations + ADD CONSTRAINT _prisma_migrations_pkey PRIMARY KEY (id); + + +-- +-- Name: admin_audit_logs admin_audit_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.admin_audit_logs + ADD CONSTRAINT admin_audit_logs_pkey PRIMARY KEY (id); + + +-- +-- Name: assets assets_object_ref_hash_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.assets + ADD CONSTRAINT assets_object_ref_hash_key UNIQUE (object_ref_hash); + + +-- +-- Name: assets assets_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.assets + ADD CONSTRAINT assets_pkey PRIMARY KEY (id); + + +-- +-- Name: assets assets_source_ref_hash_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.assets + ADD CONSTRAINT assets_source_ref_hash_key UNIQUE (source_ref_hash); + + +-- +-- Name: categories categories_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.categories + ADD CONSTRAINT categories_pkey PRIMARY KEY (id); + + +-- +-- Name: download_logs download_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.download_logs + ADD CONSTRAINT download_logs_pkey PRIMARY KEY (id); + + +-- +-- Name: download_logs download_logs_user_export_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.download_logs + ADD CONSTRAINT download_logs_user_export_key UNIQUE (user_id, export_id); + + +-- +-- Name: favorites favorites_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.favorites + ADD CONSTRAINT favorites_pkey PRIMARY KEY (id); + + +-- +-- Name: job_runs job_runs_job_run_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.job_runs + ADD CONSTRAINT job_runs_job_run_key UNIQUE (job_name, run_key); + + +-- +-- Name: job_runs job_runs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.job_runs + ADD CONSTRAINT job_runs_pkey PRIMARY KEY (id); + + +-- +-- Name: page_configs page_configs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.page_configs + ADD CONSTRAINT page_configs_pkey PRIMARY KEY (id); + + +-- +-- Name: settings settings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.settings + ADD CONSTRAINT settings_pkey PRIMARY KEY (id); + + +-- +-- Name: users users_openid_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_openid_key UNIQUE (openid); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: worksheet_grades worksheet_grades_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheet_grades + ADD CONSTRAINT worksheet_grades_pkey PRIMARY KEY (worksheet_id, grade); + + +-- +-- Name: worksheet_resource_assets worksheet_resource_assets_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheet_resource_assets + ADD CONSTRAINT worksheet_resource_assets_pkey PRIMARY KEY (id); + + +-- +-- Name: worksheet_resource_assets worksheet_resource_assets_resource_role_asset_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheet_resource_assets + ADD CONSTRAINT worksheet_resource_assets_resource_role_asset_key UNIQUE (worksheet_resource_id, role, asset_id); + + +-- +-- Name: worksheet_resources worksheet_resources_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheet_resources + ADD CONSTRAINT worksheet_resources_pkey PRIMARY KEY (id); + + +-- +-- Name: worksheets worksheets_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheets + ADD CONSTRAINT worksheets_pkey PRIMARY KEY (id); + + +-- +-- Name: admin_audit_logs_actor_created_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX admin_audit_logs_actor_created_at_idx ON public.admin_audit_logs USING btree (actor_user_id, created_at DESC); + + +-- +-- Name: admin_audit_logs_request_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX admin_audit_logs_request_id_idx ON public.admin_audit_logs USING btree (request_id); + + +-- +-- Name: admin_audit_logs_resource_created_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX admin_audit_logs_resource_created_at_idx ON public.admin_audit_logs USING btree (resource_type, resource_id, created_at DESC); + + +-- +-- Name: assets_owner_purpose_status_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX assets_owner_purpose_status_idx ON public.assets USING btree (owner_user_id, purpose, status); + + +-- +-- Name: assets_status_updated_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX assets_status_updated_at_idx ON public.assets USING btree (status, updated_at); + + +-- +-- Name: categories_parent_sort_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX categories_parent_sort_idx ON public.categories USING btree (parent_id, sort_order, id); + + +-- +-- Name: categories_status_parent_sort_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX categories_status_parent_sort_idx ON public.categories USING btree (status, parent_id, sort_order, id); + + +-- +-- Name: download_logs_user_created_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX download_logs_user_created_at_idx ON public.download_logs USING btree (user_id, created_at DESC, id DESC); + + +-- +-- Name: download_logs_worksheet_created_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX download_logs_worksheet_created_at_idx ON public.download_logs USING btree (worksheet_id, created_at DESC, id DESC); + + +-- +-- Name: favorites_user_created_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX favorites_user_created_at_idx ON public.favorites USING btree (user_id, created_at DESC, id DESC); + + +-- +-- Name: favorites_user_resource_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX favorites_user_resource_key ON public.favorites USING btree (user_id, worksheet_resource_id) WHERE (worksheet_resource_id IS NOT NULL); + + +-- +-- Name: favorites_user_worksheet_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX favorites_user_worksheet_key ON public.favorites USING btree (user_id, worksheet_id) WHERE (worksheet_resource_id IS NULL); + + +-- +-- Name: job_runs_job_created_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX job_runs_job_created_at_idx ON public.job_runs USING btree (job_name, created_at DESC); + + +-- +-- Name: job_runs_status_locked_until_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX job_runs_status_locked_until_idx ON public.job_runs USING btree (status, locked_until); + + +-- +-- Name: users_unionid_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_unionid_idx ON public.users USING btree (unionid); + + +-- +-- Name: worksheet_grades_grade_worksheet_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX worksheet_grades_grade_worksheet_idx ON public.worksheet_grades USING btree (grade, worksheet_id); + + +-- +-- Name: worksheet_resource_assets_asset_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX worksheet_resource_assets_asset_idx ON public.worksheet_resource_assets USING btree (asset_id); + + +-- +-- Name: worksheet_resource_assets_resource_role_sort_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX worksheet_resource_assets_resource_role_sort_idx ON public.worksheet_resource_assets USING btree (worksheet_resource_id, role, sort_order, id); + + +-- +-- Name: worksheet_resources_preview_asset_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX worksheet_resources_preview_asset_idx ON public.worksheet_resources USING btree (preview_asset_id); + + +-- +-- Name: worksheet_resources_worksheet_status_sort_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX worksheet_resources_worksheet_status_sort_idx ON public.worksheet_resources USING btree (worksheet_id, status, sort_order, id); + + +-- +-- Name: worksheets_category_status_sort_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX worksheets_category_status_sort_idx ON public.worksheets USING btree (category_id, status, sort_order, updated_at DESC, id); + + +-- +-- Name: worksheets_status_age_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX worksheets_status_age_idx ON public.worksheets USING btree (status, age_min, age_max); + + +-- +-- Name: worksheets_status_sort_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX worksheets_status_sort_idx ON public.worksheets USING btree (status, sort_order, updated_at DESC, id); + + +-- +-- Name: assets assets_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.assets + ADD CONSTRAINT assets_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON UPDATE RESTRICT ON DELETE SET NULL; + + +-- +-- Name: categories categories_icon_asset_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.categories + ADD CONSTRAINT categories_icon_asset_id_fkey FOREIGN KEY (icon_asset_id) REFERENCES public.assets(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: categories categories_parent_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.categories + ADD CONSTRAINT categories_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.categories(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: download_logs download_logs_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.download_logs + ADD CONSTRAINT download_logs_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: favorites favorites_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.favorites + ADD CONSTRAINT favorites_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: users users_avatar_asset_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_avatar_asset_id_fkey FOREIGN KEY (avatar_asset_id) REFERENCES public.assets(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: worksheet_grades worksheet_grades_worksheet_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheet_grades + ADD CONSTRAINT worksheet_grades_worksheet_id_fkey FOREIGN KEY (worksheet_id) REFERENCES public.worksheets(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: worksheet_resource_assets worksheet_resource_assets_asset_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheet_resource_assets + ADD CONSTRAINT worksheet_resource_assets_asset_id_fkey FOREIGN KEY (asset_id) REFERENCES public.assets(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: worksheet_resource_assets worksheet_resource_assets_resource_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheet_resource_assets + ADD CONSTRAINT worksheet_resource_assets_resource_id_fkey FOREIGN KEY (worksheet_resource_id) REFERENCES public.worksheet_resources(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: worksheet_resources worksheet_resources_preview_asset_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheet_resources + ADD CONSTRAINT worksheet_resources_preview_asset_id_fkey FOREIGN KEY (preview_asset_id) REFERENCES public.assets(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: worksheet_resources worksheet_resources_worksheet_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheet_resources + ADD CONSTRAINT worksheet_resources_worksheet_id_fkey FOREIGN KEY (worksheet_id) REFERENCES public.worksheets(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: worksheets worksheets_category_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheets + ADD CONSTRAINT worksheets_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.categories(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- Name: worksheets worksheets_preview_asset_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.worksheets + ADD CONSTRAINT worksheets_preview_asset_id_fkey FOREIGN KEY (preview_asset_id) REFERENCES public.assets(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + +-- +-- PostgreSQL database dump complete +-- + + + +-- 提交前检查迁移历史和默认配置;任何错误与建表操作在同一事务内回滚。 +DO $doodle_schema_verify$ +BEGIN + IF (SELECT count(*) FROM public._prisma_migrations WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL) <> 3 + OR EXISTS ( + SELECT 1 FROM (VALUES ('0_v35_baseline','fcf767720f78a156fff6a04a52c4a78ba85b7a6682ba5714e05f133b943da03e'), + ('20261016000000_category_publication','efb27c3d6ffc8b65c91c358c95549bb5bc74e035478dfa59da372c1771d74658'), + ('20261019000000_member_assets','22d2859a798a3b4502f27da521d7b4d9dd5438c5bf4fc18e5d52011201605158')) AS expected(name, checksum) + WHERE NOT EXISTS (SELECT 1 FROM public._prisma_migrations m + WHERE m.migration_name = expected.name AND m.checksum = expected.checksum + AND m.finished_at IS NOT NULL AND m.rolled_back_at IS NULL) + ) THEN + RAISE EXCEPTION 'Prisma 迁移历史不完整或校验和不匹配,回滚'; + END IF; + IF NOT EXISTS (SELECT 1 FROM public.page_configs WHERE id = 'current') + OR NOT EXISTS (SELECT 1 FROM public.settings WHERE id = 'homeAutoRefresh') THEN + RAISE EXCEPTION '必要默认配置缺失,回滚'; + END IF; +END +$doodle_schema_verify$; +COMMIT; diff --git a/docs/v3.5/云端数据库首次建表指南.md b/docs/v3.5/云端数据库首次建表指南.md new file mode 100644 index 0000000..7a00fc3 --- /dev/null +++ b/docs/v3.5/云端数据库首次建表指南.md @@ -0,0 +1,148 @@ +# 在 1Panel 环境初始化 doodle_prod 表结构 + +更新日期:2026-10-20。本页只说明首次建表,不导入本地分类/题型或 CloudBase 用户业务数据。已在本机隔离库验证,尚未执行云端建表。 + +## 1. 使用哪份文件 + +使用 [initialize-doodle-prod.sql](../../deploy/postgres/initialize-doodle-prod.sql),约 43 KB。 + +它从独立空库执行项目当前三次正式 Prisma 迁移后的结果生成,包含: + +- 13 张完整业务表,含外键、唯一索引、部分索引、CHECK 约束和字段注释; +- 1 张 `_prisma_migrations` 技术表,包含实际执行产生的迁移历史及校验和; +- `page_configs/current` 和 `settings/homeAutoRefresh` 两条必要默认配置。 + +没有从 `doodle_dev` 复制分类、题型、用户或其他业务数据,也没有数据库账号密码、JWT 或微信/七牛密钥。原 CloudBase 服务和任务不受影响。 + +文件为普通 PostgreSQL 18 SQL,可用于 Adminer;没有 `psql` 专用命令或 `COPY FROM STDIN` 数据段。整个初始化包在单个事务中执行,错误会回滚。 + +## 2. 从 Adminer 登录云端 PostgreSQL + +打开你已安装的 Adminer。填写: + +| 配置 | 内容 | +| ------ | -------------------------------------------------- | +| 系统 | PostgreSQL | +| 服务器 | `1Panel-postgresql-RqP6:5432` | +| 用户名 | `joey_postgresql`,当前已核查为 doodle_prod 所有者 | +| 密码 | 该数据库账号的密码 | +| 数据库 | `doodle_prod` | + +密码在 Adminer 中自行填写,不发到聊天。容器名若因重装改变,先在 1Panel 容器列表核对;Adminer 和 PostgreSQL 需要有可通信的内部 Docker 网络。 + +这里由服务器上的 Adminer 连接 PostgreSQL,不依赖本机 DBX 直连,也无需为了建表开放公网 5432。 + +## 3. 先确认选中了空库 + +进入「SQL 命令」,执行: + +```sql +SELECT current_database(), current_user; + +SELECT tablename +FROM pg_tables +WHERE schemaname = 'public' +ORDER BY tablename; +``` + +第一条的库名必须是 `doodle_prod`。第二条应无表。 + +如果已有表,先判断是否已经初始化,不执行清表或删库。初始化文件也会拒绝含用户表、视图或序列的目标,不会覆盖已有结构。 + +## 4. 上传并执行建表 SQL + +1. 在 Adminer 的「SQL 命令」页面,使用文件上传/选择文件入口。 +2. 选择本机文件:`/Users/joey-xd/sites/doodle/doodle-store/deploy/postgres/initialize-doodle-prod.sql`。 +3. 保持遇到错误停止,不选择忽略错误继续。 +4. 执行并检查结果,要求没有 SQL 错误。 + +也可以使用编辑器打开文件,全选复制到 Adminer 的 SQL 输入框,一次执行完整文件。不要只选执行其中一段:前后包含事务、空库保护和提交前校验。 + +不要改文件中的目标库名或移除保护;它只允许首次初始化空的 `doodle_prod`(另外保留了本机隔离恢复验证库名模式)。 + +如果出现报错,先保留错误信息。完整文件的业务 DDL 与默认数据在同一事务中,失败时不会提交半套结构;不要重复执行局部 CREATE TABLE 试图拼补。 + +## 5. 检查是否成功 + +重新查看表列表,应该有 14 张表: + +```sql +SELECT tablename +FROM pg_tables +WHERE schemaname = 'public' +ORDER BY tablename; +``` + +分别是: + +```text +_prisma_migrations +admin_audit_logs +assets +categories +download_logs +favorites +job_runs +page_configs +settings +users +worksheet_grades +worksheet_resource_assets +worksheet_resources +worksheets +``` + +检查迁移记录和默认配置: + +```sql +SELECT migration_name, finished_at, rolled_back_at +FROM public._prisma_migrations +ORDER BY migration_name; + +SELECT id, version FROM public.page_configs; +SELECT id, value FROM public.settings; + +SELECT + (SELECT count(*) FROM public.categories) AS categories, + (SELECT count(*) FROM public.worksheets) AS worksheets, + (SELECT count(*) FROM public.assets) AS assets, + (SELECT count(*) FROM public.users) AS users; +``` + +应有三条迁移,均 finished_at 非空、rolled_back_at 为空: + +```text +0_v35_baseline +20261016000000_category_publication +20261019000000_member_assets +``` + +默认配置应有 `current` 与 `homeAutoRefresh`。四个业务计数应全部为 0,说明只是建表,尚未导入任何业务内容。 + +执行默认配置 INSERT 不会启动任务进程;以后新后端启动时才按其设置运行,旧 CloudBase 的开关和任务持续独立。 + +## 6. 完成后做什么 + +1. 在 1Panel 为刚初始化的 `doodle_prod` 创建一份备份。 +2. 继续导入本地分类、题型及它们引用的必要资产;详细范围见 [首次内容导入手册](./分类与题型首次导入操作手册.md)。 +3. 后续再按 [生产数据整合与导入方案](./生产数据整合与导入方案.md) 迁移 CloudBase 用户业务和最新统计。 +4. 后端应用使用独立运行账号,不能把实例管理账号直接作为长期运行配置。账号授权在后端部署时单独设置。 + +这份 SQL 是当前三次迁移对应的首次空库快照,不能用于已有生产库升级。后续新增表或字段仍通过仓库正式 Prisma 迁移发布,不编辑已应用的历史迁移。 + +本机验证已经确认:快照恢复后 `prisma migrate status` 正常,随后 `migrate deploy` 没有待执行迁移,无需手工补写或再次登记这三条迁移。 + +## 7. 生成与验证记录 + +- 2026-10-20:在本机独立空库运行项目锁定的 Prisma 7.10.0 迁移,再导出普通 INSERT SQL;没有读取开发库业务表。 +- 已核对快照迁移校验和与仓库原文件一致。 +- 在第二个空库恢复成功,确认 14 张表、3 条迁移历史和 2 条默认配置。 +- 重复初始化和错误目标库保护通过。 +- 恢复后 Prisma 状态检查及无待执行迁移的 deploy 验证通过。 +- 所有本次构建/验证临时库均已删除,云端未执行初始化。 + +SHA-256: + +```text +b96493a163a20ca4fec4c597e4eb4c282361050a373a442f6651b2af25c56b3f +``` diff --git a/docs/v3.5/分类与题型首次导入操作手册.md b/docs/v3.5/分类与题型首次导入操作手册.md new file mode 100644 index 0000000..ffaf083 --- /dev/null +++ b/docs/v3.5/分类与题型首次导入操作手册.md @@ -0,0 +1,526 @@ +# 分类与题型首次导入操作手册 + +更新日期:2026-10-20。适用于把本地已整理的分类、题型及必要七牛资产映射,首次导入空的整合库或云端 `doodle_prod`。本文和配套模板不执行 CloudBase 用户数据迁移,也不代表生产库已经导入。 + +## 1. 本次到底导入哪些数据 + +| 表 | 本次处理 | +| ------------------ | --------------------------------------------------------------------------------------------- | +| `categories` | 导入本地全部分类,保留 ID、层级、发布状态、排序、图标与时间 | +| `worksheets` | 导入本地全部题型,保留 ID、内容、预览资产引用、发布状态、seed 与时间 | +| `assets` | 仅导入上述两表引用的预览图和分类图标映射,保留资产 ID、bucket、对象 key、摘要、状态及原始时间 | +| `admin_audit_logs` | 新增一条本批迁移审计;不复制本地历史操作日志 | +| 其他业务表 | 本次不复制数据;正式迁移会先创建完整表结构 | + +**不能只导出前两张表。** `worksheets.preview_asset_id` 必须引用存在的 `assets.id`,分类的文件图标也可能引用资产。只导两张表会导致外键失败,或丢失已完成的七牛映射。 + +本次不会重新上传七牛文件。保存映射不等于重新验证对象可用;导入后还要检查资源域名及预览图访问。 + +**真实下载量和收藏量在本阶段初始化为 0。** 本地旧值保存在原始导出文件中供核对;`downloads_seed` / `likes_seed` 保留本地值。后续必须从 CloudBase 最新 `worksheets` 按 ID 赋值真实 `downloads` / `likes`,不与本地旧值相加,也不在导入历史记录时再次累加。本阶段完成不表示用户历史与统计已经迁移完毕。 + +本地只读核查记录(2026-10-20): + +| 项目 | 数量 | +| -------------------------------- | ------ | +| 分类 | 7 | +| 题型 | 52 | +| 本批引用资产 | 52 | +| 本批含用户归属的资产 | 0 | +| 本批非七牛 / 非公开 / 未就绪资产 | 0 | +| 年级、内容资源、资源文件关联 | 均为 0 | + +执行时以新导出的 `manifest.json` 和校验结果为准,不把以上数量写死为永久预期。 + +## 2. 推荐操作路线 + +```text +本机 doodle_dev(只读) + → 导出 content.json + → 打包 content-import.sql + → 本机独立整合库试导、验证 + → 上传文件到云服务器 + → 在 1Panel 终端内执行 SQL + → doodle_prod 验证、备份 +``` + +第一次先导入 `doodle_migration_20261020` 进行试导。该库是一次性迁移整合库,不替代 `doodle_dev`,也无需常驻另一套服务器。 + +**云端 5432 不需要向公网放行。** 数据文件通过 1Panel 上传,SQL 在 PostgreSQL 容器内部执行。云端首次建表可按 [Adminer 建表指南](./云端数据库首次建表指南.md) 上传初始化 SQL,不依赖本机 SSH;下文另保留通过 SSH 隧道执行 Prisma 的备选方式。 + +现有旧 CloudBase 云函数、数据库、预拉取文件和任务持续保留。本次首次建库期间,不启动连接目标库的新 API 或后台任务,避免新写入影响对账。 + +## 3. 配套文件、适用条件和限制 + +配套模板: + +- [只读导出 SQL](../../deploy/postgres/content/export.sql) +- [导入事务模板](../../deploy/postgres/content/apply.sql) + +模板已包含:目标库名保护、迁移校验和与字段签名核对、分类/资产依赖检查、分类循环检查、首次空表保护、事务回滚、逐字段对账及迁移审计。 + +执行前确认: + +1. 本机 Node.js / pnpm 与项目要求一致,依赖已按锁文件安装;Docker 和本地 PostgreSQL 正常运行。 +2. 源库已应用项目正式迁移,没有失败或未完成的迁移。 +3. 目标存在完整的新后端表结构,且 `assets`、`categories`、`worksheets` 三张表都为空。本流程不会清空、覆盖或自动 upsert 已有数据。 +4. 本批资产全部为已就绪的七牛公开预览或分类图标,不包含头像。 +5. 若资产有用户归属,目标必须已有对应生产用户。找不到归属时模板停止,不自动置空 owner、不导入本地测试用户。需先完成该身份的 CloudBase 映射,或另行明确公共内容资产归属转换。 +6. 如果源库已经有年级/完整内容资源关联,或者出现 `generation_mode=resource`,本模板会停止;需扩展为包含相应关联的内容迁移,不能把缺失资源的题型当成完整导入。 + +现有 `categories:import`、`worksheets:import`、`assets:map` 是本地导入/回填工具,限定 `doodle_dev`;本手册使用独立的 PostgreSQL 内容快照流程,不修改它们的目标保护。 + +## 4. 本机:导出一致的内容快照 + +### 4.1 进入项目并确认状态 + +以下本机命令在同一个终端中顺序执行,所有文件路径均以仓库根目录为基准。每一步出现错误就停止,不继续后续步骤。 + +```bash +cd /Users/joey-xd/sites/doodle/doodle-store + +node --version +pnpm --version +pnpm infra:status + +umask 077 +doodle_batch_dir="$PWD/deploy/data/content-$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -m 700 "$doodle_batch_dir" +``` + +`deploy/data/` 已被 Git 忽略。导出文件、连接配置和真实密码不要提交仓库。不要通过 `pnpm format` 批量改写数据文件;原始快照的字节摘要用于本批对账。 + +### 4.2 执行只读导出 + +```bash +docker compose --env-file .env -f infra/compose.yaml \ + exec -T postgres sh -c \ + 'exec psql -X -qAt -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d doodle_dev' \ + < deploy/postgres/content/export.sql \ + > "$doodle_batch_dir/content.json.partial" \ + && mv "$doodle_batch_dir/content.json.partial" "$doodle_batch_dir/content.json" +``` + +导出在一个只读、可重复读事务中完成,三份数组来自同一快照;不逐表分开导出,不修改开发库。导出包含所有状态的分类/题型,不会只筛选 active;资产范围自动按引用计算。 + +只有命令成功才生成正式的 `content.json`。若只剩 `.partial` 或文件为空,先排查错误,不打包导入。 + +### 4.3 打包并生成摘要 + +以下命令只读取内容快照和仓库 SQL 模板,生成三个辅助文件。它不会连接数据库。 + +```bash +node --input-type=module - "$doodle_batch_dir" <<'JS' +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createHash } from 'node:crypto'; + +const dir = resolve(process.argv[2]); +const raw = readFileSync(resolve(dir, 'content.json')); +const payload = JSON.parse(raw.toString('utf8')); +if (payload.format !== 'doodle-content-v1' || + payload.source_database !== 'doodle_dev' || + !Array.isArray(payload.assets) || + !Array.isArray(payload.categories) || + !Array.isArray(payload.worksheets)) { + throw new Error('内容快照格式不正确'); +} +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +const digest = sha256(raw); +const template = readFileSync('deploy/postgres/content/apply.sql', 'utf8'); +const prefix = [ + '\\set ON_ERROR_STOP on', + 'BEGIN;', + "SET LOCAL TIME ZONE 'UTC';", + "SET LOCAL lock_timeout = '5s';", + "SET LOCAL statement_timeout = '60s';", + "SET LOCAL doodle.content_import_sha256 = '" + digest + "';", + 'CREATE TEMP TABLE content_import_encoded (body text NOT NULL) ON COMMIT DROP;', + 'COPY content_import_encoded (body) FROM STDIN;', +].join('\n') + '\n'; +const sql = prefix + raw.toString('base64') + '\n\\.\n' + template; +writeFileSync(resolve(dir, 'content-import.sql'), sql, { flag: 'wx', mode: 0o600 }); +const manifest = { + format: payload.format, + exportedAt: payload.exported_at, + sourceSha256: digest, + assets: payload.assets.length, + categories: payload.categories.length, + worksheets: payload.worksheets.length, + assetsWithOwner: payload.assets.filter((a) => a.owner_user_id !== null).length, + excludedRelations: payload.excluded_relations, + statisticsState: 'pending-cloudbase', +}; +writeFileSync(resolve(dir, 'manifest.json'), JSON.stringify(manifest, null, 4) + '\n', + { flag: 'wx', mode: 0o600 }); +writeFileSync(resolve(dir, 'SHA256SUMS'), + digest + ' content.json\n' + sha256(sql) + ' content-import.sql\n', + { flag: 'wx', mode: 0o600 }); +console.log(manifest); +JS +``` + +打包后的目录: + +```text +content.json 原始内容及资产快照 +content-import.sql 可直接交给 psql 的整批事务 +manifest.json 本批时间、数量和源文件摘要 +SHA256SUMS 上传前后校验文件完整性 +``` + +原始 JSON 原封不动编码进 SQL,由 PostgreSQL 自己恢复 bigint 等字段。Node.js 只读取数组长度等元数据,不将解析后的数值重新序列化为导入数据,避免大整数精度损失。Base64 用于安全封装 JSON,不是加密。 + +打包路径已存在时会报错;保留上一批次,重新导出到新目录,不覆盖原始文件。 + +## 5. 本机:建立独立整合库并试导 + +### 5.1 创建空库 + +```bash +docker compose --env-file .env -f infra/compose.yaml \ + exec -T postgres sh -c \ + 'exec createdb -U "$POSTGRES_USER" --template=template0 --encoding=UTF8 --lc-collate=C --lc-ctype=C doodle_migration_20261020' +``` + +库已存在时不要 drop 或清空。先查是否属于此前试导;新批次可改用 `doodle_migration_20261020_02`,并同步修改本节后续配置和命令。 + +### 5.2 建立仅用于整合库的连接配置 + +以下脚本读取当前服务端连接地址,检查确实是本地 `doodle_dev`,只替换库名并生成独立文件。不会改动根 `.env` 或 `apps/server/.env`,也不打印密码。 + +```bash +node --input-type=module <<'JS' +import { readFileSync, writeFileSync } from 'node:fs'; +import { parseEnv } from 'node:util'; + +const source = parseEnv(readFileSync('apps/server/.env', 'utf8')); +const url = new URL(source.DATABASE_URL); +if (!['127.0.0.1', 'localhost'].includes(url.hostname) || + url.pathname !== '/doodle_dev') { + throw new Error('请先核对本地服务端连接配置'); +} +url.pathname = '/doodle_migration_20261020'; +writeFileSync('.env.content-import', 'DATABASE_URL=' + url.href + '\n', + { flag: 'wx', mode: 0o600 }); +JS +``` + +文件已存在时先核对用途,不覆盖。该文件受 `.env.*` 忽略规则保护。 + +### 5.3 使用正式 Prisma 迁移创建结构 + +```bash +env -u DATABASE_URL node --env-file=.env.content-import --input-type=module <<'JS' +import { spawnSync } from 'node:child_process'; + +const url = new URL(process.env.DATABASE_URL); +if (!['127.0.0.1', 'localhost'].includes(url.hostname) || + url.pathname !== '/doodle_migration_20261020') { + throw new Error('连接目标不是本次本地整合库'); +} +for (const script of ['db:deploy', 'db:status']) { + const result = spawnSync('pnpm', ['--filter', '@doodle/server', script], + { stdio: 'inherit', env: process.env }); + if (result.error || result.status !== 0) process.exit(1); +} +JS +``` + +当前应成功登记三个迁移:`0_v35_baseline`、`20261016000000_category_publication`、`20261019000000_member_assets`。以后以仓库实际迁移为准,模板会核对源库和目标的迁移名称及校验和。 + +不要执行 `db push`、`migrate reset`、手工复制建表 SQL或仅手工写入迁移记录。迁移会创建完整业务表及必要默认配置,本阶段只填充内容数据。 + +### 5.4 试导同一份 SQL + +```bash +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 doodle_migration_20261020' \ + < "$doodle_batch_dir/content-import.sql" +``` + +以进程成功退出,并出现 `CONTENT_IMPORT_COMMITTED` 为成功标志。导入事务内已逐字段比较写入结果与快照;两项真实计数与已归零的临时数据比较,其余字段保持原值。 + +如出现错误,连接退出时整个业务导入事务回滚。不会留下半批分类/题型或审计。目标结构及之前单独完成的 Prisma 迁移仍保留。 + +使用第 8 节的 SQL 在整合库核对结果。**不要为测试重复执行而清空整合库;第二次导入会因三张表非空被拒绝,这是首次导入保护的预期行为。** + +## 6. 云端:创建空库、备份并初始化结构 + +### 6.1 在 1Panel 创建 doodle_prod + +如已创建,核查后沿用,不重复新建: + +| 项目 | 值 | +| --------------- | ----------------------------------------------- | +| 数据库 | `doodle_prod` | +| 所有者/迁移用户 | `doodle_prod_owner`,或此前为该库配置的专属账号 | +| 超级用户 | 不勾选 | +| 编码 | UTF-8 | +| 密码 | 独立随机密码,只保存在受控配置中 | + +在 1Panel「终端」执行以下命令,确认实际实例。容器名来自 2026-10-20 检查,若重装过实例先从容器列表核对。 + +```bash +doodle_pg_container="1Panel-postgresql-RqP6" + +docker exec "$doodle_pg_container" sh -c \ + 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d doodle_prod -c "SELECT current_database(), current_user, version(); SELECT datname, pg_get_userbyid(datdba) AS owner, pg_encoding_to_char(encoding) AS encoding FROM pg_database WHERE datname = current_database();"' +``` + +这里 `POSTGRES_USER` 在容器内部展开,代表实例初始化管理员,不是待部署 API 的运行账号。如果该实例未配置此变量,在 1Panel 连接信息中核对实际管理员用户名并替换命令中的 `-U` 参数,不输出或复制密码到聊天。 + +### 6.2 创建导入前备份 + +即使刚创建空库,也记录导入前状态。以下命令在云服务器终端执行: + +```bash +umask 077 +doodle_backup_dir="/opt/doodle/backups/content-$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -p "$doodle_backup_dir" + +docker exec "$doodle_pg_container" sh -c \ + 'exec pg_dump -U "$POSTGRES_USER" -d doodle_prod --format=custom' \ + > "$doodle_backup_dir/before-content.dump.partial" \ + && mv "$doodle_backup_dir/before-content.dump.partial" "$doodle_backup_dir/before-content.dump" + +docker exec -i "$doodle_pg_container" pg_restore --list \ + < "$doodle_backup_dir/before-content.dump" \ + > "$doodle_backup_dir/before-content.list" +``` + +检查两条命令均成功,且正式备份文件存在。`pg_restore --list` 只检查归档目录可读,不替代完整恢复验证。完整数据库备份不包含集群账号密码及服务端配置;记录所有者和权限配置以便恢复。 + +不要运行 `deploy/postgres/backup.sh`:它属于此前独立 Compose 方案,不适用于当前由 1Panel 管理的实例。 + +### 6.3 用 SSH 隧道让本机 Prisma 连接云端 + +如果已按 [首次建表指南](./云端数据库首次建表指南.md) 完整恢复初始化 SQL,并核对 14 张表和三条迁移记录,本节可跳过,直接执行第 7 节内容导入。不要再次手工建表或登记相同迁移。 + +在**本机另一个终端**启动隧道: + +```bash +ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 \ + -L 127.0.0.1:15432:127.0.0.1:5432 \ + -p 22 你的SSH用户名@175.24.138.234 +``` + +替换真实 SSH 用户和端口;`22` 只是示例。使用你已有的 SSH 密钥/密码,不修改服务器登录配置。保持该终端运行。 + +连接路径:本机 `127.0.0.1:15432` → SSH → 云服务器 `127.0.0.1:5432` → PostgreSQL。前提是云端容器仍将 5432 映射到宿主机;若绑定或端口已调整,按实际配置修改转发目标。不要新增公网 5432 放行规则。 + +回到本机项目终端,新建权限为 600 的独立配置文件: + +```bash +touch .env.production-import +chmod 600 .env.production-import +``` + +使用编辑器填入以下内容。若文件已存在,先核对其中内容,避免误用别的环境;密码中的特殊字符需要 URL 编码。 + +```dotenv +DATABASE_URL=postgresql://doodle_prod_owner:填写URL编码后的密码@127.0.0.1:15432/doodle_prod?schema=public +``` + +执行迁移: + +```bash +env -u DATABASE_URL node --env-file=.env.production-import --input-type=module <<'JS' +import { spawnSync } from 'node:child_process'; + +const url = new URL(process.env.DATABASE_URL); +if (url.hostname !== '127.0.0.1' || url.port !== '15432' || + url.pathname !== '/doodle_prod') { + throw new Error('目标不是经 SSH 隧道连接的 doodle_prod'); +} +for (const script of ['db:deploy', 'db:status']) { + const result = spawnSync('pnpm', ['--filter', '@doodle/server', script], + { stdio: 'inherit', env: process.env }); + if (result.error || result.status !== 0) process.exit(1); +} +JS +``` + +该命令只使用专属连接文件,不修改开发服务端配置。若目标已经完成相同迁移,`db:deploy` 检查后不会重建表。迁移失败先处理原因,不伪造 applied 状态。 + +如果暂时无法 SSH,不需要改用公网 5432:可以之后准备含锁定 Prisma 和迁移文件的迁移容器,在共享 Docker 网络内执行。项目当前尚无这个发布镜像,因此本文给出的可操作路线是 SSH 隧道,而不是假设云端已经安装 Node.js/pnpm。 + +## 7. 云端:上传并导入经过试导的同一批次 + +### 7.1 上传和校验 + +在 1Panel 文件管理中创建专属批次目录,例如: + +```text +/opt/doodle/imports/content-20261020-01/ +``` + +上传本机**已经试导成功**的四个文件:`content.json`、`content-import.sql`、`manifest.json`、`SHA256SUMS`。不要上传 `.env`、密码、Mac 的 node_modules 或本地整库备份。 + +在 1Panel 终端中执行: + +```bash +cd /opt/doodle/imports/content-20261020-01 +chmod 700 . +chmod 600 content.json content-import.sql manifest.json SHA256SUMS +sha256sum -c SHA256SUMS +cat manifest.json +``` + +两个摘要必须均为 `OK`,数量应与本机试导一致。上传后不要编辑 JSON 或 SQL;任何修改都生成新批次、重新试导和计算摘要。 + +摘要用于核对传输完整性,不是来源签名。文件必须来自本次受控导出,不能执行不明来源的 SQL。 + +### 7.2 复核目标为空 + +在云端执行: + +```bash +doodle_pg_container="1Panel-postgresql-RqP6" + +docker exec "$doodle_pg_container" sh -c \ + 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d doodle_prod -c "SELECT (SELECT count(*) FROM public.assets) AS assets, (SELECT count(*) FROM public.categories) AS categories, (SELECT count(*) FROM public.worksheets) AS worksheets;"' +``` + +三项必须为 0。若不是 0,停止使用本手册的首次导入入口,核对已有内容及其来源;不要 `TRUNCATE`、删除或恢复旧整库覆盖它们。 + +### 7.3 执行正式内容导入 + +仍在云端批次目录执行: + +```bash +docker exec -i "$doodle_pg_container" sh -c \ + 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d doodle_prod' \ + < content-import.sql +``` + +必须同时满足:命令成功退出、出现 `CONTENT_IMPORT_COMMITTED`。只看到 INSERT 行数不表示整个事务已提交。 + +导入顺序为资产 → 分类 → 题型 → 对账 → 迁移审计 → 提交。外键和 CHECK 约束保持启用,不使用 `session_replication_role` 或关闭触发器来绕过错误。创建表的账号所有权不会因管理员执行数据插入而改变。 + +## 8. 导入后验证 + +### 8.1 在 SQL 客户端执行 + +本机整合库可通过容器 psql 或本机客户端运行;云端可使用 1Panel 终端的 psql 或 Adminer: + +```sql +-- 先确认正在检查的库。 +SELECT current_database(); + +-- 数量与本批 manifest 对照。 +SELECT + (SELECT count(*) FROM public.assets) AS assets, + (SELECT count(*) FROM public.categories) AS categories, + (SELECT count(*) FROM public.worksheets) AS worksheets; + +SELECT status, count(*) FROM public.categories GROUP BY status ORDER BY status; +SELECT status, count(*) FROM public.worksheets GROUP BY status ORDER BY status; + +-- 预期为 0:题型缺分类或缺就绪预览。 +SELECT count(*) AS broken_worksheet_references +FROM public.worksheets w +LEFT JOIN public.categories c ON c.id = w.category_id +LEFT JOIN public.assets a ON a.id = w.preview_asset_id +WHERE c.id IS NULL OR a.id IS NULL OR a.status <> 'ready' + OR a.provider <> 'qiniu' OR a.visibility <> 'public' OR a.purpose <> 'preview'; + +-- 第一阶段真实计数为 0,seed 保留本地值。 +SELECT + sum(downloads) AS downloads, + sum(likes) AS likes, + sum(downloads_seed) AS downloads_seed, + sum(likes_seed) AS likes_seed +FROM public.worksheets; + +-- 审计中应有本批源摘要、条数及 pending-cloudbase。 +SELECT request_id, after_data, created_at +FROM public.admin_audit_logs +WHERE action = 'content.import' +ORDER BY created_at DESC; + +-- 结构迁移全部完成;不应有未完成记录。 +SELECT migration_name, checksum, finished_at, rolled_back_at +FROM public._prisma_migrations +ORDER BY migration_name; +``` + +当前记录对应 52 资产、7 分类、52 题型;状态分布和 seed 总量以该批次源数据为准。导入模板提交前已经逐字段比较全部三表,不仅检查数量。 + +### 8.2 核对资源访问 + +以实际服务端公开配置为准;当前已确认资源域名为 `https://cdn.joeyone.cn`,对象 key 自带 `doodle-assets/` 前缀。不要将该前缀重复拼接,也不要把 `source_ref` 的 cloud 地址直接当生产预览地址。 + +可在本机生成本批资源 URL 列表: + +```bash +node --input-type=module - "$doodle_batch_dir" <<'JS' +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +const dir = resolve(process.argv[2]); +const payload = JSON.parse(readFileSync(resolve(dir, 'content.json'), 'utf8')); +const urls = payload.assets.map((a) => + 'https://cdn.joeyone.cn/' + a.object_key.split('/').map(encodeURIComponent).join('/')); +writeFileSync(resolve(dir, 'asset-urls.txt'), urls.join('\n') + '\n', + { flag: 'wx', mode: 0o600 }); +JS +``` + +浏览器抽查新旧预览,或逐一请求列表中的 URL。要求无 403/404 且图片可正常显示。若发现同 key 对象内容被改变,应按原同步报告摘要重新核验;仅有 HTTP 200 不证明文件内容相同。 + +### 8.3 Adminer 和后端验收边界 + +Adminer 选择 PostgreSQL,服务器填写 `1Panel-postgresql-RqP6:5432`,数据库为 `doodle_prod`,使用该库专属账号。不要填写默认 `db`;Adminer 自身容器内的 `127.0.0.1` 也不是 PostgreSQL。 + +API 尚未部署时,到此只能确认数据库与资源;不能声称小程序已联调成功。后端部署后再验证分类、题型、详情和预览接口。分类或其祖先为 draft/hidden 时,公开查询不返回是正常发布控制,不要为了通过验收批量改成 active。 + +后续 CloudBase 用户/统计整合、页面重建和正式切换完成前,不将本阶段归零的真实计数视为最终线上统计。 + +## 9. 导入后备份、失败处理和回退 + +1. 成功后按第 6.2 节方法创建 `after-content.dump`,使用新的备份目录/文件名;保留本批原始导出、manifest、摘要、审计和导入前备份。 +2. 在独立恢复验证库试恢复 `after-content.dump`,确认表结构、数据和迁移历史都可读。仅执行 `pg_restore --list` 不足以完成恢复演练。 +3. 导入事务失败:本批数据和审计整批回滚。排查后可在仍为空的目标重试;不要通过删除约束绕过错误。 +4. 导入成功但需要撤回:先停止连接新目标库的应用写入并保留当前备份,在另一个空库恢复导入前备份、对账后再安排目标切换。本文不提供自动删生产库、清表或 `--clean` 命令。 +5. 生产库已经有新用户或用户操作后,不再用本阶段快照覆盖整库;改用受控的内容更新和后续 CloudBase 合并流程。 + +可在 1Panel 另建空的恢复验证库,再在云端运行以下命令;这里只验证备份,不覆盖 `doodle_prod`: + +```bash +docker exec -i "$doodle_pg_container" sh -c \ + 'exec pg_restore -U "$POSTGRES_USER" --dbname doodle_restore_check_20261020 --no-owner --no-acl --exit-on-error' \ + < /实际备份目录/after-content.dump +``` + +验证库恢复时省略原所有权/ACL,便于检查归档内容;这不代表生产权限已经配置完成。真实恢复上线还需按目标所有者和应用账号恢复/核对权限。备份副本需要另存服务器之外。 + +## 10. 常见错误 + +| 现象 | 处理 | +| ------------------------------------- | --------------------------------------------------------------------------------------- | +| `getaddrinfo for db failed` | Adminer 服务器填实际 PostgreSQL 容器名,且两者在可通信的 Docker 网络 | +| 连接拒绝 / SSH 本机端口冲突 | 核对 SSH 登录、隧道是否仍运行、云端宿主机映射;改本机转发端口时同步改连接文件和保护检查 | +| `permission denied for schema public` | 核对数据库 owner;Prisma 迁移使用数据库所有者,不使用只具备 DML 的运行账号 | +| 迁移名称/校验和或字段签名不一致 | 先核对仓库版本和两端迁移状态,不跳过模板检查或伪造迁移记录 | +| 目标三张表必须为空 | 可能已经成功导入,或有其他批次;核对审计及数据,不重复清空导入 | +| 内容资产含未迁移用户归属 | 先核对生产用户映射;不要自动置空归属或顺带复制开发用户 | +| 资产未就绪/用途不匹配 | 回到本地完成资源核验或修正引用后重新导出,不伪造 ready | +| 已有年级/资源关联,或资源型题型 | 本手册的三表范围已不足,扩展完整内容批次后重新试导 | +| 分类循环或引用缺失 | 在内容源修复并重新导出;不要关闭数据库约束 | +| JSON 文件只有错误输出或为空 | 导出未成功,保留错误信息排查;不要继续打包 | +| SQL 输出 INSERT 后仍报错 | 没有最终提交标记就不算成功;查询目标确认事务回滚状态 | + +## 11. 实施与验证记录 + +本手册交付范围是文档及配套 SQL 模板。2026-10-20 验证了文档中 17 段 Bash 命令与 5 段 Node.js 代码的语法,并从文档直接提取打包代码进行本机隔离库验证: + +- 正常导入:52 条资产、7 条分类、52 条题型及 1 条迁移审计,提交前逐字段核对通过。 +- 重复导入:被空表保护拒绝,已有数据保持不变。 +- 错误目标库:被库名保护拒绝。 +- 迁移校验和不一致:拒绝导入,业务表保持为空。 +- 题型 CHECK 约束失败:之前已插入的资产和分类随事务全部回滚,未留下迁移审计。 +- 计数:真实 downloads/likes 均为 0,downloads_seed=629、likes_seed=666,保留源值。 + +验证创建的三个隔离数据库已清理。源 doodle_dev 仅只读导出,未改动;未连接云端执行导入,也未在本次验证中请求七牛或测试 SSH/1Panel 上传。迁移快照、语法检查和回滚验证不能替代实际云端连接、生产权限和资源可访问性验收。 + +参考:[生产数据整合与导入方案](./生产数据整合与导入方案.md)、[Prisma 迁移说明](../../apps/server/prisma/README.md)、[PostgreSQL 18 psql](https://www.postgresql.org/docs/18/app-psql.html)、[PostgreSQL 18 JSON 函数](https://www.postgresql.org/docs/18/functions-json.html)、[Prisma 7 migrate deploy](https://www.prisma.io/docs/cli/v7/migrate/deploy)。 diff --git a/docs/v3.5/开发交接文档.md b/docs/v3.5/开发交接文档.md index 562c159..7115eb8 100644 --- a/docs/v3.5/开发交接文档.md +++ b/docs/v3.5/开发交接文档.md @@ -2,6 +2,8 @@ 更新时间:2026-10-19。本次整理当前进度和后续任务,未执行云端部署。验证结果来自此前开发记录,本次文档更新未重新运行全套测试。 +2026-10-20 最新决策:云端 PostgreSQL 由 1Panel 安装管理。本地 `doodle_dev` 提供已整理的内容及七牛映射,CloudBase 提供最新用户、收藏、下载记录及题型真实计数;建议在本机独立迁移库整合后导入 `doodle_prod`。此前“仅导内容”和“直接全量复制本地库”的约定均已被替代;逐表来源和补导边界见 [生产数据整合与导入方案](./生产数据整合与导入方案.md)。其余尚未更新的历史部署建议不覆盖本条决策。 + ## 1. 当前阶段与保留约束 本地数据库、主要业务后端、小程序开发代码的接口迁移及七牛接入已完成。下一阶段先准备部署工程,再部署云端测试 API 和独立测试数据库,供小程序体验版联调。 @@ -119,7 +121,7 @@ ### B. 云端数据与运维 - [ ] 使用 `prisma migrate deploy` 执行迁移,不使用开发期重置命令初始化云端业务库。 -- [ ] 准备受控内容初始化流程,迁入分类、题型、关联资产及必要配置;核对 ID、发布状态、计数、分类关联和预览,不整库复制测试用户及临时记录。 +- [ ] 按 2026-10-20 最新方案整合本地内容/七牛映射与 CloudBase 最新用户业务/统计;先在独立迁移库转换和对账,再迁入云端,账号和运行密钥单独配置。 - [ ] 配置数据库持久化、异机备份、恢复与迁移失败处理,并完成一次恢复验证。 - [ ] 保留上一可运行后端版本,明确应用回滚与数据库迁移的兼容边界。 diff --git a/docs/v3.5/生产数据整合与导入方案.md b/docs/v3.5/生产数据整合与导入方案.md new file mode 100644 index 0000000..cfc43c3 --- /dev/null +++ b/docs/v3.5/生产数据整合与导入方案.md @@ -0,0 +1,84 @@ +# 生产数据整合与导入方案 + +更新时间:2026-10-20。本文整理本轮最新的数据来源与推荐导入流程,尚未执行导出、合并或云端导入。 + +## 1. 最新范围与数据来源 + +目标是建立同时包含本地已整理内容与 CloudBase 最新用户业务数据的 `doodle_prod`。本条替代此前“只导内容”和“直接整库复制本地 doodle_dev”两种约定。 + +- 内容来源:本地 `doodle_dev` 中已修改的分类、题型、内容关联和七牛资源映射。 +- 用户业务来源:小程序实际使用的 CloudBase 环境中最新的用户、收藏和下载记录。 +- 统计、头像、设置等跨来源数据按下表逐字段处理,不用任何一个来源整库覆盖另一个来源。 +- 原始源文件和本地开发库保留;先在独立迁移库整合和验证,再导入云端。旧云函数、旧数据、预拉取文件及定时任务继续保留,本次不自动修改或停用。 + +## 2. 表与字段来源 + +当前 Prisma 定义 13 张业务表,另有 `_prisma_migrations` 技术表。以下为推荐执行规则,实际导入前预演并列出冲突。 + +| 目标表 | 数据来源与处理 | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `categories` | 本地。保留分类 ID、名称、层级、排序、发布状态及图标引用。 | +| `worksheets` | 内容字段取本地:标题、分类、路径、年龄/年级、难度、标签、排序、运营标记、发布状态、生成配置与预览资产引用。按相同 worksheet ID 合并 CloudBase 最新 `downloads` / `likes`;`downloads_seed` / `likes_seed` 保留本地值,不与真实计数混加。 | +| `worksheet_grades` | 本地。保留经本地维护的适用年级关系,不从旧兼容 grade 自动推断。 | +| `worksheet_resources` | 本地。保留内容资源及预览关系。 | +| `worksheet_resource_assets` | 本地。保留内容资源与文件关联。 | +| `assets` | 内容资源映射取本地;用户头像按 CloudBase 最新 `avatarUrl` 匹配已核验的七牛对象,缺失时补迁文件并建立资产。不能整表沿用本地旧头像绑定。 | +| `users` | CloudBase。保留有效的源 `_id → id`、OpenID、UnionID、昵称与原始时间;头像转换为新 `avatar_asset_id`。本地同一微信用户按 OpenID 对照,不带入本地测试身份、管理员授权或凭证状态。 | +| `favorites` | CloudBase 最新完整快照。按迁移后的 user ID 和稳定 worksheet ID 建立关联;校验唯一性,反映已取消的收藏,不将本地测试收藏合入。 | +| `download_logs` | CloudBase。保留原 ID、用户/题型引用、时间和已有 exportId;重复源记录、exportId 冲突及缺失关联进入报告,不对无 exportId 的不同历史记录擅自合并。 | +| `settings` | 逐项转换。导出 CloudBase 设置作为对照,迁移需要继续使用的业务配置;新后端专有配置单独初始化。旧 `homeAutoRefresh` 控制旧云任务,新开关控制新后端任务,不能把二者视为同一个执行器。 | +| `page_configs` | 在数据整合后由新后端重建。保留两边旧快照作核对,不直接恢复含旧资源地址或旧统计的页面缓存,不修改旧线上预拉取文件。 | +| `job_runs` | 初始化新后端任务状态。本地旧任务记录保留在源库/归档中供核对,不让开发环境的运行锁、租约或任务结果成为生产任务状态。 | +| `admin_audit_logs` | 为本次导入生成带来源、批次和摘要的迁移审计。本地历史审计保留归档,不直接当作已在线上发生的管理员操作。 | +| `_prisma_migrations` | 迁移库由仓库锁定的正式迁移初始化,保留其真实执行历史;若最终导出包含结构和该表,恢复后检查迁移状态,不重新手工建表。 | + +所有源 ID 和业务计数的最终值均需报告;用户字段或设置缺少对应目标字段时采用明确映射,不透传未知字段。 + +## 3. 合并时需要单独处理的问题 + +### 题型计数 + +旧云函数会实时更新 CloudBase `worksheets.downloads` 和 `likes`,因此这两个字段不能取本地旧值,也不能在导入日志/收藏时再累加一次。旧计数写入与记录写入并非同一事务;先保留线上源计数,同时统计日志/收藏与计数的差异,不未经核实就用记录条数覆盖历史运营计数。 + +CloudBase 中不存在的本地新题型保留内容及展示 seed,真实计数建议初始化为 0,排除开发操作产生的计数。CloudBase 中存在、但本地不存在的题型进入差异清单;先补齐内容和七牛映射,再处理相关收藏/下载,不丢弃线上记录或用旧预览地址覆盖本地内容。 + +### 用户身份、头像和权限 + +CloudBase `_id` 保留为生产 user ID,同一小程序 AppID 下的 OpenID 用于核对本地与线上的相同身份,昵称和头像不能用作身份匹配。若本地资产归属使用了另一个 user ID,需要按已核对的身份映射转换;无法匹配的归属进入冲突清单。 + +旧用户结构没有新后端的完整权限与凭证字段。缺失时按新后端规则初始化 `role=user`、`status=active`、`auth_version`;生产管理员另按明确授权设置,不继承本地开发授权。线上存在的状态/权限字段须先核对其真实含义,不能盲目套用本地值。 + +`users.total_downloads` 需与 CloudBase 原始 `totalDownloads` 和有效下载记录对账。当前旧下载云函数未维护用户的 totalDownloads,不能只复制可能一直为 0 的字段;建议按去重和关联校验后的有效下载记录数建立该用户统计,并在报告中保留原值及调整理由。 + +头像必须以本次线上用户记录为准。已清除的头像保持为空;现有七牛对象只有在源引用和文件校验匹配时才复用。此前备份的头像可能过期,不能按文件名推断归属或默认认为用户仍在使用该图片。 + +CloudBase 旧注销逻辑仅删除 users,可能留下收藏或下载记录。找不到用户的记录进入隔离报告并保留原始导出,不为了满足关联而重新创建已注销账户。 + +### 快照时效与后续补导 + +一次导入得到的是某次快照,不会自动保持实时同步。CloudBase 继续接收用户操作时,导入后仍会新增下载、修改资料、取消收藏或删除账户;仅按时间追加新行无法完整处理这些变化。 + +正式切换前需另行安排最终快照/补导与对账;旧版本仍写 CloudBase 时还需要明确兼容或持续同步策略。本方案不以“已导入”为由自动停写旧服务,也不宣称新旧系统已实时一致。后续补导以 CloudBase 用户业务为来源,不再用本地旧整库备份覆盖已运行的生产库。 + +## 4. 推荐流程:先在本机独立迁移库整合 + +第一阶段分类、题型及必要资产的逐步命令、试导、云端连接和回退流程见 [分类与题型首次导入操作手册](./分类与题型首次导入操作手册.md)。该阶段真实题型计数暂置 0,后续仍按本方案从 CloudBase 最新快照赋值。 + +建议创建本地 `doodle_migration_YYYYMMDD` 作为本轮一次性整合库;它与日常 `doodle_dev` 分开,也不是需要长期维护的 staging 环境。 + +```text +本地 doodle_dev:内容与七牛映射 ─┐ + ├→ 本机独立迁移库 → 对账与恢复验证 → doodle_prod +CloudBase:最新用户业务与统计 ──┘ +``` + +1. 保存两个来源的不可变导出、批次时间、条数与文件 SHA-256;CloudBase 导出覆盖 users、favorites、download_logs,以及用于计数/差异对照的 worksheets、categories、settings、page_configs。 +2. 用项目正式 Prisma 迁移初始化独立迁移库,不修改 `doodle_dev`。 +3. 导入本地内容及对应资产;若内容资产含用户归属,先按线上身份建立依赖所需的用户骨架。按分类父子及资源依赖顺序写入。 +4. 导入 CloudBase 用户主体(头像先留空),核验/补迁头像资产后再绑定;处理 OpenID 重复、源 ID 冲突和用户归属映射。 +5. 导入收藏、下载记录,合并线上题型计数,核对用户统计,完成设置转换和页面配置重建。 +6. 对账内容完整性、源 ID、关联、记录数量、计数、头像归属、资源可访问性及管理员权限;冲突与隔离记录必须有处理结果或明确保留状态,不将它们默认为成功导入。 +7. 对首次空目标制作完整 PostgreSQL 导入包,排除数据库集群角色/密码和本机运行密钥,试恢复验证后上传并导入 `doodle_prod`。若目标已使用,先核查并备份,改用可重复的受控合并流程,不直接覆盖。 +8. 云端验证并备份;保留批次清单与映射,作为正式切换前补导、对账和回滚依据。 + +数据库密码、JWT 密钥、微信 AppSecret、七牛 AK/SK 不写入导出报告或源码。导入包包含用户数据,使用受控目录并通过 SSH/1Panel 传输;不要求开放 PostgreSQL 公网端口。 diff --git a/packages/asset-sync/README.md b/packages/asset-sync/README.md index 7c9cd9e..791a120 100644 --- a/packages/asset-sync/README.md +++ b/packages/asset-sync/README.md @@ -2,6 +2,115 @@ 独立 Node.js 脚本项目,使用官方 qiniu SDK 7.15.2。入口命令在 doodle-store 根目录执行。只复制本地文件到已存在的七牛空间,不创建空间、不删除/覆盖远端对象、不修改数据库或业务 URL。 +## 前置步骤:通过 tcb 下载云存储备份 + +更新日期:2026-10-20。本节记录日后重新导出资源的操作流程;本次编写文档未登录云环境或执行下载。已有备份位于 `/Users/joey-xd/sites/doodle/doodle-assets/cloud_backup`,无需为了使用同步工具重复下载。 + +流程为:`原小程序云存储 → tcb 下载到本地 → SOURCE_DIR 指向备份根目录 → asset-sync 上传七牛 → 按需回填数据库`。这里下载的是云存储文件,不是数据库记录、云函数代码或完整环境备份。 + +当前线上版小程序仍使用旧云开发,原预拉取模式、地址、生成链路和全部旧云函数必须保留。本流程只读取源文件,不删除源文件、不调整存储权限、不停用云函数或定时任务;后续的七牛同步也不会替代旧线上服务。 + +### 1. 安装并检查 CloudBase CLI + +本机已安装 `@cloudbase/cli`,本次通过版本及帮助命令核对的版本为 **3.8.7**。新机器可安装同一版本复现操作;已有 tcb 时先检查版本,不必重复安装: + +```bash +# 仅新机器未安装时执行 +pnpm add -g @cloudbase/cli@3.8.7 + +tcb --version +tcb storage download --help +``` + +使用当前的空格形式 `tcb storage download`;旧文档可能使用冒号形式 `tcb storage:download`。升级 CLI 后先重新查看帮助。参考 [官方安装说明](https://docs.cloudbase.net/cli-v1/install)。 + +### 2. 登录并确认原云环境 + +以下 tcb 命令在 `doodle-mini` 目录执行,七牛同步命令则在 `doodle-store` 根目录执行: + +```bash +cd /Users/joey-xd/sites/doodle/doodle-mini + +# 按终端提示在浏览器中完成授权 +tcb login + +# 核对账号能访问的环境 +tcb env list + +# 显式指定原小程序环境,避免误用其他项目的默认环境 +tcb storage list -e cloud1-9gifs7a2756e2c87 +``` + +环境 ID 来自 `doodle-mini/cloudbaserc.json`。如果更换源环境,应重新核对并替换下列命令中的 ID;不要直接复制 `cloud://` 文件 ID 作为环境 ID。 + +授权账号必须能管理原小程序云环境的云存储。微信 AppSecret 和七牛 AK/SK 均不是 tcb 登录凭据。若列表中看不到原环境,先核对账号权限及环境所在地域;必要时按实际地域使用全局 `--region` 参数,不为下载另建环境或改变权限。 + +### 3. 下载全部资源 + +每次新备份使用独立目录,保留已有 `cloud_backup`。在同一个终端依次执行: + +```bash +DOODLE_CLOUD_BACKUP="/Users/joey-xd/sites/doodle/doodle-assets/cloud_backup-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$DOODLE_CLOUD_BACKUP" + +tcb storage download / "$DOODLE_CLOUD_BACKUP" --dir -e cloud1-9gifs7a2756e2c87 +``` + +`/` 表示下载云存储全部文件,`--dir` 表示目录下载。云端路径使用相对对象路径,不使用临时 HTTPS 链接。命令依据 [官方云存储下载说明](https://docs.cloudbase.net/cli-v1/storage)。本项目源端为旧小程序云环境,不因新后端采用 PostgreSQL 就改用 `storage objects` 等 PG 环境命令。 + +如果只补下载题型预览,可改用目录下载,目标仍保留 `assets/previews` 层级;这是全量下载的替代方式,无需两者都执行: + +```bash +mkdir -p "$DOODLE_CLOUD_BACKUP/assets/previews" +tcb storage download assets/previews "$DOODLE_CLOUD_BACKUP/assets/previews" --dir -e cloud1-9gifs7a2756e2c87 +``` + +其他目录按实际云端清单选择,例如 `assets/fonts`、`assets/materials`、`content`、`user-avatars`。云端仍在写入,目录下载不是某个时间点的原子快照;下载失败或重试后都要核对实际文件,不把命令退出成功等同于完整备份,也不将旧目录混入新备份来掩盖缺失。 + +### 4. 核对目录并连接七牛同步 + +备份根目录应保留云端相对路径,例如: + +```text +cloud_backup-<时间>/ +├── assets/ +│ ├── fonts/ +│ ├── materials/ +│ └── previews/ +│ ├── chinese/ +│ ├── english/ +│ ├── math/ +│ ├── papers/ +│ ├── pinyin/ +│ └── puzzle/ +├── content/ +│ └── page-config.json +└── user-avatars/ +``` + +这只是路径示例,不代表每个目录必有文件;以本次云端列表和下载结果为准。不要多套一层环境 ID、重复的 previews 目录,或直接把文件平铺到备份根目录,否则七牛对象 key 会变化。 + +抽查图片能打开、字体文件非空、`content/page-config.json` 能解析,并对照云端列表核对数量和大小。历史备份为 62 个文件,其中预览 51 个、头像 7 个、字体/素材/首页配置/demo 各 1 个;这是旧备份基线,不是未来下载必须满足的固定数量。demo 等其他对象也应保留原路径,不限于上方示例目录。 + +检查完成后,将 `packages/asset-sync/.env` 的 `SOURCE_DIR` 手动改为新备份的实际绝对路径;继续使用已有备份时保持原值: + +```dotenv +SOURCE_DIR=/Users/joey-xd/sites/doodle/doodle-assets/cloud_backup +``` + +不要把包含 `$DOODLE_CLOUD_BACKUP` 的字符串直接写进 `.env`;该配置需要实际路径。`SOURCE_DIR` 指向包含 `assets`、`content`、`user-avatars` 的根目录,不是 `assets/previews` 子目录。备份目录不应存放下载日志、清单或报告,这些普通文件也可能被同步工具扫描上传。 + +回到 `doodle-store`,先预演并核对报告中的相对路径、目标 key 和文件数量,再正式上传: + +```bash +cd /Users/joey-xd/sites/doodle/doodle-store + +pnpm assets:sync +pnpm assets:sync --apply +``` + +本项目当前使用 `QINIU_KEY_PREFIX=doodle-assets/`,补同步已有资源时应保持该值,并与 server 配置一致;下方配置段中的 `doodle/` 是工具默认示例,不能在补同步时误换前缀。同步与数据库回填是独立步骤,详见本文末尾。 + ## 配置 已生成本机 `packages/asset-sync/.env`,源目录配置为: