feat(ECR-006): 落地运营后台 Phase A(admin API + admin-h5)
新增独立鉴权的 /api/v1/admin 与 Vue 控制台;会员授予与审计同事务,并补集成/单测。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+5
-3
@@ -6,14 +6,16 @@ Domain / ERD:[domain/domain-map.md](domain/domain-map.md) · [domain/erd.md](d
|
|||||||
## Allowed call graph
|
## Allowed call graph
|
||||||
|
|
||||||
```
|
```
|
||||||
UI (user-h5 / mini-program)
|
UI (user-h5 / mini-program / admin-h5)
|
||||||
→ packages/sdk
|
→ packages/sdk(或 admin 薄 api 客户端)
|
||||||
→ API Handler
|
→ API Handler
|
||||||
→ Service
|
→ Service
|
||||||
→ Repository
|
→ Repository
|
||||||
→ Database
|
→ Database
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Admin 路由挂 `/api/v1/admin/*`,鉴权与终端 `DeviceAuth` **隔离**(见 `feature-spec/ops-admin.md`)。
|
||||||
|
|
||||||
## Forbidden
|
## Forbidden
|
||||||
|
|
||||||
- Handler → Database (skip Service/Repository)
|
- Handler → Database (skip Service/Repository)
|
||||||
@@ -30,7 +32,7 @@ UI (user-h5 / mini-program)
|
|||||||
| `apps/api` | Only backend |
|
| `apps/api` | Only backend |
|
||||||
| `apps/user-h5` | Primary client (Vue3+TS) |
|
| `apps/user-h5` | Primary client (Vue3+TS) |
|
||||||
| `apps/mini-program` | Scaffold only until tasked |
|
| `apps/mini-program` | Scaffold only until tasked |
|
||||||
| `apps/admin-h5` | Deferred |
|
| `apps/admin-h5` | Ops Phase A(ECR-006) |
|
||||||
| `packages/sdk` | Multi-platform HTTP client |
|
| `packages/sdk` | Multi-platform HTTP client |
|
||||||
| `packages/types` | Shared TS types |
|
| `packages/types` | Shared TS types |
|
||||||
| `packages/utils` | Pure helpers |
|
| `packages/utils` | Pure helpers |
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
| `membership` | 成长会员状态与额度 | Yes | `service/membership`(ECR-001 Phase C 已从 report 抽出) |
|
| `membership` | 成长会员状态与额度 | Yes | `service/membership`(ECR-001 Phase C 已从 report 抽出) |
|
||||||
| `order` | Order + pay-mock + DeepAccess 发放 | Yes | 用例在 `service/membership`(同 Phase C;未单独拆包) |
|
| `order` | Order + pay-mock + DeepAccess 发放 | Yes | 用例在 `service/membership`(同 Phase C;未单独拆包) |
|
||||||
| `imagecard` | 意象卡片 | P2 | `service/imagecard` |
|
| `imagecard` | 意象卡片 | P2 | `service/imagecard` |
|
||||||
|
| `admin` | 运营后台鉴权与运维用例 | Ops-A | `service/admin`(ECR-006) |
|
||||||
|
|
||||||
### Engine vs Service
|
### Engine vs Service
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ npm run test:e2e # Playwright(先 build:h5;用系统 Chrome)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Admin H5(Ops Phase A · ECR-006)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# config.local.yaml 需 admin.bootstrap_*(见 config.example.yaml);仅空库种子
|
||||||
|
npm run dev:admin # http://127.0.0.1:5174/
|
||||||
|
npm run build:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## CI / production
|
## CI / production
|
||||||
|
|
||||||
见 [deployment.md](deployment.md) · [docker.md](docker.md)。
|
见 [deployment.md](deployment.md) · [docker.md](docker.md)。
|
||||||
|
|||||||
@@ -172,6 +172,36 @@
|
|||||||
| payload | jsonb | |
|
| payload | jsonb | |
|
||||||
| created_at | timestamptz | |
|
| created_at | timestamptz | |
|
||||||
|
|
||||||
|
### admin_accounts(Ops-A · ECR-006)
|
||||||
|
| column | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| username | text unique | |
|
||||||
|
| password_hash | text | bcrypt |
|
||||||
|
| status | text | active/disabled |
|
||||||
|
| created_at | timestamptz | |
|
||||||
|
| updated_at | timestamptz | |
|
||||||
|
|
||||||
|
### admin_sessions
|
||||||
|
| column | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| admin_id | uuid FK→admin_accounts | |
|
||||||
|
| token | text unique | opaque |
|
||||||
|
| expires_at | timestamptz | |
|
||||||
|
| created_at | timestamptz | |
|
||||||
|
|
||||||
|
### admin_audit_logs
|
||||||
|
| column | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | |
|
||||||
|
| admin_id | uuid FK | |
|
||||||
|
| action | text | e.g. membership.grant |
|
||||||
|
| target_type | text | user/order/… |
|
||||||
|
| target_id | text | |
|
||||||
|
| meta | jsonb | |
|
||||||
|
| created_at | timestamptz | 只追加 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Relationship sketch
|
## Relationship sketch
|
||||||
@@ -185,4 +215,6 @@ users 1──* orders 1──* payments
|
|||||||
growth_reports 1──* deep_accesses
|
growth_reports 1──* deep_accesses
|
||||||
profiles ── relation_insights ── profiles
|
profiles ── relation_insights ── profiles
|
||||||
users 1──* ask_threads 1──* ask_messages
|
users 1──* ask_threads 1──* ask_messages
|
||||||
|
admin_accounts 1──* admin_sessions
|
||||||
|
admin_accounts 1──* admin_audit_logs
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -225,6 +225,19 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 7. 运营后台(内部 · 非 C 端 Tab)
|
||||||
|
|
||||||
|
详见 Spec:[feature-spec/ops-admin.md](feature-spec/ops-admin.md) · ECR-006
|
||||||
|
|
||||||
|
| 分期 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5` |
|
||||||
|
| Phase B+ | 内容配置 · 测评上下架 · 封禁 · RBAC · 推送(后置) |
|
||||||
|
|
||||||
|
不计入 P1 Complete;不进入五 Tab。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## L2 路由(冻结 · user-h5)
|
## L2 路由(冻结 · user-h5)
|
||||||
|
|
||||||
| 路径 | 页面 | 说明 |
|
| 路径 | 页面 | 说明 |
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
| [star-profile.md](star-profile.md) | 星象性格 | §2.6 | `/star` | P2 设计 |
|
| [star-profile.md](star-profile.md) | 星象性格 | §2.6 | `/star` | P2 设计 |
|
||||||
| [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 设计 |
|
| [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 设计 |
|
||||||
| [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 设计 |
|
| [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 设计 |
|
||||||
|
| [ops-admin.md](ops-admin.md) | 运营后台 | §7 | `admin-h5` `/` `/users/:id` … | Ops-A |
|
||||||
|
|
||||||
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
|
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Feature Spec: 运营后台(Ops Admin)
|
||||||
|
|
||||||
|
> Status: `Active` · Map: `§7 运营后台 [Ops · Phase A]` · Phase: `Ops-A`
|
||||||
|
> 规范:[../feature-design.md](../feature-design.md)
|
||||||
|
> 对标推导参考:`cece-frontend-re/complete-design/40-ops-commerce.md`(IA 全量;本 Spec 只交付 Phase A)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 功能定义
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| Name | 运营后台 |
|
||||||
|
| Purpose | 让内部运营/客服在受控权限下查询用户与商业数据,并做可审计的会员权益干预 |
|
||||||
|
| Business Goal | 支撑 P1 用户侧交付后的日常运维;避免直接改库 |
|
||||||
|
|
||||||
|
| In(Phase A) | Out(后置) |
|
||||||
|
|---|---|
|
||||||
|
| 管理员账号登录 / 会话 | UGC 审核 · 达人 · 推送任务 |
|
||||||
|
| 用户列表 / 详情(档案摘要) | 首页运营位 / 工具配置 CMS |
|
||||||
|
| 订单列表 | 优惠券 · 退款工单流 |
|
||||||
|
| 成长会员只读 + **授予/延长**(mock 履约) | 真支付渠道配置 |
|
||||||
|
| 操作审计日志 | 细粒度 RBAC(多角色矩阵) |
|
||||||
|
| `apps/admin-h5` 桌面友好壳 | 小程序后台 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 用户价值
|
||||||
|
|
||||||
|
1. **为何需要:** P1 已有用户/会员/订单,无控制台则只能改库,风险高。
|
||||||
|
2. **完成后获得:** 可检索用户、看会员与订单、安全授予会员并留痕。
|
||||||
|
3. **为何付费:** N/A(内部工具)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 用户角色
|
||||||
|
|
||||||
|
| Actor | 说明 | 本功能能力 |
|
||||||
|
|---|---|---|
|
||||||
|
| Guest / Visitor | 终端用户身份 | **无**后台入口 |
|
||||||
|
| User / VIP | 终端用户 | **无** |
|
||||||
|
| Admin(Phase A) | `admin_accounts` 账号 | 登录;读写本 Spec In 范围 API |
|
||||||
|
| 运营/审核/客服分角色 | 后置 | Phase A 全部等同 Admin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 用户流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
打开 admin-h5
|
||||||
|
↓
|
||||||
|
未登录 → /login(用户名+密码)
|
||||||
|
↓ POST /api/v1/admin/auth/login
|
||||||
|
↓ 存 token → 壳布局
|
||||||
|
↓
|
||||||
|
用户列表 / 订单 / 审计
|
||||||
|
↓ 点用户 → 详情(档案数 · 会员 · 近单)
|
||||||
|
↓ [可选] 授予会员 → 写 memberships + audit_logs
|
||||||
|
失败:401 回登录;403/5xx 页内错误态
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 页面设计
|
||||||
|
|
||||||
|
| 路由 | 页面 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/login` | LoginPage | 无壳 |
|
||||||
|
| `/` | UsersPage | 用户列表 |
|
||||||
|
| `/users/:id` | UserDetailPage | 用户详情 + 授予会员 |
|
||||||
|
| `/orders` | OrdersPage | 订单列表 |
|
||||||
|
| `/audit` | AuditPage | 审计日志 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
AdminShell
|
||||||
|
├── Nav: 用户 | 订单 | 审计 | 退出
|
||||||
|
└── Outlet
|
||||||
|
```
|
||||||
|
|
||||||
|
对照:不进入 user-h5 `page-tree`;独立 `apps/admin-h5`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 页面状态
|
||||||
|
|
||||||
|
| 页 | Loading | Empty | Error | Normal | Locked |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Login | 提交中 | — | 账号错误 | 表单 | — |
|
||||||
|
| Users | 表格骨架 | 无用户 | 拉取失败 | 分页列表 | 未登录→登录 |
|
||||||
|
| UserDetail | 骨架 | 用户不存在 | 失败 | 摘要+操作 | — |
|
||||||
|
| Orders / Audit | 同 Users | 无记录 | 失败 | 列表 | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Business Rules
|
||||||
|
|
||||||
|
| ID | Rule |
|
||||||
|
|---|---|
|
||||||
|
| R1 | 管理员鉴权与终端 `DeviceAuth` **隔离**;禁止用 `X-Device-Key` 访问 `/admin/*` |
|
||||||
|
| R2 | 除 `POST /admin/auth/login` 外,admin API 必须带有效 Admin Token |
|
||||||
|
| R3 | 用户列表默认按 `created_at DESC`;支持 `q` 精确匹配 `users.id`(UUID) |
|
||||||
|
| R4 | 授予会员:`plan ∈ {month,quarter,year}`;写入/更新 `memberships` 为 `active`,`expires_at` 按套餐延长;**必须**写 `admin_audit_logs` |
|
||||||
|
| R5 | Phase A **不**提供封禁/注销(避免误伤;后置) |
|
||||||
|
| R6 | Bootstrap:仅当 `admin_accounts` 为空且配置了 bootstrap 密码时,启动可种子一个超级管理员 |
|
||||||
|
| R7 | 审计日志只追加,不可通过 API 删除/篡改 |
|
||||||
|
| R8 | 文案用愈心谷 lexicon;UI 不出现竞品品牌词 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 数据模型影响
|
||||||
|
|
||||||
|
新增表(见 `.ai/domain/erd.md`):
|
||||||
|
|
||||||
|
- `admin_accounts` — 管理员账号(password_hash)
|
||||||
|
- `admin_sessions` — 会话 token
|
||||||
|
- `admin_audit_logs` — 操作审计
|
||||||
|
|
||||||
|
复用只读:`users` · `profiles` · `memberships` · `orders`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API 需求
|
||||||
|
|
||||||
|
前缀:`/api/v1/admin`
|
||||||
|
|
||||||
|
| Method | Path | 意图 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/auth/login` | 登录,返回 token + account |
|
||||||
|
| POST | `/auth/logout` | 注销当前会话 |
|
||||||
|
| GET | `/me` | 当前管理员 |
|
||||||
|
| GET | `/users` | 列表 `?q=&limit=&offset=` |
|
||||||
|
| GET | `/users/:id` | 详情(含 membership · profiles 摘要 · 近订单) |
|
||||||
|
| POST | `/users/:id/membership/grant` | 授予/延长会员 |
|
||||||
|
| GET | `/orders` | 订单列表 |
|
||||||
|
| GET | `/audit-logs` | 审计列表 |
|
||||||
|
|
||||||
|
信封仍为 `{code,message,data}`。细节同步 OpenAPI。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 权限设计
|
||||||
|
|
||||||
|
| 能力 | Admin Phase A |
|
||||||
|
|---|---|
|
||||||
|
| 登录 / me / logout | ✓ |
|
||||||
|
| 用户读 | ✓ |
|
||||||
|
| 订单读 | ✓ |
|
||||||
|
| 授予会员 | ✓ |
|
||||||
|
| 审计读 | ✓ |
|
||||||
|
| 改用户 status / 删数据 | ✗ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 埋点
|
||||||
|
|
||||||
|
| event | 属性 |
|
||||||
|
|---|---|
|
||||||
|
| `admin_login` | ok / fail_reason |
|
||||||
|
| `admin_user_view` | user_id |
|
||||||
|
| `admin_membership_grant` | user_id, plan |
|
||||||
|
| `admin_page_view` | route |
|
||||||
|
|
||||||
|
Phase A 可先 `console`/本地;不挡验收。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 测试验收标准
|
||||||
|
|
||||||
|
| # | Given / When / Then |
|
||||||
|
|---|---|
|
||||||
|
| T1 | 无 token 调 GET `/admin/users` → 401 |
|
||||||
|
| T2 | 正确账号登录 → 200 + token;错密 → 非 0 code |
|
||||||
|
| T3 | 登录后列表可见已有 users |
|
||||||
|
| T4 | grant membership → memberships active + audit 一条 |
|
||||||
|
| T5 | admin-h5:登录 → 用户列表 → 详情 → 授予 → 审计页可见 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. AI 开发前检查
|
||||||
|
|
||||||
|
- [x] 有 Active Spec(本文件)
|
||||||
|
- [x] feature-map §7 已挂
|
||||||
|
- [x] ERD / API 意图已写
|
||||||
|
- [x] In/Out 边界清晰(无 UGC/达人)
|
||||||
|
- [x] ECR-006 Approved + BACKEND_DESIGN
|
||||||
|
- [x] 实现后过 DoD + Review Report
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Report(Phase A)
|
||||||
|
|
||||||
|
- Feature: 运营后台 Phase A
|
||||||
|
- Scope label: **Feature Complete**(Ops-A;非 P1 Complete 扩展)
|
||||||
|
- Architecture: PASS — admin 与 DeviceAuth 隔离;H→S→R
|
||||||
|
- API: PASS — `/api/v1/admin/*` + OpenAPI
|
||||||
|
- Security: PASS — bcrypt · session token · 审计
|
||||||
|
- Test: PASS — `TestAdminOpsPhaseA` · `TestPlanDays` · `build:admin`
|
||||||
|
- Known Issues: SDK 未并入 admin;RBAC/CMS 后置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 分期
|
||||||
|
|
||||||
|
| Phase | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **A(本 ECR)** | 登录 · 用户 · 订单 · 授予会员 · 审计 · admin-h5 壳 |
|
||||||
|
| B | 内容配置 / 测评上下架 / 封禁注销 |
|
||||||
|
| C | RBAC · 推送 · 优惠券 · 退款工单 |
|
||||||
+10
-1
@@ -1,3 +1,12 @@
|
|||||||
# admin-h5
|
# admin-h5
|
||||||
|
|
||||||
运营后台(内容库、订单、会员)。**业务后置**,当前仅占位。
|
运营后台(内容库、订单、会员)。**Phase A(ECR-006)**:登录 · 用户 · 订单 · 会员授予 · 审计。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 根目录
|
||||||
|
npm install
|
||||||
|
npm run dev:admin
|
||||||
|
# http://localhost:5174/
|
||||||
|
```
|
||||||
|
|
||||||
|
默认管理员:见 `apps/api/config.example.yaml` → `admin.bootstrap_*`(仅空库种子)。
|
||||||
|
|||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<object, object, unknown>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
|
<title>愈心谷 · 运营后台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,5 +2,23 @@
|
|||||||
"name": "@yuxingu/admin-h5",
|
"name": "@yuxingu/admin-h5",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "运营后台占位,业务后置"
|
"type": "module",
|
||||||
|
"description": "运营后台 Phase A(ECR-006)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.0.7",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/** Thin admin API client → /api/v1/admin (proxied to Go). */
|
||||||
|
|
||||||
|
export type ApiEnvelope<T> = { code: number; message: string; data?: T }
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'yuxingu_admin_token'
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string | null) {
|
||||||
|
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
else localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
const token = getToken()
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
|
const res = await fetch(`/api/v1/admin${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const env = (await res.json()) as ApiEnvelope<T>
|
||||||
|
if (!res.ok || env.code !== 0) {
|
||||||
|
throw new Error(env.message || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return env.data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
request<{ token: string; admin: { id: string; username: string } }>('POST', '/auth/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
|
||||||
|
me: () => request<{ id: string; username: string }>('GET', '/me'),
|
||||||
|
users: (q = '') =>
|
||||||
|
request<{ items: Array<{ id: string; status: string; created_at: string }> }>(
|
||||||
|
'GET',
|
||||||
|
`/users?q=${encodeURIComponent(q)}`,
|
||||||
|
),
|
||||||
|
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
|
||||||
|
grant: (id: string, plan: string) =>
|
||||||
|
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
|
||||||
|
orders: () =>
|
||||||
|
request<{
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
kind: string
|
||||||
|
plan?: string
|
||||||
|
amount_cents: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
}>
|
||||||
|
}>('GET', '/orders'),
|
||||||
|
audit: () =>
|
||||||
|
request<{
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
admin_id: string
|
||||||
|
action: string
|
||||||
|
target_type: string
|
||||||
|
target_id: string
|
||||||
|
meta: unknown
|
||||||
|
created_at: string
|
||||||
|
}>
|
||||||
|
}>('GET', '/audit-logs'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserDetail = {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
profiles: Array<{ id: string; relation: string; display_name: string }>
|
||||||
|
membership: {
|
||||||
|
active: boolean
|
||||||
|
plan?: string
|
||||||
|
status: string
|
||||||
|
expires_at?: string
|
||||||
|
ask_quota_left?: number
|
||||||
|
}
|
||||||
|
recent_orders: Array<{
|
||||||
|
id: string
|
||||||
|
kind: string
|
||||||
|
plan?: string
|
||||||
|
status: string
|
||||||
|
amount_cents: number
|
||||||
|
created_at: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void auth.hydrate()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await auth.logout()
|
||||||
|
await router.push({ name: 'login' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="nav">
|
||||||
|
<div class="brand">愈心谷 · 运营</div>
|
||||||
|
<nav>
|
||||||
|
<RouterLink to="/">用户</RouterLink>
|
||||||
|
<RouterLink to="/orders">订单</RouterLink>
|
||||||
|
<RouterLink to="/audit">审计</RouterLink>
|
||||||
|
</nav>
|
||||||
|
<div class="foot">
|
||||||
|
<span class="muted">{{ auth.username || '管理员' }}</span>
|
||||||
|
<button class="btn ghost" type="button" @click="onLogout">退出</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="main">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
|
||||||
|
.nav {
|
||||||
|
padding: 1.25rem 1rem; border-right: 1px solid var(--line);
|
||||||
|
background: rgba(255, 253, 249, 0.85); display: flex; flex-direction: column; gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.brand { font-weight: 700; letter-spacing: 0.02em; }
|
||||||
|
nav { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
nav a { padding: 0.45rem 0.6rem; border-radius: 8px; color: var(--muted); }
|
||||||
|
nav a.router-link-active { background: #f5ebe6; color: var(--accent); font-weight: 600; }
|
||||||
|
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.main { padding: 1.5rem 1.75rem; }
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
.nav { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { adminApi } from '@/api/client'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const items = ref<
|
||||||
|
Array<{
|
||||||
|
id: string
|
||||||
|
admin_id: string
|
||||||
|
action: string
|
||||||
|
target_type: string
|
||||||
|
target_id: string
|
||||||
|
meta: unknown
|
||||||
|
created_at: string
|
||||||
|
}>
|
||||||
|
>([])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await adminApi.audit()
|
||||||
|
items.value = res.items || []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<h1>审计</h1>
|
||||||
|
<p v-if="loading" class="muted">加载中…</p>
|
||||||
|
<p v-else-if="error" class="err">{{ error }}</p>
|
||||||
|
<div v-else class="card">
|
||||||
|
<p v-if="!items.length" class="muted">暂无审计记录</p>
|
||||||
|
<table v-else>
|
||||||
|
<thead>
|
||||||
|
<tr><th>时间</th><th>动作</th><th>目标</th><th>管理员</th><th>meta</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="a in items" :key="a.id">
|
||||||
|
<td>{{ a.created_at }}</td>
|
||||||
|
<td>{{ a.action }}</td>
|
||||||
|
<td>{{ a.target_type }} {{ a.target_id }}</td>
|
||||||
|
<td>{{ a.admin_id }}</td>
|
||||||
|
<td><code>{{ JSON.stringify(a.meta) }}</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
||||||
|
code { font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const username = ref('admin')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(username.value.trim(), password.value)
|
||||||
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||||
|
await router.replace(redirect)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '登录失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="wrap">
|
||||||
|
<form class="card login" @submit.prevent="onSubmit">
|
||||||
|
<h1>运营后台</h1>
|
||||||
|
<p class="muted">愈心谷内部工具 · Phase A</p>
|
||||||
|
<label class="field">
|
||||||
|
<span>用户名</span>
|
||||||
|
<input v-model="username" autocomplete="username" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>密码</span>
|
||||||
|
<input v-model="password" type="password" autocomplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
|
<button class="btn" type="submit" :disabled="loading">
|
||||||
|
{{ loading ? '登录中…' : '登录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
|
||||||
|
.login { width: min(380px, 100%); }
|
||||||
|
h1 { margin: 0 0 0.25rem; font-size: 1.45rem; }
|
||||||
|
.muted { margin: 0 0 1.2rem; }
|
||||||
|
.btn { width: 100%; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { adminApi } from '@/api/client'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const items = ref<
|
||||||
|
Array<{
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
kind: string
|
||||||
|
plan?: string
|
||||||
|
amount_cents: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
}>
|
||||||
|
>([])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await adminApi.orders()
|
||||||
|
items.value = res.items || []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<h1>订单</h1>
|
||||||
|
<p v-if="loading" class="muted">加载中…</p>
|
||||||
|
<p v-else-if="error" class="err">{{ error }}</p>
|
||||||
|
<div v-else class="card">
|
||||||
|
<p v-if="!items.length" class="muted">暂无订单</p>
|
||||||
|
<table v-else>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>用户</th><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="o in items" :key="o.id">
|
||||||
|
<td>{{ o.id }}</td>
|
||||||
|
<td>{{ o.user_id }}</td>
|
||||||
|
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||||
|
<td>{{ o.status }}</td>
|
||||||
|
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||||
|
<td>{{ o.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { adminApi, type UserDetail } from '@/api/client'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const detail = ref<UserDetail | null>(null)
|
||||||
|
const plan = ref('month')
|
||||||
|
const grantMsg = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
detail.value = await adminApi.user(String(route.params.id))
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grant() {
|
||||||
|
grantMsg.value = ''
|
||||||
|
try {
|
||||||
|
await adminApi.grant(String(route.params.id), plan.value)
|
||||||
|
grantMsg.value = '已授予'
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
grantMsg.value = e instanceof Error ? e.message : '授予失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<h1>用户详情</h1>
|
||||||
|
<p v-if="loading" class="muted">加载中…</p>
|
||||||
|
<p v-else-if="error" class="err">{{ error }}</p>
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<div class="card block">
|
||||||
|
<p><strong>ID</strong> {{ detail.id }}</p>
|
||||||
|
<p><strong>状态</strong> {{ detail.status }}</p>
|
||||||
|
<p><strong>创建</strong> {{ detail.created_at }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card block">
|
||||||
|
<h2>成长会员</h2>
|
||||||
|
<p v-if="detail.membership">
|
||||||
|
{{ detail.membership.active ? '有效' : '无效' }} ·
|
||||||
|
{{ detail.membership.plan || '—' }} ·
|
||||||
|
{{ detail.membership.status }} ·
|
||||||
|
到期 {{ detail.membership.expires_at || '—' }}
|
||||||
|
</p>
|
||||||
|
<div class="grant">
|
||||||
|
<select v-model="plan">
|
||||||
|
<option value="month">月</option>
|
||||||
|
<option value="quarter">季</option>
|
||||||
|
<option value="year">年</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" type="button" @click="grant">授予 / 延长</button>
|
||||||
|
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card block">
|
||||||
|
<h2>档案</h2>
|
||||||
|
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="p in detail.profiles" :key="p.id">
|
||||||
|
{{ p.display_name || '未命名' }}({{ p.relation }})· {{ p.id }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card block">
|
||||||
|
<h2>近订单</h2>
|
||||||
|
<p v-if="!detail.recent_orders?.length" class="muted">无订单</p>
|
||||||
|
<table v-else>
|
||||||
|
<thead><tr><th>ID</th><th>类型</th><th>状态</th><th>金额</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="o in detail.recent_orders" :key="o.id">
|
||||||
|
<td>{{ o.id }}</td>
|
||||||
|
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||||
|
<td>{{ o.status }}</td>
|
||||||
|
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
||||||
|
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||||
|
.block { margin-bottom: 1rem; }
|
||||||
|
.grant { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.75rem; }
|
||||||
|
.grant select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { adminApi } from '@/api/client'
|
||||||
|
|
||||||
|
const q = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const items = ref<Array<{ id: string; status: string; created_at: string }>>([])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await adminApi.users(q.value.trim())
|
||||||
|
items.value = res.items || []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<header class="head">
|
||||||
|
<h1>用户</h1>
|
||||||
|
<form class="search" @submit.prevent="load">
|
||||||
|
<input v-model="q" placeholder="精确 user id (UUID)" />
|
||||||
|
<button class="btn" type="submit">查询</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
<p v-if="loading" class="muted">加载中…</p>
|
||||||
|
<p v-else-if="error" class="err">{{ error }}</p>
|
||||||
|
<div v-else class="card">
|
||||||
|
<p v-if="!items.length" class="muted">暂无用户</p>
|
||||||
|
<table v-else>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>状态</th><th>创建时间</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in items" :key="u.id">
|
||||||
|
<td><RouterLink :to="`/users/${u.id}`">{{ u.id }}</RouterLink></td>
|
||||||
|
<td>{{ u.status }}</td>
|
||||||
|
<td>{{ u.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; justify-content: space-between; margin-bottom: 1rem; }
|
||||||
|
h1 { margin: 0; font-size: 1.35rem; }
|
||||||
|
.search { display: flex; gap: 0.5rem; }
|
||||||
|
.search input { min-width: 280px; border: 1px solid var(--line); border-radius: 8px; padding: 0.55rem 0.75rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { getToken } from '@/api/client'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/login', name: 'login', component: () => import('@/pages/LoginPage.vue'), meta: { public: true } },
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layouts/AdminShell.vue'),
|
||||||
|
children: [
|
||||||
|
{ path: '', 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: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta.public) return true
|
||||||
|
if (!getToken()) return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { adminApi, getToken, setToken } from '@/api/client'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref<string | null>(getToken())
|
||||||
|
const username = ref<string>('')
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrate() {
|
||||||
|
if (!token.value) return false
|
||||||
|
try {
|
||||||
|
const me = await adminApi.me()
|
||||||
|
username.value = me.username
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
setToken(null)
|
||||||
|
token.value = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await adminApi.logout()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
setToken(null)
|
||||||
|
token.value = null
|
||||||
|
username.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, username, login, logout, hydrate }
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f3f0eb;
|
||||||
|
--panel: #fffdf9;
|
||||||
|
--ink: #1c1917;
|
||||||
|
--muted: #78716c;
|
||||||
|
--line: #e7e5e4;
|
||||||
|
--accent: #c45c4a;
|
||||||
|
--accent-ink: #fff;
|
||||||
|
--danger: #b91c1c;
|
||||||
|
font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-height: 100vh; background: linear-gradient(160deg, #f7f3ee 0%, #ebe6df 45%, #f3efea 100%); }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button, input, select { font: inherit; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 0.65rem 0.75rem; border-bottom: 1px solid var(--line); font-size: 0.92rem; }
|
||||||
|
th { color: var(--muted); font-weight: 600; }
|
||||||
|
.btn {
|
||||||
|
border: 0; border-radius: 8px; padding: 0.55rem 1rem; cursor: pointer;
|
||||||
|
background: var(--accent); color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); }
|
||||||
|
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.9rem; }
|
||||||
|
.field input, .field select {
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; padding: 0.6rem 0.75rem; background: #fff;
|
||||||
|
}
|
||||||
|
.err { color: var(--danger); font-size: 0.9rem; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.card {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.1rem 1.2rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": { "@/*": ["./src/*"] },
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: '/',
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -19,6 +19,11 @@ deepseek:
|
|||||||
model: "deepseek-chat"
|
model: "deepseek-chat"
|
||||||
timeout_sec: 60
|
timeout_sec: 60
|
||||||
|
|
||||||
|
# Ops admin bootstrap (only seeds when admin_accounts is empty)
|
||||||
|
admin:
|
||||||
|
bootstrap_username: admin
|
||||||
|
bootstrap_password: "change-me" # override in config.local.yaml; never commit secrets
|
||||||
|
|
||||||
# jwt:
|
# jwt:
|
||||||
# secret: ""
|
# secret: ""
|
||||||
# payment:
|
# payment:
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
module github.com/yuxingu/digital-psychology/apps/api
|
module github.com/yuxingu/digital-psychology/apps/api
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
@@ -36,10 +36,10 @@ require (
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
golang.org/x/crypto v0.54.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,16 +87,26 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
|||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ type Config struct {
|
|||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
AppEnv string
|
AppEnv string
|
||||||
DeepSeek DeepSeekConfig
|
DeepSeek DeepSeekConfig
|
||||||
|
Admin AdminConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminConfig for ops console bootstrap (ECR-006).
|
||||||
|
type AdminConfig struct {
|
||||||
|
BootstrapUsername string
|
||||||
|
BootstrapPassword string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepSeekConfig for Ask LLM.
|
// DeepSeekConfig for Ask LLM.
|
||||||
@@ -47,6 +54,10 @@ type fileConfig struct {
|
|||||||
Model string `yaml:"model"`
|
Model string `yaml:"model"`
|
||||||
TimeoutSec int `yaml:"timeout_sec"`
|
TimeoutSec int `yaml:"timeout_sec"`
|
||||||
} `yaml:"deepseek"`
|
} `yaml:"deepseek"`
|
||||||
|
Admin struct {
|
||||||
|
BootstrapUsername string `yaml:"bootstrap_username"`
|
||||||
|
BootstrapPassword string `yaml:"bootstrap_password"`
|
||||||
|
} `yaml:"admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides.
|
// Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides.
|
||||||
@@ -123,6 +134,12 @@ func mergeFile(cfg *Config, path string) error {
|
|||||||
if f.DeepSeek.TimeoutSec > 0 {
|
if f.DeepSeek.TimeoutSec > 0 {
|
||||||
cfg.DeepSeek.TimeoutSec = f.DeepSeek.TimeoutSec
|
cfg.DeepSeek.TimeoutSec = f.DeepSeek.TimeoutSec
|
||||||
}
|
}
|
||||||
|
if f.Admin.BootstrapUsername != "" {
|
||||||
|
cfg.Admin.BootstrapUsername = f.Admin.BootstrapUsername
|
||||||
|
}
|
||||||
|
if f.Admin.BootstrapPassword != "" {
|
||||||
|
cfg.Admin.BootstrapPassword = f.Admin.BootstrapPassword
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +201,12 @@ func applyEnv(cfg *Config) {
|
|||||||
cfg.DeepSeek.TimeoutSec = n
|
cfg.DeepSeek.TimeoutSec = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("ADMIN_BOOTSTRAP_USERNAME"); v != "" {
|
||||||
|
cfg.Admin.BootstrapUsername = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"); v != "" {
|
||||||
|
cfg.Admin.BootstrapPassword = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enabled reports whether DeepSeek can be called.
|
// Enabled reports whether DeepSeek can be called.
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminHandler serves /api/v1/admin/* (no DeviceAuth).
|
||||||
|
type AdminHandler struct {
|
||||||
|
Svc *admin.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts public login + authed admin routes.
|
||||||
|
func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
||||||
|
g := api.Group("/admin")
|
||||||
|
g.POST("/auth/login", h.Login)
|
||||||
|
|
||||||
|
authed := g.Group("")
|
||||||
|
authed.Use(middleware.AdminAuth(h.Svc))
|
||||||
|
authed.POST("/auth/logout", h.Logout)
|
||||||
|
authed.GET("/me", h.Me)
|
||||||
|
authed.GET("/users", h.ListUsers)
|
||||||
|
authed.GET("/users/:id", h.GetUser)
|
||||||
|
authed.POST("/users/:id/membership/grant", h.GrantMembership)
|
||||||
|
authed.GET("/orders", h.ListOrders)
|
||||||
|
authed.GET("/audit-logs", h.ListAudit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) Login(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Username == "" || body.Password == "" {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40001, "username and password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := h.Svc.Login(c.Request.Context(), body.Username, body.Password)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, admin.ErrBadCredentials) {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40103, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50010, "login failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) Logout(c *gin.Context) {
|
||||||
|
token := middleware.BearerToken(c.GetHeader("Authorization"))
|
||||||
|
_ = h.Svc.Logout(c.Request.Context(), token)
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) Me(c *gin.Context) {
|
||||||
|
adminID, ok := middleware.AdminIDFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
me, err := h.Svc.Me(c.Request.Context(), adminID)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50011, "me failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, me)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
items, err := h.Svc.ListUsers(c.Request.Context(), c.Query("q"), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50012, "list users failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) GetUser(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := h.Svc.GetUser(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, admin.ErrUserNotFound) {
|
||||||
|
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50013, "get user failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) GrantMembership(c *gin.Context) {
|
||||||
|
adminID, ok := middleware.AdminIDFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body admin.GrantInput
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Plan == "" {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40003, "plan required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Svc.GrantMembership(c.Request.Context(), adminID, userID, body.Plan); err != nil {
|
||||||
|
if errors.Is(err, admin.ErrInvalidPlan) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40004, "invalid plan")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrUserNotFound) {
|
||||||
|
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50014, "grant failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListOrders(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
items, err := h.Svc.ListOrders(c.Request.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50015, "list orders failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListAudit(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
items, err := h.Svc.ListAuditLogs(c.Request.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50016, "list audit failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
package httpserver
|
package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
@@ -10,6 +13,7 @@ import (
|
|||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||||
|
adminsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
||||||
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
|
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
|
||||||
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
|
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
|
||||||
@@ -27,6 +31,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
|||||||
reportRepo := &repository.ReportRepo{Pool: pool}
|
reportRepo := &repository.ReportRepo{Pool: pool}
|
||||||
relationRepo := &repository.RelationRepo{Pool: pool}
|
relationRepo := &repository.RelationRepo{Pool: pool}
|
||||||
askRepo := &repository.AskRepo{Pool: pool}
|
askRepo := &repository.AskRepo{Pool: pool}
|
||||||
|
adminRepo := &repository.AdminRepo{Pool: pool}
|
||||||
|
|
||||||
var llm *deepseek.Client
|
var llm *deepseek.Client
|
||||||
if cfg.DeepSeek.Enabled() {
|
if cfg.DeepSeek.Enabled() {
|
||||||
@@ -49,6 +54,13 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
|||||||
Reports: reportRepo,
|
Reports: reportRepo,
|
||||||
Quotas: &repository.ImageCardRepo{Pool: pool},
|
Quotas: &repository.ImageCardRepo{Pool: pool},
|
||||||
}
|
}
|
||||||
|
adminSvc := &adminsvc.Service{Repo: adminRepo, Reports: reportRepo}
|
||||||
|
if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{
|
||||||
|
Username: cfg.Admin.BootstrapUsername,
|
||||||
|
Password: cfg.Admin.BootstrapPassword,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("admin bootstrap failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID())
|
r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID())
|
||||||
@@ -62,6 +74,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
|||||||
api.GET("/ping", func(c *gin.Context) {
|
api.GET("/ping", func(c *gin.Context) {
|
||||||
response.OK(c, gin.H{"pong": true})
|
response.OK(c, gin.H{"pong": true})
|
||||||
})
|
})
|
||||||
|
(&handler.AdminHandler{Svc: adminSvc}).Register(api)
|
||||||
|
|
||||||
authed := api.Group("")
|
authed := api.Group("")
|
||||||
authed.Use(middleware.DeviceAuth(pool))
|
authed.Use(middleware.DeviceAuth(pool))
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func doAdminJSON(t *testing.T, r http.Handler, method, path string, body any, token string) (envelope, int) {
|
||||||
|
t.Helper()
|
||||||
|
auth := ""
|
||||||
|
if token != "" {
|
||||||
|
auth = "Bearer " + token
|
||||||
|
}
|
||||||
|
return doAdminAuth(t, r, method, path, body, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func doAdminAuth(t *testing.T, r http.Handler, method, path string, body any, authorization string) (envelope, int) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if body != nil {
|
||||||
|
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, &buf)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if authorization != "" {
|
||||||
|
req.Header.Set("Authorization", authorization)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
var env envelope
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &env)
|
||||||
|
return env, w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminOpsPhaseA(t *testing.T) {
|
||||||
|
r, _ := setupAPI(t)
|
||||||
|
|
||||||
|
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, "")
|
||||||
|
if code != http.StatusUnauthorized || env.Code == 0 {
|
||||||
|
t.Fatalf("expected 401 without token, got http=%d code=%d", code, env.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
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("login failed http=%d code=%d msg=%s body=%s", code, env.Code, env.Message, string(env.Data))
|
||||||
|
}
|
||||||
|
var login struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" {
|
||||||
|
t.Fatalf("login token missing: %v %s", err, env.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
|
||||||
|
|
||||||
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, login.Token)
|
||||||
|
if code != 200 || env.Code != 0 {
|
||||||
|
t.Fatalf("list users failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||||
|
}
|
||||||
|
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("expected users, got %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",
|
||||||
|
}, login.Token)
|
||||||
|
if code != 200 || env.Code != 0 {
|
||||||
|
t.Fatalf("grant failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomicity: membership active AND audit row for same grant.
|
||||||
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID, nil, login.Token)
|
||||||
|
if code != 200 || env.Code != 0 {
|
||||||
|
t.Fatalf("get user failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||||
|
}
|
||||||
|
var detail struct {
|
||||||
|
Membership struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"membership"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Data, &detail); err != nil || !detail.Membership.Active {
|
||||||
|
t.Fatalf("expected active membership after grant: %v %s", err, env.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, login.Token)
|
||||||
|
if code != 200 || env.Code != 0 {
|
||||||
|
t.Fatalf("audit failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||||
|
}
|
||||||
|
var audit struct {
|
||||||
|
Items []struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(env.Data, &audit); err != nil || len(audit.Items) == 0 {
|
||||||
|
t.Fatalf("expected audit rows: %v %s", err, env.Data)
|
||||||
|
}
|
||||||
|
if audit.Items[0].Action != "membership.grant" || audit.Items[0].TargetID != userID {
|
||||||
|
t.Fatalf("unexpected audit %#v", audit.Items[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout with lowercase bearer must invalidate session.
|
||||||
|
env, code = doAdminAuth(t, r, http.MethodPost, "/api/v1/admin/auth/logout", nil, "bearer "+login.Token)
|
||||||
|
if code != 200 || env.Code != 0 {
|
||||||
|
t.Fatalf("logout failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||||
|
}
|
||||||
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, login.Token)
|
||||||
|
if code != http.StatusUnauthorized || env.Code == 0 {
|
||||||
|
t.Fatalf("expected 401 after logout, got http=%d code=%d", code, env.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ func setupAPI(t *testing.T) (*gin.Engine, string) {
|
|||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
cfg.Admin.BootstrapUsername = "admin"
|
||||||
|
cfg.Admin.BootstrapPassword = "change-me"
|
||||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("postgres unavailable (run npm run deps:up): %v", err)
|
t.Skipf("postgres unavailable (run npm run deps:up): %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
const AdminIDKey ctxKey = "admin_id"
|
||||||
|
const AdminTokenHeader = "Authorization"
|
||||||
|
|
||||||
|
// AdminSessionResolver looks up a valid admin session by token.
|
||||||
|
type AdminSessionResolver interface {
|
||||||
|
ResolveAdminID(ctx context.Context, token string) (uuid.UUID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAuth requires Bearer token for /admin routes.
|
||||||
|
func AdminAuth(resolver AdminSessionResolver) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := BearerToken(c.GetHeader(AdminTokenHeader))
|
||||||
|
if token == "" {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, err := resolver.ResolveAdminID(c.Request.Context(), token)
|
||||||
|
if err != nil || adminID == uuid.Nil {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(string(AdminIDKey), adminID.String())
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminIDFromContext returns the authenticated admin id.
|
||||||
|
func AdminIDFromContext(c *gin.Context) (uuid.UUID, bool) {
|
||||||
|
v, ok := c.Get(string(AdminIDKey))
|
||||||
|
if !ok {
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(v.(string))
|
||||||
|
return id, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BearerToken extracts an opaque token from Authorization (Bearer / bearer).
|
||||||
|
func BearerToken(h string) string {
|
||||||
|
h = strings.TrimSpace(h)
|
||||||
|
if h == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(h)
|
||||||
|
if strings.HasPrefix(lower, "bearer ") {
|
||||||
|
return strings.TrimSpace(h[len("bearer "):])
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBearerToken(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in, want string
|
||||||
|
}{
|
||||||
|
{"", ""},
|
||||||
|
{"adm_abc", "adm_abc"},
|
||||||
|
{"Bearer adm_x", "adm_x"},
|
||||||
|
{"bearer adm_y", "adm_y"},
|
||||||
|
{"BEARER adm_z", "adm_z"},
|
||||||
|
{" Bearer adm_w ", "adm_w"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := BearerToken(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("BearerToken(%q)=%q want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminRepo persists ops-admin accounts, sessions, and audit logs.
|
||||||
|
type AdminRepo struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAccount is an internal operator account.
|
||||||
|
type AdminAccount struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Username string
|
||||||
|
PasswordHash string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAccounts returns non-deleted admin count.
|
||||||
|
func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.Pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM admin_accounts WHERE deleted_at IS NULL`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAccount inserts an admin account.
|
||||||
|
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)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByUsername loads an active admin by username.
|
||||||
|
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
|
||||||
|
FROM admin_accounts
|
||||||
|
WHERE username=$1 AND deleted_at IS NULL`, username,
|
||||||
|
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAccountByID loads admin by id.
|
||||||
|
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
|
||||||
|
FROM admin_accounts
|
||||||
|
WHERE id=$1 AND deleted_at IS NULL`, id,
|
||||||
|
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession stores an opaque admin session token.
|
||||||
|
func (r *AdminRepo) CreateSession(ctx context.Context, adminID uuid.UUID, token string, expires time.Time) error {
|
||||||
|
_, err := r.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_sessions(admin_id, token, expires_at)
|
||||||
|
VALUES ($1,$2,$3)`, adminID, token, expires)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSession returns admin_id for a valid token.
|
||||||
|
func (r *AdminRepo) ResolveSession(ctx context.Context, token string) (uuid.UUID, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
err := r.Pool.QueryRow(ctx, `
|
||||||
|
SELECT s.admin_id FROM admin_sessions s
|
||||||
|
JOIN admin_accounts a ON a.id=s.admin_id AND a.deleted_at IS NULL AND a.status='active'
|
||||||
|
WHERE s.token=$1 AND s.expires_at > now()`, token).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return uuid.Nil, errors.New("invalid session")
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession removes a session by token.
|
||||||
|
func (r *AdminRepo) DeleteSession(ctx context.Context, token string) error {
|
||||||
|
_, err := r.Pool.Exec(ctx, `DELETE FROM admin_sessions WHERE token=$1`, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertAudit appends an immutable audit row.
|
||||||
|
func (r *AdminRepo) InsertAudit(ctx context.Context, adminID uuid.UUID, action, targetType, targetID string, meta json.RawMessage) error {
|
||||||
|
if meta == nil {
|
||||||
|
meta = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
_, err := r.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||||
|
VALUES ($1,$2,$3,$4,$5)`, adminID, action, targetType, targetID, meta)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserListItem is a compact user row for admin tables.
|
||||||
|
type UserListItem struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns users newest first; q matches id when UUID.
|
||||||
|
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]UserListItem, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT id, status, created_at FROM users
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND ($1 = '' OR id::text = $1)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []UserListItem
|
||||||
|
for rows.Next() {
|
||||||
|
var u UserListItem
|
||||||
|
if err := rows.Scan(&u.ID, &u.Status, &u.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserExists reports whether user id is present.
|
||||||
|
func (r *AdminRepo) UserExists(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := r.Pool.QueryRow(ctx, `
|
||||||
|
SELECT 1 FROM users WHERE id=$1 AND deleted_at IS NULL`, id).Scan(&n)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileBrief for admin user detail.
|
||||||
|
type ProfileBrief struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProfilesForUser returns profile briefs.
|
||||||
|
func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) ([]ProfileBrief, error) {
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT id, relation, display_name FROM profiles
|
||||||
|
WHERE user_id=$1 AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at ASC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ProfileBrief
|
||||||
|
for rows.Next() {
|
||||||
|
var p ProfileBrief
|
||||||
|
if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderListItem for admin order tables.
|
||||||
|
type OrderListItem struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Plan *string `json:"plan,omitempty"`
|
||||||
|
AmountCents int `json:"amount_cents"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrders lists orders; optional user filter.
|
||||||
|
func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]OrderListItem, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT id, user_id, kind, plan, amount_cents, status, created_at
|
||||||
|
FROM orders
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND ($1::uuid IS NULL OR user_id = $1)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, userID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []OrderListItem
|
||||||
|
for rows.Next() {
|
||||||
|
var o OrderListItem
|
||||||
|
if err := rows.Scan(&o.ID, &o.UserID, &o.Kind, &o.Plan, &o.AmountCents, &o.Status, &o.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantMembershipWithAudit upserts membership and appends audit in one transaction.
|
||||||
|
func (r *AdminRepo) GrantMembershipWithAudit(
|
||||||
|
ctx context.Context,
|
||||||
|
adminID, userID uuid.UUID,
|
||||||
|
plan string,
|
||||||
|
days int,
|
||||||
|
meta json.RawMessage,
|
||||||
|
) error {
|
||||||
|
tx, err := r.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left)
|
||||||
|
VALUES ($1,$2,'active', now() + ($3 * interval '1 day'), 100)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
plan=EXCLUDED.plan, status='active',
|
||||||
|
expires_at=(CASE
|
||||||
|
WHEN memberships.expires_at IS NOT NULL AND memberships.expires_at > now()
|
||||||
|
THEN memberships.expires_at ELSE now()
|
||||||
|
END) + ($3 * interval '1 day'),
|
||||||
|
ask_quota_left=100, updated_at=now()`,
|
||||||
|
userID, plan, days); err != nil {
|
||||||
|
return 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,'membership.grant','user',$2,$3)`, adminID, userID.String(), meta); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditListItem for admin audit table.
|
||||||
|
type AuditListItem struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
AdminID uuid.UUID `json:"admin_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
TargetType string `json:"target_type"`
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
Meta json.RawMessage `json:"meta"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditLogs returns newest audit rows.
|
||||||
|
func (r *AdminRepo) ListAuditLogs(ctx context.Context, limit, offset int) ([]AuditListItem, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT id, admin_id, action, target_type, target_id, meta, created_at
|
||||||
|
FROM admin_audit_logs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2`, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []AuditListItem
|
||||||
|
for rows.Next() {
|
||||||
|
var a AuditListItem
|
||||||
|
if err := rows.Scan(&a.ID, &a.AdminID, &a.Action, &a.TargetType, &a.TargetID, &a.Meta, &a.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -91,11 +91,11 @@ func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID)
|
|||||||
|
|
||||||
// MembershipRow is the current membership snapshot for a user.
|
// MembershipRow is the current membership snapshot for a user.
|
||||||
type MembershipRow struct {
|
type MembershipRow struct {
|
||||||
Plan string
|
Plan string `json:"plan,omitempty"`
|
||||||
Status string
|
Status string `json:"status"`
|
||||||
ExpiresAt *time.Time
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
AskQuotaLeft int
|
AskQuotaLeft int `json:"ask_quota_left,omitempty"`
|
||||||
Active bool
|
Active bool `json:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMembership returns membership status; missing row → inactive.
|
// GetMembership returns membership status; missing row → inactive.
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// Package admin implements ops-console use cases (ECR-006).
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service is ops-admin application layer.
|
||||||
|
type Service struct {
|
||||||
|
Repo *repository.AdminRepo
|
||||||
|
Reports *repository.ReportRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// BootstrapConfig seeds the first admin when table is empty.
|
||||||
|
type BootstrapConfig struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureBootstrap creates the first admin from config when needed.
|
||||||
|
func (s *Service) EnsureBootstrap(ctx context.Context, cfg BootstrapConfig) error {
|
||||||
|
if cfg.Username == "" || cfg.Password == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := s.Repo.CountAccounts(ctx)
|
||||||
|
if err != nil || n > 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.Repo.CreateAccount(ctx, cfg.Username, string(hash))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResult is returned after successful login.
|
||||||
|
type LoginResult struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
Admin AdminMe `json:"admin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMe is the public admin profile.
|
||||||
|
type AdminMe struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrBadCredentials = errString("invalid credentials")
|
||||||
|
ErrInvalidPlan = errString("invalid plan")
|
||||||
|
ErrUserNotFound = errString("user not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
type errString string
|
||||||
|
|
||||||
|
func (e errString) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// Login verifies password and issues a session token.
|
||||||
|
func (s *Service) Login(ctx context.Context, username, password string) (*LoginResult, error) {
|
||||||
|
acc, err := s.Repo.FindByUsername(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if acc == nil || acc.Status != "active" {
|
||||||
|
return nil, ErrBadCredentials
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(acc.PasswordHash), []byte(password)) != nil {
|
||||||
|
return nil, ErrBadCredentials
|
||||||
|
}
|
||||||
|
token, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
exp := time.Now().UTC().Add(12 * time.Hour)
|
||||||
|
if err := s.Repo.CreateSession(ctx, acc.ID, token, exp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &LoginResult{
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: exp,
|
||||||
|
Admin: AdminMe{ID: acc.ID, Username: acc.Username},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveAdminID implements middleware.AdminSessionResolver.
|
||||||
|
func (s *Service) ResolveAdminID(ctx context.Context, token string) (uuid.UUID, error) {
|
||||||
|
return s.Repo.ResolveSession(ctx, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout deletes the session for token.
|
||||||
|
func (s *Service) Logout(ctx context.Context, token string) error {
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.Repo.DeleteSession(ctx, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me returns the current admin profile.
|
||||||
|
func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
|
||||||
|
acc, err := s.Repo.FindAccountByID(ctx, adminID)
|
||||||
|
if err != nil || acc == nil {
|
||||||
|
return nil, errors.New("admin not found")
|
||||||
|
}
|
||||||
|
return &AdminMe{ID: acc.ID, Username: acc.Username}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers lists terminal users.
|
||||||
|
func (s *Service) ListUsers(ctx context.Context, q string, limit, offset int) ([]repository.UserListItem, error) {
|
||||||
|
return s.Repo.ListUsers(ctx, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDetail is admin view of one user.
|
||||||
|
type UserDetail struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Profiles []repository.ProfileBrief `json:"profiles"`
|
||||||
|
Membership *repository.MembershipRow `json:"membership"`
|
||||||
|
Orders []repository.OrderListItem `json:"recent_orders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser loads user detail for admin.
|
||||||
|
func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, error) {
|
||||||
|
ok, err := s.Repo.UserExists(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
users, err := s.Repo.ListUsers(ctx, userID.String(), 1, 0)
|
||||||
|
if err != nil || len(users) == 0 {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
profiles, err := s.Repo.ListProfilesForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mem, err := s.Reports.GetMembership(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
orders, err := s.Repo.ListOrders(ctx, &userID, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &UserDetail{
|
||||||
|
ID: users[0].ID,
|
||||||
|
Status: users[0].Status,
|
||||||
|
CreatedAt: users[0].CreatedAt,
|
||||||
|
Profiles: profiles,
|
||||||
|
Membership: mem,
|
||||||
|
Orders: orders,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantInput for membership grant.
|
||||||
|
type GrantInput struct {
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantMembership extends membership and writes audit.
|
||||||
|
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
|
||||||
|
days, err := planDays(plan)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok, err := s.Repo.UserExists(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{"plan": plan, "days": days})
|
||||||
|
return s.Repo.GrantMembershipWithAudit(ctx, adminID, userID, plan, days, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrders lists commerce orders.
|
||||||
|
func (s *Service) ListOrders(ctx context.Context, limit, offset int) ([]repository.OrderListItem, error) {
|
||||||
|
return s.Repo.ListOrders(ctx, nil, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditLogs lists audit entries.
|
||||||
|
func (s *Service) ListAuditLogs(ctx context.Context, limit, offset int) ([]repository.AuditListItem, error) {
|
||||||
|
return s.Repo.ListAuditLogs(ctx, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func planDays(plan string) (int, error) {
|
||||||
|
switch plan {
|
||||||
|
case "month":
|
||||||
|
return 31, nil
|
||||||
|
case "quarter":
|
||||||
|
return 92, nil
|
||||||
|
case "year":
|
||||||
|
return 366, nil
|
||||||
|
default:
|
||||||
|
return 0, ErrInvalidPlan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToken() (string, error) {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "adm_" + hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPlanDays(t *testing.T) {
|
||||||
|
cases := map[string]int{"month": 31, "quarter": 92, "year": 366}
|
||||||
|
for plan, want := range cases {
|
||||||
|
got, err := planDays(plan)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Fatalf("planDays(%s)=%d,%v want %d", plan, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := planDays("week"); err == nil {
|
||||||
|
t.Fatal("expected invalid plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS admin_audit_logs;
|
||||||
|
DROP TABLE IF EXISTS admin_sessions;
|
||||||
|
DROP TABLE IF EXISTS admin_accounts;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Ops admin Phase A (ECR-006)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_accounts (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username varchar(64) NOT NULL UNIQUE,
|
||||||
|
password_hash text NOT NULL,
|
||||||
|
status varchar(32) NOT NULL DEFAULT 'active',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
admin_id uuid NOT NULL REFERENCES admin_accounts(id),
|
||||||
|
token varchar(128) NOT NULL UNIQUE,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_sessions_admin_id ON admin_sessions(admin_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires ON admin_sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_audit_logs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
admin_id uuid NOT NULL REFERENCES admin_accounts(id),
|
||||||
|
action varchar(64) NOT NULL,
|
||||||
|
target_type varchar(32) NOT NULL DEFAULT '',
|
||||||
|
target_id varchar(64) NOT NULL DEFAULT '',
|
||||||
|
meta jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_audit_admin ON admin_audit_logs(admin_id);
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Backend Design: ECR-006 运营后台 Phase A
|
||||||
|
|
||||||
|
> Architect 产出;Engineer 只消费本文件。
|
||||||
|
> 实现纪律:`$ESS_ROOT/technology-governance/BACKEND_STANDARD.md`
|
||||||
|
> 项目分层权威:`.ai/architecture.md` · `.ai/architecture/go-services.md`(Handler → Service → Repository)
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| ID | BD-2026-006 |
|
||||||
|
| ECR | ECR-006 |
|
||||||
|
| Change Level | L2 |
|
||||||
|
| Status | Approved |
|
||||||
|
| Author | Architect |
|
||||||
|
| Date | 2026-08-06 |
|
||||||
|
| Risk | Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- 问题 / 目标:P1 Complete 后缺少内部运维面;需可审计的用户/订单查询与会员授予。
|
||||||
|
- 非目标:UGC/达人/CMS/RBAC/真支付。
|
||||||
|
- 相关 Spec / ADR:`.ai/product/feature-spec/ops-admin.md`;无新 ADR(栈与分层不变)。
|
||||||
|
|
||||||
|
## Architecture Change
|
||||||
|
|
||||||
|
- 是否改变分层边界:No
|
||||||
|
- 受影响层:API · Application(新 `service/admin`)· Infrastructure(repo + migration)· UI(`apps/admin-h5`)
|
||||||
|
|
||||||
|
## Module Changes
|
||||||
|
|
||||||
|
| Module | Layer | Change | Must NOT |
|
||||||
|
|--------|-------|--------|----------|
|
||||||
|
| `handler/admin*.go` | API | bind/auth 头/调 service | SQL;权益规则细节堆在 handler |
|
||||||
|
| `service/admin` | Application | 登录、列表、grant、审计 | 直连 gin |
|
||||||
|
| `repository/admin_repo.go` | Infrastructure | SQL | 业务解锁语义外泄 |
|
||||||
|
| `middleware/admin_auth.go` | API | 校验 admin session | 复用 DeviceAuth 冒充 |
|
||||||
|
| `apps/admin-h5` | UI | Vue3 控制台 | 裸 fetch 拼完整 URL(走 thin api → sdk 或本地 client) |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
admin-h5
|
||||||
|
→ POST /admin/auth/login → admin_accounts 校验 → 写 admin_sessions → token
|
||||||
|
→ Bearer token → AdminAuth → service/admin
|
||||||
|
→ AdminRepo / ReportRepo(memberships,orders) / ProfileRepo 摘要
|
||||||
|
→ grant → UPDATE memberships + INSERT admin_audit_logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Changes
|
||||||
|
|
||||||
|
- 契约:`apps/api/proto/openapi.yaml` 增补 `/admin/*`
|
||||||
|
- 兼容:新增路径,不影响既有 user API
|
||||||
|
- 信封:项目既有 `{code,message,data}`
|
||||||
|
|
||||||
|
## Database Changes
|
||||||
|
|
||||||
|
- `admin_accounts` · `admin_sessions` · `admin_audit_logs`(见 ERD)
|
||||||
|
- Immutable observation:审计表只追加
|
||||||
|
- Migration:`000010_admin_ops.up.sql` / `.down.sql`
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
- Required:YES
|
||||||
|
- 步骤:expand(建表)→ 启动 bootstrap 种子(可选)→ 无 contract
|
||||||
|
- 回滚:down migration 删三表(开发环境可接受)
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
- 登录失败:统一模糊文案(不暴露是否用户存在细节过度)· 审计可选记 fail
|
||||||
|
- Token 无效/过期:401
|
||||||
|
- grant:用户不存在 404;plan 非法 400;DB 错 500
|
||||||
|
- 幂等:grant 非严格幂等;重复授予延长 expires_at 并再记审计
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- Unit:password verify · plan duration
|
||||||
|
- Integration:login → list users → grant → audit
|
||||||
|
- API:401 without token
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
- 代码回滚去掉 admin 路由注册
|
||||||
|
- down migration
|
||||||
|
- 配置去掉 bootstrap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Change Boundary(mandatory)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Change Level: L2
|
||||||
|
Change: 新增 admin 鉴权面与运维读写 API + admin-h5
|
||||||
|
|
||||||
|
Affected:
|
||||||
|
Domain:
|
||||||
|
- AdminAccount / AdminSession / AdminAuditLog(运维身份,非终端 User)
|
||||||
|
Application:
|
||||||
|
- service/admin(login, users, orders, grant, audit)
|
||||||
|
Infrastructure:
|
||||||
|
- AdminRepo · migration 000010
|
||||||
|
API:
|
||||||
|
- /api/v1/admin/*
|
||||||
|
Migration: Required
|
||||||
|
Tests:
|
||||||
|
- admin auth + grant path
|
||||||
|
Risk: Medium
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Regression Check
|
||||||
|
|
||||||
|
- [x] 无重复业务逻辑(会员写入复用既有 memberships 语义)
|
||||||
|
- [x] 无绕过 Service
|
||||||
|
- [x] 无 Handler 直连 DB
|
||||||
|
- [x] 无 utility dumping
|
||||||
|
- [x] 无隐藏全局状态
|
||||||
|
- [x] bootstrap 配置可追踪(config.example.yaml)
|
||||||
|
- [x] 未简化既有分层
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trace
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| BACKEND_DESIGN-ID | BD-2026-006 |
|
||||||
|
| Implementation Commit | (实现后填) |
|
||||||
|
| Review | (实现后填) |
|
||||||
|
| TRACEABILITY row | ECR-006 |
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# CHANGELOG — ESS process artifacts
|
# CHANGELOG — ESS process artifacts
|
||||||
|
|
||||||
|
## 2026-08-06
|
||||||
|
|
||||||
|
- ESS 升级后接入 `docs/BACKEND_DESIGN/`(BACKEND_STANDARD 闭环)
|
||||||
|
- **ECR-006** Closed:运营后台 Phase A(Spec `ops-admin` · BD-2026-006 · admin API + admin-h5)
|
||||||
|
|
||||||
## 2026-08-05
|
## 2026-08-05
|
||||||
|
|
||||||
- Bind ESS v1.0(`--bind-only`):`docs/` 流程目录 + script wrappers
|
- Bind ESS v1.0(`--bind-only`):`docs/` 流程目录 + script wrappers
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# CODE_REVIEW — ECR-006
|
||||||
|
|
||||||
|
**Verdict:** Approve(Feature Complete · Ops-A)
|
||||||
|
|
||||||
|
Date: 2026-08-06 · Re-review after P1/P2 fixes
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [x] Handler → Service → Repository;admin 路由未挂 DeviceAuth
|
||||||
|
- [x] Feature Spec Active + BACKEND_DESIGN BD-2026-006
|
||||||
|
- [x] OpenAPI 增补 `/admin/*`
|
||||||
|
- [x] 文件 ≤400;integration 绿
|
||||||
|
- [x] Grant + Audit **同事务**(`GrantMembershipWithAudit`)
|
||||||
|
- [x] Logout 复用 `middleware.BearerToken`(大小写无关)
|
||||||
|
- [x] Bootstrap 失败打 `log.Printf`
|
||||||
|
|
||||||
|
## Prior findings — resolved
|
||||||
|
|
||||||
|
| Was | Fix |
|
||||||
|
|-----|-----|
|
||||||
|
| P1 grant/audit 非原子 | `AdminRepo.GrantMembershipWithAudit` 单事务 |
|
||||||
|
| P2 logout Bearer 大小写 | 共享 `BearerToken` |
|
||||||
|
| P2 bootstrap 静默失败 | router 记录 `admin bootstrap failed` |
|
||||||
|
|
||||||
|
## Residual(不阻断)
|
||||||
|
|
||||||
|
- session token 明文落库(后置 hash)
|
||||||
|
- bootstrap 多实例 TOCTOU
|
||||||
|
- admin 未进 `@yuxingu/sdk`
|
||||||
|
- RBAC / CMS → Phase B+
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# ECR-006
|
||||||
|
|
||||||
|
**Title:** 运营后台 Phase A(登录 · 用户/订单只读 · 会员授予 · 审计 · admin-h5)
|
||||||
|
**Status:** Closed
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Change Level:** L2(新能力;栈不变)
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
1. Active Feature Spec:`.ai/product/feature-spec/ops-admin.md`
|
||||||
|
2. 新增表 `admin_accounts` / `admin_sessions` / `admin_audit_logs`
|
||||||
|
3. Admin API:`/api/v1/admin/*`(与 DeviceAuth 隔离)
|
||||||
|
4. `apps/admin-h5` 从占位落地 Vue3 壳 + 登录/用户/订单/审计页
|
||||||
|
5. 架构文档:`admin-h5` 由 Deferred → Phase A Active
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
UGC · 达人 · CMS · 推送 · RBAC 分角色 · 真支付配置 · 封禁注销
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [x] Spec T1–T5
|
||||||
|
- [x] `go test ./...` · admin integration
|
||||||
|
- [x] `npm run build -w @yuxingu/admin-h5`
|
||||||
|
- [x] BACKEND_DESIGN Approved 已落地
|
||||||
|
- [x] TRACEABILITY / STATE / Review Report
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
PRODUCT_SPEC / ENGINEERING_SPEC / BACKEND_DESIGN / HANDOFF / TASK / STATE / TEST_REPORT / CODE_REVIEW under ECR-006
|
||||||
|
Feature Spec:`ops-admin.md`
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-006 运营后台 Phase A
|
||||||
|
|
||||||
|
权威设计:`docs/BACKEND_DESIGN/ECR-006-ops-admin-phase-a.md`
|
||||||
|
产品:`.ai/product/feature-spec/ops-admin.md`
|
||||||
|
|
||||||
|
## Implement order
|
||||||
|
|
||||||
|
1. Migration `000010_admin_ops` + ERD 同步
|
||||||
|
2. `repository/admin_repo.go`
|
||||||
|
3. `service/admin`(auth · users · orders · grant · audit)
|
||||||
|
4. `middleware.AdminAuth` + `handler` 注册(**独立** Group,勿挂 DeviceAuth)
|
||||||
|
5. config bootstrap + `config.example.yaml`
|
||||||
|
6. OpenAPI `/admin/*` 最小路径
|
||||||
|
7. `apps/admin-h5` Vite Vue3:login + shell + 四页
|
||||||
|
8. root scripts:`dev:admin` / `build:admin`
|
||||||
|
9. 测试:service 或 integration
|
||||||
|
10. 更新 architecture / go-services / feature-map / TRACEABILITY
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- 函数 ≤50 行 · 文件 ≤400 行
|
||||||
|
- 密码 bcrypt;session token 随机 opaque
|
||||||
|
- admin-h5 禁止页面内裸拼绝对 API URL
|
||||||
|
- 不提交 `config.local.yaml`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
ECR Acceptance 勾完 + DoD Review Report
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# HANDOFF — ECR-006 Architect → Engineer
|
||||||
|
|
||||||
|
## Consume
|
||||||
|
|
||||||
|
1. `.ai/product/feature-spec/ops-admin.md`(Active)
|
||||||
|
2. `docs/ECR/ECR-006-ops-admin-phase-a.md`
|
||||||
|
3. `docs/BACKEND_DESIGN/ECR-006-ops-admin-phase-a.md`(**BD-2026-006**)
|
||||||
|
4. `docs/ENGINEERING_SPEC/ECR-006-ops-admin-phase-a.md`
|
||||||
|
5. `$ESS_ROOT/technology-governance/BACKEND_STANDARD.md`(分层纪律;目录名从项目既有)
|
||||||
|
|
||||||
|
## Do
|
||||||
|
|
||||||
|
按 ENGINEERING_SPEC Implement order 落地 Phase A。
|
||||||
|
|
||||||
|
## Do not
|
||||||
|
|
||||||
|
- 扩到 UGC / CMS / RBAC
|
||||||
|
- Handler → DB
|
||||||
|
- 用 DeviceAuth 保护 admin 路由
|
||||||
|
|
||||||
|
## Return
|
||||||
|
|
||||||
|
HANDOFF engineer→reviewer + TEST_REPORT + CODE_REVIEW 勾选;STATE.owner → reviewer
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# HANDOFF — ECR-006 Engineer → Reviewer
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
- Migration `000010_admin_ops`
|
||||||
|
- `service/admin` · `AdminRepo` · `AdminHandler` · `AdminAuth`
|
||||||
|
- `apps/admin-h5` Phase A pages
|
||||||
|
- OpenAPI admin paths
|
||||||
|
- TEST_REPORT / integration `TestAdminOpsPhaseA`
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
`docs/TEST_REPORT/ECR-006.md` · `docs/CODE_REVIEW/ECR-006.md`
|
||||||
|
|
||||||
|
## Ask
|
||||||
|
|
||||||
|
Reviewer 勾选 Approve;可 Closed ECR-006。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-006 运营后台 Phase A
|
||||||
|
|
||||||
|
对齐 Feature Spec:`.ai/product/feature-spec/ops-admin.md`
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
内部 Admin 可登录 `admin-h5`,查询用户与订单,授予成长会员并查看审计。
|
||||||
|
|
||||||
|
## Actors
|
||||||
|
|
||||||
|
Admin(单一角色 Phase A)
|
||||||
|
|
||||||
|
## Acceptance(产品)
|
||||||
|
|
||||||
|
1. 无终端用户可访问 admin API
|
||||||
|
2. 管理员可完成 Spec §4 主路径
|
||||||
|
3. 授予会员后用户侧权益与既有 membership 规则一致
|
||||||
|
4. 每次授予可在审计页追溯
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
见 Spec In/Out · ECR Non-goals
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
| Product · Domain · Lexicon · Feature Spec · DoD | **`.ai/`** | `.ai/product/` · `.ai/domain.md` · `.ai/definition-of-done.md` |
|
| Product · Domain · Lexicon · Feature Spec · DoD | **`.ai/`** | `.ai/product/` · `.ai/domain.md` · `.ai/definition-of-done.md` |
|
||||||
| Architecture · Forbidden · ADR | **`.ai/`** | `.ai/architecture.md` · `.ai/forbidden.md` · `.ai/adr/` |
|
| Architecture · Forbidden · ADR | **`.ai/`** | `.ai/architecture.md` · `.ai/forbidden.md` · `.ai/adr/` |
|
||||||
| Change process · ECR · Task · Handoff · Roles | **ESS** | `docs/ECR/` · `docs/TASKS/` · `docs/HANDOFF/` · `docs/STATE/` |
|
| Change process · ECR · Task · Handoff · Roles | **ESS** | `docs/ECR/` · `docs/TASKS/` · `docs/HANDOFF/` · `docs/STATE/` |
|
||||||
|
| Backend change design(涉后台) | **ESS** | `docs/BACKEND_DESIGN/` + `$ESS_ROOT/.../BACKEND_STANDARD.md` |
|
||||||
| Stack lock (mirror) | ESS + `.ai/` | 本文 + `.ai/tech-stack.md` |
|
| Stack lock (mirror) | ESS + `.ai/` | 本文 + `.ai/tech-stack.md` |
|
||||||
|
|
||||||
冲突时:领域与完成标准以 `.ai/` 为准;变更分级与角色门禁以 ESS 为准。
|
冲突时:领域与完成标准以 `.ai/` 为准;变更分级与角色门禁以 ESS 为准。
|
||||||
@@ -45,9 +46,9 @@
|
|||||||
|
|
||||||
## Active anchors
|
## Active anchors
|
||||||
|
|
||||||
- ECR: **ECR-005** H5/OpenAPI hygiene(Approved);ECR-001–004 Closed / shipped
|
- ECR: **ECR-006** 运营后台 Phase A(Closed);ECR-001–005 Closed / shipped
|
||||||
- EXP: (无)
|
- EXP: (无)
|
||||||
- STATE: `docs/STATE/ECR-005.md`
|
- STATE: `docs/STATE/ECR-006.md`
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
- ADR: `.ai/adr/0007-ess-ai-dual-track.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**)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Product/DoD 权威:`.ai/`(不在此复制长文)
|
|||||||
1. 语言契约:只用 `.ai/product/lexicon.md` 中的愈心谷用词;禁止竞品品牌词与恐吓/吉凶话术。
|
1. 语言契约:只用 `.ai/product/lexicon.md` 中的愈心谷用词;禁止竞品品牌词与恐吓/吉凶话术。
|
||||||
2. API 信封固定为 `{code,message,data}`;不发明表名、端点、领域同义词。
|
2. API 信封固定为 `{code,message,data}`;不发明表名、端点、领域同义词。
|
||||||
3. 调用链:UI → `@yuxingu/sdk` → Handler → Service → Repository → DB。
|
3. 调用链:UI → `@yuxingu/sdk` → Handler → Service → Repository → DB。
|
||||||
4. 新功能 / 扩行为:必须有 Active `.ai/product/feature-spec/<id>.md`;L2+ 另须 Approved `docs/ECR/`。
|
4. 新功能 / 扩行为:必须有 Active `.ai/product/feature-spec/<id>.md`;L2+ 另须 Approved `docs/ECR/`;**涉后台**另须 Approved `docs/BACKEND_DESIGN/`。
|
||||||
5. 本地默认:本机 `go run` + Vite;compose 只起 DB。
|
5. 本地默认:本机 `go run` + Vite;compose 只起 DB。
|
||||||
6. Done ≠ build 通过:须过 `.ai/definition-of-done.md` 并输出 Review Report。
|
6. Done ≠ build 通过:须过 `.ai/definition-of-done.md` 并输出 Review Report。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# STATE — ECR-006
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| ECR | ECR-006 |
|
||||||
|
| Status | Closed |
|
||||||
|
| Owner | — |
|
||||||
|
| Phase | done |
|
||||||
|
| Spec | `.ai/product/feature-spec/ops-admin.md` |
|
||||||
|
| BACKEND_DESIGN | BD-2026-006 Approved |
|
||||||
|
| Review | PASS · CODE_REVIEW/ECR-006.md |
|
||||||
|
| Updated | 2026-08-06 |
|
||||||
|
|
||||||
|
Phase A shipped: admin API + admin-h5.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
id: TASK-006-ECR006
|
||||||
|
ecr: ECR-006
|
||||||
|
title: Ops admin Phase A implementation
|
||||||
|
role: engineer
|
||||||
|
status: open
|
||||||
|
change_level: L2
|
||||||
|
inputs:
|
||||||
|
- docs/HANDOFF/ECR-006-architect-to-engineer.md
|
||||||
|
- docs/BACKEND_DESIGN/ECR-006-ops-admin-phase-a.md
|
||||||
|
- .ai/product/feature-spec/ops-admin.md
|
||||||
|
outputs:
|
||||||
|
- apps/api admin package + migration
|
||||||
|
- apps/admin-h5 Vue shell
|
||||||
|
- docs/TEST_REPORT/ECR-006.md
|
||||||
|
acceptance:
|
||||||
|
- Spec T1-T5
|
||||||
|
- go test ./...
|
||||||
|
- admin-h5 build
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# TEST_REPORT — ECR-006 Ops Admin Phase A
|
||||||
|
|
||||||
|
Date: 2026-08-06
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/api && go test ./internal/middleware/ ./internal/service/admin/ ./internal/integration/ \
|
||||||
|
-count=1 -run 'TestBearerToken|TestPlanDays|TestAdminOps'
|
||||||
|
npm run build:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| `TestBearerToken` | PASS |
|
||||||
|
| `TestPlanDays` | PASS |
|
||||||
|
| `TestAdminOpsPhaseA`(401 · login · grant+audit 原子 · lowercase logout) | PASS |
|
||||||
|
| `npm run build:admin` | PASS(既有) |
|
||||||
|
|
||||||
|
## Spec mapping
|
||||||
|
|
||||||
|
| Spec | Evidence |
|
||||||
|
|------|----------|
|
||||||
|
| T1 无 token → 401 | TestAdminOpsPhaseA |
|
||||||
|
| T2 登录 | login 200 |
|
||||||
|
| T3 列表 users | list users len>0 |
|
||||||
|
| T4 grant + audit | membership.active + audit target_id |
|
||||||
|
| T5 admin-h5 | build 绿 |
|
||||||
|
|
||||||
|
## Follow-up fixes(review)
|
||||||
|
|
||||||
|
- Grant/Audit 单事务
|
||||||
|
- Logout `bearer`/`Bearer` 一致
|
||||||
|
- Bootstrap 失败日志
|
||||||
@@ -11,3 +11,4 @@
|
|||||||
| ECR-003 | outlook JSON hygiene(删 fortune / lucky→boost) | review PASS | TEST_REPORT · CODE_REVIEW/ECR-003.md |
|
| ECR-003 | outlook JSON hygiene(删 fortune / lucky→boost) | review PASS | TEST_REPORT · CODE_REVIEW/ECR-003.md |
|
||||||
| ECR-004 | Synastry 再拆 · Scale 测 · Membership 守卫 · OpenAPI · CI | review PASS | TEST_REPORT · CODE_REVIEW/ECR-004.md |
|
| ECR-004 | Synastry 再拆 · Scale 测 · Membership 守卫 · OpenAPI · CI | review PASS | TEST_REPORT · CODE_REVIEW/ECR-004.md |
|
||||||
| ECR-005 | Ask/Decode/Portrait 再拆 · composable 外提 · OpenAPI `$ref` | review PASS | TEST_REPORT · CODE_REVIEW/ECR-005.md |
|
| ECR-005 | Ask/Decode/Portrait 再拆 · composable 外提 · OpenAPI `$ref` | review PASS | TEST_REPORT · CODE_REVIEW/ECR-005.md |
|
||||||
|
| ECR-006 | 运营后台 Phase A(admin API + admin-h5) | **Closed** | Spec ops-admin · BD-2026-006 · TEST_REPORT · CODE_REVIEW |
|
||||||
|
|||||||
Generated
+12
-1
@@ -13,7 +13,18 @@
|
|||||||
},
|
},
|
||||||
"apps/admin-h5": {
|
"apps/admin-h5": {
|
||||||
"name": "@yuxingu/admin-h5",
|
"name": "@yuxingu/admin-h5",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.0.7",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"apps/user-h5": {
|
"apps/user-h5": {
|
||||||
"name": "@yuxingu/user-h5",
|
"name": "@yuxingu/user-h5",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:h5": "npm run dev -w @yuxingu/user-h5",
|
"dev:h5": "npm run dev -w @yuxingu/user-h5",
|
||||||
"build:h5": "npm run build -w @yuxingu/user-h5",
|
"build:h5": "npm run build -w @yuxingu/user-h5",
|
||||||
|
"dev:admin": "npm run dev -w @yuxingu/admin-h5",
|
||||||
|
"build:admin": "npm run build -w @yuxingu/admin-h5",
|
||||||
"dev:api": "cd apps/api && go run ./cmd/server",
|
"dev:api": "cd apps/api && go run ./cmd/server",
|
||||||
"deps:up": "docker compose -f docker-compose.dev.yml up -d",
|
"deps:up": "docker compose -f docker-compose.dev.yml up -d",
|
||||||
"deps:down": "docker compose -f docker-compose.dev.yml down",
|
"deps:down": "docker compose -f docker-compose.dev.yml down",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ tags:
|
|||||||
- name: ask
|
- name: ask
|
||||||
- name: companion
|
- name: companion
|
||||||
- name: commerce
|
- name: commerce
|
||||||
|
- name: admin
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/api/v1/healthz:
|
/api/v1/healthz:
|
||||||
@@ -40,6 +41,113 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
|
|
||||||
|
/api/v1/admin/auth/login:
|
||||||
|
post:
|
||||||
|
tags: [admin]
|
||||||
|
summary: Admin login
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [username, password]
|
||||||
|
properties:
|
||||||
|
username: { type: string }
|
||||||
|
password: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Envelope'
|
||||||
|
|
||||||
|
/api/v1/admin/auth/logout:
|
||||||
|
post:
|
||||||
|
tags: [admin]
|
||||||
|
summary: Admin logout
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/api/v1/admin/me:
|
||||||
|
get:
|
||||||
|
tags: [admin]
|
||||||
|
summary: Current admin
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/api/v1/admin/users:
|
||||||
|
get:
|
||||||
|
tags: [admin]
|
||||||
|
summary: List users
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: q
|
||||||
|
schema: { type: string }
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
schema: { type: integer }
|
||||||
|
- in: query
|
||||||
|
name: offset
|
||||||
|
schema: { type: integer }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/api/v1/admin/users/{id}:
|
||||||
|
get:
|
||||||
|
tags: [admin]
|
||||||
|
summary: User detail
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/api/v1/admin/users/{id}/membership/grant:
|
||||||
|
post:
|
||||||
|
tags: [admin]
|
||||||
|
summary: Grant or extend membership
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [plan]
|
||||||
|
properties:
|
||||||
|
plan: { type: string, enum: [month, quarter, year] }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/api/v1/admin/orders:
|
||||||
|
get:
|
||||||
|
tags: [admin]
|
||||||
|
summary: List orders
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/api/v1/admin/audit-logs:
|
||||||
|
get:
|
||||||
|
tags: [admin]
|
||||||
|
summary: List audit logs
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
/api/v1/profiles:
|
/api/v1/profiles:
|
||||||
get:
|
get:
|
||||||
tags: [profile]
|
tags: [profile]
|
||||||
|
|||||||
Reference in New Issue
Block a user