From 02148c7b8b859e2c09ed860617a3a208701378a2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 20:15:37 +0800 Subject: [PATCH] docs: organize legacy reference project --- README.md | 1 + docs/refactor/project-structure.md | 123 +++++++++++++++++++++++++++++ scripts/smoke-core-api.js | 117 +++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 docs/refactor/project-structure.md create mode 100644 scripts/smoke-core-api.js diff --git a/README.md b/README.md index 1aa41e1f..15b4b0de 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ - `docs/refactor/backend-handoff-roadmap.md` - `docs/refactor/content-import-contract.md` - `docs/refactor/object-storage.md` +- `docs/refactor/project-structure.md` - `docs/refactor/next-development-todo.md` - `docs/refactor/blueprint-coverage.md` - `docs/refactor/api-structure.md` diff --git a/docs/refactor/project-structure.md b/docs/refactor/project-structure.md new file mode 100644 index 00000000..d31de68f --- /dev/null +++ b/docs/refactor/project-structure.md @@ -0,0 +1,123 @@ +# 项目结构说明 + +更新时间:2026-06-28 + +这份文档用于区分新 Supabase/PostgreSQL 重构项目和旧 PocketBase 题库参考项目,避免后续提交 Gitea 时把旧项目文件混入新仓库。 + +## 当前仓库原则 + +- Git 跟踪的新项目代码,是商用 SaaS 重构后的后端、数据库迁移、导入工具、测试脚本和重构文档。 +- 旧 PocketBase/React 题库项目只作为功能对照、数据迁移参考和前端样式参考,不作为当前 Gitea 仓库的源码主体。 +- 旧项目参考文件集中放在 `参考/旧题库项目/`,该目录被 `.gitignore` 忽略,不会进入提交。 + +## 新重构项目目录 + +```text +F:\project + apps/ + api/ Node.js 业务 API + src/ + core/ 配置、HTTP、路由、数据库访问 + features/ 业务模块 + auth/ 迁移期登录、短信 mock、OAuth 占位 + catalog/ 学生端目录、题库、资料、商品只读接口 + commerce/ 订单、支付确认、激活码、权益 + health/ 健康检查 + learning/ 练习、答题、错题、收藏、单词进度 + platform-admin/ 平台租户、套餐、账单、用量 + profile/ 学生个人中心 + referral/ 销售/代理/CRM 增长链路 + scoreline/ 分数线 + storage/ 对象存储签名 provider + tenant/ 租户解析 + tenant-admin/ 租户后台配置、成员权限、审计 + tenant-content/ 租户内容后台、导入、资源台账 + video/ 题目视频讲解 + types/ 第三方 SDK 窄类型声明 + Dockerfile + package.json + tsconfig.json + + packages/ + config/ 共享配置和 env 工具 + db/ PostgreSQL 连接池和查询封装 + domain/ 领域常量和共享类型 + + supabase/ + migrations/ PostgreSQL schema、RLS、索引、触发器 + seed.sql 本地最小 seed + config.toml Supabase local 配置 + + scripts/ + import-pocketbase/ PocketBase schema/数据导入器和校验器 + api-integration-test.js API 集成测试 + smoke-seed.js 本地 smoke seed + smoke-core-api.js 轻量核心 API 烟测 + + docs/ + pb_schema.json 旧 PocketBase schema 输入文件 + refactor/ 新架构、进度、交付和 TODO 文档 + + docker-compose.api.yml API 容器运行配置 + package.json 工作区脚本入口 + README.md 中文项目总览 +``` + +## 旧项目参考目录 + +旧项目已经整理到: + +```text +F:\project\参考\旧题库项目 +``` + +其中主要内容: + +```text +参考/旧题库项目/ + src/ 旧 React/Vite 前端 + pb_hooks/ 旧 PocketBase hooks + pb_migrations/ 旧 PocketBase migrations + docs/ 旧项目功能、导入、对接、部署文档 + public/ 旧前端静态资源 + scripts/ 旧项目迁移、部署、统计、卫星站脚本 + setup/ 旧项目安装配置 + DEPLOY.md 旧宝塔/PocketBase 部署说明 + index.html 旧 Vite 入口 + vite.config.ts 旧 Vite 配置 + tailwind.config.js 旧 Tailwind 配置 + tsconfig.json 旧前端 TS 配置 +``` + +## 后续提交规范 + +提交前建议先看: + +```bash +git status --short --branch +``` + +正常情况下,后续提交应只包含这些路径: + +- `apps/api/**` +- `packages/**` +- `supabase/**` +- `scripts/import-pocketbase/**` +- `scripts/api-integration-test.js` +- `scripts/smoke-seed.js` +- `scripts/smoke-core-api.js` +- `docs/refactor/**` +- `docs/pb_schema.json` +- 根目录的 `.env.example`、`.gitignore`、`README.md`、`package.json`、`package-lock.json`、`docker-compose.api.yml` + +如果看到 `参考/旧题库项目/**`,说明 `.gitignore` 被改坏了,必须先修复再提交。 + +## 前端重构建议 + +后续 Taro 前端建议新建: + +```text +apps/taro/ +``` + +不要把旧 `src/` 重新搬回根目录继续开发。旧前端只作为视觉、页面、交互和字段迁移参考;新 Taro 应统一调用 `apps/api`,并把跨端 API client、租户解析、主题配置、登录、支付、资料下载和刷题链路放在新工程内。 diff --git a/scripts/smoke-core-api.js b/scripts/smoke-core-api.js new file mode 100644 index 00000000..0ff251fb --- /dev/null +++ b/scripts/smoke-core-api.js @@ -0,0 +1,117 @@ +const apiBase = process.env.API_BASE || 'http://127.0.0.1:8787'; +const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001'; +const userId = process.env.USER_ID || '00000000-0000-0000-0000-000000000101'; + +const ids = { + region: '00000000-0000-0000-0000-000000000301', + question: '00000000-0000-0000-0000-000000000401', + vocabularyUnit: '00000000-0000-0000-0000-000000000811', + vocabularyWord: '00000000-0000-0000-0000-000000000812', + scorelineSchool: '00000000-0000-0000-0000-000000000831', +}; + +function url(path, query = {}) { + const target = new URL(path, apiBase); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null && value !== '') { + target.searchParams.set(key, String(value)); + } + } + return target; +} + +async function request(path, options = {}) { + const response = await fetch(url(path, options.query), { + method: options.method || 'GET', + headers: { + 'content-type': 'application/json', + 'x-tenant-id': tenantId, + 'x-user-id': userId, + ...(options.headers || {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(`${options.method || 'GET'} ${path} failed: ${response.status} ${JSON.stringify(payload)}`); + } + return payload; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function check(name, fn) { + const result = await fn(); + console.log(`[PASS] ${name}`); + return result; +} + +async function main() { + await check('health', async () => { + const payload = await request('/health'); + assert(payload.ok === true, 'health response should be ok'); + }); + + await check('profile me', async () => { + const payload = await request('/api/profile/me'); + assert(payload.item?.userId === userId, 'profile should belong to smoke user'); + assert(payload.item?.stats?.vocabulary?.totalWords >= 1, 'profile should include vocabulary stats'); + }); + + await check('scoreline fields', async () => { + const payload = await request('/api/scoreline/fields', { query: { regionId: ids.region } }); + assert(payload.items?.some(item => item.fieldKey === 'minScore'), 'scoreline fields should include minScore'); + }); + + await check('scoreline schools and records', async () => { + const schools = await request('/api/scoreline/schools', { query: { regionId: ids.region } }); + assert(schools.items?.some(item => item.id === ids.scorelineSchool), 'scoreline school should exist'); + + const records = await request('/api/scoreline/records', { query: { regionId: ids.region, pageSize: 5 } }); + assert(records.total >= 1, 'scoreline records should have data'); + + const years = await request('/api/scoreline/years', { query: { regionId: ids.region } }); + assert(years.items?.includes(2026), 'scoreline years should include 2026'); + }); + + await check('question videos', async () => { + const single = await request(`/api/questions/${ids.question}/videos`); + assert(single.total >= 1, 'question should have videos'); + + const batch = await request('/api/questions/videos/batch', { + method: 'POST', + body: { questionIds: [ids.question] }, + }); + assert(batch.data?.[ids.question]?.hasVideo === true, 'batch video lookup should mark question as having video'); + }); + + await check('vocabulary progress and favorite', async () => { + const stats = await request('/api/learning/vocabulary/stats', { query: { unitId: ids.vocabularyUnit } }); + assert(stats.item?.totalWords >= 1, 'word stats should count smoke word'); + + const progress = await request('/api/learning/vocabulary/progress', { + method: 'POST', + body: { userId, wordId: ids.vocabularyWord, status: 'mastered', correctDelta: 1 }, + }); + assert(progress.item?.status === 'mastered', 'word progress should update to mastered'); + + const favorite = await request('/api/learning/vocabulary/favorites', { + method: 'POST', + body: { userId, wordId: ids.vocabularyWord, favorite: true }, + }); + assert(favorite.favorite === true, 'word favorite should be true'); + + const favorites = await request('/api/learning/vocabulary/favorites', { query: { unitId: ids.vocabularyUnit } }); + assert(favorites.items?.some(item => item.wordId === ids.vocabularyWord), 'favorite list should include smoke word'); + }); + + console.log('Core API smoke complete.'); +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +});