feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点

落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-13 01:27:58 +08:00
co-authored by Cursor
parent 13860bf1ef
commit 89756f65b4
227 changed files with 7405 additions and 1407 deletions
+1
View File
@@ -25,6 +25,7 @@
| [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 Implemented | | [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 Implemented |
| [ops-commerce.md](ops-commerce.md) | 运营商业加深(订单筛选+展示价+退款只读) | §7 | `admin-h5` `/orders` `/pricing` | Ops-D · ECR-009 | | [ops-commerce.md](ops-commerce.md) | 运营商业加深(订单筛选+展示价+退款只读) | §7 | `admin-h5` `/orders` `/pricing` | Ops-D · ECR-009 |
| [ops-system.md](ops-system.md) | 运营系统(RBAC+封禁+推送占位) | §7 | `admin-h5` `/push` `/admins` | Ops-E · ECR-010 | | [ops-system.md](ops-system.md) | 运营系统(RBAC+封禁+推送占位) | §7 | `admin-h5` `/push` `/admins` | Ops-E · ECR-010 |
| [input-compliance.md](input-compliance.md) | 用户文字输入合规 | 横切 · 安全 | 各写接口 · code 40060 | P2 · ECR-012 |
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
+26 -4
View File
@@ -84,14 +84,16 @@ Device 绑定到账号 User
| R5 | 登出作废 session;设备可再登录其他账号 | | R5 | 登出作废 session;设备可再登录其他账号 |
| R6 | 正式环境恢复校验前须改回本 Spec | | R6 | 正式环境恢复校验前须改回本 Spec |
| R7 | 新账号未填昵称时,服务端用「意象词+场景词」词库(100×100)随机生成,如心语岛、微光谷 | | R7 | 新账号未填昵称时,服务端用「意象词+场景词」词库(100×100)随机生成,如心语岛、微光谷 |
| R8 | 账号昵称可在个人档案页修改(PATCH `/auth/me`);首页展示昵称而非「自己」 | | R8 | 账号昵称可在个人档案页修改(PATCH `/auth/me`);首页与各页「自己」展示位统一用昵称(合盘/问答/星座/档案卡) |
| R9 | 账号头像可在「我的」页更换:相册或拍照上传;服务端持久化;GET `/auth/me` 返回 `avatar_url`;未设置时 UI 用愈心小人正面默认头像(`public/default-avatar.png`,源图 `public/logo/愈心小人1.png` |
--- ---
## 8. 数据模型影响 ## 8. 数据模型影响
- `users.phone` UNIQUE · `users.password_hash` · `users.nickname` - `users.phone` UNIQUE · `users.password_hash` · `users.nickname` · `users.avatar_url`
- `user_sessions(token, user_id, expires_at)` - `user_sessions(token, user_id, expires_at)`
- 本地文件:`data/avatars/{user_id}.{jpg|png|webp}`(进程相对路径)
--- ---
@@ -102,8 +104,10 @@ Device 绑定到账号 User
| POST | `/api/v1/auth/register` | 注册(可带 nickname | | POST | `/api/v1/auth/register` | 注册(可带 nickname |
| POST | `/api/v1/auth/login` | 登录 | | POST | `/api/v1/auth/login` | 登录 |
| POST | `/api/v1/auth/logout` | 登出 | | POST | `/api/v1/auth/logout` | 登出 |
| GET | `/api/v1/auth/me` | 当前账号 | | GET | `/api/v1/auth/me` | 当前账号(含 avatar_url |
| PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` | | PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` |
| POST | `/api/v1/auth/me/avatar` | multipart `file`jpeg/png/webp;≤2MB → 更新 avatar_url |
| GET | `/api/v1/media/avatars/:file` | 公开读头像文件 |
--- ---
@@ -113,12 +117,30 @@ Device 绑定到账号 User
|---|---|---| |---|---|---|
| register/login | ✓ | ✓ | | register/login | ✓ | ✓ |
| profiles / reports 写读 | ✗ | ✓ | | profiles / reports 写读 | ✗ | ✓ |
| 上传头像 | ✗ | ✓ |
| 读头像媒体 | ✓ | ✓ |
--- ---
## 11. 埋点 ## 11. 埋点
`auth_register` · `auth_login` · `auth_logout` 经统一 `track()`(见 [analytics.md](analytics.md) · [ops-analytics.md](ops-analytics.md))。
**非** P1 核心漏斗 5 事件;接入后走自有 `POST /analytics/events`(及可选 GA)。
禁止参数含手机号、明文密码、头像绝对 URL / `user_id`
| Event | 触发 | 可选 params |
|---|---|---|
| `auth_register` | 账号新建成功(含 OpenLogin 自动建号,`is_new=true` | `source`: login_page |
| `auth_login` | 已有账号登录成功(`is_new=false` | `source`: login_page |
| `auth_logout` | 用户在「我的」点退出且本地 session 已清 | `surface`: mine |
| `avatar_sheet_opened` | 「我的」打开更换头像 sheet | `surface`: mine |
| `avatar_upload_succeeded` | 头像上传 API 成功且 UI 刷新为新图 | `source`: album \| camera |
| `avatar_upload_failed` | 头像上传失败(校验/网络/服务端) | `source`: album \| camera · `reason`: too_large \| bad_type \| network \| server \| unknown |
| `nickname_updated` | PATCH 昵称成功 | `surface`: profile |
`page_view``/login` · `/mine` 由路由自动采集,本 Spec 不重复定义。
**实现状态:** 事件名以本表为准;H5 已挂 Login / Mine / Profile`track` → 自有 ingest + 可选 GA)。
--- ---
+2
View File
@@ -65,6 +65,8 @@
```text ```text
/ask /ask
├── 愈心 AI / 顾问 Tab
├── 顾问列表 → 顾问专属对话(?advisor=key · 顶栏返回回列表)
├── Empty(无档案) ├── Empty(无档案)
├── Loading(发送中) ├── Loading(发送中)
├── Normal(历史 + 输入) ├── Normal(历史 + 输入)
+9 -4
View File
@@ -111,10 +111,15 @@ Tab 陪伴 → /companion
## 11. 埋点 ## 11. 埋点
| Event | 触发 | | Event | 触发 | 可选 params |
|---|---| |---|---|---|
| `companion_viewed` | 打开 | | `companion_viewed` | 打开 `/companion` | — |
| `mood_saved` | P2 | | `mood_saved` | 心情保存成功 | `score`(整数分,非正文) |
| `growth_plan_viewed` | 打开 `/growth-plan` 且列表加载结束 | — |
| `growth_plan_created` | 创建计划成功 | — |
| `growth_plan_checkin` | 今日打卡成功 | — |
禁止参数含计划 title/focus/note 明文。
--- ---
+48 -17
View File
@@ -45,9 +45,9 @@
进入 /scales/:slug 进入 /scales/:slug
加载题目 已有结果? → 直接展示最近结果 +「重新测试」
(无结果 / 点重测)
逐题作答(须全部完成) 加载题目 · 逐题作答(须全部完成)
无 Self → 引导建档 无 Self → 引导建档
@@ -60,10 +60,13 @@ POST result → 展示丰富结果
| 子能力 | P1 | | 子能力 | P1 |
|---|---| |---|---|
| 测试入口(探索 Tab | ✓ | | 测试入口(探索 Tab · 首页宫格) | ✓ · 产品向 `mbti-lite`;探索主路径为 curated 题库(A 集 |
| 测试列表 | ✓ | | 测试列表 | ✓ · 探索「题库精选」宫格(每类 1~2)+「题库分类」;分类页 `/explore/bank/:key` |
| 题库范围(A) | ✓ 人格 / 情绪能力 / 智能 / 生活与关系;**不含**抑郁焦虑强迫躁狂双相等临床筛查 |
| 问题流程 | ✓ | | 问题流程 | ✓ |
| 答题保存(服务端结果落库) | ✓ | | 答题保存(服务端结果落库) | ✓ |
| 最近结果回看 | ✓ GET `/scales/{slug}/result`;有结果进页不重复作答 |
| 重新测试 | ✓ 结果页按钮 → 清空草稿再答;新结果落库并覆盖展示 |
| 中断恢复 | ✓ localStorage 草稿(`scaleDraft`);提交后清除 | | 中断恢复 | ✓ localStorage 草稿(`scaleDraft`);提交后清除 |
| 结果生成(丰富结构) | ✓ | | 结果生成(丰富结构) | ✓ |
| 免费层 | ✓ 全量 result | | 免费层 | ✓ 全量 result |
@@ -79,8 +82,10 @@ POST result → 展示丰富结果
| 路由 | 页面 | | 路由 | 页面 |
|---|---| |---|---|
| `/explore` | ExplorePage | | `/explore` | ExplorePage(题库精选宫格 + 题库分类 + 其它工具) |
| `/scales/:slug` | ScalePage | | `/explore/bank/:category` | ExploreBankCategoryPage · curated 题库分类列表 |
| `/explore/:category` | ExploreCategoryPage · 产品工具分类(解码/星座等) |
| `/scales/:slug` | ScalePage(含 bank slug |
```text ```text
/scales/:slug /scales/:slug
@@ -116,6 +121,13 @@ POST result → 展示丰富结果
| R5 | result 含 label/summary/overview/dimensions/tips/scripts/growth_plan/faq | | R5 | result 含 label/summary/overview/dimensions/tips/scripts/growth_plan/faq |
| R6 | 结果「不是固定标签」需在文案中提示 | | R6 | 结果「不是固定标签」需在文案中提示 |
| R7 | 作答过程写入本地草稿(按 slug);提交成功后清除;刷新可恢复未提交答案 | | R7 | 作答过程写入本地草稿(按 slug);提交成功后清除;刷新可恢复未提交答案 |
| R8 | 同一用户同一 slug(含 curated 题库 slug)若已有 `scale_results`,再次进入直接展示**最近一次**结果,并提供「重新测试」;未点重测不得自动进入答题 |
| R9 | `mbti-lite` 产品展示名统一为「MBTI测试」(首页宫格 / 探索入口 / 量表 title) |
| R10 | **标准版 `mbti-lite`**:开源 OEJTS 1.2 · **32 题**(四维各 8)· 双极 1–5 Likert;按维累加,阈值判出 E/I·S/N·T/F·J/P 一侧,合成四字母类型;**免费** |
| R11 | **完整版 `mbti-full`**:60 题(四维各 15)· 须**成长会员**;未开通时 GET 返回 `locked=true` 且无题目;提交返回 403 |
| R12 | 禁止使用官方 MBTI® 版权题干;结果须提示「不是固定标签」;题库重大更新须 soft-delete 旧 `scale_results` |
| R13 | curated 题库(`scalebank`)仅含 A 集非临床探索量表;文案须声明「非诊断」;临床类(抑郁/焦虑/强迫等)不得进入 C 端 |
| R14 | curated 题库作答走同一 `/scales/:slug`;完成后再次点击入口须遵守 R8(只看结果或显式重测),不得每次从头作答 |
--- ---
@@ -134,6 +146,7 @@ POST result → 展示丰富结果
|---|---|---| |---|---|---|
| GET | `/api/v1/scales` | 列表 | | GET | `/api/v1/scales` | 列表 |
| GET | `/api/v1/scales/{slug}` | 题目 | | GET | `/api/v1/scales/{slug}` | 题目 |
| GET | `/api/v1/scales/{slug}/result` | 当前用户该量表最近一次结果;无则 404 |
| POST | `/api/v1/scales/{slug}/result` | 提交计分 | | POST | `/api/v1/scales/{slug}/result` | 提交计分 |
--- ---
@@ -144,18 +157,27 @@ POST result → 展示丰富结果
|---|---|---| |---|---|---|
| 看列表/题目 | ✓ | ✓ | | 看列表/题目 | ✓ | ✓ |
| 提交结果 | ✗ | ✓ | | 提交结果 | ✗ | ✓ |
| `mbti-full` 作答 | ✗(须成长会员) | ✓(会员 active) |
--- ---
## 11. 埋点 ## 11. 埋点
| Event | 触发 | 经统一 `track()`;非 P1 核心 5 事件。禁止参数含题干全文、答案明文、档案 ID。
|---|---|
| `scale_list_viewed` | 探索列表 | | Event | 触发 | 可选 params |
| `scale_started` | 进入作答 | |---|---|---|
| `scale_completed` | 出结果 | | `scale_list_viewed` | 打开 `/explore` 且题库 catalog 加载成功 | — |
| `scale_share_clicked` | 分享 | | `scale_bank_category_viewed` | 打开 `/explore/bank/:category` 且分类加载成功 | `category`key |
| `scale_cta_relation` / `scale_cta_ask` | 导流 | | `scale_started` | 用户点开始进入作答(含「重新测试」后再开始) | `slug` · `access`: free\|member |
| `scale_completed` | 提交计分成功并展示结果 | `slug` · `access`: free\|member |
| `scale_result_reopened` | 再次进入已有结果直接展示(R8,未重测) | `slug` |
| `scale_retake_clicked` | 点「重新测试」 | `slug` |
| `scale_locked_viewed` | 会员锁页展示(如 mbti-full | `slug` |
| `scale_share_clicked` | 结果页打开分享 | `slug` |
| `scale_cta_relation` / `scale_cta_ask` | 结果导流(若 UI 有) | `slug` |
`page_view``/explore` · `/explore/bank/*` · `/scales/*` 由路由自动采集。
--- ---
@@ -165,6 +187,14 @@ POST result → 展示丰富结果
**When** 答完提交 **When** 答完提交
**Then** 返回 label 与 overview/tips 等丰富字段 **Then** 返回 label 与 overview/tips 等丰富字段
**Given** 已提交过结果
**When** 再次进入 `/scales/{slug}`
**Then** 直接展示最近结果与「重新测试」;不进入答题
**Given** 结果页点「重新测试」并完成提交
**When** 再次进入
**Then** 展示新结果
**Given** 未答完 **Given** 未答完
**When** 提交 **When** 提交
**Then** 前端拦截;不成功落库 **Then** 前端拦截;不成功落库
@@ -186,7 +216,8 @@ POST result → 展示丰富结果
| 项 | 内容 | | 项 | 内容 |
|---|---| |---|---|
| Packages | `internal/scale` · `service/scale` · `ExplorePage` · `ScalePage` | | Packages | `internal/scale` · `service/scale` · `ExplorePage` · `ScalePage` |
| Seeds | communication-style · emotion-pattern | | Seeds | communication-style · emotion-pattern · **mbti-liteOEJTS 32** · **mbti-full60·会员)** |
| Gaps | 结果付费墙若要做须先改 Spec;全量 §11 埋点未挂(核心漏斗见 analytics) | | Gaps | 结果付费墙若要做须先改 Spec;全量 §11 埋点未挂(核心漏斗见 analytics) |
| Tests | score/result L1`scaleDraft.spec`Scale 流程组件测 | | Tests | score/result L1`ScoreJungian``scaleDraft.spec`Scale 流程组件测 |
| Process Review | 草稿恢复已实现 · P1 收口 | | Process Review | OEJTS 标准/完整双轨 · 最近结果回看 · P1 收口 |
| Credit | OEJTS / Open Psychometrics · openjung MIT |
+12 -6
View File
@@ -62,7 +62,7 @@
```text ```text
/ /
├── Hero / 品牌 ├── 全局顶栏:透明愈心谷 Logo(AppHeader,全站)
├── 「自己」卡片标题展示账号昵称(默认意象词+场景词;档案页可改) ├── 「自己」卡片标题展示账号昵称(默认意象词+场景词;档案页可改)
├── 穿衣指数 · 颜色搭配 · 养生推荐 ├── 穿衣指数 · 颜色搭配 · 养生推荐
├── 功能入口(4 列固定网格 · 多行换行 · 禁止横向滑动) ├── 功能入口(4 列固定网格 · 多行换行 · 禁止横向滑动)
@@ -91,14 +91,16 @@ Empty:首页本身是入口,不因无档案空白死页。
| R2 | 不展示内部运维信息(API 连接状态等) | | R2 | 不展示内部运维信息(API 连接状态等) |
| R3 | 入口文案符合 lexicon(个人画像/关系理解/成长会员…) | | R3 | 入口文案符合 lexicon(个人画像/关系理解/成长会员…) |
| R4 | Tab 五名冻结:首页·探索·问答·陪伴·我的 | | R4 | Tab 五名冻结:首页·探索·问答·陪伴·我的 |
| R5 | 「自己」卡片:穿衣指数 / 颜色搭配 / 养生推荐;有 Self 档案生日时按生日+本地时刻易经卦象种子调大模型生成;无档案或 LLM 不可用则本地回退 | | R5 | 「自己」卡片:穿衣指数 / 颜色搭配 / 养生推荐;有 Self 生日时按生日+**本地日历**+**十二时辰**易经种子生成;**每一时辰准点切换**;无档案或 LLM 不可用则本地回退 |
| R6 | 文案禁止占卜/算命恐吓与医疗疗效承诺;卦象仅作节律意象 | | R6 | 文案禁止占卜/算命恐吓与医疗疗效承诺;卦象仅作节律意象 |
| R7 | 贴士按 `user × 时辰起点` 持久缓存;GET 优先读缓存**即时返回**;缓存未命中时先回退/旧值,后台异步生成写入,禁止每次打开同步等 LLM |
| R8 | 响应含 `shichen` / `shichen_name` / `valid_until`(下一时辰起点);前端在 `valid_until` 自动静默刷新 |
--- ---
## 8. 数据模型影响 ## 8. 数据模型影响
经首页建档时写 `profiles`(见 profile Spec)。每日贴士可缓存(同用户约 45 分钟 / 按时段刷新) 经首页建档时写 `profiles``home_daily_tips(user_id, shichen_start, tips jsonb)` 缓存时辰贴士
--- ---
@@ -107,7 +109,7 @@ Empty:首页本身是入口,不因无档案空白死页。
| Method | Path | 说明 | | Method | Path | 说明 |
|---|---|---| |---|---|---|
| GET | `/api/v1/home/tools` | 宫格(已有) | | GET | `/api/v1/home/tools` | 宫格(已有) |
| GET | `/api/v1/home/daily-tips` | 自己卡片贴士(DeviceAuth;有 birthday 则 LLM | | GET | `/api/v1/home/daily-tips` | 自己卡片贴士(读缓存;含 valid_until |
| — | 复用 | `POST /profiles``POST /reports/portrait` | | — | 复用 | `POST /profiles``POST /reports/portrait` |
--- ---
@@ -142,9 +144,13 @@ Empty:首页本身是入口,不因无档案空白死页。
**When** 提交主 CTA **When** 提交主 CTA
**Then** 进入画像基础结果路径 **Then** 进入画像基础结果路径
**Given** 已登录且有 Self 生日、已配 deepseek **Given** 已登录且有 Self 生日、该时辰已有缓存
**When** GET `/home/daily-tips` **When** GET `/home/daily-tips`
**Then** `source=llm`含 clothing_index / palette / wellness **Then** 立即返回缓存(含 clothing/palette/wellness / valid_until),不阻塞 LLM
**Given** 时辰切换到点
**When** 前端到达 `valid_until`
**Then** 静默刷新;穿衣/颜色/养生随新时辰更新
**Given** 无档案或无 LLM **Given** 无档案或无 LLM
**When** GET `/home/daily-tips` **When** GET `/home/daily-tips`
@@ -0,0 +1,123 @@
# Feature Spec: 用户文字输入合规校验
> Status: `Active` · Map: `横切 · 安全/合规` · Phase: `P2`
> 关联:[lexicon.md](../lexicon.md) · [.ai/security.md](../../security.md) · account-auth / profile / ask / companion
---
## 1. 功能定义
| 字段 | 内容 |
|---|---|
| Name | 文字输入合规(Text Compliance |
| Purpose | 所有用户提交的**自由文字**在服务端统一做合法性/合规校验;前端可做即时提示,**以后端拒绝为准** |
| Business Goal | 降低 XSS/注入、恐吓与医疗疗效话术、垃圾刷屏进入 DB 与模型上下文的风险 |
| In | Out |
|---|---|
| 昵称 · 档案显示名 · 问答 content · 心情/打卡 note · 成长计划 title/focus · 邀请 display_name · 意象场景自填 | 纯数字字段(生日年月日、分数、经纬度) |
| 长度 · 格式清洗 · XSS/控制字符 · lexicon 硬禁止 · 简易刷屏特征 | 完整内容审核平台 / 人工审 / 语音 |
| 错误码统一 | 改开放登录策略本身 |
---
## 2. 用户价值
1. 避免有害/违规文案入库与进模型。
2. 提交失败时得到可读中文原因。
3. N/A(安全横切,非付费面)。
---
## 3. 用户角色
Visitor / User / Admin 凡提交自由文字均适用(Admin 运营文案可后续加强;本 Spec **先 C 端用户 API**)。
---
## 4. 用户流程
```text
用户输入文字 →(可选)前端即时校验
→ API Handler/Service 调用 textsafe.Check(kind, text)
→ OK:规范化后入库/进模型
→ 拒绝:HTTP 400 · code 40060 · message 可读原因
```
---
## 56. 页面 / 状态
各既有表单;Error 态展示 `message`。无新页面。
---
## 7. Business Rules
| ID | Rule |
|---|---|
| R1 | **后端必须校验**所有用户自由文字;前端校验不得替代后端 |
| R2 | 种类与上限(rune):`nickname` 116`display_name` 124`ask_content` 12000`note` 0200`title` 140`focus` 040`scene` 180 |
| R3 | 拒绝控制字符(允许消息内 `\n` `\t`);拒绝 HTML/脚本片段(如 `<script``javascript:` |
| R4 | 命中 lexicon 硬禁止子串(占卜/算命/改命恐吓/疗效承诺/「测测」品牌自称等)→ 拒绝 |
| R5 | 刷屏特征:连续相同字 ≥8,或可见字符全相同且长度 ≥6 → 拒绝 |
| R6 | 密码字段不做 lexicon,仅拒空字节;手机号走既有数字规则 |
| R7 | 校验通过后写库的是 **Normalize** 后的文本(Trim;压缩首尾空白) |
| R8 | 错误码:`40060`;文案过 lexicon(勿用恐吓语气) |
---
## 8. 数据模型
无新表。进程内可选短时计数(本版刷屏以内容特征为主,不做跨请求配额表)。
---
## 9. API
既有写接口;拒绝时统一:
```json
{ "code": 40060, "message": "文案不合规:…", "data": null }
```
---
## 10. 权限
同各业务接口鉴权;校验在鉴权之后、写库之前。
---
## 11. 埋点
可选:`text_rejected{kind}`(本版可不接)。
---
## 12. 验收
| Given | When | Then |
|---|---|---|
| nickname 含「算命」 | PATCH /auth/me | 40060 |
| ask content 含 `<script` | POST messages | 40060 |
| display_name 正常「小愈」 | POST profile | 200 |
| note=`啊啊啊啊啊啊啊啊` | POST mood | 40060 |
---
## 13. AI 检查
- [x] lexicon
- [x] 后端权威
- [x] 不扩开放登录
---
## 14. Implementation
| 项 | 内容 |
|---|---|
| Package | `apps/api/internal/textsafe` |
| Mirror | `@yuxingu/utils` `validateUserText`(即时提示) |
| Wire | auth · profile · ask · companion · growth · synastry accept |
+13
View File
@@ -98,6 +98,10 @@ CTA:节气陪伴 / 问答「生活节奏」/ 分享
| R5 | 推荐用语:体质倾向、生活建议、节律、平衡 | | R5 | 推荐用语:体质倾向、生活建议、节律、平衡 |
| R6 | 可展示五行隐喻,须标注探索/非医疗 | | R6 | 可展示五行隐喻,须标注探索/非医疗 |
| R7 | 与星象性格、个人画像内容可互相链,不互相覆盖主路径 | | R7 | 与星象性格、个人画像内容可互相链,不互相覆盖主路径 |
| R8 | **体质/五行倾向**随生日确定;**今日/本周生活建议**按**自然日(CST)**刷新(本周焦点随星期变) |
| R9 | GET `/reports/latest` 与 GET `/reports/{id}`type=rhythm):`as_of` ≠ 当日则**原 id 原地更新**时效段并落库;同日即时返回 |
| R10 | `summary``as_of` · `valid_until`(次日 00:00 CST);前端到点静默刷新;**不按十二时辰** |
| R11 | `summary.wuxing` 必含五行分布 `bars`(与愈心解码同源算法)及主/辅倾向;缺字段时 GET 原地补全 |
--- ---
@@ -108,6 +112,7 @@ CTA:节气陪伴 / 问答「生活节奏」/ 分享
| `growth_reports.type` | `rhythm` / `life_rhythm` | | `growth_reports.type` | `rhythm` / `life_rhythm` |
| 或 Constitution 专用表 | 若需独立聚合,实现时 ADR | | 或 Constitution 专用表 | 若需独立聚合,实现时 ADR |
| 禁止 | `luck_score`、病名诊断字段 | | 禁止 | `luck_score`、病名诊断字段 |
| 报告 JSON | `as_of` · `valid_until` · `today_tip` · `week_focus` |
--- ---
@@ -155,6 +160,14 @@ CTA:节气陪伴 / 问答「生活节奏」/ 分享
**When** 扫 summary+detail **When** 扫 summary+detail
**Then** 符合 lexicon **Then** 符合 lexicon
**Given** 已有 rhythm 报告且 `as_of` 为昨日
**When** GET latest
**Then** `today_tip` / `week_focus` 按今日更新;报告 `id` 不变
**Given** 用户停留在 `/rhythm` 跨自然日
**When** 到达 `valid_until`
**Then** 前端静默刷新建议文案
--- ---
## 13. AI 开发前检查 ## 13. AI 开发前检查
+1 -1
View File
@@ -118,7 +118,7 @@ analytics_daily_page(可选汇总)
- 卡片:DAU、新增、会话数、人均会话时长 - 卡片:DAU、新增、会话数、人均会话时长
- 图:日活趋势;页面均停留 TOP - 图:日活趋势;页面均停留 TOP
- 表:页面明细、退出页 TOP、点击 TOP - 表:页面明细、退出页 TOP、点击 TOP
- 漏斗:画像完成 → 深度 CTA → 支付成功 - 漏斗:探索列表 → 开始作答 → 量表完成 → 画像完成 → 深度 CTA → 支付成功
视觉:愈心谷色系;品牌仅 logo,无「运营后台」字样。 视觉:愈心谷色系;品牌仅 logo,无「运营后台」字样。
@@ -16,7 +16,7 @@
| In | Out | | In | Out |
|---|---| |---|---|
| Self / Other 档案创建与生日变更触发 | 无档案凭空出合盘 | | Self / Other 档案创建与生日变更触发 | 无档案凭空出合盘 |
| 单人:portrait · star · rhythm | 意象卡片抽牌(配额玩法,不预生成) | | 单人:portrait · star · rhythmstar/rhythm 运势与今日建议按自然日在 GET 时原地刷新,见各 Spec) | 意象卡片抽牌(配额玩法,不预生成) |
| 有 Self+Otherrelation + synastry 成对覆盖 | 附近的人合盘(进页再算) | | 有 Self+Otherrelation + synastry 成对覆盖 | 附近的人合盘(进页再算) |
--- ---
+13 -2
View File
@@ -107,6 +107,9 @@
| R9 | 附近的人默认 `geo_visible=false`;仅可见已开启位置的 self 档案 | | R9 | 附近的人默认 `geo_visible=false`;仅可见已开启位置的 self 档案 |
| R10 | 合盘星历走 Swiss Ephemeris(默认 Moshier);宫位制 Whole Sign | | R10 | 合盘星历走 Swiss Ephemeris(默认 Moshier);宫位制 Whole Sign |
| R11 | 合盘交叉校验:档案 A 为本账号 Self;档案 B 须为本账号 `other` 或附近可见他人 Self;拒绝 self×self / 同 id | | R11 | 合盘交叉校验:档案 A 为本账号 Self;档案 B 须为本账号 `other` 或附近可见他人 Self;拒绝 self×self / 同 id |
| R12 | **本命层**(轮盘/行星/相位/太阳月亮上升)随生日确定,不因打开时间重算;**运势层**(日/周/月/年 outlook · 行运)按**自然日(CST**刷新 |
| R13 | GET `/reports/latest` 与 GET `/reports/{id}`type=star):若 `summary.as_of` ≠ 当日则**原报告 id 原地更新**时效段并落库;同日再读即时返回;禁止为换日运新建报告 id(以免深度权益丢失) |
| R14 | `summary``as_of`YYYY-MM-DD)与 `valid_until`(次日 00:00 CST RFC3339);前端到点静默刷新;**不按十二时辰**切换 |
--- ---
@@ -119,7 +122,7 @@
| `profiles.birth_place` | 可选;常用城市表解析经纬度 | | `profiles.birth_place` | 可选;常用城市表解析经纬度 |
| `profiles.geo_lat/lng/visible` | 附近的人 | | `profiles.geo_lat/lng/visible` | 附近的人 |
| `synastry_invites` | 邀请 token · host/guest · report_id | | `synastry_invites` | 邀请 token · host/guest · report_id |
| 报告 JSON | `charts.*`(五主盘+推运)· `as_of` · 三指数 · fortune/transits | | 报告 JSON | `charts.*`(五主盘+推运)· `as_of` · `valid_until` · 三指数 · fortune/transits |
详见 erd;实现切片同步 OpenAPI。 详见 erd;实现切片同步 OpenAPI。
@@ -153,7 +156,7 @@
| Event | 触发 | | Event | 触发 |
|---|---| |---|---|
| `star_completed` / portrait_completed source=star | 基础结果展示 | | `star_completed` | 基础结果展示 |
| `star_wheel_viewed` | 点选轮盘行星 | | `star_wheel_viewed` | 点选轮盘行星 |
| `synastry_completed` | 合盘结果展示 | | `synastry_completed` | 合盘结果展示 |
| `synastry_invite_created` / `synastry_invite_accepted` | 邀请链路 | | `synastry_invite_created` / `synastry_invite_accepted` | 邀请链路 |
@@ -184,6 +187,14 @@
**When** 打开 `/star` **When** 打开 `/star`
**Then** 页内生日表单可生成 **Then** 页内生日表单可生成
**Given** 已有 star 报告且 `as_of` 为昨日
**When** GET latest
**Then** 日运等 outlook 按今日更新;报告 `id` 不变;响应含当日 `as_of``valid_until`
**Given** 用户停留在 `/star` 跨自然日
**When** 到达 `valid_until`
**Then** 前端静默刷新运势 Tab 内容
--- ---
## 13. AI 开发前检查 ## 13. AI 开发前检查
+2 -1
View File
@@ -7,7 +7,8 @@
- DB: PostgreSQL - DB: PostgreSQL
- Migrations: goose (or golang-migrate) under `apps/api/migrations/` - Migrations: goose (or golang-migrate) under `apps/api/migrations/`
- Log: structured (zap or slog wrapper). No `fmt.Println` in business paths. - Log: structured (zap or slog wrapper). No `fmt.Println` in business paths.
- Module path: `github.com/yuxingu/digital-psychology/apps/api` - Module path: `github.com/yuxingu/digital-psychology/apps/api`Go import 身份;**不**表示托管在 GitHub)
- Git host: `https://git.jackyu66.com/jack/digital-psychology.git`(唯一远程 `origin`;无 GitHub Actions
## User H5 ## User H5
-58
View File
@@ -1,58 +0,0 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
h5:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm run build:h5
- run: npm run test:h5
- name: Install Playwright Chromium
working-directory: apps/user-h5
run: npx playwright install --with-deps chromium
- run: npm run test:e2e -w @yuxingu/user-h5
env:
CI: true
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Unit tests (no integration DB)
working-directory: apps/api
run: go test $(go list ./... | grep -v /internal/integration) -count=1
ess-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ESS artifact presence (active ECR)
run: |
test -f docs/ECR/ECR-005-h5-openapi-hygiene.md
test -f docs/TASKS/TASK-20260805-ECR005.yaml
test -f docs/TEST_REPORT/ECR-005.md
test -f docs/STATE/ECR-005.md
echo "ESS docs gate OK"
- name: ess-validate when ESS_ROOT available
env:
ESS_ROOT: ${{ vars.ESS_ROOT }}
run: |
if [ -n "$ESS_ROOT" ] && [ -f "$ESS_ROOT/scripts/ess-validate.py" ]; then
python scripts/ess-validate.py --phase coding --ecr ECR-005 --task docs/TASKS/TASK-20260805-ECR005.yaml
else
echo "ESS_ROOT not configured in CI — skipped full ess-validate"
fi
+5
View File
@@ -17,6 +17,7 @@ dist/
apps/api/bin/ apps/api/bin/
apps/api/yuxingu-api apps/api/yuxingu-api
apps/api/tmp/ apps/api/tmp/
apps/api/data/
*.exe *.exe
# IDE # IDE
@@ -40,3 +41,7 @@ Thumbs.db
# Local secrets (never commit) # Local secrets (never commit)
apps/api/config.local.yaml apps/api/config.local.yaml
# Playwright / Vitest artifacts
**/test-results/
**/playwright-report/
+43 -21
View File
@@ -4,40 +4,62 @@ You are the software engineer for **愈心谷 (YuXinGu)**.
This file is for AI agents. This file is for AI agents.
## 0. ESS Intake Bootstrap(任何改码任务最先做)
`$ESS_ROOT/governance/SESSION_BOOTSTRAP.md`**先输出 ESS Mode 块**,再改文件或扩 Scope。
```text
ESS Mode: L0 | L1 | L2 | L2-BLOCKED | L3 | RETRO
Change: <one line>
ECR: <id|NONE>
Phase: <ARCHITECT|ENGINEER|REVIEWER|none>
Execution Context: <ECR id|TASK id|NONE>
Allowed: <...>
Forbidden: <...>
Action: CONTINUE | STOP
Reason: <...>
```
- `Action: STOP``L2-BLOCKED` → **禁止**改 `apps/` / `packages/` / `proto/` / `migrations/`
- 本仓 `ess_intake: strict`(见 `docs/PROJECT_RULES.md`):Retro ECR 须四字段;新主题 OUT OF SCOPE
- Solo multi-role 允许同 Session 串行换 Phase**同一 review turn 禁止 Approve + Modify**
## Load order (every task) ## Load order (every task)
1. `.ai/ai-contract.md` 1. **§0 ESS Mode 块**(上)→ `docs/PROJECT_PROFILE.md` · `docs/PROJECT_RULES.md`
2. `.ai/constitution.md` 2. `.ai/ai-contract.md`
3. `.ai/architecture.md` 3. `.ai/constitution.md`
4. `.ai/domain.md` (+ product/API tasks: `domain/domain-map.md`) 4. `.ai/architecture.md`
5. `.ai/forbidden.md` 5. `.ai/domain.md` (+ product/API tasks: `domain/domain-map.md`)
6. `.ai/definition-of-done.md`**完成标准(强制)**Done ≠ build 通过 6. `.ai/forbidden.md`
7. `.ai/environment.md` + `.ai/development.md`(本地)· 部署任务再读 `deployment.md` / `docker.md` 7. `.ai/definition-of-done.md`**完成标准(强制)**Done ≠ build 通过
8. `.ai/file-map.md` + `.ai/workflow.md` 8. `.ai/environment.md` + `.ai/development.md`(本地)· 部署任务再读 `deployment.md` / `docker.md`
9. Task rules: `.ai/coding.md` / `api.md` / `database.md` / `ui.md` / `security.md` / `testing.md` 9. `.ai/file-map.md` + `.ai/workflow.md`
10. **Product scope:** `.ai/product/lexicon.md` + `feature-map.md` + **`feature-design.md`** + 对应 **`feature-spec/<id>.md`** + `user-journey.md` + `page-tree.md` · 状态 `product/p1-status.md` 10. Task rules: `.ai/coding.md` / `api.md` / `database.md` / `ui.md` / `security.md` / `testing.md`
11. **Competitor parity / reverse engineering:** `.ai/design/reverse-engineering-spec.md`(强制 STEP 118 · Evidence First · 禁止 Demo 式省略)→ 再映射 lexicon / feature-spec 11. **Product scope:** `.ai/product/lexicon.md` + `feature-map.md` + **`feature-design.md`** + 对应 **`feature-spec/<id>.md`** + `user-journey.md` + `page-tree.md` · 状态 `product/p1-status.md`
12. **Any UI work:** `.ai/design/design-system.md` + `component-catalog.md` + matching `design/platform/*.md` (H5 default) 12. **Competitor parity / reverse engineering:** `.ai/design/reverse-engineering-spec.md`(强制 STEP 118 · Evidence First · 禁止 Demo 式省略)→ 再映射 lexicon / feature-spec
13. Matching **ADR** in `.ai/adr/` before changing stack or API style 13. **Any UI work:** `.ai/design/design-system.md` + `component-catalog.md` + matching `design/platform/*.md` (H5 default)
14. Prefer **patterns/** + **examples/** + **playbooks/**(含 `feature-spec` over inventing structure 14. Matching **ADR** in `.ai/adr/` before changing stack or API style
15. Before claiming Done: `.ai/definition-of-done.md` + `.ai/review.md` + `.ai/checklists/*` → 输出 **Review Report** 15. Prefer **patterns/** + **examples/** + **playbooks/**(含 `feature-spec` over inventing structure
16. Verify via `.ai/commands.md`(本地默认本机 Go/Vite + compose.dev 仅 DB 16. Before claiming Done: `.ai/definition-of-done.md` + `.ai/review.md` + `.ai/checklists/*` → 输出 **Review Report**
17. `prompts/` are optional helpers — not a substitute for rules above 17. Verify via `.ai/commands.md`(本地默认本机 Go/Vite + compose.dev 仅 DB
18. **ESS process (when L2+ / architecture / release):** `docs/PROJECT_PROFILE.md` → role from `$ESS_ROOT/agents/``docs/ECR/` · `docs/TASKS/` · `docs/HANDOFF/`;勿把 ESS 整树复制进仓 18. `prompts/` are optional helpers — not a substitute for rules above
19. **ESS process (L2+ / architecture / release):** `$ESS_ROOT/SKILL.md` · `SESSION_BOOTSTRAP` · role from `$ESS_ROOT/agents/``docs/ECR/` · `docs/TASKS/` · `docs/HANDOFF/`;勿把 ESS 整树复制进仓
## ESS dual-track ## ESS dual-track
| Concern | Source of truth | | Concern | Source of truth |
|---------|-----------------| |---------|-----------------|
| Lexicon · Feature Spec · DoD · domain · architecture freeze | `.ai/` | | Lexicon · Feature Spec · DoD · domain · architecture freeze | `.ai/` |
| Roles · ECR · Task Contract · Handoff · validate gates | ESS + `docs/` | | Intake Mode · Roles · ECR · Task · Handoff · validate | ESS + `docs/` |
```text ```text
Load Agent Profile: ARCHITECT | ENGINEER | REVIEWER | RELEASE_MANAGER Load Agent Profile: ARCHITECT | ENGINEER | REVIEWER | RELEASE_MANAGER
``` ```
- Cursor → ARCHITECT / REVIEWER(禁改生产实现) - Cursor Session 可 Solo 串行三角色;**当前 Phase 权限唯一**
- Engineer 实现前:Active Feature Spec +L2+Approved ECR + HANDOFF - ARCHITECT / REVIEWER phase:禁改生产实现;REVIEWER 禁 Approve+Modify 同 turn
- EngineerActive Feature Spec +L2+Approved ECR + HANDOFF;无 ECR → `L2-BLOCKED`
- `python scripts/ess-validate.py --phase <phase> --ecr ECR-xxx` - `python scripts/ess-validate.py --phase <phase> --ecr ECR-xxx`
## Hard constraints ## Hard constraints
@@ -65,6 +65,9 @@ function fmtMs(ms: number) {
} }
const funnelLabel: Record<string, string> = { const funnelLabel: Record<string, string> = {
scale_list_viewed: '探索列表',
scale_started: '开始作答',
scale_completed: '量表完成',
portrait_completed: '画像完成', portrait_completed: '画像完成',
deep_access_clicked: '深度 CTA', deep_access_clicked: '深度 CTA',
purchase_completed: '支付成功', purchase_completed: '支付成功',
+119
View File
@@ -0,0 +1,119 @@
// Package avatar stores and serves user profile photos.
package avatar
import (
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
const MaxBytes = 2 << 20 // 2 MiB
var (
ErrTooLarge = errors.New("图片过大,请选择 2MB 以内")
ErrBadType = errors.New("仅支持 JPG / PNG / WebP")
ErrEmpty = errors.New("请选择图片")
ErrBadName = errors.New("无效头像")
)
// Store writes multipart image under dir as {userID}{ext}; returns public API path.
func Store(dir string, userID uuid.UUID, fh *multipart.FileHeader) (publicPath string, err error) {
if fh == nil || fh.Size <= 0 {
return "", ErrEmpty
}
if fh.Size > MaxBytes {
return "", ErrTooLarge
}
ext, err := extFor(fh.Filename, fh.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
src, err := fh.Open()
if err != nil {
return "", err
}
defer src.Close()
name := userID.String() + ext
dstPath := filepath.Join(dir, name)
tmp := dstPath + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return "", err
}
n, copyErr := io.Copy(out, io.LimitReader(src, MaxBytes+1))
_ = out.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return "", copyErr
}
if n > MaxBytes {
_ = os.Remove(tmp)
return "", ErrTooLarge
}
// remove previous extensions for same user
for _, old := range []string{".jpg", ".jpeg", ".png", ".webp"} {
p := filepath.Join(dir, userID.String()+old)
if p != dstPath {
_ = os.Remove(p)
}
}
if err := os.Rename(tmp, dstPath); err != nil {
_ = os.Remove(tmp)
return "", err
}
return "/api/v1/media/avatars/" + name, nil
}
// ResolveAbs validates file name and returns absolute path under dir.
func ResolveAbs(dir, name string) (string, error) {
base := filepath.Base(name)
if base != name || strings.Contains(base, "..") {
return "", ErrBadName
}
ext := strings.ToLower(filepath.Ext(base))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
return "", ErrBadName
}
idPart := strings.TrimSuffix(base, ext)
if _, err := uuid.Parse(idPart); err != nil {
return "", ErrBadName
}
full := filepath.Join(dir, base)
if _, err := os.Stat(full); err != nil {
return "", ErrBadName
}
return full, nil
}
func extFor(filename, contentType string) (string, error) {
ct := strings.ToLower(contentType)
switch {
case strings.Contains(ct, "jpeg"), strings.Contains(ct, "jpg"):
return ".jpg", nil
case strings.Contains(ct, "png"):
return ".png", nil
case strings.Contains(ct, "webp"):
return ".webp", nil
}
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg":
return ".jpg", nil
case ".png":
return ".png", nil
case ".webp":
return ".webp", nil
default:
return "", fmt.Errorf("%w", ErrBadType)
}
}
+112
View File
@@ -0,0 +1,112 @@
package avatar
import (
"bytes"
"mime/multipart"
"net/textproto"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
)
func TestResolveAbsRejectsTraversal(t *testing.T) {
dir := t.TempDir()
id := uuid.New()
name := id.String() + ".jpg"
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := ResolveAbs(dir, "../"+name); err == nil {
t.Fatal("expected reject ..")
}
if _, err := ResolveAbs(dir, "not-a-uuid.jpg"); err == nil {
t.Fatal("expected reject bad id")
}
if _, err := ResolveAbs(dir, id.String()+".gif"); err == nil {
t.Fatal("expected reject gif")
}
full, err := ResolveAbs(dir, name)
if err != nil {
t.Fatal(err)
}
if filepath.Base(full) != name {
t.Fatalf("full=%s", full)
}
}
func TestStoreRejectsBadTypeAndSize(t *testing.T) {
dir := t.TempDir()
uid := uuid.New()
fhExe := mustFormFile(t, "x.exe", "application/octet-stream", []byte("MZ"))
if _, err := Store(dir, uid, fhExe); err == nil {
t.Fatal("expected bad type")
}
big := bytes.Repeat([]byte("a"), MaxBytes+10)
fhBig := mustFormFile(t, "big.jpg", "image/jpeg", big)
if _, err := Store(dir, uid, fhBig); err == nil {
t.Fatal("expected too large")
}
}
func TestStoreOKAndOverwrite(t *testing.T) {
dir := t.TempDir()
uid := uuid.New()
fh := mustFormFile(t, "a.png", "image/png", []byte{0x89, 0x50, 0x4e, 0x47})
path, err := Store(dir, uid, fh)
if err != nil {
t.Fatal(err)
}
want := "/api/v1/media/avatars/" + uid.String() + ".png"
if path != want {
t.Fatalf("path=%s want=%s", path, want)
}
if _, err := os.Stat(filepath.Join(dir, uid.String()+".png")); err != nil {
t.Fatal(err)
}
fh2 := mustFormFile(t, "b.jpg", "image/jpeg", []byte{0xff, 0xd8, 0xff})
path2, err := Store(dir, uid, fh2)
if err != nil {
t.Fatal(err)
}
if path2 != "/api/v1/media/avatars/"+uid.String()+".jpg" {
t.Fatalf("path2=%s", path2)
}
if _, err := os.Stat(filepath.Join(dir, uid.String()+".png")); !os.IsNotExist(err) {
t.Fatal("old png should be removed")
}
}
func mustFormFile(t *testing.T, filename, contentType string, data []byte) *multipart.FileHeader {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`)
h.Set("Content-Type", contentType)
part, err := w.CreatePart(h)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
r := multipart.NewReader(&buf, w.Boundary())
form, err := r.ReadForm(int64(len(data)) + 1024)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = form.RemoveAll() })
files := form.File["file"]
if len(files) == 0 {
t.Fatal("no file part")
}
return files[0]
}
+56 -17
View File
@@ -1,6 +1,8 @@
// Package explore provides the L1→L3 explore catalog. // Package explore provides the L1→L3 explore catalog.
package explore package explore
import "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
// Item is a leaf tool in the catalog. // Item is a leaf tool in the catalog.
type Item struct { type Item struct {
Key string `json:"key"` Key string `json:"key"`
@@ -22,8 +24,8 @@ type Category struct {
Items []Item `json:"items"` Items []Item `json:"items"`
} }
// Catalog returns the full explore tree (deterministic, no DB). // Catalog returns the full explore tree (deterministic base; tests filled by WithPublishedScales).
// Order: 四核心优先,量表后置。 // Order: 解码 → 量表题库 → 星座 / 匹配 / 节律 …
func Catalog() []Category { func Catalog() []Category {
return []Category{ return []Category{
{ {
@@ -33,6 +35,11 @@ func Catalog() []Category {
{Key: "portrait", Title: "愈心解码", Description: "生日生成性格解码报告", Path: "/portrait", Icon: "△", Tone: "gold", Badge: "热"}, {Key: "portrait", Title: "愈心解码", Description: "生日生成性格解码报告", Path: "/portrait", Icon: "△", Tone: "gold", Badge: "热"},
}, },
}, },
{
Key: "tests", Title: "量表题库", Description: "人格与偏好测评(已发布)",
Icon: "◆", Tone: "blue",
Items: defaultScaleItems(),
},
{ {
Key: "star", Title: "星座", Description: "排盘 · 运势 · 合盘", Key: "star", Title: "星座", Description: "排盘 · 运势 · 合盘",
Icon: "✦", Tone: "night", Icon: "✦", Tone: "night",
@@ -72,24 +79,56 @@ func Catalog() []Category {
{Key: "ask", Title: "AI 成长助手", Description: "结合档案整理感受", Path: "/ask", Icon: "◎", Tone: "gold"}, {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"},
},
},
} }
} }
func defaultScaleItems() []Item {
return []Item{
{Key: "mbti-lite", Title: "MBTI测试", Description: "标准版 32 题免费 · 完整版会员", Path: "/scales/mbti-lite", Icon: "◆", Tone: "blue", Badge: "热"},
{Key: "mbti-full", Title: "MBTI完整版", Description: "60 题 · 成长会员", Path: "/scales/mbti-full", Icon: "◆", Tone: "gold"},
{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"},
}
}
// WithPublishedScales replaces the 量表题库 items with live published scales from DB.
func WithPublishedScales(cats []Category, scales []repository.ScaleListItem) []Category {
if len(scales) == 0 {
return cats
}
items := make([]Item, 0, len(scales))
for _, s := range scales {
badge := ""
tone := "blue"
switch s.Slug {
case "mbti-lite":
badge = "热"
case "mbti-full":
tone = "gold"
}
items = append(items, Item{
Key: s.Slug, Title: s.Title, Description: s.Description,
Path: "/scales/" + s.Slug, Icon: "◆", Tone: tone, Badge: badge,
})
}
out := make([]Category, len(cats))
copy(out, cats)
for i := range out {
if out[i].Key == "tests" {
out[i].Title = "量表题库"
out[i].Description = "人格与偏好测评(已发布)"
out[i].Items = items
}
}
return out
}
// CategoryByKey returns one category or nil. // CategoryByKey returns one category or nil.
func CategoryByKey(key string) *Category { func CategoryByKey(key string) *Category {
for _, c := range Catalog() { for _, c := range Catalog() {
+22 -42
View File
@@ -1,58 +1,38 @@
package explore package explore
import ( import (
"strings"
"testing" "testing"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
) )
func TestCatalogShape(t *testing.T) { func TestCatalogHasScaleBankNearTop(t *testing.T) {
cats := Catalog() cats := Catalog()
if len(cats) < 6 { if len(cats) < 2 {
t.Fatalf("want ≥6 L1 categories, got %d", len(cats)) t.Fatal("expected categories")
} }
keys := map[string]bool{} if cats[1].Key != "tests" || cats[1].Title != "量表题库" {
for _, c := range cats { t.Fatalf("second cat=%#v want 量表题库", cats[1])
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 len(cats[1].Items) < 5 {
if !keys[k] { t.Fatalf("expected scale items, got %d", len(cats[1].Items))
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 { func TestWithPublishedScalesReplacesItems(t *testing.T) {
for i, c := range cats { cats := Catalog()
if c.Key == key { out := WithPublishedScales(cats, nil)
return i if len(out[1].Items) < 5 {
} t.Fatal("nil scales should keep defaults")
} }
return -1 out = WithPublishedScales(cats, []repository.ScaleListItem{
} {Slug: "mbti-lite", Title: "MBTI测试", Description: "from-db"},
{Slug: "eq-lite", Title: "情绪觉察探索", Description: "eq"},
func TestLexiconHardBanOnly(t *testing.T) { })
blob := "" if len(out[1].Items) != 2 {
for _, c := range Catalog() { t.Fatalf("items=%d", len(out[1].Items))
blob += c.Title + c.Description
for _, it := range c.Items {
blob += it.Title + it.Description
}
} }
for _, bad := range []string{"占卜", "算命", "测测"} { if out[1].Items[0].Description != "from-db" || out[1].Items[0].Badge != "热" {
if strings.Contains(blob, bad) { t.Fatalf("unexpected %#v", out[1].Items[0])
t.Fatalf("forbidden %q", bad)
}
} }
} }
+11 -1
View File
@@ -2,6 +2,7 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -11,6 +12,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware" "github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
asksvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask" asksvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response" "github.com/yuxingu/digital-psychology/apps/api/pkg/response"
) )
@@ -67,6 +69,9 @@ func (h *AskHandler) CreateThread(c *gin.Context) {
ProfileID: pid, Scene: req.Scene, ProfileID: pid, Scene: req.Scene,
}) })
if err != nil { if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusBadRequest, 40010, err.Error()) response.Fail(c, http.StatusBadRequest, 40010, err.Error())
return return
} }
@@ -142,6 +147,9 @@ func (h *AskHandler) SendMessage(c *gin.Context) {
out, err := h.Svc.SendMessage(c.Request.Context(), userID, tid, req.Content) out, err := h.Svc.SendMessage(c.Request.Context(), userID, tid, req.Content)
if err != nil { if err != nil {
if failTextCompliance(c, err) {
return
}
if asksvc.IsQuotaExhausted(err) { if asksvc.IsQuotaExhausted(err) {
response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可购买额度或开通成长会员") response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可购买额度或开通成长会员")
return return
@@ -180,7 +188,9 @@ func (h *AskHandler) sendMessageStream(c *gin.Context, userID, tid uuid.UUID, co
if err != nil { if err != nil {
msg := err.Error() msg := err.Error()
code := 40011 code := 40011
if asksvc.IsQuotaExhausted(err) { if errors.Is(err, textsafe.ErrRejected) {
code = 40060
} else if asksvc.IsQuotaExhausted(err) {
msg = "问答次数已用完,可购买额度或开通成长会员" msg = "问答次数已用完,可购买额度或开通成长会员"
code = 40210 code = 40210
} }
+27
View File
@@ -24,6 +24,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) {
g.POST("/logout", h.Logout) g.POST("/logout", h.Logout)
g.GET("/me", h.Me) g.GET("/me", h.Me)
g.PATCH("/me", h.PatchMe) g.PATCH("/me", h.PatchMe)
g.POST("/me/avatar", h.UploadAvatar)
} }
type authBody struct { type authBody struct {
@@ -47,6 +48,9 @@ func (h *AuthHandler) RegisterAccount(c *gin.Context) {
deviceKey := c.GetHeader(middleware.DeviceKeyHeader) deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
res, err := h.Svc.Register(c.Request.Context(), userID, deviceKey, body.Phone, body.Password, body.Nickname) res, err := h.Svc.Register(c.Request.Context(), userID, deviceKey, body.Phone, body.Password, body.Nickname)
if err != nil { if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusBadRequest, 40110, err.Error()) response.Fail(c, http.StatusBadRequest, 40110, err.Error())
return return
} }
@@ -116,12 +120,35 @@ func (h *AuthHandler) PatchMe(c *gin.Context) {
} }
me, err := h.Svc.UpdateNickname(c.Request.Context(), userID, body.Nickname) me, err := h.Svc.UpdateNickname(c.Request.Context(), userID, body.Nickname)
if err != nil { if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusBadRequest, 40113, err.Error()) response.Fail(c, http.StatusBadRequest, 40113, err.Error())
return return
} }
response.OK(c, me) response.OK(c, me)
} }
// UploadAvatar handles POST /auth/me/avatar (multipart file).
func (h *AuthHandler) UploadAvatar(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
fh, err := c.FormFile("file")
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "请选择图片")
return
}
me, err := h.Svc.UpdateAvatar(c.Request.Context(), userID, fh)
if err != nil {
response.Fail(c, http.StatusBadRequest, 40114, err.Error())
return
}
response.OK(c, me)
}
func bearerToken(c *gin.Context) string { func bearerToken(c *gin.Context) string {
h := c.GetHeader("Authorization") h := c.GetHeader("Authorization")
if strings.HasPrefix(strings.ToLower(h), "bearer ") { if strings.HasPrefix(strings.ToLower(h), "bearer ") {
+3
View File
@@ -55,6 +55,9 @@ func (h *CompanionHandler) SaveMood(c *gin.Context) {
} }
m, err := h.Svc.SaveMood(c.Request.Context(), userID, in) m, err := h.Svc.SaveMood(c.Request.Context(), userID, in)
if err != nil { if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusBadRequest, 30010, err.Error()) response.Fail(c, http.StatusBadRequest, 30010, err.Error())
return return
} }
+20 -2
View File
@@ -6,11 +6,14 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/explore" "github.com/yuxingu/digital-psychology/apps/api/internal/explore"
"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/pkg/response"
) )
// ExploreHandler serves the explore catalog. // ExploreHandler serves the explore catalog.
type ExploreHandler struct{} type ExploreHandler struct {
Scales *scale.Service
}
// Register mounts explore routes. // Register mounts explore routes.
func (h *ExploreHandler) Register(rg *gin.RouterGroup) { func (h *ExploreHandler) Register(rg *gin.RouterGroup) {
@@ -20,7 +23,13 @@ func (h *ExploreHandler) Register(rg *gin.RouterGroup) {
// Catalog handles GET /explore/catalog. // Catalog handles GET /explore/catalog.
func (h *ExploreHandler) Catalog(c *gin.Context) { func (h *ExploreHandler) Catalog(c *gin.Context) {
response.OK(c, gin.H{"categories": explore.Catalog()}) cats := explore.Catalog()
if h.Scales != nil {
if items, err := h.Scales.List(c.Request.Context()); err == nil {
cats = explore.WithPublishedScales(cats, items)
}
}
response.OK(c, gin.H{"categories": cats})
} }
// Category handles GET /explore/catalog/:key. // Category handles GET /explore/catalog/:key.
@@ -30,5 +39,14 @@ func (h *ExploreHandler) Category(c *gin.Context) {
response.Fail(c, http.StatusNotFound, 40402, "category not found") response.Fail(c, http.StatusNotFound, 40402, "category not found")
return return
} }
if cat.Key == "tests" && h.Scales != nil {
if items, err := h.Scales.List(c.Request.Context()); err == nil {
enriched := explore.WithPublishedScales([]explore.Category{*cat}, items)
if len(enriched) > 0 {
response.OK(c, enriched[0])
return
}
}
}
response.OK(c, cat) response.OK(c, cat)
} }
+22 -5
View File
@@ -2,7 +2,6 @@ package handler
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -10,6 +9,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware" "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/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response" "github.com/yuxingu/digital-psychology/apps/api/pkg/response"
) )
@@ -57,12 +57,17 @@ func (h *GrowthHandler) CreatePlan(c *gin.Context) {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request") response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return return
} }
title := strings.TrimSpace(req.Title) title, err := textsafe.Check(textsafe.Title, req.Title)
if title == "" || len([]rune(title)) > 40 { if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid title") _ = failTextCompliance(c, err)
return return
} }
p, err := h.Plans.CreatePlan(c.Request.Context(), userID, title, strings.TrimSpace(req.Focus)) focus, err := textsafe.Check(textsafe.Focus, req.Focus)
if err != nil {
_ = failTextCompliance(c, err)
return
}
p, err := h.Plans.CreatePlan(c.Request.Context(), userID, title, focus)
if err != nil { if err != nil {
response.Fail(c, http.StatusBadRequest, 30020, err.Error()) response.Fail(c, http.StatusBadRequest, 30020, err.Error())
return return
@@ -86,6 +91,18 @@ func (h *GrowthHandler) Checkin(c *gin.Context) {
Note *string `json:"note"` Note *string `json:"note"`
} }
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
if req.Note != nil {
n, err := textsafe.Check(textsafe.Note, *req.Note)
if err != nil {
_ = failTextCompliance(c, err)
return
}
if n == "" {
req.Note = nil
} else {
req.Note = &n
}
}
out, err := h.Plans.Checkin(c.Request.Context(), userID, pid, time.Now(), req.Note) out, err := h.Plans.Checkin(c.Request.Context(), userID, pid, time.Now(), req.Note)
if err != nil { if err != nil {
response.Fail(c, http.StatusBadRequest, 30021, err.Error()) response.Fail(c, http.StatusBadRequest, 30021, err.Error())
+36
View File
@@ -0,0 +1,36 @@
package handler
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
)
// MediaHandler serves uploaded media files.
type MediaHandler struct {
AvatarDir string
}
// Register mounts public media routes.
func (h *MediaHandler) Register(api *gin.RouterGroup) {
api.GET("/media/avatars/:file", h.ServeAvatar)
}
// ServeAvatar streams a stored avatar image.
func (h *MediaHandler) ServeAvatar(c *gin.Context) {
dir := h.AvatarDir
if dir == "" {
dir = "data/avatars"
}
full, err := avatar.ResolveAbs(dir, c.Param("file"))
if err != nil {
c.Status(http.StatusNotFound)
return
}
c.Header("Cache-Control", "public, max-age=86400")
c.File(full)
_ = filepath.Ext(full)
}
+6
View File
@@ -57,6 +57,9 @@ func (h *ProfileHandler) Create(c *gin.Context) {
RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace, RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
}) })
if err != nil { if err != nil {
if failTextCompliance(c, err) {
return
}
if errors.Is(err, profile.ErrSelfExists) { if errors.Is(err, profile.ErrSelfExists) {
response.Fail(c, http.StatusConflict, 40902, err.Error()) response.Fail(c, http.StatusConflict, 40902, err.Error())
return return
@@ -122,6 +125,9 @@ func (h *ProfileHandler) Update(c *gin.Context) {
GeoLat: req.GeoLat, GeoLng: req.GeoLng, GeoVisible: req.GeoVisible, GeoLat: req.GeoLat, GeoLng: req.GeoLng, GeoVisible: req.GeoVisible,
}) })
if err != nil { if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusNotFound, 40401, err.Error()) response.Fail(c, http.StatusNotFound, 40401, err.Error())
return return
} }
+49 -1
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -19,10 +20,33 @@ type ScaleHandler struct {
// Register mounts routes. // Register mounts routes.
func (h *ScaleHandler) Register(rg *gin.RouterGroup) { func (h *ScaleHandler) Register(rg *gin.RouterGroup) {
rg.GET("/scales", h.List) rg.GET("/scales", h.List)
rg.GET("/scale-bank/catalog", h.BankCatalog)
rg.GET("/scale-bank/categories/:key", h.BankCategory)
rg.GET("/scales/:slug", h.Get) rg.GET("/scales/:slug", h.Get)
rg.GET("/scales/:slug/result", h.LatestResult)
rg.POST("/scales/:slug/result", h.Submit) rg.POST("/scales/:slug/result", h.Submit)
} }
// BankCatalog handles GET /scale-bank/catalog.
func (h *ScaleHandler) BankCatalog(c *gin.Context) {
out, err := h.Svc.BankCatalog()
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50003, "bank catalog failed")
return
}
response.OK(c, out)
}
// BankCategory handles GET /scale-bank/categories/:key.
func (h *ScaleHandler) BankCategory(c *gin.Context) {
cat, items, err := h.Svc.BankCategory(c.Param("key"))
if err != nil {
response.Fail(c, http.StatusNotFound, 40402, "category not found")
return
}
response.OK(c, gin.H{"category": cat, "items": items})
}
// List handles GET /scales. // List handles GET /scales.
func (h *ScaleHandler) List(c *gin.Context) { func (h *ScaleHandler) List(c *gin.Context) {
items, err := h.Svc.List(c.Request.Context()) items, err := h.Svc.List(c.Request.Context())
@@ -35,7 +59,8 @@ func (h *ScaleHandler) List(c *gin.Context) {
// Get handles GET /scales/:slug. // Get handles GET /scales/:slug.
func (h *ScaleHandler) Get(c *gin.Context) { func (h *ScaleHandler) Get(c *gin.Context) {
d, err := h.Svc.Get(c.Request.Context(), c.Param("slug")) userID, _ := middleware.UserIDFromContext(c)
d, err := h.Svc.Get(c.Request.Context(), userID, c.Param("slug"))
if err != nil { if err != nil {
response.Fail(c, http.StatusNotFound, 40402, err.Error()) response.Fail(c, http.StatusNotFound, 40402, err.Error())
return return
@@ -43,6 +68,25 @@ func (h *ScaleHandler) Get(c *gin.Context) {
response.OK(c, d) response.OK(c, d)
} }
// LatestResult handles GET /scales/:slug/result.
func (h *ScaleHandler) LatestResult(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
out, err := h.Svc.LatestResult(c.Request.Context(), userID, c.Param("slug"))
if err != nil {
if err.Error() == "scale not found" {
response.Fail(c, http.StatusNotFound, 40402, err.Error())
return
}
response.Fail(c, http.StatusNotFound, 40411, "result not found")
return
}
response.OK(c, out)
}
// Submit handles POST /scales/:slug/result. // Submit handles POST /scales/:slug/result.
func (h *ScaleHandler) Submit(c *gin.Context) { func (h *ScaleHandler) Submit(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c) userID, ok := middleware.UserIDFromContext(c)
@@ -67,6 +111,10 @@ func (h *ScaleHandler) Submit(c *gin.Context) {
ProfileID: pid, Answers: req.Answers, ProfileID: pid, Answers: req.Answers,
}) })
if err != nil { if err != nil {
if errors.Is(err, scale.ErrMembershipRequired) {
response.Fail(c, http.StatusForbidden, 40310, "需要成长会员")
return
}
response.Fail(c, http.StatusBadRequest, 30006, err.Error()) response.Fail(c, http.StatusBadRequest, 30006, err.Error())
return return
} }
+3
View File
@@ -116,6 +116,9 @@ func (h *SynastryHandler) AcceptInvite(c *gin.Context) {
} }
rep, err := h.Svc.AcceptInvite(c.Request.Context(), userID, c.Param("token"), req.DisplayName, req.BirthDate, req.BirthTime, req.BirthPlace) rep, err := h.Svc.AcceptInvite(c.Request.Context(), userID, c.Param("token"), req.DisplayName, req.BirthDate, req.BirthTime, req.BirthPlace)
if err != nil { if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusBadRequest, 30002, err.Error()) response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return return
} }
+23
View File
@@ -0,0 +1,23 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// failTextCompliance maps textsafe rejections to HTTP 400 / code 40060.
func failTextCompliance(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, textsafe.ErrRejected) {
response.Fail(c, http.StatusBadRequest, 40060, err.Error())
return true
}
return false
}
+5 -3
View File
@@ -54,7 +54,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
membershipSvc := &membership.Service{Reports: reportRepo} membershipSvc := &membership.Service{Reports: reportRepo}
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo} relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
scaleRepo := &repository.ScaleRepo{Pool: pool} scaleRepo := &repository.ScaleRepo{Pool: pool}
scaleSvc := &scale.Service{Repo: scaleRepo, Profiles: profileRepo} scaleSvc := &scale.Service{Repo: scaleRepo, Profiles: profileRepo, Membership: membershipSvc}
askSvc := &ask.Service{Profiles: profileRepo, Reports: reportRepo, Ask: askRepo, LLM: llm} askSvc := &ask.Service{Profiles: profileRepo, Reports: reportRepo, Ask: askRepo, LLM: llm}
companionSvc := &companionsvc.Service{Moods: &repository.MoodRepo{Pool: pool}} companionSvc := &companionsvc.Service{Moods: &repository.MoodRepo{Pool: pool}}
imageCardSvc := &imagecardsvc.Service{ imageCardSvc := &imagecardsvc.Service{
@@ -64,6 +64,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
} }
homeSvc := &homesvc.Service{ homeSvc := &homesvc.Service{
Repo: &repository.HomeToolsRepo{Pool: pool}, Repo: &repository.HomeToolsRepo{Pool: pool},
Tips: &repository.HomeDailyTipsRepo{Pool: pool},
Profiles: profileRepo, Profiles: profileRepo,
LLM: llm, LLM: llm,
} }
@@ -71,7 +72,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
adminSvc := &adminsvc.Service{ adminSvc := &adminsvc.Service{
Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo, Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo,
} }
authSvc := &authsvc.Service{Repo: authRepo} authSvc := &authsvc.Service{Repo: authRepo, AvatarDir: "data/avatars"}
if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{ if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{
Username: cfg.Admin.BootstrapUsername, Username: cfg.Admin.BootstrapUsername,
Password: cfg.Admin.BootstrapPassword, Password: cfg.Admin.BootstrapPassword,
@@ -91,6 +92,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
api.GET("/ping", func(c *gin.Context) { api.GET("/ping", func(c *gin.Context) {
response.OK(c, gin.H{"pong": true}) response.OK(c, gin.H{"pong": true})
}) })
(&handler.MediaHandler{AvatarDir: "data/avatars"}).Register(api)
(&handler.AdminHandler{Svc: adminSvc, Analytics: analyticsSvc}).Register(api) (&handler.AdminHandler{Svc: adminSvc, Analytics: analyticsSvc}).Register(api)
authed := api.Group("") authed := api.Group("")
@@ -109,7 +111,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
(&handler.AskHandler{Svc: askSvc}).Register(gated) (&handler.AskHandler{Svc: askSvc}).Register(gated)
(&handler.CompanionHandler{Svc: companionSvc}).Register(gated) (&handler.CompanionHandler{Svc: companionSvc}).Register(gated)
(&handler.ImageCardHandler{Svc: imageCardSvc}).Register(gated) (&handler.ImageCardHandler{Svc: imageCardSvc}).Register(gated)
(&handler.ExploreHandler{}).Register(gated) (&handler.ExploreHandler{Scales: scaleSvc}).Register(gated)
(&handler.GrowthHandler{ (&handler.GrowthHandler{
Plans: &repository.GrowthRepo{Pool: pool}, Plans: &repository.GrowthRepo{Pool: pool},
}).Register(gated) }).Register(gated)
@@ -43,3 +43,49 @@ func TestGrowthPlanCheckin(t *testing.T) {
t.Fatal("expected checkin") t.Fatal("expected checkin")
} }
} }
func TestScaleBankResultRoundtrip(t *testing.T) {
r, _ := setupAPI(t)
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodGet, "/api/v1/scale-bank/catalog", nil, key)
cat := decodeData[map[string]any](t, env.Data)
featured, _ := cat["featured"].([]any)
if len(featured) < 1 {
t.Fatal("expected featured bank scales")
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1991-07-07", "display_name": "我",
}, key)
profile := decodeData[map[string]any](t, env.Data)
profileID, _ := profile["id"].(string)
env, key = doJSON(t, r, http.MethodGet, "/api/v1/scales/sm1", nil, key)
detail := decodeData[map[string]any](t, env.Data)
qs, _ := detail["questions"].([]any)
if len(qs) < 1 {
t.Fatal("expected sm1 questions")
}
answers := map[string]string{}
for _, raw := range qs {
q, _ := raw.(map[string]any)
id, _ := q["id"].(string)
answers[id] = "3"
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/scales/sm1/result", map[string]any{
"profile_id": profileID, "answers": answers,
}, key)
submitted := decodeData[map[string]any](t, env.Data)
if submitted["result"] == nil {
t.Fatalf("missing result: %#v", submitted)
}
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/scales/sm1/result", nil, key)
latest := decodeData[map[string]any](t, env.Data)
res, ok := latest["result"].(map[string]any)
if !ok || res["label"] == nil || res["label"] == "" {
t.Fatalf("expected latest label, got %#v", latest)
}
}
+1 -11
View File
@@ -30,7 +30,6 @@ func Build(birth time.Time, displayName string) Output {
bz := getBazi(y, int(m), d, 12) bz := getBazi(y, int(m), d, 12)
pct := calc5E(bz) pct := calc5E(bz)
strong := strongestEl(pct) strong := strongestEl(pct)
weak := weakestEl(pct)
cst := constitutionByElement[strong] cst := constitutionByElement[strong]
emo := emotionByElement[strong] emo := emotionByElement[strong]
@@ -68,16 +67,7 @@ func Build(birth time.Time, displayName string) Output {
"lock_teaser_wuxing": "体质特征、调养要点、易感倾向、食疗与作息节律,解锁后查看完整方案。", "lock_teaser_wuxing": "体质特征、调养要点、易感倾向、食疗与作息节律,解锁后查看完整方案。",
"pattern_key": main, "pattern_key": main,
"pyramid": tri.PyramidMap(), "pyramid": tri.PyramidMap(),
"wuxing": map[string]any{ "wuxing": WuxingBlock(birth),
"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, "today_tip": tip,
"dimensions": dims, "dimensions": dims,
"strengths_preview": []string{ "strengths_preview": []string{
+21
View File
@@ -115,6 +115,27 @@ func wuxingBars(p map[string]int) []map[string]any {
return bars return bars
} }
// WuxingBlock builds the shared 五行 summary block (bars + tendency labels).
func WuxingBlock(birth time.Time) map[string]any {
y, m, d := birth.Date()
bz := getBazi(y, int(m), d, 12)
pct := calc5E(bz)
strong := strongestEl(pct)
weak := weakestEl(pct)
cst := constitutionByElement[strong]
emo := emotionByElement[strong]
return 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,
}
}
func todayTip(now time.Time) map[string]any { func todayTip(now time.Time) map[string]any {
st := companion.TodaySolar(now) st := companion.TodaySolar(now)
el := seasonEl[int(now.Month())-1] el := seasonEl[int(now.Month())-1]
+14 -12
View File
@@ -21,38 +21,35 @@ type AccountRow struct {
Phone string Phone string
PasswordHash string PasswordHash string
Nickname string Nickname string
AvatarURL string
Status string Status string
} }
// GetByPhone loads a registered user by phone. // GetByPhone loads a registered user by phone.
func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) { func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) {
row := &AccountRow{} row := &AccountRow{}
var nick *string
err := r.Pool.QueryRow(ctx, ` err := r.Pool.QueryRow(ctx, `
SELECT id, phone, password_hash, COALESCE(nickname,''), status SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status
FROM users FROM users
WHERE phone=$1 AND deleted_at IS NULL`, phone, WHERE phone=$1 AND deleted_at IS NULL`, phone,
).Scan(&row.ID, &row.Phone, &row.PasswordHash, &nick, &row.Status) ).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, err return nil, err
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
if nick != nil {
row.Nickname = *nick
}
return row, nil return row, nil
} }
// GetAccount loads account fields for a user id. // GetAccount loads account fields for a user id.
func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRow, error) { func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRow, error) {
row := &AccountRow{} row := &AccountRow{}
var phone, hash, nick *string var phone, hash *string
err := r.Pool.QueryRow(ctx, ` err := r.Pool.QueryRow(ctx, `
SELECT id, phone, password_hash, nickname, status SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status
FROM users WHERE id=$1 AND deleted_at IS NULL`, userID, FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
).Scan(&row.ID, &phone, &hash, &nick, &row.Status) ).Scan(&row.ID, &phone, &hash, &row.Nickname, &row.AvatarURL, &row.Status)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -62,9 +59,6 @@ func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRo
if hash != nil { if hash != nil {
row.PasswordHash = *hash row.PasswordHash = *hash
} }
if nick != nil {
row.Nickname = *nick
}
return row, nil return row, nil
} }
@@ -105,6 +99,14 @@ func (r *AuthRepo) UpdateNickname(ctx context.Context, userID uuid.UUID, nicknam
return err return err
} }
// UpdateAvatarURL sets users.avatar_url.
func (r *AuthRepo) UpdateAvatarURL(ctx context.Context, userID uuid.UUID, url string) error {
_, err := r.Pool.Exec(ctx, `
UPDATE users SET avatar_url=$2, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL`, userID, url)
return err
}
// TouchPassword updates stored password hash (open-login record). // TouchPassword updates stored password hash (open-login record).
func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error { func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error {
_, err := r.Pool.Exec(ctx, ` _, err := r.Pool.Exec(ctx, `
@@ -0,0 +1,52 @@
package repository
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// HomeDailyTipsRepo persists per-时辰 homepage tips.
type HomeDailyTipsRepo struct {
Pool *pgxpool.Pool
}
// GetTips returns cached tips JSON for user + shichen start.
func (r *HomeDailyTipsRepo) GetTips(ctx context.Context, userID uuid.UUID, start time.Time) (json.RawMessage, string, error) {
var raw json.RawMessage
var source string
err := r.Pool.QueryRow(ctx, `
SELECT tips, source FROM home_daily_tips
WHERE user_id=$1 AND shichen_start=$2`, userID, start,
).Scan(&raw, &source)
if err != nil {
return nil, "", err
}
return raw, source, nil
}
// UpsertTips stores tips for user × shichen.
func (r *HomeDailyTipsRepo) UpsertTips(
ctx context.Context,
userID uuid.UUID,
start time.Time,
shichen int,
tips json.RawMessage,
source string,
) error {
_, err := r.Pool.Exec(ctx, `
INSERT INTO home_daily_tips(user_id, shichen_start, shichen, tips, source)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (user_id, shichen_start) DO UPDATE
SET tips=EXCLUDED.tips, source=EXCLUDED.source, shichen=EXCLUDED.shichen`,
userID, start, shichen, tips, source,
)
return err
}
// ErrNoTips is pgx.ErrNoRows alias for callers.
var ErrNoTips = pgx.ErrNoRows
@@ -68,6 +68,21 @@ func (r *ReportRepo) UpsertWithPeer(ctx context.Context, userID, profileID uuid.
return r.CreateWithPeer(ctx, userID, profileID, peer, typ, summary, detail) return r.CreateWithPeer(ctx, userID, profileID, peer, typ, summary, detail)
} }
// UpdateContent patches summary/detail in place (keeps report id for deep access).
func (r *ReportRepo) UpdateContent(ctx context.Context, userID, reportID uuid.UUID, summary, detail json.RawMessage) error {
tag, err := r.Pool.Exec(ctx, `
UPDATE growth_reports SET summary=$3, detail=$4, updated_at=now()
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
reportID, userID, summary, detail)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
// GetLatest returns newest non-deleted report for profile+type(+peer). // GetLatest returns newest non-deleted report for profile+type(+peer).
func (r *ReportRepo) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) { func (r *ReportRepo) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
rep := &model.GrowthReport{} rep := &model.GrowthReport{}
@@ -28,6 +28,8 @@ type ScaleDetail struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Access string `json:"access,omitempty"` // free | membership
Locked bool `json:"locked,omitempty"`
Questions []ScaleQuestion `json:"questions"` Questions []ScaleQuestion `json:"questions"`
} }
@@ -171,6 +173,24 @@ func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID u
return id, err return id, err
} }
// LatestResult returns the newest result for user + published slug.
func (r *ScaleRepo) LatestResult(ctx context.Context, userID uuid.UUID, slug string) (*ScaleResultRow, error) {
var row ScaleResultRow
err := r.Pool.QueryRow(ctx, `
SELECT sr.id, s.slug, sr.result
FROM scale_results sr
JOIN scales s ON s.id = sr.scale_id
WHERE sr.user_id = $1 AND s.slug = $2 AND s.deleted_at IS NULL
AND sr.deleted_at IS NULL
ORDER BY sr.created_at DESC
LIMIT 1`, userID, slug,
).Scan(&row.ID, &row.ScaleSlug, &row.Result)
if err != nil {
return nil, err
}
return &row, nil
}
// ScaleIDBySlug resolves id for a published scale. // ScaleIDBySlug resolves id for a published scale.
func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) { func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) {
var id uuid.UUID var id uuid.UUID
@@ -179,3 +199,20 @@ func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID,
WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug).Scan(&id) WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug).Scan(&id)
return id, err return id, err
} }
// EnsurePublished upserts scale metadata (curated bank stubs; questions served from embed).
func (r *ScaleRepo) EnsurePublished(ctx context.Context, slug, title, description string) (uuid.UUID, error) {
var id uuid.UUID
err := r.Pool.QueryRow(ctx, `
INSERT INTO scales (slug, title, description, status)
VALUES ($1,$2,$3,'published')
ON CONFLICT (slug) DO UPDATE SET
title=EXCLUDED.title,
description=EXCLUDED.description,
status='published',
updated_at=now(),
deleted_at=NULL
RETURNING id`, slug, title, description,
).Scan(&id)
return id, err
}
+38 -11
View File
@@ -4,6 +4,9 @@ package rhythm
import ( import (
"fmt" "fmt"
"time" "time"
"github.com/yuxingu/digital-psychology/apps/api/internal/companion"
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
) )
// Output free summary + gated detail. // Output free summary + gated detail.
@@ -14,8 +17,13 @@ type Output struct {
var elements = []string{"木", "火", "土", "金", "水"} var elements = []string{"木", "火", "土", "金", "水"}
// Build from birth date. // Build from birth date (asOf = now).
func Build(birth time.Time, displayName string) Output { func Build(birth time.Time, displayName string) Output {
return BuildWith(birth, displayName, time.Time{})
}
// BuildWith anchors today/week tips to asOf (zero = now CST).
func BuildWith(birth time.Time, displayName string, asOf time.Time) Output {
name := displayName name := displayName
if name == "" { if name == "" {
name = "你" name = "你"
@@ -36,9 +44,20 @@ func Build(birth time.Time, displayName string) Output {
{"key": "mood", "title": "情绪调节", "teaser": tip.Mood, "score": 71}, {"key": "mood", "title": "情绪调节", "teaser": tip.Mood, "score": 71},
} }
todayTip := tip.Sleep when := asOf
weekday := int(time.Now().Weekday()) if when.IsZero() {
when = time.Now().In(time.FixedZone("CST", 8*3600))
} else {
when = when.In(time.FixedZone("CST", 8*3600))
}
todayTips := []string{tip.Sleep, tip.Diet, tip.Move, tip.Mood}
todayTip := todayTips[when.YearDay()%len(todayTips)]
weekday := int(when.Weekday())
weekHints := []string{tip.Mood, tip.Move, tip.Diet, tip.Sleep, tip.WeekTip, tip.Move, tip.Mood} weekHints := []string{tip.Mood, tip.Move, tip.Diet, tip.Sleep, tip.WeekTip, tip.Move, tip.Mood}
dayKey := when.Format("2006-01-02")
validUntil := time.Date(when.Year(), when.Month(), when.Day()+1, 0, 0, 0, 0, when.Location())
wx := portrait.WuxingBlock(birth)
st := companion.TodaySolar(when)
summary := map[string]any{ summary := map[string]any{
"title": "身心节律·基础", "title": "身心节律·基础",
"style_label": primary + "倾向", "style_label": primary + "倾向",
@@ -48,12 +67,20 @@ func Build(birth time.Time, displayName string) Output {
"life_tip": tip.WeekTip, "life_tip": tip.WeekTip,
"today_tip": todayTip, "today_tip": todayTip,
"week_focus": weekHints[weekday%len(weekHints)], "week_focus": weekHints[weekday%len(weekHints)],
"keywords": []string{primary, secondary, "生活建议", "节律"}, "as_of": dayKey,
"dimensions": dims, "valid_until": validUntil.Format(time.RFC3339),
"primary_element": primary, "wuxing": wx,
"secondary_element": secondary, "solar_term": map[string]any{
"strengths_preview": tip.Strengths[:min(3, len(tip.Strengths))], "name": st.Name,
"blind_spots_preview": []string{"完整习惯方案见深度版。"}, "tip": st.Tip,
},
"keywords": []string{primary, secondary, "生活建议", "节律"},
"dimensions": dims,
"primary_element": primary,
"secondary_element": secondary,
"strengths_preview": tip.Strengths[:min(3, len(tip.Strengths))],
"blind_spots_preview": tip.BlindSpots[:min(3, len(tip.BlindSpots))],
"disclaimer_constitution": "体质与调养内容为生活方式参考,非医疗建议。",
} }
detail := map[string]any{ detail := map[string]any{
@@ -65,8 +92,8 @@ func Build(birth time.Time, displayName string) Output {
{"title": "活动与放松", "body": tip.MoveDeep, "bullets": tip.MoveBullets}, {"title": "活动与放松", "body": tip.MoveDeep, "bullets": tip.MoveBullets},
{"title": "情绪与压力", "body": tip.MoodDeep, "bullets": tip.MoodBullets}, {"title": "情绪与压力", "body": tip.MoodDeep, "bullets": tip.MoodBullets},
{"title": "与节气联动", "body": "可结合「节气生活」调整当季节奏;变化慢一点,观察身体反馈即可。", "bullets": []string{"查看今日节气", "一次只改一个习惯", "不适就医,勿自行当治疗"}}, {"title": "与节气联动", "body": "可结合「节气生活」调整当季节奏;变化慢一点,观察身体反馈即可。", "bullets": []string{"查看今日节气", "一次只改一个习惯", "不适就医,勿自行当治疗"}},
{"title": "今日生活建议", "body": tip.Sleep + " " + tip.Mood, "bullets": []string{tip.Move, tip.Diet}}, {"title": "今日生活建议", "body": todayTip + " " + tip.Mood, "bullets": []string{tip.Move, tip.Diet}},
{"title": "本周节奏", "body": tip.WeekTip, "bullets": tip.OverviewBullets}, {"title": "本周节奏", "body": weekHints[weekday%len(weekHints)], "bullets": tip.OverviewBullets},
}, },
"strengths": tip.Strengths, "strengths": tip.Strengths,
"blind_spots": tip.BlindSpots, "blind_spots": tip.BlindSpots,
+24 -9
View File
@@ -1,21 +1,36 @@
package rhythm package rhythm
import ( import (
"encoding/json"
"strings" "strings"
"testing" "testing"
"time" "time"
) )
func TestBuild(t *testing.T) { func TestBuildIncludesWuxingBars(t *testing.T) {
out := Build(time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC), "我") birth := time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC)
if out.Summary["primary_element"] == nil { out := BuildWith(birth, "测", time.Date(2026, 8, 12, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)))
t.Fatal("missing element") wx, ok := out.Summary["wuxing"].(map[string]any)
if !ok {
t.Fatal("missing wuxing")
} }
raw, _ := json.Marshal(out) bars, ok := wx["bars"].([]map[string]any)
for _, bad := range []string{"运势", "吉凶", "算命", "疗效", "治病"} { if !ok || len(bars) != 5 {
if strings.Contains(string(raw), bad) { t.Fatalf("bars=%#v", wx["bars"])
t.Fatalf("forbidden %q", bad) }
if wx["strong_el"] == nil || wx["care_dir"] == nil {
t.Fatalf("incomplete wuxing: %#v", wx)
}
prev, ok := out.Summary["blind_spots_preview"].([]string)
if !ok || len(prev) == 0 {
t.Fatalf("blind_spots_preview=%#v", out.Summary["blind_spots_preview"])
}
for _, s := range prev {
if strings.Contains(s, "深度版") {
t.Fatalf("preview should not be upsell: %q", s)
} }
} }
st, ok := out.Summary["solar_term"].(map[string]any)
if !ok || st["name"] == nil {
t.Fatalf("missing solar_term: %#v", out.Summary["solar_term"])
}
} }
+122
View File
@@ -0,0 +1,122 @@
package scale
import (
"encoding/json"
"strconv"
)
// JungianQuestionMeta is parsed from scale_questions.body for OEJTS-style items.
type JungianQuestionMeta struct {
Dimension string `json:"dimension"`
Format string `json:"format"`
Left string `json:"left"`
Right string `json:"right"`
Prompt string `json:"prompt"`
}
// ScoreJungian tallies 15 Likert answers per EI/SN/TF/JP (OEJTS rules).
// Left poles: E, S, F, J · Right poles: I, N, T, P · threshold = perDim*3.
func ScoreJungian(answers map[string]string, qMeta map[string]JungianQuestionMeta, perDim int) (typeCode string, raw map[string]int, pct map[string]int) {
raw = map[string]int{"EI": 0, "SN": 0, "TF": 0, "JP": 0}
if perDim <= 0 {
perDim = 8
}
for qid, ans := range answers {
meta, ok := qMeta[qid]
if !ok || meta.Dimension == "" {
continue
}
v, err := strconv.Atoi(ans)
if err != nil || v < 1 || v > 5 {
v = 3
}
raw[meta.Dimension] += v
}
thr := perDim * 3
pick := func(score int, left, right string) string {
if score > thr {
return right
}
return left
}
typeCode = pick(raw["EI"], "E", "I") +
pick(raw["SN"], "S", "N") +
pick(raw["TF"], "F", "T") +
pick(raw["JP"], "J", "P")
pct = map[string]int{}
minS, maxS := perDim, perDim*5
span := float64(maxS - minS)
rightPct := func(score int) int {
p := int(float64(score-minS)/span*100 + 0.5)
if p < 0 {
return 0
}
if p > 100 {
return 100
}
return p
}
eiR := rightPct(raw["EI"])
snR := rightPct(raw["SN"])
tfR := rightPct(raw["TF"])
jpR := rightPct(raw["JP"])
pct["E"], pct["I"] = 100-eiR, eiR
pct["S"], pct["N"] = 100-snR, snR
pct["F"], pct["T"] = 100-tfR, tfR
pct["J"], pct["P"] = 100-jpR, jpR
return typeCode, raw, pct
}
// ParseJungianMeta extracts dimension metadata from question body JSON.
func ParseJungianMeta(body json.RawMessage) JungianQuestionMeta {
var m JungianQuestionMeta
_ = json.Unmarshal(body, &m)
return m
}
// BuildJungianResult builds rich result with 4-letter code + preference bars.
func BuildJungianResult(typeCode string, pct map[string]int) map[string]interface{} {
label := MBTILabel(typeCode)
base := mbtiPack(typeCode, label)
base.Label = typeCode + " · " + label
base.ShareLine = "我的类型探索:" + typeCode + " · " + label
base.Dimensions = []map[string]interface{}{
{"title": "E/I 能量", "score": maxPct(pct, "E", "I"), "note": prefNote("E", "I", pct, "外向互动", "内向思考")},
{"title": "S/N 信息", "score": maxPct(pct, "S", "N"), "note": prefNote("S", "N", pct, "具体事实", "模式可能")},
{"title": "T/F 决策", "score": maxPct(pct, "T", "F"), "note": prefNote("T", "F", pct, "逻辑一致", "价值与人际")},
{"title": "J/P 节奏", "score": maxPct(pct, "J", "P"), "note": prefNote("J", "P", pct, "计划结构", "灵活开放")},
}
out := map[string]interface{}{
"title": "MBTI 探索结果",
"style_key": typeCode,
"type_code": typeCode,
"label": base.Label,
"summary": base.Summary,
"overview": base.Overview,
"share_line": base.ShareLine,
"dimensions": base.Dimensions,
"preferences": pct,
"strengths": base.Strengths,
"watchouts": base.Watchouts,
"tips": base.Tips,
"scripts": base.Scripts,
"growth_plan": base.GrowthPlan,
"faq": base.FAQ,
}
return out
}
func maxPct(pct map[string]int, a, b string) int {
if pct[a] >= pct[b] {
return pct[a]
}
return pct[b]
}
func prefNote(a, b string, pct map[string]int, aDesc, bDesc string) string {
if pct[a] >= pct[b] {
return a + " " + strconv.Itoa(pct[a]) + "% · " + aDesc
}
return b + " " + strconv.Itoa(pct[b]) + "% · " + bDesc
}
+97
View File
@@ -0,0 +1,97 @@
package scale
// mbtiPack returns exploration copy for a 4-letter preference code (原创,非官方题库).
func mbtiPack(code, fallback string) pack {
label := MBTILabel(code)
if label == "平衡探索型" && fallback != "" {
label = fallback
}
base := mbtiBase(code, label)
dims := []map[string]interface{}{
{"title": "能量", "score": dimScore(code, 0, 'E'), "note": axisNote(code, 0)},
{"title": "信息", "score": dimScore(code, 1, 'S'), "note": axisNote(code, 1)},
{"title": "决策", "score": dimScore(code, 2, 'T'), "note": axisNote(code, 2)},
{"title": "节奏", "score": dimScore(code, 3, 'J'), "note": axisNote(code, 3)},
}
base.Dimensions = dims
base.ShareLine = "我的 MBTI 探索:" + code + " · " + label
base.FAQ = append(base.FAQ, map[string]string{
"q": "这是固定人格吗?",
"a": "不是。这是当前情境下的偏好快照,可随经历变化,请当作自我了解工具。",
})
return base
}
func dimScore(code string, i int, left rune) int {
if i >= len(code) {
return 70
}
if rune(code[i]) == left {
return 82
}
return 78
}
func axisNote(code string, i int) string {
if i >= len(code) {
return ""
}
switch code[i] {
case 'E':
return "外向充能偏多"
case 'I':
return "内向充能偏多"
case 'S':
return "更抓具体与当下"
case 'N':
return "更抓可能与意义"
case 'T':
return "更重逻辑与标准"
case 'F':
return "更重感受与关系"
case 'J':
return "更爱结构与收束"
case 'P':
return "更爱弹性与开放"
}
return ""
}
func mbtiBase(code, label string) pack {
switch code {
case "INTJ":
return pack{Label: label, Summary: "你习惯先看见长期结构,再一步步落地。独处思考是你的燃料。", Overview: "你擅长把模糊目标拆成路径,讨厌无效社交与反复改口。成长点是:在推进前多留一句对人的确认,让方案更容易被接纳。", Strengths: []string{"战略感强", "独立推进", "标准清晰"}, Watchouts: []string{"显得疏离", "对低效不耐烦", "过度封闭计划"}, Tips: []string{"关键节点先同步再独断", "给情绪留 10 分钟再决策", "用清单外的「弹性项」练习放手"}, Scripts: []string{"我的建议是……,你最担心哪一步?", "我想先对齐目标,再谈细节可以吗?"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次决策前先问对方感受"}, {"phase": "本月", "focus": "公开一个半成品计划收集反馈"}}}
case "INTP":
return pack{Label: label, Summary: "你靠好奇与逻辑拆解世界,喜欢把概念想透再行动。", Overview: "分析是你的舒适区,拖延常来自还想再想清楚。成长点是设定「够好就提交」的截止点,把洞见变成可验证的小实验。", Strengths: []string{"抽象思考", "问题拆解", "开放好奇"}, Watchouts: []string{"迟迟不落地", "忽略关系节奏", "过度纠结定义"}, Tips: []string{"每个想法配一个 48 小时小实验", "讨论时先复述对方一句", "用番茄钟切分析与执行"}, Scripts: []string{"我还在梳理,今晚给你一个阶段性结论。", "我理解你的点;我补充一个角度……"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "完成一个未收尾的小输出"}, {"phase": "本月", "focus": "建立「思考→验证」双清单"}}}
case "ENTJ":
return pack{Label: label, Summary: "你天然想把事情推动到结果,目标感强、节奏快。", Overview: "你适合带队攻坚,但速度可能压到他人感受。成长点是:在下达目标时同步「为什么」与「需要的支持」。", Strengths: []string{"决策果断", "组织推进", "结果导向"}, Watchouts: []string{"控制欲过强", "忽视情绪成本", "把慢当成错"}, Tips: []string{"每周一次只听不评判的同步", "目标拆成共同里程碑", "表扬过程不只结果"}, Scripts: []string{"我们要达成……,你卡在哪里我可以支援?", "我的时间表是……,你的约束是什么?"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次会议先听完再给方案"}, {"phase": "本月", "focus": "把一个目标改成共创版"}}}
case "ENTP":
return pack{Label: label, Summary: "你爱挑战既有假设,点子多、辩论感强,讨厌无聊重复。", Overview: "灵感是你的超能力,落地与收尾是课题。成长点是选一个点子陪它走完最小闭环。", Strengths: []string{"快速联想", "打破僵局", "说服力"}, Watchouts: []string{"虎头蛇尾", "为辩而辩", "承诺过多"}, Tips: []string{"新点子先写「不做清单」", "找一个落地搭档", "争论前先确认共同目标"}, Scripts: []string{"换个角度看:如果……会怎样?", "我可以先做一版原型,我们再决定。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "只推进一个点子到可演示"}, {"phase": "本月", "focus": "练习结束争论的收束句"}}}
case "INFJ":
return pack{Label: label, Summary: "你敏感于意义与人心走向,常在安静处看见别人没说出口的事。", Overview: "理想驱动你,也容易负荷过重。成长点是把关怀加上边界:不是所有情绪都要你接住。", Strengths: []string{"洞察力", "长期愿景", "深度共情"}, Watchouts: []string{"过度负责", "理想落差挫败", "表达拐弯"}, Tips: []string{"每天留无负担独处", "重要请求直接说", "区分「关心」与「承包」"}, Scripts: []string{"我感受到你……,我能做的是……,但我需要……", "这件事对我意义很大,我想认真谈一次。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "拒绝一次超出负荷的请求"}, {"phase": "本月", "focus": "把一个愿景写成三步小行动"}}}
case "INFP":
return pack{Label: label, Summary: "你按内在价值行动,重视真实与温柔,讨厌被强迫表演。", Overview: "当环境违背价值观你会撤退或内耗。成长点是把「我在意什么」说清楚,让他人有机会配合你。", Strengths: []string{"价值感强", "创造力", "真诚"}, Watchouts: []string{"逃避冲突", "理想化他人", "自我怀疑"}, Tips: []string{"冲突时先写三句再谈", "每周做一件对齐价值的小事", "接受「足够好」的交付"}, Scripts: []string{"这件事触及我很在意的……,我们可以一起找折中吗?", "我需要一点时间整理感受,晚点认真回复你。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次温和但清楚的表达"}, {"phase": "本月", "focus": "建立个人价值清单并对照日程"}}}
case "ENFJ":
return pack{Label: label, Summary: "你擅长点燃群体、照顾节奏,常成为大家的「主心骨」。", Overview: "你容易把别人的成长扛在肩上。成长点是学会把责任还回去,并照顾自己的能量账户。", Strengths: []string{"召集力", "鼓励他人", "氛围敏感"}, Watchouts: []string{"自我耗竭", "讨好倾向", "过度介入"}, Tips: []string{"帮助前先问「你需要什么」", "每周固定自我补给", "把表扬与界限一起说"}, Scripts: []string{"我很想支持你;这次我能做到的是……", "我们一起定个节奏,好让彼此都不透支。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次帮助改为赋能提问"}, {"phase": "本月", "focus": "建立个人恢复仪式"}}}
case "ENFP":
return pack{Label: label, Summary: "你热情、好奇、连接人与可能性,讨厌被框死。", Overview: "开始很容易,专注收尾较难。成长点是给热情加一点结构:选少做深。", Strengths: []string{"感染力", "创意联想", "关系活力"}, Watchouts: []string{"分心多线", "承诺膨胀", "情绪起伏大"}, Tips: []string{"同时只养 2 个重点项目", "用伙伴盯收尾", "低能量日允许缩小社交圈"}, Scripts: []string{"我超有感觉!我们先定最小一步……", "今天我状态一般,改天再深聊可以吗?"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "砍掉一个分心事项"}, {"phase": "本月", "focus": "完成一个从灵感到交付的闭环"}}}
case "ISTJ":
return pack{Label: label, Summary: "你可靠、重承诺,喜欢把规则与流程做清楚。", Overview: "稳定是你的礼物,变化可能让你紧绷。成长点是在原则内留一个「可谈判区」。", Strengths: []string{"执行力", "细节准确", "责任感"}, Watchouts: []string{"固执流程", "不善表达情感", "对新法抵触"}, Tips: []string{"变更前先写利弊表", "主动说一句关心", "把例外规则写进清单"}, Scripts: []string{"按计划我们该……;若要改,影响是……", "我在意可靠,所以想先确认时间点。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "接受一次小范围变更"}, {"phase": "本月", "focus": "练习情绪用词清单"}}}
case "ISFJ":
return pack{Label: label, Summary: "你细心照顾日常与人情,默默把安全感建起来。", Overview: "你常先顾别人。成长点是让需要被看见:被照顾也是关系的一部分。", Strengths: []string{"体贴", "记忆细节", "稳定支持"}, Watchouts: []string{"压抑需求", "怕冲突", "过度付出"}, Tips: []string{"每周提出一个真实偏好", "把付出写成可轮换的任务", "疲惫时直接说停"}, Scripts: []string{"我一直在帮忙……,这次我也需要……", "我有点累了,今晚想安静一下。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次主动提出需要"}, {"phase": "本月", "focus": "建立付出与回收的平衡表"}}}
case "ESTJ":
return pack{Label: label, Summary: "你讲效率、抓标准,擅长把混乱整理成秩序。", Overview: "你推动系统运转,也可能被看成强硬。成长点是解释标准背后的公平意图。", Strengths: []string{"组织力", "决断", "落地快"}, Watchouts: []string{"命令口吻", "忽视个别情况", "急于纠正"}, Tips: []string{"指令后加一句「你怎么看」", "例外审批透明化", "表扬合规与创新并重"}, Scripts: []string{"标准是为了……,特殊情况我们可以……", "先按这个做一版,再一起复盘。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次决策征求异议"}, {"phase": "本月", "focus": "写清团队共同规则"}}}
case "ESFJ":
return pack{Label: label, Summary: "你重视和谐与归属,善于把人连接成互相照顾的圈。", Overview: "和谐重要,但回避真实分歧会累积委屈。成长点是温和地谈不同意。", Strengths: []string{"协调", "热心", "仪式感"}, Watchouts: []string{"过度在意评价", "讨好", "压抑不满"}, Tips: []string{"分歧用「我观察/我需要」句式", "减少即时回消息压力", "为自己保留无角色时间"}, Scripts: []string{"我很在乎我们关系,所以想说一下我的不适……", "大家开心很重要,我也需要……被考虑。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次诚实但不伤人的反馈"}, {"phase": "本月", "focus": "减少一次为和谐的妥协"}}}
case "ISTP":
return pack{Label: label, Summary: "你冷静拆解问题,动手能力强,讨厌空洞说教。", Overview: "你用行动证明理解。成长点是在关系里多给一点过程说明,避免被当成冷淡。", Strengths: []string{"实操", "危机冷静", "独立"}, Watchouts: []string{"话少被误解", "回避情绪话题", "承诺随意"}, Tips: []string{"行动前后各说一句意图", "定期短同步代替长会议", "练习命名当下感受"}, Scripts: []string{"我去处理……,大概多久回来。", "我不一定能立刻共情,但我可以帮忙做……"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "三次行动前口头同步"}, {"phase": "本月", "focus": "一次情绪向长谈不逃开"}}}
case "ISFP":
return pack{Label: label, Summary: "你活在感受与美感里,用体验表达自己,讨厌被强迫。", Overview: "你需要空间与节奏。成长点是在被压时及时说「停」,而不是默默消失。", Strengths: []string{"审美敏感", "温和", "当下投入"}, Watchouts: []string{"逃避压力", "需求不清", "突然抽离"}, Tips: []string{"压力信号出现就说", "用创作或散步调节", "重要约定写成双方可见"}, Scripts: []string{"我现在有点满,需要安静恢复一下。", "这件事的感觉对我很重要,我们慢慢谈。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次及时表达边界"}, {"phase": "本月", "focus": "建立个人恢复清单"}}}
case "ESTP":
return pack{Label: label, Summary: "你抓当下机会、行动快,喜欢真刀真枪地试。", Overview: "冲劲带来结果,也可能低估后果。成长点是重大行动前留 10 分钟风险评估。", Strengths: []string{"应变", "胆识", "现实感"}, Watchouts: []string{"冲动", "忽略长远", "听不进劝"}, Tips: []string{"大决定用「最坏情况」三问", "找一个踩刹车的伙伴", "运动发泄代替硬刚冲突"}, Scripts: []string{"我想先试一版,失败了我们立刻收。", "你提醒的风险我记下了,我的底线是……"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次冲动决定延后 24 小时"}, {"phase": "本月", "focus": "建立行动前检查卡"}}}
case "ESFP":
return pack{Label: label, Summary: "你把活力带给现场,重视体验、分享与即时快乐。", Overview: "你点亮气氛,也可能回避沉闷的深度议题。成长点是留下处理难聊话题的固定时段。", Strengths: []string{"感染力", "体贴当下", "乐观"}, Watchouts: []string{"回避严肃议题", "过度取悦", "财务/计划松散"}, Tips: []string{"每周一次「认真聊」预约", "快乐预算设上限", "用日历保护恢复日"}, Scripts: []string{"今天先开心,明天我们认真谈……可以吗?", "我很想让大家轻松,但这件事我也有压力……"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "完成一次被拖延的认真对话"}, {"phase": "本月", "focus": "建立简单收支或计划习惯"}}}
default:
return pack{Label: label, Summary: "你的四维偏好组合较均衡或尚未充分显现主导面。", Overview: "可把本次结果当作起点,过一段时间在不同情境再测,观察哪一维更稳定。", Strengths: []string{"灵活", "可塑", "情境适应"}, Watchouts: []string{"自我描述模糊", "随大流", "缺少稳定策略"}, Tips: []string{"记录一周高能/耗能场景", "对争议维多做情景题自问", "与信任的人对照描述"}, Scripts: []string{"我还在认识自己,目前更接近……", "在……情境里我更像……"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "日记标注能量来源"}, {"phase": "本月", "focus": "复测并对比变化"}}}
}
}
+32
View File
@@ -32,11 +32,43 @@ func resultPack(slug, key, fallbackLabel string) pack {
switch slug { switch slug {
case "emotion-pattern": case "emotion-pattern":
return emotionPack(key, fallbackLabel) return emotionPack(key, fallbackLabel)
case "mbti-lite":
return mbtiPack(key, fallbackLabel)
default: default:
if key == "high" || key == "mid" || key == "low" {
return bankExplorePack(fallbackLabel)
}
return communicationPack(key, fallbackLabel) return communicationPack(key, fallbackLabel)
} }
} }
func bankExplorePack(label string) pack {
if label == "" {
label = "节奏适中型"
}
return pack{
Label: label, ShareLine: "我的探索结果:" + label,
Summary: "这是基于题库作答的自我探索速览,用于觉察倾向与日常参考,不是能力定论,更不是心理或医学诊断。",
Overview: "题库结果会随作答波动。你可以把它当成一面镜子:哪些选项更容易选中、身体与情绪有什么反应,比「分数高低」更重要。",
Dimensions: []map[string]interface{}{
{"title": "自我觉察", "score": 72, "note": "留意答题时的身体感受"},
{"title": "可行动性", "score": 68, "note": "挑一条小习惯试一周"},
{"title": "非定论", "score": 90, "note": "结果可随阶段变化"},
},
Strengths: []string{"愿意自我观察", "愿意花时间完成题库"},
Watchouts: []string{"不要把结果当标签钉死", "不适情绪请寻求专业支持"},
Tips: []string{"截图保存一句对你有感的描述", "把一条建议写成今日小行动", "可与问答助手聊聊具体情境"},
Scripts: []string{"这份结果让我想到……", "我想先观察一周再决定要不要改习惯。"},
GrowthPlan: []map[string]string{
{"phase": "本周", "focus": "选一条建议做一次小实验"},
{"phase": "本月", "focus": "复盘:哪些描述仍然贴切"},
},
FAQ: []map[string]string{
{"q": "这是心理诊断吗?", "a": "不是。愈心谷题库只做自我探索参考,不能替代专业评估或治疗。"},
},
}
}
func communicationPack(key, fallback string) pack { func communicationPack(key, fallback string) pack {
switch key { switch key {
case "A": case "A":
+42
View File
@@ -40,3 +40,45 @@ func EmotionLabels() map[string]string {
"C": "行动调节型", "C": "行动调节型",
} }
} }
// ScoreMBTI tallies E/I · S/N · T/F · J/P and returns a 4-letter type code + Chinese label.
func ScoreMBTI(answers map[string]string) (typeCode, label string) {
count := func(a, b string) (na, nb int) {
for _, v := range answers {
switch v {
case a:
na++
case b:
nb++
}
}
return
}
pick := func(a, b string, na, nb int) string {
if nb > na {
return b
}
return a
}
e, i := count("E", "I")
s, n := count("S", "N")
t, f := count("T", "F")
j, p := count("J", "P")
code := pick("E", "I", e, i) + pick("S", "N", s, n) + pick("T", "F", t, f) + pick("J", "P", j, p)
return code, MBTILabel(code)
}
// MBTILabel maps 4-letter exploration code to a lexicon-safe Chinese label.
func MBTILabel(code string) string {
if l, ok := mbtiLabels[code]; ok {
return l
}
return "平衡探索型"
}
var mbtiLabels = map[string]string{
"INTJ": "战略建构者", "INTP": "概念探路者", "ENTJ": "目标推动者", "ENTP": "灵感挑战者",
"INFJ": "愿景洞察者", "INFP": "价值守护者", "ENFJ": "共鸣召集者", "ENFP": "热情启发者",
"ISTJ": "稳健执行者", "ISFJ": "细致守护者", "ESTJ": "秩序统筹者", "ESFJ": "温暖协调者",
"ISTP": "务实拆解者", "ISFP": "感受体验者", "ESTP": "当下行动者", "ESFP": "活力分享者",
}
+28 -1
View File
@@ -1,6 +1,9 @@
package scale package scale
import "testing" import (
"strconv"
"testing"
)
func TestScoreMajority_communication(t *testing.T) { func TestScoreMajority_communication(t *testing.T) {
key, label := ScoreMajority(map[string]string{ key, label := ScoreMajority(map[string]string{
@@ -20,6 +23,30 @@ func TestScoreMajority_emotion(t *testing.T) {
} }
} }
func TestScoreJungian_ENFP(t *testing.T) {
// 8 Qs per dim: EI low→E, SN high→N, TF low→F, JP high→P
meta := map[string]JungianQuestionMeta{}
answers := map[string]string{}
add := func(dim string, ids []string, vals []int) {
for i, id := range ids {
meta[id] = JungianQuestionMeta{Dimension: dim}
answers[id] = strconv.Itoa(vals[i])
}
}
add("EI", []string{"e1", "e2", "e3", "e4", "e5", "e6", "e7", "e8"}, []int{1, 1, 2, 1, 2, 1, 1, 2}) // sum 11 → E
add("SN", []string{"s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"}, []int{5, 5, 4, 5, 4, 5, 5, 4}) // sum 37 → N
add("TF", []string{"t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"}, []int{1, 2, 1, 2, 1, 1, 2, 1}) // sum 11 → F
add("JP", []string{"j1", "j2", "j3", "j4", "j5", "j6", "j7", "j8"}, []int{5, 4, 5, 5, 4, 5, 5, 4}) // sum 37 → P
code, _, pct := ScoreJungian(answers, meta, 8)
if code != "ENFP" {
t.Fatalf("code=%s want ENFP", code)
}
r := BuildJungianResult(code, pct)
if r["type_code"] != "ENFP" {
t.Fatalf("type_code=%v", r["type_code"])
}
}
func TestScoreMajority_empty(t *testing.T) { func TestScoreMajority_empty(t *testing.T) {
_, label := ScoreMajority(nil, CommunicationLabels(), "平衡探索型") _, label := ScoreMajority(nil, CommunicationLabels(), "平衡探索型")
if label != "平衡探索型" { if label != "平衡探索型" {
+266
View File
@@ -0,0 +1,266 @@
package scalebank
import (
_ "embed"
"encoding/json"
"fmt"
"strconv"
"sync"
"github.com/google/uuid"
)
//go:embed curated.json
var curatedJSON []byte
var (
bankNS = uuid.MustParse("6b1e9c2a-4d5f-4a8b-9c0d-1e2f3a4b5c6d")
once sync.Once
root *Root
loadErr error
)
// Root is the curated bank file.
type Root struct {
Version int `json:"version"`
Categories []Category `json:"categories"`
Scales []Scale `json:"scales"`
}
// Category groups scales for explore UI.
type Category struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Slugs []string `json:"slugs"`
Featured []string `json:"featured"`
}
// Scale is one exploration instrument.
type Scale struct {
Slug string `json:"slug"`
Category string `json:"category"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
QuestionCount int `json:"question_count"`
Questions []Question `json:"questions"`
Disclaimer string `json:"disclaimer"`
}
// Question is prompt + options.
type Question struct {
Prompt string `json:"prompt"`
Options []Option `json:"options"`
}
// Option is a choice.
type Option struct {
Key string `json:"key"`
Text string `json:"text"`
}
// CatalogOut is explore bank payload.
type CatalogOut struct {
Featured []FeaturedItem `json:"featured"`
Categories []CategoryCard `json:"categories"`
}
// FeaturedItem is icon+title tile.
type FeaturedItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
CategoryKey string `json:"category_key"`
Icon string `json:"icon"`
Path string `json:"path"`
}
// CategoryCard is a category row / detail header.
type CategoryCard struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Count int `json:"count"`
Featured []FeaturedItem `json:"featured"`
Path string `json:"path"`
}
// ScaleListItem is a scale in a category list.
type ScaleListItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
QuestionCount int `json:"question_count"`
Path string `json:"path"`
}
func load() (*Root, error) {
once.Do(func() {
var r Root
if err := json.Unmarshal(curatedJSON, &r); err != nil {
loadErr = err
return
}
root = &r
})
return root, loadErr
}
// MustLoad panics only in tests if embed broken; production returns error via helpers.
func MustLoad() *Root {
r, err := load()
if err != nil || r == nil {
panic(fmt.Sprintf("scalebank: %v", err))
}
return r
}
func bySlug(r *Root) map[string]Scale {
m := make(map[string]Scale, len(r.Scales))
for _, s := range r.Scales {
m[s.Slug] = s
}
return m
}
func catIcon(r *Root, key string) string {
for _, c := range r.Categories {
if c.Key == key {
return c.Icon
}
}
return "mbti"
}
func scaleIcon(s Scale) string {
if s.Icon != "" {
return s.Icon
}
return "mbti"
}
// Has reports whether slug is in the curated bank.
func Has(slug string) bool {
r, err := load()
if err != nil || r == nil {
return false
}
_, ok := bySlug(r)[slug]
return ok
}
// Catalog builds featured tiles + category cards.
func Catalog() (*CatalogOut, error) {
r, err := load()
if err != nil {
return nil, err
}
m := bySlug(r)
out := &CatalogOut{}
for _, c := range r.Categories {
card := CategoryCard{
Key: c.Key, Title: c.Title, Description: c.Description, Icon: c.Icon,
Count: len(c.Slugs), Path: "/explore/bank/" + c.Key,
}
for _, slug := range c.Featured {
s, ok := m[slug]
if !ok {
continue
}
item := FeaturedItem{
Slug: s.Slug, Title: s.Title, Description: s.Description,
CategoryKey: c.Key, Icon: scaleIcon(s), Path: "/scales/" + s.Slug,
}
card.Featured = append(card.Featured, item)
out.Featured = append(out.Featured, item)
}
out.Categories = append(out.Categories, card)
}
return out, nil
}
// CategoryScales lists scales in a category.
func CategoryScales(key string) (*CategoryCard, []ScaleListItem, error) {
r, err := load()
if err != nil {
return nil, nil, err
}
m := bySlug(r)
for _, c := range r.Categories {
if c.Key != key {
continue
}
card := CategoryCard{
Key: c.Key, Title: c.Title, Description: c.Description, Icon: c.Icon,
Count: len(c.Slugs), Path: "/explore/bank/" + c.Key,
}
items := make([]ScaleListItem, 0, len(c.Slugs))
for _, slug := range c.Slugs {
s, ok := m[slug]
if !ok {
continue
}
items = append(items, ScaleListItem{
Slug: s.Slug, Title: s.Title, Description: s.Description, Icon: scaleIcon(s),
QuestionCount: s.QuestionCount, Path: "/scales/" + s.Slug,
})
}
return &card, items, nil
}
return nil, nil, fmt.Errorf("category not found")
}
// Get returns scale detail questions in repository-compatible shape fields.
func Get(slug string) (*Scale, error) {
r, err := load()
if err != nil {
return nil, err
}
s, ok := bySlug(r)[slug]
if !ok {
return nil, fmt.Errorf("scale not found")
}
cp := s
return &cp, nil
}
// QuestionID stable id for answer keys.
func QuestionID(slug string, index int) uuid.UUID {
return uuid.NewSHA1(bankNS, []byte(fmt.Sprintf("%s/%d", slug, index)))
}
// ScoreSum maps option keys (1..n) to a soft exploration band.
func ScoreSum(answers map[string]string, questionCount int) (styleKey, label string, score, ceiling int) {
sum := 0
maxOpt := 1
for _, v := range answers {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
continue
}
sum += n
if n > maxOpt {
maxOpt = n
}
}
ceiling = questionCount * maxOpt
if ceiling <= 0 {
ceiling = questionCount * 5
}
pct := 0
if ceiling > 0 {
pct = sum * 100 / ceiling
}
switch {
case pct >= 75:
return "high", "感受偏强烈型", sum, ceiling
case pct >= 45:
return "mid", "节奏适中型", sum, ceiling
default:
return "low", "感受偏轻柔型", sum, ceiling
}
}
+34
View File
@@ -0,0 +1,34 @@
package scalebank
import "testing"
func TestCatalogCuratedASet(t *testing.T) {
out, err := Catalog()
if err != nil {
t.Fatal(err)
}
if len(out.Categories) != 4 {
t.Fatalf("cats=%d", len(out.Categories))
}
if len(out.Featured) < 6 {
t.Fatalf("featured=%d", len(out.Featured))
}
seen := map[string]bool{}
for _, f := range out.Featured {
if f.Icon == "" || seen[f.Icon] {
t.Fatalf("featured icons must be unique nonempty: %#v", out.Featured)
}
seen[f.Icon] = true
}
if !Has("mbti") || !Has("gd3") || Has("yyzcs") {
t.Fatal("unexpected slug set")
}
s, err := Get("eq1")
if err != nil || len(s.Questions) == 0 {
t.Fatalf("eq1 %#v %v", s, err)
}
key, label, sum, ceil := ScoreSum(map[string]string{"a": "3", "b": "4"}, 2)
if key == "" || label == "" || sum == 0 || ceil == 0 {
t.Fatalf("%s %s %d %d", key, label, sum, ceil)
}
}
File diff suppressed because one or more lines are too long
@@ -46,6 +46,15 @@ var allowedNames = map[string]struct{}{
"synastry_invite_created": {}, "synastry_invite_accepted": {}, "synastry_nearby_opened": {}, "synastry_invite_created": {}, "synastry_invite_accepted": {}, "synastry_nearby_opened": {},
"star_wheel_viewed": {}, "companion_viewed": {}, "mood_saved": {}, "star_wheel_viewed": {}, "companion_viewed": {}, "mood_saved": {},
"cards_scene_selected": {}, "cards_drawn": {}, "cards_quota_exhausted": {}, "cards_scene_selected": {}, "cards_drawn": {}, "cards_quota_exhausted": {},
"star_completed": {}, "rhythm_completed": {},
"auth_register": {}, "auth_login": {}, "auth_logout": {},
"avatar_sheet_opened": {}, "avatar_upload_succeeded": {}, "avatar_upload_failed": {},
"nickname_updated": {},
"scale_list_viewed": {}, "scale_bank_category_viewed": {},
"scale_started": {}, "scale_completed": {}, "scale_result_reopened": {},
"scale_retake_clicked": {}, "scale_locked_viewed": {}, "scale_share_clicked": {},
"scale_cta_relation": {}, "scale_cta_ask": {},
"growth_plan_viewed": {}, "growth_plan_created": {}, "growth_plan_checkin": {},
} }
var allowedPropKeys = map[string]struct{}{ var allowedPropKeys = map[string]struct{}{
@@ -53,9 +62,11 @@ var allowedPropKeys = map[string]struct{}{
"element_id": {}, "exit_page": {}, "duration_ms": {}, "cold": {}, "app_ver": {}, "element_id": {}, "exit_page": {}, "duration_ms": {}, "cold": {}, "app_ver": {},
"source": {}, "kind": {}, "surface": {}, "label": {}, "plan": {}, "count": {}, "source": {}, "kind": {}, "surface": {}, "label": {}, "plan": {}, "count": {},
"depth": {}, "scene": {}, "score": {}, "planet": {}, "report_id": {}, "depth": {}, "scene": {}, "score": {}, "planet": {}, "report_id": {},
"slug": {}, "category": {}, "reason": {}, "access": {},
} }
var funnelDefault = []string{ var funnelDefault = []string{
"scale_list_viewed", "scale_started", "scale_completed",
"portrait_completed", "deep_access_clicked", "purchase_completed", "portrait_completed", "deep_access_clicked", "purchase_completed",
} }
+14 -13
View File
@@ -15,6 +15,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek" "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/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
) )
// FreeQuota is the number of assistant replies allowed without membership. // FreeQuota is the number of assistant replies allowed without membership.
@@ -42,7 +43,11 @@ func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateT
return nil, errors.New("profile not found") return nil, errors.New("profile not found")
} }
var scene *string var scene *string
if sc := strings.TrimSpace(in.Scene); sc != "" { if strings.TrimSpace(in.Scene) != "" {
sc, err := textsafe.Check(textsafe.Scene, in.Scene)
if err != nil {
return nil, err
}
scene = &sc scene = &sc
} }
return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene) return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene)
@@ -137,12 +142,10 @@ type SendResult struct {
// SendMessage stores user content, consumes quota, generates assistant reply. // SendMessage stores user content, consumes quota, generates assistant reply.
func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, content string) (*SendResult, error) { func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, content string) (*SendResult, error) {
content = strings.TrimSpace(content) var err error
if content == "" { content, err = textsafe.Check(textsafe.AskContent, content)
return nil, errors.New("content required") if err != nil {
} return nil, err
if len([]rune(content)) > 2000 {
return nil, errors.New("content too long")
} }
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID) thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
@@ -281,12 +284,10 @@ func (s *Service) StreamMessage(ctx context.Context, userID, threadID uuid.UUID,
if emit == nil { if emit == nil {
return errors.New("emit required") return errors.New("emit required")
} }
content = strings.TrimSpace(content) var err error
if content == "" { content, err = textsafe.Check(textsafe.AskContent, content)
return errors.New("content required") if err != nil {
} return err
if len([]rune(content)) > 2000 {
return errors.New("content too long")
} }
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID) thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
+57 -14
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"errors" "errors"
"mime/multipart"
"strings" "strings"
"time" "time"
@@ -12,21 +13,25 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname" nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
) )
// Service handles register/login sessions. // Service handles register/login sessions.
// Temporary open mode: any non-empty phone+password can enter; missing accounts are created. // Temporary open mode: any non-empty phone+password can enter; missing accounts are created.
type Service struct { type Service struct {
Repo *repository.AuthRepo Repo *repository.AuthRepo
AvatarDir string
} }
// Me is the public account payload. // Me is the public account payload.
type Me struct { type Me struct {
ID string `json:"id"` ID string `json:"id"`
Phone string `json:"phone"` Phone string `json:"phone"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url,omitempty"`
} }
// SessionResult is returned after register/login. // SessionResult is returned after register/login.
@@ -34,6 +39,7 @@ type SessionResult struct {
Token string `json:"token"` Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
User Me `json:"user"` User Me `json:"user"`
IsNew bool `json:"is_new"`
} }
// Register upgrades or opens an account (same open rules as Login). // Register upgrades or opens an account (same open rules as Login).
@@ -52,7 +58,14 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
if phone == "" { if phone == "" {
return nil, errors.New("请填写手机号") return nil, errors.New("请填写手机号")
} }
nickIn = nickgen.Normalize(nickIn) nickIn = strings.TrimSpace(nickIn)
if nickIn != "" {
n, err := textsafe.Check(textsafe.Nickname, nickIn)
if err != nil {
return nil, err
}
nickIn = n
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -73,7 +86,7 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
nick = nickgen.Random() nick = nickgen.Random()
_ = s.Repo.UpdateNickname(ctx, acc.ID, nick) _ = s.Repo.UpdateNickname(ctx, acc.ID, nick)
} }
return s.issue(ctx, acc.ID, acc.Phone, nick) return s.issue(ctx, acc.ID, acc.Phone, nick, false)
} }
if !errors.Is(err, pgx.ErrNoRows) { if !errors.Is(err, pgx.ErrNoRows) {
return nil, err return nil, err
@@ -99,18 +112,23 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
if deviceKey != "" { if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, uid) _ = s.Repo.BindDevice(ctx, deviceKey, uid)
} }
return s.issue(ctx, uid, phone, nick) return s.issue(ctx, uid, phone, nick, true)
} }
// UpdateNickname changes account display nickname (116 chars). // UpdateNickname changes account display nickname (116 chars).
func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick string) (*Me, error) { func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick string) (*Me, error) {
nick = nickgen.Normalize(nick) nick, err := textsafe.Check(textsafe.Nickname, nick)
if nick == "" { if err != nil {
return nil, errors.New("请填写昵称") return nil, err
} }
if err := s.Repo.UpdateNickname(ctx, userID, nick); err != nil { if err := s.Repo.UpdateNickname(ctx, userID, nick); err != nil {
return nil, err return nil, err
} }
return s.Me(ctx, userID)
}
// UpdateAvatar stores profile photo and returns updated me.
func (s *Service) UpdateAvatar(ctx context.Context, userID uuid.UUID, fh *multipart.FileHeader) (*Me, error) {
acc, err := s.Repo.GetAccount(ctx, userID) acc, err := s.Repo.GetAccount(ctx, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -118,7 +136,18 @@ func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick str
if acc.Phone == "" { if acc.Phone == "" {
return nil, errors.New("未登录") return nil, errors.New("未登录")
} }
return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil dir := s.AvatarDir
if dir == "" {
dir = "data/avatars"
}
path, err := avatar.Store(dir, userID, fh)
if err != nil {
return nil, err
}
if err := s.Repo.UpdateAvatarURL(ctx, userID, path); err != nil {
return nil, err
}
return s.Me(ctx, userID)
} }
// Logout revokes the current bearer session and unbinds the device from the account // Logout revokes the current bearer session and unbinds the device from the account
@@ -141,7 +170,7 @@ func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*Me, error) {
if acc.Phone == "" { if acc.Phone == "" {
return nil, errors.New("未登录") return nil, errors.New("未登录")
} }
return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil return toMe(acc), nil
} }
// ResolveSessionUser returns user id for a live token. // ResolveSessionUser returns user id for a live token.
@@ -154,18 +183,32 @@ func (s *Service) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, err
return s.Repo.IsRegistered(ctx, userID) return s.Repo.IsRegistered(ctx, userID)
} }
func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string) (*SessionResult, error) { func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string, isNew bool) (*SessionResult, error) {
tok := "usr_" + randomHex(24) tok := "usr_" + randomHex(24)
exp := time.Now().Add(30 * 24 * time.Hour) exp := time.Now().Add(30 * 24 * time.Hour)
if err := s.Repo.CreateSession(ctx, userID, tok, exp); err != nil { if err := s.Repo.CreateSession(ctx, userID, tok, exp); err != nil {
return nil, err return nil, err
} }
me := &Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname}
if acc, err := s.Repo.GetAccount(ctx, userID); err == nil {
me = toMe(acc)
}
return &SessionResult{ return &SessionResult{
Token: tok, ExpiresAt: exp, Token: tok, ExpiresAt: exp,
User: Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname}, User: *me,
IsNew: isNew,
}, nil }, nil
} }
func toMe(acc *repository.AccountRow) *Me {
return &Me{
ID: acc.ID.String(),
Phone: maskPhone(acc.Phone),
Nickname: acc.Nickname,
AvatarURL: acc.AvatarURL,
}
}
func maskPhone(p string) string { func maskPhone(p string) string {
if len(p) != 11 { if len(p) != 11 {
return p return p
@@ -67,8 +67,10 @@ func (s *Service) genSolo(ctx context.Context, userID uuid.UUID, p *model.Profil
log.Printf("bootstrap portrait: %v", err) log.Printf("bootstrap portrait: %v", err)
} }
now := time.Now().In(time.FixedZone("CST", 8*3600))
outS, err := star.BuildWith(star.BuildOpts{ outS, err := star.BuildWith(star.BuildOpts{
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName, Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
AsOf: now,
}) })
if err != nil { if err != nil {
log.Printf("bootstrap star: %v", err) log.Printf("bootstrap star: %v", err)
@@ -80,7 +82,7 @@ func (s *Service) genSolo(ctx context.Context, userID uuid.UUID, p *model.Profil
} }
} }
outR := rhythm.Build(p.BirthDate, p.DisplayName) outR := rhythm.BuildWith(p.BirthDate, p.DisplayName, now)
sum, _ = json.Marshal(outR.Summary) sum, _ = json.Marshal(outR.Summary)
det, _ = json.Marshal(outR.Detail) det, _ = json.Marshal(outR.Detail)
if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "rhythm", sum, det); err != nil { if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "rhythm", sum, det); err != nil {
@@ -3,7 +3,6 @@ package companion
import ( import (
"context" "context"
"errors" "errors"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -12,6 +11,7 @@ import (
core "github.com/yuxingu/digital-psychology/apps/api/internal/companion" 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/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
) )
// Service exposes solar terms and moods. // Service exposes solar terms and moods.
@@ -37,11 +37,15 @@ func (s *Service) SaveMood(ctx context.Context, userID uuid.UUID, in SaveMoodInp
return nil, errors.New("score must be 1-5") return nil, errors.New("score must be 1-5")
} }
if in.Note != nil { if in.Note != nil {
n := strings.TrimSpace(*in.Note) n, err := textsafe.Check(textsafe.Note, *in.Note)
if len([]rune(n)) > 200 { if err != nil {
return nil, errors.New("note too long") return nil, err
}
if n == "" {
in.Note = nil
} else {
in.Note = &n
} }
in.Note = &n
} }
day := time.Now() day := time.Now()
if in.Day != nil { if in.Day != nil {
+150 -39
View File
@@ -14,6 +14,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek" "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/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/yijing" "github.com/yuxingu/digital-psychology/apps/api/internal/yijing"
) )
@@ -33,58 +34,131 @@ type DailyTips struct {
Source string `json:"source"` // llm | fallback Source string `json:"source"` // llm | fallback
GuaName string `json:"gua_name,omitempty"` GuaName string `json:"gua_name,omitempty"`
DayPart string `json:"day_part,omitempty"` DayPart string `json:"day_part,omitempty"`
AsOf string `json:"as_of"` // YYYY-MM-DD
Shichen int `json:"shichen"`
ShichenName string `json:"shichen_name"`
ValidUntil string `json:"valid_until"` // RFC3339 next 时辰 start
NeedBirth bool `json:"need_birth"` NeedBirth bool `json:"need_birth"`
} }
type tipsCacheEntry struct { type tipsMemEntry struct {
tips DailyTips tips DailyTips
exp time.Time
} }
var tipsCache sync.Map // key string → tipsCacheEntry var (
tipsMem sync.Map // cacheKey → tipsMemEntry
tipsInflight sync.Map // cacheKey → *sync.Mutex generating
)
// DailyTips returns personalized tips; falls back when LLM / profile unavailable. func tipsCacheKey(userID uuid.UUID, start time.Time) string {
return userID.String() + ":" + start.UTC().Format(time.RFC3339)
}
// DailyTips returns tips for the current 时辰; prefers cache, never blocks on LLM.
func (s *Service) DailyTips(ctx context.Context, userID uuid.UUID) (*DailyTips, error) { func (s *Service) DailyTips(ctx context.Context, userID uuid.UUID) (*DailyTips, error) {
now := time.Now() now := time.Now()
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
loc = time.FixedZone("CST", 8*3600)
}
now = now.In(loc)
win := CurrentShichen(now)
asOf := now.Format("2006-01-02")
self, ok := s.findSelf(ctx, userID) self, ok := s.findSelf(ctx, userID)
if !ok { if !ok || userID == uuid.Nil {
fb := fallbackTips(now, "", nil) fb := decorateTips(fallbackTips(now, "", nil), win, asOf, true)
fb.NeedBirth = true
return fb, nil return fb, nil
} }
birth := self.BirthDate.Format("2006-01-02") birth := self.BirthDate.Format("2006-01-02")
yc := yijing.Seed(birth, self.BirthTime, now) key := tipsCacheKey(userID, win.Start)
cacheKey := fmt.Sprintf("%s:%s:%s:%d", userID, birth, now.Format("2006-01-02"), now.Hour()/3)
if v, hit := tipsCache.Load(cacheKey); hit { if v, hit := tipsMem.Load(key); hit {
e := v.(tipsCacheEntry) out := v.(tipsMemEntry).tips
if time.Now().Before(e.exp) { return decorateTips(&out, win, asOf, false), nil
out := e.tips }
return &out, nil if s.Tips != nil {
if raw, source, err := s.Tips.GetTips(ctx, userID, win.Start); err == nil {
var t DailyTips
if json.Unmarshal(raw, &t) == nil {
t.Source = source
tipsMem.Store(key, tipsMemEntry{tips: t})
return decorateTips(&t, win, asOf, false), nil
}
} else if err != repository.ErrNoTips {
log.Printf("home daily-tips db: %v", err)
} }
} }
if s.LLM == nil || !s.LLM.Enabled() { // Instant path: deterministic fallback, warm LLM in background.
fb := fallbackTips(now, birth, self.BirthTime) fb := fallbackTips(now, birth, self.BirthTime)
fb.GuaName = yc.GuaName out := decorateTips(fb, win, asOf, false)
fb.DayPart = yc.DayPart tipsMem.Store(key, tipsMemEntry{tips: *out})
return fb, nil s.persistTips(userID, win, out)
} s.warmLLMAsync(userID, birth, self.BirthTime, win, asOf, key)
return out, nil
}
tips, err := s.generateLLMTips(ctx, yc) func decorateTips(t *DailyTips, win ShichenWindow, asOf string, needBirth bool) *DailyTips {
if err != nil { cp := *t
log.Printf("home daily-tips llm: %v", err) cp.AsOf = asOf
fb := fallbackTips(now, birth, self.BirthTime) cp.Shichen = win.Index
fb.GuaName = yc.GuaName cp.ShichenName = win.Name + "时"
fb.DayPart = yc.DayPart cp.ValidUntil = win.End.Format(time.RFC3339)
return fb, nil cp.NeedBirth = needBirth
return &cp
}
func (s *Service) persistTips(userID uuid.UUID, win ShichenWindow, tips *DailyTips) {
if s.Tips == nil || tips == nil {
return
} }
tips.Source = "llm" raw, err := json.Marshal(tips)
tips.GuaName = yc.GuaName if err != nil {
tips.DayPart = yc.DayPart return
tips.NeedBirth = false }
tipsCache.Store(cacheKey, tipsCacheEntry{tips: *tips, exp: now.Add(45 * time.Minute)}) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
return tips, nil defer cancel()
if err := s.Tips.UpsertTips(ctx, userID, win.Start, win.Index, raw, tips.Source); err != nil {
log.Printf("home daily-tips upsert: %v", err)
}
}
func (s *Service) warmLLMAsync(userID uuid.UUID, birth string, birthTime *string, win ShichenWindow, asOf, key string) {
if s.LLM == nil || !s.LLM.Enabled() {
return
}
if _, loaded := tipsInflight.LoadOrStore(key, true); loaded {
return
}
go func() {
defer tipsInflight.Delete(key)
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
now := win.Start.Add(30 * time.Minute) // representative instant inside 时辰
if now.After(win.End) {
now = win.Start
}
yc := yijing.Seed(birth, birthTime, now)
tips, err := s.generateLLMTips(ctx, yc, now, win)
if err != nil {
log.Printf("home daily-tips llm warm: %v", err)
return
}
tips.Source = "llm"
tips.ClothingIndex = clothingIndexFromSeed(yc, now, win.Index)
tips.GuaName = yc.GuaName
tips.DayPart = yc.DayPart
out := decorateTips(tips, win, asOf, false)
tipsMem.Store(key, tipsMemEntry{tips: *out})
s.persistTips(userID, win, out)
}()
}
func clothingIndexFromSeed(yc yijing.Context, now time.Time, shichen int) int {
n := yc.GuaIndex*11 + yc.Line*5 + now.YearDay()*3 + shichen*17
return 55 + n%41
} }
func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profile, bool) { func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profile, bool) {
@@ -103,17 +177,22 @@ func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profil
return nil, false return nil, false
} }
func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context) (*DailyTips, error) { func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context, now time.Time, win ShichenWindow) (*DailyTips, error) {
sys := `你是愈心谷的生活节律助手根据用户出生信息与当前时刻的易经卦象种子给出温和的生活建议 weekday := []string{"日", "一", "二", "三", "四", "五", "六"}[now.Weekday()]
sys := `你是愈心谷的生活节律助手根据用户出生信息与当前时辰的易经卦象种子给出温和的生活建议
硬性要求 硬性要求
- 禁止占卜/算命/改命恐吓/吉凶祸福话术 - 禁止占卜/算命/改命恐吓/吉凶祸福话术
- 禁止医疗疗效承诺 - 禁止医疗疗效承诺
- 用探索向身心节律语气可参考卦象意象不要说必有类断言 - 用探索向身心节律语气可参考卦象意象不要说必有类断言
- 文案必须贴合此刻时辰与日期体现与上一时辰不同的节律侧重
- 只输出 JSON不要 markdown - 只输出 JSON不要 markdown
{"clothing_index":70,"clothing":"一句穿衣建议","color_note":"一句颜色说明","palette":[{"name":"色名","hex":"#RRGGBB"},{"name":"色名","hex":"#RRGGBB"}],"wellness":"一句养生建议"} {"clothing_index":70,"clothing":"一句穿衣建议","color_note":"一句颜色说明","palette":[{"name":"色名","hex":"#RRGGBB"},{"name":"色名","hex":"#RRGGBB"}],"wellness":"一句养生建议"}
- clothing_index 5595 整数palette 恰好 2 文案各不超过 40 ` - clothing_index 5595 整数palette 恰好 2 文案各不超过 40 `
user := yc.PromptLine() + "\n请生成今日穿衣指数、颜色搭配与养生推荐。" user := fmt.Sprintf(
"%s\n今天是 %s(星期%s),当前%s时。请生成贴合本时辰的穿衣指数、颜色搭配与养生推荐,勿与通用套话雷同。",
yc.PromptLine(), now.Format("2006-01-02"), weekday, win.Name,
)
raw, err := s.LLM.Chat(ctx, []deepseek.Message{ raw, err := s.LLM.Chat(ctx, []deepseek.Message{
{Role: "system", Content: sys}, {Role: "system", Content: sys},
{Role: "user", Content: user}, {Role: "user", Content: user},
@@ -181,12 +260,20 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
if birth == "" { if birth == "" {
yc = yijing.Seed(now.Format("2006-01-02"), nil, now) yc = yijing.Seed(now.Format("2006-01-02"), nil, now)
} }
win := CurrentShichen(now)
palettes := [][]ColorChip{ palettes := [][]ColorChip{
{{Name: "米白", Hex: "#F5F0E8"}, {Name: "雾霾蓝", Hex: "#A8C4D8"}}, {{Name: "米白", Hex: "#F5F0E8"}, {Name: "雾霾蓝", Hex: "#A8C4D8"}},
{{Name: "燕麦色", Hex: "#E8DCC8"}, {Name: "浅杏", Hex: "#F0C9A8"}}, {{Name: "燕麦色", Hex: "#E8DCC8"}, {Name: "浅杏", Hex: "#F0C9A8"}},
{{Name: "浅灰", Hex: "#D8D6D4"}, {Name: "雾粉", Hex: "#E8B8C4"}}, {{Name: "浅灰", Hex: "#D8D6D4"}, {Name: "雾粉", Hex: "#E8B8C4"}},
{{Name: "象牙白", Hex: "#F7F4EC"}, {Name: "鼠尾草", Hex: "#A8C4B0"}}, {{Name: "象牙白", Hex: "#F7F4EC"}, {Name: "鼠尾草", Hex: "#A8C4B0"}},
{{Name: "浅卡其", Hex: "#DCC8A8"}, {Name: "浅蓝", Hex: "#B0D0E8"}}, {{Name: "浅卡其", Hex: "#DCC8A8"}, {Name: "浅蓝", Hex: "#B0D0E8"}},
{{Name: "天青", Hex: "#9BB8D4"}, {Name: "陶土", Hex: "#C4A484"}},
{{Name: "豆沙", Hex: "#C9A0A0"}, {Name: "雾绿", Hex: "#B5C9B8"}},
{{Name: "奶油", Hex: "#F3EADF"}, {Name: "烟灰", Hex: "#B8B4B0"}},
{{Name: "浅咖", Hex: "#C8B09A"}, {Name: "靛蓝", Hex: "#6B8CAE"}},
{{Name: "杏白", Hex: "#F6EBDD"}, {Name: "藕荷", Hex: "#D4B8C4"}},
{{Name: "竹青", Hex: "#A8BFA8"}, {Name: "沙色", Hex: "#E0D0B8"}},
{{Name: "月白", Hex: "#EEF2F5"}, {Name: "焦糖", Hex: "#C4A070"}},
} }
clothing := []string{ clothing := []string{
"轻薄透气更舒服,外套可备一件薄衫应付温差。", "轻薄透气更舒服,外套可备一件薄衫应付温差。",
@@ -194,18 +281,41 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
"上下同色系更省心,再加一件小配饰点睛即可。", "上下同色系更省心,再加一件小配饰点睛即可。",
"宽松剪裁更自在,适合慢节奏出门与散步。", "宽松剪裁更自在,适合慢节奏出门与散步。",
"内里干净简约,外搭一件有质感的外套就够。", "内里干净简约,外搭一件有质感的外套就够。",
"今日宜层搭:薄内搭+透气外衫,方便随时增减。",
"选垂感面料更显松弛,走路也会轻一点。",
"少用厚重配饰,让肩颈更轻松。",
"浅色上衣更提气色,下装选舒适脚感即可。",
"风大时加一件防风薄外套,比厚羽绒服更灵活。",
"本时辰适合干净线条,少印花更省心。",
"运动鞋或软底鞋更友好,站久也不累。",
}
notes := []string{
"清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。",
"安静又有呼吸感。", "干净利落,好搭配。", "此刻偏清透,层次更轻。",
"暖调一点点,气色更稳。", "冷暖各一,对比柔和。", "低饱和更耐看。",
"偏自然色,贴近户外光线。", "雾感配色,不刺眼。", "柔对比,适合慢节奏。",
} }
notes := []string{"清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。", "安静又有呼吸感。", "干净利落,好搭配。"}
well := []string{ well := []string{
"午后泡一杯温茶,给身心一点缓冲。", "午后泡一杯温茶,给身心一点缓冲。",
"今晚早点放下屏幕,让眼睛歇一会儿。", "今晚早点放下屏幕,让眼睛歇一会儿。",
"走路时把肩膀放松,呼吸会顺很多。", "走路时把肩膀放松,呼吸会顺很多。",
"饭后慢走十分钟,比猛坐着更舒服。", "饭后慢走十分钟,比猛坐着更舒服。",
"喝水提醒自己小口多次,别等口渴再灌。", "喝水提醒自己小口多次,别等口渴再灌。",
"此刻做三次深呼吸,拉长呼气更易放松。",
"站起来伸个懒腰,活动一下髋与肩。",
"晚饭少一点刺激,给肠胃留空档。",
"睡前把明天三件小事写下来,心会静些。",
"听一首慢歌,跟着节奏把呼吸放慢。",
"把窗帘开一条缝,让自然光进一点。",
"洗手洗脸用温水,给皮肤一点温柔。",
}
n := len(palettes)
i := (yc.GuaIndex*13 + yc.Line*7 + now.YearDay()*3 + win.Index*19) % n
if i < 0 {
i = -i
} }
i := (yc.GuaIndex + yc.Line + now.Hour()) % 5
return &DailyTips{ return &DailyTips{
ClothingIndex: 62 + (yc.GuaIndex % 31), ClothingIndex: clothingIndexFromSeed(yc, now, win.Index),
Clothing: clothing[i], Clothing: clothing[i],
ColorNote: notes[i], ColorNote: notes[i],
Palette: palettes[i], Palette: palettes[i],
@@ -213,5 +323,6 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
Source: "fallback", Source: "fallback",
GuaName: yc.GuaName, GuaName: yc.GuaName,
DayPart: yc.DayPart, DayPart: yc.DayPart,
AsOf: now.Format("2006-01-02"),
} }
} }
@@ -29,6 +29,7 @@ var allowedIcons = map[string]struct{}{
// Service serves homepage tool catalog and daily tips. // Service serves homepage tool catalog and daily tips.
type Service struct { type Service struct {
Repo *repository.HomeToolsRepo Repo *repository.HomeToolsRepo
Tips *repository.HomeDailyTipsRepo
Profiles *repository.ProfileRepo Profiles *repository.ProfileRepo
LLM *deepseek.Client LLM *deepseek.Client
} }
+54 -1
View File
@@ -1,6 +1,9 @@
package home package home
import "testing" import (
"testing"
"time"
)
func TestNormalizeToolOK(t *testing.T) { func TestNormalizeToolOK(t *testing.T) {
b := "热" b := "热"
@@ -28,3 +31,53 @@ func TestNormalizeToolRejects(t *testing.T) {
} }
} }
} }
func TestFallbackTipsChangeByDay(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
d1 := time.Date(2026, 8, 11, 19, 0, 0, 0, loc)
d2 := time.Date(2026, 8, 12, 19, 0, 0, 0, loc)
a := fallbackTips(d1, "1990-05-12", nil)
b := fallbackTips(d2, "1990-05-12", nil)
if a.ClothingIndex == b.ClothingIndex && a.Clothing == b.Clothing && a.Wellness == b.Wellness {
t.Fatalf("expected day-varying tips, got same %+v", a)
}
if a.AsOf == b.AsOf {
t.Fatalf("as_of should differ: %s", a.AsOf)
}
}
func TestCurrentShichenBoundaries(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
cases := []struct {
h, wantIdx int
wantName string
}{
{23, 0, "子"},
{0, 0, "子"},
{9, 5, "巳"},
{10, 5, "巳"},
{11, 6, "午"},
}
for _, c := range cases {
now := time.Date(2026, 8, 12, c.h, 15, 0, 0, loc)
w := CurrentShichen(now)
if w.Index != c.wantIdx || w.Name != c.wantName {
t.Fatalf("h=%d got %d %s want %d %s", c.h, w.Index, w.Name, c.wantIdx, c.wantName)
}
if !w.Start.Before(now) && !w.Start.Equal(now) {
t.Fatalf("start should be <= now: %v %v", w.Start, now)
}
if !w.End.After(now) {
t.Fatalf("end should be > now")
}
}
}
func TestFallbackTipsChangeByShichen(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
a := fallbackTips(time.Date(2026, 8, 12, 9, 0, 0, 0, loc), "1990-05-12", nil)
b := fallbackTips(time.Date(2026, 8, 12, 11, 0, 0, 0, loc), "1990-05-12", nil)
if a.Clothing == b.Clothing && a.Wellness == b.Wellness && a.ClothingIndex == b.ClothingIndex {
t.Fatalf("expected shichen-varying tips")
}
}
+32
View File
@@ -0,0 +1,32 @@
package home
import (
"time"
)
var shichenNames = [12]string{"子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"}
// ShichenWindow is the current Chinese double-hour window.
type ShichenWindow struct {
Index int
Name string
Start time.Time
End time.Time
}
// CurrentShichen returns the 十二时辰 window for now (子时 23:0001:00 …).
func CurrentShichen(now time.Time) ShichenWindow {
h := now.Hour()
idx := ((h + 1) % 24) / 2
startHour := (idx*2 + 23) % 24
start := time.Date(now.Year(), now.Month(), now.Day(), startHour, 0, 0, 0, now.Location())
if start.After(now) {
start = start.Add(-24 * time.Hour)
}
return ShichenWindow{
Index: idx,
Name: shichenNames[idx],
Start: start,
End: start.Add(2 * time.Hour),
}
}
@@ -76,3 +76,12 @@ func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*Me, error) {
AskQuotaLeft: row.AskQuotaLeft, AskQuotaLeft: row.AskQuotaLeft,
}, nil }, nil
} }
// IsActive reports whether the user has an active成长会员.
func (s *Service) IsActive(ctx context.Context, userID uuid.UUID) (bool, error) {
me, err := s.Get(ctx, userID)
if err != nil {
return false, err
}
return me.Active, nil
}
@@ -11,6 +11,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/model" "github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap" "github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
) )
// Service manages personal archives. // Service manages personal archives.
@@ -58,6 +59,10 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
name = "TA" name = "TA"
} }
} }
name, err := textsafe.Check(textsafe.DisplayName, name)
if err != nil {
return nil, err
}
p, err := s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace) p, err := s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -94,6 +99,12 @@ func (s *Service) Update(ctx context.Context, userID, profileID uuid.UUID, in Up
name := strings.TrimSpace(in.DisplayName) name := strings.TrimSpace(in.DisplayName)
if name == "" { if name == "" {
name = cur.DisplayName name = cur.DisplayName
} else {
var err error
name, err = textsafe.Check(textsafe.DisplayName, name)
if err != nil {
return nil, err
}
} }
birth := in.BirthDate birth := in.BirthDate
if birth.IsZero() { if birth.IsZero() {
+11 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm" "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"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry" "github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
) )
// Service creates and reads growth reports with entitlement trimming. // Service creates and reads growth reports with entitlement trimming.
@@ -98,6 +99,10 @@ func (s *Service) AcceptInvite(ctx context.Context, guestUser uuid.UUID, token,
if name == "" { if name == "" {
name = "TA" name = "TA"
} }
name, err = textsafe.Check(textsafe.DisplayName, name)
if err != nil {
return nil, err
}
host, err := s.Profiles.GetByID(ctx, inv.HostProfileID) host, err := s.Profiles.GetByID(ctx, inv.HostProfileID)
if err != nil { if err != nil {
return nil, errors.New("host profile missing") return nil, errors.New("host profile missing")
@@ -147,6 +152,7 @@ func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (
} }
out, err := star.BuildWith(star.BuildOpts{ out, err := star.BuildWith(star.BuildOpts{
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName, Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
AsOf: time.Now().In(time.FixedZone("CST", 8*3600)),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -212,7 +218,7 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
if err != nil { if err != nil {
return nil, errors.New("profile not found") return nil, errors.New("profile not found")
} }
out := rhythm.Build(p.BirthDate, p.DisplayName) out := rhythm.BuildWith(p.BirthDate, p.DisplayName, time.Now().In(time.FixedZone("CST", 8*3600)))
sum, _ := json.Marshal(out.Summary) sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail) det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "rhythm", sum, det) rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "rhythm", sum, det)
@@ -222,21 +228,23 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
return s.applyEntitlement(ctx, userID, rep) return s.applyEntitlement(ctx, userID, rep)
} }
// GetLatest returns cached report by profile+type(+peer). // GetLatest returns cached report by profile+type(+peer); refreshes star/rhythm day tips.
func (s *Service) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) { func (s *Service) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
rep, err := s.Reports.GetLatest(ctx, userID, profileID, typ, peer) rep, err := s.Reports.GetLatest(ctx, userID, profileID, typ, peer)
if err != nil { if err != nil {
return nil, errors.New("report not found") return nil, errors.New("report not found")
} }
rep, _ = s.refreshTemporalIfNeeded(ctx, userID, rep)
return s.applyEntitlement(ctx, userID, rep) return s.applyEntitlement(ctx, userID, rep)
} }
// Get returns a report with detail gated. // Get returns a report with detail gated; refreshes star/rhythm day tips.
func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) { func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
rep, err := s.Reports.GetForUser(ctx, userID, reportID) rep, err := s.Reports.GetForUser(ctx, userID, reportID)
if err != nil { if err != nil {
return nil, errors.New("report not found") return nil, errors.New("report not found")
} }
rep, _ = s.refreshTemporalIfNeeded(ctx, userID, rep)
return s.applyEntitlement(ctx, userID, rep) return s.applyEntitlement(ctx, userID, rep)
} }
@@ -0,0 +1,127 @@
package report
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
)
func cstLoc() *time.Location {
return time.FixedZone("CST", 8*3600)
}
func dayKey(t time.Time) string {
return t.In(cstLoc()).Format("2006-01-02")
}
func summaryDay(raw json.RawMessage) string {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return ""
}
s, _ := m["as_of"].(string)
return s
}
func hasWuxingBars(raw json.RawMessage) bool {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return false
}
wx, _ := m["wuxing"].(map[string]any)
if wx == nil {
return false
}
bars, ok := wx["bars"].([]any)
return ok && len(bars) > 0
}
func rhythmNeedsContentBackfill(raw json.RawMessage) bool {
if !hasWuxingBars(raw) {
return true
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return true
}
prev, _ := m["blind_spots_preview"].([]any)
if len(prev) == 0 {
return true
}
for _, item := range prev {
s, _ := item.(string)
if s == "完整习惯方案见深度版。" || strings.Contains(s, "见深度版") {
return true
}
}
return false
}
// refreshTemporalIfNeeded updates star/rhythm day-scoped tips in place when as_of is stale.
func (s *Service) refreshTemporalIfNeeded(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) {
if rep == nil || (rep.Type != "star" && rep.Type != "rhythm") {
return rep, nil
}
now := time.Now().In(cstLoc())
today := dayKey(now)
staleDay := summaryDay(rep.Summary) != today
needBackfill := rep.Type == "rhythm" && rhythmNeedsContentBackfill(rep.Summary)
if !staleDay && !needBackfill {
return stampValidUntil(rep, now), nil
}
p, err := s.Profiles.GetForUser(ctx, userID, rep.ProfileID)
if err != nil {
return rep, nil
}
var sum, det json.RawMessage
switch rep.Type {
case "star":
out, err := star.BuildWith(star.BuildOpts{
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace,
Name: p.DisplayName, AsOf: now,
})
if err != nil {
return rep, nil
}
sum, _ = json.Marshal(out.Summary)
det, _ = json.Marshal(out.Detail)
case "rhythm":
out := rhythm.BuildWith(p.BirthDate, p.DisplayName, now)
sum, _ = json.Marshal(out.Summary)
det, _ = json.Marshal(out.Detail)
default:
return rep, nil
}
if err := s.Reports.UpdateContent(ctx, userID, rep.ID, sum, det); err != nil {
return rep, nil
}
rep.Summary = sum
rep.Detail = det
return rep, nil
}
func stampValidUntil(rep *model.GrowthReport, now time.Time) *model.GrowthReport {
var m map[string]any
if err := json.Unmarshal(rep.Summary, &m); err != nil {
return rep
}
if _, ok := m["valid_until"].(string); ok && m["as_of"] == dayKey(now) {
return rep
}
m["as_of"] = dayKey(now)
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
m["valid_until"] = next.Format(time.RFC3339)
raw, err := json.Marshal(m)
if err != nil {
return rep
}
rep.Summary = raw
return rep
}
@@ -0,0 +1,69 @@
package report
import (
"encoding/json"
"testing"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
)
func TestDayKeyCST(t *testing.T) {
// 2026-08-12 23:30 UTC = 2026-08-13 07:30 CST
utc := time.Date(2026, 8, 12, 23, 30, 0, 0, time.UTC)
if got := dayKey(utc); got != "2026-08-13" {
t.Fatalf("dayKey=%s", got)
}
}
func TestStarOutlookChangesByDay(t *testing.T) {
birth := time.Date(1990, 5, 15, 0, 0, 0, 0, time.UTC)
d1 := time.Date(2026, 8, 12, 10, 0, 0, 0, cstLoc())
d2 := time.Date(2026, 8, 13, 10, 0, 0, 0, cstLoc())
a, err := star.BuildWith(star.BuildOpts{Birth: birth, Name: "测", AsOf: d1})
if err != nil {
t.Fatal(err)
}
b, err := star.BuildWith(star.BuildOpts{Birth: birth, Name: "测", AsOf: d2})
if err != nil {
t.Fatal(err)
}
if a.Summary["as_of"] != "2026-08-12" || b.Summary["as_of"] != "2026-08-13" {
t.Fatalf("as_of a=%v b=%v", a.Summary["as_of"], b.Summary["as_of"])
}
oa, _ := a.Summary["outlook"].(map[string]any)
ob, _ := b.Summary["outlook"].(map[string]any)
da, _ := oa["daily"].(map[string]any)
db, _ := ob["daily"].(map[string]any)
if da["score"] == db["score"] && da["tip"] == db["tip"] {
t.Fatalf("expected daily outlook to differ across days")
}
if a.Summary["valid_until"] == nil || b.Summary["valid_until"] == nil {
t.Fatal("missing valid_until")
}
}
func TestRhythmTipsChangeByDay(t *testing.T) {
birth := time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC)
d1 := time.Date(2026, 8, 10, 12, 0, 0, 0, cstLoc()) // Mon
d2 := time.Date(2026, 8, 11, 12, 0, 0, 0, cstLoc()) // Tue
a := rhythm.BuildWith(birth, "测", d1)
b := rhythm.BuildWith(birth, "测", d2)
if a.Summary["as_of"] != "2026-08-10" || b.Summary["as_of"] != "2026-08-11" {
t.Fatalf("as_of a=%v b=%v", a.Summary["as_of"], b.Summary["as_of"])
}
if a.Summary["week_focus"] == b.Summary["week_focus"] && a.Summary["today_tip"] == b.Summary["today_tip"] {
t.Fatalf("expected tips to differ")
}
}
func TestSummaryDay(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"as_of": "2026-08-12"})
if summaryDay(raw) != "2026-08-12" {
t.Fatal(summaryDay(raw))
}
if summaryDay(json.RawMessage(`{}`)) != "" {
t.Fatal("empty")
}
}
+124 -14
View File
@@ -8,13 +8,23 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
sc "github.com/yuxingu/digital-psychology/apps/api/internal/scale" sc "github.com/yuxingu/digital-psychology/apps/api/internal/scale"
"github.com/yuxingu/digital-psychology/apps/api/internal/scalebank"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
) )
// ErrMembershipRequired when full MBTI requires growth membership.
var ErrMembershipRequired = errors.New("membership required")
// MembershipChecker reports whether成长会员 is active.
type MembershipChecker interface {
IsActive(ctx context.Context, userID uuid.UUID) (bool, error)
}
// Service serves 探索测试. // Service serves 探索测试.
type Service struct { type Service struct {
Repo *repository.ScaleRepo Repo *repository.ScaleRepo
Profiles *repository.ProfileRepo Profiles *repository.ProfileRepo
Membership MembershipChecker
} }
// List returns published scales. // List returns published scales.
@@ -22,12 +32,42 @@ func (s *Service) List(ctx context.Context) ([]repository.ScaleListItem, error)
return s.Repo.ListPublished(ctx) return s.Repo.ListPublished(ctx)
} }
// Get returns scale detail. // Get returns scale detail; bank slugs served from curated embed; mbti-full may be locked.
func (s *Service) Get(ctx context.Context, slug string) (*repository.ScaleDetail, error) { func (s *Service) Get(ctx context.Context, userID uuid.UUID, slug string) (*repository.ScaleDetail, error) {
if bank, err := scalebank.Get(slug); err == nil {
if _, err := s.Repo.EnsurePublished(ctx, bank.Slug, bank.Title, bank.Description); err != nil {
return nil, errors.New("scale not found")
}
d := &repository.ScaleDetail{
Slug: bank.Slug, Title: bank.Title, Description: bank.Description, Access: "free",
}
for i, q := range bank.Questions {
body, _ := json.Marshal(map[string]any{
"prompt": q.Prompt, "options": q.Options,
})
d.Questions = append(d.Questions, repository.ScaleQuestion{
ID: scalebank.QuestionID(slug, i), Sort: i + 1, Body: body,
})
}
return d, nil
}
d, err := s.Repo.GetBySlug(ctx, slug) d, err := s.Repo.GetBySlug(ctx, slug)
if err != nil { if err != nil {
return nil, errors.New("scale not found") return nil, errors.New("scale not found")
} }
if slug == "mbti-full" {
d.Access = "membership"
ok := false
if s.Membership != nil && userID != uuid.Nil {
ok, _ = s.Membership.IsActive(ctx, userID)
}
if !ok {
d.Locked = true
d.Questions = nil
}
} else {
d.Access = "free"
}
return d, nil return d, nil
} }
@@ -43,19 +83,58 @@ type SubmitResult struct {
Result map[string]interface{} `json:"result"` Result map[string]interface{} `json:"result"`
} }
// Submit scores a simple majority style. // Submit scores answers and stores result.
func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in SubmitInput) (*SubmitResult, error) { func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in SubmitInput) (*SubmitResult, error) {
if _, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID); err != nil { if _, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID); err != nil {
return nil, errors.New("profile not found") return nil, errors.New("profile not found")
} }
scaleID, err := s.Repo.ScaleIDBySlug(ctx, slug) if slug == "mbti-full" {
if err != nil { ok := false
return nil, errors.New("scale not found") if s.Membership != nil {
ok, _ = s.Membership.IsActive(ctx, userID)
}
if !ok {
return nil, ErrMembershipRequired
}
} }
labels, _, _ := labelsForSlug(slug) var result map[string]interface{}
key, label := sc.ScoreMajority(in.Answers, labels, "平衡探索型") var scaleID uuid.UUID
result := sc.BuildResult(slug, key, label)
if bank, err := scalebank.Get(slug); err == nil {
scaleID, err = s.Repo.EnsurePublished(ctx, bank.Slug, bank.Title, bank.Description)
if err != nil {
return nil, errors.New("scale not found")
}
key, label, _, _ := scalebank.ScoreSum(in.Answers, bank.QuestionCount)
result = sc.BuildResult(slug, key, label)
result["disclaimer"] = bank.Disclaimer
} else {
detail, err := s.Repo.GetBySlug(ctx, slug)
if err != nil {
return nil, errors.New("scale not found")
}
scaleID, err = s.Repo.ScaleIDBySlug(ctx, slug)
if err != nil {
return nil, errors.New("scale not found")
}
if slug == "mbti-lite" || slug == "mbti-full" {
meta := map[string]sc.JungianQuestionMeta{}
for _, q := range detail.Questions {
meta[q.ID.String()] = sc.ParseJungianMeta(q.Body)
}
perDim := 8
if slug == "mbti-full" {
perDim = 15
}
code, _, pct := sc.ScoreJungian(in.Answers, meta, perDim)
result = sc.BuildJungianResult(code, pct)
} else {
labels, _, _ := labelsForSlug(slug)
key, label := sc.ScoreMajority(in.Answers, labels, "平衡探索型")
result = sc.BuildResult(slug, key, label)
}
}
ansJSON, _ := json.Marshal(in.Answers) ansJSON, _ := json.Marshal(in.Answers)
resJSON, _ := json.Marshal(result) resJSON, _ := json.Marshal(result)
id, err := s.Repo.SaveResult(ctx, userID, scaleID, in.ProfileID, ansJSON, resJSON) id, err := s.Repo.SaveResult(ctx, userID, scaleID, in.ProfileID, ansJSON, resJSON)
@@ -65,15 +144,36 @@ func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in
return &SubmitResult{ID: id, Result: result}, nil return &SubmitResult{ID: id, Result: result}, nil
} }
// LatestResult returns the newest stored result for this user + slug.
func (s *Service) LatestResult(ctx context.Context, userID uuid.UUID, slug string) (*SubmitResult, error) {
if scalebank.Has(slug) {
bank, err := scalebank.Get(slug)
if err != nil {
return nil, errors.New("scale not found")
}
if _, err := s.Repo.EnsurePublished(ctx, bank.Slug, bank.Title, bank.Description); err != nil {
return nil, errors.New("scale not found")
}
} else if _, err := s.Repo.ScaleIDBySlug(ctx, slug); err != nil {
return nil, errors.New("scale not found")
}
row, err := s.Repo.LatestResult(ctx, userID, slug)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(row.Result, &result); err != nil {
return nil, errors.New("invalid result")
}
return &SubmitResult{ID: row.ID, Result: result}, nil
}
func labelsForSlug(slug string) (labels map[string]string, sharePrefix, summary string) { func labelsForSlug(slug string) (labels map[string]string, sharePrefix, summary string) {
switch slug { switch slug {
case "emotion-pattern": case "emotion-pattern":
return sc.EmotionLabels(), return sc.EmotionLabels(),
"我的情感模式:", "我的情感模式:",
"这是你当前情绪调节偏好的探索结果,可用于自我了解与日常调整,不是固定标签。" "这是你当前情绪调节偏好的探索结果,可用于自我了解与日常调整,不是固定标签。"
case "mbti-lite":
return map[string]string{"E": "外向充能型", "I": "内向充能型", "T": "理性决策型", "F": "感受决策型", "J": "结构安排型", "P": "弹性探索型"},
"我的人格偏好:", "轻量人格偏好探索,不是固定分类。"
case "enneagram-lite": case "enneagram-lite":
return map[string]string{"A": "尽责驱动型", "B": "联结驱动型", "C": "独立驱动型"}, return map[string]string{"A": "尽责驱动型", "B": "联结驱动型", "C": "独立驱动型"},
"我的动机模式:", "内在动机轻量探索,用于自我觉察。" "我的动机模式:", "内在动机轻量探索,用于自我觉察。"
@@ -98,3 +198,13 @@ func labelsForSlug(slug string) (labels map[string]string, sharePrefix, summary
"这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。" "这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。"
} }
} }
// BankCatalog returns curated exploration bank tiles + categories.
func (s *Service) BankCatalog() (*scalebank.CatalogOut, error) {
return scalebank.Catalog()
}
// BankCategory lists scales in a curated category.
func (s *Service) BankCategory(key string) (*scalebank.CategoryCard, []scalebank.ScaleListItem, error) {
return scalebank.CategoryScales(key)
}
+7 -1
View File
@@ -66,11 +66,15 @@ func BuildWith(opts BuildOpts) (Output, error) {
asOf := opts.AsOf asOf := opts.AsOf
if asOf.IsZero() { if asOf.IsZero() {
asOf = time.Now() asOf = time.Now().In(time.FixedZone("CST", 8*3600))
} else {
asOf = asOf.In(time.FixedZone("CST", 8*3600))
} }
fort := outlook.Build(chart, asOf) fort := outlook.Build(chart, asOf)
daily := fort.Daily daily := fort.Daily
fortMap := fort.AsMap() fortMap := fort.AsMap()
dayKey := asOf.Format("2006-01-02")
validUntil := time.Date(asOf.Year(), asOf.Month(), asOf.Day()+1, 0, 0, 0, 0, asOf.Location())
planetsOut := make([]map[string]any, 0, len(chart.Planets)) planetsOut := make([]map[string]any, 0, len(chart.Planets))
for _, p := range chart.Planets { for _, p := range chart.Planets {
@@ -121,6 +125,8 @@ func BuildWith(opts BuildOpts) (Output, error) {
"title": daily.Title, "focus": daily.Focus, "tip": daily.Tip, "title": daily.Title, "focus": daily.Focus, "tip": daily.Tip,
"energy": daily.Score, "note": daily.Label, "score": daily.Score, "label": daily.Label, "energy": daily.Score, "note": daily.Label, "score": daily.Score, "label": daily.Label,
}, },
"as_of": dayKey,
"valid_until": validUntil.Format(time.RFC3339),
"strengths_preview": pack.Strengths[:min(3, len(pack.Strengths))], "strengths_preview": pack.Strengths[:min(3, len(pack.Strengths))],
"blind_spots_preview": []string{"完整相位与年运详解见深度版。"}, "blind_spots_preview": []string{"完整相位与年运详解见深度版。"},
"interaction_tags": []string{ "interaction_tags": []string{
+184
View File
@@ -0,0 +1,184 @@
// Package textsafe validates user free-text at the API boundary (Spec input-compliance).
package textsafe
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// Kind selects length limits and whether empty is allowed.
type Kind int
const (
Nickname Kind = iota
DisplayName
AskContent
Note // optional
Title
Focus // optional
Scene
)
// ErrRejected is returned for compliance failures (map to HTTP 400 / code 40060).
var ErrRejected = errors.New("文案不合规")
type limits struct {
min, max int
allowNL bool
}
func lim(k Kind) limits {
switch k {
case Nickname:
return limits{1, 16, false}
case DisplayName:
return limits{1, 24, false}
case AskContent:
return limits{1, 2000, true}
case Note:
return limits{0, 200, true}
case Title:
return limits{1, 40, false}
case Focus:
return limits{0, 40, false}
case Scene:
return limits{1, 80, false}
default:
return limits{1, 200, false}
}
}
var banned = []string{
"占卜", "算命", "改命", "测测",
"疗效", "包治", "根治", "治疗疾病", "治愈癌症",
"不测就有灾", "大师算卦", "神棍",
}
// Check normalizes and validates. Empty optional kinds return ("", nil).
func Check(kind Kind, raw string) (string, error) {
s := Normalize(raw, lim(kind).allowNL)
l := lim(kind)
n := utf8.RuneCountInString(s)
if n < l.min {
if l.min == 0 {
return "", nil
}
return "", reject("请填写内容")
}
if n > l.max {
return "", reject("内容过长")
}
if err := rejectControls(s, l.allowNL); err != nil {
return "", err
}
if err := rejectMarkup(s); err != nil {
return "", err
}
if err := rejectBanned(s); err != nil {
return "", err
}
if err := rejectSpam(s); err != nil {
return "", err
}
return s, nil
}
// Normalize trims; optionally keeps internal newlines.
func Normalize(s string, allowNL bool) string {
s = strings.TrimSpace(s)
if !allowNL {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
return r
}, s)
s = strings.Join(strings.Fields(s), " ")
}
return s
}
func reject(msg string) error {
return fmt.Errorf("%w%s", ErrRejected, msg)
}
func rejectControls(s string, allowNL bool) error {
for _, r := range s {
if r == 0 {
return reject("含非法字符")
}
if r == '\n' || r == '\r' || r == '\t' {
if !allowNL {
return reject("含非法空白")
}
continue
}
if r < 0x20 || r == 0x7f {
return reject("含非法字符")
}
}
return nil
}
func rejectMarkup(s string) error {
low := strings.ToLower(s)
if strings.Contains(low, "<script") || strings.Contains(low, "javascript:") {
return reject("含不安全内容")
}
if strings.Contains(low, "onerror=") || strings.Contains(low, "onload=") {
return reject("含不安全内容")
}
// bare tags like <img ...>
for i := 0; i < len(s); i++ {
if s[i] != '<' {
continue
}
if i+1 < len(s) {
c := s[i+1]
if c == '/' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '!' {
return reject("含不安全内容")
}
}
}
return nil
}
func rejectBanned(s string) error {
for _, b := range banned {
if b != "" && strings.Contains(s, b) {
return reject("含不允许的表述,请换一种说法")
}
}
return nil
}
func rejectSpam(s string) error {
runes := []rune(s)
if len(runes) >= 6 {
allSame := true
for i := 1; i < len(runes); i++ {
if runes[i] != runes[0] {
allSame = false
break
}
}
if allSame && !unicode.IsSpace(runes[0]) {
return reject("请勿重复刷屏")
}
}
streak := 1
for i := 1; i < len(runes); i++ {
if runes[i] == runes[i-1] && !unicode.IsSpace(runes[i]) {
streak++
if streak >= 8 {
return reject("请勿重复刷屏")
}
} else {
streak = 1
}
}
return nil
}
@@ -0,0 +1,54 @@
package textsafe_test
import (
"strings"
"testing"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
)
func TestOK(t *testing.T) {
got, err := textsafe.Check(textsafe.Nickname, " 心语岛 ")
if err != nil || got != "心语岛" {
t.Fatalf("got %q err=%v", got, err)
}
got, err = textsafe.Check(textsafe.DisplayName, "小愈")
if err != nil || got != "小愈" {
t.Fatalf("got %q err=%v", got, err)
}
}
func TestBanned(t *testing.T) {
if _, err := textsafe.Check(textsafe.Nickname, "算命达人"); err == nil {
t.Fatal("expected reject")
}
if _, err := textsafe.Check(textsafe.AskContent, "帮我占卜一下"); err == nil {
t.Fatal("expected reject")
}
}
func TestXSS(t *testing.T) {
if _, err := textsafe.Check(textsafe.AskContent, `hi <script>alert(1)</script>`); err == nil {
t.Fatal("expected reject")
}
}
func TestSpam(t *testing.T) {
if _, err := textsafe.Check(textsafe.Note, "啊啊啊啊啊啊啊啊"); err == nil {
t.Fatal("expected reject")
}
}
func TestLength(t *testing.T) {
long := strings.Repeat("字", 20)
if _, err := textsafe.Check(textsafe.Nickname, long); err == nil {
t.Fatal("expected reject")
}
}
func TestOptionalEmpty(t *testing.T) {
got, err := textsafe.Check(textsafe.Note, " ")
if err != nil || got != "" {
t.Fatalf("got %q err=%v", got, err)
}
}
+7 -7
View File
@@ -18,12 +18,12 @@ func TestSeedStableSameInputs(t *testing.T) {
} }
} }
func TestSeedChangesWithHour(t *testing.T) { func TestSeedChangesWithDay(t *testing.T) {
now1 := time.Date(2026, 8, 11, 9, 0, 0, 0, time.FixedZone("CST", 8*3600)) d1 := time.Date(2026, 8, 11, 19, 0, 0, 0, time.FixedZone("CST", 8*3600))
now2 := time.Date(2026, 8, 11, 18, 0, 0, 0, time.FixedZone("CST", 8*3600)) d2 := time.Date(2026, 8, 12, 19, 0, 0, 0, time.FixedZone("CST", 8*3600))
a := Seed("1990-05-12", nil, now1) a := Seed("1990-05-12", nil, d1)
b := Seed("1990-05-12", nil, now2) b := Seed("1990-05-12", nil, d2)
if a.DayPart == b.DayPart { if a.GuaIndex == b.GuaIndex && a.Line == b.Line {
t.Fatalf("expected different day parts, both %s", a.DayPart) t.Fatalf("expected seed to change across days, both gua=%d line=%d", a.GuaIndex, a.Line)
} }
} }
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
@@ -0,0 +1,2 @@
-- ECR avatar: account profile photo URL
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url varchar(512) NULL;
@@ -0,0 +1,9 @@
UPDATE home_tools
SET label = '人格测试', updated_at = now()
WHERE path = '/scales/mbti-lite' AND icon = 'mbti';
UPDATE scales
SET title = '人格类型探索',
description = '了解能量来源与决策偏好的轻量探索。',
updated_at = now()
WHERE slug = 'mbti-lite' AND deleted_at IS NULL;
@@ -0,0 +1,11 @@
-- Rename mbti-lite display to MBTI测试 (ECR-adjacent · explore-test R9)
UPDATE scales
SET title = 'MBTI测试',
description = '了解能量来源与决策偏好的轻量探索。',
updated_at = now()
WHERE slug = 'mbti-lite' AND deleted_at IS NULL;
UPDATE home_tools
SET label = 'MBTI测试', updated_at = now()
WHERE path = '/scales/mbti-lite' AND icon = 'mbti';
@@ -0,0 +1,20 @@
-- Revert mbti-lite to previous 3-question lite set (best-effort)
UPDATE scale_questions
SET deleted_at = now(), updated_at = now()
WHERE scale_id = '22222222-2222-2222-2222-222222222201'
AND deleted_at IS NULL;
UPDATE scales
SET title = 'MBTI测试',
description = '了解能量来源与决策偏好的轻量探索。',
updated_at = now()
WHERE id = '22222222-2222-2222-2222-222222222201';
INSERT INTO scale_questions (scale_id, sort, body) VALUES
('22222222-2222-2222-2222-222222222201', 1,
'{"prompt":"周末充电,你更想?","options":[{"key":"E","text":"和朋友见面聊天"},{"key":"I","text":"独处安静恢复"}]}'),
('22222222-2222-2222-2222-222222222201', 2,
'{"prompt":"做决定时你更看重?","options":[{"key":"T","text":"逻辑与公平"},{"key":"F","text":"感受与关系"}]}'),
('22222222-2222-2222-2222-222222222201', 3,
'{"prompt":"面对计划,你更倾向?","options":[{"key":"J","text":"提前安排清楚"},{"key":"P","text":"保持弹性随机"}]}');
@@ -0,0 +1,47 @@
-- MBTI测试:替换极简 3 题为四维 12 题;旧结果失效(explore-test R10/R11
UPDATE scale_questions
SET deleted_at = now(), updated_at = now()
WHERE scale_id = '22222222-2222-2222-2222-222222222201'
AND deleted_at IS NULL;
UPDATE scale_results
SET deleted_at = now(), updated_at = now()
WHERE scale_id = '22222222-2222-2222-2222-222222222201'
AND deleted_at IS NULL;
UPDATE scales
SET title = 'MBTI测试',
description = '四维偏好探索(能量 · 信息 · 决策 · 节奏),约 5 分钟。结果用于自我了解,不是固定标签。',
updated_at = now()
WHERE id = '22222222-2222-2222-2222-222222222201';
INSERT INTO scale_questions (scale_id, sort, body) VALUES
-- E / I
('22222222-2222-2222-2222-222222222201', 1,
'{"prompt":"参加不太熟的聚会时,你更常?","options":[{"key":"E","text":"主动找人聊天、很快融入"},{"key":"I","text":"先观察一会儿,和少数人深聊"}]}'),
('22222222-2222-2222-2222-222222222201', 2,
'{"prompt":"连续社交一天后,你更想?","options":[{"key":"E","text":"再约人聊聊,延续热闹"},{"key":"I","text":"回家独处,安静恢复"}]}'),
('22222222-2222-2222-2222-222222222201', 3,
'{"prompt":"想清楚一件难事时,你更习惯?","options":[{"key":"E","text":"边说边想,找人讨论"},{"key":"I","text":"先自己想透,再开口"}]}'),
-- S / N
('22222222-2222-2222-2222-222222222201', 4,
'{"prompt":"听别人讲经历,你更抓住?","options":[{"key":"S","text":"具体细节和实际经过"},{"key":"N","text":"背后的含义与可能趋势"}]}'),
('22222222-2222-2222-2222-222222222201', 5,
'{"prompt":"学新技能时,你更喜欢?","options":[{"key":"S","text":"按步骤练熟,先把基础做扎实"},{"key":"N","text":"先看整体框架,再联想用法"}]}'),
('22222222-2222-2222-2222-222222222201', 6,
'{"prompt":"讨论未来计划,你更容易?","options":[{"key":"S","text":"关注眼下可执行的安排"},{"key":"N","text":"先想象多种可能性与愿景"}]}'),
-- T / F
('22222222-2222-2222-2222-222222222201', 7,
'{"prompt":"朋友来倾诉冲突,你更先?","options":[{"key":"T","text":"帮对方理清对错与可行方案"},{"key":"F","text":"先接住情绪,让对方感到被理解"}]}'),
('22222222-2222-2222-2222-222222222201', 8,
'{"prompt":"团队做决定时,你更看重?","options":[{"key":"T","text":"标准一致、结果是否公正有效"},{"key":"F","text":"大家感受如何、关系是否受伤"}]}'),
('22222222-2222-2222-2222-222222222201', 9,
'{"prompt":"收到批评时,你内心更在意?","options":[{"key":"T","text":"对方说的是否准确、可改进"},{"key":"F","text":"语气是否尊重、关系有没有裂痕"}]}'),
-- J / P
('22222222-2222-2222-2222-222222222201', 10,
'{"prompt":"出门旅行,你更倾向?","options":[{"key":"J","text":"行程大体订好,心里踏实"},{"key":"P","text":"留白多一点,走到哪儿算哪儿"}]}'),
('22222222-2222-2222-2222-222222222201', 11,
'{"prompt":"面对截止日期,你通常?","options":[{"key":"J","text":"尽早推进,提前收尾更安心"},{"key":"P","text":"压力临近时效率最高,灵活赶完"}]}'),
('22222222-2222-2222-2222-222222222201', 12,
'{"prompt":"书桌/手机桌面,你更舒服的是?","options":[{"key":"J","text":"归类清楚,东西有固定位置"},{"key":"P","text":"随用随放,需要时再整理"}]}');
@@ -0,0 +1,10 @@
-- revert OEJTS seed (best-effort soft-delete full scale)
UPDATE scale_questions SET deleted_at = now(), updated_at = now()
WHERE scale_id IN (
'22222222-2222-2222-2222-222222222201',
'22222222-2222-2222-2222-222222222210'
) AND deleted_at IS NULL;
UPDATE scales SET deleted_at = now(), updated_at = now()
WHERE id = '22222222-2222-2222-2222-222222222210';
@@ -0,0 +1,101 @@
-- OEJTS 1.2 standard (32, free) + full (48, membership) · explore-test
-- Credit: Open Extended Jungian Type Scales / Open Psychometrics · openjung MIT
UPDATE scale_questions SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222201' AND deleted_at IS NULL;
UPDATE scale_results SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222201' AND deleted_at IS NULL;
UPDATE scales SET title='MBTI测试', description='开源 OEJTS 标准版:四维各 8 题,共 32 题。能量·信息·决策·节奏偏好探索(非官方 MBTI 版权题)。', updated_at=now() WHERE id='22222222-2222-2222-2222-222222222201';
INSERT INTO scale_questions (scale_id, sort, body) VALUES
('22222222-2222-2222-2222-222222222201', 1, '{"prompt": "你如何记录任务?", "format": "likert5", "dimension": "JP", "left": "制定清单", "right": "依靠记忆", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 2, '{"prompt": "你如何看待新信息?", "format": "likert5", "dimension": "TF", "left": "愿意相信", "right": "持怀疑态度", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 3, '{"prompt": "你对独处有什么感受?", "format": "likert5", "dimension": "EI", "left": "独处时感到无聊", "right": "需要独处时间", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 4, '{"prompt": "你如何看待现状?", "format": "likert5", "dimension": "SN", "left": "接受事物的本来面目", "right": "对现状不满足", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 5, '{"prompt": "你如何整理空间?", "format": "likert5", "dimension": "JP", "left": "保持房间整洁", "right": "随手放置东西", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 6, '{"prompt": "你如何看待逻辑思维?", "format": "likert5", "dimension": "TF", "left": "认为「像机器人」是侮辱", "right": "追求机械般的思维", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 7, '{"prompt": "你的能量状态如何?", "format": "likert5", "dimension": "EI", "left": "精力充沛", "right": "平静温和", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 8, '{"prompt": "你喜欢什么类型的考试?", "format": "likert5", "dimension": "SN", "left": "喜欢选择题", "right": "喜欢论述题", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 9, '{"prompt": "你如何形容自己的生活方式?", "format": "likert5", "dimension": "JP", "left": "有条理", "right": "随性混乱", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 10, '{"prompt": "你如何应对批评?", "format": "likert5", "dimension": "TF", "left": "容易受伤", "right": "脸皮厚", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 11, '{"prompt": "你在什么环境下工作最好?", "format": "likert5", "dimension": "EI", "left": "在团队中表现最佳", "right": "独自工作表现最佳", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 12, '{"prompt": "你的时间焦点在哪里?", "format": "likert5", "dimension": "SN", "left": "关注过去", "right": "关注未来", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 13, '{"prompt": "你何时制定计划?", "format": "likert5", "dimension": "JP", "left": "提前规划", "right": "最后一刻才计划", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 14, '{"prompt": "你希望从他人那里得到什么?", "format": "likert5", "dimension": "TF", "left": "渴望他人的爱", "right": "渴望他人的尊重", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 15, '{"prompt": "聚会让你感觉如何?", "format": "likert5", "dimension": "EI", "left": "因聚会而兴奋", "right": "因聚会而疲惫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 16, '{"prompt": "在群体中,你倾向于?", "format": "likert5", "dimension": "SN", "left": "融入群体", "right": "与众不同", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 17, '{"prompt": "你如何做决定?", "format": "likert5", "dimension": "JP", "left": "做出承诺", "right": "保留选择", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 18, '{"prompt": "你想擅长什么?", "format": "likert5", "dimension": "TF", "left": "想擅长帮助他人", "right": "想擅长修理事物", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 19, '{"prompt": "在交谈中,你更倾向于?", "format": "likert5", "dimension": "EI", "left": "话比较多", "right": "更善于倾听", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 20, '{"prompt": "讲故事时,你会?", "format": "likert5", "dimension": "SN", "left": "描述发生了什么", "right": "描述这意味着什么", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 21, '{"prompt": "你何时完成任务?", "format": "likert5", "dimension": "JP", "left": "立即完成工作", "right": "拖延", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 22, '{"prompt": "你做选择时更信?", "format": "likert5", "dimension": "TF", "left": "跟随内心", "right": "跟随理性", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 23, '{"prompt": "周末你更喜欢做什么?", "format": "likert5", "dimension": "EI", "left": "喜欢外出活动", "right": "喜欢待在家里", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 24, '{"prompt": "学习新事物时,你想要什么?", "format": "likert5", "dimension": "SN", "left": "想要细节", "right": "想要大局观", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 25, '{"prompt": "你如何应对新情况?", "format": "likert5", "dimension": "JP", "left": "提前准备", "right": "即兴发挥", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 26, '{"prompt": "道德的基础更接近?", "format": "likert5", "dimension": "TF", "left": "道德基于同情", "right": "道德基于正义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 27, '{"prompt": "你的自然音量如何?", "format": "likert5", "dimension": "EI", "left": "自然而然大声说话", "right": "很难大声喊叫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 28, '{"prompt": "你如何获取知识?", "format": "likert5", "dimension": "SN", "left": "经验主义", "right": "理论主义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 29, '{"prompt": "什么更能驱动你?", "format": "likert5", "dimension": "JP", "left": "努力工作", "right": "尽情玩乐", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 30, '{"prompt": "你如何看待情感?", "format": "likert5", "dimension": "TF", "left": "重视情感", "right": "对情感感到不自在", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 31, '{"prompt": "你对成为焦点有什么感受?", "format": "likert5", "dimension": "EI", "left": "喜欢表演", "right": "避免公开演讲", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 32, '{"prompt": "什么问题最让你感兴趣?", "format": "likert5", "dimension": "SN", "left": "想知道「谁/什么/何时」", "right": "想知道「为什么」", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb);
INSERT INTO scales (id, slug, title, description, status)
VALUES (
'22222222-2222-2222-2222-222222222210',
'mbti-full',
'MBTI完整版',
'成长会员专享:四维各 12 题,共 48 题,偏好更稳定。含 OEJTS 标准题 + 扩展题。',
'published'
) ON CONFLICT (slug) DO UPDATE SET title=EXCLUDED.title, description=EXCLUDED.description, status='published', updated_at=now(), deleted_at=NULL;
UPDATE scale_questions SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222210' AND deleted_at IS NULL;
UPDATE scale_results SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222210' AND deleted_at IS NULL;
INSERT INTO scale_questions (scale_id, sort, body) VALUES
('22222222-2222-2222-2222-222222222210', 1, '{"prompt": "你如何记录任务?", "format": "likert5", "dimension": "JP", "left": "制定清单", "right": "依靠记忆", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 2, '{"prompt": "你如何看待新信息?", "format": "likert5", "dimension": "TF", "left": "愿意相信", "right": "持怀疑态度", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 3, '{"prompt": "你对独处有什么感受?", "format": "likert5", "dimension": "EI", "left": "独处时感到无聊", "right": "需要独处时间", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 4, '{"prompt": "你如何看待现状?", "format": "likert5", "dimension": "SN", "left": "接受事物的本来面目", "right": "对现状不满足", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 5, '{"prompt": "你如何整理空间?", "format": "likert5", "dimension": "JP", "left": "保持房间整洁", "right": "随手放置东西", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 6, '{"prompt": "你如何看待逻辑思维?", "format": "likert5", "dimension": "TF", "left": "认为「像机器人」是侮辱", "right": "追求机械般的思维", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 7, '{"prompt": "你的能量状态如何?", "format": "likert5", "dimension": "EI", "left": "精力充沛", "right": "平静温和", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 8, '{"prompt": "你喜欢什么类型的考试?", "format": "likert5", "dimension": "SN", "left": "喜欢选择题", "right": "喜欢论述题", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 9, '{"prompt": "你如何形容自己的生活方式?", "format": "likert5", "dimension": "JP", "left": "有条理", "right": "随性混乱", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 10, '{"prompt": "你如何应对批评?", "format": "likert5", "dimension": "TF", "left": "容易受伤", "right": "脸皮厚", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 11, '{"prompt": "你在什么环境下工作最好?", "format": "likert5", "dimension": "EI", "left": "在团队中表现最佳", "right": "独自工作表现最佳", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 12, '{"prompt": "你的时间焦点在哪里?", "format": "likert5", "dimension": "SN", "left": "关注过去", "right": "关注未来", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 13, '{"prompt": "你何时制定计划?", "format": "likert5", "dimension": "JP", "left": "提前规划", "right": "最后一刻才计划", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 14, '{"prompt": "你希望从他人那里得到什么?", "format": "likert5", "dimension": "TF", "left": "渴望他人的爱", "right": "渴望他人的尊重", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 15, '{"prompt": "聚会让你感觉如何?", "format": "likert5", "dimension": "EI", "left": "因聚会而兴奋", "right": "因聚会而疲惫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 16, '{"prompt": "在群体中,你倾向于?", "format": "likert5", "dimension": "SN", "left": "融入群体", "right": "与众不同", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 17, '{"prompt": "你如何做决定?", "format": "likert5", "dimension": "JP", "left": "做出承诺", "right": "保留选择", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 18, '{"prompt": "你想擅长什么?", "format": "likert5", "dimension": "TF", "left": "想擅长帮助他人", "right": "想擅长修理事物", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 19, '{"prompt": "在交谈中,你更倾向于?", "format": "likert5", "dimension": "EI", "left": "话比较多", "right": "更善于倾听", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 20, '{"prompt": "讲故事时,你会?", "format": "likert5", "dimension": "SN", "left": "描述发生了什么", "right": "描述这意味着什么", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 21, '{"prompt": "你何时完成任务?", "format": "likert5", "dimension": "JP", "left": "立即完成工作", "right": "拖延", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 22, '{"prompt": "你做选择时更信?", "format": "likert5", "dimension": "TF", "left": "跟随内心", "right": "跟随理性", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 23, '{"prompt": "周末你更喜欢做什么?", "format": "likert5", "dimension": "EI", "left": "喜欢外出活动", "right": "喜欢待在家里", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 24, '{"prompt": "学习新事物时,你想要什么?", "format": "likert5", "dimension": "SN", "left": "想要细节", "right": "想要大局观", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 25, '{"prompt": "你如何应对新情况?", "format": "likert5", "dimension": "JP", "left": "提前准备", "right": "即兴发挥", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 26, '{"prompt": "道德的基础更接近?", "format": "likert5", "dimension": "TF", "left": "道德基于同情", "right": "道德基于正义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 27, '{"prompt": "你的自然音量如何?", "format": "likert5", "dimension": "EI", "left": "自然而然大声说话", "right": "很难大声喊叫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 28, '{"prompt": "你如何获取知识?", "format": "likert5", "dimension": "SN", "left": "经验主义", "right": "理论主义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 29, '{"prompt": "什么更能驱动你?", "format": "likert5", "dimension": "JP", "left": "努力工作", "right": "尽情玩乐", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 30, '{"prompt": "你如何看待情感?", "format": "likert5", "dimension": "TF", "left": "重视情感", "right": "对情感感到不自在", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 31, '{"prompt": "你对成为焦点有什么感受?", "format": "likert5", "dimension": "EI", "left": "喜欢表演", "right": "避免公开演讲", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 32, '{"prompt": "什么问题最让你感兴趣?", "format": "likert5", "dimension": "SN", "left": "想知道「谁/什么/何时」", "right": "想知道「为什么」", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 33, '{"prompt": "充电后你更想?", "format": "likert5", "dimension": "EI", "left": "继续跟人互动", "right": "继续安静独处", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 34, '{"prompt": "陌生场合开场,你更常?", "format": "likert5", "dimension": "EI", "left": "先开口搭话", "right": "等别人来找你", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 35, '{"prompt": "想法未成形时,你更习惯?", "format": "likert5", "dimension": "EI", "left": "边说边理清", "right": "想清楚再开口", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 36, '{"prompt": "长时间会议后,你通常?", "format": "likert5", "dimension": "EI", "left": "还想再聊聊", "right": "急需一个人待着", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 37, '{"prompt": "听报告时你更抓?", "format": "likert5", "dimension": "SN", "left": "数据和步骤", "right": "含义与趋势", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 38, '{"prompt": "解决问题更依赖?", "format": "likert5", "dimension": "SN", "left": "已被验证的做法", "right": "新的可能性", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 39, '{"prompt": "描述一个人时你更说?", "format": "likert5", "dimension": "SN", "left": "具体言行细节", "right": "整体给人的感觉", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 40, '{"prompt": "读说明书你更喜欢?", "format": "likert5", "dimension": "SN", "left": "逐步照做", "right": "先看整体再试", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 41, '{"prompt": "同事出错时你更先?", "format": "likert5", "dimension": "TF", "left": "考虑对方感受", "right": "指出问题本身", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 42, '{"prompt": "评价方案更看?", "format": "likert5", "dimension": "TF", "left": "对人是否友好", "right": "是否高效正确", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 43, '{"prompt": "争执后你更在意?", "format": "likert5", "dimension": "TF", "left": "关系有没有裂痕", "right": "道理有没有说清", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 44, '{"prompt": "给反馈时你更倾向?", "format": "likert5", "dimension": "TF", "left": "先肯定再建议", "right": "直接说关键点", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 45, '{"prompt": "旅行行李你更?", "format": "likert5", "dimension": "JP", "left": "提前打包清单", "right": "临走再塞", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 46, '{"prompt": "工作日历你更?", "format": "likert5", "dimension": "JP", "left": "日程排满更安心", "right": "留白机动更舒服", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 47, '{"prompt": "规则变化时你?", "format": "likert5", "dimension": "JP", "left": "希望尽早确定新规", "right": "可以边走边调整", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 48, '{"prompt": "桌面文件你更?", "format": "likert5", "dimension": "JP", "left": "分类归档", "right": "先堆着再用时找", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb);
@@ -0,0 +1,2 @@
UPDATE scale_questions SET deleted_at=now(), updated_at=now()
WHERE scale_id='22222222-2222-2222-2222-222222222210' AND deleted_at IS NULL AND sort > 48;
@@ -0,0 +1,67 @@
-- Expand mbti-full to 60 questions (15 per dimension)
UPDATE scale_questions SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222210' AND deleted_at IS NULL;
UPDATE scale_results SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222210' AND deleted_at IS NULL;
UPDATE scales SET title='MBTI完整版',
description='成长会员专享:四维各 15 题,共 60 题。含 OEJTS 标准 32 题 + 扩展题,偏好更稳定。',
updated_at=now() WHERE id='22222222-2222-2222-2222-222222222210';
INSERT INTO scale_questions (scale_id, sort, body) VALUES
('22222222-2222-2222-2222-222222222210', 1, '{"prompt": "你如何记录任务?", "format": "likert5", "dimension": "JP", "left": "制定清单", "right": "依靠记忆", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 2, '{"prompt": "你如何看待新信息?", "format": "likert5", "dimension": "TF", "left": "愿意相信", "right": "持怀疑态度", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 3, '{"prompt": "你对独处有什么感受?", "format": "likert5", "dimension": "EI", "left": "独处时感到无聊", "right": "需要独处时间", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 4, '{"prompt": "你如何看待现状?", "format": "likert5", "dimension": "SN", "left": "接受事物的本来面目", "right": "对现状不满足", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 5, '{"prompt": "你如何整理空间?", "format": "likert5", "dimension": "JP", "left": "保持房间整洁", "right": "随手放置东西", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 6, '{"prompt": "你如何看待逻辑思维?", "format": "likert5", "dimension": "TF", "left": "认为「像机器人」是侮辱", "right": "追求机械般的思维", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 7, '{"prompt": "你的能量状态如何?", "format": "likert5", "dimension": "EI", "left": "精力充沛", "right": "平静温和", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 8, '{"prompt": "你喜欢什么类型的考试?", "format": "likert5", "dimension": "SN", "left": "喜欢选择题", "right": "喜欢论述题", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 9, '{"prompt": "你如何形容自己的生活方式?", "format": "likert5", "dimension": "JP", "left": "有条理", "right": "随性混乱", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 10, '{"prompt": "你如何应对批评?", "format": "likert5", "dimension": "TF", "left": "容易受伤", "right": "脸皮厚", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 11, '{"prompt": "你在什么环境下工作最好?", "format": "likert5", "dimension": "EI", "left": "在团队中表现最佳", "right": "独自工作表现最佳", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 12, '{"prompt": "你的时间焦点在哪里?", "format": "likert5", "dimension": "SN", "left": "关注过去", "right": "关注未来", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 13, '{"prompt": "你何时制定计划?", "format": "likert5", "dimension": "JP", "left": "提前规划", "right": "最后一刻才计划", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 14, '{"prompt": "你希望从他人那里得到什么?", "format": "likert5", "dimension": "TF", "left": "渴望他人的爱", "right": "渴望他人的尊重", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 15, '{"prompt": "聚会让你感觉如何?", "format": "likert5", "dimension": "EI", "left": "因聚会而兴奋", "right": "因聚会而疲惫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 16, '{"prompt": "在群体中,你倾向于?", "format": "likert5", "dimension": "SN", "left": "融入群体", "right": "与众不同", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 17, '{"prompt": "你如何做决定?", "format": "likert5", "dimension": "JP", "left": "做出承诺", "right": "保留选择", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 18, '{"prompt": "你想擅长什么?", "format": "likert5", "dimension": "TF", "left": "想擅长帮助他人", "right": "想擅长修理事物", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 19, '{"prompt": "在交谈中,你更倾向于?", "format": "likert5", "dimension": "EI", "left": "话比较多", "right": "更善于倾听", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 20, '{"prompt": "讲故事时,你会?", "format": "likert5", "dimension": "SN", "left": "描述发生了什么", "right": "描述这意味着什么", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 21, '{"prompt": "你何时完成任务?", "format": "likert5", "dimension": "JP", "left": "立即完成工作", "right": "拖延", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 22, '{"prompt": "你做选择时更信?", "format": "likert5", "dimension": "TF", "left": "跟随内心", "right": "跟随理性", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 23, '{"prompt": "周末你更喜欢做什么?", "format": "likert5", "dimension": "EI", "left": "喜欢外出活动", "right": "喜欢待在家里", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 24, '{"prompt": "学习新事物时,你想要什么?", "format": "likert5", "dimension": "SN", "left": "想要细节", "right": "想要大局观", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 25, '{"prompt": "你如何应对新情况?", "format": "likert5", "dimension": "JP", "left": "提前准备", "right": "即兴发挥", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 26, '{"prompt": "道德的基础更接近?", "format": "likert5", "dimension": "TF", "left": "道德基于同情", "right": "道德基于正义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 27, '{"prompt": "你的自然音量如何?", "format": "likert5", "dimension": "EI", "left": "自然而然大声说话", "right": "很难大声喊叫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 28, '{"prompt": "你如何获取知识?", "format": "likert5", "dimension": "SN", "left": "经验主义", "right": "理论主义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 29, '{"prompt": "什么更能驱动你?", "format": "likert5", "dimension": "JP", "left": "努力工作", "right": "尽情玩乐", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 30, '{"prompt": "你如何看待情感?", "format": "likert5", "dimension": "TF", "left": "重视情感", "right": "对情感感到不自在", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 31, '{"prompt": "你对成为焦点有什么感受?", "format": "likert5", "dimension": "EI", "left": "喜欢表演", "right": "避免公开演讲", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 32, '{"prompt": "什么问题最让你感兴趣?", "format": "likert5", "dimension": "SN", "left": "想知道「谁/什么/何时」", "right": "想知道「为什么」", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 33, '{"prompt": "充电后你更想?", "format": "likert5", "dimension": "EI", "left": "继续跟人互动", "right": "继续安静独处", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 34, '{"prompt": "陌生场合开场,你更常?", "format": "likert5", "dimension": "EI", "left": "先开口搭话", "right": "等别人来找你", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 35, '{"prompt": "想法未成形时,你更习惯?", "format": "likert5", "dimension": "EI", "left": "边说边理清", "right": "想清楚再开口", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 36, '{"prompt": "长时间会议后,你通常?", "format": "likert5", "dimension": "EI", "left": "还想再聊聊", "right": "急需一个人待着", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 37, '{"prompt": "学习时你更有效的是?", "format": "likert5", "dimension": "EI", "left": "小组讨论", "right": "独自钻研", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 38, '{"prompt": "电话响了你更常?", "format": "likert5", "dimension": "EI", "left": "立刻接听很自然", "right": "先看是谁再决定", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 39, '{"prompt": "庆祝好事时你更想?", "format": "likert5", "dimension": "EI", "left": "叫上朋友一起", "right": "自己慢慢回味", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 40, '{"prompt": "听报告时你更抓?", "format": "likert5", "dimension": "SN", "left": "数据和步骤", "right": "含义与趋势", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 41, '{"prompt": "解决问题更依赖?", "format": "likert5", "dimension": "SN", "left": "已被验证的做法", "right": "新的可能性", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 42, '{"prompt": "描述一个人时你更说?", "format": "likert5", "dimension": "SN", "left": "具体言行细节", "right": "整体给人的感觉", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 43, '{"prompt": "读说明书你更喜欢?", "format": "likert5", "dimension": "SN", "left": "逐步照做", "right": "先看整体再试", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 44, '{"prompt": "回忆旅行你先想起?", "format": "likert5", "dimension": "SN", "left": "吃了什么、走了哪", "right": "当时的气氛与意义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 45, '{"prompt": "面对新工具你更?", "format": "likert5", "dimension": "SN", "left": "先摸清每个按钮", "right": "先想它能改变什么", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 46, '{"prompt": "听建议时你更信?", "format": "likert5", "dimension": "SN", "left": "有案例与证据的", "right": "有洞见与方向的", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 47, '{"prompt": "同事出错时你更先?", "format": "likert5", "dimension": "TF", "left": "考虑对方感受", "right": "指出问题本身", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 48, '{"prompt": "评价方案更看?", "format": "likert5", "dimension": "TF", "left": "对人是否友好", "right": "是否高效正确", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 49, '{"prompt": "争执后你更在意?", "format": "likert5", "dimension": "TF", "left": "关系有没有裂痕", "right": "道理有没有说清", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 50, '{"prompt": "给反馈时你更倾向?", "format": "likert5", "dimension": "TF", "left": "先肯定再建议", "right": "直接说关键点", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 51, '{"prompt": "看电影你更容易?", "format": "likert5", "dimension": "TF", "left": "代入角色情绪", "right": "分析结构与逻辑", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 52, '{"prompt": "团队冲突你更想?", "format": "likert5", "dimension": "TF", "left": "先缓和气氛", "right": "先对齐标准", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 53, '{"prompt": "做艰难决定时你更问?", "format": "likert5", "dimension": "TF", "left": "谁会受伤", "right": "哪边更合理", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 54, '{"prompt": "旅行行李你更?", "format": "likert5", "dimension": "JP", "left": "提前打包清单", "right": "临走再塞", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 55, '{"prompt": "工作日历你更?", "format": "likert5", "dimension": "JP", "left": "日程排满更安心", "right": "留白机动更舒服", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 56, '{"prompt": "规则变化时你?", "format": "likert5", "dimension": "JP", "left": "希望尽早确定新规", "right": "可以边走边调整", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 57, '{"prompt": "桌面文件你更?", "format": "likert5", "dimension": "JP", "left": "分类归档", "right": "先堆着再用时找", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 58, '{"prompt": "周末安排你更?", "format": "likert5", "dimension": "JP", "left": "大致几点做什么", "right": "看当天心情", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 59, '{"prompt": "项目收尾你更?", "format": "likert5", "dimension": "JP", "left": "尽早关掉待办", "right": "一直改到不得不停", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 60, '{"prompt": "约会迟到风险时你?", "format": "likert5", "dimension": "JP", "left": "宁可早到等待", "right": "卡点出发也行", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS home_daily_tips;
@@ -0,0 +1,14 @@
-- Persist homepage tips per user × 时辰 (ECR-adjacent · home R7)
CREATE TABLE IF NOT EXISTS home_daily_tips (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
shichen_start timestamptz NOT NULL,
shichen smallint NOT NULL CHECK (shichen BETWEEN 0 AND 11),
tips jsonb NOT NULL,
source varchar(16) NOT NULL DEFAULT 'fallback',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, shichen_start)
);
CREATE INDEX IF NOT EXISTS idx_home_daily_tips_user_start
ON home_daily_tips (user_id, shichen_start DESC);
+2 -1
View File
@@ -100,7 +100,8 @@ test('star / rhythm / cards pages render with mocked API', async ({ page }) => {
await expect(page.getByText('E2E节律')).toBeVisible() await expect(page.getByText('E2E节律')).toBeVisible()
await page.goto('/psy/cards') await page.goto('/psy/cards')
await expect(page.getByRole('heading', { name: 'Hi,我是愈心 AI' })).toBeVisible() await expect(page.getByRole('heading', { name: '意象卡片' })).toBeVisible()
await expect(page.getByText('投射整理 · 自我反思')).toBeVisible()
await page.getByRole('button', { name: '关联生日' }).click() await page.getByRole('button', { name: '关联生日' }).click()
await page.locator('input[type="number"]').nth(0).fill('1990') 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(1).fill('5')
+1
View File
@@ -24,6 +24,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+2 -24
View File
@@ -1,34 +1,12 @@
<template> <template>
<div class="app-shell" :class="{ 'is-immersive': immersive }"> <div class="app-shell">
<AppHeader v-if="!immersive" /> <AppHeader />
<router-view /> <router-view />
<TabBar /> <TabBar />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppHeader from './components/AppHeader.vue' import AppHeader from './components/AppHeader.vue'
import TabBar from './components/TabBar.vue' import TabBar from './components/TabBar.vue'
const route = useRoute()
/** 五 Tab 页自带 PageShell / 首页顶栏,避免叠 sticky Logo */
const immersive = computed(() => {
const p = route.path
return (
p === '/' ||
p === '/explore' ||
p.startsWith('/explore/') ||
p === '/ask' ||
p === '/companion' ||
p === '/mine'
)
})
</script> </script>
<style scoped>
.app-shell.is-immersive {
background: transparent;
}
</style>
+58 -13
View File
@@ -1,29 +1,74 @@
<template> <template>
<header class="app-header"> <header class="app-header">
<div class="side left">
<BackButton v-if="showBack" />
</div>
<router-link to="/" class="logo-link" aria-label="愈心谷首页"> <router-link to="/" class="logo-link" aria-label="愈心谷首页">
<BrandLogo size="header" /> <BrandLogo size="header" class="logo" />
</router-link> </router-link>
<div class="side right" aria-hidden="true" />
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import BackButton from './BackButton.vue'
import BrandLogo from './BrandLogo.vue' import BrandLogo from './BrandLogo.vue'
const route = useRoute()
/** 首页 / 我的 为 Tab 根页不显示返回;探索可从首页进入,显示返回 */
const showBack = computed(() => {
const p = route.path
return !(p === '/' || p === '/mine')
})
</script> </script>
<style scoped> <style scoped>
.app-header{ .app-header {
position:sticky;top:0;z-index:50; position: absolute;
display:flex;justify-content:center;align-items:center; top: 0;
padding:10px 16px 6px; left: 0;
background:linear-gradient(180deg, rgba(255,209,199,.92) 0%, rgba(255,209,199,.55) 70%, transparent 100%); right: 0;
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px); z-index: 50;
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: center;
gap: 4px;
padding: 10px 12px 4px;
background: transparent;
pointer-events: none;
}
.side {
display: flex;
align-items: center;
min-height: 36px;
pointer-events: auto;
}
.side.left {
justify-content: flex-start;
}
.side.right {
justify-content: flex-end;
pointer-events: none;
}
.logo-link {
display: flex;
justify-content: center;
justify-self: center;
line-height: 0;
background: transparent;
pointer-events: auto;
}
.logo {
background: transparent !important;
filter: drop-shadow(0 2px 10px rgba(180, 90, 70, 0.12));
} }
.logo-link{display:flex;justify-content:center;line-height:0;background:transparent}
.logo-link :deep(.header), .logo-link :deep(.header),
.logo-link :deep(.brand-logo){ .logo-link :deep(.brand-logo) {
width:min(120px,42vw); width: min(148px, 48vw);
background:transparent !important; max-height: 36px;
mix-blend-mode:normal; background: transparent !important;
} }
</style> </style>
+37 -17
View File
@@ -1,11 +1,14 @@
<template> <template>
<button class="back" type="button" @click="go"> <button class="back" type="button" aria-label="返回" @click="go">
<span class="icon" aria-hidden="true"> <svg class="chev" width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <path
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/> d="M11.25 3.75 6 9l5.25 5.25"
</svg> stroke="currentColor"
</span> stroke-width="2"
<span class="label">返回</span> stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button> </button>
</template> </template>
@@ -21,15 +24,32 @@ function go() {
</script> </script>
<style scoped> <style scoped>
.back{ .back {
display:inline-flex;align-items:center;gap:4px; display: inline-flex;
border:none;background:rgba(255,255,255,.78);color:#333; align-items: center;
border-radius:var(--radius-sm); justify-content: center;
padding:8px 12px 8px 10px;margin:0 0 12px;font-size:14px;font-weight:500; width: 36px;
box-shadow:0 1px 4px rgba(0,0,0,.05);cursor:pointer; height: 36px;
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px); margin: 0;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.95);
border-radius: 12px;
background: rgba(255, 255, 255, 0.78);
color: #3a3330;
box-shadow: 0 4px 14px rgba(180, 90, 70, 0.08);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
transition:
transform var(--duration-fast, 0.15s) ease,
background var(--duration-fast, 0.15s) ease;
}
.back:active {
transform: scale(0.94);
background: rgba(255, 255, 255, 0.92);
}
.chev {
display: block;
margin-right: 1px;
} }
.back:active{opacity:.75}
.icon{display:inline-flex;line-height:0;color:#333}
.label{color:#333}
</style> </style>
+31 -13
View File
@@ -205,6 +205,35 @@
<circle cx="38" cy="26" r="3" fill="#5BA8F5" /> <circle cx="38" cy="26" r="3" fill="#5BA8F5" />
</g> </g>
<!-- 九型 · 九宫圆 -->
<g v-else-if="name === 'nine'" :filter="f">
<circle cx="32" cy="32" r="23" :fill="g('purp')" />
<circle cx="32" cy="32" r="14" fill="none" stroke="#fff" stroke-width="2" opacity=".75" />
<text x="32" y="38" text-anchor="middle" font-size="20" font-weight="800" fill="#fff" font-family="system-ui,sans-serif">9</text>
</g>
<!-- 情绪智力 · 心波 -->
<g v-else-if="name === 'eq'" :filter="f">
<circle cx="32" cy="32" r="23" :fill="g('teal')" />
<path d="M18 34 Q24 20 32 32 Q40 44 46 30" fill="none" stroke="#fff" stroke-width="3.5" stroke-linecap="round" />
<circle cx="32" cy="32" r="4" fill="#FFE9A0" />
</g>
<!-- 压力 · 橙浪 -->
<g v-else-if="name === 'stress'" :filter="f">
<rect x="8" y="8" width="48" height="48" rx="14" :fill="g('coral')" />
<path d="M16 38 Q24 22 32 36 Q40 50 48 28" fill="none" stroke="#fff" stroke-width="3.5" stroke-linecap="round" />
<circle cx="48" cy="22" r="3" fill="#FFE9A0" />
</g>
<!-- 睡眠 · 月牙 -->
<g v-else-if="name === 'sleep'" :filter="f">
<circle cx="32" cy="32" r="23" :fill="g('blu')" />
<path d="M38 16 A14 14 0 1 0 38 48 A10 10 0 1 1 38 16 Z" fill="#fff" opacity=".92" />
<circle cx="44" cy="20" r="2" fill="#FFE9A0" />
<circle cx="48" cy="28" r="1.4" fill="#FFE9A0" opacity=".8" />
</g>
<g v-else :filter="f"> <g v-else :filter="f">
<circle cx="32" cy="32" r="22" :fill="g('coral')" /> <circle cx="32" cy="32" r="22" :fill="g('coral')" />
<text x="32" y="38" text-anchor="middle" font-size="22" fill="#fff" font-weight="700">·</text> <text x="32" y="38" text-anchor="middle" font-size="22" fill="#fff" font-weight="700">·</text>
@@ -214,20 +243,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, useId } from 'vue' import { computed, useId } from 'vue'
import type { HomeToolIconName } from '../lib/homeToolIconName'
export type HomeToolIconName = export type { HomeToolIconName }
| 'mbti'
| 'star'
| 'portrait'
| 'rhythm'
| 'synastry'
| 'astro'
| 'companion'
| 'ask'
| 'cards'
| 'reports'
| 'growth'
| 'relation'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
+23 -17
View File
@@ -42,23 +42,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ withDefaults(
meStyle: string defineProps<{
otherStyle: string meStyle: string
fitLabel?: string otherStyle: string
harmony?: number | null fitLabel?: string
love?: number | null harmony?: number | null
friend?: number | null love?: number | null
marriage?: number | null friend?: number | null
starCompare?: { key: string; title: string; me: string; other: string; note?: string }[] marriage?: number | null
dimensions?: { starCompare?: { key: string; title: string; me: string; other: string; note?: string }[]
key: string dimensions?: {
title: string key: string
me_score?: number title: string
other_score?: number me_score?: number
note?: string other_score?: number
}[] note?: string
}>() }[]
}>(),
{
starCompare: () => [],
dimensions: () => [],
},
)
</script> </script>
<style scoped> <style scoped>
@@ -35,6 +35,7 @@ withDefaults(
.ps { .ps {
position: relative; position: relative;
overflow-x: hidden; overflow-x: hidden;
padding-top: 48px;
padding-bottom: var(--spacing-xs); padding-bottom: var(--spacing-xs);
min-height: 100%; min-height: 100%;
background: background:
@@ -46,6 +47,7 @@ withDefaults(
.ps-flat { .ps-flat {
background: transparent; background: transparent;
min-height: 0; min-height: 0;
padding-top: 48px;
} }
.atm { .atm {
position: absolute; position: absolute;
+1 -1
View File
@@ -11,7 +11,7 @@
<div v-if="dimensions.length" class="card"> <div v-if="dimensions.length" class="card">
<h2><span class="hm" aria-hidden="true"></span>多维速览</h2> <h2><span class="hm" aria-hidden="true"></span>多维速览</h2>
<div v-for="d in dimensions" :key="d.key || d.title" class="dim"> <div v-for="d in dimensions" :key="String(d.key ?? d.title ?? '')" class="dim">
<div class="dim-head"> <div class="dim-head">
<strong>{{ d.title }}</strong> <strong>{{ d.title }}</strong>
<span v-if="d.score != null" class="score">{{ d.score }}</span> <span v-if="d.score != null" class="score">{{ d.score }}</span>
+116 -41
View File
@@ -7,7 +7,19 @@
class="item" class="item"
:class="{ ask: item.ask, on: isActive(item.key) }" :class="{ ask: item.ask, on: isActive(item.key) }"
> >
<span class="ni" aria-hidden="true">{{ item.icon }}</span> <span class="ico-wrap" aria-hidden="true">
<svg class="ico" viewBox="0 0 24 24" fill="none">
<path
v-for="(d, i) in item.paths"
:key="i"
:d="d"
stroke="currentColor"
:stroke-width="isActive(item.key) ? 2.15 : 1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="lb">{{ item.label }}</span> <span class="lb">{{ item.label }}</span>
</router-link> </router-link>
</nav> </nav>
@@ -17,13 +29,55 @@
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
const items = [
{ key: 'home', to: '/', icon: '⌂', label: '首页' }, type TabItem = {
{ key: 'explore', to: '/explore', icon: '◆', label: '探索' }, key: string
{ key: 'ask', to: '/ask', icon: '问', label: '问答', ask: true }, to: string
{ key: 'companion', to: '/companion', icon: '♡', label: '陪伴' }, label: string
{ key: 'mine', to: '/mine', icon: '◍', label: '我的' }, paths: readonly string[]
] as const ask?: boolean
}
const items: TabItem[] = [
{
key: 'home',
to: '/',
label: '首页',
paths: ['M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5.2v-5.2h-3.6V21H5a1 1 0 0 1-1-1v-9.5Z'],
},
{
key: 'explore',
to: '/explore',
label: '探索',
paths: ['M12 3.5 14.2 9.8 20.5 12 14.2 14.2 12 20.5 9.8 14.2 3.5 12 9.8 9.8 12 3.5Z'],
},
{
key: 'ask',
to: '/ask',
label: '问答',
ask: true,
paths: [
'M8.2 18.2 5.5 20.5V8.2A2.7 2.7 0 0 1 8.2 5.5h7.6A2.7 2.7 0 0 1 18.5 8.2v5.4a2.7 2.7 0 0 1-2.7 2.7H9.6l-1.4 1.9Z',
],
},
{
key: 'companion',
to: '/companion',
label: '陪伴',
paths: [
'M12 20.2S4.8 15.4 4.8 10.2A3.7 3.7 0 0 1 12 8.1a3.7 3.7 0 0 1 7.2 2.1c0 5.2-7.2 10-7.2 10Z',
],
},
{
key: 'mine',
to: '/mine',
label: '我的',
paths: [
'M12 12.2a3.4 3.4 0 1 0 0-6.8 3.4 3.4 0 0 0 0 6.8Z',
'M5.2 19.2a6.8 6.8 0 0 1 13.6 0',
],
},
]
function activeKey(): string { function activeKey(): string {
const p = route.path const p = route.path
@@ -33,7 +87,8 @@ function activeKey(): string {
p.startsWith('/mine') || p.startsWith('/mine') ||
p.startsWith('/profile') || p.startsWith('/profile') ||
p.startsWith('/membership') || p.startsWith('/membership') ||
p.startsWith('/reports') p.startsWith('/reports') ||
p.startsWith('/login')
) { ) {
return 'mine' return 'mine'
} }
@@ -69,56 +124,76 @@ function isActive(key: string): boolean {
width: 100%; width: 100%;
max-width: var(--yxg-max-w); max-width: var(--yxg-max-w);
background: rgba(255, 255, 255, 0.96); background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(14px);
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
padding: 6px 0 calc(10px + env(safe-area-inset-bottom, 0)); align-items: flex-end;
border-top: 1px solid var(--color-border); padding: 8px 4px calc(10px + env(safe-area-inset-bottom, 0));
border-top: 1px solid rgba(240, 230, 224, 0.9);
z-index: 100; z-index: 100;
box-shadow: var(--shadow-nav); box-shadow: 0 -6px 24px rgba(160, 90, 70, 0.06);
} }
.item { .item {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 2px; gap: 4px;
font-size: 10px; color: #9a8f8b;
color: var(--color-text-secondary); padding: 2px 0 0;
padding: 2px 0; letter-spacing: 0.04em;
letter-spacing: 0.02em; transition:
transition: color var(--duration-fast) ease; color var(--duration-fast) ease,
transform var(--duration-fast) ease;
} }
.item.on { .item.on {
color: var(--color-primary); color: var(--color-primary);
font-weight: 600;
} }
.ni { .item:active {
font-size: 17px; transform: scale(0.96);
line-height: 1.2; }
.ico-wrap {
width: 30px;
height: 30px;
display: grid;
place-items: center;
}
.ico {
width: 28px;
height: 28px;
display: block;
} }
.lb { .lb {
line-height: 1.2; line-height: 1.15;
font-size: 11px;
font-weight: 550;
} }
.item.ask .ni { .item.on .lb {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700; font-weight: 700;
margin-top: -14px;
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.38);
} }
.item.ask.on { .item.ask .ico-wrap {
color: var(--color-primary); width: 54px;
height: 54px;
margin-top: -22px;
border-radius: 50%;
background: linear-gradient(145deg, #ff8f80, var(--color-primary));
color: #fff;
box-shadow:
0 8px 20px rgba(229, 77, 66, 0.36),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
border: 3px solid #fff;
} }
.item.ask.on .ni { .item.ask .ico {
box-shadow: 0 8px 20px rgba(229, 77, 66, 0.45); width: 27px;
height: 27px;
}
.item.ask.on .ico-wrap {
box-shadow:
0 10px 24px rgba(229, 77, 66, 0.48),
inset 0 1px 0 rgba(255, 255, 255, 0.42);
}
.item.ask .lb {
margin-top: 2px;
} }
</style> </style>
@@ -0,0 +1,172 @@
<template>
<div class="adv-chat">
<div class="adv-head card">
<div class="av" :style="{ background: advisor.bg }">{{ advisor.mark }}</div>
<div class="copy">
<strong>{{ advisor.name }}</strong>
<span class="meta">{{ advisor.meta }}</span>
<span class="tags">{{ advisor.tags }} · 专属对话</span>
</div>
</div>
<p v-if="bootLoading" class="hint">加载中</p>
<p v-else-if="bootError" class="err">
{{ bootError }}
<button type="button" class="retry" @click="$emit('boot')">重试</button>
</p>
<AskEmpty v-else-if="!profiles.length" />
<template v-else>
<AskProfileBar
:profiles="profiles"
:profile-id="profileId"
:self-label="selfLabel"
@update:profile-id="$emit('update:profileId', $event)"
@profile-change="$emit('profile-change')"
@navigate="$emit('navigate', $event)"
/>
<div v-if="!messages.length" class="intro card">
<p class="hi">你好我是{{ advisor.name }}</p>
<p class="hi-sub">可以先从下方草稿开始或直接说出你想聊的事</p>
</div>
<AskMessageList :messages="messages" :sending="sending" />
<p v-if="sendError" class="err pad">{{ sendError }}</p>
<AskQuotaBuy
v-if="showBuy"
:lead="quotaExhausted ? '问答次数已用完,购买后可继续聊' : '想聊更多?可先加购额度'"
@purchased="$emit('quota-refreshed')"
/>
<AskComposer
:draft="draft"
:sending="sending"
:quota="quota"
@update:draft="$emit('update:draft', $event)"
@send="$emit('send')"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { askAdvisors } from '../../composables/useAskPage'
import AskComposer from './AskComposer.vue'
import AskEmpty from './AskEmpty.vue'
import AskMessageList from './AskMessageList.vue'
import AskProfileBar from './AskProfileBar.vue'
import AskQuotaBuy from './AskQuotaBuy.vue'
const props = defineProps<{
advisor: (typeof askAdvisors)[number]
bootLoading: boolean
bootError: string
profiles: Profile[]
profileId: string
selfLabel?: string
messages: AskMessage[]
draft: string
sending: boolean
sendError: string
quotaExhausted: boolean
quota: AskQuota | null
}>()
defineEmits<{
boot: []
'update:profileId': [v: string]
'profile-change': []
navigate: [to: string]
'update:draft': [v: string]
send: []
'quota-refreshed': []
}>()
const showBuy = computed(
() => props.quotaExhausted || (props.quota !== null && props.quota.remaining <= 0),
)
</script>
<style scoped>
.adv-chat {
padding: 8px 16px 108px;
position: relative;
z-index: 2;
}
.card {
background: rgba(255, 255, 255, 0.92);
border-radius: var(--radius-xl);
padding: 14px;
box-shadow: var(--shadow-card);
border: 1px solid #f6ece8;
margin-bottom: 12px;
}
.adv-head {
display: flex;
align-items: center;
gap: 12px;
}
.av {
width: 52px;
height: 52px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #c23b32;
flex-shrink: 0;
}
.copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.copy strong {
font-size: 16px;
color: #222;
}
.meta {
font-size: 12px;
color: #999;
}
.tags {
font-size: 11px;
color: var(--color-primary);
}
.intro .hi {
font-size: 15px;
font-weight: 650;
color: #222;
}
.intro .hi-sub {
margin-top: 4px;
font-size: 13px;
color: #888;
line-height: 1.5;
}
.hint {
font-size: 13px;
color: #9a8f8b;
text-align: center;
padding: 28px 16px;
}
.err {
font-size: 13px;
color: var(--color-primary);
padding: 10px 12px;
margin: 4px 0 8px;
border-radius: var(--radius-sm);
background: var(--color-primary-soft);
}
.err.pad {
margin: 0 0 8px;
}
.retry {
margin-left: 8px;
border: none;
background: transparent;
color: var(--color-primary);
font-weight: 600;
}
</style>
@@ -15,6 +15,7 @@
<AskProfileBar <AskProfileBar
:profiles="profiles" :profiles="profiles"
:profile-id="profileId" :profile-id="profileId"
:self-label="selfLabel"
@update:profile-id="$emit('update:profileId', $event)" @update:profile-id="$emit('update:profileId', $event)"
@profile-change="$emit('profile-change')" @profile-change="$emit('profile-change')"
@navigate="$emit('navigate', $event)" @navigate="$emit('navigate', $event)"
@@ -54,6 +55,7 @@ const props = defineProps<{
bootError: string bootError: string
profiles: Profile[] profiles: Profile[]
profileId: string profileId: string
selfLabel?: string
messages: AskMessage[] messages: AskMessage[]
draft: string draft: string
sending: boolean sending: boolean
@@ -15,8 +15,10 @@
:aria-selected="p.id === profileId" :aria-selected="p.id === profileId"
@click="pick(p.id)" @click="pick(p.id)"
> >
<span class="chip-mark">{{ p.relation === 'self' ? '我' : 'TA' }}</span> <span class="chip-mark">{{ p.relation === 'self' ? selfMark : 'TA' }}</span>
<span class="chip-name">{{ p.relation === 'self' ? '我的档案' : p.display_name || '未命名' }}</span> <span class="chip-name">{{
p.relation === 'self' ? selfLabel : p.display_name || '未命名'
}}</span>
</button> </button>
</div> </div>
<div class="quick"> <div class="quick">
@@ -34,14 +36,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import type { Profile } from '@yuxingu/types' import type { Profile } from '@yuxingu/types'
import { askQuickTools } from '../../composables/useAskPage' import { askQuickTools } from '../../composables/useAskPage'
const props = defineProps<{ const props = defineProps<{
profiles: Profile[] profiles: Profile[]
profileId: string profileId: string
selfLabel?: string
}>() }>()
const selfLabel = computed(() => (props.selfLabel || '').trim() || '我')
const selfMark = computed(() => selfLabel.value.slice(0, 1) || '我')
const emit = defineEmits<{ const emit = defineEmits<{
'update:profileId': [v: string] 'update:profileId': [v: string]
'profile-change': [] 'profile-change': []
@@ -1,10 +1,10 @@
<template> <template>
<div class="archive reveal" style="--d: 40ms"> <div class="archive reveal" style="--d: 40ms">
<router-link to="/portrait" class="av-self" aria-label="自己的档案"> <router-link to="/portrait" class="av-self" :aria-label="`${selfLabel}的档案`">
<span class="av" aria-hidden="true"> <span class="av" aria-hidden="true">
<BrandLogo size="card" class="av-logo" /> <img class="av-img" :src="avatarSrc" alt="" />
</span> </span>
<span class="av-name">自己</span> <span class="av-name">{{ selfLabel }}</span>
</router-link> </router-link>
<button type="button" class="av-add" aria-label="添加档案" @click="$emit('add')"> <button type="button" class="av-add" aria-label="添加档案" @click="$emit('add')">
<span class="plus-dot" aria-hidden="true">+</span> <span class="plus-dot" aria-hidden="true">+</span>
@@ -18,9 +18,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import BrandLogo from '../BrandLogo.vue' import { computed } from 'vue'
import { apiAssetURL } from '../../lib/apiAsset'
import { DEFAULT_AVATAR_SRC } from '../../lib/defaultAvatar'
const props = withDefaults(
defineProps<{
nickname?: string
avatarUrl?: string
}>(),
{ nickname: '', avatarUrl: '' },
)
defineEmits<{ add: [] }>() defineEmits<{ add: [] }>()
const selfLabel = computed(() => {
const n = (props.nickname || '').trim()
if (!n) return '访客'
return n.length > 4 ? `${n.slice(0, 4)}` : n
})
const avatarSrc = computed(() => apiAssetURL(props.avatarUrl) || DEFAULT_AVATAR_SRC)
</script> </script>
<style scoped> <style scoped>
@@ -67,16 +85,23 @@ defineEmits<{ add: [] }>()
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
border: 2px solid rgba(229, 77, 66, 0.35); border: 2px solid rgba(229, 77, 66, 0.35);
} }
.av-logo { .av-img {
width: 22px !important; width: 100%;
height: auto !important; height: 100%;
max-height: none !important; object-fit: cover;
object-position: center 42%;
display: block;
} }
.av-name { .av-name {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: #8a4a3a; color: #8a4a3a;
line-height: 1; line-height: 1;
max-width: 3.2em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
} }
.plus-dot { .plus-dot {
width: 40px; width: 40px;
@@ -2,9 +2,10 @@
<section class="self-card reveal" style="--d: 80ms" data-track="home_self_card"> <section class="self-card reveal" style="--d: 80ms" data-track="home_self_card">
<div class="self-head"> <div class="self-head">
<button type="button" class="who-btn" @click="$emit('open-profile')"> <button type="button" class="who-btn" @click="$emit('open-profile')">
{{ profileLabel }} <span class="who-name">{{ profileLabel }}</span>
<span class="caret" aria-hidden="true"></span> <span class="caret" aria-hidden="true"></span>
</button> </button>
<span class="who-day">{{ !loading && tips.asOf ? `今日 ${tips.asOf.slice(5)}` : '' }}</span>
<router-link class="more" to="/profile">更多</router-link> <router-link class="more" to="/profile">更多</router-link>
</div> </div>
@@ -126,10 +127,11 @@ const ringOffset = computed(() => {
box-shadow: var(--shadow-hero); box-shadow: var(--shadow-hero);
} }
.self-head { .self-head {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 12px;
gap: 8px;
} }
.who-btn { .who-btn {
border: none; border: none;
@@ -142,12 +144,29 @@ const ringOffset = computed(() => {
gap: 4px; gap: 4px;
padding: 0; padding: 0;
font-family: var(--font-sans); font-family: var(--font-sans);
min-width: 0;
justify-self: start;
}
.who-name {
max-width: 9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who-day {
justify-self: center;
font-size: 11px;
font-weight: 500;
color: var(--color-text-tertiary);
letter-spacing: 0.02em;
white-space: nowrap;
} }
.caret { .caret {
font-size: 10px; font-size: 10px;
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
} }
.self-head .more { .self-head .more {
justify-self: end;
font-size: 12px; font-size: 12px;
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
} }
@@ -1,251 +0,0 @@
<template>
<header class="top reveal" :class="{ 'top--menu-open': plusOpen }" style="--d: 0ms">
<router-link to="/growth-plan" class="ico-btn" aria-label="每日打卡">
<span class="ico-mark"></span>
</router-link>
<button type="button" class="search" @click="$emit('search')">
<span class="search-ico" aria-hidden="true" />
<span class="search-ph">{{ searchHint }}</span>
</button>
<div class="plus-wrap">
<button
type="button"
class="ico-btn"
aria-label="添加档案"
aria-haspopup="menu"
:aria-expanded="plusOpen"
@click="$emit('update:plusOpen', !plusOpen)"
>
<span class="ico-mark plus">+</span>
</button>
<div v-if="plusOpen" class="plus-mask" @click="$emit('update:plusOpen', false)" />
<div v-if="plusOpen" class="plus-menu" role="menu">
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'inviteFill')">
<span class="plus-ico pi-invite" aria-hidden="true" />
<span>邀请好友填档案</span>
</button>
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'add')">
<span class="plus-ico pi-add" aria-hidden="true" />
<span>添加档案</span>
</button>
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'synastry')">
<span class="plus-ico pi-heart" aria-hidden="true" />
<span>邀请好友合盘</span>
</button>
</div>
</div>
</header>
</template>
<script setup lang="ts">
defineProps<{
searchHint: string
plusOpen: boolean
}>()
defineEmits<{
search: []
'update:plusOpen': [value: boolean]
plus: [kind: 'inviteFill' | 'add' | 'synastry']
}>()
</script>
<style scoped>
.top {
display: grid;
grid-template-columns: 40px 1fr 40px;
gap: 10px;
align-items: center;
padding: 12px 14px 0;
position: relative;
/* Must sit above .sheet (z-index: 3) or the + menu is covered */
z-index: 10;
}
.top--menu-open {
z-index: 50;
}
.ico-btn {
width: 38px;
height: 38px;
border-radius: 13px;
border: 1px solid rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.78);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 14px rgba(180, 90, 70, 0.08);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: transform var(--duration-fast) ease;
}
.ico-btn:active {
transform: scale(0.94);
}
.ico-mark {
font-size: 14px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.02em;
}
.ico-mark.plus {
font-size: 20px;
font-weight: 500;
line-height: 1;
color: #666;
}
.plus-wrap {
position: relative;
z-index: 20;
}
.plus-mask {
position: fixed;
inset: 0;
z-index: 40;
background: transparent;
}
.plus-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 50;
min-width: 176px;
padding: 8px 0;
border-radius: 14px;
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 12px 32px rgba(120, 60, 50, 0.16);
animation: plusPop 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.plus-menu::before {
content: '';
position: absolute;
top: -6px;
right: 14px;
width: 12px;
height: 12px;
background: inherit;
border-left: 1px solid rgba(255, 255, 255, 0.95);
border-top: 1px solid rgba(255, 255, 255, 0.95);
transform: rotate(45deg);
}
@keyframes plusPop {
from {
opacity: 0;
transform: translateY(-6px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.plus-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
border: none;
background: transparent;
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
text-align: left;
}
.plus-item:active {
background: rgba(229, 77, 66, 0.06);
}
.plus-ico {
width: 28px;
height: 28px;
border-radius: 9px;
flex-shrink: 0;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
color: #fff;
}
.pi-invite {
background: linear-gradient(145deg, #5ecf8a, #3bb56e);
}
.pi-invite::after {
content: '邀';
}
.pi-add {
background: linear-gradient(145deg, #ffb45c, #f08a3a);
border-radius: 8px;
}
.pi-add::after {
content: '档';
}
.pi-heart {
background: linear-gradient(145deg, #ff8aa8, #e54d7a);
}
.pi-heart::after {
content: '合';
}
.search {
height: 38px;
border-radius: var(--radius-pill);
border: 1px solid rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.82);
padding: 0 14px 0 12px;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
box-shadow: 0 4px 16px rgba(180, 90, 70, 0.07);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: background var(--duration-fast) ease;
}
.search:active {
background: rgba(255, 255, 255, 0.95);
}
.search-ico {
width: 14px;
height: 14px;
flex-shrink: 0;
border: 1.5px solid rgba(0, 0, 0, 0.28);
border-radius: 50%;
position: relative;
}
.search-ico::after {
content: '';
position: absolute;
width: 6px;
height: 1.5px;
background: rgba(0, 0, 0, 0.28);
border-radius: 1px;
right: -3px;
bottom: -1px;
transform: rotate(45deg);
transform-origin: left center;
}
.search-ph {
font-size: 13px;
color: rgba(0, 0, 0, 0.36);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reveal {
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: var(--d, 0ms);
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.reveal {
animation: none !important;
}
}
</style>
@@ -1,19 +1,11 @@
<template> <template>
<div class="greet"> <div class="greet">
<div class="greet-icons" aria-hidden="true">
<HomeToolIcon name="cards" :size="44" />
<HomeToolIcon name="ask" :size="36" class="icon-offset" />
</div>
<h1 class="greet-title">Hi我是愈心 AI</h1>
<p class="greet-sub">来用意念卡片整理问题吧</p>
<p v-if="bootError" class="yxg-err">{{ bootError }}仍可填写后重试抽取</p> <p v-if="bootError" class="yxg-err">{{ bootError }}仍可填写后重试抽取</p>
<p class="quota">今日剩余 {{ quotaLabel }} · 场景 {{ scene }}</p> <p class="quota">今日剩余 {{ quotaLabel }} · 场景 {{ scene }}</p>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import HomeToolIcon from '../HomeToolIcon.vue'
defineProps<{ defineProps<{
bootError: string bootError: string
quotaLabel: string quotaLabel: string
@@ -24,31 +16,7 @@ defineProps<{
<style scoped> <style scoped>
.greet { .greet {
text-align: center; text-align: center;
padding: 8px 0 20px; padding: 0 0 12px;
}
.greet-icons {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
margin-bottom: 12px;
}
.icon-offset {
margin-bottom: -4px;
opacity: 0.85;
}
.greet-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.04em;
margin: 0;
}
.greet-sub {
font-size: 14px;
color: var(--color-text-secondary);
margin: 6px 0 10px;
} }
.quota { .quota {
font-size: 11px; font-size: 11px;

Some files were not shown because too many files have changed in this diff Show More