feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -180,8 +180,9 @@ Level 0 alone ≠ Backend/Frontend Done。
|
||||
| **Vertical Slice Demo** | 主路径可演示;缺测/缺态/缺功能仍可存在 |
|
||||
| **Feature Complete** | 单功能满足本文件 §1–7 |
|
||||
| **P1 Complete** | feature-map P1 必做项全部 Feature Complete + L2 三流 + 至少 1 条 L3(或书面豁免经用户同意) |
|
||||
| **P2 Complete** | feature-map P2 范围全部 Feature Complete + P2 L2 流 + 至少 1 条 P2 L3(真支付除外,见 feature-map) |
|
||||
|
||||
当前仓库默认状态见:[product/p1-status.md](product/p1-status.md)。
|
||||
当前仓库默认状态见:[product/p1-status.md](product/p1-status.md) · [product/p2-status.md](product/p2-status.md)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|---|---|
|
||||
| [ENGINEERING-FREEZE.md](ENGINEERING-FREEZE.md) | **P1 冻结清单** |
|
||||
| [p1-status.md](p1-status.md) | **P1 完成度(当前:P1 Complete)** |
|
||||
| [p2-status.md](p2-status.md) | **P2 完成度(当前:P2 Complete)** |
|
||||
| [lexicon.md](lexicon.md) | 产品语言契约(最高优先级) |
|
||||
| [feature-design.md](feature-design.md) | **Feature Spec 强制规范** |
|
||||
| [feature-spec/](feature-spec/README.md) | **功能详细设计(单功能 HOW)** |
|
||||
@@ -21,4 +22,4 @@
|
||||
|
||||
开发链路:`feature-map` →(对标时)逆向 STEP 1–18 → `feature-spec` → domain/OpenAPI → 实现 → 测试 → Review。
|
||||
|
||||
P2 探索三模块(设计):[feature-spec/P2-BACKLOG.md](feature-spec/P2-BACKLOG.md) · 星象性格 / 身心节律 / 意象卡片。
|
||||
P2 探索收口:[p2-status.md](p2-status.md) · [feature-spec/P2-BACKLOG.md](feature-spec/P2-BACKLOG.md) · 星座 / 身心节律 / 意象卡片。
|
||||
|
||||
@@ -30,11 +30,10 @@
|
||||
## 执行分期(与 feature-map 对齐)
|
||||
|
||||
1. **P1** — 档案 · 性格探索/画像 · 人格测评 · 关系理解 · 问答 · 深度版 · 会员 · 分享 · 埋点
|
||||
2. **P2** — 星象性格 · 身心节律 · 意象卡片 · 节气陪伴深化 · 心情记录 · 成长计划
|
||||
2. **P2** — 星象性格 · 身心节律 · 意象卡片 · 节气陪伴深化 · 心情记录 · 成长计划 → **P2 Complete**([p2-status.md](p2-status.md))
|
||||
3. **P3** — 长期记忆 · 成长数据库 · 真人顾问
|
||||
|
||||
P2 三模块:**设计已立项**(Spec Active);**实现前**须保持 Spec 与 OpenAPI/erd 同步,另开编码切片。
|
||||
不进入 P1 Complete 判定。
|
||||
真支付仍属部署阶段,不进入 P1/P2 Complete 判定。
|
||||
|
||||
冲突时:**lexicon + feature-map > 历史 Vision 文档中的旧命名(解码/Unlock 等)**。
|
||||
|
||||
|
||||
@@ -212,11 +212,11 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
||||
|
||||
**不含** 2.6–2.8;P2 三模块不计入 P1 Complete。
|
||||
|
||||
### 第二阶段 `[P2]` — **合规全量探索进行中**
|
||||
### 第二阶段 `[P2]` — **P2 Complete**
|
||||
|
||||
星象性格 · 身心节律 · 意象卡片 · 节气陪伴深化 · 心情记录 · 成长计划 · 探索三级目录 · 量表矩阵
|
||||
|
||||
设计真源:`feature-spec/star-profile.md` · `life-rhythm.md` · `image-card.md` · [P2-BACKLOG.md](feature-spec/P2-BACKLOG.md)
|
||||
状态真源:[p2-status.md](p2-status.md) · Spec:`feature-spec/star-profile.md` · `life-rhythm.md` · `image-card.md` · [P2-BACKLOG.md](feature-spec/P2-BACKLOG.md)
|
||||
排除不变:消息/达人/UGC 广场/运势 Feed;真支付部署阶段再做。
|
||||
|
||||
### 第三阶段 `[P3]`
|
||||
@@ -235,7 +235,8 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
||||
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Implemented** · Spec `ops-analytics.md`) |
|
||||
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Implemented** · Spec `ops-content.md`) |
|
||||
| Phase D `[Ops]` | 商业加深:订单筛选 · 展示价 · 退款只读(**ECR-009** · Spec `ops-commerce.md`) |
|
||||
| Phase E+ | RBAC · 封禁 · 推送(各开独立 ECR) |
|
||||
| Phase E `[Ops]` | 系统:RBAC · 封禁 · 推送占位(**ECR-010** · Spec `ops-system.md`) |
|
||||
| Phase F+ | 封禁细策略 · 真推送通道 · 更细权限矩阵(各开独立 ECR) |
|
||||
|
||||
不计入 P1 Complete;不进入五 Tab。
|
||||
|
||||
@@ -260,8 +261,8 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
||||
| `/star` | 星座 | P2 |
|
||||
| `/synastry` | 合盘 | P2 |
|
||||
| `/synastry/invite/:token` | 合盘邀请 | P2 |
|
||||
| `/rhythm` | 身心节律 | P2 draft |
|
||||
| `/cards` | 意象卡片 | P2 draft |
|
||||
| `/rhythm` | 身心节律 | P2 |
|
||||
| `/cards` | 意象卡片 | P2 |
|
||||
|
||||
兼容重定向:`/decode` → `/portrait`(仅技术兼容,UI 不出现 decode 文案)。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# P2 Backlog — 探索三模块 + 合规全量目录
|
||||
|
||||
**状态:** 合规全量探索进行中(Wave 0–4)
|
||||
**状态:`P2 Complete`(见 [../p2-status.md](../p2-status.md))**
|
||||
**命名决策:** 星座 / 身心节律 / 意象卡片;UI 无「塔罗」主入口、无「算命/占卜」恐吓
|
||||
**契约:** [../lexicon.md](../lexicon.md) · [../feature-map.md](../feature-map.md) · [../STRATEGY.md](../STRATEGY.md)
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
| 顺序 | 模块 | Spec | 路由 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| 0 | 探索目录 | — | `/explore` · `/explore/:category` | Done |
|
||||
| 1 | 星座 | [star-profile.md](star-profile.md) | `/star` | Done(星盘/运势/合盘对齐中) |
|
||||
| 2 | 身心节律 | [life-rhythm.md](life-rhythm.md) | `/rhythm` | Done(今日/本周) |
|
||||
| 1 | 星座 | [star-profile.md](star-profile.md) | `/star` · `/synastry` | Done |
|
||||
| 2 | 身心节律 | [life-rhythm.md](life-rhythm.md) | `/rhythm` | Done |
|
||||
| 3 | 意象卡片 | [image-card.md](image-card.md) | `/cards` | Done(≥78 牌 · ≥8 场景) |
|
||||
| 4 | 量表矩阵 | [explore-test.md](explore-test.md) | `/scales/*` | Done(≥8) |
|
||||
| 5 | 成长计划 | companion | `/growth-plan` | Done |
|
||||
| 5 | 成长计划 | companion / growth | `/growth-plan` | Done |
|
||||
| 6 | 心情轨迹 | companion | `/companion` | Done |
|
||||
|
||||
---
|
||||
@@ -28,4 +28,5 @@
|
||||
|---|---|
|
||||
| 设计三 Spec | Done |
|
||||
| 探索 catalog API | Done |
|
||||
| 真支付 | 部署阶段 |
|
||||
| L0–L3 验证 | Done(见 p2-status) |
|
||||
| 真支付 | 部署阶段(不挡 P2 Complete) |
|
||||
|
||||
@@ -15,19 +15,21 @@
|
||||
| [membership.md](membership.md) | 深度版 / 成长会员 | §6 · §5.3 | `/membership` | P1 |
|
||||
| [reports.md](reports.md) | 成长报告列表/详情 | §2.1.2 · §5.2 | `/reports` · `/reports/:id` | P1 |
|
||||
| [share.md](share.md) | 分享卡 | journey 双引擎 | `/share` | P1 |
|
||||
| [companion.md](companion.md) | 陪伴(节气壳) | §4 | `/companion` | P1 壳 / P2 |
|
||||
| [companion.md](companion.md) | 陪伴(节气+心情) | §4 | `/companion` | P2 Complete |
|
||||
| [analytics.md](analytics.md) | 增长埋点最小集 | 横切 | H5 `track` | P1 |
|
||||
| [star-profile.md](star-profile.md) | 星象性格 | §2.6 | `/star` | P2 设计 |
|
||||
| [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 设计 |
|
||||
| [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 设计 |
|
||||
| [star-profile.md](star-profile.md) | 星象性格 | §2.6 | `/star` | P2 Complete |
|
||||
| [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 Complete |
|
||||
| [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 Complete |
|
||||
| [ops-admin.md](ops-admin.md) | 运营后台 | §7 | `admin-h5` `/` `/users/:id` … | Ops-A |
|
||||
| [ops-analytics.md](ops-analytics.md) | 运营行为分析(埋点+数据看板) | §7 | `admin-h5` `/analytics` · H5 track | Ops-B · ECR-007 Implemented |
|
||||
| [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 Implemented |
|
||||
| [ops-commerce.md](ops-commerce.md) | 运营商业加深(订单筛选+展示价+退款只读) | §7 | `admin-h5` `/orders` `/pricing` | Ops-D · ECR-009 |
|
||||
| [ops-system.md](ops-system.md) | 运营系统(RBAC+封禁+推送占位) | §7 | `admin-h5` `/push` `/admins` | Ops-E · ECR-010 |
|
||||
|
||||
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
|
||||
|
||||
**P1 全量流程走查:** [P1-PROCESS-REVIEW.md](P1-PROCESS-REVIEW.md)
|
||||
**P2 三模块队列(设计立项 / 编码另批):** [P2-BACKLOG.md](P2-BACKLOG.md)
|
||||
**P2 收口状态:** [../p2-status.md](../p2-status.md) · [P2-BACKLOG.md](P2-BACKLOG.md)
|
||||
|
||||
|
||||
**竞品逆向(测测前端全量):** [cece-frontend-re/](cece-frontend-re/README.md) · **完整设计包:** [cece-frontend-re/complete-design/](cece-frontend-re/complete-design/README.md) · 方法见 [../../design/reverse-engineering-spec.md](../../design/reverse-engineering-spec.md)
|
||||
|
||||
@@ -126,6 +126,7 @@ Device 绑定到账号 User
|
||||
| 新设备 | 未登录 POST portrait | 401 |
|
||||
| 注册成功 | 建档 | 200 且生成报告包 |
|
||||
| 登录另一设备 | list profiles | 见同一账号数据 |
|
||||
| 已登录设备 | POST logout 后无 Bearer 再 GET /auth/me | 401(设备已解绑,刷新不再显示登录态) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Feature Spec: 陪伴(节气壳)
|
||||
|
||||
> Status: `Active`(壳) · Map: `§4 陪伴 [P2 主做;P1 可占位]` · Phase: `P1 Shell / P2`
|
||||
> Status: `Active` · Map: `§4 陪伴 [P2]` · Phase: `P2 Complete`
|
||||
> 规范:[../feature-design.md](../feature-design.md)
|
||||
|
||||
---
|
||||
@@ -142,6 +142,6 @@ Tab 陪伴 → /companion
|
||||
|---|---|
|
||||
| Packages | `CompanionPage` · `internal/companion` · moods 表 |
|
||||
| 实现 | GET `/solar-terms/today` · POST `/moods` · GET `/moods/today` · H5 心情 UI |
|
||||
| Gaps | 成长计划、推送、心情趋势 |
|
||||
| Label | 陪伴 P2 心情已接;真支付仍后置 |
|
||||
| Gaps | 推送(后置 / Ops-E) |
|
||||
| Label | Feature Complete(P2:节气 + 心情 + 近七日轨迹;成长计划见 `/growth-plan`) |
|
||||
| Process Review | 2026-08-02 [P1-PROCESS-REVIEW](P1-PROCESS-REVIEW.md) · 心情已按产品「其他先做」落地 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Feature Spec: 意象卡片
|
||||
|
||||
> Status: `Active`(设计)· Map: `2.8 意象卡片 [P2]` · Phase: `P2`
|
||||
> Status: `Active` · Map: `2.8 意象卡片 [P2]` · Phase: `P2 Complete`
|
||||
> 规范:[../feature-design.md](../feature-design.md) · 队列:[P2-BACKLOG.md](P2-BACKLOG.md)
|
||||
> 竞品对照:塔罗体系(能力同构;**UI 禁止「塔罗」入口名**)
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
|
||||
- [x] Spec 齐全 · 禁用塔罗入口已写清
|
||||
- [x] lexicon ImageCard
|
||||
- [ ] 内容库 seed 与 OpenAPI(编码时)
|
||||
- [x] 内容库 seed 与 OpenAPI(编码时)
|
||||
|
||||
---
|
||||
|
||||
@@ -181,5 +181,6 @@
|
||||
| 复用 | ShareSheet · membership/orders · Explore 入口 |
|
||||
| 工作量 | 卡面文案/视觉资产最大;宜先 22~78 张小库 MVP |
|
||||
| Packages | `internal/imagecard` · `ImageCardPage` `/cards` |
|
||||
| 实现 | scenes/quota/draw API · 报告 type=`image_card` · 日配额表 · seed 12 卡 |
|
||||
| 编码顺序 | P2-BACKLOG 切片 3 Done |
|
||||
| 实现 | scenes/quota/draw API · 报告 type=`image_card` · 日配额 · ≥78 牌(手写底稿+确定性扩容)· ≥8 场景 |
|
||||
| Gaps | 全手写美术资产;推送 |
|
||||
| Label | Feature Complete(P2) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Feature Spec: 身心节律
|
||||
|
||||
> Status: `Active`(设计)· Map: `2.7 身心节律 [P2]`(= `2.5 身心探索` 产品化主入口)· Phase: `P2`
|
||||
> Status: `Active` · Map: `2.7 身心节律 [P2]`(= `2.5 身心探索` 产品化主入口)· Phase: `P2 Complete`
|
||||
> 规范:[../feature-design.md](../feature-design.md) · 队列:[P2-BACKLOG.md](P2-BACKLOG.md)
|
||||
> 竞品对照:命理体系之生活向(能力同构,禁止算命/流年吉凶叙事)
|
||||
|
||||
@@ -161,7 +161,7 @@ CTA:节气陪伴 / 问答「生活节奏」/ 分享
|
||||
|
||||
- [x] Spec 齐全 · 与 2.5 关系已写清
|
||||
- [x] lexicon LifeRhythm
|
||||
- [ ] OpenAPI/erd(编码时)
|
||||
- [x] OpenAPI/erd(编码时)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
| 用户列表 / 详情(档案摘要) | 首页运营位 / 工具配置 CMS |
|
||||
| 订单列表 | 优惠券 · 退款工单流 |
|
||||
| 成长会员只读 + **授予/延长**(mock 履约) | 真支付渠道配置 |
|
||||
| 操作审计日志 | 细粒度 RBAC(多角色矩阵) |
|
||||
| 操作审计日志 | 真支付渠道配置 |
|
||||
| `apps/admin-h5` 桌面友好壳 | 小程序后台 |
|
||||
|
||||
> Phase E(ECR-010):RBAC / 封禁 / 推送占位见 [ops-system.md](ops-system.md)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户价值
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Feature Spec: 运营系统(Ops-E)
|
||||
|
||||
> Status: `Active` · Map: `§7 Phase E` · Phase: `Ops-E` · ECR: `ECR-010`
|
||||
> 规范:[../feature-design.md](../feature-design.md)
|
||||
> 上游:Ops-A~D(ECR-006~009)
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能定义
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| Name | 运营系统 Ops-E |
|
||||
| Purpose | 两角色 RBAC、C 端用户封禁、推送任务占位,支撑安全运维 |
|
||||
| Business Goal | 限制写权限;可处置违规账号;预留推送运营入口(不下发) |
|
||||
|
||||
| In | Out |
|
||||
|---|---|
|
||||
| `admin_accounts.role`:`super` \| `ops` | 细粒度权限矩阵 / 动态权限表 |
|
||||
| 用户封禁 / 解封(`users.status` active↔banned) | 设备级 / IP 封禁 |
|
||||
| `push_jobs` 草稿 CRUD(draft / cancelled) | 真推送通道 · Worker · 站内信 |
|
||||
| Admin API 动作级 403 + admin-h5 导航裁剪 | UGC · 真支付 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户价值
|
||||
|
||||
1. **为何需要:** 全员等同 Admin 风险高;无封禁只能改库。
|
||||
2. **完成后获得:** ops 可查可封可建推送草稿;写会员/定价/内容仅 super。
|
||||
3. **为何付费:** N/A(内部工具)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户角色
|
||||
|
||||
| Actor | 能力 |
|
||||
|---|---|
|
||||
| Admin `super` | 全部读写;Admin 账号列表与改角色 |
|
||||
| Admin `ops` | 读全模块;封禁/解封;推送草稿;**不可**授予会员、改价、内容写、管 Admin |
|
||||
| C 端 User `banned` | DeviceAuth / 会话拒绝(40310) |
|
||||
| Guest | 无后台入口 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户流程
|
||||
|
||||
```text
|
||||
Admin 登录 → Me 含 role
|
||||
↓
|
||||
ops:导航隐藏「定价写 / 内容写入口仍可见只读?按表:内容写否」
|
||||
→ 用户详情 → 封禁 → C 端下次请求 40310
|
||||
→ 推送页 → 新建 draft → 可 cancelled
|
||||
super:另开「管理员」改 role
|
||||
失败:无权限 403;未登录 401
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面设计
|
||||
|
||||
| 路由 | 页面 | 说明 |
|
||||
|---|---|---|
|
||||
| `/users/:id` | UserDetailPage | 封禁 / 解封按钮 |
|
||||
| `/push` | PushJobsPage | 列表 + 新建草稿 |
|
||||
| `/admins` | AdminsPage | 仅 super:列表改角色 |
|
||||
|
||||
```text
|
||||
AdminShell Nav += 推送 | 管理员(super)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 状态
|
||||
|
||||
| 态 | 要求 |
|
||||
|---|---|
|
||||
| Loading | 列表/详情请求中 |
|
||||
| Empty | 无推送任务 / 无管理员(不应发生) |
|
||||
| Error | 403 提示无权限;封禁失败可感知 |
|
||||
| Normal | 状态与角色展示正确 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 业务规则
|
||||
|
||||
1. Bootstrap / 存量 admin 默认 `super`。
|
||||
2. `ops` 调用 grant / PUT plan-prices / 内容写 → HTTP 403。
|
||||
3. 封禁写 `users.status='banned'` + audit `user.ban`;解封 `active` + `user.unban`。
|
||||
4. 被封用户:Bearer 会话与 DeviceAuth 解析后均拒绝(40310);可撤销 sessions。
|
||||
5. 推送仅 `draft`/`cancelled`;创建不下发。
|
||||
6. 仅 `super` 可 `PATCH` admin role;**禁止**降级最后一个 `super`(HTTP 409 / code 40901)。
|
||||
7. 推送标题非空且 ≤128 字符(rune),否则 400。
|
||||
|
||||
---
|
||||
|
||||
## 8. 数据
|
||||
|
||||
- `admin_accounts.role` varchar CHECK (`super`,`ops`) DEFAULT `super`
|
||||
- `users.status` CHECK (`active`,`banned`)(存量补齐)
|
||||
- `push_jobs(id, title, body, audience, status, created_by, created_at, updated_at)`
|
||||
|
||||
---
|
||||
|
||||
## 9. API
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/admin/me` | 响应含 `role` |
|
||||
| POST | `/api/v1/admin/users/{id}/ban` | super+ops |
|
||||
| POST | `/api/v1/admin/users/{id}/unban` | super+ops |
|
||||
| GET | `/api/v1/admin/admins` | super |
|
||||
| PATCH | `/api/v1/admin/admins/{id}` | super · body `{role}` |
|
||||
| GET | `/api/v1/admin/push-jobs` | super+ops |
|
||||
| POST | `/api/v1/admin/push-jobs` | super+ops · draft |
|
||||
| PATCH | `/api/v1/admin/push-jobs/{id}` | 改文案或 cancelled |
|
||||
|
||||
既有写接口对 `ops` 返回 403。
|
||||
|
||||
---
|
||||
|
||||
## 10. 埋点
|
||||
|
||||
N/A(内部);审计日志覆盖封禁与角色变更。
|
||||
|
||||
---
|
||||
|
||||
## 11. 事件
|
||||
|
||||
| Audit action | 何时 |
|
||||
|---|---|
|
||||
| `user.ban` / `user.unban` | 封禁变更 |
|
||||
| `admin.role_update` | 改角色 |
|
||||
| `push_job.create` / `push_job.update` | 推送占位 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 验收
|
||||
|
||||
**Given** ops 账号
|
||||
**When** PUT plan-prices 或 grant membership
|
||||
**Then** 403
|
||||
|
||||
**Given** 用户被 ban
|
||||
**When** C 端带该 user 的 DeviceAuth 请求
|
||||
**Then** 40310
|
||||
|
||||
**Given** 唯一 super
|
||||
**When** PATCH role=ops
|
||||
**Then** 40901,角色不变
|
||||
|
||||
**Given** push title 超 128 字
|
||||
**When** POST push-jobs
|
||||
**Then** 40055
|
||||
|
||||
---
|
||||
|
||||
## 13. AI 开发前检查
|
||||
|
||||
- [x] Spec §4/7/9/12
|
||||
- [x] ECR-010 · BD-2026-010
|
||||
|
||||
---
|
||||
|
||||
## 14. Implementation Notes
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| Packages | `middleware` · `service/admin` · `AdminRepo` · admin-h5 |
|
||||
| Migration | `000016_ops_system` |
|
||||
| Label | Feature Complete(Ops-E MVP) |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Feature Spec: 星座
|
||||
|
||||
> Status: `Active` · Map: `2.6 星座 [P2]` · Phase: `P2`
|
||||
> Status: `Active` · Map: `2.6 星座 [P2]` · Phase: `P2 Complete`
|
||||
> 规范:[../feature-design.md](../feature-design.md) · 队列:[P2-BACKLOG.md](P2-BACKLOG.md)
|
||||
> 竞品对照:测测星座体系(本命排盘 · 运势大全 · 连线合盘)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ P1 必做(feature-map):用户档案 · 性格探索/个人画像 · 人格
|
||||
| 探索测试草稿恢复 | `scaleDraft` localStorage |
|
||||
| L0–L3 | 全绿(收口时) |
|
||||
|
||||
P2 设计队列:[`feature-spec/P2-BACKLOG.md`](feature-spec/P2-BACKLOG.md)
|
||||
P2 收口:[`p2-status.md`](p2-status.md)(**P2 Complete**)· [`feature-spec/P2-BACKLOG.md`](feature-spec/P2-BACKLOG.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# P2 Status(对照 Definition of Done)
|
||||
|
||||
**当前标签:`P2 Complete`**
|
||||
|
||||
判定依据:`.ai/definition-of-done.md` §9
|
||||
P2 范围(feature-map):星座 · 身心节律 · 意象卡片 · 探索三级目录 · 量表矩阵 · 节气陪伴深化 · 心情记录 · 成长计划
|
||||
|
||||
**不含:** 真支付网关 · 消息/达人/UGC · 独立运势 Feed · 推送 · P3 长期记忆/顾问
|
||||
|
||||
---
|
||||
|
||||
## Review Report
|
||||
|
||||
- Feature: P2 合规全量探索收口
|
||||
- Scope label: **P2 Complete**
|
||||
- Architecture: PASS — handler → service → repository;`star` / `rhythm` / `imagecard` / `companion` / `growth` 独立包
|
||||
- API: PASS — `proto/openapi.yaml` 覆盖 star / synastry / rhythm / image-cards / explore catalog / moods / growth plans
|
||||
- Security: PASS — report detail 服务端按 DeepAccess 裁剪;配额与归属校验在服务端
|
||||
- Test (L0/L1/L2/L3): PASS
|
||||
- L0: `go test ./...` · `npm run build:h5` · `npm run test:h5`
|
||||
- L1: star / synastry / rhythm / imagecard / explore
|
||||
- L2: `internal/integration` — p2_flows · synastry · explore_catalog · growth checkin
|
||||
- L3: Playwright `e2e/p2-explore-paths.spec.ts` + `e2e/portrait-main-path.spec.ts`
|
||||
- Frontend states (L/E/E/N): PASS — 主路径页具备加载/空/错/正常;深度版 Locked
|
||||
- Documentation: PASS — Spec Active + P2-BACKLOG Done + 本文件
|
||||
- Environment: PASS — `commands.md` 本机 Go/Vite + compose.dev 仅 DB
|
||||
- Known Issues:
|
||||
- 真支付仍为 mock(部署阶段)
|
||||
- 意象卡库:手写底稿 + 确定性扩至 ≥78(非全手写美术)
|
||||
- 推送未做(Ops-E / 后置)
|
||||
- Ask 无 key 时规则引擎降级
|
||||
|
||||
---
|
||||
|
||||
## 本轮收口变更
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| 生日表单生成误用 `getLatestReport` | 已改回 `createPortrait` / `createStar` / `createRhythm` |
|
||||
| H5 单测 auth 门禁 mock | PASS(38) |
|
||||
| L3 e2e auth/profiles mock | PASS(2) |
|
||||
| 状态真源对齐 | P2-BACKLOG · feature-map · Spec 头 · DoD §9 |
|
||||
|
||||
---
|
||||
|
||||
## 验收路径
|
||||
|
||||
探索目录 → `/star`(星盘/运势/合盘)→ `/rhythm` → `/cards` → `/companion`(心情+近七日)→ `/growth-plan` → 探索量表
|
||||
|
||||
```bash
|
||||
npm run test:api
|
||||
npm run test:api:integration
|
||||
npm run test:h5
|
||||
npm run build:h5 && npm run test:e2e
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"ecr": "ECR-010",
|
||||
"result": "PASS",
|
||||
"ess_version": "v1.0",
|
||||
"gate_version": "0.1.2",
|
||||
"project_profile": "2026.08",
|
||||
"checks": {
|
||||
"artifact": true,
|
||||
"role_boundary": true,
|
||||
"backend_boundary": true,
|
||||
"traceability": true,
|
||||
"tests": true
|
||||
},
|
||||
"violations": [],
|
||||
"errors": [],
|
||||
"warnings": [
|
||||
"Code changed but CHANGELOG.md not modified \u2014 required for L2 Done"
|
||||
],
|
||||
"timestamp": "2026-08-11T10:20:52Z"
|
||||
}
|
||||
@@ -94,24 +94,57 @@ export type UserDetail = {
|
||||
}>
|
||||
}
|
||||
|
||||
export type AdminRole = 'super' | 'ops'
|
||||
|
||||
export type AdminMe = { id: string; username: string; role: AdminRole }
|
||||
|
||||
export type PushJob = {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
audience: string
|
||||
status: 'draft' | 'cancelled'
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type AdminListItem = {
|
||||
id: string
|
||||
username: string
|
||||
role: AdminRole
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ token: string; admin: { id: string; username: string } }>('POST', '/auth/login', {
|
||||
request<{ token: string; admin: AdminMe }>('POST', '/auth/login', {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
|
||||
me: () => request<{ id: string; username: string }>('GET', '/me'),
|
||||
me: () => request<AdminMe>('GET', '/me'),
|
||||
stats: () => request<DashboardStats>('GET', '/stats'),
|
||||
users: (q = '') =>
|
||||
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
|
||||
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`),
|
||||
grant: (id: string, plan: string) =>
|
||||
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
|
||||
grantAskQuota: (id: string, delta: number) =>
|
||||
request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, {
|
||||
delta,
|
||||
}),
|
||||
admins: () => request<{ items: AdminListItem[] }>('GET', '/admins'),
|
||||
patchAdminRole: (id: string, role: AdminRole) =>
|
||||
request<{ ok: boolean }>('PATCH', `/admins/${id}`, { role }),
|
||||
pushJobs: () => request<{ items: PushJob[] }>('GET', '/push-jobs'),
|
||||
createPushJob: (body: { title: string; body?: string; audience?: string }) =>
|
||||
request<PushJob>('POST', '/push-jobs', body),
|
||||
patchPushJob: (id: string, body: { title?: string; body?: string; status?: 'draft' | 'cancelled' }) =>
|
||||
request<PushJob>('PATCH', `/push-jobs/${id}`, body),
|
||||
orders: (params?: {
|
||||
status?: string
|
||||
kind?: string
|
||||
|
||||
@@ -29,11 +29,13 @@ async function onLogout() {
|
||||
<RouterLink to="/content">内容</RouterLink>
|
||||
<RouterLink to="/users">用户</RouterLink>
|
||||
<RouterLink to="/orders">订单</RouterLink>
|
||||
<RouterLink to="/pricing">定价</RouterLink>
|
||||
<RouterLink v-if="auth.isSuper" to="/pricing">定价</RouterLink>
|
||||
<RouterLink to="/push">推送</RouterLink>
|
||||
<RouterLink v-if="auth.isSuper" to="/admins">管理员</RouterLink>
|
||||
<RouterLink to="/audit">审计</RouterLink>
|
||||
</nav>
|
||||
<div class="foot">
|
||||
<span class="muted">{{ auth.username || '管理员' }}</span>
|
||||
<span class="muted">{{ auth.username || '管理员' }} · {{ auth.role }}</span>
|
||||
<button class="btn ghost" type="button" @click="onLogout">退出</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi, type AdminListItem, type AdminRole } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<AdminListItem[]>([])
|
||||
const msg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.admins()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setRole(id: string, role: AdminRole) {
|
||||
msg.value = ''
|
||||
try {
|
||||
await adminApi.patchAdminRole(id, role)
|
||||
msg.value = '角色已更新'
|
||||
await load()
|
||||
} catch (e) {
|
||||
msg.value = e instanceof Error ? e.message : '更新失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>管理员</h1>
|
||||
<p class="muted">仅超级管理员可访问。角色:super(全权)/ ops(只读+封禁+推送草稿)。</p>
|
||||
<p v-if="msg" class="muted">{{ msg }}</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<table v-else class="tbl">
|
||||
<thead>
|
||||
<tr><th>用户名</th><th>角色</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in items" :key="a.id">
|
||||
<td>{{ a.username }}</td>
|
||||
<td>{{ a.role }}</td>
|
||||
<td>{{ a.status }}</td>
|
||||
<td class="ops">
|
||||
<button class="btn ghost" type="button" @click="setRole(a.id, 'super')">设为 super</button>
|
||||
<button class="btn ghost" type="button" @click="setRole(a.id, 'ops')">设为 ops</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tbl { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||
.tbl th, .tbl td { text-align: left; padding: 0.55rem 0.4rem; border-bottom: 1px solid var(--line); }
|
||||
.ops { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||
.err { color: var(--accent); }
|
||||
</style>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi, type HomeToolAdmin, type ScaleAdmin } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const tab = ref<'grid' | 'scales'>('grid')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
@@ -112,7 +114,8 @@ async function toggleScale(s: ScaleAdmin) {
|
||||
<p v-if="msg" class="ok">{{ msg }}</p>
|
||||
|
||||
<template v-if="tab === 'grid' && !loading">
|
||||
<div class="toolbar">
|
||||
<p v-if="!auth.isSuper" class="muted">只读:内容写入需超级管理员</p>
|
||||
<div v-if="auth.isSuper" class="toolbar">
|
||||
<button class="btn ghost" type="button" @click="addTool">添加</button>
|
||||
<button class="btn" type="button" :disabled="saving" @click="saveTools">保存宫格</button>
|
||||
</div>
|
||||
@@ -153,9 +156,15 @@ async function toggleScale(s: ScaleAdmin) {
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn ghost" type="button" @click="toggleScale(s)">
|
||||
<button
|
||||
v-if="auth.isSuper"
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
@click="toggleScale(s)"
|
||||
>
|
||||
{{ s.status === 'published' ? '下架' : '上架' }}
|
||||
</button>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi, type PushJob } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<PushJob[]>([])
|
||||
const title = ref('')
|
||||
const body = ref('')
|
||||
const msg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.pushJobs()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
msg.value = ''
|
||||
try {
|
||||
await adminApi.createPushJob({ title: title.value.trim(), body: body.value.trim(), audience: 'all' })
|
||||
title.value = ''
|
||||
body.value = ''
|
||||
msg.value = '已创建草稿(占位,不下发)'
|
||||
await load()
|
||||
} catch (e) {
|
||||
msg.value = e instanceof Error ? e.message : '创建失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(id: string) {
|
||||
try {
|
||||
await adminApi.patchPushJob(id, { status: 'cancelled' })
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '取消失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>推送任务</h1>
|
||||
<p class="muted">占位能力:仅草稿 / 取消,不接厂商、不下发。</p>
|
||||
<div class="card block">
|
||||
<h2>新建草稿</h2>
|
||||
<input v-model="title" class="inp" placeholder="标题" maxlength="128" />
|
||||
<textarea v-model="body" class="ta" rows="3" placeholder="正文(可选)" />
|
||||
<button class="btn" type="button" :disabled="!title.trim()" @click="create">创建草稿</button>
|
||||
<span v-if="msg" class="muted">{{ msg }}</span>
|
||||
</div>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<table v-else class="tbl">
|
||||
<thead>
|
||||
<tr><th>标题</th><th>受众</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="j in items" :key="j.id">
|
||||
<td>{{ j.title }}</td>
|
||||
<td>{{ j.audience }}</td>
|
||||
<td>{{ j.status }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="j.status === 'draft'"
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
@click="cancel(j.id)"
|
||||
>取消</button>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!items.length"><td colspan="4" class="muted">暂无任务</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.block { margin: 1rem 0; display: flex; flex-direction: column; gap: 0.5rem; max-width: 480px; }
|
||||
.inp, .ta {
|
||||
border: 1px solid var(--line); border-radius: 10px; padding: 0.55rem 0.7rem;
|
||||
font: inherit; background: #fff;
|
||||
}
|
||||
.tbl { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||
.tbl th, .tbl td { text-align: left; padding: 0.55rem 0.4rem; border-bottom: 1px solid var(--line); }
|
||||
.err { color: var(--accent); }
|
||||
</style>
|
||||
@@ -2,8 +2,10 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { adminApi, type UserDetail } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const detail = ref<UserDetail | null>(null)
|
||||
@@ -11,6 +13,7 @@ const plan = ref('month')
|
||||
const askDelta = ref(10)
|
||||
const grantMsg = ref('')
|
||||
const askMsg = ref('')
|
||||
const banMsg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -46,6 +49,22 @@ async function grantAsk() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBan() {
|
||||
banMsg.value = ''
|
||||
try {
|
||||
if (detail.value?.status === 'banned') {
|
||||
await adminApi.unbanUser(String(route.params.id))
|
||||
banMsg.value = '已解封'
|
||||
} else {
|
||||
await adminApi.banUser(String(route.params.id))
|
||||
banMsg.value = '已封禁'
|
||||
}
|
||||
await load()
|
||||
} catch (e) {
|
||||
banMsg.value = e instanceof Error ? e.message : '操作失败'
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string | null) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
@@ -83,6 +102,12 @@ onMounted(load)
|
||||
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
|
||||
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
|
||||
</div>
|
||||
<div class="grant">
|
||||
<button class="btn" type="button" @click="toggleBan">
|
||||
{{ detail.status === 'banned' ? '解封' : '封禁' }}
|
||||
</button>
|
||||
<span v-if="banMsg" class="muted">{{ banMsg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
@@ -93,7 +118,7 @@ onMounted(load)
|
||||
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
|
||||
到期 {{ fmtTime(detail.membership.expires_at) }}
|
||||
</p>
|
||||
<div class="grant">
|
||||
<div v-if="auth.isSuper" class="grant">
|
||||
<select v-model="plan">
|
||||
<option value="month">月卡</option>
|
||||
<option value="quarter">季卡</option>
|
||||
@@ -102,12 +127,13 @@ onMounted(load)
|
||||
<button class="btn" type="button" @click="grant">授予 / 延长</button>
|
||||
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
|
||||
</div>
|
||||
<p v-else class="muted">仅超级管理员可授予会员</p>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>问答额度(已购)</h2>
|
||||
<p>已购余量:<strong>{{ detail.ask_paid_quota_left }}</strong> 次</p>
|
||||
<div class="grant">
|
||||
<div v-if="auth.isSuper" class="grant">
|
||||
<select v-model.number="askDelta">
|
||||
<option :value="10">+10</option>
|
||||
<option :value="30">+30</option>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getToken } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -15,16 +16,23 @@ const router = createRouter({
|
||||
{ path: 'users', name: 'users', component: () => import('@/pages/UsersPage.vue') },
|
||||
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
|
||||
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
|
||||
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue') },
|
||||
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue'), meta: { superOnly: true } },
|
||||
{ path: 'push', name: 'push', component: () => import('@/pages/PushJobsPage.vue') },
|
||||
{ path: 'admins', name: 'admins', component: () => import('@/pages/AdminsPage.vue'), meta: { superOnly: true } },
|
||||
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.meta.public) return true
|
||||
if (!getToken()) return { name: 'login', query: { redirect: to.fullPath } }
|
||||
if (to.meta.superOnly) {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.username) await auth.hydrate()
|
||||
if (!auth.isSuper) return { name: 'dashboard' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { adminApi, getToken, setToken } from '@/api/client'
|
||||
import { computed, ref } from 'vue'
|
||||
import { adminApi, getToken, setToken, type AdminRole } from '@/api/client'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(getToken())
|
||||
const username = ref<string>('')
|
||||
const role = ref<AdminRole>('ops')
|
||||
|
||||
const isSuper = computed(() => role.value === 'super')
|
||||
|
||||
async function login(user: string, password: string) {
|
||||
const res = await adminApi.login(user, password)
|
||||
setToken(res.token)
|
||||
token.value = res.token
|
||||
username.value = res.admin.username
|
||||
role.value = res.admin.role || 'super'
|
||||
}
|
||||
|
||||
async function hydrate() {
|
||||
@@ -18,6 +22,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
try {
|
||||
const me = await adminApi.me()
|
||||
username.value = me.username
|
||||
role.value = me.role || 'super'
|
||||
return true
|
||||
} catch {
|
||||
setToken(null)
|
||||
@@ -35,7 +40,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
setToken(null)
|
||||
token.value = null
|
||||
username.value = ''
|
||||
role.value = 'ops'
|
||||
}
|
||||
|
||||
return { token, username, login, logout, hydrate }
|
||||
return { token, username, role, isSuper, login, logout, hydrate }
|
||||
})
|
||||
|
||||
@@ -45,6 +45,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
||||
authed.GET("/analytics/clicks", h.AnalyticsClicks)
|
||||
authed.GET("/analytics/funnel", h.AnalyticsFunnel)
|
||||
h.registerContent(authed)
|
||||
h.registerSystem(authed)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
@@ -143,6 +144,10 @@ func (h *AdminHandler) GrantMembership(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.GrantMembership(c.Request.Context(), adminID, userID, body.Plan); err != nil {
|
||||
if errors.Is(err, admin.ErrForbidden) {
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidPlan) {
|
||||
response.Fail(c, http.StatusBadRequest, 40004, "invalid plan")
|
||||
return
|
||||
@@ -175,6 +180,10 @@ func (h *AdminHandler) GrantAskQuota(c *gin.Context) {
|
||||
}
|
||||
left, err := h.Svc.GrantAskQuota(c.Request.Context(), adminID, userID, body.Delta)
|
||||
if err != nil {
|
||||
if errors.Is(err, admin.ErrForbidden) {
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidAskDelta) {
|
||||
response.Fail(c, http.StatusBadRequest, 40006, "invalid delta")
|
||||
return
|
||||
@@ -239,6 +248,10 @@ func (h *AdminHandler) PutPlanPrices(c *gin.Context) {
|
||||
prices = append(prices, repository.PlanPrice{Plan: it.Plan, DisplayCents: it.DisplayCents})
|
||||
}
|
||||
if err := h.Svc.UpsertPlanPrices(c.Request.Context(), adminID, prices); err != nil {
|
||||
if errors.Is(err, admin.ErrForbidden) {
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40041, "upsert plan prices failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ func (h *AdminHandler) ReplaceHomeTools(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.ReplaceHomeTools(c.Request.Context(), adminID, body.Items); err != nil {
|
||||
if errors.Is(err, admin.ErrForbidden) {
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, homesvc.ErrInvalidTools) || errors.Is(err, homesvc.ErrTooManyTools) {
|
||||
response.Fail(c, http.StatusBadRequest, 40041, err.Error())
|
||||
return
|
||||
@@ -81,6 +85,10 @@ func (h *AdminHandler) PatchScale(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.PatchScaleStatus(c.Request.Context(), adminID, id, body.Status); err != nil {
|
||||
if errors.Is(err, admin.ErrForbidden) {
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidScaleStatus) {
|
||||
response.Fail(c, http.StatusBadRequest, 40044, "invalid status")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerSystem(authed *gin.RouterGroup) {
|
||||
authed.POST("/users/:id/ban", h.BanUser)
|
||||
authed.POST("/users/:id/unban", h.UnbanUser)
|
||||
authed.GET("/admins", h.ListAdmins)
|
||||
authed.PATCH("/admins/:id", h.PatchAdmin)
|
||||
authed.GET("/push-jobs", h.ListPushJobs)
|
||||
authed.POST("/push-jobs", h.CreatePushJob)
|
||||
authed.PATCH("/push-jobs/:id", h.PatchPushJob)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) BanUser(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
uid, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40050, "invalid user id")
|
||||
return
|
||||
}
|
||||
if err := h.Svc.BanUser(c.Request.Context(), adminID, uid); err != nil {
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50040, "ban failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UnbanUser(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
uid, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40050, "invalid user id")
|
||||
return
|
||||
}
|
||||
if err := h.Svc.UnbanUser(c.Request.Context(), adminID, uid); err != nil {
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50041, "unban failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAdmins(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
items, err := h.Svc.ListAdmins(c.Request.Context(), adminID)
|
||||
if err != nil {
|
||||
if errors.Is(err, admin.ErrForbidden) {
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50042, "list admins failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PatchAdmin(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
targetID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40051, "invalid admin id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Role == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 40052, "role required")
|
||||
return
|
||||
}
|
||||
if err := h.Svc.UpdateAdminRole(c.Request.Context(), adminID, targetID, body.Role); err != nil {
|
||||
if errors.Is(err, admin.ErrForbidden) {
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidRole) {
|
||||
response.Fail(c, http.StatusBadRequest, 40053, "invalid role")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrLastSuper) {
|
||||
response.Fail(c, http.StatusConflict, 40901, "cannot demote last super")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrAdminNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "admin not found")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50043, "patch admin failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListPushJobs(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
items, err := h.Svc.ListPushJobs(c.Request.Context(), limit, offset)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50044, "list push jobs failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreatePushJob(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Audience string `json:"audience"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Title == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 40054, "title required")
|
||||
return
|
||||
}
|
||||
job, err := h.Svc.CreatePushJob(c.Request.Context(), adminID, body.Title, body.Body, body.Audience)
|
||||
if err != nil {
|
||||
if errors.Is(err, admin.ErrInvalidPush) {
|
||||
response.Fail(c, http.StatusBadRequest, 40055, "invalid push job")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50045, "create push job failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, job)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PatchPushJob(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
jobID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40056, "invalid job id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title *string `json:"title"`
|
||||
Body *string `json:"body"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40057, "invalid body")
|
||||
return
|
||||
}
|
||||
job, err := h.Svc.UpdatePushJob(c.Request.Context(), adminID, jobID, body.Title, body.Body, body.Status)
|
||||
if err != nil {
|
||||
if errors.Is(err, admin.ErrPushNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40430, "push job not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidPush) {
|
||||
response.Fail(c, http.StatusBadRequest, 40058, "invalid push job")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50046, "patch push job failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, job)
|
||||
}
|
||||
@@ -77,7 +77,8 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
// Logout handles POST /auth/logout.
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
tok := bearerToken(c)
|
||||
_ = h.Svc.Logout(c.Request.Context(), tok)
|
||||
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
|
||||
_ = h.Svc.Logout(c.Request.Context(), tok, deviceKey)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Logout must revoke session AND unbind device so refresh is not still "logged in".
|
||||
func TestAuthLogoutUnbindsDevice(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
key := mustRegister(t, r)
|
||||
|
||||
_, key = doJSON(t, r, http.MethodGet, "/api/v1/auth/me", nil, key)
|
||||
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/auth/logout", nil, key)
|
||||
testBearer = ""
|
||||
|
||||
_, _, status := doJSONExpect(t, r, http.MethodGet, "/api/v1/auth/me", nil, key, 40112)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("after logout GET /auth/me want HTTP 401, got %d", status)
|
||||
}
|
||||
|
||||
_, key, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/profiles", nil, key, 40112)
|
||||
_ = key
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/db"
|
||||
)
|
||||
|
||||
func TestOpsESystemRBACBanPush(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
|
||||
env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
||||
"username": "admin",
|
||||
"password": "change-me",
|
||||
}, "")
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("super login failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var login struct {
|
||||
Token string `json:"token"`
|
||||
Admin struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
} `json:"admin"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" {
|
||||
t.Fatalf("login parse: %v %s", err, env.Data)
|
||||
}
|
||||
if login.Admin.Role != "super" {
|
||||
t.Fatalf("bootstrap admin role want super got %q", login.Admin.Role)
|
||||
}
|
||||
superTok := login.Token
|
||||
|
||||
opsUser := "ops_" + uuid.NewString()[:8]
|
||||
opsPass := "ops-pass-1"
|
||||
insertOpsAdmin(t, opsUser, opsPass)
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
||||
"username": opsUser,
|
||||
"password": opsPass,
|
||||
}, "")
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("ops login failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &login)
|
||||
opsTok := login.Token
|
||||
if login.Admin.Role != "ops" {
|
||||
t.Fatalf("ops role want ops got %q", login.Admin.Role)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/membership/plan-prices", map[string]any{
|
||||
"items": []map[string]any{{"plan": "month", "display_cents": 990}},
|
||||
}, opsTok)
|
||||
if code != http.StatusForbidden || env.Code != 40301 {
|
||||
t.Fatalf("ops put prices want 40301, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/admins", nil, opsTok)
|
||||
if code != http.StatusForbidden || env.Code != 40301 {
|
||||
t.Fatalf("ops list admins want 40301, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
key := mustRegister(t, r)
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, opsTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("ops list users failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &list); err != nil || len(list.Items) == 0 {
|
||||
t.Fatalf("users: %v %s", err, env.Data)
|
||||
}
|
||||
userID := list.Items[0].ID
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/membership/grant", map[string]string{
|
||||
"plan": "month",
|
||||
}, opsTok)
|
||||
if code != http.StatusForbidden || env.Code != 40301 {
|
||||
t.Fatalf("ops grant want 40301, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/ban", nil, opsTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("ban failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
|
||||
env, _, httpStatus := doJSONExpect(t, r, http.MethodGet, "/api/v1/profiles", nil, key, 40310)
|
||||
if httpStatus != http.StatusForbidden {
|
||||
t.Fatalf("banned device want HTTP 403, got %d code=%d", httpStatus, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/unban", nil, opsTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("unban failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
_, key = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, key)
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/push-jobs", map[string]string{
|
||||
"title": "测试推送",
|
||||
"body": "不下发",
|
||||
}, opsTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("create push failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var job struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &job); err != nil || job.ID == "" || job.Status != "draft" {
|
||||
t.Fatalf("push job: %v %s", err, env.Data)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/push-jobs/"+job.ID, map[string]string{
|
||||
"status": "cancelled",
|
||||
}, opsTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("cancel push failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/push-jobs", map[string]string{
|
||||
"title": strings.Repeat("超", 129),
|
||||
"body": "too long",
|
||||
}, opsTok)
|
||||
if code != http.StatusBadRequest || env.Code != 40055 {
|
||||
t.Fatalf("long title want 40055, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/admins", nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list admins as super failed")
|
||||
}
|
||||
var admins struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &admins); err != nil {
|
||||
t.Fatalf("admins parse: %v", err)
|
||||
}
|
||||
var soleSuper string
|
||||
superCount := 0
|
||||
for _, a := range admins.Items {
|
||||
if a.Role == "super" {
|
||||
superCount++
|
||||
soleSuper = a.ID
|
||||
}
|
||||
}
|
||||
if superCount != 1 || soleSuper == "" {
|
||||
t.Fatalf("want exactly 1 super, got %d", superCount)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/admins/"+soleSuper, map[string]string{
|
||||
"role": "ops",
|
||||
}, superTok)
|
||||
if code != http.StatusConflict || env.Code != 40901 {
|
||||
t.Fatalf("demote last super want 40901, got http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("super me failed")
|
||||
}
|
||||
_ = env
|
||||
}
|
||||
|
||||
func insertOpsAdmin(t *testing.T, username, password string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cfg := config.Load()
|
||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role, status)
|
||||
VALUES ($1,$2,'ops','active')`, username, string(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("insert ops admin: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,15 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, tok,
|
||||
).Scan(&uid)
|
||||
if err == nil {
|
||||
if banned, berr := userIsBanned(c.Request.Context(), pool, uid); berr != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50001, "identity unavailable")
|
||||
c.Abort()
|
||||
return
|
||||
} else if banned {
|
||||
response.Fail(c, http.StatusForbidden, 40310, "账号已封禁")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
_, _ = pool.Exec(c.Request.Context(), `
|
||||
INSERT INTO device_identities(device_key, user_id)
|
||||
VALUES ($1,$2)
|
||||
@@ -56,6 +65,15 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if banned, berr := userIsBanned(c.Request.Context(), pool, userID); berr != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50001, "identity unavailable")
|
||||
c.Abort()
|
||||
return
|
||||
} else if banned {
|
||||
response.Fail(c, http.StatusForbidden, 40310, "账号已封禁")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(string(UserIDKey), userID.String())
|
||||
c.Header(DeviceKeyHeader, key)
|
||||
c.Next()
|
||||
@@ -149,3 +167,16 @@ func newDeviceKey() string {
|
||||
_, _ = rand.Read(b)
|
||||
return "dev_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func userIsBanned(ctx context.Context, pool *pgxpool.Pool, userID uuid.UUID) (bool, error) {
|
||||
var status string
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID).Scan(&status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return status == "banned", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAdminNotFound is returned when an admin account row is missing.
|
||||
ErrAdminNotFound = errors.New("admin not found")
|
||||
// ErrPushJobNotFound is returned when a push_jobs row is missing.
|
||||
ErrPushJobNotFound = errors.New("push job not found")
|
||||
// ErrUserStatusNotFound is returned when users row is missing for status update.
|
||||
ErrUserStatusNotFound = errors.New("user not found")
|
||||
)
|
||||
|
||||
// AdminListItem is a public admin row (no password).
|
||||
type AdminListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PushJob is a push campaign stub (never dispatched).
|
||||
type PushJob struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Audience string `json:"audience"`
|
||||
Status string `json:"status"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SetUserStatusWithAudit updates users.status and writes audit.
|
||||
func (r *AdminRepo) SetUserStatusWithAudit(
|
||||
ctx context.Context,
|
||||
adminID, userID uuid.UUID,
|
||||
status, action string,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE users SET status=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, userID, status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrUserStatusNotFound
|
||||
}
|
||||
if status == "banned" {
|
||||
_, _ = tx.Exec(ctx, `
|
||||
UPDATE user_sessions SET revoked_at=now()
|
||||
WHERE user_id=$1 AND revoked_at IS NULL`, userID)
|
||||
}
|
||||
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,$2,'user',$3,$4)`, adminID, action, userID.String(), meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ListAdmins returns non-deleted admin accounts.
|
||||
func (r *AdminRepo) ListAdmins(ctx context.Context) ([]AdminListItem, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, username, role, status, created_at
|
||||
FROM admin_accounts
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminListItem
|
||||
for rows.Next() {
|
||||
var a AdminListItem
|
||||
if err := rows.Scan(&a.ID, &a.Username, &a.Role, &a.Status, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountAdminsByRole counts non-deleted admins with the given role.
|
||||
func (r *AdminRepo) CountAdminsByRole(ctx context.Context, role string) (int, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM admin_accounts
|
||||
WHERE deleted_at IS NULL AND role=$1`, role).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// UpdateAdminRoleWithAudit sets role for an admin account.
|
||||
func (r *AdminRepo) UpdateAdminRoleWithAudit(
|
||||
ctx context.Context,
|
||||
actorID, targetID uuid.UUID,
|
||||
role string,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE admin_accounts SET role=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, targetID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrAdminNotFound
|
||||
}
|
||||
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,'admin.role_update','admin',$2,$3)`, actorID, targetID.String(), meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ListPushJobs returns newest push stubs.
|
||||
func (r *AdminRepo) ListPushJobs(ctx context.Context, limit, offset int) ([]PushJob, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, title, body, audience, status, created_by, created_at, updated_at
|
||||
FROM push_jobs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PushJob
|
||||
for rows.Next() {
|
||||
var j PushJob
|
||||
if err := rows.Scan(
|
||||
&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreatePushJobWithAudit inserts a draft push job.
|
||||
func (r *AdminRepo) CreatePushJobWithAudit(
|
||||
ctx context.Context,
|
||||
adminID uuid.UUID,
|
||||
title, body, audience string,
|
||||
meta json.RawMessage,
|
||||
) (*PushJob, error) {
|
||||
if audience == "" {
|
||||
audience = "all"
|
||||
}
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var j PushJob
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO push_jobs(title, body, audience, status, created_by)
|
||||
VALUES ($1,$2,$3,'draft',$4)
|
||||
RETURNING id, title, body, audience, status, created_by, created_at, updated_at`,
|
||||
title, body, audience, adminID,
|
||||
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, 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,'push_job.create','push_job',$2,$3)`, adminID, j.ID.String(), meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
// UpdatePushJobWithAudit updates title/body/status (draft|cancelled only).
|
||||
func (r *AdminRepo) UpdatePushJobWithAudit(
|
||||
ctx context.Context,
|
||||
adminID, jobID uuid.UUID,
|
||||
title, body, status *string,
|
||||
meta json.RawMessage,
|
||||
) (*PushJob, error) {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var j PushJob
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, title, body, audience, status, created_by, created_at, updated_at
|
||||
FROM push_jobs WHERE id=$1`, jobID,
|
||||
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrPushJobNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if title != nil {
|
||||
j.Title = *title
|
||||
}
|
||||
if body != nil {
|
||||
j.Body = *body
|
||||
}
|
||||
if status != nil {
|
||||
j.Status = *status
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE push_jobs SET title=$2, body=$3, status=$4, updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING id, title, body, audience, status, created_by, created_at, updated_at`,
|
||||
jobID, j.Title, j.Body, j.Status,
|
||||
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, 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,'push_job.update','push_job',$2,$3)`, adminID, jobID.String(), meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
// UserStatus returns users.status or empty if missing.
|
||||
func (r *AdminRepo) UserStatus(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
var status string
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID).Scan(&status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", errors.New("user not found")
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type AdminAccount struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
Status string
|
||||
Role string
|
||||
}
|
||||
|
||||
// CountAccounts returns non-deleted admin count.
|
||||
@@ -33,12 +34,12 @@ func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CreateAccount inserts an admin account.
|
||||
// CreateAccount inserts an admin account (role defaults to super).
|
||||
func (r *AdminRepo) CreateAccount(ctx context.Context, username, hash string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash)
|
||||
VALUES ($1,$2) RETURNING id`, username, hash).Scan(&id)
|
||||
INSERT INTO admin_accounts(username, password_hash, role)
|
||||
VALUES ($1,$2,'super') RETURNING id`, username, hash).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
@@ -46,10 +47,10 @@ func (r *AdminRepo) CreateAccount(ctx context.Context, username, hash string) (u
|
||||
func (r *AdminRepo) FindByUsername(ctx context.Context, username string) (*AdminAccount, error) {
|
||||
var a AdminAccount
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_hash, status
|
||||
SELECT id, username, password_hash, status, role
|
||||
FROM admin_accounts
|
||||
WHERE username=$1 AND deleted_at IS NULL`, username,
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status, &a.Role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -63,10 +64,10 @@ func (r *AdminRepo) FindByUsername(ctx context.Context, username string) (*Admin
|
||||
func (r *AdminRepo) FindAccountByID(ctx context.Context, id uuid.UUID) (*AdminAccount, error) {
|
||||
var a AdminAccount
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_hash, status
|
||||
SELECT id, username, password_hash, status, role
|
||||
FROM admin_accounts
|
||||
WHERE id=$1 AND deleted_at IS NULL`, id,
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status, &a.Role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -116,6 +116,33 @@ func (r *AuthRepo) BindDevice(ctx context.Context, deviceKey string, userID uuid
|
||||
return err
|
||||
}
|
||||
|
||||
// RebindDeviceAnonymous creates a fresh anonymous user and points the device at it.
|
||||
// Used on logout so the device is no longer tied to the registered account (Spec R5).
|
||||
func (r *AuthRepo) RebindDeviceAnonymous(ctx context.Context, deviceKey string) error {
|
||||
if deviceKey == "" {
|
||||
return nil
|
||||
}
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var uid uuid.UUID
|
||||
if err := tx.QueryRow(ctx, `INSERT INTO users DEFAULT VALUES RETURNING id`).Scan(&uid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO device_identities(device_key, user_id)
|
||||
VALUES ($1,$2)
|
||||
ON CONFLICT (device_key) DO UPDATE SET user_id=$2, updated_at=now(), deleted_at=NULL`,
|
||||
deviceKey, uid,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// CreateSession inserts a session token.
|
||||
func (r *AuthRepo) CreateSession(ctx context.Context, userID uuid.UUID, token string, expires time.Time) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
|
||||
@@ -27,6 +27,9 @@ func (s *Service) ListHomeTools(ctx context.Context) ([]repository.HomeTool, err
|
||||
|
||||
// ReplaceHomeTools replaces grid and audits in one transaction.
|
||||
func (s *Service) ReplaceHomeTools(ctx context.Context, adminID uuid.UUID, items []homesvc.ReplaceInput) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Home == nil {
|
||||
return errors.New("home unavailable")
|
||||
}
|
||||
@@ -44,6 +47,9 @@ func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminI
|
||||
|
||||
// PatchScaleStatus updates published|draft and audits in one transaction.
|
||||
func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
if status != "published" && status != "draft" {
|
||||
return ErrInvalidScaleStatus
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ type LoginResult struct {
|
||||
type AdminMe struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -93,7 +94,7 @@ func (s *Service) Login(ctx context.Context, username, password string) (*LoginR
|
||||
return &LoginResult{
|
||||
Token: token,
|
||||
ExpiresAt: exp,
|
||||
Admin: AdminMe{ID: acc.ID, Username: acc.Username},
|
||||
Admin: AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -116,7 +117,7 @@ func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
|
||||
if err != nil || acc == nil {
|
||||
return nil, errors.New("admin not found")
|
||||
}
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username}, nil
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role}, nil
|
||||
}
|
||||
|
||||
// ListUsers lists terminal users.
|
||||
@@ -197,6 +198,9 @@ type GrantInput struct {
|
||||
|
||||
// GrantMembership extends membership and writes audit.
|
||||
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
days, err := planDays(plan)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -221,6 +225,9 @@ var ErrInvalidAskDelta = errString("invalid ask quota delta")
|
||||
|
||||
// GrantAskQuota adds purchased ask quota and audits.
|
||||
func (s *Service) GrantAskQuota(ctx context.Context, adminID, userID uuid.UUID, delta int) (int, error) {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if delta <= 0 || delta > 1000 {
|
||||
return 0, ErrInvalidAskDelta
|
||||
}
|
||||
@@ -247,6 +254,9 @@ func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, e
|
||||
|
||||
// UpsertPlanPrices updates display prices (not historical order amounts).
|
||||
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 {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleSuper = "super"
|
||||
RoleOps = "ops"
|
||||
|
||||
pushTitleMaxRunes = 128
|
||||
)
|
||||
|
||||
var (
|
||||
ErrForbidden = errString("forbidden")
|
||||
ErrInvalidRole = errString("invalid role")
|
||||
ErrPushNotFound = errString("push job not found")
|
||||
ErrInvalidPush = errString("invalid push job")
|
||||
ErrAdminNotFound = errString("admin not found")
|
||||
ErrLastSuper = errString("cannot demote last super")
|
||||
)
|
||||
|
||||
// RequireSuper returns ErrForbidden unless admin role is super.
|
||||
func (s *Service) RequireSuper(ctx context.Context, adminID uuid.UUID) error {
|
||||
acc, err := s.Repo.FindAccountByID(ctx, adminID)
|
||||
if err != nil || acc == nil {
|
||||
return ErrAdminNotFound
|
||||
}
|
||||
if acc.Role != RoleSuper {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BanUser sets users.status=banned.
|
||||
func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
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.
|
||||
func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
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).
|
||||
func (s *Service) ListAdmins(ctx context.Context, actorID uuid.UUID) ([]repository.AdminListItem, error) {
|
||||
if err := s.RequireSuper(ctx, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Repo.ListAdmins(ctx)
|
||||
}
|
||||
|
||||
// UpdateAdminRole changes an admin role (super only).
|
||||
func (s *Service) UpdateAdminRole(ctx context.Context, actorID, targetID uuid.UUID, role string) error {
|
||||
if err := s.RequireSuper(ctx, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
if role != RoleSuper && role != RoleOps {
|
||||
return ErrInvalidRole
|
||||
}
|
||||
target, err := s.Repo.FindAccountByID(ctx, targetID)
|
||||
if err != nil || target == nil {
|
||||
return ErrAdminNotFound
|
||||
}
|
||||
if target.Role == RoleSuper && role != RoleSuper {
|
||||
n, err := s.Repo.CountAdminsByRole(ctx, RoleSuper)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrLastSuper
|
||||
}
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"role": role})
|
||||
err = s.Repo.UpdateAdminRoleWithAudit(ctx, actorID, targetID, role, meta)
|
||||
if errors.Is(err, repository.ErrAdminNotFound) {
|
||||
return ErrAdminNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ListPushJobs lists push stubs.
|
||||
func (s *Service) ListPushJobs(ctx context.Context, limit, offset int) ([]repository.PushJob, error) {
|
||||
return s.Repo.ListPushJobs(ctx, limit, offset)
|
||||
}
|
||||
|
||||
// CreatePushJob creates a draft push stub.
|
||||
func (s *Service) CreatePushJob(ctx context.Context, adminID uuid.UUID, title, body, audience string) (*repository.PushJob, error) {
|
||||
if err := validatePushTitle(title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"title": title})
|
||||
return s.Repo.CreatePushJobWithAudit(ctx, adminID, title, body, audience, meta)
|
||||
}
|
||||
|
||||
// UpdatePushJob updates a push stub.
|
||||
func (s *Service) UpdatePushJob(
|
||||
ctx context.Context,
|
||||
adminID, jobID uuid.UUID,
|
||||
title, body, status *string,
|
||||
) (*repository.PushJob, error) {
|
||||
if status != nil && *status != "draft" && *status != "cancelled" {
|
||||
return nil, ErrInvalidPush
|
||||
}
|
||||
if title != nil {
|
||||
if err := validatePushTitle(*title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{})
|
||||
job, err := s.Repo.UpdatePushJobWithAudit(ctx, adminID, jobID, title, body, status, meta)
|
||||
if errors.Is(err, repository.ErrPushJobNotFound) {
|
||||
return nil, ErrPushNotFound
|
||||
}
|
||||
return job, err
|
||||
}
|
||||
|
||||
func validatePushTitle(title string) error {
|
||||
if title == "" || utf8.RuneCountInString(title) > pushTitleMaxRunes {
|
||||
return ErrInvalidPush
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -93,12 +93,15 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
|
||||
return s.issue(ctx, uid, phone, nickname)
|
||||
}
|
||||
|
||||
// Logout revokes bearer token.
|
||||
func (s *Service) Logout(ctx context.Context, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
// 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).
|
||||
func (s *Service) Logout(ctx context.Context, token, deviceKey string) error {
|
||||
if token != "" {
|
||||
if err := s.Repo.RevokeSession(ctx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.Repo.RevokeSession(ctx, token)
|
||||
return s.Repo.RebindDeviceAnonymous(ctx, deviceKey)
|
||||
}
|
||||
|
||||
// Me returns account if registered.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_push_jobs_created;
|
||||
DROP TABLE IF EXISTS push_jobs;
|
||||
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_status_check;
|
||||
|
||||
ALTER TABLE admin_accounts DROP CONSTRAINT IF EXISTS admin_accounts_role_check;
|
||||
ALTER TABLE admin_accounts DROP COLUMN IF EXISTS role;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Ops-E: RBAC role · user status check · push_jobs stub
|
||||
|
||||
ALTER TABLE admin_accounts
|
||||
ADD COLUMN IF NOT EXISTS role varchar(16) NOT NULL DEFAULT 'super';
|
||||
|
||||
ALTER TABLE admin_accounts
|
||||
DROP CONSTRAINT IF EXISTS admin_accounts_role_check;
|
||||
|
||||
ALTER TABLE admin_accounts
|
||||
ADD CONSTRAINT admin_accounts_role_check
|
||||
CHECK (role IN ('super', 'ops'));
|
||||
|
||||
UPDATE admin_accounts SET role = 'super' WHERE role IS NULL OR role = '';
|
||||
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_status_check;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_status_check
|
||||
CHECK (status IN ('active', 'banned'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_jobs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title varchar(128) NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
audience varchar(64) NOT NULL DEFAULT 'all',
|
||||
status varchar(32) NOT NULL DEFAULT 'draft',
|
||||
created_by uuid NOT NULL REFERENCES admin_accounts(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT push_jobs_status_check CHECK (status IN ('draft', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_jobs_created ON push_jobs(created_at DESC);
|
||||
@@ -14,6 +14,14 @@ test('star / rhythm / cards pages render with mocked API', async ({ page }) => {
|
||||
const url = route.request().url()
|
||||
const method = route.request().method()
|
||||
|
||||
if (url.includes('/auth/me')) {
|
||||
await route.fulfill(ok({ id: 'u1', phone: '13800000000' }))
|
||||
return
|
||||
}
|
||||
if (url.includes('/profiles') && method === 'GET') {
|
||||
await route.fulfill(ok({ items: [] }))
|
||||
return
|
||||
}
|
||||
if (url.includes('/profiles') && method === 'POST') {
|
||||
await route.fulfill(ok({
|
||||
id: 'prof-e2e',
|
||||
|
||||
@@ -7,6 +7,28 @@ test('home birth form opens portrait with summary and deep CTA', async ({ page }
|
||||
const url = req.url()
|
||||
const method = req.method()
|
||||
|
||||
if (url.includes('/auth/me')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: { id: 'u1', phone: '13800000000' },
|
||||
}),
|
||||
headers: { 'X-Device-Key': 'e2e-device' },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.includes('/profiles') && method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 0, message: 'success', data: { items: [] } }),
|
||||
headers: { 'X-Device-Key': 'e2e-device' },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.includes('/profiles') && method === 'POST') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -64,7 +86,7 @@ test('home birth form opens portrait with summary and deep CTA', async ({ page }
|
||||
|
||||
await page.goto('/psy/portrait?y=1990&m=5&d=12')
|
||||
await expect(page.getByRole('navigation', { name: '主导航' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '愈心解码' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '愈心解码', level: 1 })).toBeVisible()
|
||||
await expect(page.getByText('模拟摘要').first()).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /在报告页打开/ })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /解锁完整分析/ }).first()).toBeVisible()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
|
||||
export const askScenes = [
|
||||
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
|
||||
@@ -25,6 +26,7 @@ export const askAdvisors = [
|
||||
|
||||
export function useAskPage() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const rail = ref<'ai' | 'advisor'>('ai')
|
||||
|
||||
const bootLoading = ref(true)
|
||||
@@ -43,6 +45,10 @@ export function useAskPage() {
|
||||
async function boot() {
|
||||
bootLoading.value = true
|
||||
bootError.value = ''
|
||||
if (!(await ensureAccount(router, route.fullPath))) {
|
||||
bootLoading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
|
||||
profiles.value = list.items || []
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
|
||||
export const promptRows = [
|
||||
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
|
||||
@@ -12,6 +14,8 @@ export const promptRows = [
|
||||
]
|
||||
|
||||
export function useImageCardPage() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const scenes = ref<{ key: string; label: string }[]>([])
|
||||
const scene = ref('情绪整理')
|
||||
const quota = ref<{ remaining: number; daily_free: number; unlimited: boolean } | null>(null)
|
||||
@@ -70,6 +74,7 @@ export function useImageCardPage() {
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
try {
|
||||
const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()])
|
||||
scenes.value = sc.items || []
|
||||
@@ -82,6 +87,7 @@ export function useImageCardPage() {
|
||||
}
|
||||
|
||||
async function draw() {
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
error.value = ''
|
||||
const y = Number(year.value)
|
||||
const m = Number(month.value)
|
||||
|
||||
@@ -70,7 +70,7 @@ export function useLifeRhythmPage() {
|
||||
try {
|
||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
|
||||
report.value = await api.getLatestReport(profile.id, 'rhythm')
|
||||
report.value = await api.createRhythm(profile.id)
|
||||
tab.value = 'overview'
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
|
||||
} catch (e) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function usePortraitPage() {
|
||||
try {
|
||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
|
||||
report.value = await api.getLatestReport(profile.id, 'portrait')
|
||||
report.value = await api.createPortrait(profile.id)
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { GrowthReport, Profile } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
import type { RelationSharePayload } from '../lib/shareLink'
|
||||
|
||||
export const focusTabs = [
|
||||
@@ -24,6 +26,8 @@ export const resultTabs = [
|
||||
]
|
||||
|
||||
export function useRelationPage() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const otherName = ref('TA')
|
||||
const relationType = ref('partner')
|
||||
const oy = ref('1992')
|
||||
@@ -97,6 +101,7 @@ export function useRelationPage() {
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
needSelf.value = false
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ export { reportStarPanels, reportSynTabs, reportFortuneKeys }
|
||||
|
||||
export function useReportPage() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const report = ref<GrowthReport | null>(null)
|
||||
@@ -91,6 +93,10 @@ export function useReportPage() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
report.value = null
|
||||
if (!(await ensureAccount(router, route.fullPath))) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const id = String(route.params.id || '')
|
||||
if (!id) throw new Error('缺少报告 id')
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
|
||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
||||
|
||||
export function useScalePage() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const slug = String(route.params.slug || '')
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
@@ -100,6 +102,10 @@ export function useScalePage() {
|
||||
loadingScale.value = true
|
||||
loadError.value = ''
|
||||
needProfile.value = false
|
||||
if (!(await ensureAccount(router, route.fullPath))) {
|
||||
loadingScale.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const d = await api.getScale(slug)
|
||||
title.value = d.title
|
||||
|
||||
@@ -118,7 +118,7 @@ export function useStarProfilePage() {
|
||||
birth_time: bt,
|
||||
birth_place: birthPlace.value || undefined,
|
||||
})
|
||||
report.value = await api.getLatestReport(profile.id, 'star')
|
||||
report.value = await api.createStar(profile.id)
|
||||
view.value = 'overview'
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
|
||||
} catch (e) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
sectionsFromDetail,
|
||||
type SynastryMainTab,
|
||||
} from '../lib/synastryChart'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
import { createSynastryPageActions } from './synastryPageActions'
|
||||
|
||||
export type MainTab = SynastryMainTab
|
||||
@@ -135,6 +136,7 @@ export function useSynastryPage() {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
await loadProfiles()
|
||||
if (route.query.invite === '1') {
|
||||
inviteHighlight.value = true
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function ensureAccount(router: Router, redirect: string): Promise<A
|
||||
|
||||
export async function findSelfProfile(): Promise<Profile | null> {
|
||||
const { items } = await api.listProfiles()
|
||||
return items.find((p) => p.relation === 'self') || null
|
||||
return (items ?? []).find((p) => p.relation === 'self') || null
|
||||
}
|
||||
|
||||
/** Load cached solo report for self profile; null report means need archive. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import AskPage from './AskPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
getAskQuota: vi.fn(),
|
||||
createAskThread: vi.fn(),
|
||||
@@ -16,8 +17,8 @@ const { api } = vi.hoisted(() => ({
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn(), push: vi.fn() }),
|
||||
useRoute: () => ({ query: {}, fullPath: '/ask' }),
|
||||
useRouter: () => ({ back: vi.fn(), push: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
const RouterLinkStub = defineComponent({
|
||||
@@ -30,6 +31,7 @@ const RouterLinkStub = defineComponent({
|
||||
describe('AskPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
})
|
||||
|
||||
it('shows empty state when no profiles', async () => {
|
||||
|
||||
@@ -4,20 +4,26 @@ import CompanionPage from './CompanionPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
getSolarTermsToday: vi.fn(),
|
||||
getMoodToday: vi.fn(),
|
||||
getMoodsRecent: vi.fn(),
|
||||
saveMood: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ path: '/companion' }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRoute: () => ({ path: '/companion', fullPath: '/companion' }),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('CompanionPage', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
api.getMoodsRecent.mockResolvedValue({ items: [] })
|
||||
})
|
||||
|
||||
it('shows solar term and saves mood', async () => {
|
||||
api.getSolarTermsToday.mockResolvedValue({ name: '立秋', tip: '收敛节奏', date: '2026-08-02' })
|
||||
|
||||
@@ -91,11 +91,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { track } from '../lib/analytics'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const moodMarks = ['◦', '○', '◎', '●', '◉']
|
||||
const term = ref({ name: '…', tip: '加载中…', date: '' })
|
||||
const score = ref<number | null>(null)
|
||||
@@ -111,6 +115,7 @@ const todayLabel = computed(() =>
|
||||
)
|
||||
|
||||
async function load() {
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
track('companion_viewed')
|
||||
try {
|
||||
term.value = await api.getSolarTermsToday()
|
||||
@@ -135,6 +140,7 @@ async function load() {
|
||||
|
||||
async function save() {
|
||||
if (!score.value) return
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
saving.value = true
|
||||
moodErr.value = ''
|
||||
saved.value = false
|
||||
|
||||
@@ -33,11 +33,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
import { toolIconFor } from '../lib/toolIconMap'
|
||||
|
||||
type Item = {
|
||||
@@ -59,6 +60,7 @@ type Cat = {
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const cat = ref<Cat | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
@@ -67,6 +69,10 @@ async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
cat.value = null
|
||||
if (!(await ensureAccount(router, route.fullPath))) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
cat.value = await api.getExploreCategory(String(route.params.category))
|
||||
} catch (e) {
|
||||
|
||||
@@ -53,11 +53,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import HomeToolIcon, { type HomeToolIconName } from '../components/HomeToolIcon.vue'
|
||||
import ListRow from '../components/ListRow.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
import { toolIconFor } from '../lib/toolIconMap'
|
||||
|
||||
type Cat = {
|
||||
@@ -69,6 +71,9 @@ type Cat = {
|
||||
items?: { badge?: string }[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const featured: { to: string; label: string; icon: HomeToolIconName; badge?: string; badgeTone?: 'hot' | 'new' }[] = [
|
||||
{ to: '/scales/mbti-lite', label: '人格测试', icon: 'mbti' },
|
||||
{ to: '/star', label: '星座', icon: 'star' },
|
||||
@@ -96,6 +101,10 @@ function hotBadge(c: Cat) {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
if (!(await ensureAccount(router, route.fullPath))) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.getExploreCatalog()
|
||||
categories.value = res.categories || []
|
||||
|
||||
@@ -41,11 +41,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const plans = ref<{ id: string; title: string; focus: string }[]>([])
|
||||
const checkins = ref<Record<string, { id: string; day: string; note?: string }[]>>({})
|
||||
const title = ref('')
|
||||
@@ -57,6 +61,10 @@ const err = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
if (!(await ensureAccount(router, route.fullPath))) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.listGrowthPlans()
|
||||
plans.value = res.items || []
|
||||
|
||||
@@ -4,6 +4,7 @@ import ImageCardPage from './ImageCardPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
listImageCardScenes: vi.fn(),
|
||||
getImageCardQuota: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
@@ -16,13 +17,14 @@ const { api } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
useRoute: () => ({ query: {}, fullPath: '/cards' }),
|
||||
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('ImageCardPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
api.listImageCardScenes.mockResolvedValue({
|
||||
items: [
|
||||
{ key: '情绪整理', label: '情绪整理' },
|
||||
|
||||
@@ -4,6 +4,9 @@ import LifeRhythmPage from './LifeRhythmPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
getLatestReport: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
createRhythm: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
@@ -14,12 +17,16 @@ const { api } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: { y: '1992', m: '6', d: '8' } }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
useRoute: () => ({ query: { y: '1992', m: '6', d: '8' }, fullPath: '/rhythm' }),
|
||||
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('LifeRhythmPage', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
api.listProfiles.mockResolvedValue({ items: [] })
|
||||
})
|
||||
|
||||
it('creates rhythm report from birth query', async () => {
|
||||
api.createProfile.mockResolvedValue({ id: 'p1' })
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
@@ -126,6 +126,15 @@ async function submit() {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await api.authMe()
|
||||
await router.replace(String(route.query.redirect || '/mine'))
|
||||
} catch {
|
||||
/* stay on login */
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -4,6 +4,7 @@ import MembershipPage from './MembershipPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
createOrder: vi.fn(),
|
||||
payMock: vi.fn(),
|
||||
@@ -13,13 +14,14 @@ const { api } = vi.hoisted(() => ({
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
useRoute: () => ({ query: {}, fullPath: '/membership' }),
|
||||
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('MembershipPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
})
|
||||
|
||||
it('shows subscribe CTA when inactive', async () => {
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { MembershipMe } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import AskQuotaBuy from '../components/ask/AskQuotaBuy.vue'
|
||||
@@ -62,6 +63,10 @@ import BackButton from '../components/BackButton.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const plans = [
|
||||
{ key: 'month', label: '月卡', price: '模拟开通', desc: '按月灵活体验', featured: false, tag: '' },
|
||||
@@ -93,6 +98,10 @@ function formatDate(iso: string) {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
if (!(await ensureAccount(router, route.fullPath))) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
me.value = await api.getMembership()
|
||||
} catch (e) {
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { AuthUser, MembershipMe } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import BrandLogo from '../components/BrandLogo.vue'
|
||||
@@ -80,8 +79,6 @@ import PageShell from '../components/PageShell.vue'
|
||||
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
|
||||
import { clearAuthToken } from '../lib/authSession'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const groupArchive: { to: string; label: string; icon: HomeToolIconName }[] = [
|
||||
{ to: '/profile', label: '个人档案', icon: 'portrait' },
|
||||
{ to: '/reports', label: '我的成长报告', icon: 'reports' },
|
||||
@@ -121,14 +118,22 @@ async function load() {
|
||||
error.value = ''
|
||||
try {
|
||||
me.value = await api.authMe()
|
||||
} catch {
|
||||
// 未登录:展示游客态(未登录 + 登录入口),不把 401 当成整页错误
|
||||
me.value = null
|
||||
membership.value = null
|
||||
profileCount.value = 0
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
|
||||
membership.value = m
|
||||
profileCount.value = (profiles.items || []).length
|
||||
} catch (e) {
|
||||
me.value = null
|
||||
membership.value = null
|
||||
profileCount.value = 0
|
||||
error.value = e instanceof Error ? e.message : '请先登录'
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -142,7 +147,9 @@ async function logout() {
|
||||
}
|
||||
clearAuthToken()
|
||||
me.value = null
|
||||
await router.push('/login')
|
||||
membership.value = null
|
||||
profileCount.value = 0
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
@@ -4,6 +4,9 @@ import PortraitPage from './PortraitPage.vue'
|
||||
|
||||
const { api, routeQuery } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
getLatestReport: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
createPortrait: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
@@ -16,8 +19,8 @@ const { api, routeQuery } = vi.hoisted(() => ({
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: routeQuery }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
useRoute: () => ({ query: routeQuery, fullPath: '/portrait' }),
|
||||
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
|
||||
RouterLink: {
|
||||
name: 'RouterLink',
|
||||
props: ['to'],
|
||||
@@ -57,6 +60,8 @@ describe('PortraitPage', () => {
|
||||
routeQuery.y = ''
|
||||
routeQuery.m = ''
|
||||
routeQuery.d = ''
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
api.listProfiles.mockResolvedValue({ items: [] })
|
||||
})
|
||||
|
||||
it('shows birth form when opened without query', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import ProfilePage from './ProfilePage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
@@ -14,7 +15,7 @@ const { api } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRoute: () => ({ query: {}, fullPath: '/profile' }),
|
||||
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
@@ -26,7 +27,10 @@ const RouterLinkStub = defineComponent({
|
||||
})
|
||||
|
||||
describe('ProfilePage', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
})
|
||||
|
||||
it('shows empty state', async () => {
|
||||
api.listProfiles.mockResolvedValue({ items: [] })
|
||||
|
||||
@@ -4,6 +4,7 @@ import RelationPage from './RelationPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
createRelationInsight: vi.fn(),
|
||||
@@ -16,13 +17,14 @@ const { api } = vi.hoisted(() => ({
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
useRoute: () => ({ query: {}, fullPath: '/relation' }),
|
||||
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('RelationPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
})
|
||||
|
||||
it('generates relation insight summary and gates tips', async () => {
|
||||
|
||||
@@ -65,13 +65,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
import { toolIconFor } from '../lib/toolIconMap'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const items = ref<GrowthReport[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
@@ -132,6 +136,10 @@ function badgeLabel(b: 'new' | 'hot') {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
if (!(await ensureAccount(router, route.fullPath))) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.listReports()
|
||||
items.value = res.items || []
|
||||
|
||||
@@ -4,6 +4,7 @@ import ScalePage from './ScalePage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
getScale: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
submitScale: vi.fn(),
|
||||
@@ -17,7 +18,8 @@ vi.mock('../lib/scaleDraft', () => ({
|
||||
clearScaleDraft: vi.fn(),
|
||||
}))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ params: { slug: 'mbti-lite' } }),
|
||||
useRoute: () => ({ params: { slug: 'mbti-lite' }, fullPath: '/scales/mbti-lite' }),
|
||||
useRouter: () => ({ replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
const scaleDetail = {
|
||||
@@ -53,6 +55,7 @@ const scaleDetail = {
|
||||
describe('ScalePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
api.getScale.mockResolvedValue(scaleDetail)
|
||||
api.listProfiles.mockResolvedValue({
|
||||
items: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
|
||||
|
||||
@@ -4,6 +4,9 @@ import StarProfilePage from './StarProfilePage.vue'
|
||||
|
||||
const { api, routeQuery } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
getLatestReport: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
createStar: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
@@ -15,8 +18,8 @@ const { api, routeQuery } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: routeQuery }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
useRoute: () => ({ query: routeQuery, fullPath: '/star' }),
|
||||
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
const summary = {
|
||||
@@ -42,6 +45,8 @@ const summary = {
|
||||
describe('StarProfilePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
api.listProfiles.mockResolvedValue({ items: [] })
|
||||
api.createProfile.mockResolvedValue({ id: 'p1' })
|
||||
api.createStar.mockResolvedValue({
|
||||
id: 'r1',
|
||||
|
||||
@@ -58,6 +58,7 @@ import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -71,6 +72,10 @@ const td = ref('')
|
||||
const meta = ref<{ host_name: string; already_accepted: boolean } | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!(await ensureAccount(router, route.fullPath))) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const token = String(route.params.token || '')
|
||||
try {
|
||||
meta.value = await api.getSynastryInvite(token)
|
||||
@@ -82,6 +87,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function accept() {
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
const y = Number(ty.value)
|
||||
const m = Number(tm.value)
|
||||
const d = Number(td.value)
|
||||
|
||||
@@ -4,6 +4,7 @@ import SynastryPage from './SynastryPage.vue'
|
||||
|
||||
const { api } = vi.hoisted(() => ({
|
||||
api: {
|
||||
authMe: vi.fn(),
|
||||
listProfiles: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
createSynastry: vi.fn(),
|
||||
@@ -18,13 +19,14 @@ const { api } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRoute: () => ({ query: {}, fullPath: '/synastry' }),
|
||||
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('SynastryPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
||||
api.listProfiles.mockResolvedValue({
|
||||
items: [
|
||||
{ id: 'a1', relation: 'self', display_name: '我', birth_date: '1990-05-12' },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# BD-2026-010 — Ops-E System
|
||||
|
||||
Status: Approved
|
||||
**ECR:** ECR-010
|
||||
**Date:** 2026-08-11
|
||||
|
||||
## Backend Change Boundary
|
||||
|
||||
### In
|
||||
|
||||
- `admin_accounts.role` TEXT NOT NULL DEFAULT `super` CHECK (`super`,`ops`)
|
||||
- `users.status` CHECK (`active`,`banned`)(存量保持)
|
||||
- 表 `push_jobs`
|
||||
- AdminRepo:SetUserStatus · ListAdmins · UpdateAdminRole · PushJobs CRUD
|
||||
- admin.Service:RequireSuper · Ban/Unban · role on Me
|
||||
- DeviceAuth:banned → 40310 + revoke sessions
|
||||
- Handler 路由挂载;OpenAPI
|
||||
|
||||
### Out
|
||||
|
||||
- 推送厂商 · Worker · 动态权限表 · Handler 直连 DB
|
||||
|
||||
### Ownership
|
||||
|
||||
| Layer | Owner |
|
||||
|-------|--------|
|
||||
| Repository | AdminRepo |
|
||||
| Service | admin.Service |
|
||||
| Handler | AdminHandler |
|
||||
| Middleware | DeviceAuth / AdminAuth |
|
||||
| UI | admin-h5 UserDetail / Push / Admins |
|
||||
|
||||
## Architecture Evidence
|
||||
|
||||
仍:Handler → Service → Repository;无新外部依赖。
|
||||
|
||||
## Failure / Rollback
|
||||
|
||||
- down migration 删 role 列与 push_jobs;封禁用户需人工恢复 status
|
||||
- 错误码:40301 无权限 · 40310 用户已封禁
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- integration:ops 403 写接口;ban/unban + DeviceAuth;push CRUD
|
||||
- admin-h5 build
|
||||
@@ -0,0 +1,32 @@
|
||||
# CODE_REVIEW — ECR-010 Ops-E
|
||||
|
||||
**Date:** 2026-08-11
|
||||
**Reviewer:** Agent (pre-landing)
|
||||
**Verdict:** Approve
|
||||
|
||||
## Checklist(.ai/review.md)
|
||||
|
||||
| Area | Result | Note |
|
||||
|------|--------|------|
|
||||
| Architecture | PASS | Handler→Service→Repo;DeviceAuth 闸门 |
|
||||
| Naming | PASS | ban/unban · role · push_jobs 与 Spec 一致 |
|
||||
| Performance | PASS | 列表有 limit |
|
||||
| Security | PASS | RBAC 服务端校验;末位 super 降级 → 40901 |
|
||||
| Readability | PASS* | 新文件合规;`admin_repo.go` 仍超 400(旧债) |
|
||||
| Error handling | PASS | `errors.Is` + sentinel;push title 长度 → 400 |
|
||||
| Logging | N/A | 审计表覆盖关键动作 |
|
||||
| Test | PASS | last-super · long title · ban · RBAC |
|
||||
| Deployment | PASS | migration 000016 |
|
||||
| Docs | PASS | Spec · OpenAPI 已补 409 |
|
||||
| Design | PASS | admin-h5 导航裁剪 |
|
||||
| Environment | PASS | |
|
||||
| Scope | PASS* | 落地时仍建议与 P2 文档拆 commit |
|
||||
|
||||
## Fixed since Conditional Approve
|
||||
|
||||
- P0:`CountAdminsByRole` + `ErrLastSuper`(40901) — integration 覆盖
|
||||
- P1:repo sentinel + `errors.Is`;push title ≤128 runes → 40055
|
||||
|
||||
## Residual(接受)
|
||||
|
||||
- 真推送未接;user 级封禁非 device 级;工作区可另拆 P2 commit
|
||||
@@ -0,0 +1,49 @@
|
||||
# ECR-010
|
||||
|
||||
**Title:** 运营系统 Ops-E(RBAC · 封禁 · 推送占位)
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-11
|
||||
**Approved:** 2026-08-11(Human 确认实施计划)
|
||||
**Change Level:** L2
|
||||
|
||||
## Change
|
||||
|
||||
1. Active Spec:`.ai/product/feature-spec/ops-system.md`
|
||||
2. migration `000016_ops_system`:`admin_accounts.role` · `users.status` CHECK · `push_jobs`
|
||||
3. Admin:ban/unban · admins 列表/改角色 · push-jobs CRUD;Me 含 role
|
||||
4. DeviceAuth 拒绝 `banned`(40310)
|
||||
5. admin-h5:封禁 · `/push` · `/admins`(super)· 导航裁剪
|
||||
6. OpenAPI · TRACEABILITY · TEST_REPORT · CODE_REVIEW
|
||||
|
||||
## Motivation
|
||||
|
||||
全员等同 Admin;无封禁与推送占位,运维只能改库。
|
||||
|
||||
## Scope
|
||||
|
||||
### Allowed
|
||||
|
||||
- 上列 Spec / migration / admin API / DeviceAuth / admin-h5
|
||||
|
||||
### Forbidden
|
||||
|
||||
- 真推送通道 · 细粒度权限矩阵 · UGC · 真支付 · 改 ESS
|
||||
|
||||
## Risk
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| 误封正常用户 | 审计 + 一键 unban |
|
||||
| ops 越权写 | service 层角色校验 + integration |
|
||||
| 封禁未生效 | DeviceAuth 统一闸门 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] ops 写定价/授予会员 → 403
|
||||
- [x] ban 后 C 端 40310;unban 恢复
|
||||
- [x] push draft CRUD;不下发
|
||||
- [x] integration + admin-h5 build
|
||||
|
||||
## Trace
|
||||
|
||||
Spec `ops-system` · BD-2026-010 · TASK-010
|
||||
@@ -0,0 +1,6 @@
|
||||
# ENGINEERING_SPEC — ECR-010
|
||||
|
||||
- Stack:Go gin · Vue3 admin-h5 · PostgreSQL migration 000016
|
||||
- Layers:Handler → Service → Repository;DeviceAuth 闸门
|
||||
- BD:BD-2026-010 Approved
|
||||
- Non-goals:真推送 / 权限矩阵 / UGC / 真支付
|
||||
@@ -0,0 +1,9 @@
|
||||
# HANDOFF — ECR-010 Architect → Engineer
|
||||
|
||||
**From:** Architect
|
||||
**To:** Engineer
|
||||
**ECR:** ECR-010 Approved
|
||||
**BD:** BD-2026-010 Approved
|
||||
**Spec:** ops-system Active
|
||||
|
||||
Implement Spec §9 + BD boundary only. Do not wait for re-confirmation.
|
||||
@@ -0,0 +1,9 @@
|
||||
# HANDOFF — ECR-010 Engineer → Reviewer
|
||||
|
||||
**From:** Engineer
|
||||
**To:** Reviewer
|
||||
**ECR:** ECR-010
|
||||
**Test:** `docs/TEST_REPORT/ECR-010.md` PASS
|
||||
**Self-review:** `docs/CODE_REVIEW/ECR-010.md` Approve
|
||||
|
||||
Ready for Reviewer Closed.
|
||||
@@ -0,0 +1,24 @@
|
||||
# PRODUCT_SPEC — ECR-010 运营系统 Ops-E
|
||||
|
||||
对齐 Feature Spec:`.ai/product/feature-spec/ops-system.md`
|
||||
|
||||
## Outcome
|
||||
|
||||
1. Admin 分 `super` / `ops`;写会员/定价/内容仅 super。
|
||||
2. 可封禁/解封 C 端用户;被封请求返回 40310。
|
||||
3. 推送任务占位可建草稿与取消;不下发。
|
||||
|
||||
## Actors
|
||||
|
||||
- Admin super / ops
|
||||
- C 端 User(含 banned)
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- ops 无法完成授予会员或改展示价
|
||||
- 封禁用户无法继续调用需 DeviceAuth 的 API
|
||||
- push_jobs 仅 draft/cancelled
|
||||
|
||||
## Out of scope
|
||||
|
||||
真推送 · 细粒度 RBAC · UGC · 真支付
|
||||
@@ -49,12 +49,13 @@
|
||||
|
||||
## Active anchors
|
||||
|
||||
- ECR: **ECR-006** 运营后台 Phase A(Closed);ECR-001–005 Closed / shipped
|
||||
- ECR: ECR-006 Closed;ECR-007/008 Implemented(待 Closed);ECR-009 Implemented · Human Approved;**ECR-010 Implemented(待 Reviewer Closed)**;ECR-001–005 Closed
|
||||
- EXP: (无)
|
||||
- STATE: `docs/STATE/ECR-006.md`
|
||||
- STATE: `docs/STATE/ECR-010.md`(及 006–009)
|
||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`
|
||||
- Product status: `.ai/product/p1-status.md`(**P1 Complete**)
|
||||
- Product status: `.ai/product/p1-status.md`(**P1 Complete**)· `.ai/product/p2-status.md`(**P2 Complete**)
|
||||
- Active Spec: `.ai/product/feature-spec/ops-system.md`
|
||||
|
||||
## Pointers
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# STATE — ECR-010
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ECR | ECR-010 |
|
||||
| Title | Ops-E 系统(RBAC · 封禁 · 推送占位) |
|
||||
| Status | Implemented(待 Reviewer Closed) |
|
||||
| Phase | review |
|
||||
| Owner | reviewer |
|
||||
| Spec | Active `ops-system` |
|
||||
| Backend Design | BD-2026-010 Approved |
|
||||
| Test | TEST_REPORT/ECR-010.md PASS |
|
||||
| Review | CODE_REVIEW/ECR-010.md Approve |
|
||||
| Updated | 2026-08-11 |
|
||||
@@ -0,0 +1,9 @@
|
||||
ecr: ECR-010
|
||||
title: Ops-E system RBAC ban push stub
|
||||
status: ready
|
||||
owner: engineer
|
||||
change_level: L2
|
||||
artifacts:
|
||||
- docs/ECR/ECR-010-ops-system.md
|
||||
- docs/BACKEND_DESIGN/BD-2026-010-ops-system.md
|
||||
- .ai/product/feature-spec/ops-system.md
|
||||
@@ -0,0 +1,30 @@
|
||||
# TEST_REPORT — ECR-010 Ops-E
|
||||
|
||||
**Date:** 2026-08-11
|
||||
**Result:** PASS
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cd apps/api && go test ./... -count=1
|
||||
cd apps/api && go test ./internal/integration/ -run TestOpsESystemRBACBanPush -count=1
|
||||
npm run build --workspace=@yuxingu/admin-h5
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
| Case | Result |
|
||||
|------|--------|
|
||||
| Bootstrap admin role=super | PASS |
|
||||
| ops PUT plan-prices → 40301 | PASS |
|
||||
| ops list admins → 40301 | PASS |
|
||||
| ops grant membership → 40301 | PASS |
|
||||
| ops ban → DeviceAuth 40310 | PASS |
|
||||
| ops unban → profiles OK | PASS |
|
||||
| push draft + cancel | PASS |
|
||||
| admin-h5 build | PASS |
|
||||
|
||||
## Notes
|
||||
|
||||
- Migration `000016_ops_system` applied via integration Migrate
|
||||
- 真推送未测(Forbidden)
|
||||
@@ -15,3 +15,4 @@
|
||||
| ECR-007 | 运营行为分析 Ops-B(埋点 + 数据看板) | **Implemented** | Spec Active · BD-2026-007 · TEST_REPORT · CODE_REVIEW · HANDOFF review |
|
||||
| ECR-008 | 运营内容 Ops-C(首页宫格 + 测评上下架) | **Implemented** | Spec Active · BD-2026-008 · TEST_REPORT · CODE_REVIEW |
|
||||
| ECR-009 | 运营商业加深 Ops-D(订单筛选 · 展示价 · 退款只读) | **Implemented** | Spec ops-commerce · BD-2026-009 · commit `ca3b135` · LOOP-RUN-002 · Gate PASS |
|
||||
| ECR-010 | 运营系统 Ops-E(RBAC · 封禁 · 推送占位) | **Implemented** | Spec ops-system · BD-2026-010 · TEST_REPORT · CODE_REVIEW · HANDOFF review |
|
||||
|
||||
+102
-2
@@ -74,10 +74,10 @@ paths:
|
||||
/api/v1/admin/me:
|
||||
get:
|
||||
tags: [admin]
|
||||
summary: Current admin
|
||||
summary: Current admin (includes role)
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
description: OK (id, username, role)
|
||||
|
||||
/api/v1/admin/users:
|
||||
get:
|
||||
@@ -131,6 +131,106 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'403':
|
||||
description: Forbidden (ops role)
|
||||
|
||||
/api/v1/admin/users/{id}/ban:
|
||||
post:
|
||||
tags: [admin]
|
||||
summary: Ban user (Ops-E)
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
|
||||
/api/v1/admin/users/{id}/unban:
|
||||
post:
|
||||
tags: [admin]
|
||||
summary: Unban user (Ops-E)
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
|
||||
/api/v1/admin/admins:
|
||||
get:
|
||||
tags: [admin]
|
||||
summary: List admin accounts (super only)
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'403':
|
||||
description: Forbidden
|
||||
|
||||
/api/v1/admin/admins/{id}:
|
||||
patch:
|
||||
tags: [admin]
|
||||
summary: Update admin role (super only)
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [role]
|
||||
properties:
|
||||
role: { type: string, enum: [super, ops] }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'409':
|
||||
description: Cannot demote last super
|
||||
|
||||
/api/v1/admin/push-jobs:
|
||||
get:
|
||||
tags: [admin]
|
||||
summary: List push job stubs (Ops-E)
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
post:
|
||||
tags: [admin]
|
||||
summary: Create push draft (no dispatch)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [title]
|
||||
properties:
|
||||
title: { type: string }
|
||||
body: { type: string }
|
||||
audience: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
|
||||
/api/v1/admin/push-jobs/{id}:
|
||||
patch:
|
||||
tags: [admin]
|
||||
summary: Update push stub (draft or cancelled)
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
|
||||
/api/v1/admin/orders:
|
||||
get:
|
||||
|
||||
Reference in New Issue
Block a user