Compare commits

24 Commits
Author SHA1 Message Date
jackyu66gitandCursor 7155b8b53a feat(api): 接入微信登录并原生实现咨询域(ECR-049/050)
小程序可在 Go 上完成微信手机号登录、测评、预约和下单,不再反代 Java。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 00:26:29 +08:00
jackyu66gitandCursor db4d118f9a fix(h5): 小程序 nh=1 模式保留子页返回按钮
原生顶栏隐藏 logo 顶栏时,非首页/我的仍显示 BackButton。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 04:43:07 +08:00
jackyu66gitandCursor fc53be22f8 fix(h5): 统一 nh=1 与 sbh 顶距逻辑,与 index.html 首屏一致
applyMpEmbedDom 在原生顶栏模式读取 session sbh;补充 nh+sbh 单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 04:21:52 +08:00
jackyu66gitandCursor e7290ed726 fix(h5): 首屏清 title + 桃色顶区对齐小程序 nav-bar(nh=1)
index.html 在 Vue 前清 document.title 并预应用 mp-native-header;
CSS 桃色顶区、隐藏 AppHeader,减轻黑字与渐变冲顶。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 04:09:12 +08:00
jackyu66git db3669c595 Revert "fix(h5): 小程序 nh=1 原生顶栏模式与 sbh 顶距(配 202608278)"
This reverts commit 580ba85b61.
2026-08-25 03:29:37 +08:00
jackyu66gitandCursor 580ba85b61 fix(h5): 小程序 nh=1 原生顶栏模式与 sbh 顶距(配 202608278)
H5 在 nh=1 时隐藏 AppHeader,支持 sbh 控制内容顶距,首屏 inline 预应用。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 03:24:42 +08:00
jackyu66gitandCursor 57687de360 feat(h5): 小程序原生顶栏 + 本地联调与一键部署
nh=1 隐藏 H5 顶栏并由 cover 承载 logo;H5 仅 4px 顶距避免重复留白。新增 dev:mp、deploy:h5 与联调文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:38:32 +08:00
jackyu66gitandCursor f4bd57ce1b fix(h5): 修复 WebView 顶栏 logo 与内容重叠
embed 模式 header 改文档流、移除负 margin;持久化 sbh 并在路由中保留,避免胶囊区与档案条重叠。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:22:39 +08:00
jackyu66git 9d4fb52d29 fix(h5): 小程序 WebView 通过 sbh 参数铺满刘海区
WebView 内 safe-area-inset-top 常为 0,改由小程序传入 statusBarHeight;
全屏洗底层上延并同步原生页背景色,修复刘海露白。
2026-08-25 01:18:16 +08:00
jackyu66gitandCursor 96292d0e95 fix(h5): 小程序 WebView 刘海区背景与顶栏安全区
Logo 下移 safe-area、embed 隐藏 HTML 标题;暖色渐变铺满刘海,页面背景上延与全屏洗底层对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:14:07 +08:00
jackyu66gitandCursor 635a83fe11 fix(h5): 修复小程序 WebView 内 logo 与备案号展示
Logo 改用 BASE_URL 路径并在 embed 模式始终显示顶部栏;备案号改为页面底部文档流,避免悬浮遮挡内容。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:04:18 +08:00
jackyu66gitandCursor ebc2d3ad80 docs: 补充 Ubuntu+MySQL 并存与 PostgreSQL 部署指南
DEPLOY-MINI-PROGRAM-H5 增加 PG 安装三种方式、Go API systemd、Checklist 与双库架构说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 00:46:58 +08:00
jackyu66gitandCursor 8d856fb56c feat(h5): 小程序 embed 模式与全站 ICP 备案页脚
H5 支持 ?mp=1 嵌入微信 WebView;user-h5/admin-h5 底部展示蜀ICP备2025140386号-2 并链至工信部;补充小程序+H5 部署说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 00:41:14 +08:00
jackyu66gitandCursor d33a25a663 docs(BD-2026-044): rhythm-config-write 标记 Implemented · Closed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 22:49:28 +08:00
jackyu66gitandCursor c693ac4bbc docs(ECR-048): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:51:42 +08:00
jackyu66gitandCursor e0f605558b feat(ECR-048): ReportTemplate 写面闭环并 Closed
growth.write POST/PUT · migration 000057 · 无新 C 端 · Loop STOP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:51:42 +08:00
jackyu66gitandCursor aa197b719b docs(ECR-047): Freeze/Review 收口 → FROZEN WAIT
单刀授权硬边界 · 禁自动 ECR-048 · origin 齐平后冻结

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:32:25 +08:00
jackyu66gitandCursor 580f5ea3f0 docs(ECR-047): Human authorize Closed(不含 push)
Unauthorized Scope 经 authorize ECR-047 Closed 纳入正式 Closed;Loop STOP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:21:54 +08:00
jackyu66gitandCursor 69cb99600f docs(ECR-047): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:14:21 +08:00
jackyu66gitandCursor 1db5fcc018 feat(ECR-047): FunnelDefinition 写面闭环并 Closed
growth.write POST/PUT · migration 000056 · 无新 C 端 · Loop STOP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:14:21 +08:00
jackyu66gitandCursor 258deb0184 docs(ECR-046): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 21:56:11 +08:00
jackyu66gitandCursor b3fe4363fd feat(ECR-046): ScaleDefinition 元数据写面闭环并 Closed
explore.write POST/PUT · create→draft · status 仍 ECR-008 · migration 000055 · Loop STOP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 21:56:11 +08:00
jackyu66gitandCursor fa8b0ba963 docs(ECR-045): TEST_REPORT 补 commit sha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 21:29:43 +08:00
jackyu66gitandCursor 0158036341 feat(ECR-045): ImageCardDeck 写面闭环并 Closed
复用 admin.explore.write、POST/PUT+审计、C端 GET /cards/decks、
H5 无 active 空态/失败回退;migration 000054。无牌面内容编辑。
ExploreConfig Loop STOP,禁自动 ECR-046。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 21:29:43 +08:00
162 changed files with 7939 additions and 105 deletions
+29
View File
@@ -0,0 +1,29 @@
# ADR-0008 — Go 为唯一用户后台,咨询域过渡反代
- Status: Accepted
- Date: 2026-09-14
- Tags: identity, miniprogram, consult, gateway
## Context
愈心谷 C 端同时打两套后台:咨询/测评在 Java + MySQL,愈心魔方在 Go + PostgreSQL。
`wx.login``code` 只能用一次,无法先后换两套 token。小程序需要单一身份与单一请求入口。
## Decision
1. **Go + PostgreSQL 是唯一用户身份源。** 微信 openid / 手机号写在 `users`
2. **Java 过渡期为被调方**Go 用 openid/phone 调内网 `issue-mini-app` 换 Java Redis token,并反代 `/app-api``/admin-api`
3. **咨询业务表本 ADR 不迁**;支付与档期另开 ECR。
4. **Admin 登录独立**,不走本决策。
## Consequences
- 小程序只配置 Go 域名(现网经 `/psy/api/` 反代)。
- 下线 Java 必须另开 ECR,并先迁支付。
- 历史魔方账密用户按手机号合并到 `wx_openid`
## Alternatives considered
- 只做 SSO、双库长期并存:否决(用户要求合库合服务)。
- 以 Java 吸收魔方:否决(栈已锁定 Go + PG)。
- 客户端双 token:否决(code 单次使用,且拖住前端统一接入)。
+8 -4
View File
@@ -53,10 +53,14 @@
| [ops-star-config-write.md](ops-star-config-write.md) | StarConfig **写面** | §7 | admin 写 · `GET /star/configs` | ExploreConfig · **ECR-043** | | [ops-star-config-write.md](ops-star-config-write.md) | StarConfig **写面** | §7 | admin 写 · `GET /star/configs` | ExploreConfig · **ECR-043** |
| [ops-rhythm-config.md](ops-rhythm-config.md) | ExploreConfig RhythmConfig(只读) | §7 | `/admin/explore/rhythm-configs*` | Ops · **ECR-036** | | [ops-rhythm-config.md](ops-rhythm-config.md) | ExploreConfig RhythmConfig(只读) | §7 | `/admin/explore/rhythm-configs*` | Ops · **ECR-036** |
| [ops-rhythm-config-write.md](ops-rhythm-config-write.md) | RhythmConfig **写面** | §7 | admin 写 · `GET /rhythm/configs` | ExploreConfig · **ECR-044** | | [ops-rhythm-config-write.md](ops-rhythm-config-write.md) | RhythmConfig **写面** | §7 | admin 写 · `GET /rhythm/configs` | ExploreConfig · **ECR-044** |
| [ops-image-card-deck.md](ops-image-card-deck.md) | ExploreConfig ImageCardDeck | §7 | `/admin/explore/image-card-decks*` | Ops · **ECR-037** | | [ops-image-card-deck.md](ops-image-card-deck.md) | ExploreConfig ImageCardDeck(只读) | §7 | `/admin/explore/image-card-decks*` | Ops · **ECR-037** |
| [ops-report-template.md](ops-report-template.md) | GrowthInsights ReportTemplate | §7 | `/admin/growth/report-templates*` | Ops · **ECR-038** | | [ops-image-card-deck-write.md](ops-image-card-deck-write.md) | ImageCardDeck **写面** | §7 | admin 写 · `GET /cards/decks` | ExploreConfig · **ECR-045** |
| [ops-funnel-definition.md](ops-funnel-definition.md) | GrowthInsights FunnelDefinition | §7 | `/admin/analytics/funnel-definitions*` | Ops · **ECR-039** | | [ops-report-template.md](ops-report-template.md) | GrowthInsights ReportTemplate(只读) | §7 | `/admin/growth/report-templates*` | Ops · **ECR-038** |
| [ops-scale-definition.md](ops-scale-definition.md) | ExploreConfig ScaleDefinition | §7 | `/admin/explore/scales*` | Ops · **ECR-040** | | [ops-report-template-write.md](ops-report-template-write.md) | ReportTemplate **写面** | §7 | admin POST/PUT · growth.write | GrowthInsights · **ECR-048** |
| [ops-funnel-definition.md](ops-funnel-definition.md) | GrowthInsights FunnelDefinition(只读) | §7 | `/admin/analytics/funnel-definitions*` | Ops · **ECR-039** |
| [ops-funnel-definition-write.md](ops-funnel-definition-write.md) | FunnelDefinition **写面** | §7 | admin POST/PUT · growth.write | GrowthInsights · **ECR-047** |
| [ops-scale-definition.md](ops-scale-definition.md) | ExploreConfig ScaleDefinition(只读) | §7 | `/admin/explore/scales*` | Ops · **ECR-040** |
| [ops-scale-definition-write.md](ops-scale-definition-write.md) | ScaleDefinition **写面** | §7 | admin POST/PUT · status 仍 ECR-008 | ExploreConfig · **ECR-046** |
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
+13 -10
View File
@@ -10,13 +10,13 @@
| 字段 | 内容 | | 字段 | 内容 |
|---|---| |---|---|
| Name | 账号登录(手机号+密码 | | Name | 账号登录(小程序微信;H5 过渡账密 |
| Purpose | 跨设备持久用户身份;未登录不可生成/查看生日衍生结果 | | Purpose | 跨设备持久用户身份;未登录不可生成/查看生日衍生结果 |
| Business Goal | 数据归属清晰;为会员与深度版付费打底 | | Business Goal | 数据归属清晰;咨询与魔方同一人;为会员与深度版付费打底 |
| In | Out | | In | Out |
|---|---| |---|---|
| 注册 / 登录 / 登出 / me | 微信 OAuth、短信 OTP(后续) | | 小程序微信登录 / 登出 / me | 短信 OTP |
| 设备身份绑定到已注册账号 | 游客可看完整报告 | | 设备身份绑定到已注册账号 | 游客可看完整报告 |
| Bearer Session | 运营 Admin 登录(独立) | | Bearer Session | 运营 Admin 登录(独立) |
@@ -45,7 +45,7 @@
```text ```text
进入结果页或建档 进入结果页或建档
未登录? → /login 注册或登录 未登录? → 小程序 login-pop(微信)或 H5 /login(过渡账密)
Device 绑定到账号 User Device 绑定到账号 User
@@ -58,7 +58,8 @@ Device 绑定到账号 User
| 路由 | 页面 | | 路由 | 页面 |
|---|---| |---|---|
| `/login` | LoginPage注册/登录切换 | | `/login` | H5 LoginPage过渡账密;小程序已删除此页 |
| 小程序 `login-pop` | 微信手机号授权(唯一 C 端登录) |
| `/mine` | 展示账号手机号尾号 + 退出 | | `/mine` | 展示账号手机号尾号 + 退出 |
--- ---
@@ -77,8 +78,8 @@ Device 绑定到账号 User
| ID | Rule | | ID | Rule |
|---|---| |---|---|
| R1 | **临时开放** 任意非空手机号 + 任意密码均可登录;未注册则自动建号写入 DB | | R1 | **小程序** 微信 `code` + 手机号授权登录;按 `wx_openid` 命中,否则按手机号合并历史账密账号;未命中则建号 |
| R2 | 登录/注册统一走 OpenLogin;不校验历史密码,仍会更新 password_hash 记录本次输入 | | R2 | **H5 过渡:** OpenLogin(任意非空手机号+密码)仍可用,避免 H5 断服;小程序不再提供账密页。正式关掉账密须另改本 Spec |
| R3 | 登录成功后:签发 sessiondevice_identities.user_id 改绑到账号 | | R3 | 登录成功后:签发 sessiondevice_identities.user_id 改绑到账号 |
| R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 | | R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 |
| R5 | 登出作废 session;设备可再登录其他账号 | | R5 | 登出作废 session;设备可再登录其他账号 |
@@ -91,7 +92,7 @@ Device 绑定到账号 User
## 8. 数据模型影响 ## 8. 数据模型影响
- `users.phone` UNIQUE · `users.password_hash` · `users.nickname` · `users.avatar_url` - `users.phone` UNIQUE · `users.wx_openid` UNIQUE · `users.wx_unionid` · `users.java_platform_user_id` · `users.password_hash` · `users.nickname` · `users.avatar_url`
- `user_sessions(token, user_id, expires_at)` - `user_sessions(token, user_id, expires_at)`
- 本地文件:`data/avatars/{user_id}.{jpg|png|webp}`(进程相对路径) - 本地文件:`data/avatars/{user_id}.{jpg|png|webp}`(进程相对路径)
@@ -101,8 +102,9 @@ Device 绑定到账号 User
| Method | Path | 说明 | | Method | Path | 说明 |
|---|---|---| |---|---|---|
| POST | `/api/v1/auth/register` | 注册(可带 nickname | | POST | `/api/v1/auth/wechat` | 小程序微信登录 `{ code, encryptedData, iv }` |
| POST | `/api/v1/auth/login` | 登录 | | POST | `/api/v1/auth/register` | H5 过渡注册(可带 nickname |
| POST | `/api/v1/auth/login` | H5 过渡登录 |
| POST | `/api/v1/auth/logout` | 登出 | | POST | `/api/v1/auth/logout` | 登出 |
| GET | `/api/v1/auth/me` | 当前账号(含 avatar_url | | GET | `/api/v1/auth/me` | 当前账号(含 avatar_url |
| PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` | | PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` |
@@ -159,3 +161,4 @@ Device 绑定到账号 User
- [x] lexicon:登录/注册/账号 - [x] lexicon:登录/注册/账号
- [x] 不碰 Admin Auth - [x] 不碰 Admin Auth
- [x] 咨询域见 Spec `consult-miniprogram`ECR-050
@@ -0,0 +1,22 @@
# Feature Spec: 小程序咨询域(原生 Go)
> Status: `Active` · ECR-050
## 1. 功能定义
小程序首页/咨询/测评/订单/我的资料不再打 Java。Go 用 PostgreSQL 提供与原 `/psychic/*` 相同的字段合同。
## 2. Business Rules
| ID | Rule |
|---|---|
| C1 | 未登录可看 banner、资讯、测评列表、咨询师、档期、协议 |
| C2 | 登录后才能交卷、关注、下单、改资料、反馈 |
| C3 | 下单锁时段;取消未支付订单释放时段 |
| C4 | 金额单位:分;支付 openid = `users.wx_openid` |
| C5 | 测评仅单选计分,按分数区间匹配结果 |
| C6 | 协议路径保持历史拼写 `procotol` |
## 3. API
全部挂在 `/api/v1/psychic/*`,字段与原 Java App VO 一致(见 ECR-050 BD)。
@@ -0,0 +1,72 @@
# Feature Spec: GrowthInsights · FunnelDefinition 写面(Ops · ECR-047
> Status: `Active`**Implemented · ECR-047 Closed**
> Map: `§7` · Capability: `GrowthInsights` · BC: `Analytics_OpsB`
> Auth: `docs/WAVE0/ECR-047_WRITE_AUTHORIZATION.md`**仅 ECR-047**
> Predecessor: ECR-039 Closed(只读)· ECR-046 Closed
> 关联:[ops-funnel-definition.md](ops-funnel-definition.md)
---
## 1. 目标
运营可 **创建 / 更新 / 上下架** `FunnelDefinition`(漏斗目录);写鉴权 + 审计;**无**新 C 端路由。
**Closed 后立即 STOP**,不自动 ECR-048。
## 2. In / Out
| In | Out |
|----|-----|
| Admin POST/PUT `/admin/analytics/funnel-definitions*` | ReportTemplate 写 |
| 加法 `admin.growth.write` | SystemPrompt · Knowledge · Chunk |
| 复用表 `funnel_definitions` | soft-delete · 真支付 · UGC |
| 审计 | 题干 · Crisis/Handoff |
| | 自动 ECR-048 · 新 C 端路由 |
## 3. Domain
| 字段 | 规则 |
|------|------|
| `code` | 唯一;`^[a-z][a-z0-9_]{1,62}$``system=true` **不可改 code** |
| `title` | 必填 · ≤128 |
| `active` | `false` = 下架(唯一下架手段) |
| `system` | 种子行;禁止物理删除 |
## 4. API
| Method | Path | 权限 |
|--------|------|------|
| GET | `/admin/analytics/funnel-definitions` · `/{id}` | `admin.growth.read` |
| POST | `/admin/analytics/funnel-definitions` | `admin.growth.write` |
| PUT | `/admin/analytics/funnel-definitions/{id}` | `admin.growth.write` |
无 DELETE · 无新 C 端路径。
## 5. Permission / Migration
- Migration `000056_ops_funnel_definition_write`:授予 `super_admin``admin.growth.write`
- 编码时占号(Max=`000055``000056`
## 6. UI
admin-h5 漏斗定义页:列表 + 新建/编辑/上下架。
## 7. AC
| ID | Then |
|----|------|
| AC-F-01 | POST → list/get 可见 |
| AC-F-02 | PUT active=false → list 仍可见且 active=false |
| AC-F-03 | 重复 code → 409 |
| AC-S-01/02 | 401;仅 growth.read → POST 403 |
| AC-A-01 | 写产生审计 |
| AC-O-01 | 无 soft-delete · 无 ReportTemplate/Prompt 写路径 |
## 8. Closed 后
**立即 STOP**。ECR-048 须新 Candidate Review + Human 拍板。
## 9. Trace
ECR-047 · BD-2026-047 · CONTRACT_DIFF · ECR-047_WRITE_AUTHORIZATION
@@ -1,6 +1,7 @@
# Feature Spec: GrowthInsights · FunnelDefinitionOps · ECR-039 # Feature Spec: GrowthInsights · FunnelDefinitionOps · ECR-039
> Status: `Active`Loop continuous · **ECR-039 Closed** > Status: `Active`Loop continuous · **ECR-039 Closed**
> **写面:** [ops-funnel-definition-write.md](ops-funnel-definition-write.md)**ECR-047 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-038 Closed > Parent: WAVE0-FROZEN · Predecessor: ECR-038 Closed
> Capability: `GrowthInsights` · BC: `Analytics_OpsB` > Capability: `GrowthInsights` · BC: `Analytics_OpsB`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` > 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
@@ -0,0 +1,95 @@
# Feature Spec: ExploreConfig · ImageCardDeck 写面(Ops · ECR-045
> Status: `Active`**Implemented · ECR-045 Closed**
> Map: `§7` · Capability: `ExploreConfig` · BC: `Explore_Reports`
> Auth: `docs/WAVE0/ECR-045_WRITE_AUTHORIZATION.md`Human 批准 · **仅 ECR-045**
> Predecessor: ECR-037 Closed(只读)· ECR-044 ClosedRhythm 写)
> 关联:[ops-image-card-deck.md](ops-image-card-deck.md)
---
## 1. 目标
运营可 **创建 / 更新 / 上下架** `ImageCardDeck`(牌组目录);写操作鉴权 + 审计;C 端可读 active deck 并影响 `/cards` 可用性;失败回退「现网可抽」。
**Closed 后立即 STOP**,不自动 ECR-046。
## 2. In / Out
| In | Out |
|----|-----|
| Admin POST/PUT `/admin/explore/image-card-decks*` | **牌面/单卡内容 CRUD** · Star / Rhythm / Scale |
| 复用 `admin.explore.write` | SystemPrompt · Knowledge · Chunk |
| 复用表 `image_card_decks` | soft-delete · 真支付 · UGC |
| C 端 GET active decks | Crisis / Handoff 写 |
| `/cards`:无 active → 空态禁抽卡 CTA;失败 → 回退可抽 | 自动 ECR-046 |
| 审计 | 新权限子系统 |
## 3. Domain
| 字段 | 规则 |
|------|------|
| `code` | 唯一;`^[a-z][a-z0-9_]{1,62}$``system=true` 不可改 code |
| `title` | 必填 · ≤128 |
| `active` | `false` = 下架(唯一下架手段) |
| `system` | 种子行;禁止物理删除 |
## 4. API
### Admin
| Method | Path | 权限 |
|--------|------|------|
| GET | `/admin/explore/image-card-decks` · `/{id}` | `admin.explore.read` |
| POST | `/admin/explore/image-card-decks` | `admin.explore.write` |
| PUT | `/admin/explore/image-card-decks/{id}` | `admin.explore.write` |
无 DELETE。
### C 端
| Method | Path | Auth | 语义 |
|--------|------|------|------|
| GET | `/api/v1/cards/decks` | DeviceAuth | 仅 `active=true`;按 code 排序 |
## 5. C 端生效
| 条件 | user-h5 `/cards` |
|------|------------------|
| ≥1 active | 保持现网抽卡 |
| 成功且 0 active | 「意象牌组暂未开放」空态;禁用抽卡 CTA |
| 请求失败 | **回退**不阻断抽卡 |
## 6. Permission / Migration
- Migration `000054_ops_image_card_deck_write`:幂等确认 `super_admin``admin.explore.write`
- 编码时占号(Max=`000053`
## 7. UI
| 端 | 行为 |
|----|------|
| admin-h5 意象牌组页 | 列表 + 新建/编辑/上下架(仅 Deck) |
| user-h5 `/cards` | 按 §5 |
## 8. AC
| ID | Then |
|----|------|
| AC-F-01 | POST → list/get 可见 |
| AC-F-02 | PUT active=false → C 端不含 |
| AC-F-03 | 有 active → C 端非空 |
| AC-F-04 | 重复 code → 409 |
| AC-S-01/02 | 401;仅 read → POST 403 |
| AC-A-01 | 写产生审计 |
| AC-C-01 | 全下架后空态/禁 CTA |
| AC-C-02 | API 失败回退可抽 |
| AC-O-01 | 无牌面写路径 · 无 soft-delete |
## 9. Closed 后
**立即 STOP**。ECR-046 须新 Candidate Review + Human 拍板。
## 10. Trace
ECR-045 · BD-2026-045 · CONTRACT_DIFF · ECR-045_WRITE_AUTHORIZATION
@@ -1,6 +1,7 @@
# Feature Spec: ExploreConfig · ImageCardDeckOps · ECR-037 # Feature Spec: ExploreConfig · ImageCardDeckOps · ECR-037
> Status: `Active`Loop continuous · **ECR-037 Closed** > Status: `Active`Loop continuous · **ECR-037 Closed**
> **写面:** [ops-image-card-deck-write.md](ops-image-card-deck-write.md)**ECR-045 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-036 Closed > Parent: WAVE0-FROZEN · Predecessor: ECR-036 Closed
> Capability: `ExploreConfig` · BC: `Explore_Reports` > Capability: `ExploreConfig` · BC: `Explore_Reports`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` > 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
@@ -0,0 +1,73 @@
# Feature Spec: GrowthInsights · ReportTemplate 写面(Ops · ECR-048
> Status: `Active`**Implemented · ECR-048 Closed**
> Map: `§7` · Capability: `GrowthInsights` · BC: `Explore_Reports`
> Auth: `docs/WAVE0/ECR-048_WRITE_AUTHORIZATION.md`**仅 ECR-048**
> Predecessor: ECR-038 Closed(只读)· ECR-047 ClosedFunnel 写)
> 关联:[ops-report-template.md](ops-report-template.md)
---
## 1. 目标
运营可 **创建 / 更新 / 上下架** `ReportTemplate`(报告模板目录);写鉴权 + 审计;**无**新 C 端路由。
**Closed 后立即 STOP**,不自动 ECR-049。
## 2. In / Out
| In | Out |
|----|-----|
| Admin POST/PUT `/admin/growth/report-templates*` | Funnel 再写 |
| 复用 `admin.growth.write` | SystemPrompt · Knowledge · Chunk |
| 复用表 `report_templates` | soft-delete · 真支付 · UGC |
| 审计 | 题干 · Crisis/Handoff |
| | 自动 ECR-049 · 新 C 端路由 |
## 3. Domain
| 字段 | 规则 |
|------|------|
| `code` | 唯一;`^[a-z][a-z0-9_]{1,62}$``system=true` **不可改 code** |
| `title` | 必填 · ≤128 |
| `scene` | 必填;`^[a-z][a-z0-9_]{0,30}$` · ≤32 |
| `active` | `false` = 下架 |
| `system` | 种子行;禁止物理删除 |
## 4. API
| Method | Path | 权限 |
|--------|------|------|
| GET | `/admin/growth/report-templates` · `/{id}` | `admin.growth.read` |
| POST | `/admin/growth/report-templates` | `admin.growth.write` |
| PUT | `/admin/growth/report-templates/{id}` | `admin.growth.write` |
无 DELETE · 无新 C 端路径。
## 5. Permission / Migration
- Migration `000057_ops_report_template_write`:幂等确认 `super_admin``admin.growth.write`
- 编码时占号(Max=`000056``000057`
## 6. UI
admin-h5 报告模板页:列表 + 新建/编辑/上下架。
## 7. AC
| ID | Then |
|----|------|
| AC-F-01 | POST → list/get 可见 |
| AC-F-02 | PUT active=false → list 仍可见且 active=false |
| AC-F-03 | 重复 code → 409 |
| AC-S-01/02 | 401;仅 growth.read → POST 403 |
| AC-A-01 | 写产生审计 |
| AC-O-01 | 无 soft-delete · 无 Funnel/Prompt 写路径 |
## 8. Closed 后
**立即 STOP**。ECR-049 须新 Candidate Review + Human 拍板。
## 9. Trace
ECR-048 · BD-2026-048 · CONTRACT_DIFF · ECR-048_WRITE_AUTHORIZATION
@@ -1,6 +1,7 @@
# Feature Spec: GrowthInsights · ReportTemplateOps · ECR-038 # Feature Spec: GrowthInsights · ReportTemplateOps · ECR-038
> Status: `Active`Loop continuous · **ECR-038 Closed** > Status: `Active`Loop continuous · **ECR-038 Closed**
> **写面:** [ops-report-template-write.md](ops-report-template-write.md)**ECR-048 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-037 Closed > Parent: WAVE0-FROZEN · Predecessor: ECR-037 Closed
> Capability: `GrowthInsights` · BC: `Explore_Reports` > Capability: `GrowthInsights` · BC: `Explore_Reports`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` > 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
@@ -0,0 +1,68 @@
# Feature Spec: ExploreConfig · ScaleDefinition 写面(Ops · ECR-046
> Status: `Active`**Implemented · ECR-046 Closed**
> Auth: `docs/WAVE0/ECR-046_WRITE_AUTHORIZATION.md`**仅 ECR-046**
> Predecessor: ECR-040 只读 · ECR-008 status PATCH · ECR-045 Closed
> 关联:[ops-scale-definition.md](ops-scale-definition.md) · [ops-content.md](ops-content.md)
## 1. 目标
运营可在 Explore 侧 **创建/更新量表元数据**slug/title/description);**上下架仍走 ECR-008**。Closed 后 STOP。
## 2. In / Out
| In | Out |
|----|-----|
| POST/PUT `/admin/explore/scales*` | **PATCH status**ECR-008 独占) |
| 复用 `admin.explore.write` | 题干 / scale_questions |
| 复用表 `scales` | soft-delete · Prompt/Knowledge/Chunk |
| 创建默认 `status=draft` | 支付 · UGC · 自动 ECR-047 |
| 审计 | 新权限子系统 |
## 3. 双通道矩阵(强制)
| API | 权限 | 字段 |
|-----|------|------|
| `POST/PUT /admin/explore/scales*` | `admin.explore.write` | slug · title · description |
| `PATCH /admin/scales/{id}` | `admin.content.write` | **仅 status**(既有) |
## 4. Domain
| 字段 | 规则 |
|------|------|
| `slug` | 唯一;`^[a-z][a-z0-9-]{1,62}$` |
| `title` | 必填 · ≤128 |
| `description` | ≤512 · 可空串 |
| `status` | 创建固定 `draft`;本刀 PUT **不得**改 status |
## 5. C 端可观察
| 条件 | 行为 |
|------|------|
| 新建 draft | C 端 published list **不含** |
| ECR-008 PATCH → published | C 端 list **含**(既有 `/scales` |
| 不新增 C 端路由 | — |
## 6. Migration
`000054``000055_ops_scale_definition_write`:幂等确认 `admin.explore.write`
## 7. UI
admin-h5:量表元数据页(新建/编辑 slug·title·description);上下架仍在「内容」页。
## 8. AC
| ID | Then |
|----|------|
| AC-F-01 | POST → list/get 可见 · status=draft |
| AC-F-02 | PUT 改 title → get 反映;status 不变 |
| AC-F-03 | 重复 slug → 409 |
| AC-F-04 | draft 不在 C 端 published listPATCH published 后出现 |
| AC-S-01/02 | 401;仅 read → POST 403 |
| AC-A-01 | 写产生审计 |
| AC-O-01 | explore PUT 带 status 被忽略或不接受;无题干写;无 soft-delete |
## 9. Closed 后
立即 STOP · 禁自动 ECR-047
@@ -1,6 +1,7 @@
# Feature Spec: ExploreConfig · ScaleDefinitionOps · ECR-040 # Feature Spec: ExploreConfig · ScaleDefinitionOps · ECR-040
> Status: `Active`Loop continuous · **ECR-040 Closed** > Status: `Active`Loop continuous · **ECR-040 Closed**
> **写面:** [ops-scale-definition-write.md](ops-scale-definition-write.md)**ECR-046 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-039 Closed > Parent: WAVE0-FROZEN · Predecessor: ECR-039 Closed
> Capability: `ExploreConfig` · BC: `Explore_Reports` > Capability: `ExploreConfig` · BC: `Explore_Reports`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` > 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
+1
View File
@@ -3,6 +3,7 @@ __pycache__/
.venv/ .venv/
*.log *.log
.env .env
deploy.env
.DS_Store .DS_Store
# ESS — machine-local root pointer(仓库内用 scripts 包装器) # ESS — machine-local root pointer(仓库内用 scripts 包装器)
+161 -8
View File
@@ -677,21 +677,174 @@ export const adminApi = {
updated_at: string updated_at: string
}>('PUT', `/explore/rhythm-configs/${id}`, body), }>('PUT', `/explore/rhythm-configs/${id}`, body),
imageCardDecks: () => imageCardDecks: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/image-card-decks'), request<{
items: Array<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/explore/image-card-decks'),
imageCardDeck: (id: string) => imageCardDeck: (id: string) =>
request<Record<string, unknown>>('GET', `/explore/image-card-decks/${id}`), request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/explore/image-card-decks/${id}`),
createImageCardDeck: (body: { code: string; title: string; active: boolean }) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('POST', '/explore/image-card-decks', body),
updateImageCardDeck: (
id: string,
body: { code: string; title: string; active: boolean },
) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('PUT', `/explore/image-card-decks/${id}`, body),
reportTemplates: () => reportTemplates: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/growth/report-templates'), request<{
items: Array<{
id: string
code: string
title: string
scene: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/growth/report-templates'),
reportTemplate: (id: string) => reportTemplate: (id: string) =>
request<Record<string, unknown>>('GET', `/growth/report-templates/${id}`), request<{
id: string
code: string
title: string
scene: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/growth/report-templates/${id}`),
createReportTemplate: (body: {
code: string
title: string
scene: string
active: boolean
}) =>
request<{
id: string
code: string
title: string
scene: string
active: boolean
system: boolean
updated_at: string
}>('POST', '/growth/report-templates', body),
updateReportTemplate: (
id: string,
body: { code: string; title: string; scene: string; active: boolean },
) =>
request<{
id: string
code: string
title: string
scene: string
active: boolean
system: boolean
updated_at: string
}>('PUT', `/growth/report-templates/${id}`, body),
funnelDefinitions: () => funnelDefinitions: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/analytics/funnel-definitions'), request<{
items: Array<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/analytics/funnel-definitions'),
funnelDefinition: (id: string) => funnelDefinition: (id: string) =>
request<Record<string, unknown>>('GET', `/analytics/funnel-definitions/${id}`), request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/analytics/funnel-definitions/${id}`),
createFunnelDefinition: (body: { code: string; title: string; active: boolean }) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('POST', '/analytics/funnel-definitions', body),
updateFunnelDefinition: (
id: string,
body: { code: string; title: string; active: boolean },
) =>
request<{
id: string
code: string
title: string
active: boolean
system: boolean
updated_at: string
}>('PUT', `/analytics/funnel-definitions/${id}`, body),
exploreScales: () => exploreScales: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/scales'), request<{
items: Array<{
id: string
slug: string
title: string
description: string
status: string
}>
}>('GET', '/explore/scales'),
exploreScale: (id: string) => exploreScale: (id: string) =>
request<Record<string, unknown>>('GET', `/explore/scales/${id}`), request<{
id: string
slug: string
title: string
description: string
status: string
}>('GET', `/explore/scales/${id}`),
createExploreScale: (body: { slug: string; title: string; description: string }) =>
request<{
id: string
slug: string
title: string
description: string
status: string
}>('POST', '/explore/scales', body),
updateExploreScale: (
id: string,
body: { slug: string; title: string; description: string },
) =>
request<{
id: string
slug: string
title: string
description: string
status: string
}>('PUT', `/explore/scales/${id}`, body),
orders: (params?: { orders: (params?: {
status?: string status?: string
kind?: string kind?: string
@@ -0,0 +1,25 @@
<template>
<footer class="icp-footer">
<a class="icp-link" :href="ICP_MIIT_URL" target="_blank" rel="noopener noreferrer">{{ ICP_RECORD }}</a>
</footer>
</template>
<script setup lang="ts">
import { ICP_MIIT_URL, ICP_RECORD } from '@yuxingu/utils'
</script>
<style scoped>
.icp-footer {
text-align: center;
padding: 1rem 0 0.5rem;
}
.icp-link {
font-size: 12px;
line-height: 1.4;
color: var(--muted, #8a7f7b);
text-decoration: none;
}
.icp-link:hover {
color: var(--accent, #e54d42);
}
</style>
+16 -2
View File
@@ -2,6 +2,7 @@
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router' import { RouterLink, RouterView, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue' import BrandLogo from '@/components/BrandLogo.vue'
import IcpFooter from '@/components/IcpFooter.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore() const auth = useAuthStore()
@@ -37,6 +38,10 @@ async function onLogout() {
<RouterLink to="/cms">CMS</RouterLink> <RouterLink to="/cms">CMS</RouterLink>
<RouterLink to="/star-configs">星座配置</RouterLink> <RouterLink to="/star-configs">星座配置</RouterLink>
<RouterLink to="/rhythm-configs">节律配置</RouterLink> <RouterLink to="/rhythm-configs">节律配置</RouterLink>
<RouterLink to="/image-card-decks">意象牌组</RouterLink>
<RouterLink to="/scale-definitions">量表元数据</RouterLink>
<RouterLink to="/funnel-definitions">漏斗定义</RouterLink>
<RouterLink to="/report-templates">报告模板</RouterLink>
<RouterLink to="/catalogs">目录仓</RouterLink> <RouterLink to="/catalogs">目录仓</RouterLink>
<RouterLink to="/orders">订单</RouterLink> <RouterLink to="/orders">订单</RouterLink>
<RouterLink v-if="auth.can('admin.membership.plans.read')" to="/pricing">定价</RouterLink> <RouterLink v-if="auth.can('admin.membership.plans.read')" to="/pricing">定价</RouterLink>
@@ -51,7 +56,10 @@ async function onLogout() {
</div> </div>
</aside> </aside>
<main class="main"> <main class="main">
<RouterView /> <div class="main-body">
<RouterView />
</div>
<IcpFooter />
</main> </main>
</div> </div>
</template> </template>
@@ -87,7 +95,13 @@ nav a.router-link-active {
} }
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; } .foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
.role { font-size: 0.75rem; color: var(--muted); } .role { font-size: 0.75rem; color: var(--muted); }
.main { padding: 1.5rem 1.75rem; } .main {
padding: 1.5rem 1.75rem;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-body { flex: 1; min-width: 0; }
@media (max-width: 800px) { @media (max-width: 800px) {
.shell { grid-template-columns: 1fr; } .shell { grid-template-columns: 1fr; }
.nav { border-right: 0; border-bottom: 1px solid var(--line); } .nav { border-right: 0; border-bottom: 1px solid var(--line); }
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Row = Awaited<ReturnType<typeof adminApi.funnelDefinitions>>['items'][number]
const auth = useAuthStore()
const canWrite = () => auth.can('admin.growth.write')
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
const selected = ref<Row | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
code: '',
title: '',
active: true,
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.funnelDefinitions()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
form.code = ''
form.title = ''
form.active = true
selected.value = null
formErr.value = ''
}
async function openRow(id: string) {
formErr.value = ''
try {
const row = await adminApi.funnelDefinition(id)
selected.value = row
form.code = row.code
form.title = row.title
form.active = row.active
} catch {
selected.value = null
}
}
function payload() {
return {
code: form.code.trim(),
title: form.title.trim(),
active: form.active,
}
}
async function createRow() {
if (!canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createFunnelDefinition(payload())
await load()
await openRow(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveRow() {
if (!selected.value || !canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateFunnelDefinition(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function toggleActive() {
if (!selected.value || !canWrite()) return
form.active = !form.active
await saveRow()
}
onMounted(() => {
void load()
})
</script>
<template>
<section>
<h1>漏斗定义</h1>
<p class="muted">FunnelDefinition 写面 · 下架仅 active=false · ECR-047</p>
<p v-if="!canWrite()" class="err">需要 admin.growth.write 才可写</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>列表</h2>
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.code }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.active ? '启用' : '停用' }}</td>
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑' : '新建' }}</h2>
<label>代码 <input v-model="form.code" :disabled="!!selected?.system || !canWrite()" /></label>
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
<label class="check"><input v-model="form.active" type="checkbox" :disabled="!canWrite()" /> 启用</label>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div v-if="canWrite()" class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
创建
</button>
<template v-else>
<button class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
{{ form.active ? '下架' : '上架' }}
</button>
</template>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input { width: 100%; margin-top: 0.2rem; }
.check { display: flex; align-items: center; gap: 0.4rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Row = Awaited<ReturnType<typeof adminApi.imageCardDecks>>['items'][number]
const auth = useAuthStore()
const canWrite = () => auth.can('admin.explore.write')
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
const selected = ref<Row | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
code: '',
title: '',
active: true,
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.imageCardDecks()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
form.code = ''
form.title = ''
form.active = true
selected.value = null
formErr.value = ''
}
async function openRow(id: string) {
formErr.value = ''
try {
const row = await adminApi.imageCardDeck(id)
selected.value = row
form.code = row.code
form.title = row.title
form.active = row.active
} catch {
selected.value = null
}
}
function payload() {
return {
code: form.code.trim(),
title: form.title.trim(),
active: form.active,
}
}
async function createRow() {
if (!canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createImageCardDeck(payload())
await load()
await openRow(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveRow() {
if (!selected.value || !canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateImageCardDeck(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function toggleActive() {
if (!selected.value || !canWrite()) return
form.active = !form.active
await saveRow()
}
onMounted(() => {
void load()
})
</script>
<template>
<section>
<h1>意象牌组</h1>
<p class="muted">ImageCardDeck 写面 · 下架仅 active=false · ECR-045</p>
<p v-if="!canWrite()" class="err">需要 admin.explore.write 才可写</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>列表</h2>
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.code }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.active ? '启用' : '停用' }}</td>
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑' : '新建' }}</h2>
<label>代码 <input v-model="form.code" :disabled="!!selected?.system || !canWrite()" /></label>
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
<label class="check"><input v-model="form.active" type="checkbox" :disabled="!canWrite()" /> 启用</label>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div v-if="canWrite()" class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
创建
</button>
<template v-else>
<button class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
{{ form.active ? '下架' : '上架' }}
</button>
</template>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input { width: 100%; margin-top: 0.2rem; }
.check { display: flex; align-items: center; gap: 0.4rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
+15 -1
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue' import BrandLogo from '@/components/BrandLogo.vue'
import IcpFooter from '@/components/IcpFooter.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore() const auth = useAuthStore()
@@ -46,11 +47,24 @@ async function onSubmit() {
{{ loading ? '登录中…' : '登录' }} {{ loading ? '登录中…' : '登录' }}
</button> </button>
</form> </form>
<IcpFooter />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; } .wrap {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.wrap :deep(.icp-footer) {
margin-top: auto;
width: 100%;
padding-bottom: 0.25rem;
}
.login { width: min(380px, 100%); text-align: center; } .login { width: min(380px, 100%); text-align: center; }
.login :deep(.field) { text-align: left; } .login :deep(.field) { text-align: left; }
h1 { margin: 0 0 0.25rem; font-size: 1.45rem; font-family: "Noto Serif SC", "Songti SC", serif; } h1 { margin: 0 0 0.25rem; font-size: 1.45rem; font-family: "Noto Serif SC", "Songti SC", serif; }
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Row = Awaited<ReturnType<typeof adminApi.reportTemplates>>['items'][number]
const auth = useAuthStore()
const canWrite = () => auth.can('admin.growth.write')
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
const selected = ref<Row | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
code: '',
title: '',
scene: 'portrait',
active: true,
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.reportTemplates()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
form.code = ''
form.title = ''
form.scene = 'portrait'
form.active = true
selected.value = null
formErr.value = ''
}
async function openRow(id: string) {
formErr.value = ''
try {
const row = await adminApi.reportTemplate(id)
selected.value = row
form.code = row.code
form.title = row.title
form.scene = row.scene
form.active = row.active
} catch {
selected.value = null
}
}
function payload() {
return {
code: form.code.trim(),
title: form.title.trim(),
scene: form.scene.trim(),
active: form.active,
}
}
async function createRow() {
if (!canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createReportTemplate(payload())
await load()
await openRow(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveRow() {
if (!selected.value || !canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateReportTemplate(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function toggleActive() {
if (!selected.value || !canWrite()) return
form.active = !form.active
await saveRow()
}
onMounted(() => {
void load()
})
</script>
<template>
<section>
<h1>报告模板</h1>
<p class="muted">ReportTemplate 写面 · 下架仅 active=false · ECR-048</p>
<p v-if="!canWrite()" class="err">需要 admin.growth.write 才可写</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>列表</h2>
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>代码</th><th>标题</th><th>场景</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.code }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.scene }}</td>
<td>{{ b.active ? '启用' : '停用' }}</td>
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑' : '新建' }}</h2>
<label>代码 <input v-model="form.code" :disabled="!!selected?.system || !canWrite()" /></label>
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
<label>场景 <input v-model="form.scene" :disabled="!canWrite()" /></label>
<label class="check"><input v-model="form.active" type="checkbox" :disabled="!canWrite()" /> 启用</label>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div v-if="canWrite()" class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
创建
</button>
<template v-else>
<button class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
{{ form.active ? '下架' : '上架' }}
</button>
</template>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input { width: 100%; margin-top: 0.2rem; }
.check { display: flex; align-items: center; gap: 0.4rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Row = Awaited<ReturnType<typeof adminApi.exploreScales>>['items'][number]
const auth = useAuthStore()
const canWrite = () => auth.can('admin.explore.write')
const loading = ref(false)
const error = ref('')
const items = ref<Row[]>([])
const selected = ref<Row | null>(null)
const saving = ref(false)
const formErr = ref('')
const form = reactive({
slug: '',
title: '',
description: '',
})
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.exploreScales()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function resetForm() {
form.slug = ''
form.title = ''
form.description = ''
selected.value = null
formErr.value = ''
}
async function openRow(id: string) {
formErr.value = ''
try {
const row = await adminApi.exploreScale(id)
selected.value = row
form.slug = row.slug
form.title = row.title
form.description = row.description || ''
} catch {
selected.value = null
}
}
function payload() {
return {
slug: form.slug.trim(),
title: form.title.trim(),
description: form.description.trim(),
}
}
async function createRow() {
if (!canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.createExploreScale(payload())
await load()
await openRow(row.id)
} catch (e) {
formErr.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function saveRow() {
if (!selected.value || !canWrite()) return
saving.value = true
formErr.value = ''
try {
const row = await adminApi.updateExploreScale(selected.value.id, payload())
selected.value = row
await load()
} catch (e) {
formErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<template>
<section>
<h1>量表元数据</h1>
<p class="muted">
ScaleDefinition 写面 · slug/title/description · 创建为 draft · 上下架请用内容ECR-008· ECR-046
</p>
<p v-if="!canWrite()" class="err">需要 admin.explore.write 才可写</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>列表</h2>
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
<p v-if="!items.length" class="muted">暂无</p>
<table v-else>
<thead>
<tr><th>slug</th><th>标题</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="b in items" :key="b.id">
<td><code>{{ b.slug }}</code></td>
<td>{{ b.title }}</td>
<td>{{ b.status }}</td>
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>{{ selected ? '编辑元数据' : '新建(draft' }}</h2>
<label>slug <input v-model="form.slug" :disabled="!canWrite()" /></label>
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
<label>简介 <textarea v-model="form.description" rows="3" :disabled="!canWrite()" /></label>
<p v-if="selected" class="muted">当前 status={{ selected.status }}本页不可改</p>
<p v-if="formErr" class="err">{{ formErr }}</p>
<div v-if="canWrite()" class="actions">
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
创建
</button>
<button v-else class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
</div>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
code { font-size: 0.8rem; }
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
.card input, .card textarea { width: 100%; margin-top: 0.2rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.btn.primary { font-weight: 600; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
+24
View File
@@ -36,6 +36,30 @@ const router = createRouter({
component: () => import('@/pages/RhythmConfigPage.vue'), component: () => import('@/pages/RhythmConfigPage.vue'),
meta: { permission: 'admin.explore.read' }, meta: { permission: 'admin.explore.read' },
}, },
{
path: 'image-card-decks',
name: 'image-card-decks',
component: () => import('@/pages/ImageCardDeckPage.vue'),
meta: { permission: 'admin.explore.read' },
},
{
path: 'scale-definitions',
name: 'scale-definitions',
component: () => import('@/pages/ScaleDefinitionPage.vue'),
meta: { permission: 'admin.explore.read' },
},
{
path: 'funnel-definitions',
name: 'funnel-definitions',
component: () => import('@/pages/FunnelDefinitionPage.vue'),
meta: { permission: 'admin.growth.read' },
},
{
path: 'report-templates',
name: 'report-templates',
component: () => import('@/pages/ReportTemplatePage.vue'),
meta: { permission: 'admin.growth.read' },
},
{ path: 'catalogs', name: 'catalogs', component: () => import('@/pages/CatalogHubPage.vue') }, { path: 'catalogs', name: 'catalogs', component: () => import('@/pages/CatalogHubPage.vue') },
{ path: 'push', name: 'push', component: () => import('@/pages/PushJobsPage.vue') }, { path: 'push', name: 'push', component: () => import('@/pages/PushJobsPage.vue') },
{ path: 'admins', name: 'admins', component: () => import('@/pages/AdminsPage.vue'), meta: { superOnly: true } }, { path: 'admins', name: 'admins', component: () => import('@/pages/AdminsPage.vue'), meta: { superOnly: true } },
+17
View File
@@ -24,6 +24,23 @@ admin:
bootstrap_username: admin bootstrap_username: admin
bootstrap_password: "change-me" # override in config.local.yaml; never commit secrets bootstrap_password: "change-me" # override in config.local.yaml; never commit secrets
# Mini-program WeChat login (never commit real secrets)
wechat:
app_id: ""
secret: ""
# Legacy Java (ECR-049, unused after ECR-050 native consult)
java:
base_url: ""
internal_key: ""
# WeChat JSAPI pay. Empty = mock pay params (dev).
pay:
app_id: ""
mch_id: ""
api_key: ""
notify_url: "https://h5.yuxingu.com.cn/psy/api/v1/psychic/pay/notify"
# jwt: # jwt:
# secret: "" # secret: ""
# payment: # payment:
+85
View File
@@ -19,6 +19,29 @@ type Config struct {
AppEnv string AppEnv string
DeepSeek DeepSeekConfig DeepSeek DeepSeekConfig
Admin AdminConfig Admin AdminConfig
WeChat WeChatConfig
Java JavaConfig
Pay PayConfig
}
// PayConfig is WeChat JSAPI merchant settings.
type PayConfig struct {
AppID string
MchID string
APIKey string
NotifyURL string
}
// WeChatConfig is mini-program jscode2session credentials.
type WeChatConfig struct {
AppID string
Secret string
}
// JavaConfig is the consult stack bridge (internal issue + proxy).
type JavaConfig struct {
BaseURL string
InternalKey string
} }
// AdminConfig for ops console bootstrap (ECR-006). // AdminConfig for ops console bootstrap (ECR-006).
@@ -58,6 +81,20 @@ type fileConfig struct {
BootstrapUsername string `yaml:"bootstrap_username"` BootstrapUsername string `yaml:"bootstrap_username"`
BootstrapPassword string `yaml:"bootstrap_password"` BootstrapPassword string `yaml:"bootstrap_password"`
} `yaml:"admin"` } `yaml:"admin"`
WeChat struct {
AppID string `yaml:"app_id"`
Secret string `yaml:"secret"`
} `yaml:"wechat"`
Java struct {
BaseURL string `yaml:"base_url"`
InternalKey string `yaml:"internal_key"`
} `yaml:"java"`
Pay struct {
AppID string `yaml:"app_id"`
MchID string `yaml:"mch_id"`
APIKey string `yaml:"api_key"`
NotifyURL string `yaml:"notify_url"`
} `yaml:"pay"`
} }
// Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides. // Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides.
@@ -140,6 +177,30 @@ func mergeFile(cfg *Config, path string) error {
if f.Admin.BootstrapPassword != "" { if f.Admin.BootstrapPassword != "" {
cfg.Admin.BootstrapPassword = f.Admin.BootstrapPassword cfg.Admin.BootstrapPassword = f.Admin.BootstrapPassword
} }
if f.WeChat.AppID != "" {
cfg.WeChat.AppID = f.WeChat.AppID
}
if f.WeChat.Secret != "" {
cfg.WeChat.Secret = f.WeChat.Secret
}
if f.Java.BaseURL != "" {
cfg.Java.BaseURL = strings.TrimRight(f.Java.BaseURL, "/")
}
if f.Java.InternalKey != "" {
cfg.Java.InternalKey = f.Java.InternalKey
}
if f.Pay.AppID != "" {
cfg.Pay.AppID = f.Pay.AppID
}
if f.Pay.MchID != "" {
cfg.Pay.MchID = f.Pay.MchID
}
if f.Pay.APIKey != "" {
cfg.Pay.APIKey = f.Pay.APIKey
}
if f.Pay.NotifyURL != "" {
cfg.Pay.NotifyURL = f.Pay.NotifyURL
}
return nil return nil
} }
@@ -207,6 +268,30 @@ func applyEnv(cfg *Config) {
if v := os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"); v != "" { if v := os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"); v != "" {
cfg.Admin.BootstrapPassword = v cfg.Admin.BootstrapPassword = v
} }
if v := os.Getenv("WECHAT_MINI_APP_ID"); v != "" {
cfg.WeChat.AppID = v
}
if v := os.Getenv("WECHAT_MINI_APP_SECRET"); v != "" {
cfg.WeChat.Secret = v
}
if v := os.Getenv("JAVA_CONSULT_BASE_URL"); v != "" {
cfg.Java.BaseURL = strings.TrimRight(v, "/")
}
if v := os.Getenv("JAVA_INTERNAL_KEY"); v != "" {
cfg.Java.InternalKey = v
}
if v := os.Getenv("PAY_APP_ID"); v != "" {
cfg.Pay.AppID = v
}
if v := os.Getenv("PAY_MCH_ID"); v != "" {
cfg.Pay.MchID = v
}
if v := os.Getenv("PAY_API_KEY"); v != "" {
cfg.Pay.APIKey = v
}
if v := os.Getenv("PAY_NOTIFY_URL"); v != "" {
cfg.Pay.NotifyURL = v
}
} }
// Enabled reports whether DeepSeek can be called. // Enabled reports whether DeepSeek can be called.
@@ -16,6 +16,8 @@ func (h *AdminHandler) registerExploreScales(authed *gin.RouterGroup) {
g := authed.Group("/explore") g := authed.Group("/explore")
g.GET("/scales", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.ListExploreScales) g.GET("/scales", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.ListExploreScales)
g.GET("/scales/:id", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.GetExploreScale) g.GET("/scales/:id", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.GetExploreScale)
g.POST("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.CreateExploreScale)
g.PUT("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.UpdateExploreScale)
} }
func (h *AdminHandler) ListExploreScales(c *gin.Context) { func (h *AdminHandler) ListExploreScales(c *gin.Context) {
@@ -44,3 +46,66 @@ func (h *AdminHandler) GetExploreScale(c *gin.Context) {
} }
response.OK(c, row) response.OK(c, row)
} }
func (h *AdminHandler) CreateExploreScale(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
var body admin.ScaleDefinitionWriteBody
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
return
}
row, err := h.Svc.CreateScaleDefinition(c.Request.Context(), adminID, body)
if errors.Is(err, admin.ErrInvalidScaleDefinition) {
response.Fail(c, http.StatusBadRequest, 40055, "invalid scale definition")
return
}
if errors.Is(err, admin.ErrScaleDefinitionConflict) {
response.Fail(c, http.StatusConflict, 40912, "scale slug conflict")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50062, "create scale definition failed")
return
}
response.OK(c, row)
}
func (h *AdminHandler) UpdateExploreScale(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
return
}
var body admin.ScaleDefinitionWriteBody
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
return
}
row, err := h.Svc.UpdateScaleDefinition(c.Request.Context(), adminID, id, body)
if errors.Is(err, admin.ErrScaleNotFound) {
response.Fail(c, http.StatusNotFound, 40430, "scale not found")
return
}
if errors.Is(err, admin.ErrInvalidScaleDefinition) {
response.Fail(c, http.StatusBadRequest, 40055, "invalid scale definition")
return
}
if errors.Is(err, admin.ErrScaleDefinitionConflict) {
response.Fail(c, http.StatusConflict, 40912, "scale slug conflict")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50063, "update scale definition failed")
return
}
response.OK(c, row)
}
@@ -16,6 +16,8 @@ func (h *AdminHandler) registerFunnelDefinitions(authed *gin.RouterGroup) {
g := authed.Group("/analytics") g := authed.Group("/analytics")
g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListFunnelDefinitions) g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListFunnelDefinitions)
g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetFunnelDefinition) g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetFunnelDefinition)
g.POST("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthWrite), h.CreateFunnelDefinition)
g.PUT("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthWrite), h.UpdateFunnelDefinition)
} }
func (h *AdminHandler) ListFunnelDefinitions(c *gin.Context) { func (h *AdminHandler) ListFunnelDefinitions(c *gin.Context) {
@@ -44,3 +46,66 @@ func (h *AdminHandler) GetFunnelDefinition(c *gin.Context) {
} }
response.OK(c, row) response.OK(c, row)
} }
func (h *AdminHandler) CreateFunnelDefinition(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
var body admin.FunnelDefinitionWriteBody
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
return
}
row, err := h.Svc.CreateFunnelDefinition(c.Request.Context(), adminID, body)
if errors.Is(err, admin.ErrInvalidFunnelDefinition) {
response.Fail(c, http.StatusBadRequest, 40055, "invalid funnel definition")
return
}
if errors.Is(err, admin.ErrFunnelDefinitionConflict) {
response.Fail(c, http.StatusConflict, 40912, "funnel definition code conflict")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50052, "create funnel definition failed")
return
}
response.OK(c, row)
}
func (h *AdminHandler) UpdateFunnelDefinition(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
return
}
var body admin.FunnelDefinitionWriteBody
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
return
}
row, err := h.Svc.UpdateFunnelDefinition(c.Request.Context(), adminID, id, body)
if errors.Is(err, admin.ErrFunnelDefinitionNotFound) {
response.Fail(c, http.StatusNotFound, 40420, "funnel-definition not found")
return
}
if errors.Is(err, admin.ErrInvalidFunnelDefinition) {
response.Fail(c, http.StatusBadRequest, 40055, "invalid funnel definition")
return
}
if errors.Is(err, admin.ErrFunnelDefinitionConflict) {
response.Fail(c, http.StatusConflict, 40912, "funnel definition code conflict")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50053, "update funnel definition failed")
return
}
response.OK(c, row)
}
@@ -16,6 +16,8 @@ func (h *AdminHandler) registerImageCardDecks(authed *gin.RouterGroup) {
g := authed.Group("/explore") g := authed.Group("/explore")
g.GET("/image-card-decks", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListImageCardDecks) g.GET("/image-card-decks", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListImageCardDecks)
g.GET("/image-card-decks/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetImageCardDeck) g.GET("/image-card-decks/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetImageCardDeck)
g.POST("/image-card-decks", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.CreateImageCardDeck)
g.PUT("/image-card-decks/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.UpdateImageCardDeck)
} }
func (h *AdminHandler) ListImageCardDecks(c *gin.Context) { func (h *AdminHandler) ListImageCardDecks(c *gin.Context) {
@@ -44,3 +46,66 @@ func (h *AdminHandler) GetImageCardDeck(c *gin.Context) {
} }
response.OK(c, row) response.OK(c, row)
} }
func (h *AdminHandler) CreateImageCardDeck(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
var body admin.ImageCardDeckWriteBody
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
return
}
row, err := h.Svc.CreateImageCardDeck(c.Request.Context(), adminID, body)
if errors.Is(err, admin.ErrInvalidImageCardDeck) {
response.Fail(c, http.StatusBadRequest, 40055, "invalid image card deck")
return
}
if errors.Is(err, admin.ErrImageCardDeckConflict) {
response.Fail(c, http.StatusConflict, 40912, "image card deck code conflict")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50052, "create image card deck failed")
return
}
response.OK(c, row)
}
func (h *AdminHandler) UpdateImageCardDeck(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
return
}
var body admin.ImageCardDeckWriteBody
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
return
}
row, err := h.Svc.UpdateImageCardDeck(c.Request.Context(), adminID, id, body)
if errors.Is(err, admin.ErrImageCardDeckNotFound) {
response.Fail(c, http.StatusNotFound, 40420, "image-card-deck not found")
return
}
if errors.Is(err, admin.ErrInvalidImageCardDeck) {
response.Fail(c, http.StatusBadRequest, 40055, "invalid image card deck")
return
}
if errors.Is(err, admin.ErrImageCardDeckConflict) {
response.Fail(c, http.StatusConflict, 40912, "image card deck code conflict")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50053, "update image card deck failed")
return
}
response.OK(c, row)
}
@@ -16,6 +16,8 @@ func (h *AdminHandler) registerReportTemplates(authed *gin.RouterGroup) {
g := authed.Group("/growth") g := authed.Group("/growth")
g.GET("/report-templates", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListReportTemplates) g.GET("/report-templates", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListReportTemplates)
g.GET("/report-templates/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetReportTemplate) g.GET("/report-templates/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetReportTemplate)
g.POST("/report-templates", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthWrite), h.CreateReportTemplate)
g.PUT("/report-templates/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthWrite), h.UpdateReportTemplate)
} }
func (h *AdminHandler) ListReportTemplates(c *gin.Context) { func (h *AdminHandler) ListReportTemplates(c *gin.Context) {
@@ -44,3 +46,66 @@ func (h *AdminHandler) GetReportTemplate(c *gin.Context) {
} }
response.OK(c, row) response.OK(c, row)
} }
func (h *AdminHandler) CreateReportTemplate(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
var body admin.ReportTemplateWriteBody
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
return
}
row, err := h.Svc.CreateReportTemplate(c.Request.Context(), adminID, body)
if errors.Is(err, admin.ErrInvalidReportTemplate) {
response.Fail(c, http.StatusBadRequest, 40055, "invalid report template")
return
}
if errors.Is(err, admin.ErrReportTemplateConflict) {
response.Fail(c, http.StatusConflict, 40912, "report template code conflict")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50052, "create report template failed")
return
}
response.OK(c, row)
}
func (h *AdminHandler) UpdateReportTemplate(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
return
}
var body admin.ReportTemplateWriteBody
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
return
}
row, err := h.Svc.UpdateReportTemplate(c.Request.Context(), adminID, id, body)
if errors.Is(err, admin.ErrReportTemplateNotFound) {
response.Fail(c, http.StatusNotFound, 40420, "report-template not found")
return
}
if errors.Is(err, admin.ErrInvalidReportTemplate) {
response.Fail(c, http.StatusBadRequest, 40055, "invalid report template")
return
}
if errors.Is(err, admin.ErrReportTemplateConflict) {
response.Fail(c, http.StatusConflict, 40912, "report template code conflict")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50053, "update report template failed")
return
}
response.OK(c, row)
}
+33
View File
@@ -22,6 +22,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) {
g := api.Group("/auth") g := api.Group("/auth")
g.POST("/register", h.RegisterAccount) g.POST("/register", h.RegisterAccount)
g.POST("/login", h.Login) g.POST("/login", h.Login)
g.POST("/wechat", h.WeChat)
g.POST("/logout", h.Logout) g.POST("/logout", h.Logout)
g.GET("/me", h.Me) g.GET("/me", h.Me)
g.PATCH("/me", h.PatchMe) g.PATCH("/me", h.PatchMe)
@@ -34,6 +35,12 @@ type authBody struct {
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
} }
type wechatBody struct {
Code string `json:"code"`
EncryptedData string `json:"encryptedData"`
IV string `json:"iv"`
}
// RegisterAccount handles POST /auth/register (DeviceAuth required on group). // RegisterAccount handles POST /auth/register (DeviceAuth required on group).
func (h *AuthHandler) RegisterAccount(c *gin.Context) { func (h *AuthHandler) RegisterAccount(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c) userID, ok := middleware.UserIDFromContext(c)
@@ -88,6 +95,32 @@ func (h *AuthHandler) Login(c *gin.Context) {
response.OK(c, res) response.OK(c, res)
} }
// WeChat handles POST /auth/wechat (mini-program phone login).
func (h *AuthHandler) WeChat(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var body wechatBody
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
res, err := h.Svc.WeChatLogin(c.Request.Context(), userID, deviceKey, body.Code, body.EncryptedData, body.IV)
if err != nil {
if errors.Is(err, auth.ErrAccountRestricted) {
response.Fail(c, http.StatusUnauthorized, 40113, err.Error())
return
}
response.Fail(c, http.StatusBadRequest, 40115, err.Error())
return
}
c.Set(string(middleware.UserIDKey), res.User.ID)
response.OK(c, res)
}
// Logout handles POST /auth/logout. // Logout handles POST /auth/logout.
func (h *AuthHandler) Logout(c *gin.Context) { func (h *AuthHandler) Logout(c *gin.Context) {
tok := bearerToken(c) tok := bearerToken(c)
+384
View File
@@ -0,0 +1,384 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/consult"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// ConsultHandler serves /api/v1/psychic/* (ECR-050).
type ConsultHandler struct {
Svc *consult.Service
}
func (h *ConsultHandler) Register(api *gin.RouterGroup) {
g := api.Group("/psychic")
g.POST("/banner/all", h.banners)
g.GET("/news/page", h.newsPage)
g.GET("/news/get", h.newsGet)
g.POST("/procotol/getByCode", h.protocol)
g.POST("/study/list", h.studyList)
g.POST("/study/getSingleDetail", h.studyDetail)
g.POST("/user-choice/save", h.choiceSave)
g.POST("/user-choice/getResult", h.choiceResult)
g.POST("/user-choice/my-test", h.myTest)
g.GET("/doctor-info/business-scope-list", h.scopes)
g.GET("/doctor-info/page", h.doctorPage)
g.GET("/doctor-info/get", h.doctorGet)
g.POST("/doctor-info/focus", h.focusOn)
g.POST("/doctor-info/cancel-focus", h.focusOff)
g.GET("/doctor-info/get-show-info", h.showInfo)
g.POST("/appointment/remain-list", h.remain)
g.POST("/appointment/date-detail-list", h.dateDetail)
g.POST("/order/create", h.orderCreate)
g.GET("/order/get-pay-param", h.payParam)
g.POST("/order/order-list", h.orderList)
g.POST("/order/order-detail", h.orderDetail)
g.POST("/order/cancel", h.orderCancel)
g.POST("/order/delete-order", h.orderDelete)
g.POST("/pay/createOrder", h.payDemo)
g.POST("/pay/refund", h.payRefund)
g.POST("/pay/notify", h.payNotify)
g.GET("/platform-user/getSelfInfo", h.selfInfo)
g.POST("/platform-user/update", h.updateSelf)
g.GET("/platform-user/not-read-num", h.notRead)
g.POST("/platform-user/focus-to-read", h.focusRead)
g.POST("/platform-user/order-to-read", h.orderRead)
g.POST("/platform-user/focus-all", h.focusAll)
g.POST("/platform-user/user-feedback", h.feedback)
g.POST("/platform-user/feedback-flag", h.feedbackFlag)
}
func (h *ConsultHandler) banners(c *gin.Context) {
data, err := h.Svc.Banners(c.Request.Context())
h.ok(c, data, err)
}
func (h *ConsultHandler) newsPage(c *gin.Context) {
data, err := h.Svc.NewsPage(c.Request.Context(), qInt(c, "type", 0), qInt(c, "showMain", -1), qInt(c, "pageNo", 1), qInt(c, "pageSize", 20))
h.ok(c, data, err)
}
func (h *ConsultHandler) ok(c *gin.Context, data any, err error) {
if err != nil {
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
return
}
response.OK(c, data)
}
func (h *ConsultHandler) newsGet(c *gin.Context) {
data, err := h.Svc.NewsGet(c.Request.Context(), qInt64(c, "id"))
h.ok(c, data, err)
}
func (h *ConsultHandler) protocol(c *gin.Context) {
data, err := h.Svc.ProtocolByCode(c.Request.Context(), c.Query("code"))
h.ok(c, data, err)
}
func (h *ConsultHandler) studyList(c *gin.Context) {
data, err := h.Svc.TestList(c.Request.Context(), qInt(c, "showMain", -1))
h.ok(c, data, err)
}
func (h *ConsultHandler) studyDetail(c *gin.Context) {
data, err := h.Svc.TestDetail(c.Request.Context(), qInt64(c, "studyId"))
h.ok(c, data, err)
}
func (h *ConsultHandler) choiceSave(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
var in consult.ChoiceIn
if err := c.ShouldBindJSON(&in); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
data, err := h.Svc.SaveChoice(c.Request.Context(), uid, in)
h.ok(c, data, err)
}
func (h *ConsultHandler) choiceResult(c *gin.Context) {
data, err := h.Svc.GetResult(c.Request.Context(), qInt64(c, "resultId"))
h.ok(c, data, err)
}
func (h *ConsultHandler) myTest(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
var p struct {
PageNo int `json:"pageNo"`
PageSize int `json:"pageSize"`
}
_ = c.ShouldBindJSON(&p)
data, err := h.Svc.MyTests(c.Request.Context(), uid, p.PageNo, p.PageSize)
h.ok(c, data, err)
}
func (h *ConsultHandler) scopes(c *gin.Context) {
data, err := h.Svc.Scopes(c.Request.Context())
h.ok(c, data, err)
}
func (h *ConsultHandler) doctorPage(c *gin.Context) {
data, err := h.Svc.DoctorPage(c.Request.Context(), c.Query("businessScope"), qInt(c, "isTop", -1), qInt(c, "pageNo", 1), qInt(c, "pageSize", 20))
h.ok(c, data, err)
}
func (h *ConsultHandler) doctorGet(c *gin.Context) {
uid, _ := middleware.UserIDFromContext(c)
data, err := h.Svc.DoctorGet(c.Request.Context(), qInt64(c, "id"), uid)
h.ok(c, data, err)
}
func (h *ConsultHandler) focusOn(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
if err := h.Svc.SetFocus(c.Request.Context(), uid, qInt64(c, "id"), true); err != nil {
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
return
}
response.OK(c, true)
}
func (h *ConsultHandler) focusOff(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
if err := h.Svc.SetFocus(c.Request.Context(), uid, qInt64(c, "id"), false); err != nil {
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
return
}
response.OK(c, true)
}
func (h *ConsultHandler) showInfo(c *gin.Context) {
uid, _ := middleware.UserIDFromContext(c)
data, err := h.Svc.ShowInfo(c.Request.Context(), qInt64(c, "id"), uid)
h.ok(c, data, err)
}
func (h *ConsultHandler) remain(c *gin.Context) {
var in struct {
DoctorID int64 `json:"doctorId"`
}
_ = c.ShouldBindJSON(&in)
data, err := h.Svc.RemainList(c.Request.Context(), in.DoctorID)
h.ok(c, data, err)
}
func (h *ConsultHandler) dateDetail(c *gin.Context) {
var in struct {
DoctorID int64 `json:"doctorId"`
}
_ = c.ShouldBindJSON(&in)
data, err := h.Svc.DateDetail(c.Request.Context(), in.DoctorID)
h.ok(c, data, err)
}
func (h *ConsultHandler) orderCreate(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
var in consult.OrderIn
if err := c.ShouldBindJSON(&in); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
data, err := h.Svc.CreateOrder(c.Request.Context(), uid, in)
h.ok(c, data, err)
}
func (h *ConsultHandler) payParam(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
data, err := h.Svc.PayParam(c.Request.Context(), uid, c.Query("orderSn"))
h.ok(c, data, err)
}
func (h *ConsultHandler) orderList(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
var p struct {
PageNo int `json:"pageNo"`
PageSize int `json:"pageSize"`
}
_ = c.ShouldBindJSON(&p)
data, err := h.Svc.OrderList(c.Request.Context(), uid, p.PageNo, p.PageSize)
h.ok(c, data, err)
}
func (h *ConsultHandler) orderDetail(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
data, err := h.Svc.OrderDetail(c.Request.Context(), uid, qInt64(c, "orderId"))
h.ok(c, data, err)
}
func (h *ConsultHandler) orderCancel(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
if err := h.Svc.CancelOrder(c.Request.Context(), uid, qInt64(c, "orderId")); err != nil {
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
return
}
response.OK(c, true)
}
func (h *ConsultHandler) orderDelete(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
if err := h.Svc.DeleteOrder(c.Request.Context(), uid, qInt64(c, "orderId")); err != nil {
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
return
}
response.OK(c, true)
}
func (h *ConsultHandler) payDemo(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
data, err := h.Svc.DemoPay(c.Request.Context(), uid)
h.ok(c, data, err)
}
func (h *ConsultHandler) payRefund(c *gin.Context) {
response.OK(c, true)
}
func (h *ConsultHandler) payNotify(c *gin.Context) {
raw, _ := c.GetRawData()
sn := extractXML(string(raw), "out_trade_no")
if sn != "" {
_ = h.Svc.MarkPaid(c.Request.Context(), sn)
}
c.String(http.StatusOK, "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>")
}
func (h *ConsultHandler) selfInfo(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
data, err := h.Svc.SelfInfo(c.Request.Context(), uid)
h.ok(c, data, err)
}
func (h *ConsultHandler) updateSelf(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
var in struct {
NickName string `json:"nickName"`
AvatarUrl string `json:"avatarUrl"`
StayPeriod string `json:"stayPeriod"`
}
_ = c.ShouldBindJSON(&in)
if err := h.Svc.UpdateSelf(c.Request.Context(), uid, in.NickName, in.AvatarUrl, in.StayPeriod); err != nil {
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
return
}
response.OK(c, true)
}
func (h *ConsultHandler) notRead(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
data, err := h.Svc.NotRead(c.Request.Context(), uid)
h.ok(c, data, err)
}
func (h *ConsultHandler) focusRead(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
_ = h.Svc.FocusToRead(c.Request.Context(), uid)
response.OK(c, true)
}
func (h *ConsultHandler) orderRead(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
_ = h.Svc.OrderToRead(c.Request.Context(), uid)
response.OK(c, true)
}
func (h *ConsultHandler) focusAll(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
data, err := h.Svc.FocusAll(c.Request.Context(), uid)
h.ok(c, data, err)
}
func (h *ConsultHandler) feedback(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
var in struct {
ContentText string `json:"contentText"`
}
_ = c.ShouldBindJSON(&in)
if err := h.Svc.Feedback(c.Request.Context(), uid, in.ContentText); err != nil {
response.Fail(c, http.StatusBadRequest, 40001, err.Error())
return
}
response.OK(c, true)
}
func (h *ConsultHandler) feedbackFlag(c *gin.Context) {
uid, ok := h.mustUser(c)
if !ok {
return
}
data, err := h.Svc.FeedbackFlag(c.Request.Context(), uid)
h.ok(c, data, err)
}
func (h *ConsultHandler) mustUser(c *gin.Context) (uuid.UUID, bool) {
uid, ok := middleware.UserIDFromContext(c)
if !ok || !h.Svc.Registered(c.Request.Context(), uid) {
response.Fail(c, http.StatusUnauthorized, 40112, "请先登录后再使用")
return uuid.Nil, false
}
return uid, true
}
func qInt(c *gin.Context, key string, def int) int {
s := c.Query(key)
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
func qInt64(c *gin.Context, key string) int64 {
n, _ := strconv.ParseInt(c.Query(key), 10, 64)
return n
}
func extractXML(s, tag string) string {
open, close := "<"+tag+">", "</"+tag+">"
i := strings.Index(s, open)
if i < 0 {
open, close = "<"+tag+"><![CDATA[", "]]></"+tag+">"
i = strings.Index(s, open)
if i < 0 {
return ""
}
}
i += len(open)
j := strings.Index(s[i:], close)
if j < 0 {
return ""
}
return s[i : i+j]
}
@@ -0,0 +1,36 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// ImageCardDeckPublicHandler serves GET /api/v1/cards/decks (DeviceAuth).
type ImageCardDeckPublicHandler struct {
Repo *repository.AdminRepo
}
// Register mounts public image card deck routes.
func (h *ImageCardDeckPublicHandler) Register(api *gin.RouterGroup) {
api.GET("/cards/decks", h.ListActive)
}
func (h *ImageCardDeckPublicHandler) ListActive(c *gin.Context) {
if h.Repo == nil {
response.OK(c, gin.H{"items": []repository.ImageCardDeckRow{}})
return
}
items, err := h.Repo.ListActiveImageCardDecks(c.Request.Context())
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50054, "card decks failed")
return
}
if items == nil {
items = []repository.ImageCardDeckRow{}
}
response.OK(c, gin.H{"items": items})
}
+59
View File
@@ -1,12 +1,18 @@
package handler package handler
import ( import (
"crypto/rand"
"encoding/hex"
"io"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar" "github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
) )
// MediaHandler serves uploaded media files. // MediaHandler serves uploaded media files.
@@ -17,6 +23,8 @@ type MediaHandler struct {
// Register mounts public media routes. // Register mounts public media routes.
func (h *MediaHandler) Register(api *gin.RouterGroup) { func (h *MediaHandler) Register(api *gin.RouterGroup) {
api.GET("/media/avatars/:file", h.ServeAvatar) api.GET("/media/avatars/:file", h.ServeAvatar)
api.GET("/media/uploads/:file", h.ServeUpload)
api.POST("/upload", h.Upload)
} }
// ServeAvatar streams a stored avatar image. // ServeAvatar streams a stored avatar image.
@@ -34,3 +42,54 @@ func (h *MediaHandler) ServeAvatar(c *gin.Context) {
c.File(full) c.File(full)
_ = filepath.Ext(full) _ = filepath.Ext(full)
} }
func (h *MediaHandler) uploadDir() string {
return "data/uploads"
}
func (h *MediaHandler) ServeUpload(c *gin.Context) {
name := filepath.Base(c.Param("file"))
full := filepath.Join(h.uploadDir(), name)
if _, err := os.Stat(full); err != nil {
c.Status(http.StatusNotFound)
return
}
c.File(full)
}
func (h *MediaHandler) Upload(c *gin.Context) {
fh, err := c.FormFile("file")
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "请选择文件")
return
}
src, err := fh.Open()
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "读取失败")
return
}
defer src.Close()
ext := strings.ToLower(filepath.Ext(fh.Filename))
if ext == "" {
ext = ".jpg"
}
var b [8]byte
_, _ = rand.Read(b[:])
name := hex.EncodeToString(b[:]) + ext
if err := os.MkdirAll(h.uploadDir(), 0o755); err != nil {
response.Fail(c, http.StatusInternalServerError, 50001, "保存失败")
return
}
dstPath := filepath.Join(h.uploadDir(), name)
dst, err := os.Create(dstPath)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50001, "保存失败")
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
response.Fail(c, http.StatusInternalServerError, 50001, "保存失败")
return
}
response.OK(c, "/api/v1/media/uploads/"+name)
}
+18 -1
View File
@@ -19,6 +19,7 @@ import (
authsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/auth" authsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/auth"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap" "github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion" companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
consultsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/consult"
homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home" homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard" imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/membership" "github.com/yuxingu/digital-psychology/apps/api/internal/service/membership"
@@ -26,6 +27,8 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/service/relation" "github.com/yuxingu/digital-psychology/apps/api/internal/service/relation"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/report" "github.com/yuxingu/digital-psychology/apps/api/internal/service/report"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/scale" "github.com/yuxingu/digital-psychology/apps/api/internal/service/scale"
"github.com/yuxingu/digital-psychology/apps/api/internal/wechat"
"github.com/yuxingu/digital-psychology/apps/api/internal/wechatpay"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response" "github.com/yuxingu/digital-psychology/apps/api/pkg/response"
) )
@@ -73,7 +76,18 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
adminSvc := &adminsvc.Service{ adminSvc := &adminsvc.Service{
Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo, Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo,
} }
authSvc := &authsvc.Service{Repo: authRepo, AvatarDir: "data/avatars"} authSvc := &authsvc.Service{
Repo: authRepo,
AvatarDir: "data/avatars",
WeChat: &wechat.APIClient{AppID: cfg.WeChat.AppID, Secret: cfg.WeChat.Secret},
WeChatApp: cfg.WeChat.AppID,
}
consultSvc := &consultsvc.Service{
Pool: pool,
Pay: wechatpay.Config{
AppID: cfg.Pay.AppID, MchID: cfg.Pay.MchID, APIKey: cfg.Pay.APIKey, NotifyURL: cfg.Pay.NotifyURL,
},
}
if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{ if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{
Username: cfg.Admin.BootstrapUsername, Username: cfg.Admin.BootstrapUsername,
Password: cfg.Admin.BootstrapPassword, Password: cfg.Admin.BootstrapPassword,
@@ -87,6 +101,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
c.Header("Access-Control-Expose-Headers", "X-Device-Key, X-Request-Id") c.Header("Access-Control-Expose-Headers", "X-Device-Key, X-Request-Id")
c.Next() c.Next()
}) })
r.Static("/yxg-mp", "data/yxg-mp")
api := r.Group("/api/v1") api := r.Group("/api/v1")
handler.NewHealthHandler().Register(api) handler.NewHealthHandler().Register(api)
@@ -99,10 +114,12 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
authed := api.Group("") authed := api.Group("")
authed.Use(middleware.DeviceAuth(pool)) authed.Use(middleware.DeviceAuth(pool))
(&handler.AuthHandler{Svc: authSvc}).Register(authed) (&handler.AuthHandler{Svc: authSvc}).Register(authed)
(&handler.ConsultHandler{Svc: consultSvc}).Register(authed)
(&handler.AnalyticsHandler{Svc: analyticsSvc}).Register(authed) (&handler.AnalyticsHandler{Svc: analyticsSvc}).Register(authed)
(&handler.HomeHandler{Svc: homeSvc}).Register(authed) (&handler.HomeHandler{Svc: homeSvc}).Register(authed)
(&handler.StarConfigPublicHandler{Repo: adminRepo}).Register(authed) (&handler.StarConfigPublicHandler{Repo: adminRepo}).Register(authed)
(&handler.RhythmConfigPublicHandler{Repo: adminRepo}).Register(authed) (&handler.RhythmConfigPublicHandler{Repo: adminRepo}).Register(authed)
(&handler.ImageCardDeckPublicHandler{Repo: adminRepo}).Register(authed)
gated := authed.Group("") gated := authed.Group("")
gated.Use(middleware.RequireRegistered(pool)) gated.Use(middleware.RequireRegistered(pool))
@@ -0,0 +1,30 @@
package integration_test
import (
"net/http"
"testing"
)
func TestConsultNativePublic(t *testing.T) {
r, _ := setupAPI(t)
env, _ := doJSON(t, r, http.MethodPost, "/api/v1/psychic/banner/all", nil, "")
if env.Code != 0 {
t.Fatalf("banner code %d", env.Code)
}
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/psychic/news/page?type=1&showMain=1&pageNo=1&pageSize=10", nil, "")
if env.Code != 0 {
t.Fatalf("news code %d", env.Code)
}
env, _ = doJSON(t, r, http.MethodPost, "/api/v1/psychic/study/list?showMain=1", nil, "")
if env.Code != 0 {
t.Fatalf("study code %d", env.Code)
}
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/psychic/doctor-info/page?pageNo=1&pageSize=10&isTop=1", nil, "")
if env.Code != 0 {
t.Fatalf("doctor page code %d", env.Code)
}
env, _ = doJSON(t, r, http.MethodPost, "/api/v1/psychic/procotol/getByCode?code=consultation_appointment_agreement", nil, "")
if env.Code != 0 {
t.Fatalf("protocol code %d", env.Code)
}
}
@@ -0,0 +1,109 @@
package integration_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
func TestGrowthFunnelDefinitionWrite(t *testing.T) {
r, pool := setupAPIPool(t)
ctx := context.Background()
tok := adminLogin(t, r, "admin", "change-me")
code := fmt.Sprintf("fn_w_%d", time.Now().UnixNano()%1_000_000)
body := map[string]any{"code": code, "title": "测试漏斗", "active": true}
env, httpCode := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/analytics/funnel-definitions", body, tok)
if httpCode != 200 || env.Code != 0 {
t.Fatalf("create http=%d code=%d msg=%s", httpCode, env.Code, env.Message)
}
var created struct {
ID string `json:"id"`
Code string `json:"code"`
Active bool `json:"active"`
}
_ = json.Unmarshal(env.Data, &created)
if created.ID == "" || created.Code != code || !created.Active {
t.Fatalf("bad create %#v", created)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM funnel_definitions WHERE id=$1`, created.ID)
})
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/analytics/funnel-definitions", body, tok)
if httpCode != http.StatusConflict {
t.Fatalf("dup expected 409 got %d", httpCode)
}
env, httpCode = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/funnel-definitions", nil, tok)
if httpCode != 200 {
t.Fatalf("list %d", httpCode)
}
var list struct {
Items []struct {
Code string `json:"code"`
Active bool `json:"active"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &list)
found := false
for _, it := range list.Items {
if it.Code == code && it.Active {
found = true
break
}
}
if !found {
t.Fatalf("missing active in list %#v", list.Items)
}
body["active"] = false
body["title"] = "测试漏斗下架"
env, httpCode = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/analytics/funnel-definitions/"+created.ID, body, tok)
if httpCode != 200 {
t.Fatalf("update %d", httpCode)
}
var updated struct {
Active bool `json:"active"`
Title string `json:"title"`
}
_ = json.Unmarshal(env.Data, &updated)
if updated.Active || updated.Title != "测试漏斗下架" {
t.Fatalf("bad update %#v", updated)
}
var n int
_ = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_audit_logs
WHERE action IN ('growth.funnel_definition.create','growth.funnel_definition.update') AND target_id=$1`,
created.ID).Scan(&n)
if n < 2 {
t.Fatalf("audit %d", n)
}
limitedRoleID := uuid.New()
_, _ = pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
limitedRoleID, "fn_ro_"+limitedRoleID.String()[:8])
_, _ = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.growth.read')`, limitedRoleID)
hash, _ := bcrypt.GenerateFromPassword([]byte("ro-pass"), bcrypt.DefaultCost)
roUser := fmt.Sprintf("fnro_%d", time.Now().UnixNano())
_, _ = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
roUser, string(hash), limitedRoleID)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, roUser)
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
})
roTok := adminLogin(t, r, roUser, "ro-pass")
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/analytics/funnel-definitions", map[string]any{
"code": "x_ro", "title": "no", "active": true,
}, roTok)
if httpCode != http.StatusForbidden {
t.Fatalf("expected 403 got %d", httpCode)
}
}
@@ -0,0 +1,116 @@
package integration_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
func TestExploreImageCardDeckWrite(t *testing.T) {
r, pool := setupAPIPool(t)
ctx := context.Background()
tok := adminLogin(t, r, "admin", "change-me")
devKey := fmt.Sprintf("deck-cfg-%d", time.Now().UnixNano())
code := fmt.Sprintf("deck_w_%d", time.Now().UnixNano()%1_000_000)
body := map[string]any{"code": code, "title": "测试意象牌组", "active": true}
env, httpCode := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/image-card-decks", body, tok)
if httpCode != 200 || env.Code != 0 {
t.Fatalf("create http=%d code=%d msg=%s", httpCode, env.Code, env.Message)
}
var created struct {
ID string `json:"id"`
Code string `json:"code"`
}
_ = json.Unmarshal(env.Data, &created)
if created.ID == "" {
t.Fatal("bad create")
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM image_card_decks WHERE id=$1`, created.ID)
})
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/image-card-decks", body, tok)
if httpCode != http.StatusConflict {
t.Fatalf("dup expected 409 got %d", httpCode)
}
env, _, httpCode = doJSONExpect(t, r, http.MethodGet, "/api/v1/cards/decks", nil, devKey, 0)
if httpCode != 200 {
t.Fatalf("public %d", httpCode)
}
var pub struct {
Items []struct {
Code string `json:"code"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &pub)
found := false
for _, it := range pub.Items {
if it.Code == code {
found = true
break
}
}
if !found {
t.Fatalf("missing active %#v", pub.Items)
}
body["active"] = false
_, httpCode = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/explore/image-card-decks/"+created.ID, body, tok)
if httpCode != 200 {
t.Fatalf("update %d", httpCode)
}
env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/cards/decks", nil, devKey, 0)
_ = json.Unmarshal(env.Data, &pub)
for _, it := range pub.Items {
if it.Code == code {
t.Fatal("inactive still listed")
}
}
_, _ = pool.Exec(ctx, `UPDATE image_card_decks SET active=false`)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `UPDATE image_card_decks SET active=true WHERE system=true`)
})
env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/cards/decks", nil, devKey, 0)
_ = json.Unmarshal(env.Data, &pub)
if len(pub.Items) != 0 {
t.Fatalf("expected empty after all inactive, got %#v", pub.Items)
}
var n int
_ = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_audit_logs
WHERE action IN ('explore.image_card_deck.create','explore.image_card_deck.update') AND target_id=$1`,
created.ID).Scan(&n)
if n < 2 {
t.Fatalf("audit %d", n)
}
limitedRoleID := uuid.New()
_, _ = pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
limitedRoleID, "dc_ro_"+limitedRoleID.String()[:8])
_, _ = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.explore.read')`, limitedRoleID)
hash, _ := bcrypt.GenerateFromPassword([]byte("ro-pass"), bcrypt.DefaultCost)
roUser := fmt.Sprintf("dcro_%d", time.Now().UnixNano())
_, _ = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
roUser, string(hash), limitedRoleID)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, roUser)
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
})
roTok := adminLogin(t, r, roUser, "ro-pass")
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/image-card-decks", map[string]any{
"code": "x_ro", "title": "no", "active": true,
}, roTok)
if httpCode != http.StatusForbidden {
t.Fatalf("expected 403 got %d", httpCode)
}
}
@@ -0,0 +1,110 @@
package integration_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
func TestGrowthReportTemplateWrite(t *testing.T) {
r, pool := setupAPIPool(t)
ctx := context.Background()
tok := adminLogin(t, r, "admin", "change-me")
code := fmt.Sprintf("rt_w_%d", time.Now().UnixNano()%1_000_000)
body := map[string]any{"code": code, "title": "测试报告模板", "scene": "portrait", "active": true}
env, httpCode := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/growth/report-templates", body, tok)
if httpCode != 200 || env.Code != 0 {
t.Fatalf("create http=%d code=%d msg=%s", httpCode, env.Code, env.Message)
}
var created struct {
ID string `json:"id"`
Code string `json:"code"`
Scene string `json:"scene"`
Active bool `json:"active"`
}
_ = json.Unmarshal(env.Data, &created)
if created.ID == "" || created.Code != code || created.Scene != "portrait" || !created.Active {
t.Fatalf("bad create %#v", created)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM report_templates WHERE id=$1`, created.ID)
})
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/growth/report-templates", body, tok)
if httpCode != http.StatusConflict {
t.Fatalf("dup expected 409 got %d", httpCode)
}
env, httpCode = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/growth/report-templates", nil, tok)
if httpCode != 200 {
t.Fatalf("list %d", httpCode)
}
var list struct {
Items []struct {
Code string `json:"code"`
Active bool `json:"active"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &list)
found := false
for _, it := range list.Items {
if it.Code == code && it.Active {
found = true
break
}
}
if !found {
t.Fatalf("missing active in list %#v", list.Items)
}
body["active"] = false
body["title"] = "测试报告模板下架"
env, httpCode = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/growth/report-templates/"+created.ID, body, tok)
if httpCode != 200 {
t.Fatalf("update %d", httpCode)
}
var updated struct {
Active bool `json:"active"`
Title string `json:"title"`
}
_ = json.Unmarshal(env.Data, &updated)
if updated.Active || updated.Title != "测试报告模板下架" {
t.Fatalf("bad update %#v", updated)
}
var n int
_ = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_audit_logs
WHERE action IN ('growth.report_template.create','growth.report_template.update') AND target_id=$1`,
created.ID).Scan(&n)
if n < 2 {
t.Fatalf("audit %d", n)
}
limitedRoleID := uuid.New()
_, _ = pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
limitedRoleID, "rt_ro_"+limitedRoleID.String()[:8])
_, _ = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.growth.read')`, limitedRoleID)
hash, _ := bcrypt.GenerateFromPassword([]byte("ro-pass"), bcrypt.DefaultCost)
roUser := fmt.Sprintf("rtro_%d", time.Now().UnixNano())
_, _ = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
roUser, string(hash), limitedRoleID)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, roUser)
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
})
roTok := adminLogin(t, r, roUser, "ro-pass")
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/growth/report-templates", map[string]any{
"code": "x_ro", "title": "no", "scene": "portrait", "active": true,
}, roTok)
if httpCode != http.StatusForbidden {
t.Fatalf("expected 403 got %d", httpCode)
}
}
@@ -0,0 +1,116 @@
package integration_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
func TestExploreScaleDefinitionWrite(t *testing.T) {
r, pool := setupAPIPool(t)
ctx := context.Background()
tok := adminLogin(t, r, "admin", "change-me")
slug := fmt.Sprintf("scale-w-%d", time.Now().UnixNano()%1_000_000)
body := map[string]any{"slug": slug, "title": "测试量表元数据", "description": "desc"}
env, httpCode := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/scales", body, tok)
if httpCode != 200 || env.Code != 0 {
t.Fatalf("create http=%d code=%d msg=%s", httpCode, env.Code, env.Message)
}
var created struct {
ID string `json:"id"`
Slug string `json:"slug"`
Status string `json:"status"`
Title string `json:"title"`
}
_ = json.Unmarshal(env.Data, &created)
if created.ID == "" || created.Status != "draft" {
t.Fatalf("bad create %#v", created)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM scales WHERE id=$1`, created.ID)
})
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/scales", body, tok)
if httpCode != http.StatusConflict {
t.Fatalf("dup expected 409 got %d", httpCode)
}
// draft must not appear on C-end published list
key := mustRegister(t, r)
env, key = doJSON(t, r, http.MethodGet, "/api/v1/scales", nil, key)
var pub struct {
Items []struct {
Slug string `json:"slug"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &pub)
for _, it := range pub.Items {
if it.Slug == slug {
t.Fatal("draft listed on C-end")
}
}
body["title"] = "改标题"
env, httpCode = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/explore/scales/"+created.ID, body, tok)
if httpCode != 200 {
t.Fatalf("update %d", httpCode)
}
_ = json.Unmarshal(env.Data, &created)
if created.Title != "改标题" || created.Status != "draft" {
t.Fatalf("put changed status or missed title %#v", created)
}
// publish via ECR-008 channel
_, httpCode = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/scales/"+created.ID, map[string]any{"status": "published"}, tok)
if httpCode != 200 {
t.Fatalf("patch status %d", httpCode)
}
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/scales", nil, key)
_ = json.Unmarshal(env.Data, &pub)
found := false
for _, it := range pub.Items {
if it.Slug == slug {
found = true
break
}
}
if !found {
t.Fatal("published missing on C-end")
}
var n int
_ = pool.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_audit_logs
WHERE action IN ('explore.scale_definition.create','explore.scale_definition.update') AND target_id=$1`,
created.ID).Scan(&n)
if n < 2 {
t.Fatalf("audit %d", n)
}
limitedRoleID := uuid.New()
_, _ = pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
limitedRoleID, "sd_ro_"+limitedRoleID.String()[:8])
_, _ = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.explore.read')`, limitedRoleID)
hash, _ := bcrypt.GenerateFromPassword([]byte("ro-pass"), bcrypt.DefaultCost)
roUser := fmt.Sprintf("sdro_%d", time.Now().UnixNano())
_, _ = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
roUser, string(hash), limitedRoleID)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, roUser)
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
})
roTok := adminLogin(t, r, roUser, "ro-pass")
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/scales", map[string]any{
"slug": "x-ro", "title": "no", "description": "",
}, roTok)
if httpCode != http.StatusForbidden {
t.Fatalf("expected 403 got %d", httpCode)
}
}
+119
View File
@@ -0,0 +1,119 @@
package javabridge
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client talks to the Java consult stack (internal issue + logout).
type Client struct {
BaseURL string
InternalKey string
HTTP *http.Client
}
// Token is a Java Redis access token.
type Token struct {
AccessToken string
MemberID int64
}
type issueBody struct {
OpenID string `json:"openid"`
UnionID string `json:"unionId"`
Phone string `json:"phone"`
}
type javaEnvelope struct {
Success bool `json:"success"`
Code any `json:"code"`
Info string `json:"info"`
Msg string `json:"msg"`
Data struct {
AccessToken string `json:"accessToken"`
MemberID int64 `json:"memberId"`
} `json:"data"`
}
// Enabled reports whether Java bridging is configured.
func (c *Client) Enabled() bool {
return c != nil && strings.TrimSpace(c.BaseURL) != "" && strings.TrimSpace(c.InternalKey) != ""
}
// IssueMiniApp asks Java to register-or-find mini_app account and mint a Redis token.
func (c *Client) IssueMiniApp(ctx context.Context, openid, unionID, phone string) (*Token, error) {
if !c.Enabled() {
return nil, errors.New("咨询后台未配置")
}
raw, _ := json.Marshal(issueBody{OpenID: openid, UnionID: unionID, Phone: phone})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/app-api/oauth/internal/issue-mini-app", bytes.NewReader(raw))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Internal-Key", c.InternalKey)
cli := c.http()
resp, err := cli.Do(req)
if err != nil {
return nil, errors.New("咨询账号同步失败")
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var env javaEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, errors.New("咨询账号同步失败")
}
codeOK := env.Success || fmt.Sprint(env.Code) == "0" || fmt.Sprint(env.Code) == "200"
if resp.StatusCode >= 300 || (!codeOK && env.Data.AccessToken == "") {
msg := env.Info
if msg == "" {
msg = env.Msg
}
if msg == "" {
msg = "咨询账号同步失败"
}
return nil, errors.New(msg)
}
if env.Data.AccessToken == "" {
return nil, errors.New("咨询账号同步失败")
}
return &Token{AccessToken: env.Data.AccessToken, MemberID: env.Data.MemberID}, nil
}
// Logout revokes a Java access token (best effort).
func (c *Client) Logout(ctx context.Context, accessToken string) {
if !c.Enabled() || strings.TrimSpace(accessToken) == "" {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/app-api/oauth/app/logout", nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := c.http().Do(req)
if err == nil && resp != nil {
_ = resp.Body.Close()
}
}
func (c *Client) http() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: 20 * time.Second}
}
// HTTPDo relays an already-built request (consult proxy).
func (c *Client) HTTPDo(req *http.Request) (*http.Response, error) {
if c == nil {
return nil, errors.New("咨询服务未配置")
}
return c.http().Do(req)
}
+94 -10
View File
@@ -17,22 +17,27 @@ type AuthRepo struct {
// AccountRow is a registered user snapshot. // AccountRow is a registered user snapshot.
type AccountRow struct { type AccountRow struct {
ID uuid.UUID ID uuid.UUID
Phone string Phone string
PasswordHash string PasswordHash string
Nickname string Nickname string
AvatarURL string AvatarURL string
Status string Status string
WxOpenID string
WxUnionID string
JavaPlatformUserID int64
} }
// GetByPhone loads a registered user by phone. // GetByPhone loads a registered user by phone.
func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) { func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) {
row := &AccountRow{} row := &AccountRow{}
err := r.Pool.QueryRow(ctx, ` err := r.Pool.QueryRow(ctx, `
SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status,
COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0)
FROM users FROM users
WHERE phone=$1 AND deleted_at IS NULL`, phone, WHERE phone=$1 AND deleted_at IS NULL`, phone,
).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status) ).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status,
&row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, err return nil, err
} }
@@ -47,9 +52,11 @@ func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRo
row := &AccountRow{} row := &AccountRow{}
var phone, hash *string var phone, hash *string
err := r.Pool.QueryRow(ctx, ` err := r.Pool.QueryRow(ctx, `
SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status,
COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0)
FROM users WHERE id=$1 AND deleted_at IS NULL`, userID, FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
).Scan(&row.ID, &phone, &hash, &row.Nickname, &row.AvatarURL, &row.Status) ).Scan(&row.ID, &phone, &hash, &row.Nickname, &row.AvatarURL, &row.Status,
&row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -179,6 +186,83 @@ func (r *AuthRepo) RevokeSession(ctx context.Context, token string) error {
return err return err
} }
// GetByWxOpenid loads a user by WeChat openid.
func (r *AuthRepo) GetByWxOpenid(ctx context.Context, openid string) (*AccountRow, error) {
row := &AccountRow{}
err := r.Pool.QueryRow(ctx, `
SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status,
COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0)
FROM users
WHERE wx_openid=$1 AND deleted_at IS NULL`, openid,
).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status,
&row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return row, nil
}
// BindWeChat writes openid/unionid (and optional phone) onto a user.
func (r *AuthRepo) BindWeChat(ctx context.Context, userID uuid.UUID, openid, unionID, phone string) error {
_, err := r.Pool.Exec(ctx, `
UPDATE users
SET wx_openid=$2,
wx_unionid=COALESCE(NULLIF($3,''), wx_unionid),
phone=COALESCE(NULLIF($4,''), phone),
updated_at=now()
WHERE id=$1 AND deleted_at IS NULL`,
userID, openid, unionID, phone,
)
return err
}
// SetJavaPlatformUserID stores the Java t_platform_user.id.
func (r *AuthRepo) SetJavaPlatformUserID(ctx context.Context, userID uuid.UUID, javaID int64) error {
_, err := r.Pool.Exec(ctx, `
UPDATE users SET java_platform_user_id=$2, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL`, userID, javaID)
return err
}
// SetSessionJavaToken stores the Java Redis token on a Go session.
func (r *AuthRepo) SetSessionJavaToken(ctx context.Context, goToken, javaToken string) error {
_, err := r.Pool.Exec(ctx, `
UPDATE user_sessions SET java_access_token=$2
WHERE token=$1 AND revoked_at IS NULL`, goToken, javaToken)
return err
}
// JavaTokenByGoToken returns the cached Java token for a live Go session.
func (r *AuthRepo) JavaTokenByGoToken(ctx context.Context, goToken string) (string, error) {
var tok *string
err := r.Pool.QueryRow(ctx, `
SELECT java_access_token FROM user_sessions
WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, goToken,
).Scan(&tok)
if err != nil {
return "", err
}
if tok == nil {
return "", nil
}
return *tok, nil
}
// CreateUserWithWeChat inserts a registered user bound to WeChat.
func (r *AuthRepo) CreateUserWithWeChat(ctx context.Context, phone, hash, nickname, openid, unionID string) (uuid.UUID, error) {
var id uuid.UUID
err := r.Pool.QueryRow(ctx, `
INSERT INTO users(phone, password_hash, nickname, wx_openid, wx_unionid)
VALUES ($1,$2,NULLIF($3,''),$4,NULLIF($5,''))
RETURNING id`,
phone, hash, nickname, openid, unionID,
).Scan(&id)
return id, err
}
// IsRegistered reports whether user has phone. // IsRegistered reports whether user has phone.
func (r *AuthRepo) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) { func (r *AuthRepo) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) {
var ok bool var ok bool
@@ -0,0 +1,116 @@
package repository
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// FunnelDefinitionWriteInput is create/update payload.
type FunnelDefinitionWriteInput struct {
Code string
Title string
Active bool
}
// CreateFunnelDefinitionWithAudit inserts and audits.
func (r *AdminRepo) CreateFunnelDefinitionWithAudit(
ctx context.Context, adminID uuid.UUID, in FunnelDefinitionWriteInput, meta json.RawMessage,
) (*FunnelDefinitionRow, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var row FunnelDefinitionRow
err = tx.QueryRow(ctx, `
INSERT INTO funnel_definitions(code, title, active, system)
VALUES ($1,$2,$3,false)
RETURNING id, code, title, active, system, updated_at`,
in.Code, in.Title, in.Active,
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
if err != nil {
return nil, mapFunnelDefinitionWriteErr(err)
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'growth.funnel_definition.create','funnel_definition',$2,$3)`,
adminID, row.ID.String(), meta,
); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &row, nil
}
// UpdateFunnelDefinitionWithAudit updates and audits.
func (r *AdminRepo) UpdateFunnelDefinitionWithAudit(
ctx context.Context, adminID, id uuid.UUID, in FunnelDefinitionWriteInput, meta json.RawMessage,
) (*FunnelDefinitionRow, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var system bool
var oldCode string
err = tx.QueryRow(ctx, `SELECT system, code FROM funnel_definitions WHERE id=$1`, id).Scan(&system, &oldCode)
if errors.Is(err, pgx.ErrNoRows) {
return nil, pgx.ErrNoRows
}
if err != nil {
return nil, err
}
code := in.Code
if system {
code = oldCode
}
var row FunnelDefinitionRow
err = tx.QueryRow(ctx, `
UPDATE funnel_definitions
SET code=$2, title=$3, active=$4, updated_at=now()
WHERE id=$1
RETURNING id, code, title, active, system, updated_at`,
id, code, in.Title, in.Active,
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
if err != nil {
return nil, mapFunnelDefinitionWriteErr(err)
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'growth.funnel_definition.update','funnel_definition',$2,$3)`,
adminID, id.String(), meta,
); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &row, nil
}
func mapFunnelDefinitionWriteErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return errString("funnel definition code conflict")
}
return err
}
// FunnelDefinitionCodeConflict reports unique violation.
func FunnelDefinitionCodeConflict(err error) bool {
return err != nil && strings.Contains(err.Error(), "funnel definition code conflict")
}
@@ -0,0 +1,139 @@
package repository
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// ImageCardDeckWriteInput is create/update payload.
type ImageCardDeckWriteInput struct {
Code string
Title string
Active bool
}
// ListActiveImageCardDecks returns active decks for C-end.
func (r *AdminRepo) ListActiveImageCardDecks(ctx context.Context) ([]ImageCardDeckRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM image_card_decks
WHERE active = true
ORDER BY code ASC
LIMIT 100`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ImageCardDeckRow
for rows.Next() {
var row ImageCardDeckRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// CreateImageCardDeckWithAudit inserts and audits.
func (r *AdminRepo) CreateImageCardDeckWithAudit(
ctx context.Context, adminID uuid.UUID, in ImageCardDeckWriteInput, meta json.RawMessage,
) (*ImageCardDeckRow, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var row ImageCardDeckRow
err = tx.QueryRow(ctx, `
INSERT INTO image_card_decks(code, title, active, system)
VALUES ($1,$2,$3,false)
RETURNING id, code, title, active, system, updated_at`,
in.Code, in.Title, in.Active,
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
if err != nil {
return nil, mapImageCardDeckWriteErr(err)
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'explore.image_card_deck.create','image_card_deck',$2,$3)`,
adminID, row.ID.String(), meta,
); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &row, nil
}
// UpdateImageCardDeckWithAudit updates and audits.
func (r *AdminRepo) UpdateImageCardDeckWithAudit(
ctx context.Context, adminID, id uuid.UUID, in ImageCardDeckWriteInput, meta json.RawMessage,
) (*ImageCardDeckRow, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var system bool
var oldCode string
err = tx.QueryRow(ctx, `SELECT system, code FROM image_card_decks WHERE id=$1`, id).Scan(&system, &oldCode)
if errors.Is(err, pgx.ErrNoRows) {
return nil, pgx.ErrNoRows
}
if err != nil {
return nil, err
}
code := in.Code
if system {
code = oldCode
}
var row ImageCardDeckRow
err = tx.QueryRow(ctx, `
UPDATE image_card_decks
SET code=$2, title=$3, active=$4, updated_at=now()
WHERE id=$1
RETURNING id, code, title, active, system, updated_at`,
id, code, in.Title, in.Active,
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
if err != nil {
return nil, mapImageCardDeckWriteErr(err)
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'explore.image_card_deck.update','image_card_deck',$2,$3)`,
adminID, id.String(), meta,
); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &row, nil
}
func mapImageCardDeckWriteErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return errString("image card deck code conflict")
}
return err
}
// ImageCardDeckCodeConflict reports unique violation.
func ImageCardDeckCodeConflict(err error) bool {
return err != nil && strings.Contains(err.Error(), "image card deck code conflict")
}
@@ -0,0 +1,117 @@
package repository
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// ReportTemplateWriteInput is create/update payload.
type ReportTemplateWriteInput struct {
Code string
Title string
Scene string
Active bool
}
// CreateReportTemplateWithAudit inserts and audits.
func (r *AdminRepo) CreateReportTemplateWithAudit(
ctx context.Context, adminID uuid.UUID, in ReportTemplateWriteInput, meta json.RawMessage,
) (*ReportTemplateRow, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var row ReportTemplateRow
err = tx.QueryRow(ctx, `
INSERT INTO report_templates(code, title, scene, active, system)
VALUES ($1,$2,$3,$4,false)
RETURNING id, code, title, scene, active, system, updated_at`,
in.Code, in.Title, in.Scene, in.Active,
).Scan(&row.ID, &row.Code, &row.Title, &row.Scene, &row.Active, &row.System, &row.UpdatedAt)
if err != nil {
return nil, mapReportTemplateWriteErr(err)
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'growth.report_template.create','report_template',$2,$3)`,
adminID, row.ID.String(), meta,
); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &row, nil
}
// UpdateReportTemplateWithAudit updates and audits.
func (r *AdminRepo) UpdateReportTemplateWithAudit(
ctx context.Context, adminID, id uuid.UUID, in ReportTemplateWriteInput, meta json.RawMessage,
) (*ReportTemplateRow, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var system bool
var oldCode string
err = tx.QueryRow(ctx, `SELECT system, code FROM report_templates WHERE id=$1`, id).Scan(&system, &oldCode)
if errors.Is(err, pgx.ErrNoRows) {
return nil, pgx.ErrNoRows
}
if err != nil {
return nil, err
}
code := in.Code
if system {
code = oldCode
}
var row ReportTemplateRow
err = tx.QueryRow(ctx, `
UPDATE report_templates
SET code=$2, title=$3, scene=$4, active=$5, updated_at=now()
WHERE id=$1
RETURNING id, code, title, scene, active, system, updated_at`,
id, code, in.Title, in.Scene, in.Active,
).Scan(&row.ID, &row.Code, &row.Title, &row.Scene, &row.Active, &row.System, &row.UpdatedAt)
if err != nil {
return nil, mapReportTemplateWriteErr(err)
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'growth.report_template.update','report_template',$2,$3)`,
adminID, id.String(), meta,
); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &row, nil
}
func mapReportTemplateWriteErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return errString("report template code conflict")
}
return err
}
// ReportTemplateCodeConflict reports unique violation.
func ReportTemplateCodeConflict(err error) bool {
return err != nil && strings.Contains(err.Error(), "report template code conflict")
}
@@ -0,0 +1,106 @@
package repository
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// ScaleDefinitionWriteInput is explore-side metadata payload (no status).
type ScaleDefinitionWriteInput struct {
Slug string
Title string
Description string
}
// CreateScaleDefinitionWithAudit inserts draft scale metadata + audit.
func (r *ScaleRepo) CreateScaleDefinitionWithAudit(
ctx context.Context, adminID uuid.UUID, in ScaleDefinitionWriteInput, meta json.RawMessage,
) (*ScaleAdminItem, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var it ScaleAdminItem
err = tx.QueryRow(ctx, `
INSERT INTO scales(slug, title, description, status)
VALUES ($1,$2,$3,'draft')
RETURNING id, slug, title, description, status`,
in.Slug, in.Title, in.Description,
).Scan(&it.ID, &it.Slug, &it.Title, &it.Description, &it.Status)
if err != nil {
return nil, mapScaleDefinitionWriteErr(err)
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'explore.scale_definition.create','scale',$2,$3)`,
adminID, it.ID.String(), meta,
); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &it, nil
}
// UpdateScaleDefinitionWithAudit updates metadata only (never status).
func (r *ScaleRepo) UpdateScaleDefinitionWithAudit(
ctx context.Context, adminID, id uuid.UUID, in ScaleDefinitionWriteInput, meta json.RawMessage,
) (*ScaleAdminItem, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var it ScaleAdminItem
err = tx.QueryRow(ctx, `
UPDATE scales
SET slug=$2, title=$3, description=$4, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL
RETURNING id, slug, title, description, status`,
id, in.Slug, in.Title, in.Description,
).Scan(&it.ID, &it.Slug, &it.Title, &it.Description, &it.Status)
if errors.Is(err, pgx.ErrNoRows) {
return nil, pgx.ErrNoRows
}
if err != nil {
return nil, mapScaleDefinitionWriteErr(err)
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'explore.scale_definition.update','scale',$2,$3)`,
adminID, id.String(), meta,
); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &it, nil
}
func mapScaleDefinitionWriteErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return errString("scale slug conflict")
}
return err
}
// ScaleSlugConflict reports unique violation.
func ScaleSlugConflict(err error) bool {
return err != nil && strings.Contains(err.Error(), "scale slug conflict")
}
@@ -0,0 +1,71 @@
package admin
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrInvalidFunnelDefinition = errors.New("invalid funnel definition")
ErrFunnelDefinitionConflict = errors.New("funnel definition code conflict")
funnelDefinitionCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`)
)
// FunnelDefinitionWriteBody is JSON for create/update.
type FunnelDefinitionWriteBody struct {
Code string `json:"code"`
Title string `json:"title"`
Active bool `json:"active"`
}
// CreateFunnelDefinition validates, inserts, audits.
func (s *Service) CreateFunnelDefinition(ctx context.Context, adminID uuid.UUID, body FunnelDefinitionWriteBody) (*repository.FunnelDefinitionRow, error) {
in, err := normalizeFunnelDefinitionWrite(body)
if err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
row, err := s.Repo.CreateFunnelDefinitionWithAudit(ctx, adminID, in, meta)
if repository.FunnelDefinitionCodeConflict(err) {
return nil, ErrFunnelDefinitionConflict
}
return row, err
}
// UpdateFunnelDefinition validates, updates, audits.
func (s *Service) UpdateFunnelDefinition(ctx context.Context, adminID, id uuid.UUID, body FunnelDefinitionWriteBody) (*repository.FunnelDefinitionRow, error) {
in, err := normalizeFunnelDefinitionWrite(body)
if err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
row, err := s.Repo.UpdateFunnelDefinitionWithAudit(ctx, adminID, id, in, meta)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrFunnelDefinitionNotFound
}
if repository.FunnelDefinitionCodeConflict(err) {
return nil, ErrFunnelDefinitionConflict
}
return row, err
}
func normalizeFunnelDefinitionWrite(body FunnelDefinitionWriteBody) (repository.FunnelDefinitionWriteInput, error) {
code := strings.TrimSpace(body.Code)
title := strings.TrimSpace(body.Title)
if !funnelDefinitionCodeRe.MatchString(code) {
return repository.FunnelDefinitionWriteInput{}, ErrInvalidFunnelDefinition
}
if title == "" || utf8.RuneCountInString(title) > 128 {
return repository.FunnelDefinitionWriteInput{}, ErrInvalidFunnelDefinition
}
return repository.FunnelDefinitionWriteInput{Code: code, Title: title, Active: body.Active}, nil
}
@@ -0,0 +1,71 @@
package admin
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrInvalidImageCardDeck = errors.New("invalid image card deck")
ErrImageCardDeckConflict = errors.New("image card deck code conflict")
imageCardDeckCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`)
)
// ImageCardDeckWriteBody is JSON for create/update.
type ImageCardDeckWriteBody struct {
Code string `json:"code"`
Title string `json:"title"`
Active bool `json:"active"`
}
// CreateImageCardDeck validates, inserts, audits.
func (s *Service) CreateImageCardDeck(ctx context.Context, adminID uuid.UUID, body ImageCardDeckWriteBody) (*repository.ImageCardDeckRow, error) {
in, err := normalizeImageCardDeckWrite(body)
if err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
row, err := s.Repo.CreateImageCardDeckWithAudit(ctx, adminID, in, meta)
if repository.ImageCardDeckCodeConflict(err) {
return nil, ErrImageCardDeckConflict
}
return row, err
}
// UpdateImageCardDeck validates, updates, audits.
func (s *Service) UpdateImageCardDeck(ctx context.Context, adminID, id uuid.UUID, body ImageCardDeckWriteBody) (*repository.ImageCardDeckRow, error) {
in, err := normalizeImageCardDeckWrite(body)
if err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
row, err := s.Repo.UpdateImageCardDeckWithAudit(ctx, adminID, id, in, meta)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrImageCardDeckNotFound
}
if repository.ImageCardDeckCodeConflict(err) {
return nil, ErrImageCardDeckConflict
}
return row, err
}
func normalizeImageCardDeckWrite(body ImageCardDeckWriteBody) (repository.ImageCardDeckWriteInput, error) {
code := strings.TrimSpace(body.Code)
title := strings.TrimSpace(body.Title)
if !imageCardDeckCodeRe.MatchString(code) {
return repository.ImageCardDeckWriteInput{}, ErrInvalidImageCardDeck
}
if title == "" || utf8.RuneCountInString(title) > 128 {
return repository.ImageCardDeckWriteInput{}, ErrInvalidImageCardDeck
}
return repository.ImageCardDeckWriteInput{Code: code, Title: title, Active: body.Active}, nil
}
+3 -2
View File
@@ -35,7 +35,8 @@ const (
PermPrivacyRead = "admin.privacy.read" PermPrivacyRead = "admin.privacy.read"
PermExploreRead = "admin.explore.read" PermExploreRead = "admin.explore.read"
PermExploreWrite = "admin.explore.write" PermExploreWrite = "admin.explore.write"
PermGrowthRead = "admin.growth.read" PermGrowthRead = "admin.growth.read"
PermGrowthWrite = "admin.growth.write"
) )
var knownPermissions = map[string]struct{}{ var knownPermissions = map[string]struct{}{
@@ -45,7 +46,7 @@ var knownPermissions = map[string]struct{}{
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {}, PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {}, PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
PermAskRead: {}, PermAskTranscriptRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {}, PermAskRead: {}, PermAskTranscriptRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermCMSWrite: {}, PermGrowthRead: {}, PermExploreRead: {}, PermExploreWrite: {}, PermPrivacyRead: {}, PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermCMSWrite: {}, PermGrowthRead: {}, PermGrowthWrite: {}, PermExploreRead: {}, PermExploreWrite: {}, PermPrivacyRead: {},
} }
var ( var (
@@ -0,0 +1,77 @@
package admin
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrInvalidReportTemplate = errors.New("invalid report template")
ErrReportTemplateConflict = errors.New("report template code conflict")
reportTemplateCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`)
reportTemplateSceneRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,30}$`)
)
// ReportTemplateWriteBody is JSON for create/update.
type ReportTemplateWriteBody struct {
Code string `json:"code"`
Title string `json:"title"`
Scene string `json:"scene"`
Active bool `json:"active"`
}
// CreateReportTemplate validates, inserts, audits.
func (s *Service) CreateReportTemplate(ctx context.Context, adminID uuid.UUID, body ReportTemplateWriteBody) (*repository.ReportTemplateRow, error) {
in, err := normalizeReportTemplateWrite(body)
if err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{"code": in.Code, "scene": in.Scene, "active": in.Active})
row, err := s.Repo.CreateReportTemplateWithAudit(ctx, adminID, in, meta)
if repository.ReportTemplateCodeConflict(err) {
return nil, ErrReportTemplateConflict
}
return row, err
}
// UpdateReportTemplate validates, updates, audits.
func (s *Service) UpdateReportTemplate(ctx context.Context, adminID, id uuid.UUID, body ReportTemplateWriteBody) (*repository.ReportTemplateRow, error) {
in, err := normalizeReportTemplateWrite(body)
if err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{"code": in.Code, "scene": in.Scene, "active": in.Active})
row, err := s.Repo.UpdateReportTemplateWithAudit(ctx, adminID, id, in, meta)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrReportTemplateNotFound
}
if repository.ReportTemplateCodeConflict(err) {
return nil, ErrReportTemplateConflict
}
return row, err
}
func normalizeReportTemplateWrite(body ReportTemplateWriteBody) (repository.ReportTemplateWriteInput, error) {
code := strings.TrimSpace(body.Code)
title := strings.TrimSpace(body.Title)
scene := strings.TrimSpace(body.Scene)
if !reportTemplateCodeRe.MatchString(code) {
return repository.ReportTemplateWriteInput{}, ErrInvalidReportTemplate
}
if !reportTemplateSceneRe.MatchString(scene) {
return repository.ReportTemplateWriteInput{}, ErrInvalidReportTemplate
}
if title == "" || utf8.RuneCountInString(title) > 128 {
return repository.ReportTemplateWriteInput{}, ErrInvalidReportTemplate
}
return repository.ReportTemplateWriteInput{Code: code, Title: title, Scene: scene, Active: body.Active}, nil
}
@@ -0,0 +1,81 @@
package admin
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrInvalidScaleDefinition = errors.New("invalid scale definition")
ErrScaleDefinitionConflict = errors.New("scale slug conflict")
scaleSlugRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}$`)
)
// ScaleDefinitionWriteBody is explore-side JSON (no status).
type ScaleDefinitionWriteBody struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
}
// CreateScaleDefinition creates draft metadata.
func (s *Service) CreateScaleDefinition(ctx context.Context, adminID uuid.UUID, body ScaleDefinitionWriteBody) (*repository.ScaleAdminItem, error) {
if s.Scales == nil {
return nil, errors.New("scales unavailable")
}
in, err := normalizeScaleDefinitionWrite(body)
if err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{"slug": in.Slug})
row, err := s.Scales.CreateScaleDefinitionWithAudit(ctx, adminID, in, meta)
if repository.ScaleSlugConflict(err) {
return nil, ErrScaleDefinitionConflict
}
return row, err
}
// UpdateScaleDefinition updates metadata; preserves status.
func (s *Service) UpdateScaleDefinition(ctx context.Context, adminID, id uuid.UUID, body ScaleDefinitionWriteBody) (*repository.ScaleAdminItem, error) {
if s.Scales == nil {
return nil, errors.New("scales unavailable")
}
in, err := normalizeScaleDefinitionWrite(body)
if err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{"slug": in.Slug})
row, err := s.Scales.UpdateScaleDefinitionWithAudit(ctx, adminID, id, in, meta)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrScaleNotFound
}
if repository.ScaleSlugConflict(err) {
return nil, ErrScaleDefinitionConflict
}
return row, err
}
func normalizeScaleDefinitionWrite(body ScaleDefinitionWriteBody) (repository.ScaleDefinitionWriteInput, error) {
slug := strings.TrimSpace(body.Slug)
title := strings.TrimSpace(body.Title)
desc := strings.TrimSpace(body.Description)
if !scaleSlugRe.MatchString(slug) {
return repository.ScaleDefinitionWriteInput{}, ErrInvalidScaleDefinition
}
if title == "" || utf8.RuneCountInString(title) > 128 {
return repository.ScaleDefinitionWriteInput{}, ErrInvalidScaleDefinition
}
if utf8.RuneCountInString(desc) > 512 {
return repository.ScaleDefinitionWriteInput{}, ErrInvalidScaleDefinition
}
return repository.ScaleDefinitionWriteInput{Slug: slug, Title: title, Description: desc}, nil
}
+101
View File
@@ -14,9 +14,11 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar" "github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
"github.com/yuxingu/digital-psychology/apps/api/internal/javabridge"
nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname" nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe" "github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
"github.com/yuxingu/digital-psychology/apps/api/internal/wechat"
) )
// Service handles register/login sessions. // Service handles register/login sessions.
@@ -24,6 +26,9 @@ import (
type Service struct { type Service struct {
Repo *repository.AuthRepo Repo *repository.AuthRepo
AvatarDir string AvatarDir string
WeChat wechat.Client
WeChatApp string
Java *javabridge.Client
} }
// Me is the public account payload. // Me is the public account payload.
@@ -159,10 +164,106 @@ func (s *Service) UpdateAvatar(ctx context.Context, userID uuid.UUID, fh *multip
return s.Me(ctx, userID) return s.Me(ctx, userID)
} }
// WeChatLogin signs in with mini-program js_code + phone encryptedData.
func (s *Service) WeChatLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, code, encryptedData, iv string) (*SessionResult, error) {
if s.WeChat == nil {
return nil, errors.New("微信登录未配置")
}
sess, err := s.WeChat.Code2Session(ctx, code)
if err != nil {
return nil, err
}
phone, err := wechat.DecryptPhone(sess.SessionKey, encryptedData, iv, s.WeChatApp)
if err != nil {
return nil, err
}
acc, err := s.Repo.GetByWxOpenid(ctx, sess.OpenID)
if err == nil {
if acc.Status != "active" {
return nil, ErrAccountRestricted
}
_ = s.Repo.BindWeChat(ctx, acc.ID, sess.OpenID, sess.UnionID, phone)
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
}
return s.finishWeChat(ctx, acc.ID, phone, acc.Nickname, false, sess, deviceKey)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
byPhone, err := s.Repo.GetByPhone(ctx, phone)
if err == nil {
if byPhone.Status != "active" {
return nil, ErrAccountRestricted
}
_ = s.Repo.BindWeChat(ctx, byPhone.ID, sess.OpenID, sess.UnionID, phone)
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, byPhone.ID)
}
nick := byPhone.Nickname
if nick == "" {
nick = nickgen.Random()
_ = s.Repo.UpdateNickname(ctx, byPhone.ID, nick)
}
return s.finishWeChat(ctx, byPhone.ID, phone, nick, false, sess, deviceKey)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
hash, err := bcrypt.GenerateFromPassword([]byte("wx:"+sess.OpenID), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
nick := nickgen.Random()
uid := deviceUserID
cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
if curErr == nil && cur.Phone == "" && cur.WxOpenID == "" {
if cur.Status != "active" {
return nil, ErrAccountRestricted
}
if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, string(hash), nick); err != nil {
return nil, errors.New("登录失败,请重试")
}
_ = s.Repo.BindWeChat(ctx, deviceUserID, sess.OpenID, sess.UnionID, phone)
} else {
uid, err = s.Repo.CreateUserWithWeChat(ctx, phone, string(hash), nick, sess.OpenID, sess.UnionID)
if err != nil {
return nil, errors.New("登录失败,请重试")
}
}
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, uid)
}
return s.finishWeChat(ctx, uid, phone, nick, true, sess, deviceKey)
}
func (s *Service) finishWeChat(ctx context.Context, userID uuid.UUID, phone, nickname string, isNew bool, sess wechat.Session, _ string) (*SessionResult, error) {
res, err := s.issue(ctx, userID, phone, nickname, isNew)
if err != nil {
return nil, err
}
if s.Java != nil && s.Java.Enabled() {
if tok, jerr := s.Java.IssueMiniApp(ctx, sess.OpenID, sess.UnionID, phone); jerr == nil && tok != nil {
_ = s.Repo.SetSessionJavaToken(ctx, res.Token, tok.AccessToken)
if tok.MemberID > 0 {
_ = s.Repo.SetJavaPlatformUserID(ctx, userID, tok.MemberID)
}
}
}
return res, nil
}
// Logout revokes the current bearer session and unbinds the device from the account // Logout revokes the current bearer session and unbinds the device from the account
// so a refresh no longer resolves as logged-in via X-Device-Key (Spec R5). // so a refresh no longer resolves as logged-in via X-Device-Key (Spec R5).
func (s *Service) Logout(ctx context.Context, token, deviceKey string) error { func (s *Service) Logout(ctx context.Context, token, deviceKey string) error {
if token != "" { if token != "" {
if s.Java != nil && s.Java.Enabled() {
if jt, err := s.Repo.JavaTokenByGoToken(ctx, token); err == nil && jt != "" {
s.Java.Logout(ctx, jt)
}
}
if err := s.Repo.RevokeSession(ctx, token); err != nil { if err := s.Repo.RevokeSession(ctx, token); err != nil {
return err return err
} }
@@ -0,0 +1,818 @@
package consult
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/wechatpay"
)
// Service implements miniprogram consult APIs on PostgreSQL.
type Service struct {
Pool *pgxpool.Pool
Pay wechatpay.Config
}
// Registered reports users.phone present.
func (s *Service) Registered(ctx context.Context, userID uuid.UUID) bool {
var ok bool
_ = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id=$1 AND phone IS NOT NULL AND deleted_at IS NULL)`, userID).Scan(&ok)
return ok
}
func (s *Service) Banners(ctx context.Context) ([]map[string]any, error) {
rows, err := s.Pool.Query(ctx, `SELECT id, banner_name, banner_image, click_url, describe, order_index, created_at, jump_type FROM consult_banners ORDER BY order_index, id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, order, jump int64
var name, img, url, desc string
var created time.Time
if err := rows.Scan(&id, &name, &img, &url, &desc, &order, &created, &jump); err != nil {
return nil, err
}
out = append(out, map[string]any{
"id": id, "bannerName": name, "bannerImage": img, "clickUrl": url,
"describe": desc, "orderIndex": order, "createTime": created.Format(time.RFC3339), "jumpType": jump,
})
}
return out, rows.Err()
}
func (s *Service) NewsPage(ctx context.Context, typ, showMain, pageNo, pageSize int) (map[string]any, error) {
if pageNo <= 0 {
pageNo = 1
}
if pageSize <= 0 {
pageSize = 20
}
var total int
q := `SELECT COUNT(*) FROM consult_news WHERE ($1=0 OR type=$1) AND ($2<0 OR show_main=$2)`
if err := s.Pool.QueryRow(ctx, q, typ, showMain).Scan(&total); err != nil {
return nil, err
}
rows, err := s.Pool.Query(ctx, `
SELECT id, type, title, show_image, content, show_main
FROM consult_news WHERE ($1=0 OR type=$1) AND ($2<0 OR show_main=$2)
ORDER BY id DESC OFFSET $3 LIMIT $4`, typ, showMain, (pageNo-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
defer rows.Close()
list := []map[string]any{}
for rows.Next() {
var id, t, sm int64
var title, img, content string
if err := rows.Scan(&id, &t, &title, &img, &content, &sm); err != nil {
return nil, err
}
list = append(list, map[string]any{"id": id, "type": t, "title": title, "showImage": img, "content": content, "showMain": sm})
}
return map[string]any{"list": list, "total": total}, rows.Err()
}
func (s *Service) NewsGet(ctx context.Context, id int64) (map[string]any, error) {
var t, sm int64
var title, img, content string
err := s.Pool.QueryRow(ctx, `SELECT type, title, show_image, content, show_main FROM consult_news WHERE id=$1`, id).
Scan(&t, &title, &img, &content, &sm)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("资讯不存在")
}
if err != nil {
return nil, err
}
return map[string]any{"id": id, "type": t, "title": title, "showImage": img, "content": content, "showMain": sm}, nil
}
func (s *Service) ProtocolByCode(ctx context.Context, code string) (map[string]any, error) {
var id int64
var title, contextText string
err := s.Pool.QueryRow(ctx, `SELECT id, title, context FROM consult_protocols WHERE code=$1 AND status=1`, code).
Scan(&id, &title, &contextText)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("协议不存在")
}
if err != nil {
return nil, err
}
return map[string]any{"id": id, "code": code, "title": title, "context": contextText}, nil
}
func (s *Service) TestList(ctx context.Context, showMain int) ([]map[string]any, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, test_name, sub_title, test_pic, total_num, show_main
FROM consult_tests WHERE status=1 AND ($1<0 OR show_main=$1) ORDER BY id`, showMain)
if err != nil {
return nil, err
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, total, sm int64
var name, sub, pic string
if err := rows.Scan(&id, &name, &sub, &pic, &total, &sm); err != nil {
return nil, err
}
out = append(out, map[string]any{"id": id, "testName": name, "subTitle": sub, "testPic": pic, "totalNum": total, "showMain": sm})
}
return out, rows.Err()
}
func (s *Service) TestDetail(ctx context.Context, studyID int64) (map[string]any, error) {
var name, sub, pic, intro, notice string
var total int64
err := s.Pool.QueryRow(ctx, `
SELECT test_name, sub_title, test_pic, test_introduction, total_num, test_notice
FROM consult_tests WHERE id=$1 AND status=1`, studyID).
Scan(&name, &sub, &pic, &intro, &total, &notice)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("测评不存在")
}
if err != nil {
return nil, err
}
qrows, err := s.Pool.Query(ctx, `
SELECT id, question_type, question_text, question_image, required, order_index
FROM consult_questions WHERE test_id=$1 ORDER BY order_index, id`, studyID)
if err != nil {
return nil, err
}
defer qrows.Close()
questions := []map[string]any{}
for qrows.Next() {
var qid, qt, req, ord int64
var text, img string
if err := qrows.Scan(&qid, &qt, &text, &img, &req, &ord); err != nil {
return nil, err
}
orows, err := s.Pool.Query(ctx, `SELECT id, option_text, order_index FROM consult_options WHERE question_id=$1 ORDER BY order_index, id`, qid)
if err != nil {
return nil, err
}
opts := []map[string]any{}
for orows.Next() {
var oid, oord int64
var ot string
if err := orows.Scan(&oid, &ot, &oord); err != nil {
orows.Close()
return nil, err
}
opts = append(opts, map[string]any{"id": oid, "optionText": ot, "orderIndex": oord})
}
orows.Close()
questions = append(questions, map[string]any{
"id": qid, "questionType": qt, "questionText": text, "questionImgae": img,
"required": req, "orderIndex": ord, "questionOptionVOList": opts,
})
}
return map[string]any{
"id": studyID, "testName": name, "subTitle": sub, "testPic": pic,
"testIntroduction": intro, "totalNum": total, "testNotice": notice,
"questionDetailVOList": questions,
}, nil
}
type ChoiceIn struct {
ID int64 `json:"id"`
TotalTime int `json:"totalTime"`
UserChoiceOptionVOList []struct {
QuestionID int64 `json:"questionId"`
OptionID int64 `json:"optionId"`
} `json:"userChoiceOptionVOList"`
}
func (s *Service) SaveChoice(ctx context.Context, userID uuid.UUID, in ChoiceIn) (int64, error) {
if in.ID == 0 {
return 0, errors.New("缺少测评")
}
score := 0
for _, it := range in.UserChoiceOptionVOList {
var sc int
_ = s.Pool.QueryRow(ctx, `SELECT option_score FROM consult_options WHERE id=$1`, it.OptionID).Scan(&sc)
score += sc
}
var resultID int64
err := s.Pool.QueryRow(ctx, `
SELECT id FROM consult_test_results
WHERE test_id=$1 AND $2 BETWEEN min_score AND max_score
ORDER BY id LIMIT 1`, in.ID, score).Scan(&resultID)
if errors.Is(err, pgx.ErrNoRows) {
err = s.Pool.QueryRow(ctx, `SELECT id FROM consult_test_results WHERE test_id=$1 ORDER BY min_score LIMIT 1`, in.ID).Scan(&resultID)
}
if err != nil {
return 0, errors.New("暂无匹配结果")
}
raw, _ := json.Marshal(in.UserChoiceOptionVOList)
var choiceID int64
err = s.Pool.QueryRow(ctx, `
INSERT INTO consult_user_choices(user_id, test_id, result_id, total_time, choice_info)
VALUES ($1,$2,$3,$4,$5) RETURNING id`, userID, in.ID, resultID, in.TotalTime, raw).Scan(&choiceID)
if err != nil {
return 0, err
}
_, _ = s.Pool.Exec(ctx, `UPDATE consult_tests SET total_num=total_num+1, actual_num=actual_num+1 WHERE id=$1`, in.ID)
return resultID, nil
}
func (s *Service) GetResult(ctx context.Context, resultID int64) (map[string]any, error) {
var testID int64
var desc, analysis, plan, name, sub, pic string
err := s.Pool.QueryRow(ctx, `
SELECT r.test_id, r.result_desc, r.result_analysis, r.treat_plan, t.test_name, t.sub_title, t.test_pic
FROM consult_test_results r JOIN consult_tests t ON t.id=r.test_id
WHERE r.id=$1`, resultID).Scan(&testID, &desc, &analysis, &plan, &name, &sub, &pic)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("结果不存在")
}
if err != nil {
return nil, err
}
return map[string]any{
"id": resultID, "testId": testID, "testName": name, "subTitle": sub, "testPic": pic,
"resultDesc": desc, "resultAnalysis": analysis, "treatPlan": plan,
}, nil
}
func (s *Service) MyTests(ctx context.Context, userID uuid.UUID, pageNo, pageSize int) (map[string]any, error) {
if pageNo <= 0 {
pageNo = 1
}
if pageSize <= 0 {
pageSize = 10
}
var total int
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_user_choices WHERE user_id=$1`, userID).Scan(&total)
rows, err := s.Pool.Query(ctx, `
SELECT c.id, t.test_name, t.test_pic, t.sub_title, c.result_id, to_char(c.created_at,'YYYY-MM-DD HH24:MI')
FROM consult_user_choices c JOIN consult_tests t ON t.id=c.test_id
WHERE c.user_id=$1 ORDER BY c.id DESC OFFSET $2 LIMIT $3`, userID, (pageNo-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
defer rows.Close()
list := []map[string]any{}
for rows.Next() {
var id, rid int64
var name, pic, sub, start string
if err := rows.Scan(&id, &name, &pic, &sub, &rid, &start); err != nil {
return nil, err
}
list = append(list, map[string]any{"id": id, "testName": name, "testPic": pic, "subTitle": sub, "testResultId": rid, "startTime": start})
}
return map[string]any{"list": list, "total": total}, rows.Err()
}
func (s *Service) Scopes(ctx context.Context) ([]map[string]any, error) {
rows, err := s.Pool.Query(ctx, `SELECT code, name FROM consult_business_scopes ORDER BY code`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var code, name string
if err := rows.Scan(&code, &name); err != nil {
return nil, err
}
out = append(out, map[string]any{"code": code, "name": name})
}
return out, rows.Err()
}
func (s *Service) DoctorPage(ctx context.Context, scope string, isTop, pageNo, pageSize int) (map[string]any, error) {
if pageNo <= 0 {
pageNo = 1
}
if pageSize <= 0 {
pageSize = 20
}
scope = strings.TrimSpace(scope)
var total int
_ = s.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM consult_doctors
WHERE status=1 AND ($1='' OR business_scope LIKE '%'||$1||'%') AND ($2<0 OR is_top=$2)`, scope, isTop).Scan(&total)
rows, err := s.Pool.Query(ctx, `
SELECT id, name, avatar, business_scope, tags, consultation_method, introduction, price
FROM consult_doctors
WHERE status=1 AND ($1='' OR business_scope LIKE '%'||$1||'%') AND ($2<0 OR is_top=$2)
ORDER BY is_top DESC, id OFFSET $3 LIMIT $4`, scope, isTop, (pageNo-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
defer rows.Close()
list := []map[string]any{}
for rows.Next() {
var id, price int64
var name, avatar, bs, tags, method, intro string
if err := rows.Scan(&id, &name, &avatar, &bs, &tags, &method, &intro, &price); err != nil {
return nil, err
}
list = append(list, map[string]any{
"id": id, "name": name, "avatar": avatar, "businessScope": bs, "tags": tags,
"consultationMethod": method, "introduction": intro, "price": price, "availableDate": "",
})
}
return map[string]any{"list": list, "total": total}, rows.Err()
}
func (s *Service) DoctorGet(ctx context.Context, id int64, userID uuid.UUID) (map[string]any, error) {
var name, avatar, bs, cover, method, edu, intro, resume, notice, tags, exp, addr, addrD string
var price, status, showN, top int64
var workStart *time.Time
var created time.Time
err := s.Pool.QueryRow(ctx, `
SELECT name, avatar, business_scope, cover_url, consultation_method, education, introduction, resume, notice, tags,
work_experience, work_start_time, price, status, address, address_detail, show_service_num, is_top, created_at
FROM consult_doctors WHERE id=$1`, id).Scan(
&name, &avatar, &bs, &cover, &method, &edu, &intro, &resume, &notice, &tags,
&exp, &workStart, &price, &status, &addr, &addrD, &showN, &top, &created)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("咨询师不存在")
}
if err != nil {
return nil, err
}
focus := false
if userID != uuid.Nil {
_ = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consult_focus WHERE user_id=$1 AND doctor_id=$2 AND status=1)`, userID, id).Scan(&focus)
}
ws := ""
if workStart != nil {
ws = workStart.Format("2006-01-02")
}
if strings.TrimSpace(cover) == "" {
cover = avatar
}
return map[string]any{
"id": id, "userId": 0, "name": name, "avatar": avatar, "businessScope": bs, "coverUrl": cover,
"consultationMethod": method, "education": edu, "introduction": intro, "resume": resume,
"notice": notice, "tags": tags, "workExperience": exp, "workStartTime": ws, "price": price,
"createTime": created.Format(time.RFC3339), "focus": focus, "address": addr, "addressDetail": addrD,
}, nil
}
func (s *Service) SetFocus(ctx context.Context, userID uuid.UUID, doctorID int64, on bool) error {
st := 0
if on {
st = 1
}
_, err := s.Pool.Exec(ctx, `
INSERT INTO consult_focus(user_id, doctor_id, status, read_status)
VALUES ($1,$2,$3,0)
ON CONFLICT (user_id, doctor_id) DO UPDATE SET status=$3, read_status=0`, userID, doctorID, st)
return err
}
func (s *Service) ShowInfo(ctx context.Context, doctorID int64, userID uuid.UUID) (map[string]any, error) {
d, err := s.DoctorGet(ctx, doctorID, userID)
if err != nil {
return nil, err
}
out := map[string]any{
"id": d["id"], "name": d["name"], "avatar": d["avatar"],
"consultationMethod": d["consultationMethod"], "price": d["price"], "address": d["address"],
}
if userID != uuid.Nil {
var name, phone string
var sex int
var bday *time.Time
var em string
err := s.Pool.QueryRow(ctx, `
SELECT appointment_name, birthday, phone, sex, emergency_contact_info
FROM consult_orders WHERE user_id=$1 AND user_deleted=0 ORDER BY id DESC LIMIT 1`, userID).
Scan(&name, &bday, &phone, &sex, &em)
if err == nil {
bs := ""
if bday != nil {
bs = bday.Format("2006-01-02")
}
emPhone, emType := "", 1
var obj map[string]any
if json.Unmarshal([]byte(em), &obj) == nil {
if v, ok := obj["phone"].(string); ok {
emPhone = v
}
switch v := obj["type"].(type) {
case float64:
emType = int(v)
}
}
out["appointmentInfoAppRespVO"] = map[string]any{
"appointmentName": name, "birthday": bs, "phone": phone, "sex": sex,
"emergencyContactPhone": emPhone, "emergencyContactType": emType,
}
}
}
return out, nil
}
func (s *Service) RemainList(ctx context.Context, doctorID int64) ([]map[string]any, error) {
rows, err := s.Pool.Query(ctx, `
SELECT d.schedule_date, d.id,
(SELECT COUNT(*) FROM consult_slots s WHERE s.schedule_id=d.id AND s.status=0)
FROM consult_schedule_days d
WHERE d.doctor_id=$1 AND d.schedule_date >= CURRENT_DATE AND d.schedule_date < CURRENT_DATE+30
ORDER BY d.schedule_date`, doctorID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var day time.Time
var sid, remain int64
if err := rows.Scan(&day, &sid, &remain); err != nil {
return nil, err
}
out = append(out, map[string]any{"scheduleDate": day.Format("2006-01-02"), "remainNum": remain, "scheduleDateId": sid})
}
return out, rows.Err()
}
func (s *Service) DateDetail(ctx context.Context, doctorID int64) ([]map[string]any, error) {
rows, err := s.Pool.Query(ctx, `
SELECT d.id, d.schedule_date FROM consult_schedule_days d
WHERE d.doctor_id=$1 AND d.schedule_date >= CURRENT_DATE AND d.schedule_date < CURRENT_DATE+13
ORDER BY d.schedule_date`, doctorID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var sid int64
var day time.Time
if err := rows.Scan(&sid, &day); err != nil {
return nil, err
}
srows, err := s.Pool.Query(ctx, `
SELECT id, start_time, end_time, consultation_method FROM consult_slots
WHERE schedule_id=$1 AND status=0 ORDER BY start_time`, sid)
if err != nil {
return nil, err
}
slots := []map[string]any{}
for srows.Next() {
var id int64
var st, et time.Time
var method string
if err := srows.Scan(&id, &st, &et, &method); err != nil {
srows.Close()
return nil, err
}
slots = append(slots, map[string]any{
"slotId": id, "startTime": st.Format("15:04:05"), "endTime": et.Format("15:04:05"),
"consultationMethod": method,
})
}
srows.Close()
out = append(out, map[string]any{
"scheduleId": sid, "scheduleDate": day.Format("2006-01-02"),
"weekStr": weekdayCN(day), "slotVOList": slots,
})
}
return out, rows.Err()
}
type OrderIn struct {
SlotID int64 `json:"slotId"`
AppointmentName string `json:"appointmentName"`
Birthday string `json:"birthday"`
Phone string `json:"phone"`
Sex int `json:"sex"`
EmergencyContactInfo string `json:"emergencyContactInfo"`
TotalAmount int `json:"totalAmount"`
ConsultationMethod string `json:"consultationMethod"`
}
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in OrderIn) (wechatpay.JSAPIParams, error) {
var doctorID int64
var day time.Time
var st, et time.Time
var status int
var price int
err := s.Pool.QueryRow(ctx, `
SELECT s.doctor_id, d.schedule_date, s.start_time, s.end_time, s.status, doc.price
FROM consult_slots s
JOIN consult_schedule_days d ON d.id=s.schedule_id
JOIN consult_doctors doc ON doc.id=s.doctor_id
WHERE s.id=$1`, in.SlotID).Scan(&doctorID, &day, &st, &et, &status, &price)
if errors.Is(err, pgx.ErrNoRows) {
return wechatpay.JSAPIParams{}, errors.New("时段不存在")
}
if err != nil {
return wechatpay.JSAPIParams{}, err
}
if status != 0 {
return wechatpay.JSAPIParams{}, errors.New("该时段已被预约")
}
if in.TotalAmount > 0 && in.TotalAmount != price {
return wechatpay.JSAPIParams{}, errors.New("价格已变化,请刷新")
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return wechatpay.JSAPIParams{}, err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `UPDATE consult_slots SET status=1 WHERE id=$1 AND status=0`, in.SlotID)
if err != nil || tag.RowsAffected() == 0 {
return wechatpay.JSAPIParams{}, errors.New("该时段已被预约")
}
sn := fmt.Sprintf("YG%s%06d", time.Now().Format("20060102150405"), in.SlotID%1000000)
method := in.ConsultationMethod
if method == "" {
method = "online"
}
var bday *time.Time
if in.Birthday != "" {
if t, e := time.Parse("2006-01-02", in.Birthday); e == nil {
bday = &t
}
}
valid := time.Now().Add(15 * time.Minute)
var oid int64
err = tx.QueryRow(ctx, `
INSERT INTO consult_orders(order_sn, slot_id, doctor_id, user_id, appointment_date, start_time, end_time, status,
appointment_name, birthday, phone, sex, emergency_contact_info, total_amount, valid_time, consultation_method)
VALUES ($1,$2,$3,$4,$5,$6,$7,0,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id`,
sn, in.SlotID, doctorID, userID, day, st, et, in.AppointmentName, bday, in.Phone, in.Sex,
in.EmergencyContactInfo, price, valid, method).Scan(&oid)
if err != nil {
return wechatpay.JSAPIParams{}, err
}
openid := ""
_ = tx.QueryRow(ctx, `SELECT COALESCE(wx_openid,'') FROM users WHERE id=$1`, userID).Scan(&openid)
pay, err := wechatpay.UnifiedOrder(s.Pay, openid, sn, price)
if err != nil {
return wechatpay.JSAPIParams{}, err
}
raw, _ := json.Marshal(pay)
_, _ = tx.Exec(ctx, `UPDATE consult_orders SET pay_param=$2 WHERE id=$1`, oid, raw)
if err := tx.Commit(ctx); err != nil {
return wechatpay.JSAPIParams{}, err
}
return pay, nil
}
func (s *Service) PayParam(ctx context.Context, userID uuid.UUID, orderSn string) (wechatpay.JSAPIParams, error) {
var raw []byte
var owner uuid.UUID
err := s.Pool.QueryRow(ctx, `SELECT user_id, pay_param FROM consult_orders WHERE order_sn=$1 AND user_deleted=0`, orderSn).
Scan(&owner, &raw)
if err != nil {
return wechatpay.JSAPIParams{}, errors.New("订单不存在")
}
if owner != userID {
return wechatpay.JSAPIParams{}, errors.New("无权查看")
}
var p wechatpay.JSAPIParams
if len(raw) > 0 {
_ = json.Unmarshal(raw, &p)
}
if p.PaySign == "" {
p = wechatpay.MockJSAPI()
}
return p, nil
}
func (s *Service) OrderList(ctx context.Context, userID uuid.UUID, pageNo, pageSize int) (map[string]any, error) {
if pageNo <= 0 {
pageNo = 1
}
if pageSize <= 0 {
pageSize = 10
}
var total int
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_orders WHERE user_id=$1 AND user_deleted=0`, userID).Scan(&total)
rows, err := s.Pool.Query(ctx, `
SELECT o.id, o.doctor_id, o.order_sn, d.name, d.avatar, o.consultation_method, o.status, o.total_amount,
to_char(o.start_time,'HH24:MI:SS'), to_char(o.end_time,'HH24:MI:SS'), o.valid_time, o.created_at, o.cancel_time, o.cancel_flag
FROM consult_orders o JOIN consult_doctors d ON d.id=o.doctor_id
WHERE o.user_id=$1 AND o.user_deleted=0
ORDER BY o.id DESC OFFSET $2 LIMIT $3`, userID, (pageNo-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
defer rows.Close()
list := []map[string]any{}
for rows.Next() {
var id, did, status, amount, cancelFlag int64
var sn, name, avatar, method, st, et string
var valid, created *time.Time
var cancel *time.Time
if err := rows.Scan(&id, &did, &sn, &name, &avatar, &method, &status, &amount, &st, &et, &valid, &created, &cancel, &cancelFlag); err != nil {
return nil, err
}
item := map[string]any{
"id": id, "doctorId": did, "orderSn": sn, "name": name, "avatar": avatar,
"consultationMethod": method, "status": status, "totalAmount": amount,
"startTime": st, "endTime": et, "cancelFlag": cancelFlag,
}
if valid != nil {
item["validTime"] = valid.Format(time.RFC3339)
}
if created != nil {
item["createTime"] = created.Format(time.RFC3339)
}
if cancel != nil {
item["cancelTime"] = cancel.Format(time.RFC3339)
}
list = append(list, item)
}
return map[string]any{"list": list, "total": total}, rows.Err()
}
func (s *Service) OrderDetail(ctx context.Context, userID uuid.UUID, orderID int64) (map[string]any, error) {
var did int64
var sn, aname, phone, method, em, addrD string
var status, sex, amount, cancelFlag int64
var bday *time.Time
var st, et time.Time
var day time.Time
var name, avatar string
err := s.Pool.QueryRow(ctx, `
SELECT o.doctor_id, o.order_sn, o.appointment_name, o.birthday, o.phone, o.sex, o.emergency_contact_info,
o.status, o.total_amount, o.consultation_method, o.cancel_flag, o.appointment_date, o.start_time, o.end_time,
d.name, d.avatar, d.address_detail
FROM consult_orders o JOIN consult_doctors d ON d.id=o.doctor_id
WHERE o.id=$1 AND o.user_id=$2 AND o.user_deleted=0`, orderID, userID).
Scan(&did, &sn, &aname, &bday, &phone, &sex, &em, &status, &amount, &method, &cancelFlag, &day, &st, &et, &name, &avatar, &addrD)
if err != nil {
return nil, errors.New("订单不存在")
}
bs := ""
if bday != nil {
bs = bday.Format("2006-01-02")
}
return map[string]any{
"id": orderID, "doctorId": did, "orderSn": sn, "name": name, "avatar": avatar,
"appointmentName": aname, "birthday": bs, "phone": phone, "sex": sex,
"emergencyContactInfo": em, "status": status, "totalAmount": amount,
"consultationMethod": method, "cancelFlag": cancelFlag, "addressDetail": addrD,
"appointmentDate": day.Format("2006-01-02"),
"startTime": st.Format("15:04:05"), "endTime": et.Format("15:04:05"),
}, nil
}
func (s *Service) CancelOrder(ctx context.Context, userID uuid.UUID, orderID int64) error {
var slotID int64
var status int
err := s.Pool.QueryRow(ctx, `SELECT slot_id, status FROM consult_orders WHERE id=$1 AND user_id=$2 AND user_deleted=0`, orderID, userID).
Scan(&slotID, &status)
if err != nil {
return errors.New("订单不存在")
}
if status != 0 {
return errors.New("当前状态不可取消")
}
_, _ = s.Pool.Exec(ctx, `UPDATE consult_slots SET status=0 WHERE id=$1`, slotID)
_, err = s.Pool.Exec(ctx, `UPDATE consult_orders SET status=9, cancel_time=now(), cancel_flag=2 WHERE id=$1`, orderID)
return err
}
func (s *Service) DeleteOrder(ctx context.Context, userID uuid.UUID, orderID int64) error {
tag, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET user_deleted=1 WHERE id=$1 AND user_id=$2`, orderID, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("订单不存在")
}
return nil
}
func (s *Service) MarkPaid(ctx context.Context, orderSn string) error {
_, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET status=1, pay_time=now() WHERE order_sn=$1 AND status=0`, orderSn)
return err
}
func (s *Service) DemoPay(ctx context.Context, userID uuid.UUID) (wechatpay.JSAPIParams, error) {
openid := ""
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(wx_openid,'') FROM users WHERE id=$1`, userID).Scan(&openid)
return wechatpay.UnifiedOrder(s.Pay, openid, fmt.Sprintf("DEMO%d", time.Now().Unix()), 1)
}
func (s *Service) SelfInfo(ctx context.Context, userID uuid.UUID) (map[string]any, error) {
var nick, avatar, phone, status string
var created time.Time
err := s.Pool.QueryRow(ctx, `SELECT COALESCE(nickname,''), COALESCE(avatar_url,''), COALESCE(phone,''), status, created_at FROM users WHERE id=$1`, userID).
Scan(&nick, &avatar, &phone, &status, &created)
if err != nil {
return nil, errors.New("请先登录")
}
var stay, idCard, realName string
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(stay_period,''), COALESCE(id_card,''), COALESCE(real_name,'') FROM consult_user_ext WHERE user_id=$1`, userID).
Scan(&stay, &idCard, &realName)
st := 1
if status != "active" {
st = 0
}
return map[string]any{
"id": userID.String(), "status": st, "idCard": idCard, "realName": realName,
"createTime": created.Format(time.RFC3339), "nickName": nick, "avatarUrl": avatar, "stayPeriod": stay, "phone": phone,
}, nil
}
func (s *Service) UpdateSelf(ctx context.Context, userID uuid.UUID, nick, avatar, stay string) error {
if nick != "" {
_, _ = s.Pool.Exec(ctx, `UPDATE users SET nickname=$2, updated_at=now() WHERE id=$1`, userID, nick)
}
if avatar != "" {
_, _ = s.Pool.Exec(ctx, `UPDATE users SET avatar_url=$2, updated_at=now() WHERE id=$1`, userID, avatar)
}
_, err := s.Pool.Exec(ctx, `
INSERT INTO consult_user_ext(user_id, stay_period) VALUES ($1,$2)
ON CONFLICT (user_id) DO UPDATE SET stay_period=COALESCE(NULLIF($2,''), consult_user_ext.stay_period), updated_at=now()`,
userID, stay)
return err
}
func (s *Service) NotRead(ctx context.Context, userID uuid.UUID) (map[string]any, error) {
var focus, order int
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_focus WHERE user_id=$1 AND status=1 AND read_status=0`, userID).Scan(&focus)
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_orders WHERE user_id=$1 AND user_deleted=0 AND user_read=0`, userID).Scan(&order)
return map[string]any{"focusNum": focus, "orderNum": order}, nil
}
func (s *Service) FocusToRead(ctx context.Context, userID uuid.UUID) error {
_, err := s.Pool.Exec(ctx, `UPDATE consult_focus SET read_status=1 WHERE user_id=$1`, userID)
return err
}
func (s *Service) OrderToRead(ctx context.Context, userID uuid.UUID) error {
_, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET user_read=1 WHERE user_id=$1`, userID)
return err
}
func (s *Service) FocusAll(ctx context.Context, userID uuid.UUID) ([]map[string]any, error) {
rows, err := s.Pool.Query(ctx, `
SELECT d.id, d.name, d.avatar, d.show_service_num, d.introduction, d.price, d.work_start_time
FROM consult_focus f JOIN consult_doctors d ON d.id=f.doctor_id
WHERE f.user_id=$1 AND f.status=1 ORDER BY f.id DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, show, price int64
var name, avatar, intro string
var ws *time.Time
if err := rows.Scan(&id, &name, &avatar, &show, &intro, &price, &ws); err != nil {
return nil, err
}
workNum := 0
if ws != nil {
workNum = time.Now().Year() - ws.Year()
if workNum < 0 {
workNum = 0
}
}
out = append(out, map[string]any{
"id": id, "name": name, "avatar": avatar, "workNum": workNum,
"showServiceNum": show, "introduction": intro, "price": price,
"workStartTime": timeOrEmpty(ws),
})
}
return out, rows.Err()
}
func (s *Service) Feedback(ctx context.Context, userID uuid.UUID, text string) error {
if strings.TrimSpace(text) == "" {
return errors.New("请填写内容")
}
phone := ""
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(phone,'') FROM users WHERE id=$1`, userID).Scan(&phone)
_, err := s.Pool.Exec(ctx, `INSERT INTO consult_feedback(user_id, content_text, contact) VALUES ($1,$2,$3)`, userID, text, phone)
return err
}
func (s *Service) FeedbackFlag(ctx context.Context, userID uuid.UUID) (bool, error) {
var n int
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_feedback WHERE user_id=$1 AND created_at > now()-interval '5 minutes'`, userID).Scan(&n)
return n == 0, nil
}
func weekdayCN(t time.Time) string {
names := []string{"星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"}
return names[int(t.Weekday())]
}
func timeOrEmpty(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02")
}
+84
View File
@@ -0,0 +1,84 @@
package wechat
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// Session is the jscode2session result.
type Session struct {
OpenID string
UnionID string
SessionKey string
}
// Client exchanges a mini-program js_code for a session.
type Client interface {
Code2Session(ctx context.Context, code string) (Session, error)
}
// APIClient calls api.weixin.qq.com.
type APIClient struct {
AppID string
Secret string
HTTP *http.Client
}
type wxSessionResp struct {
OpenID string `json:"openid"`
SessionKey string `json:"session_key"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
// Enabled reports whether WeChat credentials are configured.
func (c *APIClient) Enabled() bool {
return c != nil && strings.TrimSpace(c.AppID) != "" && strings.TrimSpace(c.Secret) != ""
}
// Code2Session implements Client.
func (c *APIClient) Code2Session(ctx context.Context, code string) (Session, error) {
if !c.Enabled() {
return Session{}, errors.New("微信登录未配置")
}
code = strings.TrimSpace(code)
if code == "" {
return Session{}, errors.New("缺少微信登录码")
}
q := url.Values{}
q.Set("appid", c.AppID)
q.Set("secret", c.Secret)
q.Set("js_code", code)
q.Set("grant_type", "authorization_code")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+q.Encode(), nil)
if err != nil {
return Session{}, err
}
cli := c.HTTP
if cli == nil {
cli = &http.Client{Timeout: 10 * time.Second}
}
resp, err := cli.Do(req)
if err != nil {
return Session{}, errors.New("微信服务暂不可用")
}
defer resp.Body.Close()
var body wxSessionResp
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return Session{}, errors.New("微信服务暂不可用")
}
if body.ErrCode != 0 {
return Session{}, fmt.Errorf("微信登录失败(%d", body.ErrCode)
}
if body.OpenID == "" || body.SessionKey == "" {
return Session{}, errors.New("微信登录失败")
}
return Session{OpenID: body.OpenID, UnionID: body.UnionID, SessionKey: body.SessionKey}, nil
}
+75
View File
@@ -0,0 +1,75 @@
package wechat
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"errors"
"strings"
)
type phonePayload struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
Watermark struct {
AppID string `json:"appid"`
} `json:"watermark"`
}
// DecryptPhone decrypts WeChat getPhoneNumber encryptedData (AES-128-CBC).
func DecryptPhone(sessionKey, encryptedData, iv, expectAppID string) (string, error) {
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sessionKey))
if err != nil || len(key) != 16 {
return "", errors.New("无效的 session_key")
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encryptedData))
if err != nil || len(raw) == 0 || len(raw)%aes.BlockSize != 0 {
return "", errors.New("无效的加密数据")
}
ivb, err := base64.StdEncoding.DecodeString(strings.TrimSpace(iv))
if err != nil || len(ivb) != aes.BlockSize {
return "", errors.New("无效的 iv")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
plain := make([]byte, len(raw))
cipher.NewCBCDecrypter(block, ivb).CryptBlocks(plain, raw)
plain, err = pkcs7Unpad(plain)
if err != nil {
return "", errors.New("解密失败")
}
var p phonePayload
if err := json.Unmarshal(plain, &p); err != nil {
return "", errors.New("解密失败")
}
if expectAppID != "" && p.Watermark.AppID != "" && p.Watermark.AppID != expectAppID {
return "", errors.New("微信数据校验失败")
}
phone := strings.TrimSpace(p.PhoneNumber)
if phone == "" {
phone = strings.TrimSpace(p.PurePhoneNumber)
}
if phone == "" {
return "", errors.New("未获取到手机号")
}
return phone, nil
}
func pkcs7Unpad(b []byte) ([]byte, error) {
if len(b) == 0 {
return nil, errors.New("empty")
}
n := int(b[len(b)-1])
if n == 0 || n > len(b) {
return nil, errors.New("pad")
}
for i := 0; i < n; i++ {
if b[len(b)-1-i] != byte(n) {
return nil, errors.New("pad")
}
}
return b[:len(b)-n], nil
}
+47
View File
@@ -0,0 +1,47 @@
package wechat
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"testing"
)
func TestDecryptPhone(t *testing.T) {
key := make([]byte, 16)
iv := make([]byte, 16)
_, _ = rand.Read(key)
_, _ = rand.Read(iv)
plain := []byte(`{"phoneNumber":"13800138000","purePhoneNumber":"13800138000","watermark":{"appid":"wxapp"}}`)
padded := pkcs7Pad(plain, aes.BlockSize)
block, err := aes.NewCipher(key)
if err != nil {
t.Fatal(err)
}
enc := make([]byte, len(padded))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(enc, padded)
phone, err := DecryptPhone(
base64.StdEncoding.EncodeToString(key),
base64.StdEncoding.EncodeToString(enc),
base64.StdEncoding.EncodeToString(iv),
"wxapp",
)
if err != nil {
t.Fatal(err)
}
if phone != "13800138000" {
t.Fatalf("got %s", phone)
}
}
func pkcs7Pad(b []byte, block int) []byte {
n := block - (len(b) % block)
out := make([]byte, len(b)+n)
copy(out, b)
for i := 0; i < n; i++ {
out[len(b)+i] = byte(n)
}
return out
}
+158
View File
@@ -0,0 +1,158 @@
package wechatpay
import (
"crypto/md5"
"crypto/rand"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// Config is WeChat JSAPI merchant config.
type Config struct {
AppID string
MchID string
APIKey string
NotifyURL string
}
// Enabled reports merchant credentials present.
func (c Config) Enabled() bool {
return strings.TrimSpace(c.AppID) != "" && strings.TrimSpace(c.MchID) != "" && strings.TrimSpace(c.APIKey) != ""
}
// JSAPIParams is returned to uni.requestPayment.
type JSAPIParams struct {
TimeStamp string `json:"timeStamp"`
NonceStr string `json:"nonceStr"`
PackageStr string `json:"packageStr"`
SignType string `json:"signType"`
PaySign string `json:"paySign"`
}
// SignMD5 builds WeChat v2 sign.
func SignMD5(params map[string]string, apiKey string) string {
keys := make([]string, 0, len(params))
for k, v := range params {
if k == "sign" || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for i, k := range keys {
if i > 0 {
b.WriteByte('&')
}
b.WriteString(k)
b.WriteByte('=')
b.WriteString(params[k])
}
b.WriteString("&key=")
b.WriteString(apiKey)
sum := md5.Sum([]byte(b.String()))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
// BuildJSAPI signs prepay_id for the miniprogram.
func BuildJSAPI(appID, prepayID, apiKey string) JSAPIParams {
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := nonceStr()
pkg := "prepay_id=" + prepayID
m := map[string]string{
"appId": appID, "timeStamp": ts, "nonceStr": nonce,
"package": pkg, "signType": "MD5",
}
return JSAPIParams{
TimeStamp: ts, NonceStr: nonce, PackageStr: pkg, SignType: "MD5",
PaySign: SignMD5(m, apiKey),
}
}
// MockJSAPI is used when merchant keys are absent (dev).
func MockJSAPI() JSAPIParams {
return JSAPIParams{
TimeStamp: strconv.FormatInt(time.Now().Unix(), 10), NonceStr: nonceStr(),
PackageStr: "prepay_id=mock", SignType: "MD5", PaySign: "MOCK",
}
}
type unifiedXML struct {
XMLName xml.Name `xml:"xml"`
AppID string `xml:"appid"`
MchID string `xml:"mch_id"`
Nonce string `xml:"nonce_str"`
Body string `xml:"body"`
OutNo string `xml:"out_trade_no"`
Fee string `xml:"total_fee"`
IP string `xml:"spbill_create_ip"`
Notify string `xml:"notify_url"`
Trade string `xml:"trade_type"`
OpenID string `xml:"openid"`
Sign string `xml:"sign"`
}
type unifiedResp struct {
ReturnCode string `xml:"return_code"`
ResultCode string `xml:"result_code"`
PrepayID string `xml:"prepay_id"`
ErrCodeDes string `xml:"err_code_des"`
ReturnMsg string `xml:"return_msg"`
}
// UnifiedOrder calls WeChat v2 JSAPI unifiedorder.
func UnifiedOrder(cfg Config, openid, orderNo string, amountFen int) (JSAPIParams, error) {
if !cfg.Enabled() {
return MockJSAPI(), nil
}
nonce := nonceStr()
params := map[string]string{
"appid": cfg.AppID, "mch_id": cfg.MchID, "nonce_str": nonce,
"body": "课程预约", "out_trade_no": orderNo, "total_fee": strconv.Itoa(amountFen),
"spbill_create_ip": "127.0.0.1", "notify_url": cfg.NotifyURL,
"trade_type": "JSAPI", "openid": openid,
}
params["sign"] = SignMD5(params, cfg.APIKey)
raw, err := xml.Marshal(unifiedXML{
AppID: cfg.AppID, MchID: cfg.MchID, Nonce: nonce, Body: "课程预约",
OutNo: orderNo, Fee: params["total_fee"], IP: "127.0.0.1",
Notify: cfg.NotifyURL, Trade: "JSAPI", OpenID: openid, Sign: params["sign"],
})
if err != nil {
return JSAPIParams{}, err
}
resp, err := http.Post("https://api.mch.weixin.qq.com/pay/unifiedorder", "application/xml", strings.NewReader(string(raw)))
if err != nil {
return JSAPIParams{}, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var out unifiedResp
if err := xml.Unmarshal(b, &out); err != nil {
return JSAPIParams{}, err
}
if out.ReturnCode != "SUCCESS" || out.ResultCode != "SUCCESS" || out.PrepayID == "" {
msg := out.ErrCodeDes
if msg == "" {
msg = out.ReturnMsg
}
if msg == "" {
msg = "创建订单失败"
}
return JSAPIParams{}, fmt.Errorf("%s", msg)
}
return BuildJSAPI(cfg.AppID, out.PrepayID, cfg.APIKey), nil
}
func nonceStr() string {
var b [8]byte
_, _ = rand.Read(b[:])
return strings.ToUpper(hex.EncodeToString(b[:]))
}
+17
View File
@@ -0,0 +1,17 @@
package wechatpay
import (
"crypto/md5"
"encoding/hex"
"strings"
"testing"
)
func TestSignMD5(t *testing.T) {
got := SignMD5(map[string]string{"appid": "wx123", "mch_id": "foo", "sign": "x"}, "k")
sum := md5.Sum([]byte("appid=wx123&mch_id=foo&key=k"))
want := strings.ToUpper(hex.EncodeToString(sum[:]))
if got != want {
t.Fatalf("got %s want %s", got, want)
}
}
@@ -0,0 +1,2 @@
-- ECR-045 rollback marker (permission shared — do not revoke explore.write)
SELECT 1;
@@ -0,0 +1,7 @@
-- ECR-045 ExploreConfig ImageCardDeck write (reuse admin.explore.write)
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, 'admin.explore.write'
FROM admin_roles r
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,2 @@
-- ECR-046 rollback marker (permission shared — do not revoke explore.write)
SELECT 1;
@@ -0,0 +1,7 @@
-- ECR-046 ScaleDefinition metadata write (reuse admin.explore.write)
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, 'admin.explore.write'
FROM admin_roles r
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,5 @@
-- ECR-047 rollback
DELETE FROM admin_role_permissions
WHERE code = 'admin.growth.write'
AND role_id IN (SELECT id FROM admin_roles WHERE name = 'super_admin');
@@ -0,0 +1,7 @@
-- ECR-047 GrowthInsights FunnelDefinition write (additive admin.growth.write)
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, 'admin.growth.write'
FROM admin_roles r
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,2 @@
-- ECR-048 rollback marker (permission shared with ECR-047 — do not revoke growth.write)
SELECT 1;
@@ -0,0 +1,7 @@
-- ECR-048 GrowthInsights ReportTemplate write (reuse admin.growth.write)
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, 'admin.growth.write'
FROM admin_roles r
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,5 @@
ALTER TABLE user_sessions DROP COLUMN IF EXISTS java_access_token;
DROP INDEX IF EXISTS idx_users_wx_openid_unique;
ALTER TABLE users DROP COLUMN IF EXISTS java_platform_user_id;
ALTER TABLE users DROP COLUMN IF EXISTS wx_unionid;
ALTER TABLE users DROP COLUMN IF EXISTS wx_openid;
@@ -0,0 +1,10 @@
-- ECR-049: WeChat identity + Java consult token on session
ALTER TABLE users ADD COLUMN IF NOT EXISTS wx_openid varchar(64) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS wx_unionid varchar(64) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS java_platform_user_id bigint NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_wx_openid_unique
ON users(wx_openid) WHERE wx_openid IS NOT NULL AND deleted_at IS NULL;
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS java_access_token text NULL;
@@ -0,0 +1,16 @@
DROP TABLE IF EXISTS consult_orders;
DROP TABLE IF EXISTS consult_user_choices;
DROP TABLE IF EXISTS consult_test_results;
DROP TABLE IF EXISTS consult_options;
DROP TABLE IF EXISTS consult_questions;
DROP TABLE IF EXISTS consult_tests;
DROP TABLE IF EXISTS consult_feedback;
DROP TABLE IF EXISTS consult_focus;
DROP TABLE IF EXISTS consult_slots;
DROP TABLE IF EXISTS consult_schedule_days;
DROP TABLE IF EXISTS consult_doctors;
DROP TABLE IF EXISTS consult_business_scopes;
DROP TABLE IF EXISTS consult_protocols;
DROP TABLE IF EXISTS consult_news;
DROP TABLE IF EXISTS consult_banners;
DROP TABLE IF EXISTS consult_user_ext;
@@ -0,0 +1,259 @@
-- ECR-050: miniprogram consult domain (native PostgreSQL)
CREATE TABLE IF NOT EXISTS consult_user_ext (
user_id uuid PRIMARY KEY REFERENCES users(id),
stay_period varchar(64) NULL,
id_card varchar(32) NULL,
real_name varchar(64) NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS consult_banners (
id bigserial PRIMARY KEY,
banner_name varchar(128) NOT NULL DEFAULT '',
banner_image text NOT NULL DEFAULT '',
click_url text NOT NULL DEFAULT '',
describe text NOT NULL DEFAULT '',
order_index int NOT NULL DEFAULT 0,
jump_type int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS consult_news (
id bigserial PRIMARY KEY,
type int NOT NULL,
title varchar(255) NOT NULL DEFAULT '',
show_image text NOT NULL DEFAULT '',
content text NOT NULL DEFAULT '',
show_main int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS consult_protocols (
id bigserial PRIMARY KEY,
code varchar(128) NOT NULL UNIQUE,
title varchar(255) NOT NULL DEFAULT '',
context text NOT NULL DEFAULT '',
status int NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS consult_business_scopes (
code varchar(64) PRIMARY KEY,
name varchar(64) NOT NULL
);
CREATE TABLE IF NOT EXISTS consult_doctors (
id bigserial PRIMARY KEY,
name varchar(64) NOT NULL,
avatar text NOT NULL DEFAULT '',
business_scope varchar(255) NOT NULL DEFAULT '',
cover_url text NOT NULL DEFAULT '',
consultation_method varchar(128) NOT NULL DEFAULT 'online',
education varchar(128) NOT NULL DEFAULT '',
introduction text NOT NULL DEFAULT '',
resume text NOT NULL DEFAULT '',
notice text NOT NULL DEFAULT '',
tags varchar(255) NOT NULL DEFAULT '',
work_experience varchar(128) NOT NULL DEFAULT '',
work_start_time date NULL,
price int NOT NULL DEFAULT 0,
status int NOT NULL DEFAULT 1,
address varchar(255) NOT NULL DEFAULT '',
address_detail varchar(255) NOT NULL DEFAULT '',
show_service_num int NOT NULL DEFAULT 0,
is_top int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS consult_schedule_days (
id bigserial PRIMARY KEY,
doctor_id bigint NOT NULL REFERENCES consult_doctors(id),
schedule_date date NOT NULL,
UNIQUE (doctor_id, schedule_date)
);
CREATE TABLE IF NOT EXISTS consult_slots (
id bigserial PRIMARY KEY,
schedule_id bigint NOT NULL REFERENCES consult_schedule_days(id) ON DELETE CASCADE,
doctor_id bigint NOT NULL REFERENCES consult_doctors(id),
start_time time NOT NULL,
end_time time NOT NULL,
consultation_method varchar(64) NOT NULL DEFAULT 'online',
status int NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_consult_slots_doctor_date ON consult_slots(doctor_id, status);
CREATE TABLE IF NOT EXISTS consult_focus (
id bigserial PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id),
doctor_id bigint NOT NULL REFERENCES consult_doctors(id),
status int NOT NULL DEFAULT 1,
read_status int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, doctor_id)
);
CREATE TABLE IF NOT EXISTS consult_feedback (
id bigserial PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id),
content_text text NOT NULL,
contact varchar(32) NOT NULL DEFAULT '',
status int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS consult_tests (
id bigserial PRIMARY KEY,
test_name varchar(128) NOT NULL,
sub_title varchar(255) NOT NULL DEFAULT '',
test_pic text NOT NULL DEFAULT '',
test_introduction text NOT NULL DEFAULT '',
test_notice text NOT NULL DEFAULT '',
total_num int NOT NULL DEFAULT 0,
actual_num int NOT NULL DEFAULT 0,
show_main int NOT NULL DEFAULT 0,
status int NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS consult_questions (
id bigserial PRIMARY KEY,
test_id bigint NOT NULL REFERENCES consult_tests(id) ON DELETE CASCADE,
question_type int NOT NULL DEFAULT 1,
question_text text NOT NULL,
question_image text NOT NULL DEFAULT '',
required int NOT NULL DEFAULT 1,
order_index int NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS consult_options (
id bigserial PRIMARY KEY,
question_id bigint NOT NULL REFERENCES consult_questions(id) ON DELETE CASCADE,
option_text text NOT NULL,
option_score int NOT NULL DEFAULT 0,
order_index int NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS consult_test_results (
id bigserial PRIMARY KEY,
test_id bigint NOT NULL REFERENCES consult_tests(id) ON DELETE CASCADE,
min_score int NOT NULL DEFAULT 0,
max_score int NOT NULL DEFAULT 0,
result_desc text NOT NULL DEFAULT '',
result_analysis text NOT NULL DEFAULT '',
treat_plan text NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS consult_user_choices (
id bigserial PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id),
test_id bigint NOT NULL REFERENCES consult_tests(id),
result_id bigint NOT NULL REFERENCES consult_test_results(id),
total_time int NOT NULL DEFAULT 0,
choice_info jsonb NOT NULL DEFAULT '[]',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS consult_orders (
id bigserial PRIMARY KEY,
order_sn varchar(64) NOT NULL UNIQUE,
slot_id bigint NOT NULL REFERENCES consult_slots(id),
doctor_id bigint NOT NULL REFERENCES consult_doctors(id),
user_id uuid NOT NULL REFERENCES users(id),
appointment_date date NOT NULL,
start_time time NOT NULL,
end_time time NOT NULL,
status int NOT NULL DEFAULT 0,
user_deleted int NOT NULL DEFAULT 0,
appointment_name varchar(64) NOT NULL DEFAULT '',
birthday date NULL,
phone varchar(20) NOT NULL DEFAULT '',
sex int NOT NULL DEFAULT 1,
emergency_contact_info text NOT NULL DEFAULT '',
user_read int NOT NULL DEFAULT 0,
total_amount int NOT NULL DEFAULT 0,
valid_time timestamptz NULL,
pay_time timestamptz NULL,
cancel_time timestamptz NULL,
cancel_flag int NOT NULL DEFAULT 0,
consultation_method varchar(64) NOT NULL DEFAULT '',
pay_param jsonb NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_consult_orders_user ON consult_orders(user_id, user_deleted);
-- seed: scopes, protocol, banner, news, doctor, test, 14-day slots
INSERT INTO consult_business_scopes(code, name) VALUES
('emotion', '情绪压力'),
('relation', '亲密关系'),
('career', '职业发展')
ON CONFLICT (code) DO NOTHING;
INSERT INTO consult_protocols(code, title, context, status) VALUES
('consultation_appointment_agreement', '咨询预约协议', '<p>预约即表示你已阅读并同意本协议。本服务不替代危机干预或医疗诊断。</p>', 1),
('psychological_show_appointment_agreement', '心理咨询展示与预约说明', '<p>咨询师信息仅供预约参考。如需紧急帮助请联系当地心理援助热线。</p>', 1),
('privacy_protection_agreement', '愈心谷咨询小程序隐私保护指引', '<p>我们仅在提供服务所必需的范围内处理你的手机号与预约信息。</p>', 1)
ON CONFLICT (code) DO NOTHING;
INSERT INTO consult_banners(banner_name, banner_image, click_url, describe, order_index, jump_type)
SELECT '愈心谷', '/static/n-main/yxg-brand-logo.png', '', '欢迎来到愈心谷', 1, 1
WHERE NOT EXISTS (SELECT 1 FROM consult_banners);
INSERT INTO consult_news(type, title, show_image, content, show_main)
SELECT 1, '艺术疗愈入门', '/static/n-main/yxg-brand-logo.png', '<p>用创作看见自己的情绪。</p>', 1
WHERE NOT EXISTS (SELECT 1 FROM consult_news WHERE type=1);
INSERT INTO consult_news(type, title, show_image, content, show_main)
SELECT 2, '本周活动', '/static/n-main/yxg-brand-logo.png', '<p>线上主题分享,欢迎预约咨询师一对一。</p>', 1
WHERE NOT EXISTS (SELECT 1 FROM consult_news WHERE type=2);
INSERT INTO consult_doctors(name, avatar, business_scope, consultation_method, education, introduction, resume, notice, tags, work_experience, work_start_time, price, status, address, address_detail, show_service_num, is_top)
SELECT '林安', '/static/n-main/yxg-brand-logo.png', 'emotion,relation', 'online,face_to_face', '应用心理硕士',
'关注情绪调节与关系议题', '国家二级心理咨询师', '请提前10分钟进入会谈。', '情绪,关系',
'8年', DATE '2018-03-01', 22600, 1, '线上/线下', '预约成功后发送地址', 128, 1
WHERE NOT EXISTS (SELECT 1 FROM consult_doctors);
INSERT INTO consult_schedule_days(doctor_id, schedule_date)
SELECT d.id, (CURRENT_DATE + g.n)
FROM consult_doctors d
CROSS JOIN generate_series(1, 14) AS g(n)
ON CONFLICT (doctor_id, schedule_date) DO NOTHING;
INSERT INTO consult_slots(schedule_id, doctor_id, start_time, end_time, consultation_method, status)
SELECT s.id, s.doctor_id, t.st, t.et, 'online', 0
FROM consult_schedule_days s
CROSS JOIN (VALUES (TIME '09:00', TIME '10:00'), (TIME '14:00', TIME '15:00')) AS t(st, et)
WHERE NOT EXISTS (
SELECT 1 FROM consult_slots x WHERE x.schedule_id=s.id AND x.start_time=t.st
);
INSERT INTO consult_tests(test_name, sub_title, test_pic, test_introduction, test_notice, total_num, show_main, status)
SELECT '情绪小测', '用两分钟看看最近的状态', '/static/n-main/yxg-brand-logo.png',
'本测评仅供自我觉察,不是诊断。', '请按第一直觉作答。', 1280, 1, 1
WHERE NOT EXISTS (SELECT 1 FROM consult_tests);
INSERT INTO consult_questions(test_id, question_type, question_text, required, order_index)
SELECT t.id, 1, '最近两周,我感到紧张或坐立不安。', 1, 1
FROM consult_tests t
WHERE t.test_name='情绪小测' AND NOT EXISTS (SELECT 1 FROM consult_questions q WHERE q.test_id=t.id);
INSERT INTO consult_questions(test_id, question_type, question_text, required, order_index)
SELECT t.id, 1, '最近两周,我仍然能享受日常小事。', 1, 2
FROM consult_tests t
WHERE t.test_name='情绪小测' AND (SELECT COUNT(*) FROM consult_questions q WHERE q.test_id=t.id) < 2;
INSERT INTO consult_options(question_id, option_text, option_score, order_index)
SELECT q.id, o.txt, o.sc, o.ord
FROM consult_questions q
JOIN consult_tests t ON t.id=q.test_id AND t.test_name='情绪小测'
CROSS JOIN (VALUES ('很少', 0, 1), ('有时', 1, 2), ('经常', 2, 3)) AS o(txt, sc, ord)
WHERE NOT EXISTS (SELECT 1 FROM consult_options x WHERE x.question_id=q.id);
INSERT INTO consult_test_results(test_id, min_score, max_score, result_desc, result_analysis, treat_plan)
SELECT t.id, 0, 2, '状态平稳', '你最近的情绪波动在可调节范围内。', '保持作息,需要时可以和咨询师聊聊。'
FROM consult_tests t WHERE t.test_name='情绪小测'
AND NOT EXISTS (SELECT 1 FROM consult_test_results r WHERE r.test_id=t.id AND r.min_score=0);
INSERT INTO consult_test_results(test_id, min_score, max_score, result_desc, result_analysis, treat_plan)
SELECT t.id, 3, 8, '需要被看见', '最近的紧绷感偏高,适合做一次梳理。', '建议预约咨询,或先做呼吸放松。'
FROM consult_tests t WHERE t.test_name='情绪小测'
AND NOT EXISTS (SELECT 1 FROM consult_test_results r WHERE r.test_id=t.id AND r.min_score=3);
+27
View File
@@ -4,6 +4,33 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no,viewport-fit=cover" /> <meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no,viewport-fit=cover" />
<title>愈心谷</title> <title>愈心谷</title>
<script>
;(function () {
var q = location.search.slice(1).split('&')
var p = {}
for (var i = 0; i < q.length; i++) {
var kv = q[i].split('=')
if (kv[0]) p[kv[0]] = kv[1] || ''
}
if (p.mp === '1') {
document.documentElement.classList.add('mp-embed')
document.title = '\u200b'
try { sessionStorage.setItem('yxg_mp_embed', '1') } catch (e) {}
}
if (p.nh === '1') {
document.documentElement.classList.add('mp-native-header')
document.title = '\u200b'
try { sessionStorage.setItem('yxg_mp_native_header', '1') } catch (e) {}
var top = 4
if (p.sbh && /^\d+$/.test(p.sbh)) {
top = parseInt(p.sbh, 10)
try { sessionStorage.setItem('yxg_sbh', p.sbh) } catch (e) {}
}
document.documentElement.style.setProperty('--yxg-safe-top', '0px')
document.documentElement.style.setProperty('--yxg-page-top', top + 'px')
}
})()
</script>
<link rel="icon" type="image/png" href="/logo.png" /> <link rel="icon" type="image/png" href="/logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

+26 -4
View File
@@ -1,12 +1,34 @@
<template> <template>
<div class="app-shell"> <div class="app-shell" :class="shellClass">
<AppHeader /> <div class="app-body">
<router-view /> <AppHeader v-if="showFullHeader" />
<TabBar /> <AppHeader v-else-if="showMpBackBar" back-only />
<router-view />
</div>
<IcpFooter class="app-footer" />
<TabBar v-if="!mpEmbed" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppHeader from './components/AppHeader.vue' import AppHeader from './components/AppHeader.vue'
import IcpFooter from './components/IcpFooter.vue'
import TabBar from './components/TabBar.vue' import TabBar from './components/TabBar.vue'
import { isMpEmbed, isMpNativeHeader } from './lib/mpEmbed'
const route = useRoute()
const mpEmbed = isMpEmbed()
const mpNativeHeader = isMpNativeHeader()
const showFullHeader = computed(() => !mpNativeHeader)
const showMpBackBar = computed(() => {
if (!mpEmbed || !mpNativeHeader) return false
const p = route.path
return p !== '/' && p !== '/mine'
})
const shellClass = computed(() => ({
'mp-embed': mpEmbed,
'mp-native-header': mpNativeHeader,
}))
</script> </script>
+28 -4
View File
@@ -1,12 +1,20 @@
<template> <template>
<header class="app-header"> <header
class="app-header"
:class="{ 'is-embed': mpEmbed, 'back-only': backOnly }"
>
<div class="side left"> <div class="side left">
<BackButton v-if="showBack" /> <BackButton v-if="showBack" />
</div> </div>
<router-link to="/" class="logo-link" aria-label="愈心谷首页"> <router-link
v-if="!backOnly"
to="/"
class="logo-link"
aria-label="愈心谷首页"
>
<BrandLogo size="header" class="logo" /> <BrandLogo size="header" class="logo" />
</router-link> </router-link>
<div class="side right" aria-hidden="true" /> <div v-if="!backOnly" class="side right" aria-hidden="true" />
</header> </header>
</template> </template>
@@ -15,8 +23,13 @@ import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import BackButton from './BackButton.vue' import BackButton from './BackButton.vue'
import BrandLogo from './BrandLogo.vue' import BrandLogo from './BrandLogo.vue'
import { isMpEmbed } from '../lib/mpEmbed'
const props = defineProps<{ backOnly?: boolean }>()
const route = useRoute() const route = useRoute()
const mpEmbed = isMpEmbed()
const backOnly = computed(() => props.backOnly === true)
/** 首页 / 我的 为 Tab 根页不显示返回;探索可从首页进入,显示返回 */ /** 首页 / 我的 为 Tab 根页不显示返回;探索可从首页进入,显示返回 */
const showBack = computed(() => { const showBack = computed(() => {
@@ -36,10 +49,21 @@ const showBack = computed(() => {
grid-template-columns: 44px 1fr 44px; grid-template-columns: 44px 1fr 44px;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 10px 12px 4px; padding: calc(8px + var(--yxg-safe-top)) 12px 4px;
background: transparent; background: transparent;
pointer-events: none; pointer-events: none;
} }
.app-header.is-embed {
position: relative;
padding: calc(6px + var(--yxg-safe-top)) 12px 8px;
background: transparent;
}
.app-header.back-only {
display: block;
grid-template-columns: 44px;
padding: calc(4px + var(--yxg-page-top, 0px)) 12px 4px;
pointer-events: none;
}
.side { .side {
display: flex; display: flex;
align-items: center; align-items: center;
+3 -1
View File
@@ -2,7 +2,7 @@
<img <img
class="brand-logo" class="brand-logo"
:class="size" :class="size"
src="/logo.png?v=3" :src="logoSrc"
alt="愈心谷" alt="愈心谷"
decoding="async" decoding="async"
/> />
@@ -16,6 +16,8 @@ withDefaults(
}>(), }>(),
{ size: 'header' }, { size: 'header' },
) )
const logoSrc = `${import.meta.env.BASE_URL}logo.png?v=3`
</script> </script>
<style scoped> <style scoped>
+33
View File
@@ -0,0 +1,33 @@
<template>
<footer class="icp-footer" :class="{ 'mp-embed': mpEmbed }">
<a class="icp-link" :href="ICP_MIIT_URL" target="_blank" rel="noopener noreferrer">{{ ICP_RECORD }}</a>
</footer>
</template>
<script setup lang="ts">
import { ICP_MIIT_URL, ICP_RECORD } from '@yuxingu/utils'
import { isMpEmbed } from '../lib/mpEmbed'
const mpEmbed = isMpEmbed()
</script>
<style scoped>
.icp-footer {
flex-shrink: 0;
text-align: center;
padding: 20px 12px 8px;
margin-top: 8px;
}
.icp-footer.mp-embed {
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
.icp-link {
font-size: 11px;
line-height: 1.4;
color: #9a8f8b;
text-decoration: none;
}
.icp-link:active {
opacity: 0.75;
}
</style>
+3 -7
View File
@@ -35,19 +35,15 @@ withDefaults(
.ps { .ps {
position: relative; position: relative;
overflow-x: hidden; overflow-x: hidden;
padding-top: 48px; padding-top: var(--yxg-page-top);
padding-bottom: var(--spacing-xs); padding-bottom: var(--spacing-xs);
min-height: 100%; min-height: 100%;
background: background: var(--yxg-page-wash);
radial-gradient(110% 70% at 85% -8%, rgba(255, 190, 170, 0.65) 0%, transparent 52%),
radial-gradient(90% 55% at 8% 6%, rgba(255, 220, 200, 0.75) 0%, transparent 48%),
radial-gradient(60% 40% at 50% 0%, rgba(255, 255, 255, 0.55) 0%, transparent 60%),
linear-gradient(180deg, var(--color-bg-start) 0%, #ffe4d8 28%, #fff4ef 52%, var(--color-bg-sheet) 100%);
} }
.ps-flat { .ps-flat {
background: transparent; background: transparent;
min-height: 0; min-height: 0;
padding-top: 48px; padding-top: var(--yxg-page-top);
} }
.atm { .atm {
position: absolute; position: absolute;
@@ -27,6 +27,8 @@ export function useImageCardPage() {
const paying = ref(false) const paying = ref(false)
const error = ref('') const error = ref('')
const bootError = ref('') const bootError = ref('')
/** Fail-open until proven empty. */
const configAvailable = ref(true)
const cards = ref<{ id: string; title: string; imagery: string; prompt: string; tip: string }[]>([]) const cards = ref<{ id: string; title: string; imagery: string; prompt: string; tip: string }[]>([])
const report = ref<GrowthReport | null>(null) const report = ref<GrowthReport | null>(null)
const questionInput = ref('') const questionInput = ref('')
@@ -52,21 +54,38 @@ export function useImageCardPage() {
track('cards_scene_selected', { scene: key }) track('cards_scene_selected', { scene: key })
} }
async function refreshConfigGate() {
try {
const res = await api.getCardDecks()
configAvailable.value = (res.items || []).length > 0
} catch {
configAvailable.value = true
}
}
function onPromptClick(row: { label: string; scene: string }) { function onPromptClick(row: { label: string; scene: string }) {
if (!configAvailable.value) {
error.value = '意象牌组暂未开放'
return
}
selectScene(row.scene) selectScene(row.scene)
questionInput.value = row.label questionInput.value = row.label
if (birthFilled()) { if (birthFilled()) {
draw() void draw()
} else { } else {
showBirth.value = true showBirth.value = true
} }
} }
function sendDraw() { function sendDraw() {
if (!configAvailable.value) {
error.value = '意象牌组暂未开放'
return
}
if (questionInput.value.trim()) { if (questionInput.value.trim()) {
scene.value = questionInput.value.trim() scene.value = questionInput.value.trim()
} }
draw() void draw()
} }
async function refreshQuota() { async function refreshQuota() {
@@ -75,6 +94,8 @@ export function useImageCardPage() {
async function boot() { async function boot() {
if (!(await ensureAccount(router, route.fullPath))) return if (!(await ensureAccount(router, route.fullPath))) return
await refreshConfigGate()
if (!configAvailable.value) return
try { try {
const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()]) const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()])
scenes.value = sc.items || [] scenes.value = sc.items || []
@@ -87,6 +108,10 @@ export function useImageCardPage() {
} }
async function draw() { async function draw() {
if (!configAvailable.value) {
error.value = '意象牌组暂未开放'
return
}
if (!(await ensureAccount(router, route.fullPath))) return if (!(await ensureAccount(router, route.fullPath))) return
error.value = '' error.value = ''
const y = Number(year.value) const y = Number(year.value)
@@ -145,6 +170,7 @@ export function useImageCardPage() {
return { return {
scene, scene,
configAvailable,
quotaLabel, quotaLabel,
year, year,
month, month,
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { applyMpEmbedDom, initMpEmbed, isMpEmbed, isMpNativeHeader } from './mpEmbed'
describe('mpEmbed', () => {
beforeEach(() => {
sessionStorage.clear()
document.documentElement.className = ''
document.documentElement.style.removeProperty('--yxg-safe-top')
document.documentElement.style.removeProperty('--yxg-page-top')
window.history.replaceState({}, '', '/psy/')
})
afterEach(() => {
sessionStorage.clear()
})
it('persists embed flag from ?mp=1', () => {
window.history.replaceState({}, '', '/psy/?mp=1')
initMpEmbed()
expect(isMpEmbed()).toBe(true)
})
it('persists status bar height from ?sbh=', () => {
window.history.replaceState({}, '', '/psy/?mp=1&sbh=47')
initMpEmbed()
expect(document.documentElement.style.getPropertyValue('--yxg-safe-top')).toBe('47px')
expect(document.documentElement.style.getPropertyValue('--yxg-page-top')).toBe('calc(47px + 50px)')
expect(sessionStorage.getItem('yxg_sbh')).toBe('47')
window.history.replaceState({}, '', '/psy/?mp=1')
applyMpEmbedDom()
expect(document.documentElement.style.getPropertyValue('--yxg-safe-top')).toBe('47px')
})
it('uses native mini-program header mode with ?nh=1', () => {
window.history.replaceState({}, '', '/psy/?mp=1&nh=1')
initMpEmbed()
expect(isMpNativeHeader()).toBe(true)
expect(document.documentElement.classList.contains('mp-native-header')).toBe(true)
expect(document.documentElement.style.getPropertyValue('--yxg-safe-top')).toBe('0px')
expect(document.documentElement.style.getPropertyValue('--yxg-page-top')).toBe('4px')
})
it('applies sbh in native header mode with ?nh=1&sbh=', () => {
window.history.replaceState({}, '', '/psy/?mp=1&nh=1&sbh=12')
initMpEmbed()
expect(isMpNativeHeader()).toBe(true)
expect(sessionStorage.getItem('yxg_sbh')).toBe('12')
expect(document.documentElement.style.getPropertyValue('--yxg-page-top')).toBe('12px')
window.history.replaceState({}, '', '/psy/?mp=1&nh=1')
applyMpEmbedDom()
expect(document.documentElement.style.getPropertyValue('--yxg-page-top')).toBe('12px')
})
it('returns false without mp query', () => {
initMpEmbed()
expect(isMpEmbed()).toBe(false)
})
})
+89
View File
@@ -0,0 +1,89 @@
const MP_EMBED_KEY = 'yxg_mp_embed'
const MP_NATIVE_HEADER_KEY = 'yxg_mp_native_header'
const MP_QUERY = 'mp'
const NH_QUERY = 'nh'
const SBH_QUERY = 'sbh'
const SBH_KEY = 'yxg_sbh'
const HEADER_H_PX = 50
const MP_CONTENT_TOP_PX = 4
/** 小程序顶栏在 web-view 外,H5 内容区仅留极小顶距 */
function applyNativeHeaderInset(topPx: number): void {
if (typeof document === 'undefined') return
const root = document.documentElement
root.style.setProperty('--yxg-safe-top', '0px')
root.style.setProperty('--yxg-page-top', `${topPx}px`)
}
function applyNativeHeaderMode(): void {
applyNativeHeaderInset(MP_CONTENT_TOP_PX)
}
function applySafeTopInset(px: number): void {
if (typeof document === 'undefined' || px <= 0) return
const root = document.documentElement
const safe = `${px}px`
root.style.setProperty('--yxg-safe-top', safe)
root.style.setProperty('--yxg-page-top', `calc(${safe} + ${HEADER_H_PX}px)`)
}
function readStoredSbh(): number {
if (typeof sessionStorage === 'undefined') return 0
const raw = sessionStorage.getItem(SBH_KEY)
if (!raw || !/^\d+$/.test(raw)) return 0
return parseInt(raw, 10)
}
export function isMpNativeHeader(): boolean {
if (typeof window === 'undefined') return false
return sessionStorage.getItem(MP_NATIVE_HEADER_KEY) === '1'
}
export function applyMpEmbedDom(): void {
if (typeof document === 'undefined' || !isMpEmbed()) return
document.title = '\u200b'
document.documentElement.classList.add('mp-embed')
if (isMpNativeHeader()) {
document.documentElement.classList.add('mp-native-header')
const stored = readStoredSbh()
if (stored > 0) {
applyNativeHeaderInset(stored)
} else {
applyNativeHeaderMode()
}
return
}
const stored = readStoredSbh()
if (stored > 0) applySafeTopInset(stored)
}
export function initMpEmbed(): void {
if (typeof window === 'undefined') return
const params = new URLSearchParams(window.location.search)
if (params.get(MP_QUERY) === '1') {
sessionStorage.setItem(MP_EMBED_KEY, '1')
}
if (params.get(NH_QUERY) === '1') {
sessionStorage.setItem(MP_NATIVE_HEADER_KEY, '1')
const nativeSbh = params.get(SBH_QUERY)
if (nativeSbh && /^\d+$/.test(nativeSbh)) {
sessionStorage.setItem(SBH_KEY, nativeSbh)
applyNativeHeaderInset(parseInt(nativeSbh, 10))
} else {
applyNativeHeaderMode()
}
}
if (!isMpNativeHeader()) {
const sbh = params.get(SBH_QUERY)
if (sbh && /^\d+$/.test(sbh)) {
sessionStorage.setItem(SBH_KEY, sbh)
applySafeTopInset(parseInt(sbh, 10))
}
}
applyMpEmbedDom()
}
export function isMpEmbed(): boolean {
if (typeof window === 'undefined') return false
return sessionStorage.getItem(MP_EMBED_KEY) === '1'
}
+3
View File
@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { initAnalytics, trackPageView } from './lib/analytics' import { initAnalytics, trackPageView } from './lib/analytics'
import { initMpEmbed } from './lib/mpEmbed'
initMpEmbed()
import '@yuxingu/ui/tokens.css' import '@yuxingu/ui/tokens.css'
import './styles/app.css' import './styles/app.css'
+2 -6
View File
@@ -48,14 +48,10 @@ const {
.home { .home {
position: relative; position: relative;
overflow-x: hidden; overflow-x: hidden;
padding-top: 48px; padding-top: var(--yxg-page-top);
padding-bottom: var(--spacing-xs); padding-bottom: var(--spacing-xs);
min-height: 100%; min-height: 100%;
background: background: var(--yxg-page-wash);
radial-gradient(110% 70% at 85% -8%, rgba(255, 190, 170, 0.65) 0%, transparent 52%),
radial-gradient(90% 55% at 8% 6%, rgba(255, 220, 200, 0.75) 0%, transparent 48%),
radial-gradient(60% 40% at 50% 0%, rgba(255, 255, 255, 0.55) 0%, transparent 60%),
linear-gradient(180deg, var(--color-bg-start) 0%, #ffe4d8 28%, #fff4ef 52%, var(--color-bg-sheet) 100%);
} }
.atm { .atm {
position: absolute; position: absolute;
+68 -27
View File
@@ -11,41 +11,50 @@
</template> </template>
<div class="body" :class="{ 'has-results': cards.length }"> <div class="body" :class="{ 'has-results': cards.length }">
<ImageCardGreeting <div v-if="!configAvailable && !cards.length" class="empty-gate">
:boot-error="bootError" <HomeToolIcon name="cards" :size="56" />
:quota-label="quotaLabel" <p class="empty-title">意象牌组暂未开放</p>
:scene="scene" <p class="empty-sub">运营尚未启用意象牌组请稍后再来</p>
/> <button class="cta-off" type="button" disabled>抽取意象卡</button>
</div>
<ImageCardLanding <template v-else>
v-if="!cards.length" <ImageCardGreeting
v-model:want-depth="wantDepth" :boot-error="bootError"
v-model:show-birth="showBirth" :quota-label="quotaLabel"
v-model:year="year" :scene="scene"
v-model:month="month" />
v-model:day="day"
:drawing="drawing"
@prompt-click="onPromptClick"
@draw="draw"
/>
<ImageCardResults <ImageCardLanding
v-if="cards.length" v-if="!cards.length"
:cards="cards" v-model:want-depth="wantDepth"
:report="report" v-model:show-birth="showBirth"
:summary="summary" v-model:year="year"
:detail="detail" v-model:month="month"
:want-depth="wantDepth" v-model:day="day"
:paying="paying" :drawing="drawing"
@buy-deep="buyDeep" @prompt-click="onPromptClick"
/> @draw="draw"
/>
<ImageCardResults
v-if="cards.length"
:cards="cards"
:report="report"
:summary="summary"
:detail="detail"
:want-depth="wantDepth"
:paying="paying"
@buy-deep="buyDeep"
/>
</template>
<p class="yxg-disc ai-note">意象卡片用于投射与自我反思不判定好坏也不预测未来部分内容由 AI 生成仅供参考</p> <p class="yxg-disc ai-note">意象卡片用于投射与自我反思不判定好坏也不预测未来部分内容由 AI 生成仅供参考</p>
<p v-if="error" class="yxg-err err-float">{{ error }}</p> <p v-if="error" class="yxg-err err-float">{{ error }}</p>
</div> </div>
<ImageCardInputBar <ImageCardInputBar
v-if="!cards.length" v-if="configAvailable && !cards.length"
v-model:question-input="questionInput" v-model:question-input="questionInput"
:drawing="drawing" :drawing="drawing"
@send="sendDraw" @send="sendDraw"
@@ -64,6 +73,7 @@ import { useImageCardPage } from '../composables/useImageCardPage'
const { const {
scene, scene,
configAvailable,
quotaLabel, quotaLabel,
year, year,
month, month,
@@ -119,4 +129,35 @@ const {
.err-float { .err-float {
margin-top: 8px; margin-top: 8px;
} }
.empty-gate {
text-align: center;
padding: 28px 16px 12px;
}
.empty-title {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
margin-top: 10px;
letter-spacing: 0.06em;
}
.empty-sub {
font-size: 13px;
color: rgba(0, 0, 0, 0.42);
margin-top: 8px;
line-height: 1.5;
}
.cta-off {
margin-top: 16px;
width: 100%;
max-width: 320px;
padding: 13px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
opacity: 0.45;
cursor: not-allowed;
}
</style> </style>
+22
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { isMpEmbed } from '../lib/mpEmbed'
const router = createRouter({ const router = createRouter({
history: createWebHistory('/psy/'), history: createWebHistory('/psy/'),
@@ -48,4 +49,25 @@ const router = createRouter({
}, },
}) })
router.beforeEach((to) => {
if (!isMpEmbed()) return true
const q = { ...to.query }
let changed = false
if (q.mp !== '1') {
q.mp = '1'
changed = true
}
if (sessionStorage.getItem('yxg_mp_native_header') === '1' && q.nh !== '1') {
q.nh = '1'
changed = true
}
const storedSbh = sessionStorage.getItem('yxg_sbh')
if (storedSbh && q.sbh !== storedSbh && q.nh !== '1') {
q.sbh = storedSbh
changed = true
}
if (!changed) return true
return { path: to.path, query: q, hash: to.hash, replace: true }
})
export default router export default router
+78 -6
View File
@@ -1,22 +1,95 @@
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent} *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body,#app{min-height:100%} html,body,#app{min-height:100%;min-height:100dvh}
html{
background-color:var(--color-bg-start);
}
body{ body{
font-family:var(--font-sans); font-family:var(--font-sans);
font-size:14px; font-size:14px;
line-height:1.65; line-height:1.65;
color:var(--color-text-primary); color:var(--color-text-primary);
-webkit-font-smoothing:antialiased; -webkit-font-smoothing:antialiased;
background: background:var(--yxg-body-wash);
background-attachment:fixed;
}
:root{
--yxg-body-wash:
radial-gradient(120% 80% at 80% -10%, rgba(255,180,160,.45) 0%, transparent 55%), radial-gradient(120% 80% at 80% -10%, rgba(255,180,160,.45) 0%, transparent 55%),
radial-gradient(90% 60% at 10% 8%, rgba(255,210,190,.55) 0%, transparent 50%), radial-gradient(90% 60% at 10% 8%, rgba(255,210,190,.55) 0%, transparent 50%),
linear-gradient(180deg, var(--yxg-bg-start) 0%, var(--yxg-bg-end) 28%, var(--color-bg-sheet) 100%); linear-gradient(180deg, var(--yxg-bg-start) 0%, var(--yxg-bg-end) 28%, var(--color-bg-sheet) 100%);
background-attachment:fixed; --yxg-page-wash:
radial-gradient(110% 70% at 85% -8%, rgba(255, 190, 170, 0.65) 0%, transparent 52%),
radial-gradient(90% 55% at 8% 6%, rgba(255, 220, 200, 0.75) 0%, transparent 48%),
radial-gradient(60% 40% at 50% 0%, rgba(255, 255, 255, 0.55) 0%, transparent 60%),
linear-gradient(180deg, var(--color-bg-start) 0%, #ffe4d8 28%, #fff4ef 52%, var(--color-bg-sheet) 100%);
--yxg-header-h:50px;
--yxg-page-top:calc(env(safe-area-inset-top,0px) + var(--yxg-header-h));
--yxg-safe-top:env(safe-area-inset-top,0px);
} }
.app-shell{ .app-shell{
max-width:var(--yxg-max-w);margin:0 auto;min-height:100vh; max-width:var(--yxg-max-w);margin:0 auto;min-height:100vh;min-height:100dvh;
padding-bottom:calc(var(--yxg-nav-h) + env(safe-area-inset-bottom,0px) + 12px); display:flex;flex-direction:column;
padding-bottom:calc(var(--yxg-nav-h) + env(safe-area-inset-bottom,0px));
position:relative; position:relative;
} }
.app-body{
flex:1 0 auto;
position:relative;
z-index:1;
}
html.mp-embed{
background-color:var(--color-bg-start);
background-image:var(--yxg-body-wash);
background-attachment:fixed;
}
html.mp-embed body{
background:transparent;
}
.app-shell.mp-embed{
padding-bottom:env(safe-area-inset-bottom,0px);
isolation:isolate;
}
.app-shell.mp-embed::before{
content:"";
position:fixed;
inset:0;
z-index:0;
pointer-events:none;
background:var(--yxg-body-wash);
background-color:var(--color-bg-start);
}
.app-shell.mp-embed .home,
.app-shell.mp-embed .ps{
margin-top:0;
padding-top:var(--yxg-page-top);
min-height:100%;
}
/* 小程序原生顶栏:桃色顶区对齐 nav-bar,隐藏 H5 AppHeader,洗底不冲顶 */
html.mp-embed.mp-native-header{
background-color:#ffd1c7;
background-image:none;
}
html.mp-embed.mp-native-header body{
background:transparent;
}
html.mp-embed.mp-native-header .app-shell.mp-embed::before{
background-color:#ffd1c7;
}
html.mp-embed.mp-native-header .app-header.back-only{
display:block;
}
html.mp-embed.mp-native-header .app-header:not(.back-only){
display:none !important;
}
html.mp-embed.mp-native-header .app-shell .home,
html.mp-embed.mp-native-header .app-shell .ps,
html.mp-embed.mp-native-header .app-shell .yxg-page{
padding-top:var(--yxg-page-top);
}
.app-footer{
position:relative;
z-index:1;
}
a{color:inherit;text-decoration:none} a{color:inherit;text-decoration:none}
/* ── Page shell (测测式:暖色洗底 + 白 sheet) ── */ /* ── Page shell (测测式:暖色洗底 + 白 sheet) ── */
@@ -261,4 +334,3 @@ a{color:inherit;text-decoration:none}
.fc-c .yxg-feed-cover{background:linear-gradient(145deg,#D6E8FF,#B0D0F5);color:#3A6FB0} .fc-c .yxg-feed-cover{background:linear-gradient(145deg,#D6E8FF,#B0D0F5);color:#3A6FB0}
.fc-d .yxg-feed-cover{background:linear-gradient(145deg,#D8F0DC,#B5E0BE);color:#3A8A4A} .fc-d .yxg-feed-cover{background:linear-gradient(145deg,#D8F0DC,#B5E0BE);color:#3A8A4A}
.fc-e .yxg-feed-cover{background:linear-gradient(145deg,#FFE6C8,#F5C98A);color:#B07A28} .fc-e .yxg-feed-cover{background:linear-gradient(145deg,#FFE6C8,#F5C98A);color:#B07A28}
)
+6
View File
@@ -0,0 +1,6 @@
# 复制为 deploy.env(勿提交 deploy.env
# 用法: ./scripts/deploy-h5.sh 或 DEPLOY_ENV=./deploy.env ./scripts/deploy-h5.sh
H5_DEPLOY_USER=ubuntu
H5_DEPLOY_HOST=your-server.example.com
H5_DEPLOY_PATH=/var/www/h5.yuxingu.com.cn/psy
@@ -3,8 +3,8 @@
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| ID | BD-2026-044 | | ID | BD-2026-044 |
| Status | **Approved** | | Status | **Approved · Implemented** |
| Coding | **AUTHORIZED** | | Coding | **Done · Closed** |
| Level | L2 | | Level | L2 |
| Migration | `000053_ops_rhythm_config_write`(编码时分配) | | Migration | `000053_ops_rhythm_config_write`(编码时分配) |
@@ -0,0 +1,23 @@
# Backend Design: ECR-045 ImageCardDeck Write
| Field | Value |
|-------|-------|
| ID | BD-2026-045 |
| Status | **Approved · Implemented** |
| Coding | **Done · Closed** |
| Level | L2 |
| Migration | `000054_ops_image_card_deck_write` |
## Boundary
```text
POST/PUT /api/v1/admin/explore/image-card-decks*
GET /api/v1/cards/decks
Permission: admin.explore.write (reuse)
Table: image_card_decks
Audit: explore.image_card_deck.create|update
```
## Out
牌面/单卡 · Star · Rhythm · Scale · AICore · soft-delete · payment · UGC · auto ECR-046
@@ -0,0 +1,22 @@
# Backend Design: ECR-046 ScaleDefinition Write
| Field | Value |
|-------|-------|
| ID | BD-2026-046 |
| Status | **Closed** |
| Coding | **Closed** |
| Migration | `000055_ops_scale_definition_write` |
## Boundary
```text
POST/PUT /api/v1/admin/explore/scales*
Permission: admin.explore.write
Fields: slug, title, description (create → draft)
Status: ECR-008 PATCH only
Audit: explore.scale_definition.create|update
```
## Out
questions · status on explore path · soft-delete · Prompt · auto ECR-047
@@ -0,0 +1,23 @@
# Backend Design: ECR-047 FunnelDefinition Write
| Field | Value |
|-------|-------|
| ID | BD-2026-047 |
| Status | **Closed** |
| Coding | **Closed** |
| Migration | `000056_ops_funnel_definition_write` |
## Boundary
```text
POST/PUT /api/v1/admin/analytics/funnel-definitions*
Permission: admin.growth.write
Fields: code, title, active
system=true: code immutable
Audit: growth.funnel_definition.create|update
No C-end route · no DELETE
```
## Out
ReportTemplate · Prompt · soft-delete · auto ECR-048
@@ -0,0 +1,23 @@
# Backend Design: ECR-048 ReportTemplate Write
| Field | Value |
|-------|-------|
| ID | BD-2026-048 |
| Status | **Closed** |
| Coding | **Closed** |
| Migration | `000057_ops_report_template_write` |
## Boundary
```text
POST/PUT /api/v1/admin/growth/report-templates*
Permission: admin.growth.write (reuse)
Fields: code, title, scene, active
system=true: code immutable
Audit: growth.report_template.create|update
No C-end route · no DELETE
```
## Out
Funnel re-write · Prompt · soft-delete · auto ECR-049
@@ -0,0 +1,26 @@
# Backend Design: ECR-049 WeChat Auth + Consult Gateway
| Field | Value |
|-------|-------|
| ID | BD-2026-049 |
| Status | **Approved** |
| Coding | **In progress** |
| Migration | `000058_wechat_auth_gateway` |
## Boundary
```text
POST /api/v1/auth/wechat
{ code, encryptedData, iv } → existing SessionResult
users.wx_openid UNIQUE · wx_unionid · java_platform_user_id
user_sessions.java_access_token
ANY /api/app-api/* → JAVA_BASE/app-api/* (Go Bearer → Java Bearer)
ANY /api/admin-api/* → JAVA_BASE/admin-api/*
Java POST /app-api/oauth/internal/issue-mini-app (X-Internal-Key)
Identity: wx_openid = t_platform_user_account.account (mini_app)
phone = t_platform_user.mobile
```
## Out
Module migrate · Java decommission · Admin auth · auto ECR-050
@@ -0,0 +1,21 @@
# Backend Design: ECR-050 Consult Native
| Field | Value |
|-------|-------|
| ID | BD-2026-050 |
| Status | **Approved** |
| Migration | `000059_consult_native` |
## Boundary
```text
/api/v1/psychic/banner|news|procotol|study|user-choice|doctor-info|appointment|order|pay|platform-user
/api/v1/upload
PG tables consult_*
WeChat JSAPI unifiedorder when PAY_* set
Remove /api/app-api proxy
```
## Out
Live Java shutdown · MySQL 全量灌数(另做运维脚本)· Admin
+17
View File
@@ -1,7 +1,24 @@
# CHANGELOG — ESS process artifacts # CHANGELOG — ESS process artifacts
## 2026-09-14
- **ECR-050 Approved · Coding** 咨询/测评/订单/支付迁入 Gomigration `000059`)· 去掉 Java 反代 · Human「阶段3,4一起实现再测试」· **禁自动 ECR-051**
- **ECR-049 Approved · Coding** 微信小程序登录 + 咨询过渡反代(ADR-0008 · migration `000058` · 不迁咨询业务)· Human「按这个执行」· **禁自动 ECR-050**
## 2026-08-13 ## 2026-08-13
- **ECR-048 Closed** ReportTemplate 薄写(复用 `admin.growth.write` POST/PUT · migration `000057` · 无新 C 端)· **Loop STOP** · 禁自动 ECR-049 · 无 Funnel/Prompt
- **ECR-048 Candidate Review Approved** 首刀 = **ReportTemplate** 薄写(复用 `admin.growth.write`;编码占 `000057`)· Spec/Loop/Coding **未开** · Auth 草案 PENDING · **不得继承 047 Auth**
- **ECR-047 Freeze/Review → FROZEN WAIT** push 齐平 `580f5ea` · 单刀授权硬边界 · **禁自动 ECR-048** · 下一刀须 Candidate Review
- **ECR-047 Authorized Closed** Human `authorize ECR-047 Closed` · 接受本地 `1db5fcc`/`69cb996`/`000056` 为正式 Closed · **不含 push** · Loop STOP · 禁自动 ECR-048
- **ECR-047 Closed** FunnelDefinition 薄写(`admin.growth.write` POST/PUT · migration `000056` · 无新 C 端)· **Loop STOP** · 禁自动 ECR-048 · 无 ReportTemplate
- **ECR-047 Candidate Review Approved** 首刀 = **FunnelDefinition** 薄写(code/title/active`admin.growth.write`)· **不**做 ReportTemplate · Spec/Loop/Coding **未开** · Auth 草案 PENDING
- **ECR-047 Candidate Review DRAFT** ExploreConfig 写面耗尽后开刀;**推荐 ReportTemplate** · 备选 FunnelDefinition · 禁 Prompt/Knowledge/Chunk · Spec/Loop/Coding **未开**
- **ECR-046 Closed** ScaleDefinition 元数据薄写(`explore.write` POST/PUT · create→draft · status 仍 ECR-008 · migration `000055`)· **Loop STOP** · 禁自动 ECR-047 · 无题干编辑
- **ECR-046 Candidate Review Approved** 首刀 = **ScaleDefinition 元数据薄写**slug/title/descriptioncreate→draft)· **status 仍独占 ECR-008** · Spec/Loop/Coding **未开** · Auth 草案 PENDING
- **ECR-046 Candidate Review DRAFT** 推荐 ExploreConfig 写面波次 **STOP**;慎选 ScaleDefinition(须相对 ECR-008 增量);禁 Prompt/Knowledge/Chunk · 待 Human 拍板
- **ECR-045 Closed** ExploreConfig ImageCardDeck 薄写面(复用 `admin.explore.write` · POST/PUT · `GET /cards/decks` · `/cards` 空态/失败回退 · migration `000054` · **无牌面编辑**)· **Loop STOP** · 禁自动 ECR-046
- **ECR-045 Candidate Review Approved** 首刀 = **ImageCardDeck**(不预设 SystemPrompt;禁牌面内容编辑)· Spec/Loop/Coding **未开** · Auth 草案 PENDING
- **ECR-044 Closed** ExploreConfig RhythmConfig 薄写面(复用 `admin.explore.write` · POST/PUT · `GET /rhythm/configs` · `/rhythm` 空态/失败回退 · migration `000053`)· **Continuous Loop STOP** · 禁自动 ECR-045 - **ECR-044 Closed** ExploreConfig RhythmConfig 薄写面(复用 `admin.explore.write` · POST/PUT · `GET /rhythm/configs` · `/rhythm` 空态/失败回退 · migration `000053`)· **Continuous Loop STOP** · 禁自动 ECR-045
- **ECR-044 Candidate Review Approved** 首刀 = **RhythmConfig**(不预设 SystemPrompt)· Spec/Loop/Coding **未开** · Auth 草案 PENDING - **ECR-044 Candidate Review Approved** 首刀 = **RhythmConfig**(不预设 SystemPrompt)· Spec/Loop/Coding **未开** · Auth 草案 PENDING
- **ECR-043 Closed** ExploreConfig StarConfig 薄写面(`admin.explore.write` · POST/PUT · `GET /star/configs` · `/star` 空态/失败回退 · migration `000052`)· **Continuous Loop STOP** · 禁自动 ECR-044 - **ECR-043 Closed** ExploreConfig StarConfig 薄写面(`admin.explore.write` · POST/PUT · `GET /star/configs` · `/star` 空态/失败回退 · migration `000052`)· **Continuous Loop STOP** · 禁自动 ECR-044
+3
View File
@@ -0,0 +1,3 @@
# CODE_REVIEW — ECR-045
**Verdict:** Approve · ImageCardDeck 目录薄写 only · 无牌面/单卡 CRUD · 复用 explore.write · Closed 后 STOP · 禁自动 ECR-046 · ECR-008 Live Promote 仍 BLOCKED
+3
View File
@@ -0,0 +1,3 @@
# CODE_REVIEW — ECR-046
**Verdict:** Approve · ScaleDefinition 元数据 only · status 通道未侵入 · 无题干/soft-delete/Prompt · Closed 后 STOP · ECR-008 Live Promote 仍 BLOCKED
+3
View File
@@ -0,0 +1,3 @@
# CODE_REVIEW — ECR-047
**Verdict:** Approve · FunnelDefinition 薄写 only · 无 ReportTemplate/Prompt/soft-delete · Closed 后 STOP · ECR-008 Live Promote 仍 BLOCKED
+3
View File
@@ -0,0 +1,3 @@
# CODE_REVIEW — ECR-048
**Verdict:** Approve · ReportTemplate 薄写 only · 无 Funnel/Prompt/soft-delete · Closed 后 STOP · ECR-008 Live Promote 仍 BLOCKED
+20
View File
@@ -0,0 +1,20 @@
ecr: ECR-045
capability: ExploreConfig
change:
type: additive
breaking_change: false
migration_required: true
apis:
- method: POST
path: /api/v1/admin/explore/image-card-decks
change: added
- method: PUT
path: /api/v1/admin/explore/image-card-decks/{id}
change: added
- method: GET
path: /api/v1/cards/decks
change: added
perms:
- code: admin.explore.write
change: reused
migration: 000054_ops_image_card_deck_write
+20
View File
@@ -0,0 +1,20 @@
ecr: ECR-046
capability: ExploreConfig
change:
type: additive
breaking_change: false
migration_required: true
apis:
- method: POST
path: /api/v1/admin/explore/scales
change: added
- method: PUT
path: /api/v1/admin/explore/scales/{id}
change: added
perms:
- code: admin.explore.write
change: reused
- code: admin.content.write
change: unchanged_status_channel
migration: 000055_ops_scale_definition_write
notes: status publish remains PATCH /admin/scales/{id} (ECR-008)
+18
View File
@@ -0,0 +1,18 @@
ecr: ECR-047
capability: GrowthInsights
change:
type: additive
breaking_change: false
migration_required: true
apis:
- method: POST
path: /api/v1/admin/analytics/funnel-definitions
change: added
- method: PUT
path: /api/v1/admin/analytics/funnel-definitions/{id}
change: added
perms:
- code: admin.growth.write
change: added
migration: 000056_ops_funnel_definition_write
notes: no C-end route; ReportTemplate out of scope
+18
View File
@@ -0,0 +1,18 @@
ecr: ECR-048
capability: GrowthInsights
change:
type: additive
breaking_change: false
migration_required: true
apis:
- method: POST
path: /api/v1/admin/growth/report-templates
change: added
- method: PUT
path: /api/v1/admin/growth/report-templates/{id}
change: added
perms:
- code: admin.growth.write
change: reused
migration: 000057_ops_report_template_write
notes: no C-end route; Funnel out of scope

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