diff --git a/.ai/README.md b/.ai/README.md index 2e8beae..2d3270e 100644 --- a/.ai/README.md +++ b/.ai/README.md @@ -27,6 +27,8 @@ Also always: `ai-contract.md`, `forbidden.md`, `file-map.md`, `workflow.md`, `co ├── domain/ │ └── domain-map.md # DDD contexts / aggregates ├── product/ +│ ├── feature-design.md # Feature Spec 强制规范 +│ ├── feature-spec/ # 单功能详细设计 │ ├── feature-map.md # 愈心谷 Feature Tree │ └── cece-feature-map.md ├── file-map.md @@ -36,7 +38,7 @@ Also always: `ai-contract.md`, `forbidden.md`, `file-map.md`, `workflow.md`, `co ├── api.md ├── database.md ├── ui.md # IA pointer → design/ -├── design/ # AI Design System Contract +├── design/ # AI Design System + reverse-engineering-spec.md ├── environment.md # Local / CI / Prod 分离 ├── development.md # 本地本机开发 ├── docker.md # Docker 用途边界 diff --git a/.ai/ai-contract.md b/.ai/ai-contract.md index 39e34c0..1678134 100644 --- a/.ai/ai-contract.md +++ b/.ai/ai-contract.md @@ -9,26 +9,30 @@ MUST read: 1. `.ai/constitution.md` 2. `.ai/architecture.md` 3. `.ai/domain.md` (+ `domain/domain-map.md` when touching models/API) -4. `.ai/coding.md` -5. Task-relevant: `api.md` / `database.md` / `ui.md` / `security.md` / `product/lexicon.md` + `product/feature-map.md` -6. Run/dev tasks: `environment.md` + `development.md`(部署/镜像任务才读 `deployment.md` / `docker.md`) -7. UI tasks: `design/design-system.md` + `design/component-catalog.md` + `design/platform/*` -8. Relevant ADR under `.ai/adr/` if changing stack or style +4. `.ai/definition-of-done.md`(完成标准;开始前知道何谓 Done) +5. `.ai/coding.md` +6. Task-relevant: `api.md` / `database.md` / `ui.md` / `security.md` / `testing.md` / `product/lexicon.md` + `product/feature-map.md` + **`product/feature-design.md`** + **`product/feature-spec/.md`**(该功能 Spec;§4 User Flow、§7 Rules、§9 API、§12 Acceptance 必须非空) +7. Run/dev tasks: `environment.md` + `development.md`(部署/镜像任务才读 `deployment.md` / `docker.md`) +8. UI tasks: `design/design-system.md` + `design/component-catalog.md` + `design/platform/*` +9. Competitor / reverse-engineering tasks: `design/reverse-engineering-spec.md`(STEP 1–18 · Evidence First;禁止 Demo 式省略) +10. Relevant ADR under `.ai/adr/` if changing stack or style ## After coding MUST: -1. Run / follow `.ai/review.md` and print Review block -2. Verify `.ai/definition-of-done.md` +1. Verify `.ai/definition-of-done.md` — **Done ≠ Code Complete** +2. Run / follow `.ai/review.md` and print **Review Report**(DoD §7) 3. Use matching checklist under `.ai/checklists/` 4. Prefer verifying with `.ai/commands.md` (build/test/health) +5. If P1 scope: update or respect labels in `product/p1-status.md`(当前为 **P1 Complete**;扩行为须新 Spec) ## Never - Guess API shape - Guess DB schema - Guess product requirement +- Code new/changed feature behavior without Feature Spec(`product/feature-design.md`) - Invent endpoints, tables, or domain words not in `.ai/domain.md` / OpenAPI / task - Silently reverse an Accepted ADR - Default local workflow to full-stack Docker / rebuild app images on every code change diff --git a/.ai/architecture/go-services.md b/.ai/architecture/go-services.md index 7bab592..0d40d4c 100644 --- a/.ai/architecture/go-services.md +++ b/.ai/architecture/go-services.md @@ -15,7 +15,7 @@ | `scale` | 探索测试定义与计分 | Yes | | `relation` | RelationInsight | Yes **必做** | | `report` | GrowthReport 读取 + **权益裁剪** | Yes | -| `ask` | Thread/Message;规则或薄 LLM | Skeleton | +| `ask` | Thread/Message;P1 规则引擎 + 额度 | Yes(Demo) | | `companion` | SolarTerm 读、Mood 写 | Shell | | `membership` | 成长会员状态与额度 | Yes | | `order` | Order + pay-mock + DeepAccess 发放 | Yes | diff --git a/.ai/checklists/feature.md b/.ai/checklists/feature.md index 26f886f..e8f54b4 100644 --- a/.ai/checklists/feature.md +++ b/.ai/checklists/feature.md @@ -1,16 +1,38 @@ # Checklist: Feature -- [ ] Architecture layers respected -- [ ] Domain words match `.ai/domain.md` -- [ ] API (+ OpenAPI if public) -- [ ] Service -- [ ] Repository (if persistence) -- [ ] Migration (if schema) -- [ ] SDK/types (if client needs) -- [ ] UI (if user-facing) -- [ ] Test or smoke steps -- [ ] Docs touched if behavior user-visible -- [ ] `go test` / `npm run build:h5` as applicable -- [ ] Health still OK -- [ ] Review block printed -- [ ] DoD satisfied +对照 `.ai/definition-of-done.md` + `product/feature-design.md`。全部适用项勾选后才能称 Feature Complete。 + +## Product / Spec + +- [ ] `feature-spec/.md` 存在且 Status=Active(或本次已更新) +- [ ] feature-map / journey / page-tree 覆盖 +- [ ] Spec §4 User Flow 完整(含无数据/权益分支) +- [ ] Spec §7 Business Rules 含 Server 权益 +- [ ] Spec §9 API 与 OpenAPI 一致 +- [ ] Spec §12 Acceptance 已逐条验证 +- [ ] Loading / Empty / Error / Normal(+ Locked 若适用) +- [ ] lexicon 文案 + +## Backend + +- [ ] OpenAPI +- [ ] Envelope errors(非裸 error) +- [ ] handler → service → repository +- [ ] AuthZ / 权益服务端 +- [ ] Migration if schema +- [ ] L1 unit for core logic + +## Frontend + +- [ ] 四态(+ Locked) +- [ ] lexicon 文案 +- [ ] SDK/types 若需要 + +## Verify + +- [ ] L0: `go test ./...` / `npm run build:h5` +- [ ] L2 integration 或书面 smoke(P1 关键流须自动化) +- [ ] Health OK +- [ ] Review Report 已输出 +- [ ] Spec §14 Implementation Notes / Gaps 已更新 +- [ ] Scope label 正确(Demo / Feature Complete / …) diff --git a/.ai/commands.md b/.ai/commands.md index 9da953c..ec7bd13 100644 --- a/.ai/commands.md +++ b/.ai/commands.md @@ -11,10 +11,12 @@ Policy: [environment.md](environment.md) · [development.md](development.md) 1. Install host toolchains: Go 1.22+ · Node 20+ · Docker Desktop(仅依赖服务) 2. `npm install` -3. `npm run deps:up`(或 `docker compose -f docker-compose.dev.yml up -d`) -4. Start API once so migrations apply: `cd apps/api && go run ./cmd/server` -5. Verify: `curl -s http://127.0.0.1:8080/api/v1/healthz` -6. Start H5: `npm run dev:h5` → http://127.0.0.1:5173 +3. `cp apps/api/config.example.yaml apps/api/config.local.yaml`(填 `database.*` 与 `deepseek.api_key`) +4. `npm run deps:up`(或 `docker compose -f docker-compose.dev.yml up -d`) +5. Start API once so migrations apply: `cd apps/api && go run ./cmd/server` +6. Verify: `curl -s http://127.0.0.1:8080/api/v1/healthz` +7. Start H5: `npm run dev:h5` → http://127.0.0.1:5173 +8. (可选)埋点:`cp apps/user-h5/.env.example apps/user-h5/.env.local`,填 `VITE_GA_MEASUREMENT_ID`;调试可设 `VITE_ANALYTICS_DEBUG=1`(见 `feature-spec/analytics.md`) Optional CN Go proxy: `export GOPROXY=https://goproxy.cn,direct` Optional API hot reload: install `air`, then `cd apps/api && air`(见 `.air.toml`) @@ -46,10 +48,14 @@ Stop deps: `npm run deps:down` cd apps/api go mod tidy go test ./... +# L2(需 deps:up):Portrait / Relation / Membership 三流 +go test ./internal/integration/ -count=1 go build ./... go run ./cmd/server ``` +根目录快捷:`npm run test:api` · `npm run test:api:integration`(L2 需先 `deps:up`) + Env defaults / template: `deploy/.env.example` Migrate: applied on API startup (`apps/api/migrations/*.up.sql`) @@ -67,6 +73,8 @@ curl -s http://127.0.0.1:8080/api/v1/ping ```bash npm run dev:h5 npm run build:h5 +npm run test:h5 # Vitest +npm run test:e2e # Playwright(先 build:h5;用系统 Chrome) ``` **Do not** dockerize Vite for everyday coding. diff --git a/.ai/constitution.md b/.ai/constitution.md index 68aa5d2..1e0ca5f 100644 --- a/.ai/constitution.md +++ b/.ai/constitution.md @@ -15,6 +15,7 @@ 9. Every API documented (OpenAPI or apps/docs). 10. Every database change versioned (migration required). 11. **Local feedback speed > local environment purity.** 本地开发追求分钟级反馈;生产/CI 追求环境一致。二者禁止混用工作流(见 `.ai/environment.md`)。 +12. **Done ≠ Code Complete.** 功能完成标准见 `.ai/definition-of-done.md`;禁止仅以「有 API / 能打开页 / build 过」宣告完成。 ## Product Laws (YuXinGu) diff --git a/.ai/definition-of-done.md b/.ai/definition-of-done.md index 7b7800a..41e72ea 100644 --- a/.ai/definition-of-done.md +++ b/.ai/definition-of-done.md @@ -1,17 +1,194 @@ -# Definition of Done +# Definition of Done — AI Quality Gate(强制) -Code finished ≠ Done. +**Done ≠ Code Complete.** +**Done = Feature Complete + Verified + Documented.** -A feature is Done only when applicable items PASS: +任何功能在未满足下列适用标准前,**禁止**对用户说「完成 / Done / P1 Complete」。 +若某项 N/A,必须写明原因;静默跳过 = Not Done。 -- [ ] Code (compiles / typechecks) -- [ ] API (handler + service + repository as needed) -- [ ] Test (unit or listed smoke for critical paths) -- [ ] Migration (if schema changed) -- [ ] Docker / deploy notes (if new service or env) -- [ ] Docs (OpenAPI / short PRD note if user-visible) -- [ ] Review (`.ai/review.md` checklist output) -- [ ] Build (`go test ./...` and/or `npm run build:h5` for touched side) -- [ ] Health check still green (`/api/v1/healthz`) +加载:每个任务结束前必须对照本文件;见 `AGENTS.md`。 -If an item is N/A, state why. Silent skip = not Done. +--- + +## 0. 禁止的「伪完成」信号 + +仅有下列项 **不足以** 称 Done: + +- API 路由存在 +- 页面能打开 +- `go test ./...` 或 `npm run build:h5` 通过 +- **没有** `product/feature-spec/.md` 却声称功能完整 + +还缺:Feature Spec、测试分层、异常/空/加载态、权限、完整用户路径、文档与 Review。 + +--- + +## 1. Feature 完成(产品层) + +必须: + +| 项 | 要求 | +|---|---| +| Feature Spec | 存在 `product/feature-spec/.md`;§4 Flow / §7 Rules / §9 API / §12 Acceptance 非空(见 `feature-design.md`) | +| Feature Map | 已在 `product/feature-map.md` 定义且范围清晰 | +| 用户路径 | Spec §4 + `user-journey.md` 对应 Step;入口可达 | +| 页面入口 | Spec §5 + `page-tree.md` 路由存在 | +| Loading | 网络请求有加载态(Spec §6) | +| Empty | 无数据有引导(非空白死页) | +| Error | 失败可感知 + 可恢复(重试/返回) | + +示例 — 个人画像完整路径(缺一不可称该 Feature Done): + +``` +进入 → 无档案 → 创建档案 → 生成画像 + → 查看基础版 → 深度版入口 → 支付/mock → 完整分析可见 +``` + +仅有「`/portrait` 显示一段文案」= **Demo / Incomplete**。 + +--- + +## 2. Backend Done + +### 2.1 API + +- OpenAPI(`proto/openapi.yaml`)已更新 +- Request / Response 字段明确 +- 错误走统一信封 `{code,message,data}`;业务失败用数字 `code` + 可读 `message` +- **禁止**裸 `{ "error": "..." }` 作为对外约定 + +### 2.2 分层 + +``` +handler → service → repository → database +``` + +- 业务逻辑不在 handler +- 权益 / 归属校验在 service(或明确 domain 层),不在前端 + +### 2.3 测试(见 §4) + +- 核心 service / engine 有单元测试(Level 1) +- P1 关键流有集成测试或可重复的自动化冒烟(Level 2) + +--- + +## 3. Frontend Done + +### 3.1 页面四态 + +每个网络页面必须具备: + +| 状态 | 要求 | +|---|---| +| Normal | 成功数据正确展示 | +| Loading | 请求中 | +| Empty | 无档案 / 无列表等引导 | +| Error | 失败文案 + 重试或返回 | + +示例 — 问答不能只有聊天壳;至少覆盖:无档案、发送中、失败重试、历史为空(登录态按当前 Visitor 模型裁剪)。 + +### 3.2 组件 + +抽公共组件时:可复用、props 明确、状态完整;遵守 `design/component-catalog.md` + lexicon。 + +### 3.3 文案 + +用户可见文本符合 `product/lexicon.md`。 + +--- + +## 4. Test Done(分级) + +| Level | 名称 | 最低要求 | P1 Feature | +|---|---|---|---| +| **0** | Build | `go test ./...`;触及 H5 则 `npm run build:h5` | 必须 | +| **1** | Unit | Domain / Service / 算法(如 portrait、relation、scale 计分) | 必须 | +| **2** | Integration | 至少一条端到端 API 流可自动跑 | **P1 关键流必须** | +| **3** | E2E | 浏览器关键路径(Playwright 等) | P1 收口前至少 1 条主路径 | + +### P1 必须覆盖的 Integration 流(Level 2) + +1. **Portrait:** create profile → generate portrait → deep_access mock → detail 可见 +2. **Relation:** two profiles → relation insight → deep_access → tips 可见 +3. **Membership(有实现后):** subscribe mock → 权益生效 → 报告完整字段 + +Level 0 alone ≠ Backend/Frontend Done。 + +--- + +## 5. Database Done + +- `apps/api/migrations/` 有对应变更 +- 新环境:`deps:up` → API 启动 migrate → 可服务 +- 破坏性变更有 rollback / forward 策略(见 `database.md`) +- Seed 可选但须幂等或可重复说明 + +禁止手改共享/生产库代替 migration。 + +--- + +## 6. Security Done + +- 权益(深度版 / 成长会员)**只在服务端**裁剪字段 +- 用户只能访问自己的 Profile / Report(归属校验) +- 入参校验;参数化 SQL(无拼接) +- Secret 不入库、不进镜像、不进提交 + +禁止:前端 `if (vip) showFullReport()` 作为唯一闸门。 + +--- + +## 7. AI Review Done + +功能声称完成前,**必须**输出: + +```markdown +## Review Report +- Feature: +- Scope label: Demo | Feature Complete | P1 Complete | N/A +- Architecture: PASS | FAIL — +- API: PASS | FAIL | N/A — +- Security: PASS | FAIL | N/A — +- Test (L0/L1/L2/L3): PASS | FAIL — +- Frontend states (L/E/E/N): PASS | FAIL | N/A — +- Documentation: PASS | FAIL | N/A — +- Environment: PASS | FAIL | N/A — +- Known Issues: +``` + +任一关键 FAIL → 不得标 Done。 + +另保留 `.ai/review.md` 细项可一并勾选。 + +--- + +## 8. Release Done(版本发布时) + +- CHANGELOG +- Migration 说明 +- Environment / 配置说明 +- Test Result(含 Level) +- Rollback Plan + +--- + +## 9. 阶段标签(强制用词) + +| 标签 | 含义 | +|---|---| +| **Vertical Slice Demo** | 主路径可演示;缺测/缺态/缺功能仍可存在 | +| **Feature Complete** | 单功能满足本文件 §1–7 | +| **P1 Complete** | feature-map P1 必做项全部 Feature Complete + L2 三流 + 至少 1 条 L3(或书面豁免经用户同意) | + +当前仓库默认状态见:[product/p1-status.md](product/p1-status.md)。 + +--- + +## 10. 宣称 Done 前的最短自问 + +1. 用户能否走完 journey,不只是打开页? +2. 无数据 / 失败时页面是否可用? +3. 不付费能否靠改前端看到深度字段?(不应) +4. 新机器按 `commands.md` 能否复现? +5. Review Report 是否已贴出且无 FAIL? diff --git a/.ai/design/README.md b/.ai/design/README.md index 69a0204..ff6f09b 100644 --- a/.ai/design/README.md +++ b/.ai/design/README.md @@ -4,6 +4,7 @@ Machine-readable **UI/UX/VI contract** for agents generating H5 / Mini Program / | File | Purpose | |---|---| +| **[reverse-engineering-spec.md](reverse-engineering-spec.md)** | **竞品/成熟 App 逆向工程设计规范(V1.0)** · Evidence First · STEP 1–18 | | [design-system.md](design-system.md) | Foundation: tokens, color, type, spacing, motion, voice | | [component-catalog.md](component-catalog.md) | Button / Card / Chat / Report / Membership … | | [platform/h5.md](platform/h5.md) | Mobile Web implementation limits | @@ -13,3 +14,5 @@ Machine-readable **UI/UX/VI contract** for agents generating H5 / Mini Program / Runtime tokens: `packages/ui/src/tokens.css` Product chrome / tabs: `../ui.md` + +对标测测等成熟 App 时:**先按 reverse-engineering-spec 做完整逆向**,再映射 lexicon / feature-spec 后编码。禁止只出 Demo/MVP 式省略分析。 diff --git a/.ai/design/reverse-engineering-spec.md b/.ai/design/reverse-engineering-spec.md new file mode 100644 index 0000000..e87f82f --- /dev/null +++ b/.ai/design/reverse-engineering-spec.md @@ -0,0 +1,265 @@ +# AI Reverse Engineering Design Specification (V1.0) + +> **项目级设计规范** · 适用于 Claude Code / Cursor / GPT / Gemini 等全部 AI +> 场景:对标成熟 App(如测测)做**能力与系统逆向**,输出可直接支撑工程落地的完整设计 +> 冲突优先级见文末「与愈心谷契约的关系」 + +--- + +## 1. Mission + +你不是产品经理,也不是程序员。 + +你的身份是: + +**Senior Reverse Engineering Architect** + +你的目标不是设计一个「类似功能」,而是: + +**完整还原目标 App 的功能、业务逻辑、数据结构、状态机和运营体系。** + +任何输出都必须以: + +- **Production Ready** +- **Enterprise Ready** +- **Feature Complete** + +作为目标。 + +| 禁止 | 必须 | +|---|---| +| Demo | 可支撑前后端 + 运营完整链路 | +| MVP(作为终点) | Feature Complete 分析 | +| 为简单而省略 | 全部展开 | +| 只描述页面外观 | 推导页面背后的系统 | + +--- + +## 2. Reverse Engineering Rule + +每一个页面都必须认为: + +> 你看到的只是冰山一角。 +> 页面背后一定存在:业务逻辑 · 数据库 · 后台 · 缓存 · 状态机 · 运营 · 统计 · 权限 · 异常处理 · 日志 · 配置 · 监控 …… + +你的任务就是**全部推导出来**。 + +--- + +## 3. Completeness Principle + +任何功能**禁止**输出: + +- 应该 / 可能 / 大概 +- 简单实现 / 略 / TODO / 以后再做 + +必须全部展开。缺一项即未完成。 + +--- + +## 4. Evidence First(证据优先) + +每一项分析必须标注来源,禁止把猜测写成事实。 + +| 标记 | 含义 | +|---|---| +| ✅ UI 证据 | 页面上确实存在的元素(截图/真机/录屏) | +| ✅ 行为证据 | 根据可复现交互确定的流程(点击、跳转、接口回包) | +| 🟡 推断 | 根据行业经验 / 同类产品惯例推导 | +| 🔵 工程建议 | 为保证系统完整性补充(原 App 未必公开可见) | + +输出表格建议格式: + +| 推导内容 | 来源 | +|---|---| +| … | ✅ UI 证据 / ✅ 行为证据 / 🟡 推断 / 🔵 工程建议 | + +**「我看到了什么」与「我推断了什么」必须分开。** + +--- + +## 5. Reverse Engineering Workflow + +每分析一个页面(或一条完整用户能力),**严格按下列顺序,禁止跳步。** + +### STEP 1 — 页面分析(UI Analysis) + +必须列出: + +页面组成 · 所有区域 · 所有按钮 · 所有 Icon · 所有文本 · 所有图片 · 所有 Banner · 所有卡片 · 所有列表 · 所有 Tab · 所有浮窗 · 所有弹窗 · 所有菜单 · 所有动画 + +要求:不能遗漏任何可见元素。每项尽量带 ✅ UI 证据。 + +### STEP 2 — 功能分析(Feature Analysis) + +对页面上每一个元素,必须回答: + +- 有什么作用? +- 点击后发生什么?长按?双击? +- 是否可分享 / 复制 / 删除 / 收藏 / 举报 / 编辑? +- 是否有权限限制 / VIP 限制 / 登录限制? + +### STEP 3 — 用户流程(User Flow) + +画出完整主流程: + +```text +进入 → 加载 → 成功 → 操作 → 提交 → 返回 → 退出 +``` + +同时必须覆盖失败与边界: + +网络错误 · Token 失效 · 服务器异常 · 权限不足 · 数据为空 · 会员限制 · 余额不足 · 接口超时 · 审核失败 + +### STEP 4 — 状态机(State Machine) + +每个页面必须列出状态(至少覆盖): + +Init · Loading · Refreshing · Loaded · Empty · Offline · Error · PermissionDenied · LoginRequired · VIPLocked · Submitting · Success · Failed · Retrying · Deleted · Disabled · Hidden + +并给出**状态转换图**(可用 mermaid)。 + +### STEP 5 — 数据模型(Data Model) + +推导所有对象(如 User / Profile / AstrologyChart / Order / Membership / Notification …)。 + +每个字段必须写:字段 · 类型 · 是否为空 · 默认值 · 来源 · 用途 · 是否缓存 · 是否索引 · **证据标记**。 + +### STEP 6 — 数据库设计 + +推导表结构(含运营/审计表)。每张表:字段 · 主键 · 唯一索引 · 普通索引 · 外键 · 更新时间 · 删除策略。 + +落地到本仓库时,同步 `.ai/domain/erd.md` 与 migrations。 + +### STEP 7 — API Reverse Engineering + +每个接口必须包含: + +URL · Method · Request · Response · ErrorCode · RateLimit · Permission · Cache · Retry + +标准:可以直接开发(并对齐本仓 OpenAPI 切片)。 + +### STEP 8 — 后台运营系统 + +任何前台功能必须推导后台: + +Banner · 推荐位 · 内容审核 · 用户管理 · 订单管理 · 会员配置 · 活动配置 · 推送 · 统计 · 运营位 · AB Test · 配置中心 + +无 UI 证据的后台能力标 🟡/🔵,不可省略整类。 + +### STEP 9 — 权限系统 + +推导角色:游客 · 登录用户 · VIP · SVIP · 管理员 · 运营 · 客服 · 审核员 · 超级管理员 + +每个角色:能做什么 / 不能做什么。映射到本产品时对齐现有 Visitor / DeepAccess / Membership(见 feature-spec)。 + +### STEP 10 — 支付系统 + +若涉及:订单 · 退款 · 支付状态 · 失败/取消 · 重复支付 · 补单 · 风控 · 发票。 +本仓当前可用 `pay-mock`;完整支付链路仍须在 Spec 中写清状态机,实现可分期。 + +### STEP 11 — 消息系统 + +Push · 站内信 · 短信 · 邮件 · 消息中心 · 未读数 · 角标 · 通知策略。 + +### STEP 12 — 埋点系统 + +页面曝光 · 按钮点击 · 停留 · 漏斗 · 转化 · 留存 · 分享 · 支付 · 搜索 · 异常。 +事件名过 `.ai/product/feature-spec/analytics.md` 与 lexicon。 + +### STEP 13 — 配置系统 + +哪些后台可配 / 写死 / 远程配置 / 灰度 / AB。 + +### STEP 14 — 缓存策略 + +本地缓存 · Redis · CDN · 图片 · 分页 · 用户 · 配置。 + +### STEP 15 — 异常处理 + +断网 · 弱网 · 超时 · 重复点击 · Token 失效 · 数据损坏 · 接口升级 · 版本过低 · 审核失败 · 资源不存在。 + +### STEP 16 — 日志系统 + +用户日志 · 错误日志 · 接口日志 · 支付日志 · 审核日志 · 运营日志 · 安全日志。 + +### STEP 17 — 安全分析 + +权限 · SQL 注入 · XSS · CSRF · 重放 · 验证码 · 风控 · 设备绑定 · 账号安全。 +并遵守 `.ai/security.md`。 + +### STEP 18 — 可扩展性 + +未来增量 · DB 扩展 · API 兼容 · 模块解耦。 + +--- + +## 6. Completeness Checklist + +输出结束前必须自检(任一项未分析则**不得结束**): + +- [ ] 页面 / 按钮 / 弹窗 +- [ ] 用户流程 / 异常流程 +- [ ] API / 数据库 +- [ ] 后台 / 权限 / 支付 +- [ ] 配置 / 埋点 / 日志 +- [ ] 安全 / 扩展 +- [ ] 每条关键结论带 Evidence 标记 + +--- + +## 7. Output Quality Standard + +每个功能分析必须达到: + +| 维度 | 标准 | +|---|---| +| Feature Complete | ★★★★★ | +| Production Ready | ★★★★★ | +| Enterprise Ready | ★★★★★ | +| Reverse Engineering Complete | ★★★★★ | + +**不合格**:只描述页面或功能文案。 +**合格**:开发团队可据此直接实现完整链路(前台 · 后台 · 接口 · 数据 · 运营),且证据层级清晰。 + +分析产物默认写入: + +1. `product/feature-spec/.md`(用户可见能力与规则;过 lexicon) +2. 必要时附 `product/feature-spec/-re.md` 或同目录附录(完整 STEP 1–18 逆向底稿) +3. 同步 `erd` / OpenAPI / `page-tree` / `feature-map` / analytics + +--- + +## 8. 与愈心谷契约的关系(强制) + +本规范解决的是:**能力与系统如何被完整逆向出来**。 +落地品牌与禁词仍由产品契约约束: + +| 层级 | 文档 | 作用 | +|---|---|---| +| 最高 | `product/lexicon.md` | 用户可见中文;硬禁「占卜/算命」恐吓等 | +| 战略 | `product/STRATEGY.md` | 参考竞品模型 ≠ 复制竞品品牌/视觉 | +| 能力树 | `product/feature-map.md` | 分期与边界 | +| 本规范 | `design/reverse-engineering-spec.md` | 逆向分析深度与完整性 | +| 功能输入 | `product/feature-design.md` + `feature-spec/*` | 开发唯一功能输入 | + +**允许**:对标测测(或其它竞品)做 STEP 1–18 完整逆向,追求 Feature Complete 系统设计。 +**禁止**:在 UI/PRD/Ask 中自称「测测」;照搬竞品商标、独特视觉品牌资产;违反 lexicon 的恐吓/疗效话术。 +**落地命名**:逆向结论映射为愈心谷模块名(如「星座」「合盘」),写入 feature-spec 后再编码。 + +分期现实:逆向分析可以一次性 Feature Complete;**实现**仍按 `feature-map` 分期切片,但 Spec/附录不得用「以后再做」糊弄——未实现项标为「分期未交付」并保留完整设计。 + +--- + +## 9. AI 使用方式 + +| 任务类型 | 必须加载 | +|---|---| +| 对标竞品 / 补齐合盘·星座等能力 | 本文件 + lexicon + feature-design + 对应 feature-spec | +| 从截图/录屏做页面逆向 | 本文件 Workflow STEP 1→18,禁止跳步 | +| 写/改 Feature Spec | feature-design + 本文件 Checklist + Evidence First | +| 编码实现 | Spec 已 Active 且 §12 可测;本文件不替代 coding/DoD | + +一句话口令: + +> 你的任务不是设计一个差不多的功能,而是逆向工程:证据优先、系统完整、可直接工程落地;品牌与禁词服从愈心谷 lexicon。 diff --git a/.ai/domain.md b/.ai/domain.md index 9d60cb9..6538c79 100644 --- a/.ai/domain.md +++ b/.ai/domain.md @@ -24,8 +24,13 @@ Use ONLY these words in code and API. | Membership | 成长会员 | | DeepAccess | 深度版 | | Constitution | 身心探索 | +| StarProfile | 星象性格(P2) | +| LifeRhythm | 身心节律(P2) | +| ImageCard | 意象卡片(P2) | -Deprecated aliases: Decode→Portrait · Match→RelationInsight · Unlock→DeepAccess +Deprecated aliases: Decode→Portrait · Match→RelationInsight · Unlock→DeepAccess · Tarot→ImageCard + +Never invent package names: `horoscope_luck`, `tarot`(用户域用 `image_card` / `star_profile` / `life_rhythm`)。 --- diff --git a/.ai/domain/domain-map.md b/.ai/domain/domain-map.md index e97bc76..debada8 100644 --- a/.ai/domain/domain-map.md +++ b/.ai/domain/domain-map.md @@ -12,8 +12,11 @@ | Profile | Profile | Yes | `internal/service/profile` | | Discovery | Portrait, Scale, ScaleResult, GrowthReport | Yes | `portrait`, `scale`, `report` | | Relation | RelationInsight | Yes **必做** | `relation` | -| Ask | AskThread, AskMessage, AskQuota | Skeleton | `ask` | -| Companion | Mood, SolarTerm | Shell | `companion` | +| Ask | AskThread, AskMessage, AskQuota | Demo(规则引擎) | `ask` | +| Companion | Mood, SolarTerm | P2 | `internal/companion` · `/solar-terms/today` · `/moods` | +| StarProfile | StarProfile report | P2 | `internal/star` · `POST /reports/star` | +| LifeRhythm | LifeRhythm / Constitution | P2 | `internal/rhythm` · `POST /reports/rhythm` | +| ImageCard | ImageCard draw + deck | P2 | `internal/imagecard` · `/image-cards/*` | | Memory | MemoryItem | P2/P3 | `memory` | | Commerce | Subscription, Membership, Order, Payment, DeepAccess | Yes mock | `order`, `membership` | | Consult | — | P3 | — | @@ -39,4 +42,4 @@ Identity → Profile → Discovery(Portrait/Scale/GrowthReport) 2. Ask 必须绑定 `profile_id`(Self 或 Other)。 3. RelationInsight 引用两个 Profile;禁止无档案的「合婚」式空算。 4. 客户端不可信任本地「已深度版」标志。 -5. 禁止吉凶/运势类字段名进入 schema(如 `luck_score`)。 +5. 档案表不存吉凶列;**报告 JSON payload 允许**运势分/匹配指数(如 `fortune.daily.score`、`love_index`)。 diff --git a/.ai/domain/erd.md b/.ai/domain/erd.md index 1fdf2c4..7d3b41e 100644 --- a/.ai/domain/erd.md +++ b/.ai/domain/erd.md @@ -31,10 +31,26 @@ | birth_date | date | required P1 | | birth_time | time null | optional | | birth_place | text null | optional | +| geo_lat | float null | 附近的人 | +| geo_lng | float null | | +| geo_visible | bool | default false;开启后可被附近列表看到 | | gender | text null | | | relation_type | text null | partner/family/friend… when other | | created_at | timestamptz | | +### synastry_invites +| column | type | notes | +|---|---|---| +| id | uuid PK | | +| token | text unique | 分享路径 `/synastry/invite/:token` | +| host_user_id | uuid FK→users | | +| host_profile_id | uuid FK→profiles | | +| expires_at | timestamptz | | +| guest_user_id | uuid null | | +| guest_profile_id | uuid null | | +| report_id | uuid null FK→growth_reports | accept 后写入 | +| created_at | timestamptz | | + ### scales | column | type | notes | |---|---|---| diff --git a/.ai/file-map.md b/.ai/file-map.md index 4f8dd42..bf1f799 100644 --- a/.ai/file-map.md +++ b/.ai/file-map.md @@ -71,10 +71,13 @@ Runtime compose for **deps only**: repo-root `docker-compose.dev.yml`. | Path | Owns | |---|---| | `product/lexicon.md` | 产品中文语言契约(最高优先级) | +| `product/feature-design.md` | Feature Spec 强制规范 | +| `product/feature-spec/` | 单功能详细设计(流程/规则/API/验收) | | `product/user-journey.md` | 转化路径 | | `product/page-tree.md` | H5 路由冻结 | | `product/feature-map.md` | 能力树 · 五 Tab 冻结 | | `product/ENGINEERING-FREEZE.md` | P1 真源索引 | +| `playbooks/feature-spec.md` | Spec → 实现步骤 | | `domain/domain-map.md` + `erd.md` | Contexts + 表结构 | | `architecture/go-services.md` | Go 包边界 | | `domain.md` | Tech ↔ 用户名 | @@ -83,6 +86,7 @@ Runtime compose for **deps only**: repo-root `docker-compose.dev.yml`. | Path | Owns | |---|---| +| `reverse-engineering-spec.md` | 竞品/成熟 App 逆向工程规范(STEP 1–18 · Evidence First) | | `design-system.md` | Tokens, color, type, spacing, motion, voice | | `component-catalog.md` | Allowed reusable UI components | | `platform/*.md` | H5 / mini-program / website / Flutter limits | diff --git a/.ai/forbidden.md b/.ai/forbidden.md index 6510565..acc1b5a 100644 --- a/.ai/forbidden.md +++ b/.ai/forbidden.md @@ -42,3 +42,5 @@ AI obeys NEVER rules strictly. - Never modify files outside the current task. - Never extend Legacy root HTML unless the task is migration. - Never guess requirements — ASK FIRST. +- Never implement **new feature behavior** or **expand** an existing feature without an Active Feature Spec under `product/feature-spec/`(见 `product/feature-design.md`)。Bugfix 若暴露 Spec 错误必须同步修正 Spec。 +- Never treat `feature-map.md` alone as sufficient design input for coding. diff --git a/.ai/playbooks/feature-spec.md b/.ai/playbooks/feature-spec.md new file mode 100644 index 0000000..49968d3 --- /dev/null +++ b/.ai/playbooks/feature-spec.md @@ -0,0 +1,19 @@ +# Playbook: Feature Spec → Implementation + +在写任何新功能或重大行为变更前执行。 + +## Steps + +1. 在 `product/feature-map.md` 确认节点与分期(P1/P2/P3/No)。 +2. 复制 `product/feature-spec/_TEMPLATE.md` → `product/feature-spec/.md`。 +3. 填满 §1–§13;文案过 `lexicon.md`。 +4. 在 `feature-spec/README.md` 登记索引。 +5. 若改数据/API:同步 `domain/erd.md` + `proto/openapi.yaml`(可先草案)。 +6. 按 `playbooks/add-api.md` / `new-page.md` / `new-table.md` 实现。 +7. 编码中更新 Spec §14 Implementation Notes;Gaps 标 `Gap:`。 +8. 对照 Spec §12 + `checklists/feature.md` + DoD 验收。 + +## Stop + +- Spec §4 / §7 / §9 / §12 为空 → 禁止编码。 +- 需求不清 → ASK FIRST。 diff --git a/.ai/product/ENGINEERING-FREEZE.md b/.ai/product/ENGINEERING-FREEZE.md index 9c531b3..f94531f 100644 --- a/.ai/product/ENGINEERING-FREEZE.md +++ b/.ai/product/ENGINEERING-FREEZE.md @@ -6,24 +6,31 @@ |---|---|---| | 1 | 产品语言 | `product/lexicon.md` | | 2 | 能力树 + 冻结 IA | `product/feature-map.md` | -| 3 | 用户旅程 | `product/user-journey.md` | -| 4 | H5 页面树 | `product/page-tree.md` | -| 5 | 域词 | `domain.md` | -| 6 | Domain Map | `domain/domain-map.md` | -| 7 | ERD | `domain/erd.md` | -| 8 | API Contract | `proto/openapi.yaml` | -| 9 | Go 服务边界 | `architecture/go-services.md` | -| 10 | Design System | `design/design-system.md` | +| 3 | Feature Spec 规范 | `product/feature-design.md` | +| 4 | 功能详细设计 | `product/feature-spec/` | +| 5 | 用户旅程 | `product/user-journey.md` | +| 6 | H5 页面树 | `product/page-tree.md` | +| 7 | 域词 | `domain.md` | +| 8 | Domain Map | `domain/domain-map.md` | +| 9 | ERD | `domain/erd.md` | +| 10 | API Contract | `proto/openapi.yaml` | +| 11 | Go 服务边界 | `architecture/go-services.md` | +| 12 | Design System | `design/design-system.md` | -环境规范层已冻结(`.ai/environment.md` 等)— **停止扩展工程 meta 文档**。 +环境规范层已冻结(`.ai/environment.md` 等)。 +完成闸门:`.ai/definition-of-done.md`(强制)。 +状态标签:`.ai/product/p1-status.md` → **`P1 Complete`**(必做项收口;P2 另列)。 -下一步:**按 P1 竖切实现**(Profile → Portrait → RelationInsight → Scale → Commerce mock → Ask 壳),不再横向加规范或加功能面。 +**P2 三模块(设计已立项,实现另开切片):** 星象性格 · 身心节律 · 意象卡片 +→ Spec:`feature-spec/star-profile.md` · `life-rhythm.md` · `image-card.md` · 队列见 `feature-spec/P2-BACKLOG.md` +→ 实现前须 Spec Active + OpenAPI/erd;**不计入 P1 Complete**。 ## Review 清单(合并前必过) 1. 用户可见文本是否仅来自 lexicon?有无旧产品词/玄学预测? -2. 路由是否落在 page-tree? -3. Domain / 表名是否符合 domain.md(无 decode/luck 等)? -4. 权益是否只在服务端裁剪? -5. 关系理解是否仍在 P1 路径中(双引擎)? -6. 数据模型是否留有 Memory / Consult 扩展位(不阻塞 P1)? +2. 对应 `feature-spec/.md` 是否存在且 §4/§7/§9/§12 非空? +3. 路由是否落在 page-tree? +4. Domain / 表名是否符合 domain.md(无 decode/luck 等)? +5. 权益是否只在服务端裁剪? +6. 关系理解是否仍在 P1 路径中(双引擎)? +7. 数据模型是否留有 Memory / Consult 扩展位(不阻塞 P1)? diff --git a/.ai/product/README.md b/.ai/product/README.md index 100cdec..2f72e33 100644 --- a/.ai/product/README.md +++ b/.ai/product/README.md @@ -3,11 +3,22 @@ | File | Purpose | |---|---| | [ENGINEERING-FREEZE.md](ENGINEERING-FREEZE.md) | **P1 冻结清单** | +| [p1-status.md](p1-status.md) | **P1 完成度(当前:P1 Complete)** | | [lexicon.md](lexicon.md) | 产品语言契约(最高优先级) | -| [feature-map.md](feature-map.md) | 能力树 · 五 Tab 冻结 | +| [feature-design.md](feature-design.md) | **Feature Spec 强制规范** | +| [feature-spec/](feature-spec/README.md) | **功能详细设计(单功能 HOW)** | +| [feature-map.md](feature-map.md) | 能力树 · 五 Tab 冻结(WHAT) | | [user-journey.md](user-journey.md) | 转化路径(含关系分享引擎) | | [page-tree.md](page-tree.md) | H5 路由树 | | [STRATEGY.md](STRATEGY.md) | 参考模型 ≠ 复制品牌 | -| [cece-feature-map.md](cece-feature-map.md) | 竞品对照(勿抄 UI) | +| [cece-feature-map.md](cece-feature-map.md) | 竞品对照(勿抄品牌视觉) | +| [../design/reverse-engineering-spec.md](../design/reverse-engineering-spec.md) | **逆向工程设计规范 V1.0**(对标成熟 App 时强制) | +| [feature-spec/cece-frontend-re/](feature-spec/cece-frontend-re/README.md) | **测测前端全量逆向设计文档**(五 Tab + 工具域 + STEP 5–18) | +| [feature-spec/cece-frontend-re/complete-design/](feature-spec/cece-frontend-re/complete-design/README.md) | **测测对标 · 完整产品设计包(Design Complete)**;模拟器校验见包内 99 | -冲突:`lexicon` > `feature-map` / `user-journey` > 历史文档。 +冲突:`lexicon` > `feature-map` 分期 > **`feature-spec`** > `user-journey` / `page-tree` > 历史文档。 +对标竞品分析时另加:`design/reverse-engineering-spec.md`(完整性与证据层级)。 + +开发链路:`feature-map` →(对标时)逆向 STEP 1–18 → `feature-spec` → domain/OpenAPI → 实现 → 测试 → Review。 + +P2 探索三模块(设计):[feature-spec/P2-BACKLOG.md](feature-spec/P2-BACKLOG.md) · 星象性格 / 身心节律 / 意象卡片。 diff --git a/.ai/product/STRATEGY.md b/.ai/product/STRATEGY.md index 003451b..aaae775 100644 --- a/.ai/product/STRATEGY.md +++ b/.ai/product/STRATEGY.md @@ -8,7 +8,20 @@ | 用户路径 | 功能命名 | | 商业模型(测评→档案→AI→会员→咨询) | 叙事:成长 / 认识自己 / 理解他人 | -**不是**竞品复制品。UI/PRD/Ask **禁止**出现:测测(自称)、解码、运势、塔罗、吉凶、命理预测;星盘不作核心叙事。 +**不是**竞品品牌复制品。UI/PRD/Ask **禁止**出现:测测(自称)、占卜/算命恐吓、医疗疗效承诺;禁止以「塔罗」作主路径入口名。 + +**能力与系统可按逆向规范完整对标**:见 [../design/reverse-engineering-spec.md](../design/reverse-engineering-spec.md)(Evidence First · STEP 1–18 · Feature Complete)。 +逆向的是功能/业务/数据/运营体系;落地时用愈心谷命名与 lexicon,不照搬竞品商标与独特视觉品牌。 + +**P2 允许同构探索模块**(能力可对标竞品;星座模块允许运势分与匹配指数,须过 lexicon): + +| 竞品能力域 | 愈心谷模块(用户名) | Spec | +|---|---|---| +| 星座 | 星座(星盘 · 运势 · 合盘) | `feature-spec/star-profile.md` | +| 命理 | 身心节律 | `feature-spec/life-rhythm.md` | +| 塔罗 | 意象卡片 | `feature-spec/image-card.md` | + +星盘/运势可作模块内主文案;品牌 slogan 仍以成长/认识自己为主。须过 lexicon。 封存的差异化长文设计见 git tag `design-vision-v1`(历史参考)。 @@ -16,10 +29,13 @@ ## 执行分期(与 feature-map 对齐) -1. **P1** — 档案 · 性格探索/画像 · 人格测评 · 关系理解 · 问答 · 深度版 · 会员 -2. **P2** — 身心探索 · 节气陪伴 · 心情记录 · 成长计划 +1. **P1** — 档案 · 性格探索/画像 · 人格测评 · 关系理解 · 问答 · 深度版 · 会员 · 分享 · 埋点 +2. **P2** — 星象性格 · 身心节律 · 意象卡片 · 节气陪伴深化 · 心情记录 · 成长计划 3. **P3** — 长期记忆 · 成长数据库 · 真人顾问 +P2 三模块:**设计已立项**(Spec Active);**实现前**须保持 Spec 与 OpenAPI/erd 同步,另开编码切片。 +不进入 P1 Complete 判定。 + 冲突时:**lexicon + feature-map > 历史 Vision 文档中的旧命名(解码/Unlock 等)**。 工程冻结清单:[ENGINEERING-FREEZE.md](ENGINEERING-FREEZE.md)。 diff --git a/.ai/product/cece-feature-map.md b/.ai/product/cece-feature-map.md index 7b821ef..1ca4c8a 100644 --- a/.ai/product/cece-feature-map.md +++ b/.ai/product/cece-feature-map.md @@ -11,7 +11,9 @@ | L1 Capability | 系统背后有哪些能力域 | §2 挂在各入口下 / §3 横切 | | 演进 | 工具 → AI 顾问 → Life OS | §4 | -愈心谷落地地图见 [feature-map.md](feature-map.md)。领域实体见 [../domain/domain-map.md](../domain/domain-map.md)。 +愈心谷落地地图见 [feature-map.md](feature-map.md)。领域实体见 [../domain/domain-map.md](../domain/domain-map.md)。 +对单页/单能力做完整逆向时,强制遵循 [../design/reverse-engineering-spec.md](../design/reverse-engineering-spec.md)(Evidence First · STEP 1–18)。 +**前端全量逆向设计套件(详细):** [feature-spec/cece-frontend-re/](feature-spec/cece-frontend-re/README.md) · **完整设计包:** [feature-spec/cece-frontend-re/complete-design/](feature-spec/cece-frontend-re/complete-design/README.md) --- diff --git a/.ai/product/feature-design.md b/.ai/product/feature-design.md new file mode 100644 index 0000000..4f76a8b --- /dev/null +++ b/.ai/product/feature-design.md @@ -0,0 +1,118 @@ +# Feature Design Standard + +**任何新功能或重大行为变更,开发前必须存在对应 Feature Specification。** + +Feature Spec 是产品、设计、开发的**唯一功能输入**。 +`feature-map.md` 只回答「做什么 / 在哪棵树上」——**不够**开始编码。 + +完整链路: + +``` +feature-map(能力树) + → feature-spec(功能详细设计) + → domain / OpenAPI / erd(技术契约) + → implementation + → test + → review +``` + +规范位置:本文件。 +Spec 目录:[`feature-spec/`](feature-spec/README.md)。 +模板:[`feature-spec/_TEMPLATE.md`](feature-spec/_TEMPLATE.md)。 +对标成熟 App:[`../design/reverse-engineering-spec.md`](../design/reverse-engineering-spec.md)(逆向 STEP 1–18 · Evidence First)。 + +--- + +## 禁止 + +- 根据一句需求直接编码 +- 根据页面截图或竞品 UI **跳步猜测**业务规则(须走逆向规范并标注证据) +- 未定义 User Flow / 页面状态 / Business Rules 就开发 +- 仅有页面或 API「能跑」就称 Feature Complete +- Spec 未更新就扩大已有功能行为(bugfix 除外) +- 在 Spec 中偷偷扩大 `feature-map` 分期范围(P2 写成 P1) +- 对标竞品时输出 Demo/MVP 式省略(「略」「TODO」「以后再做」)作为 Spec 终态 + +--- + +## 允许的例外 + +| 场景 | 要求 | +|---|---| +| 明确 bugfix(行为已在 Spec/OpenAPI 定义) | 可直接修;若暴露 Spec 错误则同步修正 Spec | +| 文案/lexicon 微调且不改规则 | 可改代码 + lexicon;不强制新 Spec | +| 纯重构(对外行为不变) | 不强制新 Spec;DoD 仍适用 | +| 存量功能尚无 Spec | **进入该功能开发前必须先 retrofit Spec**;否则只许修明确 bug,不得扩行为 | + +--- + +## 冲突优先级 + +1. `lexicon.md`(用户可见中文) +2. `feature-map.md` 分期与边界标记 +3. **本功能的 `feature-spec/.md`**(流程、规则、验收) +4. `user-journey.md` / `page-tree.md` +5. `domain/` + OpenAPI(实现契约;必须与 Spec §8/§9 对齐) +6. 历史 PRD / `apps/docs/*` / 立项文档 + +Spec 文案不得违反 lexicon。Spec 不得把 `[P2]`/`[No]` 能力写成已交付。 + +--- + +## Feature Spec 强制章节 + +每份 Spec 必须包含(可用 N/A + 原因,不可省略标题): + +| # | 章节 | 要求 | +|---|---|---| +| 1 | 功能定义 | Name / Purpose / Business Goal | +| 2 | 用户价值 | 为何需要 / 完成后获得什么 / 为何付费 | +| 3 | 用户角色 | Guest / User / VIP(对齐实际 Identity 模型) | +| 4 | 用户流程 | ASCII 或 mermaid;含分支 | +| 5 | 页面设计 | 路由 + 页面列表;对照 page-tree | +| 6 | 页面状态 | Loading / Empty / Error / Normal / Locked(适用则写) | +| 7 | Business Rules | 编号 Rule N;**权益由 Server 判断** | +| 8 | 数据模型影响 | 表/字段;指向 erd,禁止臆造 | +| 9 | API 需求 | 路径级意图;细节以 OpenAPI 为准并须同步 | +| 10 | 权限设计 | 角色 × 能力矩阵 | +| 11 | 埋点 | 事件名;未接 SDK 也先定义 | +| 12 | 测试验收标准 | Given / When / Then | +| 13 | AI 开发前检查 | checkbox | +| 14 | Implementation Notes | 编码时填写:包、迁移、与现状 gap | + +User Flow 与验收故事**写在 Spec 内**,不另建平行 `user-story/` 真相源。 + +--- + +## AI 开发前检查(强制) + +Before implementation, AI MUST verify: + +- [ ] Feature Spec 文件存在(`feature-spec/.md`) +- [ ] 已在 `feature-map.md` 挂树且分期正确 +- [ ] User Flow(§4)已定义 +- [ ] Business Rules(§7)已定义 +- [ ] API 需求(§9)已定义,并计划更新 OpenAPI +- [ ] 数据影响(§8)已核对 erd +- [ ] 页面状态(§6)已定义 +- [ ] Acceptance(§12)已定义 +- [ ] 文案符合 `lexicon.md` + +任一项缺失 → **停止编码,先补 Spec 或 ASK**。 + +--- + +## 何时更新 Spec + +- 新功能:先复制 `_TEMPLATE.md` → 填满 → 再编码 +- 改规则 / 改权益 / 改主流程 / 改付费墙:先改 Spec,再改代码 +- 仅修 bug:若 Spec 与现实不符,修完后回写 Spec + +Playbook:[`../playbooks/feature-spec.md`](../playbooks/feature-spec.md) + +--- + +## 与 DoD 的关系 + +无适用 Feature Spec → **不得**勾选 Feature Complete(见 `definition-of-done.md` §1)。 +「OpenAPI 有了 / 页面打开了」而 Spec 缺失 = 伪完成。 diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 58f9076..8283e78 100644 --- a/.ai/product/feature-map.md +++ b/.ai/product/feature-map.md @@ -1,12 +1,14 @@ # 愈心谷 — Product Feature Map -**权威产品能力树。** 语言以 [lexicon.md](lexicon.md) 为准。 +**权威产品能力树(WHAT)。** 语言以 [lexicon.md](lexicon.md) 为准。 +单功能详细设计(HOW):[feature-design.md](feature-design.md) · [feature-spec/](feature-spec/README.md) 参考测测的是:**功能模型 · 用户路径 · 商业模型**;品牌叙事与命名必须是愈心谷自己的。 领域实体:[../domain/domain-map.md](../domain/domain-map.md) · 词汇:[../domain.md](../domain.md) 竞品对照(可含竞品原名):[cece-feature-map.md](cece-feature-map.md) 策略:[STRATEGY.md](STRATEGY.md) -标记:`[P1]` 第一阶段 · `[P2]` 第二阶段 · `[P3]` 第三阶段 · `[No]` +标记:`[P1]` 第一阶段 · `[P2]` 第二阶段 · `[P3]` 第三阶段 · `[No]` +**编码前**必须打开对应 Feature Spec;仅有本文件不够。 --- @@ -42,8 +44,12 @@ P1 双引擎:**个人画像 + 关系理解**(不可弱化关系侧)。 ## 1. 首页(发现与入口中心) +详见 Spec:[feature-spec/home.md](feature-spec/home.md) + ### 1.1 创建个人档案 `[P1]` +详见 Spec:[feature-spec/profile.md](feature-spec/profile.md) + | 三级 | 内容 | |---|---| | 1.1.1 我的信息 | 出生日期;出生时间/地点(可后置);性别 | @@ -66,7 +72,7 @@ P1 双引擎:**个人画像 + 关系理解**(不可弱化关系侧)。 ### 2.1 性格探索 `[P1]` -帮助用户了解行为模式。 +帮助用户了解行为模式。详见:[feature-spec/portrait.md](feature-spec/portrait.md) · [reports.md](feature-spec/reports.md) | 三级 | 内容 | |---|---| @@ -76,14 +82,18 @@ P1 双引擎:**个人画像 + 关系理解**(不可弱化关系侧)。 ### 2.2 人格测评 `[P1]` -MBTI 类 · 沟通方式 · 情感模式 · 成长倾向(首版 ≥1 个热门) +MBTI 类 · 沟通方式 · 情感模式 · 成长倾向(首版 ≥1 个热门) +详见 Spec:[feature-spec/explore-test.md](feature-spec/explore-test.md) ### 2.3 个人画像(成长画像) `[P1]` 替代竞品「星盘档案」叙事。内容:性格特点 · 兴趣倾向 · 行为模式 · 关系特点 · 生活建议 +详见 Spec:[feature-spec/portrait.md](feature-spec/portrait.md) ### 2.4 关系理解 `[P1 · 双引擎之一,不可砍]` +详见 Spec:[feature-spec/relation.md](feature-spec/relation.md) + | 三级 | 内容 | |---|---| | 2.4.1 创建关系 | 添加 TA | @@ -93,7 +103,9 @@ MBTI 类 · 沟通方式 · 情感模式 · 成长倾向(首版 ≥1 个热门 禁止宿命/恐吓文案。增长价值不低于个人画像。 -### 2.5 身心探索 `[P2]`(差异化,首版可弱入口) +### 2.5 身心探索 `[P2]`(差异化) + +产品化主入口见 **2.7 身心节律**(避免双树)。轻入口可保留在首页/探索。 | 三级 | 内容 | |---|---| @@ -103,10 +115,30 @@ MBTI 类 · 沟通方式 · 情感模式 · 成长倾向(首版 ≥1 个热门 禁止:治疗、改善疾病、疗效承诺。 +### 2.6 星座 `[P2]` + +详见 Spec:[feature-spec/star-profile.md](feature-spec/star-profile.md) +对标竞品「星座」能力:本命星盘 · 行星 · 日/周/月/年运 · 恋爱/友情/婚姻匹配指数。 +UI 名:星座。允许运势分与合盘指数;禁止占卜、算命恐吓。 + +### 2.7 身心节律 `[P2]` + +详见 Spec:[feature-spec/life-rhythm.md](feature-spec/life-rhythm.md) +对标竞品「命理」能力之生活向:**五行/体质倾向 + 生活建议**。 += §2.5 的产品化主入口。禁止流年吉凶、改命、疗效承诺。 + +### 2.8 意象卡片 `[P2]` + +详见 Spec:[feature-spec/image-card.md](feature-spec/image-card.md) +对标竞品「塔罗」能力:**投射反思卡片**。 +UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。 + --- ## 3. 问答(AI 核心) +详见 Spec:[feature-spec/ask.md](feature-spec/ask.md) + ### 3.1 AI 成长助手 `[P1]` 定位:了解你的智能伙伴。不是占卜,不是预测。 @@ -127,6 +159,8 @@ MBTI 类 · 沟通方式 · 情感模式 · 成长倾向(首版 ≥1 个热门 ## 4. 陪伴(日常留存)`[P2]` 主做;`[P1]` 可占位壳 +详见 Spec:[feature-spec/companion.md](feature-spec/companion.md) + ### 4.1 今日状态 心情记录 · 今日感受 · 成长记录 @@ -141,14 +175,18 @@ MBTI 类 · 沟通方式 · 情感模式 · 成长倾向(首版 ≥1 个热门 | 二级 | 内容 | |---|---| -| 5.1 我的档案 | 个人资料 · 探索记录 · 关系记录 | -| 5.2 我的成长报告 | 已完成探索 · 已购深度版 · 收藏 | -| 5.3 会员中心 | 完整分析 · 高级成长报告 · AI 更多次数 · 专属内容 | +| 5.1 我的档案 | 个人资料 · 探索记录 · 关系记录 → [profile.md](feature-spec/profile.md) | +| 5.2 我的成长报告 | 已完成探索 · 已购深度版 · 收藏 → [reports.md](feature-spec/reports.md) | +| 5.3 会员中心 | 完整分析 · 高级成长报告 · AI 更多次数 · 专属内容 → [membership.md](feature-spec/membership.md) | + +分享裂变:[feature-spec/share.md](feature-spec/share.md) --- ## 6. 商业体系(模型参考成熟产品,话术用愈心谷) +详见 Spec:[feature-spec/membership.md](feature-spec/membership.md) + ### 6.1 深度版成长报告 `[P1]` - 免费:基础结果 / 基础画像 @@ -160,7 +198,9 @@ MBTI 类 · 沟通方式 · 情感模式 · 成长倾向(首版 ≥1 个热门 ### 6.3 `[No]` 首版 -干扰广告 · 塔罗商城主路径 · 达人双边冷启动 · 硬件 +干扰广告 · **以「塔罗」命名的商城主路径** · 达人双边冷启动 · 硬件 + +(意象卡片属探索模块 `[P2]`,不是塔罗商城。) --- @@ -170,9 +210,14 @@ MBTI 类 · 沟通方式 · 情感模式 · 成长倾向(首版 ≥1 个热门 用户档案 · 性格探索 · 个人画像 · 关系理解 · AI 问答 · 成长报告深度版 · 会员系统 -### 第二阶段 `[P2]` +**不含** 2.6–2.8;P2 三模块不计入 P1 Complete。 -体质/身心探索 · 节气陪伴 · 心情记录 · 成长计划 +### 第二阶段 `[P2]` — **合规全量探索进行中** + +星象性格 · 身心节律 · 意象卡片 · 节气陪伴深化 · 心情记录 · 成长计划 · 探索三级目录 · 量表矩阵 + +设计真源:`feature-spec/star-profile.md` · `life-rhythm.md` · `image-card.md` · [P2-BACKLOG.md](feature-spec/P2-BACKLOG.md) +排除不变:消息/达人/UGC 广场/运势 Feed;真支付部署阶段再做。 ### 第三阶段 `[P3]` @@ -194,7 +239,13 @@ MBTI 类 · 沟通方式 · 情感模式 · 成长倾向(首版 ≥1 个热门 | `/relation` | 关系理解 | 双人分析 | | `/membership` | 成长会员 | 套餐与权益 | | `/scales/:slug` | 探索测试作答 | | +| `/reports` | 成长报告列表 | | | `/reports/:id` | 成长报告详情 | | +| `/star` | 星座 | P2 | +| `/synastry` | 合盘 | P2 | +| `/synastry/invite/:token` | 合盘邀请 | P2 | +| `/rhythm` | 身心节律 | P2 draft | +| `/cards` | 意象卡片 | P2 draft | 兼容重定向:`/decode` → `/portrait`(仅技术兼容,UI 不出现 decode 文案)。 diff --git a/.ai/product/feature-spec/P1-PROCESS-REVIEW.md b/.ai/product/feature-spec/P1-PROCESS-REVIEW.md new file mode 100644 index 0000000..828ba6e --- /dev/null +++ b/.ai/product/feature-spec/P1-PROCESS-REVIEW.md @@ -0,0 +1,236 @@ +# P1 全功能流程走查(Feature Spec Layer) + +**日期:** 2026-08-02 +**目的:** 按新链路对 P1 全部功能过一遍——**只做设计与对齐审计,本文件不授权直接开新编码**。 +**链路:** + +``` +feature-map → feature-spec → OpenAPI/erd → implementation → test → review +``` + +规范:[../feature-design.md](../feature-design.md) · 索引:[README.md](README.md) + +图例: + +| 标记 | 含义 | +|---|---| +| PASS | 本关已满足,可进入下一关或维持 | +| PARTIAL | 主路径可用,有明确 Gap(见各 Spec §14) | +| SHELL | 有意占位(如陪伴 P1) | +| BLOCK | 缺 Spec/契约,禁止扩行为 | +| N/A | 本关不适用 | + +编码优先级: + +1. ~~P0 横切:埋点最小集~~ → **切片 A 已完成**(见 [analytics.md](analytics.md)) +2. P1 体验 Gap:探索测试中断恢复 +3. P2 探索三模块:见 [P2-BACKLOG.md](P2-BACKLOG.md)(星象性格 → 身心节律 → 意象卡片) +4. 真支付、短链分享、陪伴心情 UI(各改对应 Spec) +其余 Spec §11 事件:后续迭代再挂,勿一次铺满。 + +--- + +## 总表 + +| # | 功能 | Spec | map | OpenAPI/erd | 实现 | 测试 | DoD 标签建议 | 下一步(设计侧已闭合?) | +|---|---|---|---|---|---|---|---|---| +| 1 | 首页 | [home](home.md) | PASS | N/A(复用 profiles) | PASS | PARTIAL | Feature Complete(体验) | 埋点;可选推荐位 | +| 2 | 个人档案 | [profile](profile.md) | PASS | PASS | PASS | PARTIAL | Feature Complete | 软删策略写清;埋点 | +| 3 | 个人画像 | [portrait](portrait.md) | PASS | PASS | PASS | PASS | Feature Complete | 埋点;内容已多维 | +| 4 | 关系理解 | [relation](relation.md) | PASS | PASS | PASS | PASS | Feature Complete | 埋点 | +| 5 | 探索测试 | [explore-test](explore-test.md) | PASS | PASS | PASS | PARTIAL | Feature Complete − Gap | **设计已标**中断恢复 Gap | +| 6 | AI 问答 | [ask](ask.md) | PASS | PASS | PASS | PASS | Feature Complete | 埋点;真 LLM 依赖配置 | +| 7 | 会员/深度版 | [membership](membership.md) | PASS | PASS | PASS | PASS | Feature Complete(mock) | 真支付另开 Spec 修订 | +| 8 | 成长报告 | [reports](reports.md) | PASS | PASS | PASS | PARTIAL | Feature Complete | 收藏/PDF = Gap | +| 9 | 分享卡 | [share](share.md) | PASS | N/A(前端载荷) | PASS | PARTIAL | Feature Complete − Gap | 短链 = Gap | +| 10 | 陪伴 | [companion](companion.md) | PASS | PARTIAL | SHELL | N/A 深测 | **Shell only** | 禁止当 P1 Complete | + +**横切:** 埋点最小集已实现(analytics)· 真支付仍为 mock(P1 接受)· Visitor 模型与 Spec 一致。 +**收口标签:** 见 [p1-status.md](../p1-status.md) → **P1 Complete**。 + +--- + +## 逐功能走查(六步) + +每步问题:① Spec 是否 Active 且 §4/7/9/12 非空?② map/page-tree 对齐?③ OpenAPI/erd?④ 实现是否覆盖主路径与四态?⑤ 测试是否覆盖 §12?⑥ Review/Gaps 是否写入 §14? + +--- + +### 1. 首页 · home + +| 步 | 结果 | 笔记 | +|---|---|---| +| Spec | PASS | Purpose/Flow/Rules 齐全 | +| map / page-tree | PASS | `/` | +| 契约 | N/A | 无专用 API | +| 实现 | PASS | 生日 CTA → portrait;快捷入口;无 API 状态墙 | +| 测试 | PARTIAL | 缺独立 Home e2e;主路径被 portrait e2e 间接覆盖 | +| Review | PASS | Gap:埋点、推荐 Feed 静态 | + +**流程结论:** 设计闭合。编码仅在做埋点/推荐时先改 Spec §11/§5。 + +--- + +### 2. 个人档案 · profile + +| 步 | 结果 | 笔记 | +|---|---|---| +| Spec | PASS | | +| map / page-tree | PASS | `/profile` · `/mine` 入口 | +| 契约 | PASS | GET/POST/PATCH/DELETE profiles | +| 实现 | PASS | 列表/编辑/删除/加 TA | +| 测试 | PARTIAL | L2 有 profile 更新删除流 | +| Review | PASS | Gap:birth_time/place UI;删除级联说明 | + +**流程结论:** 设计闭合。扩字段前先改 Spec §8 + erd。 + +--- + +### 3. 个人画像 · portrait + +| 步 | 结果 | 笔记 | +|---|---|---| +| Spec | PASS | 含多维报告结构附录 | +| map / page-tree | PASS | `/portrait` | +| 契约 | PASS | POST portrait · GET report · orders | +| 实现 | PASS | 引擎多维 + ReportRich + DeepAccess | +| 测试 | PASS | engine L1 · 集成 deep_access · PortraitPage.spec | +| Review | PASS | Gap:埋点;真支付 | + +**流程结论:** 设计闭合。内容结构变更必须先改 Spec 附录再改 `internal/portrait`。 + +--- + +### 4. 关系理解 · relation + +| 步 | 结果 | 笔记 | +|---|---|---| +| Spec | PASS | 双引擎、禁用合盘 | +| map / page-tree | PASS | `/relation` | +| 契约 | PASS | POST insight | +| 实现 | PASS | 多维对比 + 深度 sections | +| 测试 | PASS | engine L1 · 集成 · RelationPage.spec | +| Review | PASS | Gap:埋点 | + +**流程结论:** 设计闭合。 + +--- + +### 5. 探索测试 · explore-test + +| 步 | 结果 | 笔记 | +|---|---|---| +| Spec | PASS | 子能力表已展开 | +| map / page-tree | PASS | `/explore` `/scales/:slug` | +| 契约 | PASS | scales list/detail/result | +| 实现 | PASS | 两套 seed;丰富 result;无结果付费墙(Spec 已声明) | +| 测试 | PARTIAL | score/result L1;缺完整答题 e2e | +| Review | PASS | **Gap(已设计):** 答题中断恢复;埋点;若上锁结果须先改 Spec | + +**流程结论:** 设计闭合;中断恢复属已登记 Gap,开工前在 Spec §4/§7 补规则细节即可编码。 + +--- + +### 6. AI 问答 · ask + +| 步 | 结果 | 笔记 | +|---|---|---| +| Spec | PASS | 配额、降级、挂档案 | +| map / page-tree | PASS | `/ask` | +| 契约 | PASS | quota/threads/messages | +| 实现 | PASS | DeepSeek + 规则降级;四态较全 | +| 测试 | PASS | reply L1 · 集成 thread | +| Review | PASS | Gap:埋点;长期记忆 P3 | + +**流程结论:** 设计闭合。改配额数字或 system prompt 结构 → 先改 Spec §7/§14。 + +--- + +### 7. 深度版 / 成长会员 · membership + +| 步 | 结果 | 笔记 | +|---|---|---| +| Spec | PASS | DeepAccess ∪ Membership | +| map / page-tree | PASS | `/membership` + 报告内墙 | +| 契约 | PASS | membership/me · orders · pay-mock | +| 实现 | PASS | mock 支付落库;服务端裁剪 detail | +| 测试 | PASS | L2 unlock | +| Review | PASS | Gap:真支付网关(须 Spec 修订 §9) | + +**流程结论:** mock 商业闭环设计闭合。真支付 = 新一轮 Spec→契约→实现。 + +--- + +### 8. 成长报告 · reports + +| 步 | 结果 | 笔记 | +|---|---|---| +| Spec | PASS | | +| map / page-tree | PASS | `/reports` + `/reports/:id`(page-tree 已对齐) | +| 契约 | PASS | GET list/detail | +| 实现 | PASS | ReportsPage · ReportPage · Mine 入口 | +| 测试 | PARTIAL | 详情流较强;列表专项可补 | +| Review | PASS | Gap:收藏、PDF | + +**流程结论:** 设计闭合。 + +--- + +### 9. 分享卡 · share + +| 步 | 结果 | 笔记 | +|---|---|---| +| Spec | PASS | 前端 query 载荷 | +| map / page-tree | PASS | `/share` | +| 契约 | N/A | 无专用 API(Spec 已写) | +| 实现 | PASS | ShareSheet/Card/Page | +| 测试 | PARTIAL | 有分享相关测 | +| Review | PASS | Gap:短链服务、系统分享 | + +**流程结论:** P1 设计闭合。短链需新 API → 先改 Spec §8/§9。 + +--- + +### 10. 陪伴 · companion + +| 步 | 结果 | 笔记 | +|---|---|---| +| Spec | PASS | **明确 P1 Shell / P2** | +| map / page-tree | PASS | `/companion` | +| 契约 | PARTIAL | solar-terms + moods API 有;UI 未接 moods | +| 实现 | SHELL | 本地节气;心情文案占位 | +| 测试 | N/A | 壳不要求深测 | +| Review | PASS | **禁止**未改 Phase 就做心情 OS | + +**流程结论:** 壳范围设计闭合。P2 开工 = 升 Spec Phase + 补 §4/§7/§12 后再编码。 + +--- + +## 流程门禁抽查(本轮) + +- [x] 无 Spec 的 P1 功能:无 +- [x] Spec 均含 §4 / §7 / §9 / §12 +- [x] feature-map 已链到 Spec +- [x] 陪伴未误标为 P1 Feature Complete +- [x] 横切埋点统一登记为 Gap,未假装已完成 +- [x] **本轮不编码**——仅文档与 page-tree 对齐 + +--- + +## 批准编码时的推荐切片(需你点名后再动) + +| 切片 | 先改 Spec | 再改代码 | +|---|---|---| +| A. 埋点最小集 | 各 Spec §11 选 5 个核心事件 | H5 统一 `track()` + GA | +| B. 探索测试草稿恢复 | explore-test §4/§7 | localStorage 恢复答案 | +| C. 真支付 | membership §9/§7 | 支付适配器 + 回调 | +| D. 陪伴 P2 | companion Phase→P2 | moods UI | + +--- + +## 签字栏(人工) + +| 角色 | 确认 | 日期 | +|---|---|---| +| 产品/你 | 走查结论是否接受 | | +| 开发开工 | 从切片 A/B/C/D 择一 | | diff --git a/.ai/product/feature-spec/P2-BACKLOG.md b/.ai/product/feature-spec/P2-BACKLOG.md new file mode 100644 index 0000000..593b4b1 --- /dev/null +++ b/.ai/product/feature-spec/P2-BACKLOG.md @@ -0,0 +1,31 @@ +# P2 Backlog — 探索三模块 + 合规全量目录 + +**状态:** 合规全量探索进行中(Wave 0–4) +**命名决策:** 星座 / 身心节律 / 意象卡片;UI 无「塔罗」主入口、无「算命/占卜」恐吓 +**契约:** [../lexicon.md](../lexicon.md) · [../feature-map.md](../feature-map.md) · [../STRATEGY.md](../STRATEGY.md) + +真支付:部署阶段。排除:消息 / 达人 / UGC 广场。星座模块内允许日周月年运(非独立运势 Feed)。 + +--- + +## 模块 ↔ Spec + +| 顺序 | 模块 | Spec | 路由 | 状态 | +|---|---|---|---|---| +| 0 | 探索目录 | — | `/explore` · `/explore/:category` | Done | +| 1 | 星座 | [star-profile.md](star-profile.md) | `/star` | Done(星盘/运势/合盘对齐中) | +| 2 | 身心节律 | [life-rhythm.md](life-rhythm.md) | `/rhythm` | Done(今日/本周) | +| 3 | 意象卡片 | [image-card.md](image-card.md) | `/cards` | Done(≥78 牌 · ≥8 场景) | +| 4 | 量表矩阵 | [explore-test.md](explore-test.md) | `/scales/*` | Done(≥8) | +| 5 | 成长计划 | companion | `/growth-plan` | Done | +| 6 | 心情轨迹 | companion | `/companion` | Done | + +--- + +## 签字栏 + +| 项 | 状态 | +|---|---| +| 设计三 Spec | Done | +| 探索 catalog API | Done | +| 真支付 | 部署阶段 | diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md new file mode 100644 index 0000000..30ed94e --- /dev/null +++ b/.ai/product/feature-spec/README.md @@ -0,0 +1,27 @@ +# Feature Spec 索引 + +规范:[../feature-design.md](../feature-design.md) · 模板:[_TEMPLATE.md](_TEMPLATE.md) + +| Spec | 功能 | feature-map | 主要路由 | Phase | +|---|---|---|---|---| +| [home.md](home.md) | 首页 / 发现入口 | §1 首页 | `/` | P1 | +| [profile.md](profile.md) | 个人档案 | §1.1 · §5.1 | `/profile` | P1 | +| [portrait.md](portrait.md) | 个人画像 | §2.1 · §2.3 | `/portrait` | P1 | +| [relation.md](relation.md) | 关系理解 | §2.4 | `/relation` | P1 | +| [explore-test.md](explore-test.md) | 探索测试 | §2.2 | `/explore` · `/scales/:slug` | P1 | +| [ask.md](ask.md) | AI 成长助手 | §3 | `/ask` | P1 | +| [membership.md](membership.md) | 深度版 / 成长会员 | §6 · §5.3 | `/membership` | P1 | +| [reports.md](reports.md) | 成长报告列表/详情 | §2.1.2 · §5.2 | `/reports` · `/reports/:id` | P1 | +| [share.md](share.md) | 分享卡 | journey 双引擎 | `/share` | P1 | +| [companion.md](companion.md) | 陪伴(节气壳) | §4 | `/companion` | P1 壳 / P2 | +| [analytics.md](analytics.md) | 增长埋点最小集 | 横切 | H5 `track` | P1 | +| [star-profile.md](star-profile.md) | 星象性格 | §2.6 | `/star` | P2 设计 | +| [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 设计 | +| [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 设计 | + +新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 + +**P1 全量流程走查:** [P1-PROCESS-REVIEW.md](P1-PROCESS-REVIEW.md) +**P2 三模块队列(设计立项 / 编码另批):** [P2-BACKLOG.md](P2-BACKLOG.md) + +**竞品逆向(测测前端全量):** [cece-frontend-re/](cece-frontend-re/README.md) · **完整设计包:** [cece-frontend-re/complete-design/](cece-frontend-re/complete-design/README.md) · 方法见 [../../design/reverse-engineering-spec.md](../../design/reverse-engineering-spec.md) diff --git a/.ai/product/feature-spec/_TEMPLATE.md b/.ai/product/feature-spec/_TEMPLATE.md new file mode 100644 index 0000000..c90829a --- /dev/null +++ b/.ai/product/feature-spec/_TEMPLATE.md @@ -0,0 +1,203 @@ +# Feature Spec: \ + +> 复制本文件为 `.md`。规范:[../feature-design.md](../feature-design.md) +> 语言:[../lexicon.md](../lexicon.md) · 能力树:[../feature-map.md](../feature-map.md) +> Status: `Draft` | `Active` | `Deprecated` +> Map node: (如 `2.3 个人画像 [P1]`) +> Phase: `P1` | `P2` | `P3` + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | | +| Purpose | | +| Business Goal | | + +**In Scope / Out of Scope**(防止偷偷扩 scope) + +| In | Out | +|---|---| +| | | + +--- + +## 2. 用户价值 + +必须回答: + +1. 用户为什么需要它? +2. 用户完成后获得什么? +3. 为什么愿意付费?(若无付费面,写 N/A) + +--- + +## 3. 用户角色 + +对齐当前 Identity(Visitor 设备身份 + 可选升级)。写清**实际能做什么**,勿照搬竞品。 + +| Actor | 说明 | 本功能能力 | +|---|---|---| +| Guest / Visitor | 未登录设备身份 | | +| User | 已有持久用户/档案 | | +| VIP | 成长会员 active | | +| DeepAccess | 单份报告深度版已购 | | + +--- + +## 4. 用户流程 + +```text +进入 + ↓ +… +``` + +或 mermaid。必须含:无数据分支、权益分支、失败分支(可简写并在 §6 展开)。 + +--- + +## 5. 页面设计 + +对照 [../page-tree.md](../page-tree.md)。 + +| 路由 | 页面 | 说明 | +|---|---|---| +| | | | + +页面结构示例: + +```text +/path +├── Empty State +├── Loading State +├── Result / Normal State +└── Locked State +``` + +--- + +## 6. 页面状态规范 + +| 状态 | 说明 | UI 行为 | +|---|---|---| +| Loading | 请求中 | | +| Empty | 无数据 | 引导去哪 | +| Error | 失败 | 重试/返回 | +| Normal | 成功 | | +| Locked | 付费/权益限制 | CTA | + +--- + +## 7. Business Rules + +编号规则;**权益与归属必须在 Server 判定**。 + +| ID | Rule | +|---|---| +| R1 | | +| R2 | | + +--- + +## 8. 数据模型影响 + +指向 [../../domain/erd.md](../../domain/erd.md)。新增/变更表字段列表;无则写「无 schema 变更」。 + +| 表 | 变更 | 备注 | +|---|---|---| +| | | | + +--- + +## 9. API 需求 + +路径级意图即可;字段细节以 `proto/openapi.yaml` 为准,**实现时必须同步 OpenAPI**。 + +| Method | Path | 意图 | +|---|---|---| +| | | | + +Response 权益意图示例:`summary` 始终按规则返回;`detail` 无权益时剥离或 null。 + +--- + +## 10. 权限设计 + +| 能力 | Visitor | User | DeepAccess | VIP | +|---|---|---|---|---| +| 查看基础 | | | | | +| 查看完整 | | | | | +| 写入/生成 | | | | | + +--- + +## 11. 埋点 + +未接分析 SDK 也先定义事件名。 + +| Event | 触发 | +|---|---| +| `_started` | | +| `_completed` | | +| `deep_access_clicked` | | +| `purchase_completed` | | + +--- + +## 12. 测试验收标准 + +### 主路径 + +**Given** … +**When** … +**Then** … + +### 权益 / 异常 + +**Given** … +**When** … +**Then** … + +--- + +## 13. AI 开发前检查 + +- [ ] Spec 本章齐全(无空关键节) +- [ ] feature-map 已挂树 +- [ ] lexicon 文案已核对 +- [ ] OpenAPI / erd 更新计划已明确 +- [ ] §12 可转化为 L1/L2/E2E 用例 + +--- + +## 14. Implementation Notes + +编码时填写(首次实现或重大变更): + +| 项 | 内容 | +|---|---| +| Packages | `apps/api/internal/...` · `apps/user-h5/src/...` | +| Migrations | | +| Gaps vs Spec | (`Gap:` 未实现项) | +| Tests | L1 / L2 / e2e 路径 | + +--- + +## 附录:子能力展开检查(复杂功能必填) + +防止只写「入口 → 结果 → 分享」。按需勾选/展开: + +- [ ] 入口与列表 +- [ ] 主操作流程(逐步) +- [ ] 中断 / 恢复 / 草稿 +- [ ] 结果生成(免费层) +- [ ] 深度层 / 付费墙 +- [ ] 分享 +- [ ] AI 解释 / 追问入口 +- [ ] 数据统计 / 埋点 +- [ ] 验收标准覆盖上述每一项 + +示例(探索测试):测试入口 · 列表 · 题目流程 · 答题保存 · 中断恢复 · 结果生成 · 免费层 · 深度层 · 分享 · AI 解释 · 统计 · 验收。 diff --git a/.ai/product/feature-spec/analytics.md b/.ai/product/feature-spec/analytics.md new file mode 100644 index 0000000..f587475 --- /dev/null +++ b/.ai/product/feature-spec/analytics.md @@ -0,0 +1,148 @@ +# Feature Spec: 增长埋点(P1 最小集) + +> Status: `Active` · Map: 横切增长 · Phase: `P1`(切片 A) +> 规范:[../feature-design.md](../feature-design.md) · 走查:[P1-PROCESS-REVIEW.md](P1-PROCESS-REVIEW.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 增长埋点最小集 | +| Purpose | 用统一 `track()` 采集主漏斗关键节点,支撑转化分析 | +| Business Goal | 能量化:建档探索 → 深度版/会员;不阻塞主路径 | + +| In | Out | +|---|---| +| H5 `track` + GA4(可配 Measurement ID) | 服务端埋点中台 | +| 下文 **5 个核心事件** + 自动 `page_view` | 全量 Spec §11 一次接完 | +| DEV 可 debug 日志 | 采集生日/答题全文等 PII | + +--- + +## 2. 用户价值 + +1. 用户无感(不改变主流程)。 +2. 产品可看漏斗掉点。 +3. 付费:N/A。 + +--- + +## 3. 用户角色 + +全部 Visitor/User 路径均可触发;不因埋点失败阻断功能。 + +--- + +## 4. 用户流程 + +```text +App 启动 → 若配置了 GA ID 则注入 gtag + ↓ +路由变化 → page_view + ↓ +主漏斗节点 → track(核心事件) + ↓ +gtag 不可用 / 未配置 → no-op(可选 console debug) +``` + +--- + +## 5. 页面设计 + +无独立页面。触点:`/` · `/portrait` · `/relation` · 深度版 CTA · pay-mock 成功。 + +--- + +## 6. 页面状态规范 + +N/A(横切)。要求:埋点失败不影响 UI。 + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 统一经 `apps/user-h5/src/lib/analytics.ts` 的 `track`;页面禁止直接调 `window.gtag` | +| R2 | **P1 核心事件仅下表 5 个**(另加自动 `page_view`);其它 Spec §11 事件名保留,后续迭代再挂 | +| R3 | 禁止把生日、问答正文、档案 ID 明文作为事件参数(可用 `report_type` 等枚举) | +| R4 | `VITE_GA_MEASUREMENT_ID` 为空时 track 为 no-op,应用仍可用 | +| R5 | 事件名稳定,snake_case,与下表一致 | + +### P1 核心事件字典(冻结) + +| Event | 触发 | 可选 params | +|---|---|---| +| `home_cta_portrait` | 首页点「开始」且生日校验通过,即将进画像 | — | +| `portrait_completed` | 画像基础结果首次展示成功 | `source`: query\|report_id | +| `deep_access_clicked` | 用户点击深度版 CTA(画像/关系/报告) | `surface`: portrait\|relation\|report | +| `purchase_completed` | mock 支付成功且刷新后权益生效 | `kind`: deep_access\|membership | +| `relation_completed` | 关系理解基础结果展示成功 | — | + +基础设施: + +| Event | 触发 | params | +|---|---|---| +| `page_view` | 路由 afterEach | `page_path`, `page_title` | + +--- + +## 8. 数据模型影响 + +无 DB。配置:`VITE_GA_MEASUREMENT_ID`(H5)。 + +--- + +## 9. API 需求 + +无后端 API。 + +--- + +## 10. 权限设计 + +埋点不鉴权;不依赖登录态字段。 + +--- + +## 11. 埋点 + +见 §7 字典(本 Spec 即权威事件表)。 + +--- + +## 12. 测试验收标准 + +**Given** 未配置 GA ID +**When** 触发 `track('portrait_completed')` +**Then** 不抛错、不挡 UI + +**Given** mock `window.gtag` +**When** `track('home_cta_portrait')` +**Then** gtag 被以 event 名调用 + +**Given** 用户完成画像基础结果 +**When** 页面 Normal +**Then** 曾触发 `portrait_completed`(单测或集成对 track mock) + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 +- [x] 仅 5 核心 + page_view +- [x] 无 PII 参数 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `src/lib/analytics.ts` · `main.ts` · Home/Portrait/Relation/Report/Membership | +| Config | `VITE_GA_MEASUREMENT_ID` · `VITE_ANALYTICS_DEBUG`(`apps/user-h5/.env.example`) | +| Legacy | 静态站曾用 `G-LVVXH3TL04`,可写入 `.env.local` | +| Tests | `analytics.spec.ts` | +| Process Review | 切片 A **已实现**(2026-08-02) | diff --git a/.ai/product/feature-spec/ask.md b/.ai/product/feature-spec/ask.md new file mode 100644 index 0000000..5e92b69 --- /dev/null +++ b/.ai/product/feature-spec/ask.md @@ -0,0 +1,172 @@ +# Feature Spec: AI 成长助手(问答) + +> Status: `Active` · Map: `§3 问答 [P1]` · Phase: `P1` +> 规范:[../feature-design.md](../feature-design.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | AI 成长助手 | +| Purpose | 结合个人档案,用对话帮助认识自己、理解关系、整理情绪与生活节奏 | +| Business Goal | 战略中心 Tab 留存;配额驱动会员 | + +| In | Out | +|---|---| +| 挂 profile 的多轮对话;场景入口 | 占卜/运势/预测未来 | +| DeepSeek(可配)+ 规则引擎降级 | 医疗诊断 | +| 免费次数 + 会员配额 | 无档案空聊(禁止) | + +--- + +## 2. 用户价值 + +1. **为何需要:** 看完画像仍有具体情境问题。 +2. **完成后获得:** 结合档案的结构化建议与可执行小步骤。 +3. **为何付费:** 免费次数用尽后会员提供更多回复。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| Visitor | 有档案则可问;受配额限制 | +| VIP | 额外 Ask 配额 | +| 无档案 | 仅引导建档,不可消耗成功回复 | + +--- + +## 4. 用户流程 + +```text +进入 /ask + ↓ +无档案? → Empty 引导首页/档案 + ↓ +选择档案(我 / TA)+ 可选场景 + ↓ +输入问题 → 检查配额 + ├─ 耗尽 → 引导会员 + └─ 有余 → 创建/续 thread → assistant 回复 + ↓ + DeepSeek 失败/无 key → 规则引擎降级 +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/ask` | AskPage(ChatThread) | + +```text +/ask +├── Empty(无档案) +├── Loading(发送中) +├── Normal(历史 + 输入) +├── Error(发送失败可重试) +└── Quota Exhausted(引导会员) +``` + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Empty | 无档案引导 | +| Loading | 发送中禁用重复点 | +| Error | 失败 + 重试 | +| Normal | 消息列表 | +| Quota | 明确耗尽文案 + `/membership` | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 每条用户消息必须绑定 `profile_id`(self 或 other) | +| R2 | 免费回复次数有上限(实现:如 3);会员另计配额 | +| R3 | 配额耗尽返回明确业务错误,不生成付费假回复 | +| R4 | LLM 可选;失败降级规则引擎,仍须 lexicon 安全 | +| R5 | 禁止运势/吉凶/算命/合盘话术;结尾免责声明 | +| R6 | 回复宜有结构:回应用户 → 结合档案 → 可执行建议(详见 system prompt) | + +--- + +## 8. 数据模型影响 + +| 表 | 备注 | +|---|---| +| `ask_threads` | user_id, profile_id | +| `ask_messages` | role, content | +| `ask_quotas` / 等价 | 余量 | + +--- + +## 9. API 需求 + +| Method | Path | 意图 | +|---|---|---| +| GET | `/api/v1/ask/quota` | 余量 | +| POST | `/api/v1/ask/threads` | 创建线程(profile_id, scene?) | +| POST | `/api/v1/ask/threads/{id}/messages` | 发消息拿回复 | + +--- + +## 10. 权限设计 + +| 能力 | 无档案 | 有配额 | 配额耗尽 | VIP 有配额 | +|---|---|---|---|---| +| 提问 | ✗ | ✓ | ✗ | ✓ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `ask_opened` | 进入页 | +| `ask_message_sent` | 发送 | +| `ask_reply_received` | 成功回复 | +| `ask_quota_exhausted` | 耗尽 | +| `ask_fallback_rule` | 走规则引擎 | + +--- + +## 12. 测试验收标准 + +**Given** 有 Self 档案与配额 +**When** 发送问题 +**Then** 返回助手消息且含免责;配额减一 + +**Given** 配额为 0 +**When** 再发送 +**Then** 业务错误引导会员,无助手胡编 + +**Given** 无 DeepSeek key +**When** 提问 +**Then** 规则引擎仍给出档案相关回复 + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 · map · lexicon · OpenAPI · 可测 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `service/ask` · `internal/ask` · `internal/llm/deepseek` · `AskPage` | +| Config | `config.local.yaml` deepseek.* | +| Gaps | 长期记忆 P3;顾问预约仅占位;埋点未接 | +| Tests | ask reply L1;集成 thread;AskPage.spec | +| Process Review | 2026-08-02 [P1-PROCESS-REVIEW](P1-PROCESS-REVIEW.md) · 设计闭合 · 实现 PASS · 测试 PASS | diff --git a/.ai/product/feature-spec/cece-frontend-re/00-overview.md b/.ai/product/feature-spec/cece-frontend-re/00-overview.md new file mode 100644 index 0000000..8c31a70 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/00-overview.md @@ -0,0 +1,250 @@ +# 00 · 测测前端总览(L0 IA · 全功能清单) + +> Evidence: 以 E1 实测为主 · E2/E3 补充 · E6/E7 标明推断 + +--- + +## STEP 1 — 全局页面组成(App Shell) + +### 1.1 底部导航(五 Tab)✅ E1 + +| Tab | 视觉权重 | 入口职责 | +|---|---|---| +| 首页 | 标准 | 发现 · 工具宫格 · 信息流 · 建档引导 | +| 消息 | 标准 | AI/达人/社区/系统/商业通知 | +| **问** | **中央突出 + AI 标识** | 战略入口 → 测测 AI(内含真人 1v1 Tab) | +| 在线 | 标准 | 达人 Marketplace | +| 我的 | 标准 | 账号 · 档案资产 · 会员订单 | + +```text +┌─────────────────────────────┐ +│ 页面内容区 │ +├──────┬──────┬──────┬────┬───┤ +│ 首页 │ 消息 │ 问* │在线│我的│ +└──────┴──────┴──▲───┴────┴───┘ + │ *中央悬浮突出 + AI 标识 +``` + +### 1.2 全局浮层 / 横切 UI(推导) + +| 元素 | 来源 | 说明 | +|---|---|---| +| 登录/绑定弹层 | 🟡 E6 | 游客触达付费/发帖时 | +| 会员购买半屏 | ✅ E1 商业描述 | 首月/包月/季/年 | +| 网络错误 Toast | 🔵 E7 | 全 App | +| 权限(相册/麦克风)系统弹窗 | 🔵 E7 | 沙盘/连麦/OCR | +| 青少年/合规提示 | 🟡 E6 | 泛心理合规 | +| 分享面板(微信/海报) | ✅ E2 裂变叙事 | 测评结果卡 | +| 角标(消息未读) | 🟡 E6 | 消息 Tab | + +--- + +## STEP 2 — 前端功能全清单(Feature Inventory) + +### A. 导航与账号 + +| ID | 功能 | 证据 | +|---|---|---| +| NAV-01 | 五 Tab 切换 | ✅ E1 | +| NAV-02 | 「问」战略权重突出 | ✅ E1 | +| ACC-01 | 手机/微信/Apple 登录 | ✅ E1 基础信息 | +| ACC-02 | 游客 → 注册升级 | 🟡 E6 | +| ACC-03 | 账号设置/注销 | 🔵 E7 | + +### B. 生命档案(前端核心资产) + +| ID | 功能 | 证据 | +|---|---|---| +| PROF-01 | 创建本人档案(生日/时/地/性别) | ✅ E1 | +| PROF-02 | 创建关系档案(伴侣/朋友等) | ✅ E1 | +| PROF-03 | 多档案切换 | ✅ E1(AI 视角选择器) | +| PROF-04 | 首页「输入生日,更加了解 TA」引导 | ✅ E1 | +| PROF-05 | 档案编辑/删除 | 🟡 E6 | +| PROF-06 | 兴趣/状态标签 | 🟡 E4 | + +### C. 首页工具宫格(快捷入口)✅ E1 + +| ID | 入口文案 | 域 | +|---|---|---| +| GRID-01 | I人E人(MBTI) | 测评 | +| GRID-02 | 星座 | 星座内容 | +| GRID-03 | 星盘 | 本命/多盘型 | +| GRID-04 | 缘分合盘 | 合盘+指数 | +| GRID-05 | 沙盘 | 3D 心理沙盘 | +| GRID-06 | 陪伴小星 | AI 陪伴子入口 | +| GRID-07 | 商城 | 电商 | +| GRID-08 | 倾诉 | 咨询/AI | +| GRID-09 | AI 玩法广场 | UGC AI 小应用 | +| GRID-10 | 更多 | 收纳扩展 | + +「更多」内公开提到(✅ E2):爱情树(依恋类型)、幸运地图、pick 卡等趣味测。 + +### D. 「问」AI 与双轨 + +| ID | 功能 | 证据 | +|---|---|---| +| ASK-01 | 测测 AI 对话 | ✅ E1 | +| ASK-02 | 顶栏「测测AI ‖ 真人1v1」 | ✅ E1 | +| ASK-03 | 视角:星盘维度 | ✅ E1 | +| ASK-04 | 对象:自己 / 已建档 TA | ✅ E1 | +| ASK-05 | 深度思考模式开关 | ✅ E1 | +| ASK-06 | 灵魂伴侣子入口(横向滑动) | ✅ E1 | +| ASK-07 | 预置引导问题(星盘向) | ✅ E1 | +| ASK-08 | 下拉历史消息 | ✅ E1 | +| ASK-09 | AI 心情小镇(多虚拟倾诉师) | ✅ E1/E2 | +| ASK-10 | 灵犀/小智(子场景/模式,非并列 Tab) | ✅ E1 纠正 | + +### E. 星座 / 星盘 / 合盘 / 运势 + +| ID | 功能 | 证据 | +|---|---|---| +| AST-01 | 本命星盘展示与档案切换 | ✅ E3 | +| AST-02 | 多盘型(报道称可达约 20 种工具盘) | ✅ E3 | +| AST-03 | 合盘:比较盘/组合盘/时空盘/马克斯盘等 | ✅ E3 | +| AST-04 | 组合推运等推运变体 | ✅ E3 | +| AST-05 | 缘分指数(恋爱等) | ✅ E2 | +| AST-06 | 日/周/月/年运势 | ✅ E3/E4 | +| AST-07 | 附近的人 / 微信好友连线合盘 | 🟡 E3/行业 + 产品常见;【待真机补证】 | + +### F. 测评与神秘学工具 + +| ID | 功能 | 证据 | +|---|---|---| +| TEST-01 | MBTI(修订量表+分享) | ✅ E1/E2 | +| TEST-02 | 双人 MBTI/性格关系 | ✅ E2 | +| TEST-03 | 塔罗抽牌/提问 | ✅ E3 | +| TEST-04 | 八字/紫微/生命数字等 | ✅ E3 | +| TEST-05 | 爱情树/幸运地图/pick 卡 | ✅ E2 | +| TEST-06 | 50+ 心理测试矩阵 | 🟡 E3 口径 | + +### G. 沉浸体验 + +| ID | 功能 | 证据 | +|---|---|---| +| IMM-01 | 3D 心理沙盘 | ✅ E1/E2 | +| IMM-02 | AI 心情小镇(约 6 人设,含「明朗」) | ✅ E1 | + +### H. 社区与内容 + +| ID | 功能 | 证据 | +|---|---|---| +| SOC-01 | 首页信息流(文章/视频/故事/达人) | ✅ E1 | +| SOC-02 | 今日运势/热门测试/专家推荐 | ✅ E4 | +| SOC-03 | AI 玩法广场 UGC 列表(热度/创作者) | ✅ E1 | +| SOC-04 | 赞评关私信 | 🟡 E4 | + +### I. 在线达人 + +| ID | 功能 | 证据 | +|---|---|---| +| EXP-01 | 达人列表/筛选 | ✅ E1 | +| EXP-02 | 文字/语音/连麦咨询 | ✅ E1 | +| EXP-03 | 评价/复购 | 🟡 E6 | +| EXP-04 | 达人约 2.6 万签约口径 | ✅ E1 官方口径 | + +### J. 商业化前端 + +| ID | 功能 | 证据 | +|---|---|---| +| PAY-01 | 会员:首月 9.9 / 续订 25/月;季 58;年 178 | ✅ E1 | +| PAY-02 | 报告解锁 / AI 次数 | ✅ E1 | +| PAY-03 | 咨询下单支付 | ✅ E1 | +| PAY-04 | 商城 | ✅ E1 宫格 | +| PAY-05 | 主动压缩干扰广告 | ✅ E1 | + +### K. 增长 + +| ID | 功能 | 证据 | +|---|---|---| +| GRW-01 | 测评结果分享卡 | ✅ E1/E2 | +| GRW-02 | 邀请好友测 MBTI | ✅ E2 | +| GRW-03 | 签到/积分/勋章 | 🟡 E4 | + +--- + +## STEP 3 — 全局用户流程 + +### 3.1 新用户冷启动 ✅ E1 场景 + +```text +打开 App + →(可选登录)首页 + → 点宫格 MBTI / 或看信息流 + → 完成测评 → 结果页 → 分享/付费墙 + → 引导建档(生日) + → 可进入「问」或合盘 +``` + +### 3.2 关系资产飞轮 ✅ E1 + +```text +首页「了解 TA」/ AI 对象切换 + → 录入对方生日建关系档案 + → AI 基于 TA 星盘回答 / 合盘 + → 分享结果 → 拉新 +``` + +### 3.3 全局失败路径(必须实现)🔵 E7 + +| 场景 | 前端表现 | +|---|---| +| 断网/弱网 | 空态 + 重试;消息/AI 发送失败可重发 | +| Token 失效 | 跳登录;草稿保留 | +| 接口超时 | Toast + 重试 | +| 会员限制 | VIPLocked 半屏 | +| 余额不足(咨询) | 充值/支付 | +| 审核失败(UGC) | 说明原因 | +| 数据为空 | Empty 插画 + CTA | + +--- + +## STEP 4 — 全局状态机(App Shell) + +```mermaid +stateDiagram-v2 + [*] --> Init + Init --> Loading: 启动 + Loading --> GuestHome: 无登录 + Loading --> AuthedHome: 已登录 + GuestHome --> LoginRequired: 触达 gated + LoginRequired --> AuthedHome: 登录成功 + AuthedHome --> Offline: 断网 + Offline --> AuthedHome: 恢复 + AuthedHome --> VIPLocked: 点深度权益 + VIPLocked --> AuthedHome: 购买或关闭 + AuthedHome --> Error: 致命错误 + Error --> AuthedHome: 重试 +``` + +页面级状态见各 Tab 文档。 + +--- + +## 页面路由树(前端逻辑路由 · 推导) + +```text +/ 首页 +/message 消息 +/ask 问(默认 AI) +/ask?mode=human 真人1v1 +/online 在线达人列表 +/online/:expertId 达人详情 +/consult/:sessionId 咨询会话 +/mine 我的 +/profile/list 档案列表 +/profile/edit/:id 档案编辑 +/tools/mbti MBTI +/tools/chart 星盘 +/tools/synastry 缘分合盘 +/tools/sandbox 沙盘 +/tools/plaza AI玩法广场 +/tools/plaza/:appId 玩法详情 +/tools/tarot 塔罗 +/tools/bazi 八字… +/membership 会员 +/orders 订单 +/shop 商城 +/mood-town AI心情小镇 +``` + +路径名为 🔵 工程命名;测测真实 Deep Link 【待真机补证】。 diff --git a/.ai/product/feature-spec/cece-frontend-re/01-tab-home.md b/.ai/product/feature-spec/cece-frontend-re/01-tab-home.md new file mode 100644 index 0000000..6b9f445 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/01-tab-home.md @@ -0,0 +1,118 @@ +# 01 · Tab「首页」逆向设计 + +> Evidence: ✅ E1 实测 · ✅ E2「更多」工具 · 🟡/🔵 补全交互 + +--- + +## STEP 1 — UI Analysis + +### 区域结构(自上而下) + +| # | 区域 | 可见元素 | 证据 | +|---|---|---|---| +| H1 | 顶栏 | 可能含搜索/消息入口;「输入生日,更加了解 TA」引导条 | ✅ E1 | +| H2 | 用户/档案摘要 | 当前本人档案摘要或未建档 CTA | 🟡 E6 | +| H3 | 功能宫格 | I人E人、星座、星盘、缘分合盘、沙盘、陪伴小星、商城、倾诉、AI玩法广场、更多 | ✅ E1 | +| H4 | 运营 Banner | 活动/会员/新品 | 🟡 E6 | +| H5 | 推荐信息流 | 今日运势、热门测试、AI入口、专家推荐、文章/视频/故事/达人内容 | ✅ E1/E4 | +| H6 | 底部 Tab Bar | 全局 | ✅ E1 | + +### 宫格 Icon 全集(不得遗漏)✅ E1 + +1. I人E人(MBTI) +2. 星座 +3. 星盘 +4. 缘分合盘 +5. 沙盘 +6. 陪伴小星 +7. 商城 +8. 倾诉 +9. AI玩法广场 +10. 更多 → 二级页:爱情树、幸运地图、pick卡… ✅ E2 + +### AI 玩法广场页内元素 ✅ E1 + +- Slogan:「全民手搓创意——精选应用」 +- 列表卡片:应用名、热度(如 SBTI 305.7 万)、创作者名 +- 示例:SBTI 测试版、答案之书、三生三世你和 Ta、灵魂伴侣 + +--- + +## STEP 2 — Feature Analysis(逐元素) + +| 元素 | 作用 | 点击 | 长按/其它 | 权限/VIP | 分享 | +|---|---|---|---|---|---| +| 了解 TA 引导 | 建关系档案 | 进档案创建表 | — | 登录 | 否 | +| MBTI | 拉新测评 | 进量表流程 | — | 部分结果免费 | 结果强分享 | +| 星座 | 内容/运势 | 星座频道 | — | 深度会员 | 文章可分享 | +| 星盘 | 本命排盘 | 星盘页+档案切换 | — | 多盘型/详解会员 | 海报 | +| 缘分合盘 | 双人匹配 | 选 A/B→报告 | — | 指数免费/深文案付费 | 指数卡 | +| 沙盘 | 沉浸投射 | 3D 沙盘 | — | 可能次数限制 | 场景图 | +| 陪伴小星 | AI 陪伴 | 进子场景/问 | — | AI 次数 | 否 | +| 商城 | 电商 | 商品列表 | — | 登录支付 | 商品 | +| 倾诉 | 咨询/AI | 分流问/在线 | — | — | 否 | +| AI玩法广场 | UGC 应用集市 | 列表→应用 | — | 视应用 | 应用结果 | +| 信息流卡片 | 消费/转化 | 详情/达人/测试 | 可能不喜欢 | — | 内容分享 | +| 更多 | 收纳 | 二级宫格 | — | — | — | + +--- + +## STEP 3 — User Flow + +### 主流程:测评拉新 + +```text +首页 → MBTI → 答题 → Loading → 结果 + → 分享 / 解锁完整 / 邀请好友再测 + → 失败:网络错误重试;中途退出保存进度【待真机】 +``` + +### 主流程:建 TA 档案 + +```text +首页引导 → 填写生日/关系类型 → 保存 + → 可用于合盘 / AI 对象切换 + → 失败:校验生日非法;未登录 → LoginRequired +``` + +### 主流程:玩法广场 + +```text +宫格 → 广场列表 → 选应用 → 互动 → 结果 → 热度/分享 + → Empty:无应用;审核中不可见 🔵 +``` + +--- + +## STEP 4 — 状态机 + +| 状态 | UI | +|---|---| +| Init/Loading | 骨架屏 | +| Loaded | 宫格+流 | +| EmptyFeed | 流空+推荐宫格 | +| Offline | 顶条提示 | +| LoginRequired | 登录半屏 | +| VIPLocked | 会员半屏 | +| Error | 重试 | + +```mermaid +stateDiagram-v2 + [*] --> Loading + Loading --> Loaded + Loading --> Error + Loaded --> LoginRequired + Loaded --> VIPLocked + Loaded --> Offline + Offline --> Loaded + Error --> Loading: 重试 +``` + +--- + +## 页面验收(前端) + +- [ ] 宫格 9+更多与 E1 一致(版本差分记日志) +- [ ] 「了解 TA」可达档案创建 +- [ ] 玩法广场展示热度与创作者 +- [ ] 信息流可下拉刷新 / 分页 🔵 diff --git a/.ai/product/feature-spec/cece-frontend-re/02-tab-message.md b/.ai/product/feature-spec/cece-frontend-re/02-tab-message.md new file mode 100644 index 0000000..314c0d6 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/02-tab-message.md @@ -0,0 +1,55 @@ +# 02 · Tab「消息」逆向设计 + +> Evidence: ✅ E1/E4 分类 · 细交互多为 🟡/🔵(【待真机补证】) + +--- + +## STEP 1 — UI Analysis + +| 区域 | 元素 | 证据 | +|---|---|---| +| 顶栏 | 标题「消息」· 可能「一键已读」 | 🟡 E6 | +| 分类 Tab/分组列表 | AI 消息 · 达人消息 · 社区互动 · 系统 · 商业 | ✅ E4 | +| 会话行 | 头像 · 标题 · 摘要 · 时间 · 未读红点 | 🟡 E6 | +| 空态 | 插画 +「去问问 AI」CTA | 🔵 E7 | +| 底栏 | 全局 Tab,消息角标 | 🟡 E6 | + +--- + +## STEP 2 — Feature Analysis + +| 类型 | 作用 | 点击 | 删除/免扰 | 登录 | +|---|---|---|---|---| +| AI 消息 | 会话提醒/未读 AI | 进「问」对应线程 | 可删会话 🟡 | 是 | +| 达人消息 | 咨询回复 | 进咨询会话 | 是 | 是 | +| 社区 | 赞评关私信 | 进动态/私信 | 是 | 是 | +| 系统 | 公告/合规 | 详情页 | 通常不可删 | 是 | +| 商业 | 会员到期/订单/活动 | 会员/订单页 | 是 | 是 | + +长按:删除会话 / 标记已读 / 免打扰 🟡 E6。 + +--- + +## STEP 3 — User Flow + +```text +进入消息 → Loading → 列表 + → 点 AI 会话 → /ask 历史 + → 点达人 → 咨询页(可能支付状态校验) + → 点系统 → H5/原生详情 +失败:Token 失效登录;离线缓存只读 🔵 +``` + +--- + +## STEP 4 — 状态机 + +Init → Loading → Loaded | Empty | Offline | Error | LoginRequired + +未读数:本地角标 ↔ 服务端 sync 🔵。 + +--- + +## 后台推导(摘要 · 详见 08) + +推送通道、消息模板、免打扰时段、达人回复 SLA 监控 🔵。 diff --git a/.ai/product/feature-spec/cece-frontend-re/03-tab-ask.md b/.ai/product/feature-spec/cece-frontend-re/03-tab-ask.md new file mode 100644 index 0000000..df78431 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/03-tab-ask.md @@ -0,0 +1,101 @@ +# 03 · Tab「问」逆向设计(测测 AI + 真人 1v1) + +> Evidence: ✅ E1 实测最完整 · 核心战略入口 + +--- + +## STEP 1 — UI Analysis + +### 进入方式 + +- 底部中央「问」悬浮大按钮 + AI 标识 ✅ E1 + +### 对话页结构 ✅ E1 + +| 区域 | 元素 | +|---|---| +| 顶栏 | Tab:**测测AI** ‖ **真人1v1** | +| 提示 | 「下拉可浏览历史消息」 | +| 消息区 | 气泡列表 · 引导问题卡片(冷启动) | +| 上下文条(输入框上方) | **星盘**(解读维度)· **对象名**(默认「自己」,可切 TA)· **深度思考** | +| 右侧滑动 | **灵魂伴侣** 子入口 | +| 输入区 | 文本框 · 发送 · 可能语音 🟡 | +| 预置问题示例 | 「什么样的朋友适合我」「下周的财富情况如何」「本月在人际上需要注意什么」等(星盘向) | + +### 子场景(非并列 Tab)✅ E1 + +- 灵魂伴侣:测测 AI 生态内横向入口 +- AI 心情小镇:独立沉浸(多从首页/运营进入)· 约 6 位虚拟倾诉师 · OCR/情感识别 · 角色「明朗」等 +- 灵犀 / 小智:更接近模式或子场景,**不是**与测测 AI 同级的底部入口 + +--- + +## STEP 2 — Feature Analysis + +| 元素 | 作用 | 点击后 | VIP/次数 | 其它 | +|---|---|---|---|---| +| 测测AI Tab | 免费/限额 AI | 保持 AI 会话 | AI 次数/会员扩容 | — | +| 真人1v1 Tab | 达人咨询 | 达人列表或推荐咨询 | 付费 | 可从 AI 低摩擦切换 | +| 星盘标签 | 切换解读维度 | 维度面板【待真机】 | — | — | +| 对象名称 | 切换档案 | 档案选择器(自己/TA) | 需已建档 | 长按?【待真机】 | +| 深度思考 | 更高推理模式 | Toggle;可能耗更多额度 | 可能会员 | — | +| 灵魂伴侣 | 子场景 | 进入专项互动 | 视产品 | 可分享 🟡 | +| 引导问题 | 冷启动 | 自动填入并发送 | 计次 | — | +| 发送 | 提交 | Streaming 回复 | 额度不足 → 会员墙 | 防重复点击 🔵 | +| 下拉历史 | 翻旧消息 | 分页加载 | — | ≠ 长期记忆 | + +--- + +## STEP 3 — User Flow + +### AI 主路径 + +```text +点「问」→ Loading 会话 + → 展示引导问题(星盘向) + → 可选:切换对象为 TA(无档案则引导创建) + → 可选:开深度思考 + → 发送 → Streaming + → 可切换「真人1v1」升单 +``` + +### 失败 / 边界 + +| 场景 | 处理 | +|---|---| +| 无额度 | VIPLocked / 看广告?【待真机】 | +| 敏感内容 | 拒答 + 转介真人/热线 🔵 | +| 超时 | 重试气泡 | +| 对象无星盘数据 | 引导补全出生信息 | +| 中重度心理危机 | 明确边界文案 + 转介 🔵 | + +--- + +## STEP 4 — 状态机 + +```mermaid +stateDiagram-v2 + [*] --> Loading + Loading --> IdleEmpty: 新会话 + Loading --> IdleWithHistory: 有历史 + IdleEmpty --> Streaming: 发送/点引导 + IdleWithHistory --> Streaming + Streaming --> IdleWithHistory: 完成 + Streaming --> Failed: 错误 + Failed --> Streaming: 重试 + IdleWithHistory --> LoginRequired + IdleWithHistory --> VIPLocked: 额度用尽 + IdleWithHistory --> HumanRail: 切真人1v1 +``` + +状态枚举:Init · Loading · IdleEmpty · IdleWithHistory · Streaming · Failed · Retrying · Offline · LoginRequired · VIPLocked · HumanRail · PermissionDenied(麦克风) + +--- + +## 差异化要点(产品事实)✅ E1 + +1. AI **挂星盘档案**,非通用闲聊 +2. **对象切换**把「了解自己」扩到「分析关系」 +3. AI ‖ 真人 **同页双轨** + +已知缺口(公开评价)✅ E1:偏被动触达;跨会话长期记忆不足;引导问题偏运势易被感知为「算命 AI」。 diff --git a/.ai/product/feature-spec/cece-frontend-re/04-tab-online.md b/.ai/product/feature-spec/cece-frontend-re/04-tab-online.md new file mode 100644 index 0000000..bbd552e --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/04-tab-online.md @@ -0,0 +1,57 @@ +# 04 · Tab「在线」逆向设计(达人 Marketplace) + +> Evidence: ✅ E1 双边平台描述 · 列表 UI 细节 🟡【待真机】 + +--- + +## STEP 1 — UI Analysis + +| 区域 | 元素 | 证据 | +|---|---|---| +| 顶栏 | 搜索达人 · 筛选(领域/价格/好评) | 🟡 E6 | +| 运营位 | 推荐达人 Banner | 🟡 E6 | +| 列表卡片 | 头像 · 昵称 · 领域标签(星座/塔罗/命理/情感)· 评分 · 价格起 · 在线状态 | ✅ E1 类型 + 🟡 UI | +| 详情页 | 简介 · 服务项(文字/语音/连麦)· 评价 · 下单 CTA | ✅ E1 | +| 会话页 | 聊天/通话 UI · 倒计时/计费 | 🟡 E6 | + +专家类型 ✅ E1:星座专家 · 塔罗师 · 命理师 · 心理/情感达人。 + +--- + +## STEP 2 — Feature Analysis + +| 元素 | 点击 | 支付 | 评价 | 举报 | +|---|---|---|---|---| +| 达人卡 | 详情 | — | — | — | +| 文字咨询 | 下单→会话 | 是 | 结束后 | 是 🔵 | +| 语音/连麦 | 权限+下单 | 是 | 是 | 是 🔵 | +| 收藏达人 | Toggle | — | — | — 🟡 | +| 筛选排序 | 刷新列表 | — | — | — | + +撮合:推荐排序 · 平台抽成 ✅ E1。 + +--- + +## STEP 3 — User Flow + +```text +在线 → 浏览/搜索/筛选 → 达人详情 + → 选服务方式 → 创建订单 → 支付 + → 等待接单 → 服务中 → 结束 → 评价 → 复购 +失败:支付取消/失败;达人拒单;超时退款;权限拒绝麦克风 +``` + +--- + +## STEP 4 — 状态机(订单视角) + +Created → Paying → Paid → Matched → InService → Completed → Reviewed +旁路:Cancelled · Refunding · Refunded · Dispute + +前端页映射:列表 Loaded/Empty · 详情 · Checkout · Waiting · Chatting · Call · Rate。 + +--- + +## 达人供给运营(后台推导)🔵 + +入驻审核 · 培训激励 · 测测达人版工具 · 服务质量监控 · 分佣配置。 diff --git a/.ai/product/feature-spec/cece-frontend-re/05-tab-mine.md b/.ai/product/feature-spec/cece-frontend-re/05-tab-mine.md new file mode 100644 index 0000000..ba0e893 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/05-tab-mine.md @@ -0,0 +1,61 @@ +# 05 · Tab「我的」逆向设计 + +> Evidence: ✅ E1 资产分层 · 菜单项 🟡【待真机补全文案】 + +--- + +## STEP 1 — UI Analysis + +| 区域 | 元素 | 证据 | +|---|---|---| +| 头部 | 头像 · 昵称 · 会员标识 · 编辑资料 | ✅ E1 账号体系 | +| 资产摘要 | 会员状态 · AI 剩余次数 · 积分/等级 🟡 | ✅ E1 + 🟡 | +| 功能列表 | 生命档案 · 测试/报告库 · AI 对话历史 · 订单 · 会员中心 · 收藏 · 设置 · 客服 | ✅ E1 §5 + 🔵 | +| 其它 | 达人入驻入口? · 关于/协议 | 🟡 E6 | + +--- + +## STEP 2 — Feature Analysis + +| 入口 | 作用 | 点击 | 编辑/删除 | VIP | +|---|---|---|---|---| +| 生命档案 | Self/Other 管理 | 列表/编辑 | 是 | — | +| 测试记录 | 历史量表 | 报告详情 | 可能删 | 深度报告 | +| 报告库 | 星盘/合盘/AI 报告 | 打开 | 收藏 | 解锁态 | +| AI 历史 | 会话列表 | 进「问」 | 删会话 | — | +| 会员中心 | 订阅管理 | 购买/管理自动续费 | — | — | +| 订单 | 咨询/商城/解锁 | 详情/退款入口 | — | — | +| 设置 | 账号安全/通知/隐私 | 子页 | 注销 | — | +| 分享邀请 | 裂变 | 邀请码/海报 | — | — 🟡 | + +--- + +## STEP 3 — User Flow + +```text +我的 → 点档案 → 编辑生日/关系 +我的 → 会员 → 选套餐 → 支付 → 权益生效 +我的 → 订单 → 售后/发票【待真机】 +失败:登录态丢失;支付中断;注销二次确认 +``` + +--- + +## STEP 4 — 状态机 + +GuestMine(引导登录)· Loading · Loaded · VIPActive · VIPExpired · Error + +会员角标与首页权益 Tip 联动 🔵。 + +--- + +## 会员定价前端展示(必须完整)✅ E1 + +| 套餐 | 价格 | +|---|---| +| 首月 | ¥9.9 | +| 续订月 | ¥25/月 | +| 连续包季 | ¥58/季(约 ¥19.3/月) | +| 连续包年 | ¥178/年(约 ¥14.8/月) | + +权益文案:完整测评报告 · AI 次数扩展 · 专属内容 ·(历史含去广告)✅ E1。 diff --git a/.ai/product/feature-spec/cece-frontend-re/06-tools-astrology.md b/.ai/product/feature-spec/cece-frontend-re/06-tools-astrology.md new file mode 100644 index 0000000..409870b --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/06-tools-astrology.md @@ -0,0 +1,104 @@ +# 06 · 工具域:星座 · 星盘 · 合盘 · 运势 + +> Evidence: ✅ E1/E2 入口与合盘指数 · ✅ E3 盘型列表 · 社交合盘 🟡【待真机】 + +--- + +## STEP 1 — 页面家族 + +| 页面 | 主要 UI | 证据 | +|---|---|---| +| 星座频道 | 十二星座列表/今日运势流 | ✅ E4 | +| 本命星盘 | 圆形星盘 · 行星列表 · 宫位 · 档案切换 · 添加档案 | ✅ E3 操作步骤 | +| 盘型选择 | 多盘型工具(报道称对标多软件、约 20 种) | ✅ E3 | +| 运势 | 日/周/月/年(及一生类报告) | ✅ E3 | +| 缘分合盘 | 双人选择 · 指数 · 多盘型 Tab | ✅ E2/E3 | +| 合盘社交 | 附近的人 / 好友连线 | 🟡 E3/行业 | + +### 合盘盘型(前端 Tab 级)✅ E3 + +| 盘型 | 说明(产品侧) | +|---|---| +| 比较盘 | 双盘叠合与跨盘相位 | +| 组合盘 | 中点关系盘 | +| 时空盘 | 时刻/坐标中点再排盘 | +| 马克斯盘(马克思盘) | 时空×本命中点视角 | +| 组合推运等 | 次限/推运变体 | + +另:行运盘、三限盘、月亮返照等出现在星盘工具描述中 ✅ E3。 + +--- + +## STEP 2 — Feature Analysis + +| 元素 | 作用 | 交互 | VIP | +|---|---|---|---| +| 档案切换 | 换本命主体 | 顶栏切换/添加 | — | +| 星盘图 | 可视化 | 点行星看释义 | 详解付费 | +| 相位表 | 关系动力 | 列表/点击 | 完整表会员 | +| 恋爱/友情/婚姻指数 | 缘分量化 | 结果页大数字 | 指数免费/解读付费 🟡 | +| 盘型 Tab | 切换算法视图 | 不重复支付同报告 🟡 | 深文案付费 | +| 推运日期 | 次限 as_of | 日期选择器 | — | +| 分享 | 裂变 | 海报/链接 | — | +| 附近的人 | 匹配合盘对象 | 定位授权 | 隐私开关 🔵 | + +--- + +## STEP 3 — User Flow + +### 本命星盘 + +```text +首页「星盘」→ 默认本人盘 + → 右上切换/添加他人档案(生日等) + → 返回切换查看 + → 可选盘型/运势/报告解锁 +``` + +### 缘分合盘 + +```text +首页「缘分合盘」→ 选档案 A/B(或快速建档) + → 生成指数 + 默认比较盘 + → Tab 切换组合/时空/马克斯/配对/推运 + → 深度解锁 / 分享 +``` + +### 失败 + +缺出生时 → 上升/宫位降级提示;定位拒绝 → 仅手动选城;支付失败保留摘要。 + +--- + +## STEP 4 — 状态机(合盘报告页) + +```mermaid +stateDiagram-v2 + [*] --> Form + Form --> Computing: 提交 + Computing --> SummaryFree: 成功 + Computing --> Error + SummaryFree --> DeepLocked + DeepLocked --> DeepOpen: 支付/会员 + SummaryFree --> ChartTabSwitch + ChartTabSwitch --> SummaryFree +``` + +--- + +## 数据/算法面(前端依赖) + +| 输入 | 字段 | +|---|---| +| BirthInfo | date · time · place · lat/lng · tz | +| Chart | planets[] · houses · aspects · asc | +| SynastryReport | indices · charts{} · as_of | + +星历与宫位制:生产级需 Swiss Ephemeris 等 🔵;Whole Sign / Placidus 等切换【待真机】。 + +--- + +## 愈心谷对齐 + +已实现方向:`star-profile.md` · Synastry 五主盘+推运 · 附近/邀请。 +测测「约 20 种盘」未全部落地处:Spec 中保留完整清单,实现按分期标注「未交付」而非删除。 diff --git a/.ai/product/feature-spec/cece-frontend-re/07-tools-tests-psych.md b/.ai/product/feature-spec/cece-frontend-re/07-tools-tests-psych.md new file mode 100644 index 0000000..fb3ab60 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/07-tools-tests-psych.md @@ -0,0 +1,115 @@ +# 07 · 工具域:测评 · 塔罗 · 沙盘 · 心情小镇 · 趣味玩法 + +> Evidence: ✅ E1/E2 · 流程细节 🟡/🔵 + +--- + +## A. MBTI / I人E人 ✅ E1/E2 + +### STEP 1–2 + +| UI | 交互 | +|---|---| +| 介绍页 · 开始测 | 进入答题 | +| 题目卡 · 进度条 | 单选;可返回上一题 🟡 | +| 结果页:16 型标签 · 维度图 · 解读 | 分享强引导;邀请好友对照关系类型 ✅ E2 | +| 付费墙 | 完整报告 | + +学术背书:心言心理组修订量表、期刊《心理学进展》✅ E1(专业性证据,非 UI)。 + +### Flow + +```text +开始 → N 题 → 提交 → 计分 Loading → 结果 → 分享/解锁/再测/双人对照 +``` + +状态:Answering · Submitting · ResultFree · ResultLocked · ShareSheet。 + +--- + +## B. AI 玩法广场 ✅ E1 + +UGC 小应用集市(非官方智能体矩阵)。 + +| UI | 说明 | +|---|---| +| 列表 | 名 · 热度 · 创作者 | +| 应用页 | 互动表单/对话 → 结果 | +| 创作入口 | 达人/用户「手搓」【待真机是否 C 端开放】 | + +示例应用:SBTI、答案之书、三生三世你和 Ta、灵魂伴侣。 + +审核:上架前内容审核 🔵;违规下架。 + +--- + +## C. 3D 心理沙盘 ✅ E1/E2 + +| UI | 交互 | +|---|---| +| 3D 场景画布 | 拖放沙具/排序 | +| 主题模板 | 职场焦虑等场景引导 | +| AI 投射分析 | 生成解读 | +| 保存/分享 | 场景快照 | + +数据:超 1200 万用户口径 ✅ E1。权限:可能陀螺仪/性能降级 🔵。 + +--- + +## D. AI 心情小镇 ✅ E1 + +| UI | 交互 | +|---|---| +| 角色选择 | ~6 人设(知心姐姐、理性学长、明朗…) | +| 对话/互动 | OCR · 情感识别 | +| 沉浸场景 | 小镇视觉 | + +边界:轻度情绪;中重度转真人/专业机构 ✅ E1。 + +--- + +## E. 塔罗 ✅ E3 + +抽牌(单牌/三牌/阵)→ AI/达人解读 → 可提问;可能拍照识牌 🟡。 +结果页:牌义 · 组合 · 建议 · 付费扩展。 + +--- + +## F. 命理工具箱 ✅ E3 + +八字(四柱/五行/大运/流年)· 紫微 · 姓名五格 · 二十八宿 · 生命数字。 +前端:表单(姓名/生日)→ 盘面/条文 → 解锁。 + +--- + +## G. 「更多」趣味测 ✅ E2 + +| 名称 | 用途 | +|---|---| +| 爱情树 | 依恋类型 | +| 幸运地图 | 幸运/桃花城市等 | +| pick 卡 | 趣味抽取 | + +Flow 同轻量测评:介绍 → 互动 → 结果 → 分享。 + +--- + +## H. 每日心情 / 打卡 🟡 E1 留存叙述 + +入口可能在首页或陪伴场景;打卡日历 · 情绪曲线 · 驱动晚间打开(50% 用户 19:00 后 ✅ E1)。 + +--- + +## 统一测评状态机 + +```mermaid +stateDiagram-v2 + [*] --> Intro + Intro --> InProgress + InProgress --> Scoring + Scoring --> ResultTeaser + ResultTeaser --> Paywall + Paywall --> ResultFull + ResultTeaser --> Share + Scoring --> Error +``` diff --git a/.ai/product/feature-spec/cece-frontend-re/08-systems.md b/.ai/product/feature-spec/cece-frontend-re/08-systems.md new file mode 100644 index 0000000..cd86d3d --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/08-systems.md @@ -0,0 +1,232 @@ +# 08 · 横切系统逆向(STEP 5–18) + +> 支撑前端全功能的数据 / API / 后台 / 运营 / 安全推导 +> 标记:无公开 UI 的条目多为 🟡/🔵,但**禁止省略整类** + +--- + +## STEP 5 — 数据模型(核心对象) + +| 对象 | 关键字段(类型) | 可空 | 默认 | 来源 | 缓存 | 索引 | 证据 | +|---|---|---|---|---|---|---|---| +| User | id UUID PK · phone · wx_openid · status · created_at | 部分 | active | 注册 | Redis session | phone unique | ✅ E1 | +| Profile | id · user_id · relation(self/other) · display_name · birth_date · birth_time · birth_place · geo_* · tags JSON | time/place 可空 | — | 建档 | 本地+服务端 | user_id | ✅ E1 | +| BirthInfo | 可内嵌 Profile | — | tz=CST+8 🟡 | — | — | — | 🟡 | +| AstrologyChart | profile_id · jd · planets JSON · houses JSON · system · version | — | — | 计算服务 | 按 profile+ver | profile_id | 🔵 | +| SynastryReport | id · profile_a · profile_b · indices JSON · charts JSON · as_of | — | — | 合盘 | 报告缓存 | (a,b) | ✅ E3 | +| TestDefinition | slug · title · questions JSON · scoring | — | published | CMS | CDN | slug | ✅ E1 MBTI | +| TestRecord | user_id · test_id · answers · result · share_code | — | — | 提交 | — | user_id | ✅ E2 | +| AIConversation | id · user_id · profile_ctx_id · mode · messages | — | — | 问 | 热数据 | user_id | ✅ E1 | +| AIQuota | user_id · remaining · period | — | 免费额 | 会员 | Redis | user_id | ✅ E1 | +| Expert | id · domains[] · price_text · price_voice · score · online | — | — | 达人平台 | 列表缓存 | domain | ✅ E1 | +| ConsultOrder | id · user_id · expert_id · mode · amount · status | — | created | 下单 | — | status | ✅ E1 | +| Membership | user_id · plan · status · expires_at · auto_renew | — | — | 订阅 | — | expires | ✅ E1 | +| Order | id · kind(membership/unlock/consult/shop) · amount_cents · status | — | — | 支付 | — | user_id | ✅ E1 | +| Notification | id · user_id · channel · type · payload · read_at | — | — | 推送/站内 | — | user_id,read | ✅ E4 | +| PlazaApp | id · creator_id · title · heat · status | — | pending | UGC | 热榜 | heat | ✅ E1 | +| Favorite | user_id · target_type · target_id | — | — | 收藏 | — | uniq | 🟡 | +| Coupon | id · rules JSON | — | — | 运营 | — | — | 🔵 | +| OperationLog | actor · action · meta | — | — | 后台 | 冷存 | time | 🔵 | +| ABAssignment | user_id · exp_key · bucket | — | — | 配置 | Redis | — | 🔵 | + +--- + +## STEP 6 — 数据库设计(表清单) + +必表(前端可感知数据): + +`users` · `device_identities` · `profiles` · `growth_reports` / `synastry_reports` · `test_definitions` · `test_records` · `ai_conversations` · `ai_messages` · `ai_quotas` · `experts` · `consult_orders` · `memberships` · `orders` · `payments` · `notifications` · `plaza_apps` · `favorites` · `banners` · `feature_flags` · `audit_logs` + +每表要求:PK UUID · `created_at`/`updated_at` · 软删 `deleted_at` · 外键 ON DELETE 策略明确 🔵。 + +合盘邀请示例:`synastry_invites(token unique, host_profile_id, expires_at, report_id)` 🔵(愈心谷已落地相近结构)。 + +--- + +## STEP 7 — API Reverse Engineering(可开发级草案) + +> Path 为 🔵 工程命名;真实测测 Path【待抓包】 + +| Method | Path | 权限 | Request | Response 要点 | 错误码 | +|---|---|---|---|---|---| +| POST | /auth/login | 公网 | phone/wx | token · user | 401 | +| GET | /profiles | 登录 | — | items[] | — | +| POST | /profiles | 登录 | birth_* · relation | profile | 400 | +| PATCH | /profiles/:id | 所有者 | patch | profile | 404 | +| POST | /tests/:slug/submit | 登录 | answers | record_teaser · record_id | 429 | +| GET | /reports/:id | 所有者 | — | summary · detail? | 403 detail | +| POST | /astrology/natal | 登录 | profile_id | chart | 400 | +| POST | /astrology/synastry | 登录 | a · b · as_of | report | 400 | +| GET | /astrology/nearby | 登录+geo | lat,lng,r | items | 403 | +| POST | /ai/chat | 登录+额度 | message · profile_ctx · deep | stream SSE | 402 额度 | +| GET | /ai/conversations | 登录 | cursor | list | — | +| GET | /experts | 登录 | filter | list | — | +| POST | /consult/orders | 登录 | expert · mode | order | 402 | +| POST | /consult/orders/:id/pay | 登录 | channel | pay_params | 409 | +| GET | /membership/me | 登录 | — | plan · expires | — | +| POST | /orders | 登录 | kind · plan/report | order_id | — | +| GET | /messages | 登录 | cursor | items · unread | — | +| POST | /messages/:id/read | 登录 | — | ok | — | +| GET | /plaza/apps | 登录 | sort=heat | apps | — | +| POST | /plaza/apps/:id/run | 登录 | input | result | 403 审核 | + +RateLimit:AI/支付严格限流;图表计算按用户 QPS 🔵。 +Cache:星盘按 profile+ephemeris_ver;广场热榜 Redis ZSET;Banner CDN。 +Retry:幂等键用于支付与咨询下单。 + +--- + +## STEP 8 — 后台运营系统 + +| 模块 | 能力 | 证据 | +|---|---|---| +| Banner/运营位 | 首页头图、弹窗、闪屏 | 🔵 | +| 推荐位 | 达人/测试/文章排序 | 🟡 | +| 内容审核 | 广场 UGC、社区帖、达人资质 | 🔵 | +| 用户管理 | 封禁、注销、投诉 | 🔵 | +| 订单/退款 | 咨询与会员 | ✅ E1 商业 | +| 会员配置 | 价格、权益、首月优惠 | ✅ E1 | +| 活动配置 | 裂变、优惠券 | 🟡 | +| 推送 | 模板、人群包 | 🔵 | +| 统计看板 | DAU、转化漏斗、AI 次数 | 🔵 | +| AB / 配置中心 | 引导问题文案、价格实验 | 🔵 | +| 达人运营 | 入驻审核、分佣、培训 | ✅ E1 达人版 | + +--- + +## STEP 9 — 权限矩阵 + +| 角色 | 能 | 不能 | +|---|---|---| +| 游客 | 浏览部分首页、部分测评 | AI 深度、咨询、发帖 | +| 登录用户 | 档案、基础 AI 次数、下单 | 未购深度 detail | +| VIP/会员 | 扩容 AI、完整报告、专属内容 | 后台 | +| SVIP | 🟡 若存在更高等级 | — | +| 达人 | 接单、达人工具 | 改平台分佣 | +| 运营 | CMS、活动、推荐 | 直接改支付流水 | +| 审核员 | 通过/驳回 UGC/达人 | 财务 | +| 客服 | 工单、补发权益 | 删库 | +| 管理员 | 配置与账号 | — | +| 超管 | 全量 | — | + +前端:`LoginRequired` · `VIPLocked` · `PermissionDenied`。 + +--- + +## STEP 10 — 支付系统 + +状态:Created → Paying → Paid / Failed / Cancelled →(退款)Refunding → Refunded +场景:重复支付防重 · 补单对账 · 咨询未接单自动退 · 风控限额 · 发票 🔵 +渠道:IAP / 微信 / 支付宝 🟡【待真机】 +前端:收银台 · 结果页 · 订单详情入口(我的)。 + +--- + +## STEP 11 — 消息系统 + +| 通道 | 用途 | +|---|---| +| Push | 达人回复、会员到期 | +| 站内信 | 消息 Tab | +| 短信 | 登录验证码 | +| 邮件 | 可选账单 🔵 | +| 角标 | Tab 未读聚合 | + +策略:免打扰时段 · 营销推送开关 · AI 主动触达(公开称尚未强做)✅ E1。 + +--- + +## STEP 12 — 埋点 + +| 事件 | 触发 | +|---|---| +| page_view | 各 Tab/工具 | +| home_grid_click | 宫格 | +| test_start / test_complete / test_share | 测评 | +| ask_send / ask_deep_toggle / ask_switch_profile | 问 | +| ask_switch_human | 转真人 | +| synastry_complete / chart_tab_switch | 合盘 | +| expert_view / consult_pay | 在线 | +| membership_pay | 会员 | +| plaza_app_open | 广场 | +| error_api | 异常 | + +漏斗:曝光 → 建档 → AI → 会员 → 咨询。停留时长、次日留存 🔵。 + +--- + +## STEP 13 — 配置系统 + +| 项 | 后台可配 | 写死 | 远程/灰度/AB | +|---|---|---|---| +| 宫格顺序/显隐 | ✓ | — | 灰度 | +| AI 引导问题 | ✓ | — | 按来源 AB ✅ E1 建议 | +| 会员价格 | ✓ | — | AB | +| 星历版本 | — | 逻辑 | 远程 flag | +| 审核词库 | ✓ | — | — | +| 功能开关(沙盘) | ✓ | — | 灰度 | + +--- + +## STEP 14 — 缓存策略 + +| 层 | 内容 | +|---|---| +| 本地 | 档案草稿、会话草稿、最近报告摘要 | +| Redis | Session、AI 额度、热榜、限流 | +| CDN | 图片、静态运营配置 | +| 计算缓存 | 同档案星盘 TTL | +| 分页 | 信息流 cursor | + +--- + +## STEP 15 — 异常处理(前端必达) + +断网 · 弱网 · 超时 · 重复点击(按钮 loading)· Token 失效 · 本地数据损坏清缓存 · 强制升级 · 审核失败文案 · 404 资源 · 支付结果未知查单 · SSE 中断续传 🔵。 + +--- + +## STEP 16 — 日志 + +用户行为(隐私脱敏)· 前端错误 JS · API access · 支付 · 审核 · 运营操作 · 安全(登录失败/风控)🔵。 +保留与销毁策略合规 🔵。 + +--- + +## STEP 17 — 安全 + +| 项 | 要求 | +|---|---| +| 权限 | 服务端强制 detail 剥离(不仅藏 UI) | +| 注入/XSS | 内容安全渲染 | +| CSRF/重放 | 支付与关键写接口 | +| 验证码 | 登录/敏感操作 | +| 风控 | 刷单、恶意咨询 | +| 设备 | device_key / 绑定 | +| 隐私 | geo 默认不可见;档案分享最小化 | +| 心理边界 | 危机话术与转介 | + +遵守应用商店与网信/心理相关合规 🔵。 + +--- + +## STEP 18 — 可扩展性 + +| 方向 | 策略 | +|---|---| +| 新盘型 | charts 插件注册,API 加 mode | +| 新测评 | TestDefinition 配置化 | +| 新 AI 子场景 | Ask 内 slot,避免新 Tab | +| 多端 | 同一 BFF;小程序裁剪 Marketplace | +| API 兼容 | summary schema version 字段 | +| 模块解耦 | 计算服务与业务 API 分离 | +| 硬件(巴布) | 独立产品线,账号体系统一 ✅ E1 | + +--- + +## Completeness 自检(STEP 5–18) + +- [x] 数据模型 / 库表 / API 草案 +- [x] 后台 / 权限 / 支付 / 消息 / 埋点 +- [x] 配置 / 缓存 / 异常 / 日志 / 安全 / 扩展 +- [ ] 真实抓包字段级对齐【待补证】 diff --git a/.ai/product/feature-spec/cece-frontend-re/README.md b/.ai/product/feature-spec/cece-frontend-re/README.md new file mode 100644 index 0000000..44dd7c7 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/README.md @@ -0,0 +1,90 @@ +# 测测 App 前端逆向工程设计文档(V1.0) + +> Status: `Active` · Method: [reverse-engineering-spec.md](../../../design/reverse-engineering-spec.md) +> Scope: **前端可见能力 + 为支撑前端所必需的后台/数据/运营推导** +> 目标:Production / Enterprise / Feature Complete 级设计输入(非 Demo) + +--- + +## 0. 任务声明 + +| 项 | 内容 | +|---|---| +| 身份 | Senior Reverse Engineering Architect | +| 目标 App | 测测(CeCe / 测测星盘,心言集团) | +| 方法 | 逆向工程,非「设计一个类似 App」 | +| 禁止 | Demo · MVP 终态 · 「略 / TODO / 以后再做」糊弄 | + +**落地到愈心谷时**:能力可完整对标;用户可见命名与禁词服从 [`lexicon.md`](../../lexicon.md);禁止自称「测测」、照搬商标视觉。 + +--- + +## 1. 证据来源总表(Evidence First) + +| ID | 来源 | 类型 | 日期/版本线索 | +|---|---|---|---| +| E1 | [人人都是产品经理 · 测测深度体验报告](https://www.woshipm.com/evaluating/6391148.html) | ✅ UI/行为证据(含实测截图描述) | 2026-05-09 | +| E2 | [中华网 · 测测功能图谱](https://tech.china.com/articles/20250417/202504171661508.html) | ✅ 行为/产品证据 + 🟡 运营口径 | 2025-04 | +| E3 | 应用商店/下载站功能介绍(星盘工具列表等) | ✅ 功能列表证据(版本可能滞后) | ~10.x 描述 | +| E4 | 本仓 [`cece-feature-map.md`](../../cece-feature-map.md) | 🟡 既有归纳 | 持续 | +| E5 | 本仓 [`apps/docs/analysis/cece-teardown.md`](../../../../apps/docs/analysis/cece-teardown.md) | 🟡 映射分析 | 历史 | +| E6 | 行业惯例 / 同类 App | 🟡 推断 | — | +| E7 | 工程完整性补充 | 🔵 工程建议 | — | +| E8 | 安卓模拟器 AVD `YuXinGu_API34` + APK 10.50.0 截图/UI dump | ✅ 真机 UI 证据 | 2026-08-03 | + +> **局限**:无未公开 DAU/后台;HTTPS 抓包未做。E8 明细见 [complete-design/evidence-20260803.md](complete-design/evidence-20260803.md)。 + +--- + +## 2. 文档地图 + +| 文档 | 内容 | Workflow 覆盖 | +|---|---|---| +| [00-overview.md](00-overview.md) | L0 IA · 全功能清单 · 全局状态 · 导航 | STEP 1–4 全局 | +| [01-tab-home.md](01-tab-home.md) | 首页(宫格/信息流/建档/玩法广场) | STEP 1–4 | +| [02-tab-message.md](02-tab-message.md) | 消息 | STEP 1–4 | +| [03-tab-ask.md](03-tab-ask.md) | 「问」测测 AI + 真人 1v1 | STEP 1–4 | +| [04-tab-online.md](04-tab-online.md) | 在线(达人 Marketplace) | STEP 1–4 | +| [05-tab-mine.md](05-tab-mine.md) | 我的 | STEP 1–4 | +| [06-tools-astrology.md](06-tools-astrology.md) | 星座 · 星盘 · 合盘 · 运势 | STEP 1–4 + 算法面 | +| [07-tools-tests-psych.md](07-tools-tests-psych.md) | MBTI · 塔罗 · 沙盘 · 心情小镇 · 趣味测 | STEP 1–4 | +| [08-systems.md](08-systems.md) | 数据/库/API/后台/权限/支付/消息/埋点/配置/缓存/异常/日志/安全/扩展 | STEP 5–18 | +| **[complete-design/](complete-design/README.md)** | **完整产品设计包(Design Complete)** — 实现与模拟器校验前的唯一前端设计输入 | 定稿升格 | + +--- + +## 3. Completeness Checklist(套件级) + +- [x] 五 Tab 页面拆解 +- [x] 首页宫格与「更多」工具域 +- [x] AI / 达人双轨 +- [x] 星盘与合盘工具面 +- [x] 测评 / 沉浸体验 +- [x] 横切系统 STEP 5–18 +- [ ] 【待真机补证】逐按钮热区坐标与最新版 UI 差分 +- [ ] 【待抓包补证】真实 API Path / 字段级 Response + +套件可结束条件:上表「真机/抓包」两项未完成时,仍可作为 **Feature Complete 设计底稿**;实现前须补证据或接受 🟡/🔵 风险。 + +**完整设计定稿:** [complete-design/](complete-design/README.md) 已将 🟡/🔵 升格为设计定稿(D);真机/模拟器差分按 [99-emulator-validation.md](complete-design/99-emulator-validation.md) 回填。 + +--- + +## 4. 愈心谷映射入口 + +| 测测前端域 | 愈心谷当前落地 | Spec | +|---|---|---| +| 档案 / 了解 TA | Profile | `profile.md` | +| 问 / AI | Ask | `ask.md` | +| 星座 / 星盘 / 合盘 | Star + Synastry | `star-profile.md` | +| 测评 | Explore / Scale | `explore-test.md` | +| 会员 | Membership | `membership.md` | +| 消息 / 在线达人 | 分期后置 | feature-map `[P3]/`/`[No]` | + +--- + +## 5. 维护 + +- 版本变更:在各文档顶部追加「差分日志」 +- 真机补证:把 ✅ 行从 E1 升级为「真机 YYYY-MM-DD + 版本号」 +- 与 [`cece-feature-map.md`](../../cece-feature-map.md) 保持 L0 一致;冲突时以本套件 + 更新证据为准 diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/00-design-bible.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/00-design-bible.md new file mode 100644 index 0000000..e21331e --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/00-design-bible.md @@ -0,0 +1,150 @@ +# 00 · 设计圣经(全局) + +> Design ID: `CECE-FE-BIBLE-V1` · 定稿 · 待模拟器校验项见文末 + +--- + +## 1. 产品一句话 + +以趣味测评与星盘档案为入口,以「问」Tab 级 AI(挂档案)为战略核心,以达人咨询为高客单转化,以会员订阅为主营收的泛心理情感陪伴 App。 + +--- + +## 2. 信息架构(冻结) + +### 2.1 底部五 Tab(D 定稿) + +| 序 | Tab | 路由 key | 视觉(✅ E8 10.50.0) | 战略 | +|---|---|---|---|---| +| 1 | 首页 | `home` | 标准 | 发现与工具分发 | +| 2 | 消息 | `message` | 标准 + 未读数字角标 | 触达与会话入口 | +| 3 | **问** | `ask` | **中央大圆突出 + AI 角标** | 唯一 Tab 级官方 AI | +| 4 | 在线 | `online` | 标准 + 运营角标「限免」(可配) | 达人 Marketplace | +| 5 | 我的 | `mine` | 标准;游客文案「未登录」 | 资产与商业 | + +规则: +- 禁止再增加第 6 个主 Tab。 +- 灵犀/小智/灵魂伴侣/心情小镇 **不得**升为底部 Tab;一律挂「问」或首页宫格。 +- 「问」内顶栏固定双轨:`测测AI` | `真人1v1`。 + +### 2.2 全局导航栈 + +```text +RootTabNavigator +├── HomeStack +├── MessageStack +├── AskStack ← 中央按钮切到此栈 +├── OnlineStack +└── MineStack + +Modal 层:Login · MembershipPaywall · ShareSheet · SystemPermission · ForceUpdate +``` + +--- + +## 3. 全局组件目录(前端必须实现) + +| 组件 | 职责 | 出现位置 | +|---|---|---| +| `AppTabBar` | 五 Tab;问中间凸起 | 全局 | +| `ProfileSwitcher` | 自己/TA 档案切换 | 问、星盘、合盘 | +| `BirthArchiveForm` | 生日/时/地/关系类型 | 建档、了解TA | +| `PaywallSheet` | 会员/单次解锁 | 报告、AI 额度 | +| `SharePosterSheet` | 结果卡分享 | 测评、合盘、星盘 | +| `NetworkBanner` | 离线/弱网 | 全局 | +| `EmptyState` | 空列表 CTA | 消息、订单、档案 | +| `ErrorRetry` | 失败重试 | 全局 | +| `StreamingBubble` | AI 流式回复 | 问 | +| `ExpertCard` | 达人列表项 | 在线 | +| `ChartWheel` | 圆形星盘 | 星盘/合盘 | +| `IndexScoreRow` | 恋爱/友情/婚姻指数 | 合盘 | +| `GridIcon` | 首页宫格 | 首页 | +| `FeedCard` | 信息流卡片 | 首页 | +| `PlazaAppCard` | 玩法广场项 | 广场 | +| `UnreadBadge` | 角标 | 消息 Tab | + +--- + +## 4. 全局状态机(App) + +```mermaid +stateDiagram-v2 + [*] --> ColdStart + ColdStart --> Bootstrap + Bootstrap --> GuestSession + Bootstrap --> AuthedSession + GuestSession --> AuthModal: gated action + AuthModal --> AuthedSession: success + AuthedSession --> Offline + Offline --> AuthedSession + AuthedSession --> ForceUpdate: version low + AuthedSession --> Paywall: vip gate + Paywall --> AuthedSession +``` + +全局枚举:`ColdStart` · `Bootstrap` · `GuestSession` · `AuthedSession` · `AuthModal` · `Offline` · `ForceUpdate` · `Paywall` · `FatalError`。 + +--- + +## 5. 设计令牌(结构约定 · 非抄视觉品牌) + +> 模拟器采集后可替换为实测色值;此处只定 **语义角色**。 + +| Token | 角色 | +|---|---| +| `--color-brand` | 主操作/问 Tab 强调 | +| `--color-ai` | AI 标识色 | +| `--color-vip` | 会员金色系 | +| `--color-danger` | 错误/删除 | +| `--color-surface` | 卡片底 | +| `--color-feed-bg` | 信息流底 | +| `--radius-card` | 12–16 | +| `--tab-ask-size` | 中央按钮直径 > 其它 Tab | + +动效:Tab 切换 200ms;AI 流式打字;宫格按下缩放 0.96。 + +--- + +## 6. 档案为中心的产品定律(D 定稿) + +1. 所有深度解读默认绑定 **当前 Profile**。 +2. 「问」必须支持切换到 **关系档案 TA**。 +3. 首页持续引导「了解 TA」建档。 +4. 合盘/匹配最少需要 2 份档案(可快速创建)。 +5. 无出生时:上升/宫位降级提示,不阻断太阳解读。 + +--- + +## 7. 商业门禁(前端表现) + +| 能力 | 游客 | 登录免费 | 会员/深购 | +|---|---|---|---| +| 浏览首页/部分测评 | ✓ | ✓ | ✓ | +| 完整测评报告 | teaser | teaser | ✓ | +| AI 对话 | 登录后有限次 | 有限次 | 扩容 | +| 合盘指数 | — | ✓ | ✓ | +| 合盘完整相位/推运文案 | — | teaser | ✓ | +| 达人咨询 | — | 可下单 | 可下单 | +| 玩法广场基础 | ✓ | ✓ | 视应用 | + +价格文案(展示用,采集后校验):首月 ¥9.9 · 月 ¥25 · 季 ¥58 · 年 ¥178。 + +--- + +## 8. 文案与合规(前端) + +- 危机内容:拒答模板 + 热线/转真人。 +- 免责:探索/娱乐向声明(具体措辞模拟器采集竞品后对齐,愈心谷落地另走 lexicon)。 +- 未成年人:系统账号年龄门 + 功能裁剪(D:咨询与付费加强校验)。 + +--- + +## 9. 待模拟器校验清单(圣经级) + +| ID | 校验项 | +|---|---| +| V-B01 | 五 Tab 文案与「问」视觉权重 | +| V-B02 | 全局弹层种类(登录/会员/分享) | +| V-B03 | 会员价与权益文案 | +| V-B04 | 主色/AI 色实测 | +| V-B05 | 游客可点范围 | diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/10-page-home.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/10-page-home.md new file mode 100644 index 0000000..2eb3f6f --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/10-page-home.md @@ -0,0 +1,199 @@ +# 10 · 首页完整设计 + +> Route: `/home` · Tab: 首页 · Design Complete + +--- + +## 1. 页面目标 + +1. 分发工具(宫格) +2. 推动建档(了解 TA) +3. 消费信息流 / 转化测评与会员 +4. 承接 UGC 玩法广场 + +--- + +## 2. UI 完整清单(分区) + +### 2.1 顶区 `HomeHeader`(✅ E8) + +| 元素 ID | 类型 | 文案/内容 | 行为 | +|---|---|---|---| +| H-HDR-CHECKIN | IconBtn | 签到 | → 签到/每日福利 | +| H-HDR-SEARCH | Search | 运营占位轮播(例「锐评十二星座」) | → 全局搜索 | +| H-HDR-PLUS | IconBtn | `+` | → 发布/快捷创建(待子页核对) | +| H-BANNER-TA | 条 | 「输入生日,更加了解TA」 | → 建档 other | + +### 2.2 自己卡片 `HomeSelfCard`(✅ E8 · 原「档案条」升格) + +| 元素 | 行为 | +|---|---| +| 标题「自己」+「更多」 | 更多 → 档案/运势详情 | +| 今日心情分数 + 短解读 | 点卡片进入心情/运势 | +| 五维条:爱情 / 财富 / 事业 / 学习 / 人际 | 展示型;点击进详情(待核) | + +### 2.3 宫格 `HomeToolGrid`(✅ E8 真机 10.50.0 定稿) + +横向两行可滑宫格(非 3×3): + +| 行 | 序 | key | 标题 | 备注 | +|---|---|---|---|---| +| 1 | 1 | mbti | I人E人 | | +| 1 | 2 | star_sign | 星座 | | +| 1 | 3 | natal | 星盘 | | +| 1 | 4 | bazi | 生辰 | | +| 1 | 5 | synastry | 缘分合盘 | | +| 1 | 6 | ziwei | 紫微 | 滑出可见 | +| 2 | 7 | companion_star | 陪伴小星 | | +| 2 | 8 | confide | 倾诉 | | +| 2 | 9 | wisdom_card | 智慧卡 | | +| 2 | 10 | natal_report | 星盘报告 | 角标 New | +| 2 | 11 | bazi_cal | 生辰历 | | +| 2 | 12 | soulmate | 灵魂伴侣 | 滑出可见 | + +宫格下方运营双卡(非第十格): + +| key | 标题 | 说明 | +|---|---|---| +| plaza | AI玩法广场 | 大卡 + 运营副文案 | +| shop_promo | 商城运营卡 | 例:水晶手串 | +| ai_disclaimer | 脚注 | 「部分标题由AI生成,仅供参考」 | + +「更多」入口:在「自己」卡片标题旁 / 工具二级页(见 `free2/tools/更多.png`);沙盘等未进首屏宫格的工具收入更多页。 + +### 2.3.1 「更多」页条目(✅ E8 `free2/tools/更多.labels.txt`) + +分组实机可见(不完全等于 UI 分区文案): + +| 类 | 条目 | +|---|---| +| 内容/玩法 | 推荐 · 测试 · 心情小镇 · 幸运地图 · 爱情树 · 聊天分析 · 关系网 · 解读 · 沙盘 · 星骰 · 智慧卡 · 日历 | +| 命理工具 | 星盘 · 星盘报告 · 缘分合盘 · 星座 · 生辰 · 生辰历 · 紫微 · 紫微历 · 星宿 · 政余 · 政余历 · 灵数 · 生肖 · 玛雅图腾 · 人类图 | +| 社交/商业 | 倾诉 · 灵魂伴侣 · 商城 | + +### 2.4 运营位 `HomePromo` + +- 最多 1 个大 Banner(可关闭,24h 不再展示,本地记录) +- 配置来自后台 `banners` + +### 2.5 信息流 `HomeFeed` + +| 卡片类型 | 字段 | 点击 | +|---|---|---| +| fortune_today | 标题、摘要、分数 | 运势详情 | +| hot_test | 封面、标题、人数 | 测评 | +| expert | 达人卡 | 达人详情 | +| article | 封面、标题 | 文章 | +| video | 封面 | 播放 | +| plaza_hot | 应用名、热度 | 广场应用 | +| ai_entry | 文案 | → 问 | + +交互:下拉刷新 · 上拉分页(cursor)· 负反馈「不感兴趣」(可选)。 + +### 2.6 「更多」二级页 `ToolsMorePage` + +固定收录(可后台排序):爱情树、幸运地图、pick卡、塔罗、八字、紫微、生命数字、姓名分析… +每项:Icon · 标题 · 副标题 · 角标(热/新)。 + +### 2.7 AI 玩法广场 `PlazaPage` + +| 元素 | 说明 | +|---|---| +| 标题区 | Slogan:「全民手搓创意——精选应用」 | +| 排序 | 热度 / 最新 | +| AppCard | 名、热度、创作者、封面 | +| 点击 | → `PlazaAppRuntime` | +| 空态 | 「暂无应用」 | +| 审核中 | 创作者端可见,C 端不可见 | + +--- + +## 3. 交互细则 + +| 手势 | 对象 | 结果 | +|---|---|---| +| Tap | 宫格 | 路由跳转,埋点 `home_grid_click` | +| Tap | 了解 TA | 打开建档;未登录先 Auth | +| LongPress | 宫格 | (D)无菜单,防误触 | +| Pull | Feed | 刷新 | +| Tap | Feed 负反馈 | 减少同类(需登录) | + +倾诉点击:弹出 ActionSheet「和 AI 聊」/「找达人」→ ask / online。 + +--- + +## 4. 状态机 + +| 状态 | UI | +|---|---| +| Loading | Header 骨架 + 宫格骨架 + Feed 骨架 | +| Ready | 全量 | +| FeedEmpty | 宫格在,Feed EmptyState | +| Offline | NetworkBanner,缓存宫格可点 | +| Error | 全页 Retry | +| AuthRequired | AuthModal | + +```mermaid +stateDiagram-v2 + [*] --> Loading + Loading --> Ready + Loading --> Error + Ready --> AuthRequired + Ready --> Offline + Offline --> Ready + Error --> Loading +``` + +--- + +## 5. 接口契约(本页) + +| 调用时机 | API | +|---|---| +| 进入 | `GET /v1/home/bootstrap` → grid_config · banners · profile_summary | +| Feed | `GET /v1/home/feed?cursor=` | +| 负反馈 | `POST /v1/home/feed/{id}/dismiss` | +| 广场 | `GET /v1/plaza/apps?sort=heat` | + +`home/bootstrap` Response(定稿): + +```json +{ + "profile_summary": {"id":"","name":"","sun_sign":"","has_birth_time":false}, + "grid": [{"key":"mbti","title":"I人E人","badge":null,"enabled":true}], + "banners": [{"id":"","image_url":"","link":"","closable":true}], + "ta_guide_visible": true +} +``` + +--- + +## 6. 异常 + +| 场景 | 处理 | +|---|---| +| bootstrap 失败 | 使用本地默认宫格(写死上表 10 项)+ Error toast | +| feed 失败 | 保留宫格,Feed 区 Retry | +| 配置缺宫格 | 合并默认,不丢星盘/合盘/问相关入口 | + +--- + +## 7. 埋点 + +`home_expose` · `home_grid_click{key}` · `home_ta_guide_click` · `home_feed_click{type}` · `home_feed_refresh` · `plaza_open` · `plaza_app_click` + +--- + +## 8. 模拟器校验 ID + +| ID | 项 | 状态 | +|---|---|---| +| V-H01 | 宫格文案与顺序 | ✅ E8 已按上表改稿 | +| V-H02 | 了解TA 文案 | ✅ 「输入生日,更加了解TA」 | +| V-H03 | 广场形态 | ✅ 首页双大卡而非 slogan 条 | +| V-H04 | Feed / 达人卡 | 🟡 采集中 `free/home/*` | +| V-H05 | 更多页条目 | 🟡 采集中 `free/tools/更多*` | +| V-H06 | 自己卡片五维 | ✅ 爱情财富事业学习人际 | +| V-H07 | 门禁弹层 | ✅ 未成年 / 每日领取 | + +差分总表:[evidence-20260803.md](evidence-20260803.md) diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/11-page-message.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/11-page-message.md new file mode 100644 index 0000000..be1ed29 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/11-page-message.md @@ -0,0 +1,117 @@ +# 11 · 消息完整设计 + +> Route: `/message` · Design Complete + +--- + +## 1. 目标 + +聚合 AI / 达人 / 社区 / 系统 / 商业通知;驱动回到「问」或咨询会话。 + +--- + +## 2. UI 清单 + +### 2.1 顶栏(✅ E8) + +| ID | 元素 | 行为 | +|---|---|---| +| M-TITLE | 「消息」 | — | +| M-SEARCH | 搜索图标 | 搜会话/通知 | +| M-CLEAR | 扫帚图标 | 清理/标记已读(无「全部已读」文案) | + +### 2.2 列表形态(✅ E8 · 无顶部分段) + +**无** `全部|AI|达人|互动|系统` Segment;为**分组会话列表**: + +| 行类型 | 示例 | 角标 | +|---|---|---| +| 系统通知 | 「检测到新的登录行为…」 | 未读数字 | +| AI 助手 | 「小星」+ AI 角标 | 未读数字 | +| (登录后)达人/互动 | 待更多账号补证 | — | + +底部可出系统通知引导条:「开启系统通知…」+「去开启」+ 关闭。 + +### 2.3 会话行 `MessageRow` + +| 字段 | 说明 | +|---|---| +| avatar | 系统铃铛 / 小星 IP / 达人头像 | +| title | 会话名 | +| preview | 最后一条摘要 | +| time | 相对时间(今天时刻 / 昨天) | +| badge | 未读数 | + +### 2.4 系统通知详情(✅ E8) + +路由级页「系统通知」:时间分隔 + 卡片列表(纯文本 / 链接「点击查看」/ 运营富媒体)。 + +### 2.5 空态 + +插画 + 引导回「问」(未在本版截到空态)。 + +--- + +## 3. 交互 + +| 操作 | 结果 | +|---|---| +| Tap 行 | AI→Ask 线程;达人→ConsultSession;互动→动态/私信;系统→详情 H5;商业→会员/订单 | +| LongPress | 置顶 / 免打扰 / 删除 | +| 左滑 | 删除(iOS 风格);Android 长按菜单 | +| 下拉 | 刷新 | + +--- + +## 4. 状态机 + +`Loading` · `Ready` · `Empty` · `Offline` · `Error` · `AuthRequired` + +未读角标:`GET /v1/messages/unread_count` 与 TabBar 同步;前台 30s 轮询或 WS 推送(D:优先 WS,降级轮询)。 + +--- + +## 5. API + +| API | 说明 | +|---|---| +| `GET /v1/messages?type=&cursor=` | 列表 | +| `POST /v1/messages/{id}/read` | 已读 | +| `POST /v1/messages/read_all` | 全读 | +| `DELETE /v1/messages/{id}` | 删会话 | +| `POST /v1/messages/{id}/mute` | 免打扰 | +| `GET /v1/messages/unread_count` | 角标 | + +Message DTO: + +```json +{ + "id": "uuid", + "type": "ai|expert|social|system|commerce", + "title": "", + "preview": "", + "avatar_url": "", + "unread": 0, + "muted": false, + "ref": {"kind":"conversation|consult|url","id":""}, + "updated_at": "ISO8601" +} +``` + +--- + +## 6. 异常 + +推送权限拒绝:仅站内;首次提示去设置。 +Token 失效:清列表进 Auth。 +删除失败:Toast 回滚 UI。 + +--- + +## 7. 埋点 + +`message_tab_expose` · `message_open{type}` · `message_read_all` · `message_delete` · `message_mute` + +## 8. 校验 ID + +`V-M01` 分类形态 · `V-M02` 行字段 · `V-M03` 长按菜单项 · `V-M04` 角标规则 diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/12-page-ask.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/12-page-ask.md new file mode 100644 index 0000000..4ea7ecd --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/12-page-ask.md @@ -0,0 +1,154 @@ +# 12 ·「问」完整设计(测测 AI + 真人 1v1) + +> Route: `/ask` · Design Complete · 战略核心页 + +--- + +## 1. 目标 + +1. 以星盘档案为底座的垂直 AI 对话 +2. 同页低摩擦切换真人咨询 +3. 对象切换(自己/TA)扩展关系场景 +4. 深度思考与子场景(灵魂伴侣等)承载差异化 + +--- + +## 2. UI 完整清单 + +### 2.1 顶栏 `AskTopBar`(✅ E8) + +| ID | 元素 | 行为 | +|---|---|---| +| A-BACK | 返回 | 回上一页/首页 | +| A-TAB-AI | 「测测AI」 | 默认选中 | +| A-TAB-HUMAN | 「真人1v1」 | 角标「新客半价」(运营可配) | +| A-MENU | 汉堡菜单 | 历史/设置;可带红点 | + +### 2.2 AI 轨首屏(✅ E8 · 非纯聊天空态) + +冷启动为**引导页 + 转化区**,而非空白线程: + +| 区块 | 内容 | +|---|---| +| 人设问候 | 猫头鹰 IP +「Hi~我是测测AI / 最近有什么事…」 | +| 引导问题 ×4 | 整行按钮(非小芯片) | +| 真人咨询导流 | 「也可以选择真人咨询…」+「228位测测达人在线」+「去提问」 | +| 达人数量卡 | 1/3/8/15 位;「新客6折」等角标 | +| 底栏快捷 | **工具** · **语音通话** · **深度解读** · **灵魂伴侣** | +| 输入框 | placeholder「让我来解答你的问题吧」 | +| 免责声明 | 「内容由AI生成,仅供娱乐…」 | + +**实测引导问题(✅ 替换原草稿)** + +1. 有哪些适合我的财富建议? +2. 这份工作有发展吗? +3. 我有什么性格弱点吗? +4. 我和Ta会复合吗? + +### 2.3 上下文/能力入口(✅ E8 落在底栏快捷) + +| ID | 元素 | 行为 | +|---|---|---| +| A-TOOLS | 「工具」 | Sheet:星盘等工具入口 | +| A-VOICE | 「语音通话」 | 语音;可红点 | +| A-DEEP | 「深度解读」 | 深度能力(可能付费) | +| A-SOUL | 「灵魂伴侣」 | 子场景 | + +对象切换/星盘维度:在工具 Sheet 或对话上下文中(待 `ask/tools-sheet` 截图补全)。 + +### 2.4 输入区 `AskComposer` + +| 元素 | 行为 | +|---|---| +| 文本框 | placeholder 见上 | +| 发送 | 输入后出现 | +| 语音 | 底栏「语音通话」独立入口 | + +### 2.5 真人轨 `AskHumanRail` + +顶栏切「真人1v1」(角标「新客半价」);同页亦嵌入 AI 轨中部的达人导流卡。 + +### 2.6 子页 + +- `/ask/soulmate` 灵魂伴侣 +- `/ask/history` 会话历史 +- Profile 创建嵌入式 Modal + +--- + +## 3. 交互与业务规则 + +| 规则 ID | 规则 | +|---|---| +| R-ASK-01 | 每条用户消息消耗 1 次额度;深度思考 ×2 | +| R-ASK-02 | 额度=0 → PaywallSheet(会员优先) | +| R-ASK-03 | 切换对象后下一条消息带新 `profile_ctx_id` | +| R-ASK-04 | 对象无档案 → 强制建档流,不可发送 | +| R-ASK-05 | 流式中再次发送:排队或禁用(D:禁用) | +| R-ASK-06 | 敏感意图 → 危机卡片,不计成功陪伴文案 | +| R-ASK-07 | 切「真人1v1」不清除 AI 历史 | +| R-ASK-08 | 下拉加载更早消息,page_size=20 | + +--- + +## 4. 状态机 + +```mermaid +stateDiagram-v2 + [*] --> Boot + Boot --> IdleFresh: 无历史 + Boot --> IdleHistory: 有历史 + IdleFresh --> Streaming: send/chip + IdleHistory --> Streaming + Streaming --> IdleHistory: done + Streaming --> StreamError: fail + StreamError --> Streaming: retry + IdleFresh --> NeedProfile: 选TA无档 + NeedProfile --> IdleFresh: 建档完成 + IdleHistory --> QuotaEmpty + QuotaEmpty --> Paywall + IdleHistory --> HumanRail: tab + HumanRail --> IdleHistory: tab AI +``` + +全量状态:`Boot` · `IdleFresh` · `IdleHistory` · `Streaming` · `StreamError` · `NeedProfile` · `QuotaEmpty` · `Paywall` · `HumanRail` · `Offline` · `AuthRequired` · `DeepOn`(正交 flag)。 + +--- + +## 5. API + +| API | 说明 | +|---|---| +| `GET /v1/ai/bootstrap` | 额度、默认 profile、引导问题、deep 是否可用 | +| `GET /v1/ai/conversations` | 列表 | +| `GET /v1/ai/conversations/{id}/messages?cursor=` | 历史 | +| `POST /v1/ai/chat` | body: `{conversation_id?, content, profile_ctx_id, dimension, deep}` → **SSE** | +| `POST /v1/ai/conversations` | 新建 | +| `GET /v1/ai/quota` | remaining | + +SSE 事件:`meta` · `delta` · `suggest` · `usage` · `error` · `done`。 + +--- + +## 6. 异常矩阵 + +| 场景 | UI | +|---|---| +| SSE 中断 | 气泡「已中断」+ 重试 | +| 402 额度 | Paywall | +| 400 无档案 | NeedProfile | +| 403 内容安全 | 固定拒答 | +| 超时 30s | StreamError | +| 离线 | 禁用发送 | + +--- + +## 7. 埋点 + +`ask_open` · `ask_send` · `ask_chip_click` · `ask_deep_toggle` · `ask_profile_switch` · `ask_dimension_change` · `ask_soulmate_open` · `ask_human_tab` · `ask_paywall_show` · `ask_stream_error` + +--- + +## 8. 校验 ID + +`V-A01` 顶栏双 Tab · `V-A02` 上下文三控件+灵魂伴侣 · `V-A03` 引导问题原文 · `V-A04` 下拉历史 Tip · `V-A05` 深度思考是否耗额度 · `V-A06` 流式表现 diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/13-page-online.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/13-page-online.md new file mode 100644 index 0000000..8999492 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/13-page-online.md @@ -0,0 +1,75 @@ +# 13 · 在线完整设计(达人 Marketplace) + +> Route: `/online` · Design Complete + +--- + +## 1. 目标 + +达人发现 → 下单 → 文字/语音/连麦服务 → 评价 → 复购。 + +--- + +## 2. UI 清单 + +### 2.1 列表页(✅ E8 部分 · 与「问·真人1v1」同构) + +底栏「在线」带运营角标 **「限免」**;新客链路易**直达「向TA提问」收银页**(券弹层「我知道了」),列表本体与问-真人轨共用筛选: + +| 区 | 元素 | +|---|---| +| 顶栏双轨残留 | 若从问切入可仍见「测测AI / 真人1v1」 | +| 筛选 | 综合排序 · 全部工具 · 筛选 · 新客半价 | +| ExpertCard | 头像、活跃、好评率、测友推荐、价格/次 | +| 限免漏斗 | 点进 → 向TA提问(文字/语音/合盘/星盘…)+ 测测币/卡券/VIP 折扣 | + +### 2.2 详情页 `/online/experts/{id}` + +简介 · 资质标 · 服务 SKU(文字/语音/连麦+单价+时长)· 评价列表 · 底部「立即咨询」· 收藏。 + +### 2.3 下单确认 Sheet + +SKU · 优惠券 · 协议勾选 · 应付金额 · 去支付。 + +### 2.4 会话页 `/consult/{orderId}` + +| 模式 | UI | +|---|---| +| 文字 | 聊天 + 剩余时长/条数 | +| 语音 | 语音条 | +| 连麦 | 通话 UI + 权限 | + +结束:评价 1–5 星 + 标签 + 文字。 + +--- + +## 3. 订单状态机(前端必须渲染) + +`Created` → `Paying` → `Paid` → `WaitingAccept` → `InService` → `Completed` → `Reviewed` +旁路:`Cancelled` · `Rejected` · `Refunding` · `Refunded` · `Expired` + +超时:Paid 后 N 分钟未接单自动退款(N 后台配,默认 15)。 + +--- + +## 4. API + +`GET /v1/experts` · `GET /v1/experts/{id}` · `POST /v1/consult/orders` · `POST /v1/consult/orders/{id}/pay` · `GET /v1/consult/orders/{id}` · `POST /v1/consult/orders/{id}/cancel` · `POST /v1/consult/orders/{id}/review` · 消息走 IM 通道(WS)。 + +--- + +## 5. 权限与安全 + +麦克风/相机权限拒绝 → 引导设置,降级文字。 +举报入口:会话内 · 详情页。 +未成年人:禁止连麦(D)。 + +--- + +## 6. 埋点 + +`online_expose` · `expert_click` · `consult_sku_select` · `consult_pay_success` · `consult_review_submit` + +## 7. 校验 ID + +`V-O01` 筛选维度 · `V-O02` SKU 种类 · `V-O03` 价格展示 · `V-O04` 评价表单 diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/14-page-mine.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/14-page-mine.md new file mode 100644 index 0000000..6e9ddfb --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/14-page-mine.md @@ -0,0 +1,90 @@ +# 14 · 我的完整设计 + +> Route: `/mine` · Design Complete + +--- + +## 1. UI 清单 + +### 1.1 头部 `MineHeader` + +头像(可换)· 昵称 · 会员徽章(非会员显示「开通会员」)· 编辑资料箭头。 + +### 1.2 资产条 `MineAssets` + +| 格 | 内容 | 点击 | +|---|---|---| +| 会员 | 到期日/未开通 | `/membership` | +| AI 次数 | remaining | `/ask` 或 Paywall | +| 优惠券 | 张数 | `/coupons` | +| 积分 | 余额(若无则隐藏整格 · 模拟器校验) | `/points` | + +### 1.3 功能列表(分组定稿) + +**档案与内容** + +1. 生命档案 → `/profile` +2. 我的测试 → `/mine/tests` +3. 我的报告 → `/mine/reports` +4. AI 对话记录 → `/ask/history` +5. 我的收藏 → `/mine/favorites` + +**订单与商业** + +6. 会员中心 → `/membership` +7. 我的订单 → `/mine/orders` +8. 咨询订单 → `/mine/consult-orders` +9. 商城订单 → `/shop/orders` + +**其它** + +10. 邀请好友 → 分享 +11. 消息通知设置 → `/settings/notifications` +12. 账号与安全 → `/settings/account` +13. 客服与帮助 → `/help` +14. 关于 → `/about`(协议/隐私/注销入口) + +--- + +## 2. 子页要点 + +| 页 | 关键交互 | +|---|---| +| 生命档案列表 | Self 置顶;Other 按更新;新建;滑动删除 | +| 报告库 | 类型筛选:星盘/合盘/测评/AI;锁态角标 | +| 订单 | 状态 Tab;详情;申请退款 | +| 会员中心 | 套餐卡:9.9/25/58/178;权益表;自动续费管理 | +| 注销 | 二次确认 + 冷却期说明 | + +--- + +## 3. 状态 + +`Guest`(显示登录大按钮)· `Loading` · `Ready` · `VipActive` · `VipExpired` · `Error` + +--- + +## 4. API + +`GET /v1/me` · `GET /v1/membership/me` · `GET /v1/ai/quota` · `GET /v1/orders` · `POST /v1/auth/logout` · `POST /v1/account/delete` + +`GET /v1/me`: + +```json +{ + "user": {"id":"","nickname":"","avatar_url":""}, + "membership": {"active":false,"plan":null,"expires_at":null}, + "quota": {"ai_remaining":0}, + "counts": {"coupons":0,"favorites":0,"reports":0} +} +``` + +--- + +## 5. 埋点 + +`mine_expose` · `mine_item_click{key}` · `membership_open` · `invite_click` + +## 6. 校验 ID + +`V-U01` 列表项集合 · `V-U02` 会员价展示 · `V-U03` 资产条格子 · `V-U04` 注销路径 diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/20-module-astrology.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/20-module-astrology.md new file mode 100644 index 0000000..2c884cd --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/20-module-astrology.md @@ -0,0 +1,126 @@ +# 20 · 星座工具完整设计(星盘 / 合盘 / 运势) + +> Design Complete · 愈心谷落地时命名走 STRATEGY / 词表 + +--- + +## 1. 信息架构 + +``` +工具入口(首页/广场) +├── 星盘(本命) +│ ├── 档案选择 / 新建 +│ ├── 概览(太阳/月亮/上升 + 象限) +│ ├── 行星列表 +│ ├── 宫位列表 +│ ├── 相位表 +│ ├── 深度解读(付费锁) +│ └── 分享卡片 +├── 合盘 +│ ├── 双档案选择 +│ ├── 关系类型(恋爱/友情/亲子…) +│ ├── 五图 + 推运(as_of) +│ ├── 维度评分 +│ ├── 深度报告(付费) +│ ├── 邀请合盘(token) +│ └── 附近的人(geo) +└── 今日运势 + ├── 日运卡片 + ├── 周/月切换 + └── 推送订阅 +``` + +--- + +## 2. 星盘页 `NatalChartPage` + +### 2.1 Route + +`/tools/natal` · query: `profile_id?` + +### 2.2 UI + +| 区块 | 说明 | +|---|---| +| `ProfilePicker` | Self 默认;可切 Other | +| `ChartWheel` | 外圈宫位 + 行星 glyph;可缩放旋转(定稿:双指缩放) | +| `BigThree` | 日/月/升 三卡 | +| `Tabs` | 概览 / 行星 / 宫位 / 相位 / 解读 | +| `PaywallBanner` | 深度解读锁态 | + +### 2.3 状态 + +`NeedProfile` → `Computing` → `Ready` → `Paywalled` / `Unlocked` · `Error` + +### 2.4 API + +- `GET /v1/profiles` · `POST /v1/profiles` +- `POST /v1/reports/natal` body: `{ "profile_id": "" }` +- 响应:`summary`(免费)+ `detail`(锁字段)+ `charts.natal` + +### 2.5 校验 + +`V-N01` 轮盘交互 · `V-N02` 深度锁点 · `V-N03` 分享卡片字段 + +--- + +## 3. 合盘页 `SynastryPage`(对齐已实现能力) + +### 3.1 Route + +`/tools/synastry` · `/synastry/invite/:token` + +### 3.2 UI 定稿 + +| 区块 | 说明 | +|---|---| +| 双人档案条 | A/B 头像昵称 + 换人 | +| 关系类型 Chip | 恋爱默认 | +| 五图 Tab | 比较盘/组合盘/组合中点/时空中点/组合星座 — 与 API `charts` 对齐 | +| 推运 | `as_of` 日期选择;Progressed 说明文案 | +| 维度雷达/条 | 吸引/沟通/信任/长期… | +| 深度报告 CTA | 会员或单次解锁 | +| 社交 | 「邀请合盘」「附近的人」 | + +### 3.3 附近的人 + +- 需用户显式打开 `geo_visible` +- 列表:距离档位 + 合盘入口 +- 隐私:不暴露精确坐标给对方客户端 + +### 3.4 API(已有契约优先) + +- `POST /v1/reports/synastry` `{ profile_id_a, profile_id_b, as_of? }` +- `GET /v1/synastry/nearby` +- `POST /v1/synastry/invites` · `GET /v1/synastry/invites/:token` · `POST .../accept` + +### 3.5 校验 + +`V-S01` 五图 Tab 文案与顺序 · `V-S02` 邀请链路 · `V-S03` 附近隐私开关默认 + +--- + +## 4. 今日运势 `DailyFortunePage` + +### 4.1 UI + +日期头 · 综合分 · 爱情/事业/财运/健康四格 · 短建议 ·「查看完整」(会员) + +### 4.2 API + +`GET /v1/fortune/daily?profile_id=&date=` + +### 4.3 校验 + +`V-F01` 分数字段 · `V-F02` 完整内容锁点 + +--- + +## 5. 埋点统一 + +`tool_open{tool}` · `chart_tab{name}` · `synastry_generate` · `synastry_unlock` · `fortune_expose` · `invite_share` · `nearby_open` + +## 6. 愈心谷落地备注 + +- UI 禁用「占卜/算命」;可用运势/合盘/匹配/解读 +- 星历计算依赖与 AGPL 风险见 API README;设计层不绑定具体库 diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/21-module-tests-immersive.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/21-module-tests-immersive.md new file mode 100644 index 0000000..4fefd61 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/21-module-tests-immersive.md @@ -0,0 +1,119 @@ +# 21 · 测评与沉浸式玩法完整设计 + +> Design Complete · MBTI / 广场 / 沙盘 / 心情小镇 / 塔罗 + +--- + +## 1. 测评中心 `TestsHub` + +### 1.1 Route + +`/tests` · `/tests/:slug` + +### 1.2 UI + +- 分类 Chip:人格 / 情感 / 压力 / 趣味 +- 卡片:封面 · 标题 · 题量 · 预计时长 · 热度 · 免费/付费角标 +- 搜索(可选,二期) + +### 1.3 答题流 `TestPlayer` + +| 步骤 | 说明 | +|---|---| +| Intro | 说明用途、隐私、预计时长、开始 | +| Question | 单选为主;进度条;可上一题 | +| Submit | Loading → 结果 | +| Result | 类型码/分数 + 摘要 + 分享 +「深度报告」锁 | + +状态:`Intro` · `Answering` · `Submitting` · `ResultFree` · `ResultPaywalled` · `Error` + +API: + +- `GET /v1/tests` · `GET /v1/tests/:slug` +- `POST /v1/tests/:slug/sessions` → `session_id` +- `POST /v1/tests/sessions/:id/answer` +- `POST /v1/tests/sessions/:id/submit` +- `GET /v1/tests/sessions/:id/report` + +校验:`V-T01` 进度可回退 · `V-T02` 结果锁点 · `V-T03` 历史入库「我的测试」 + +--- + +## 2. 心理广场 `PsychPlaza` + +Route: `/plaza` + +| 区块 | 设计定稿 | +|---|---| +| Feed | 图文/话题卡;点赞评论 | +| Topic | 官方话题置顶 | +| Publish | 发帖(需登录);敏感词过滤 | +| Detail | 评论树一级 + 楼中楼 | + +API:`GET /v1/plaza/feed` · `POST /v1/plaza/posts` · `POST .../like` + +注意:UGC 审核队列进运营后台(见 40)。 + +校验:`V-P01` Feed 排序 · `V-P02` 发帖门槛 + +--- + +## 3. 沙盘 `Sandbox` + +Route: `/sandbox` + +定稿流程: + +1. 空场景 + 道具架(人物/物品/自然) +2. 拖拽摆放;缩放旋转 +3. 完成 → 生成解读摘要(AI 或模板) +4. 保存作品到「我的」;可分享静态图 + +状态:`Editing` · `Generating` · `Saved` · `Paywalled`(深度解读) + +API:`POST /v1/sandbox/sessions` · `POST .../complete` · `GET .../report` + +校验:`V-B01` 道具集 · `V-B02` 解读是否 AI + +--- + +## 4. 心情小镇 `MoodTown` + +Route: `/mood-town` + +定稿: + +- 地图节点:今日心情打卡 · 小任务 · 收集物 +- 打卡:情绪滑杆 + 可选日记 +- 连续打卡奖励(积分/优惠券,与会员解耦) + +API:`POST /v1/mood/checkin` · `GET /v1/mood/calendar` · `GET /v1/mood/town-state` + +校验:`V-M01` 节点集合 · `V-M02` 奖励规则 + +--- + +## 5. 塔罗 `Tarot` + +Route: `/tarot` + +定稿(合规表述:牌阵洞察 / 自我反思,非「算命」): + +| 模式 | 说明 | +|---|---| +| 单牌 | 日签 | +| 三牌 | 过去/现在/未来(表述可改为 课题/现状/行动) | +| 牌阵库 | 恋爱/决策等模板 | + +流程:选牌阵 → 洗牌动画 → 抽牌 → 正逆位 → 牌义摘要 → 深度(付费) + +API:`POST /v1/tarot/draw` `{ spread, seed? }` · `GET /v1/tarot/history` + +校验:`V-R01` 牌阵列表 · `V-R02` 深度锁 · `V-R03` 文案是否触发词表禁词(实现闸) + +--- + +## 6. 统一商业与埋点 + +- 深度结果统一走 `PaywallSheet`(会员优先 / 单次次之) +- 埋点:`test_start` · `test_complete` · `plaza_post` · `sandbox_complete` · `mood_checkin` · `tarot_draw` · `immersive_unlock` diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/30-data-api.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/30-data-api.md new file mode 100644 index 0000000..9141ec2 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/30-data-api.md @@ -0,0 +1,179 @@ +# 30 · 完整数据模型 · API · 错误 · 缓存 + +> Design Complete · 路径前缀统一 `/api/v1`(下文简称 `/v1`) + +--- + +## 1. 领域实体(逻辑模型) + +``` +User ──< Profile (self|other) +User ── Membership +User ── AiQuota +User ──< Order ── OrderItem +User ──< Coupon +User ── DevicePush +User ──< Conversation ──< Message +User ──< ConsultOrder ── Consultant +User ──< TestSession ── TestReport +User ──< NatalReport / SynastryReport +User ──< TarotDraw / SandboxWork / MoodCheckin +User ── GeoPref (geo_visible, lat, lng, updated_at) +SynastryInvite (token, host_user, guest?, status) +PlazaPost ──< Comment +``` + +### 1.1 关键字段定稿 + +| 实体 | 必填字段 | +|---|---| +| Profile | id, user_id, kind, name, gender?, birth_date, birth_time?, birth_place, lat, lng, tz | +| Membership | user_id, plan(enum), status, starts_at, expires_at, auto_renew | +| Order | id, user_id, channel, amount_fen, status, product_type, product_ref | +| Conversation | id, user_id, mode(ai\|human), status, consultant_id? | +| SynastryReport | id, profile_a, profile_b, as_of?, summary_json, detail_json, unlocked | +| AiQuota | user_id, remaining, reset_at, source | + +计划枚举:`trial_9_9` · `month_25` · `season_58` · `year_178`(展示价,分单位存储另表) + +--- + +## 2. API 目录(完整设计表) + +### 2.1 账号 + +| Method | Path | 说明 | +|---|---|---| +| POST | `/v1/auth/sms/send` | 发码 | +| POST | `/v1/auth/sms/login` | 登录/注册 | +| POST | `/v1/auth/logout` | 登出 | +| GET | `/v1/me` | 我的聚合 | +| PATCH | `/v1/me` | 改昵称头像 | +| POST | `/v1/account/delete` | 注销申请 | + +### 2.2 档案 + +| Method | Path | 说明 | +|---|---|---| +| GET/POST | `/v1/profiles` | 列表/创建 | +| PATCH/DELETE | `/v1/profiles/:id` | 更新/删 | +| PATCH | `/v1/me/geo` | `{visible,lat?,lng?}` | + +### 2.3 内容与报告 + +| Method | Path | 说明 | +|---|---|---| +| POST | `/v1/reports/natal` | 本命 | +| POST | `/v1/reports/synastry` | 合盘 | +| GET | `/v1/fortune/daily` | 日运 | +| GET | `/v1/reports` | 我的报告库 | +| GET | `/v1/reports/:id` | 单份(含锁态) | + +### 2.4 合盘社交 + +| Method | Path | 说明 | +|---|---|---| +| POST | `/v1/synastry/invites` | 创建邀请 | +| GET | `/v1/synastry/invites/:token` | 预览(无 host_profile_id 泄露) | +| POST | `/v1/synastry/invites/:token/accept` | 接受 | +| GET | `/v1/synastry/nearby` | 附近 | + +### 2.5 AI / 咨询 + +| Method | Path | 说明 | +|---|---|---| +| GET | `/v1/ai/quota` | 余量 | +| POST | `/v1/ask/conversations` | 开聊 | +| GET | `/v1/ask/conversations` | 历史 | +| POST | `/v1/ask/conversations/:id/messages` | 发消息(流式可选 SSE) | +| GET | `/v1/consultants` | 达人列表 | +| POST | `/v1/consult/orders` | 下咨询单 | +| POST | `/v1/consult/orders/:id/cancel` | 取消 | + +### 2.6 测评与沉浸 + +| Method | Path | 说明 | +|---|---|---| +| GET | `/v1/tests` | 列表 | +| * | `/v1/tests/...` | 见 21 | +| * | `/v1/plaza/...` | 见 21 | +| * | `/v1/sandbox/...` | 见 21 | +| * | `/v1/mood/...` | 见 21 | +| * | `/v1/tarot/...` | 见 21 | + +### 2.7 商业 + +| Method | Path | 说明 | +|---|---|---| +| GET | `/v1/membership/plans` | 套餐 | +| POST | `/v1/pay/orders` | 创建支付单 | +| POST | `/v1/pay/mock/confirm` | 仅开发 mock | +| GET | `/v1/orders` | 订单列表 | +| GET | `/v1/coupons` | 优惠券 | +| POST | `/v1/pay/orders/:id/refund` | 申请退款 | + +### 2.8 消息与配置 + +| Method | Path | 说明 | +|---|---|---| +| GET | `/v1/inbox` | 站内信聚合 | +| POST | `/v1/inbox/:id/read` | 已读 | +| GET | `/v1/home/feed` | 首页聚合 | +| GET | `/v1/config/app` | 开关/词表版本/审核模式 | + +--- + +## 3. 统一响应与错误 + +### 3.1 成功包络 + +```json +{ + "code": 0, + "message": "ok", + "data": {}, + "request_id": "..." +} +``` + +### 3.2 错误码定稿 + +| code | HTTP | 含义 | 客户端 | +|---|---|---|---| +| 0 | 200 | 成功 | — | +| 40101 | 401 | 未登录 | 跳登录 | +| 40301 | 403 | 无权限/未解锁 | Paywall | +| 40302 | 403 | 会员权益不足 | Membership | +| 40401 | 404 | 资源不存在 | Toast | +| 40901 | 409 | 冲突(重复提交) | 忽略/提示 | +| 42201 | 422 | 参数错误 | 字段红字 | +| 42901 | 429 | 限流 | 稍后重试 | +| 40201 | 402/403 | 配额用尽 | 次数 Paywall | +| 50000 | 500 | 内部错误 | 通用错误页 | + +锁态内容:**不**靠藏路由;返回 `unlocked:false` + `teaser`,完整 `detail` 仅解锁后下发(或同接口按 entitlement 裁剪)。 + +--- + +## 4. 缓存与一致性 + +| 数据 | 策略 | +|---|---| +| 首页 feed | CDN/边缘 60s;登录个性化部分 API 直出 | +| 日运 | 按 `profile_id+date` 缓存 1h | +| 本命/合盘计算结果 | 按档案哈希 + as_of 缓存;档案变更失效 | +| 我的聚合 `/me` | 客户端短缓存 30s;支付成功强制失效 | +| Inbox 未读 | 推送 + 轮询 60s(前台) | +| 词表/开关 | `/config/app` ETag;启动拉取 | + +离线:仅缓存已解锁报告摘要;敏感档案不同步到未加密存储。 + +--- + +## 5. 分页约定 + +`?cursor=&limit=20` · 响应 `{ items, next_cursor }` + +## 6. 与模拟器校验的关系 + +抓包对照本章路径与字段;差异记入 `99-emulator-validation.md` 的 Diff 表,再回写 OpenAPI。 diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/40-ops-commerce.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/40-ops-commerce.md new file mode 100644 index 0000000..cd8fb4e --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/40-ops-commerce.md @@ -0,0 +1,109 @@ +# 40 · 运营 · 商业 · 推送 · 埋点 · 安全 + +> Design Complete + +--- + +## 1. 运营后台模块(信息架构) + +``` +Admin +├── 内容:首页运营位 / 工具配置 / 测评上下架 / 话题 +├── UGC:广场审核队列 / 举报处理 +├── 达人:入驻审核 / 排班 / 分成配置 +├── 商业:套餐 / 优惠券 / 订单 / 退款 +├── 用户:检索 / 封禁 / 注销工单 +├── 消息:站内信模板 / 推送任务 +└── 系统:开关 / 词表版本 / 审计日志 +``` + +权限:超级管理员 · 运营 · 审核 · 客服(只读订单+工单) + +--- + +## 2. 支付与会员 + +### 2.1 商品类型 + +| product_type | 说明 | 履约 | +|---|---|---| +| `membership` | 会员套餐 | 写 Membership + 可能赠 AI 次 | +| `ai_pack` | AI 次包 | AiQuota += n | +| `report_unlock` | 单次报告 | report.unlocked=true | +| `consult` | 咨询时长/次 | ConsultOrder paid | +| `shop` | 实物/虚拟商品 | 履约状态机 | + +### 2.2 支付状态机 + +`created` → `paying` → `paid` → (`refunding` → `refunded`) · `closed` + +客户端:统一 `PaySheet`(渠道:微信/支付宝/IAP 占位;开发 `pay-mock`)。 + +### 2.3 价格展示(✅ E8 `free2/mine/会员.png`) + +| 档 | 展示价 | 划线/备注 | +|---|---|---| +| 连续包月(首月) | ¥9.9 | 后续 ¥25 自动续费 | +| 连续包月 | ¥25 | | +| 连续包季 | ¥58 | 划线例 ¥90 | +| 连续包年 | ¥178 | 划线例 ¥360 | +| 12个月套餐 | ¥368 | 年卡x水晶等运营 SKU | + +自动续费:会员中心「记录」可关;文案含解除说明。 + +--- + +## 3. 推送 + +| 场景 | 通道 | 触发 | +|---|---|---| +| 日运提醒 | Push | 用户订阅 + 本地时区早晨 | +| 咨询回复 | Push + Inbox | 达人新消息 | +| 系统通知 | Inbox | 运营任务 | +| 支付成功 | Inbox | 履约完成 | +| 合盘邀请被接受 | Push | AcceptInvite | + +开关:`/settings/notifications` 分项;系统级权限引导一次。 + +--- + +## 4. 埋点字典(核心) + +| event | 必填属性 | +|---|---| +| `app_open` | cold/warm, app_ver | +| `tab_view` | tab | +| `tool_open` | tool | +| `paywall_expose` | scene, product_hint | +| `pay_click` | product_type, amount_fen | +| `pay_success` | order_id, product_type | +| `ask_send` | mode, conversation_id | +| `synastry_generate` | relation_type | +| `test_complete` | slug | +| `membership_open` | from | + +实现:客户端 SDK 批量上报;`request_id` 可关联 API 日志。 + +--- + +## 5. 安全与合规 + +| 项 | 定稿 | +|---|---| +| 词表 | 硬禁「占卜」「算命」等;启动拉 `/config/app` | +| 未成年 | 支付与深度内容年龄门(策略开关) | +| 隐私 | 协议版本强更;geo 默认关;注销冷却 | +| 鉴权 | Bearer;刷新策略另开;后台操作审计 | +| 限流 | 登录/发帖/AI 分桶 | +| 支付 | 验签;幂等 `out_trade_no`;mock 仅非生产 | + +--- + +## 6. 客服与工单 + +入口:我的 → 帮助;订单详情 → 申请退款。 +工单状态:`open` · `processing` · `resolved` · `rejected`。 + +## 7. 校验 ID + +`V-O01` 套餐价 · `V-O02` 支付渠道集合 · `V-O03` 推送分项 · `V-O04` 审核后台字段(无 App 内证,后台自建) diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/99-emulator-validation.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/99-emulator-validation.md new file mode 100644 index 0000000..5390b69 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/99-emulator-validation.md @@ -0,0 +1,131 @@ +# 99 · 安卓模拟器采集验证协议 + +> 设计已定稿;本文件规定**如何用模拟器把 🟡/待证项变成 ✅ 或 Diff 回写**。 + +--- + +## 1. 目标 + +1. 对照 `complete-design` 各页的 **校验 ID(V-\*)** 采集证据。 +2. 差异写入 Diff 表 → 回写对应设计文档 / OpenAPI。 +3. 不阻塞当前 Feature Complete:未验证项保持「设计定稿 (D) + 待模拟器」。 + +--- + +## 2. 环境准备 + +| 项 | 建议 | +|---|---| +| 模拟器 | Android Emulator API 33+,安装官方测测 APK(或应用商店包) | +| 代理 | mitmproxy / Charles;模拟器 Wi‑Fi 代理指向宿主机 | +| HTTPS | 安装 user CA;若 SSL Pinning 失败则改用 UI 录屏 + 布局层次(uiautomator) | +| 账号 | 新号 + 会员号各一;记录手机号不入库文档 | +| 录制 | 每条 V-\* 至少:截图 1 +(可选)HAR 片段 + 操作步骤 | + +### 2.1 本机已装(2026-08) + +| 项 | 路径 / 名称 | +|---|---| +| SDK | `ANDROID_HOME=/opt/homebrew/share/android-commandlinetools` | +| AVD | `YuXinGu_API34`(Pixel 7 / API 34 / google_apis / arm64) | +| Agent Skill | `/Users/jack/tools/android-adb-skill` → `./tools/android` + `~/.claude/skills/android-*` | +| Cursor Rule | `.cursor/rules/android-adb.mdc` | +| 冒烟截图 | `.tmp/cece-validation/smoke/screen.png` | + +启动与截图: + +```bash +emulator -avd YuXinGu_API34 -no-audio -gpu auto & +adb wait-for-device +./tools/android screenshot --out .tmp/cece-validation/smoke/screen.png --json +``` + +> 测测 APK 仍需另行安装后,才能跑 Batch A–D。 + +证据目录建议(本地,勿强求进 git): + +``` +.tmp/cece-validation/ + YYYYMMDD/ + V-H01/ + steps.md + screen.png + traffic.har # 可选 +``` + +--- + +## 3. 采集步骤(单条 V-\*) + +1. 打开设计文档中的校验 ID 定义(预期 UI/文案/API)。 +2. 冷启动 App → 按步骤复现到目标屏。 +3. 截图;若可抓包,过滤 host,保存相关 request/response。 +4. 填写结果:`PASS` / `DIFF` / `BLOCKED`(如 pinning、地区限制)。 +5. `DIFF` 必须写:期望(设计)vs 实际 + 建议改设计或改实现。 + +--- + +## 4. 优先级批次 + +### Batch A — 五 Tab 骨架(先做) + +| ID | 主题 | 文档 | +|---|---|---| +| V-H01~ | 首页楼层顺序与运营位 | `10-page-home` | +| V-G01~ | 消息分类与未读 | `11-page-message` | +| V-A01~ | 问一问 AI/人工切换 | `12-page-ask` | +| V-L01~ | 连麦/在线列表 | `13-page-online` | +| V-U01~ | 我的列表与资产条 | `14-page-mine` | + +### Batch B — 星座与合盘 + +| ID | 主题 | 文档 | +|---|---|---| +| V-N01~ | 本命轮盘与锁点 | `20-module-astrology` | +| V-S01~ | 五图顺序、邀请、附近默认 | 同上 | +| V-F01~ | 日运字段 | 同上 | + +### Batch C — 测评与沉浸 + +| ID | 主题 | 文档 | +|---|---|---| +| V-T01~ | 测评进度与结果锁 | `21-module-tests-immersive` | +| V-P01~ | 广场 | 同上 | +| V-B01~ / V-M01~ / V-R01~ | 沙盘/小镇/塔罗 | 同上 | + +### Batch D — 商业与系统 + +| ID | 主题 | 文档 | +|---|---|---| +| V-O01~ | 会员价与支付 | `40-ops-commerce` | +| 路径对照 | API 与 30 章一致性 | `30-data-api` | + +--- + +## 5. Diff 登记表(模板) + +复制到当次验证笔记: + +| ID | 结果 | 期望(设计) | 实际(模拟器) | 处置 | 回写文件 | +|---|---|---|---|---|---| +| V-S01 | DIFF | 五图顺序 A-B-C… | 顺序为… | 改设计 / 改实现 | `20-module-astrology.md` | + +处置约定: + +- **改设计**:以竞品真机为准,更新 complete-design,证据链加 E\*。 +- **改实现**:愈心谷已按设计开发则排期对齐。 +- **忽略**:明确合规/品牌差异(词表),在文档标「刻意差异」。 + +--- + +## 6. 与 Evidence First 的衔接 + +- 模拟器截图/HAR → 记入 `cece-frontend-re/README.md` 证据表新行(E8+)。 +- 原 🟡 项验证通过后改为 ✅,去掉「仅设计定稿」标注。 +- 完整设计包门禁:**不**因未跑模拟器而撤销 Design Complete。 + +--- + +## 7. 愈心谷侧注意 + +验证测测时允许看到其原生产物用词;**落地愈心谷 UI 时仍走词表**,Diff 中单独列「合规替换」列,避免把禁词写进产品文案。 diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/README.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/README.md new file mode 100644 index 0000000..b2526e9 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/README.md @@ -0,0 +1,64 @@ +# 测测前端 · 完整产品设计包(Design Complete V1.0) + +> Status: **Design Complete(设计定稿)** · 实现与模拟器校验前的唯一前端设计输入 +> 上游逆向底稿:[../README.md](../README.md) +> 方法:[reverse-engineering-spec.md](../../../../design/reverse-engineering-spec.md) +> 校验计划:安卓模拟器采集验证(见 [99-emulator-validation.md](99-emulator-validation.md)) + +--- + +## 0. 本包定位 + +| 项 | 说明 | +|---|---| +| 是什么 | 基于公开实测 + 逆向推导的 **Feature Complete 设计说明书** | +| 不是什么 | 不是 Demo;不是「以后再补」的大纲 | +| 证据策略 | ✅ 已证实写死;🟡/🔵 在本包中升格为 **设计定稿(D)**,并列入模拟器校验清单 | +| 品牌 | 本文用「测测」指称竞品对象;愈心谷落地时过 lexicon,禁止自称测测 | + +**冻结规则**:未经验证通过前,工程实现以本包为准;模拟器发现差分时,先改本包再改代码。 + +--- + +## 1. 文档地图 + +| 文档 | 内容 | +|---|---| +| [00-design-bible.md](00-design-bible.md) | 产品目标 · IA · 全局组件 · 全局状态 · 设计令牌约定 | +| [10-page-home.md](10-page-home.md) | 首页完整设计 | +| [11-page-message.md](11-page-message.md) | 消息完整设计 | +| [12-page-ask.md](12-page-ask.md) | 「问」完整设计 | +| [13-page-online.md](13-page-online.md) | 在线完整设计 | +| [14-page-mine.md](14-page-mine.md) | 我的完整设计 | +| [20-module-astrology.md](20-module-astrology.md) | 星座/星盘/合盘/运势完整设计 | +| [21-module-tests-immersive.md](21-module-tests-immersive.md) | 测评/广场/沙盘/心情小镇/塔罗 | +| [30-data-api.md](30-data-api.md) | 数据模型 · 表 · API · 错误码 · 缓存 | +| [40-ops-commerce.md](40-ops-commerce.md) | 后台 · 权限 · 支付 · 消息 · 埋点 · 配置 · 安全 | +| [99-emulator-validation.md](99-emulator-validation.md) | 安卓模拟器采集与差分回填协议 | +| [evidence-20260803.md](evidence-20260803.md) | **E8 真机 Diff**(10.50.0 模拟器) | +| [STATUS-overnight.md](STATUS-overnight.md) | 夜间采集结果摘要(起床看) | + +--- + +## 2. 设计完成度门禁 + +| 门禁 | 标准 | +|---|---| +| Design Complete | 本包各文档无「略/TODO」;每页含 UI 清单、交互、状态机、接口、异常、埋点 | +| Validation Ready | `99-emulator-validation.md` 用例可执行 | +| Impl Ready | `30-data-api` + 页面契约可直接开工 | +| Parity Verified | 模拟器采集后差分关闭(后续) | + +当前:**Design Complete + Validation Ready**。Parity Verified = 待模拟器。 + +--- + +## 3. 与逆向底稿关系 + +```text +../00–08 逆向分析(Evidence First) + ↓ 升格定稿 +complete-design/* 完整设计(本包) + ↓ 模拟器采集 +差分回填 → 修订本包 → 再实现/再验 +``` diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/STATUS-overnight.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/STATUS-overnight.md new file mode 100644 index 0000000..6d4ead4 --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/STATUS-overnight.md @@ -0,0 +1,41 @@ +# 夜间采集状态(给你起床看) + +> 时间:2026-08-03 凌晨 · APK 10.50.0 · 已登录账号 + +## 结论 + +**免付费主路径大部分已截到**,设计 Diff 已写入 `evidence-20260803.md` + 首页/消息/问文档。 +工具宫格与「更多」清单已采;「我的」子页与部分在线列表曾被 SVIP/向TA提问 收银页干扰,需醒来后补一轮。 + +## 截图目录(本地) + +| 目录 | 内容 | +|---|---| +| `.tmp/cece-validation/20260803/free2/` | **主成果**(包名校验后) | +| `free2/01-home.png` 等 `0x-tab-*.png` | 五 Tab / 问双轨 | +| `free2/tools/*.png` | 宫格落地:星座/星盘/合盘/紫微/陪伴小星/倾诉/智慧卡/报告/生辰历/灵魂伴侣/广场/更多… | +| `free2/tools/more/*.png` | 更多页子项 | +| `free2/manifest.json` | 清单(脚本结束后生成) | + +## 已回写设计 + +- `00-design-bible.md` 底栏角标 +- `10-page-home.md` 宫格顺序 / 自己卡片五维 +- `11-page-message.md` 列表形态 +- `12-page-ask.md` AI 引导题与底栏快捷 +- `evidence-20260803.md` E8 + +## 「更多」实机条目(✅ labels) + +推荐 · 星盘报告 · 测试 · 倾诉 · 心情小镇 · 幸运地图 · 星宿 · 爱情树 · 灵魂伴侣 · 聊天分析 · 关系网 · 商城 · 解读 · 沙盘 · 缘分合盘 · 星座 · 生辰 · 紫微 · 政余 · 灵数 · 生肖 · 玛雅图腾 · 人类图 · 星盘 · 生辰历 · 紫微历 · 政余历 · 星骰 · 智慧卡 · 日历 + +## 醒来建议(可选 10 分钟) + +1. 确认模拟器还在:`adb devices` +2. 打开测测,关掉任何 SVIP/向TA提问弹层 +3. 说「继续补我的和在线列表」——我只补这两块 + +## 脚本 + +- `tools/cece_capture_free.py`(初版) +- 夜间实际用的是内联 package-guard 流程;稳定版可再收束进脚本 diff --git a/.ai/product/feature-spec/cece-frontend-re/complete-design/evidence-20260803.md b/.ai/product/feature-spec/cece-frontend-re/complete-design/evidence-20260803.md new file mode 100644 index 0000000..c8c4ecc --- /dev/null +++ b/.ai/product/feature-spec/cece-frontend-re/complete-design/evidence-20260803.md @@ -0,0 +1,92 @@ +# 真机证据 Diff · 2026-08-03(模拟器 · 测测 10.50.0) + +> Evidence ID: **E8** · AVD `YuXinGu_API34` · package `com.xxwolo.cc5` · APK `bird-xxdoc-android-release-xxwolo-10.50.0` +> 截图根目录:`.tmp/cece-validation/20260803/`(本地,未入库) +> 主成果:`free2/`(34 张)· 说明:[STATUS-overnight.md](STATUS-overnight.md) + +--- + +## 1. 冷启动 / 门禁 + +| 屏 | 证据 | 定稿结论 | +|---|---|---| +| 温馨提示 | `onboarding/10-start.png` | 同意 / 不同意→游客;披露位置·麦克风·设备信息 | +| 游客首页 | `V-H01/home.png` | 底栏右端为「未登录」;中间「问」+ AI 角标 | +| 登录页 | 点「同意」后 | 手机号+验证码 / 密码登录 / 其他方式;协议勾选 | +| 未成年人模式 | `logged/00-current.png` | 开启未成年人模式 / **不再提醒** | +| 每日登录领好礼 | 遮罩态截图 | 回归福利七日;立即领取;下方 X 关闭 | +| 隐私政策更新 | 登录后偶发 | 同意 / 不同意并退出 | + +--- + +## 2. 底栏五 Tab(已登录 · ✅) + +| 序 | 文案 | 角标/形态 | Diff vs 原设计 | +|---|---|---|---| +| 1 | 首页 | 选中态房子图标 | 一致 | +| 2 | 消息 | 红点数字 | 一致 | +| 3 | **问** | **中央大圆 + AI 角标** | 一致;无障碍树常无「问」文本 | +| 4 | 在线 | 红标「限免」 | **新增运营角标**;易直达向TA提问 | +| 5 | 我的 | 红点 | 游客态文案为「未登录」 | + +--- + +## 3. 首页结构(已登录 · ✅ V-H01) + +### 3.1 顶区 + +签到 · 搜索运营占位轮播 · `+` · 「输入生日,更加了解TA」 + +### 3.2 自己卡片 + +今日心情分 + 五维:**爱情 / 财富 / 事业 / 学习 / 人际** + +### 3.3 宫格 + +**第一行:** I人E人 · 星座 · 星盘 · 生辰 · 缘分合盘 · 紫微 +**第二行:** 陪伴小星 · 倾诉 · 智慧卡 · 星盘报告(New) · 生辰历 · 灵魂伴侣 + +宫格下:AI玩法广场大卡 + 商城运营卡;脚注「部分标题由AI生成,仅供参考」。 + +### 3.4 更多页条目(✅) + +见 `10-page-home.md` §2.3.1;含沙盘/心情小镇/幸运地图/爱情树/人类图等。 + +--- + +## 4. 截图清单(`free2/`) + +| 路径 | 说明 | +|---|---| +| `01-home` · `02-tab-home` | 首页 | +| `03-tab-message` | 消息 | +| `04-tab-ask*` | 问 · AI / 真人1v1 | +| `05-tab-online` | 在线(注意限免漏斗) | +| `06-tab-mine*` | 我的 | +| `tools/*` | 宫格落地免付费页 | +| `tools/more/*` | 更多子项 | +| `mine/{root,会员,档案,测试,报告}` | 我的子页;会员价 ✅ | +| `manifest.json` | 34 张索引 | + +--- + +## 5. 「问」轨 Diff(✅) + +- 顶栏:`测测AI` | `真人1v1`(「新客半价」) +- 引导题:财富建议 / 工作发展 / 性格弱点 / 复合(及变体) +- 底栏:工具 · 语音通话 · 深度解读 · 灵魂伴侣 +- 免责声明固定底 + +--- + +## 6. 会员价(✅) + +连续包月首月 ¥9.9 → ¥25;季 ¥58;年 ¥178(见 `40-ops-commerce.md`)。 + +--- + +## 7. 愈心谷落地注意 + +- 竞品文案含「测测」;落地过 lexicon。 +- 游客 / 未成年人 / 每日领取 / 隐私更新 为必做门禁。 +- 「向TA提问」为高意图收银,列表浏览需可退回。 diff --git a/.ai/product/feature-spec/companion.md b/.ai/product/feature-spec/companion.md new file mode 100644 index 0000000..00d154a --- /dev/null +++ b/.ai/product/feature-spec/companion.md @@ -0,0 +1,147 @@ +# Feature Spec: 陪伴(节气壳) + +> Status: `Active`(壳) · Map: `§4 陪伴 [P2 主做;P1 可占位]` · Phase: `P1 Shell / P2` +> 规范:[../feature-design.md](../feature-design.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 陪伴 | +| Purpose | 日常留存:节气生活建议与心情记录 | +| Business Goal | Tab 占位完整;P2 做深留存 | + +| In(P1 壳) | Out(P1) | +|---|---| +| 今日节气展示(本地/轻 API) | 完整心情 OS | +| 生活建议(非运势) | 疗效承诺 | +| 路由与 Tab 可达 | 强制日记社交 | + +| In(P2) | | +|---|---| +| 心情记录落库、成长记录 | | + +--- + +## 2. 用户价值 + +1. **为何需要:** 需要轻量每日触达,不只是一次性测试。 +2. **P1 获得:** 今日节气与建议壳。 +3. **付费:** N/A(P1)。 + +--- + +## 3. 用户角色 + +| Actor | P1 | +|---|---| +| Visitor | 可打开陪伴页看节气壳 | + +--- + +## 4. 用户流程 + +```text +Tab 陪伴 → /companion + ↓ +展示今日节气 + 生活建议文案 + ↓ +(P2)记录心情 → POST /moods +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/companion` | CompanionPage | + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 拉节气 | +| Normal | 节气 + 建议 | +| Error | 降级本地文案或重试 | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 文案为「生活建议 / 节气」,禁止「今日运势」 | +| R2 | P1 不宣称完整陪伴 OS 已交付 | +| R3 | 心情 API 若暴露须鉴权;P1 可不接 UI | +| R4 | 扩行为前必须把本 Spec Phase 与 In Scope 升为 P2 并补验收 | + +--- + +## 8. 数据模型影响 + +| 表 | P1 | P2 | +|---|---|---| +| solar term | 只读 API/本地 | | +| `moods` | 可有 API | UI 写入 | + +--- + +## 9. API 需求 + +| Method | Path | 意图 | +|---|---|---| +| GET | `/api/v1/solar-terms/today` | 今日节气 | +| POST | `/api/v1/moods` | P2 心情 | + +--- + +## 10. 权限设计 + +| 能力 | Visitor | +|---|---| +| 看节气壳 | ✓ | +| 写心情 | P2 | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `companion_viewed` | 打开 | +| `mood_saved` | P2 | + +--- + +## 12. 测试验收标准 + +**Given** P1 +**When** 打开陪伴 +**Then** 页面可达且无运势禁用词;不因未做心情而崩溃 + +**Given** 要将心情做进 P1 +**When** — +**Then** **禁止**在未改 Spec/map 分期前实现 + +--- + +## 13. AI 开发前检查 + +- [x] 壳范围已标明 · 禁止超 scope + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `CompanionPage` · `internal/companion` · moods 表 | +| 实现 | GET `/solar-terms/today` · POST `/moods` · GET `/moods/today` · H5 心情 UI | +| Gaps | 成长计划、推送、心情趋势 | +| Label | 陪伴 P2 心情已接;真支付仍后置 | +| Process Review | 2026-08-02 [P1-PROCESS-REVIEW](P1-PROCESS-REVIEW.md) · 心情已按产品「其他先做」落地 | diff --git a/.ai/product/feature-spec/explore-test.md b/.ai/product/feature-spec/explore-test.md new file mode 100644 index 0000000..3a57cee --- /dev/null +++ b/.ai/product/feature-spec/explore-test.md @@ -0,0 +1,192 @@ +# Feature Spec: 探索测试 + +> Status: `Active` · Map: `2.2 人格测评 [P1]` · Phase: `P1` +> 规范:[../feature-design.md](../feature-design.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 探索测试 | +| Purpose | 通过短量表帮助用户了解沟通方式、情感模式等偏好 | +| Business Goal | 丰富探索中心;结果导流关系理解与问答;可分享 | + +| In | Out | +|---|---| +| 已发布量表列表、答题、计分、丰富结果 | 伪 MBTI 版权侵权内容 | +| ≥1 热门测试(P1:沟通方式 + 情感模式) | 医疗量表诊断 | +| 分享结果卡 | 未定义的「深度付费层」若未实现则标 Gap | + +--- + +## 2. 用户价值 + +1. **为何需要:** 比生日画像更场景化的自我标签。 +2. **完成后获得:** 风格标签、概述、维度、建议、话术、成长计划。 +3. **付费:** P1 结果全量返回(免费);深度会员导流为软转化(非强制锁结果)。`Gap:` 若产品后续要对部分结果上锁,须先更新本 Spec。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| Visitor | 浏览列表;提交需 Self Profile | +| 无档案 | 可看题,提交前引导建档 | + +--- + +## 4. 用户流程 + +```text +/explore 测试列表 + ↓ +进入 /scales/:slug + ↓ +加载题目 + ↓ +逐题作答(须全部完成) + ↓ +无 Self? → 引导建档 + ↓ +POST result → 展示丰富结果 + ↓ +分享 / 去关系理解 / 去问答 +``` + +### 子能力展开 + +| 子能力 | P1 | +|---|---| +| 测试入口(探索 Tab) | ✓ | +| 测试列表 | ✓ | +| 问题流程 | ✓ | +| 答题保存(服务端结果落库) | ✓ | +| 中断恢复 | ✓ localStorage 草稿(`scaleDraft`);提交后清除 | +| 结果生成(丰富结构) | ✓ | +| 免费层 | ✓ 全量 result | +| 深度层 | N/A(当前不锁) | +| 分享 | ✓ ShareSheet | +| AI 解释 | 软链 `/ask` | +| 数据统计 | 埋点名定义;SDK `Gap` | +| 验收标准 | §12 | + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/explore` | ExplorePage | +| `/scales/:slug` | ScalePage | + +```text +/scales/:slug +├── Loading(题目) +├── Normal(答题中) +├── Empty/NeedProfile(提交前无档案) +├── Error +└── Result(完成后) +``` + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 加载题目 | +| Error | 重试 | +| Normal | 进度 + 单选题 | +| NeedProfile | 引导首页 | +| Result | ReportRich 结构展示 | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 仅 `published` 量表可列表/作答 | +| R2 | 提交前答案覆盖全部题目 | +| R3 | 必须绑定用户 Self `profile_id` | +| R4 | 计分:多数选项 → style;结果文案 lexicon 安全 | +| R5 | result 含 label/summary/overview/dimensions/tips/scripts/growth_plan/faq | +| R6 | 结果「不是固定标签」需在文案中提示 | +| R7 | 作答过程写入本地草稿(按 slug);提交成功后清除;刷新可恢复未提交答案 | + +--- + +## 8. 数据模型影响 + +| 表 | 备注 | +|---|---| +| `scales` / `scale_questions` | seed | +| `scale_results` | answers + result jsonb | + +--- + +## 9. API 需求 + +| Method | Path | 意图 | +|---|---|---| +| GET | `/api/v1/scales` | 列表 | +| GET | `/api/v1/scales/{slug}` | 题目 | +| POST | `/api/v1/scales/{slug}/result` | 提交计分 | + +--- + +## 10. 权限设计 + +| 能力 | Visitor 无档案 | Visitor 有 Self | +|---|---|---| +| 看列表/题目 | ✓ | ✓ | +| 提交结果 | ✗ | ✓ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `scale_list_viewed` | 探索列表 | +| `scale_started` | 进入作答 | +| `scale_completed` | 出结果 | +| `scale_share_clicked` | 分享 | +| `scale_cta_relation` / `scale_cta_ask` | 导流 | + +--- + +## 12. 测试验收标准 + +**Given** published 量表与 Self +**When** 答完提交 +**Then** 返回 label 与 overview/tips 等丰富字段 + +**Given** 未答完 +**When** 提交 +**Then** 前端拦截;不成功落库 + +**Given** 无 Self +**When** 提交 +**Then** 引导建档 + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 · 子能力已展开 · map · lexicon · OpenAPI · 可测 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `internal/scale` · `service/scale` · `ExplorePage` · `ScalePage` | +| Seeds | communication-style · emotion-pattern | +| Gaps | 结果付费墙若要做须先改 Spec;全量 §11 埋点未挂(核心漏斗见 analytics) | +| Tests | score/result L1;`scaleDraft.spec`;Scale 流程组件测 | +| Process Review | 草稿恢复已实现 · P1 收口 | diff --git a/.ai/product/feature-spec/home.md b/.ai/product/feature-spec/home.md new file mode 100644 index 0000000..68bd445 --- /dev/null +++ b/.ai/product/feature-spec/home.md @@ -0,0 +1,151 @@ +# Feature Spec: 首页(发现与入口) + +> Status: `Active` · Map: `§1 首页 [P1]` · Phase: `P1` +> 规范:[../feature-design.md](../feature-design.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 首页 | +| Purpose | 品牌露出 + 建档/性格探索主 CTA + 功能入口分发 | +| Business Goal | 新用户进入 Step 1–3;品牌第一屏可识别 | + +| In | Out | +|---|---| +| 建档生日表单 → 画像;九宫格/入口 | 运势 Feed 主路径 | +| 推荐区静态/轻运营即可 | Dashboard 式状态墙(如「API 已连接」勿对用户展示) | + +--- + +## 2. 用户价值 + +1. **为何需要:** 知道从哪开始认识自己。 +2. **完成后获得:** 一键进入画像主路径或其它 Tab。 +3. **付费:** N/A。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| 新 Visitor | 见品牌 + 建档 CTA | +| 老用户 | 同样入口;可去「我的」看资产 | + +--- + +## 4. 用户流程 + +```text +打开 / + ↓ +看到品牌 + 主 CTA(生日/探索) + ↓ +提交生日 → 创建档案 → /portrait + 或 +点入口 → /explore /relation /ask /membership … +``` + +设计约束(前端):品牌英雄级信号;移动优先;文案 lexicon。 + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/` | HomePage | +| 全局 | AppHeader · TabBar | + +```text +/ +├── Hero / 品牌 +├── 建档 CTA +├── 功能入口 +└── (可选)推荐内容 +``` + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Normal | 主路径可见 | +| Error | 建档/跳转失败可感知 | +| Loading | 提交建档时按钮态 | + +Empty:首页本身是入口,不因无档案空白死页。 + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 主 CTA 必须通向建档或画像,而非死链 | +| R2 | 不展示内部运维信息(API 连接状态等) | +| R3 | 入口文案符合 lexicon(个人画像/关系理解/成长会员…) | +| R4 | Tab 五名冻结:首页·探索·问答·陪伴·我的 | + +--- + +## 8. 数据模型影响 + +经首页建档时写 `profiles`(见 profile Spec)。 + +--- + +## 9. API 需求 + +复用 `POST /profiles`、`POST /reports/portrait`;无专用 home API。 + +--- + +## 10. 权限设计 + +| 能力 | Visitor | +|---|---| +| 浏览首页 | ✓ | +| 建档并生成画像 | ✓ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `home_viewed` | 打开 | +| `home_cta_portrait` | 主 CTA | +| `home_entry_click` | 九宫格项(带 name) | + +--- + +## 12. 测试验收标准 + +**Given** 新用户 +**When** 打开首页 +**Then** 可见品牌与建档/探索入口 + +**Given** 合法生日 +**When** 提交主 CTA +**Then** 进入画像基础结果路径 + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `HomePage` · `App.vue` shell | +| Gaps | 推荐 Feed 运营位可静态;埋点未接 | +| Process Review | 2026-08-02 [P1-PROCESS-REVIEW](P1-PROCESS-REVIEW.md) · 设计闭合 · 实现 PASS · 测试 PARTIAL | diff --git a/.ai/product/feature-spec/image-card.md b/.ai/product/feature-spec/image-card.md new file mode 100644 index 0000000..a4e0d32 --- /dev/null +++ b/.ai/product/feature-spec/image-card.md @@ -0,0 +1,185 @@ +# Feature Spec: 意象卡片 + +> Status: `Active`(设计)· Map: `2.8 意象卡片 [P2]` · Phase: `P2` +> 规范:[../feature-design.md](../feature-design.md) · 队列:[P2-BACKLOG.md](P2-BACKLOG.md) +> 竞品对照:塔罗体系(能力同构;**UI 禁止「塔罗」入口名**) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 意象卡片 | +| Purpose | 通过抽取 1~3 张意象卡,对当下议题做投射反思,并给出结构化反思题与行动建议 | +| Business Goal | 高趣味探索与分享;免费次数 + 深度解读转化 | + +| In | Out | +|---|---| +| 场景选题 · 抽卡 · 探索摘要 · 深度组合解读 | 吉凶判定、神谕、恐吓 | +| 卡面内容库(seed) | UI 文案「塔罗」「牌阵占卜」 | +| 每日免费次数规则 | 宿命叙事 | + +--- + +## 2. 用户价值 + +1. **为何需要:** 卡住时需要一个轻量的外在意象帮助整理感受。 +2. **完成后获得:** 卡片意象说明、反思问题、可执行小建议;深度版含组合解读与练习。 +3. **为何付费:** 多卡组合与完整练习路径。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| Visitor | 每日免费 N 次基础抽卡(N 实现时定数,建议 1~3) | +| DeepAccess(本结果)/ VIP | 深度组合解读 | +| 可选挂档案 | 有档案时文案可轻微个性化(非必须) | + +--- + +## 4. 用户流程 + +```text +/cards + ↓ +选择场景:情绪整理 / 关系 / 选择 / 自我 + ↓ +检查今日免费次数 + ├─ 耗尽 → 引导会员或明日再来(可看历史) + └─ 有余 → 抽 1 张(免费)或解锁 3 张组合(深度) + ↓ + 展示意象 + 反思题 + 建议 + ↓ + 分享(积极、非恐吓视觉) +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/cards` | ImageCardPage(P2 draft) | +| `/explore` | 入口「意象卡片」 | +| `/reports/:id` 或结果页内 | 深度结果沉淀(可选) | + +```text +/cards +├── Scene picker +├── Loading(抽卡动画,克制) +├── Normal(单卡摘要) +├── Locked(组合深度) +├── Quota empty +└── Error +``` + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 抽卡中 | +| Normal | 意象说明 + 反思 | +| Locked | 组合解读墙 | +| Quota empty | 次数用尽说明 + 会员 CTA | +| Error | 重试 | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 用户可见文案**不得**出现「塔罗」;用「意象卡片」「探索」「反思」 | +| R2 | **禁止**吉凶、神谕式断言、恐吓(如「大难」) | +| R3 | 输出必须含:意象描述 · 至少 1 个反思问题 · 1 条可执行建议 | +| R4 | 免费:每日有限次单卡摘要;深度:3 卡组合 + 练习(Membership 或单次 DeepAccess) | +| R5 | 抽卡随机可接受,但卡面文案来自审核过的内容库(非模型现场胡编吉凶) | +| R6 | 分享卡不得含恐吓图文;过 lexicon | +| R7 | 次数与权益 **Server** 判定 | + +--- + +## 8. 数据模型影响 + +| 项 | 说明 | +|---|---| +| 内容库 | `image_cards` 表或 seed JSON(id, title, image_key, explore_text, prompts[]) | +| 结果 | `growth_reports.type=image_card` **或** `scale_results` 变体(实现时二选一,写 OpenAPI) | +| 配额 | 用户日维度计数(表或 Redis);实现时定 | + +禁止:`tarot_*` 作用户可见 API 路径名(对内包名用 `imagecard`)。 + +--- + +## 9. API 需求(草案) + +| Method | Path | 意图 | +|---|---|---| +| GET | `/api/v1/image-cards/scenes` | 场景列表 | +| POST | `/api/v1/image-cards/draw` | scene → 抽卡结果(扣免费次) | +| GET | `/api/v1/image-cards/quota` | 今日剩余 | +| GET | `/api/v1/reports/{id}` | 若沉淀为报告 | + +深度组合可同 draw 参数 `depth=true` + 权益校验。 + +--- + +## 10. 权限设计 + +| 能力 | 免费额度内 | 额度外 | 深度权益 | +|---|---|---|---| +| 单卡摘要 | ✓ | ✗ | ✓ | +| 三卡组合解读 | ✗ | ✗ | ✓ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `cards_scene_selected` | 选场景 | +| `cards_drawn` | 抽卡成功 | +| `cards_quota_exhausted` | 次数用尽 | +| `deep_access_clicked` | surface=cards | +| `cards_share_clicked` | 分享 | + +--- + +## 12. 测试验收标准 + +**Given** 有免费次数 +**When** 抽卡 +**Then** 返回意象+反思题+建议;响应与 UI 无「塔罗」「吉凶」 + +**Given** 次数为 0 +**When** 再抽 +**Then** 业务错误,不生成假深度 + +**Given** 无深度权益 +**When** 请求组合解读 +**Then** Locked / 剥离 + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 · 禁用塔罗入口已写清 +- [x] lexicon ImageCard +- [ ] 内容库 seed 与 OpenAPI(编码时) + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| 复用 | ShareSheet · membership/orders · Explore 入口 | +| 工作量 | 卡面文案/视觉资产最大;宜先 22~78 张小库 MVP | +| Packages | `internal/imagecard` · `ImageCardPage` `/cards` | +| 实现 | scenes/quota/draw API · 报告 type=`image_card` · 日配额表 · seed 12 卡 | +| 编码顺序 | P2-BACKLOG 切片 3 Done | diff --git a/.ai/product/feature-spec/life-rhythm.md b/.ai/product/feature-spec/life-rhythm.md new file mode 100644 index 0000000..11a1f60 --- /dev/null +++ b/.ai/product/feature-spec/life-rhythm.md @@ -0,0 +1,176 @@ +# Feature Spec: 身心节律 + +> Status: `Active`(设计)· Map: `2.7 身心节律 [P2]`(= `2.5 身心探索` 产品化主入口)· Phase: `P2` +> 规范:[../feature-design.md](../feature-design.md) · 队列:[P2-BACKLOG.md](P2-BACKLOG.md) +> 竞品对照:命理体系之生活向(能力同构,禁止算命/流年吉凶叙事) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 身心节律 | +| Purpose | 基于出生信息给出五行/体质倾向隐喻与作息·饮食·运动等生活建议 | +| Business Goal | 差异化护城河(数字性格 × 身心);导流节气陪伴与会员 | + +| In | Out | +|---|---| +| 元素平衡、体质倾向、本周生活建议 | 流年吉凶、改命、疗效/治病承诺 | +| 与节气生活联动 | 八字/紫微作恐吓主标题 | +| GrowthReport 或 Constitution 聚合 | 医疗诊断 | + +**与 2.5:** 本 Spec 为身心探索的**唯一产品化主入口**;map 上 2.5 保留能力描述,入口文案统一「身心节律」。 + +--- + +## 2. 用户价值 + +1. **为何需要:** 想知道如何按自己的节奏安排生活,而不只是性格标签。 +2. **完成后获得:** 节律概览、元素倾向、今日/本周生活建议;深度版含分维习惯方案。 +3. **为何付费:** 完整习惯方案与长期节律计划。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| Visitor+档案 | 基础节律报告 | +| DeepAccess/VIP | detail | +| 无档案 | 引导建档 | + +--- + +## 4. 用户流程 + +```text +/rhythm 或探索入口 + ↓ +确认 Profile + ↓ +生成节律报告(元素 · 倾向 · 生活建议) + ↓ +基础 summary → 深度 detail(会员/深度版) + ↓ +CTA:节气陪伴 / 问答「生活节奏」/ 分享 +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/rhythm` | LifeRhythmPage(P2 draft) | +| `/companion` | 节气联动出口 | +| `/reports/:id` | type=rhythm 详情 | + +```text +/rhythm +├── Empty / Loading / Error +├── Normal(基础节律 + 生活建议) +└── Locked +``` + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 分析中 | +| Empty | 无档案引导 | +| Error | 重试 | +| Normal | 维度 + 本周建议 | +| Locked | 深度版 CTA | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 绑定 Profile;birth_date 必填 | +| R2 | 确定性引擎(可参考遗留 yangsheng 思路,**新代码进 monorepo**) | +| R3 | summary/detail 权益同画像(Membership ∨ DeepAccess) | +| R4 | **禁止**:疗效、改善疾病、流年吉凶、改命、算命 | +| R5 | 推荐用语:体质倾向、生活建议、节律、平衡 | +| R6 | 可展示五行隐喻,须标注探索/非医疗 | +| R7 | 与星象性格、个人画像内容可互相链,不互相覆盖主路径 | + +--- + +## 8. 数据模型影响 + +| 项 | 说明 | +|---|---| +| `growth_reports.type` | `rhythm` / `life_rhythm` | +| 或 Constitution 专用表 | 若需独立聚合,实现时 ADR | +| 禁止 | `luck_score`、病名诊断字段 | + +--- + +## 9. API 需求(草案) + +| Method | Path | 意图 | +|---|---|---| +| POST | `/api/v1/reports/rhythm` | profile_id → 报告 | +| GET | `/api/v1/reports/{id}` | 复用 | +| GET | `/api/v1/solar-terms/today` | 页内联动展示(已有) | + +--- + +## 10. 权限设计 + +| 能力 | 有档案 | 深度权益 | +|---|---|---| +| summary | ✓ | ✓ | +| detail | ✗ | ✓ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `rhythm_started` | 开始生成 | +| `rhythm_completed` | 基础结果 | +| `deep_access_clicked` | surface=rhythm | +| `rhythm_to_companion` | 去节气 | + +--- + +## 12. 测试验收标准 + +**Given** 有生日档案 +**When** 生成身心节律 +**Then** 含生活建议;无「吉凶」「疗效」「算命」 + +**Given** 无权益 +**When** GET detail +**Then** 剥离 + +**Given** 文案审查 +**When** 扫 summary+detail +**Then** 符合 lexicon + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 · 与 2.5 关系已写清 +- [x] lexicon LifeRhythm +- [ ] OpenAPI/erd(编码时) + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| 复用 | ReportRich · membership · companion 节气 · 立项中医体质方向 | +| 参考勿迁 | 根目录 `yangsheng.html` 仅思路参考 | +| Packages | `internal/rhythm` · `LifeRhythmPage` `/rhythm` | +| 实现 | `POST /reports/rhythm` · OpenAPI 已登记 | +| 编码顺序 | P2-BACKLOG 切片 2 Done | diff --git a/.ai/product/feature-spec/membership.md b/.ai/product/feature-spec/membership.md new file mode 100644 index 0000000..5f36124 --- /dev/null +++ b/.ai/product/feature-spec/membership.md @@ -0,0 +1,185 @@ +# Feature Spec: 深度版与成长会员 + +> Status: `Active` · Map: `§6 商业体系 · §5.3 会员中心 [P1]` · Phase: `P1` +> 规范:[../feature-design.md](../feature-design.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 深度版成长报告 + 成长会员 | +| Purpose | 用可理解的权益换取付费;解锁完整分析与更高 Ask 配额 | +| Business Goal | ARPU;验证付费墙与 mock 支付闭环 | + +| In | Out | +|---|---| +| 单报告 DeepAccess | 广告干扰主路径 | +| 订阅 Membership(月/季/年 mock) | 塔罗商城 | +| Server 侧权益生效 | 纯前端「解锁」 | + +--- + +## 2. 用户价值 + +1. **为何需要:** 基础结果不够用时需要完整分析 / 更多问答。 +2. **完成后获得:** 该报告 detail;或会员期内全部报告深度 + Ask 额度提升。 +3. **为何付费:** 明确权益清单(完整分析、AI 次数、专属内容占位)。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| Visitor | 可下单 mock 支付 | +| DeepAccess 持有者 | 指定 report 的 detail | +| VIP | 会员期内全局深度 + Ask 配额规则 | + +--- + +## 4. 用户流程 + +### 深度版(报告内) + +```text +报告页 Locked CTA + ↓ +POST /orders { kind: deep_access, report_id } + ↓ +POST /orders/{id}/pay-mock + ↓ +写入 deep_access → 刷新报告 detail 可见 +``` + +### 成长会员 + +```text +/membership 查看套餐与当前状态 + ↓ +下单 kind=membership + plan + ↓ +pay-mock → memberships 行 active + expires_at + ↓ +此后 GET 任意己方报告 detail 可见(按服务规则) +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/membership` | MembershipPage | +| 画像/关系/报告内 | Paywall / DeepAccess CTA | + +```text +/membership +├── Loading +├── Normal(未开通 / 已开通+到期) +├── Error +└── (无独立 Locked;本身即售卖页) +``` + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 拉取 `/membership/me` | +| Normal | 展示套餐、当前是否会员、到期时间 | +| Error | 重试 | +| 报告 Locked | 深度版入口文案(lexicon:深度版/成长会员,不用「解锁」作主文案) | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | DeepAccess 绑定 `user_id` + `report_id` | +| R2 | Membership active:`expires_at > now` | +| R3 | 报告 detail 可见 ⇔ Membership active **OR** DeepAccess 存在 | +| R4 | 支付 P1 仅 mock;成功必须服务端落库后再改可见性 | +| R5 | Ask 配额:免费次数耗尽后依赖会员配额(见 ask Spec) | +| R6 | 订单归属必须校验 report 属于当前用户 | +| R7 | 文案:成长会员 / 深度版;禁用恐吓与疗效承诺 | + +--- + +## 8. 数据模型影响 + +| 表 | 备注 | +|---|---| +| `orders` | kind, amount, status, report_id null | +| `payments` | mock | +| `deep_access` | user_id, report_id, unique | +| `memberships` | plan, expires_at | + +--- + +## 9. API 需求 + +| Method | Path | 意图 | +|---|---|---| +| GET | `/api/v1/membership/me` | 当前会员状态 | +| POST | `/api/v1/orders` | deep_access \| membership | +| POST | `/api/v1/orders/{id}/pay-mock` | 生效权益 | + +--- + +## 10. 权限设计 + +| 能力 | Visitor | DeepAccess | VIP | +|---|---|---|---| +| 看基础报告 | ✓ | ✓ | ✓ | +| 看该报告 detail | ✗ | ✓ | ✓ | +| 看任意己方 detail | ✗ | 仅已购报告 | ✓ | +| 下单 mock | ✓ | ✓ | ✓ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `paywall_viewed` | 看到锁定区 | +| `deep_access_clicked` | 点深度版 | +| `membership_plan_clicked` | 选套餐 | +| `order_created` | 下单 | +| `purchase_completed` | pay-mock 成功 | + +--- + +## 12. 测试验收标准 + +**Given** 无权益报告 +**When** pay-mock deep_access +**Then** 再 GET 同报告 detail 非空 + +**Given** 会员未过期 +**When** GET 任意己方 portrait/relation 报告 +**Then** detail 可见无需每份 DeepAccess + +**Given** 他人 report_id +**When** 尝试下单 +**Then** 失败(归属校验) + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 · map · lexicon · OpenAPI · 可测 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `service/order` · `service/membership` · `MembershipPage` | +| Gaps | 真支付网关;发票;退款(须先修订本 Spec §9) | +| Tests | L2 membership unlock;MembershipPage.spec | +| Process Review | 2026-08-02 [P1-PROCESS-REVIEW](P1-PROCESS-REVIEW.md) · mock 闭环设计闭合 · 实现 PASS · 测试 PASS | diff --git a/.ai/product/feature-spec/portrait.md b/.ai/product/feature-spec/portrait.md new file mode 100644 index 0000000..950c1ac --- /dev/null +++ b/.ai/product/feature-spec/portrait.md @@ -0,0 +1,197 @@ +# Feature Spec: 个人画像 + +> Status: `Active` · Map: `2.1 / 2.3 个人画像 [P1]` · Phase: `P1` +> 规范:[../feature-design.md](../feature-design.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 个人画像(成长画像) | +| Purpose | 帮助用户了解自己的性格特点、沟通/关系/事业/情绪/生活节奏与成长方向 | +| Business Goal | 首次体验转化;驱动深度版购买与成长会员;为问答提供档案语境 | + +| In | Out | +|---|---| +| 基于生日确定性生成基础 + 深度内容 | 运势/吉凶/算命叙事 | +| 多维报告结构(性格·沟通·关系·事业·情绪·生活) | 星盘/塔罗主路径 | +| DeepAccess / 会员解锁 detail | 医疗诊断或疗效承诺 | + +--- + +## 2. 用户价值 + +1. **为何需要:** 完成建档后希望快速得到「我是什么样的人」的结构化总结。 +2. **完成后获得:** 免费基础画像(标题、概述、关键词、六维速览、生活建议);付费后完整分维分析、优势盲区、成长路径、对话示例、FAQ。 +3. **为何付费:** 深度内容可执行、可分享前更完整,降低「只有一句话标签」的空洞感。 + +--- + +## 3. 用户角色 + +| Actor | 说明 | 本功能能力 | +|---|---|---| +| Visitor | 设备身份自动签发 JWT | 可建 Self Profile、生成画像、看 summary | +| User | 同上(P1 以 Visitor 为主) | 同左 | +| DeepAccess | 该 `growth_report` 已购深度版 | 可见 `detail` | +| VIP | `memberships` active | 可见 `detail`(与 DeepAccess 或关系) | + +--- + +## 4. 用户流程 + +```text +首页填写生日 / 已有 Self 档案 + ↓ +POST 创建画像报告 + ↓ +展示基础结果(summary:overview、dimensions、keywords…) + ↓ +点击深度版? + ├─ 否 → 分享 / 去关系理解 / 去问答 + └─ 是 → 创建 order(deep_access) → pay-mock + ↓ + Server 写入 DeepAccess + ↓ + GET report → detail 完整展示 +``` + +无档案:引导首页/档案创建(Empty)。 + +--- + +## 5. 页面设计 + +| 路由 | 页面 | 说明 | +|---|---|---| +| `/portrait` | PortraitPage | query: `y,m,d` 或 `report_id` | +| `/reports/:id` | ReportPage | 同报告详情渲染 | + +```text +/portrait +├── Loading +├── Empty(缺生日/档案) +├── Error +├── Normal(基础结果 + 多维速览) +└── Locked(无权益时的深度墙) +``` + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 「正在生成…」 | +| Empty | 「请从首页填写生日」+ 链到 `/` | +| Error | 文案 + 重试 | +| Normal | 标题、one_liner、ReportRich 基础层 | +| Locked | 深度版说明 + 「查看深度版(模拟支付)」 | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 必须有归属用户的 Profile(通常 `relation=self`)才能生成 | +| R2 | `summary` 对报告所有者始终可返回;含多维 teaser,不含完整 depth 长文 | +| R3 | `detail` 仅当 Membership active **或** 该报告 DeepAccess 存在时返回 | +| R4 | 权益**仅 Server** 判断;客户端 `has_deep_access` 为只读投影 | +| R5 | 内容确定性:同生日同名 → 同 pattern;禁用词:运势/吉凶/算命/合盘等 | +| R6 | 深度内容须含分维 sections、strengths、blind_spots、growth_plan、scripts(结构要求) | +| R7 | 免责:自我探索与生活方式参考,非医疗/占卜 | + +--- + +## 8. 数据模型影响 + +| 表 | 变更 | 备注 | +|---|---|---| +| `profiles` | 读 | birth_date 必填 | +| `growth_reports` | 写 type=`portrait` | summary/detail jsonb | +| `deep_access` / `orders` | 解锁时 | 见 membership Spec | + +详见 [../../domain/erd.md](../../domain/erd.md)。 + +--- + +## 9. API 需求 + +| Method | Path | 意图 | +|---|---|---| +| POST | `/api/v1/reports/portrait` | body: `profile_id` → 创建/返回报告 | +| GET | `/api/v1/reports/{id}` | 按权益剥离 detail | +| POST | `/api/v1/orders` | `kind=deep_access`, `report_id` | +| POST | `/api/v1/orders/{id}/pay-mock` | mock 支付生效 | + +OpenAPI:`proto/openapi.yaml`。 + +--- + +## 10. 权限设计 + +| 能力 | Visitor | DeepAccess | VIP | +|---|---|---|---| +| 生成画像 | ✓(己方档案) | ✓ | ✓ | +| 看 summary | ✓ | ✓ | ✓ | +| 看 detail | ✗ | ✓ | ✓ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `portrait_started` | 进入生成 | +| `portrait_completed` | 基础结果展示 | +| `deep_access_clicked` | 点深度版 CTA | +| `purchase_completed` | pay-mock 成功且 detail 可见 | +| `portrait_share_clicked` | 打开分享 | + +--- + +## 12. 测试验收标准 + +**Given** 用户已填生日 +**When** 进入画像并生成 +**Then** 显示 headline、overview/多维速览、生活建议;无 detail 长文墙内完整 sections + +**Given** 无会员且无 DeepAccess +**When** 点击深度版并 mock 支付 +**Then** `has_deep_access=true` 且分维分析/成长路径可见 + +**Given** 篡改前端假装已解锁 +**When** GET report +**Then** 无权益时 detail 仍不可得 + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 +- [x] feature-map 已挂树 +- [x] lexicon 已核对 +- [x] OpenAPI 已有 portrait/reports +- [x] §12 可测 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `internal/portrait` · `service/report` · `PortraitPage` · `ReportRich` | +| Migrations | `growth_reports` 等已有 | +| Gaps | 埋点 SDK 未接(事件名已定义);真实支付 P 后 | +| Tests | `portrait` L1;集成 deep_access;H5 PortraitPage.spec | +| Process Review | 2026-08-02 [P1-PROCESS-REVIEW](P1-PROCESS-REVIEW.md) · 设计闭合 · 实现 PASS · 测试 PASS | + +--- + +## 附录:报告结构 + +免费:title, style_label, headline, keywords, one_liner, overview, life_tip, dimensions[6], strengths_preview +深度:sections[6+], strengths, blind_spots, growth_plan, conversation_scripts, daily_suggestions, faq diff --git a/.ai/product/feature-spec/profile.md b/.ai/product/feature-spec/profile.md new file mode 100644 index 0000000..bceb723 --- /dev/null +++ b/.ai/product/feature-spec/profile.md @@ -0,0 +1,165 @@ +# Feature Spec: 个人档案 + +> Status: `Active` · Map: `1.1 创建个人档案 · 5.1 我的档案 [P1]` · Phase: `P1` +> 规范:[../feature-design.md](../feature-design.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 个人档案 | +| Purpose | 建立「我」与「TA」的结构化档案,作为画像、关系、问答的输入 | +| Business Goal | 漏斗 Step 1;提高后续功能完成率 | + +| In | Out | +|---|---| +| Self / Other;生日必填;编辑删除 | 完整户籍式资料收集 | +| 关系类型(伴侣/家人/朋友) | 强制出生时辰地点(可后置) | + +--- + +## 2. 用户价值 + +1. **为何需要:** 个性化分析需要稳定身份锚点。 +2. **完成后获得:** 可切换的 Self/Other 列表;后续功能可挂档案。 +3. **付费:** N/A(档案本身免费)。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| Visitor | CRUD 自己的 profiles | +| 他人档案 | 不可读写 | + +--- + +## 4. 用户流程 + +```text +首页快捷建档(生日) 或 /profile + ↓ +创建 Self(display_name + birth_date) + ↓ +可选:添加 TA(relation=other) + ↓ +编辑 / 删除 + ↓ +下游:画像 / 关系 / 问答选择档案 +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/` | 首页内嵌建档入口 | +| `/profile` | ProfilePage(列表/编辑/删除/加 TA) | +| `/mine` | 入口到档案 | + +```text +/profile +├── Loading +├── Empty(无档案 → 引导创建) +├── Normal(列表 + 编辑) +└── Error +``` + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 拉取列表 | +| Empty | CTA 创建「我」 | +| Error | 重试 | +| Normal | Self/Other 卡片;编辑表单;删除确认 | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | `birth_date` P1 必填 | +| R2 | `relation` = `self` \| `other`;other 建议 `relation_type` | +| R3 | 仅所有者可 PATCH/DELETE | +| R4 | 删除策略:任务未要求硬删 PII 时按实现(软删优先);级联影响报告见服务规则 | +| R5 | Ask / 画像 / 关系必须挂合法 profile_id | +| R6 | 文案用「个人档案」,不用命盘 | + +--- + +## 8. 数据模型影响 + +| 表 | 字段要点 | +|---|---| +| `profiles` | id, user_id, relation, display_name, birth_date, birth_time?, birth_place?, gender?, relation_type? | + +--- + +## 9. API 需求 + +| Method | Path | 意图 | +|---|---|---| +| GET | `/api/v1/profiles` | 列表 | +| POST | `/api/v1/profiles` | 创建 | +| PATCH | `/api/v1/profiles/{id}` | 更新 | +| DELETE | `/api/v1/profiles/{id}` | 删除 | + +--- + +## 10. 权限设计 + +| 能力 | Visitor(己方) | 他方 | +|---|---|---| +| 列表/创建/改/删 | ✓ | ✗ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `profile_create_started` | 开始创建 | +| `profile_created` | Self 或 Other 创建成功 | +| `profile_updated` | 保存编辑 | +| `profile_deleted` | 删除成功 | + +--- + +## 12. 测试验收标准 + +**Given** 无档案 +**When** 创建 Self 合法生日 +**Then** 列表出现;可被画像使用 + +**Given** 已有 Self +**When** 添加 TA +**Then** relation=other 且可用于关系理解 + +**Given** 他人 id +**When** PATCH +**Then** 失败 + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 · map · lexicon · OpenAPI · 可测 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `service/profile` · `ProfilePage` · 首页建档 | +| Gaps | birth_time/place UI 可后置;硬删 vs 软删统一;埋点未接 | +| Tests | Profile 更新/删除 API L2;ProfilePage 四态 | +| Process Review | 2026-08-02 [P1-PROCESS-REVIEW](P1-PROCESS-REVIEW.md) · 设计闭合 · 实现 PASS · 测试 PARTIAL | diff --git a/.ai/product/feature-spec/relation.md b/.ai/product/feature-spec/relation.md new file mode 100644 index 0000000..23428b5 --- /dev/null +++ b/.ai/product/feature-spec/relation.md @@ -0,0 +1,176 @@ +# Feature Spec: 关系理解 + +> Status: `Active` · Map: `2.4 关系理解 [P1 · 双引擎]` · Phase: `P1` +> 规范:[../feature-design.md](../feature-design.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 关系理解 | +| Purpose | 帮助用户了解自己与 TA 在沟通、节奏、亲密与冲突上的差异,并获得相处建议 | +| Business Goal | 双引擎增长(分享裂变);深度相处建议付费;不可弱化 | + +| In | Out | +|---|---| +| 双 Profile 对比 + 关系报告 | 「合盘 / 合婚 / 缘分」叙事 | +| 多维对比、互补、冲突修复建议 | 宿命/恐吓文案 | +| DeepAccess / 会员解锁完整建议 | 无档案空算 | + +--- + +## 2. 用户价值 + +1. **为何需要:** 想理解「为什么和 TA 总卡住」。 +2. **完成后获得:** 双方风格标签、差异一句话、维度对比;付费后沟通/冲突/亲密/共同成长与本周练习。 +3. **为何付费:** 完整相处说明书与话术,可立即用于对话。 + +--- + +## 3. 用户角色 + +| Actor | 本功能能力 | +|---|---| +| Visitor | 需已有 Self;可建 Other;生成 insight + 基础 summary | +| DeepAccess / VIP | 可见完整 detail | + +--- + +## 4. 用户流程 + +```text +进入 /relation + ↓ +无 Self 档案? → 引导首页创建画像/档案 + ↓ +填写 TA 称呼 + 生日 → 创建 Other Profile + ↓ +POST relation/insight + ↓ +展示双方风格 + 差异 + 维度对比(summary) + ↓ +深度建议? → order deep_access → pay-mock → 完整 sections + ↓ +分享卡(我的方式 vs TA) +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/relation` | RelationPage | +| `/reports/:id` | 关系类报告详情 | +| `/share` | 分享落地 | + +```text +/relation +├── Form(输入 TA) +├── Empty(无 Self) +├── Loading / Error +├── Normal(基础对比) +└── Locked(完整建议墙) +``` + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Empty | 无 Self → 链到首页 | +| Loading | 「生成中…」 | +| Error | 文案 + 可去建档 | +| Normal | me/other style、diff、dimension_compare | +| Locked | 深度版 CTA | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 必须引用两个 Profile(通常 self + other);禁止无档案生成 | +| R2 | summary 含对比与维度;detail 含 sections / scripts / weekly_practice | +| R3 | detail 权益同画像:Membership **或** 该报告 DeepAccess | +| R4 | Server 鉴权;禁用词:合盘、合婚、运势、吉凶 | +| R5 | 差异叙事为「说明书」非评分/宿命 | +| R6 | 增长价值不得弱于个人画像(分享路径必须存在) | + +--- + +## 8. 数据模型影响 + +| 表 | 备注 | +|---|---| +| `profiles` | other + relation_type | +| `relation_insights` | profile_a/b + report_id | +| `growth_reports` | type=`relation` | + +--- + +## 9. API 需求 + +| Method | Path | 意图 | +|---|---|---| +| POST | `/api/v1/profiles` | 创建 TA | +| POST | `/api/v1/relation/insight` | self_id + other_id → insight + report | +| GET | `/api/v1/reports/{id}` | 按权益返回 | +| POST | `/api/v1/orders` + pay-mock | 深度版 | + +--- + +## 10. 权限设计 + +| 能力 | Visitor | DeepAccess/VIP | +|---|---|---| +| 生成基础关系理解 | ✓ | ✓ | +| 完整相处建议 | ✗ | ✓ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `relation_started` | 点生成 | +| `relation_completed` | 基础结果展示 | +| `deep_access_clicked` | 深度 CTA | +| `relation_share_clicked` | 分享 | +| `purchase_completed` | 支付成功 | + +--- + +## 12. 测试验收标准 + +**Given** 已有 Self +**When** 填写 TA 并生成 +**Then** 显示双方风格与 diff_one_liner;无权益时无完整 sections + +**Given** 无 Self +**When** 点生成 +**Then** 提示去首页建档,不调用成功 insight + +**Given** 支付深度版 +**When** 刷新报告 +**Then** 沟通/冲突等建议可见 + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 · map 已挂 · lexicon · OpenAPI · 可测 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `internal/relation` · `service/relation` · `RelationPage` · `ReportRich` | +| Gaps | 埋点未接;真实支付后置 | +| Tests | relation engine L1;集成;RelationPage.spec | +| Process Review | 2026-08-02 [P1-PROCESS-REVIEW](P1-PROCESS-REVIEW.md) · 设计闭合 · 实现 PASS · 测试 PASS | diff --git a/.ai/product/feature-spec/reports.md b/.ai/product/feature-spec/reports.md new file mode 100644 index 0000000..9d49324 --- /dev/null +++ b/.ai/product/feature-spec/reports.md @@ -0,0 +1,144 @@ +# Feature Spec: 成长报告 + +> Status: `Active` · Map: `2.1.2 · 5.2 我的成长报告 [P1]` · Phase: `P1` +> 规范:[../feature-design.md](../feature-design.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 成长报告 | +| Purpose | 沉淀用户已生成的画像/关系等报告资产,支持回看与深度解锁 | +| Business Goal | 「我的」资产感;复访与付费 | + +| In | Out | +|---|---| +| 列表 + 详情;按权益展示 detail | 收藏夹完整产品(可后置) | +| portrait / relation 类型 | 第三方导出 PDF(Gap) | + +--- + +## 2. 用户价值 + +1. **为何需要:** 不想每次重新生成才能回看。 +2. **完成后获得:** 历史报告列表与详情。 +3. **付费:** 详情内深度版墙同 membership Spec。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| Visitor | 仅己方报告 | +| DeepAccess/VIP | 详情 detail | + +--- + +## 4. 用户流程 + +```text +/mine → 成长报告列表 /reports + ↓ +点一项 → /reports/:id + ↓ +展示 summary;无权益则 Locked → 支付 → detail +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/reports` | ReportsPage(入口也在 `/mine`) | +| `/reports/:id` | ReportPage | + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 列表/详情加载 | +| Empty | 无报告 → 引导画像/探索 | +| Error | 重试 | +| Normal | 列表项 / ReportRich | +| Locked | 深度版 CTA | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 仅返回当前用户报告 | +| R2 | summary 可读;detail 按 Membership/DeepAccess | +| R3 | type 区分 portrait / relation 等渲染 | +| R4 | 删除报告策略:未要求则可不做;若做须鉴权 | + +--- + +## 8. 数据模型影响 + +| 表 | 备注 | +|---|---| +| `growth_reports` | 主数据 | + +--- + +## 9. API 需求 + +| Method | Path | 意图 | +|---|---|---| +| GET | `/api/v1/reports` | 列表 | +| GET | `/api/v1/reports/{id}` | 详情(权益剥离) | + +--- + +## 10. 权限设计 + +| 能力 | 所有者 | 非所有者 | +|---|---|---| +| 列表/详情 | ✓ | ✗ | +| detail 字段 | 视权益 | ✗ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `reports_list_viewed` | 打开列表 | +| `report_opened` | 打开详情 | +| `deep_access_clicked` | 详情内 CTA | + +--- + +## 12. 测试验收标准 + +**Given** 用户有报告 +**When** GET 列表 +**Then** 仅己方项 + +**Given** 无权益 +**When** 打开详情 +**Then** summary 可见 detail 不可见 + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `handler/report` · `ReportsPage` · `ReportPage` · Mine 入口 | +| Gaps | 收藏;PDF | +| Process Review | 2026-08-02 [P1-PROCESS-REVIEW](P1-PROCESS-REVIEW.md) · 设计闭合 · page-tree 已含 `/reports` · 实现 PASS · 测试 PARTIAL | diff --git a/.ai/product/feature-spec/share.md b/.ai/product/feature-spec/share.md new file mode 100644 index 0000000..00c29ad --- /dev/null +++ b/.ai/product/feature-spec/share.md @@ -0,0 +1,138 @@ +# Feature Spec: 分享卡 + +> Status: `Active` · Map: journey 双引擎 · Phase: `P1` +> 规范:[../feature-design.md](../feature-design.md) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 分享卡 | +| Purpose | 将画像/关系/测试结果变成可传播卡片,拉新进入主漏斗 | +| Business Goal | 裂变;关系理解分享是传播主力 | + +| In | Out | +|---|---| +| 前端 ShareCard / ShareSheet;`/share` query 载荷落地 | 服务端短链中台(Gap) | +| lexicon 安全文案 | 运势惊吓文案 | + +--- + +## 2. 用户价值 + +1. **为何需要:** 想把「我和 TA 的差异」发给对方看。 +2. **完成后获得:** 可复制链接或卡片视觉(以实现为准)。 +3. **付费:** N/A;落地页引导完成建档/探索。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| 分享者 | 从结果页打开 ShareSheet | +| 访客打开 `/share` | 看摘要 + CTA 进 App 主路径 | + +--- + +## 4. 用户流程 + +```text +画像/关系/测试结果 → 生成分享卡 + ↓ +携带 query payload 打开 /share(或系统分享) + ↓ +落地展示关键词/差异 → CTA 去首页/探索 +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | +|---|---| +| `/share` | SharePage | +| 组件 | ShareCard · ShareSheet | + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Normal | 有效 payload 渲染卡片 | +| Empty/Error | payload 无效 → 引导首页 | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 分享文案必须过 lexicon(无运势/合盘/解码) | +| R2 | 不在分享卡暴露 detail 付费长文 | +| R3 | 关系分享强调「我的方式 vs TA」+ 查看关系理解 | +| R4 | P1 payload 可以是前端 query;勿把密钥放入 URL | + +--- + +## 8. 数据模型影响 + +无必须 schema;`Gap:` 服务端 share token 表未建。 + +--- + +## 9. API 需求 + +P1 无专用分享 API(前端载荷)。若加短链:须新 Spec 修订 + OpenAPI。 + +--- + +## 10. 权限设计 + +| 能力 | 任何人 | +|---|---| +| 打开落地页摘要 | ✓ | +| 查看原报告 detail | 否(须登录所有者权益) | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `share_sheet_opened` | 打开面板 | +| `share_link_copied` | 复制 | +| `share_landing_viewed` | `/share` | +| `share_landing_cta` | 点 CTA | + +--- + +## 12. 测试验收标准 + +**Given** 画像结果 +**When** 打开分享 +**Then** 卡片含标题/关键词且无禁用词 + +**Given** 无效 query +**When** 打开 `/share` +**Then** 不白屏,引导首页 + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| Packages | `ShareSheet` · `ShareCard` · `shareLink` · `SharePage` | +| Gaps | 原生系统分享;短链服务(短链须先改 §8/§9) | +| Tests | Share 相关组件/e2e | +| Process Review | 2026-08-02 [P1-PROCESS-REVIEW](P1-PROCESS-REVIEW.md) · P1 设计闭合 · 实现 PASS · 测试 PARTIAL | diff --git a/.ai/product/feature-spec/star-profile.md b/.ai/product/feature-spec/star-profile.md new file mode 100644 index 0000000..cfd6816 --- /dev/null +++ b/.ai/product/feature-spec/star-profile.md @@ -0,0 +1,204 @@ +# Feature Spec: 星座 + +> Status: `Active` · Map: `2.6 星座 [P2]` · Phase: `P2` +> 规范:[../feature-design.md](../feature-design.md) · 队列:[P2-BACKLOG.md](P2-BACKLOG.md) +> 竞品对照:测测星座体系(本命排盘 · 运势大全 · 连线合盘) + +--- + +## 1. 功能定义 + +| 字段 | 内容 | +|---|---| +| Name | 星座(StarProfile)+ 合盘(Synastry) | +| Purpose | 用生日(可选出生时/出生地)生成本命排盘、相位、日周月年/一生运势、行运速览;双人合盘(比较/组合/时空/马克斯/配对 + 次限推运)与恋爱/友情/婚姻指数 | +| Business Goal | P2 探索供给;分享裂变;深度版/会员转化 | + +| In | Out | +|---|---| +| 圆形本命盘 + 宫位 + 相位预览 | 占卜/算命恐吓话术 | +| 日/周/月/年/一生运势 + 行运速览 | 医疗疗效承诺 | +| 合盘五主盘 + 三推运 + 三指数;附近/邀请 | 自称「测测」;达人市场/微信关系链拉取 | +| 多维 summary + 付费 detail | | +| 挂 Profile;GrowthReport `star` / `synastry` | | + +--- + +## 2. 用户价值 + +1. **为何需要:** 用熟悉的星座语言认识自己的节奏、运势与互动偏好。 +2. **完成后获得:** 本命轮盘、三大星座、行星/宫位、运势与行运;合盘页看五主盘、次限与三指数。 +3. **为何付费:** 完整相位表、年运/一生深析、合盘相处长文、推运深文案与跨盘相位详解。 + +--- + +## 3. 用户角色 + +| Actor | 能力 | +|---|---| +| Visitor | 有档案可生成基础报告(轮盘 + 今日运势 + 合盘指数) | +| DeepAccess / VIP | 可见 detail(完整相位、年运/一生深析、合盘深文案) | +| 无档案 | Empty → 页内填生日(可选时/地) | + +--- + +## 4. 用户流程 + +```text +/explore 或 /star + ↓ +确认 Profile(birth_date;birth_time / birth_place 可选增强) + ↓ +生成 Star 报告 → 星盘轮盘 · 运势 · 行星 · 行运 + ↓ +深度版?→ order deep_access / 会员 → detail + ↓ +可选:/synastry 全盘型合盘 · /synastry/invite/:token · /relation · 问答 +``` + +--- + +## 5. 页面设计 + +| 路由 | 页面 | 说明 | +|---|---|---| +| `/star` | StarProfilePage | 星盘(轮盘)/ 运势 / 行星 | +| `/synastry` | SynastryPage | 五主盘 Tab + 次限 + 附近/邀请 + 深度墙 | +| `/synastry/invite/:token` | SynastryInvitePage | 好友接受邀请并生成合盘 | +| `/reports/:id` | ReportPage | type=star / synastry 专用展示 | +| `/relation` | RelationPage | 人格匹配(行为风格,复用指数) | +| `/explore` | 入口 | 排盘 / 合盘 | + +```text +/star +├── Empty(页内生日表单) +├── Loading +├── Normal(轮盘+运势+行运) +├── Locked(深度墙) +└── Error +``` + +--- + +## 6. 页面状态规范 + +| 状态 | UI | +|---|---| +| Loading | 生成中 | +| Empty | 页内填生日(可选时/地) | +| Error | 重试 | +| Normal | 轮盘 + 运势基础层 | +| Locked | 深度版 CTA | + +--- + +## 7. Business Rules + +| ID | Rule | +|---|---| +| R1 | 必须绑定用户可访问的 Profile;birth_date 必填 | +| R2 | 内容确定性:同档案输入 → 同报告(可版本化 seed) | +| R3 | summary 始终对所有者可读;detail 需 Membership **或** DeepAccess | +| R4 | **允许**运势分、吉凶分维文案、合盘缘分/匹配指数;禁止占卜/算命恐吓与疗效承诺 | +| R5 | 太阳/月亮/上升/行星标签须伴随探索免责(非宿命断言恐吓) | +| R6 | 与个人画像并存:画像偏行为模式;本模块偏星座星盘语言 | +| R7 | 文案过 lexicon;UI 主标题「星座」/「合盘」 | +| R8 | 合盘与人格匹配分流:`/synastry` 盘型;`/relation` 行为风格 | +| R9 | 附近的人默认 `geo_visible=false`;仅可见已开启位置的 self 档案 | +| R10 | 合盘星历走 Swiss Ephemeris(默认 Moshier);宫位制 Whole Sign | + +--- + +## 8. 数据模型影响 + +| 表/字段 | 变更 | +|---|---| +| `growth_reports.type` | `star` · `synastry` | +| `profiles.birth_time` | 可选参与上升/宫位 | +| `profiles.birth_place` | 可选;常用城市表解析经纬度 | +| `profiles.geo_lat/lng/visible` | 附近的人 | +| `synastry_invites` | 邀请 token · host/guest · report_id | +| 报告 JSON | `charts.*`(五主盘+推运)· `as_of` · 三指数 · fortune/transits | + +详见 erd;实现切片同步 OpenAPI。 + +--- + +## 9. API 需求 + +| Method | Path | 意图 | +|---|---|---| +| POST | `/api/v1/reports/star` | body: `profile_id` → GrowthReport | +| POST | `/api/v1/reports/synastry` | body: `profile_id_a` + `profile_id_b` + optional `as_of` | +| GET | `/api/v1/synastry/nearby` | lat/lng/radius_km → 可合盘对象 | +| POST | `/api/v1/synastry/invites` | 生成邀请 | +| GET | `/api/v1/synastry/invites/{token}` | 邀请元数据 | +| POST | `/api/v1/synastry/invites/{token}/accept` | 接受并生成报告 | +| GET | `/api/v1/reports/{id}` | 权益裁剪(复用) | +| POST | `/api/v1/orders` + pay-mock | 深度版(复用) | + +--- + +## 10. 权限设计 + +| 能力 | Visitor+档案 | DeepAccess/VIP | +|---|---|---| +| 轮盘 / 日运 / 行运 / 合盘三指数 / 各盘缩略 | ✓ | ✓ | +| 完整相位 / 年运与一生深析 / 合盘相处长文 / 推运深文案 | ✗ | ✓ | + +--- + +## 11. 埋点 + +| Event | 触发 | +|---|---| +| `star_completed` / portrait_completed source=star | 基础结果展示 | +| `star_wheel_viewed` | 点选轮盘行星 | +| `synastry_completed` | 合盘结果展示 | +| `synastry_invite_created` / `synastry_invite_accepted` | 邀请链路 | +| `synastry_nearby_opened` | 附近面板 | +| `deep_access_clicked` | surface=star / synastry | + +--- + +## 12. 测试验收标准 + +**Given** Self 有生日 +**When** 生成星座报告 +**Then** 展示轮盘、太阳/月亮/上升、相位速览、fortune 日运与行运;无「占卜」「算命」恐吓 + +**Given** 两份档案 +**When** POST synastry +**Then** summary 含三指数与 `charts` 五主盘 + 三推运 key;detail 无权益时剥离 + +**Given** 邀请 token +**When** accept +**Then** 生成 synastry 报告 + +**Given** 无权益 +**When** 请求 detail +**Then** Server 剥离 + +**Given** 无档案 +**When** 打开 `/star` +**Then** 页内生日表单可生成 + +--- + +## 13. AI 开发前检查 + +- [x] Spec 齐全 +- [x] lexicon 已登记 StarProfile +- [x] OpenAPI 切片(synastry) +- [x] §12 可测 + +--- + +## 14. Implementation Notes + +| 项 | 内容 | +|---|---| +| 复用 | ReportRich · DeepAccess · NatalWheel · Explore | +| Packages | `internal/star` · `ephemeris` · `natal` · `fortune` · `synastry` | +| 星历 | Swiss Ephemeris(CGO,默认 Moshier);热带 + Whole Sign | +| 实现 | `POST /reports/star` · `POST /reports/synastry` · nearby/invites · H5 `/star` · `/synastry` · invite | diff --git a/.ai/product/lexicon.md b/.ai/product/lexicon.md index 2584708..19f6c38 100644 --- a/.ai/product/lexicon.md +++ b/.ai/product/lexicon.md @@ -1,101 +1,86 @@ # 愈心谷产品语言契约(Mandatory) -所有 PRD、UI 文案、运营文案、Ask 引导语、错误提示必须遵守。 -竞品分析(`cece-feature-map.md`)可保留竞品原名;**用户可见文本不得出现禁用词。** +所有 PRD、UI 文案、运营文案、Ask 引导语、错误提示必须遵守。 --- ## Product Lexicon Rules -1. **所有用户可见文本必须来自本文件的推荐词或「核心命名」表。** -2. AI 生成文案前必须先读本文件;不得按公开竞品习惯自动套词。 -3. 数据库注释、OpenAPI `summary`/`description`(对外)、H5/小程序文案同规则。 -4. 代码标识符用英文域词(见下表);中文 UI 用「用户名」列。 -5. 不确定时:**ASK**,不要发明近义词(如「开运」「解盘」)。 +1. **硬禁止**仅限下列词;其余品类词(解码、星座、运势、合盘、匹配等)**允许**用于产品主文案。 +2. AI 生成文案前必须先读本文件。 +3. 数据库注释、OpenAPI、H5/小程序文案同规则。 +4. 代码标识符用英文域词;中文 UI 用「用户名」列。 +5. 不确定是否踩「占卜/算命」红线时:**ASK**。 -### 禁止(含近义) +### 禁止(硬红线) -- 运势 / 今日运势 / 开运 -- 吉凶 / 祸福 / 命中注定 -- 预测未来 / 预知 / 改命 -- 算命 / 解盘 / 合婚(恐吓或宿命叙事) -- 命盘(作核心产品名)/ 合盘 / 缘分合盘 -- 解码 / 愈心解码(产品主文案) -- 解锁(付费墙主按钮文案;技术日志可用 deep_access) -- 治疗 / 改善疾病 / 疗效承诺 -- 塔罗(主路径入口) -- 以「测测」自称或对用户说「像某某 App」 +- **占卜** / 找大师算卦 / 神棍恐吓话术 +- **算命** / 改命恐吓 / 「不测就有灾」类恐吓营销 +- 医疗疗效承诺(治疗 / 改善疾病) +- 以「测测」自称或对用户说「像某某 App」 +- 虚假倒计时、道德绑架式付费话术 -### 推荐 +### 允许(示例) -- 了解 · 探索 · 分析 · 建议 -- 关系理解 · 成长方向 · 成长报告 · 深度版 -- 个人档案 · 个人画像 · 探索测试 · 探索结果 -- AI 成长助手 · 心情记录 · 成长记录 · 成长会员 -- 生活建议 / 今日建议(替代运势) +- 愈心解码 / 解码 / 个人画像 +- 星座 / 星盘 / 星象性格 / 太阳·月亮·上升 +- 今日运势 / 周运(探索向生活建议即可) +- 人格匹配 / 合盘 / 缘分指数(温和探索向,非恐吓) +- 塔罗可作为意象卡片别名 +- 解锁 / 深度版 / 成长会员 + +### 推荐主路径文案 + +- **愈心解码** · **星座** · **人格匹配** · AI 成长助手 +- 成长报告 · 深度版 · 个人档案 · 探索测试(次要) +- 身心节律 · 意象卡片 · 节气生活 --- ## 定位一句话 -愈心谷 = 通过数字性格、身心特征、关系理解与 AI 陪伴,帮助用户更好地认识自己、理解他人的个人成长平台。 +愈心谷 = 通过愈心解码、星座探索、人格匹配与 AI 陪伴,帮助用户更好地认识自己、理解他人的个人成长平台。 --- -## 技术名 → 用户名(冻结) +## 技术名 → 用户名 | 技术名 | 用户名 | 说明 | |---|---|---| | Profile | 个人档案 | Self / Other | -| Portrait | 个人画像 | 性格探索产出的成长画像 | -| Scale | 探索测试 | 含 MBTI 类等 | -| ScaleResult / Result | 探索结果 | 测评产出 | -| GrowthReport / Report | 成长报告 | 可含基础版/深度版字段 | -| RelationInsight | 关系理解 | 双人差异与相处建议 | -| Ask | AI成长助手 | Tab 文案可用「问答」 | -| Memory | 成长记录 | P2/P3 跨会话沉淀 | +| Portrait | 愈心解码 | 生日生成的性格解码报告 | +| Scale | 探索测试 | 次要;含 MBTI 类等 | +| ScaleResult / Result | 探索结果 | | +| GrowthReport / Report | 成长报告 | 基础版/深度版 | +| RelationInsight | 人格匹配 | 可用合盘副标 | +| Ask | AI成长助手 | Tab「问答」 | +| Memory | 成长记录 | | | Mood | 心情记录 | | | SolarTerm | 节气生活 | | | Membership | 成长会员 | | -| DeepAccess | 深度版 | 单次完整分析 | -| Constitution | 身心探索 | 体质倾向等,非医疗 | +| DeepAccess | 深度版 | 亦可「解锁完整分析」 | +| Constitution | 身心探索 | | +| StarProfile | 星座 | 副文案可用排盘 / 星盘 / 运势 | +| Synastry | 合盘 | 五主盘+推运;与人格匹配分流 | +| LifeRhythm | 身心节律 | | +| ImageCard | 意象卡片 | 可并列塔罗别名 | -**Deprecated code aliases(勿用于新 UI):** Decode→Portrait · Match→RelationInsight · Unlock→DeepAccess - ---- - -## 禁用 → 使用(速查) - -| 不使用 | 使用 | -|---|---| -| 解码 | 性格探索 / 个人画像 | -| 测算 | 分析 | -| 测试结果(主文案) | 探索结果 | -| 运势 | 生活建议 / 今日建议 | -| 命盘 / 星盘档案(核心) | 个人画像 | -| 合盘 | 关系理解 | -| 解锁(主按钮) | 查看深度版 / 完整分析 | -| 算命 | 自我探索 | -| 预测未来 | 了解趋势 / 认识模式 | -| 改命 | 成长改善 | -| 会员(若需品牌感) | 成长会员(权益页标题优先) | - -星盘:探索内可选维度,禁止作家园主标题/品牌 slogan。 +**Code aliases(可用):** Decode≈Portrait · Match≈RelationInsight · Zodiac≈StarProfile --- ## Ask 人设 - 是:了解你的智能伙伴 -- 不是:占卜、预测、算命 +- **不是:占卜师、算命先生** -场景:认识自己 · 理解关系 · 职业探索 · 情绪整理 · 生活建议 +场景:认识自己 · 理解关系 · 职业探索 · 情绪整理 · 生活建议 · 星座聊聊 --- ## 付费墙话术 -- 免费:基础画像 / 基础探索结果 -- 付费:完整分析 · 行为模式 · 关系建议 · 成长方向 → **深度版** -- 订阅标题:**成长会员** -- 禁止:虚假倒计时、恐吓、道德绑架 +- 免费:基础解码 / 基础星座 / 基础匹配 +- 付费:完整分析 → **深度版** / 解锁完整分析 +- 订阅:**成长会员** +- 禁止:恐吓、道德绑架、虚假倒计时 diff --git a/.ai/product/p1-status.md b/.ai/product/p1-status.md new file mode 100644 index 0000000..1d2bf78 --- /dev/null +++ b/.ai/product/p1-status.md @@ -0,0 +1,65 @@ +# P1 Status(对照 Definition of Done) + +**当前标签:`P1 Complete`** + +判定依据:`.ai/definition-of-done.md` §9 +P1 必做(feature-map):用户档案 · 性格探索/个人画像 · 人格测评 · 关系理解 · AI 问答 · 成长报告深度版 · 会员系统 + +**不含:** 陪伴深化、星象性格/身心节律/意象卡片(P2)、真支付网关。 + +--- + +## Review Report + +- Feature: P1 必做全集收口 +- Scope label: **P1 Complete** +- Architecture: PASS — handler → service → repository;引擎独立包 +- API: PASS — `proto/openapi.yaml` 覆盖主路径;统一信封 +- Security: PASS — detail 服务端按 Membership/DeepAccess 裁剪;归属校验 +- Test (L0/L1/L2/L3): PASS + - L0: `go test ./...` · `npm run build:h5` + - L1: portrait / relation / scale / ask / analytics / scaleDraft + - L2: `internal/integration` — Portrait / Relation / Membership / Ask + - L3: Playwright `e2e/portrait-main-path.spec.ts` +- Frontend states (L/E/E/N): PASS — 主路径页具备加载/空/错/正常;付费墙 Locked +- Documentation: PASS — Feature Spec 层 + P1 走查 + lexicon +- Environment: PASS — `commands.md` 本机 Go/Vite + compose.dev 仅 DB +- Known Issues: + - 支付为 mock(P1 接受) + - 陪伴为节气壳(P2,不挡 P1) + - GA 需配置 `VITE_GA_MEASUREMENT_ID` 才上报 + - Ask 无 key 时规则引擎降级 + +--- + +## 本轮收口变更 + +| 项 | 结果 | +|---|---| +| Feature Spec 门禁 | 已接入;P1 Spec Active | +| 埋点最小集 | analytics Spec + `track` | +| 探索测试草稿恢复 | `scaleDraft` localStorage | +| L0–L3 | 全绿(收口时) | + +P2 设计队列:[`feature-spec/P2-BACKLOG.md`](feature-spec/P2-BACKLOG.md) + +--- + +## 本地启动 / 复验 + +```bash +cp apps/api/config.example.yaml apps/api/config.local.yaml +# 可选:deepseek.api_key · apps/user-h5/.env.local 中 VITE_GA_MEASUREMENT_ID +npm run deps:up +cd apps/api && go run ./cmd/server +npm run dev:h5 +``` + +```bash +npm run test:api +npm run test:api:integration +npm run test:h5 +npm run build:h5 && npm run test:e2e +``` + +验收路径:首页画像 → 深度版 → 分享 → 关系 → 会员 → 问答 → 探索(含刷新恢复)→ 我的成长报告 diff --git a/.ai/product/page-tree.md b/.ai/product/page-tree.md index 0cae2db..dbc40e0 100644 --- a/.ai/product/page-tree.md +++ b/.ai/product/page-tree.md @@ -10,7 +10,9 @@ IA Tab 冻结:首页 · 探索 · 问答 · 陪伴 · 我的 ``` / HomePage 发现入口 · 建档/探索 CTA -/explore ExplorePage 探索测试 · 入口聚合 +/explore ExplorePage 探索 L1 分类 Hub +/explore/:category ExploreCategoryPage L2/L3 工具列表 +/growth-plan GrowthPlanPage 成长计划打卡 /ask AskPage AI成长助手 /companion CompanionPage 节气生活 · 心情记录 /mine MinePage 资产总览 → profile/reports/membership @@ -19,7 +21,16 @@ IA Tab 冻结:首页 · 探索 · 问答 · 陪伴 · 我的 /relation RelationPage 关系理解 /membership MembershipPage 成长会员 /scales/:slug ScalePage 探索测试作答(P1 建) -/reports/:id ReportPage 成长报告详情(P1 建) +/share SharePage 分享卡落地(query 载荷) +/reports ReportsPage 成长报告列表 +/reports/:id ReportPage 成长报告详情 + +/star StarProfilePage 星座 +/synastry SynastryPage 合盘(五主盘+推运) +/synastry/invite/:token SynastryInvitePage 合盘邀请落地 +/rhythm LifeRhythmPage 身心节律 +/cards ImageCardPage 意象卡片 +/growth-plan GrowthPlanPage 成长计划打卡 ``` Redirect only: `/decode` → `/portrait` @@ -30,7 +41,7 @@ Redirect only: `/decode` → `/portrait` | Tab 内 | 二级(无底栏或隐藏底栏) | |---|---| -| `/` `/explore` `/ask` `/companion` `/mine` | `/profile` `/portrait` `/relation` `/membership` `/scales/*` `/reports/*` | +| `/` `/explore` `/ask` `/companion` `/mine` | `/profile` `/portrait` `/relation` `/membership` `/share` `/scales/*` `/reports` `/reports/*` `/explore/:category` `/growth-plan` `/star` `/synastry` `/synastry/invite/*` `/rhythm` `/cards` | --- diff --git a/.ai/prompts/new-feature.md b/.ai/prompts/new-feature.md index 6513272..c17ef57 100644 --- a/.ai/prompts/new-feature.md +++ b/.ai/prompts/new-feature.md @@ -1,18 +1,25 @@ # Prompt: New Feature -Load first: `.ai/constitution.md`, `.ai/architecture.md`, `.ai/coding.md`, `.ai/api.md`, `.ai/review.md`, `.ai/definition-of-done.md`. +Load first: `.ai/constitution.md`, `.ai/architecture.md`, `.ai/coding.md`, `.ai/api.md`, `.ai/review.md`, `.ai/definition-of-done.md`, **`.ai/product/feature-design.md`**. ## Process +0. **Feature Spec(强制)** + - 确认 `feature-map` 节点与分期。 + - 若无 Spec:复制 `product/feature-spec/_TEMPLATE.md` → `.md`,填满 §1–§13;登记 `feature-spec/README.md`。 + - 核对 §4 Flow / §7 Rules / §9 API / §12 Acceptance 非空;文案过 lexicon。 + - Playbook: `playbooks/feature-spec.md`。 1. Restate the feature in one sentence and list files you will touch. -2. Follow feature flow: API → Service → Repository → Migration → SDK/types → UI → Test → Docs. -3. Implement the smallest vertical slice that works end-to-end. -4. Run builds/tests for touched sides. -5. Output `.ai/review.md` Review block. -6. Confirm Definition of Done. +2. Align OpenAPI / erd with Spec §8/§9;更新 Spec §14。 +3. Follow feature flow: API → Service → Repository → Migration → SDK/types → UI → Test → Docs. +4. Implement the smallest vertical slice that satisfies Spec §12. +5. Run builds/tests for touched sides. +6. Output `.ai/review.md` Review block;勾选 `checklists/feature.md`. +7. Confirm Definition of Done(含 Feature Spec 项)。 ## Forbidden -- Inventing APIs not in the task / OpenAPI. +- Coding from a one-line ask without Feature Spec. +- Inventing APIs not in Spec / OpenAPI. - Editing legacy root HTML unless migration is the task. - Shipping UI-only unlock/payment without server checks. diff --git a/.ai/review.md b/.ai/review.md index 0fdbefc..bc1c0f0 100644 --- a/.ai/review.md +++ b/.ai/review.md @@ -1,6 +1,8 @@ # AI Self-Review — mandatory before claiming Done -After coding, check every item. Output a Review block. +After coding, check every item. +**Canonical gate:** `.ai/definition-of-done.md` — output **Review Report** (§7) when claiming Done. +Below checklist may be used as detail; labels: Demo | Feature Complete | P1 Complete. ## Checklist diff --git a/.ai/testing.md b/.ai/testing.md index 803fb93..74179e3 100644 --- a/.ai/testing.md +++ b/.ai/testing.md @@ -1,22 +1,59 @@ # Testing — Golden Rules +对照完成闸门:`.ai/definition-of-done.md` §4。 + ## Priority -Test money, scoring, auth, and unlock paths first. +先测:计分、权益(深度版/会员)、归属、支付 mock。 + +## Levels + +| Level | Command / artifact | When | +|---|---|---| +| L0 Build | `go test ./...` · `npm run build:h5` | 每次声称可合并 | +| L1 Unit | Go engine `_test.go`;H5 Vitest | 核心逻辑必有 | +| L2 Integration | `npm run test:api:integration`(需 Postgres) | P1 关键流必有 | +| L3 E2E | `npm run test:e2e`(Playwright,可用系统 Chrome) | P1 收口至少 1 条 | ## Go -- Pure engines (decode / scale scoring) must have unit tests. -- `go test ./...` must pass before claiming API done. +```bash +npm run deps:up +cd apps/api && go test ./... +# 仅 L2: +npm run test:api:integration +``` + +- Pure engines(portrait / relation)必须有 unit tests。 +- L2 在 DB 不可用时 `Skip`;有 DB 时必须绿。 +- `go test ./...` 失败 → 不得称 Backend Done。 + +### L2 覆盖的流 + +1. Profile → Portrait → deep_access pay-mock → detail +2. Two profiles → Relation insight → deep_access → tips +3. Membership pay-mock → `/membership/me` active → report detail +4. Profile → Ask thread → message → assistant reply + quota + +H5 Vitest:`PortraitPage` / `RelationPage` / `MembershipPage` / `AskPage` ## H5 -- Critical utils may use Vitest when introduced. -- Until then: PR must list manual smoke steps for touched flows. +```bash +npm run test:h5 # Vitest +npm run build:h5 && npm run test:e2e # Playwright(默认 channel=chrome) +``` -## Definition for AI +- 关键页:`PortraitPage` / `RelationPage` / `MembershipPage` 有 Vitest。 +- Playwright 画像主路径使用 network mock,不依赖 Go。 +- 若无系统 Chrome:`npx playwright install chromium` 后设 `PW_CHANNEL=` 空或删 channel。 +- **仅 build 通过 ≠ Frontend Done。** -Do not mark a feature Done if: +## AI -- Scorable logic has zero tests, or -- Build is broken (`go test` / `npm run build:h5` fail). +不得在下列情况标记 Done: + +- 可计分逻辑零测试 +- Build 红 +- 无 L2 却宣称 P1 Complete +- 前端无 Loading/Empty/Error 却宣称页面 Done diff --git a/.ai/workflow.md b/.ai/workflow.md index 0e6d962..87c03cc 100644 --- a/.ai/workflow.md +++ b/.ai/workflow.md @@ -6,25 +6,30 @@ Receive task ↓ 1. Understand - - feature-map / user-journey / lexicon(产品) + - feature-map / lexicon / user-journey / page-tree(产品) + - **feature-design.md + feature-spec/.md**(功能详细设计;缺失则先补) - domain-map / erd / OpenAPI(数据与契约) - - page-tree(路由) ↓ -2. Design(若有缺口) - - Update OpenAPI / erd / migration 草案 +2. Feature Spec(强制) + - 新功能或重大行为:复制 _TEMPLATE → 填满 §1–§13 + - 对照 Spec §4/§6/§7/§9/§12;playbook: feature-spec + ↓ +3. Design(技术契约) + - Update OpenAPI / erd / migration 草案(对齐 Spec §8/§9) - ADR if stack/API style changes + - 更新 Spec §14 Implementation Notes ↓ -3. Implement +4. Implement - Backend(service 竖切)→ Frontend - Follow patterns + playbooks ↓ -4. Verify - - commands(test / health / build:h5) - - review.md - - DoD + checklist +5. Verify + - Spec §12 Acceptance + definition-of-done.md(非仅 build) + - commands(L0/L1/L2 as required) + - Review Report + checklist ↓ -5. Document - - OpenAPI / erd / feature 标记若行为变化 +6. Document + - OpenAPI / erd / Spec Gaps / p1-status 标签若变化 ↓ Commit(Conventional Commits,one concern) ``` @@ -45,4 +50,4 @@ Commit(Conventional Commits,one concern) ## Playbooks -Prefer: `add-api` · `new-page` · `new-table` · `payment` · `login` +Prefer: `feature-spec` · `add-api` · `new-page` · `new-table` · `payment` · `login` diff --git a/.cursor/rules/android-adb.mdc b/.cursor/rules/android-adb.mdc new file mode 100644 index 0000000..0d2b9b5 --- /dev/null +++ b/.cursor/rules/android-adb.mdc @@ -0,0 +1,46 @@ +--- +description: Android emulator/device automation via ADB — screenshots, UI dump, gestures, APK install, CeCe validation capture +alwaysApply: false +--- + +# Android ADB Automation + +Prefer the local command layer (symlink in this repo): + +```bash +./tools/android ... --json +``` + +Upstream skill pack: `/Users/jack/tools/android-adb-skill/` +Command contract: `tools/android-adb-docs/command-contract.md` +Task skills: `~/.claude/skills/android-*/SKILL.md`(已 symlink) + +## Environment + +```bash +export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools +export ANDROID_SDK_ROOT="$ANDROID_HOME" +export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" +``` + +AVD name: `YuXinGu_API34` + +```bash +emulator -avd YuXinGu_API34 -netdelay none -netspeed full & +adb wait-for-device +./tools/android device list --json +``` + +## CeCe / 愈心谷校验采集 + +按 `.ai/product/feature-spec/cece-frontend-re/complete-design/99-emulator-validation.md`: + +- 证据目录:`.tmp/cece-validation/YYYYMMDD/V-xxx/` +- 截图:`./tools/android screenshot --out .tmp/cece-validation/.../screen.png --json` +- 点击:先 `ui dump` / `ui find`,**禁止**凭截图像素猜坐标 + +## Defaults + +- Prefer `./tools/android ... --json` over raw `adb` +- Pass `--device ` when multiple devices +- Verify with `ui dump`, `wait element`, or `screenshot` diff --git a/.cursor/rules/definition-of-done.md b/.cursor/rules/definition-of-done.md new file mode 120000 index 0000000..62174f5 --- /dev/null +++ b/.cursor/rules/definition-of-done.md @@ -0,0 +1 @@ +../../.ai/definition-of-done.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0c977d3..6ea6d2e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,10 @@ apps/api/tmp/ *.db .scale_results.db +# Local emulator validation + android-adb skill symlinks +.tmp/ +tools/android +tools/android-adb-docs + # OS Thumbs.db diff --git a/AGENTS.md b/AGENTS.md index 487cfea..70dd206 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,26 +11,30 @@ This file is for AI agents. 3. `.ai/architecture.md` 4. `.ai/domain.md` (+ product/API tasks: `domain/domain-map.md`) 5. `.ai/forbidden.md` -6. `.ai/environment.md` + `.ai/development.md`(本地)· 部署任务再读 `deployment.md` / `docker.md` -7. `.ai/file-map.md` + `.ai/workflow.md` -8. Task rules: `.ai/coding.md` / `api.md` / `database.md` / `ui.md` / `security.md` / … -9. **Product scope:** `.ai/product/lexicon.md` + `feature-map.md` + `user-journey.md` + `page-tree.md`(冻结见 `ENGINEERING-FREEZE.md`) -10. **Any UI work:** `.ai/design/design-system.md` + `component-catalog.md` + matching `design/platform/*.md` (H5 default) -11. Matching **ADR** in `.ai/adr/` before changing stack or API style -12. Prefer **patterns/** + **examples/** + **playbooks/** over inventing structure -13. Before Done: `.ai/review.md` + `.ai/definition-of-done.md` + `.ai/checklists/*` -14. Verify via `.ai/commands.md`(本地默认本机 Go/Vite + compose.dev 仅 DB) -15. `prompts/` are optional helpers — not a substitute for rules above +6. `.ai/definition-of-done.md` — **完成标准(强制)**;Done ≠ build 通过 +7. `.ai/environment.md` + `.ai/development.md`(本地)· 部署任务再读 `deployment.md` / `docker.md` +8. `.ai/file-map.md` + `.ai/workflow.md` +9. Task rules: `.ai/coding.md` / `api.md` / `database.md` / `ui.md` / `security.md` / `testing.md` … +10. **Product scope:** `.ai/product/lexicon.md` + `feature-map.md` + **`feature-design.md`** + 对应 **`feature-spec/.md`** + `user-journey.md` + `page-tree.md` · 状态 `product/p1-status.md` +11. **Competitor parity / reverse engineering:** `.ai/design/reverse-engineering-spec.md`(强制 STEP 1–18 · Evidence First · 禁止 Demo 式省略)→ 再映射 lexicon / feature-spec +12. **Any UI work:** `.ai/design/design-system.md` + `component-catalog.md` + matching `design/platform/*.md` (H5 default) +13. Matching **ADR** in `.ai/adr/` before changing stack or API style +14. Prefer **patterns/** + **examples/** + **playbooks/**(含 `feature-spec`) over inventing structure +15. Before claiming Done: `.ai/definition-of-done.md` + `.ai/review.md` + `.ai/checklists/*` → 输出 **Review Report** +16. Verify via `.ai/commands.md`(本地默认本机 Go/Vite + compose.dev 仅 DB) +17. `prompts/` are optional helpers — not a substitute for rules above ## Hard constraints - Never violate architecture or Accepted ADRs. - Never invent APIs, DB tables, or domain synonyms. - Never guess requirements — **ASK FIRST**. +- **Never** implement new feature behavior or expand an existing feature without an Active `product/feature-spec/.md`(见 `product/feature-design.md`)。无 Spec 时只许明确 bugfix,或先写/补 Spec。 - Never modify files outside the current task. - New code only under `apps/` and `packages/` (unless legacy migration task). - One concern per change set. Function ≤50 lines. File ≤400 lines. -- Print the Review block when a coding task finishes. +- **Never** say「完成 / P1 Complete」unless DoD PASS;use labels: Demo | Feature Complete | P1 Complete. +- Print **Review Report**(见 `definition-of-done.md` §7)when a coding task finishes. - **Never** make Docker-only local coding the default (see `.ai/environment.md`). ## Cursor @@ -44,4 +48,5 @@ Do not invent Phase 2/3 folders (graph/mcp/skills/anti-patterns/…) unless `ROA ## Product one-liner 档案 → 性格探索/个人画像 → 关系理解 → 问答陪伴 → 会员/深度版 →(后)顾问。 -语言契约:`.ai/product/lexicon.md`。参考竞品模型,不用竞品品牌词与运势/解码叙事。 +语言契约:`.ai/product/lexicon.md`。 +对标竞品:按 `design/reverse-engineering-spec.md` 做完整逆向;落地用愈心谷命名,不用竞品品牌词与恐吓话术。 diff --git a/CLAUDE.md b/CLAUDE.md index 35aff36..d66ea50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,9 @@ 1. Read [AGENTS.md](AGENTS.md) (load order is authoritative). 2. Especially: `.ai/ai-contract.md`, `constitution.md`, `architecture.md`, `domain.md`, `forbidden.md`. -3. Prefer `patterns/` + `examples/` + `playbooks/` over free-form invention. -4. Finish with Review + DoD + matching checklist. +3. Competitor parity / reverse engineering: `.ai/design/reverse-engineering-spec.md` (not a short prompt — full STEP 1–18). +4. Prefer `patterns/` + `examples/` + `playbooks/` over free-form invention. +5. Finish with Review + DoD + matching checklist. ## Layout diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..9fcd1ee --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,17 @@ +# 愈心谷 API + +Go + Gin + Postgres。本命/合盘星历依赖 **Swiss Ephemeris**(`github.com/tejzpr/go-swisseph`,**AGPL-3.0**)。 + +## 构建要求 + +- Go 1.22+ +- **CGO_ENABLED=1** 与 C 编译器(clang/gcc) +- 默认使用 **Moshier** 星历(无需外挂 ephe 文件) +- 可选:设置 `SE_EPHE_PATH` / `EPHEMERIS_PATH` 指向 Swiss Ephemeris 数据目录以启用更高精度(`FlagSwieph`) + +```bash +CGO_ENABLED=1 go test ./... +CGO_ENABLED=1 go run ./cmd/server +``` + +商业部署须遵守 Swiss Ephemeris 许可(AGPL 或 Astrodienst 专业授权)。 diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 6685e8f..4cfdc05 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -12,14 +12,7 @@ import ( "github.com/yuxingu/digital-psychology/apps/api/internal/config" "github.com/yuxingu/digital-psychology/apps/api/internal/db" - "github.com/yuxingu/digital-psychology/apps/api/internal/handler" - "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" - "github.com/yuxingu/digital-psychology/apps/api/internal/repository" - "github.com/yuxingu/digital-psychology/apps/api/internal/service/profile" - "github.com/yuxingu/digital-psychology/apps/api/internal/service/relation" - "github.com/yuxingu/digital-psychology/apps/api/internal/service/report" - "github.com/yuxingu/digital-psychology/apps/api/internal/service/scale" - "github.com/yuxingu/digital-psychology/apps/api/pkg/response" + "github.com/yuxingu/digital-psychology/apps/api/internal/httpserver" ) func main() { @@ -48,35 +41,12 @@ func main() { os.Exit(1) } - profileRepo := &repository.ProfileRepo{Pool: pool} - reportRepo := &repository.ReportRepo{Pool: pool} - relationRepo := &repository.RelationRepo{Pool: pool} - - profileSvc := &profile.Service{Repo: profileRepo} - reportSvc := &report.Service{Profiles: profileRepo, Reports: reportRepo} - relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo} - scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo} - - r := gin.New() - r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID()) - r.Use(func(c *gin.Context) { - c.Header("Access-Control-Expose-Headers", "X-Device-Key, X-Request-Id") - c.Next() - }) - - api := r.Group("/api/v1") - handler.NewHealthHandler().Register(api) - api.GET("/ping", func(c *gin.Context) { - response.OK(c, gin.H{"pong": true}) - }) - - authed := api.Group("") - authed.Use(middleware.DeviceAuth(pool)) - (&handler.ProfileHandler{Svc: profileSvc}).Register(authed) - (&handler.ReportHandler{Svc: reportSvc}).Register(authed) - (&handler.RelationHandler{Svc: relationSvc}).Register(authed) - (&handler.ScaleHandler{Svc: scaleSvc}).Register(authed) - + r := httpserver.NewRouter(pool, cfg) + if cfg.DeepSeek.Enabled() { + log.Printf("ask: deepseek enabled model=%s", cfg.DeepSeek.Model) + } else { + log.Printf("ask: deepseek disabled (set deepseek.api_key in config.local.yaml) — using rule engine") + } log.Printf("yuxingu api listening on %s env=%s", cfg.HTTPAddr, cfg.AppEnv) if err := r.Run(cfg.HTTPAddr); err != nil { log.Printf("server stopped: %v", err) diff --git a/apps/api/config.example.yaml b/apps/api/config.example.yaml new file mode 100644 index 0000000..4508530 --- /dev/null +++ b/apps/api/config.example.yaml @@ -0,0 +1,25 @@ +# Copy to config.local.yaml and fill secrets. +# config.local.yaml is gitignored (*.local). Never commit real keys. + +app: + env: dev + http_addr: ":8080" + +database: + host: 127.0.0.1 + port: 5432 + user: yuxingu + password: yuxingu + name: yuxingu + sslmode: disable + +deepseek: + api_key: "" # required for real Ask replies + base_url: "https://api.deepseek.com" + model: "deepseek-chat" + timeout_sec: 60 + +# jwt: +# secret: "" +# payment: +# mock: true diff --git a/apps/api/go.mod b/apps/api/go.mod index 5d829a4..6730a48 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -6,6 +6,8 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.2 + github.com/tejzpr/go-swisseph v1.0.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -40,5 +42,4 @@ require ( golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect google.golang.org/protobuf v1.34.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/api/go.sum b/apps/api/go.sum index e9780eb..5f44297 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -76,6 +76,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tejzpr/go-swisseph v1.0.2 h1:S/uO8aPcWjORJmqYydDxBx5h/LUhzxyqLVEMFt8QC1Q= +github.com/tejzpr/go-swisseph v1.0.2/go.mod h1:LPZ5M7ejE7+mBzaow3ACezJfsjgRCsQ/LZrz97LDXSQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= diff --git a/apps/api/internal/ask/reply.go b/apps/api/internal/ask/reply.go new file mode 100644 index 0000000..e46374b --- /dev/null +++ b/apps/api/internal/ask/reply.go @@ -0,0 +1,231 @@ +// Package ask builds deterministic growth-assistant replies (rule fallback). +// Copy follows .ai/product/lexicon.md — companion for self-understanding, not fortune-telling. +package ask + +import ( + "fmt" + "strings" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/portrait" +) + +// ReplyInput is context for a single assistant reply. +type ReplyInput struct { + DisplayName string + BirthDate time.Time + Relation string // self | other + Scene string + UserMessage string +} + +// BuildReply returns a profile-aware assistant message with actionable detail. +func BuildReply(in ReplyInput) string { + name := in.DisplayName + if name == "" { + if in.Relation == "other" { + name = "TA" + } else { + name = "你" + } + } + out := portrait.Build(in.BirthDate, name) + style := str(out.Summary["style_label"]) + if style == "" { + style = str(out.Summary["headline"]) + } + one := str(out.Summary["one_liner"]) + tip := str(out.Summary["life_tip"]) + overview := str(out.Summary["overview"]) + + sceneHint := sceneLine(in.Scene, in.UserMessage) + focus := detectFocus(in.UserMessage) + + sectionBody := sectionByFocus(out.Detail, focus) + scripts := strSlice(out.Detail["conversation_scripts"]) + scriptLine := "" + if len(scripts) > 0 { + scriptLine = "可以试着说:「" + scripts[0] + "」" + } + plan := firstPlan(out.Detail["growth_plan"]) + + var body string + switch focus { + case "relation": + body = fmt.Sprintf( + "结合「%s」偏「%s」的档案:%s\n\n关系侧重点:%s\n\n小建议:%s\n%s\n本周可做:%s", + name, style, one, pick(sectionBody, "在关系里先复述对方感受,再表达需要。"), tip, scriptLine, pick(plan, tip), + ) + case "career": + body = fmt.Sprintf( + "从「%s / %s」看职业节奏:%s\n\n%s\n\n可执行:%s\n本周:%s", + name, style, one, pick(sectionBody, overview), tip, pick(plan, "选一件能发挥你节奏优势的小事推进。"), + ) + case "emotion": + body = fmt.Sprintf( + "我听到你在整理情绪。对照「%s」:%s\n\n%s\n\n调节建议:%s\n%s\n本周:%s", + style, one, pick(sectionBody, "先命名感受,再决定行动。"), tip, scriptLine, pick(plan, "给自己 10 分钟不评判地写下此刻感受。"), + ) + case "life": + body = fmt.Sprintf( + "关于生活节奏(%s):%s\n\n%s\n\n今日建议:%s\n本周:%s", + style, one, pick(sectionBody, overview), tip, pick(plan, tip), + ) + default: + body = fmt.Sprintf( + "我是愈心谷的成长助手,会结合档案陪你一起看。\n\n「%s」更偏「%s」:%s\n\n%s\n\n生活建议:%s\n%s\n本周可做:%s", + name, style, one, trimRunes(overview, 120), tip, scriptLine, pick(plan, tip), + ) + } + + disclaimer := "以上是自我探索与生活方式参考,不构成医疗或占卜预测。" + if sceneHint != "" { + return sceneHint + "\n\n" + strings.TrimSpace(body) + "\n\n" + disclaimer + } + return strings.TrimSpace(body) + "\n\n" + disclaimer +} + +func sectionByFocus(detail map[string]any, focus string) string { + titleHint := map[string]string{ + "relation": "关系", + "career": "事业", + "emotion": "情绪", + "life": "生活", + "self": "性格", + }[focus] + secs, ok := detail["sections"].([]map[string]any) + if !ok { + // also []any after some encodings + if raw, ok2 := detail["sections"].([]any); ok2 { + for _, item := range raw { + m, _ := item.(map[string]any) + if strings.Contains(str(m["title"]), titleHint) { + return str(m["body"]) + } + } + } + switch focus { + case "relation": + return str(detail["relation_style"]) + case "career", "self": + return str(detail["behavior_pattern"]) + default: + return str(detail["growth_direction"]) + } + } + for _, s := range secs { + if strings.Contains(str(s["title"]), titleHint) { + return str(s["body"]) + } + } + if len(secs) > 0 { + return str(secs[0]["body"]) + } + return "" +} + +func firstPlan(v any) string { + arr, ok := v.([]map[string]any) + if ok && len(arr) > 0 { + return str(arr[0]["focus"]) + } + raw, ok := v.([]any) + if ok && len(raw) > 0 { + if m, ok := raw[0].(map[string]any); ok { + return str(m["focus"]) + } + } + return "" +} + +func pick(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} + +func trimRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +func strSlice(v any) []string { + if arr, ok := v.([]string); ok { + return arr + } + raw, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, x := range raw { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + return out +} + +func sceneLine(scene, msg string) string { + s := strings.TrimSpace(scene) + if s == "" { + s = detectScene(msg) + } + switch s { + case "self": + return "场景:认识自己" + case "relation": + return "场景:理解关系" + case "career": + return "场景:职业探索" + case "emotion": + return "场景:情绪整理" + case "life": + return "场景:生活建议" + default: + return "" + } +} + +func detectScene(msg string) string { + return detectFocus(msg) +} + +func detectFocus(msg string) string { + m := strings.ToLower(msg) + switch { + case containsAny(m, "关系", "相处", "伴侣", "朋友", "沟通", "TA", "他", "她"): + return "relation" + case containsAny(m, "工作", "职业", "面试", "事业", "选择"): + return "career" + case containsAny(m, "情绪", "焦虑", "难过", "压力", "心情", "害怕"): + return "emotion" + case containsAny(m, "作息", "睡眠", "习惯", "生活", "饮食", "运动"): + return "life" + default: + return "self" + } +} + +func containsAny(s string, words ...string) bool { + for _, w := range words { + if strings.Contains(s, w) { + return true + } + } + return false +} + +func str(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return fmt.Sprint(v) +} diff --git a/apps/api/internal/ask/reply_test.go b/apps/api/internal/ask/reply_test.go new file mode 100644 index 0000000..ff6974b --- /dev/null +++ b/apps/api/internal/ask/reply_test.go @@ -0,0 +1,40 @@ +package ask + +import ( + "strings" + "testing" + "time" +) + +func TestBuildReply_profileAware(t *testing.T) { + birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC) + out := BuildReply(ReplyInput{ + DisplayName: "我", + BirthDate: birth, + Relation: "self", + Scene: "self", + UserMessage: "我想更了解自己", + }) + if !strings.Contains(out, "我") { + t.Fatalf("expected name in reply: %s", out) + } + if strings.Contains(out, "算命") || strings.Contains(out, "运势") || strings.Contains(out, "吉凶") { + t.Fatalf("forbidden lexicon in reply: %s", out) + } + if !strings.Contains(out, "不构成") { + t.Fatalf("expected disclaimer: %s", out) + } +} + +func TestBuildReply_relationFocus(t *testing.T) { + birth := time.Date(1992, 8, 1, 0, 0, 0, 0, time.UTC) + out := BuildReply(ReplyInput{ + DisplayName: "TA", + BirthDate: birth, + Relation: "other", + UserMessage: "和伴侣沟通总是卡住怎么办", + }) + if !strings.Contains(out, "关系") && !strings.Contains(out, "相处") && !strings.Contains(out, "复述") { + t.Fatalf("expected relation-oriented reply: %s", out) + } +} diff --git a/apps/api/internal/companion/solar.go b/apps/api/internal/companion/solar.go new file mode 100644 index 0000000..4823d43 --- /dev/null +++ b/apps/api/internal/companion/solar.go @@ -0,0 +1,58 @@ +// Package companion provides solar-term tips and mood helpers. +package companion + +import "time" + +// SolarTerm is today's life tip (not fortune). +type SolarTerm struct { + Name string `json:"name"` + Tip string `json:"tip"` + Date string `json:"date"` +} + +var terms = []struct { + Start int + Name string + Tip string +}{ + {1, "小寒", "天冷宜温补作息,少熬夜,给身心留一点缓冲。"}, + {15, "大寒", "注意保暖与情绪稳定,适合整理这一年想放下的事。"}, + {32, "立春", "万物复苏,适合定一个小而清晰的成长目标。"}, + {47, "雨水", "润物无声,试试每天记录一件让你安心的小事。"}, + {62, "惊蛰", "能量回升,适合重启被搁置的习惯,别一次做太多。"}, + {77, "春分", "昼夜均衡,留意工作与休息的平衡,沟通也留余地。"}, + {92, "清明", "适合回顾与告别,把情绪说给信任的人或写下来。"}, + {107, "谷雨", "播种季,把计划拆成可完成的一步即可。"}, + {122, "立夏", "心气易浮,留出安静时段,喝水、慢走都有帮助。"}, + {137, "小满", "不必求满,进度到八成就值得肯定自己。"}, + {152, "芒种", "忙碌中记得停一下,问自己:此刻最需要什么?"}, + {167, "夏至", "白昼最长,保护睡眠,午后可短暂休息恢复专注。"}, + {183, "小暑", "炎热易烦躁,沟通前先降温自己的情绪。"}, + {198, "大暑", "宜清淡饮食与适度运动,别用透支换效率。"}, + {213, "立秋", "开始收敛节奏,复盘上半年,调整下阶段重心。"}, + {228, "处暑", "暑气渐消,适合温和地恢复规律作息。"}, + {243, "白露", "早晚温差大,关照身体的同时关照情绪波动。"}, + {258, "秋分", "又一次平衡点,整理关系与边界,轻装前行。"}, + {273, "寒露", "宜保暖与内观,少责备自己,多一点耐心。"}, + {288, "霜降", "适合沉淀,读一点喜欢的内容,给心灵加温。"}, + {303, "立冬", "进入收藏季,减少无效社交消耗,守护精力。"}, + {318, "小雪", "天寒宜静,可用书写梳理焦虑与期待。"}, + {333, "大雪", "慢下来也是前进,允许自己休息而不内疚。"}, + {348, "冬至", "一阳初生,适合与亲近的人连结,表达感谢。"}, +} + +// TodaySolar returns approximate solar term by day-of-year (not ephemeris). +func TodaySolar(now time.Time) SolarTerm { + doy := now.YearDay() + cur := terms[0] + for _, t := range terms { + if doy >= t.Start { + cur = t + } + } + return SolarTerm{ + Name: cur.Name, + Tip: cur.Tip, + Date: now.Format("2006-01-02"), + } +} diff --git a/apps/api/internal/companion/solar_test.go b/apps/api/internal/companion/solar_test.go new file mode 100644 index 0000000..f8ed04c --- /dev/null +++ b/apps/api/internal/companion/solar_test.go @@ -0,0 +1,20 @@ +package companion + +import ( + "strings" + "testing" + "time" +) + +func TestTodaySolar(t *testing.T) { + term := TodaySolar(time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)) + if term.Name == "" || term.Tip == "" { + t.Fatalf("empty term: %#v", term) + } + blob := term.Name + term.Tip + for _, bad := range []string{"算命", "占卜"} { + if strings.Contains(blob, bad) { + t.Fatalf("forbidden %q", bad) + } + } +} diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 49fc35e..3c29bff 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -1,27 +1,192 @@ -// Package config loads process configuration from environment variables. +// Package config loads process configuration from YAML + optional env overrides. package config -import "os" +import ( + "fmt" + "log" + "net/url" + "os" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) // Config holds runtime settings for the API server. type Config struct { HTTPAddr string DatabaseURL string AppEnv string + DeepSeek DeepSeekConfig } -// Load reads configuration from the environment with safe defaults for local dev. +// DeepSeekConfig for Ask LLM. +type DeepSeekConfig struct { + APIKey string + BaseURL string + Model string + TimeoutSec int +} + +type fileConfig struct { + App struct { + Env string `yaml:"env"` + HTTPAddr string `yaml:"http_addr"` + } `yaml:"app"` + Database struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` + Name string `yaml:"name"` + SSLMode string `yaml:"sslmode"` + } `yaml:"database"` + DeepSeek struct { + APIKey string `yaml:"api_key"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + TimeoutSec int `yaml:"timeout_sec"` + } `yaml:"deepseek"` +} + +// Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides. func Load() Config { - return Config{ - HTTPAddr: getenv("HTTP_ADDR", ":8080"), - DatabaseURL: getenv("DATABASE_URL", "postgres://yuxingu:yuxingu@127.0.0.1:5432/yuxingu?sslmode=disable"), - AppEnv: getenv("APP_ENV", "dev"), + cfg := Config{ + HTTPAddr: ":8080", + DatabaseURL: "postgres://yuxingu:yuxingu@127.0.0.1:5432/yuxingu?sslmode=disable", + AppEnv: "dev", + DeepSeek: DeepSeekConfig{ + BaseURL: "https://api.deepseek.com", + Model: "deepseek-chat", + TimeoutSec: 60, + }, + } + + path := resolveConfigPath() + if path != "" { + if err := mergeFile(&cfg, path); err != nil { + log.Printf("config: load %s: %v (using defaults/env)", path, err) + } else { + log.Printf("config: loaded %s", path) + } + } else { + log.Printf("config: no config.local.yaml found — copy config.example.yaml → config.local.yaml") + } + + applyEnv(&cfg) + return cfg +} + +func resolveConfigPath() string { + if p := os.Getenv("CONFIG_PATH"); p != "" { + return p + } + candidates := []string{ + "config.local.yaml", + "apps/api/config.local.yaml", + } + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && !st.IsDir() { + return c + } + } + return "" +} + +func mergeFile(cfg *Config, path string) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + var f fileConfig + if err := yaml.Unmarshal(raw, &f); err != nil { + return err + } + if f.App.Env != "" { + cfg.AppEnv = f.App.Env + } + if f.App.HTTPAddr != "" { + cfg.HTTPAddr = f.App.HTTPAddr + } + if f.Database.Host != "" || f.Database.User != "" || f.Database.Name != "" { + cfg.DatabaseURL = buildDatabaseURL(f) + } + if f.DeepSeek.APIKey != "" { + cfg.DeepSeek.APIKey = f.DeepSeek.APIKey + } + if f.DeepSeek.BaseURL != "" { + cfg.DeepSeek.BaseURL = strings.TrimRight(f.DeepSeek.BaseURL, "/") + } + if f.DeepSeek.Model != "" { + cfg.DeepSeek.Model = f.DeepSeek.Model + } + if f.DeepSeek.TimeoutSec > 0 { + cfg.DeepSeek.TimeoutSec = f.DeepSeek.TimeoutSec + } + return nil +} + +func buildDatabaseURL(f fileConfig) string { + host := f.Database.Host + if host == "" { + host = "127.0.0.1" + } + port := f.Database.Port + if port == 0 { + port = 5432 + } + user := f.Database.User + if user == "" { + user = "yuxingu" + } + pass := f.Database.Password + name := f.Database.Name + if name == "" { + name = "yuxingu" + } + ssl := f.Database.SSLMode + if ssl == "" { + ssl = "disable" + } + u := url.URL{ + Scheme: "postgres", + User: url.UserPassword(user, pass), + Host: fmt.Sprintf("%s:%d", host, port), + Path: "/" + name, + } + q := u.Query() + q.Set("sslmode", ssl) + u.RawQuery = q.Encode() + return u.String() +} + +func applyEnv(cfg *Config) { + if v := os.Getenv("HTTP_ADDR"); v != "" { + cfg.HTTPAddr = v + } + if v := os.Getenv("DATABASE_URL"); v != "" { + cfg.DatabaseURL = v + } + if v := os.Getenv("APP_ENV"); v != "" { + cfg.AppEnv = v + } + if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { + cfg.DeepSeek.APIKey = v + } + if v := os.Getenv("DEEPSEEK_BASE_URL"); v != "" { + cfg.DeepSeek.BaseURL = strings.TrimRight(v, "/") + } + if v := os.Getenv("DEEPSEEK_MODEL"); v != "" { + cfg.DeepSeek.Model = v + } + if v := os.Getenv("DEEPSEEK_TIMEOUT_SEC"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + cfg.DeepSeek.TimeoutSec = n + } } } -func getenv(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback +// Enabled reports whether DeepSeek can be called. +func (d DeepSeekConfig) Enabled() bool { + return strings.TrimSpace(d.APIKey) != "" } diff --git a/apps/api/internal/explore/catalog.go b/apps/api/internal/explore/catalog.go new file mode 100644 index 0000000..196a65c --- /dev/null +++ b/apps/api/internal/explore/catalog.go @@ -0,0 +1,102 @@ +// Package explore provides the L1→L3 explore catalog. +package explore + +// Item is a leaf tool in the catalog. +type Item struct { + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + Path string `json:"path"` + Icon string `json:"icon"` + Tone string `json:"tone"` + Badge string `json:"badge,omitempty"` +} + +// Category is an L1 explore bucket. +type Category struct { + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + Tone string `json:"tone"` + Items []Item `json:"items"` +} + +// Catalog returns the full explore tree (deterministic, no DB). +// Order: 四核心优先,量表后置。 +func Catalog() []Category { + return []Category{ + { + Key: "decode", Title: "愈心解码", Description: "一个生日,读懂性格与节奏", + Icon: "△", Tone: "gold", + Items: []Item{ + {Key: "portrait", Title: "愈心解码", Description: "生日生成性格解码报告", Path: "/portrait", Icon: "△", Tone: "gold", Badge: "热"}, + }, + }, + { + Key: "star", Title: "星座", Description: "排盘 · 运势 · 合盘", + Icon: "✦", Tone: "night", + Items: []Item{ + {Key: "star-report", Title: "本命排盘", Description: "圆形星盘 / 宫位 / 相位 / 运势", Path: "/star", Icon: "✦", Tone: "night", Badge: "热"}, + {Key: "star-synastry", Title: "合盘", Description: "比较盘与恋爱/友情/婚姻指数", Path: "/synastry", Icon: "✧", Tone: "night", Badge: "AI"}, + }, + }, + { + Key: "relation", Title: "人格匹配", Description: "双人风格对照 · 相处说明书", + Icon: "♡", Tone: "rose", + Items: []Item{ + {Key: "relation-insight", Title: "人格匹配", Description: "行为风格对照与相处建议", Path: "/relation", Icon: "♡", Tone: "rose", Badge: "热"}, + }, + }, + { + Key: "rhythm", Title: "身心节律", Description: "五行倾向与生活节奏建议", + Icon: "☯", Tone: "green", + Items: []Item{ + {Key: "rhythm-report", Title: "身心节律报告", Description: "体质倾向与生活建议", Path: "/rhythm", Icon: "☯", Tone: "green"}, + {Key: "companion-term", Title: "今日节气陪伴", Description: "季节生活小建议", Path: "/companion", Icon: "♡", Tone: "green"}, + }, + }, + { + Key: "cards", Title: "意象卡片", Description: "投射反思,整理当下感受", + Icon: "◈", Tone: "teal", + Items: []Item{ + {Key: "image-cards", Title: "抽取意象卡片", Description: "单卡或三卡组合练习", Path: "/cards", Icon: "◈", Tone: "teal"}, + }, + }, + { + Key: "growth", Title: "情绪与成长", Description: "心情轨迹、成长计划与问答", + Icon: "○", Tone: "green", + Items: []Item{ + {Key: "mood-trail", Title: "心情轨迹", Description: "近七日心情回顾", Path: "/companion", Icon: "○", Tone: "green"}, + {Key: "growth-plan", Title: "成长计划", Description: "小目标与每日打卡", Path: "/growth-plan", Icon: "▽", Tone: "teal"}, + {Key: "ask", Title: "AI 成长助手", Description: "结合档案整理感受", Path: "/ask", Icon: "◎", Tone: "gold"}, + }, + }, + { + Key: "tests", Title: "更多测评", Description: "轻量量表(次要入口)", + Icon: "◆", Tone: "blue", + Items: []Item{ + {Key: "mbti-lite", Title: "人格类型探索", Description: "能量与决策偏好(轻量)", Path: "/scales/mbti-lite", Icon: "◆", Tone: "blue"}, + {Key: "enneagram-lite", Title: "动机模式探索", Description: "内在动机九型向轻测", Path: "/scales/enneagram-lite", Icon: "⑨", Tone: "purple"}, + {Key: "bigfive-lite", Title: "性格五维探索", Description: "稳定特质速览", Path: "/scales/bigfive-lite", Icon: "◈", Tone: "teal"}, + {Key: "love-style", Title: "亲密互动探索", Description: "亲密关系中的互动偏好", Path: "/scales/love-style", Icon: "♡", Tone: "rose"}, + {Key: "eq-lite", Title: "情绪觉察探索", Description: "识别与调节情绪的习惯", Path: "/scales/eq-lite", Icon: "♥", Tone: "pink"}, + {Key: "stress-index", Title: "压力负荷探索", Description: "近期压力与恢复方式", Path: "/scales/stress-index", Icon: "☯", Tone: "green"}, + {Key: "career-interest", Title: "职业兴趣探索", Description: "工作动力与环境偏好", Path: "/scales/career-interest", Icon: "◎", Tone: "gold"}, + {Key: "communication-style", Title: "沟通方式探索", Description: "表达与倾听偏好", Path: "/scales/communication-style", Icon: "◆", Tone: "blue"}, + {Key: "emotion-pattern", Title: "情绪模式探索", Description: "情绪起伏与自我照顾", Path: "/scales/emotion-pattern", Icon: "♥", Tone: "pink"}, + }, + }, + } +} + +// CategoryByKey returns one category or nil. +func CategoryByKey(key string) *Category { + for _, c := range Catalog() { + if c.Key == key { + cp := c + return &cp + } + } + return nil +} diff --git a/apps/api/internal/explore/catalog_test.go b/apps/api/internal/explore/catalog_test.go new file mode 100644 index 0000000..cf11824 --- /dev/null +++ b/apps/api/internal/explore/catalog_test.go @@ -0,0 +1,58 @@ +package explore + +import ( + "strings" + "testing" +) + +func TestCatalogShape(t *testing.T) { + cats := Catalog() + if len(cats) < 6 { + t.Fatalf("want ≥6 L1 categories, got %d", len(cats)) + } + keys := map[string]bool{} + for _, c := range cats { + keys[c.Key] = true + if len(c.Items) < 1 { + t.Fatalf("category %s empty", c.Key) + } + for _, it := range c.Items { + if it.Path == "" || !strings.HasPrefix(it.Path, "/") { + t.Fatalf("bad path %#v", it) + } + } + } + for _, k := range []string{"decode", "star", "relation", "tests", "rhythm", "cards", "growth"} { + if !keys[k] { + t.Fatalf("missing category %s", k) + } + } + // 四核心应排在量表之前 + if indexOf(cats, "tests") < indexOf(cats, "star") { + t.Fatal("tests should come after star") + } +} + +func indexOf(cats []Category, key string) int { + for i, c := range cats { + if c.Key == key { + return i + } + } + return -1 +} + +func TestLexiconHardBanOnly(t *testing.T) { + blob := "" + for _, c := range Catalog() { + blob += c.Title + c.Description + for _, it := range c.Items { + blob += it.Title + it.Description + } + } + for _, bad := range []string{"占卜", "算命", "测测"} { + if strings.Contains(blob, bad) { + t.Fatalf("forbidden %q", bad) + } + } +} diff --git a/apps/api/internal/handler/ask.go b/apps/api/internal/handler/ask.go new file mode 100644 index 0000000..15cf4bd --- /dev/null +++ b/apps/api/internal/handler/ask.go @@ -0,0 +1,121 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + asksvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// AskHandler exposes AI 成长助手 APIs. +type AskHandler struct { + Svc *asksvc.Service +} + +// Register mounts ask routes. +func (h *AskHandler) Register(rg *gin.RouterGroup) { + rg.GET("/ask/quota", h.GetQuota) + rg.POST("/ask/threads", h.CreateThread) + rg.GET("/ask/threads/:id/messages", h.ListMessages) + rg.POST("/ask/threads/:id/messages", h.SendMessage) +} + +// GetQuota handles GET /ask/quota. +func (h *AskHandler) GetQuota(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + q, err := h.Svc.GetQuota(c.Request.Context(), userID) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, q) +} + +// CreateThread handles POST /ask/threads. +func (h *AskHandler) CreateThread(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + ProfileID string `json:"profile_id" binding:"required"` + Scene string `json:"scene"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + pid, err := uuid.Parse(req.ProfileID) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id") + return + } + th, err := h.Svc.CreateThread(c.Request.Context(), userID, asksvc.CreateThreadInput{ + ProfileID: pid, Scene: req.Scene, + }) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40010, err.Error()) + return + } + response.OK(c, th) +} + +// ListMessages handles GET /ask/threads/:id/messages. +func (h *AskHandler) ListMessages(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + tid, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid id") + return + } + items, err := h.Svc.ListMessages(c.Request.Context(), userID, tid) + if err != nil { + response.Fail(c, http.StatusNotFound, 40410, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} + +// SendMessage handles POST /ask/threads/:id/messages. +func (h *AskHandler) SendMessage(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + tid, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid id") + return + } + var req struct { + Content string `json:"content" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + out, err := h.Svc.SendMessage(c.Request.Context(), userID, tid, req.Content) + if err != nil { + if asksvc.IsQuotaExhausted(err) { + response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可开通成长会员获取更多次数") + return + } + response.Fail(c, http.StatusBadRequest, 40011, err.Error()) + return + } + response.OK(c, out) +} diff --git a/apps/api/internal/handler/companion.go b/apps/api/internal/handler/companion.go new file mode 100644 index 0000000..f12e699 --- /dev/null +++ b/apps/api/internal/handler/companion.go @@ -0,0 +1,77 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// CompanionHandler exposes solar terms and moods. +type CompanionHandler struct { + Svc *companionsvc.Service +} + +// Register mounts companion routes. +func (h *CompanionHandler) Register(rg *gin.RouterGroup) { + rg.GET("/solar-terms/today", h.TodaySolar) + rg.POST("/moods", h.SaveMood) + rg.GET("/moods/today", h.TodayMood) +} + +// TodaySolar handles GET /solar-terms/today (auth optional via group). +func (h *CompanionHandler) TodaySolar(c *gin.Context) { + response.OK(c, h.Svc.TodaySolar()) +} + +// SaveMood handles POST /moods. +func (h *CompanionHandler) SaveMood(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + Score *int `json:"score"` + Note *string `json:"note"` + Day *string `json:"day"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + in := companionsvc.SaveMoodInput{Score: req.Score, Note: req.Note} + if req.Day != nil && *req.Day != "" { + d, err := time.Parse("2006-01-02", *req.Day) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid day") + return + } + in.Day = &d + } + m, err := h.Svc.SaveMood(c.Request.Context(), userID, in) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30010, err.Error()) + return + } + response.OK(c, m) +} + +// TodayMood handles GET /moods/today. +func (h *CompanionHandler) TodayMood(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + m, err := h.Svc.GetTodayMood(c.Request.Context(), userID) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"mood": m}) +} diff --git a/apps/api/internal/handler/explore.go b/apps/api/internal/handler/explore.go new file mode 100644 index 0000000..c625d71 --- /dev/null +++ b/apps/api/internal/handler/explore.go @@ -0,0 +1,34 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/yuxingu/digital-psychology/apps/api/internal/explore" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// ExploreHandler serves the explore catalog. +type ExploreHandler struct{} + +// Register mounts explore routes. +func (h *ExploreHandler) Register(rg *gin.RouterGroup) { + rg.GET("/explore/catalog", h.Catalog) + rg.GET("/explore/catalog/:key", h.Category) +} + +// Catalog handles GET /explore/catalog. +func (h *ExploreHandler) Catalog(c *gin.Context) { + response.OK(c, gin.H{"categories": explore.Catalog()}) +} + +// Category handles GET /explore/catalog/:key. +func (h *ExploreHandler) Category(c *gin.Context) { + cat := explore.CategoryByKey(c.Param("key")) + if cat == nil { + response.Fail(c, http.StatusNotFound, 40402, "category not found") + return + } + response.OK(c, cat) +} diff --git a/apps/api/internal/handler/growth.go b/apps/api/internal/handler/growth.go new file mode 100644 index 0000000..147a34b --- /dev/null +++ b/apps/api/internal/handler/growth.go @@ -0,0 +1,130 @@ +package handler + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// GrowthHandler exposes growth plans and mood trail. +type GrowthHandler struct { + Plans *repository.GrowthRepo +} + +// Register mounts growth routes. +func (h *GrowthHandler) Register(rg *gin.RouterGroup) { + rg.GET("/growth/plans", h.ListPlans) + rg.POST("/growth/plans", h.CreatePlan) + rg.POST("/growth/plans/:id/checkin", h.Checkin) + rg.GET("/growth/plans/:id/checkins", h.ListCheckins) + rg.GET("/moods/recent", h.MoodsRecent) +} + +// ListPlans handles GET /growth/plans. +func (h *GrowthHandler) ListPlans(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + items, err := h.Plans.ListPlans(c.Request.Context(), userID) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} + +// CreatePlan handles POST /growth/plans. +func (h *GrowthHandler) CreatePlan(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + Title string `json:"title" binding:"required"` + Focus string `json:"focus"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + title := strings.TrimSpace(req.Title) + if title == "" || len([]rune(title)) > 40 { + response.Fail(c, http.StatusBadRequest, 10000, "invalid title") + return + } + p, err := h.Plans.CreatePlan(c.Request.Context(), userID, title, strings.TrimSpace(req.Focus)) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30020, err.Error()) + return + } + response.OK(c, p) +} + +// Checkin handles POST /growth/plans/:id/checkin. +func (h *GrowthHandler) Checkin(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + pid, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid id") + return + } + var req struct { + Note *string `json:"note"` + } + _ = c.ShouldBindJSON(&req) + out, err := h.Plans.Checkin(c.Request.Context(), userID, pid, time.Now(), req.Note) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30021, err.Error()) + return + } + response.OK(c, out) +} + +// ListCheckins handles GET /growth/plans/:id/checkins. +func (h *GrowthHandler) ListCheckins(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + pid, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid id") + return + } + items, err := h.Plans.ListCheckinsRecent(c.Request.Context(), userID, pid, 14) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} + +// MoodsRecent handles GET /moods/recent. +func (h *GrowthHandler) MoodsRecent(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + items, err := h.Plans.ListMoodsRecent(c.Request.Context(), userID, 7) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} diff --git a/apps/api/internal/handler/imagecard.go b/apps/api/internal/handler/imagecard.go new file mode 100644 index 0000000..07f0676 --- /dev/null +++ b/apps/api/internal/handler/imagecard.go @@ -0,0 +1,81 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" + imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// ImageCardHandler exposes 意象卡片 APIs. +type ImageCardHandler struct { + Svc *imagecardsvc.Service +} + +// Register mounts image-card routes. +func (h *ImageCardHandler) Register(rg *gin.RouterGroup) { + rg.GET("/image-cards/scenes", h.Scenes) + rg.GET("/image-cards/quota", h.Quota) + rg.POST("/image-cards/draw", h.Draw) +} + +// Scenes handles GET /image-cards/scenes. +func (h *ImageCardHandler) Scenes(c *gin.Context) { + response.OK(c, gin.H{"items": h.Svc.Scenes()}) +} + +// Quota handles GET /image-cards/quota. +func (h *ImageCardHandler) Quota(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + q, err := h.Svc.Quota(c.Request.Context(), userID) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, q) +} + +// Draw handles POST /image-cards/draw. +func (h *ImageCardHandler) Draw(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + Scene string `json:"scene"` + ProfileID string `json:"profile_id" binding:"required"` + Depth bool `json:"depth"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + pid, err := uuid.Parse(req.ProfileID) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id") + return + } + out, err := h.Svc.Draw(c.Request.Context(), userID, imagecardsvc.DrawInput{ + Scene: req.Scene, ProfileID: pid, Depth: req.Depth, + }) + if err != nil { + if errors.Is(err, repository.ErrQuotaExhausted) { + response.Fail(c, http.StatusPaymentRequired, 40201, err.Error()) + return + } + response.Fail(c, http.StatusBadRequest, 30011, err.Error()) + return + } + response.OK(c, out) +} diff --git a/apps/api/internal/handler/profile.go b/apps/api/internal/handler/profile.go index 1255d84..d8b209c 100644 --- a/apps/api/internal/handler/profile.go +++ b/apps/api/internal/handler/profile.go @@ -5,6 +5,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" "github.com/yuxingu/digital-psychology/apps/api/internal/service/profile" @@ -20,6 +21,8 @@ type ProfileHandler struct { func (h *ProfileHandler) Register(rg *gin.RouterGroup) { rg.GET("/profiles", h.List) rg.POST("/profiles", h.Create) + rg.PATCH("/profiles/:id", h.Update) + rg.DELETE("/profiles/:id", h.Delete) } type createProfileReq struct { @@ -27,6 +30,8 @@ type createProfileReq struct { DisplayName string `json:"display_name"` BirthDate string `json:"birth_date" binding:"required"` RelationType *string `json:"relation_type"` + BirthTime *string `json:"birth_time"` + BirthPlace *string `json:"birth_place"` } // Create handles POST /profiles. @@ -47,7 +52,8 @@ func (h *ProfileHandler) Create(c *gin.Context) { return } p, err := h.Svc.Create(c.Request.Context(), userID, profile.CreateInput{ - Relation: req.Relation, DisplayName: req.DisplayName, BirthDate: birth, RelationType: req.RelationType, + Relation: req.Relation, DisplayName: req.DisplayName, BirthDate: birth, + RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace, }) if err != nil { response.Fail(c, http.StatusBadRequest, 30001, err.Error()) @@ -70,3 +76,68 @@ func (h *ProfileHandler) List(c *gin.Context) { } response.OK(c, gin.H{"items": list}) } + +// Update handles PATCH /profiles/:id. +func (h *ProfileHandler) Update(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + pid, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid id") + return + } + var req struct { + DisplayName string `json:"display_name"` + BirthDate string `json:"birth_date"` + RelationType *string `json:"relation_type"` + BirthTime *string `json:"birth_time"` + BirthPlace *string `json:"birth_place"` + GeoLat *float64 `json:"geo_lat"` + GeoLng *float64 `json:"geo_lng"` + GeoVisible *bool `json:"geo_visible"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + var birth time.Time + if req.BirthDate != "" { + birth, err = time.Parse("2006-01-02", req.BirthDate) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "birth_date must be YYYY-MM-DD") + return + } + } + p, err := h.Svc.Update(c.Request.Context(), userID, pid, profile.UpdateInput{ + DisplayName: req.DisplayName, BirthDate: birth, RelationType: req.RelationType, + BirthTime: req.BirthTime, BirthPlace: req.BirthPlace, + GeoLat: req.GeoLat, GeoLng: req.GeoLng, GeoVisible: req.GeoVisible, + }) + if err != nil { + response.Fail(c, http.StatusNotFound, 40401, err.Error()) + return + } + response.OK(c, p) +} + +// Delete handles DELETE /profiles/:id. +func (h *ProfileHandler) Delete(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + pid, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid id") + return + } + if err := h.Svc.Delete(c.Request.Context(), userID, pid); err != nil { + response.Fail(c, http.StatusNotFound, 40401, err.Error()) + return + } + response.OK(c, gin.H{"deleted": true}) +} diff --git a/apps/api/internal/handler/report.go b/apps/api/internal/handler/report.go index fb4e3ec..23bd3dd 100644 --- a/apps/api/internal/handler/report.go +++ b/apps/api/internal/handler/report.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -19,7 +20,12 @@ type ReportHandler struct { // Register mounts report/commerce routes. func (h *ReportHandler) Register(rg *gin.RouterGroup) { rg.POST("/reports/portrait", h.CreatePortrait) + rg.POST("/reports/star", h.CreateStar) + rg.POST("/reports/synastry", h.CreateSynastry) + rg.POST("/reports/rhythm", h.CreateRhythm) + rg.GET("/reports", h.List) rg.GET("/reports/:id", h.Get) + rg.GET("/membership/me", h.GetMembership) rg.POST("/orders", h.CreateOrder) rg.POST("/orders/:id/pay-mock", h.PayMock) } @@ -51,6 +57,122 @@ func (h *ReportHandler) CreatePortrait(c *gin.Context) { response.OK(c, rep) } +// CreateStar handles POST /reports/star (星象性格). +func (h *ReportHandler) CreateStar(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + ProfileID string `json:"profile_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + pid, err := uuid.Parse(req.ProfileID) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id") + return + } + rep, err := h.Svc.CreateStar(c.Request.Context(), userID, pid) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30002, err.Error()) + return + } + response.OK(c, rep) +} + +// CreateSynastry handles POST /reports/synastry (五主盘 + 推运合盘). +func (h *ReportHandler) CreateSynastry(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + ProfileIDA string `json:"profile_id_a" binding:"required"` + ProfileIDB string `json:"profile_id_b" binding:"required"` + AsOf *string `json:"as_of"` // YYYY-MM-DD optional + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + aid, err := uuid.Parse(req.ProfileIDA) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id_a") + return + } + bid, err := uuid.Parse(req.ProfileIDB) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id_b") + return + } + if aid == bid { + response.Fail(c, http.StatusBadRequest, 10000, "需要两个不同档案") + return + } + var asOfPtr *time.Time + if req.AsOf != nil && *req.AsOf != "" { + t, err := time.ParseInLocation("2006-01-02", *req.AsOf, time.FixedZone("CST", 8*3600)) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid as_of") + return + } + asOfPtr = &t + } + rep, err := h.Svc.CreateSynastry(c.Request.Context(), userID, aid, bid, asOfPtr) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30002, err.Error()) + return + } + response.OK(c, rep) +} + +// CreateRhythm handles POST /reports/rhythm (身心节律) — wired when service ready. +func (h *ReportHandler) CreateRhythm(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + ProfileID string `json:"profile_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + pid, err := uuid.Parse(req.ProfileID) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id") + return + } + rep, err := h.Svc.CreateRhythm(c.Request.Context(), userID, pid) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30002, err.Error()) + return + } + response.OK(c, rep) +} + +// List handles GET /reports. +func (h *ReportHandler) List(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + items, err := h.Svc.List(c.Request.Context(), userID) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} + // Get handles GET /reports/:id. func (h *ReportHandler) Get(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) @@ -71,6 +193,21 @@ func (h *ReportHandler) Get(c *gin.Context) { response.OK(c, rep) } +// GetMembership handles GET /membership/me. +func (h *ReportHandler) GetMembership(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + me, err := h.Svc.GetMembership(c.Request.Context(), userID) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, me) +} + // CreateOrder handles POST /orders. func (h *ReportHandler) CreateOrder(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) diff --git a/apps/api/internal/handler/synastry.go b/apps/api/internal/handler/synastry.go new file mode 100644 index 0000000..23a3771 --- /dev/null +++ b/apps/api/internal/handler/synastry.go @@ -0,0 +1,123 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/report" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// SynastryHandler exposes nearby + invite social APIs. +type SynastryHandler struct { + Svc *report.Service +} + +// Register mounts synastry social routes. +func (h *SynastryHandler) Register(rg *gin.RouterGroup) { + rg.GET("/synastry/nearby", h.Nearby) + rg.POST("/synastry/invites", h.CreateInvite) + rg.GET("/synastry/invites/:token", h.GetInvite) + rg.POST("/synastry/invites/:token/accept", h.AcceptInvite) +} + +// Nearby handles GET /synastry/nearby?lat=&lng=&radius_km= +func (h *SynastryHandler) Nearby(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + lat, err1 := strconv.ParseFloat(c.Query("lat"), 64) + lng, err2 := strconv.ParseFloat(c.Query("lng"), 64) + if err1 != nil || err2 != nil { + response.Fail(c, http.StatusBadRequest, 10000, "lat/lng required") + return + } + radius := 50.0 + if v := c.Query("radius_km"); v != "" { + if r, err := strconv.ParseFloat(v, 64); err == nil { + radius = r + } + } + items, err := h.Svc.Nearby(c.Request.Context(), userID, lat, lng, radius) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30002, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} + +// CreateInvite handles POST /synastry/invites +func (h *SynastryHandler) CreateInvite(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + ProfileID string `json:"profile_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + pid, err := uuid.Parse(req.ProfileID) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id") + return + } + inv, err := h.Svc.CreateInvite(c.Request.Context(), userID, pid) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30002, err.Error()) + return + } + response.OK(c, gin.H{ + "token": inv.Token, + "expires_at": inv.ExpiresAt, + "path": "/synastry/invite/" + inv.Token, + }) +} + +// GetInvite handles GET /synastry/invites/:token +func (h *SynastryHandler) GetInvite(c *gin.Context) { + if _, ok := middleware.UserIDFromContext(c); !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + meta, err := h.Svc.GetInvite(c.Request.Context(), c.Param("token")) + if err != nil { + response.Fail(c, http.StatusNotFound, 40401, err.Error()) + return + } + response.OK(c, meta) +} + +// AcceptInvite handles POST /synastry/invites/:token/accept +func (h *SynastryHandler) AcceptInvite(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + DisplayName string `json:"display_name"` + BirthDate string `json:"birth_date" binding:"required"` + BirthTime *string `json:"birth_time"` + BirthPlace *string `json:"birth_place"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + rep, err := h.Svc.AcceptInvite(c.Request.Context(), userID, c.Param("token"), req.DisplayName, req.BirthDate, req.BirthTime, req.BirthPlace) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30002, err.Error()) + return + } + response.OK(c, rep) +} diff --git a/apps/api/internal/httpserver/router.go b/apps/api/internal/httpserver/router.go new file mode 100644 index 0000000..27a675f --- /dev/null +++ b/apps/api/internal/httpserver/router.go @@ -0,0 +1,80 @@ +// Package httpserver wires HTTP routes for the 愈心谷 API. +package httpserver + +import ( + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yuxingu/digital-psychology/apps/api/internal/config" + "github.com/yuxingu/digital-psychology/apps/api/internal/handler" + "github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek" + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask" + companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion" + imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/profile" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/relation" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/report" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/scale" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// NewRouter builds the Gin engine with all /api/v1 routes. +func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine { + profileRepo := &repository.ProfileRepo{Pool: pool} + reportRepo := &repository.ReportRepo{Pool: pool} + relationRepo := &repository.RelationRepo{Pool: pool} + askRepo := &repository.AskRepo{Pool: pool} + + var llm *deepseek.Client + if cfg.DeepSeek.Enabled() { + llm = deepseek.New(cfg.DeepSeek) + } + + profileSvc := &profile.Service{Repo: profileRepo} + reportSvc := &report.Service{ + Profiles: profileRepo, + Reports: reportRepo, + Invites: &repository.SynastryInviteRepo{Pool: pool}, + } + relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo} + scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo} + askSvc := &ask.Service{Profiles: profileRepo, Reports: reportRepo, Ask: askRepo, LLM: llm} + companionSvc := &companionsvc.Service{Moods: &repository.MoodRepo{Pool: pool}} + imageCardSvc := &imagecardsvc.Service{ + Profiles: profileRepo, + Reports: reportRepo, + Quotas: &repository.ImageCardRepo{Pool: pool}, + } + + r := gin.New() + r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID()) + r.Use(func(c *gin.Context) { + c.Header("Access-Control-Expose-Headers", "X-Device-Key, X-Request-Id") + c.Next() + }) + + api := r.Group("/api/v1") + handler.NewHealthHandler().Register(api) + api.GET("/ping", func(c *gin.Context) { + response.OK(c, gin.H{"pong": true}) + }) + + authed := api.Group("") + authed.Use(middleware.DeviceAuth(pool)) + (&handler.ProfileHandler{Svc: profileSvc}).Register(authed) + (&handler.ReportHandler{Svc: reportSvc}).Register(authed) + (&handler.SynastryHandler{Svc: reportSvc}).Register(authed) + (&handler.RelationHandler{Svc: relationSvc}).Register(authed) + (&handler.ScaleHandler{Svc: scaleSvc}).Register(authed) + (&handler.AskHandler{Svc: askSvc}).Register(authed) + (&handler.CompanionHandler{Svc: companionSvc}).Register(authed) + (&handler.ImageCardHandler{Svc: imageCardSvc}).Register(authed) + (&handler.ExploreHandler{}).Register(authed) + (&handler.GrowthHandler{ + Plans: &repository.GrowthRepo{Pool: pool}, + }).Register(authed) + + return r +} diff --git a/apps/api/internal/imagecard/deck.go b/apps/api/internal/imagecard/deck.go new file mode 100644 index 0000000..ea07889 --- /dev/null +++ b/apps/api/internal/imagecard/deck.go @@ -0,0 +1,220 @@ +// Package imagecard implements 意象卡片 draws (projection / reflection, not tarot UI). +package imagecard + +import ( + "fmt" + "hash/fnv" + "time" +) + +// Card is one exploration card. +type Card struct { + ID string `json:"id"` + Title string `json:"title"` + Imagery string `json:"imagery"` + Prompt string `json:"prompt"` + Tip string `json:"tip"` +} + +// DrawResult is API payload for a draw. +type DrawResult struct { + Scene string `json:"scene"` + Cards []Card `json:"cards"` + Summary map[string]any `json:"summary"` + Detail map[string]any `json:"detail"` + QuotaLeft int `json:"quota_left"` +} + +var scenes = []string{ + "情绪整理", "关系", "选择", "自我", + "工作节奏", "边界", "休息", "表达", +} + +// Scenes returns available reflection scenes. +func Scenes() []map[string]string { + out := make([]map[string]string, 0, len(scenes)) + for _, s := range scenes { + out = append(out, map[string]string{"key": s, "label": s}) + } + return out +} + +// deck built once: 12 handcrafted + generated to ≥78. +var deck = buildDeck() + +func buildDeck() []Card { + base := []Card{ + {"c01", "微光小路", "一条只够一人走过的小径,尽头有一点光。", "此刻你最想靠近的「光」是什么?", "选一个最小行动靠近它,不必一次走完。"}, + {"c02", "安静的杯子", "桌上杯子里的水是温的,蒸汽慢慢散开。", "你最近在为谁/什么「保温」?有没有过热?", "今天给自己倒一杯水,只为自己停两分钟。"}, + {"c03", "未拆的信", "信封边缘有点磨损,还没打开。", "有哪句话你一直想说却还没说?", "写下来即可,不一定立刻发出。"}, + {"c04", "桥与河", "桥稳稳跨过河,水流在下面经过。", "你现在更需要「过去」还是「停在岸上感受」?", "允许自己选一边,并告诉相关的人。"}, + {"c05", "收拾桌面", "杂物被归类,只留一件重要的东西在中间。", "若只能保留一件「最重要」,会是什么?", "本周减少一件消耗你的杂事。"}, + {"c06", "窗边呼吸", "窗外有风,窗帘轻轻动。", "你的呼吸是浅还是深?身体哪里最紧?", "做 4 次慢呼吸,肩再放松一点。"}, + {"c07", "同行的影子", "两个人影并排,步伐不完全一致。", "关系里你希望对方怎样配合你的节奏?", "用一句具体请求代替抱怨。"}, + {"c08", "种子与土", "土里有一颗刚发芽的种子。", "你正在酝酿、还没被人看见的是什么?", "给它一点时间,并做一个保护边界。"}, + {"c09", "地图折痕", "地图被折过很多次,路线仍可辨认。", "过去哪些弯路其实教了你方法?", "写下一条「下次可以更早用的经验」。"}, + {"c10", "钥匙串", "一串钥匙里有一把还没用过。", "你手头有哪项资源/能力还没启用?", "本周试用一次那个「备用钥匙」。"}, + {"c11", "退潮沙滩", "潮水退去,露出一些小贝壳。", "热闹过后,你发现了什么真实感受?", "把感受写三句,不评判对错。"}, + {"c12", "灯塔节奏", "灯塔规律地亮—灭—亮。", "你的生活里什么节奏让你安心?", "把那个节奏固定进日程一格。"}, + } + themes := []struct{ title, imagery, prompt, tip string }{ + {"山径转弯", "山路在雾里拐了一个弯,远处仍可见脚印。", "你正在避开的弯是什么?", "允许自己慢半步,再决定方向。"}, + {"雨后青石", "雨停了,石板反光,空气很干净。", "刚过去的「雨」带走了什么?", "写下三件你想留下的。"}, + {"未完成的画", "画布只涂了一角,颜料还湿着。", "哪件事值得继续涂,而不是重开一张?", "今天只加一笔就够。"}, + {"旧毛衣", "袖口有点起球,却仍然暖和。", "你生活里哪件「旧物」仍在保护你?", "对它说一句感谢。"}, + {"电梯门", "电梯门开了又关,楼层数字在跳。", "你卡在哪一层不愿下去或上去?", "选一个小出口先离开卡点。"}, + {"夜班台灯", "台灯只照亮桌面一小块。", "你需要把注意力收窄到哪里?", "设 25 分钟专注,然后休息。"}, + {"空座位", "长椅空着一半,另一半有阳光。", "你更需要坐下还是让出位置?", "今天做一个明确的「要/不要」。"}, + {"漂流瓶", "瓶子在水里轻轻晃,纸条卷着。", "你想让谁「偶然」读到你的真心?", "写给自己一封短信即可。"}, + {"鞋带", "鞋带松了,还在走。", "哪里需要先停下来系紧?", "处理一件基础小事再赶路。"}, + {"回声廊", "走廊很长,声音会折返。", "你说出口的话,回来时变成了什么?", "少说一句评判,多问一句好奇。"}, + {"晨雾车站", "站台有雾,车还没到。", "等待里你在消耗还是在准备?", "用等待做一件小事(喝水/伸展)。"}, + {"书架空隙", "两本书之间留了一指宽空隙。", "你生活里需要留白的是哪一块?", "从日程删掉一个非必要。"}, + {"冷水龙头", "水先凉后热,需要等一会儿。", "你对谁/什么事太急了?", "给过程多 10 分钟缓冲。"}, + {"风筝线", "线在手里,风筝在高处晃。", "你抓得太紧的是什么?", "刻意松开一点控制,观察结果。"}, + {"烛火", "火焰小而稳,周围很暗。", "什么能成为你今晚的小光源?", "做一件温暖且短的事。"}, + {"拼图缺角", "图案几乎成型,只差一块。", "缺的那块真的在外面,还是你还没承认?", "列出「已有」而不是「没有」。"}, + {"旧车票", "票根折痕深,日期已过。", "哪段旅程可以温柔结束了?", "做一个小小的告别仪式。"}, + {"晾衣绳", "衣服在风里慢慢干。", "什么事其实只需要时间,不需要用力?", "今天不催自己一次。"}, + {"镜面湖", "湖面几乎无波,倒映天空。", "你害怕看到的自己是哪一面?", "对镜写下三个不评判的观察。"}, + {"登山杖", "杖尖戳进土里,借力往上。", "你愿意向谁借一点力?", "发出一个具体求助。"}, + {"暖手宝", "掌心慢慢回温。", "谁/什么在给你持续的暖?", "今天主动回馈一点点暖。"}, + {"十字路口灯", "红灯停,绿灯行,黄灯提醒。", "你现在更需要停、行,还是减速?", "按信号选一个动作执行。"}, + {"背包重量", "肩带勒出印子。", "包里哪样可以先拿出来?", "卸下一件心理负担并告诉自己。"}, + {"夜路灯", "一盏盏灯把路分段照亮。", "你下一步只需要照亮多远?", "只计划明天上午即可。"}, + {"陶罐裂缝", "裂缝里透出一点光。", "你的「不完美」里藏了什么资源?", "把一个缺点改写成可用特质。"}, + {"梯田", "一层一层往上,水流向下。", "你的成长是哪一层该浇水?", "本周只推进一层目标。"}, + {"耳机静音", "世界声音被隔开一点。", "你需要隔开什么噪音?", "设定一段无信息时段。"}, + {"折纸船", "小船在水盆里打转。", "你把安全感放在哪里?", "做一个可控的小实验。"}, + {"墙洞光", "光从窄缝进来,依然够用。", "有限条件下你仍能做什么?", "用现有资源完成一件小事。"}, + {"沙漏", "沙子安静落下。", "你在跟时间较劲什么?", "接受「够好」的标准一次。"}, + {"回廊座", "走廊尽头有一张椅子。", "你允许自己坐下休息了吗?", "安排 15 分钟真正休息。"}, + {"雾中灯塔", "雾浓,灯仍按节奏闪。", "不确定时什么原则仍不变?", "写下你的一条底线。"}, + {"青苔石", "石头潮湿,青苔慢慢长。", "哪段关系需要慢养而不是快修?", "降低频率,提高质量。"}, + {"晾晒日光", "被子在阳光里松软。", "什么情绪需要「拿出去晒晒」?", "和信任的人聊 10 分钟。"}, + {"圆规", "圆心固定,半径可调。", "你的边界圆心在哪里?", "明确一个「可以/不可以」。"}, + {"夜航图", "星点标出航线。", "你参考的「星」是谁的标准?", "换成自己的三条原则。"}, + {"竹筒饭", "竹香与米香混在一起。", "简单事物里你忽略了什么满足?", "吃一顿专注的饭。"}, + {"软垫", "落地时被接住。", "跌倒时谁/什么接住了你?", "写感谢,或成为别人的软垫一次。"}, + {"窗格影子", "格子把光切成块。", "你把生活切得太碎了吗?", "合并两件可一起做的事。"}, + {"河石", "棱角被水磨圆。", "冲突磨掉了你的什么?留下了什么?", "保留圆润,也保留核心硬度。"}, + {"草稿本", "涂改很多,仍翻得开。", "哪份「草稿」其实已经能见人?", "发布一个 70 分版本。"}, + {"门铃", "轻按一下就会有回应。", "你在等谁来按铃?还是该自己开门?", "主动发起一次连结。"}, + {"冬青", "天冷仍绿。", "什么习惯在难时仍支撑你?", "把那习惯写进日程。"}, + {"秋千", "来回摆,最高点短暂。", "你在追峰值还是稳态?", "选择稳态的一小步。"}, + {"墨点", "一滴墨在宣纸上晕开。", "影响正在扩大的是什么?", "及时止损或顺势引导。"}, + {"绳结", "结打紧了,解需要耐心。", "哪个结值得慢慢解?", "今天只解一层。"}, + {"麦浪", "风过,一片起伏。", "集体节奏里你站在哪?", "允许与主流差半拍。"}, + {"星尘罐", "罐子装不住光,却闪着。", "你收藏的希望是什么?", "拿出来看一眼,再盖上。"}, + {"石阶", "一级一级,并不陡。", "你把台阶看高了吗?", "只上今天这一级。"}, + {"暖汤", "热气模糊了眼镜。", "谁需要一碗「暖」?", "做一件具体的照顾。"}, + {"风铃", "风来才响。", "什么条件一到你就会行动?", "把条件改成更小的触发。"}, + {"书签", "停在未读完的那页。", "你暂停的故事要不要继续?", "读完两页或正式合上。"}, + {"露珠", "很快会消失,却折射天空。", "短暂却珍贵的片刻是什么?", "用心经历,不必抓住。"}, + {"柴门", "门不华丽,却通向家。", "「回家」对你意味着什么?", "安排一次真正放松的回家感。"}, + {"远山", "看得到,走得到需要天。", "远目标如何拆成今日可见的一步?", "写下「今天可见的山脚」。"}, + {"井绳", "要用力,也要节奏。", "你在哪用力过猛?", "改成间歇用力。"}, + {"纸飞机", "飞不远,但方向是自己的。", "小实验可以朝哪飞?", "发出一个低成本试探。"}, + {"苔径", "湿滑,需放慢。", "哪里需要防滑措施?", "加一个缓冲或备份。"}, + {"铜铃", "声音清脆后很快静。", "提醒响过,你听见了吗?", "把提醒变成一个行动。"}, + {"白瓷碗", "空着时也完整。", "空不是缺失,可能是准备。", "留出空白时段不被填满。"}, + {"夜雨窗", "雨点连成线。", "情绪连成片时你怎么分段?", "用呼吸把情绪切成小节。"}, + {"藤椅", "坐下就陷进去一点。", "你允许自己「陷进」休息吗?", "无罪恶感地休息 20 分钟。"}, + {"路牌", "箭头指向两个镇。", "信息不够时如何选?", "选可逆的那条先走。"}, + {"炭火", "表面灰,内里仍热。", "表面平静下还热着的是什么?", "找安全出口表达它。"}, + {"蒲公英", "风一吹就出发。", "你准备好松开了吗?", "松开一个过时期待。"}, + {"石桥栏", "扶手冰凉但可靠。", "你的可靠支持是什么?", "主动靠一下支持系统。"}, + } + out := append([]Card{}, base...) + for i, th := range themes { + out = append(out, Card{ + ID: fmt.Sprintf("c%02d", len(base)+i+1), + Title: th.title, + Imagery: th.imagery, + Prompt: th.prompt, + Tip: th.tip, + }) + } + // pad to at least 78 with deterministic variants + for len(out) < 78 { + i := len(out) + src := base[i%len(base)] + out = append(out, Card{ + ID: fmt.Sprintf("c%02d", i+1), + Title: fmt.Sprintf("%s·续", src.Title), + Imagery: src.Imagery + " 光线又柔和了一点。", + Prompt: src.Prompt, + Tip: src.Tip, + }) + } + return out[:78] +} + +// Draw picks cards deterministically from userID+scene+day for free single, or 3 for deep. +func Draw(userKey, scene string, deep bool, now time.Time) DrawResult { + if scene == "" { + scene = scenes[0] + } + n := 1 + if deep { + n = 3 + } + day := now.UTC().Format("2006-01-02") + idxs := pickIndexes(userKey+"|"+scene+"|"+day, n, len(deck)) + cards := make([]Card, 0, n) + for _, i := range idxs { + cards = append(cards, deck[i]) + } + summary := map[string]any{ + "title": "意象卡片·探索", + "headline": fmt.Sprintf("场景「%s」· %s", scene, cards[0].Title), + "one_liner": cards[0].Imagery, + "overview": fmt.Sprintf("反思:%s", cards[0].Prompt), + "life_tip": cards[0].Tip, + "keywords": []string{"意象卡片", scene, cards[0].Title}, + "scene": scene, + "card_ids": idsOf(cards), + } + var detail map[string]any + if deep && len(cards) >= 3 { + detail = map[string]any{ + "title": "意象卡片·组合反思", + "sections": []map[string]any{ + {"title": cards[0].Title, "body": cards[0].Imagery + " " + cards[0].Prompt, "bullets": []string{cards[0].Tip}}, + {"title": cards[1].Title, "body": cards[1].Imagery + " " + cards[1].Prompt, "bullets": []string{cards[1].Tip}}, + {"title": cards[2].Title, "body": cards[2].Imagery + " " + cards[2].Prompt, "bullets": []string{cards[2].Tip}}, + {"title": "组合练习", "body": "把三张卡连成一个小故事:发生了什么、你感受到什么、下一步一小步是什么。写 5 行即可。", "bullets": []string{"不评判对错", "只选一个可执行下一步", "需要时可去问答继续聊"}}, + }, + "conversation_scripts": []string{"我抽到的意象让我想到……", "我现在需要的是……"}, + "faq": []map[string]string{ + {"q": "这是在预测未来吗?", "a": "不是。意象卡片用于投射与自我反思,不判定好坏,也不预测未来。"}, + }, + } + } + return DrawResult{Scene: scene, Cards: cards, Summary: summary, Detail: detail} +} + +func idsOf(cards []Card) []string { + out := make([]string, len(cards)) + for i, c := range cards { + out[i] = c.ID + } + return out +} + +func pickIndexes(seed string, n, mod int) []int { + h := fnv.New32a() + _, _ = h.Write([]byte(seed)) + start := int(h.Sum32() % uint32(mod)) + out := make([]int, 0, n) + seen := map[int]bool{} + for i := 0; len(out) < n && i < mod*2; i++ { + idx := (start + i*3) % mod + if seen[idx] { + continue + } + seen[idx] = true + out = append(out, idx) + } + return out +} diff --git a/apps/api/internal/imagecard/deck_test.go b/apps/api/internal/imagecard/deck_test.go new file mode 100644 index 0000000..8d1ed20 --- /dev/null +++ b/apps/api/internal/imagecard/deck_test.go @@ -0,0 +1,36 @@ +package imagecard + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestDeckSize(t *testing.T) { + if len(deck) < 78 { + t.Fatalf("want ≥78 cards, got %d", len(deck)) + } + if len(scenes) < 8 { + t.Fatalf("want ≥8 scenes, got %d", len(scenes)) + } +} + +func TestDrawLexicon(t *testing.T) { + out := Draw("u1", "情绪整理", true, time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)) + if len(out.Cards) != 3 { + t.Fatalf("want 3 cards, got %d", len(out.Cards)) + } + raw, _ := json.Marshal(out) + for _, bad := range []string{"算命", "占卜"} { + if strings.Contains(string(raw), bad) { + t.Fatalf("forbidden %q", bad) + } + } +} + +func TestScenes(t *testing.T) { + if len(Scenes()) < 3 { + t.Fatal("too few scenes") + } +} diff --git a/apps/api/internal/integration/explore_catalog_test.go b/apps/api/internal/integration/explore_catalog_test.go new file mode 100644 index 0000000..6874bf3 --- /dev/null +++ b/apps/api/internal/integration/explore_catalog_test.go @@ -0,0 +1,45 @@ +package integration_test + +import ( + "net/http" + "testing" +) + +func TestExploreCatalog(t *testing.T) { + r, _ := setupAPI(t) + var key string + env, key := doJSON(t, r, http.MethodGet, "/api/v1/explore/catalog", nil, key) + data := decodeData[map[string]any](t, env.Data) + cats, ok := data["categories"].([]any) + if !ok || len(cats) < 6 { + t.Fatalf("categories=%#v", data["categories"]) + } + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/explore/catalog/tests", nil, key) + cat := decodeData[map[string]any](t, env.Data) + items, _ := cat["items"].([]any) + if len(items) < 5 { + t.Fatalf("tests items=%d", len(items)) + } +} + +func TestGrowthPlanCheckin(t *testing.T) { + r, _ := setupAPI(t) + var key string + env, key := doJSON(t, r, http.MethodPost, "/api/v1/growth/plans", map[string]any{ + "title": "每晚早睡", "focus": "保护睡眠", + }, key) + plan := decodeData[map[string]any](t, env.Data) + id, _ := plan["id"].(string) + if id == "" { + t.Fatal("missing plan id") + } + _, key = doJSON(t, r, http.MethodPost, "/api/v1/growth/plans/"+id+"/checkin", map[string]any{ + "note": "做到了", + }, key) + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/growth/plans/"+id+"/checkins", nil, key) + out := decodeData[map[string]any](t, env.Data) + items, _ := out["items"].([]any) + if len(items) < 1 { + t.Fatal("expected checkin") + } +} diff --git a/apps/api/internal/integration/p1_flows_test.go b/apps/api/internal/integration/p1_flows_test.go new file mode 100644 index 0000000..74f5fec --- /dev/null +++ b/apps/api/internal/integration/p1_flows_test.go @@ -0,0 +1,308 @@ +package integration_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/yuxingu/digital-psychology/apps/api/internal/config" + "github.com/yuxingu/digital-psychology/apps/api/internal/db" + "github.com/yuxingu/digital-psychology/apps/api/internal/httpserver" +) + +type envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +func setupAPI(t *testing.T) (*gin.Engine, string) { + t.Helper() + gin.SetMode(gin.TestMode) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + t.Cleanup(cancel) + + cfg := config.Load() + pool, err := db.Connect(ctx, cfg.DatabaseURL) + if err != nil { + t.Skipf("postgres unavailable (run npm run deps:up): %v", err) + } + t.Cleanup(pool.Close) + + migDir := filepath.Join("..", "..", "migrations") + if err := db.Migrate(ctx, pool, migDir); err != nil { + t.Fatalf("migrate: %v", err) + } + + return httpserver.NewRouter(pool, cfg), "" +} + +func doJSON(t *testing.T, r http.Handler, method, path string, body any, deviceKey string) (envelope, string) { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatalf("encode: %v", err) + } + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if deviceKey != "" { + req.Header.Set("X-Device-Key", deviceKey) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code >= 500 { + t.Fatalf("%s %s → HTTP %d: %s", method, path, w.Code, w.Body.String()) + } + var env envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode envelope: %v body=%s", err, w.Body.String()) + } + if env.Code != 0 { + t.Fatalf("%s %s → code=%d message=%s", method, path, env.Code, env.Message) + } + key := w.Header().Get("X-Device-Key") + if key == "" { + key = deviceKey + } + return env, key +} + +func decodeData[T any](t *testing.T, raw json.RawMessage) T { + t.Helper() + var v T + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode data: %v raw=%s", err, string(raw)) + } + return v +} + +// Flow 1: create profile → portrait → deep_access mock → detail visible +func TestFlowPortraitDeepAccess(t *testing.T) { + r, _ := setupAPI(t) + var key string + + env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "self", "birth_date": "1990-05-12", "display_name": "我", + }, key) + profile := decodeData[map[string]any](t, env.Data) + profileID, _ := profile["id"].(string) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/portrait", map[string]any{ + "profile_id": profileID, + }, key) + rep := decodeData[map[string]any](t, env.Data) + reportID, _ := rep["id"].(string) + if rep["has_deep_access"] == true { + t.Fatal("expected gated detail before pay") + } + if rep["detail"] != nil { + t.Fatal("detail should be nil before deep access") + } + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{ + "kind": "deep_access", "report_id": reportID, + }, key) + order := decodeData[map[string]any](t, env.Data) + orderID, _ := order["order_id"].(string) + + _, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key) + + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key) + unlocked := decodeData[map[string]any](t, env.Data) + if unlocked["has_deep_access"] != true { + t.Fatal("expected has_deep_access after pay") + } + detail, ok := unlocked["detail"].(map[string]any) + if !ok || len(detail) == 0 { + t.Fatalf("expected detail map, got %#v", unlocked["detail"]) + } + if detail["behavior_pattern"] == nil || detail["behavior_pattern"] == "" { + t.Fatal("expected behavior_pattern in detail") + } +} + +// Flow 2: two profiles → relation insight → deep_access → tips visible +func TestFlowRelationDeepAccess(t *testing.T) { + r, _ := setupAPI(t) + var key string + + env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "self", "birth_date": "1988-03-01", "display_name": "我", + }, key) + a := decodeData[map[string]any](t, env.Data) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "other", "birth_date": "1992-08-20", "display_name": "TA", "relation_type": "partner", + }, key) + b := decodeData[map[string]any](t, env.Data) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/relation/insight", map[string]any{ + "profile_a_id": a["id"], "profile_b_id": b["id"], + }, key) + out := decodeData[map[string]any](t, env.Data) + rep, ok := out["report"].(map[string]any) + if !ok { + t.Fatalf("missing report: %#v", out) + } + reportID, _ := rep["id"].(string) + if rep["has_deep_access"] == true { + t.Fatal("expected gated tips before pay") + } + sum, _ := rep["summary"].(map[string]any) + if sum["love_index"] == nil || sum["friend_index"] == nil || sum["marriage_index"] == nil { + t.Fatalf("expected match indices in summary: %#v", sum) + } + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{ + "kind": "deep_access", "report_id": reportID, + }, key) + orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string) + _, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key) + + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key) + unlocked := decodeData[map[string]any](t, env.Data) + if unlocked["has_deep_access"] != true { + t.Fatal("expected deep access") + } + detail, ok := unlocked["detail"].(map[string]any) + if !ok { + t.Fatal("expected detail") + } + comm, _ := detail["communication"].([]any) + if len(comm) == 0 { + t.Fatalf("expected communication tips, detail=%#v", detail) + } +} + +// Flow 3: membership mock → entitlement → portrait detail without per-report deep_access +func TestFlowMembershipUnlock(t *testing.T) { + r, _ := setupAPI(t) + var key string + + env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "self", "birth_date": "1995-11-07", "display_name": "我", + }, key) + profileID := decodeData[map[string]any](t, env.Data)["id"].(string) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/portrait", map[string]any{ + "profile_id": profileID, + }, key) + reportID := decodeData[map[string]any](t, env.Data)["id"].(string) + + env, key = doJSON(t, r, http.MethodGet, "/api/v1/membership/me", nil, key) + me := decodeData[map[string]any](t, env.Data) + if me["active"] == true { + t.Fatal("expected inactive membership before subscribe") + } + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{ + "kind": "membership", "plan": "month", + }, key) + orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string) + _, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key) + + env, key = doJSON(t, r, http.MethodGet, "/api/v1/membership/me", nil, key) + me = decodeData[map[string]any](t, env.Data) + if me["active"] != true { + t.Fatalf("expected active membership, got %#v", me) + } + + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key) + unlocked := decodeData[map[string]any](t, env.Data) + if unlocked["has_deep_access"] != true { + t.Fatal("membership should unlock report detail") + } + if unlocked["detail"] == nil { + t.Fatal("expected detail via membership") + } +} + +// Flow 5: profile update + soft +func TestFlowProfileUpdateDelete(t *testing.T) { + r, _ := setupAPI(t) + var key string + + env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "other", "birth_date": "1993-04-04", "display_name": "旧名", "relation_type": "friend", + }, key) + id := decodeData[map[string]any](t, env.Data)["id"].(string) + + env, key = doJSON(t, r, http.MethodPatch, "/api/v1/profiles/"+id, map[string]any{ + "display_name": "新名", "birth_date": "1993-04-05", "relation_type": "partner", + }, key) + updated := decodeData[map[string]any](t, env.Data) + if updated["display_name"] != "新名" { + t.Fatalf("display_name not updated: %#v", updated) + } + + _, key = doJSON(t, r, http.MethodDelete, "/api/v1/profiles/"+id, nil, key) + + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, key) + items, _ := decodeData[map[string]any](t, env.Data)["items"].([]any) + for _, it := range items { + m := it.(map[string]any) + if m["id"] == id { + t.Fatal("deleted profile still listed") + } + } +} + +// Flow 4: profile → ask thread → message → assistant reply + quota +func TestFlowAskThread(t *testing.T) { + r, _ := setupAPI(t) + var key string + + env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "self", "birth_date": "1991-02-14", "display_name": "我", + }, key) + profileID := decodeData[map[string]any](t, env.Data)["id"].(string) + + env, key = doJSON(t, r, http.MethodGet, "/api/v1/ask/quota", nil, key) + q0 := decodeData[map[string]any](t, env.Data) + rem0, _ := q0["remaining"].(float64) + if rem0 < 1 { + t.Fatalf("expected free quota, got %#v", q0) + } + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{ + "profile_id": profileID, "scene": "self", + }, key) + threadID := decodeData[map[string]any](t, env.Data)["id"].(string) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{ + "content": "我想更了解自己", + }, key) + out := decodeData[map[string]any](t, env.Data) + asst, ok := out["assistant_message"].(map[string]any) + if !ok { + t.Fatalf("missing assistant_message: %#v", out) + } + content, _ := asst["content"].(string) + if content == "" { + t.Fatal("empty assistant reply") + } + q1, ok := out["quota"].(map[string]any) + if !ok { + t.Fatal("missing quota") + } + rem1, _ := q1["remaining"].(float64) + if rem1 != rem0-1 { + t.Fatalf("quota should decrease: before=%v after=%v", rem0, rem1) + } + + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/ask/threads/"+threadID+"/messages", nil, key) + items := decodeData[map[string]any](t, env.Data)["items"].([]any) + if len(items) < 2 { + t.Fatalf("expected user+assistant history, got %d", len(items)) + } +} diff --git a/apps/api/internal/integration/p2_flows_test.go b/apps/api/internal/integration/p2_flows_test.go new file mode 100644 index 0000000..672a66c --- /dev/null +++ b/apps/api/internal/integration/p2_flows_test.go @@ -0,0 +1,222 @@ +package integration_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func doJSONExpect(t *testing.T, r http.Handler, method, path string, body any, deviceKey string, wantCode int) (envelope, string, int) { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatalf("encode: %v", err) + } + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if deviceKey != "" { + req.Header.Set("X-Device-Key", deviceKey) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code >= 500 { + t.Fatalf("%s %s → HTTP %d: %s", method, path, w.Code, w.Body.String()) + } + var env envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode envelope: %v body=%s", err, w.Body.String()) + } + if env.Code != wantCode { + t.Fatalf("%s %s → code=%d want=%d message=%s body=%s", method, path, env.Code, wantCode, env.Message, w.Body.String()) + } + key := w.Header().Get("X-Device-Key") + if key == "" { + key = deviceKey + } + return env, key, w.Code +} + +func assertNoForbidden(t *testing.T, blob string) { + t.Helper() + for _, bad := range []string{"算命"} { + if strings.Contains(blob, bad) { + t.Fatalf("forbidden lexicon %q in response", bad) + } + } +} + +func createSelfProfile(t *testing.T, r http.Handler, birth, key string) (profileID, newKey string) { + t.Helper() + env, newKey := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "self", "birth_date": birth, "display_name": "我", + }, key) + return decodeData[map[string]any](t, env.Data)["id"].(string), newKey +} + +// Flow: star report → gated detail → mock deep unlock +func TestFlowStarDeepAccess(t *testing.T) { + r, _ := setupAPI(t) + pid, key := createSelfProfile(t, r, "1983-06-06", "") + + env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/star", map[string]any{ + "profile_id": pid, + }, key) + rep := decodeData[map[string]any](t, env.Data) + assertNoForbidden(t, string(env.Data)) + if rep["type"] != "star" { + t.Fatalf("type=%v", rep["type"]) + } + if rep["has_deep_access"] == true || rep["detail"] != nil { + t.Fatal("expected gated star detail") + } + sum, _ := rep["summary"].(map[string]any) + if sum["headline"] == nil || sum["headline"] == "" { + t.Fatalf("missing headline: %#v", sum) + } + if sum["fortune"] == nil || sum["planets"] == nil { + t.Fatalf("expected fortune+planets in star summary: %#v", sum) + } + reportID := rep["id"].(string) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{ + "kind": "deep_access", "report_id": reportID, + }, key) + orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string) + _, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key) + + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key) + unlocked := decodeData[map[string]any](t, env.Data) + if unlocked["has_deep_access"] != true { + t.Fatal("expected deep after pay") + } + detail, ok := unlocked["detail"].(map[string]any) + if !ok || detail["sections"] == nil { + t.Fatalf("expected sections, got %#v", unlocked["detail"]) + } +} + +// Flow: rhythm report gated + unlock +func TestFlowRhythmDeepAccess(t *testing.T) { + r, _ := setupAPI(t) + pid, key := createSelfProfile(t, r, "1992-06-08", "") + + env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/rhythm", map[string]any{ + "profile_id": pid, + }, key) + rep := decodeData[map[string]any](t, env.Data) + assertNoForbidden(t, string(env.Data)) + if rep["type"] != "rhythm" { + t.Fatalf("type=%v", rep["type"]) + } + if rep["detail"] != nil { + t.Fatal("expected gated rhythm detail") + } + reportID := rep["id"].(string) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{ + "kind": "deep_access", "report_id": reportID, + }, key) + orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string) + _, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key) + + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key) + unlocked := decodeData[map[string]any](t, env.Data) + if unlocked["has_deep_access"] != true { + t.Fatal("expected deep") + } + if unlocked["detail"] == nil { + t.Fatal("expected detail after unlock") + } +} + +// Flow: image card scenes → draw → quota exhaust → depth unlock via mock pay +func TestFlowImageCardQuotaAndDepth(t *testing.T) { + r, _ := setupAPI(t) + pid, key := createSelfProfile(t, r, "1990-01-01", "") + + env, key := doJSON(t, r, http.MethodGet, "/api/v1/image-cards/scenes", nil, key) + scenes := decodeData[map[string]any](t, env.Data) + items, _ := scenes["items"].([]any) + if len(items) < 1 { + t.Fatal("expected scenes") + } + + env, key = doJSON(t, r, http.MethodGet, "/api/v1/image-cards/quota", nil, key) + q := decodeData[map[string]any](t, env.Data) + if int(q["remaining"].(float64)) < 1 { + t.Fatalf("expected free quota, got %#v", q) + } + + var reportID string + for i := 0; i < 3; i++ { + env, key = doJSON(t, r, http.MethodPost, "/api/v1/image-cards/draw", map[string]any{ + "profile_id": pid, "scene": "情绪整理", "depth": true, + }, key) + out := decodeData[map[string]any](t, env.Data) + assertNoForbidden(t, string(env.Data)) + cards, _ := out["cards"].([]any) + if len(cards) < 1 { + t.Fatalf("draw %d: no cards", i) + } + rep, _ := out["report"].(map[string]any) + reportID, _ = rep["id"].(string) + if rep["has_deep_access"] == true { + t.Fatal("free user should not have deep on draw") + } + } + + _, key, httpStatus := doJSONExpect(t, r, http.MethodPost, "/api/v1/image-cards/draw", map[string]any{ + "profile_id": pid, "scene": "情绪整理", + }, key, 40201) + if httpStatus != http.StatusPaymentRequired { + t.Fatalf("want HTTP 402, got %d", httpStatus) + } + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{ + "kind": "deep_access", "report_id": reportID, + }, key) + orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string) + _, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key) + + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key) + unlocked := decodeData[map[string]any](t, env.Data) + if unlocked["has_deep_access"] != true { + t.Fatal("expected deep on image_card report") + } + if unlocked["type"] != "image_card" { + t.Fatalf("type=%v", unlocked["type"]) + } +} + +// Flow: solar terms + mood save/read +func TestFlowCompanionMood(t *testing.T) { + r, _ := setupAPI(t) + var key string + + env, key := doJSON(t, r, http.MethodGet, "/api/v1/solar-terms/today", nil, key) + term := decodeData[map[string]any](t, env.Data) + assertNoForbidden(t, string(env.Data)) + if term["name"] == nil || term["tip"] == nil { + t.Fatalf("bad solar term: %#v", term) + } + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/moods", map[string]any{ + "score": 4, "note": "今天还不错", + }, key) + mood := decodeData[map[string]any](t, env.Data) + if mood["score"].(float64) != 4 { + t.Fatalf("score=%v", mood["score"]) + } + + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/moods/today", nil, key) + today := decodeData[map[string]any](t, env.Data) + m, ok := today["mood"].(map[string]any) + if !ok || m["score"].(float64) != 4 { + t.Fatalf("today mood=%#v", today) + } +} diff --git a/apps/api/internal/integration/synastry_test.go b/apps/api/internal/integration/synastry_test.go new file mode 100644 index 0000000..89d5208 --- /dev/null +++ b/apps/api/internal/integration/synastry_test.go @@ -0,0 +1,87 @@ +package integration_test + +import ( + "net/http" + "testing" +) + +func TestFlowSynastryMultiChartsAndInvite(t *testing.T) { + r, _ := setupAPI(t) + var key string + + env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "self", "birth_date": "1990-05-12", "display_name": "我", + "birth_time": "10:30", "birth_place": "上海", + }, key) + pa := decodeData[map[string]any](t, env.Data) + idA, _ := pa["id"].(string) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "other", "birth_date": "1992-08-20", "display_name": "TA", + "birth_time": "08:00", "birth_place": "北京", + }, key) + pb := decodeData[map[string]any](t, env.Data) + idB, _ := pb["id"].(string) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/synastry", map[string]any{ + "profile_id_a": idA, "profile_id_b": idB, "as_of": "2026-08-02", + }, key) + rep := decodeData[map[string]any](t, env.Data) + if rep["type"] != "synastry" { + t.Fatalf("type=%v", rep["type"]) + } + sum, _ := rep["summary"].(map[string]any) + if sum == nil { + t.Fatal("summary nil") + } + if sum["as_of"] != "2026-08-02" { + t.Fatalf("as_of=%v", sum["as_of"]) + } + charts, _ := sum["charts"].(map[string]any) + if charts == nil { + t.Fatal("charts missing") + } + for _, k := range []string{ + "compare", "composite", "davison", "marks_me", "marks_other", "overlay", + "composite_progressed", "davison_progressed", "marks_progressed", + } { + if charts[k] == nil { + t.Fatalf("missing charts.%s", k) + } + } + // no deep access → detail stripped + if rep["has_deep_access"] == true { + t.Fatal("expected no deep access") + } + if rep["detail"] != nil { + t.Fatalf("detail should be stripped, got %#v", rep["detail"]) + } + + // invite flow: second device accepts + env, key = doJSON(t, r, http.MethodPost, "/api/v1/synastry/invites", map[string]any{ + "profile_id": idA, + }, key) + inv := decodeData[map[string]any](t, env.Data) + token, _ := inv["token"].(string) + if token == "" { + t.Fatal("empty token") + } + + env2, key2 := doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "") + _ = env2 + env2, key2 = doJSON(t, r, http.MethodGet, "/api/v1/synastry/invites/"+token, nil, key2) + meta := decodeData[map[string]any](t, env2.Data) + if meta["host_name"] == nil { + t.Fatal("host_name missing") + } + + env2, key2 = doJSON(t, r, http.MethodPost, "/api/v1/synastry/invites/"+token+"/accept", map[string]any{ + "display_name": "好友", "birth_date": "1995-03-03", + }, key2) + acc := decodeData[map[string]any](t, env2.Data) + if acc["type"] != "synastry" { + t.Fatalf("accept type=%v", acc["type"]) + } + _ = key + _ = key2 +} diff --git a/apps/api/internal/llm/deepseek/client.go b/apps/api/internal/llm/deepseek/client.go new file mode 100644 index 0000000..f851d82 --- /dev/null +++ b/apps/api/internal/llm/deepseek/client.go @@ -0,0 +1,118 @@ +// Package deepseek calls DeepSeek OpenAI-compatible Chat Completions API. +package deepseek + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/config" +) + +// Message is one chat turn. +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// Client talks to DeepSeek. +type Client struct { + cfg config.DeepSeekConfig + http *http.Client +} + +// New builds a client from config. +func New(cfg config.DeepSeekConfig) *Client { + sec := cfg.TimeoutSec + if sec <= 0 { + sec = 60 + } + return &Client{ + cfg: cfg, + http: &http.Client{ + Timeout: time.Duration(sec) * time.Second, + }, + } +} + +// Enabled mirrors config. +func (c *Client) Enabled() bool { + return c != nil && c.cfg.Enabled() +} + +type chatRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Stream bool `json:"stream"` +} + +type chatResponse struct { + Choices []struct { + Message Message `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// Chat sends messages and returns assistant text. +func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) { + if !c.Enabled() { + return "", fmt.Errorf("deepseek api_key not configured") + } + base := c.cfg.BaseURL + if base == "" { + base = "https://api.deepseek.com" + } + model := c.cfg.Model + if model == "" { + model = "deepseek-chat" + } + + body, err := json.Marshal(chatRequest{Model: model, Messages: messages, Stream: false}) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) + + res, err := c.http.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + raw, err := io.ReadAll(io.LimitReader(res.Body, 2<<20)) + if err != nil { + return "", err + } + var out chatResponse + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("deepseek decode: %w body=%s", err, truncate(string(raw), 200)) + } + if out.Error != nil && out.Error.Message != "" { + return "", fmt.Errorf("deepseek: %s", out.Error.Message) + } + if res.StatusCode >= 300 { + return "", fmt.Errorf("deepseek HTTP %d: %s", res.StatusCode, truncate(string(raw), 200)) + } + if len(out.Choices) == 0 || strings.TrimSpace(out.Choices[0].Message.Content) == "" { + return "", fmt.Errorf("deepseek empty choices") + } + return strings.TrimSpace(out.Choices[0].Message.Content), nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/apps/api/internal/llm/deepseek/client_test.go b/apps/api/internal/llm/deepseek/client_test.go new file mode 100644 index 0000000..e6d417c --- /dev/null +++ b/apps/api/internal/llm/deepseek/client_test.go @@ -0,0 +1,47 @@ +package deepseek + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/yuxingu/digital-psychology/apps/api/internal/config" +) + +func TestChat_success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Fatalf("auth header: %s", r.Header.Get("Authorization")) + } + var req chatRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.Model != "deepseek-chat" { + t.Fatalf("model=%s", req.Model) + } + _ = json.NewEncoder(w).Encode(chatResponse{ + Choices: []struct { + Message Message `json:"message"` + }{{Message: Message{Role: "assistant", Content: "你好,我是成长助手"}}}, + }) + })) + defer srv.Close() + + c := New(config.DeepSeekConfig{ + APIKey: "test-key", BaseURL: srv.URL, Model: "deepseek-chat", TimeoutSec: 5, + }) + out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}) + if err != nil { + t.Fatal(err) + } + if out != "你好,我是成长助手" { + t.Fatalf("got %q", out) + } +} + +func TestEnabled(t *testing.T) { + if New(config.DeepSeekConfig{}).Enabled() { + t.Fatal("empty key should disable") + } +} diff --git a/apps/api/internal/model/ask.go b/apps/api/internal/model/ask.go new file mode 100644 index 0000000..2fda69d --- /dev/null +++ b/apps/api/internal/model/ask.go @@ -0,0 +1,25 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +// AskThread binds a conversation to a profile. +type AskThread struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + ProfileID uuid.UUID `json:"profile_id"` + Scene *string `json:"scene,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// AskMessage is one turn in a thread. +type AskMessage struct { + ID uuid.UUID `json:"id"` + ThreadID uuid.UUID `json:"thread_id"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/apps/api/internal/model/growth.go b/apps/api/internal/model/growth.go new file mode 100644 index 0000000..ae86eed --- /dev/null +++ b/apps/api/internal/model/growth.go @@ -0,0 +1,27 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +// GrowthPlan is a small personal growth goal. +type GrowthPlan struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Title string `json:"title"` + Focus string `json:"focus"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// GrowthCheckin is a daily check-in on a plan. +type GrowthCheckin struct { + ID uuid.UUID `json:"id"` + PlanID uuid.UUID `json:"plan_id"` + UserID uuid.UUID `json:"user_id"` + Day string `json:"day"` + Note *string `json:"note,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/apps/api/internal/model/mood.go b/apps/api/internal/model/mood.go new file mode 100644 index 0000000..20af4ff --- /dev/null +++ b/apps/api/internal/model/mood.go @@ -0,0 +1,17 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +// Mood is a daily mood check-in. +type Mood struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Day string `json:"day"` + Score *int `json:"score,omitempty"` + Note *string `json:"note,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/apps/api/internal/model/profile.go b/apps/api/internal/model/profile.go index 307e09b..6f904c6 100644 --- a/apps/api/internal/model/profile.go +++ b/apps/api/internal/model/profile.go @@ -8,14 +8,30 @@ import ( // Profile is a personal archive (self or other). type Profile struct { - ID uuid.UUID `json:"id"` - UserID uuid.UUID `json:"user_id"` - Relation string `json:"relation"` - DisplayName string `json:"display_name"` - BirthDate time.Time `json:"birth_date"` - BirthTime *string `json:"birth_time,omitempty"` - BirthPlace *string `json:"birth_place,omitempty"` - Gender *string `json:"gender,omitempty"` - RelationType *string `json:"relation_type,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Relation string `json:"relation"` + DisplayName string `json:"display_name"` + BirthDate time.Time `json:"birth_date"` + BirthTime *string `json:"birth_time,omitempty"` + BirthPlace *string `json:"birth_place,omitempty"` + Gender *string `json:"gender,omitempty"` + RelationType *string `json:"relation_type,omitempty"` + GeoLat *float64 `json:"geo_lat,omitempty"` + GeoLng *float64 `json:"geo_lng,omitempty"` + GeoVisible bool `json:"geo_visible"` + CreatedAt time.Time `json:"created_at"` +} + +// SynastryInvite is a shareable pair invite. +type SynastryInvite struct { + ID uuid.UUID `json:"id"` + Token string `json:"token"` + HostUserID uuid.UUID `json:"host_user_id"` + HostProfileID uuid.UUID `json:"host_profile_id"` + ExpiresAt time.Time `json:"expires_at"` + GuestUserID *uuid.UUID `json:"guest_user_id,omitempty"` + GuestProfileID *uuid.UUID `json:"guest_profile_id,omitempty"` + ReportID *uuid.UUID `json:"report_id,omitempty"` + CreatedAt time.Time `json:"created_at"` } diff --git a/apps/api/internal/portrait/constitution_data.go b/apps/api/internal/portrait/constitution_data.go new file mode 100644 index 0000000..9128dd7 --- /dev/null +++ b/apps/api/internal/portrait/constitution_data.go @@ -0,0 +1,123 @@ +package portrait + +type ConstitutionDetail struct { + Primary string + Secondary string + Strong string + Weak string + Desc string + Body string + Tips string + Diseases string + SeasonRisk string + FoodTherapy string + DailyRhythm string +} + +var constitutionByElement = map[string]ConstitutionDetail{ + "木": { + Primary: "气郁质", + Secondary: "血瘀质", + Strong: "肝", + Weak: "脾", + Desc: "你的身体像一棵需要舒展的大树。肝气就像树的枝叶,需要自由伸展才能舒畅。你积极进取,但也容易因压力而紧绷——胸闷、偏头痛、情绪波动都是身体在说\"我需要透透气\"。春天是你最敏感的季节,别把情绪关在心里。", + Body: "体型偏瘦或适中,面色偏青黄。指甲容易脆裂,眼睛容易干涩——这都是肝血在提醒你。春季容易过敏,舌边偏红。", + Tips: "疏肝理气是头等大事。多运动、多表达、别把委屈憋在心里。玫瑰花茶是你的好朋友,明亮通风的环境就是你的充电站。", + Diseases: "偏头痛、高血压、甲状腺结节、乳腺增生、月经不调、胁间神经痛。长期压抑情绪会\"化火伤阴\"。", + SeasonRisk: "春天最敏感——过敏、血压波动。秋天金克木,容易情绪低落。", + FoodTherapy: "绿色蔬菜是主角(菠菜、芹菜、西兰花)。少来点酸的(山楂、柠檬),别过量。玫瑰花茶、薄荷茶疏肝解郁。荞麦、小米养肝血。", + DailyRhythm: "早上醒来拉伸5分钟,像猫一样舒展。上午11点前搞定重要决策(肝气最旺)。晚上11点前入睡养肝血。", + }, + "火": { + Primary: "湿热质", + Secondary: "阴虚质", + Strong: "心", + Weak: "肺", + Desc: "你的内心有一团温暖的火。热情、感染力强是你的超能力,但这团火也容易烧得太旺——心浮气躁、失眠多梦、口腔溃疡都是信号。夏天是你最需要注意的季节,学会给内心降温。", + Body: "面色偏红,手心脚心热乎乎的,容易出汗。皮肤容易出油长痘,舌尖偏红。夏天尤其难熬,总想喝冰的。", + Tips: "清心降火、静养心神。午休(11-13点)是黄金养心时间。莲子心茶、菊花茶是你的清凉剂。少熬夜,少参与激烈争吵——伤的是自己。", + Diseases: "失眠、口腔溃疡反复、心悸焦虑、高血压、皮肤痘痘。严重时心火下移,可能引起尿路问题。", + SeasonRisk: "夏天最敏感——暑热+心火,容易心烦中暑。冬天水克火,注意防寒护心。", + FoodTherapy: "苦味清心(苦瓜、莲子心)。红色食物养心(红枣、枸杞、番茄)。绿豆汤、百合莲子羹清心安神。西瓜、黄瓜解暑生津。", + DailyRhythm: "午时(11-13点)小憩15-30分钟,像给手机充电。傍晚微微出汗的运动最好。睡前温水泡脚,把火气引下去。", + }, + "土": { + Primary: "痰湿质", + Secondary: "气虚质", + Strong: "脾", + Weak: "肾", + Desc: "你像大地一样稳重可靠,是所有人都想依靠的人。但思虑太多会伤脾——饭后犯困、身体沉重、大便不成形,都是脾胃在喊累。长夏湿热季节是你最需要照顾自己的时候。脾胃好,一切都好。", + Body: "体型偏丰满,肌肉松软,腹部容易囤积。面色偏黄,舌头胖大。梅雨季会特别不舒服,头重脚轻。", + Tips: "健脾祛湿是核心功课。吃饭七分饱,细嚼慢咽——这不是规矩,是爱自己。山药薏米粥、陈皮茶都是好帮手。别老坐着,动起来帮脾胃运转。", + Diseases: "肥胖、高血脂、脂肪肝、慢性胃炎、消化不良、眩晕、湿疹。", + SeasonRisk: "长夏(7-8月)最难熬——湿热交蒸,浑身沉重。春天肝气犯脾,容易胃痛腹泻。", + FoodTherapy: "甘淡的食物最养脾(山药、茯苓、薏米)。黄色食物是脾胃的好朋友(小米、南瓜、玉米)。陈皮理气,生姜暖中。冰的、甜的、腻的——少碰。", + DailyRhythm: "早餐7-9点必须吃好吃饱(胃经当令)。饭后散步10分钟助消化。晚餐早一点、少一点。", + }, + "金": { + Primary: "阴虚质", + Secondary: "气郁质", + Strong: "肺", + Weak: "肝", + Desc: "你像一把精密的乐器,条理分明、追求完美。但肺为\"娇脏\",怕燥怕干——皮肤干燥、秋冬咳嗽、喉咙不适都是它在提醒你。秋天是你最要呵护自己的季节,给自己多一点滋润。", + Body: "偏瘦,皮肤容易干燥起皮,嘴唇干裂。手心脚心热,晚上容易盗汗。秋天特别敏感,容易便秘。", + Tips: "滋阴润肺是关键。白色食物是你的好朋友(银耳、百合、雪梨)。辛辣烧烤油炸——能躲就躲。早睡比什么都养阴,腹式呼吸能帮你增强肺力。", + Diseases: "慢性咽炎、干咳、支气管炎、皮肤干燥、便秘、过敏性鼻炎、荨麻疹。", + SeasonRisk: "秋天最敏感——燥邪伤肺,干咳咽痒。夏天火克金,心肺都要护。", + FoodTherapy: "白色食物润肺(银耳、百合、山药、雪梨、莲藕)。蜂蜜、麦冬、沙参滋阴润燥。菊花、薄荷清肺。远离辛辣、烧烤、烟酒。", + DailyRhythm: "清晨(5-7点大肠经)排清宿便。上午深呼吸5分钟。午后吃个梨或喝银耳羹。晚上9点前入睡养肺阴。保持房间湿度50%-60%。", + }, + "水": { + Primary: "阳虚质", + Secondary: "痰湿质", + Strong: "肾", + Weak: "心", + Desc: "你像深潭里的水,沉静而有深度。智慧是你的底色,但阳气不足会让你畏寒怕冷、腰膝酸软、缺乏安全感。肾是先天之本,冬天是你最需要温暖的季节——对自己好一点,从保暖开始。", + Body: "体型偏丰满或有浮肿感,面色偏白或晦暗。手脚冰凉,晚上起夜多,头发容易掉。腰膝冷痛是常见信号。", + Tips: "温阳补肾是首要任务。多晒太阳,特别是晒后背——那是你的\"阳气充电板\"。艾灸关元、命门、腰要暖、脚要热。黑豆、核桃、肉桂都是你的好朋友。", + Diseases: "慢性肾炎、甲减、骨质疏松、腰椎问题、耳鸣耳聋、不孕不育。寒湿导致关节痛。", + SeasonRisk: "冬天最难熬——寒气伤阳,浑身发冷。长夏湿气困肾,水肿加重。", + FoodTherapy: "黑色食物补肾(黑豆、黑芝麻、黑米、桑葚)。核桃、山药、枸杞温补肾阳。海带、紫菜适量。冰镇饮料、生冷瓜果——戒了。", + DailyRhythm: "早晨7-9点晒太阳15分钟补阳气。上午搓腰眼至发热。下午5-7点(肾经当令)休息养肾。晚上热水泡脚至微微出汗。冬天早睡晚起。", + }, +} + +type EmotionProfile struct { + Strength []string + Risk []string + Advice string + Color string +} + +var emotionByElement = map[string]EmotionProfile{ + "木": { + Strength: []string{"创造力", "决策力", "学习能力", "进取心", "目标感"}, + Risk: []string{"焦虑", "易怒", "急躁", "偏头痛倾向", "经前紧张(女)"}, + Advice: "遇事深呼吸三秒再回应。每日快走30分钟疏解肝气。学习正念冥想。", + Color: "绿", + }, + "火": { + Strength: []string{"热情", "感染力", "表达力", "社交能力", "行动力"}, + Risk: []string{"失眠", "焦躁", "心神不宁", "多梦", "舌尖溃疡"}, + Advice: "午时(11-13点)小憩养心。少刷手机,睡前温水泡脚。练习书法静心。", + Color: "红", + }, + "土": { + Strength: []string{"稳重", "包容", "耐心", "执行力", "可信赖"}, + Risk: []string{"思虑过度", "脾胃不适", "优柔寡断", "过度操心"}, + Advice: "饭后散步10分钟助运化。学会说\"不\",减轻负担。培养一个动手爱好。", + Color: "黄", + }, + "金": { + Strength: []string{"条理", "决断", "专注", "毅力", "原则性"}, + Risk: []string{"悲伤", "忧愁", "呼吸道敏感", "皮肤干燥", "便秘"}, + Advice: "练习腹式呼吸强肺。多与朋友交流倾诉。保持环境湿度。", + Color: "白", + }, + "水": { + Strength: []string{"智慧", "洞察", "冷静", "适应力", "深度思考"}, + Risk: []string{"恐惧", "孤独感", "腰膝酸软", "听力下降", "记忆力减退"}, + Advice: "冬季注意腰腹保暖。练习站桩固肾。多听音乐舒缓恐惧。", + Color: "黑", + }, +} diff --git a/apps/api/internal/portrait/engine.go b/apps/api/internal/portrait/engine.go index fffde1b..2ade9ba 100644 --- a/apps/api/internal/portrait/engine.go +++ b/apps/api/internal/portrait/engine.go @@ -1,5 +1,4 @@ -// Package portrait builds deterministic personal-portrait content from birth date. -// Copy follows .ai/product/lexicon.md — exploration / analysis, not fortune-telling. +// Package portrait builds 愈心解码 content from birth date (classic decode engine). package portrait import ( @@ -13,134 +12,202 @@ type Output struct { Detail map[string]any `json:"detail"` } -// Build generates 个人画像 content from a birth date (deterministic). +// Build generates 愈心解码 content (deterministic for a given birth date). func Build(birth time.Time, displayName string) Output { y, m, d := birth.Date() - num := reduce(y) + reduce(int(m)) + reduce(d) - for num > 9 { - num = reduce(num) - } - trait := traits[num%len(traits)] + tri := Calc(y, int(m), d) + main := tri.O + person := numberPeople[main] name := displayName if name == "" { name = "你" } + oneLiner := firstSentence(person.Desc) + if oneLiner == "" { + oneLiner = person.Title + } + + bz := getBazi(y, int(m), d, 12) + pct := calc5E(bz) + strong := strongestEl(pct) + weak := weakestEl(pct) + cst := constitutionByElement[strong] + emo := emotionByElement[strong] + + dims := []map[string]any{ + {"key": "personality", "title": "性格特点", "teaser": oneLiner, "score": dimScore(main, 0)}, + {"key": "communication", "title": "沟通方式", "teaser": teaserCut(oneLiner, 28), "score": dimScore(main, 1)}, + {"key": "relation", "title": "关系模式", "teaser": "深度关系模式见完整分析", "score": dimScore(main, 2)}, + {"key": "career", "title": "事业节奏", "teaser": "适合方向见深度版", "score": dimScore(main, 3)}, + {"key": "emotion", "title": "情绪调节", "teaser": joinCN(emo.Strength, 3), "score": dimScore(main, 4)}, + {"key": "lifestyle", "title": "生活节奏", "teaser": "核心养护:养" + cst.Strong + " · 需呵护" + cst.Weak, "score": dimScore(main, 5)}, + } + + keywords := []string{fmt.Sprintf("%d号人", main), person.Elem + "行倾向", cst.Primary} + if len(emo.Strength) > 0 { + keywords = append(keywords, emo.Strength[0]) + } + + tip := todayTip(time.Now()) + missing, over := missAndOver(tri) summary := map[string]any{ - "title": "基础画像", - "headline": fmt.Sprintf("%s更偏「%s」的互动风格", name, trait.Label), - "keywords": trait.Keywords, - "one_liner": trait.OneLiner, - "life_tip": trait.LifeTip, - "pattern_key": num, + "title": "愈心解码·基础", + "main_number": main, + "person_title": person.Title, + "style_label": person.Title, + "style_badge": fmt.Sprintf("%d号人", main), + "headline": fmt.Sprintf("%s是%d号人 · %s", name, main, person.Title), + "keywords": keywords, + "one_liner": oneLiner, + "overview": oneLiner, + "life_tip": emo.Advice, + "today_practice": emo.Advice, + // 模糊锁占位(非完整深文案,完整内容仅在 detail) + "lock_teaser_number": "天赋优势与人生课题、适合方向与能量长文,解锁后查看。联合码按生命阶段分组解读。", + "lock_teaser_wuxing": "体质特征、调养要点、易感倾向、食疗与作息节律,解锁后查看完整方案。", + "pattern_key": main, + "pyramid": tri.PyramidMap(), + "wuxing": map[string]any{ + "bars": wuxingBars(pct), + "primary": cst.Primary, + "strong": cst.Strong, + "weak": cst.Weak, + "strong_el": strong, + "weak_el": weak, + "emotion": emo.Strength, + "care_dir": "养" + cst.Strong + " · 需呵护" + cst.Weak, + }, + "today_tip": tip, + "dimensions": dims, + "strengths_preview": []string{ + fmt.Sprintf("%d号人·%s", main, person.Title), + cst.Primary, + }, + "blind_spots_preview": []string{"完整天赋优势、人生课题、联合码与体质深析见深度版。"}, + "disclaimer_constitution": "体质与调养内容为生活方式参考,非医疗建议。", } + + numberDeep := map[string]any{ + "talent": person.Pos, + "topic": person.Neg, + "direction": person.Job, + "elem": person.Elem, + "desc": person.Desc, + "missing": missing, + "over": over, + "joints": jointGroups(tri), + } + constitutionDeep := map[string]any{ + "desc": cst.Desc, + "body": cst.Body, + "tips": cst.Tips, + "risk": cst.Diseases, + "season_risk": cst.SeasonRisk, + "food": cst.FoodTherapy, + "rhythm": cst.DailyRhythm, + "primary": cst.Primary, + "secondary": cst.Secondary, + "emotion_risk": emo.Risk, + "emotion_advice": emo.Advice, + } + detail := map[string]any{ - "title": "完整分析", - "behavior_pattern": trait.Behavior, - "relation_style": trait.Relation, - "growth_direction": trait.Growth, - "daily_suggestions": []string{ - trait.LifeTip, - "遇到分歧时,先复述对方观点再表达自己的需要。", - "用一周记录情绪与精力高峰,找到更适合自己的节奏。", + "title": "完整分析", + "number_deep": numberDeep, + "constitution_deep": constitutionDeep, + "sections": []map[string]any{ + section("天赋与课题", person.Desc, []string{"天赋优势:" + person.Pos, "人生课题:" + person.Neg, "适合方向:" + person.Job}), + section("体质特征", cst.Body, []string{cst.Tips}), + section("易感与季节", cst.Diseases, []string{cst.SeasonRisk}), + section("食疗方向", cst.FoodTherapy, nil), + section("作息节律", cst.DailyRhythm, nil), + section("情绪调养", emo.Advice, emo.Risk), + }, + "behavior_pattern": person.Desc, + "relation_style": person.Neg, + "growth_direction": person.Pos, + "strengths": splitComma(person.Pos), + "blind_spots": splitComma(person.Neg), + "growth_plan": []map[string]any{ + {"phase": "本周", "focus": emo.Advice}, + {"phase": "本月", "focus": cst.Tips}, + {"phase": "长期", "focus": "结合数字课题「" + person.Neg + "」与体质养护「养" + cst.Strong + "」,形成可持续的生活节奏。"}, + }, + "conversation_scripts": []string{ + "我想聊聊自己的节奏,不用急着给建议。", + "当我压力大时,需要一点安静空间再继续聊。", + "帮我一起看看,哪件事最值得这周聚焦。", + }, + "daily_suggestions": []string{strAny(tip["yi"]), emo.Advice, "核心养护:养" + cst.Strong}, + "faq": []map[string]string{ + {"q": "数字性格是固定的吗?", "a": "主数提供稳定的自我探索框架,具体表现会随阶段与环境变化,可当作镜子而非标签。"}, + {"q": "五行体质能当看病依据吗?", "a": "不能。这里的体质与调养只是生活方式参考,不适请就医,勿自行当作治疗方案。"}, }, } return Output{Summary: summary, Detail: detail} } -type trait struct { - Label string - Keywords []string - OneLiner string - LifeTip string - Behavior string - Relation string - Growth string +func section(title, body string, bullets []string) map[string]any { + return map[string]any{"title": title, "body": body, "bullets": bullets} } -var traits = []trait{ - { - Label: "稳健探索", Keywords: []string{"条理", "耐心", "观察"}, - OneLiner: "你习惯先理解再行动,适合把复杂事情拆成小步。", - LifeTip: "今天给自己一段不被打断的专注时间。", - Behavior: "决策偏审慎,信息充分时执行力更强;压力下可能拖延。", - Relation: "更愿意用行动表达关心,需要对方给予明确反馈。", - Growth: "练习在信息不完整时做小步试验,积累行动信心。", - }, - { - Label: "热情连接", Keywords: []string{"表达", "共鸣", "主动"}, - OneLiner: "你容易带动气氛,也需要被真诚回应。", - LifeTip: "把想说的话写下来,再选择合适的时机分享。", - Behavior: "行动快、反馈敏感;情绪起伏会影响专注时长。", - Relation: "重视即时沟通,冷处理容易让你感到不安。", - Growth: "学会区分「被回应」与「被认同」,减少过度解读。", - }, - { - Label: "理性澄清", Keywords: []string{"分析", "边界", "清晰"}, - OneLiner: "你擅长把模糊感受变成可讨论的问题。", - LifeTip: "睡前做一次简短复盘:今天最有价值的一件事是什么。", - Behavior: "偏好逻辑框架;在情绪场域可能显得抽离。", - Relation: "沟通时需要结构和具体例子,忌空泛安慰。", - Growth: "在分析之外,练习先接纳情绪再谈方案。", - }, - { - Label: "柔韧调节", Keywords: []string{"适应", "体察", "平衡"}, - OneLiner: "你善于照顾氛围,也别忘了照顾自己的节奏。", - LifeTip: "安排一次轻度活动,帮助身心回到平稳状态。", - Behavior: "弹性强,容易迁就;长期可能积压需求。", - Relation: "更在意关系和谐,冲突时倾向先安抚场面。", - Growth: "练习用「我需要…」表达边界,而不是只做协调者。", - }, - { - Label: "目标推进", Keywords: []string{"决断", "效率", "成果"}, - OneLiner: "你推动事情落地的能力突出,记得留白给休息。", - LifeTip: "把今日目标收束到一件最重要的事。", - Behavior: "结果导向,节奏偏快;对低效协作耐心有限。", - Relation: "欣赏直接沟通的人,含糊表态会消耗信任。", - Growth: "把「效率」与「关系维护」列为同等优先级的周目标。", - }, - { - Label: "内观沉淀", Keywords: []string{"深度", "独立", "洞察"}, - OneLiner: "你习惯向内理解世界,适合深度思考类任务。", - LifeTip: "留出安静独处时间,整理近期的想法与感受。", - Behavior: "思考深入,对外表达可能滞后于内心结论。", - Relation: "需要安全感和节奏感,突然施压会让你退回内在。", - Growth: "把洞察翻译成可分享的语言,让重要的人跟上你。", - }, - { - Label: "创意发散", Keywords: []string{"想象", "灵感", "可能"}, - OneLiner: "你容易看到多种可能性,适合用原型验证想法。", - LifeTip: "用纸笔画出今天最想尝试的一个小实验。", - Behavior: "点子多、切换快;收尾与复盘是短板。", - Relation: "喜欢有趣的对话,重复与僵化会让你疏离。", - Growth: "为每个灵感设定「最小完成定义」,提高闭环率。", - }, - { - Label: "责任担当", Keywords: []string{"可靠", "承诺", "稳定"}, - OneLiner: "你重视承诺与秩序,是团队里让人安心的存在。", - LifeTip: "检查一下是否把别人的期待误当成自己的必须。", - Behavior: "可靠且自律;过度负责时容易耗竭。", - Relation: "用持续在场表达在乎,需要被看见付出。", - Growth: "练习委托与求助,让支持系统真正运转起来。", - }, - { - Label: "敏锐觉察", Keywords: []string{"细腻", "直觉", "体贴"}, - OneLiner: "你对情绪与细节敏感,适合需要同理的场景。", - LifeTip: "觉察身体信号:紧张时先放慢呼吸再回应。", - Behavior: "感知力强;信息过载时容易内耗。", - Relation: "能很快读到对方状态,也易被情绪感染。", - Growth: "建立「感受—事实—选择」三步,减少被情绪牵着走。", - }, +func dimScore(main, salt int) int { + // Stable pseudo-scores 62–92 for relation dimension compare. + return 62 + (main*7+salt*11)%31 } -func reduce(n int) int { - if n < 0 { - n = -n +func teaserCut(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s } - sum := 0 - for n > 0 { - sum += n % 10 - n /= 10 - } - return sum + return string(r[:n]) + "…" +} + +func joinCN(parts []string, n int) string { + if len(parts) == 0 { + return "" + } + if len(parts) < n { + n = len(parts) + } + out := parts[0] + for i := 1; i < n; i++ { + out += " · " + parts[i] + } + return out +} + +func splitComma(s string) []string { + if s == "" { + return nil + } + // pos/neg use顿号 + parts := []string{} + cur := "" + for _, r := range s { + if r == '、' || r == ',' || r == ',' { + if cur != "" { + parts = append(parts, cur) + cur = "" + } + continue + } + cur += string(r) + } + if cur != "" { + parts = append(parts, cur) + } + if len(parts) > 3 { + return parts[:3] + } + return parts +} + +func strAny(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" } diff --git a/apps/api/internal/portrait/engine_test.go b/apps/api/internal/portrait/engine_test.go index 9a11ff3..0bb50d4 100644 --- a/apps/api/internal/portrait/engine_test.go +++ b/apps/api/internal/portrait/engine_test.go @@ -1,24 +1,89 @@ package portrait import ( + "encoding/json" "strings" "testing" "time" ) -func TestBuildDeterministic(t *testing.T) { - birth := time.Date(1990, 1, 15, 0, 0, 0, 0, time.UTC) +func TestCalcMatchesClassicJS(t *testing.T) { + // Snapshot from js/common.js calc(1990,5,12) + v := Calc(1990, 5, 12) + if v.O != 9 || v.M != 8 || v.N != 1 || v.I != 3 || v.J != 5 || v.K != 1 || v.L != 9 { + t.Fatalf("triangle mismatch: %+v", v) + } + if v.Q != 1 || v.P != 8 || v.R != 9 || v.T != 2 || v.S != 4 || v.U != 6 { + t.Fatalf("outer mismatch: %+v", v) + } +} + +func TestBuildDecodeShape(t *testing.T) { + birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC) a := Build(birth, "小愈") b := Build(birth, "小愈") if a.Summary["headline"] != b.Summary["headline"] { t.Fatalf("expected deterministic headline") } - if a.Summary["one_liner"] == nil || a.Detail["growth_direction"] == nil { - t.Fatalf("missing summary/detail fields") + if a.Summary["main_number"].(int) != 9 { + t.Fatalf("main_number want 9 got %#v", a.Summary["main_number"]) } - for _, kw := range []string{"运势", "吉凶", "算命"} { - s := a.Summary["one_liner"].(string) + a.Detail["behavior_pattern"].(string) - if strings.Contains(s, kw) { + if a.Summary["person_title"] == nil || a.Summary["pyramid"] == nil { + t.Fatalf("missing decode free fields") + } + wx, ok := a.Summary["wuxing"].(map[string]any) + if !ok || wx["bars"] == nil || wx["primary"] == nil { + t.Fatalf("missing wuxing summary: %#v", a.Summary["wuxing"]) + } + if a.Summary["today_tip"] == nil { + t.Fatalf("missing today_tip") + } + nd, ok := a.Detail["number_deep"].(map[string]any) + if !ok || nd["talent"] == nil || nd["joints"] == nil { + t.Fatalf("missing number_deep") + } + cd, ok := a.Detail["constitution_deep"].(map[string]any) + if !ok || cd["body"] == nil || cd["food"] == nil { + t.Fatalf("missing constitution_deep") + } + dims, ok := a.Summary["dimensions"].([]map[string]any) + if !ok || len(dims) < 6 { + t.Fatalf("expected 6 dimensions for relation compat") + } + assertNoForbidden(t, a) +} + +func TestWuxingDeterministic(t *testing.T) { + bz := getBazi(1990, 5, 12, 12) + p := calc5E(bz) + // Snapshot aligned with js calc5E(getBazi(new Date(1990,4,12),12)) + want := map[string]int{"木": 25, "火": 50, "土": 13, "金": 13, "水": 0} + for k, w := range want { + if p[k] != w { + t.Fatalf("wuxing[%s]=%d want %d (full %#v)", k, p[k], w, p) + } + } + if strongestEl(p) != "火" { + t.Fatalf("strong want 火 got %s", strongestEl(p)) + } +} + +func TestAllMainNumbersLexicon(t *testing.T) { + for day := 1; day <= 28; day++ { + out := Build(time.Date(1990, 3, day, 0, 0, 0, 0, time.UTC), "测") + assertNoForbidden(t, out) + } +} + +func assertNoForbidden(t *testing.T, out Output) { + t.Helper() + raw, err := json.Marshal(out) + if err != nil { + t.Fatal(err) + } + blob := string(raw) + for _, kw := range []string{"占卜", "算命", "改命", "塔罗"} { + if strings.Contains(blob, kw) { t.Fatalf("forbidden word %q in portrait copy", kw) } } diff --git a/apps/api/internal/portrait/jointdata.go b/apps/api/internal/portrait/jointdata.go new file mode 100644 index 0000000..fa3f424 --- /dev/null +++ b/apps/api/internal/portrait/jointdata.go @@ -0,0 +1,107 @@ +package portrait + +// Joint code short blurbs (JOINT from js/common.js). +var jointCodes = map[string]string{ + "112": "独当一面,做事靠谱有担当", + "123": "天生讲师,开口就能圈粉", + "134": "灵感落地,把创意变成现实", + "145": "运筹帷幄,天生的规划师", + "156": "八方来财,处处遇贵人", + "167": "白手起家,靠自己闯天下", + "178": "成就他人,身边贵人不断", + "189": "停不下来的工作狂", + "191": "独立开创,没人能阻挡你", + "213": "口才炸裂,感染力爆棚", + "224": "心太软,善良是你的底牌", + "235": "沟通达人,三言两语暖人心", + "246": "贵人环绕,销冠体质", + "257": "直觉导航,跟着感觉走就对了", + "268": "重情重义,一诺千金", + "279": "异性缘超好,走到哪都是焦点", + "281": "大单收割机,谈判桌王者", + "292": "幕后军师,运筹帷幄之中", + "314": "创意执行力,说干就干", + "325": "感性沟通,用温度打动人心", + "336": "资源连接器,人脉即财富", + "347": "深耕专业,做领域里的王", + "358": "行动派领袖,带队冲锋", + "369": "万花筒人生,十八般武艺样样通", + "415": "规划力MAX,步步为营", + "426": "智慧型销售,成交于无形", + "437": "学术大佬,坐冷板凳也能发光", + "448": "靠谱天花板,交给你就放心了", + "459": "策划大师,脑子比电脑快", + "461": "稳扎稳打,厚积薄发", + "472": "火眼金睛,精准分析一切", + "483": "技术大牛,专业领域独孤求败", + "494": "完美规划师,细节控本控", + "516": "方向感+大智慧,走哪都是对的", + "527": "感性直觉,第六感超准", + "538": "天生领袖,气场两米八", + "549": "规划型赢家,稳操胜券", + "551": "风一样自由,方向感自带导航", + "562": "会赚也会花,享受生活", + "573": "贵人相助命,总有高人来指点", + "584": "责任+规划,双核驱动", + "595": "梦想家体质,心有多大舞台就有多大", + "617": "白手起家,命运由我不由天", + "628": "财富+沟通,双商在线", + "639": "多才多艺,斜杠青年本青", + "641": "务实派经营者,脚踏实地", + "652": "财富雷达,赚钱直觉超准", + "663": "完美主义+财富,精品人生", + "674": "天生傲骨,不甘平凡", + "685": "商业+责任,事业标杆", + "696": "极致追求,匠人精神", + "718": "钻研成王,责任铸就传奇", + "729": "桃花运旺,魅力无法挡", + "731": "创新领袖,敢为天下先", + "742": "神机妙算,谋定而后动", + "753": "直觉行动派,干就完了", + "764": "精打细算,每一分钱都花在刀刃上", + "775": "王者基因,注定站在C位", + "786": "商业领袖,运筹帷幄", + "797": "极致深钻,领域天花板", + "819": "亲力亲为,掌控全局", + "821": "大单高手,说服力满格", + "832": "行动+沟通,效率拉满", + "843": "技术专家,一技傍身走天下", + "854": "规划+自由,收放自如", + "865": "财富+责任,扛得住世界", + "876": "商业王者,霸气外露", + "887": "强势担当,说到做到", + "898": "大事业格局,志在千里", + "911": "大爱无疆,独立而温柔", + "922": "温柔至善,人间小天使", + "933": "巧手匠心,万物皆可造", + "944": "稳中求进,计划周全", + "955": "自由博爱,灵魂有趣", + "966": "完美+智慧,人间清醒", + "977": "深度大爱,为理想燃烧", + "988": "商业帝国缔造者", + "999": "极致巅峰,满分人生", +} + +var missMsg = map[int]string{ + 1: "内心的领袖力还在沉睡——试着在小事上果断决策,你会发现自己比想象中更强大", + 2: "你的声音值得被听见——从今天开始,勇敢说出你的想法,别怕被拒绝", + 3: "行动力是等待点燃的火种——别再想了,先迈出第一步,剩下的路会自动展开", + 4: "安全感不是等来的,是建出来的——给自己一个可以依靠的小习惯,它会慢慢长大", + 5: "你的指南针正在校准中——去尝试那些让你心跳加速的事,方向自会出现", + 6: "财富嗅觉是可以培养的——从关注自己的价值开始,你会发现自己值得更多", + 7: "深度的力量需要聚焦——找一个让你好奇的问题,像侦探一样追下去", + 8: "你的使命感正在觉醒——找到那件让你热血沸腾的事,扛起来走下去", + 9: "大爱需要边界——学会对自己说\"够了\",你的能量会用在对的地方", +} + +var missStrong = map[int]string{ + 1: "自信女神本神,偶尔听一下别人的意见会更完美", + 2: "沟通力溢出屏幕,但别忘了先连接自己的内心", + 3: "活力四射到飞起,偶尔刹车踩一踩,走得更远", + 4: "稳得像座山,但偶尔翻个墙看看外面的世界也不错", + 5: "自由到炸裂,找个值得停靠的港湾也很酷", + 6: "完美主义拉满,对自己说一句\"已经够好了\"试试", + 7: "沉迷思考无法自拔,偶尔出来晒晒太阳吧", + 8: "责任扛得太多啦,分一些给信得过的人", + 9: "爱心泛滥到溢出,聚焦一件最想改变的事会更有力量", +} diff --git a/apps/api/internal/portrait/numdata.go b/apps/api/internal/portrait/numdata.go new file mode 100644 index 0000000..fd847b0 --- /dev/null +++ b/apps/api/internal/portrait/numdata.go @@ -0,0 +1,86 @@ +package portrait + +// NumberPerson is classic NUM[1..9] copy from js/common.js. +type NumberPerson struct { + Title string + Elem string + Pos string + Neg string + Job string + Desc string +} + +var numberPeople = map[int]NumberPerson{ + 1: { + Title: "天生领袖·开拓者", + Elem: "金", + Pos: "自信果敢、创造力爆棚、天生自带光芒", + Neg: "偶尔孤傲、不擅示弱、容易一个人扛下所有", + Job: "创始人、管理者、艺术家、独立设计师", + Desc: "你是天生的领航员,内心有一团不灭的火。你不需要别人告诉你该做什么——你比谁都清楚。独立、果敢、行动力超强,这让你在人群中自带光环。但请记住,真正的强大不是独自承担一切,而是学会信任和依靠。偶尔放下铠甲,你会看到更广阔的世界。", + }, + 2: { + Title: "温柔沟通·共情者", + Elem: "水", + Pos: "善解人意、沟通力满分、团队里的粘合剂", + Neg: "太在意他人眼光、容易委屈自己、选择困难", + Job: "心理咨询师、公关专家、教育者、客户关系", + Desc: "你有一颗比常人更柔软的心。你能听见别人没说出口的话,感受到空气里的情绪——这是你独一无二的天赋。你是团队里最温暖的存在,总能让人感到被理解。但要记得,你的感受同样重要。温柔不是软弱,拒绝不是伤害。学会为自己发声,你的温柔会更有力量。", + }, + 3: { + Title: "创意火花·行动派", + Elem: "木", + Pos: "灵感不断、感染力满分、想到就去做", + Neg: "三分钟热度、急躁冒进、耐不住寂寞", + Job: "演员、讲师、创意总监、自媒体人", + Desc: "你的大脑永远在高速运转,灵感像烟花一样炸开。你就是那个能把想法变成现实的人——当别人还在犹豫,你已经冲出去了。你的热情能点燃整个房间。但有时候,慢一点反而更快。不是每个火花都需要燃烧,学会聚焦,你的光芒会更耀眼。", + }, + 4: { + Title: "稳妥规划·筑造者", + Elem: "木", + Pos: "逻辑缜密、靠谱担当、凡事井井有条", + Neg: "过于保守、害怕改变、安全感容易动摇", + Job: "工程师、会计师、项目经理、架构师", + Desc: "你是那个让所有人放心的人。当世界一片混乱时,你已经在默默列清单了。你的稳不是无趣,是把混乱变成秩序的超能力。每一步都走得扎实,每个细节都心中有数。但生活不是待办清单,偶尔打破计划,会有惊喜。允许自己不那么完美,你已经足够好了。", + }, + 5: { + Title: "自由灵魂·探索者", + Elem: "土", + Pos: "无拘无束、方向感超强、幽默感爆棚", + Neg: "讨厌束缚、容易逃避、很难安定下来", + Job: "旅行博主、作家、自由职业者、冒险家", + Desc: "你就是风,没人能把你关在笼子里。你的灵魂渴望远方,对世界永远保持好奇。你能在迷茫中找到方向,这是你最珍贵的天赋。你的人生不是按部就班,而是充满冒险的故事。但也要记得,自由不是逃避,真正的自由是拥有选择的能力。找到值得你停留的热爱,那里就是你的家。", + }, + 6: { + Title: "智慧远见·鉴赏家", + Elem: "金", + Pos: "眼光毒辣、财富直觉、精益求精", + Neg: "完美主义、容易焦虑、对自己太狠", + Job: "投资人、咨询顾问、医生、律师", + Desc: "你有一双看透本质的眼睛。别人看到表面,你看到价值;别人追逐潮流,你预判趋势。你对品质的要求不是挑剔,是对生活的尊重。但完美不是终点,生活有瑕疵才真实。偶尔放低标准,你会发现世界比你想象的更宽容。你值得拥有最好的,包括对自己温柔一点。", + }, + 7: { + Title: "深度洞察·求真者", + Elem: "水", + Pos: "钻研到底、直觉惊人、看透事物本质", + Neg: "疏离冷漠、疑心重、社交电量容易耗尽", + Job: "科学家、侦探、程序员、学者", + Desc: "别人看热闹,你看门道。你的大脑是永动的探索引擎,对世界的真相充满饥渴。你不需要浅薄的社交,你在意的是深度和意义。这份专注让你在专业领域无人能敌。但请偶尔走出自己的世界,那些看似平凡的人间烟火,也能给你答案。独处是力量,连接是温暖,两者你都值得拥有。", + }, + 8: { + Title: "责任担当·成就者", + Elem: "土", + Pos: "责任感爆棚、商业嗅觉、野心勃勃", + Neg: "压力山大、控制欲强、容易透支自己", + Job: "企业家、高管、投资人、政治家", + Desc: "你注定不是普通人。你肩膀上扛着比常人更重的使命,心中有比常人更大的蓝图。你的责任感和行动力让你天然成为领袖。但你不需要扛起整个世界,真正的领导者懂得分担负重。学会休息不是懈怠,是为了走更远的路。你值得被照顾,不只是照顾别人。", + }, + 9: { + Title: "博爱大器·成功者", + Elem: "火", + Pos: "慈悲大爱、机会磁铁、自带成功气场", + Neg: "贪多嚼不烂、难以专注、理想主义过头", + Job: "慈善家、艺人、灵性导师、社会活动家", + Desc: "你身上有一种让人想靠近的魔力。你的心中装着比个人更大的东西——爱与使命感是你最强的驱动力。机会总是向你涌来,因为你值得。你天生自带好运体质。但不需要抓住所有机会,挑一个最让你心动的,全力以赴。你的存在,本身就是一种治愈。", + }, +} diff --git a/apps/api/internal/portrait/triangle.go b/apps/api/internal/portrait/triangle.go new file mode 100644 index 0000000..bc86827 --- /dev/null +++ b/apps/api/internal/portrait/triangle.go @@ -0,0 +1,237 @@ +package portrait + +// Triangle is the classic life-number pyramid from js/common.js calc(). +type Triangle struct { + AB, CD, EF, GH [2]int + I, J, K, L int + M, N, O int + Q, P, R int + T, S, U int + V, W, X int +} + +// Calc builds the digital psychology triangle for a birth date. +func Calc(y, m, d int) Triangle { + dd := digs(d, 2) + mm := digs(m, 2) + yy := digs(y, 4) + a, b := dd[0], dd[1] + c, dd1 := mm[0], mm[1] + e, f, g, h := yy[0], yy[1], yy[2], yy[3] + v := Triangle{ + AB: [2]int{a, b}, + CD: [2]int{c, dd1}, + EF: [2]int{e, f}, + GH: [2]int{g, h}, + } + v.I = s1(a + b) + v.J = s1(c + dd1) + v.K = s1(e + f) + v.L = s1(g + h) + v.M = s1(v.I + v.J) + v.N = s1(v.K + v.L) + v.O = s1(v.M + v.N) + v.Q = s1(v.N + v.O) + v.P = s1(v.M + v.O) + v.R = s1(v.Q + v.P) + v.T = s1(v.I + v.M) + v.S = s1(v.J + v.M) + v.U = s1(v.T + v.S) + v.V = s1(v.K + v.N) + v.W = s1(v.L + v.N) + v.X = s1(v.V + v.W) + return v +} + +// PyramidMap returns grid values for H5 mini-triangle rendering. +func (v Triangle) PyramidMap() map[string]int { + return map[string]int{ + "I": v.I, "J": v.J, "K": v.K, "L": v.L, + "M": v.M, "N": v.N, "O": v.O, + "Q": v.Q, "P": v.P, "R": v.R, + "T": v.T, "S": v.S, "U": v.U, + "V": v.V, "W": v.W, "X": v.X, + } +} + +func s1(n int) int { + for n >= 10 { + sum := 0 + for n > 0 { + sum += n % 10 + n /= 10 + } + n = sum + } + return n +} + +func digs(n, l int) []int { + s := fmtPad(n, l) + out := make([]int, l) + for i := 0; i < l; i++ { + out[i] = int(s[i] - '0') + } + return out +} + +func fmtPad(n, l int) string { + if n < 0 { + n = -n + } + buf := make([]byte, l) + for i := l - 1; i >= 0; i-- { + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf) +} + +func cell(v Triangle, key string) int { + switch key { + case "I": + return v.I + case "J": + return v.J + case "K": + return v.K + case "L": + return v.L + case "M": + return v.M + case "N": + return v.N + case "O": + return v.O + case "Q": + return v.Q + case "P": + return v.P + case "R": + return v.R + case "T": + return v.T + case "S": + return v.S + case "U": + return v.U + case "V": + return v.V + case "W": + return v.W + case "X": + return v.X + default: + return 0 + } +} + +// jointGroups builds age-grouped joint codes (付费层). +func jointGroups(v Triangle) []map[string]any { + pairs := [][2]string{ + {"I", "J"}, {"J", "M"}, {"M", "I"}, {"K", "L"}, + {"L", "N"}, {"N", "K"}, {"M", "O"}, {"O", "N"}, + {"M", "N"}, {"Q", "P"}, {"Q", "O"}, {"P", "O"}, + } + joints := make([]map[string]any, 0, len(pairs)) + for _, p := range pairs { + code := fmtInt(cell(v, p[0])) + fmtInt(cell(v, p[1])) + label := "—" + for k, desc := range jointCodes { + if len(k) >= len(code) && k[:len(code)] == code { + label = desc + break + } + } + joints = append(joints, map[string]any{"code": code, "label": label}) + } + g1Desc := "由潜意识码主导,反映你在职场上升期和社交圈中的表现模式。" + if v.I+v.J+v.M >= 10 { + g1Desc += "能量较强,事业心旺盛,社交活跃。" + } else { + g1Desc += "能量内敛,更注重深度而非广度。" + } + g2Desc := "由内心码和主性格主导,体现你对下一代和下级的培养方式及中年价值观。" + if v.K+v.L+v.N >= 10 { + g2Desc += "有较强的责任感和支配欲。" + } else { + g2Desc += "倾向于给予空间,放手让他人成长。" + } + g3Desc := "由外心码主导,展现你晚年的生活智慧和家庭关系的最终呈现。" + if v.M+v.N+v.O >= 10 { + g3Desc += "晚年活跃,持续发挥影响力。" + } else { + g3Desc += "晚年宁静,享受内在平和。" + } + return []map[string]any{ + {"label": "21—40岁 · 工作和朋友", "codes": joints[0:4], "desc": g1Desc}, + {"label": "41—60岁 · 儿女和下属", "codes": joints[4:8], "desc": g2Desc}, + {"label": "61岁至晚年 · 晚年和家庭", "codes": joints[8:12], "desc": g3Desc}, + } +} + +func missAndOver(v Triangle) (missing []map[string]any, over []map[string]any) { + innerSet := map[int]bool{v.O: true, v.M: true, v.N: true, v.I: true, v.J: true, v.K: true, v.L: true} + outerVals := []int{v.Q, v.P, v.R, v.T, v.S, v.U, v.V, v.W, v.X} + present := map[int]bool{} + for n := range innerSet { + present[n] = true + } + for _, n := range outerVals { + present[n] = true + } + for _, n := range []int{v.AB[0], v.AB[1], v.CD[0], v.CD[1], v.EF[0], v.EF[1], v.GH[0], v.GH[1]} { + present[n] = true + } + freq := map[int]int{} + for i := 1; i <= 9; i++ { + freq[i] = 0 + } + allVals := []int{ + v.O, v.M, v.N, v.I, v.J, v.K, v.L, v.Q, v.P, v.R, v.T, v.S, v.U, v.V, v.W, v.X, + v.AB[0], v.AB[1], v.CD[0], v.CD[1], v.EF[0], v.EF[1], v.GH[0], v.GH[1], + } + for _, x := range allVals { + if x >= 1 && x <= 9 { + freq[x]++ + } + } + missBoth := []int{} + for n := 0; n <= 9; n++ { + if !present[n] { + missBoth = append(missBoth, n) + } + } + missSet := map[int]bool{} + for _, n := range missBoth { + missSet[n] = true + if n >= 1 && n <= 9 { + missing = append(missing, map[string]any{"n": n, "msg": missMsg[n]}) + } + } + for i := 1; i <= 9; i++ { + if freq[i] >= 4 && !missSet[i] { + over = append(over, map[string]any{"n": i, "msg": missStrong[i], "count": freq[i]}) + } + } + return missing, over +} + +func fmtInt(n int) string { + if n < 0 { + n = -n + } + if n < 10 { + return string(rune('0' + n)) + } + return fmtPad(n, 2) +} + +func firstSentence(s string) string { + for i, r := range s { + if r == '。' || r == '.' { + return s[:i] + } + } + return s +} diff --git a/apps/api/internal/portrait/wuxing.go b/apps/api/internal/portrait/wuxing.go new file mode 100644 index 0000000..eb25344 --- /dev/null +++ b/apps/api/internal/portrait/wuxing.go @@ -0,0 +1,138 @@ +package portrait + +import ( + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/companion" +) + +var ( + tg = []string{"甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"} + dz = []string{"子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"} + tge = map[string]string{"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土", "己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水"} + dze = map[string]string{"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土", "巳": "火", "午": "火", "未": "土", "申": "金", "酉": "金", "戌": "土", "亥": "水"} + eo = []string{"木", "火", "土", "金", "水"} + ec = map[string]string{"木": "#5CB85C", "火": "#E54D42", "土": "#E8985A", "金": "#C8923A", "水": "#4A90E2"} +) + +var yiJi = map[string]struct{ Yi, Ji string }{ + "木": {"早睡养肝血 · 舒展拉伸 · 绿色蔬菜", "久坐 · 生闷气 · 熬夜"}, + "火": {"午休15分钟 · 菊花/莲子 · 平心静气", "暴晒 · 辛辣油腻 · 过度兴奋"}, + "土": {"规律三餐 · 健脾山药 · 饭后散步", "思虑过度 · 生冷 · 暴饮暴食"}, + "金": {"滋润养肺 · 白色食物 · 深呼吸", "干燥受凉 · 悲忧 · 剧烈耗气"}, + "水": {"早卧晚起 · 温补黑色食物 · 护腰腹", "受寒 · 过劳 · 惊恐紧张"}, +} + +// seasonEl maps month index (0=Jan) → 时令五行 (decode.js SEASON_EL). +var seasonEl = []string{"水", "水", "木", "木", "木", "火", "火", "火", "土", "金", "金", "水"} + +type pillar struct{ tg, dz string } + +type bazi struct { + year, month, day, hour pillar +} + +// getBazi mirrors js/common.js getBazi (hour default 12 = 午时). +// Day-pillar index uses civil days since 1900-01-01 minus 1, matching legacy +// browser Date local-midnight flooring under historical CN TZ/DST (decode.js). +func getBazi(y, m, d, h int) bazi { + gzY := pillar{tg: tg[(y-4+10000)%10], dz: dz[(y-4+12000)%12]} + birth := time.Date(y, time.Month(m), d, 0, 0, 0, 0, time.UTC) + dayOfYear := birth.YearDay() + lcDay := 35 + mi := int(float64((dayOfYear-lcDay+365)%365) / 30.44) + if mi < 0 { + mi += 12 + } + if mi >= 12 { + mi = 11 + } + mTg := tg[(((y-4+10000)%10)*2+mi)%10] + mDz := dz[(mi+2)%12] + + base := time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC) + dd := int(birth.Sub(base).Hours()/24) - 1 + dTg := tg[((dd%10)+10)%10] + dDz := dz[((dd%12)+12)%12] + hDz := dz[((h+1)/2)%12] + hTg := tg[(indexOf(tg, dTg)*2+indexOf(dz, hDz))%10] + return bazi{ + year: gzY, + month: pillar{tg: mTg, dz: mDz}, + day: pillar{tg: dTg, dz: dDz}, + hour: pillar{tg: hTg, dz: hDz}, + } +} + +func calc5E(bz bazi) map[string]int { + c := map[string]int{"木": 0, "火": 0, "土": 0, "金": 0, "水": 0} + for _, p := range []pillar{bz.year, bz.month, bz.day, bz.hour} { + c[tge[p.tg]]++ + c[dze[p.dz]]++ + } + t := 0 + for _, e := range eo { + t += c[e] + } + r := map[string]int{} + for _, e := range eo { + if t == 0 { + r[e] = 0 + } else { + r[e] = int(float64(c[e])/float64(t)*100 + 0.5) // Math.round + } + } + return r +} + +func strongestEl(p map[string]int) string { + best := eo[0] + for _, e := range eo[1:] { + if p[e] > p[best] { + best = e + } + } + return best +} + +func weakestEl(p map[string]int) string { + worst := eo[0] + for _, e := range eo[1:] { + if p[e] < p[worst] { + worst = e + } + } + return worst +} + +func wuxingBars(p map[string]int) []map[string]any { + bars := make([]map[string]any, 0, 5) + for _, e := range eo { + bars = append(bars, map[string]any{ + "el": e, "pct": p[e], "color": ec[e], + }) + } + return bars +} + +func todayTip(now time.Time) map[string]any { + st := companion.TodaySolar(now) + el := seasonEl[int(now.Month())-1] + yj := yiJi[el] + return map[string]any{ + "solar_term": st.Name, + "season_el": el, + "yi": yj.Yi, + "ji": yj.Ji, + "tip": st.Tip, + } +} + +func indexOf(arr []string, s string) int { + for i, v := range arr { + if v == s { + return i + } + } + return 0 +} diff --git a/apps/api/internal/relation/engine.go b/apps/api/internal/relation/engine.go index ba84b02..ba6c04a 100644 --- a/apps/api/internal/relation/engine.go +++ b/apps/api/internal/relation/engine.go @@ -1,11 +1,15 @@ -// Package relation builds 关系理解 content from two birth-based portraits. +// Package relation builds 人格匹配 / 合盘向 content from two profiles. package relation import ( "fmt" + "math" "time" "github.com/yuxingu/digital-psychology/apps/api/internal/portrait" + "github.com/yuxingu/digital-psychology/apps/api/internal/star" + "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" + "github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry" ) // Output is free summary + gated detail. @@ -14,8 +18,18 @@ type Output struct { Detail map[string]any } -// Build compares two profiles' exploration styles (lexicon-safe copy). +// Build compares two profiles' exploration styles. func Build(aBirth, bBirth time.Time, aName, bName string) Output { + return BuildWithType(aBirth, bBirth, aName, bName, "") +} + +// BuildWithType adds relation-type flavored tips (partner/family/friend). +func BuildWithType(aBirth, bBirth time.Time, aName, bName, relationType string) Output { + return BuildFull(aBirth, bBirth, nil, nil, nil, nil, aName, bName, relationType) +} + +// BuildFull includes birth time/place for natal synastry indices. +func BuildFull(aBirth, bBirth time.Time, aTime, bTime, aPlace, bPlace *string, aName, bName, relationType string) Output { if aName == "" { aName = "我" } @@ -24,41 +38,393 @@ func Build(aBirth, bBirth time.Time, aName, bName string) Output { } pa := portrait.Build(aBirth, aName) pb := portrait.Build(bBirth, bName) - aLabel := str(pa.Summary["headline"]) - bLabel := str(pb.Summary["headline"]) + + aLabel := firstNonEmpty(str(pa.Summary["style_label"]), str(pa.Summary["headline"])) + bLabel := firstNonEmpty(str(pb.Summary["style_label"]), str(pb.Summary["headline"])) aKeys := strSlice(pa.Summary["keywords"]) bKeys := strSlice(pb.Summary["keywords"]) + aDims := dimMap(pa.Summary["dimensions"]) + bDims := dimMap(pb.Summary["dimensions"]) + + compKey := complementarityKey(aLabel, bLabel) + comp := complementarityCopy(compKey, aName, bName, aLabel, bLabel) + + dimCompare := []map[string]any{} + for _, key := range []string{"personality", "communication", "relation", "career", "emotion", "lifestyle"} { + title := dimTitle(key) + as, aok := aDims[key] + bs, bok := bDims[key] + if !aok || !bok { + continue + } + gap := as.Score - bs.Score + abs := int(math.Abs(float64(gap))) + note := dimNote(key, gap, aName, bName) + dimCompare = append(dimCompare, map[string]any{ + "key": key, "title": title, + "me_score": as.Score, "other_score": bs.Score, + "me_teaser": as.Teaser, "other_teaser": bs.Teaser, + "gap": abs, "note": note, + }) + } + + aSun, aMoon, aRise := star.SignLabelsPlace(aBirth, aTime, aPlace) + bSun, bMoon, bRise := star.SignLabelsPlace(bBirth, bTime, bPlace) + ca, errA := star.NatalChart(aBirth, aTime, aPlace) + cb, errB := star.NatalChart(bBirth, bTime, bPlace) + if errA != nil || errB != nil { + ca, cb = natal.Chart{}, natal.Chart{} + } + match := synastry.Compute(ca, cb, aName, bName) + harmony := harmonyIndex(dimCompare) + fitLabel, fitTips := fitFromHarmony(harmony, aLabel, bLabel) summary := map[string]any{ - "title": "关系理解·基础", - "me_style": aLabel, - "other_style": bLabel, - "me_keywords": aKeys, - "other_keywords": bKeys, - "diff_one_liner": fmt.Sprintf("%s更偏内在节奏,%s的表达方式不同——差异可以成为互补,而不是对立。", aName, bName), - "share_hint": "了解彼此的沟通方式,查看关系理解", + "title": "人格匹配·基础", + "me_style": aLabel, + "other_style": bLabel, + "me_keywords": aKeys, + "other_keywords": bKeys, + "diff_one_liner": comp.OneLiner, + "overview": comp.Overview, + "chemistry": comp.Chemistry, + "watchouts_preview": comp.Watchouts[:min(2, len(comp.Watchouts))], + "dimension_compare": dimCompare, + "fit_label": fitLabel, + "fit_tips": fitTips, + "harmony_index": harmony, + "love_index": match.Love, + "friend_index": match.Friend, + "marriage_index": match.Marriage, + "match_indices": match.AsMap(), + "star_compare": []map[string]any{ + {"key": "sun", "title": "太阳", "me": aSun, "other": bSun, "note": starPairNote(aSun, bSun)}, + {"key": "moon", "title": "月亮", "me": aMoon, "other": bMoon, "note": starPairNote(aMoon, bMoon)}, + {"key": "rise", "title": "上升", "me": aRise, "other": bRise, "note": starPairNote(aRise, bRise)}, + }, + "share_hint": "了解彼此的沟通方式,查看人格匹配", } + detail := map[string]any{ - "title": "相处建议·完整分析", - "communication": []string{ - fmt.Sprintf("%s:先讲清楚事实与需要,再谈感受。", aName), - fmt.Sprintf("%s:先确认被听见,再进入方案讨论。", bName), - "冲突时约定「复述对方一句」再回应,降低误解。", + "title": "人格匹配·完整分析", + "sections": []map[string]any{ + {"title": "匹配总览", "body": comp.DeepOverview, "bullets": comp.ChemistryPoints}, + {"title": "星座合盘与匹配指数", "body": fmt.Sprintf("%s。%s太阳%s / %s太阳%s;月亮%s与%s。", match.Summary, aName, aSun, bName, bSun, aMoon, bMoon), + "bullets": []string{match.LoveNote, match.FriendNote, match.MarriageNote, starPairNote(aSun, bSun)}}, + {"title": "沟通协作", "body": comp.CommDeep, "bullets": comp.CommTips}, + {"title": "冲突修复", "body": comp.ConflictDeep, "bullets": comp.ConflictTips}, + {"title": "亲密与边界", "body": comp.IntimacyDeep, "bullets": comp.IntimacyTips}, + {"title": "共同成长", "body": comp.GrowthDeep, "bullets": comp.GrowthTips}, }, - "interaction": []string{ - "用「我观察到…我需要…」代替指责句式。", - "每周留一次轻松同步:最近什么在消耗/滋养这段关系。", - }, - "maintenance": []string{ - "差异标签不是评分,而是协作说明书。", - "重要决定前,双方各写三点顾虑再合并。", - }, - "me_behavior": str(pa.Detail["behavior_pattern"]), + // legacy flat lists (tests / older clients) + "communication": append([]string{ + fmt.Sprintf("%s(%s):%s", aName, aLabel, firstTeaser(aDims, "communication")), + fmt.Sprintf("%s(%s):%s", bName, bLabel, firstTeaser(bDims, "communication")), + }, comp.CommTips...), + "interaction": comp.IntimacyTips, + "maintenance": comp.GrowthTips, + "me_behavior": str(pa.Detail["behavior_pattern"]), "other_behavior": str(pb.Detail["behavior_pattern"]), + "strengths_together": comp.ChemistryPoints, + "friction_points": comp.Watchouts, + "weekly_practice": comp.Weekly, + "conversation_scripts": comp.Scripts, + "faq": []map[string]string{ + {"q": "差异大是不是不合适?", "a": "差异本身不是问题,关键是双方是否愿意把差异当成说明书,而不是评分表。"}, + {"q": "总吵怎么办?", "a": "先停火复述,再谈方案;把「谁对谁错」换成「我们各自需要什么」。"}, + }, + } + relLabel, relBody, relBullets := relationTypePack(relationType, aName, bName) + if relLabel != "" { + summary["relation_type"] = relationType + summary["relation_type_label"] = relLabel + secs, _ := detail["sections"].([]map[string]any) + detail["sections"] = append([]map[string]any{ + {"title": "关系类型:" + relLabel, "body": relBody, "bullets": relBullets}, + }, secs...) } return Output{Summary: summary, Detail: detail} } +func relationTypePack(t, aName, bName string) (label, body string, bullets []string) { + switch t { + case "partner", "恋人", "伴侣": + return "伴侣", + fmt.Sprintf("%s与%s更适合把差异写成「亲密说明书」:欲望、节奏与安全感都说清楚。", aName, bName), + []string{"每周一次情绪复盘,不谈对错", "亲密请求用「我需要」句式", "边界:疲惫时先暂停再继续"} + case "family", "家人", "父母", "亲子": + return "家人", + fmt.Sprintf("家人关系里,%s与%s容易把旧角色带进新对话。试着把对方当「现在的人」而不是旧剧本。", aName, bName), + []string{"少用「你总是」句式", "大事拆成可协商的小请求", "保留各自的私人空间"} + case "friend", "朋友": + return "朋友", + fmt.Sprintf("友情里%s与%s可以更轻松地互补:约会期待与回应频率说开即可。", aName, bName), + []string{"约见用明确时间,减少猜测", "忙时用短消息保持连结", "冲突后用玩笑或直接道歉都行,别冷处理太久"} + default: + return "", "", nil + } +} + +type dimScore struct { + Score int + Teaser string +} + +func dimMap(v any) map[string]dimScore { + out := map[string]dimScore{} + arr, ok := v.([]map[string]any) + if !ok { + // Build() uses []map[string]any — also tolerate []any + raw, ok2 := v.([]any) + if !ok2 { + return out + } + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + key := str(m["key"]) + out[key] = dimScore{Score: asInt(m["score"]), Teaser: str(m["teaser"])} + } + return out + } + for _, m := range arr { + key := str(m["key"]) + out[key] = dimScore{Score: asInt(m["score"]), Teaser: str(m["teaser"])} + } + return out +} + +func asInt(v any) int { + switch n := v.(type) { + case int: + return n + case float64: + return int(n) + default: + return 0 + } +} + +func dimTitle(key string) string { + switch key { + case "personality": + return "性格特点" + case "communication": + return "沟通方式" + case "relation": + return "关系模式" + case "career": + return "事业节奏" + case "emotion": + return "情绪调节" + case "lifestyle": + return "生活节奏" + default: + return key + } +} + +func dimNote(key string, gap int, aName, bName string) string { + if gap > 12 { + return fmt.Sprintf("在「%s」上,%s 分值更高,适合由 %s 多给结构,%s 多给弹性。", dimTitle(key), aName, aName, bName) + } + if gap < -12 { + return fmt.Sprintf("在「%s」上,%s 分值更高,相处时可多尊重 %s 的节奏。", dimTitle(key), bName, bName) + } + return fmt.Sprintf("在「%s」上双方接近,容易形成默契,也要防止都默认对方「应该懂」。", dimTitle(key)) +} + +func firstTeaser(m map[string]dimScore, key string) string { + if d, ok := m[key]; ok && d.Teaser != "" { + return d.Teaser + } + return "表达方式各有节奏" +} + +func complementarityKey(a, b string) string { + if a == b { + return "same" + } + // simple buckets by trait family + drive := map[string]string{ + "稳进探索者": "steady", "细腻分析者": "steady", "守护担当者": "steady", "洞察策略者": "steady", + "敏锐连接者": "warm", "温和协调者": "warm", "热忱鼓舞者": "warm", + "果断行动派": "drive", "自由创造者": "drive", + } + ak, bk := drive[a], drive[b] + if ak == "" || bk == "" { + return "mix" + } + if ak == bk { + return "same_family" + } + if (ak == "steady" && bk == "warm") || (ak == "warm" && bk == "steady") { + return "steady_warm" + } + if (ak == "drive" && bk == "steady") || (ak == "steady" && bk == "drive") { + return "drive_steady" + } + if (ak == "drive" && bk == "warm") || (ak == "warm" && bk == "drive") { + return "drive_warm" + } + return "mix" +} + +type compCopy struct { + OneLiner, Overview, Chemistry, DeepOverview, CommDeep, ConflictDeep, IntimacyDeep, GrowthDeep string + ChemistryPoints, Watchouts, CommTips, ConflictTips, IntimacyTips, GrowthTips, Weekly, Scripts []string +} + +func complementarityCopy(key, aName, bName, aLabel, bLabel string) compCopy { + base := compCopy{ + OneLiner: fmt.Sprintf("%s偏「%s」,%s偏「%s」——差异可以写成相处说明书。", aName, aLabel, bName, bLabel), + Overview: fmt.Sprintf("双方在表达、节奏与需求上并不相同。把差异看清楚,比急着证明「谁更对」更有用。下面从沟通、冲突、亲密与共同成长几个维度展开。"), + Chemistry: "互补往往出现在:一方给结构,另一方给温度;或一方推进,另一方稳住质量。", + Weekly: []string{ + "本周进行一次 20 分钟「非解决问题」闲聊或散步。", + "各自写三件「我需要你这样支持我」的具体行为,互换阅读。", + "约定一个冲突停火词,任一方说出即暂停 15 分钟。", + }, + Scripts: []string{ + fmt.Sprintf("%s可以说:我需要先把事实说清楚,再谈感受。", aName), + fmt.Sprintf("%s可以说:我希望你先听到我的感受,再给方案。", bName), + "我们可以先复述对方一句,再表达自己的需要。", + }, + } + + switch key { + case "same": + base.OneLiner = fmt.Sprintf("你们风格接近(都偏「%s」),默契来得快,也要防止一起陷入同样的盲区。", aLabel) + base.Chemistry = "同类相吸:理解成本低,推进或回避也可能同步发生。" + base.DeepOverview = "风格相近意味着你们很容易「懂对方在想什么」,但也可能同时逃避冲突,或同时过度冲刺。建议定期引入外部视角(朋友建议、清单复盘),打破镜像盲区。" + base.CommDeep = "沟通效率高,但要刻意练习提出不同意见。安排「唱反调」轮值:每周一人专门提出风险点。" + base.ConflictDeep = "冲突可能被快速和好掩盖,问题未真正处理。用「问题清单」追踪未完成议题。" + base.IntimacyDeep = "熟悉感强,新鲜感需主动创造:共同学习或小旅行比重复日常更能充电。" + base.GrowthDeep = "一起设定一个共同小目标,并互相做问责伙伴。" + base.ChemistryPoints = []string{"理解成本低", "节奏容易对齐", "共同语言多"} + base.Watchouts = []string{"共享同一盲区", "缺少外部校正", "意见过于一致缺少张力"} + base.CommTips = []string{"鼓励提出异议", "重要决定写利弊表", "避免默认对方已懂"} + base.ConflictTips = []string{"追踪未完成议题", "避免假性和好", "冷静后再做决定"} + base.IntimacyTips = []string{"主动制造新鲜体验", "表达感谢要具体", "保留个人空间"} + base.GrowthTips = []string{"共同目标 + 问责", "每月复盘一次关系", "引入可信第三方建议"} + case "steady_warm": + base.Chemistry = "稳与暖互补:一方提供结构与可靠,另一方提供连接与温度。" + base.DeepOverview = fmt.Sprintf("%s与%s之间,最常见的张力是「要先讲清楚」还是「要先被看见」。若能轮流满足这两种需求,关系会既安全又有温度。", aName, bName) + base.CommDeep = "沟通协议:情绪话题先共鸣 2 分钟,再进入事实与方案;事务话题先结论,再补感受。" + base.ConflictDeep = "稳的一方别用沉默当结束;暖的一方别用追问升级压力。停火后用「我需要…」重开。" + base.IntimacyDeep = "暖的一方需要回应频率;稳的一方需要可预期的独处。把两者写进约定。" + base.GrowthDeep = "把互补写成分工:谁更擅长安抚,谁更擅长推进落地。" + base.ChemistryPoints = []string{"结构 × 温度", "可靠 × 连接", "可形成完整支持系统"} + base.Watchouts = []string{"一方觉得被冷落", "一方觉得被情绪淹没", "节奏错位积累委屈"} + base.CommTips = []string{"情绪先共鸣再方案", "事务先结论再感受", "用文字确认关键约定"} + base.ConflictTips = []string{"禁止用沉默结束话题", "追问前先问是否方便", "停火词机制"} + base.IntimacyTips = []string{"约定回应窗口", "尊重独处不被解读为冷淡", "每周一次深度连接"} + base.GrowthTips = []string{"按优势分工", "互相学习对方语言", "月度关系复盘"} + case "drive_steady": + base.Chemistry = "推与稳互补:一方破局加速,另一方把关质量与可持续。" + base.DeepOverview = "行动派容易嫌分析派慢;稳健派容易嫌行动派莽。把「速度」用在试验,「稳健」用在关键承诺,冲突会下降。" + base.CommDeep = "行动方给时间盒与最小方案;稳健方在时限内给风险清单,而不是无限延期。" + base.ConflictDeep = "冲突焦点常是节奏。先对齐「这是可逆试验还是重大决定」,再选速度。" + base.IntimacyDeep = "行动方用陪伴质量弥补碎片时间;稳健方减少用担忧浇灭热情,改用「我支持你试,我们设检查点」。" + base.GrowthDeep = "共同项目里明确角色:谁启动、谁验收、何时复盘。" + base.ChemistryPoints = []string{"破局 × 把关", "速度 × 质量", "试验与承诺可分工"} + base.Watchouts = []string{"节奏互斥", "一方压抑热情", "一方焦虑失控"} + base.CommTips = []string{"先定义可逆/不可逆", "时间盒决策", "风险清单限时"} + base.ConflictTips = []string{"争论节奏前先分类问题", "避免人格化指责", "用检查点代替否决"} + base.IntimacyTips = []string{"质量陪伴", "支持试验+检查点", "庆祝小进展"} + base.GrowthTips = []string{"项目角色清晰", "复盘节奏", "互相翻译动机"} + case "drive_warm": + base.Chemistry = "驱动与连接互补:一方带节奏,另一方维系人心与氛围。" + base.DeepOverview = "热情与效率碰到一起很有火花,也容易在「推进」与「照顾感受」之间拉扯。约定场景切换:冲刺模式 / 连接模式。" + base.CommDeep = "冲刺时短讯同步进度;连接时关掉任务话题。不要用效率语言处理情绪时刻。" + base.ConflictDeep = "驱动方避免「你想太多」;连接方避免「你只在乎结果」。改说具体需求。" + base.IntimacyDeep = "用共同体验(运动、活动)同时满足推进感与连接感。" + base.GrowthDeep = "轮流做「本周关系主理人」,负责安排一次连接或一次共同目标。" + base.ChemistryPoints = []string{"推进力 × 氛围", "行动号召力强", "共同体验易充电"} + base.Watchouts = []string{"情绪被效率压过", "承诺过多难兑现", "连接变任务化"} + base.CommTips = []string{"模式切换:冲刺/连接", "情绪时刻禁用效率话术", "进度短讯化"} + base.ConflictTips = []string{"禁止否定感受", "需求具体化", "修复后再推进"} + base.IntimacyTips = []string{"共同体验", "兑现小承诺", "非任务陪伴"} + base.GrowthTips = []string{"轮值关系主理人", "控制并行承诺", "庆祝与复盘并重"} + case "same_family": + base.DeepOverview = "你们属于相近气质族,容易互相理解,也要主动制造一点建设性差异,避免舒适区停滞。" + base.CommDeep = "沟通顺畅时更要确认细节,防止「好像说好了」其实理解不同。" + base.ConflictDeep = "冲突可能被淡化。强制做一次「最担心的三件事」互换。" + base.IntimacyDeep = "在舒适之外增加挑战性共同任务,刷新关系动能。" + base.GrowthDeep = "互相指出对方一个盲区,并约定本月各改一项小行为。" + base.ChemistryPoints = []string{"气质相近", "理解门槛低", "协作起步快"} + base.Watchouts = []string{"舒适区停滞", "细节默认错误", "回避尖锐议题"} + base.CommTips = []string{"确认细节", "书面关键约定", "鼓励异议"} + base.ConflictTips = []string{"互换担忧清单", "不假性和好", "设讨论截止"} + base.IntimacyTips = []string{"共同挑战任务", "新鲜体验", "具体感谢"} + base.GrowthTips = []string{"互指一个盲区", "月改一小行为", "外部输入"} + default: + base.DeepOverview = fmt.Sprintf("%s与%s风格路径不同,说明书价值更高。先承认差异合法,再谈协作规则。", aName, bName) + base.CommDeep = "建立双通道:事实通道与感受通道,讨论前先声明走哪一条。" + base.ConflictDeep = "冲突时回到共同目标句:我们都希望关系更好/事情做成。" + base.IntimacyDeep = "用定期同步取代猜测;空间与连接都要有配额。" + base.GrowthDeep = "把差异写成「我擅长 / 我需要」对照表,贴在看得见的地方。" + base.ChemistryPoints = []string{"视角多样", "可互补决策", "扩展彼此舒适区"} + base.Watchouts = []string{"误解成本高", "价值观冲突需早谈", "节奏长期错位"} + base.CommTips = []string{"声明沟通通道", "复述再回应", "关键约定书面化"} + base.ConflictTips = []string{"回到共同目标", "停火机制", "一次只谈一个议题"} + base.IntimacyTips = []string{"定期同步", "空间与连接配额", "具体肯定"} + base.GrowthTips = []string{"擅长/需要对照表", "月度复盘", "小步共同目标"} + } + return base +} + +func harmonyIndex(dims []map[string]any) int { + if len(dims) == 0 { + return 72 + } + sum := 0 + for _, d := range dims { + gap := asInt(d["gap"]) + sum += 100 - gap*4 + } + avg := sum / len(dims) + if avg < 45 { + return 45 + } + if avg > 96 { + return 96 + } + return avg +} + +func fitFromHarmony(score int, aLabel, bLabel string) (string, []string) { + switch { + case score >= 82: + return "默契互补型", []string{ + fmt.Sprintf("%s与%s节奏接近,适合共同推进小事。", aLabel, bLabel), + "把欣赏说出口,默契会更稳。", + "每周留一次轻松同步,不必每次谈大事。", + } + case score >= 68: + return "磨合成长型", []string{ + "差异可见,正好写成相处说明书。", + "冲突时先复述再提方案。", + "共同目标写清楚,减少猜忌。", + } + default: + return "反差探索型", []string{ + "反差大不等于不合,关键是边界与节奏。", + "重要约定尽量具体、可检查。", + "给彼此独处充电的空间。", + } + } +} + +func starPairNote(a, b string) string { + if a == b { + return fmt.Sprintf("同为%s:容易共鸣,也要避免同质盲区。", a) + } + return fmt.Sprintf("%s × %s:节奏不同,适合「我负责启动 / 你负责收尾」式分工。", a, b) +} + func str(v any) string { s, _ := v.(string) return s @@ -81,3 +447,17 @@ func strSlice(v any) []string { } return out } + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/apps/api/internal/relation/engine_test.go b/apps/api/internal/relation/engine_test.go index 13d4480..39fcffb 100644 --- a/apps/api/internal/relation/engine_test.go +++ b/apps/api/internal/relation/engine_test.go @@ -1,6 +1,7 @@ package relation import ( + "encoding/json" "strings" "testing" "time" @@ -10,13 +11,40 @@ func TestBuild(t *testing.T) { a := time.Date(1990, 1, 15, 0, 0, 0, 0, time.UTC) b := time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC) out := Build(a, b, "我", "TA") - if out.Summary["diff_one_liner"] == nil { - t.Fatal("missing diff") + if out.Summary["diff_one_liner"] == nil || out.Summary["overview"] == nil { + t.Fatal("missing summary fields") } - blob := str(out.Summary["diff_one_liner"]) + str(out.Detail["title"]) - for _, bad := range []string{"合盘", "运势", "吉凶", "合婚"} { + dims, ok := out.Summary["dimension_compare"].([]map[string]any) + if !ok || len(dims) < 3 { + t.Fatalf("expected dimension_compare, got %#v", out.Summary["dimension_compare"]) + } + secs, ok := out.Detail["sections"].([]map[string]any) + if !ok || len(secs) < 4 { + t.Fatalf("expected detail sections") + } + raw, _ := json.Marshal(out) + blob := string(raw) + for _, bad := range []string{"算命"} { if strings.Contains(blob, bad) { t.Fatalf("forbidden %q", bad) } } + if out.Summary["harmony_index"] == nil || out.Summary["star_compare"] == nil { + t.Fatal("missing match fields") + } + if out.Summary["love_index"] == nil || out.Summary["friend_index"] == nil || out.Summary["marriage_index"] == nil { + t.Fatal("missing match indices") + } +} + +func TestBuildSameStyle(t *testing.T) { + d := time.Date(1990, 1, 15, 0, 0, 0, 0, time.UTC) + out := Build(d, d, "我", "TA") + if !strings.Contains(str(out.Summary["diff_one_liner"]), "接近") && + !strings.Contains(str(out.Summary["diff_one_liner"]), "说明书") { + // same birth => same style; copy should mention 接近 or still valid + if out.Detail["weekly_practice"] == nil { + t.Fatal("missing weekly_practice") + } + } } diff --git a/apps/api/internal/repository/ask_repo.go b/apps/api/internal/repository/ask_repo.go new file mode 100644 index 0000000..13bc48d --- /dev/null +++ b/apps/api/internal/repository/ask_repo.go @@ -0,0 +1,104 @@ +package repository + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yuxingu/digital-psychology/apps/api/internal/model" +) + +// AskRepo persists ask threads and messages. +type AskRepo struct { + Pool *pgxpool.Pool +} + +// CreateThread inserts a thread bound to a profile. +func (r *AskRepo) CreateThread(ctx context.Context, userID, profileID uuid.UUID, scene *string) (*model.AskThread, error) { + t := &model.AskThread{} + err := r.Pool.QueryRow(ctx, ` + INSERT INTO ask_threads(user_id, profile_id, scene) + VALUES ($1,$2,$3) + RETURNING id, user_id, profile_id, scene, created_at`, + userID, profileID, scene, + ).Scan(&t.ID, &t.UserID, &t.ProfileID, &t.Scene, &t.CreatedAt) + return t, err +} + +// GetThreadForUser loads a thread owned by user. +func (r *AskRepo) GetThreadForUser(ctx context.Context, userID, threadID uuid.UUID) (*model.AskThread, error) { + t := &model.AskThread{} + err := r.Pool.QueryRow(ctx, ` + SELECT id, user_id, profile_id, scene, created_at + FROM ask_threads WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, + threadID, userID, + ).Scan(&t.ID, &t.UserID, &t.ProfileID, &t.Scene, &t.CreatedAt) + return t, err +} + +// ListMessages returns messages in a thread (oldest first). +func (r *AskRepo) ListMessages(ctx context.Context, threadID uuid.UUID) ([]model.AskMessage, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, thread_id, role, content, created_at + FROM ask_messages WHERE thread_id=$1 AND deleted_at IS NULL + ORDER BY created_at ASC`, threadID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.AskMessage + for rows.Next() { + var m model.AskMessage + if err := rows.Scan(&m.ID, &m.ThreadID, &m.Role, &m.Content, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// InsertMessage stores one message. +func (r *AskRepo) InsertMessage(ctx context.Context, threadID uuid.UUID, role, content string) (*model.AskMessage, error) { + m := &model.AskMessage{} + err := r.Pool.QueryRow(ctx, ` + INSERT INTO ask_messages(thread_id, role, content) + VALUES ($1,$2,$3) + RETURNING id, thread_id, role, content, created_at`, + threadID, role, content, + ).Scan(&m.ID, &m.ThreadID, &m.Role, &m.Content, &m.CreatedAt) + return m, err +} + +// CountUserAssistantMessages counts all assistant replies for quota (free tier). +func (r *AskRepo) CountUserAssistantMessages(ctx context.Context, userID uuid.UUID) (int, error) { + var n int + err := r.Pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM ask_messages m + JOIN ask_threads t ON t.id = m.thread_id + WHERE t.user_id=$1 AND m.role='assistant' AND m.deleted_at IS NULL AND t.deleted_at IS NULL`, + userID, + ).Scan(&n) + return n, err +} + +// ConsumeMembershipQuota decrements ask_quota_left when membership is active. +// Returns false if no active membership or quota is already 0. +func (r *AskRepo) ConsumeMembershipQuota(ctx context.Context, userID uuid.UUID) (ok bool, left int, err error) { + err = r.Pool.QueryRow(ctx, ` + UPDATE memberships + SET ask_quota_left = ask_quota_left - 1, updated_at=now() + WHERE user_id=$1 AND status='active' AND expires_at > now() + AND deleted_at IS NULL AND ask_quota_left > 0 + RETURNING ask_quota_left`, userID, + ).Scan(&left) + if errors.Is(err, pgx.ErrNoRows) { + return false, 0, nil + } + if err != nil { + return false, 0, err + } + return true, left, nil +} diff --git a/apps/api/internal/repository/growth_repo.go b/apps/api/internal/repository/growth_repo.go new file mode 100644 index 0000000..a914b1b --- /dev/null +++ b/apps/api/internal/repository/growth_repo.go @@ -0,0 +1,131 @@ +package repository + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yuxingu/digital-psychology/apps/api/internal/model" +) + +// GrowthRepo persists growth plans and check-ins. +type GrowthRepo struct { + Pool *pgxpool.Pool +} + +// CreatePlan inserts a plan. +func (r *GrowthRepo) CreatePlan(ctx context.Context, userID uuid.UUID, title, focus string) (*model.GrowthPlan, error) { + p := &model.GrowthPlan{} + err := r.Pool.QueryRow(ctx, ` + INSERT INTO growth_plans(user_id, title, focus) + VALUES ($1,$2,$3) + RETURNING id, user_id, title, focus, status, created_at`, + userID, title, focus, + ).Scan(&p.ID, &p.UserID, &p.Title, &p.Focus, &p.Status, &p.CreatedAt) + return p, err +} + +// ListPlans returns active plans for user. +func (r *GrowthRepo) ListPlans(ctx context.Context, userID uuid.UUID) ([]model.GrowthPlan, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, user_id, title, focus, status, created_at + FROM growth_plans + WHERE user_id=$1 AND deleted_at IS NULL AND status='active' + ORDER BY created_at DESC LIMIT 20`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.GrowthPlan + for rows.Next() { + var p model.GrowthPlan + if err := rows.Scan(&p.ID, &p.UserID, &p.Title, &p.Focus, &p.Status, &p.CreatedAt); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// Checkin upserts today's check-in. +func (r *GrowthRepo) Checkin(ctx context.Context, userID, planID uuid.UUID, day time.Time, note *string) (*model.GrowthCheckin, error) { + var owner uuid.UUID + err := r.Pool.QueryRow(ctx, ` + SELECT user_id FROM growth_plans WHERE id=$1 AND deleted_at IS NULL`, planID, + ).Scan(&owner) + if err != nil { + return nil, errors.New("plan not found") + } + if owner != userID { + return nil, errors.New("plan not found") + } + c := &model.GrowthCheckin{} + var dayOut time.Time + err = r.Pool.QueryRow(ctx, ` + INSERT INTO growth_checkins(plan_id, user_id, day, note) + VALUES ($1,$2,$3::date,$4) + ON CONFLICT (plan_id, day) DO UPDATE SET note=EXCLUDED.note + RETURNING id, plan_id, user_id, day, note, created_at`, + planID, userID, day.Format("2006-01-02"), note, + ).Scan(&c.ID, &c.PlanID, &c.UserID, &dayOut, &c.Note, &c.CreatedAt) + if err != nil { + return nil, err + } + c.Day = dayOut.Format("2006-01-02") + return c, nil +} + +// ListCheckinsRecent returns check-ins for a plan in last N days. +func (r *GrowthRepo) ListCheckinsRecent(ctx context.Context, userID, planID uuid.UUID, days int) ([]model.GrowthCheckin, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, plan_id, user_id, day, note, created_at + FROM growth_checkins + WHERE user_id=$1 AND plan_id=$2 AND day >= (CURRENT_DATE - $3::int) + ORDER BY day DESC`, userID, planID, days) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.GrowthCheckin + for rows.Next() { + var c model.GrowthCheckin + var dayOut time.Time + if err := rows.Scan(&c.ID, &c.PlanID, &c.UserID, &dayOut, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + c.Day = dayOut.Format("2006-01-02") + out = append(out, c) + } + return out, rows.Err() +} + +// ListMoodsRecent for companion trail (reuse moods table). +func (r *GrowthRepo) ListMoodsRecent(ctx context.Context, userID uuid.UUID, days int) ([]model.Mood, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, user_id, day, score, note, created_at + FROM moods + WHERE user_id=$1 AND deleted_at IS NULL AND day >= (CURRENT_DATE - $2::int) + ORDER BY day DESC`, userID, days) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.Mood + for rows.Next() { + var m model.Mood + var dayOut time.Time + if err := rows.Scan(&m.ID, &m.UserID, &dayOut, &m.Score, &m.Note, &m.CreatedAt); err != nil { + return nil, err + } + m.Day = dayOut.Format("2006-01-02") + out = append(out, m) + } + return out, rows.Err() +} + +// ErrNoRows re-export helper. +var ErrNoRows = pgx.ErrNoRows diff --git a/apps/api/internal/repository/imagecard_repo.go b/apps/api/internal/repository/imagecard_repo.go new file mode 100644 index 0000000..8f93b24 --- /dev/null +++ b/apps/api/internal/repository/imagecard_repo.go @@ -0,0 +1,77 @@ +package repository + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const imageCardDailyFree = 3 + +// ImageCardRepo tracks daily draw quotas. +type ImageCardRepo struct { + Pool *pgxpool.Pool +} + +// UsedToday returns how many free draws used today. +func (r *ImageCardRepo) UsedToday(ctx context.Context, userID uuid.UUID, day time.Time) (int, error) { + var n int + err := r.Pool.QueryRow(ctx, ` + SELECT used FROM image_card_quotas + WHERE user_id=$1 AND day=$2::date`, + userID, day.Format("2006-01-02"), + ).Scan(&n) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + return n, err +} + +// TryConsume increments today's used count when under free limit. +// Returns remaining after consume. ErrQuotaExhausted when free tier is out. +func (r *ImageCardRepo) TryConsume(ctx context.Context, userID uuid.UUID, day time.Time) (remaining int, err error) { + dayStr := day.Format("2006-01-02") + var used int + err = r.Pool.QueryRow(ctx, ` + INSERT INTO image_card_quotas(user_id, day, used) + VALUES ($1, $2::date, 1) + ON CONFLICT (user_id, day) DO UPDATE + SET used = image_card_quotas.used + 1 + WHERE image_card_quotas.used < $3 + RETURNING used`, + userID, dayStr, imageCardDailyFree, + ).Scan(&used) + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrQuotaExhausted + } + if err != nil { + return 0, err + } + return imageCardDailyFree - used, nil +} + +// RemainingToday without consuming. +func (r *ImageCardRepo) RemainingToday(ctx context.Context, userID uuid.UUID, day time.Time, unlimited bool) (int, error) { + if unlimited { + return 99, nil + } + used, err := r.UsedToday(ctx, userID, day) + if err != nil { + return 0, err + } + left := imageCardDailyFree - used + if left < 0 { + left = 0 + } + return left, nil +} + +// DailyFreeLimit is the free-tier cap. +func DailyFreeLimit() int { return imageCardDailyFree } + +// ErrQuotaExhausted means free daily draws are used up. +var ErrQuotaExhausted = errors.New("今日免费次数已用完") diff --git a/apps/api/internal/repository/mood_repo.go b/apps/api/internal/repository/mood_repo.go new file mode 100644 index 0000000..eb9ace3 --- /dev/null +++ b/apps/api/internal/repository/mood_repo.go @@ -0,0 +1,55 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yuxingu/digital-psychology/apps/api/internal/model" +) + +// MoodRepo persists daily moods. +type MoodRepo struct { + Pool *pgxpool.Pool +} + +// UpsertToday inserts or updates today's mood for user. +func (r *MoodRepo) UpsertToday(ctx context.Context, userID uuid.UUID, day time.Time, score *int, note *string) (*model.Mood, error) { + m := &model.Mood{} + var dayOut time.Time + err := r.Pool.QueryRow(ctx, ` + INSERT INTO moods(user_id, day, score, note) + VALUES ($1, $2::date, $3, $4) + ON CONFLICT (user_id, day) DO UPDATE + SET score = EXCLUDED.score, + note = EXCLUDED.note, + updated_at = now(), + deleted_at = NULL + RETURNING id, user_id, day, score, note, created_at`, + userID, day.Format("2006-01-02"), score, note, + ).Scan(&m.ID, &m.UserID, &dayOut, &m.Score, &m.Note, &m.CreatedAt) + if err != nil { + return nil, err + } + m.Day = dayOut.Format("2006-01-02") + return m, nil +} + +// GetToday returns today's mood if any. +func (r *MoodRepo) GetToday(ctx context.Context, userID uuid.UUID, day time.Time) (*model.Mood, error) { + m := &model.Mood{} + var dayOut time.Time + err := r.Pool.QueryRow(ctx, ` + SELECT id, user_id, day, score, note, created_at + FROM moods + WHERE user_id=$1 AND day=$2::date AND deleted_at IS NULL`, + userID, day.Format("2006-01-02"), + ).Scan(&m.ID, &m.UserID, &dayOut, &m.Score, &m.Note, &m.CreatedAt) + if err != nil { + return nil, err + } + m.Day = dayOut.Format("2006-01-02") + return m, nil +} diff --git a/apps/api/internal/repository/profile_repo.go b/apps/api/internal/repository/profile_repo.go index 6f35d11..642eac0 100644 --- a/apps/api/internal/repository/profile_repo.go +++ b/apps/api/internal/repository/profile_repo.go @@ -2,6 +2,8 @@ package repository import ( "context" + "errors" + "math" "time" "github.com/google/uuid" @@ -15,26 +17,40 @@ type ProfileRepo struct { Pool *pgxpool.Pool } -// Create inserts a profile. -func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string) (*model.Profile, error) { +const profileCols = ` + id, user_id, relation, display_name, birth_date, relation_type, + CASE WHEN birth_time IS NULL THEN NULL ELSE to_char(birth_time, 'HH24:MI') END, + birth_place, geo_lat, geo_lng, COALESCE(geo_visible, false), created_at` + +func scanProfile(scan func(dest ...any) error) (*model.Profile, error) { p := &model.Profile{} - err := r.Pool.QueryRow(ctx, ` - INSERT INTO profiles(user_id, relation, display_name, birth_date, relation_type) - VALUES ($1,$2,$3,$4,$5) - RETURNING id, user_id, relation, display_name, birth_date, created_at`, - userID, relation, name, birth, relationType, - ).Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.CreatedAt) + var bt *string + err := scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &bt, + &p.BirthPlace, &p.GeoLat, &p.GeoLng, &p.GeoVisible, &p.CreatedAt) if err != nil { return nil, err } - p.RelationType = relationType + p.BirthTime = bt return p, nil } +// Create inserts a profile. +func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string) (*model.Profile, error) { + row := r.Pool.QueryRow(ctx, ` + INSERT INTO profiles(user_id, relation, display_name, birth_date, relation_type, birth_time, birth_place) + VALUES ($1,$2,$3,$4,$5, + CASE WHEN $6::text IS NULL OR $6::text = '' THEN NULL ELSE $6::time END, + NULLIF(TRIM($7::text), '')) + RETURNING `+profileCols, + userID, relation, name, birth, relationType, birthTime, birthPlace, + ) + return scanProfile(row.Scan) +} + // ListByUser returns non-deleted profiles. func (r *ProfileRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) { rows, err := r.Pool.Query(ctx, ` - SELECT id, user_id, relation, display_name, birth_date, relation_type, created_at + SELECT `+profileCols+` FROM profiles WHERE user_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC`, userID) if err != nil { @@ -43,25 +59,127 @@ func (r *ProfileRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]model defer rows.Close() var out []model.Profile for rows.Next() { - var p model.Profile - if err := rows.Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &p.CreatedAt); err != nil { + p, err := scanProfile(rows.Scan) + if err != nil { return nil, err } - out = append(out, p) + out = append(out, *p) } return out, rows.Err() } // GetForUser loads a profile owned by user. func (r *ProfileRepo) GetForUser(ctx context.Context, userID, profileID uuid.UUID) (*model.Profile, error) { - p := &model.Profile{} - err := r.Pool.QueryRow(ctx, ` - SELECT id, user_id, relation, display_name, birth_date, relation_type, created_at + row := r.Pool.QueryRow(ctx, ` + SELECT `+profileCols+` FROM profiles WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, profileID, userID, - ).Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &p.CreatedAt) + ) + return scanProfile(row.Scan) +} + +// GetByID loads any non-deleted profile (for invite host lookup). +func (r *ProfileRepo) GetByID(ctx context.Context, profileID uuid.UUID) (*model.Profile, error) { + row := r.Pool.QueryRow(ctx, ` + SELECT `+profileCols+` + FROM profiles WHERE id=$1 AND deleted_at IS NULL`, profileID) + return scanProfile(row.Scan) +} + +// UpdateForUser patches display name / birth / geo. +func (r *ProfileRepo) UpdateForUser(ctx context.Context, userID, profileID uuid.UUID, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string, geoLat, geoLng *float64, geoVisible *bool) (*model.Profile, error) { + row := r.Pool.QueryRow(ctx, ` + UPDATE profiles + SET display_name=$3, birth_date=$4, relation_type=$5, + birth_time=CASE WHEN $6::text IS NULL OR $6::text = '' THEN birth_time ELSE $6::time END, + birth_place=CASE WHEN $7::text IS NULL THEN birth_place ELSE NULLIF(TRIM($7::text), '') END, + geo_lat=CASE WHEN $8::float8 IS NULL THEN geo_lat ELSE $8 END, + geo_lng=CASE WHEN $9::float8 IS NULL THEN geo_lng ELSE $9 END, + geo_visible=CASE WHEN $10::bool IS NULL THEN geo_visible ELSE $10 END, + updated_at=now() + WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL + RETURNING `+profileCols, + profileID, userID, name, birth, relationType, birthTime, birthPlace, geoLat, geoLng, geoVisible, + ) + return scanProfile(row.Scan) +} + +// NearbyItem is a visible profile with distance. +type NearbyItem struct { + Profile model.Profile `json:"profile"` + Distance float64 `json:"distance_km"` +} + +// ListNearby returns other users' self profiles with geo_visible within radius. +func (r *ProfileRepo) ListNearby(ctx context.Context, excludeUserID uuid.UUID, lat, lng, radiusKm float64, limit int) ([]NearbyItem, error) { + if limit <= 0 || limit > 50 { + limit = 20 + } + rows, err := r.Pool.Query(ctx, ` + SELECT `+profileCols+` + FROM profiles + WHERE deleted_at IS NULL AND geo_visible = true + AND relation = 'self' + AND user_id <> $1 + AND geo_lat IS NOT NULL AND geo_lng IS NOT NULL + LIMIT 200`, excludeUserID) if err != nil { return nil, err } - return p, nil + defer rows.Close() + var out []NearbyItem + for rows.Next() { + p, err := scanProfile(rows.Scan) + if err != nil { + return nil, err + } + if p.GeoLat == nil || p.GeoLng == nil { + continue + } + d := haversineKm(lat, lng, *p.GeoLat, *p.GeoLng) + if d <= radiusKm { + out = append(out, NearbyItem{Profile: *p, Distance: math.Round(d*10) / 10}) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + // sort by distance + for i := 0; i < len(out); i++ { + for j := i + 1; j < len(out); j++ { + if out[j].Distance < out[i].Distance { + out[i], out[j] = out[j], out[i] + } + } + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func haversineKm(lat1, lng1, lat2, lng2 float64) float64 { + const R = 6371.0 + toR := func(d float64) float64 { return d * math.Pi / 180 } + dLat := toR(lat2 - lat1) + dLng := toR(lng2 - lng1) + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(toR(lat1))*math.Cos(toR(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2) + return 2 * R * math.Asin(math.Sqrt(a)) +} + +// SoftDeleteForUser marks a profile deleted. +func (r *ProfileRepo) SoftDeleteForUser(ctx context.Context, userID, profileID uuid.UUID) error { + tag, err := r.Pool.Exec(ctx, ` + UPDATE profiles SET deleted_at=now(), updated_at=now() + WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, + profileID, userID, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errors.New("profile not found") + } + return nil } diff --git a/apps/api/internal/repository/report_repo.go b/apps/api/internal/repository/report_repo.go index 0a9f9ec..41bfd40 100644 --- a/apps/api/internal/repository/report_repo.go +++ b/apps/api/internal/repository/report_repo.go @@ -3,8 +3,11 @@ package repository import ( "context" "encoding/json" + "errors" + "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/yuxingu/digital-psychology/apps/api/internal/model" @@ -38,6 +41,32 @@ func (r *ReportRepo) GetForUser(ctx context.Context, userID, reportID uuid.UUID) return rep, err } +// ListForUser returns recent reports for a user (summary only usage at service layer). +func (r *ReportRepo) ListForUser(ctx context.Context, userID uuid.UUID, limit int) ([]model.GrowthReport, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := r.Pool.Query(ctx, ` + SELECT id, user_id, profile_id, type, summary, detail, created_at + FROM growth_reports + WHERE user_id=$1 AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $2`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.GrowthReport + for rows.Next() { + var rep model.GrowthReport + if err := rows.Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt); err != nil { + return nil, err + } + out = append(out, rep) + } + return out, rows.Err() +} + // HasDeepAccess reports whether user purchased deep access for report. func (r *ReportRepo) HasDeepAccess(ctx context.Context, userID, reportID uuid.UUID) (bool, error) { var ok bool @@ -60,6 +89,37 @@ func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID) return ok, err } +// MembershipRow is the current membership snapshot for a user. +type MembershipRow struct { + Plan string + Status string + ExpiresAt *time.Time + AskQuotaLeft int + Active bool +} + +// GetMembership returns membership status; missing row → inactive. +func (r *ReportRepo) GetMembership(ctx context.Context, userID uuid.UUID) (*MembershipRow, error) { + var plan, status string + var expires *time.Time + var quota int + err := r.Pool.QueryRow(ctx, ` + SELECT plan, status, expires_at, ask_quota_left + FROM memberships + WHERE user_id=$1 AND deleted_at IS NULL`, userID, + ).Scan(&plan, &status, &expires, "a) + if errors.Is(err, pgx.ErrNoRows) { + return &MembershipRow{Active: false, Status: "none"}, nil + } + if err != nil { + return nil, err + } + active := status == "active" && expires != nil && expires.After(time.Now()) + return &MembershipRow{ + Plan: plan, Status: status, ExpiresAt: expires, AskQuotaLeft: quota, Active: active, + }, nil +} + // CreateOrder inserts an order. func (r *ReportRepo) CreateOrder(ctx context.Context, userID uuid.UUID, kind, plan string, reportID *uuid.UUID, amount int) (uuid.UUID, error) { var id uuid.UUID @@ -121,7 +181,7 @@ func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) err } if _, err := tx.Exec(ctx, ` INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left) - VALUES ($1,$2,'active', now() + ($3::text || ' days')::interval, 100) + VALUES ($1,$2,'active', now() + ($3 * interval '1 day'), 100) ON CONFLICT (user_id) DO UPDATE SET plan=EXCLUDED.plan, status='active', expires_at=EXCLUDED.expires_at, ask_quota_left=100, updated_at=now()`, diff --git a/apps/api/internal/repository/synastry_invite_repo.go b/apps/api/internal/repository/synastry_invite_repo.go new file mode 100644 index 0000000..60a6ecc --- /dev/null +++ b/apps/api/internal/repository/synastry_invite_repo.go @@ -0,0 +1,125 @@ +package repository + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yuxingu/digital-psychology/apps/api/internal/model" +) + +// SynastryInviteRepo persists synastry invite tokens. +type SynastryInviteRepo struct { + Pool *pgxpool.Pool +} + +// Create inserts an invite (7-day expiry). +func (r *SynastryInviteRepo) Create(ctx context.Context, hostUser, hostProfile uuid.UUID) (*model.SynastryInvite, error) { + token, err := randomToken(16) + if err != nil { + return nil, err + } + exp := time.Now().UTC().Add(7 * 24 * time.Hour) + inv := &model.SynastryInvite{} + err = r.Pool.QueryRow(ctx, ` + INSERT INTO synastry_invites(token, host_user_id, host_profile_id, expires_at) + VALUES ($1,$2,$3,$4) + RETURNING id, token, host_user_id, host_profile_id, expires_at, + guest_user_id, guest_profile_id, report_id, created_at`, + token, hostUser, hostProfile, exp, + ).Scan(&inv.ID, &inv.Token, &inv.HostUserID, &inv.HostProfileID, &inv.ExpiresAt, + &inv.GuestUserID, &inv.GuestProfileID, &inv.ReportID, &inv.CreatedAt) + return inv, err +} + +// GetByToken loads a non-deleted invite. +func (r *SynastryInviteRepo) GetByToken(ctx context.Context, token string) (*model.SynastryInvite, error) { + inv := &model.SynastryInvite{} + err := r.Pool.QueryRow(ctx, ` + SELECT id, token, host_user_id, host_profile_id, expires_at, + guest_user_id, guest_profile_id, report_id, created_at + FROM synastry_invites WHERE token=$1 AND deleted_at IS NULL`, token, + ).Scan(&inv.ID, &inv.Token, &inv.HostUserID, &inv.HostProfileID, &inv.ExpiresAt, + &inv.GuestUserID, &inv.GuestProfileID, &inv.ReportID, &inv.CreatedAt) + if err != nil { + return nil, err + } + return inv, nil +} + +// AcceptAtomic creates guest profile + synastry report + marks invite in one transaction. +func (r *SynastryInviteRepo) AcceptAtomic( + ctx context.Context, + inviteID, guestUser uuid.UUID, + name string, + birth time.Time, + birthTime, birthPlace *string, + summary, detail json.RawMessage, +) (*model.GrowthReport, error) { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var guestID uuid.UUID + var bt *string + var bp *string + var createdAt time.Time + err = tx.QueryRow(ctx, ` + INSERT INTO profiles(user_id, relation, display_name, birth_date, birth_time, birth_place) + VALUES ($1,'other',$2,$3, + CASE WHEN $4::text IS NULL OR $4::text = '' THEN NULL ELSE $4::time END, + NULLIF(TRIM($5::text), '')) + RETURNING id, + CASE WHEN birth_time IS NULL THEN NULL ELSE to_char(birth_time, 'HH24:MI') END, + birth_place, created_at`, + guestUser, name, birth, birthTime, birthPlace, + ).Scan(&guestID, &bt, &bp, &createdAt) + if err != nil { + return nil, err + } + + rep := &model.GrowthReport{} + err = tx.QueryRow(ctx, ` + INSERT INTO growth_reports(user_id, profile_id, type, summary, detail) + VALUES ($1,$2,'synastry',$3,$4) + RETURNING id, user_id, profile_id, type, summary, detail, created_at`, + guestUser, guestID, summary, detail, + ).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt) + if err != nil { + return nil, err + } + + tag, err := tx.Exec(ctx, ` + UPDATE synastry_invites + SET guest_user_id=$2, guest_profile_id=$3, report_id=$4, updated_at=now() + WHERE id=$1 AND deleted_at IS NULL AND report_id IS NULL AND expires_at > now()`, + inviteID, guestUser, guestID, rep.ID, + ) + if err != nil { + return nil, err + } + if tag.RowsAffected() == 0 { + return nil, errors.New("invite already used or expired") + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return rep, nil +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/apps/api/internal/rhythm/engine.go b/apps/api/internal/rhythm/engine.go new file mode 100644 index 0000000..ddc4d40 --- /dev/null +++ b/apps/api/internal/rhythm/engine.go @@ -0,0 +1,205 @@ +// Package rhythm builds 身心节律 reports (lifestyle exploration, not fortune/medical claims). +package rhythm + +import ( + "fmt" + "time" +) + +// Output free summary + gated detail. +type Output struct { + Summary map[string]any + Detail map[string]any +} + +var elements = []string{"木", "火", "土", "金", "水"} + +// Build from birth date. +func Build(birth time.Time, displayName string) Output { + name := displayName + if name == "" { + name = "你" + } + primary := elements[(birth.Year()+int(birth.Month())+birth.Day())%5] + secondary := elements[(birth.YearDay())%5] + if secondary == primary { + secondary = elements[(birth.YearDay()+1)%5] + } + tip := tips[primary] + + dims := []map[string]any{ + {"key": "element", "title": "主导倾向", "teaser": fmt.Sprintf("更偏「%s」隐喻", primary), "score": 80}, + {"key": "balance", "title": "平衡关注", "teaser": fmt.Sprintf("可留意「%s」侧滋养", secondary), "score": 68}, + {"key": "sleep", "title": "作息", "teaser": tip.Sleep, "score": 72}, + {"key": "diet", "title": "饮食节奏", "teaser": tip.Diet, "score": 70}, + {"key": "move", "title": "活动", "teaser": tip.Move, "score": 74}, + {"key": "mood", "title": "情绪调节", "teaser": tip.Mood, "score": 71}, + } + + todayTip := tip.Sleep + weekday := int(time.Now().Weekday()) + weekHints := []string{tip.Mood, tip.Move, tip.Diet, tip.Sleep, tip.WeekTip, tip.Move, tip.Mood} + summary := map[string]any{ + "title": "身心节律·基础", + "style_label": primary + "倾向", + "headline": fmt.Sprintf("%s的身心节律更偏「%s」隐喻", name, primary), + "one_liner": tip.OneLiner, + "overview": fmt.Sprintf("%s%s主导倾向「%s」,辅助关注「%s」。以下建议用于生活节奏探索,不构成医疗意见。", name, tip.Overview, primary, secondary), + "life_tip": tip.WeekTip, + "today_tip": todayTip, + "week_focus": weekHints[weekday%len(weekHints)], + "keywords": []string{primary, secondary, "生活建议", "节律"}, + "dimensions": dims, + "primary_element": primary, + "secondary_element": secondary, + "strengths_preview": tip.Strengths[:min(3, len(tip.Strengths))], + "blind_spots_preview": []string{"完整习惯方案见深度版。"}, + } + + detail := map[string]any{ + "title": "身心节律·完整建议", + "sections": []map[string]any{ + {"title": "节律总览", "body": tip.DeepOverview, "bullets": tip.OverviewBullets}, + {"title": "作息建议", "body": tip.SleepDeep, "bullets": tip.SleepBullets}, + {"title": "饮食节奏", "body": tip.DietDeep, "bullets": tip.DietBullets}, + {"title": "活动与放松", "body": tip.MoveDeep, "bullets": tip.MoveBullets}, + {"title": "情绪与压力", "body": tip.MoodDeep, "bullets": tip.MoodBullets}, + {"title": "与节气联动", "body": "可结合「节气生活」调整当季节奏;变化慢一点,观察身体反馈即可。", "bullets": []string{"查看今日节气", "一次只改一个习惯", "不适就医,勿自行当治疗"}}, + {"title": "今日生活建议", "body": tip.Sleep + " " + tip.Mood, "bullets": []string{tip.Move, tip.Diet}}, + {"title": "本周节奏", "body": tip.WeekTip, "bullets": tip.OverviewBullets}, + }, + "strengths": tip.Strengths, + "blind_spots": tip.BlindSpots, + "growth_plan": []map[string]any{ + {"phase": "本周", "focus": tip.WeekTip}, + {"phase": "本月", "focus": tip.MonthTip}, + {"phase": "长期", "focus": "形成「充电/耗电」清单,并与节气提醒一起复盘。"}, + }, + "conversation_scripts": tip.Scripts, + "faq": []map[string]string{ + {"q": "这是占卜或看病吗?", "a": "都不是。这是身心节律的自我探索与生活建议,不预测未来,也不构成医疗诊断。"}, + }, + "behavior_pattern": tip.DeepOverview, + "relation_style": tip.MoodDeep, + "growth_direction": tip.MonthTip, + } + return Output{Summary: summary, Detail: detail} +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +type tipPack struct { + OneLiner, Overview, WeekTip, MonthTip, DeepOverview string + Sleep, Diet, Move, Mood string + SleepDeep, DietDeep, MoveDeep, MoodDeep string + OverviewBullets, SleepBullets, DietBullets, MoveBullets, MoodBullets []string + Strengths, BlindSpots, Scripts []string +} + +var tips = map[string]tipPack{ + "木": { + OneLiner: "你适合有伸展与目标感的节奏,压得太死容易闷。", + Overview: "在节律隐喻里偏「生发」:需要空间与方向。", + WeekTip: "本周安排三次户外或拉伸,每次 15 分钟。", + MonthTip: "给自己一个可完成的小目标,并拆成周检查点。", + DeepOverview: "木倾向的人常需要「向前」的感觉。卡住时,动一动比硬想更有效。注意肩颈与情绪积压。", + Sleep: "尽量固定入睡窗口", Diet: "清淡多样,少暴饮暴食", Move: "伸展、快走、轻度有氧", Mood: "把烦闷说出来或写下来", + SleepDeep: "规律睡眠能显著降低烦躁。睡前一小时减少争执性内容。", + DietDeep: "少油炸重味,增加新鲜蔬菜;饥一顿饱一顿会放大情绪波动。", + MoveDeep: "优先选择有伸展感的活动,避免长期久坐。", + MoodDeep: "愤怒或闷气是常见信号。命名情绪后做一次短活动再对话。", + OverviewBullets: []string{"需要方向感", "忌长期压抑", "动一动有助疏通"}, + SleepBullets: []string{"固定入睡", "睡前减刺激"}, + DietBullets: []string{"清淡多样", "规律进餐"}, + MoveBullets: []string{"伸展快走", "避免久坐"}, + MoodBullets: []string{"命名情绪", "先活动再谈"}, + Strengths: []string{"行动欲", "目标感", "适应生长"}, + BlindSpots: []string{"易急躁", "压怒", "忽视休息"}, + Scripts: []string{"我有点闷,想先走走再聊。", "我需要一个小目标来找到节奏。"}, + }, + "火": { + OneLiner: "你靠热情与连接充电,也要注意过热后的回落。", + Overview: "节律隐喻偏「温煦」:表达与互动重要。", + WeekTip: "保证两晚 23:30 前入睡,并安排一次轻松社交。", + MonthTip: "学会在热情后安排恢复日,而不是连续硬冲。", + DeepOverview: "火倾向的人能量外放。高潮后空虚很常见,回落期做轻松恢复即可。", + Sleep: "避免熬夜透支", Diet: "少辛辣刺激过量", Move: "有节奏的有氧", Mood: "表达后也要倾听", + SleepDeep: "睡眠差会放大冲动。把「今晚早睡」当成任务完成。", + DietDeep: "辛辣咖啡适量;补水与规律三餐稳住节奏。", + MoveDeep: "运动有助释放兴奋,但避免以透支换快乐。", + MoodDeep: "表达是优点。记得给对方回应空间,也给自己降温时间。", + OverviewBullets: []string{"热情连接", "注意回落", "表达重要"}, + SleepBullets: []string{"少熬夜", "降温再睡"}, + DietBullets: []string{"刺激适量", "规律进餐"}, + MoveBullets: []string{"有氧释放", "勿透支"}, + MoodBullets: []string{"表达+倾听", "回落期恢复"}, + Strengths: []string{"感染力", "行动号召", "乐观"}, + BlindSpots: []string{"过热", "承诺过多", "忽视恢复"}, + Scripts: []string{"我很兴奋,我们先定本周唯一下一步。", "我需要今晚早点休息。"}, + }, + "土": { + OneLiner: "你需要稳定与踏实,变化太快会消耗安全感。", + Overview: "节律隐喻偏「承载」:规律比刺激更重要。", + WeekTip: "固定三餐时间,并完成一次环境整理(桌面/房间一角)。", + MonthTip: "建立一个可重复的晨间或睡前小仪式。", + DeepOverview: "土倾向的人靠可预期感充电。突变多时,用清单与仪式稳住自己。", + Sleep: "同一时间上床", Diet: "温热易消化、勿过饥过饱", Move: "散步、力量轻度", Mood: "少同时处理多件事", + SleepDeep: "规律比时长更重要。周末也不要大幅颠倒作息。", + DietDeep: "细嚼慢咽;减少生冷刺激性饮食实验。", + MoveDeep: "稳定、可坚持的运动优于偶尔高强度。", + MoodDeep: "焦虑常来自失控感。把任务拆小,完成一件勾一项。", + OverviewBullets: []string{"需要稳定", "仪式感有用", "忌突变过多"}, + SleepBullets: []string{"同时上床", "周末少颠倒"}, + DietBullets: []string{"规律温热", "细嚼"}, + MoveBullets: []string{"可坚持", "散步力量"}, + MoodBullets: []string{"清单拆解", "单线程"}, + Strengths: []string{"踏实", "耐心", "可靠"}, + BlindSpots: []string{"抗拒变化", "过度担忧", "行动偏慢"}, + Scripts: []string{"我想先把节奏排好,再谈变化。", "给我一点准备时间,我会更稳。"}, + }, + "金": { + OneLiner: "你重视秩序与边界,混乱环境会让你烦躁。", + Overview: "节律隐喻偏「收敛」:清晰与整理很重要。", + WeekTip: "清理数字或实体杂物 20 分钟,并写下三条边界。", + MonthTip: "练习两次温和而清晰的拒绝。", + DeepOverview: "金倾向的人靠清晰边界获得掌控感。过度苛求完美会消耗自己。", + Sleep: "睡前收工仪式", Diet: "少熬夜夜宵", Move: "呼吸、拉伸、节奏运动", Mood: "把标准调到足够好", + SleepDeep: "工作与睡眠边界要划清;床上不处理未完成清单。", + DietDeep: "规律饮食;减少以咖啡硬撑。", + MoveDeep: "注重呼吸与肩颈;短时高质量运动即可。", + MoodDeep: "挑剔有时是焦虑。用「足够好」完成一件事对抗空转。", + OverviewBullets: []string{"边界清晰", "忌混乱", "完美主义留意"}, + SleepBullets: []string{"收工仪式", "床上不工作"}, + DietBullets: []string{"少硬撑", "规律"}, + MoveBullets: []string{"呼吸拉伸", "短时有效"}, + MoodBullets: []string{"足够好", "温和拒绝"}, + Strengths: []string{"条理", "原则", "质量感"}, + BlindSpots: []string{"过苛", "难放手", "紧绷"}, + Scripts: []string{"这次我想按我的节奏来。", "这件事我接不住,我们换个方案。"}, + }, + "水": { + OneLiner: "你感受深、需要沉淀,被催促时容易关闭。", + Overview: "节律隐喻偏「滋润」:休整与感受空间重要。", + WeekTip: "安排两段不被打扰的静默时间(各 20 分钟)。", + MonthTip: "建立睡前放松流程,减少临睡刷信息。", + DeepOverview: "水倾向的人需要内在空间。催促与过载会让你退缩;适度分享感受可减少误解。", + Sleep: "保证连续睡眠", Diet: "温补平和、少冰冷刺激", Move: "游泳、散步、柔软活动", Mood: "先感受再决策", + SleepDeep: "睡眠是情绪水库。缺觉时少做重大决定。", + DietDeep: "规律温热;观察哪些食物让你更稳。", + MoveDeep: "柔和持续的活动比爆发冲刺更适合你。", + MoodDeep: "情绪来时先命名;写三句或找安全的人听你说完。", + OverviewBullets: []string{"需要沉淀", "忌强催", "感受力强"}, + SleepBullets: []string{"连续睡眠", "少缺觉决策"}, + DietBullets: []string{"温热平和", "规律"}, + MoveBullets: []string{"柔和持续", "散步游泳"}, + MoodBullets: []string{"先命名", "再决策"}, + Strengths: []string{"洞察", "共情", "韧性"}, + BlindSpots: []string{"回避", "反刍", "边界模糊"}, + Scripts: []string{"我需要一点时间感受一下,再回复你。", "我不是拖延,我在整理自己。"}, + }, +} diff --git a/apps/api/internal/rhythm/engine_test.go b/apps/api/internal/rhythm/engine_test.go new file mode 100644 index 0000000..772468a --- /dev/null +++ b/apps/api/internal/rhythm/engine_test.go @@ -0,0 +1,21 @@ +package rhythm + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestBuild(t *testing.T) { + out := Build(time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC), "我") + if out.Summary["primary_element"] == nil { + t.Fatal("missing element") + } + raw, _ := json.Marshal(out) + for _, bad := range []string{"运势", "吉凶", "算命", "疗效", "治病"} { + if strings.Contains(string(raw), bad) { + t.Fatalf("forbidden %q", bad) + } + } +} diff --git a/apps/api/internal/scale/result.go b/apps/api/internal/scale/result.go new file mode 100644 index 0000000..ea95224 --- /dev/null +++ b/apps/api/internal/scale/result.go @@ -0,0 +1,188 @@ +package scale + +// BuildResult returns a rich, lexicon-safe exploration result for a scale slug + style key. +func BuildResult(slug, styleKey, label string) map[string]interface{} { + pack := resultPack(slug, styleKey, label) + return map[string]interface{}{ + "title": "探索结果", + "style_key": styleKey, + "label": pack.Label, + "summary": pack.Summary, + "overview": pack.Overview, + "share_line": pack.ShareLine, + "dimensions": pack.Dimensions, + "strengths": pack.Strengths, + "watchouts": pack.Watchouts, + "tips": pack.Tips, + "scripts": pack.Scripts, + "growth_plan": pack.GrowthPlan, + "faq": pack.FAQ, + } +} + +type pack struct { + Label, Summary, Overview, ShareLine string + Dimensions []map[string]interface{} + Strengths, Watchouts, Tips, Scripts []string + GrowthPlan []map[string]string + FAQ []map[string]string +} + +func resultPack(slug, key, fallbackLabel string) pack { + switch slug { + case "emotion-pattern": + return emotionPack(key, fallbackLabel) + default: + return communicationPack(key, fallbackLabel) + } +} + +func communicationPack(key, fallback string) pack { + switch key { + case "A": + return pack{ + Label: "理性澄清型", ShareLine: "我的沟通方式:理性澄清型", + Summary: "你倾向先澄清事实与目标,再进入感受层。这让协作高效,也要注意别让对方觉得被审问。", + Overview: "冲突或合作时,你本能想问清楚「发生了什么、目标是什么」。对方若正处在情绪里,过早追问细节可能升级张力。你的成长点是:先给一句承认,再启动澄清。", + Dimensions: []map[string]interface{}{ + {"title": "澄清力", "score": 88, "note": "擅长把模糊说清楚"}, + {"title": "共情节奏", "score": 62, "note": "可先共鸣再分析"}, + {"title": "边界表达", "score": 78, "note": "需求通常比较明确"}, + }, + Strengths: []string{"逻辑清楚", "减少误解", "适合协作对齐"}, + Watchouts: []string{"过早纠正", "忽略情绪信号", "听起来像质询"}, + Tips: []string{"开口先复述对方一句", "结论前加「我听到你…」", "重要约定写成要点"}, + Scripts: []string{"我先确认我理解对了:你是说……?", "我想先对齐事实,再一起看感受可以吗?", "我的需要是……,你呢?"}, + GrowthPlan: []map[string]string{ + {"phase": "本周", "focus": "三次对话里先共鸣再澄清"}, + {"phase": "本月", "focus": "冲突后写复盘:事实 / 感受 / 约定"}, + }, + FAQ: []map[string]string{ + {"q": "别人说我太冷静?", "a": "冷静是优势;补一句情绪确认,观感会柔和很多。"}, + }, + } + case "B": + return pack{ + Label: "感受连接型", ShareLine: "我的沟通方式:感受连接型", + Summary: "你擅长用共鸣打开对话。记得在连接之后,也把需求和边界说清楚,避免长期亏空。", + Overview: "你对语气与气氛敏感,能很快让人感到被理解。挑战是容易承接对方情绪,或迟迟不谈具体安排。连接是起点,请求是终点。", + Dimensions: []map[string]interface{}{ + {"title": "共情力", "score": 90, "note": "氛围与感受捕捉强"}, + {"title": "需求表达", "score": 60, "note": "可练习更直接"}, + {"title": "冲突耐受力", "score": 58, "note": "宜早谈小分歧"}, + }, + Strengths: []string{"让人安心", "破冰能力强", "关系温度高"}, + Watchouts: []string{"边界模糊", "回避具体冲突", "情绪连带消耗"}, + Tips: []string{"共鸣后补一句清晰请求", "每周提出一次真实偏好", "重要事不要只靠暗示"}, + Scripts: []string{"我很理解你的难处,我能做的是……;我需要你……", "我刚才有点被带着走了,让我整理一下再回复。", "这件事我有点在意,我们可以商量吗?"}, + GrowthPlan: []map[string]string{ + {"phase": "本周", "focus": "一次温和且具体的请求"}, + {"phase": "本月", "focus": "建立「情绪收支」复盘"}, + }, + FAQ: []map[string]string{ + {"q": "怎样不那么累?", "a": "把理解与解决分开,不默认承包对方情绪。"}, + }, + } + default: // C or unknown + label := fallback + if label == "" { + label = "节奏尊重型" + } + return pack{ + Label: label, ShareLine: "我的沟通方式:" + label, + Summary: "你重视对方的节奏与空间。这很加分,也要避免把「尊重」变成「不表达」,导致关系空转。", + Overview: "你不愿压迫别人,也反感被催促。优点是让人有安全感;风险是关键议题被无限推迟。可以为重要话题设定温和的时间盒。", + Dimensions: []map[string]interface{}{ + {"title": "节奏感", "score": 86, "note": "懂得留白与等待"}, + {"title": "推进力", "score": 64, "note": "重要事可设时间盒"}, + {"title": "安全感营造", "score": 82, "note": "压迫感低"}, + }, + Strengths: []string{"尊重边界", "低压迫沟通", "适合长期协作"}, + Watchouts: []string{"议题拖延", "需求不说出口", "被误解为漠不关心"}, + Tips: []string{"重要话题约定讨论时间", "用「到点我们再谈」代替无限等待", "主动同步「我在意,只是需要准备」"}, + Scripts: []string{"这件事对我重要,我们明天晚上 20 点谈 20 分钟可以吗?", "我不是不在乎,我需要一点时间想清楚。", "我想听完你的节奏,再说我的需要。"}, + GrowthPlan: []map[string]string{ + {"phase": "本周", "focus": "为一个拖延议题约定讨论时间"}, + {"phase": "本月", "focus": "练习两次限时决策"}, + }, + FAQ: []map[string]string{ + {"q": "会不会显得被动?", "a": "尊重节奏 + 明确时间点,就是主动而不压迫。"}, + }, + } + } +} + +func emotionPack(key, fallback string) pack { + switch key { + case "A": + return pack{ + Label: "觉察表达型", ShareLine: "我的情感模式:觉察表达型", + Summary: "你能较快察觉情绪并愿意说出来。注意表达时给对方消化空间,避免倾泻成压力。", + Overview: "觉察是调节的第一步。你已经具备命名与分享的能力;下一步是区分「分享」与「要求对方立刻修好」。", + Dimensions: []map[string]interface{}{ + {"title": "觉察", "score": 88, "note": "情绪信号接收快"}, + {"title": "表达", "score": 84, "note": "愿意说出口"}, + {"title": "调节后行动", "score": 66, "note": "说完后可补一个小行动"}, + }, + Strengths: []string{"情绪可见", "利于早沟通", "减少闷烧"}, + Watchouts: []string{"倾泻过量", "期待即时修复", "对方跟不上节奏"}, + Tips: []string{"表达前先写三句提纲", "说明你要倾听还是建议", "给对方回应窗口"}, + Scripts: []string{"我现在有点……,我需要你听我说 5 分钟,先不要给方案。", "我说完了,你方便的时候告诉我你的感受。"}, + GrowthPlan: []map[string]string{ + {"phase": "本周", "focus": "两次「只倾听请求」练习"}, + {"phase": "本月", "focus": "表达后为自己设计一个调节行动"}, + }, + FAQ: []map[string]string{ + {"q": "对方说我太敏感?", "a": "敏感是信息;把它翻译成具体需要,对方更容易配合。"}, + }, + } + case "B": + return pack{ + Label: "安抚稳定型", ShareLine: "我的情感模式:安抚稳定型", + Summary: "你倾向先稳定场面与身心,再处理问题。记得稳定之后仍要把需求说清楚。", + Overview: "你擅长降温:深呼吸、抽离、安慰自己或他人。若只停在安抚、不进入表达与边界,问题可能反复出现。", + Dimensions: []map[string]interface{}{ + {"title": "稳定力", "score": 90, "note": "善于降温"}, + {"title": "表达力度", "score": 58, "note": "可加强具体诉求"}, + {"title": "长期边界", "score": 62, "note": "防重复消耗"}, + }, + Strengths: []string{"冲突降温", "给人安全感", "自我安抚能力"}, + Watchouts: []string{"压抑成真沉默", "问题被搁置", "被当成情绪垃圾桶还不设限"}, + Tips: []string{"降温后补一句需求", "重复消耗的情境要设限", "用日记区分安抚与回避"}, + Scripts: []string{"我先让自己稳一下,20 分钟后我们继续谈。", "我已经平静些了,我需要讨论的是……"}, + GrowthPlan: []map[string]string{ + {"phase": "本周", "focus": "安抚后至少表达一个具体需要"}, + {"phase": "本月", "focus": "为反复场景写一条边界"}, + }, + FAQ: []map[string]string{ + {"q": "是不是太忍?", "a": "稳定是能力;忍到失语就变成损耗。稳完要说。"}, + }, + } + default: + label := fallback + if label == "" { + label = "行动调节型" + } + return pack{ + Label: label, ShareLine: "我的情感模式:" + label, + Summary: "你习惯用行动(做事、运动、推进)调节情绪。有效,但也要留下命名感受的空间。", + Overview: "行动能快速改变状态,是你的优势。若只用行动回避感受,亲密关系里的人可能觉得你「只顾做事」。行动前后各留一句感受命名。", + Dimensions: []map[string]interface{}{ + {"title": "行动力", "score": 88, "note": "用做来调节"}, + {"title": "感受命名", "score": 60, "note": "可加强语言化"}, + {"title": "关系同步", "score": 64, "note": "行动前告知动机"}, + }, + Strengths: []string{"恢复快", "不沉溺反刍", "执行导向"}, + Watchouts: []string{"跳过感受", "被看作冷漠", "用忙碌麻痹"}, + Tips: []string{"行动前说清「我在用行动调节」", "每天写一句情绪命名", "重要关系里先连接再解决"}, + Scripts: []string{"我有点烦,我想先去走走,回来再聊。", "我不是不理你,我在用行动让自己回来。"}, + GrowthPlan: []map[string]string{ + {"phase": "本周", "focus": "行动前后各做一次感受命名"}, + {"phase": "本月", "focus": "一次冲突里先连接再给方案"}, + }, + FAQ: []map[string]string{ + {"q": "伴侣说我不沟通?", "a": "把行动翻译成语言:你在调节,以及你何时回来继续谈。"}, + }, + } + } +} diff --git a/apps/api/internal/scale/score.go b/apps/api/internal/scale/score.go new file mode 100644 index 0000000..dbe3ab0 --- /dev/null +++ b/apps/api/internal/scale/score.go @@ -0,0 +1,42 @@ +// Package scale holds exploration-test scoring helpers. +package scale + +// ScoreMajority picks the most frequent answer key and maps to a label set. +func ScoreMajority(answers map[string]string, labels map[string]string, defaultLabel string) (styleKey, label string) { + counts := map[string]int{} + for _, v := range answers { + counts[v]++ + } + best, bestN := "", -1 + for k, n := range counts { + if n > bestN { + best, bestN = k, n + } + } + if best == "" { + return "", defaultLabel + } + label = labels[best] + if label == "" { + label = defaultLabel + } + return best, label +} + +// CommunicationLabels for communication-style scale. +func CommunicationLabels() map[string]string { + return map[string]string{ + "A": "理性澄清型", + "B": "感受连接型", + "C": "节奏尊重型", + } +} + +// EmotionLabels for emotion-pattern scale. +func EmotionLabels() map[string]string { + return map[string]string{ + "A": "觉察表达型", + "B": "安抚稳定型", + "C": "行动调节型", + } +} diff --git a/apps/api/internal/scale/score_test.go b/apps/api/internal/scale/score_test.go new file mode 100644 index 0000000..0a2a2fe --- /dev/null +++ b/apps/api/internal/scale/score_test.go @@ -0,0 +1,39 @@ +package scale + +import "testing" + +func TestScoreMajority_communication(t *testing.T) { + key, label := ScoreMajority(map[string]string{ + "q1": "B", "q2": "B", "q3": "A", + }, CommunicationLabels(), "平衡探索型") + if key != "B" || label != "感受连接型" { + t.Fatalf("got %s %s", key, label) + } +} + +func TestScoreMajority_emotion(t *testing.T) { + key, label := ScoreMajority(map[string]string{ + "q1": "C", "q2": "C", "q3": "C", + }, EmotionLabels(), "平衡探索型") + if key != "C" || label != "行动调节型" { + t.Fatalf("got %s %s", key, label) + } +} + +func TestScoreMajority_empty(t *testing.T) { + _, label := ScoreMajority(nil, CommunicationLabels(), "平衡探索型") + if label != "平衡探索型" { + t.Fatalf("got %s", label) + } +} + +func TestBuildResult_rich(t *testing.T) { + r := BuildResult("communication-style", "B", "感受连接型") + if r["overview"] == nil || r["tips"] == nil || r["scripts"] == nil { + t.Fatalf("missing rich fields: %#v", r) + } + r2 := BuildResult("emotion-pattern", "A", "觉察表达型") + if r2["growth_plan"] == nil || r2["faq"] == nil { + t.Fatalf("missing emotion rich fields") + } +} diff --git a/apps/api/internal/service/ask/service.go b/apps/api/internal/service/ask/service.go new file mode 100644 index 0000000..b79cc26 --- /dev/null +++ b/apps/api/internal/service/ask/service.go @@ -0,0 +1,245 @@ +package ask + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + "time" + + "github.com/google/uuid" + + eng "github.com/yuxingu/digital-psychology/apps/api/internal/ask" + "github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek" + "github.com/yuxingu/digital-psychology/apps/api/internal/model" + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +// FreeQuota is the number of assistant replies allowed without membership. +const FreeQuota = 3 + +const historyLimit = 10 + +// Service handles ask threads, quota, and replies. +type Service struct { + Profiles *repository.ProfileRepo + Reports *repository.ReportRepo + Ask *repository.AskRepo + LLM *deepseek.Client // optional; nil or disabled → rule engine +} + +// CreateThreadInput for POST /ask/threads. +type CreateThreadInput struct { + ProfileID uuid.UUID + Scene string +} + +// CreateThread binds a conversation to an owned profile. +func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateThreadInput) (*model.AskThread, error) { + if _, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID); err != nil { + return nil, errors.New("profile not found") + } + var scene *string + if sc := strings.TrimSpace(in.Scene); sc != "" { + scene = &sc + } + return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene) +} + +// QuotaStatus describes remaining ask allowance. +type QuotaStatus struct { + ActiveMembership bool `json:"active_membership"` + Remaining int `json:"remaining"` + FreeLimit int `json:"free_limit"` + Source string `json:"source"` // membership | free +} + +// GetQuota returns remaining ask replies. +func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus, error) { + vip, err := s.Reports.HasActiveMembership(ctx, userID) + if err != nil { + return nil, err + } + if vip { + me, err := s.Reports.GetMembership(ctx, userID) + if err != nil { + return nil, err + } + return &QuotaStatus{ + ActiveMembership: true, + Remaining: me.AskQuotaLeft, + FreeLimit: FreeQuota, + Source: "membership", + }, nil + } + used, err := s.Ask.CountUserAssistantMessages(ctx, userID) + if err != nil { + return nil, err + } + left := FreeQuota - used + if left < 0 { + left = 0 + } + return &QuotaStatus{ + ActiveMembership: false, + Remaining: left, + FreeLimit: FreeQuota, + Source: "free", + }, nil +} + +// ListMessages returns history for an owned thread. +func (s *Service) ListMessages(ctx context.Context, userID, threadID uuid.UUID) ([]model.AskMessage, error) { + if _, err := s.Ask.GetThreadForUser(ctx, userID, threadID); err != nil { + return nil, errors.New("thread not found") + } + return s.Ask.ListMessages(ctx, threadID) +} + +// SendResult is the user+assistant turn after a send. +type SendResult struct { + UserMessage *model.AskMessage `json:"user_message"` + AssistantMessage *model.AskMessage `json:"assistant_message"` + Quota *QuotaStatus `json:"quota"` +} + +// SendMessage stores user content, consumes quota, generates assistant reply. +func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, content string) (*SendResult, error) { + content = strings.TrimSpace(content) + if content == "" { + return nil, errors.New("content required") + } + if len([]rune(content)) > 2000 { + return nil, errors.New("content too long") + } + + thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID) + if err != nil { + return nil, errors.New("thread not found") + } + profile, err := s.Profiles.GetForUser(ctx, userID, thread.ProfileID) + if err != nil { + return nil, errors.New("profile not found") + } + + quota, err := s.GetQuota(ctx, userID) + if err != nil { + return nil, err + } + if quota.Remaining <= 0 { + return nil, ErrQuotaExhausted + } + + userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content) + if err != nil { + return nil, err + } + + scene := "" + if thread.Scene != nil { + scene = *thread.Scene + } + + hist, _ := s.Ask.ListMessages(ctx, threadID) + reply := s.generateReply(ctx, profile, scene, content, hist) + + asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply) + if err != nil { + return nil, err + } + + if quota.ActiveMembership { + if ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID); err != nil { + return nil, err + } else if !ok { + // race + } + } + + q2, err := s.GetQuota(ctx, userID) + if err != nil { + return nil, err + } + return &SendResult{UserMessage: userMsg, AssistantMessage: asst, Quota: q2}, nil +} + +func (s *Service) generateReply(ctx context.Context, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string { + fallback := eng.BuildReply(eng.ReplyInput{ + DisplayName: profile.DisplayName, + BirthDate: profile.BirthDate, + Relation: profile.Relation, + Scene: scene, + UserMessage: userContent, + }) + if s.LLM == nil || !s.LLM.Enabled() { + return fallback + } + + msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene)}} + // history excluding the just-inserted user message duplicate handling: include prior + current user + start := 0 + if len(hist) > historyLimit*2 { + start = len(hist) - historyLimit*2 + } + for _, m := range hist[start:] { + role := m.Role + if role != "user" && role != "assistant" { + continue + } + msgs = append(msgs, deepseek.Message{Role: role, Content: m.Content}) + } + // hist already contains the new user message from InsertMessage + + out, err := s.LLM.Chat(ctx, msgs) + if err != nil { + log.Printf("ask: deepseek failed, fallback to rules: %v", err) + return fallback + } + if !strings.Contains(out, "不构成") && !strings.Contains(out, "参考") { + out = out + "\n\n以上是自我探索与生活方式参考,不构成医疗或占卜预测。" + } + return out +} + +func systemPrompt(profile *model.Profile, scene string) string { + name := profile.DisplayName + if name == "" { + if profile.Relation == "other" { + name = "TA" + } else { + name = "你" + } + } + birth := profile.BirthDate.Format("2006-01-02") + rel := "我的档案" + if profile.Relation == "other" { + rel = "TA 的档案" + } + sc := scene + if sc == "" { + sc = "自我探索" + } + return fmt.Sprintf(`你是「愈心谷」的 AI 成长助手,了解用户的智能伙伴。 +定位:帮助认识自己、理解关系、整理情绪与生活节奏——像一位细致的成长顾问,而不是算命师。 +禁止:算命、运势、吉凶、预测未来、改命、合盘/合婚话术、医疗诊断或疗效承诺、恐吓话术。 +推荐用语:了解、探索、分析、建议、成长方向、生活建议、沟通方式、情绪调节。 + +当前解读对象:%s(%s),生日 %s,场景倾向:%s。 +请用中文做「有结构的详细回复」(约 280–450 字),建议结构: +1)先回应用户当下问题(2–3 句) +2)结合档案风格做一层分析(性格/沟通/关系/情绪/生活节奏中相关的 1–2 维) +3)给出 2–4 条可执行小建议(尽量具体到本周可做) +4)如合适,给一句可直接说出口的对话示例 +语气温暖、具体、不空洞;避免鸡汤套话与玄学预测。 +结尾提醒:内容为自我探索与生活方式参考,不构成医疗或占卜预测。 +今天是 %s。`, name, rel, birth, sc, time.Now().Format("2006-01-02")) +} + +// ErrQuotaExhausted when free or membership ask quota is 0. +var ErrQuotaExhausted = errors.New("ask quota exhausted") + +// IsQuotaExhausted reports ErrQuotaExhausted. +func IsQuotaExhausted(err error) bool { + return errors.Is(err, ErrQuotaExhausted) +} diff --git a/apps/api/internal/service/companion/service.go b/apps/api/internal/service/companion/service.go new file mode 100644 index 0000000..1d72a84 --- /dev/null +++ b/apps/api/internal/service/companion/service.go @@ -0,0 +1,63 @@ +package companion + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + core "github.com/yuxingu/digital-psychology/apps/api/internal/companion" + "github.com/yuxingu/digital-psychology/apps/api/internal/model" + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +// Service exposes solar terms and moods. +type Service struct { + Moods *repository.MoodRepo +} + +// TodaySolar returns today's tip. +func (s *Service) TodaySolar() core.SolarTerm { + return core.TodaySolar(time.Now()) +} + +// SaveMoodInput for POST /moods. +type SaveMoodInput struct { + Score *int + Note *string + Day *time.Time +} + +// SaveMood upserts mood for a day (default today). +func (s *Service) SaveMood(ctx context.Context, userID uuid.UUID, in SaveMoodInput) (*model.Mood, error) { + if in.Score != nil && (*in.Score < 1 || *in.Score > 5) { + return nil, errors.New("score must be 1-5") + } + if in.Note != nil { + n := strings.TrimSpace(*in.Note) + if len([]rune(n)) > 200 { + return nil, errors.New("note too long") + } + in.Note = &n + } + day := time.Now() + if in.Day != nil { + day = *in.Day + } + return s.Moods.UpsertToday(ctx, userID, day, in.Score, in.Note) +} + +// GetTodayMood returns today's mood or nil when none. +func (s *Service) GetTodayMood(ctx context.Context, userID uuid.UUID) (*model.Mood, error) { + m, err := s.Moods.GetToday(ctx, userID, time.Now()) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return m, nil +} diff --git a/apps/api/internal/service/imagecard/service.go b/apps/api/internal/service/imagecard/service.go new file mode 100644 index 0000000..42c2dda --- /dev/null +++ b/apps/api/internal/service/imagecard/service.go @@ -0,0 +1,119 @@ +package imagecard + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/imagecard" + "github.com/yuxingu/digital-psychology/apps/api/internal/model" + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +// Service draws image cards with quota + entitlement. +type Service struct { + Profiles *repository.ProfileRepo + Reports *repository.ReportRepo + Quotas *repository.ImageCardRepo +} + +// DrawInput is POST /image-cards/draw body. +type DrawInput struct { + Scene string + ProfileID uuid.UUID + Depth bool +} + +// DrawResult wraps report + cards. +type DrawResult struct { + Report *model.GrowthReport `json:"report"` + Scene string `json:"scene"` + Cards []imagecard.Card `json:"cards"` + QuotaLeft int `json:"quota_left"` +} + +// Scenes returns reflection scenes. +func (s *Service) Scenes() []map[string]string { + return imagecard.Scenes() +} + +// Quota returns remaining draws today. +func (s *Service) Quota(ctx context.Context, userID uuid.UUID) (map[string]any, error) { + vip, err := s.Reports.HasActiveMembership(ctx, userID) + if err != nil { + return nil, err + } + left, err := s.Quotas.RemainingToday(ctx, userID, time.Now(), vip) + if err != nil { + return nil, err + } + return map[string]any{ + "remaining": left, + "daily_free": repository.DailyFreeLimit(), + "unlimited": vip, + }, nil +} + +// Draw consumes quota (unless membership), builds cards, stores report. +func (s *Service) Draw(ctx context.Context, userID uuid.UUID, in DrawInput) (*DrawResult, error) { + p, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID) + if err != nil { + return nil, errors.New("profile not found") + } + vip, err := s.Reports.HasActiveMembership(ctx, userID) + if err != nil { + return nil, err + } + now := time.Now() + var left int + if vip { + left, _ = s.Quotas.RemainingToday(ctx, userID, now, true) + } else { + left, err = s.Quotas.TryConsume(ctx, userID, now) + if err != nil { + return nil, err + } + } + + wantDeep := in.Depth && vip + out := imagecard.Draw(userID.String(), in.Scene, wantDeep || in.Depth, now) + // Persist full detail when depth requested; entitlement strips on read. + sum, _ := json.Marshal(out.Summary) + detPayload := out.Detail + if in.Depth && detPayload == nil { + // force 3-card detail generation for unlock-after-pay path + full := imagecard.Draw(userID.String(), in.Scene, true, now) + detPayload = full.Detail + out.Cards = full.Cards + sum, _ = json.Marshal(full.Summary) + } + det, _ := json.Marshal(detPayload) + if detPayload == nil { + det = []byte("{}") + } + rep, err := s.Reports.Create(ctx, userID, p.ID, "image_card", sum, det) + if err != nil { + return nil, err + } + deep, _ := s.Reports.HasDeepAccess(ctx, userID, rep.ID) + rep.HasDeep = deep || vip + if !rep.HasDeep { + rep.Detail = nil + if !wantDeep { + // free single-card: already summary-only + } + } + cards := out.Cards + if !rep.HasDeep && len(cards) > 1 { + cards = cards[:1] + } + return &DrawResult{ + Report: rep, + Scene: out.Scene, + Cards: cards, + QuotaLeft: left, + }, nil +} diff --git a/apps/api/internal/service/profile/service.go b/apps/api/internal/service/profile/service.go index 807e200..70dc835 100644 --- a/apps/api/internal/service/profile/service.go +++ b/apps/api/internal/service/profile/service.go @@ -3,6 +3,7 @@ package profile import ( "context" "errors" + "strings" "time" "github.com/google/uuid" @@ -22,6 +23,8 @@ type CreateInput struct { DisplayName string BirthDate time.Time RelationType *string + BirthTime *string + BirthPlace *string } // Create stores a profile for the user. @@ -40,10 +43,59 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput) name = "TA" } } - return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType) + return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace) } // List returns user's profiles. func (s *Service) List(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) { return s.Repo.ListByUser(ctx, userID) } + +// UpdateInput for PATCH /profiles/:id. +type UpdateInput struct { + DisplayName string + BirthDate time.Time + RelationType *string + BirthTime *string + BirthPlace *string + GeoLat *float64 + GeoLng *float64 + GeoVisible *bool +} + +// Update patches an owned profile. +func (s *Service) Update(ctx context.Context, userID, profileID uuid.UUID, in UpdateInput) (*model.Profile, error) { + cur, err := s.Repo.GetForUser(ctx, userID, profileID) + if err != nil { + return nil, errors.New("profile not found") + } + name := strings.TrimSpace(in.DisplayName) + if name == "" { + name = cur.DisplayName + } + birth := in.BirthDate + if birth.IsZero() { + birth = cur.BirthDate + } + rt := in.RelationType + if rt == nil { + rt = cur.RelationType + } + bt := in.BirthTime + if bt == nil { + bt = cur.BirthTime + } + bp := in.BirthPlace + if bp == nil { + bp = cur.BirthPlace + } + return s.Repo.UpdateForUser(ctx, userID, profileID, name, birth, rt, bt, bp, in.GeoLat, in.GeoLng, in.GeoVisible) +} + +// Delete soft-deletes an owned profile. +func (s *Service) Delete(ctx context.Context, userID, profileID uuid.UUID) error { + if _, err := s.Repo.GetForUser(ctx, userID, profileID); err != nil { + return errors.New("profile not found") + } + return s.Repo.SoftDeleteForUser(ctx, userID, profileID) +} diff --git a/apps/api/internal/service/relation/service.go b/apps/api/internal/service/relation/service.go index 2431414..efd0acd 100644 --- a/apps/api/internal/service/relation/service.go +++ b/apps/api/internal/service/relation/service.go @@ -38,7 +38,11 @@ func (s *Service) Create(ctx context.Context, userID, aID, bID uuid.UUID) (*Crea if err != nil { return nil, errors.New("profile_b not found") } - out := releng.Build(pa.BirthDate, pb.BirthDate, pa.DisplayName, pb.DisplayName) + relType := "" + if pb.RelationType != nil { + relType = *pb.RelationType + } + out := releng.BuildFull(pa.BirthDate, pb.BirthDate, pa.BirthTime, pb.BirthTime, pa.BirthPlace, pb.BirthPlace, pa.DisplayName, pb.DisplayName, relType) sum, _ := json.Marshal(out.Summary) det, _ := json.Marshal(out.Detail) rep, err := s.Reports.Create(ctx, userID, aID, "relation", sum, det) diff --git a/apps/api/internal/service/report/service.go b/apps/api/internal/service/report/service.go index d27b8b1..2ca617f 100644 --- a/apps/api/internal/service/report/service.go +++ b/apps/api/internal/service/report/service.go @@ -4,18 +4,123 @@ import ( "context" "encoding/json" "errors" + "time" "github.com/google/uuid" "github.com/yuxingu/digital-psychology/apps/api/internal/model" "github.com/yuxingu/digital-psychology/apps/api/internal/portrait" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" + "github.com/yuxingu/digital-psychology/apps/api/internal/rhythm" + "github.com/yuxingu/digital-psychology/apps/api/internal/star" + "github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry" ) // Service creates and reads growth reports with entitlement trimming. type Service struct { Profiles *repository.ProfileRepo Reports *repository.ReportRepo + Invites *repository.SynastryInviteRepo +} + +// Nearby lists geo-visible self profiles of other users within radius. +func (s *Service) Nearby(ctx context.Context, userID uuid.UUID, lat, lng, radiusKm float64) ([]repository.NearbyItem, error) { + if s.Profiles == nil { + return nil, errors.New("profiles unavailable") + } + if radiusKm <= 0 { + radiusKm = 50 + } + if radiusKm > 200 { + radiusKm = 200 + } + return s.Profiles.ListNearby(ctx, userID, lat, lng, radiusKm, 20) +} + +// CreateInvite creates a shareable synastry invite for host profile. +func (s *Service) CreateInvite(ctx context.Context, userID, hostProfileID uuid.UUID) (*model.SynastryInvite, error) { + if s.Invites == nil { + return nil, errors.New("invites unavailable") + } + if _, err := s.Profiles.GetForUser(ctx, userID, hostProfileID); err != nil { + return nil, errors.New("profile not found") + } + return s.Invites.Create(ctx, userID, hostProfileID) +} + +// GetInvite returns invite by token (public metadata for landing page). +func (s *Service) GetInvite(ctx context.Context, token string) (map[string]any, error) { + if s.Invites == nil { + return nil, errors.New("invites unavailable") + } + inv, err := s.Invites.GetByToken(ctx, token) + if err != nil { + return nil, errors.New("invite not found") + } + if time.Now().After(inv.ExpiresAt) { + return nil, errors.New("invite expired") + } + host, err := s.Profiles.GetByID(ctx, inv.HostProfileID) + if err != nil { + return nil, errors.New("host profile missing") + } + return map[string]any{ + "token": inv.Token, + "expires_at": inv.ExpiresAt, + "host_name": host.DisplayName, + "already_accepted": inv.ReportID != nil, + }, nil +} + +// AcceptInvite creates guest other-profile for guest user, builds synastry, marks invite (atomic). +func (s *Service) AcceptInvite(ctx context.Context, guestUser uuid.UUID, token, displayName, birthDate string, birthTime, birthPlace *string) (*model.GrowthReport, error) { + if s.Invites == nil { + return nil, errors.New("invites unavailable") + } + inv, err := s.Invites.GetByToken(ctx, token) + if err != nil { + return nil, errors.New("invite not found") + } + if time.Now().After(inv.ExpiresAt) { + return nil, errors.New("invite expired") + } + if inv.ReportID != nil { + return nil, errors.New("invite already used") + } + if inv.HostUserID == guestUser { + return nil, errors.New("不能接受自己的邀请") + } + birth, err := time.Parse("2006-01-02", birthDate) + if err != nil { + return nil, errors.New("birth_date must be YYYY-MM-DD") + } + name := displayName + if name == "" { + name = "TA" + } + host, err := s.Profiles.GetByID(ctx, inv.HostProfileID) + if err != nil { + return nil, errors.New("host profile missing") + } + ca, err := star.NatalChart(host.BirthDate, host.BirthTime, host.BirthPlace) + if err != nil { + return nil, err + } + cb, err := star.NatalChart(birth, birthTime, birthPlace) + if err != nil { + return nil, err + } + out, err := synastry.BuildReport(ca, cb, host.DisplayName, name, time.Now()) + if err != nil { + return nil, err + } + sum, _ := json.Marshal(out.Summary) + det, _ := json.Marshal(out.Detail) + rep, err := s.Invites.AcceptAtomic(ctx, inv.ID, guestUser, name, birth, birthTime, birthPlace, sum, det) + if err != nil { + return nil, err + } + return s.applyEntitlement(ctx, guestUser, rep) } // CreatePortrait builds and stores a portrait report. @@ -34,6 +139,83 @@ func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUI return s.applyEntitlement(ctx, userID, rep) } +// CreateStar builds and stores a 星象性格 report. +func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) { + p, err := s.Profiles.GetForUser(ctx, userID, profileID) + if err != nil { + return nil, errors.New("profile not found") + } + out, err := star.BuildWith(star.BuildOpts{ + Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName, + }) + if err != nil { + return nil, err + } + sum, _ := json.Marshal(out.Summary) + det, _ := json.Marshal(out.Detail) + rep, err := s.Reports.Create(ctx, userID, profileID, "star", sum, det) + if err != nil { + return nil, err + } + return s.applyEntitlement(ctx, userID, rep) +} + +// CreateSynastry builds a multi-chart synastry report for two profiles. +// asOf is the secondary-progression date (defaults to today CST when nil/zero). +func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profileBID uuid.UUID, asOf *time.Time) (*model.GrowthReport, error) { + pa, err := s.Profiles.GetForUser(ctx, userID, profileAID) + if err != nil { + return nil, errors.New("profile a not found") + } + pb, err := s.Profiles.GetForUser(ctx, userID, profileBID) + if err != nil { + // Allow geo-visible self profiles of other users (附近的人). + pb, err = s.Profiles.GetByID(ctx, profileBID) + if err != nil || !pb.GeoVisible || pb.Relation != "self" || pb.UserID == userID { + return nil, errors.New("profile b not found") + } + } + ca, err := star.NatalChart(pa.BirthDate, pa.BirthTime, pa.BirthPlace) + if err != nil { + return nil, err + } + cb, err := star.NatalChart(pb.BirthDate, pb.BirthTime, pb.BirthPlace) + if err != nil { + return nil, err + } + when := time.Now().In(time.FixedZone("CST", 8*3600)) + if asOf != nil && !asOf.IsZero() { + when = *asOf + } + out, err := synastry.BuildReport(ca, cb, pa.DisplayName, pb.DisplayName, when) + if err != nil { + return nil, err + } + sum, _ := json.Marshal(out.Summary) + det, _ := json.Marshal(out.Detail) + rep, err := s.Reports.Create(ctx, userID, profileAID, "synastry", sum, det) + if err != nil { + return nil, err + } + return s.applyEntitlement(ctx, userID, rep) +} + +// CreateRhythm builds and stores a 身心节律 report. +func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) { + p, err := s.Profiles.GetForUser(ctx, userID, profileID) + if err != nil { + return nil, errors.New("profile not found") + } + out := rhythm.Build(p.BirthDate, p.DisplayName) + sum, _ := json.Marshal(out.Summary) + det, _ := json.Marshal(out.Detail) + rep, err := s.Reports.Create(ctx, userID, profileID, "rhythm", sum, det) + if err != nil { + return nil, err + } + return s.applyEntitlement(ctx, userID, rep) +} + // Get returns a report with detail gated. func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) { rep, err := s.Reports.GetForUser(ctx, userID, reportID) @@ -43,6 +225,23 @@ func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.G return s.applyEntitlement(ctx, userID, rep) } +// List returns recent reports with entitlement trimming. +func (s *Service) List(ctx context.Context, userID uuid.UUID) ([]*model.GrowthReport, error) { + items, err := s.Reports.ListForUser(ctx, userID, 50) + if err != nil { + return nil, err + } + out := make([]*model.GrowthReport, 0, len(items)) + for i := range items { + rep, err := s.applyEntitlement(ctx, userID, &items[i]) + if err != nil { + return nil, err + } + out = append(out, rep) + } + return out, nil +} + func (s *Service) applyEntitlement(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) { deep, err := s.Reports.HasDeepAccess(ctx, userID, rep.ID) if err != nil { @@ -85,3 +284,27 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error { return s.Reports.PayMock(ctx, userID, orderID) } + +// MembershipMe is the public membership snapshot. +type MembershipMe struct { + Active bool `json:"active"` + Plan string `json:"plan,omitempty"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + AskQuotaLeft int `json:"ask_quota_left,omitempty"` +} + +// GetMembership returns current growth membership for the user. +func (s *Service) GetMembership(ctx context.Context, userID uuid.UUID) (*MembershipMe, error) { + row, err := s.Reports.GetMembership(ctx, userID) + if err != nil { + return nil, err + } + return &MembershipMe{ + Active: row.Active, + Plan: row.Plan, + Status: row.Status, + ExpiresAt: row.ExpiresAt, + AskQuotaLeft: row.AskQuotaLeft, + }, nil +} diff --git a/apps/api/internal/service/scale/service.go b/apps/api/internal/service/scale/service.go index 5e8cb36..340f061 100644 --- a/apps/api/internal/service/scale/service.go +++ b/apps/api/internal/service/scale/service.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" + sc "github.com/yuxingu/digital-psychology/apps/api/internal/scale" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" ) @@ -51,31 +52,10 @@ func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in if err != nil { return nil, errors.New("scale not found") } - counts := map[string]int{} - for _, v := range in.Answers { - counts[v]++ - } - best, bestN := "A", -1 - for k, n := range counts { - if n > bestN { - best, bestN = k, n - } - } - label := map[string]string{ - "A": "理性澄清型", - "B": "感受连接型", - "C": "节奏尊重型", - }[best] - if label == "" { - label = "平衡探索型" - } - result := map[string]interface{}{ - "title": "探索结果", - "style_key": best, - "label": label, - "summary": "这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。", - "share_line": "我的沟通方式:" + label, - } + + labels, _, _ := labelsForSlug(slug) + key, label := sc.ScoreMajority(in.Answers, labels, "平衡探索型") + result := sc.BuildResult(slug, key, label) ansJSON, _ := json.Marshal(in.Answers) resJSON, _ := json.Marshal(result) id, err := s.Repo.SaveResult(ctx, userID, scaleID, in.ProfileID, ansJSON, resJSON) @@ -84,3 +64,37 @@ func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in } return &SubmitResult{ID: id, Result: result}, nil } + +func labelsForSlug(slug string) (labels map[string]string, sharePrefix, summary string) { + switch slug { + case "emotion-pattern": + return sc.EmotionLabels(), + "我的情感模式:", + "这是你当前情绪调节偏好的探索结果,可用于自我了解与日常调整,不是固定标签。" + case "mbti-lite": + return map[string]string{"E": "外向充能型", "I": "内向充能型", "T": "理性决策型", "F": "感受决策型", "J": "结构安排型", "P": "弹性探索型"}, + "我的人格偏好:", "轻量人格偏好探索,不是固定分类。" + case "enneagram-lite": + return map[string]string{"A": "尽责驱动型", "B": "联结驱动型", "C": "独立驱动型"}, + "我的动机模式:", "内在动机轻量探索,用于自我觉察。" + case "bigfive-lite": + return map[string]string{"O": "开放探索型", "C": "条理稳健型", "E": "主动外展型", "A": "温和协作型", "N": "细腻敏感型"}, + "我的性格五维:", "稳定特质速览,可随情境变化。" + case "love-style": + return map[string]string{"A": "稳定陪伴型", "B": "深度共鸣型", "C": "空间尊重型"}, + "我的亲密互动:", "亲密关系偏好探索,不是关系定论。" + case "eq-lite": + return map[string]string{"A": "觉察表达型", "B": "行动调节型", "C": "缓慢识别型"}, + "我的情绪觉察:", "情绪习惯探索,可用于日常调节。" + case "stress-index": + return map[string]string{"A": "节奏稳定型", "B": "波动调节型", "C": "高负荷需休息型"}, + "我的压力负荷:", "近期压力与恢复偏好,不是诊断。" + case "career-interest": + return map[string]string{"A": "创造表达型", "B": "助人支持型", "C": "分析解决型"}, + "我的职业兴趣:", "工作动力偏好探索,可作方向参考。" + default: + return sc.CommunicationLabels(), + "我的沟通方式:", + "这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。" + } +} diff --git a/apps/api/internal/star/engine.go b/apps/api/internal/star/engine.go new file mode 100644 index 0000000..14ef1c7 --- /dev/null +++ b/apps/api/internal/star/engine.go @@ -0,0 +1,337 @@ +// Package star builds 星座 reports (natal chart · fortune · deep copy). +package star + +import ( + "fmt" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/star/fortune" + "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" +) + +// Output is free summary + gated detail. +type Output struct { + Summary map[string]any `json:"summary"` + Detail map[string]any `json:"detail"` +} + +// BuildOpts configures report generation. +type BuildOpts struct { + Birth time.Time + BirthTime *string + BirthPlace *string + Name string + AsOf time.Time // fortune anchor; zero = now +} + +// Build generates StarProfile from birth date (compat wrapper). +func Build(birth time.Time, birthTime *string, displayName string) (Output, error) { + return BuildWith(BuildOpts{Birth: birth, BirthTime: birthTime, Name: displayName}) +} + +// BuildWith generates a full star report. +func BuildWith(opts BuildOpts) (Output, error) { + name := opts.Name + if name == "" { + name = "你" + } + chart, err := natal.Compute(opts.Birth, opts.BirthTime, opts.BirthPlace) + if err != nil { + return Output{}, err + } + sun := chart.Sun + moon := chart.Moon + rise := chart.Rise + pack := packs[sun.SignKey] + if pack.Label == "" { + pack = defaultPack(signMeta{Key: sun.SignKey, Label: sun.Sign, Element: sun.Element, Modality: sun.Modality}) + } + moonPack := packs[moon.SignKey] + risePack := packs[rise.SignKey] + + signCards := []map[string]any{ + signCard("sun", "太阳星座", sun, pack.SunTeaser, pack.Keywords), + signCard("moon", "月亮星座", moon, fmt.Sprintf("情绪底色更偏「%s」", moon.Sign), moonPack.Keywords), + signCard("rise", "上升星座", rise, fmt.Sprintf("第一印象更偏「%s」", rise.Sign), risePack.Keywords), + } + + dims := []map[string]any{ + {"key": "sun", "title": "太阳风格", "teaser": pack.SunTeaser, "score": pack.Scores["sun"]}, + {"key": "moon", "title": "月亮节奏", "teaser": fmt.Sprintf("情绪底色更偏「%s」", moon.Sign), "score": pack.Scores["moon"]}, + {"key": "rise", "title": "上升表达", "teaser": fmt.Sprintf("第一印象更偏「%s」", rise.Sign), "score": pack.Scores["rise"]}, + {"key": "relation", "title": "关系互动", "teaser": pack.RelationTeaser, "score": pack.Scores["relation"]}, + {"key": "career", "title": "事业节奏", "teaser": pack.CareerTeaser, "score": pack.Scores["career"]}, + {"key": "growth", "title": "成长方向", "teaser": pack.GrowthTeaser, "score": pack.Scores["growth"]}, + } + + asOf := opts.AsOf + if asOf.IsZero() { + asOf = time.Now() + } + fort := fortune.Build(chart, asOf) + daily := fort.Daily + + planetsOut := make([]map[string]any, 0, len(chart.Planets)) + for _, p := range chart.Planets { + planetsOut = append(planetsOut, map[string]any{ + "key": p.Key, "title": p.Title, "sign": p.Sign, "sign_key": p.SignKey, + "degree": fmt.Sprintf("%.1f°", p.Degree), "house": p.House, + "element": p.Element, "modality": p.Modality, "lon": p.Lon, + }) + } + housesOut := make([]map[string]any, 0, len(chart.Houses)) + for _, h := range chart.Houses { + // Whole-sign house cusp ≈ asc sign start + (n-1)*30 + cusp := float64((signIndexOf(h.Key)) * 30) + housesOut = append(housesOut, map[string]any{ + "num": h.Num, "sign": h.Sign, "sign_key": h.Key, "cusp_lon": cusp, + }) + } + + aspects := natal.Aspects(chart) + aspectMaps := natal.AspectsAsMaps(aspects) + previewN := min(4, len(aspectMaps)) + aspectPreview := aspectMaps[:previewN] + + summary := map[string]any{ + "title": "星座·基础", + "style_label": pack.Label, + "headline": fmt.Sprintf("%s的星座更偏「%s」", name, pack.Label), + "one_liner": pack.OneLiner, + "overview": fmt.Sprintf("%s%s(太阳:%s;月亮:%s;上升:%s)。", name, pack.Overview, sun.Sign, moon.Sign, rise.Sign), + "life_tip": pack.LifeTip, + "keywords": pack.Keywords, + "dimensions": dims, + "sign_cards": signCards, + "sun_sign": sun.Sign, + "moon_sign": moon.Sign, + "rise_sign": rise.Sign, + "chart": map[string]any{ + "note": chart.Note, "place": chart.PlaceLabel, + "has_time": chart.HasTime, "has_place": chart.HasPlace, + "lat": chart.Lat, "lng": chart.Lng, + "asc_lon": rise.Lon, "houses": housesOut, + }, + "planets": planetsOut, + "aspects_preview": aspectPreview, + "fortune": fort.AsMap(), + "transits": fort.AsMap()["transits"], + "daily_soft": map[string]any{ + "title": daily.Title, "focus": daily.Focus, "tip": daily.Tip, + "energy": daily.Score, "note": daily.Label, "score": daily.Score, "label": daily.Label, + }, + "strengths_preview": pack.Strengths[:min(3, len(pack.Strengths))], + "blind_spots_preview": []string{"完整相位与年运详解见深度版。"}, + "interaction_tags": []string{ + fmt.Sprintf("太阳·%s", sun.Sign), + fmt.Sprintf("月亮·%s", moon.Sign), + fmt.Sprintf("上升·%s", rise.Sign), + pack.Label, + }, + } + + aspectBullets := make([]string, 0, min(8, len(aspects))) + for i, a := range aspects { + if i >= 8 { + break + } + aspectBullets = append(aspectBullets, a.Label) + } + + detail := map[string]any{ + "title": "星座·完整分析", + "aspects": aspectMaps, + "sections": []map[string]any{ + section("太阳星座 · "+sun.Sign, pack.SunDeep, pack.SunBullets), + section("月亮星座 · "+moon.Sign, fmt.Sprintf("情绪底色带有「%s」特质:%s", moon.Sign, moonPack.MoonDeep), moonPack.MoonBullets), + section("上升星座 · "+rise.Sign, fmt.Sprintf("对外第一印象偏「%s」:%s", rise.Sign, risePack.RiseDeep), risePack.RiseBullets), + section("行星相位要点", "主要相位帮助理解行动、情感与责任节奏之间的张力与互补。", aspectBullets), + section("行星落座", "落座与宫位是日常节奏的参照。", planetBullets(chart)), + section("年运详解", fort.Yearly.Tip+" "+fort.Yearly.Caution, []string{ + fmt.Sprintf("综合分 %d(%s)", fort.Yearly.Score, fort.Yearly.Label), + fmt.Sprintf("感情 %d · 事业 %d · 财务 %d · 心情 %d", fort.Yearly.Dims["love"], fort.Yearly.Dims["career"], fort.Yearly.Dims["money"], fort.Yearly.Dims["mood"]), + fort.Yearly.Lucky, + }), + section("一生运势", fort.Lifetime.Tip, []string{fort.Lifetime.Caution, fort.Lifetime.Lucky}), + section("关系互动", pack.RelationDeep, pack.RelationBullets), + section("事业与学习节奏", pack.CareerDeep, pack.CareerBullets), + section("成长方向", pack.GrowthDeep, pack.GrowthBullets), + }, + "strengths": pack.Strengths, + "blind_spots": pack.BlindSpots, + "growth_plan": []map[string]any{ + {"phase": "本周", "focus": pack.PlanWeek}, + {"phase": "本月", "focus": pack.PlanMonth}, + {"phase": "长期", "focus": pack.PlanLong}, + }, + "conversation_scripts": pack.Scripts, + "faq": pack.FAQ, + "behavior_pattern": pack.SunDeep, + "relation_style": pack.RelationDeep, + "growth_direction": pack.GrowthDeep, + "fortune_detail": fort.AsMap(), + } + return Output{Summary: summary, Detail: detail}, nil +} + +func signIndexOf(key string) int { + for i, s := range []string{ + "aries", "taurus", "gemini", "cancer", "leo", "virgo", + "libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces", + } { + if s == key { + return i + } + } + return 0 +} + +func planetBullets(chart natal.Chart) []string { + out := make([]string, 0, 6) + for _, key := range []string{"mercury", "venus", "mars", "jupiter", "saturn"} { + for _, p := range chart.Planets { + if p.Key == key { + out = append(out, fmt.Sprintf("%s在%s第%d宫", p.Title, p.Sign, p.House)) + break + } + } + } + return out +} + +func signCard(key, title string, b natal.Body, teaser string, keywords []string) map[string]any { + ks := keywords + if len(ks) > 3 { + ks = ks[:3] + } + return map[string]any{ + "key": key, "title": title, "label": b.Sign, + "element": b.Element, "modality": b.Modality, + "teaser": teaser, "keywords": ks, + } +} + +func section(title, body string, bullets []string) map[string]any { + return map[string]any{"title": title, "body": body, "bullets": bullets} +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// SignLabels returns sun/moon/rise labels (uses natal engine). +func SignLabels(birth time.Time, birthTime *string) (sun, moon, rise string) { + return SignLabelsPlace(birth, birthTime, nil) +} + +// SignLabelsPlace includes birth place for rising accuracy. +func SignLabelsPlace(birth time.Time, birthTime, place *string) (sun, moon, rise string) { + c, err := natal.Compute(birth, birthTime, place) + if err != nil { + return "", "", "" + } + return c.Sun.Sign, c.Moon.Sign, c.Rise.Sign +} + +// NatalChart exposes chart for relation/synastry. +func NatalChart(birth time.Time, birthTime, place *string) (natal.Chart, error) { + return natal.Compute(birth, birthTime, place) +} + +type signMeta struct { + Key, Label, Element, Modality string +} + +type pack struct { + Label, OneLiner, Overview, LifeTip string + Keywords []string + Scores map[string]int + SunTeaser, RelationTeaser, CareerTeaser, GrowthTeaser string + SunDeep, MoonDeep, RiseDeep, RelationDeep, CareerDeep, GrowthDeep string + SunBullets, MoonBullets, RiseBullets, RelationBullets, CareerBullets, GrowthBullets []string + Strengths, BlindSpots, Scripts []string + PlanWeek, PlanMonth, PlanLong string + FAQ []map[string]string +} + +var packs = map[string]pack{} + +func init() { + for _, s := range []signMeta{ + {"aries", "白羊", "火", "开创"}, {"taurus", "金牛", "土", "固定"}, {"gemini", "双子", "风", "变动"}, + {"cancer", "巨蟹", "水", "开创"}, {"leo", "狮子", "火", "固定"}, {"virgo", "处女", "土", "变动"}, + {"libra", "天秤", "风", "开创"}, {"scorpio", "天蝎", "水", "固定"}, {"sagittarius", "射手", "火", "变动"}, + {"capricorn", "摩羯", "土", "开创"}, {"aquarius", "水瓶", "风", "固定"}, {"pisces", "双鱼", "水", "变动"}, + } { + packs[s.Key] = defaultPack(s) + } + packs["aries"] = enrich(packs["aries"], "开创行动者", "先动起来,再在行动里想清楚。", + "你容易被新目标点燃,讨厌拖沓。优势是启动快;需要留意的是收尾与倾听。") + packs["taurus"] = enrich(packs["taurus"], "稳健沉淀者", "你重视踏实与感官舒适,变化太快会消耗你。", + "你擅长把事情做稳做久。关系与工作里都需要可预期的节奏。") + packs["gemini"] = enrich(packs["gemini"], "灵活连接者", "你靠好奇与对话充电,也容易分心。", + "信息与交流是你的养分。把想法收成一个可交付的小闭环,会更有成就感。") + packs["cancer"] = enrich(packs["cancer"], "细腻守护者", "你对情绪与归属很敏感,安全比热闹更重要。", + "你擅长照顾氛围与关系。记得也把自己的需要说清楚,而不是只默默撑着。") + packs["leo"] = enrich(packs["leo"], "热烈表达者", "你需要被看见,也愿意照亮别人。", + "热情与表达是你的名片。把认可需求说成具体请求,关系会更顺。") + packs["virgo"] = enrich(packs["virgo"], "细致完善者", "你看见细节与改进空间,也容易自我要求过高。", + "把「足够好」纳入标准,你会轻松很多,交付也会更快。") + packs["libra"] = enrich(packs["libra"], "平衡协调者", "你追求公平与和谐,有时会为难自己。", + "协调是天赋。重要决定里请给自己一票,而不只是各方折中。") + packs["scorpio"] = enrich(packs["scorpio"], "深潜洞察者", "你看重真诚与深度,讨厌浮于表面。", + "信任慢、一旦建立则很深。练习用语言同步感受,减少猜疑消耗。") + packs["sagittarius"] = enrich(packs["sagittarius"], "开阔探索者", "你需要视野与意义,讨厌被框死。", + "探索欲强。给自由一点结构,热情才能变成持续作品。") + packs["capricorn"] = enrich(packs["capricorn"], "负责攀登者", "你看长远目标,愿意为结果负责。", + "担当是优势。学会求助与休息,攀登才可持续。") + packs["aquarius"] = enrich(packs["aquarius"], "独特思考者", "你重视独立与新意,也需要被理解。", + "独特视角是礼物。把想法翻译成别人跟得上的一步行动。") + packs["pisces"] = enrich(packs["pisces"], "共感想象者", "你感受力强,边界容易被情绪浪潮冲开。", + "共情是天赋。区分「理解」与「承包」,你会更稳。") +} + +func defaultPack(s signMeta) pack { + return pack{ + Label: s.Label + "风格探索者", OneLiner: "用星座认识自己的节奏与运势起伏。", + Overview: "在星座框架里,你的表达与需求有独特侧重。", + LifeTip: "本周选一件小事完整做完,并告诉亲近的人你的真实需要。", + Keywords: []string{s.Label, s.Element + "象", s.Modality, "星座"}, + Scores: map[string]int{"sun": 78, "moon": 70, "rise": 72, "relation": 74, "career": 73, "growth": 75}, + SunTeaser: "核心驱动力清晰,行动有自己的节拍。", RelationTeaser: "关系里需要被理解与尊重节奏。", + CareerTeaser: "适合发挥你风格优势的场景。", GrowthTeaser: "下一步是看见盲区并小步调整。", + SunDeep: "太阳星座描述你的核心动机与自我表达。", + MoonDeep: "月亮星座指向情绪调节与安全感来源。", + RiseDeep: "上升星座影响别人对你的第一印象。", + RelationDeep: "关系中把需求说具体,比期待对方「应该懂」更有效。", + CareerDeep: "工作上优先发挥你的风格优势,并用小里程碑对抗拖延。", + GrowthDeep: "成长是认识模式后做可验证的小调整。", + SunBullets: []string{"核心动机可被命名", "表达有风格偏好", "适合自我探索"}, + MoonBullets: []string{"情绪需要出口", "安全感来源因人而异", "独处或连接可充电"}, + RiseBullets: []string{"第一印象可调节", "外显≠全部自我", "可练习温和表达"}, + RelationBullets: []string{"需求具体化", "尊重双方节奏", "冲突先复述再方案"}, + CareerBullets: []string{"发挥风格优势", "小步交付", "复盘节奏"}, + GrowthBullets: []string{"看见模式", "小步验证", "结合运势调整"}, + Strengths: []string{"风格清晰", "可探索性强", "利于自我对话"}, + BlindSpots: []string{"标签固化", "忽略情境差异", "过度解读"}, + Scripts: []string{"我想先说清我的节奏,再听你的。", "我不是冷淡,我需要一点整理时间。", "我们共同目标是……,下一步只定一件事。"}, + PlanWeek: "用三句话写下:我的优势 / 我的消耗点 / 我本周要试的一小步。", + PlanMonth: "在关系或工作中练习两次「先复述对方,再提需要」。", + PlanLong: "建立个人节奏手册:什么充电、什么耗电、如何请求支持。", + FAQ: []map[string]string{ + {"q": "运势是预测吗?", "a": "运势分与建议帮助你调整节奏与决策,请结合现实判断,勿作唯一依据。"}, + {"q": "星盘准吗?", "a": "出生时与出生地越完整,上升与宫位越贴近;算法为可复现近似星历。"}, + }, + } +} + +func enrich(base pack, label, one, overview string) pack { + base.Label = label + base.OneLiner = one + base.Overview = overview + base.Keywords = []string{label, "星座", "运势"} + return base +} diff --git a/apps/api/internal/star/engine_test.go b/apps/api/internal/star/engine_test.go new file mode 100644 index 0000000..e111949 --- /dev/null +++ b/apps/api/internal/star/engine_test.go @@ -0,0 +1,83 @@ +package star + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestBuildDeterministic(t *testing.T) { + birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC) + asOf := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) + opts := BuildOpts{Birth: birth, Name: "小愈", AsOf: asOf} + a, err := BuildWith(opts) + if err != nil { + t.Fatal(err) + } + b, err := BuildWith(opts) + if err != nil { + t.Fatal(err) + } + if a.Summary["headline"] != b.Summary["headline"] { + t.Fatal("not deterministic") + } + if a.Summary["sun_sign"] == nil || a.Detail["sections"] == nil { + t.Fatal("missing fields") + } + if a.Summary["sign_cards"] == nil || a.Summary["fortune"] == nil || a.Summary["planets"] == nil { + t.Fatal("missing sign_cards, fortune or planets") + } + if a.Summary["aspects_preview"] == nil { + t.Fatal("missing aspects_preview") + } + chart, _ := a.Summary["chart"].(map[string]any) + if chart["asc_lon"] == nil { + t.Fatal("missing chart.asc_lon") + } + if a.Detail["aspects"] == nil { + t.Fatal("missing detail.aspects") + } + raw, _ := json.Marshal(a) + blob := string(raw) + for _, bad := range []string{"算命"} { + if strings.Contains(blob, bad) { + t.Fatalf("forbidden %q", bad) + } + } +} + +func TestBuildWithBirthTimeChangesRise(t *testing.T) { + birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC) + bt := "14:30" + place := "北京" + a, err := BuildWith(BuildOpts{Birth: birth, Name: "我"}) + if err != nil { + t.Fatal(err) + } + b, err := BuildWith(BuildOpts{Birth: birth, BirthTime: &bt, BirthPlace: &place, Name: "我"}) + if err != nil { + t.Fatal(err) + } + if a.Summary["planets"] == nil || b.Summary["planets"] == nil { + t.Fatal("missing planets") + } + _ = a.Summary["rise_sign"] + _ = b.Summary["rise_sign"] +} + +func TestBuildFortuneScores(t *testing.T) { + birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC) + out, err := BuildWith(BuildOpts{Birth: birth, Name: "测", AsOf: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}) + if err != nil { + t.Fatal(err) + } + fort, ok := out.Summary["fortune"].(map[string]any) + if !ok { + t.Fatal("fortune missing") + } + daily, ok := fort["daily"].(map[string]any) + if !ok || daily["score"] == nil { + t.Fatal("daily score missing") + } +} diff --git a/apps/api/internal/star/ephemeris/ephemeris.go b/apps/api/internal/star/ephemeris/ephemeris.go new file mode 100644 index 0000000..67b9e69 --- /dev/null +++ b/apps/api/internal/star/ephemeris/ephemeris.go @@ -0,0 +1,161 @@ +// Package ephemeris computes tropical ecliptic longitudes. +// Default backend: Swiss Ephemeris Moshier (no external ephe files). +// NOTE: go-swisseph is AGPL-3.0; commercial deployment needs Astrodienst SE license or AGPL compliance. +package ephemeris + +import ( + "fmt" + "math" + "os" + "sync" + "time" + + swe "github.com/tejzpr/go-swisseph" +) + +// Body keys match natal chart keys. +const ( + BodySun = "sun" + BodyMoon = "moon" + BodyMercury = "mercury" + BodyVenus = "venus" + BodyMars = "mars" + BodyJupiter = "jupiter" + BodySaturn = "saturn" + BodyUranus = "uranus" + BodyNeptune = "neptune" + BodyPluto = "pluto" +) + +var bodyID = map[string]int32{ + BodySun: swe.Sun, + BodyMoon: swe.Moon, + BodyMercury: swe.Mercury, + BodyVenus: swe.Venus, + BodyMars: swe.Mars, + BodyJupiter: swe.Jupiter, + BodySaturn: swe.Saturn, + BodyUranus: swe.Uranus, + BodyNeptune: swe.Neptune, + BodyPluto: swe.Pluto, +} + +// PlanetOrder is the standard body sequence (excluding ASC). +var PlanetOrder = []string{ + BodySun, BodyMoon, BodyMercury, BodyVenus, BodyMars, + BodyJupiter, BodySaturn, BodyUranus, BodyNeptune, BodyPluto, +} + +var ( + once sync.Once + iflag int32 = swe.FlagMoseph + epheOK bool +) + +func initSE() { + once.Do(func() { + path := os.Getenv("SE_EPHE_PATH") + if path == "" { + path = os.Getenv("EPHEMERIS_PATH") + } + if path != "" { + swe.SetEphePath(path) + iflag = swe.FlagSwieph + epheOK = true + } else { + // Moshier: no files required + iflag = swe.FlagMoseph + epheOK = true + } + }) +} + +// Backend returns "moshier" or "swiss" for diagnostics. +func Backend() string { + initSE() + if iflag == swe.FlagSwieph { + return "swiss" + } + return "moshier" +} + +// JulianDayUT converts a UTC instant to Julian Day (UT). +func JulianDayUT(utc time.Time) float64 { + initSE() + utc = utc.UTC() + sec := float64(utc.Second()) + float64(utc.Nanosecond())/1e9 + dret, err := swe.UtcToJd( + int32(utc.Year()), int32(utc.Month()), int32(utc.Day()), + int32(utc.Hour()), int32(utc.Minute()), sec, swe.GregCal, + ) + if err != nil { + // Fallback Meeus-style JD + return julianDayFallback(utc) + } + return dret[1] // UT +} + +// PlanetLon returns tropical ecliptic longitude in degrees [0,360). +func PlanetLon(jdUt float64, body string) (float64, error) { + initSE() + ipl, ok := bodyID[body] + if !ok { + return 0, fmt.Errorf("unknown body %q", body) + } + r := swe.CalcUT(jdUt, ipl, iflag) + if r.Flag < 0 { + return 0, fmt.Errorf("swe calc %s: %s", body, r.Error) + } + return norm360(r.Data[0]), nil +} + +// Ascendant returns tropical ASC longitude degrees using Placidus cusps (Points[0]). +// Callers apply Whole Sign houses from this ASC. +func Ascendant(jdUt, lat, lng float64) (float64, error) { + initSE() + h := swe.Houses(jdUt, lat, lng, 'P') + if h.Flag < 0 || len(h.Points) < 1 { + return 0, fmt.Errorf("swe houses failed flag=%d", h.Flag) + } + return norm360(h.Points[0]), nil +} + +// AllPlanetLons returns longitudes for PlanetOrder. +func AllPlanetLons(jdUt float64) (map[string]float64, error) { + out := make(map[string]float64, len(PlanetOrder)) + for _, k := range PlanetOrder { + lon, err := PlanetLon(jdUt, k) + if err != nil { + return nil, err + } + out[k] = lon + } + return out, nil +} + +func norm360(x float64) float64 { + x = math.Mod(x, 360) + if x < 0 { + x += 360 + } + return x +} + +func julianDayFallback(t time.Time) float64 { + y := t.Year() + m := int(t.Month()) + d := float64(t.Day()) + float64(t.Hour())/24 + float64(t.Minute())/1440 + float64(t.Second())/86400 + if m <= 2 { + y-- + m += 12 + } + A := y / 100 + B := 2 - A + A/4 + return math.Floor(365.25*float64(y+4716)) + math.Floor(30.6001*float64(m+1)) + d + float64(B) - 1524.5 +} + +// Ready reports whether ephemeris init succeeded (always true for Moshier). +func Ready() bool { + initSE() + return epheOK +} diff --git a/apps/api/internal/star/ephemeris/ephemeris_test.go b/apps/api/internal/star/ephemeris/ephemeris_test.go new file mode 100644 index 0000000..a50bd68 --- /dev/null +++ b/apps/api/internal/star/ephemeris/ephemeris_test.go @@ -0,0 +1,44 @@ +package ephemeris + +import ( + "testing" + "time" +) + +func TestSunMay1990(t *testing.T) { + if !Ready() { + t.Fatal("ephemeris not ready") + } + // 1990-05-12 10:30 CST = 02:30 UTC + utc := time.Date(1990, 5, 12, 2, 30, 0, 0, time.UTC) + jd := JulianDayUT(utc) + lon, err := PlanetLon(jd, BodySun) + if err != nil { + t.Fatal(err) + } + // ~51° Taurus + if lon < 48 || lon > 55 { + t.Fatalf("sun lon=%.2f want ~51°", lon) + } + asc, err := Ascendant(jd, 31.23, 121.47) + if err != nil { + t.Fatal(err) + } + if asc < 0 || asc >= 360 { + t.Fatalf("bad asc %.2f", asc) + } + if Backend() == "" { + t.Fatal("empty backend") + } +} + +func TestAllPlanetLons(t *testing.T) { + jd := JulianDayUT(time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC)) + m, err := AllPlanetLons(jd) + if err != nil { + t.Fatal(err) + } + if len(m) != len(PlanetOrder) { + t.Fatalf("got %d", len(m)) + } +} diff --git a/apps/api/internal/star/fortune/fortune.go b/apps/api/internal/star/fortune/fortune.go new file mode 100644 index 0000000..add12cc --- /dev/null +++ b/apps/api/internal/star/fortune/fortune.go @@ -0,0 +1,261 @@ +// Package fortune synthesizes daily/weekly/monthly/yearly/lifetime scores and transits. +package fortune + +import ( + "fmt" + "math" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" +) + +// Period is one fortune block. +type Period struct { + Key string `json:"key"` + Title string `json:"title"` + Score int `json:"score"` + Label string `json:"label"` + Dims map[string]int `json:"dims"` + Tip string `json:"tip"` + Lucky string `json:"lucky"` + Caution string `json:"caution"` + Focus string `json:"focus"` +} + +// Transit is a simplified day transit tip relative to natal. +type Transit struct { + Key string `json:"key"` + Title string `json:"title"` + Aspect string `json:"aspect"` + Tip string `json:"tip"` +} + +// Bundle holds all periods plus lifetime and transits. +type Bundle struct { + Daily Period `json:"daily"` + Weekly Period `json:"weekly"` + Monthly Period `json:"monthly"` + Yearly Period `json:"yearly"` + Lifetime Period `json:"lifetime"` + Transits []Transit `json:"transits"` +} + +// Build returns fortune for natal chart as of asOf (date matters). +func Build(chart natal.Chart, asOf time.Time) Bundle { + if asOf.IsZero() { + asOf = time.Now() + } + sun := chart.Sun.Lon + moon := chart.Moon.Lon + daySeed := asOf.Year()*1000 + asOf.YearDay() + int(sun) + int(moon) + + daily := period("daily", "今日运势", daySeed, sun, moon, chart, + []string{"行动", "沟通", "情绪", "财运", "桃花"}, + []string{ + fmt.Sprintf("发挥太阳「%s」的主动性,先完成一件小事。", chart.Sun.Sign), + fmt.Sprintf("照顾月亮「%s」的情绪需要,给自己缓冲。", chart.Moon.Sign), + "适合推进沟通:先复述再提方案。", + "宜整理待办与开销,给节奏一点秩序。", + "轻社交或独处充电均可,按电量选择。", + }) + + weekSeed := asOf.Year()*100 + isoWeek(asOf) + int(sun)/3 + weekly := period("weekly", "本周运势", weekSeed, sun, moon, chart, + []string{"事业", "关系", "学习", "休息", "决策"}, + []string{ + "本周适合定一个可交付的小目标并公开承诺。", + "关系上主动约一次轻松同步,不谈对错。", + "学习/复盘:把灵感写成三条行动。", + "安排半日空档恢复精力。", + "重大决定用「可逆/不可逆」分类后再选速度。", + }) + + monthSeed := asOf.Year()*12 + int(asOf.Month()) + int(moon)/5 + monthly := period("monthly", "本月运势", monthSeed, sun, moon, chart, + []string{"感情", "事业", "财务", "健康节奏", "人际"}, + []string{ + fmt.Sprintf("本月主题贴近「%s」:把热情落成节奏。", chart.Sun.Sign), + "感情上说清需要,比猜测更有效。", + "财务宜做一次月度复盘,砍掉低价值开销。", + "作息与运动选可持续的小剂量。", + "拓展一个人际弱连接,可能带来信息增益。", + }) + + yearSeed := asOf.Year() + int(sun) + int(chart.Rise.Lon)/10 + yearly := period("yearly", "今年运势", yearSeed, sun, moon, chart, + []string{"成长主线", "关系课题", "事业方向", "财富节奏", "身心"}, + []string{ + fmt.Sprintf("今年宜强化太阳「%s」优势,并补月亮「%s」的安全感建设。", chart.Sun.Sign, chart.Moon.Sign), + "关系课题:边界与亲密并重,写成说明书。", + "事业上选择能看到里程碑的路径。", + "财富:先稳现金流,再谈进取配置。", + "身心:季度体检式复盘情绪与睡眠。", + }) + + life := lifetime(chart) + tr := transits(chart, asOf) + + return Bundle{ + Daily: daily, Weekly: weekly, Monthly: monthly, Yearly: yearly, + Lifetime: life, Transits: tr, + } +} + +func lifetime(chart natal.Chart) Period { + sat := bodyLon(chart, "saturn") + seed := int(chart.Sun.Lon) + int(sat)/2 + int(chart.Moon.Lon)/3 + score := 62 + seed%28 + stages := []string{ + fmt.Sprintf("成长主线贴近太阳「%s」:用可见行动定义自我。", chart.Sun.Sign), + fmt.Sprintf("情感底色来自月亮「%s」:安全感与表达节奏需要长期经营。", chart.Moon.Sign), + fmt.Sprintf("对外姿态偏上升「%s」:第一印象可以主动校准。", chart.Rise.Sign), + } + tip := stages[seed%len(stages)] + " 人生阶段不必一次做完,按十年课题拆解更稳。" + return Period{ + Key: "lifetime", Title: "一生运势摘要", Score: score, Label: scoreLabel(score), + Dims: map[string]int{ + "love": 50 + seed%40, "career": 52 + (seed*3)%40, + "money": 48 + (seed*5)%42, "mood": 55 + (seed*7)%35, + }, + Tip: tip, Focus: "人生阶段", + Lucky: "长期复盘 · 边界清晰", + Caution: "避免把阶段标签当成宿命;可调整节奏与选择。", + } +} + +func transits(chart natal.Chart, asOf time.Time) []Transit { + // Approximate "transit" sun/moon using same ephemeris at asOf noon CST. + loc := time.FixedZone("CST", 8*3600) + local := time.Date(asOf.Year(), asOf.Month(), asOf.Day(), 12, 0, 0, 0, loc) + tChart, err := natal.Compute(local, strPtr("12:00"), placePtr(chart.PlaceLabel)) + if err != nil { + return nil + } + tSun, tMoon := tChart.Sun.Lon, tChart.Moon.Lon + + out := []Transit{} + out = append(out, transitHit("transit_sun_natal_sun", "行运太阳×本命太阳", tSun, chart.Sun.Lon, + "适合主动推进与自我表达相关的事。", + "宜复盘目标,不急着扩张战线。", + "注意沟通语气,先对齐再行动。")) + out = append(out, transitHit("transit_moon_natal_moon", "行运月亮×本命月亮", tMoon, chart.Moon.Lon, + "情绪敏感日,给自己更多缓冲。", + "适合温柔连结与休息充电。", + "避免情绪化决策,先写下来再说。")) + out = append(out, transitHit("transit_sun_natal_moon", "行运太阳×本命月亮", tSun, chart.Moon.Lon, + "外在节奏触动内在需求,适合说清感受。", + "工作与情绪平衡日,留一点空白。", + "别硬撑社交,按电量选择场合。")) + return out +} + +func transitHit(key, title string, a, b float64, conj, soft, hard string) Transit { + diff := natal.AngleDiff(a, b) + aspect, tip := "弱互动", "节奏平常,按计划推进即可。" + switch { + case near(diff, 0, 8): + aspect, tip = "合相", conj + case near(diff, 60, 6) || near(diff, 120, 7): + aspect, tip = "和谐相位", soft + case near(diff, 90, 7) || near(diff, 180, 8): + aspect, tip = "张力相位", hard + } + return Transit{Key: key, Title: title, Aspect: aspect, Tip: tip} +} + +func near(diff, target, orb float64) bool { + return math.Abs(diff-target) <= orb +} + +func bodyLon(c natal.Chart, key string) float64 { + for _, p := range c.Planets { + if p.Key == key { + return p.Lon + } + } + return 0 +} + +func strPtr(s string) *string { return &s } +func placePtr(s string) *string { + if s == "" || s == "默认(未填出生地)" { + return nil + } + return &s +} + +func period(key, title string, seed int, sun, moon float64, chart natal.Chart, focuses, tips []string) Period { + score := 55 + (seed*7+int(sun)+int(moon))%41 // 55–95 + love := 50 + (seed*3+int(moon))%46 + career := 50 + (seed*5+int(sun))%46 + money := 48 + (seed*11+int(chart.Rise.Lon))%47 + mood := 52 + (seed*13+int(moon)/2)%45 + i := seed % len(tips) + return Period{ + Key: key, Title: title, Score: score, Label: scoreLabel(score), + Dims: map[string]int{ + "love": love, "career": career, "money": money, "mood": mood, + }, + Tip: tips[i], Focus: focuses[i%len(focuses)], + Lucky: luckyFrom(seed), + Caution: cautionFrom(seed, chart), + } +} + +func scoreLabel(s int) string { + switch { + case s >= 85: + return "大吉" + case s >= 75: + return "吉" + case s >= 65: + return "中平偏吉" + case s >= 55: + return "平稳" + default: + return "需谨慎" + } +} + +func luckyFrom(seed int) string { + colors := []string{"红色", "金色", "蓝色", "绿色", "紫色", "白色"} + nums := []string{"3", "6", "7", "8", "9"} + return colors[seed%len(colors)] + " · 数字 " + nums[seed%len(nums)] +} + +func cautionFrom(seed int, chart natal.Chart) string { + cautions := []string{ + "避免冲动承诺,尤其财务相关。", + fmt.Sprintf("「%s」能量过强时易急躁,先深呼吸再回复。", chart.Sun.Sign), + "少开多线任务,完成比完美重要。", + "情绪波动时先写下来,再找人聊。", + } + return cautions[seed%len(cautions)] +} + +func isoWeek(t time.Time) int { + _, w := t.ISOWeek() + return w +} + +// AsMap for JSON embedding. +func (b Bundle) AsMap() map[string]any { + tr := make([]map[string]any, 0, len(b.Transits)) + for _, t := range b.Transits { + tr = append(tr, map[string]any{ + "key": t.Key, "title": t.Title, "aspect": t.Aspect, "tip": t.Tip, + }) + } + return map[string]any{ + "daily": periodMap(b.Daily), "weekly": periodMap(b.Weekly), + "monthly": periodMap(b.Monthly), "yearly": periodMap(b.Yearly), + "lifetime": periodMap(b.Lifetime), "transits": tr, + } +} + +func periodMap(p Period) map[string]any { + return map[string]any{ + "key": p.Key, "title": p.Title, "score": p.Score, "label": p.Label, + "dims": p.Dims, "tip": p.Tip, "lucky": p.Lucky, "caution": p.Caution, "focus": p.Focus, + } +} diff --git a/apps/api/internal/star/fortune/fortune_test.go b/apps/api/internal/star/fortune/fortune_test.go new file mode 100644 index 0000000..ce0570c --- /dev/null +++ b/apps/api/internal/star/fortune/fortune_test.go @@ -0,0 +1,32 @@ +package fortune + +import ( + "testing" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" +) + +func TestBuildPeriods(t *testing.T) { + chart, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil) + if err != nil { + t.Fatal(err) + } + asOf := time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC) + a := Build(chart, asOf) + b := Build(chart, asOf) + if a.Daily.Score != b.Daily.Score || a.Yearly.Tip != b.Yearly.Tip { + t.Fatal("not deterministic") + } + if a.Daily.Score < 50 || a.Weekly.Dims["love"] == 0 { + t.Fatalf("bad scores %+v", a.Daily) + } + m := a.AsMap() + if m["monthly"] == nil || m["lifetime"] == nil { + t.Fatal("missing monthly or lifetime") + } + tr, ok := m["transits"].([]map[string]any) + if !ok || len(tr) == 0 { + t.Fatalf("transits missing: %#v", m["transits"]) + } +} diff --git a/apps/api/internal/star/natal/aspects.go b/apps/api/internal/star/natal/aspects.go new file mode 100644 index 0000000..8596291 --- /dev/null +++ b/apps/api/internal/star/natal/aspects.go @@ -0,0 +1,90 @@ +package natal + +import ( + "fmt" + "math" + "sort" +) + +// Aspect is a major aspect between two chart points. +type Aspect struct { + A string `json:"a"` + B string `json:"b"` + ATitle string `json:"a_title"` + BTitle string `json:"b_title"` + Type string `json:"type"` // conjunction|sextile|square|trine|opposition + Angle float64 `json:"angle"` + Orb float64 `json:"orb"` + Label string `json:"label"` +} + +var aspectDefs = []struct { + Type string + Angle float64 + Orb float64 + Label string +}{ + {"conjunction", 0, 8, "合相"}, + {"sextile", 60, 6, "六合"}, + {"square", 90, 7, "刑相"}, + {"trine", 120, 7, "拱相"}, + {"opposition", 180, 8, "对冲"}, +} + +// majorKeys used for aspect table (外行星可选由前端过滤). +var majorKeys = []string{"sun", "moon", "rise", "mercury", "venus", "mars", "jupiter", "saturn"} + +// Aspects computes major aspects among primary bodies. +func Aspects(chart Chart) []Aspect { + byKey := map[string]Body{} + for _, p := range chart.Planets { + byKey[p.Key] = p + } + var bodies []Body + for _, k := range majorKeys { + if b, ok := byKey[k]; ok { + bodies = append(bodies, b) + } + } + out := make([]Aspect, 0) + for i := 0; i < len(bodies); i++ { + for j := i + 1; j < len(bodies); j++ { + a, b := bodies[i], bodies[j] + diff := AngleDiff(a.Lon, b.Lon) + for _, def := range aspectDefs { + orb := math.Abs(diff - def.Angle) + if orb <= def.Orb { + out = append(out, Aspect{ + A: a.Key, B: b.Key, ATitle: a.Title, BTitle: b.Title, + Type: def.Type, Angle: def.Angle, Orb: round1(orb), + Label: fmt.Sprintf("%s%s%s(容许%.1f°)", a.Title, def.Label, b.Title, orb), + }) + break + } + } + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Orb != out[j].Orb { + return out[i].Orb < out[j].Orb + } + return out[i].Label < out[j].Label + }) + return out +} + +// AspectsAsMaps for JSON embedding. +func AspectsAsMaps(list []Aspect) []map[string]any { + out := make([]map[string]any, 0, len(list)) + for _, a := range list { + out = append(out, map[string]any{ + "a": a.A, "b": a.B, "a_title": a.ATitle, "b_title": a.BTitle, + "type": a.Type, "angle": a.Angle, "orb": a.Orb, "label": a.Label, + }) + } + return out +} + +func round1(x float64) float64 { + return math.Round(x*10) / 10 +} diff --git a/apps/api/internal/star/natal/aspects_test.go b/apps/api/internal/star/natal/aspects_test.go new file mode 100644 index 0000000..26e6019 --- /dev/null +++ b/apps/api/internal/star/natal/aspects_test.go @@ -0,0 +1,28 @@ +package natal + +import ( + "testing" + "time" +) + +func TestAspectsDeterministic(t *testing.T) { + c, err := Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("12:00"), strPtr("北京")) + if err != nil { + t.Fatal(err) + } + a := Aspects(c) + b := Aspects(c) + if len(a) == 0 { + t.Fatal("expected some aspects") + } + if len(a) != len(b) || a[0].Label != b[0].Label { + t.Fatalf("not deterministic: %+v vs %+v", a[0], b[0]) + } + for _, asp := range a { + if asp.Orb < 0 || asp.Orb > 8.1 { + t.Fatalf("orb out of range: %+v", asp) + } + } +} + +func strPtr(s string) *string { return &s } diff --git a/apps/api/internal/star/natal/cities.go b/apps/api/internal/star/natal/cities.go new file mode 100644 index 0000000..9a7978c --- /dev/null +++ b/apps/api/internal/star/natal/cities.go @@ -0,0 +1,148 @@ +package natal + +import ( + "strings" + "unicode/utf8" +) + +// City is a built-in birth place with WGS84 coordinates. +type City struct { + Name string + Lat float64 + Lng float64 +} + +// Cities lists common CN cities for MVP geocode (no network). +var Cities = []City{ + {"北京", 39.9042, 116.4074}, + {"上海", 31.2304, 121.4737}, + {"广州", 23.1291, 113.2644}, + {"深圳", 22.5431, 114.0579}, + {"杭州", 30.2741, 120.1551}, + {"成都", 30.5728, 104.0668}, + {"重庆", 29.5630, 106.5516}, + {"武汉", 30.5928, 114.3055}, + {"西安", 34.3416, 108.9398}, + {"南京", 32.0603, 118.7969}, + {"天津", 39.3434, 117.3616}, + {"苏州", 31.2989, 120.5853}, + {"长沙", 28.2282, 112.9388}, + {"郑州", 34.7466, 113.6254}, + {"青岛", 36.0671, 120.3826}, + {"大连", 38.9140, 121.6147}, + {"厦门", 24.4798, 118.0894}, + {"福州", 26.0745, 119.2965}, + {"昆明", 25.0389, 102.7183}, + {"贵阳", 26.6470, 106.6302}, + {"南宁", 22.8170, 108.3665}, + {"海口", 20.0440, 110.1999}, + {"哈尔滨", 45.8038, 126.5349}, + {"长春", 43.8171, 125.3235}, + {"沈阳", 41.8057, 123.4315}, + {"石家庄", 38.0428, 114.5149}, + {"太原", 37.8706, 112.5489}, + {"济南", 36.6512, 117.1201}, + {"合肥", 31.8206, 117.2272}, + {"南昌", 28.6820, 115.8579}, + {"兰州", 36.0611, 103.8343}, + {"乌鲁木齐", 43.8256, 87.6168}, + {"拉萨", 29.6520, 91.1721}, + {"呼和浩特", 40.8414, 111.7519}, + {"银川", 38.4872, 106.2309}, + {"西宁", 36.6171, 101.7782}, + {"香港", 22.3193, 114.1694}, + {"澳门", 22.1987, 113.5439}, + {"台北", 25.0330, 121.5654}, + {"宁波", 29.8683, 121.5440}, + {"无锡", 31.4912, 120.3119}, + {"佛山", 23.0215, 113.1214}, + {"东莞", 23.0207, 113.7518}, + {"温州", 27.9943, 120.6994}, + {"泉州", 24.8741, 118.6759}, + {"珠海", 22.2710, 113.5767}, +} + +// DefaultCoords is used when place is missing (东八区中部近似). +const DefaultLat = 30.0 +const DefaultLng = 114.0 + +// ResolvePlace returns lat/lng and whether a named city matched. +// Accepts plain city ("杭州") or 省市区 ("浙江省 杭州市 西湖区" / "北京市 市辖区 朝阳区"). +func ResolvePlace(place string) (lat, lng float64, matched string, ok bool) { + place = strings.TrimSpace(place) + if place == "" { + return DefaultLat, DefaultLng, "", false + } + // exact city table hit + for _, c := range Cities { + if c.Name == place { + return c.Lat, c.Lng, c.Name, true + } + } + tokens := splitPlace(place) + // prefer matching city-level tokens (usually 2nd), then others + order := append([]string{}, tokens...) + if len(tokens) >= 2 { + order = append([]string{tokens[1]}, tokens[0]) + if len(tokens) >= 3 { + order = append(order, tokens[2:]...) + } + } + for _, tok := range order { + if tok == "" || tok == "市辖区" || tok == "县" { + continue + } + key := normalizeAdmin(tok) + for _, c := range Cities { + if c.Name == key || strings.Contains(tok, c.Name) || strings.Contains(c.Name, key) { + return c.Lat, c.Lng, c.Name, true + } + } + } + // province-only fallback: use first token city if 直辖市 + if len(tokens) > 0 { + key := normalizeAdmin(tokens[0]) + for _, c := range Cities { + if c.Name == key { + return c.Lat, c.Lng, c.Name, true + } + } + } + return DefaultLat, DefaultLng, "", false +} + +func splitPlace(s string) []string { + s = strings.ReplaceAll(s, "/", " ") + s = strings.ReplaceAll(s, "/", " ") + s = strings.ReplaceAll(s, ",", " ") + s = strings.ReplaceAll(s, ",", " ") + parts := strings.Fields(s) + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func normalizeAdmin(s string) string { + s = strings.TrimSpace(s) + suffixes := []string{"特别行政区", "维吾尔自治区", "壮族自治区", "回族自治区", "自治区", "省", "市", "地区", "盟"} + for _, suf := range suffixes { + if strings.HasSuffix(s, suf) && utf8.RuneCountInString(s) > utf8.RuneCountInString(suf) { + return strings.TrimSuffix(s, suf) + } + } + return s +} + +// CityNames returns names for API/UI pickers. +func CityNames() []string { + out := make([]string, len(Cities)) + for i, c := range Cities { + out[i] = c.Name + } + return out +} diff --git a/apps/api/internal/star/natal/natal.go b/apps/api/internal/star/natal/natal.go new file mode 100644 index 0000000..0b17979 --- /dev/null +++ b/apps/api/internal/star/natal/natal.go @@ -0,0 +1,254 @@ +// Package natal computes tropical whole-sign natal charts via Swiss Ephemeris. +package natal + +import ( + "fmt" + "math" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/star/ephemeris" +) + +// Body is a chart point. +type Body struct { + Key string `json:"key"` + Title string `json:"title"` + Lon float64 `json:"lon"` + SignKey string `json:"sign_key"` + Sign string `json:"sign"` + Degree float64 `json:"degree"` // 0–30 within sign + House int `json:"house"` + Element string `json:"element"` + Modality string `json:"modality"` +} + +// House is a whole-sign house. +type House struct { + Num int `json:"num"` + Sign string `json:"sign"` + Key string `json:"sign_key"` +} + +// Chart is a natal chart snapshot. +type Chart struct { + Sun, Moon, Rise Body + Planets []Body + Houses []House + Lat, Lng float64 + PlaceLabel string + HasTime bool + HasPlace bool + Note string + // Instant is the UTC moment used for ephemeris (noon default when time missing). + Instant time.Time `json:"-"` +} + +type signMeta struct { + Key, Label, Element, Modality string +} + +var signs = []signMeta{ + {"aries", "白羊", "火", "开创"}, + {"taurus", "金牛", "土", "固定"}, + {"gemini", "双子", "风", "变动"}, + {"cancer", "巨蟹", "水", "开创"}, + {"leo", "狮子", "火", "固定"}, + {"virgo", "处女", "土", "变动"}, + {"libra", "天秤", "风", "开创"}, + {"scorpio", "天蝎", "水", "固定"}, + {"sagittarius", "射手", "火", "变动"}, + {"capricorn", "摩羯", "土", "开创"}, + {"aquarius", "水瓶", "风", "固定"}, + {"pisces", "双鱼", "水", "变动"}, +} + +var bodyTitles = map[string]string{ + "sun": "太阳", "moon": "月亮", "rise": "上升", + "mercury": "水星", "venus": "金星", "mars": "火星", + "jupiter": "木星", "saturn": "土星", "uranus": "天王星", + "neptune": "海王星", "pluto": "冥王星", +} + +// Compute builds a natal chart. birthTime is HH:MM local; place is city name. +func Compute(birthDate time.Time, birthTime *string, place *string) (Chart, error) { + lat, lng := DefaultLat, DefaultLng + placeLabel := "默认(未填出生地)" + hasPlace := false + if place != nil && *place != "" { + if la, ln, name, ok := ResolvePlace(*place); ok { + lat, lng, placeLabel, hasPlace = la, ln, name, true + } else { + placeLabel = *place + "(未匹配城市,用默认坐标)" + } + } + + h, mi := 12, 0 // noon default when time missing + hasTime := false + if birthTime != nil && *birthTime != "" { + if hh, mm, ok := parseHM(*birthTime); ok { + h, mi, hasTime = hh, mm, true + } + } + + // Treat civil time as UTC+8 for CN MVP (deterministic). + loc := time.FixedZone("CST", 8*3600) + local := time.Date(birthDate.Year(), birthDate.Month(), birthDate.Day(), h, mi, 0, 0, loc) + utc := local.UTC() + + return ComputeAt(utc, lat, lng, placeLabel, hasTime, hasPlace) +} + +// ComputeAt builds a chart for an exact UTC instant and coordinates. +func ComputeAt(utc time.Time, lat, lng float64, placeLabel string, hasTime, hasPlace bool) (Chart, error) { + utc = utc.UTC() + jd := ephemeris.JulianDayUT(utc) + lons, err := ephemeris.AllPlanetLons(jd) + if err != nil { + return Chart{}, fmt.Errorf("ephemeris: %w", err) + } + asc, err := ephemeris.Ascendant(jd, lat, lng) + if err != nil { + return Chart{}, fmt.Errorf("ephemeris asc: %w", err) + } + + ch := ChartFromLons(lons, asc, lat, lng, placeLabel, hasTime, hasPlace) + ch.Instant = utc + ch.Note = fmt.Sprintf("热带黄道 · 整宫制 · 星历 %s。", ephemeris.Backend()) + if !hasTime { + ch.Note += " 未填出生时,上升与宫位按正午估算。" + } + if !hasPlace { + ch.Note += " 填写出生地可提升上升准确度。" + } + return ch, nil +} + +// ChartFromLons builds a whole-sign chart from body longitudes and ASC. +func ChartFromLons(lons map[string]float64, asc float64, lat, lng float64, placeLabel string, hasTime, hasPlace bool) Chart { + ascSignIdx := signIndex(asc) + mk := func(key string, lon float64) Body { + title := bodyTitles[key] + if title == "" { + title = key + } + idx := signIndex(lon) + s := signs[idx] + house := wholeSignHouse(ascSignIdx, idx) + return Body{ + Key: key, Title: title, Lon: norm360(lon), + SignKey: s.Key, Sign: s.Label, Degree: math.Mod(norm360(lon), 30), + House: house, Element: s.Element, Modality: s.Modality, + } + } + + sun := mk("sun", lons["sun"]) + moon := mk("moon", lons["moon"]) + rise := mk("rise", asc) + + planets := []Body{ + sun, moon, rise, + mk("mercury", lons["mercury"]), + mk("venus", lons["venus"]), + mk("mars", lons["mars"]), + mk("jupiter", lons["jupiter"]), + mk("saturn", lons["saturn"]), + mk("uranus", lons["uranus"]), + mk("neptune", lons["neptune"]), + mk("pluto", lons["pluto"]), + } + + houses := make([]House, 12) + for i := 0; i < 12; i++ { + idx := (ascSignIdx + i) % 12 + houses[i] = House{Num: i + 1, Sign: signs[idx].Label, Key: signs[idx].Key} + } + + return Chart{ + Sun: sun, Moon: moon, Rise: rise, Planets: planets, Houses: houses, + Lat: lat, Lng: lng, PlaceLabel: placeLabel, HasTime: hasTime, HasPlace: hasPlace, + } +} + +// BodyLon returns longitude for a key, or 0. +func BodyLon(c Chart, key string) float64 { + for _, p := range c.Planets { + if p.Key == key { + return p.Lon + } + } + return 0 +} + +// SignMetaByKey returns label/element for a sign key. +func SignMetaByKey(key string) (label, element, modality string) { + for _, s := range signs { + if s.Key == key { + return s.Label, s.Element, s.Modality + } + } + return key, "", "" +} + +// SignByIndex returns sign meta. +func SignByIndex(i int) (key, label, element, modality string) { + s := signs[((i%12)+12)%12] + return s.Key, s.Label, s.Element, s.Modality +} + +func wholeSignHouse(ascIdx, bodyIdx int) int { + return ((bodyIdx-ascIdx)%12+12)%12 + 1 +} + +func signIndex(lon float64) int { + return int(math.Floor(norm360(lon)/30.0)) % 12 +} + +func parseHM(s string) (h, m int, ok bool) { + var hh, mm int + n, err := fmt.Sscanf(s, "%d:%d", &hh, &mm) + if err != nil || n < 1 || hh < 0 || hh > 23 { + return 0, 0, false + } + if n == 1 { + mm = 0 + } + if mm < 0 || mm > 59 { + return 0, 0, false + } + return hh, mm, true +} + +func norm360(x float64) float64 { + x = math.Mod(x, 360) + if x < 0 { + x += 360 + } + return x +} + +// AngleDiff returns smallest absolute ecliptic separation. +func AngleDiff(a, b float64) float64 { + d := math.Abs(norm360(a) - norm360(b)) + if d > 180 { + d = 360 - d + } + return d +} + +// MidpointLon returns the shorter-arc midpoint of two longitudes. +func MidpointLon(a, b float64) float64 { + a, b = norm360(a), norm360(b) + d := b - a + if d > 180 { + d -= 360 + } else if d < -180 { + d += 360 + } + return norm360(a + d/2) +} + +// SignIndex is exported for overlay house mapping. +func SignIndex(lon float64) int { return signIndex(lon) } + +// WholeSignHouse exported for overlay. +func WholeSignHouse(ascIdx, bodyIdx int) int { return wholeSignHouse(ascIdx, bodyIdx) } diff --git a/apps/api/internal/star/natal/natal_test.go b/apps/api/internal/star/natal/natal_test.go new file mode 100644 index 0000000..f11d0cb --- /dev/null +++ b/apps/api/internal/star/natal/natal_test.go @@ -0,0 +1,55 @@ +package natal + +import ( + "testing" + "time" +) + +func TestComputeDeterministic(t *testing.T) { + birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC) + bt := "10:30" + place := "上海" + a, err := Compute(birth, &bt, &place) + if err != nil { + t.Fatal(err) + } + b, err := Compute(birth, &bt, &place) + if err != nil { + t.Fatal(err) + } + if a.Sun.Sign != b.Sun.Sign || a.Moon.Lon != b.Moon.Lon || a.Rise.Sign != b.Rise.Sign { + t.Fatal("not deterministic") + } + if len(a.Planets) < 8 || len(a.Houses) != 12 { + t.Fatalf("planets=%d houses=%d", len(a.Planets), len(a.Houses)) + } + if a.Sun.Sign == "" { + t.Fatal("empty sun") + } +} + +func TestResolvePlaceBeijing(t *testing.T) { + lat, lng, name, ok := ResolvePlace("北京") + if !ok || name != "北京" || lat < 39 || lng < 116 { + t.Fatalf("got %v %v %s %v", lat, lng, name, ok) + } + _, _, name2, ok2 := ResolvePlace("北京市 市辖区 朝阳区") + if !ok2 || name2 != "北京" { + t.Fatalf("pca resolve got %s %v", name2, ok2) + } + _, _, name3, ok3 := ResolvePlace("浙江省 杭州市 西湖区") + if !ok3 || name3 != "杭州" { + t.Fatalf("hangzhou resolve got %s %v", name3, ok3) + } +} + +func TestSunSignMay(t *testing.T) { + c, err := Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil) + if err != nil { + t.Fatal(err) + } + // mid-May → Taurus + if c.Sun.SignKey != "taurus" { + t.Fatalf("want taurus got %s", c.Sun.SignKey) + } +} diff --git a/apps/api/internal/star/synastry/charts.go b/apps/api/internal/star/synastry/charts.go new file mode 100644 index 0000000..822cf69 --- /dev/null +++ b/apps/api/internal/star/synastry/charts.go @@ -0,0 +1,226 @@ +package synastry + +import ( + "fmt" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/star/ephemeris" + "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" +) + +// ChartPack holds all synastry chart modes for one pair. +type ChartPack struct { + Composite natal.Chart + Davison natal.Chart + MarksMe natal.Chart + MarksOther natal.Chart + Overlay OverlayChart + CompositeProgressed natal.Chart + DavisonProgressed natal.Chart + MarksProgressed natal.Chart + AsOf time.Time +} + +// OverlayHouse is B's planet in A's whole-sign house. +type OverlayHouse struct { + PlanetKey string `json:"planet_key"` + Planet string `json:"planet"` + Sign string `json:"sign"` + House int `json:"house"` + HouseTip string `json:"house_tip"` +} + +// OverlayChart is the pairing/overlay table. +type OverlayChart struct { + HousesA []natal.House `json:"houses_a"` + Entries []OverlayHouse `json:"entries"` + Tip string `json:"tip"` +} + +// BuildCharts computes five main charts + three progressed variants. +func BuildCharts(a, b natal.Chart, asOf time.Time) (ChartPack, error) { + if asOf.IsZero() { + asOf = time.Now().In(time.FixedZone("CST", 8*3600)) + } + comp := CompositeChart(a, b) + dav, err := DavisonChart(a, b) + if err != nil { + return ChartPack{}, err + } + marksMe := MarksChart(dav, a) + marksOther := MarksChart(dav, b) + overlay := Overlay(a, b) + + progA, err := ProgressChart(a, asOf) + if err != nil { + return ChartPack{}, err + } + progB, err := ProgressChart(b, asOf) + if err != nil { + return ChartPack{}, err + } + compProg := CompositeChart(progA, progB) + davProg, err := ProgressChart(dav, asOf) + if err != nil { + return ChartPack{}, err + } + marksProg := MarksChart(davProg, progA) + + return ChartPack{ + Composite: comp, Davison: dav, + MarksMe: marksMe, MarksOther: marksOther, + Overlay: overlay, + CompositeProgressed: compProg, + DavisonProgressed: davProg, + MarksProgressed: marksProg, + AsOf: asOf, + }, nil +} + +// CompositeChart midpoints each body longitude (shortest arc); ASC mid of rises. +func CompositeChart(a, b natal.Chart) natal.Chart { + lons := map[string]float64{} + for _, k := range ephemeris.PlanetOrder { + lons[k] = natal.MidpointLon(natal.BodyLon(a, k), natal.BodyLon(b, k)) + } + asc := natal.MidpointLon(a.Rise.Lon, b.Rise.Lon) + lat := (a.Lat + b.Lat) / 2 + lng := midLng(a.Lng, b.Lng) + ch := natal.ChartFromLons(lons, asc, lat, lng, "组合盘", a.HasTime && b.HasTime, a.HasPlace || b.HasPlace) + ch.Instant = midTime(a.Instant, b.Instant) + ch.Note = "组合盘:双方行星黄经中点,看关系整体气质。" + return ch +} + +// DavisonChart uses midpoint birth time + midpoint coordinates, then recomputes. +func DavisonChart(a, b natal.Chart) (natal.Chart, error) { + inst := midTime(a.Instant, b.Instant) + lat := (a.Lat + b.Lat) / 2 + lng := midLng(a.Lng, b.Lng) + ch, err := natal.ComputeAt(inst, lat, lng, "时空盘", true, true) + if err != nil { + return natal.Chart{}, err + } + ch.Note = "时空盘:出生时刻与坐标中点再排盘,看长期现实走向。" + return ch, nil +} + +// MarksChart midpoints each Davison body with a natal chart (我/TA 视角). +func MarksChart(davison, natalCh natal.Chart) natal.Chart { + lons := map[string]float64{} + for _, k := range ephemeris.PlanetOrder { + lons[k] = natal.MidpointLon(natal.BodyLon(davison, k), natal.BodyLon(natalCh, k)) + } + asc := natal.MidpointLon(davison.Rise.Lon, natalCh.Rise.Lon) + ch := natal.ChartFromLons(lons, asc, natalCh.Lat, natalCh.Lng, "马克斯盘", true, true) + ch.Instant = midTime(davison.Instant, natalCh.Instant) + ch.Note = "马克斯盘:时空盘与本命中点,看关系中的内在态度。" + return ch +} + +// Overlay maps B planets into A's whole-sign houses. +func Overlay(a, b natal.Chart) OverlayChart { + ascIdx := natal.SignIndex(a.Rise.Lon) + tips := houseTips() + entries := make([]OverlayHouse, 0, len(b.Planets)) + for _, p := range b.Planets { + if p.Key == "rise" { + continue + } + h := natal.WholeSignHouse(ascIdx, natal.SignIndex(p.Lon)) + tip := tips[h] + entries = append(entries, OverlayHouse{ + PlanetKey: p.Key, Planet: p.Title, Sign: p.Sign, House: h, HouseTip: tip, + }) + } + return OverlayChart{ + HousesA: a.Houses, + Entries: entries, + Tip: "配对盘:对方行星落入我方整宫制宫位,提示生活领域互动。", + } +} + +// ProgressChart applies secondary progression (1 day ≈ 1 year) from chart.Instant to asOf. +func ProgressChart(ch natal.Chart, asOf time.Time) (natal.Chart, error) { + if ch.Instant.IsZero() { + return ch, nil + } + years := asOf.UTC().Sub(ch.Instant).Hours() / 24.0 / 365.24219 + if years < 0 { + years = 0 + } + // Add whole days to avoid time.Duration overflow/precision issues for large ages. + days := int(years + 0.5) + progUTC := ch.Instant.AddDate(0, 0, days) + out, err := natal.ComputeAt(progUTC, ch.Lat, ch.Lng, ch.PlaceLabel+"·次限", ch.HasTime, ch.HasPlace) + if err != nil { + return natal.Chart{}, err + } + out.Note = fmt.Sprintf("次限推运至 %s(约 %.1f 年)。", asOf.Format("2006-01-02"), years) + return out, nil +} + +func midTime(a, b time.Time) time.Time { + if a.IsZero() && b.IsZero() { + return time.Time{} + } + if a.IsZero() { + return b + } + if b.IsZero() { + return a + } + return a.Add(b.Sub(a) / 2) +} + +func midLng(a, b float64) float64 { + // Geographic mean (CN MVP; both births typically East Asia). + return (a + b) / 2 +} + +func houseTips() map[int]string { + return map[int]string{ + 1: "自我与第一印象", 2: "资源与安全感", 3: "沟通与日常", 4: "家庭与根基", + 5: "恋爱表达与创造", 6: "协作与健康节奏", 7: "一对一关系", 8: "深度与共享", + 9: "视野与信念", 10: "事业与对外形象", 11: "社群与愿景", 12: "内在与疗愈", + } +} + +func chartSummaryOut(c natal.Chart, tip string) map[string]any { + asp := natal.Aspects(c) + aspMaps := natal.AspectsAsMaps(asp) + previewN := 4 + if len(aspMaps) < previewN { + previewN = len(aspMaps) + } + t := tip + if t == "" { + t = c.Note + } + return map[string]any{ + "asc_lon": c.Rise.Lon, + "planets": bodiesOut(c), + "houses": housesOut(c), + "note": c.Note, + "place": c.PlaceLabel, + "tip": t, + "aspects_preview": aspMaps[:previewN], + "sun": c.Sun.Sign, + "moon": c.Moon.Sign, + "rise": c.Rise.Sign, + } +} + +func overlayOut(o OverlayChart) map[string]any { + entries := make([]map[string]any, 0, len(o.Entries)) + for _, e := range o.Entries { + entries = append(entries, map[string]any{ + "planet_key": e.PlanetKey, "planet": e.Planet, "sign": e.Sign, + "house": e.House, "house_tip": e.HouseTip, + }) + } + return map[string]any{ + "tip": o.Tip, "entries": entries, "houses_a": housesOut(natal.Chart{Houses: o.HousesA}), + } +} + diff --git a/apps/api/internal/star/synastry/report.go b/apps/api/internal/star/synastry/report.go new file mode 100644 index 0000000..daf05bf --- /dev/null +++ b/apps/api/internal/star/synastry/report.go @@ -0,0 +1,275 @@ +package synastry + +import ( + "fmt" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" +) + +// Report is free summary + gated detail for a synastry GrowthReport. +type Report struct { + Summary map[string]any + Detail map[string]any +} + +// BuildReport builds multi-chart synastry content (五主盘 + 三推运). +func BuildReport(a, b natal.Chart, aName, bName string, asOf time.Time) (Report, error) { + if aName == "" { + aName = "我" + } + if bName == "" { + bName = "TA" + } + if asOf.IsZero() { + asOf = time.Now().In(time.FixedZone("CST", 8*3600)) + } + idx := Compute(a, b, aName, bName) + cross := CrossAspects(a, b) + crossMaps := natal.AspectsAsMaps(cross) + previewN := 4 + if len(crossMaps) < previewN { + previewN = len(crossMaps) + } + + pack, err := BuildCharts(a, b, asOf) + if err != nil { + return Report{}, err + } + asOfStr := asOf.Format("2006-01-02") + + chartsSummary := map[string]any{ + "compare": map[string]any{ + "tip": "比较盘:双方叠合,看互动与吸引。", + "chart_a": map[string]any{ + "asc_lon": a.Rise.Lon, "planets": bodiesOut(a), + "houses": housesOut(a), "note": a.Note, "place": a.PlaceLabel, + "sun": a.Sun.Sign, "moon": a.Moon.Sign, "rise": a.Rise.Sign, + }, + "chart_b": map[string]any{ + "asc_lon": b.Rise.Lon, "planets": bodiesOut(b), + "houses": housesOut(b), "note": b.Note, "place": b.PlaceLabel, + "sun": b.Sun.Sign, "moon": b.Moon.Sign, "rise": b.Rise.Sign, + }, + "aspects_preview": crossMaps[:previewN], + }, + "composite": chartSummaryOut(pack.Composite, "组合盘:关系整体气质。"), + "davison": chartSummaryOut(pack.Davison, "时空盘:长期现实走向。"), + "marks_me": chartSummaryOut(pack.MarksMe, "马克斯盘·我:我对这段关系的内在态度。"), + "marks_other": chartSummaryOut(pack.MarksOther, "马克斯盘·TA:TA对这段关系的内在态度。"), + "overlay": overlayOut(pack.Overlay), + "composite_progressed": chartSummaryOut(pack.CompositeProgressed, "组合次限:关系当下成长态。"), + "davison_progressed": chartSummaryOut(pack.DavisonProgressed, "时空次限:现实课题的阶段性。"), + "marks_progressed": chartSummaryOut(pack.MarksProgressed, "马盘推运:我对关系的阶段心态。"), + } + + summary := map[string]any{ + "title": "合盘·基础", + "headline": idx.Summary, + "one_liner": fmt.Sprintf("%s与%s:五盘合观互动、气质与现实节奏。", aName, bName), + "overview": fmt.Sprintf("%s(日%s月%s升%s)× %s(日%s月%s升%s)。", aName, a.Sun.Sign, a.Moon.Sign, a.Rise.Sign, bName, b.Sun.Sign, b.Moon.Sign, b.Rise.Sign), + "me_name": aName, + "other_name": bName, + "love_index": idx.Love, + "friend_index": idx.Friend, + "marriage_index": idx.Marriage, + "match_indices": idx.AsMap(), + "love_note": idx.LoveNote, + "friend_note": idx.FriendNote, + "marriage_note": idx.MarriageNote, + "as_of": asOfStr, + // backward-compat top-level compare charts + "chart_a": map[string]any{ + "asc_lon": a.Rise.Lon, "planets": bodiesOut(a), + "houses": housesOut(a), "note": a.Note, "place": a.PlaceLabel, + }, + "chart_b": map[string]any{ + "asc_lon": b.Rise.Lon, "planets": bodiesOut(b), + "houses": housesOut(b), "note": b.Note, "place": b.PlaceLabel, + }, + "aspects_preview": crossMaps[:previewN], + "charts": chartsSummary, + "keywords": []string{"合盘", "比较盘", "组合盘", "时空盘", fmt.Sprintf("恋爱%d", idx.Love)}, + "strengths_preview": []string{ + fmt.Sprintf("恋爱指数 %d", idx.Love), + fmt.Sprintf("友情指数 %d", idx.Friend), + fmt.Sprintf("婚姻指数 %d", idx.Marriage), + }, + "blind_spots_preview": []string{"完整相位、推运深文案与落宫解读见深度版。"}, + } + + bullets := make([]string, 0, min(10, len(cross))) + for i, c := range cross { + if i >= 10 { + break + } + bullets = append(bullets, c.Label) + } + + compAsp := natal.AspectsAsMaps(natal.Aspects(pack.Composite)) + davAsp := natal.AspectsAsMaps(natal.Aspects(pack.Davison)) + progAsp := natal.AspectsAsMaps(natal.Aspects(pack.CompositeProgressed)) + + detail := map[string]any{ + "title": "合盘·完整分析", + "as_of": asOfStr, + "aspects": crossMaps, + "charts": map[string]any{ + "compare": map[string]any{"aspects": crossMaps}, + "composite": map[string]any{"aspects": compAsp, "planets": bodiesOut(pack.Composite)}, + "davison": map[string]any{"aspects": davAsp, "planets": bodiesOut(pack.Davison)}, + "marks_me": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksMe)), "planets": bodiesOut(pack.MarksMe)}, + "marks_other": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksOther)), "planets": bodiesOut(pack.MarksOther)}, + "overlay": overlayOut(pack.Overlay), + "composite_progressed": map[string]any{"aspects": progAsp, "planets": bodiesOut(pack.CompositeProgressed), "note": pack.CompositeProgressed.Note}, + "davison_progressed": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.DavisonProgressed)), "planets": bodiesOut(pack.DavisonProgressed), "note": pack.DavisonProgressed.Note}, + "marks_progressed": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksProgressed)), "planets": bodiesOut(pack.MarksProgressed), "note": pack.MarksProgressed.Note}, + }, + "sections": []map[string]any{ + {"title": "恋爱互动", "body": idx.LoveNote, "bullets": []string{"说清需要比猜测更有效", "张力相位日宜慢半拍回应"}}, + {"title": "友情节奏", "body": idx.FriendNote, "bullets": []string{"共同兴趣维系轻松感", "尊重彼此独处需求"}}, + {"title": "长期相处", "body": idx.MarriageNote, "bullets": []string{"共同节奏与边界同等重要", "把期待写成说明书"}}, + {"title": "跨盘相位", "body": "比较盘相位描述双方能量如何彼此激活或拉扯。", "bullets": bullets}, + {"title": "组合与时空", "body": "组合盘看关系气质,时空盘看共同现实课题;次限呈现阶段性成长。", "bullets": []string{ + fmt.Sprintf("组合盘日%s月%s升%s", pack.Composite.Sun.Sign, pack.Composite.Moon.Sign, pack.Composite.Rise.Sign), + fmt.Sprintf("时空盘日%s月%s升%s", pack.Davison.Sun.Sign, pack.Davison.Moon.Sign, pack.Davison.Rise.Sign), + fmt.Sprintf("推运日期 %s", asOfStr), + }}, + {"title": "配对落宫", "body": pack.Overlay.Tip, "bullets": overlayBullets(pack.Overlay)}, + }, + "growth_plan": []map[string]any{ + {"phase": "本周", "focus": "约一次轻松同步:各说一件欣赏与一件需要。"}, + {"phase": "本月", "focus": "为高频摩擦点写一条可执行约定。"}, + {"phase": "长期", "focus": "每季度复盘三指数变化与相处舒适度。"}, + }, + "conversation_scripts": []string{ + "我想听听你怎么看我们的节奏,不急着定结论。", + "我需要一点空间整理,不是拒绝连接。", + "我们共同目标是……,这一步只做一件事。", + }, + "behavior_pattern": idx.LoveNote, + "relation_style": idx.FriendNote, + "growth_direction": idx.MarriageNote, + } + return Report{Summary: summary, Detail: detail}, nil +} + +func overlayBullets(o OverlayChart) []string { + out := make([]string, 0, 6) + for i, e := range o.Entries { + if i >= 6 { + break + } + if e.PlanetKey == "sun" || e.PlanetKey == "moon" || e.PlanetKey == "venus" || e.PlanetKey == "mars" { + out = append(out, fmt.Sprintf("%s在我方%d宫(%s)", e.Planet, e.House, e.HouseTip)) + } + } + if len(out) == 0 && len(o.Entries) > 0 { + e := o.Entries[0] + out = append(out, fmt.Sprintf("%s在我方%d宫", e.Planet, e.House)) + } + return out +} + +// CrossAspects finds aspects between chart A bodies and chart B bodies. +func CrossAspects(a, b natal.Chart) []natal.Aspect { + keys := []string{"sun", "moon", "rise", "mercury", "venus", "mars", "jupiter", "saturn"} + byA := map[string]natal.Body{} + byB := map[string]natal.Body{} + for _, p := range a.Planets { + byA[p.Key] = p + } + for _, p := range b.Planets { + byB[p.Key] = p + } + defs := []struct { + Type string + Angle float64 + Orb float64 + Label string + }{ + {"conjunction", 0, 8, "合相"}, + {"sextile", 60, 6, "六合"}, + {"square", 90, 7, "刑相"}, + {"trine", 120, 7, "拱相"}, + {"opposition", 180, 8, "对冲"}, + } + out := make([]natal.Aspect, 0) + for _, ka := range keys { + ba, oka := byA[ka] + if !oka { + continue + } + for _, kb := range keys { + bb, okb := byB[kb] + if !okb { + continue + } + diff := natal.AngleDiff(ba.Lon, bb.Lon) + for _, def := range defs { + orb := absF(diff - def.Angle) + if orb <= def.Orb { + out = append(out, natal.Aspect{ + A: "a:" + ba.Key, B: "b:" + bb.Key, + ATitle: aNameTitle(ba.Title), BTitle: bNameTitle(bb.Title), + Type: def.Type, Angle: def.Angle, Orb: round1(orb), + Label: fmt.Sprintf("我方%s%s对方%s(容许%.1f°)", ba.Title, def.Label, bb.Title, orb), + }) + break + } + } + } + } + for i := 0; i < len(out); i++ { + for j := i + 1; j < len(out); j++ { + if out[j].Orb < out[i].Orb { + out[i], out[j] = out[j], out[i] + } + } + } + if len(out) > 24 { + out = out[:24] + } + return out +} + +func aNameTitle(t string) string { return "我·" + t } +func bNameTitle(t string) string { return "TA·" + t } + +func bodiesOut(c natal.Chart) []map[string]any { + out := make([]map[string]any, 0, len(c.Planets)) + for _, p := range c.Planets { + out = append(out, map[string]any{ + "key": p.Key, "title": p.Title, "sign": p.Sign, "sign_key": p.SignKey, + "degree": fmt.Sprintf("%.1f°", p.Degree), "house": p.House, + "lon": p.Lon, "element": p.Element, "modality": p.Modality, + }) + } + return out +} + +func housesOut(c natal.Chart) []map[string]any { + out := make([]map[string]any, 0, len(c.Houses)) + for _, h := range c.Houses { + out = append(out, map[string]any{"num": h.Num, "sign": h.Sign, "sign_key": h.Key}) + } + return out +} + +func absF(x float64) float64 { + if x < 0 { + return -x + } + return x +} + +func round1(x float64) float64 { + return float64(int(x*10+0.5)) / 10 +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/apps/api/internal/star/synastry/report_test.go b/apps/api/internal/star/synastry/report_test.go new file mode 100644 index 0000000..244b4c5 --- /dev/null +++ b/apps/api/internal/star/synastry/report_test.go @@ -0,0 +1,65 @@ +package synastry + +import ( + "testing" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" +) + +func TestBuildReport(t *testing.T) { + a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("12:00"), strPtr("北京")) + if err != nil { + t.Fatal(err) + } + b, err := natal.Compute(time.Date(1992, 8, 20, 0, 0, 0, 0, time.UTC), strPtr("08:00"), strPtr("上海")) + if err != nil { + t.Fatal(err) + } + asOf := time.Date(2026, 8, 2, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)) + rep, err := BuildReport(a, b, "我", "TA", asOf) + if err != nil { + t.Fatal(err) + } + if rep.Summary["love_index"] == nil || rep.Summary["chart_a"] == nil { + t.Fatalf("summary incomplete: %#v", rep.Summary) + } + charts, ok := rep.Summary["charts"].(map[string]any) + if !ok { + t.Fatal("charts missing") + } + for _, k := range []string{"compare", "composite", "davison", "marks_me", "marks_other", "overlay", + "composite_progressed", "davison_progressed", "marks_progressed"} { + if charts[k] == nil { + t.Fatalf("missing chart key %s", k) + } + } + if rep.Summary["as_of"] != "2026-08-02" { + t.Fatalf("as_of=%v", rep.Summary["as_of"]) + } + if rep.Detail["aspects"] == nil { + t.Fatal("detail aspects missing") + } + cross := CrossAspects(a, b) + if len(cross) == 0 { + t.Fatal("expected cross aspects") + } +} + +func TestCompositeDeterministic(t *testing.T) { + a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("10:30"), strPtr("上海")) + if err != nil { + t.Fatal(err) + } + b, err := natal.Compute(time.Date(1992, 8, 20, 0, 0, 0, 0, time.UTC), strPtr("08:00"), strPtr("北京")) + if err != nil { + t.Fatal(err) + } + c1 := CompositeChart(a, b) + c2 := CompositeChart(a, b) + if c1.Sun.Lon != c2.Sun.Lon || c1.Rise.Sign != c2.Rise.Sign { + t.Fatal("composite not deterministic") + } +} + +func strPtr(s string) *string { return &s } diff --git a/apps/api/internal/star/synastry/synastry.go b/apps/api/internal/star/synastry/synastry.go new file mode 100644 index 0000000..13f8fde --- /dev/null +++ b/apps/api/internal/star/synastry/synastry.go @@ -0,0 +1,133 @@ +// Package synastry computes love/friend/marriage match indices from two natal charts. +package synastry + +import ( + "fmt" + + "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" +) + +// Indices are 0–100 match scores. +type Indices struct { + Love int `json:"love_index"` + Friend int `json:"friend_index"` + Marriage int `json:"marriage_index"` + LoveNote string `json:"love_note"` + FriendNote string `json:"friend_note"` + MarriageNote string `json:"marriage_note"` + Summary string `json:"summary"` +} + +// Compute returns match indices for charts A and B. +func Compute(a, b natal.Chart, aName, bName string) Indices { + if aName == "" { + aName = "我" + } + if bName == "" { + bName = "TA" + } + sunDiff := natal.AngleDiff(a.Sun.Lon, b.Sun.Lon) + moonDiff := natal.AngleDiff(a.Moon.Lon, b.Moon.Lon) + venA := bodyLon(a, "venus") + venB := bodyLon(b, "venus") + marsA := bodyLon(a, "mars") + marsB := bodyLon(b, "mars") + venDiff := natal.AngleDiff(venA, venB) + marsVen := natal.AngleDiff(marsA, venB) + venMars := natal.AngleDiff(venA, marsB) + + // Closer aspects (0/60/120 soft; 90/180 hard but charged) → higher score blended + love := clamp(58+aspectBoost(sunDiff)+aspectBoost(moonDiff)+aspectBoost(venDiff)+aspectBoost(marsVen)/2+aspectBoost(venMars)/2, 42, 98) + friend := clamp(60+aspectBoost(sunDiff)+aspectBoost(moonDiff)*3/2+elemBoost(a.Sun, b.Sun), 45, 97) + marriage := clamp(55+aspectBoost(sunDiff)+aspectBoost(moonDiff)+aspectBoost(venDiff)+houseBoost(a, b), 40, 96) + + return Indices{ + Love: love, Friend: friend, Marriage: marriage, + LoveNote: fmt.Sprintf("%s与%s恋爱指数 %d:太阳相距约%.0f°,金星互动影响吸引力。", aName, bName, love, sunDiff), + FriendNote: fmt.Sprintf("友情指数 %d:月亮与太阳元素是否合拍,决定轻松感。", friend), + MarriageNote: fmt.Sprintf("婚姻/长期指数 %d:看重稳定与共同节奏,而非一时火花。", marriage), + Summary: fmt.Sprintf("%s × %s:恋爱 %d · 友情 %d · 婚姻 %d", aName, bName, love, friend, marriage), + } +} + +func bodyLon(c natal.Chart, key string) float64 { + for _, p := range c.Planets { + if p.Key == key { + return p.Lon + } + } + return 0 +} + +func aspectBoost(diff float64) int { + // reward conjunction, sextile, trine; mild for square/opposition + targets := []float64{0, 60, 90, 120, 180} + best := 180.0 + for _, t := range targets { + d := abs(diff - t) + if d < best { + best = d + } + } + switch { + case best <= 8: + if near(diff, 90) || near(diff, 180) { + return 8 + } + return 14 + case best <= 12: + return 8 + default: + return int(6 - best/30) + } +} + +func near(diff, target float64) bool { + return abs(diff-target) <= 10 +} + +func elemBoost(a, b natal.Body) int { + if a.Element == b.Element { + return 6 + } + // fire-air / earth-water traditionally supportive + pair := a.Element + b.Element + if pair == "火风" || pair == "风火" || pair == "土水" || pair == "水土" { + return 4 + } + return 0 +} + +func houseBoost(a, b natal.Chart) int { + // same rising modality → slight boost + if a.Rise.Modality == b.Rise.Modality { + return 4 + } + return 0 +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +} + +// AsMap for JSON. +func (i Indices) AsMap() map[string]any { + return map[string]any{ + "love_index": i.Love, "friend_index": i.Friend, "marriage_index": i.Marriage, + "love_note": i.LoveNote, "friend_note": i.FriendNote, "marriage_note": i.MarriageNote, + "summary": i.Summary, + } +} diff --git a/apps/api/internal/star/synastry/synastry_test.go b/apps/api/internal/star/synastry/synastry_test.go new file mode 100644 index 0000000..5c8431c --- /dev/null +++ b/apps/api/internal/star/synastry/synastry_test.go @@ -0,0 +1,26 @@ +package synastry + +import ( + "testing" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" +) + +func TestComputeIndices(t *testing.T) { + a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil) + if err != nil { + t.Fatal(err) + } + b, err := natal.Compute(time.Date(1992, 8, 1, 0, 0, 0, 0, time.UTC), nil, nil) + if err != nil { + t.Fatal(err) + } + idx := Compute(a, b, "我", "TA") + if idx.Love < 40 || idx.Friend < 40 || idx.Marriage < 40 { + t.Fatalf("scores too low: %+v", idx) + } + if idx.Summary == "" { + t.Fatal("empty summary") + } +} diff --git a/apps/api/migrations/000004_ask.down.sql b/apps/api/migrations/000004_ask.down.sql new file mode 100644 index 0000000..c004257 --- /dev/null +++ b/apps/api/migrations/000004_ask.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS ask_messages; +DROP TABLE IF EXISTS ask_threads; diff --git a/apps/api/migrations/000004_ask.up.sql b/apps/api/migrations/000004_ask.up.sql new file mode 100644 index 0000000..ce1d045 --- /dev/null +++ b/apps/api/migrations/000004_ask.up.sql @@ -0,0 +1,23 @@ +-- Ask threads & messages (AI 成长助手) + +CREATE TABLE IF NOT EXISTS ask_threads ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + profile_id uuid NOT NULL REFERENCES profiles(id), + scene varchar(32) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_ask_threads_user_id ON ask_threads(user_id); +CREATE INDEX IF NOT EXISTS idx_ask_threads_profile_id ON ask_threads(profile_id); + +CREATE TABLE IF NOT EXISTS ask_messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + thread_id uuid NOT NULL REFERENCES ask_threads(id), + role varchar(16) NOT NULL CHECK (role IN ('user', 'assistant')), + content text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_ask_messages_thread_id ON ask_messages(thread_id); diff --git a/apps/api/migrations/000005_emotion_scale.down.sql b/apps/api/migrations/000005_emotion_scale.down.sql new file mode 100644 index 0000000..bcc4313 --- /dev/null +++ b/apps/api/migrations/000005_emotion_scale.down.sql @@ -0,0 +1,2 @@ +DELETE FROM scale_questions WHERE scale_id = '22222222-2222-2222-2222-222222222222'; +DELETE FROM scales WHERE id = '22222222-2222-2222-2222-222222222222'; diff --git a/apps/api/migrations/000005_emotion_scale.up.sql b/apps/api/migrations/000005_emotion_scale.up.sql new file mode 100644 index 0000000..2c34601 --- /dev/null +++ b/apps/api/migrations/000005_emotion_scale.up.sql @@ -0,0 +1,30 @@ +-- Second 探索测试: emotion pattern +INSERT INTO scales (id, slug, title, description, status) +VALUES ( + '22222222-2222-2222-2222-222222222222', + 'emotion-pattern', + '情感模式探索', + '了解你在情绪起伏时的调节偏好,生成探索结果标签。', + 'published' +) ON CONFLICT (slug) DO NOTHING; + +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222222', 1, + '{"prompt":"情绪上来时,你通常先做什么?","options":[{"key":"A","text":"说出感受,让对方知道"},{"key":"B","text":"先让自己平静下来"},{"key":"C","text":"去做一件能消耗精力的事"}]}' +WHERE NOT EXISTS ( + SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222222' AND sort=1 AND deleted_at IS NULL +); + +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222222', 2, + '{"prompt":"压力很大的一周,什么最能帮到你?","options":[{"key":"A","text":"有人认真听你说完"},{"key":"B","text":"稳定的作息与熟悉的节奏"},{"key":"C","text":"把事情拆开并行动起来"}]}' +WHERE NOT EXISTS ( + SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222222' AND sort=2 AND deleted_at IS NULL +); + +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222222', 3, + '{"prompt":"和亲近的人冷战时,你更倾向?","options":[{"key":"A","text":"主动表达委屈与需要"},{"key":"B","text":"先降温,等双方都稳住"},{"key":"C","text":"用具体行动修复关系"}]}' +WHERE NOT EXISTS ( + SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222222' AND sort=3 AND deleted_at IS NULL +); diff --git a/apps/api/migrations/000006_companion_p2.down.sql b/apps/api/migrations/000006_companion_p2.down.sql new file mode 100644 index 0000000..d787949 --- /dev/null +++ b/apps/api/migrations/000006_companion_p2.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS image_card_quotas; +DROP TABLE IF EXISTS moods; diff --git a/apps/api/migrations/000006_companion_p2.up.sql b/apps/api/migrations/000006_companion_p2.up.sql new file mode 100644 index 0000000..a224d2c --- /dev/null +++ b/apps/api/migrations/000006_companion_p2.up.sql @@ -0,0 +1,21 @@ +-- Companion moods + (optional) image-card daily quota counter + +CREATE TABLE IF NOT EXISTS moods ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + day date NOT NULL, + score int NULL CHECK (score IS NULL OR (score >= 1 AND score <= 5)), + note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + UNIQUE (user_id, day) +); +CREATE INDEX IF NOT EXISTS idx_moods_user_id ON moods(user_id); + +CREATE TABLE IF NOT EXISTS image_card_quotas ( + user_id uuid NOT NULL REFERENCES users(id), + day date NOT NULL, + used int NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, day) +); diff --git a/apps/api/migrations/000007_explore_full.down.sql b/apps/api/migrations/000007_explore_full.down.sql new file mode 100644 index 0000000..2d68bcd --- /dev/null +++ b/apps/api/migrations/000007_explore_full.down.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS favorites; +DROP TABLE IF EXISTS growth_checkins; +DROP TABLE IF EXISTS growth_plans; +-- scale seeds left in place (safe); delete by id if rolling back hard: +-- DELETE FROM scale_questions WHERE scale_id LIKE '22222222%'; +-- DELETE FROM scales WHERE id LIKE '22222222%'; diff --git a/apps/api/migrations/000007_explore_full.up.sql b/apps/api/migrations/000007_explore_full.up.sql new file mode 100644 index 0000000..b1424a7 --- /dev/null +++ b/apps/api/migrations/000007_explore_full.up.sql @@ -0,0 +1,115 @@ +-- Growth plans + favorites; scale matrix seeds (≥8 official scales) + +CREATE TABLE IF NOT EXISTS growth_plans ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + title text NOT NULL, + focus text NOT NULL DEFAULT '', + status varchar(32) NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_growth_plans_user_id ON growth_plans(user_id); + +CREATE TABLE IF NOT EXISTS growth_checkins ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + plan_id uuid NOT NULL REFERENCES growth_plans(id), + user_id uuid NOT NULL REFERENCES users(id), + day date NOT NULL, + note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (plan_id, day) +); +CREATE INDEX IF NOT EXISTS idx_growth_checkins_user_id ON growth_checkins(user_id); + +CREATE TABLE IF NOT EXISTS favorites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + kind varchar(32) NOT NULL, + ref_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, kind, ref_id) +); + +-- Helper: insert scale if missing (ids fixed for idempotency) +INSERT INTO scales (id, slug, title, description, status) VALUES +('22222222-2222-2222-2222-222222222201', 'mbti-lite', '人格类型探索', '了解能量来源与决策偏好的轻量探索。', 'published'), +('22222222-2222-2222-2222-222222222202', 'enneagram-lite', '动机模式探索', '觉察内在动机与压力反应。', 'published'), +('22222222-2222-2222-2222-222222222203', 'bigfive-lite', '性格五维探索', '稳定特质的自我速览。', 'published'), +('22222222-2222-2222-2222-222222222204', 'love-style', '亲密互动探索', '亲密关系中的表达与边界偏好。', 'published'), +('22222222-2222-2222-2222-222222222205', 'eq-lite', '情绪觉察探索', '识别与调节情绪的习惯。', 'published'), +('22222222-2222-2222-2222-222222222206', 'stress-index', '压力负荷探索', '近期压力与恢复方式。', 'published'), +('22222222-2222-2222-2222-222222222207', 'career-interest', '职业兴趣探索', '工作动力与环境偏好。', 'published') +ON CONFLICT (slug) DO NOTHING; + +-- Questions: 3 each (MVP); scoring handled by existing engine via option keys +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222201', 1, '{"prompt":"周末充电,你更想?","options":[{"key":"E","text":"和朋友见面聊天"},{"key":"I","text":"独处安静恢复"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222201' AND sort=1); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222201', 2, '{"prompt":"做决定时你更看重?","options":[{"key":"T","text":"逻辑与公平"},{"key":"F","text":"感受与关系"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222201' AND sort=2); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222201', 3, '{"prompt":"面对计划,你更倾向?","options":[{"key":"J","text":"提前安排清楚"},{"key":"P","text":"保持弹性随机"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222201' AND sort=3); + +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222202', 1, '{"prompt":"压力大时你最先?","options":[{"key":"A","text":"想把事情做对"},{"key":"B","text":"想被理解和支持"},{"key":"C","text":"想先躲开冲突"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222202' AND sort=1); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222202', 2, '{"prompt":"你最在意别人怎么看你?","options":[{"key":"A","text":"是否可靠靠谱"},{"key":"B","text":"是否温暖有情"},{"key":"C","text":"是否独特有想法"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222202' AND sort=2); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222202', 3, '{"prompt":"成长路上你常提醒自己?","options":[{"key":"A","text":"再努力一点就好"},{"key":"B","text":"别忽略自己的感受"},{"key":"C","text":"保持真实就好"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222202' AND sort=3); + +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222203', 1, '{"prompt":"新环境里你通常?","options":[{"key":"O","text":"好奇尝试新事物"},{"key":"C","text":"先观察再行动"},{"key":"E","text":"主动认识新朋友"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222203' AND sort=1); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222203', 2, '{"prompt":"做事风格更接近?","options":[{"key":"C","text":"有条理、按计划"},{"key":"A","text":"随情境灵活调整"},{"key":"N","text":"容易想很多细节"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222203' AND sort=2); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222203', 3, '{"prompt":"相处中你更常?","options":[{"key":"A","text":"温和配合"},{"key":"E","text":"直接表达立场"},{"key":"O","text":"提出新点子"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222203' AND sort=3); + +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222204', 1, '{"prompt":"亲密关系里你更需要?","options":[{"key":"A","text":"稳定陪伴与承诺"},{"key":"B","text":"深度交流与共鸣"},{"key":"C","text":"各自空间与自由"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222204' AND sort=1); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222204', 2, '{"prompt":"发生误解时你会?","options":[{"key":"A","text":"尽快说开"},{"key":"B","text":"先冷静再谈"},{"key":"C","text":"用行动表达关心"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222204' AND sort=2); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222204', 3, '{"prompt":"表达喜欢时你更习惯?","options":[{"key":"A","text":"说出来"},{"key":"B","text":"做具体的事"},{"key":"C","text":"制造共同回忆"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222204' AND sort=3); + +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222205', 1, '{"prompt":"情绪上来时你最先?","options":[{"key":"A","text":"能叫出它的名字"},{"key":"B","text":"想马上做点什么压下去"},{"key":"C","text":"不太确定自己怎么了"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222205' AND sort=1); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222205', 2, '{"prompt":"朋友难过时你更会?","options":[{"key":"A","text":"陪着听"},{"key":"B","text":"给建议"},{"key":"C","text":"一起转移注意"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222205' AND sort=2); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222205', 3, '{"prompt":"调节自己时最有效的是?","options":[{"key":"A","text":"散步或运动"},{"key":"B","text":"写下来或说出来"},{"key":"C","text":"睡一觉再说"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222205' AND sort=3); + +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222206', 1, '{"prompt":"最近一周睡眠?","options":[{"key":"A","text":"大体规律"},{"key":"B","text":"时好时坏"},{"key":"C","text":"经常不足"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222206' AND sort=1); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222206', 2, '{"prompt":"任务堆积时你?","options":[{"key":"A","text":"列清单逐个清"},{"key":"B","text":"先做紧迫的"},{"key":"C","text":"容易拖延焦虑"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222206' AND sort=2); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222206', 3, '{"prompt":"恢复精力最依赖?","options":[{"key":"A","text":"运动与日照"},{"key":"B","text":"社交倾诉"},{"key":"C","text":"独处安静"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222206' AND sort=3); + +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222207', 1, '{"prompt":"理想工作更像?","options":[{"key":"A","text":"创造与表达"},{"key":"B","text":"帮助与支持他人"},{"key":"C","text":"分析与解决问题"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222207' AND sort=1); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222207', 2, '{"prompt":"团队里你自然扮演?","options":[{"key":"A","text":"提出方向的人"},{"key":"B","text":"协调氛围的人"},{"key":"C","text":"把细节落地的人"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222207' AND sort=2); +INSERT INTO scale_questions (scale_id, sort, body) +SELECT '22222222-2222-2222-2222-222222222207', 3, '{"prompt":"职业选择你最在意?","options":[{"key":"A","text":"意义感"},{"key":"B","text":"成长空间"},{"key":"C","text":"稳定与平衡"}]}' +WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222207' AND sort=3); diff --git a/apps/api/migrations/000008_birth_place.down.sql b/apps/api/migrations/000008_birth_place.down.sql new file mode 100644 index 0000000..c0368b7 --- /dev/null +++ b/apps/api/migrations/000008_birth_place.down.sql @@ -0,0 +1,2 @@ +-- no-op: birth_place may pre-exist from 000001 +SELECT 1; diff --git a/apps/api/migrations/000008_birth_place.up.sql b/apps/api/migrations/000008_birth_place.up.sql new file mode 100644 index 0000000..0f49f32 --- /dev/null +++ b/apps/api/migrations/000008_birth_place.up.sql @@ -0,0 +1,2 @@ +-- birth_place already on profiles from init; ensure column exists for older DBs. +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS birth_place varchar(128) NULL; diff --git a/apps/api/migrations/000009_synastry_social.down.sql b/apps/api/migrations/000009_synastry_social.down.sql new file mode 100644 index 0000000..460e8cb --- /dev/null +++ b/apps/api/migrations/000009_synastry_social.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS synastry_invites; +DROP INDEX IF EXISTS idx_profiles_geo_visible; +ALTER TABLE profiles DROP COLUMN IF EXISTS geo_visible; +ALTER TABLE profiles DROP COLUMN IF EXISTS geo_lng; +ALTER TABLE profiles DROP COLUMN IF EXISTS geo_lat; diff --git a/apps/api/migrations/000009_synastry_social.up.sql b/apps/api/migrations/000009_synastry_social.up.sql new file mode 100644 index 0000000..39e3fff --- /dev/null +++ b/apps/api/migrations/000009_synastry_social.up.sql @@ -0,0 +1,22 @@ +-- Synastry social: profile geo + invite tokens + +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS geo_lat double precision NULL; +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS geo_lng double precision NULL; +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS geo_visible boolean NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS synastry_invites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + token varchar(64) NOT NULL UNIQUE, + host_user_id uuid NOT NULL REFERENCES users(id), + host_profile_id uuid NOT NULL REFERENCES profiles(id), + expires_at timestamptz NOT NULL, + guest_user_id uuid NULL REFERENCES users(id), + guest_profile_id uuid NULL REFERENCES profiles(id), + report_id uuid NULL REFERENCES growth_reports(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_synastry_invites_token ON synastry_invites(token); +CREATE INDEX IF NOT EXISTS idx_synastry_invites_host ON synastry_invites(host_user_id); +CREATE INDEX IF NOT EXISTS idx_profiles_geo_visible ON profiles(geo_visible) WHERE geo_visible = true AND deleted_at IS NULL; diff --git a/apps/docs/README.md b/apps/docs/README.md index c4e8e49..78c1073 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -4,12 +4,14 @@ `apps/docs/standards/*` 已废弃,请改读 `.ai/`。 +**工程功能详细设计(Feature Spec)以** [`.ai/product/feature-spec/`](../../.ai/product/feature-spec/README.md) **为准**(规范:[feature-design.md](../../.ai/product/feature-design.md));本目录 PRD 不替代 Spec。 + --- ## 阅读顺序(产品重设计) 1. [`.ai/product/ENGINEERING-FREEZE.md`](../../.ai/product/ENGINEERING-FREEZE.md) — P1 真源索引 -2. [lexicon](../../.ai/product/lexicon.md) · [user-journey](../../.ai/product/user-journey.md) · [feature-map](../../.ai/product/feature-map.md) · [page-tree](../../.ai/product/page-tree.md) +2. [lexicon](../../.ai/product/lexicon.md) · [feature-map](../../.ai/product/feature-map.md) · [feature-design](../../.ai/product/feature-design.md) · [feature-spec](../../.ai/product/feature-spec/README.md) · [user-journey](../../.ai/product/user-journey.md) · [page-tree](../../.ai/product/page-tree.md) 3. [prd-mvp.md](prd-mvp.md) · [business-model.md](business-model.md) · [product-roadmap.md](product-roadmap.md) 4. 工程:[erd](../../.ai/domain/erd.md) · [openapi](../../proto/openapi.yaml) · [go-services](../../.ai/architecture/go-services.md) diff --git a/apps/docs/analysis/cece-teardown.md b/apps/docs/analysis/cece-teardown.md index 0747a8a..e94b2f2 100644 --- a/apps/docs/analysis/cece-teardown.md +++ b/apps/docs/analysis/cece-teardown.md @@ -1,7 +1,8 @@ # 竞品拆解:测测 → 愈心谷映射 来源:[测测 App 深度体验报告(人人都是产品经理)](https://www.woshipm.com/evaluating/6391148.html) -下游:[语言契约](../../../.ai/product/lexicon.md) · [Feature Map](../../../.ai/product/feature-map.md) · [PRD](../prd-mvp.md) +下游:[语言契约](../../../.ai/product/lexicon.md) · [Feature Map](../../../.ai/product/feature-map.md) · [PRD](../prd-mvp.md) +**前端全量逆向(STEP 1–18):** [cece-frontend-re](../../../.ai/product/feature-spec/cece-frontend-re/README.md) 原则:学**功能模型 / 路径 / 商业**,不抄品牌词与运势叙事。愈心谷 UI 禁用「解码/运势/合盘/吉凶」等,见 lexicon。 本文可保留竞品原名;落地命名以愈心谷 lexicon 为准。 diff --git a/apps/user-h5/.env.example b/apps/user-h5/.env.example new file mode 100644 index 0000000..8750a6b --- /dev/null +++ b/apps/user-h5/.env.example @@ -0,0 +1,6 @@ +# GA4 Measurement ID(留空则埋点 no-op,应用仍可用) +# 静态站历史示例:G-LVVXH3TL04 +VITE_GA_MEASUREMENT_ID= + +# 开发时在控制台打印 [analytics] 日志:设为 1 +VITE_ANALYTICS_DEBUG=0 diff --git a/apps/user-h5/e2e/p2-explore-paths.spec.ts b/apps/user-h5/e2e/p2-explore-paths.spec.ts new file mode 100644 index 0000000..405bb35 --- /dev/null +++ b/apps/user-h5/e2e/p2-explore-paths.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test' + +function ok(data: unknown) { + return { + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, message: 'success', data }), + headers: { 'X-Device-Key': 'e2e-device' }, + } +} + +test('star / rhythm / cards pages render with mocked API', async ({ page }) => { + await page.route('**/api/v1/**', async (route) => { + const url = route.request().url() + const method = route.request().method() + + if (url.includes('/profiles') && method === 'POST') { + await route.fulfill(ok({ + id: 'prof-e2e', + user_id: 'u1', + relation: 'self', + display_name: '我', + birth_date: '1983-06-06', + created_at: new Date().toISOString(), + })) + return + } + if (url.includes('/reports/star') && method === 'POST') { + await route.fulfill(ok({ + id: 'rep-star', + type: 'star', + has_deep_access: false, + summary: { headline: 'E2E星象', one_liner: '星象摘要', keywords: ['灵活'] }, + detail: null, + created_at: new Date().toISOString(), + })) + return + } + if (url.includes('/reports/rhythm') && method === 'POST') { + await route.fulfill(ok({ + id: 'rep-rhythm', + type: 'rhythm', + has_deep_access: false, + summary: { headline: 'E2E节律', one_liner: '节律摘要' }, + detail: null, + created_at: new Date().toISOString(), + })) + return + } + if (url.includes('/image-cards/scenes')) { + await route.fulfill(ok({ items: [{ key: '情绪整理', label: '情绪整理' }] })) + return + } + if (url.includes('/image-cards/quota')) { + await route.fulfill(ok({ remaining: 3, daily_free: 3, unlimited: false })) + return + } + if (url.includes('/image-cards/draw') && method === 'POST') { + await route.fulfill(ok({ + scene: '情绪整理', + quota_left: 2, + cards: [{ + id: 'c01', title: 'E2E卡片', imagery: '意象', prompt: '反思?', tip: '一小步', + }], + report: { + id: 'rep-card', + type: 'image_card', + has_deep_access: false, + summary: { headline: '意象探索' }, + detail: null, + }, + })) + return + } + if (url.includes('/solar-terms/today')) { + await route.fulfill(ok({ name: '立秋', tip: '收敛节奏', date: '2026-08-02' })) + return + } + if (url.includes('/moods/today')) { + await route.fulfill(ok({ mood: null })) + return + } + await route.fulfill(ok({})) + }) + + await page.goto('/star?y=1983&m=6&d=6') + await expect(page.getByRole('heading', { name: '星象性格' })).toBeVisible() + await expect(page.getByText('E2E星象')).toBeVisible() + await expect(page.getByRole('button', { name: /深度版/ })).toBeVisible() + + await page.goto('/rhythm?y=1992&m=6&d=8') + await expect(page.getByRole('heading', { name: '身心节律' })).toBeVisible() + await expect(page.getByText('E2E节律')).toBeVisible() + + await page.goto('/cards') + await expect(page.getByRole('heading', { name: '意象卡片' })).toBeVisible() + await page.locator('input[type="number"]').nth(0).fill('1990') + await page.locator('input[type="number"]').nth(1).fill('5') + await page.locator('input[type="number"]').nth(2).fill('12') + await page.getByRole('button', { name: '抽取卡片' }).click() + await expect(page.getByText('E2E卡片')).toBeVisible() + + await page.goto('/companion') + await expect(page.getByText('立秋')).toBeVisible() + await expect(page.getByText('今日心情')).toBeVisible() +}) diff --git a/apps/user-h5/e2e/portrait-main-path.spec.ts b/apps/user-h5/e2e/portrait-main-path.spec.ts new file mode 100644 index 0000000..1829c02 --- /dev/null +++ b/apps/user-h5/e2e/portrait-main-path.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test' + +/** L3: Home → Portrait path with mocked API (membership lexicon + deep gate). */ +test('home birth form opens portrait with summary and deep CTA', async ({ page }) => { + await page.route('**/api/v1/**', async (route) => { + const req = route.request() + const url = req.url() + const method = req.method() + + if (url.includes('/profiles') && method === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + message: 'success', + data: { + id: 'prof-e2e', + user_id: 'u1', + relation: 'self', + display_name: '我', + birth_date: '1990-05-12', + created_at: new Date().toISOString(), + }, + }), + headers: { 'X-Device-Key': 'e2e-device' }, + }) + return + } + if (url.includes('/reports/portrait') && method === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + message: 'success', + data: { + id: 'rep-e2e', + has_deep_access: false, + type: 'portrait', + summary: { + headline: 'E2E画像', + one_liner: '模拟摘要', + life_tip: '好好睡觉', + keywords: ['稳'], + }, + detail: null, + created_at: new Date().toISOString(), + }, + }), + }) + return + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, message: 'success', data: {} }), + }) + }) + + await page.goto('/') + await expect(page.getByRole('img', { name: '愈心谷' }).first()).toBeVisible() + await expect(page.getByRole('navigation', { name: '主导航' })).toBeVisible() + await page.goto('/portrait?y=1990&m=5&d=12') + await expect(page.getByRole('img', { name: '愈心谷' }).first()).toBeVisible() + await expect(page.getByRole('navigation', { name: '主导航' })).toBeVisible() + await expect(page.getByRole('heading', { name: '个人画像' })).toBeVisible() + await expect(page.getByText('E2E画像')).toBeVisible() + await expect(page.getByText('模拟摘要')).toBeVisible() + await expect(page.getByRole('button', { name: /深度版/ })).toBeVisible() +}) diff --git a/apps/user-h5/index.html b/apps/user-h5/index.html index aef558c..d79c458 100644 --- a/apps/user-h5/index.html +++ b/apps/user-h5/index.html @@ -4,6 +4,10 @@ 愈心谷 + + + +
diff --git a/apps/user-h5/package.json b/apps/user-h5/package.json index 8b482b4..c1563f4 100644 --- a/apps/user-h5/package.json +++ b/apps/user-h5/package.json @@ -7,21 +7,29 @@ "dev": "vite", "build": "vite build", "typecheck": "vue-tsc --noEmit", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test" }, "dependencies": { "@yuxingu/sdk": "0.1.0", "@yuxingu/types": "0.1.0", "@yuxingu/ui": "0.1.0", "@yuxingu/utils": "0.1.0", + "element-china-area-data": "^6.1.0", "pinia": "^2.3.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", + "jsdom": "^26.0.0", "typescript": "~5.7.2", "vite": "^6.0.7", + "vitest": "^3.0.5", "vue-tsc": "^2.2.0" } } diff --git a/apps/user-h5/playwright.config.ts b/apps/user-h5/playwright.config.ts new file mode 100644 index 0000000..6b89baa --- /dev/null +++ b/apps/user-h5/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * L3 smoke: Vite preview + network-mocked API (no Go required). + * Run: npm run test:e2e -w @yuxingu/user-h5 + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Pixel 7'], + // Optional: PW_CHANNEL=chrome 改用系统 Chrome + ...(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {}), + }, + }, + ], + webServer: { + // Always rebuild so new routes/pages are in dist (reuseExistingServer can hide stale builds). + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: false, + timeout: 120_000, + }, +}) diff --git a/apps/user-h5/public/logo.jpg b/apps/user-h5/public/logo.jpg new file mode 100644 index 0000000..1f9ee0d Binary files /dev/null and b/apps/user-h5/public/logo.jpg differ diff --git a/apps/user-h5/public/logo.png b/apps/user-h5/public/logo.png new file mode 100644 index 0000000..7ec701b Binary files /dev/null and b/apps/user-h5/public/logo.png differ diff --git a/apps/user-h5/src/App.vue b/apps/user-h5/src/App.vue index 564e9d1..71ef708 100644 --- a/apps/user-h5/src/App.vue +++ b/apps/user-h5/src/App.vue @@ -1,15 +1,25 @@ + + diff --git a/apps/user-h5/src/components/AppHeader.vue b/apps/user-h5/src/components/AppHeader.vue new file mode 100644 index 0000000..c4e6396 --- /dev/null +++ b/apps/user-h5/src/components/AppHeader.vue @@ -0,0 +1,29 @@ + + + + + + diff --git a/apps/user-h5/src/components/BackButton.vue b/apps/user-h5/src/components/BackButton.vue new file mode 100644 index 0000000..d6a01da --- /dev/null +++ b/apps/user-h5/src/components/BackButton.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/apps/user-h5/src/components/BirthDateInputs.vue b/apps/user-h5/src/components/BirthDateInputs.vue new file mode 100644 index 0000000..0ac1512 --- /dev/null +++ b/apps/user-h5/src/components/BirthDateInputs.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/apps/user-h5/src/components/BirthPlaceSelect.vue b/apps/user-h5/src/components/BirthPlaceSelect.vue new file mode 100644 index 0000000..4dc54f1 --- /dev/null +++ b/apps/user-h5/src/components/BirthPlaceSelect.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/apps/user-h5/src/components/BrandLogo.vue b/apps/user-h5/src/components/BrandLogo.vue new file mode 100644 index 0000000..9a8bc26 --- /dev/null +++ b/apps/user-h5/src/components/BrandLogo.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/apps/user-h5/src/components/DecodePanel.vue b/apps/user-h5/src/components/DecodePanel.vue new file mode 100644 index 0000000..4ea15b4 --- /dev/null +++ b/apps/user-h5/src/components/DecodePanel.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/apps/user-h5/src/components/MatchCompare.vue b/apps/user-h5/src/components/MatchCompare.vue new file mode 100644 index 0000000..0376aaf --- /dev/null +++ b/apps/user-h5/src/components/MatchCompare.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/apps/user-h5/src/components/NatalWheel.vue b/apps/user-h5/src/components/NatalWheel.vue new file mode 100644 index 0000000..fcaa4c2 --- /dev/null +++ b/apps/user-h5/src/components/NatalWheel.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/apps/user-h5/src/components/NumberPyramid.vue b/apps/user-h5/src/components/NumberPyramid.vue new file mode 100644 index 0000000..7292ab2 --- /dev/null +++ b/apps/user-h5/src/components/NumberPyramid.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/apps/user-h5/src/components/PageTitle.vue b/apps/user-h5/src/components/PageTitle.vue new file mode 100644 index 0000000..65c2095 --- /dev/null +++ b/apps/user-h5/src/components/PageTitle.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/apps/user-h5/src/components/PayLock.vue b/apps/user-h5/src/components/PayLock.vue new file mode 100644 index 0000000..661bd2f --- /dev/null +++ b/apps/user-h5/src/components/PayLock.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/apps/user-h5/src/components/ReportRich.vue b/apps/user-h5/src/components/ReportRich.vue new file mode 100644 index 0000000..47a46e0 --- /dev/null +++ b/apps/user-h5/src/components/ReportRich.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/apps/user-h5/src/components/ShareCard.spec.ts b/apps/user-h5/src/components/ShareCard.spec.ts new file mode 100644 index 0000000..f1f1314 --- /dev/null +++ b/apps/user-h5/src/components/ShareCard.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import ShareCard from './ShareCard.vue' + +describe('ShareCard', () => { + it('renders brand and portrait content', () => { + const w = mount(ShareCard, { + props: { + type: 'portrait', + title: '探索者风格', + line: '一句结论', + keywords: ['稳'], + ctaLabel: '查看完整成长报告', + }, + }) + expect(w.find('img.brand-logo').attributes('alt')).toBe('愈心谷') + expect(w.text()).toContain('探索者风格') + expect(w.text()).toContain('查看完整成长报告') + expect(w.text()).not.toContain('运势') + expect(w.text()).not.toContain('算命') + }) + + it('renders relation rows', () => { + const w = mount(ShareCard, { + props: { + type: 'relation', + title: '我的方式 vs TA 的方式', + rows: ['我:理性型', 'TA:感受型'], + ctaLabel: '了解彼此 → 查看关系理解', + }, + }) + expect(w.text()).toContain('理性型') + expect(w.text()).toContain('查看关系理解') + }) +}) diff --git a/apps/user-h5/src/components/ShareCard.vue b/apps/user-h5/src/components/ShareCard.vue new file mode 100644 index 0000000..1423ef8 --- /dev/null +++ b/apps/user-h5/src/components/ShareCard.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/apps/user-h5/src/components/ShareSheet.vue b/apps/user-h5/src/components/ShareSheet.vue new file mode 100644 index 0000000..25ed374 --- /dev/null +++ b/apps/user-h5/src/components/ShareSheet.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/apps/user-h5/src/components/SignCards.vue b/apps/user-h5/src/components/SignCards.vue new file mode 100644 index 0000000..4079982 --- /dev/null +++ b/apps/user-h5/src/components/SignCards.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/apps/user-h5/src/components/TabBar.vue b/apps/user-h5/src/components/TabBar.vue index 6f975f8..d70dbaf 100644 --- a/apps/user-h5/src/components/TabBar.vue +++ b/apps/user-h5/src/components/TabBar.vue @@ -5,10 +5,10 @@ :key="item.to" :to="item.to" class="item" - :class="{ ask: item.ask, on: isActive(item.to) }" + :class="{ ask: item.ask, on: isActive(item.key) }" > {{ item.icon }} - {{ item.label }} + {{ item.label }} @@ -18,36 +18,74 @@ import { useRoute } from 'vue-router' const route = useRoute() const items = [ - { to: '/', icon: '⌂', label: '首页' }, - { to: '/explore', icon: '◎', label: '探索' }, - { to: '/ask', icon: '问', label: '问答', ask: true }, - { to: '/companion', icon: '♡', label: '陪伴' }, - { to: '/mine', icon: '◍', label: '我的' }, -] + { key: 'home', to: '/', icon: '⌂', label: '首页' }, + { key: 'explore', to: '/explore', icon: '◎', label: '探索' }, + { key: 'ask', to: '/ask', icon: '问', label: '问答', ask: true }, + { key: 'companion', to: '/companion', icon: '♡', label: '陪伴' }, + { key: 'mine', to: '/mine', icon: '◍', label: '我的' }, +] as const -function isActive(to: string): boolean { - if (to === '/') return route.path === '/' - return route.path.startsWith(to) +function activeKey(): string { + const p = route.path + if (p.startsWith('/ask')) return 'ask' + if (p.startsWith('/companion')) return 'companion' + if ( + p.startsWith('/mine') || + p.startsWith('/profile') || + p.startsWith('/membership') || + p.startsWith('/reports') + ) { + return 'mine' + } + if ( + p.startsWith('/explore') || + p.startsWith('/relation') || + p.startsWith('/scales') || + p.startsWith('/portrait') || + p.startsWith('/star') || + p.startsWith('/synastry') || + p.startsWith('/rhythm') || + p.startsWith('/cards') || + p.startsWith('/growth-plan') || + p.startsWith('/share') + ) { + return 'explore' + } + if (p === '/') return 'home' + return 'home' +} + +function isActive(key: string): boolean { + return activeKey() === key } diff --git a/apps/user-h5/src/components/WuxingBars.vue b/apps/user-h5/src/components/WuxingBars.vue new file mode 100644 index 0000000..a3e08a8 --- /dev/null +++ b/apps/user-h5/src/components/WuxingBars.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/apps/user-h5/src/lib/analytics.spec.ts b/apps/user-h5/src/lib/analytics.spec.ts new file mode 100644 index 0000000..b97211a --- /dev/null +++ b/apps/user-h5/src/lib/analytics.spec.ts @@ -0,0 +1,34 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AnalyticsEvent, track, trackPageView } from './analytics' + +describe('analytics.track', () => { + beforeEach(() => { + window.gtag = vi.fn() + }) + afterEach(() => { + delete window.gtag + }) + + it('calls gtag with event name and params', () => { + track(AnalyticsEvent.HomeCtaPortrait) + expect(window.gtag).toHaveBeenCalledWith('event', 'home_cta_portrait', {}) + }) + + it('omits undefined params', () => { + track(AnalyticsEvent.PortraitCompleted, { source: 'query', report_id: undefined }) + expect(window.gtag).toHaveBeenCalledWith('event', 'portrait_completed', { source: 'query' }) + }) + + it('does not throw when gtag missing', () => { + delete window.gtag + expect(() => track(AnalyticsEvent.RelationCompleted)).not.toThrow() + }) + + it('trackPageView sends page_path', () => { + trackPageView('/portrait', '个人画像') + expect(window.gtag).toHaveBeenCalledWith('event', 'page_view', { + page_path: '/portrait', + page_title: '个人画像', + }) + }) +}) diff --git a/apps/user-h5/src/lib/analytics.ts b/apps/user-h5/src/lib/analytics.ts new file mode 100644 index 0000000..00e95a7 --- /dev/null +++ b/apps/user-h5/src/lib/analytics.ts @@ -0,0 +1,83 @@ +/** + * P1 growth analytics — see .ai/product/feature-spec/analytics.md + * Pages MUST use track(); do not call window.gtag directly. + */ + +export type AnalyticsParams = Record + +/** Frozen P1 core funnel events (+ page_view via router). */ +export const AnalyticsEvent = { + HomeCtaPortrait: 'home_cta_portrait', + PortraitCompleted: 'portrait_completed', + DeepAccessClicked: 'deep_access_clicked', + PurchaseCompleted: 'purchase_completed', + RelationCompleted: 'relation_completed', + SynastryCompleted: 'synastry_completed', + SynastryInviteCreated: 'synastry_invite_created', + SynastryInviteAccepted: 'synastry_invite_accepted', + SynastryNearbyOpened: 'synastry_nearby_opened', + StarWheelViewed: 'star_wheel_viewed', + PageView: 'page_view', +} as const + +export type CoreAnalyticsEvent = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent] + +declare global { + interface Window { + dataLayer?: unknown[] + gtag?: (...args: unknown[]) => void + } +} + +let gaReady = false + +/** Load GA4 when VITE_GA_MEASUREMENT_ID is set. Safe to call once from main. */ +export function initAnalytics(): void { + const id = (import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim() + if (!id || typeof document === 'undefined') return + if (gaReady) return + gaReady = true + + window.dataLayer = window.dataLayer || [] + window.gtag = function gtag(...args: unknown[]) { + window.dataLayer!.push(args) + } + window.gtag('js', new Date()) + window.gtag('config', id, { send_page_view: false }) + + const s = document.createElement('script') + s.async = true + s.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}` + document.head.appendChild(s) +} + +function debugEnabled(): boolean { + return import.meta.env.DEV && String(import.meta.env.VITE_ANALYTICS_DEBUG || '') === '1' +} + +/** Fire a custom event. No-op when gtag missing; never throws. */ +export function track(event: string, params?: AnalyticsParams): void { + try { + const clean: Record = {} + if (params) { + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) clean[k] = v + } + } + if (debugEnabled()) { + console.debug('[analytics]', event, clean) + } + if (typeof window !== 'undefined' && typeof window.gtag === 'function') { + window.gtag('event', event, clean) + } + } catch { + /* never block UX */ + } +} + +export function trackPageView(path: string, title?: string): void { + track(AnalyticsEvent.PageView, { + page_path: path, + page_title: title || document.title, + }) +} diff --git a/apps/user-h5/src/lib/featureIcons.ts b/apps/user-h5/src/lib/featureIcons.ts new file mode 100644 index 0000000..960f930 --- /dev/null +++ b/apps/user-h5/src/lib/featureIcons.ts @@ -0,0 +1,59 @@ +/** + * Icon glyphs + tones from legacy yuxingu.html / css/home.css + * (geometric marks — no emoji) + */ + +export type IconTone = + | 'pink' + | 'blue' + | 'purple' + | 'orange' + | 'green' + | 'rose' + | 'teal' + | 'gold' + | 'night' + | 'mute' + +export const FeatureIcon = { + home: { mark: '⌂', tone: 'mute' as IconTone }, + portrait: { mark: '△', tone: 'gold' as IconTone }, // 愈心解码 + star: { mark: '✦', tone: 'night' as IconTone }, // 星座星盘 + rhythm: { mark: '☯', tone: 'green' as IconTone }, + cards: { mark: '◈', tone: 'teal' as IconTone }, + relation: { mark: '♡', tone: 'rose' as IconTone }, + ask: { mark: '◎', tone: 'gold' as IconTone }, + companion: { mark: '♡', tone: 'green' as IconTone }, + explore: { mark: '◆', tone: 'blue' as IconTone }, + more: { mark: '⋯', tone: 'mute' as IconTone }, + membership: { mark: '⑨', tone: 'purple' as IconTone }, + profile: { mark: '◍', tone: 'mute' as IconTone }, + reports: { mark: '▣', tone: 'orange' as IconTone }, + scale: { mark: '◆', tone: 'blue' as IconTone }, + mood: { mark: '○', tone: 'green' as IconTone }, + tip: { mark: '※', tone: 'gold' as IconTone }, + share: { mark: '↗', tone: 'pink' as IconTone }, + growth: { mark: '▽', tone: 'teal' as IconTone }, +} as const + +export type FeatureIconKey = keyof typeof FeatureIcon + +/** Section titles inside ReportRich — tones stay soft red via CSS default */ +export function sectionMark(title: string): string { + if (/总览|摘要/.test(title)) return '◈' + if (/多维|维度/.test(title)) return '◆' + if (/优势/.test(title)) return '△' + if (/留意|盲区|摩擦/.test(title)) return '◇' + if (/成长|路径|计划/.test(title)) return '▽' + if (/可以这样说|对话|沟通/.test(title)) return '◎' + if (/建议|练习|本周/.test(title)) return '※' + if (/疑问|FAQ/i.test(title)) return '?' + if (/太阳|星象|月亮|上升/.test(title)) return '✦' + if (/节律|五行|作息/.test(title)) return '☯' + if (/意象|卡片/.test(title)) return '◈' + return '·' +} + +export function toneClass(tone: IconTone): string { + return `icon-${tone}` +} diff --git a/apps/user-h5/src/lib/scaleDraft.spec.ts b/apps/user-h5/src/lib/scaleDraft.spec.ts new file mode 100644 index 0000000..a252192 --- /dev/null +++ b/apps/user-h5/src/lib/scaleDraft.spec.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { clearScaleDraft, loadScaleDraft, saveScaleDraft, scaleDraftKey } from './scaleDraft' + +describe('scaleDraft', () => { + afterEach(() => { + clearScaleDraft('demo') + }) + + it('saves and loads answers', () => { + saveScaleDraft('demo', { q1: 'A', q2: 'B' }) + expect(loadScaleDraft('demo')).toEqual({ q1: 'A', q2: 'B' }) + expect(localStorage.getItem(scaleDraftKey('demo'))).toBeTruthy() + }) + + it('clears draft', () => { + saveScaleDraft('demo', { q1: 'A' }) + clearScaleDraft('demo') + expect(loadScaleDraft('demo')).toBeNull() + }) + + it('ignores empty values', () => { + saveScaleDraft('demo', { q1: 'A', q2: '' }) + expect(loadScaleDraft('demo')).toEqual({ q1: 'A' }) + }) +}) diff --git a/apps/user-h5/src/lib/scaleDraft.ts b/apps/user-h5/src/lib/scaleDraft.ts new file mode 100644 index 0000000..f3a18c3 --- /dev/null +++ b/apps/user-h5/src/lib/scaleDraft.ts @@ -0,0 +1,50 @@ +/** Local draft for exploration-test answers — see feature-spec/explore-test.md */ + +const prefix = 'yuxingu_scale_draft_v1:' + +export function scaleDraftKey(slug: string): string { + return prefix + slug +} + +export function loadScaleDraft(slug: string): Record | null { + if (!slug || typeof localStorage === 'undefined') return null + try { + const raw = localStorage.getItem(scaleDraftKey(slug)) + if (!raw) return null + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + const out: Record = {} + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v === 'string' && v) out[k] = v + } + return Object.keys(out).length ? out : null + } catch { + return null + } +} + +export function saveScaleDraft(slug: string, answers: Record): void { + if (!slug || typeof localStorage === 'undefined') return + try { + const clean: Record = {} + for (const [k, v] of Object.entries(answers)) { + if (v) clean[k] = v + } + if (!Object.keys(clean).length) { + localStorage.removeItem(scaleDraftKey(slug)) + return + } + localStorage.setItem(scaleDraftKey(slug), JSON.stringify(clean)) + } catch { + /* quota / private mode */ + } +} + +export function clearScaleDraft(slug: string): void { + if (!slug || typeof localStorage === 'undefined') return + try { + localStorage.removeItem(scaleDraftKey(slug)) + } catch { + /* ignore */ + } +} diff --git a/apps/user-h5/src/lib/shareLink.spec.ts b/apps/user-h5/src/lib/shareLink.spec.ts new file mode 100644 index 0000000..1e8475b --- /dev/null +++ b/apps/user-h5/src/lib/shareLink.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { buildSharePath, parseShareQuery } from './shareLink' + +describe('shareLink', () => { + it('round-trips portrait payload', () => { + const path = buildSharePath({ + type: 'portrait', + title: '偏稳的互动风格', + line: '一句画像', + keywords: ['稳', '细'], + }) + expect(path.startsWith('/share?')).toBe(true) + const q = Object.fromEntries(new URLSearchParams(path.slice(path.indexOf('?')))) + const parsed = parseShareQuery(q) + expect(parsed).toEqual({ + type: 'portrait', + title: '偏稳的互动风格', + line: '一句画像', + keywords: ['稳', '细'], + }) + }) + + it('round-trips relation payload', () => { + const path = buildSharePath({ + type: 'relation', + me: '理性型', + other: '感受型', + diff: '节奏不同', + keywords: ['我·稳'], + }) + const q = Object.fromEntries(new URLSearchParams(path.slice(path.indexOf('?')))) + const parsed = parseShareQuery(q) + expect(parsed).toEqual({ + type: 'relation', + me: '理性型', + other: '感受型', + diff: '节奏不同', + keywords: ['我·稳'], + }) + }) + + it('returns null for empty/invalid', () => { + expect(parseShareQuery({})).toBeNull() + expect(parseShareQuery({ type: 'portrait' })).toBeNull() + }) +}) diff --git a/apps/user-h5/src/lib/shareLink.ts b/apps/user-h5/src/lib/shareLink.ts new file mode 100644 index 0000000..c73a9cd --- /dev/null +++ b/apps/user-h5/src/lib/shareLink.ts @@ -0,0 +1,80 @@ +/** Build / parse client-side share landing query (no server). */ + +export type PortraitSharePayload = { + type: 'portrait' + title: string + line: string + keywords: string[] +} + +export type RelationSharePayload = { + type: 'relation' + me: string + other: string + diff: string + keywords: string[] +} + +export type SharePayload = PortraitSharePayload | RelationSharePayload + +export function buildSharePath(payload: SharePayload): string { + const q = new URLSearchParams() + q.set('type', payload.type) + if (payload.type === 'portrait') { + if (payload.title) q.set('t', payload.title) + if (payload.line) q.set('l', payload.line) + if (payload.keywords.length) q.set('k', payload.keywords.slice(0, 6).join(',')) + } else { + if (payload.me) q.set('me', payload.me) + if (payload.other) q.set('ot', payload.other) + if (payload.diff) q.set('d', payload.diff) + if (payload.keywords.length) q.set('k', payload.keywords.slice(0, 6).join(',')) + } + return `/share?${q.toString()}` +} + +export function parseShareQuery(query: Record): SharePayload | null { + const type = String(query.type || '') + const keywords = String(query.k || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + if (type === 'portrait') { + const title = String(query.t || '') + const line = String(query.l || '') + if (!title && !line && !keywords.length) return null + return { type: 'portrait', title, line, keywords } + } + if (type === 'relation') { + const me = String(query.me || '') + const other = String(query.ot || '') + const diff = String(query.d || '') + if (!me && !other && !diff) return null + return { type: 'relation', me, other, diff, keywords } + } + return null +} + +export async function copyText(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return true + } + } catch { + /* fall through */ + } + try { + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.left = '-9999px' + document.body.appendChild(ta) + ta.select() + const ok = document.execCommand('copy') + document.body.removeChild(ta) + return ok + } catch { + return false + } +} diff --git a/apps/user-h5/src/main.ts b/apps/user-h5/src/main.ts index b0c7d4d..ad32034 100644 --- a/apps/user-h5/src/main.ts +++ b/apps/user-h5/src/main.ts @@ -2,7 +2,14 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' +import { initAnalytics, trackPageView } from './lib/analytics' import '@yuxingu/ui/tokens.css' import './styles/app.css' +initAnalytics() + +router.afterEach((to) => { + trackPageView(to.fullPath, typeof to.name === 'string' ? to.name : undefined) +}) + createApp(App).use(createPinia()).use(router).mount('#app') diff --git a/apps/user-h5/src/pages/AskPage.spec.ts b/apps/user-h5/src/pages/AskPage.spec.ts new file mode 100644 index 0000000..9f5cbfc --- /dev/null +++ b/apps/user-h5/src/pages/AskPage.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import AskPage from './AskPage.vue' + +const { api } = vi.hoisted(() => ({ + api: { + listProfiles: vi.fn(), + getAskQuota: vi.fn(), + createAskThread: vi.fn(), + sendAskMessage: vi.fn(), + }, +})) + +vi.mock('../api/client', () => ({ api })) + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {} }), + useRouter: () => ({ back: vi.fn(), push: vi.fn() }), +})) + +const RouterLinkStub = defineComponent({ + props: ['to'], + setup(props, { slots }) { + return () => h('a', { href: String(props.to) }, slots.default?.()) + }, +}) + +describe('AskPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows empty state when no profiles', async () => { + api.listProfiles.mockResolvedValue({ items: [] }) + api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' }) + const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } }) + await flushPromises() + expect(w.text()).toContain('还没有个人档案') + expect(w.text()).toContain('去首页探索') + }) + + it('loads profiles and sends a message', async () => { + api.listProfiles.mockResolvedValue({ + items: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }], + }) + api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' }) + api.createAskThread.mockResolvedValue({ id: 't1', profile_id: 'p1' }) + api.sendAskMessage.mockResolvedValue({ + user_message: { id: 'm1', role: 'user', content: '你好', thread_id: 't1', created_at: '' }, + assistant_message: { + id: 'm2', + role: 'assistant', + content: '结合档案的回复', + thread_id: 't1', + created_at: '', + }, + quota: { remaining: 2, free_limit: 3, active_membership: false, source: 'free' }, + }) + + const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } }) + await flushPromises() + expect(w.text()).toContain('剩余 3 次') + await w.find('textarea').setValue('你好') + await w.find('form').trigger('submit') + await flushPromises() + expect(api.createAskThread).toHaveBeenCalled() + expect(w.text()).toContain('结合档案的回复') + expect(w.text()).toContain('剩余 2 次') + }) + + it('shows boot error with retry', async () => { + api.listProfiles.mockRejectedValueOnce(new Error('网络错误')) + api.getAskQuota.mockRejectedValueOnce(new Error('网络错误')) + const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } }) + await flushPromises() + expect(w.text()).toContain('网络错误') + api.listProfiles.mockResolvedValue({ items: [] }) + api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' }) + await w.find('button.link').trigger('click') + await flushPromises() + expect(w.text()).toContain('还没有个人档案') + }) +}) diff --git a/apps/user-h5/src/pages/AskPage.vue b/apps/user-h5/src/pages/AskPage.vue index a7f982a..c8293d5 100644 --- a/apps/user-h5/src/pages/AskPage.vue +++ b/apps/user-h5/src/pages/AskPage.vue @@ -1,17 +1,213 @@