Compare commits
41
Commits
62cd8c45dd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7155b8b53a | ||
|
|
db4d118f9a | ||
|
|
fc53be22f8 | ||
|
|
e7290ed726 | ||
|
|
db3669c595 | ||
|
|
580ba85b61 | ||
|
|
57687de360 | ||
|
|
f4bd57ce1b | ||
|
|
9d4fb52d29 | ||
|
|
96292d0e95 | ||
|
|
635a83fe11 | ||
|
|
ebc2d3ad80 | ||
|
|
8d856fb56c | ||
|
|
d33a25a663 | ||
|
|
c693ac4bbc | ||
|
|
e0f605558b | ||
|
|
aa197b719b | ||
|
|
580f5ea3f0 | ||
|
|
69cb99600f | ||
|
|
1db5fcc018 | ||
|
|
258deb0184 | ||
|
|
b3fe4363fd | ||
|
|
fa8b0ba963 | ||
|
|
0158036341 | ||
|
|
dc93999f9e | ||
|
|
87b463b2bb | ||
|
|
fcc7667ba3 | ||
|
|
19091bff12 | ||
|
|
77907f79ee | ||
|
|
2b295472a4 | ||
|
|
f492088237 | ||
|
|
cd3ac2c9ab | ||
|
|
ec20e9744e | ||
|
|
aff6621ff8 | ||
|
|
56f661748a | ||
|
|
655141980a | ||
|
|
9707b72808 | ||
|
|
ad45d17dff | ||
|
|
d81e8364cc | ||
|
|
b7b0b18802 | ||
|
|
0d50c0ee73 |
@@ -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 单次使用,且拖住前端统一接入)。
|
||||||
@@ -238,7 +238,7 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
|||||||
| Phase D `[Ops]` | 商业加深:订单筛选 · 展示价 · 退款只读(**ECR-009** · Spec `ops-commerce.md`) |
|
| Phase D `[Ops]` | 商业加深:订单筛选 · 展示价 · 退款只读(**ECR-009** · Spec `ops-commerce.md`) |
|
||||||
| Phase E `[Ops]` | 系统:RBAC · 封禁 · 推送占位(**ECR-010** · Spec `ops-system.md`) |
|
| Phase E `[Ops]` | 系统:RBAC · 封禁 · 推送占位(**ECR-010** · Spec `ops-system.md`) |
|
||||||
| Phase D+ Contract | **Contract-First** 扩展目录:**ECR-013A…040**(RBAC/兑换码/问答运营/安全/危机/CMS/ExploreConfig 等)。见 `docs/WAVE0/` |
|
| Phase D+ Contract | **Contract-First** 扩展目录:**ECR-013A…040**(RBAC/兑换码/问答运营/安全/危机/CMS/ExploreConfig 等)。见 `docs/WAVE0/` |
|
||||||
| Phase F+ | 封禁细策略 · 真推送通道 · 更细权限矩阵;**UGC 仍 `[No]`**;真支付最后 |
|
| Phase F+ / Write-Wave | **写面加深**(首刀 **ECR-041 Banner** · `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`);封禁细策略 · 真推送;**UGC 仍 `[No]`**;**真支付最后** |
|
||||||
|
|
||||||
不计入 P1 Complete;不进入五 Tab。
|
不计入 P1 Complete;不进入五 Tab。
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,10 @@
|
|||||||
| [ops-ai-core-config.md](ops-ai-core-config.md) | AI 核心配置 AICoreConfig | §7 | `admin-h5` `/ai` | Ops · **ECR-021** |
|
| [ops-ai-core-config.md](ops-ai-core-config.md) | AI 核心配置 AICoreConfig | §7 | `admin-h5` `/ai` | Ops · **ECR-021** |
|
||||||
| [ops-crisis-care.md](ops-crisis-care.md) | 危机关怀 CrisisCare | §7 | `admin-h5` `/crisis` | Ops · **ECR-022** |
|
| [ops-crisis-care.md](ops-crisis-care.md) | 危机关怀 CrisisCare | §7 | `admin-h5` `/crisis` | Ops · **ECR-022** |
|
||||||
| [ops-knowledge-source.md](ops-knowledge-source.md) | AI 知识源 KnowledgeSource | §7 | `admin-h5` `/ai` | Ops · **ECR-023** |
|
| [ops-knowledge-source.md](ops-knowledge-source.md) | AI 知识源 KnowledgeSource | §7 | `admin-h5` `/ai` | Ops · **ECR-023** |
|
||||||
| [ops-banner.md](ops-banner.md) | OpsCMS Banner | §7 | `admin-h5` `/cms` | Ops · **ECR-024** |
|
| [ops-banner.md](ops-banner.md) | OpsCMS Banner(只读基线) | §7 | `admin-h5` `/cms` | Ops · **ECR-024** |
|
||||||
| [ops-feed-slot.md](ops-feed-slot.md) | OpsCMS FeedSlot | §7 | `admin-h5` `/cms` | Ops · **ECR-025** |
|
| [ops-banner-write.md](ops-banner-write.md) | OpsCMS Banner **写面** | §7 | admin CMS 写 · `GET /home/banners` | Write-Wave · **ECR-041** |
|
||||||
|
| [ops-feed-slot.md](ops-feed-slot.md) | OpsCMS FeedSlot(只读基线) | §7 | `admin-h5` `/cms` | Ops · **ECR-025** |
|
||||||
|
| [ops-feed-slot-write.md](ops-feed-slot-write.md) | OpsCMS FeedSlot **写面** | §7 | admin CMS 写 · `GET /home/feed-slots` | Write-Wave · **ECR-042** |
|
||||||
| [ops-scheduled-publication.md](ops-scheduled-publication.md) | OpsCMS ScheduledPublication | §7 | `/admin/cms/publications*` | Ops · **ECR-026** |
|
| [ops-scheduled-publication.md](ops-scheduled-publication.md) | OpsCMS ScheduledPublication | §7 | `/admin/cms/publications*` | Ops · **ECR-026** |
|
||||||
| [ops-knowledge-chunk.md](ops-knowledge-chunk.md) | AICoreConfig KnowledgeChunk | §7 | `/admin/ai/knowledge-chunks*` | Ops · **ECR-027** |
|
| [ops-knowledge-chunk.md](ops-knowledge-chunk.md) | AICoreConfig KnowledgeChunk | §7 | `/admin/ai/knowledge-chunks*` | Ops · **ECR-027** |
|
||||||
| [ops-tool-definition.md](ops-tool-definition.md) | AICoreConfig ToolDefinition | §7 | `/admin/ai/tools*` | Ops · **ECR-028** |
|
| [ops-tool-definition.md](ops-tool-definition.md) | AICoreConfig ToolDefinition | §7 | `/admin/ai/tools*` | Ops · **ECR-028** |
|
||||||
@@ -47,12 +49,18 @@
|
|||||||
| [ops-intervention-outcome.md](ops-intervention-outcome.md) | CrisisCare InterventionOutcome | §7 | `/admin/crisis/interventions*` | Ops · **ECR-032** |
|
| [ops-intervention-outcome.md](ops-intervention-outcome.md) | CrisisCare InterventionOutcome | §7 | `/admin/crisis/interventions*` | Ops · **ECR-032** |
|
||||||
| [ops-handoff-case.md](ops-handoff-case.md) | AskOperations HandoffCase | §7 | `/admin/ask/handoffs*` | Ops · **ECR-033** |
|
| [ops-handoff-case.md](ops-handoff-case.md) | AskOperations HandoffCase | §7 | `/admin/ask/handoffs*` | Ops · **ECR-033** |
|
||||||
| [ops-privacy-request.md](ops-privacy-request.md) | AdminGovernance PrivacyRequest | §7 | `/admin/privacy/requests*` | Ops · **ECR-034** |
|
| [ops-privacy-request.md](ops-privacy-request.md) | AdminGovernance PrivacyRequest | §7 | `/admin/privacy/requests*` | Ops · **ECR-034** |
|
||||||
| [ops-star-config.md](ops-star-config.md) | ExploreConfig StarConfig | §7 | `/admin/explore/star-configs*` | Ops · **ECR-035** |
|
| [ops-star-config.md](ops-star-config.md) | ExploreConfig StarConfig(只读) | §7 | `/admin/explore/star-configs*` | Ops · **ECR-035** |
|
||||||
| [ops-rhythm-config.md](ops-rhythm-config.md) | ExploreConfig RhythmConfig | §7 | `/admin/explore/rhythm-configs*` | Ops · **ECR-036** |
|
| [ops-star-config-write.md](ops-star-config-write.md) | StarConfig **写面** | §7 | admin 写 · `GET /star/configs` | ExploreConfig · **ECR-043** |
|
||||||
| [ops-image-card-deck.md](ops-image-card-deck.md) | ExploreConfig ImageCardDeck | §7 | `/admin/explore/image-card-decks*` | Ops · **ECR-037** |
|
| [ops-rhythm-config.md](ops-rhythm-config.md) | ExploreConfig RhythmConfig(只读) | §7 | `/admin/explore/rhythm-configs*` | Ops · **ECR-036** |
|
||||||
| [ops-report-template.md](ops-report-template.md) | GrowthInsights ReportTemplate | §7 | `/admin/growth/report-templates*` | Ops · **ECR-038** |
|
| [ops-rhythm-config-write.md](ops-rhythm-config-write.md) | RhythmConfig **写面** | §7 | admin 写 · `GET /rhythm/configs` | ExploreConfig · **ECR-044** |
|
||||||
| [ops-funnel-definition.md](ops-funnel-definition.md) | GrowthInsights FunnelDefinition | §7 | `/admin/analytics/funnel-definitions*` | Ops · **ECR-039** |
|
| [ops-image-card-deck.md](ops-image-card-deck.md) | ExploreConfig ImageCardDeck(只读) | §7 | `/admin/explore/image-card-decks*` | Ops · **ECR-037** |
|
||||||
| [ops-scale-definition.md](ops-scale-definition.md) | ExploreConfig ScaleDefinition | §7 | `/admin/explore/scales*` | Ops · **ECR-040** |
|
| [ops-image-card-deck-write.md](ops-image-card-deck-write.md) | ImageCardDeck **写面** | §7 | admin 写 · `GET /cards/decks` | ExploreConfig · **ECR-045** |
|
||||||
|
| [ops-report-template.md](ops-report-template.md) | GrowthInsights ReportTemplate(只读) | §7 | `/admin/growth/report-templates*` | Ops · **ECR-038** |
|
||||||
|
| [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` → 填满 → 在本表登记 → 再编码。
|
||||||
|
|
||||||
|
|||||||
@@ -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 | 登录成功后:签发 session;device_identities.user_id 改绑到账号 |
|
| R3 | 登录成功后:签发 session;device_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 }` |
|
||||||
@@ -158,4 +160,5 @@ Device 绑定到账号 User
|
|||||||
## 13. AI 开发前检查
|
## 13. AI 开发前检查
|
||||||
|
|
||||||
- [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,112 @@
|
|||||||
|
# Feature Spec: OpsCMS · Banner 写面(Ops · ECR-041)
|
||||||
|
|
||||||
|
> Status: `Active`(**ECR-041 Closed** · Feature Complete)
|
||||||
|
> Map: `§7 运营后台` · Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
|
||||||
|
> Parent: WAVE0-FROZEN · Write-Wave: `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`
|
||||||
|
> Predecessor: ECR-024 Closed(Banner 只读)
|
||||||
|
> 关联:[ops-banner.md](ops-banner.md)(只读基线)· [ops-content.md](ops-content.md) · [home.md](home.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
让运营可 **创建 / 更新 / 上下架** Banner;C 端首页对 `placement=home` 的 active Banner **生效**;写操作鉴权 + 审计;失败可回退静态。
|
||||||
|
|
||||||
|
验证 Write-Wave 首刀闭环,**不**做 FeedSlot、真支付、UGC。
|
||||||
|
|
||||||
|
## 2. In / Out
|
||||||
|
|
||||||
|
| In | Out |
|
||||||
|
|----|-----|
|
||||||
|
| Admin:Banner create / update / 设 `active` | **FeedSlot** 一切写与 API |
|
||||||
|
| 加法权限 `admin.cms.write`(既有 RBAC) | soft-delete 物理删行(`system=true` 禁止删) |
|
||||||
|
| C 端:`GET` active banners(至少 `placement=home`) | ScheduledPublication 写 · 外链任意 URL |
|
||||||
|
| 首页推荐区:API Banner 投影优先,空则回退静态 `homeFeeds` | 把 FeedSlot 与 Banner 合并为一 ECR |
|
||||||
|
| `admin_audit_logs` 记录写操作 | Crisis / Handoff / ContentSafety 写 · 真支付 · UGC |
|
||||||
|
| OpenAPI · contract_diff · integration | 新权限子系统 · 改引擎分层 |
|
||||||
|
|
||||||
|
### 与 `homeFeeds` 的关系(诚实边界)
|
||||||
|
|
||||||
|
- ECR-008 将「首页 Feeds CMS」后置;表意信息流位属 **FeedSlot**(ECR-025 只读,写面另 ECR)。
|
||||||
|
- **本切片**:用 **Banner(placement=home)** 驱动首页推荐区展示(投影字段见下);**不**写 `ops_feed_slots`。
|
||||||
|
- 完整 Feeds 槽位运营(多 slot、排期)→ 后续 FeedSlot 写面 ECR。
|
||||||
|
|
||||||
|
## 3. Domain
|
||||||
|
|
||||||
|
| 概念 | 语义 |
|
||||||
|
|------|------|
|
||||||
|
| `Banner` | `code` 唯一;`placement` ∈ {home,explore,ask};`active`;`system` |
|
||||||
|
| 写语义 | upsert 字段;`active=false` = 下架;禁止 soft-delete 列/物理删 system 行 |
|
||||||
|
| C 端投影 | 仅 `active=true`;按 `sort_order` 升序 |
|
||||||
|
|
||||||
|
### 首页投影映射(Banner → 原 homeFeeds 卡)
|
||||||
|
|
||||||
|
| homeFeeds 字段 | 来源 |
|
||||||
|
|----------------|------|
|
||||||
|
| `to` | `link_path`(须站内 `/` 相对路径) |
|
||||||
|
| `title` | `title` |
|
||||||
|
| `meta` | 可选:无则空串或固定「运营推荐」 |
|
||||||
|
| `icon` / `tone` / `stat` / `tag` | 本切片可用缺省;**不**扩表除非 migration 明确(默认缺省,避免 scope creep) |
|
||||||
|
|
||||||
|
## 4. API
|
||||||
|
|
||||||
|
### Admin(既有前缀 `/api/v1/admin/cms`)
|
||||||
|
|
||||||
|
| Method | Path | 权限 | 语义 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/banners` · `/banners/{id}` | `admin.cms.read` | 保持 ECR-024 |
|
||||||
|
| POST | `/banners` | `admin.cms.write` | 创建(code 唯一) |
|
||||||
|
| PUT | `/banners/{id}` | `admin.cms.write` | 更新 title/placement/image_url/link_path/sort_order/active |
|
||||||
|
| — | DELETE | — | **不做**(下架用 `active=false`) |
|
||||||
|
|
||||||
|
校验:`link_path` 若非空须以 `/` 开头、禁 `http(s):`;`placement` 枚举;`system=true` 行不可改 `code`、不可删。
|
||||||
|
|
||||||
|
### C 端
|
||||||
|
|
||||||
|
| Method | Path | Auth | 语义 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/v1/home/banners` | DeviceAuth(与 `/home/tools` 同级) | 仅 `active`;query `placement` 默认 `home` |
|
||||||
|
|
||||||
|
## 5. Permission / Audit
|
||||||
|
|
||||||
|
- Migration 授予 `super_admin`:`admin.cms.write`(加法码,非新 RBAC 模型)。
|
||||||
|
- 每次 POST/PUT 写 `admin_audit_logs`(actor · action · target id/code · diff 摘要)。
|
||||||
|
|
||||||
|
## 6. Migration
|
||||||
|
|
||||||
|
- 若仅需权限种子:`000051_ops_banner_write`(以 TRACEABILITY Max+1 为准,编码时再锁)。
|
||||||
|
- **表结构**:默认复用 `ops_banners`;本切片 **不**为 FeedSlot 改表。若投影缺省不够且 Human 未扩 scope,不得私自加列。
|
||||||
|
|
||||||
|
## 7. UI
|
||||||
|
|
||||||
|
| 端 | 行为 |
|
||||||
|
|----|------|
|
||||||
|
| admin-h5 `/cms` | Banner 列表 + 新建/编辑/上下架(只动 Banner;FeedSlot 仍只读展示可保留) |
|
||||||
|
| user-h5 首页 | `GET /home/banners` → 推荐区;失败或空 → 静态 `homeFeeds` |
|
||||||
|
|
||||||
|
## 8. AC
|
||||||
|
|
||||||
|
| ID | Then |
|
||||||
|
|----|------|
|
||||||
|
| AC-F-01 | POST 合法 Banner → list/get 可见 |
|
||||||
|
| AC-F-02 | PUT `active=false` → C 端 list 不含 |
|
||||||
|
| AC-F-03 | PUT `active=true` + placement=home → `GET /home/banners` 含该项 |
|
||||||
|
| AC-F-04 | 重复 `code` → 4xx |
|
||||||
|
| AC-F-05 | 非法 `link_path`(外链)→ 4xx |
|
||||||
|
| AC-S-01 | 无 Admin → 401 |
|
||||||
|
| AC-S-02 | 仅 read 无 write → POST/PUT 403 |
|
||||||
|
| AC-A-01 | 写操作产生审计行 |
|
||||||
|
| AC-C-01 | H5 首页:有 active home Banner 时推荐区非纯静态 |
|
||||||
|
| AC-C-02 | API 失败时首页不白屏(回退静态) |
|
||||||
|
| AC-O-01 | 不触碰 FeedSlot 写路径 · 无 soft-delete · 无支付 |
|
||||||
|
|
||||||
|
## 9. Non-goals(STOP 对齐)
|
||||||
|
|
||||||
|
FeedSlot 写 · ScheduledPublication 写 · UGC · 真支付 · soft-delete · Crisis/Handoff/ContentSafety 写 · 扩大为本 ECR 合并多概念。
|
||||||
|
|
||||||
|
## 10. Trace
|
||||||
|
|
||||||
|
- ECR: `docs/ECR/ECR-041-banner-write.md`
|
||||||
|
- BD: `docs/BACKEND_DESIGN/BD-2026-041-banner-write.md`
|
||||||
|
- PRODUCT_SPEC · CONTRACT_DIFF · TEST_REPORT · CODE_REVIEW(实现阶段)
|
||||||
|
- Auth: `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
# Feature Spec: OpsCMS · Banner(Ops · ECR-024)
|
# Feature Spec: OpsCMS · Banner(Ops · ECR-024 只读基线)
|
||||||
|
|
||||||
> Status: `Active`(Loop continuous · **ECR-024 Closed**)
|
> Status: `Active`(**ECR-024 Closed** · 只读)
|
||||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-023 Closed
|
> Parent: WAVE0-FROZEN · Predecessor: ECR-023 Closed
|
||||||
> Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
|
> Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
|
||||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
> 授权(只读队列):`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||||
|
> **写面:** [ops-banner-write.md](ops-banner-write.md)(**ECR-041** · Write-Wave)
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals(本只读 Spec)
|
||||||
|
|
||||||
Banner 写发布 · FeedSlot · ScheduledPublication · UGC · 真支付
|
Banner 写发布(见 ECR-041)· FeedSlot · ScheduledPublication · UGC · 真支付
|
||||||
|
|
||||||
## L2 Domain
|
## L2 Domain
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
| In | Out |
|
| In | Out |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 首页宫格 CRUD(最多约 24 项;默认 12) | 首页 Feeds 信息流 CMS(本 ECR 后置,仍用静态 `homeFeeds`) |
|
| 首页宫格 CRUD(最多约 24 项;默认 12) | 首页 Feeds/推荐区运营:ECR-008 后置;**Banner 写面见 ECR-041**;完整 FeedSlot 写另 ECR |
|
||||||
| 工具 path / icon 白名单校验 | 任意外链、自定义 SVG 上传 |
|
| 工具 path / icon 白名单校验 | 任意外链、自定义 SVG 上传 |
|
||||||
| scales 列表 + 上架/下架 + 审计 | 新建量表题目编辑器 · 探索目录全量 CMS |
|
| scales 列表 + 上架/下架 + 审计 | 新建量表题目编辑器 · 探索目录全量 CMS |
|
||||||
| Admin「内容」页 | RBAC / 封禁 / 推送(Ops-E) |
|
| Admin「内容」页 | RBAC / 封禁 / 推送(Ops-E) |
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Feature Spec: OpsCMS · FeedSlot 写面(Ops · ECR-042)
|
||||||
|
|
||||||
|
> Status: `Active`(**ECR-042 Closed**)
|
||||||
|
> Map: `§7` · Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
|
||||||
|
> Write-Wave: `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`
|
||||||
|
> Predecessor: ECR-025 Closed · ECR-041 Closed
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
运营可创建/更新/上下架 FeedSlot;C 端可读 active 槽位;首页主信息流区按 `home.main`(或 placement=home 的 active 槽)显隐;失败回退「仍展示推荐区」。
|
||||||
|
|
||||||
|
## In / Out
|
||||||
|
|
||||||
|
| In | Out |
|
||||||
|
|----|-----|
|
||||||
|
| Admin POST/PUT feed-slots · `admin.cms.write` | Banner 再扩 · ScheduledPublication 写 |
|
||||||
|
| C 端 GET `/home/feed-slots` | UGC 内容流正文 CMS · soft-delete |
|
||||||
|
| 首页:无 active home 槽 → 隐藏推荐区;API 失败 → 仍显示 | 真支付 · Crisis/Handoff · 新权限模型 |
|
||||||
|
|
||||||
|
## Domain
|
||||||
|
|
||||||
|
复用 `ops_feed_slots`:`code` 唯一 · `slot_key` · `placement` ∈ {home,explore,ask} · `active` · `system`(system 不可改 code)。
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Perm |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/admin/cms/feed-slots*` | cms.read |
|
||||||
|
| POST/PUT | `/admin/cms/feed-slots*` | cms.write |
|
||||||
|
| GET | `/home/feed-slots?placement=home` | DeviceAuth |
|
||||||
|
|
||||||
|
无 DELETE;下架 `active=false`。
|
||||||
|
|
||||||
|
## AC
|
||||||
|
|
||||||
|
AC-F create/update/conflict/invalid · AC-S 401/403 · AC-A audit · AC-C 首页显隐与失败回退 · AC-O 无 Banner 合并 / 无 soft-delete
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
无新表;权限已在 `000051`。本切片 **不新增 migration 文件**(Max 保持 `000051`)。
|
||||||
@@ -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 · FunnelDefinition(Ops · ECR-039)
|
# Feature Spec: GrowthInsights · FunnelDefinition(Ops · 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 Closed(Rhythm 写)
|
||||||
|
> 关联:[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 · ImageCardDeck(Ops · ECR-037)
|
# Feature Spec: ExploreConfig · ImageCardDeck(Ops · 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 Closed(Funnel 写)
|
||||||
|
> 关联:[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 · ReportTemplate(Ops · ECR-038)
|
# Feature Spec: GrowthInsights · ReportTemplate(Ops · 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,98 @@
|
|||||||
|
# Feature Spec: ExploreConfig · RhythmConfig 写面(Ops · ECR-044)
|
||||||
|
|
||||||
|
> Status: `Active`(**Implemented · ECR-044 Closed**)
|
||||||
|
> Map: `§7` · Capability: `ExploreConfig` · BC: `Explore_Reports`
|
||||||
|
> Auth: `docs/WAVE0/ECR-044_WRITE_AUTHORIZATION.md`(Human 批准 · **仅 ECR-044**)
|
||||||
|
> Predecessor: ECR-036 Closed(只读)· ECR-043 Closed(StarConfig 写)
|
||||||
|
> 关联:[ops-rhythm-config.md](ops-rhythm-config.md) · [life-rhythm.md](life-rhythm.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
运营可 **创建 / 更新 / 上下架** `RhythmConfig`;写操作鉴权 + 审计;C 端可读 active 配置并影响节律入口可用性;失败回退「现网可生成」。
|
||||||
|
|
||||||
|
验证 ExploreConfig 写面同构复用;**Closed 后立即 STOP**,不自动 ECR-045。
|
||||||
|
|
||||||
|
## 2. In / Out
|
||||||
|
|
||||||
|
| In | Out |
|
||||||
|
|----|-----|
|
||||||
|
| Admin POST/PUT `/admin/explore/rhythm-configs*` | **StarConfig / ImageCardDeck / ScaleDefinition** 一切 |
|
||||||
|
| 复用权限 `admin.explore.write` | SystemPrompt · KnowledgeSource · Chunk · ToolDefinition |
|
||||||
|
| 复用表 `rhythm_configs`(code/title/active/system) | soft-delete · 改节律引擎 · 真支付 · UGC |
|
||||||
|
| C 端 GET active rhythm configs | Crisis / Handoff / ContentSafety 写 |
|
||||||
|
| 节律页:无 active → 温和不可用态;API 失败 → 回退可生成 | 合并多概念 · 自动开 ECR-045 |
|
||||||
|
| 审计 `admin_audit_logs` | 新权限子系统 |
|
||||||
|
|
||||||
|
## 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/rhythm-configs` · `/{id}` | `admin.explore.read`(保持) |
|
||||||
|
| POST | `/admin/explore/rhythm-configs` | `admin.explore.write` |
|
||||||
|
| PUT | `/admin/explore/rhythm-configs/{id}` | `admin.explore.write` |
|
||||||
|
|
||||||
|
无 DELETE。
|
||||||
|
|
||||||
|
### C 端
|
||||||
|
|
||||||
|
| Method | Path | Auth | 语义 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/v1/rhythm/configs` | DeviceAuth | 仅 `active=true`;按 code 排序 |
|
||||||
|
|
||||||
|
## 5. C 端生效(可观察)
|
||||||
|
|
||||||
|
| 条件 | user-h5 `/rhythm` |
|
||||||
|
|------|-------------------|
|
||||||
|
| 至少 1 条 active | 保持现网生成路径 |
|
||||||
|
| 成功且 0 条 active | 展示「节律配置暂未开放」类空态;禁用主生成 CTA |
|
||||||
|
| 请求失败 | **回退**:不阻断现网生成 |
|
||||||
|
|
||||||
|
不改节律引擎、不改报告 JSON 契约。
|
||||||
|
|
||||||
|
## 6. Permission / Migration
|
||||||
|
|
||||||
|
- Migration `000053_ops_rhythm_config_write`:幂等确认 `super_admin` → `admin.explore.write`(043 已授)
|
||||||
|
- 编码时占号(当前 Max=`000052`)
|
||||||
|
|
||||||
|
## 7. UI
|
||||||
|
|
||||||
|
| 端 | 行为 |
|
||||||
|
|----|------|
|
||||||
|
| admin-h5 节律配置页 | 列表 + 新建/编辑/上下架(仅 RhythmConfig) |
|
||||||
|
| user-h5 `/rhythm` | 按 §5 显隐 CTA |
|
||||||
|
|
||||||
|
## 8. AC
|
||||||
|
|
||||||
|
| ID | Then |
|
||||||
|
|----|------|
|
||||||
|
| AC-F-01 | POST 合法 → list/get 可见 |
|
||||||
|
| AC-F-02 | PUT `active=false` → C 端 list 不含 |
|
||||||
|
| AC-F-03 | 有 active → C 端 list 非空;节律页可生成 |
|
||||||
|
| AC-F-04 | 重复 code → 409 |
|
||||||
|
| AC-S-01/02 | 401;仅 read → POST 403 |
|
||||||
|
| AC-A-01 | 写产生审计 |
|
||||||
|
| AC-C-01 | 全下架后节律页空态/禁 CTA |
|
||||||
|
| AC-C-02 | C 端 API 失败不白屏、仍可走回退生成 |
|
||||||
|
| AC-O-01 | 无 Star/Card/Scale/Prompt 写路径;无 soft-delete |
|
||||||
|
|
||||||
|
## 9. Closed 后
|
||||||
|
|
||||||
|
**立即 STOP** Continuous Loop。
|
||||||
|
ECR-045 必须重新 Candidate Review + Human 单独拍板。
|
||||||
|
|
||||||
|
## 10. Trace
|
||||||
|
|
||||||
|
ECR-044 · BD-2026-044 · PRODUCT_SPEC · CONTRACT_DIFF · ECR-044_WRITE_AUTHORIZATION
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# Feature Spec: ExploreConfig · RhythmConfig(Ops · ECR-036)
|
# Feature Spec: ExploreConfig · RhythmConfig(Ops · ECR-036)
|
||||||
|
|
||||||
> Status: `Active`(Loop continuous · **ECR-036 Closed**)
|
> Status: `Active`(Loop continuous · **ECR-036 Closed**)
|
||||||
|
> **写面:** [ops-rhythm-config-write.md](ops-rhythm-config-write.md)(**ECR-044**)
|
||||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-035 Closed
|
> Parent: WAVE0-FROZEN · Predecessor: ECR-035 Closed
|
||||||
> Capability: `ExploreConfig` · BC: `Explore_Reports`
|
> Capability: `ExploreConfig` · 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 list;PATCH 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 · ScaleDefinition(Ops · ECR-040)
|
# Feature Spec: ExploreConfig · ScaleDefinition(Ops · 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`
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Feature Spec: ExploreConfig · StarConfig 写面(Ops · ECR-043)
|
||||||
|
|
||||||
|
> Status: `Active`(**Implemented · ECR-043 Closed**)
|
||||||
|
> Map: `§7` · Capability: `ExploreConfig` · BC: `Explore_Reports`
|
||||||
|
> Auth: `docs/WAVE0/EXPLORE_CONFIG_WRITE_AUTHORIZATION.md`(Human 批准 · **仅 ECR-043**)
|
||||||
|
> Predecessor: ECR-035 Closed(只读)· CMS 041–042 Closed
|
||||||
|
> 关联:[ops-star-config.md](ops-star-config.md) · [star-profile.md](star-profile.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
运营可 **创建 / 更新 / 上下架** `StarConfig`;写操作鉴权 + 审计;C 端可读 active 配置并影响星座入口可用性;失败回退「现网可生成」。
|
||||||
|
|
||||||
|
验证 ExploreConfig 写面与 CMS 同类闭环;**Closed 后立即 STOP**,不自动 ECR-044。
|
||||||
|
|
||||||
|
## 2. In / Out
|
||||||
|
|
||||||
|
| In | Out |
|
||||||
|
|----|-----|
|
||||||
|
| Admin POST/PUT `/admin/explore/star-configs*` | **RhythmConfig / ImageCardDeck / ScaleDefinition** 一切 |
|
||||||
|
| 加法权限 `admin.explore.write` | SystemPrompt · KnowledgeSource · Chunk · ToolDefinition |
|
||||||
|
| 复用表 `star_configs`(code/title/active/system) | soft-delete · 改引擎/宫位制 · 真支付 · UGC |
|
||||||
|
| C 端 GET active star configs | Crisis / Handoff / ContentSafety 写 |
|
||||||
|
| 星座页:无 active → 温和不可用态;API 失败 → 回退可生成 | 合并多概念 · 自动开 ECR-044 |
|
||||||
|
| 审计 `admin_audit_logs` | 新权限子系统 |
|
||||||
|
|
||||||
|
## 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/star-configs` · `/{id}` | `admin.explore.read`(保持) |
|
||||||
|
| POST | `/admin/explore/star-configs` | `admin.explore.write` |
|
||||||
|
| PUT | `/admin/explore/star-configs/{id}` | `admin.explore.write` |
|
||||||
|
|
||||||
|
无 DELETE。
|
||||||
|
|
||||||
|
### C 端
|
||||||
|
|
||||||
|
| Method | Path | Auth | 语义 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/v1/star/configs` | DeviceAuth | 仅 `active=true`;按 code 排序 |
|
||||||
|
|
||||||
|
## 5. C 端生效(可观察)
|
||||||
|
|
||||||
|
| 条件 | user-h5 `/star` |
|
||||||
|
|------|-----------------|
|
||||||
|
| 至少 1 条 active | 保持现网生成路径 |
|
||||||
|
| 成功且 0 条 active | 展示「星座配置暂未开放」类空态;禁用主生成 CTA |
|
||||||
|
| 请求失败 | **回退**:不阻断现网生成(与 Banner/FeedSlot 失败回退同哲学) |
|
||||||
|
|
||||||
|
不改星历引擎、不改报告 JSON 契约。
|
||||||
|
|
||||||
|
## 6. Permission / Migration
|
||||||
|
|
||||||
|
- Migration `000052_ops_star_config_write`:授予 `super_admin` → `admin.explore.write`
|
||||||
|
- 编码时占号(当前 Max=`000051`)
|
||||||
|
- `knownPermissions` 注册加法码
|
||||||
|
|
||||||
|
## 7. UI
|
||||||
|
|
||||||
|
| 端 | 行为 |
|
||||||
|
|----|------|
|
||||||
|
| admin-h5 探索/星座配置页 | 列表 + 新建/编辑/上下架(仅 StarConfig) |
|
||||||
|
| user-h5 `/star` | 按 §5 显隐 CTA |
|
||||||
|
|
||||||
|
## 8. AC
|
||||||
|
|
||||||
|
| ID | Then |
|
||||||
|
|----|------|
|
||||||
|
| AC-F-01 | POST 合法 → list/get 可见 |
|
||||||
|
| AC-F-02 | PUT `active=false` → C 端 list 不含 |
|
||||||
|
| AC-F-03 | 有 active → C 端 list 非空;星座页可生成 |
|
||||||
|
| AC-F-04 | 重复 code → 409 |
|
||||||
|
| AC-S-01/02 | 401;仅 read → POST 403 |
|
||||||
|
| AC-A-01 | 写产生审计 |
|
||||||
|
| AC-C-01 | 全下架后星座页空态/禁 CTA |
|
||||||
|
| AC-C-02 | C 端 API 失败不白屏、仍可走回退生成 |
|
||||||
|
| AC-O-01 | 无 Rhythm/Card/Scale/Prompt 写路径;无 soft-delete |
|
||||||
|
|
||||||
|
## 9. Closed 后
|
||||||
|
|
||||||
|
**立即 STOP** Continuous Loop。
|
||||||
|
ECR-044 必须重新 Candidate Review + Human 单独拍板。
|
||||||
|
|
||||||
|
## 10. Trace
|
||||||
|
|
||||||
|
ECR-043 · BD-2026-043 · PRODUCT_SPEC · CONTRACT_DIFF(实现时)· EXPLORE_CONFIG_WRITE_AUTHORIZATION
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
# Feature Spec: ExploreConfig · StarConfig(Ops · ECR-035)
|
# Feature Spec: ExploreConfig · StarConfig(Ops · ECR-035 只读基线)
|
||||||
|
|
||||||
> Status: `Active`(Loop continuous · **ECR-035 Closed**)
|
> Status: `Active`(**ECR-035 Closed** · 只读)
|
||||||
|
> **写面:** [ops-star-config-write.md](ops-star-config-write.md)(**ECR-043 Closed**)
|
||||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-034 Closed
|
> Parent: WAVE0-FROZEN · Predecessor: ECR-034 Closed
|
||||||
> Capability: `ExploreConfig` · BC: `Explore_Reports`
|
> Capability: `ExploreConfig` · BC: `Explore_Reports`
|
||||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
> 授权(只读队列):`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals(本只读 Spec)
|
||||||
|
|
||||||
配置写发布 · 引擎改分层 · UGC · 真支付
|
配置写发布(见 ECR-043)· 引擎改分层 · UGC · 真支付
|
||||||
|
|
||||||
## L2 Domain
|
## L2 Domain
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +161,8 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
|
|||||||
await ok({})
|
await ok({})
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.goto('/login')
|
// Leading `/login` would resolve to host root and miss Vite base `/psy/admin/`.
|
||||||
|
await page.goto('login')
|
||||||
await expect(page.getByRole('heading', { name: '愈心谷' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: '愈心谷' })).toBeVisible()
|
||||||
await page.locator('input[type="password"]').fill('change-me')
|
await page.locator('input[type="password"]').fill('change-me')
|
||||||
await page.getByRole('button', { name: '登录' }).click()
|
await page.getByRole('button', { name: '登录' }).click()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Ops admin L3 smoke: Vite preview + mocked /api/v1/admin (no Go required).
|
* Ops admin L3 smoke: Vite preview + mocked /api/v1/admin (no Go required).
|
||||||
|
* Build uses base `/psy/admin/` — baseURL must include that prefix.
|
||||||
* Run: npm run test:e2e -w @yuxingu/admin-h5
|
* Run: npm run test:e2e -w @yuxingu/admin-h5
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -10,7 +11,8 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://127.0.0.1:4174',
|
// Trailing slash so relative goto('login') resolves under /psy/admin/
|
||||||
|
baseURL: 'http://127.0.0.1:4174/psy/admin/',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
@@ -24,7 +26,7 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4174',
|
command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4174',
|
||||||
url: 'http://127.0.0.1:4174',
|
url: 'http://127.0.0.1:4174/psy/admin/',
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
|
|||||||
+321
-14
@@ -148,8 +148,6 @@ export const adminApi = {
|
|||||||
users: (q = '') =>
|
users: (q = '') =>
|
||||||
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
|
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
|
||||||
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
|
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
|
||||||
banUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/ban`),
|
|
||||||
unbanUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/unban`),
|
|
||||||
setUserStatus: (id: string, status: string, reason: string) =>
|
setUserStatus: (id: string, status: string, reason: string) =>
|
||||||
request<UserDetail>('POST', `/users/${id}/status`, { status, reason }),
|
request<UserDetail>('POST', `/users/${id}/status`, { status, reason }),
|
||||||
statusTransitions: (id: string) =>
|
statusTransitions: (id: string) =>
|
||||||
@@ -454,6 +452,51 @@ export const adminApi = {
|
|||||||
system: boolean
|
system: boolean
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}>('GET', `/cms/banners/${id}`),
|
}>('GET', `/cms/banners/${id}`),
|
||||||
|
createBanner: (body: {
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
placement: string
|
||||||
|
image_url?: string | null
|
||||||
|
link_path?: string | null
|
||||||
|
sort_order: number
|
||||||
|
active: boolean
|
||||||
|
}) =>
|
||||||
|
request<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
placement: string
|
||||||
|
image_url?: string
|
||||||
|
link_path?: string
|
||||||
|
sort_order: number
|
||||||
|
active: boolean
|
||||||
|
system: boolean
|
||||||
|
updated_at: string
|
||||||
|
}>('POST', '/cms/banners', body),
|
||||||
|
updateBanner: (
|
||||||
|
id: string,
|
||||||
|
body: {
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
placement: string
|
||||||
|
image_url?: string | null
|
||||||
|
link_path?: string | null
|
||||||
|
sort_order: number
|
||||||
|
active: boolean
|
||||||
|
},
|
||||||
|
) =>
|
||||||
|
request<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
placement: string
|
||||||
|
image_url?: string
|
||||||
|
link_path?: string
|
||||||
|
sort_order: number
|
||||||
|
active: boolean
|
||||||
|
system: boolean
|
||||||
|
updated_at: string
|
||||||
|
}>('PUT', `/cms/banners/${id}`, body),
|
||||||
feedSlots: () =>
|
feedSlots: () =>
|
||||||
request<{
|
request<{
|
||||||
items: Array<{
|
items: Array<{
|
||||||
@@ -478,6 +521,43 @@ export const adminApi = {
|
|||||||
system: boolean
|
system: boolean
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}>('GET', `/cms/feed-slots/${id}`),
|
}>('GET', `/cms/feed-slots/${id}`),
|
||||||
|
createFeedSlot: (body: {
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
slot_key: string
|
||||||
|
placement: string
|
||||||
|
active: boolean
|
||||||
|
}) =>
|
||||||
|
request<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
slot_key: string
|
||||||
|
placement: string
|
||||||
|
active: boolean
|
||||||
|
system: boolean
|
||||||
|
updated_at: string
|
||||||
|
}>('POST', '/cms/feed-slots', body),
|
||||||
|
updateFeedSlot: (
|
||||||
|
id: string,
|
||||||
|
body: {
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
slot_key: string
|
||||||
|
placement: string
|
||||||
|
active: boolean
|
||||||
|
},
|
||||||
|
) =>
|
||||||
|
request<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
slot_key: string
|
||||||
|
placement: string
|
||||||
|
active: boolean
|
||||||
|
system: boolean
|
||||||
|
updated_at: string
|
||||||
|
}>('PUT', `/cms/feed-slots/${id}`, body),
|
||||||
publications: () =>
|
publications: () =>
|
||||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/cms/publications'),
|
request<{ items: Array<Record<string, unknown>> }>('GET', '/cms/publications'),
|
||||||
publication: (id: string) =>
|
publication: (id: string) =>
|
||||||
@@ -515,29 +595,256 @@ export const adminApi = {
|
|||||||
privacyRequest: (id: string) =>
|
privacyRequest: (id: string) =>
|
||||||
request<Record<string, unknown>>('GET', `/privacy/requests/${id}`),
|
request<Record<string, unknown>>('GET', `/privacy/requests/${id}`),
|
||||||
starConfigs: () =>
|
starConfigs: () =>
|
||||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/star-configs'),
|
request<{
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
active: boolean
|
||||||
|
system: boolean
|
||||||
|
updated_at: string
|
||||||
|
}>
|
||||||
|
}>('GET', '/explore/star-configs'),
|
||||||
starConfig: (id: string) =>
|
starConfig: (id: string) =>
|
||||||
request<Record<string, unknown>>('GET', `/explore/star-configs/${id}`),
|
request<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
active: boolean
|
||||||
|
system: boolean
|
||||||
|
updated_at: string
|
||||||
|
}>('GET', `/explore/star-configs/${id}`),
|
||||||
|
createStarConfig: (body: { code: string; title: string; active: boolean }) =>
|
||||||
|
request<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
active: boolean
|
||||||
|
system: boolean
|
||||||
|
updated_at: string
|
||||||
|
}>('POST', '/explore/star-configs', body),
|
||||||
|
updateStarConfig: (
|
||||||
|
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/star-configs/${id}`, body),
|
||||||
rhythmConfigs: () =>
|
rhythmConfigs: () =>
|
||||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/rhythm-configs'),
|
request<{
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
active: boolean
|
||||||
|
system: boolean
|
||||||
|
updated_at: string
|
||||||
|
}>
|
||||||
|
}>('GET', '/explore/rhythm-configs'),
|
||||||
rhythmConfig: (id: string) =>
|
rhythmConfig: (id: string) =>
|
||||||
request<Record<string, unknown>>('GET', `/explore/rhythm-configs/${id}`),
|
request<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
active: boolean
|
||||||
|
system: boolean
|
||||||
|
updated_at: string
|
||||||
|
}>('GET', `/explore/rhythm-configs/${id}`),
|
||||||
|
createRhythmConfig: (body: { code: string; title: string; active: boolean }) =>
|
||||||
|
request<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
active: boolean
|
||||||
|
system: boolean
|
||||||
|
updated_at: string
|
||||||
|
}>('POST', '/explore/rhythm-configs', body),
|
||||||
|
updateRhythmConfig: (
|
||||||
|
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/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>
|
||||||
@@ -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()
|
||||||
@@ -35,9 +36,15 @@ async function onLogout() {
|
|||||||
<RouterLink to="/ai">AI</RouterLink>
|
<RouterLink to="/ai">AI</RouterLink>
|
||||||
<RouterLink to="/crisis">危机</RouterLink>
|
<RouterLink to="/crisis">危机</RouterLink>
|
||||||
<RouterLink to="/cms">CMS</RouterLink>
|
<RouterLink to="/cms">CMS</RouterLink>
|
||||||
|
<RouterLink to="/star-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.isSuper" to="/pricing">定价</RouterLink>
|
<RouterLink v-if="auth.can('admin.membership.plans.read')" to="/pricing">定价</RouterLink>
|
||||||
<RouterLink to="/push">推送</RouterLink>
|
<RouterLink to="/push">推送</RouterLink>
|
||||||
<RouterLink v-if="auth.isSuper" to="/admins">管理员</RouterLink>
|
<RouterLink v-if="auth.isSuper" to="/admins">管理员</RouterLink>
|
||||||
<RouterLink to="/audit">审计</RouterLink>
|
<RouterLink to="/audit">审计</RouterLink>
|
||||||
@@ -49,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>
|
||||||
@@ -85,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); }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { adminApi } from '@/api/client'
|
import { adminApi } from '@/api/client'
|
||||||
|
|
||||||
type Banner = Awaited<ReturnType<typeof adminApi.banners>>['items'][number]
|
type Banner = Awaited<ReturnType<typeof adminApi.banners>>['items'][number]
|
||||||
@@ -9,11 +9,30 @@ const loading = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const items = ref<Banner[]>([])
|
const items = ref<Banner[]>([])
|
||||||
const selected = ref<Banner | null>(null)
|
const selected = ref<Banner | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const formErr = ref('')
|
||||||
|
const form = reactive({
|
||||||
|
code: '',
|
||||||
|
title: '',
|
||||||
|
placement: 'home',
|
||||||
|
link_path: '',
|
||||||
|
sort_order: 0,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
|
||||||
const slotsLoading = ref(false)
|
const slotsLoading = ref(false)
|
||||||
const slotsError = ref('')
|
const slotsError = ref('')
|
||||||
const slots = ref<FeedSlot[]>([])
|
const slots = ref<FeedSlot[]>([])
|
||||||
const selectedSlot = ref<FeedSlot | null>(null)
|
const selectedSlot = ref<FeedSlot | null>(null)
|
||||||
|
const slotSaving = ref(false)
|
||||||
|
const slotFormErr = ref('')
|
||||||
|
const slotForm = reactive({
|
||||||
|
code: '',
|
||||||
|
title: '',
|
||||||
|
slot_key: 'home.main',
|
||||||
|
placement: 'home',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -41,22 +60,149 @@ async function loadSlots() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.code = ''
|
||||||
|
form.title = ''
|
||||||
|
form.placement = 'home'
|
||||||
|
form.link_path = ''
|
||||||
|
form.sort_order = 0
|
||||||
|
form.active = true
|
||||||
|
selected.value = null
|
||||||
|
formErr.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function openBanner(id: string) {
|
async function openBanner(id: string) {
|
||||||
|
formErr.value = ''
|
||||||
try {
|
try {
|
||||||
selected.value = await adminApi.banner(id)
|
const b = await adminApi.banner(id)
|
||||||
|
selected.value = b
|
||||||
|
form.code = b.code
|
||||||
|
form.title = b.title
|
||||||
|
form.placement = b.placement
|
||||||
|
form.link_path = b.link_path || ''
|
||||||
|
form.sort_order = b.sort_order
|
||||||
|
form.active = b.active
|
||||||
} catch {
|
} catch {
|
||||||
selected.value = null
|
selected.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSlot(id: string) {
|
async function openSlot(id: string) {
|
||||||
|
slotFormErr.value = ''
|
||||||
try {
|
try {
|
||||||
selectedSlot.value = await adminApi.feedSlot(id)
|
const s = await adminApi.feedSlot(id)
|
||||||
|
selectedSlot.value = s
|
||||||
|
slotForm.code = s.code
|
||||||
|
slotForm.title = s.title
|
||||||
|
slotForm.slot_key = s.slot_key
|
||||||
|
slotForm.placement = s.placement
|
||||||
|
slotForm.active = s.active
|
||||||
} catch {
|
} catch {
|
||||||
selectedSlot.value = null
|
selectedSlot.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetSlotForm() {
|
||||||
|
slotForm.code = ''
|
||||||
|
slotForm.title = ''
|
||||||
|
slotForm.slot_key = 'home.main'
|
||||||
|
slotForm.placement = 'home'
|
||||||
|
slotForm.active = true
|
||||||
|
selectedSlot.value = null
|
||||||
|
slotFormErr.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotPayload() {
|
||||||
|
return {
|
||||||
|
code: slotForm.code.trim(),
|
||||||
|
title: slotForm.title.trim(),
|
||||||
|
slot_key: slotForm.slot_key.trim(),
|
||||||
|
placement: slotForm.placement,
|
||||||
|
active: slotForm.active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSlot() {
|
||||||
|
slotSaving.value = true
|
||||||
|
slotFormErr.value = ''
|
||||||
|
try {
|
||||||
|
const row = await adminApi.createFeedSlot(slotPayload())
|
||||||
|
await loadSlots()
|
||||||
|
await openSlot(row.id)
|
||||||
|
} catch (e) {
|
||||||
|
slotFormErr.value = e instanceof Error ? e.message : '创建失败'
|
||||||
|
} finally {
|
||||||
|
slotSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSlot() {
|
||||||
|
if (!selectedSlot.value) return
|
||||||
|
slotSaving.value = true
|
||||||
|
slotFormErr.value = ''
|
||||||
|
try {
|
||||||
|
const row = await adminApi.updateFeedSlot(selectedSlot.value.id, slotPayload())
|
||||||
|
selectedSlot.value = row
|
||||||
|
await loadSlots()
|
||||||
|
} catch (e) {
|
||||||
|
slotFormErr.value = e instanceof Error ? e.message : '保存失败'
|
||||||
|
} finally {
|
||||||
|
slotSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSlotActive() {
|
||||||
|
if (!selectedSlot.value) return
|
||||||
|
slotForm.active = !slotForm.active
|
||||||
|
await saveSlot()
|
||||||
|
}
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
return {
|
||||||
|
code: form.code.trim(),
|
||||||
|
title: form.title.trim(),
|
||||||
|
placement: form.placement,
|
||||||
|
link_path: form.link_path.trim() || null,
|
||||||
|
sort_order: Number(form.sort_order) || 0,
|
||||||
|
active: form.active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBanner() {
|
||||||
|
saving.value = true
|
||||||
|
formErr.value = ''
|
||||||
|
try {
|
||||||
|
const row = await adminApi.createBanner(payload())
|
||||||
|
await load()
|
||||||
|
await openBanner(row.id)
|
||||||
|
} catch (e) {
|
||||||
|
formErr.value = e instanceof Error ? e.message : '创建失败'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBanner() {
|
||||||
|
if (!selected.value) return
|
||||||
|
saving.value = true
|
||||||
|
formErr.value = ''
|
||||||
|
try {
|
||||||
|
const row = await adminApi.updateBanner(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) return
|
||||||
|
form.active = !form.active
|
||||||
|
await saveBanner()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load()
|
void load()
|
||||||
void loadSlots()
|
void loadSlots()
|
||||||
@@ -66,12 +212,13 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h1>运营位 CMS</h1>
|
<h1>运营位 CMS</h1>
|
||||||
<p class="muted">Banner / FeedSlot 只读 · 非 UGC · 本切片不可发布</p>
|
<p class="muted">Banner / FeedSlot 可写 · 下架用停用 · 非 UGC</p>
|
||||||
<p v-if="loading" class="muted">加载中…</p>
|
<p v-if="loading" class="muted">加载中…</p>
|
||||||
<p v-else-if="error" class="err">{{ error }}</p>
|
<p v-else-if="error" class="err">{{ error }}</p>
|
||||||
<div v-else class="layout">
|
<div v-else class="layout">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>横幅</h2>
|
<h2>横幅</h2>
|
||||||
|
<button class="btn" type="button" @click="resetForm">新建</button>
|
||||||
<p v-if="!items.length" class="muted">暂无</p>
|
<p v-if="!items.length" class="muted">暂无</p>
|
||||||
<table v-else>
|
<table v-else>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -83,18 +230,38 @@ onMounted(() => {
|
|||||||
<td>{{ b.title }}</td>
|
<td>{{ b.title }}</td>
|
||||||
<td>{{ b.placement }}</td>
|
<td>{{ b.placement }}</td>
|
||||||
<td>{{ b.active ? '启用' : '停用' }}</td>
|
<td>{{ b.active ? '启用' : '停用' }}</td>
|
||||||
<td><button class="btn" type="button" @click="openBanner(b.id)">查看</button></td>
|
<td><button class="btn" type="button" @click="openBanner(b.id)">编辑</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>横幅详情</h2>
|
<h2>{{ selected ? '编辑横幅' : '新建横幅' }}</h2>
|
||||||
<template v-if="selected">
|
<label>代码 <input v-model="form.code" :disabled="!!selected?.system" /></label>
|
||||||
<p>{{ selected.title }} · {{ selected.placement }}</p>
|
<label>标题 <input v-model="form.title" /></label>
|
||||||
<p class="muted">链接 {{ selected.link_path || '—' }} · 排序 {{ selected.sort_order }}</p>
|
<label>
|
||||||
</template>
|
位置
|
||||||
<p v-else class="muted">选择左侧横幅</p>
|
<select v-model="form.placement">
|
||||||
|
<option value="home">home</option>
|
||||||
|
<option value="explore">explore</option>
|
||||||
|
<option value="ask">ask</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>链接 <input v-model="form.link_path" placeholder="/membership" /></label>
|
||||||
|
<label>排序 <input v-model.number="form.sort_order" type="number" /></label>
|
||||||
|
<label class="check"><input v-model="form.active" type="checkbox" /> 启用</label>
|
||||||
|
<p v-if="formErr" class="err">{{ formErr }}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createBanner">
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
<template v-else>
|
||||||
|
<button class="btn primary" type="button" :disabled="saving" @click="saveBanner">保存</button>
|
||||||
|
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
|
||||||
|
{{ form.active ? '下架' : '上架' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,29 +270,49 @@ onMounted(() => {
|
|||||||
<div v-else class="layout gap">
|
<div v-else class="layout gap">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>栏目位 FeedSlot</h2>
|
<h2>栏目位 FeedSlot</h2>
|
||||||
|
<button class="btn" type="button" @click="resetSlotForm">新建</button>
|
||||||
<p v-if="!slots.length" class="muted">暂无</p>
|
<p v-if="!slots.length" class="muted">暂无</p>
|
||||||
<table v-else>
|
<table v-else>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>代码</th><th>标题</th><th>slot_key</th><th>位置</th><th></th></tr>
|
<tr><th>代码</th><th>标题</th><th>slot_key</th><th>状态</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="s in slots" :key="s.id">
|
<tr v-for="s in slots" :key="s.id">
|
||||||
<td><code>{{ s.code }}</code></td>
|
<td><code>{{ s.code }}</code></td>
|
||||||
<td>{{ s.title }}</td>
|
<td>{{ s.title }}</td>
|
||||||
<td><code>{{ s.slot_key }}</code></td>
|
<td><code>{{ s.slot_key }}</code></td>
|
||||||
<td>{{ s.placement }}</td>
|
<td>{{ s.active ? '启用' : '停用' }}</td>
|
||||||
<td><button class="btn" type="button" @click="openSlot(s.id)">查看</button></td>
|
<td><button class="btn" type="button" @click="openSlot(s.id)">编辑</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>栏目位详情</h2>
|
<h2>{{ selectedSlot ? '编辑栏目位' : '新建栏目位' }}</h2>
|
||||||
<template v-if="selectedSlot">
|
<label>代码 <input v-model="slotForm.code" :disabled="!!selectedSlot?.system" /></label>
|
||||||
<p>{{ selectedSlot.title }} · {{ selectedSlot.placement }}</p>
|
<label>标题 <input v-model="slotForm.title" /></label>
|
||||||
<p class="muted">slot_key {{ selectedSlot.slot_key }}</p>
|
<label>slot_key <input v-model="slotForm.slot_key" /></label>
|
||||||
</template>
|
<label>
|
||||||
<p v-else class="muted">选择左侧栏目位</p>
|
位置
|
||||||
|
<select v-model="slotForm.placement">
|
||||||
|
<option value="home">home</option>
|
||||||
|
<option value="explore">explore</option>
|
||||||
|
<option value="ask">ask</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="check"><input v-model="slotForm.active" type="checkbox" /> 启用</label>
|
||||||
|
<p v-if="slotFormErr" class="err">{{ slotFormErr }}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button v-if="!selectedSlot" class="btn primary" type="button" :disabled="slotSaving" @click="createSlot">
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
<template v-else>
|
||||||
|
<button class="btn primary" type="button" :disabled="slotSaving" @click="saveSlot">保存</button>
|
||||||
|
<button class="btn" type="button" :disabled="slotSaving" @click="toggleSlotActive">
|
||||||
|
{{ slotForm.active ? '下架' : '上架' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -137,5 +324,10 @@ h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
|||||||
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||||
.gap { margin-top: 1.5rem; }
|
.gap { margin-top: 1.5rem; }
|
||||||
code { font-size: 0.8rem; }
|
code { font-size: 0.8rem; }
|
||||||
|
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
|
||||||
|
.card input, .card select { 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; } }
|
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||||
</style>
|
</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.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>
|
||||||
@@ -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,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.rhythmConfigs>>['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.rhythmConfigs()
|
||||||
|
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.rhythmConfig(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.createRhythmConfig(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.updateRhythmConfig(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">RhythmConfig 写面 · 下架仅 active=false · ECR-044</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>
|
||||||
@@ -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>
|
||||||
@@ -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.starConfigs>>['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.starConfigs()
|
||||||
|
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.starConfig(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.createStarConfig(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.updateStarConfig(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">StarConfig 写面 · 下架仅 active=false · ECR-043</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>
|
||||||
@@ -125,22 +125,6 @@ async function changeStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleBan() {
|
|
||||||
statusMsg.value = ''
|
|
||||||
try {
|
|
||||||
if (detail.value?.status === 'banned') {
|
|
||||||
await adminApi.unbanUser(String(route.params.id))
|
|
||||||
statusMsg.value = '已解封'
|
|
||||||
} else {
|
|
||||||
await adminApi.banUser(String(route.params.id))
|
|
||||||
statusMsg.value = '已封禁'
|
|
||||||
}
|
|
||||||
await load()
|
|
||||||
} catch (e) {
|
|
||||||
statusMsg.value = e instanceof Error ? e.message : '操作失败'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(iso?: string | null) {
|
function fmtTime(iso?: string | null) {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
try {
|
try {
|
||||||
@@ -194,9 +178,6 @@ onMounted(load)
|
|||||||
</select>
|
</select>
|
||||||
<input v-model="statusReason" class="reason" type="text" placeholder="原因(必填)" />
|
<input v-model="statusReason" class="reason" type="text" placeholder="原因(必填)" />
|
||||||
<button class="btn" type="button" @click="changeStatus">变更状态</button>
|
<button class="btn" type="button" @click="changeStatus">变更状态</button>
|
||||||
<button class="btn ghost" type="button" @click="toggleBan">
|
|
||||||
{{ detail.status === 'banned' ? '快捷解封' : '快捷封禁' }}
|
|
||||||
</button>
|
|
||||||
<span v-if="statusMsg" class="muted">{{ statusMsg }}</span>
|
<span v-if="statusMsg" class="muted">{{ statusMsg }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="transitions.length" class="trans">
|
<div v-if="transitions.length" class="trans">
|
||||||
|
|||||||
@@ -18,12 +18,48 @@ const router = createRouter({
|
|||||||
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
|
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
|
||||||
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') },
|
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') },
|
||||||
{ path: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.vue') },
|
{ path: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.vue') },
|
||||||
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue'), meta: { superOnly: true } },
|
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue'), meta: { permission: 'admin.membership.plans.read' } },
|
||||||
{ path: 'ask', name: 'ask', component: () => import('@/pages/AskPage.vue') },
|
{ path: 'ask', name: 'ask', component: () => import('@/pages/AskPage.vue') },
|
||||||
{ path: 'safety', name: 'safety', component: () => import('@/pages/SafetyPage.vue') },
|
{ path: 'safety', name: 'safety', component: () => import('@/pages/SafetyPage.vue') },
|
||||||
{ path: 'ai', name: 'ai', component: () => import('@/pages/AIConfigPage.vue') },
|
{ path: 'ai', name: 'ai', component: () => import('@/pages/AIConfigPage.vue') },
|
||||||
{ path: 'crisis', name: 'crisis', component: () => import('@/pages/CrisisPage.vue') },
|
{ path: 'crisis', name: 'crisis', component: () => import('@/pages/CrisisPage.vue') },
|
||||||
{ path: 'cms', name: 'cms', component: () => import('@/pages/CMSPage.vue') },
|
{ path: 'cms', name: 'cms', component: () => import('@/pages/CMSPage.vue') },
|
||||||
|
{
|
||||||
|
path: 'star-configs',
|
||||||
|
name: 'star-configs',
|
||||||
|
component: () => import('@/pages/StarConfigPage.vue'),
|
||||||
|
meta: { permission: 'admin.explore.read' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'rhythm-configs',
|
||||||
|
name: 'rhythm-configs',
|
||||||
|
component: () => import('@/pages/RhythmConfigPage.vue'),
|
||||||
|
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 } },
|
||||||
@@ -41,6 +77,11 @@ router.beforeEach(async (to) => {
|
|||||||
if (!auth.username) await auth.hydrate()
|
if (!auth.username) await auth.hydrate()
|
||||||
if (!auth.isSuper) return { name: 'dashboard' }
|
if (!auth.isSuper) return { name: 'dashboard' }
|
||||||
}
|
}
|
||||||
|
if (typeof to.meta.permission === 'string') {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (!auth.username) await auth.hydrate()
|
||||||
|
if (!auth.can(to.meta.permission)) return { name: 'dashboard' }
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const permissions = ref<string[]>([])
|
const permissions = ref<string[]>([])
|
||||||
|
|
||||||
const isSuper = computed(
|
const isSuper = computed(
|
||||||
() =>
|
() => role.value === 'super' || role.value === 'super_admin',
|
||||||
role.value === 'super' ||
|
|
||||||
role.value === 'super_admin' ||
|
|
||||||
permissions.value.includes('admin.roles.write'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
function applyMe(me: AdminMe) {
|
function applyMe(me: AdminMe) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
|||||||
authed.POST("/users/:id/membership/grant", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipGrant), h.GrantMembership)
|
authed.POST("/users/:id/membership/grant", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipGrant), h.GrantMembership)
|
||||||
authed.POST("/users/:id/ask-quota/grant", middleware.RequireAdminPermission(h.Svc, admin.PermAskQuotaGrant), h.GrantAskQuota)
|
authed.POST("/users/:id/ask-quota/grant", middleware.RequireAdminPermission(h.Svc, admin.PermAskQuotaGrant), h.GrantAskQuota)
|
||||||
authed.GET("/orders", middleware.RequireAdminPermission(h.Svc, admin.PermOrdersRead), h.ListOrders)
|
authed.GET("/orders", middleware.RequireAdminPermission(h.Svc, admin.PermOrdersRead), h.ListOrders)
|
||||||
authed.GET("/membership/plan-prices", h.ListPlanPrices)
|
authed.GET("/membership/plan-prices", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.ListPlanPrices)
|
||||||
authed.PUT("/membership/plan-prices", h.PutPlanPrices)
|
authed.PUT("/membership/plan-prices", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansWrite), h.PutPlanPrices)
|
||||||
authed.GET("/audit-logs", middleware.RequireAdminPermission(h.Svc, admin.PermAuditRead), h.ListAudit)
|
authed.GET("/audit-logs", middleware.RequireAdminPermission(h.Svc, admin.PermAuditRead), h.ListAudit)
|
||||||
authed.GET("/analytics/overview", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsOverview)
|
authed.GET("/analytics/overview", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsOverview)
|
||||||
authed.GET("/analytics/pages", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsPages)
|
authed.GET("/analytics/pages", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsPages)
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ func (h *AdminHandler) registerCMS(authed *gin.RouterGroup) {
|
|||||||
g := authed.Group("/cms")
|
g := authed.Group("/cms")
|
||||||
g.GET("/banners", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListBanners)
|
g.GET("/banners", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListBanners)
|
||||||
g.GET("/banners/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetBanner)
|
g.GET("/banners/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetBanner)
|
||||||
|
g.POST("/banners", middleware.RequireAdminPermission(h.Svc, admin.PermCMSWrite), h.CreateBanner)
|
||||||
|
g.PUT("/banners/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSWrite), h.UpdateBanner)
|
||||||
g.GET("/feed-slots", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListFeedSlots)
|
g.GET("/feed-slots", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListFeedSlots)
|
||||||
g.GET("/feed-slots/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetFeedSlot)
|
g.GET("/feed-slots/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetFeedSlot)
|
||||||
|
g.POST("/feed-slots", middleware.RequireAdminPermission(h.Svc, admin.PermCMSWrite), h.CreateFeedSlot)
|
||||||
|
g.PUT("/feed-slots/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSWrite), h.UpdateFeedSlot)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) ListBanners(c *gin.Context) {
|
func (h *AdminHandler) ListBanners(c *gin.Context) {
|
||||||
@@ -47,6 +51,69 @@ func (h *AdminHandler) GetBanner(c *gin.Context) {
|
|||||||
response.OK(c, row)
|
response.OK(c, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateBanner(c *gin.Context) {
|
||||||
|
adminID, ok := middleware.AdminIDFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body admin.BannerWriteBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40050, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.Svc.CreateBanner(c.Request.Context(), adminID, body)
|
||||||
|
if errors.Is(err, admin.ErrInvalidBanner) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40051, "invalid banner")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrBannerConflict) {
|
||||||
|
response.Fail(c, http.StatusConflict, 40910, "banner code conflict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50044, "create banner failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) UpdateBanner(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.BannerWriteBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40050, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.Svc.UpdateBanner(c.Request.Context(), adminID, id, body)
|
||||||
|
if errors.Is(err, admin.ErrBannerNotFound) {
|
||||||
|
response.Fail(c, http.StatusNotFound, 40410, "banner not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrInvalidBanner) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40051, "invalid banner")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrBannerConflict) {
|
||||||
|
response.Fail(c, http.StatusConflict, 40910, "banner code conflict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50045, "update banner failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, row)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) ListFeedSlots(c *gin.Context) {
|
func (h *AdminHandler) ListFeedSlots(c *gin.Context) {
|
||||||
items, err := h.Svc.ListFeedSlots(c.Request.Context())
|
items, err := h.Svc.ListFeedSlots(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,3 +140,66 @@ func (h *AdminHandler) GetFeedSlot(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.OK(c, row)
|
response.OK(c, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateFeedSlot(c *gin.Context) {
|
||||||
|
adminID, ok := middleware.AdminIDFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body admin.FeedSlotWriteBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40052, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.Svc.CreateFeedSlot(c.Request.Context(), adminID, body)
|
||||||
|
if errors.Is(err, admin.ErrInvalidFeedSlot) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40053, "invalid feed slot")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrFeedSlotConflict) {
|
||||||
|
response.Fail(c, http.StatusConflict, 40911, "feed slot code conflict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50047, "create feed slot failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) UpdateFeedSlot(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.FeedSlotWriteBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40052, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.Svc.UpdateFeedSlot(c.Request.Context(), adminID, id, body)
|
||||||
|
if errors.Is(err, admin.ErrFeedSlotNotFound) {
|
||||||
|
response.Fail(c, http.StatusNotFound, 40411, "feed slot not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrInvalidFeedSlot) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40053, "invalid feed slot")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrFeedSlotConflict) {
|
||||||
|
response.Fail(c, http.StatusConflict, 40911, "feed slot code conflict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50048, "update feed slot failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, row)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ func (h *AdminHandler) registerRhythmConfigs(authed *gin.RouterGroup) {
|
|||||||
g := authed.Group("/explore")
|
g := authed.Group("/explore")
|
||||||
g.GET("/rhythm-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListRhythmConfigs)
|
g.GET("/rhythm-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListRhythmConfigs)
|
||||||
g.GET("/rhythm-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetRhythmConfig)
|
g.GET("/rhythm-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetRhythmConfig)
|
||||||
|
g.POST("/rhythm-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.CreateRhythmConfig)
|
||||||
|
g.PUT("/rhythm-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.UpdateRhythmConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) ListRhythmConfigs(c *gin.Context) {
|
func (h *AdminHandler) ListRhythmConfigs(c *gin.Context) {
|
||||||
@@ -44,3 +46,66 @@ func (h *AdminHandler) GetRhythmConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.OK(c, row)
|
response.OK(c, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateRhythmConfig(c *gin.Context) {
|
||||||
|
adminID, ok := middleware.AdminIDFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body admin.RhythmConfigWriteBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.Svc.CreateRhythmConfig(c.Request.Context(), adminID, body)
|
||||||
|
if errors.Is(err, admin.ErrInvalidRhythmConfig) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40055, "invalid rhythm config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrRhythmConfigConflict) {
|
||||||
|
response.Fail(c, http.StatusConflict, 40912, "rhythm config code conflict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50052, "create rhythm config failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) UpdateRhythmConfig(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.RhythmConfigWriteBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.Svc.UpdateRhythmConfig(c.Request.Context(), adminID, id, body)
|
||||||
|
if errors.Is(err, admin.ErrRhythmConfigNotFound) {
|
||||||
|
response.Fail(c, http.StatusNotFound, 40420, "rhythm-config not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrInvalidRhythmConfig) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40055, "invalid rhythm config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrRhythmConfigConflict) {
|
||||||
|
response.Fail(c, http.StatusConflict, 40912, "rhythm config code conflict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50053, "update rhythm config failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, row)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ func (h *AdminHandler) registerStarConfigs(authed *gin.RouterGroup) {
|
|||||||
g := authed.Group("/explore")
|
g := authed.Group("/explore")
|
||||||
g.GET("/star-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListStarConfigs)
|
g.GET("/star-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListStarConfigs)
|
||||||
g.GET("/star-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetStarConfig)
|
g.GET("/star-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetStarConfig)
|
||||||
|
g.POST("/star-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.CreateStarConfig)
|
||||||
|
g.PUT("/star-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.UpdateStarConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) ListStarConfigs(c *gin.Context) {
|
func (h *AdminHandler) ListStarConfigs(c *gin.Context) {
|
||||||
@@ -44,3 +46,66 @@ func (h *AdminHandler) GetStarConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.OK(c, row)
|
response.OK(c, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateStarConfig(c *gin.Context) {
|
||||||
|
adminID, ok := middleware.AdminIDFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body admin.StarConfigWriteBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.Svc.CreateStarConfig(c.Request.Context(), adminID, body)
|
||||||
|
if errors.Is(err, admin.ErrInvalidStarConfig) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40055, "invalid star config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrStarConfigConflict) {
|
||||||
|
response.Fail(c, http.StatusConflict, 40912, "star config code conflict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50052, "create star config failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) UpdateStarConfig(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.StarConfigWriteBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.Svc.UpdateStarConfig(c.Request.Context(), adminID, id, body)
|
||||||
|
if errors.Is(err, admin.ErrStarConfigNotFound) {
|
||||||
|
response.Fail(c, http.StatusNotFound, 40420, "star-config not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrInvalidStarConfig) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40055, "invalid star config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrStarConfigConflict) {
|
||||||
|
response.Fail(c, http.StatusConflict, 40912, "star config code conflict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50053, "update star config failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, row)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (h *AdminHandler) registerSystem(authed *gin.RouterGroup) {
|
func (h *AdminHandler) registerSystem(authed *gin.RouterGroup) {
|
||||||
authed.POST("/users/:id/ban", h.BanUser)
|
authed.POST("/users/:id/ban", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.BanUser)
|
||||||
authed.POST("/users/:id/unban", h.UnbanUser)
|
authed.POST("/users/:id/unban", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.UnbanUser)
|
||||||
authed.GET("/admins", h.ListAdmins)
|
authed.GET("/admins", h.ListAdmins)
|
||||||
authed.PATCH("/admins/:id", h.PatchAdmin)
|
authed.PATCH("/admins/:id", h.PatchAdmin)
|
||||||
authed.GET("/push-jobs", h.ListPushJobs)
|
authed.GET("/push-jobs", h.ListPushJobs)
|
||||||
@@ -39,6 +39,10 @@ func (h *AdminHandler) BanUser(c *gin.Context) {
|
|||||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, admin.ErrReasonRequired) || errors.Is(err, admin.ErrInvalidStatusEdge) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
response.Fail(c, http.StatusInternalServerError, 50040, "ban failed")
|
response.Fail(c, http.StatusInternalServerError, 50040, "ban failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,10 @@ func (h *AdminHandler) UnbanUser(c *gin.Context) {
|
|||||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, admin.ErrReasonRequired) || errors.Is(err, admin.ErrInvalidStatusEdge) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
response.Fail(c, http.StatusInternalServerError, 50041, "unban failed")
|
response.Fail(c, http.StatusInternalServerError, 50041, "unban failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ type HomeHandler struct {
|
|||||||
func (h *HomeHandler) Register(api *gin.RouterGroup) {
|
func (h *HomeHandler) Register(api *gin.RouterGroup) {
|
||||||
g := api.Group("/home")
|
g := api.Group("/home")
|
||||||
g.GET("/tools", h.Tools)
|
g.GET("/tools", h.Tools)
|
||||||
|
g.GET("/banners", h.Banners)
|
||||||
|
g.GET("/feed-slots", h.FeedSlots)
|
||||||
g.GET("/daily-tips", h.DailyTips)
|
g.GET("/daily-tips", h.DailyTips)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +37,26 @@ func (h *HomeHandler) Tools(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, gin.H{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HomeHandler) Banners(c *gin.Context) {
|
||||||
|
placement := c.DefaultQuery("placement", "home")
|
||||||
|
items, err := h.Svc.ListPublicBanners(c.Request.Context(), placement)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50046, "home banners failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HomeHandler) FeedSlots(c *gin.Context) {
|
||||||
|
placement := c.DefaultQuery("placement", "home")
|
||||||
|
items, err := h.Svc.ListPublicFeedSlots(c.Request.Context(), placement)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50049, "home feed slots failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HomeHandler) DailyTips(c *gin.Context) {
|
func (h *HomeHandler) DailyTips(c *gin.Context) {
|
||||||
uid, _ := middleware.UserIDFromContext(c)
|
uid, _ := middleware.UserIDFromContext(c)
|
||||||
tips, err := h.Svc.DailyTips(c.Request.Context(), uid)
|
tips, err := h.Svc.DailyTips(c.Request.Context(), uid)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RhythmConfigPublicHandler serves GET /api/v1/rhythm/configs (DeviceAuth).
|
||||||
|
type RhythmConfigPublicHandler struct {
|
||||||
|
Repo *repository.AdminRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts public rhythm config routes.
|
||||||
|
func (h *RhythmConfigPublicHandler) Register(api *gin.RouterGroup) {
|
||||||
|
api.GET("/rhythm/configs", h.ListActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RhythmConfigPublicHandler) ListActive(c *gin.Context) {
|
||||||
|
if h.Repo == nil {
|
||||||
|
response.OK(c, gin.H{"items": []repository.RhythmConfigRow{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.Repo.ListActiveRhythmConfigs(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50054, "rhythm configs failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []repository.RhythmConfigRow{}
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StarConfigPublicHandler serves GET /api/v1/star/configs (DeviceAuth).
|
||||||
|
type StarConfigPublicHandler struct {
|
||||||
|
Repo *repository.AdminRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts public star config routes.
|
||||||
|
func (h *StarConfigPublicHandler) Register(api *gin.RouterGroup) {
|
||||||
|
api.GET("/star/configs", h.ListActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StarConfigPublicHandler) ListActive(c *gin.Context) {
|
||||||
|
if h.Repo == nil {
|
||||||
|
response.OK(c, gin.H{"items": []repository.StarConfigRow{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.Repo.ListActiveStarConfigs(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50054, "star configs failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []repository.StarConfigRow{}
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,13 +69,25 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
|||||||
Repo: &repository.HomeToolsRepo{Pool: pool},
|
Repo: &repository.HomeToolsRepo{Pool: pool},
|
||||||
Tips: &repository.HomeDailyTipsRepo{Pool: pool},
|
Tips: &repository.HomeDailyTipsRepo{Pool: pool},
|
||||||
Profiles: profileRepo,
|
Profiles: profileRepo,
|
||||||
|
CMS: adminRepo,
|
||||||
LLM: llm,
|
LLM: llm,
|
||||||
}
|
}
|
||||||
analyticsSvc := &analyticssvc.Service{Repo: analyticsRepo}
|
analyticsSvc := &analyticssvc.Service{Repo: analyticsRepo}
|
||||||
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,
|
||||||
@@ -86,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)
|
||||||
@@ -98,8 +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.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,137 @@
|
|||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpsCMSBannerWrite(t *testing.T) {
|
||||||
|
r, pool := setupAPIPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tok := adminLogin(t, r, "admin", "change-me")
|
||||||
|
devKey := fmt.Sprintf("banner-write-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
code := fmt.Sprintf("home_w_%d", time.Now().UnixNano()%1_000_000)
|
||||||
|
body := map[string]any{
|
||||||
|
"code": code, "title": "写面横幅", "placement": "home",
|
||||||
|
"link_path": "/membership", "sort_order": 3, "active": true,
|
||||||
|
}
|
||||||
|
env, httpCode := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/cms/banners", 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 == "" || created.Code != code {
|
||||||
|
t.Fatalf("bad create %#v", created)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM ops_banners WHERE id=$1`, created.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/cms/banners", body, tok)
|
||||||
|
if httpCode != http.StatusConflict {
|
||||||
|
t.Fatalf("dup expected 409 got %d", httpCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/cms/banners", map[string]any{
|
||||||
|
"code": code + "_x", "title": "外链", "placement": "home",
|
||||||
|
"link_path": "https://evil.example", "active": true,
|
||||||
|
}, tok)
|
||||||
|
if httpCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("ext link expected 400 got %d", httpCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, _, httpCode = doJSONExpect(t, r, http.MethodGet, "/api/v1/home/banners?placement=home", nil, devKey, 0)
|
||||||
|
if httpCode != 200 {
|
||||||
|
t.Fatalf("home banners http=%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("active banner missing in C-end: %#v", pub.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
body["active"] = false
|
||||||
|
body["title"] = "已下架"
|
||||||
|
env, httpCode = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/cms/banners/"+created.ID, body, tok)
|
||||||
|
if httpCode != 200 || env.Code != 0 {
|
||||||
|
t.Fatalf("update %d %s", httpCode, env.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, _, httpCode = doJSONExpect(t, r, http.MethodGet, "/api/v1/home/banners?placement=home", nil, devKey, 0)
|
||||||
|
if httpCode != 200 {
|
||||||
|
t.Fatalf("home after deactivate %d", httpCode)
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(env.Data, &pub)
|
||||||
|
for _, it := range pub.Items {
|
||||||
|
if it.Code == code {
|
||||||
|
t.Fatalf("inactive still listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
err := pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM admin_audit_logs
|
||||||
|
WHERE action IN ('cms.banner.create','cms.banner.update') AND target_id=$1`,
|
||||||
|
created.ID).Scan(&n)
|
||||||
|
if err != nil || n < 2 {
|
||||||
|
t.Fatalf("expected ≥2 audit rows, got %d err=%v", n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
limitedRoleID := uuid.New()
|
||||||
|
_, err = pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||||
|
limitedRoleID, "cms_ro_"+limitedRoleID.String()[:8])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.cms.read')`, limitedRoleID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("ro-pass"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
roUser := fmt.Sprintf("cmsro_%d", time.Now().UnixNano())
|
||||||
|
_, err = pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||||
|
roUser, string(hash), limitedRoleID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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/cms/banners", map[string]any{
|
||||||
|
"code": "x_ro", "title": "no", "placement": "home", "active": true,
|
||||||
|
}, roTok)
|
||||||
|
if httpCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("read-only write expected 403 got %d", httpCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 TestOpsCMSFeedSlotWrite(t *testing.T) {
|
||||||
|
r, pool := setupAPIPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tok := adminLogin(t, r, "admin", "change-me")
|
||||||
|
devKey := fmt.Sprintf("feed-slot-write-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
code := fmt.Sprintf("slot_w_%d", time.Now().UnixNano()%1_000_000)
|
||||||
|
body := map[string]any{
|
||||||
|
"code": code, "title": "测试槽", "slot_key": "home.test",
|
||||||
|
"placement": "home", "active": true,
|
||||||
|
}
|
||||||
|
env, httpCode := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/cms/feed-slots", 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.Fatalf("bad create")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM ops_feed_slots WHERE id=$1`, created.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/cms/feed-slots", body, tok)
|
||||||
|
if httpCode != http.StatusConflict {
|
||||||
|
t.Fatalf("dup expected 409 got %d", httpCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, _, httpCode = doJSONExpect(t, r, http.MethodGet, "/api/v1/home/feed-slots?placement=home", nil, devKey, 0)
|
||||||
|
if httpCode != 200 {
|
||||||
|
t.Fatalf("home slots %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 slot %#v", pub.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
body["active"] = false
|
||||||
|
_, httpCode = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/cms/feed-slots/"+created.ID, body, tok)
|
||||||
|
if httpCode != 200 {
|
||||||
|
t.Fatalf("update %d", httpCode)
|
||||||
|
}
|
||||||
|
env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/home/feed-slots?placement=home", nil, devKey, 0)
|
||||||
|
_ = json.Unmarshal(env.Data, &pub)
|
||||||
|
for _, it := range pub.Items {
|
||||||
|
if it.Code == code {
|
||||||
|
t.Fatalf("inactive still listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
_ = pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM admin_audit_logs
|
||||||
|
WHERE action IN ('cms.feed_slot.create','cms.feed_slot.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, "fs_ro_"+limitedRoleID.String()[:8])
|
||||||
|
_, _ = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.cms.read')`, limitedRoleID)
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("ro-pass"), bcrypt.DefaultCost)
|
||||||
|
roUser := fmt.Sprintf("fsro_%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/cms/feed-slots", map[string]any{
|
||||||
|
"code": "x_ro", "title": "no", "slot_key": "home.x", "placement": "home", "active": true,
|
||||||
|
}, roTok)
|
||||||
|
if httpCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 got %d", httpCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,9 +188,26 @@ func insertOpsAdmin(t *testing.T, username, password string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("hash: %v", err)
|
t.Fatalf("hash: %v", err)
|
||||||
}
|
}
|
||||||
|
var roleID uuid.UUID
|
||||||
|
err = pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO admin_roles(name, system)
|
||||||
|
VALUES ('ops', false)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id`).Scan(&roleID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensure ops role: %v", err)
|
||||||
|
}
|
||||||
_, err = pool.Exec(ctx, `
|
_, err = pool.Exec(ctx, `
|
||||||
INSERT INTO admin_accounts(username, password_hash, role, status)
|
INSERT INTO admin_role_permissions(role_id, code) VALUES
|
||||||
VALUES ($1,$2,'ops','active')`, username, string(hash))
|
($1, 'admin.users.read'),
|
||||||
|
($1, 'admin.users.status.write')
|
||||||
|
ON CONFLICT DO NOTHING`, roleID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ops role perms: %v", err)
|
||||||
|
}
|
||||||
|
_, err = pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_accounts(username, password_hash, role, status, role_id)
|
||||||
|
VALUES ($1,$2,'ops','active',$3)`, username, string(hash), roleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("insert ops admin: %v", err)
|
t.Fatalf("insert ops admin: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 TestExploreRhythmConfigWrite(t *testing.T) {
|
||||||
|
r, pool := setupAPIPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tok := adminLogin(t, r, "admin", "change-me")
|
||||||
|
devKey := fmt.Sprintf("rhythm-cfg-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
code := fmt.Sprintf("rhythm_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/rhythm-configs", 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 rhythm_configs WHERE id=$1`, created.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/rhythm-configs", body, tok)
|
||||||
|
if httpCode != http.StatusConflict {
|
||||||
|
t.Fatalf("dup expected 409 got %d", httpCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, _, httpCode = doJSONExpect(t, r, http.MethodGet, "/api/v1/rhythm/configs", 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/rhythm-configs/"+created.ID, body, tok)
|
||||||
|
if httpCode != 200 {
|
||||||
|
t.Fatalf("update %d", httpCode)
|
||||||
|
}
|
||||||
|
env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/rhythm/configs", 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 rhythm_configs SET active=false`)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, `UPDATE rhythm_configs SET active=true WHERE system=true`)
|
||||||
|
})
|
||||||
|
env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/rhythm/configs", 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.rhythm_config.create','explore.rhythm_config.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, "rc_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("rcro_%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/rhythm-configs", 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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExploreStarConfigWrite(t *testing.T) {
|
||||||
|
r, pool := setupAPIPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tok := adminLogin(t, r, "admin", "change-me")
|
||||||
|
devKey := fmt.Sprintf("star-cfg-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
code := fmt.Sprintf("star_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/star-configs", 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 star_configs WHERE id=$1`, created.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/star-configs", body, tok)
|
||||||
|
if httpCode != http.StatusConflict {
|
||||||
|
t.Fatalf("dup expected 409 got %d", httpCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, _, httpCode = doJSONExpect(t, r, http.MethodGet, "/api/v1/star/configs", 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/star-configs/"+created.ID, body, tok)
|
||||||
|
if httpCode != 200 {
|
||||||
|
t.Fatalf("update %d", httpCode)
|
||||||
|
}
|
||||||
|
env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/star/configs", nil, devKey, 0)
|
||||||
|
_ = json.Unmarshal(env.Data, &pub)
|
||||||
|
for _, it := range pub.Items {
|
||||||
|
if it.Code == code {
|
||||||
|
t.Fatal("inactive still listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate all including seed — C-end may be empty
|
||||||
|
_, _ = pool.Exec(ctx, `UPDATE star_configs SET active=false`)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, `UPDATE star_configs SET active=true WHERE system=true`)
|
||||||
|
})
|
||||||
|
env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/star/configs", 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.star_config.create','explore.star_config.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, "sc_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("scro_%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/star-configs", 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,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)
|
||||||
|
}
|
||||||
@@ -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,162 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BannerWriteInput is create/update payload for ops_banners.
|
||||||
|
type BannerWriteInput struct {
|
||||||
|
Code string
|
||||||
|
Title string
|
||||||
|
Placement string
|
||||||
|
ImageURL *string
|
||||||
|
LinkPath *string
|
||||||
|
SortOrder int
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveBanners returns active banners for a placement (C-end).
|
||||||
|
func (r *AdminRepo) ListActiveBanners(ctx context.Context, placement string) ([]BannerRow, error) {
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at
|
||||||
|
FROM ops_banners
|
||||||
|
WHERE active = true AND placement = $1
|
||||||
|
ORDER BY sort_order ASC, code ASC
|
||||||
|
LIMIT 100`, placement)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []BannerRow
|
||||||
|
for rows.Next() {
|
||||||
|
var b BannerRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&b.ID, &b.Code, &b.Title, &b.Placement, &b.ImageURL, &b.LinkPath,
|
||||||
|
&b.SortOrder, &b.Active, &b.System, &b.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBannerWithAudit inserts a banner and audits.
|
||||||
|
func (r *AdminRepo) CreateBannerWithAudit(
|
||||||
|
ctx context.Context,
|
||||||
|
adminID uuid.UUID,
|
||||||
|
in BannerWriteInput,
|
||||||
|
meta json.RawMessage,
|
||||||
|
) (*BannerRow, error) {
|
||||||
|
tx, err := r.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var b BannerRow
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO ops_banners(code, title, placement, image_url, link_path, sort_order, active, system)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,false)
|
||||||
|
RETURNING id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at`,
|
||||||
|
in.Code, in.Title, in.Placement, in.ImageURL, in.LinkPath, in.SortOrder, in.Active,
|
||||||
|
).Scan(
|
||||||
|
&b.ID, &b.Code, &b.Title, &b.Placement, &b.ImageURL, &b.LinkPath,
|
||||||
|
&b.SortOrder, &b.Active, &b.System, &b.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapBannerWriteErr(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,'cms.banner.create','banner',$2,$3)`,
|
||||||
|
adminID, b.ID.String(), meta,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBannerWithAudit updates mutable fields and audits.
|
||||||
|
func (r *AdminRepo) UpdateBannerWithAudit(
|
||||||
|
ctx context.Context,
|
||||||
|
adminID, id uuid.UUID,
|
||||||
|
in BannerWriteInput,
|
||||||
|
meta json.RawMessage,
|
||||||
|
) (*BannerRow, 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 ops_banners 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 b BannerRow
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
UPDATE ops_banners
|
||||||
|
SET code=$2, title=$3, placement=$4, image_url=$5, link_path=$6,
|
||||||
|
sort_order=$7, active=$8, updated_at=now()
|
||||||
|
WHERE id=$1
|
||||||
|
RETURNING id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at`,
|
||||||
|
id, code, in.Title, in.Placement, in.ImageURL, in.LinkPath, in.SortOrder, in.Active,
|
||||||
|
).Scan(
|
||||||
|
&b.ID, &b.Code, &b.Title, &b.Placement, &b.ImageURL, &b.LinkPath,
|
||||||
|
&b.SortOrder, &b.Active, &b.System, &b.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapBannerWriteErr(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,'cms.banner.update','banner',$2,$3)`,
|
||||||
|
adminID, id.String(), meta,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapBannerWriteErr(err error) error {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return errString("banner code conflict")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerCodeConflict reports unique code violation.
|
||||||
|
func BannerCodeConflict(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), "banner code conflict")
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedSlotWriteInput is create/update payload.
|
||||||
|
type FeedSlotWriteInput struct {
|
||||||
|
Code string
|
||||||
|
Title string
|
||||||
|
SlotKey string
|
||||||
|
Placement string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveFeedSlots returns active slots for placement.
|
||||||
|
func (r *AdminRepo) ListActiveFeedSlots(ctx context.Context, placement string) ([]FeedSlotRow, error) {
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT id, code, title, slot_key, placement, active, system, updated_at
|
||||||
|
FROM ops_feed_slots
|
||||||
|
WHERE active = true AND placement = $1
|
||||||
|
ORDER BY code ASC
|
||||||
|
LIMIT 100`, placement)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []FeedSlotRow
|
||||||
|
for rows.Next() {
|
||||||
|
var s FeedSlotRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&s.ID, &s.Code, &s.Title, &s.SlotKey, &s.Placement, &s.Active, &s.System, &s.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFeedSlotWithAudit inserts and audits.
|
||||||
|
func (r *AdminRepo) CreateFeedSlotWithAudit(
|
||||||
|
ctx context.Context, adminID uuid.UUID, in FeedSlotWriteInput, meta json.RawMessage,
|
||||||
|
) (*FeedSlotRow, error) {
|
||||||
|
tx, err := r.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
var s FeedSlotRow
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO ops_feed_slots(code, title, slot_key, placement, active, system)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,false)
|
||||||
|
RETURNING id, code, title, slot_key, placement, active, system, updated_at`,
|
||||||
|
in.Code, in.Title, in.SlotKey, in.Placement, in.Active,
|
||||||
|
).Scan(&s.ID, &s.Code, &s.Title, &s.SlotKey, &s.Placement, &s.Active, &s.System, &s.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapFeedSlotWriteErr(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,'cms.feed_slot.create','feed_slot',$2,$3)`,
|
||||||
|
adminID, s.ID.String(), meta,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFeedSlotWithAudit updates and audits.
|
||||||
|
func (r *AdminRepo) UpdateFeedSlotWithAudit(
|
||||||
|
ctx context.Context, adminID, id uuid.UUID, in FeedSlotWriteInput, meta json.RawMessage,
|
||||||
|
) (*FeedSlotRow, 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 ops_feed_slots 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 s FeedSlotRow
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
UPDATE ops_feed_slots
|
||||||
|
SET code=$2, title=$3, slot_key=$4, placement=$5, active=$6, updated_at=now()
|
||||||
|
WHERE id=$1
|
||||||
|
RETURNING id, code, title, slot_key, placement, active, system, updated_at`,
|
||||||
|
id, code, in.Title, in.SlotKey, in.Placement, in.Active,
|
||||||
|
).Scan(&s.ID, &s.Code, &s.Title, &s.SlotKey, &s.Placement, &s.Active, &s.System, &s.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapFeedSlotWriteErr(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,'cms.feed_slot.update','feed_slot',$2,$3)`,
|
||||||
|
adminID, id.String(), meta,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapFeedSlotWriteErr(err error) error {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return errString("feed slot code conflict")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedSlotCodeConflict reports unique violation.
|
||||||
|
func FeedSlotCodeConflict(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), "feed slot code conflict")
|
||||||
|
}
|
||||||
@@ -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,139 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RhythmConfigWriteInput is create/update payload.
|
||||||
|
type RhythmConfigWriteInput struct {
|
||||||
|
Code string
|
||||||
|
Title string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveRhythmConfigs returns active configs for C-end.
|
||||||
|
func (r *AdminRepo) ListActiveRhythmConfigs(ctx context.Context) ([]RhythmConfigRow, error) {
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT id, code, title, active, system, updated_at
|
||||||
|
FROM rhythm_configs
|
||||||
|
WHERE active = true
|
||||||
|
ORDER BY code ASC
|
||||||
|
LIMIT 100`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []RhythmConfigRow
|
||||||
|
for rows.Next() {
|
||||||
|
var row RhythmConfigRow
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRhythmConfigWithAudit inserts and audits.
|
||||||
|
func (r *AdminRepo) CreateRhythmConfigWithAudit(
|
||||||
|
ctx context.Context, adminID uuid.UUID, in RhythmConfigWriteInput, meta json.RawMessage,
|
||||||
|
) (*RhythmConfigRow, error) {
|
||||||
|
tx, err := r.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
var row RhythmConfigRow
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO rhythm_configs(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, mapRhythmConfigWriteErr(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.rhythm_config.create','rhythm_config',$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
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRhythmConfigWithAudit updates and audits.
|
||||||
|
func (r *AdminRepo) UpdateRhythmConfigWithAudit(
|
||||||
|
ctx context.Context, adminID, id uuid.UUID, in RhythmConfigWriteInput, meta json.RawMessage,
|
||||||
|
) (*RhythmConfigRow, 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 rhythm_configs 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 RhythmConfigRow
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
UPDATE rhythm_configs
|
||||||
|
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, mapRhythmConfigWriteErr(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.rhythm_config.update','rhythm_config',$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 mapRhythmConfigWriteErr(err error) error {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return errString("rhythm config code conflict")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RhythmConfigCodeConflict reports unique violation.
|
||||||
|
func RhythmConfigCodeConflict(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), "rhythm config 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,139 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StarConfigWriteInput is create/update payload.
|
||||||
|
type StarConfigWriteInput struct {
|
||||||
|
Code string
|
||||||
|
Title string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveStarConfigs returns active configs for C-end.
|
||||||
|
func (r *AdminRepo) ListActiveStarConfigs(ctx context.Context) ([]StarConfigRow, error) {
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT id, code, title, active, system, updated_at
|
||||||
|
FROM star_configs
|
||||||
|
WHERE active = true
|
||||||
|
ORDER BY code ASC
|
||||||
|
LIMIT 100`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []StarConfigRow
|
||||||
|
for rows.Next() {
|
||||||
|
var row StarConfigRow
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateStarConfigWithAudit inserts and audits.
|
||||||
|
func (r *AdminRepo) CreateStarConfigWithAudit(
|
||||||
|
ctx context.Context, adminID uuid.UUID, in StarConfigWriteInput, meta json.RawMessage,
|
||||||
|
) (*StarConfigRow, error) {
|
||||||
|
tx, err := r.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
var row StarConfigRow
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO star_configs(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, mapStarConfigWriteErr(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.star_config.create','star_config',$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
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStarConfigWithAudit updates and audits.
|
||||||
|
func (r *AdminRepo) UpdateStarConfigWithAudit(
|
||||||
|
ctx context.Context, adminID, id uuid.UUID, in StarConfigWriteInput, meta json.RawMessage,
|
||||||
|
) (*StarConfigRow, 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 star_configs 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 StarConfigRow
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
UPDATE star_configs
|
||||||
|
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, mapStarConfigWriteErr(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.star_config.update','star_config',$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 mapStarConfigWriteErr(err error) error {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return errString("star config code conflict")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarConfigCodeConflict reports unique violation.
|
||||||
|
func StarConfigCodeConflict(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), "star config code conflict")
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
ErrInvalidFeedSlot = errors.New("invalid feed slot")
|
||||||
|
ErrFeedSlotConflict = errors.New("feed slot code conflict")
|
||||||
|
slotCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`)
|
||||||
|
slotKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_.]{1,62}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedSlotWriteBody is JSON for create/update.
|
||||||
|
type FeedSlotWriteBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
SlotKey string `json:"slot_key"`
|
||||||
|
Placement string `json:"placement"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFeedSlot validates, inserts, audits.
|
||||||
|
func (s *Service) CreateFeedSlot(ctx context.Context, adminID uuid.UUID, body FeedSlotWriteBody) (*repository.FeedSlotRow, error) {
|
||||||
|
in, err := normalizeFeedSlotWrite(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||||
|
row, err := s.Repo.CreateFeedSlotWithAudit(ctx, adminID, in, meta)
|
||||||
|
if repository.FeedSlotCodeConflict(err) {
|
||||||
|
return nil, ErrFeedSlotConflict
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFeedSlot validates, updates, audits.
|
||||||
|
func (s *Service) UpdateFeedSlot(ctx context.Context, adminID, id uuid.UUID, body FeedSlotWriteBody) (*repository.FeedSlotRow, error) {
|
||||||
|
in, err := normalizeFeedSlotWrite(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||||
|
row, err := s.Repo.UpdateFeedSlotWithAudit(ctx, adminID, id, in, meta)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrFeedSlotNotFound
|
||||||
|
}
|
||||||
|
if repository.FeedSlotCodeConflict(err) {
|
||||||
|
return nil, ErrFeedSlotConflict
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFeedSlotWrite(body FeedSlotWriteBody) (repository.FeedSlotWriteInput, error) {
|
||||||
|
code := strings.TrimSpace(body.Code)
|
||||||
|
title := strings.TrimSpace(body.Title)
|
||||||
|
slotKey := strings.TrimSpace(body.SlotKey)
|
||||||
|
placement := strings.TrimSpace(body.Placement)
|
||||||
|
if !slotCodeRe.MatchString(code) || !slotKeyRe.MatchString(slotKey) {
|
||||||
|
return repository.FeedSlotWriteInput{}, ErrInvalidFeedSlot
|
||||||
|
}
|
||||||
|
if title == "" || utf8.RuneCountInString(title) > 128 {
|
||||||
|
return repository.FeedSlotWriteInput{}, ErrInvalidFeedSlot
|
||||||
|
}
|
||||||
|
if _, ok := bannerPlacements[placement]; !ok {
|
||||||
|
return repository.FeedSlotWriteInput{}, ErrInvalidFeedSlot
|
||||||
|
}
|
||||||
|
return repository.FeedSlotWriteInput{
|
||||||
|
Code: code, Title: title, SlotKey: slotKey, Placement: placement, Active: body.Active,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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 (
|
||||||
|
ErrInvalidBanner = errors.New("invalid banner")
|
||||||
|
ErrBannerConflict = errors.New("banner code conflict")
|
||||||
|
bannerPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_./-]{1,200}$`)
|
||||||
|
bannerPlacements = map[string]struct{}{"home": {}, "explore": {}, "ask": {}}
|
||||||
|
bannerCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// BannerWriteBody is JSON for create/update.
|
||||||
|
type BannerWriteBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Placement string `json:"placement"`
|
||||||
|
ImageURL *string `json:"image_url"`
|
||||||
|
LinkPath *string `json:"link_path"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBanner validates, inserts, audits.
|
||||||
|
func (s *Service) CreateBanner(ctx context.Context, adminID uuid.UUID, body BannerWriteBody) (*repository.BannerRow, error) {
|
||||||
|
in, err := normalizeBannerWrite(body, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||||
|
row, err := s.Repo.CreateBannerWithAudit(ctx, adminID, in, meta)
|
||||||
|
if repository.BannerCodeConflict(err) {
|
||||||
|
return nil, ErrBannerConflict
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBanner validates, updates, audits.
|
||||||
|
func (s *Service) UpdateBanner(ctx context.Context, adminID, id uuid.UUID, body BannerWriteBody) (*repository.BannerRow, error) {
|
||||||
|
in, err := normalizeBannerWrite(body, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||||
|
row, err := s.Repo.UpdateBannerWithAudit(ctx, adminID, id, in, meta)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrBannerNotFound
|
||||||
|
}
|
||||||
|
if repository.BannerCodeConflict(err) {
|
||||||
|
return nil, ErrBannerConflict
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBannerWrite(body BannerWriteBody, requireCode bool) (repository.BannerWriteInput, error) {
|
||||||
|
code := strings.TrimSpace(body.Code)
|
||||||
|
title := strings.TrimSpace(body.Title)
|
||||||
|
placement := strings.TrimSpace(body.Placement)
|
||||||
|
if requireCode && code == "" {
|
||||||
|
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||||
|
}
|
||||||
|
if code != "" && !bannerCodeRe.MatchString(code) {
|
||||||
|
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||||
|
}
|
||||||
|
if title == "" || utf8.RuneCountInString(title) > 128 {
|
||||||
|
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||||
|
}
|
||||||
|
if _, ok := bannerPlacements[placement]; !ok {
|
||||||
|
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||||
|
}
|
||||||
|
var link *string
|
||||||
|
if body.LinkPath != nil {
|
||||||
|
lp := strings.TrimSpace(*body.LinkPath)
|
||||||
|
if lp == "" {
|
||||||
|
link = nil
|
||||||
|
} else {
|
||||||
|
if strings.Contains(lp, "://") || !bannerPathRe.MatchString(lp) {
|
||||||
|
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||||
|
}
|
||||||
|
link = &lp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var img *string
|
||||||
|
if body.ImageURL != nil {
|
||||||
|
u := strings.TrimSpace(*body.ImageURL)
|
||||||
|
if u == "" {
|
||||||
|
img = nil
|
||||||
|
} else if utf8.RuneCountInString(u) > 2000 {
|
||||||
|
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||||
|
} else {
|
||||||
|
img = &u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repository.BannerWriteInput{
|
||||||
|
Code: code, Title: title, Placement: placement,
|
||||||
|
ImageURL: img, LinkPath: link, SortOrder: body.SortOrder, 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 (
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -31,9 +31,12 @@ const (
|
|||||||
PermAIConfigRead = "admin.ai_config.read"
|
PermAIConfigRead = "admin.ai_config.read"
|
||||||
PermCrisisRead = "admin.crisis.read"
|
PermCrisisRead = "admin.crisis.read"
|
||||||
PermCMSRead = "admin.cms.read"
|
PermCMSRead = "admin.cms.read"
|
||||||
|
PermCMSWrite = "admin.cms.write"
|
||||||
PermPrivacyRead = "admin.privacy.read"
|
PermPrivacyRead = "admin.privacy.read"
|
||||||
PermExploreRead = "admin.explore.read"
|
PermExploreRead = "admin.explore.read"
|
||||||
PermGrowthRead = "admin.growth.read"
|
PermExploreWrite = "admin.explore.write"
|
||||||
|
PermGrowthRead = "admin.growth.read"
|
||||||
|
PermGrowthWrite = "admin.growth.write"
|
||||||
)
|
)
|
||||||
|
|
||||||
var knownPermissions = map[string]struct{}{
|
var knownPermissions = map[string]struct{}{
|
||||||
@@ -43,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: {}, PermGrowthRead: {}, PermExploreRead: {}, 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,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 (
|
||||||
|
ErrInvalidRhythmConfig = errors.New("invalid rhythm config")
|
||||||
|
ErrRhythmConfigConflict = errors.New("rhythm config code conflict")
|
||||||
|
rhythmConfigCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// RhythmConfigWriteBody is JSON for create/update.
|
||||||
|
type RhythmConfigWriteBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRhythmConfig validates, inserts, audits.
|
||||||
|
func (s *Service) CreateRhythmConfig(ctx context.Context, adminID uuid.UUID, body RhythmConfigWriteBody) (*repository.RhythmConfigRow, error) {
|
||||||
|
in, err := normalizeRhythmConfigWrite(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||||
|
row, err := s.Repo.CreateRhythmConfigWithAudit(ctx, adminID, in, meta)
|
||||||
|
if repository.RhythmConfigCodeConflict(err) {
|
||||||
|
return nil, ErrRhythmConfigConflict
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRhythmConfig validates, updates, audits.
|
||||||
|
func (s *Service) UpdateRhythmConfig(ctx context.Context, adminID, id uuid.UUID, body RhythmConfigWriteBody) (*repository.RhythmConfigRow, error) {
|
||||||
|
in, err := normalizeRhythmConfigWrite(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||||
|
row, err := s.Repo.UpdateRhythmConfigWithAudit(ctx, adminID, id, in, meta)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrRhythmConfigNotFound
|
||||||
|
}
|
||||||
|
if repository.RhythmConfigCodeConflict(err) {
|
||||||
|
return nil, ErrRhythmConfigConflict
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRhythmConfigWrite(body RhythmConfigWriteBody) (repository.RhythmConfigWriteInput, error) {
|
||||||
|
code := strings.TrimSpace(body.Code)
|
||||||
|
title := strings.TrimSpace(body.Title)
|
||||||
|
if !rhythmConfigCodeRe.MatchString(code) {
|
||||||
|
return repository.RhythmConfigWriteInput{}, ErrInvalidRhythmConfig
|
||||||
|
}
|
||||||
|
if title == "" || utf8.RuneCountInString(title) > 128 {
|
||||||
|
return repository.RhythmConfigWriteInput{}, ErrInvalidRhythmConfig
|
||||||
|
}
|
||||||
|
return repository.RhythmConfigWriteInput{Code: code, Title: title, 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
|
||||||
|
}
|
||||||
@@ -234,10 +234,8 @@ func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpsertPlanPrices updates display prices (not historical order amounts).
|
// UpsertPlanPrices updates display prices (not historical order amounts).
|
||||||
|
// Caller must enforce admin.membership.plans.write.
|
||||||
func (s *Service) UpsertPlanPrices(ctx context.Context, adminID uuid.UUID, items []repository.PlanPrice) error {
|
func (s *Service) UpsertPlanPrices(ctx context.Context, adminID uuid.UUID, items []repository.PlanPrice) error {
|
||||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return ErrInvalidPlan
|
return ErrInvalidPlan
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
ErrInvalidStarConfig = errors.New("invalid star config")
|
||||||
|
ErrStarConfigConflict = errors.New("star config code conflict")
|
||||||
|
starConfigCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// StarConfigWriteBody is JSON for create/update.
|
||||||
|
type StarConfigWriteBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateStarConfig validates, inserts, audits.
|
||||||
|
func (s *Service) CreateStarConfig(ctx context.Context, adminID uuid.UUID, body StarConfigWriteBody) (*repository.StarConfigRow, error) {
|
||||||
|
in, err := normalizeStarConfigWrite(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||||
|
row, err := s.Repo.CreateStarConfigWithAudit(ctx, adminID, in, meta)
|
||||||
|
if repository.StarConfigCodeConflict(err) {
|
||||||
|
return nil, ErrStarConfigConflict
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStarConfig validates, updates, audits.
|
||||||
|
func (s *Service) UpdateStarConfig(ctx context.Context, adminID, id uuid.UUID, body StarConfigWriteBody) (*repository.StarConfigRow, error) {
|
||||||
|
in, err := normalizeStarConfigWrite(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||||
|
row, err := s.Repo.UpdateStarConfigWithAudit(ctx, adminID, id, in, meta)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrStarConfigNotFound
|
||||||
|
}
|
||||||
|
if repository.StarConfigCodeConflict(err) {
|
||||||
|
return nil, ErrStarConfigConflict
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStarConfigWrite(body StarConfigWriteBody) (repository.StarConfigWriteInput, error) {
|
||||||
|
code := strings.TrimSpace(body.Code)
|
||||||
|
title := strings.TrimSpace(body.Title)
|
||||||
|
if !starConfigCodeRe.MatchString(code) {
|
||||||
|
return repository.StarConfigWriteInput{}, ErrInvalidStarConfig
|
||||||
|
}
|
||||||
|
if title == "" || utf8.RuneCountInString(title) > 128 {
|
||||||
|
return repository.StarConfigWriteInput{}, ErrInvalidStarConfig
|
||||||
|
}
|
||||||
|
return repository.StarConfigWriteInput{Code: code, Title: title, Active: body.Active}, nil
|
||||||
|
}
|
||||||
@@ -39,38 +39,14 @@ func (s *Service) RequireSuper(ctx context.Context, adminID uuid.UUID) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BanUser sets users.status=banned.
|
// BanUser transitions user status to banned via the lifecycle state machine.
|
||||||
func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
||||||
ok, err := s.Repo.UserExists(ctx, userID)
|
return s.TransitionUserStatus(ctx, adminID, userID, "banned", "admin ban")
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return ErrUserNotFound
|
|
||||||
}
|
|
||||||
meta, _ := json.Marshal(map[string]any{"status": "banned"})
|
|
||||||
err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "banned", "user.ban", meta)
|
|
||||||
if errors.Is(err, repository.ErrUserStatusNotFound) {
|
|
||||||
return ErrUserNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnbanUser sets users.status=active.
|
// UnbanUser transitions user status to active via the lifecycle state machine.
|
||||||
func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
||||||
ok, err := s.Repo.UserExists(ctx, userID)
|
return s.TransitionUserStatus(ctx, adminID, userID, "active", "admin unban")
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return ErrUserNotFound
|
|
||||||
}
|
|
||||||
meta, _ := json.Marshal(map[string]any{"status": "active"})
|
|
||||||
err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "active", "user.unban", meta)
|
|
||||||
if errors.Is(err, repository.ErrUserStatusNotFound) {
|
|
||||||
return ErrUserNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAdmins returns admin accounts (super only caller).
|
// ListAdmins returns admin accounts (super only caller).
|
||||||
|
|||||||
@@ -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, ¬ice)
|
||||||
|
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, ¬ice, &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")
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ type Service struct {
|
|||||||
Repo *repository.HomeToolsRepo
|
Repo *repository.HomeToolsRepo
|
||||||
Tips *repository.HomeDailyTipsRepo
|
Tips *repository.HomeDailyTipsRepo
|
||||||
Profiles *repository.ProfileRepo
|
Profiles *repository.ProfileRepo
|
||||||
|
CMS *repository.AdminRepo
|
||||||
LLM *deepseek.Client
|
LLM *deepseek.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +40,42 @@ func (s *Service) ListPublic(ctx context.Context) ([]repository.HomeTool, error)
|
|||||||
return s.Repo.ListEnabled(ctx)
|
return s.Repo.ListEnabled(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPublicBanners returns active banners for placement (default home).
|
||||||
|
func (s *Service) ListPublicBanners(ctx context.Context, placement string) ([]repository.BannerRow, error) {
|
||||||
|
if placement == "" {
|
||||||
|
placement = "home"
|
||||||
|
}
|
||||||
|
if s.CMS == nil {
|
||||||
|
return []repository.BannerRow{}, nil
|
||||||
|
}
|
||||||
|
items, err := s.CMS.ListActiveBanners(ctx, placement)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []repository.BannerRow{}
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublicFeedSlots returns active feed slots for placement.
|
||||||
|
func (s *Service) ListPublicFeedSlots(ctx context.Context, placement string) ([]repository.FeedSlotRow, error) {
|
||||||
|
if placement == "" {
|
||||||
|
placement = "home"
|
||||||
|
}
|
||||||
|
if s.CMS == nil {
|
||||||
|
return []repository.FeedSlotRow{}, nil
|
||||||
|
}
|
||||||
|
items, err := s.CMS.ListActiveFeedSlots(ctx, placement)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []repository.FeedSlotRow{}
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListAdmin returns all tools.
|
// ListAdmin returns all tools.
|
||||||
func (s *Service) ListAdmin(ctx context.Context) ([]repository.HomeTool, error) {
|
func (s *Service) ListAdmin(ctx context.Context) ([]repository.HomeTool, error) {
|
||||||
return s.Repo.ListAll(ctx)
|
return s.Repo.ListAll(ctx)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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[:]))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user