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:
@@ -25,6 +25,7 @@
|
||||
| [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-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` → 填满 → 在本表登记 → 再编码。
|
||||
|
||||
|
||||
@@ -84,14 +84,16 @@ Device 绑定到账号 User
|
||||
| R5 | 登出作废 session;设备可再登录其他账号 |
|
||||
| R6 | 正式环境恢复校验前须改回本 Spec |
|
||||
| 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. 数据模型影响
|
||||
|
||||
- `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)`
|
||||
- 本地文件:`data/avatars/{user_id}.{jpg|png|webp}`(进程相对路径)
|
||||
|
||||
---
|
||||
|
||||
@@ -102,8 +104,10 @@ Device 绑定到账号 User
|
||||
| POST | `/api/v1/auth/register` | 注册(可带 nickname) |
|
||||
| POST | `/api/v1/auth/login` | 登录 |
|
||||
| POST | `/api/v1/auth/logout` | 登出 |
|
||||
| GET | `/api/v1/auth/me` | 当前账号 |
|
||||
| GET | `/api/v1/auth/me` | 当前账号(含 avatar_url) |
|
||||
| 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 | ✓ | ✓ |
|
||||
| profiles / reports 写读 | ✗ | ✓ |
|
||||
| 上传头像 | ✗ | ✓ |
|
||||
| 读头像媒体 | ✓ | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## 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)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
|
||||
```text
|
||||
/ask
|
||||
├── 愈心 AI / 顾问 Tab
|
||||
├── 顾问列表 → 顾问专属对话(?advisor=key · 顶栏返回回列表)
|
||||
├── Empty(无档案)
|
||||
├── Loading(发送中)
|
||||
├── Normal(历史 + 输入)
|
||||
|
||||
@@ -111,10 +111,15 @@ Tab 陪伴 → /companion
|
||||
|
||||
## 11. 埋点
|
||||
|
||||
| Event | 触发 |
|
||||
|---|---|
|
||||
| `companion_viewed` | 打开 |
|
||||
| `mood_saved` | P2 |
|
||||
| Event | 触发 | 可选 params |
|
||||
|---|---|---|
|
||||
| `companion_viewed` | 打开 `/companion` | — |
|
||||
| `mood_saved` | 心情保存成功 | `score`(整数分,非正文) |
|
||||
| `growth_plan_viewed` | 打开 `/growth-plan` 且列表加载结束 | — |
|
||||
| `growth_plan_created` | 创建计划成功 | — |
|
||||
| `growth_plan_checkin` | 今日打卡成功 | — |
|
||||
|
||||
禁止参数含计划 title/focus/note 明文。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
↓
|
||||
进入 /scales/:slug
|
||||
↓
|
||||
加载题目
|
||||
↓
|
||||
逐题作答(须全部完成)
|
||||
已有结果? → 直接展示最近结果 +「重新测试」
|
||||
↓(无结果 / 点重测)
|
||||
加载题目 · 逐题作答(须全部完成)
|
||||
↓
|
||||
无 Self? → 引导建档
|
||||
↓
|
||||
@@ -60,10 +60,13 @@ POST result → 展示丰富结果
|
||||
|
||||
| 子能力 | P1 |
|
||||
|---|---|
|
||||
| 测试入口(探索 Tab) | ✓ |
|
||||
| 测试列表 | ✓ |
|
||||
| 测试入口(探索 Tab · 首页宫格) | ✓ · 产品向 `mbti-lite`;探索主路径为 curated 题库(A 集) |
|
||||
| 测试列表 | ✓ · 探索「题库精选」宫格(每类 1~2)+「题库分类」;分类页 `/explore/bank/:key` |
|
||||
| 题库范围(A) | ✓ 人格 / 情绪能力 / 智能 / 生活与关系;**不含**抑郁焦虑强迫躁狂双相等临床筛查 |
|
||||
| 问题流程 | ✓ |
|
||||
| 答题保存(服务端结果落库) | ✓ |
|
||||
| 最近结果回看 | ✓ GET `/scales/{slug}/result`;有结果进页不重复作答 |
|
||||
| 重新测试 | ✓ 结果页按钮 → 清空草稿再答;新结果落库并覆盖展示 |
|
||||
| 中断恢复 | ✓ localStorage 草稿(`scaleDraft`);提交后清除 |
|
||||
| 结果生成(丰富结构) | ✓ |
|
||||
| 免费层 | ✓ 全量 result |
|
||||
@@ -79,8 +82,10 @@ POST result → 展示丰富结果
|
||||
|
||||
| 路由 | 页面 |
|
||||
|---|---|
|
||||
| `/explore` | ExplorePage |
|
||||
| `/scales/:slug` | ScalePage |
|
||||
| `/explore` | ExplorePage(题库精选宫格 + 题库分类 + 其它工具) |
|
||||
| `/explore/bank/:category` | ExploreBankCategoryPage · curated 题库分类列表 |
|
||||
| `/explore/:category` | ExploreCategoryPage · 产品工具分类(解码/星座等) |
|
||||
| `/scales/:slug` | ScalePage(含 bank slug) |
|
||||
|
||||
```text
|
||||
/scales/:slug
|
||||
@@ -116,6 +121,13 @@ POST result → 展示丰富结果
|
||||
| R5 | result 含 label/summary/overview/dimensions/tips/scripts/growth_plan/faq |
|
||||
| R6 | 结果「不是固定标签」需在文案中提示 |
|
||||
| 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/{slug}` | 题目 |
|
||||
| GET | `/api/v1/scales/{slug}/result` | 当前用户该量表最近一次结果;无则 404 |
|
||||
| POST | `/api/v1/scales/{slug}/result` | 提交计分 |
|
||||
|
||||
---
|
||||
@@ -144,18 +157,27 @@ POST result → 展示丰富结果
|
||||
|---|---|---|
|
||||
| 看列表/题目 | ✓ | ✓ |
|
||||
| 提交结果 | ✗ | ✓ |
|
||||
| `mbti-full` 作答 | ✗(须成长会员) | ✓(会员 active) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 埋点
|
||||
|
||||
| Event | 触发 |
|
||||
|---|---|
|
||||
| `scale_list_viewed` | 探索列表 |
|
||||
| `scale_started` | 进入作答 |
|
||||
| `scale_completed` | 出结果 |
|
||||
| `scale_share_clicked` | 分享 |
|
||||
| `scale_cta_relation` / `scale_cta_ask` | 导流 |
|
||||
经统一 `track()`;非 P1 核心 5 事件。禁止参数含题干全文、答案明文、档案 ID。
|
||||
|
||||
| Event | 触发 | 可选 params |
|
||||
|---|---|---|
|
||||
| `scale_list_viewed` | 打开 `/explore` 且题库 catalog 加载成功 | — |
|
||||
| `scale_bank_category_viewed` | 打开 `/explore/bank/:category` 且分类加载成功 | `category`(key) |
|
||||
| `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** 答完提交
|
||||
**Then** 返回 label 与 overview/tips 等丰富字段
|
||||
|
||||
**Given** 已提交过结果
|
||||
**When** 再次进入 `/scales/{slug}`
|
||||
**Then** 直接展示最近结果与「重新测试」;不进入答题
|
||||
|
||||
**Given** 结果页点「重新测试」并完成提交
|
||||
**When** 再次进入
|
||||
**Then** 展示新结果
|
||||
|
||||
**Given** 未答完
|
||||
**When** 提交
|
||||
**Then** 前端拦截;不成功落库
|
||||
@@ -186,7 +216,8 @@ POST result → 展示丰富结果
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| Packages | `internal/scale` · `service/scale` · `ExplorePage` · `ScalePage` |
|
||||
| Seeds | communication-style · emotion-pattern |
|
||||
| Seeds | communication-style · emotion-pattern · **mbti-lite(OEJTS 32)** · **mbti-full(60·会员)** |
|
||||
| Gaps | 结果付费墙若要做须先改 Spec;全量 §11 埋点未挂(核心漏斗见 analytics) |
|
||||
| Tests | score/result L1;`scaleDraft.spec`;Scale 流程组件测 |
|
||||
| Process Review | 草稿恢复已实现 · P1 收口 |
|
||||
| Tests | score/result L1;`ScoreJungian`;`scaleDraft.spec`;Scale 流程组件测 |
|
||||
| Process Review | OEJTS 标准/完整双轨 · 最近结果回看 · P1 收口 |
|
||||
| Credit | OEJTS / Open Psychometrics · openjung MIT |
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
```text
|
||||
/
|
||||
├── Hero / 品牌
|
||||
├── 全局顶栏:透明愈心谷 Logo(AppHeader,全站)
|
||||
├── 「自己」卡片标题展示账号昵称(默认意象词+场景词;档案页可改)
|
||||
├── 穿衣指数 · 颜色搭配 · 养生推荐
|
||||
├── 功能入口(4 列固定网格 · 多行换行 · 禁止横向滑动)
|
||||
@@ -91,14 +91,16 @@ Empty:首页本身是入口,不因无档案空白死页。
|
||||
| R2 | 不展示内部运维信息(API 连接状态等) |
|
||||
| R3 | 入口文案符合 lexicon(个人画像/关系理解/成长会员…) |
|
||||
| R4 | Tab 五名冻结:首页·探索·问答·陪伴·我的 |
|
||||
| R5 | 「自己」卡片:穿衣指数 / 颜色搭配 / 养生推荐;有 Self 档案生日时按生日+本地时刻易经卦象种子调大模型生成;无档案或 LLM 不可用则本地回退 |
|
||||
| R5 | 「自己」卡片:穿衣指数 / 颜色搭配 / 养生推荐;有 Self 生日时按生日+**本地日历**+**十二时辰**易经种子生成;**每一时辰准点切换**;无档案或 LLM 不可用则本地回退 |
|
||||
| R6 | 文案禁止占卜/算命恐吓与医疗疗效承诺;卦象仅作节律意象 |
|
||||
| R7 | 贴士按 `user × 时辰起点` 持久缓存;GET 优先读缓存**即时返回**;缓存未命中时先回退/旧值,后台异步生成写入,禁止每次打开同步等 LLM |
|
||||
| R8 | 响应含 `shichen` / `shichen_name` / `valid_until`(下一时辰起点);前端在 `valid_until` 自动静默刷新 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 数据模型影响
|
||||
|
||||
经首页建档时写 `profiles`(见 profile Spec)。每日贴士可缓存(同用户约 45 分钟 / 按时段刷新)。
|
||||
经首页建档时写 `profiles`。`home_daily_tips(user_id, shichen_start, tips jsonb)` 缓存时辰贴士。
|
||||
|
||||
---
|
||||
|
||||
@@ -107,7 +109,7 @@ Empty:首页本身是入口,不因无档案空白死页。
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| 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` |
|
||||
|
||||
---
|
||||
@@ -142,9 +144,13 @@ Empty:首页本身是入口,不因无档案空白死页。
|
||||
**When** 提交主 CTA
|
||||
**Then** 进入画像基础结果路径
|
||||
|
||||
**Given** 已登录且有 Self 生日、已配 deepseek
|
||||
**Given** 已登录且有 Self 生日、该时辰已有缓存
|
||||
**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
|
||||
**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 可读原因
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5–6. 页面 / 状态
|
||||
|
||||
各既有表单;Error 态展示 `message`。无新页面。
|
||||
|
||||
---
|
||||
|
||||
## 7. Business Rules
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| R1 | **后端必须校验**所有用户自由文字;前端校验不得替代后端 |
|
||||
| R2 | 种类与上限(rune):`nickname` 1–16;`display_name` 1–24;`ask_content` 1–2000;`note` 0–200;`title` 1–40;`focus` 0–40;`scene` 1–80 |
|
||||
| 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 |
|
||||
@@ -98,6 +98,10 @@ CTA:节气陪伴 / 问答「生活节奏」/ 分享
|
||||
| R5 | 推荐用语:体质倾向、生活建议、节律、平衡 |
|
||||
| R6 | 可展示五行隐喻,须标注探索/非医疗 |
|
||||
| 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` |
|
||||
| 或 Constitution 专用表 | 若需独立聚合,实现时 ADR |
|
||||
| 禁止 | `luck_score`、病名诊断字段 |
|
||||
| 报告 JSON | `as_of` · `valid_until` · `today_tip` · `week_focus` |
|
||||
|
||||
---
|
||||
|
||||
@@ -155,6 +160,14 @@ CTA:节气陪伴 / 问答「生活节奏」/ 分享
|
||||
**When** 扫 summary+detail
|
||||
**Then** 符合 lexicon
|
||||
|
||||
**Given** 已有 rhythm 报告且 `as_of` 为昨日
|
||||
**When** GET latest
|
||||
**Then** `today_tip` / `week_focus` 按今日更新;报告 `id` 不变
|
||||
|
||||
**Given** 用户停留在 `/rhythm` 跨自然日
|
||||
**When** 到达 `valid_until`
|
||||
**Then** 前端静默刷新建议文案
|
||||
|
||||
---
|
||||
|
||||
## 13. AI 开发前检查
|
||||
|
||||
@@ -118,7 +118,7 @@ analytics_daily_page(可选汇总)
|
||||
- 卡片:DAU、新增、会话数、人均会话时长
|
||||
- 图:日活趋势;页面均停留 TOP
|
||||
- 表:页面明细、退出页 TOP、点击 TOP
|
||||
- 漏斗:画像完成 → 深度 CTA → 支付成功
|
||||
- 漏斗:探索列表 → 开始作答 → 量表完成 → 画像完成 → 深度 CTA → 支付成功
|
||||
|
||||
视觉:愈心谷色系;品牌仅 logo,无「运营后台」字样。
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| In | Out |
|
||||
|---|---|
|
||||
| Self / Other 档案创建与生日变更触发 | 无档案凭空出合盘 |
|
||||
| 单人:portrait · star · rhythm | 意象卡片抽牌(配额玩法,不预生成) |
|
||||
| 单人:portrait · star · rhythm(star/rhythm 运势与今日建议按自然日在 GET 时原地刷新,见各 Spec) | 意象卡片抽牌(配额玩法,不预生成) |
|
||||
| 有 Self+Other:relation + synastry 成对覆盖 | 附近的人合盘(进页再算) |
|
||||
|
||||
---
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
| R9 | 附近的人默认 `geo_visible=false`;仅可见已开启位置的 self 档案 |
|
||||
| R10 | 合盘星历走 Swiss Ephemeris(默认 Moshier);宫位制 Whole Sign |
|
||||
| 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.geo_lat/lng/visible` | 附近的人 |
|
||||
| `synastry_invites` | 邀请 token · host/guest · report_id |
|
||||
| 报告 JSON | `charts.*`(五主盘+推运)· `as_of` · 三指数 · fortune/transits |
|
||||
| 报告 JSON | `charts.*`(五主盘+推运)· `as_of` · `valid_until` · 三指数 · fortune/transits |
|
||||
|
||||
详见 erd;实现切片同步 OpenAPI。
|
||||
|
||||
@@ -153,7 +156,7 @@
|
||||
|
||||
| Event | 触发 |
|
||||
|---|---|
|
||||
| `star_completed` / portrait_completed source=star | 基础结果展示 |
|
||||
| `star_completed` | 基础结果展示 |
|
||||
| `star_wheel_viewed` | 点选轮盘行星 |
|
||||
| `synastry_completed` | 合盘结果展示 |
|
||||
| `synastry_invite_created` / `synastry_invite_accepted` | 邀请链路 |
|
||||
@@ -184,6 +187,14 @@
|
||||
**When** 打开 `/star`
|
||||
**Then** 页内生日表单可生成
|
||||
|
||||
**Given** 已有 star 报告且 `as_of` 为昨日
|
||||
**When** GET latest
|
||||
**Then** 日运等 outlook 按今日更新;报告 `id` 不变;响应含当日 `as_of` 与 `valid_until`
|
||||
|
||||
**Given** 用户停留在 `/star` 跨自然日
|
||||
**When** 到达 `valid_until`
|
||||
**Then** 前端静默刷新运势 Tab 内容
|
||||
|
||||
---
|
||||
|
||||
## 13. AI 开发前检查
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
- DB: PostgreSQL
|
||||
- Migrations: goose (or golang-migrate) under `apps/api/migrations/`
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -17,6 +17,7 @@ dist/
|
||||
apps/api/bin/
|
||||
apps/api/yuxingu-api
|
||||
apps/api/tmp/
|
||||
apps/api/data/
|
||||
*.exe
|
||||
|
||||
# IDE
|
||||
@@ -40,3 +41,7 @@ Thumbs.db
|
||||
|
||||
# Local secrets (never commit)
|
||||
apps/api/config.local.yaml
|
||||
|
||||
# Playwright / Vitest artifacts
|
||||
**/test-results/
|
||||
**/playwright-report/
|
||||
|
||||
@@ -4,40 +4,62 @@ You are the software engineer for **愈心谷 (YuXinGu)**.
|
||||
|
||||
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)
|
||||
|
||||
1. `.ai/ai-contract.md`
|
||||
2. `.ai/constitution.md`
|
||||
3. `.ai/architecture.md`
|
||||
4. `.ai/domain.md` (+ product/API tasks: `domain/domain-map.md`)
|
||||
5. `.ai/forbidden.md`
|
||||
6. `.ai/definition-of-done.md` — **完成标准(强制)**;Done ≠ build 通过
|
||||
7. `.ai/environment.md` + `.ai/development.md`(本地)· 部署任务再读 `deployment.md` / `docker.md`
|
||||
8. `.ai/file-map.md` + `.ai/workflow.md`
|
||||
9. Task rules: `.ai/coding.md` / `api.md` / `database.md` / `ui.md` / `security.md` / `testing.md` …
|
||||
10. **Product scope:** `.ai/product/lexicon.md` + `feature-map.md` + **`feature-design.md`** + 对应 **`feature-spec/<id>.md`** + `user-journey.md` + `page-tree.md` · 状态 `product/p1-status.md`
|
||||
11. **Competitor parity / reverse engineering:** `.ai/design/reverse-engineering-spec.md`(强制 STEP 1–18 · Evidence First · 禁止 Demo 式省略)→ 再映射 lexicon / feature-spec
|
||||
12. **Any UI work:** `.ai/design/design-system.md` + `component-catalog.md` + matching `design/platform/*.md` (H5 default)
|
||||
13. Matching **ADR** in `.ai/adr/` before changing stack or API style
|
||||
14. Prefer **patterns/** + **examples/** + **playbooks/**(含 `feature-spec`) over inventing structure
|
||||
15. Before claiming Done: `.ai/definition-of-done.md` + `.ai/review.md` + `.ai/checklists/*` → 输出 **Review Report**
|
||||
16. Verify via `.ai/commands.md`(本地默认本机 Go/Vite + compose.dev 仅 DB)
|
||||
17. `prompts/` are optional helpers — not a substitute for rules above
|
||||
18. **ESS process (when L2+ / architecture / release):** `docs/PROJECT_PROFILE.md` → role from `$ESS_ROOT/agents/` → `docs/ECR/` · `docs/TASKS/` · `docs/HANDOFF/`;勿把 ESS 整树复制进仓
|
||||
1. **§0 ESS Mode 块**(上)→ `docs/PROJECT_PROFILE.md` · `docs/PROJECT_RULES.md`
|
||||
2. `.ai/ai-contract.md`
|
||||
3. `.ai/constitution.md`
|
||||
4. `.ai/architecture.md`
|
||||
5. `.ai/domain.md` (+ product/API tasks: `domain/domain-map.md`)
|
||||
6. `.ai/forbidden.md`
|
||||
7. `.ai/definition-of-done.md` — **完成标准(强制)**;Done ≠ build 通过
|
||||
8. `.ai/environment.md` + `.ai/development.md`(本地)· 部署任务再读 `deployment.md` / `docker.md`
|
||||
9. `.ai/file-map.md` + `.ai/workflow.md`
|
||||
10. Task rules: `.ai/coding.md` / `api.md` / `database.md` / `ui.md` / `security.md` / `testing.md` …
|
||||
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. **Competitor parity / reverse engineering:** `.ai/design/reverse-engineering-spec.md`(强制 STEP 1–18 · Evidence First · 禁止 Demo 式省略)→ 再映射 lexicon / feature-spec
|
||||
13. **Any UI work:** `.ai/design/design-system.md` + `component-catalog.md` + matching `design/platform/*.md` (H5 default)
|
||||
14. Matching **ADR** in `.ai/adr/` before changing stack or API style
|
||||
15. Prefer **patterns/** + **examples/** + **playbooks/**(含 `feature-spec`) over inventing structure
|
||||
16. Before claiming Done: `.ai/definition-of-done.md` + `.ai/review.md` + `.ai/checklists/*` → 输出 **Review Report**
|
||||
17. Verify via `.ai/commands.md`(本地默认本机 Go/Vite + compose.dev 仅 DB)
|
||||
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
|
||||
|
||||
| Concern | Source of truth |
|
||||
|---------|-----------------|
|
||||
| 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
|
||||
Load Agent Profile: ARCHITECT | ENGINEER | REVIEWER | RELEASE_MANAGER
|
||||
```
|
||||
|
||||
- Cursor → ARCHITECT / REVIEWER(禁改生产实现)
|
||||
- Engineer 实现前:Active Feature Spec +(L2+)Approved ECR + HANDOFF
|
||||
- Cursor Session 可 Solo 串行三角色;**当前 Phase 权限唯一**
|
||||
- ARCHITECT / REVIEWER phase:禁改生产实现;REVIEWER 禁 Approve+Modify 同 turn
|
||||
- Engineer:Active Feature Spec +(L2+)Approved ECR + HANDOFF;无 ECR → `L2-BLOCKED`
|
||||
- `python scripts/ess-validate.py --phase <phase> --ecr ECR-xxx`
|
||||
|
||||
## Hard constraints
|
||||
|
||||
@@ -65,6 +65,9 @@ function fmtMs(ms: number) {
|
||||
}
|
||||
|
||||
const funnelLabel: Record<string, string> = {
|
||||
scale_list_viewed: '探索列表',
|
||||
scale_started: '开始作答',
|
||||
scale_completed: '量表完成',
|
||||
portrait_completed: '画像完成',
|
||||
deep_access_clicked: '深度 CTA',
|
||||
purchase_completed: '支付成功',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// Package explore provides the L1→L3 explore catalog.
|
||||
package explore
|
||||
|
||||
import "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
|
||||
// Item is a leaf tool in the catalog.
|
||||
type Item struct {
|
||||
Key string `json:"key"`
|
||||
@@ -22,8 +24,8 @@ type Category struct {
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
// Catalog returns the full explore tree (deterministic, no DB).
|
||||
// Order: 四核心优先,量表后置。
|
||||
// Catalog returns the full explore tree (deterministic base; tests filled by WithPublishedScales).
|
||||
// Order: 解码 → 量表题库 → 星座 / 匹配 / 节律 …
|
||||
func Catalog() []Category {
|
||||
return []Category{
|
||||
{
|
||||
@@ -33,6 +35,11 @@ func Catalog() []Category {
|
||||
{Key: "portrait", Title: "愈心解码", Description: "生日生成性格解码报告", Path: "/portrait", Icon: "△", Tone: "gold", Badge: "热"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "tests", Title: "量表题库", Description: "人格与偏好测评(已发布)",
|
||||
Icon: "◆", Tone: "blue",
|
||||
Items: defaultScaleItems(),
|
||||
},
|
||||
{
|
||||
Key: "star", Title: "星座", Description: "排盘 · 运势 · 合盘",
|
||||
Icon: "✦", Tone: "night",
|
||||
@@ -72,24 +79,56 @@ func Catalog() []Category {
|
||||
{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.
|
||||
func CategoryByKey(key string) *Category {
|
||||
for _, c := range Catalog() {
|
||||
|
||||
@@ -1,58 +1,38 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
func TestCatalogShape(t *testing.T) {
|
||||
func TestCatalogHasScaleBankNearTop(t *testing.T) {
|
||||
cats := Catalog()
|
||||
if len(cats) < 6 {
|
||||
t.Fatalf("want ≥6 L1 categories, got %d", len(cats))
|
||||
if len(cats) < 2 {
|
||||
t.Fatal("expected categories")
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, c := range cats {
|
||||
keys[c.Key] = true
|
||||
if len(c.Items) < 1 {
|
||||
t.Fatalf("category %s empty", c.Key)
|
||||
}
|
||||
for _, it := range c.Items {
|
||||
if it.Path == "" || !strings.HasPrefix(it.Path, "/") {
|
||||
t.Fatalf("bad path %#v", it)
|
||||
}
|
||||
}
|
||||
if cats[1].Key != "tests" || cats[1].Title != "量表题库" {
|
||||
t.Fatalf("second cat=%#v want 量表题库", cats[1])
|
||||
}
|
||||
for _, k := range []string{"decode", "star", "relation", "tests", "rhythm", "cards", "growth"} {
|
||||
if !keys[k] {
|
||||
t.Fatalf("missing category %s", k)
|
||||
}
|
||||
}
|
||||
// 四核心应排在量表之前
|
||||
if indexOf(cats, "tests") < indexOf(cats, "star") {
|
||||
t.Fatal("tests should come after star")
|
||||
if len(cats[1].Items) < 5 {
|
||||
t.Fatalf("expected scale items, got %d", len(cats[1].Items))
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(cats []Category, key string) int {
|
||||
for i, c := range cats {
|
||||
if c.Key == key {
|
||||
return i
|
||||
}
|
||||
func TestWithPublishedScalesReplacesItems(t *testing.T) {
|
||||
cats := Catalog()
|
||||
out := WithPublishedScales(cats, nil)
|
||||
if len(out[1].Items) < 5 {
|
||||
t.Fatal("nil scales should keep defaults")
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestLexiconHardBanOnly(t *testing.T) {
|
||||
blob := ""
|
||||
for _, c := range Catalog() {
|
||||
blob += c.Title + c.Description
|
||||
for _, it := range c.Items {
|
||||
blob += it.Title + it.Description
|
||||
}
|
||||
out = WithPublishedScales(cats, []repository.ScaleListItem{
|
||||
{Slug: "mbti-lite", Title: "MBTI测试", Description: "from-db"},
|
||||
{Slug: "eq-lite", Title: "情绪觉察探索", Description: "eq"},
|
||||
})
|
||||
if len(out[1].Items) != 2 {
|
||||
t.Fatalf("items=%d", len(out[1].Items))
|
||||
}
|
||||
for _, bad := range []string{"占卜", "算命", "测测"} {
|
||||
if strings.Contains(blob, bad) {
|
||||
t.Fatalf("forbidden %q", bad)
|
||||
}
|
||||
if out[1].Items[0].Description != "from-db" || out[1].Items[0].Badge != "热" {
|
||||
t.Fatalf("unexpected %#v", out[1].Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
asksvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
"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,
|
||||
})
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40010, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -142,6 +147,9 @@ func (h *AskHandler) SendMessage(c *gin.Context) {
|
||||
|
||||
out, err := h.Svc.SendMessage(c.Request.Context(), userID, tid, req.Content)
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
if asksvc.IsQuotaExhausted(err) {
|
||||
response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可购买额度或开通成长会员")
|
||||
return
|
||||
@@ -180,7 +188,9 @@ func (h *AskHandler) sendMessageStream(c *gin.Context, userID, tid uuid.UUID, co
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
code := 40011
|
||||
if asksvc.IsQuotaExhausted(err) {
|
||||
if errors.Is(err, textsafe.ErrRejected) {
|
||||
code = 40060
|
||||
} else if asksvc.IsQuotaExhausted(err) {
|
||||
msg = "问答次数已用完,可购买额度或开通成长会员"
|
||||
code = 40210
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) {
|
||||
g.POST("/logout", h.Logout)
|
||||
g.GET("/me", h.Me)
|
||||
g.PATCH("/me", h.PatchMe)
|
||||
g.POST("/me/avatar", h.UploadAvatar)
|
||||
}
|
||||
|
||||
type authBody struct {
|
||||
@@ -47,6 +48,9 @@ func (h *AuthHandler) RegisterAccount(c *gin.Context) {
|
||||
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
|
||||
res, err := h.Svc.Register(c.Request.Context(), userID, deviceKey, body.Phone, body.Password, body.Nickname)
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40110, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -116,12 +120,35 @@ func (h *AuthHandler) PatchMe(c *gin.Context) {
|
||||
}
|
||||
me, err := h.Svc.UpdateNickname(c.Request.Context(), userID, body.Nickname)
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40113, err.Error())
|
||||
return
|
||||
}
|
||||
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 {
|
||||
h := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
|
||||
@@ -55,6 +55,9 @@ func (h *CompanionHandler) SaveMood(c *gin.Context) {
|
||||
}
|
||||
m, err := h.Svc.SaveMood(c.Request.Context(), userID, in)
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 30010, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ExploreHandler serves the explore catalog.
|
||||
type ExploreHandler struct{}
|
||||
type ExploreHandler struct {
|
||||
Scales *scale.Service
|
||||
}
|
||||
|
||||
// Register mounts explore routes.
|
||||
func (h *ExploreHandler) Register(rg *gin.RouterGroup) {
|
||||
@@ -20,7 +23,13 @@ func (h *ExploreHandler) Register(rg *gin.RouterGroup) {
|
||||
|
||||
// Catalog handles GET /explore/catalog.
|
||||
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.
|
||||
@@ -30,5 +39,14 @@ func (h *ExploreHandler) Category(c *gin.Context) {
|
||||
response.Fail(c, http.StatusNotFound, 40402, "category not found")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
"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")
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" || len([]rune(title)) > 40 {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid title")
|
||||
title, err := textsafe.Check(textsafe.Title, req.Title)
|
||||
if err != nil {
|
||||
_ = failTextCompliance(c, err)
|
||||
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 {
|
||||
response.Fail(c, http.StatusBadRequest, 30020, err.Error())
|
||||
return
|
||||
@@ -86,6 +91,18 @@ func (h *GrowthHandler) Checkin(c *gin.Context) {
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
_ = 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)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 30021, err.Error())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -57,6 +57,9 @@ func (h *ProfileHandler) Create(c *gin.Context) {
|
||||
RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
|
||||
})
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, profile.ErrSelfExists) {
|
||||
response.Fail(c, http.StatusConflict, 40902, err.Error())
|
||||
return
|
||||
@@ -122,6 +125,9 @@ func (h *ProfileHandler) Update(c *gin.Context) {
|
||||
GeoLat: req.GeoLat, GeoLng: req.GeoLng, GeoVisible: req.GeoVisible,
|
||||
})
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusNotFound, 40401, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,10 +20,33 @@ type ScaleHandler struct {
|
||||
// Register mounts routes.
|
||||
func (h *ScaleHandler) Register(rg *gin.RouterGroup) {
|
||||
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/result", h.LatestResult)
|
||||
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.
|
||||
func (h *ScaleHandler) List(c *gin.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.
|
||||
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 {
|
||||
response.Fail(c, http.StatusNotFound, 40402, err.Error())
|
||||
return
|
||||
@@ -43,6 +68,25 @@ func (h *ScaleHandler) Get(c *gin.Context) {
|
||||
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.
|
||||
func (h *ScaleHandler) Submit(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
@@ -67,6 +111,10 @@ func (h *ScaleHandler) Submit(c *gin.Context) {
|
||||
ProfileID: pid, Answers: req.Answers,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, scale.ErrMembershipRequired) {
|
||||
response.Fail(c, http.StatusForbidden, 40310, "需要成长会员")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 30006, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
membershipSvc := &membership.Service{Reports: reportRepo}
|
||||
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
|
||||
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}
|
||||
companionSvc := &companionsvc.Service{Moods: &repository.MoodRepo{Pool: pool}}
|
||||
imageCardSvc := &imagecardsvc.Service{
|
||||
@@ -64,6 +64,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
}
|
||||
homeSvc := &homesvc.Service{
|
||||
Repo: &repository.HomeToolsRepo{Pool: pool},
|
||||
Tips: &repository.HomeDailyTipsRepo{Pool: pool},
|
||||
Profiles: profileRepo,
|
||||
LLM: llm,
|
||||
}
|
||||
@@ -71,7 +72,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
adminSvc := &adminsvc.Service{
|
||||
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{
|
||||
Username: cfg.Admin.BootstrapUsername,
|
||||
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) {
|
||||
response.OK(c, gin.H{"pong": true})
|
||||
})
|
||||
(&handler.MediaHandler{AvatarDir: "data/avatars"}).Register(api)
|
||||
(&handler.AdminHandler{Svc: adminSvc, Analytics: analyticsSvc}).Register(api)
|
||||
|
||||
authed := api.Group("")
|
||||
@@ -109,7 +111,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
(&handler.AskHandler{Svc: askSvc}).Register(gated)
|
||||
(&handler.CompanionHandler{Svc: companionSvc}).Register(gated)
|
||||
(&handler.ImageCardHandler{Svc: imageCardSvc}).Register(gated)
|
||||
(&handler.ExploreHandler{}).Register(gated)
|
||||
(&handler.ExploreHandler{Scales: scaleSvc}).Register(gated)
|
||||
(&handler.GrowthHandler{
|
||||
Plans: &repository.GrowthRepo{Pool: pool},
|
||||
}).Register(gated)
|
||||
|
||||
@@ -43,3 +43,49 @@ func TestGrowthPlanCheckin(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ func Build(birth time.Time, displayName string) Output {
|
||||
bz := getBazi(y, int(m), d, 12)
|
||||
pct := calc5E(bz)
|
||||
strong := strongestEl(pct)
|
||||
weak := weakestEl(pct)
|
||||
cst := constitutionByElement[strong]
|
||||
emo := emotionByElement[strong]
|
||||
|
||||
@@ -68,16 +67,7 @@ func Build(birth time.Time, displayName string) Output {
|
||||
"lock_teaser_wuxing": "体质特征、调养要点、易感倾向、食疗与作息节律,解锁后查看完整方案。",
|
||||
"pattern_key": main,
|
||||
"pyramid": tri.PyramidMap(),
|
||||
"wuxing": map[string]any{
|
||||
"bars": wuxingBars(pct),
|
||||
"primary": cst.Primary,
|
||||
"strong": cst.Strong,
|
||||
"weak": cst.Weak,
|
||||
"strong_el": strong,
|
||||
"weak_el": weak,
|
||||
"emotion": emo.Strength,
|
||||
"care_dir": "养" + cst.Strong + " · 需呵护" + cst.Weak,
|
||||
},
|
||||
"wuxing": WuxingBlock(birth),
|
||||
"today_tip": tip,
|
||||
"dimensions": dims,
|
||||
"strengths_preview": []string{
|
||||
|
||||
@@ -115,6 +115,27 @@ func wuxingBars(p map[string]int) []map[string]any {
|
||||
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 {
|
||||
st := companion.TodaySolar(now)
|
||||
el := seasonEl[int(now.Month())-1]
|
||||
|
||||
@@ -21,38 +21,35 @@ type AccountRow struct {
|
||||
Phone string
|
||||
PasswordHash string
|
||||
Nickname string
|
||||
AvatarURL string
|
||||
Status string
|
||||
}
|
||||
|
||||
// GetByPhone loads a registered user by phone.
|
||||
func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) {
|
||||
row := &AccountRow{}
|
||||
var nick *string
|
||||
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
|
||||
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) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nick != nil {
|
||||
row.Nickname = *nick
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetAccount loads account fields for a user id.
|
||||
func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRow, error) {
|
||||
row := &AccountRow{}
|
||||
var phone, hash, nick *string
|
||||
var phone, hash *string
|
||||
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,
|
||||
).Scan(&row.ID, &phone, &hash, &nick, &row.Status)
|
||||
).Scan(&row.ID, &phone, &hash, &row.Nickname, &row.AvatarURL, &row.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,9 +59,6 @@ func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRo
|
||||
if hash != nil {
|
||||
row.PasswordHash = *hash
|
||||
}
|
||||
if nick != nil {
|
||||
row.Nickname = *nick
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
@@ -105,6 +99,14 @@ func (r *AuthRepo) UpdateNickname(ctx context.Context, userID uuid.UUID, nicknam
|
||||
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).
|
||||
func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error {
|
||||
_, 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)
|
||||
}
|
||||
|
||||
// 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).
|
||||
func (r *ReportRepo) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep := &model.GrowthReport{}
|
||||
|
||||
@@ -28,6 +28,8 @@ type ScaleDetail struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Access string `json:"access,omitempty"` // free | membership
|
||||
Locked bool `json:"locked,omitempty"`
|
||||
Questions []ScaleQuestion `json:"questions"`
|
||||
}
|
||||
|
||||
@@ -171,6 +173,24 @@ func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID u
|
||||
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.
|
||||
func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ package rhythm
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/companion"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
|
||||
)
|
||||
|
||||
// Output free summary + gated detail.
|
||||
@@ -14,8 +17,13 @@ type Output struct {
|
||||
|
||||
var elements = []string{"木", "火", "土", "金", "水"}
|
||||
|
||||
// Build from birth date.
|
||||
// Build from birth date (asOf = now).
|
||||
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
|
||||
if name == "" {
|
||||
name = "你"
|
||||
@@ -36,9 +44,20 @@ func Build(birth time.Time, displayName string) Output {
|
||||
{"key": "mood", "title": "情绪调节", "teaser": tip.Mood, "score": 71},
|
||||
}
|
||||
|
||||
todayTip := tip.Sleep
|
||||
weekday := int(time.Now().Weekday())
|
||||
when := asOf
|
||||
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}
|
||||
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{
|
||||
"title": "身心节律·基础",
|
||||
"style_label": primary + "倾向",
|
||||
@@ -48,12 +67,20 @@ func Build(birth time.Time, displayName string) Output {
|
||||
"life_tip": tip.WeekTip,
|
||||
"today_tip": todayTip,
|
||||
"week_focus": weekHints[weekday%len(weekHints)],
|
||||
"keywords": []string{primary, secondary, "生活建议", "节律"},
|
||||
"dimensions": dims,
|
||||
"primary_element": primary,
|
||||
"secondary_element": secondary,
|
||||
"strengths_preview": tip.Strengths[:min(3, len(tip.Strengths))],
|
||||
"blind_spots_preview": []string{"完整习惯方案见深度版。"},
|
||||
"as_of": dayKey,
|
||||
"valid_until": validUntil.Format(time.RFC3339),
|
||||
"wuxing": wx,
|
||||
"solar_term": map[string]any{
|
||||
"name": st.Name,
|
||||
"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{
|
||||
@@ -65,8 +92,8 @@ func Build(birth time.Time, displayName string) Output {
|
||||
{"title": "活动与放松", "body": tip.MoveDeep, "bullets": tip.MoveBullets},
|
||||
{"title": "情绪与压力", "body": tip.MoodDeep, "bullets": tip.MoodBullets},
|
||||
{"title": "与节气联动", "body": "可结合「节气生活」调整当季节奏;变化慢一点,观察身体反馈即可。", "bullets": []string{"查看今日节气", "一次只改一个习惯", "不适就医,勿自行当治疗"}},
|
||||
{"title": "今日生活建议", "body": tip.Sleep + " " + tip.Mood, "bullets": []string{tip.Move, tip.Diet}},
|
||||
{"title": "本周节奏", "body": tip.WeekTip, "bullets": tip.OverviewBullets},
|
||||
{"title": "今日生活建议", "body": todayTip + " " + tip.Mood, "bullets": []string{tip.Move, tip.Diet}},
|
||||
{"title": "本周节奏", "body": weekHints[weekday%len(weekHints)], "bullets": tip.OverviewBullets},
|
||||
},
|
||||
"strengths": tip.Strengths,
|
||||
"blind_spots": tip.BlindSpots,
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
package rhythm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuild(t *testing.T) {
|
||||
out := Build(time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC), "我")
|
||||
if out.Summary["primary_element"] == nil {
|
||||
t.Fatal("missing element")
|
||||
func TestBuildIncludesWuxingBars(t *testing.T) {
|
||||
birth := time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC)
|
||||
out := BuildWith(birth, "测", time.Date(2026, 8, 12, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)))
|
||||
wx, ok := out.Summary["wuxing"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("missing wuxing")
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
for _, bad := range []string{"运势", "吉凶", "算命", "疗效", "治病"} {
|
||||
if strings.Contains(string(raw), bad) {
|
||||
t.Fatalf("forbidden %q", bad)
|
||||
bars, ok := wx["bars"].([]map[string]any)
|
||||
if !ok || len(bars) != 5 {
|
||||
t.Fatalf("bars=%#v", wx["bars"])
|
||||
}
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 1–5 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
|
||||
}
|
||||
@@ -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,11 +32,43 @@ func resultPack(slug, key, fallbackLabel string) pack {
|
||||
switch slug {
|
||||
case "emotion-pattern":
|
||||
return emotionPack(key, fallbackLabel)
|
||||
case "mbti-lite":
|
||||
return mbtiPack(key, fallbackLabel)
|
||||
default:
|
||||
if key == "high" || key == "mid" || key == "low" {
|
||||
return bankExplorePack(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 {
|
||||
switch key {
|
||||
case "A":
|
||||
|
||||
@@ -40,3 +40,45 @@ func EmotionLabels() map[string]string {
|
||||
"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": "活力分享者",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package scale
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScoreMajority_communication(t *testing.T) {
|
||||
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) {
|
||||
_, label := ScoreMajority(nil, CommunicationLabels(), "平衡探索型")
|
||||
if label != "平衡探索型" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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": {},
|
||||
"star_wheel_viewed": {}, "companion_viewed": {}, "mood_saved": {},
|
||||
"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{}{
|
||||
@@ -53,9 +62,11 @@ var allowedPropKeys = map[string]struct{}{
|
||||
"element_id": {}, "exit_page": {}, "duration_ms": {}, "cold": {}, "app_ver": {},
|
||||
"source": {}, "kind": {}, "surface": {}, "label": {}, "plan": {}, "count": {},
|
||||
"depth": {}, "scene": {}, "score": {}, "planet": {}, "report_id": {},
|
||||
"slug": {}, "category": {}, "reason": {}, "access": {},
|
||||
}
|
||||
|
||||
var funnelDefault = []string{
|
||||
"scale_list_viewed", "scale_started", "scale_completed",
|
||||
"portrait_completed", "deep_access_clicked", "purchase_completed",
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
// 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")
|
||||
}
|
||||
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
|
||||
}
|
||||
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.
|
||||
func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, content string) (*SendResult, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, errors.New("content required")
|
||||
}
|
||||
if len([]rune(content)) > 2000 {
|
||||
return nil, errors.New("content too long")
|
||||
var err error
|
||||
content, err = textsafe.Check(textsafe.AskContent, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 {
|
||||
return errors.New("emit required")
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return errors.New("content required")
|
||||
}
|
||||
if len([]rune(content)) > 2000 {
|
||||
return errors.New("content too long")
|
||||
var err error
|
||||
content, err = textsafe.Check(textsafe.AskContent, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,21 +13,25 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
|
||||
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/textsafe"
|
||||
)
|
||||
|
||||
// Service handles register/login sessions.
|
||||
// Temporary open mode: any non-empty phone+password can enter; missing accounts are created.
|
||||
type Service struct {
|
||||
Repo *repository.AuthRepo
|
||||
Repo *repository.AuthRepo
|
||||
AvatarDir string
|
||||
}
|
||||
|
||||
// Me is the public account payload.
|
||||
type Me struct {
|
||||
ID string `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
Nickname string `json:"nickname"`
|
||||
ID string `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
// SessionResult is returned after register/login.
|
||||
@@ -34,6 +39,7 @@ type SessionResult struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
User Me `json:"user"`
|
||||
IsNew bool `json:"is_new"`
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -73,7 +86,7 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
|
||||
nick = nickgen.Random()
|
||||
_ = 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) {
|
||||
return nil, err
|
||||
@@ -99,18 +112,23 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
|
||||
if deviceKey != "" {
|
||||
_ = 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 (1–16 chars).
|
||||
func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick string) (*Me, error) {
|
||||
nick = nickgen.Normalize(nick)
|
||||
if nick == "" {
|
||||
return nil, errors.New("请填写昵称")
|
||||
nick, err := textsafe.Check(textsafe.Nickname, nick)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.Repo.UpdateNickname(ctx, userID, nick); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -118,7 +136,18 @@ func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick str
|
||||
if acc.Phone == "" {
|
||||
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
|
||||
@@ -141,7 +170,7 @@ func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*Me, error) {
|
||||
if acc.Phone == "" {
|
||||
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.
|
||||
@@ -154,18 +183,32 @@ func (s *Service) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, err
|
||||
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)
|
||||
exp := time.Now().Add(30 * 24 * time.Hour)
|
||||
if err := s.Repo.CreateSession(ctx, userID, tok, exp); err != nil {
|
||||
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{
|
||||
Token: tok, ExpiresAt: exp,
|
||||
User: Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname},
|
||||
User: *me,
|
||||
IsNew: isNew,
|
||||
}, 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 {
|
||||
if len(p) != 11 {
|
||||
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)
|
||||
}
|
||||
|
||||
now := time.Now().In(time.FixedZone("CST", 8*3600))
|
||||
outS, err := star.BuildWith(star.BuildOpts{
|
||||
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
|
||||
AsOf: now,
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
det, _ = json.Marshal(outR.Detail)
|
||||
if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "rhythm", sum, det); err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package companion
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
core "github.com/yuxingu/digital-psychology/apps/api/internal/companion"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
// 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")
|
||||
}
|
||||
if in.Note != nil {
|
||||
n := strings.TrimSpace(*in.Note)
|
||||
if len([]rune(n)) > 200 {
|
||||
return nil, errors.New("note too long")
|
||||
n, err := textsafe.Check(textsafe.Note, *in.Note)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == "" {
|
||||
in.Note = nil
|
||||
} else {
|
||||
in.Note = &n
|
||||
}
|
||||
in.Note = &n
|
||||
}
|
||||
day := time.Now()
|
||||
if in.Day != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/yijing"
|
||||
)
|
||||
|
||||
@@ -33,58 +34,131 @@ type DailyTips struct {
|
||||
Source string `json:"source"` // llm | fallback
|
||||
GuaName string `json:"gua_name,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"`
|
||||
}
|
||||
|
||||
type tipsCacheEntry struct {
|
||||
type tipsMemEntry struct {
|
||||
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) {
|
||||
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)
|
||||
if !ok {
|
||||
fb := fallbackTips(now, "", nil)
|
||||
fb.NeedBirth = true
|
||||
if !ok || userID == uuid.Nil {
|
||||
fb := decorateTips(fallbackTips(now, "", nil), win, asOf, true)
|
||||
return fb, nil
|
||||
}
|
||||
|
||||
birth := self.BirthDate.Format("2006-01-02")
|
||||
yc := yijing.Seed(birth, self.BirthTime, now)
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s:%d", userID, birth, now.Format("2006-01-02"), now.Hour()/3)
|
||||
if v, hit := tipsCache.Load(cacheKey); hit {
|
||||
e := v.(tipsCacheEntry)
|
||||
if time.Now().Before(e.exp) {
|
||||
out := e.tips
|
||||
return &out, nil
|
||||
key := tipsCacheKey(userID, win.Start)
|
||||
|
||||
if v, hit := tipsMem.Load(key); hit {
|
||||
out := v.(tipsMemEntry).tips
|
||||
return decorateTips(&out, win, asOf, false), 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() {
|
||||
fb := fallbackTips(now, birth, self.BirthTime)
|
||||
fb.GuaName = yc.GuaName
|
||||
fb.DayPart = yc.DayPart
|
||||
return fb, nil
|
||||
}
|
||||
// Instant path: deterministic fallback, warm LLM in background.
|
||||
fb := fallbackTips(now, birth, self.BirthTime)
|
||||
out := decorateTips(fb, win, asOf, false)
|
||||
tipsMem.Store(key, tipsMemEntry{tips: *out})
|
||||
s.persistTips(userID, win, out)
|
||||
s.warmLLMAsync(userID, birth, self.BirthTime, win, asOf, key)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
tips, err := s.generateLLMTips(ctx, yc)
|
||||
if err != nil {
|
||||
log.Printf("home daily-tips llm: %v", err)
|
||||
fb := fallbackTips(now, birth, self.BirthTime)
|
||||
fb.GuaName = yc.GuaName
|
||||
fb.DayPart = yc.DayPart
|
||||
return fb, nil
|
||||
func decorateTips(t *DailyTips, win ShichenWindow, asOf string, needBirth bool) *DailyTips {
|
||||
cp := *t
|
||||
cp.AsOf = asOf
|
||||
cp.Shichen = win.Index
|
||||
cp.ShichenName = win.Name + "时"
|
||||
cp.ValidUntil = win.End.Format(time.RFC3339)
|
||||
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"
|
||||
tips.GuaName = yc.GuaName
|
||||
tips.DayPart = yc.DayPart
|
||||
tips.NeedBirth = false
|
||||
tipsCache.Store(cacheKey, tipsCacheEntry{tips: *tips, exp: now.Add(45 * time.Minute)})
|
||||
return tips, nil
|
||||
raw, err := json.Marshal(tips)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
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) {
|
||||
@@ -103,17 +177,22 @@ func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profil
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context) (*DailyTips, error) {
|
||||
sys := `你是愈心谷的生活节律助手。根据用户出生信息与当前时刻的易经卦象种子,给出温和的生活建议。
|
||||
func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context, now time.Time, win ShichenWindow) (*DailyTips, error) {
|
||||
weekday := []string{"日", "一", "二", "三", "四", "五", "六"}[now.Weekday()]
|
||||
sys := `你是愈心谷的生活节律助手。根据用户出生信息与当前时辰的易经卦象种子,给出温和的生活建议。
|
||||
硬性要求:
|
||||
- 禁止占卜/算命/改命恐吓/吉凶祸福话术
|
||||
- 禁止医疗疗效承诺
|
||||
- 用探索向、身心节律语气;可参考卦象意象,不要说「必有」类断言
|
||||
- 文案必须贴合「此刻时辰」与日期,体现与上一时辰不同的节律侧重
|
||||
- 只输出 JSON,不要 markdown:
|
||||
{"clothing_index":70,"clothing":"一句穿衣建议","color_note":"一句颜色说明","palette":[{"name":"色名","hex":"#RRGGBB"},{"name":"色名","hex":"#RRGGBB"}],"wellness":"一句养生建议"}
|
||||
- clothing_index 为 55–95 整数;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{
|
||||
{Role: "system", Content: sys},
|
||||
{Role: "user", Content: user},
|
||||
@@ -181,12 +260,20 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
|
||||
if birth == "" {
|
||||
yc = yijing.Seed(now.Format("2006-01-02"), nil, now)
|
||||
}
|
||||
win := CurrentShichen(now)
|
||||
palettes := [][]ColorChip{
|
||||
{{Name: "米白", Hex: "#F5F0E8"}, {Name: "雾霾蓝", Hex: "#A8C4D8"}},
|
||||
{{Name: "燕麦色", Hex: "#E8DCC8"}, {Name: "浅杏", Hex: "#F0C9A8"}},
|
||||
{{Name: "浅灰", Hex: "#D8D6D4"}, {Name: "雾粉", Hex: "#E8B8C4"}},
|
||||
{{Name: "象牙白", Hex: "#F7F4EC"}, {Name: "鼠尾草", Hex: "#A8C4B0"}},
|
||||
{{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{
|
||||
"轻薄透气更舒服,外套可备一件薄衫应付温差。",
|
||||
@@ -194,18 +281,41 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
|
||||
"上下同色系更省心,再加一件小配饰点睛即可。",
|
||||
"宽松剪裁更自在,适合慢节奏出门与散步。",
|
||||
"内里干净简约,外搭一件有质感的外套就够。",
|
||||
"今日宜层搭:薄内搭+透气外衫,方便随时增减。",
|
||||
"选垂感面料更显松弛,走路也会轻一点。",
|
||||
"少用厚重配饰,让肩颈更轻松。",
|
||||
"浅色上衣更提气色,下装选舒适脚感即可。",
|
||||
"风大时加一件防风薄外套,比厚羽绒服更灵活。",
|
||||
"本时辰适合干净线条,少印花更省心。",
|
||||
"运动鞋或软底鞋更友好,站久也不累。",
|
||||
}
|
||||
notes := []string{
|
||||
"清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。",
|
||||
"安静又有呼吸感。", "干净利落,好搭配。", "此刻偏清透,层次更轻。",
|
||||
"暖调一点点,气色更稳。", "冷暖各一,对比柔和。", "低饱和更耐看。",
|
||||
"偏自然色,贴近户外光线。", "雾感配色,不刺眼。", "柔对比,适合慢节奏。",
|
||||
}
|
||||
notes := []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{
|
||||
ClothingIndex: 62 + (yc.GuaIndex % 31),
|
||||
ClothingIndex: clothingIndexFromSeed(yc, now, win.Index),
|
||||
Clothing: clothing[i],
|
||||
ColorNote: notes[i],
|
||||
Palette: palettes[i],
|
||||
@@ -213,5 +323,6 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
|
||||
Source: "fallback",
|
||||
GuaName: yc.GuaName,
|
||||
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.
|
||||
type Service struct {
|
||||
Repo *repository.HomeToolsRepo
|
||||
Tips *repository.HomeDailyTipsRepo
|
||||
Profiles *repository.ProfileRepo
|
||||
LLM *deepseek.Client
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package home
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeToolOK(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:00–01: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,
|
||||
}, 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/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
// Service manages personal archives.
|
||||
@@ -58,6 +59,10 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
if name == "" {
|
||||
name = cur.DisplayName
|
||||
} else {
|
||||
var err error
|
||||
name, err = textsafe.Check(textsafe.DisplayName, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
birth := in.BirthDate
|
||||
if birth.IsZero() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
// 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 == "" {
|
||||
name = "TA"
|
||||
}
|
||||
name, err = textsafe.Check(textsafe.DisplayName, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, err := s.Profiles.GetByID(ctx, inv.HostProfileID)
|
||||
if err != nil {
|
||||
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{
|
||||
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
|
||||
AsOf: time.Now().In(time.FixedZone("CST", 8*3600)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -212,7 +218,7 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
|
||||
if err != nil {
|
||||
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)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
rep, err := s.Reports.GetLatest(ctx, userID, profileID, typ, peer)
|
||||
if err != nil {
|
||||
return nil, errors.New("report not found")
|
||||
}
|
||||
rep, _ = s.refreshTemporalIfNeeded(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) {
|
||||
rep, err := s.Reports.GetForUser(ctx, userID, reportID)
|
||||
if err != nil {
|
||||
return nil, errors.New("report not found")
|
||||
}
|
||||
rep, _ = s.refreshTemporalIfNeeded(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")
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,23 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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 探索测试.
|
||||
type Service struct {
|
||||
Repo *repository.ScaleRepo
|
||||
Profiles *repository.ProfileRepo
|
||||
Repo *repository.ScaleRepo
|
||||
Profiles *repository.ProfileRepo
|
||||
Membership MembershipChecker
|
||||
}
|
||||
|
||||
// List returns published scales.
|
||||
@@ -22,12 +32,42 @@ func (s *Service) List(ctx context.Context) ([]repository.ScaleListItem, error)
|
||||
return s.Repo.ListPublished(ctx)
|
||||
}
|
||||
|
||||
// Get returns scale detail.
|
||||
func (s *Service) Get(ctx context.Context, slug string) (*repository.ScaleDetail, error) {
|
||||
// Get returns scale detail; bank slugs served from curated embed; mbti-full may be locked.
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -43,19 +83,58 @@ type SubmitResult struct {
|
||||
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) {
|
||||
if _, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID); err != nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
scaleID, err := s.Repo.ScaleIDBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
if slug == "mbti-full" {
|
||||
ok := false
|
||||
if s.Membership != nil {
|
||||
ok, _ = s.Membership.IsActive(ctx, userID)
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrMembershipRequired
|
||||
}
|
||||
}
|
||||
|
||||
labels, _, _ := labelsForSlug(slug)
|
||||
key, label := sc.ScoreMajority(in.Answers, labels, "平衡探索型")
|
||||
result := sc.BuildResult(slug, key, label)
|
||||
var result map[string]interface{}
|
||||
var scaleID uuid.UUID
|
||||
|
||||
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)
|
||||
resJSON, _ := json.Marshal(result)
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
switch slug {
|
||||
case "emotion-pattern":
|
||||
return sc.EmotionLabels(),
|
||||
"我的情感模式:",
|
||||
"这是你当前情绪调节偏好的探索结果,可用于自我了解与日常调整,不是固定标签。"
|
||||
case "mbti-lite":
|
||||
return map[string]string{"E": "外向充能型", "I": "内向充能型", "T": "理性决策型", "F": "感受决策型", "J": "结构安排型", "P": "弹性探索型"},
|
||||
"我的人格偏好:", "轻量人格偏好探索,不是固定分类。"
|
||||
case "enneagram-lite":
|
||||
return map[string]string{"A": "尽责驱动型", "B": "联结驱动型", "C": "独立驱动型"},
|
||||
"我的动机模式:", "内在动机轻量探索,用于自我觉察。"
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -66,11 +66,15 @@ func BuildWith(opts BuildOpts) (Output, error) {
|
||||
|
||||
asOf := opts.AsOf
|
||||
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)
|
||||
daily := fort.Daily
|
||||
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))
|
||||
for _, p := range chart.Planets {
|
||||
@@ -121,6 +125,8 @@ func BuildWith(opts BuildOpts) (Output, error) {
|
||||
"title": daily.Title, "focus": daily.Focus, "tip": daily.Tip,
|
||||
"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))],
|
||||
"blind_spots_preview": []string{"完整相位与年运详解见深度版。"},
|
||||
"interaction_tags": []string{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,12 @@ func TestSeedStableSameInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedChangesWithHour(t *testing.T) {
|
||||
now1 := time.Date(2026, 8, 11, 9, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
now2 := time.Date(2026, 8, 11, 18, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
a := Seed("1990-05-12", nil, now1)
|
||||
b := Seed("1990-05-12", nil, now2)
|
||||
if a.DayPart == b.DayPart {
|
||||
t.Fatalf("expected different day parts, both %s", a.DayPart)
|
||||
func TestSeedChangesWithDay(t *testing.T) {
|
||||
d1 := time.Date(2026, 8, 11, 19, 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, d1)
|
||||
b := Seed("1990-05-12", nil, d2)
|
||||
if a.GuaIndex == b.GuaIndex && a.Line == b.Line {
|
||||
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);
|
||||
@@ -100,7 +100,8 @@ test('star / rhythm / cards pages render with mocked API', async ({ page }) => {
|
||||
await expect(page.getByText('E2E节律')).toBeVisible()
|
||||
|
||||
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.locator('input[type="number"]').nth(0).fill('1990')
|
||||
await page.locator('input[type="number"]').nth(1).fill('5')
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/node": "^26.2.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"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 |
@@ -1,34 +1,12 @@
|
||||
<template>
|
||||
<div class="app-shell" :class="{ 'is-immersive': immersive }">
|
||||
<AppHeader v-if="!immersive" />
|
||||
<div class="app-shell">
|
||||
<AppHeader />
|
||||
<router-view />
|
||||
<TabBar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppHeader from './components/AppHeader.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>
|
||||
|
||||
<style scoped>
|
||||
.app-shell.is-immersive {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,74 @@
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="side left">
|
||||
<BackButton v-if="showBack" />
|
||||
</div>
|
||||
<router-link to="/" class="logo-link" aria-label="愈心谷首页">
|
||||
<BrandLogo size="header" />
|
||||
<BrandLogo size="header" class="logo" />
|
||||
</router-link>
|
||||
<div class="side right" aria-hidden="true" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BackButton from './BackButton.vue'
|
||||
import BrandLogo from './BrandLogo.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
/** 首页 / 我的 为 Tab 根页不显示返回;探索可从首页进入,显示返回 */
|
||||
const showBack = computed(() => {
|
||||
const p = route.path
|
||||
return !(p === '/' || p === '/mine')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-header{
|
||||
position:sticky;top:0;z-index:50;
|
||||
display:flex;justify-content:center;align-items:center;
|
||||
padding:10px 16px 6px;
|
||||
background:linear-gradient(180deg, rgba(255,209,199,.92) 0%, rgba(255,209,199,.55) 70%, transparent 100%);
|
||||
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
|
||||
.app-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
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(.brand-logo){
|
||||
width:min(120px,42vw);
|
||||
background:transparent !important;
|
||||
mix-blend-mode:normal;
|
||||
.logo-link :deep(.brand-logo) {
|
||||
width: min(148px, 48vw);
|
||||
max-height: 36px;
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<button class="back" type="button" @click="go">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="label">返回</span>
|
||||
<button class="back" type="button" aria-label="返回" @click="go">
|
||||
<svg class="chev" width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M11.25 3.75 6 9l5.25 5.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -21,15 +24,32 @@ function go() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.back{
|
||||
display:inline-flex;align-items:center;gap:4px;
|
||||
border:none;background:rgba(255,255,255,.78);color:#333;
|
||||
border-radius:var(--radius-sm);
|
||||
padding:8px 12px 8px 10px;margin:0 0 12px;font-size:14px;font-weight:500;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.05);cursor:pointer;
|
||||
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
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>
|
||||
|
||||
@@ -205,6 +205,35 @@
|
||||
<circle cx="38" cy="26" r="3" fill="#5BA8F5" />
|
||||
</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">
|
||||
<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>
|
||||
@@ -214,20 +243,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import type { HomeToolIconName } from '../lib/homeToolIconName'
|
||||
|
||||
export type HomeToolIconName =
|
||||
| 'mbti'
|
||||
| 'star'
|
||||
| 'portrait'
|
||||
| 'rhythm'
|
||||
| 'synastry'
|
||||
| 'astro'
|
||||
| 'companion'
|
||||
| 'ask'
|
||||
| 'cards'
|
||||
| 'reports'
|
||||
| 'growth'
|
||||
| 'relation'
|
||||
export type { HomeToolIconName }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@@ -42,23 +42,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
meStyle: string
|
||||
otherStyle: string
|
||||
fitLabel?: string
|
||||
harmony?: number | null
|
||||
love?: number | null
|
||||
friend?: number | null
|
||||
marriage?: number | null
|
||||
starCompare?: { key: string; title: string; me: string; other: string; note?: string }[]
|
||||
dimensions?: {
|
||||
key: string
|
||||
title: string
|
||||
me_score?: number
|
||||
other_score?: number
|
||||
note?: string
|
||||
}[]
|
||||
}>()
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
meStyle: string
|
||||
otherStyle: string
|
||||
fitLabel?: string
|
||||
harmony?: number | null
|
||||
love?: number | null
|
||||
friend?: number | null
|
||||
marriage?: number | null
|
||||
starCompare?: { key: string; title: string; me: string; other: string; note?: string }[]
|
||||
dimensions?: {
|
||||
key: string
|
||||
title: string
|
||||
me_score?: number
|
||||
other_score?: number
|
||||
note?: string
|
||||
}[]
|
||||
}>(),
|
||||
{
|
||||
starCompare: () => [],
|
||||
dimensions: () => [],
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -35,6 +35,7 @@ withDefaults(
|
||||
.ps {
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
padding-top: 48px;
|
||||
padding-bottom: var(--spacing-xs);
|
||||
min-height: 100%;
|
||||
background:
|
||||
@@ -46,6 +47,7 @@ withDefaults(
|
||||
.ps-flat {
|
||||
background: transparent;
|
||||
min-height: 0;
|
||||
padding-top: 48px;
|
||||
}
|
||||
.atm {
|
||||
position: absolute;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div v-if="dimensions.length" class="card">
|
||||
<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">
|
||||
<strong>{{ d.title }}</strong>
|
||||
<span v-if="d.score != null" class="score">{{ d.score }}</span>
|
||||
|
||||
@@ -7,7 +7,19 @@
|
||||
class="item"
|
||||
: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>
|
||||
</router-link>
|
||||
</nav>
|
||||
@@ -17,13 +29,55 @@
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const items = [
|
||||
{ key: 'home', to: '/', icon: '⌂', label: '首页' },
|
||||
{ key: 'explore', to: '/explore', icon: '◆', label: '探索' },
|
||||
{ key: 'ask', to: '/ask', icon: '问', label: '问答', ask: true },
|
||||
{ key: 'companion', to: '/companion', icon: '♡', label: '陪伴' },
|
||||
{ key: 'mine', to: '/mine', icon: '◍', label: '我的' },
|
||||
] as const
|
||||
|
||||
type TabItem = {
|
||||
key: string
|
||||
to: string
|
||||
label: string
|
||||
paths: readonly string[]
|
||||
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 {
|
||||
const p = route.path
|
||||
@@ -33,7 +87,8 @@ function activeKey(): string {
|
||||
p.startsWith('/mine') ||
|
||||
p.startsWith('/profile') ||
|
||||
p.startsWith('/membership') ||
|
||||
p.startsWith('/reports')
|
||||
p.startsWith('/reports') ||
|
||||
p.startsWith('/login')
|
||||
) {
|
||||
return 'mine'
|
||||
}
|
||||
@@ -69,56 +124,76 @@ function isActive(key: string): boolean {
|
||||
width: 100%;
|
||||
max-width: var(--yxg-max-w);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 6px 0 calc(10px + env(safe-area-inset-bottom, 0));
|
||||
border-top: 1px solid var(--color-border);
|
||||
align-items: flex-end;
|
||||
padding: 8px 4px calc(10px + env(safe-area-inset-bottom, 0));
|
||||
border-top: 1px solid rgba(240, 230, 224, 0.9);
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-nav);
|
||||
box-shadow: 0 -6px 24px rgba(160, 90, 70, 0.06);
|
||||
}
|
||||
.item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 2px 0;
|
||||
letter-spacing: 0.02em;
|
||||
transition: color var(--duration-fast) ease;
|
||||
gap: 4px;
|
||||
color: #9a8f8b;
|
||||
padding: 2px 0 0;
|
||||
letter-spacing: 0.04em;
|
||||
transition:
|
||||
color var(--duration-fast) ease,
|
||||
transform var(--duration-fast) ease;
|
||||
}
|
||||
.item.on {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ni {
|
||||
font-size: 17px;
|
||||
line-height: 1.2;
|
||||
.item:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.ico-wrap {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.ico {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: block;
|
||||
}
|
||||
.lb {
|
||||
line-height: 1.2;
|
||||
line-height: 1.15;
|
||||
font-size: 11px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.item.ask .ni {
|
||||
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;
|
||||
.item.on .lb {
|
||||
font-weight: 700;
|
||||
margin-top: -14px;
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.38);
|
||||
}
|
||||
.item.ask.on {
|
||||
color: var(--color-primary);
|
||||
.item.ask .ico-wrap {
|
||||
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 {
|
||||
box-shadow: 0 8px 20px rgba(229, 77, 66, 0.45);
|
||||
.item.ask .ico {
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
:profiles="profiles"
|
||||
:profile-id="profileId"
|
||||
:self-label="selfLabel"
|
||||
@update:profile-id="$emit('update:profileId', $event)"
|
||||
@profile-change="$emit('profile-change')"
|
||||
@navigate="$emit('navigate', $event)"
|
||||
@@ -54,6 +55,7 @@ const props = defineProps<{
|
||||
bootError: string
|
||||
profiles: Profile[]
|
||||
profileId: string
|
||||
selfLabel?: string
|
||||
messages: AskMessage[]
|
||||
draft: string
|
||||
sending: boolean
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
:aria-selected="p.id === profileId"
|
||||
@click="pick(p.id)"
|
||||
>
|
||||
<span class="chip-mark">{{ p.relation === 'self' ? '我' : 'TA' }}</span>
|
||||
<span class="chip-name">{{ p.relation === 'self' ? '我的档案' : p.display_name || '未命名' }}</span>
|
||||
<span class="chip-mark">{{ p.relation === 'self' ? selfMark : 'TA' }}</span>
|
||||
<span class="chip-name">{{
|
||||
p.relation === 'self' ? selfLabel : p.display_name || '未命名'
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick">
|
||||
@@ -34,14 +36,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Profile } from '@yuxingu/types'
|
||||
import { askQuickTools } from '../../composables/useAskPage'
|
||||
|
||||
const props = defineProps<{
|
||||
profiles: Profile[]
|
||||
profileId: string
|
||||
selfLabel?: string
|
||||
}>()
|
||||
|
||||
const selfLabel = computed(() => (props.selfLabel || '').trim() || '我')
|
||||
const selfMark = computed(() => selfLabel.value.slice(0, 1) || '我')
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:profileId': [v: string]
|
||||
'profile-change': []
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<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">
|
||||
<BrandLogo size="card" class="av-logo" />
|
||||
<img class="av-img" :src="avatarSrc" alt="" />
|
||||
</span>
|
||||
<span class="av-name">自己</span>
|
||||
<span class="av-name">{{ selfLabel }}</span>
|
||||
</router-link>
|
||||
<button type="button" class="av-add" aria-label="添加档案" @click="$emit('add')">
|
||||
<span class="plus-dot" aria-hidden="true">+</span>
|
||||
@@ -18,9 +18,27 @@
|
||||
</template>
|
||||
|
||||
<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: [] }>()
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -67,16 +85,23 @@ defineEmits<{ add: [] }>()
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
border: 2px solid rgba(229, 77, 66, 0.35);
|
||||
}
|
||||
.av-logo {
|
||||
width: 22px !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
.av-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center 42%;
|
||||
display: block;
|
||||
}
|
||||
.av-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8a4a3a;
|
||||
line-height: 1;
|
||||
max-width: 3.2em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
.plus-dot {
|
||||
width: 40px;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
<section class="self-card reveal" style="--d: 80ms" data-track="home_self_card">
|
||||
<div class="self-head">
|
||||
<button type="button" class="who-btn" @click="$emit('open-profile')">
|
||||
{{ profileLabel }}
|
||||
<span class="who-name">{{ profileLabel }}</span>
|
||||
<span class="caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<span class="who-day">{{ !loading && tips.asOf ? `今日 ${tips.asOf.slice(5)}` : '' }}</span>
|
||||
<router-link class="more" to="/profile">更多</router-link>
|
||||
</div>
|
||||
|
||||
@@ -126,10 +127,11 @@ const ringOffset = computed(() => {
|
||||
box-shadow: var(--shadow-hero);
|
||||
}
|
||||
.self-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.who-btn {
|
||||
border: none;
|
||||
@@ -142,12 +144,29 @@ const ringOffset = computed(() => {
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
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 {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.self-head .more {
|
||||
justify-self: end;
|
||||
font-size: 12px;
|
||||
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>
|
||||
<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 class="quota">今日剩余 {{ quotaLabel }} · 场景 {{ scene }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||
|
||||
defineProps<{
|
||||
bootError: string
|
||||
quotaLabel: string
|
||||
@@ -24,31 +16,7 @@ defineProps<{
|
||||
<style scoped>
|
||||
.greet {
|
||||
text-align: center;
|
||||
padding: 8px 0 20px;
|
||||
}
|
||||
.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;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
.quota {
|
||||
font-size: 11px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user