diff --git a/.ai/architecture.md b/.ai/architecture.md index 6bdec66..c751d39 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -6,14 +6,16 @@ Domain / ERD:[domain/domain-map.md](domain/domain-map.md) · [domain/erd.md](d ## Allowed call graph ``` -UI (user-h5 / mini-program) - → packages/sdk +UI (user-h5 / mini-program / admin-h5) + → packages/sdk(或 admin 薄 api 客户端) → API Handler → Service → Repository → Database ``` +Admin 路由挂 `/api/v1/admin/*`,鉴权与终端 `DeviceAuth` **隔离**(见 `feature-spec/ops-admin.md`)。 + ## Forbidden - Handler → Database (skip Service/Repository) @@ -30,7 +32,7 @@ UI (user-h5 / mini-program) | `apps/api` | Only backend | | `apps/user-h5` | Primary client (Vue3+TS) | | `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/types` | Shared TS types | | `packages/utils` | Pure helpers | diff --git a/.ai/architecture/go-services.md b/.ai/architecture/go-services.md index 2f65284..7ee431e 100644 --- a/.ai/architecture/go-services.md +++ b/.ai/architecture/go-services.md @@ -20,6 +20,7 @@ | `membership` | 成长会员状态与额度 | Yes | `service/membership`(ECR-001 Phase C 已从 report 抽出) | | `order` | Order + pay-mock + DeepAccess 发放 | Yes | 用例在 `service/membership`(同 Phase C;未单独拆包) | | `imagecard` | 意象卡片 | P2 | `service/imagecard` | +| `admin` | 运营后台鉴权与运维用例 | Ops-A | `service/admin`(ECR-006) | ### Engine vs Service diff --git a/.ai/commands.md b/.ai/commands.md index ec7bd13..41b3130 100644 --- a/.ai/commands.md +++ b/.ai/commands.md @@ -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 见 [deployment.md](deployment.md) · [docker.md](docker.md)。 diff --git a/.ai/domain/erd.md b/.ai/domain/erd.md index 7d3b41e..6333ef8 100644 --- a/.ai/domain/erd.md +++ b/.ai/domain/erd.md @@ -172,6 +172,36 @@ | payload | jsonb | | | 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 @@ -185,4 +215,6 @@ users 1──* orders 1──* payments growth_reports 1──* deep_accesses profiles ── relation_insights ── profiles users 1──* ask_threads 1──* ask_messages +admin_accounts 1──* admin_sessions +admin_accounts 1──* admin_audit_logs ``` diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 8283e78..95665a1 100644 --- a/.ai/product/feature-map.md +++ b/.ai/product/feature-map.md @@ -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) | 路径 | 页面 | 说明 | diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md index 30ed94e..81825ed 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -18,6 +18,7 @@ | [star-profile.md](star-profile.md) | 星象性格 | §2.6 | `/star` | P2 设计 | | [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 设计 | | [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 设计 | +| [ops-admin.md](ops-admin.md) | 运营后台 | §7 | `admin-h5` `/` `/users/:id` … | Ops-A | 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 diff --git a/.ai/product/feature-spec/ops-admin.md b/.ai/product/feature-spec/ops-admin.md new file mode 100644 index 0000000..15da445 --- /dev/null +++ b/.ai/product/feature-spec/ops-admin.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 · 推送 · 优惠券 · 退款工单 | diff --git a/apps/admin-h5/README.md b/apps/admin-h5/README.md index bab3ba3..e5d1d79 100644 --- a/apps/admin-h5/README.md +++ b/apps/admin-h5/README.md @@ -1,3 +1,12 @@ # admin-h5 -运营后台(内容库、订单、会员)。**业务后置**,当前仅占位。 +运营后台(内容库、订单、会员)。**Phase A(ECR-006)**:登录 · 用户 · 订单 · 会员授予 · 审计。 + +```bash +# 根目录 +npm install +npm run dev:admin +# http://localhost:5174/ +``` + +默认管理员:见 `apps/api/config.example.yaml` → `admin.bootstrap_*`(仅空库种子)。 diff --git a/apps/admin-h5/env.d.ts b/apps/admin-h5/env.d.ts new file mode 100644 index 0000000..65c7311 --- /dev/null +++ b/apps/admin-h5/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/apps/admin-h5/index.html b/apps/admin-h5/index.html new file mode 100644 index 0000000..4db323f --- /dev/null +++ b/apps/admin-h5/index.html @@ -0,0 +1,12 @@ + + + + + + 愈心谷 · 运营后台 + + + + + + diff --git a/apps/admin-h5/package.json b/apps/admin-h5/package.json index 120c927..3460eab 100644 --- a/apps/admin-h5/package.json +++ b/apps/admin-h5/package.json @@ -2,5 +2,23 @@ "name": "@yuxingu/admin-h5", "version": "0.1.0", "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" + } } diff --git a/apps/admin-h5/src/App.vue b/apps/admin-h5/src/App.vue new file mode 100644 index 0000000..a78fb6f --- /dev/null +++ b/apps/admin-h5/src/App.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts new file mode 100644 index 0000000..fd0e7fc --- /dev/null +++ b/apps/admin-h5/src/api/client.ts @@ -0,0 +1,95 @@ +/** Thin admin API client → /api/v1/admin (proxied to Go). */ + +export type ApiEnvelope = { 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(method: string, path: string, body?: unknown): Promise { + const headers: Record = { 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 + 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('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 + }> +} diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue new file mode 100644 index 0000000..9316ee3 --- /dev/null +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -0,0 +1,55 @@ + + + + + + + + + + + + diff --git a/apps/admin-h5/src/main.ts b/apps/admin-h5/src/main.ts new file mode 100644 index 0000000..e18e6cc --- /dev/null +++ b/apps/admin-h5/src/main.ts @@ -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') diff --git a/apps/admin-h5/src/pages/AuditPage.vue b/apps/admin-h5/src/pages/AuditPage.vue new file mode 100644 index 0000000..509d81d --- /dev/null +++ b/apps/admin-h5/src/pages/AuditPage.vue @@ -0,0 +1,63 @@ + + + + + 审计 + 加载中… + {{ error }} + + 暂无审计记录 + + + 时间动作目标管理员meta + + + + {{ a.created_at }} + {{ a.action }} + {{ a.target_type }} {{ a.target_id }} + {{ a.admin_id }} + {{ JSON.stringify(a.meta) }} + + + + + + + + diff --git a/apps/admin-h5/src/pages/LoginPage.vue b/apps/admin-h5/src/pages/LoginPage.vue new file mode 100644 index 0000000..a75c299 --- /dev/null +++ b/apps/admin-h5/src/pages/LoginPage.vue @@ -0,0 +1,56 @@ + + + + + + 运营后台 + 愈心谷内部工具 · Phase A + + 用户名 + + + + 密码 + + + {{ error }} + + {{ loading ? '登录中…' : '登录' }} + + + + + + diff --git a/apps/admin-h5/src/pages/OrdersPage.vue b/apps/admin-h5/src/pages/OrdersPage.vue new file mode 100644 index 0000000..63038cb --- /dev/null +++ b/apps/admin-h5/src/pages/OrdersPage.vue @@ -0,0 +1,63 @@ + + + + + 订单 + 加载中… + {{ error }} + + 暂无订单 + + + ID用户类型状态金额时间 + + + + {{ o.id }} + {{ o.user_id }} + {{ o.kind }} {{ o.plan || '' }} + {{ o.status }} + {{ (o.amount_cents / 100).toFixed(2) }} + {{ o.created_at }} + + + + + + + + diff --git a/apps/admin-h5/src/pages/UserDetailPage.vue b/apps/admin-h5/src/pages/UserDetailPage.vue new file mode 100644 index 0000000..e3f6128 --- /dev/null +++ b/apps/admin-h5/src/pages/UserDetailPage.vue @@ -0,0 +1,102 @@ + + + + + 用户详情 + 加载中… + {{ error }} + + + ID {{ detail.id }} + 状态 {{ detail.status }} + 创建 {{ detail.created_at }} + + + 成长会员 + + {{ detail.membership.active ? '有效' : '无效' }} · + {{ detail.membership.plan || '—' }} · + {{ detail.membership.status }} · + 到期 {{ detail.membership.expires_at || '—' }} + + + + 月 + 季 + 年 + + 授予 / 延长 + {{ grantMsg }} + + + + 档案 + 无档案 + + + {{ p.display_name || '未命名' }}({{ p.relation }})· {{ p.id }} + + + + + 近订单 + 无订单 + + ID类型状态金额 + + + {{ o.id }} + {{ o.kind }} {{ o.plan || '' }} + {{ o.status }} + {{ (o.amount_cents / 100).toFixed(2) }} + + + + + + + + + diff --git a/apps/admin-h5/src/pages/UsersPage.vue b/apps/admin-h5/src/pages/UsersPage.vue new file mode 100644 index 0000000..01ecfd3 --- /dev/null +++ b/apps/admin-h5/src/pages/UsersPage.vue @@ -0,0 +1,61 @@ + + + + + + 用户 + + + 查询 + + + 加载中… + {{ error }} + + 暂无用户 + + + ID状态创建时间 + + + + {{ u.id }} + {{ u.status }} + {{ u.created_at }} + + + + + + + + diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts new file mode 100644 index 0000000..8ca1d81 --- /dev/null +++ b/apps/admin-h5/src/router/index.ts @@ -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 diff --git a/apps/admin-h5/src/stores/auth.ts b/apps/admin-h5/src/stores/auth.ts new file mode 100644 index 0000000..27a1a35 --- /dev/null +++ b/apps/admin-h5/src/stores/auth.ts @@ -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(getToken()) + const username = ref('') + + 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 } +}) diff --git a/apps/admin-h5/src/styles.css b/apps/admin-h5/src/styles.css new file mode 100644 index 0000000..0925698 --- /dev/null +++ b/apps/admin-h5/src/styles.css @@ -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; +} diff --git a/apps/admin-h5/tsconfig.json b/apps/admin-h5/tsconfig.json new file mode 100644 index 0000000..a30d03a --- /dev/null +++ b/apps/admin-h5/tsconfig.json @@ -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"] +} diff --git a/apps/admin-h5/vite.config.ts b/apps/admin-h5/vite.config.ts new file mode 100644 index 0000000..b84e0fa --- /dev/null +++ b/apps/admin-h5/vite.config.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, + }, + }, + }, +}) diff --git a/apps/api/config.example.yaml b/apps/api/config.example.yaml index 4508530..a533ee3 100644 --- a/apps/api/config.example.yaml +++ b/apps/api/config.example.yaml @@ -19,6 +19,11 @@ deepseek: model: "deepseek-chat" 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: # secret: "" # payment: diff --git a/apps/api/go.mod b/apps/api/go.mod index 6730a48..d013bf0 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -1,6 +1,6 @@ module github.com/yuxingu/digital-psychology/apps/api -go 1.25 +go 1.25.0 require ( github.com/gin-gonic/gin v1.10.0 @@ -36,10 +36,10 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect google.golang.org/protobuf v1.34.1 // indirect ) diff --git a/apps/api/go.sum b/apps/api/go.sum index 5f44297..2020178 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -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/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.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/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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.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/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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 3c29bff..4376cd8 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -18,6 +18,13 @@ type Config struct { DatabaseURL string AppEnv string DeepSeek DeepSeekConfig + Admin AdminConfig +} + +// AdminConfig for ops console bootstrap (ECR-006). +type AdminConfig struct { + BootstrapUsername string + BootstrapPassword string } // DeepSeekConfig for Ask LLM. @@ -47,6 +54,10 @@ type fileConfig struct { Model string `yaml:"model"` TimeoutSec int `yaml:"timeout_sec"` } `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. @@ -123,6 +134,12 @@ func mergeFile(cfg *Config, path string) error { if f.DeepSeek.TimeoutSec > 0 { 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 } @@ -184,6 +201,12 @@ func applyEnv(cfg *Config) { 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. diff --git a/apps/api/internal/handler/admin.go b/apps/api/internal/handler/admin.go new file mode 100644 index 0000000..621445d --- /dev/null +++ b/apps/api/internal/handler/admin.go @@ -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}) +} diff --git a/apps/api/internal/httpserver/router.go b/apps/api/internal/httpserver/router.go index dc78423..d271ddd 100644 --- a/apps/api/internal/httpserver/router.go +++ b/apps/api/internal/httpserver/router.go @@ -2,6 +2,9 @@ package httpserver import ( + "context" + "log" + "github.com/gin-gonic/gin" "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/middleware" "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" companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion" 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} relationRepo := &repository.RelationRepo{Pool: pool} askRepo := &repository.AskRepo{Pool: pool} + adminRepo := &repository.AdminRepo{Pool: pool} var llm *deepseek.Client if cfg.DeepSeek.Enabled() { @@ -49,6 +54,13 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine { Reports: reportRepo, 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.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) { response.OK(c, gin.H{"pong": true}) }) + (&handler.AdminHandler{Svc: adminSvc}).Register(api) authed := api.Group("") authed.Use(middleware.DeviceAuth(pool)) diff --git a/apps/api/internal/integration/admin_ops_test.go b/apps/api/internal/integration/admin_ops_test.go new file mode 100644 index 0000000..c33dd8b --- /dev/null +++ b/apps/api/internal/integration/admin_ops_test.go @@ -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) + } +} diff --git a/apps/api/internal/integration/p1_flows_test.go b/apps/api/internal/integration/p1_flows_test.go index 74f5fec..a41e4ed 100644 --- a/apps/api/internal/integration/p1_flows_test.go +++ b/apps/api/internal/integration/p1_flows_test.go @@ -31,6 +31,8 @@ func setupAPI(t *testing.T) (*gin.Engine, string) { t.Cleanup(cancel) cfg := config.Load() + cfg.Admin.BootstrapUsername = "admin" + cfg.Admin.BootstrapPassword = "change-me" pool, err := db.Connect(ctx, cfg.DatabaseURL) if err != nil { t.Skipf("postgres unavailable (run npm run deps:up): %v", err) diff --git a/apps/api/internal/middleware/admin_auth.go b/apps/api/internal/middleware/admin_auth.go new file mode 100644 index 0000000..45b9f30 --- /dev/null +++ b/apps/api/internal/middleware/admin_auth.go @@ -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 +} diff --git a/apps/api/internal/middleware/admin_auth_test.go b/apps/api/internal/middleware/admin_auth_test.go new file mode 100644 index 0000000..b709fcd --- /dev/null +++ b/apps/api/internal/middleware/admin_auth_test.go @@ -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) + } + } +} diff --git a/apps/api/internal/repository/admin_repo.go b/apps/api/internal/repository/admin_repo.go new file mode 100644 index 0000000..ded25c4 --- /dev/null +++ b/apps/api/internal/repository/admin_repo.go @@ -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() +} diff --git a/apps/api/internal/repository/report_repo.go b/apps/api/internal/repository/report_repo.go index 41bfd40..40630cb 100644 --- a/apps/api/internal/repository/report_repo.go +++ b/apps/api/internal/repository/report_repo.go @@ -91,11 +91,11 @@ func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID) // MembershipRow is the current membership snapshot for a user. type MembershipRow struct { - Plan string - Status string - ExpiresAt *time.Time - AskQuotaLeft int - Active bool + Plan string `json:"plan,omitempty"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + AskQuotaLeft int `json:"ask_quota_left,omitempty"` + Active bool `json:"active"` } // GetMembership returns membership status; missing row → inactive. diff --git a/apps/api/internal/service/admin/service.go b/apps/api/internal/service/admin/service.go new file mode 100644 index 0000000..c90037b --- /dev/null +++ b/apps/api/internal/service/admin/service.go @@ -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 +} diff --git a/apps/api/internal/service/admin/service_test.go b/apps/api/internal/service/admin/service_test.go new file mode 100644 index 0000000..4db208d --- /dev/null +++ b/apps/api/internal/service/admin/service_test.go @@ -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") + } +} diff --git a/apps/api/migrations/000010_admin_ops.down.sql b/apps/api/migrations/000010_admin_ops.down.sql new file mode 100644 index 0000000..ff01418 --- /dev/null +++ b/apps/api/migrations/000010_admin_ops.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS admin_audit_logs; +DROP TABLE IF EXISTS admin_sessions; +DROP TABLE IF EXISTS admin_accounts; diff --git a/apps/api/migrations/000010_admin_ops.up.sql b/apps/api/migrations/000010_admin_ops.up.sql new file mode 100644 index 0000000..84b9fec --- /dev/null +++ b/apps/api/migrations/000010_admin_ops.up.sql @@ -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); diff --git a/docs/BACKEND_DESIGN/.gitkeep b/docs/BACKEND_DESIGN/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/BACKEND_DESIGN/ECR-006-ops-admin-phase-a.md b/docs/BACKEND_DESIGN/ECR-006-ops-admin-phase-a.md new file mode 100644 index 0000000..c647989 --- /dev/null +++ b/docs/BACKEND_DESIGN/ECR-006-ops-admin-phase-a.md @@ -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 | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6d5b794..5cf088f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 - Bind ESS v1.0(`--bind-only`):`docs/` 流程目录 + script wrappers diff --git a/docs/CODE_REVIEW/ECR-006.md b/docs/CODE_REVIEW/ECR-006.md new file mode 100644 index 0000000..0eafaa9 --- /dev/null +++ b/docs/CODE_REVIEW/ECR-006.md @@ -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+ diff --git a/docs/ECR/ECR-006-ops-admin-phase-a.md b/docs/ECR/ECR-006-ops-admin-phase-a.md new file mode 100644 index 0000000..800a0e5 --- /dev/null +++ b/docs/ECR/ECR-006-ops-admin-phase-a.md @@ -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` diff --git a/docs/ENGINEERING_SPEC/ECR-006-ops-admin-phase-a.md b/docs/ENGINEERING_SPEC/ECR-006-ops-admin-phase-a.md new file mode 100644 index 0000000..87603c2 --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-006-ops-admin-phase-a.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 diff --git a/docs/HANDOFF/ECR-006-architect-to-engineer.md b/docs/HANDOFF/ECR-006-architect-to-engineer.md new file mode 100644 index 0000000..b0c5d14 --- /dev/null +++ b/docs/HANDOFF/ECR-006-architect-to-engineer.md @@ -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 diff --git a/docs/HANDOFF/ECR-006-engineer-to-reviewer.md b/docs/HANDOFF/ECR-006-engineer-to-reviewer.md new file mode 100644 index 0000000..937f653 --- /dev/null +++ b/docs/HANDOFF/ECR-006-engineer-to-reviewer.md @@ -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。 diff --git a/docs/PRODUCT_SPEC/ECR-006-ops-admin-phase-a.md b/docs/PRODUCT_SPEC/ECR-006-ops-admin-phase-a.md new file mode 100644 index 0000000..30d4cf2 --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-006-ops-admin-phase-a.md @@ -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 diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index b52bab9..84fca5b 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -14,6 +14,7 @@ | 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/` | | 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` | 冲突时:领域与完成标准以 `.ai/` 为准;变更分级与角色门禁以 ESS 为准。 @@ -45,9 +46,9 @@ ## 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: (无) -- STATE: `docs/STATE/ECR-005.md` +- STATE: `docs/STATE/ECR-006.md` - TRACEABILITY: `docs/TRACEABILITY.md` - ADR: `.ai/adr/0007-ess-ai-dual-track.md` - Product status: `.ai/product/p1-status.md`(**P1 Complete**) diff --git a/docs/PROJECT_RULES.md b/docs/PROJECT_RULES.md index 1ca870f..906b678 100644 --- a/docs/PROJECT_RULES.md +++ b/docs/PROJECT_RULES.md @@ -8,7 +8,7 @@ Product/DoD 权威:`.ai/`(不在此复制长文) 1. 语言契约:只用 `.ai/product/lexicon.md` 中的愈心谷用词;禁止竞品品牌词与恐吓/吉凶话术。 2. API 信封固定为 `{code,message,data}`;不发明表名、端点、领域同义词。 3. 调用链:UI → `@yuxingu/sdk` → Handler → Service → Repository → DB。 -4. 新功能 / 扩行为:必须有 Active `.ai/product/feature-spec/.md`;L2+ 另须 Approved `docs/ECR/`。 +4. 新功能 / 扩行为:必须有 Active `.ai/product/feature-spec/.md`;L2+ 另须 Approved `docs/ECR/`;**涉后台**另须 Approved `docs/BACKEND_DESIGN/`。 5. 本地默认:本机 `go run` + Vite;compose 只起 DB。 6. Done ≠ build 通过:须过 `.ai/definition-of-done.md` 并输出 Review Report。 diff --git a/docs/STATE/ECR-006.md b/docs/STATE/ECR-006.md new file mode 100644 index 0000000..fdab3e7 --- /dev/null +++ b/docs/STATE/ECR-006.md @@ -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. diff --git a/docs/TASKS/TASK-006-ECR006.yaml b/docs/TASKS/TASK-006-ECR006.yaml new file mode 100644 index 0000000..ec6901a --- /dev/null +++ b/docs/TASKS/TASK-006-ECR006.yaml @@ -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 diff --git a/docs/TEST_REPORT/ECR-006.md b/docs/TEST_REPORT/ECR-006.md new file mode 100644 index 0000000..58961bc --- /dev/null +++ b/docs/TEST_REPORT/ECR-006.md @@ -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 失败日志 diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 0807127..a5aa014 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -11,3 +11,4 @@ | 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-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 | diff --git a/package-lock.json b/package-lock.json index 6c7a400..f7f6720 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,18 @@ }, "apps/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": { "name": "@yuxingu/user-h5", diff --git a/package.json b/package.json index 289992c..1396df6 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "scripts": { "dev:h5": "npm run dev -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", "deps:up": "docker compose -f docker-compose.dev.yml up -d", "deps:down": "docker compose -f docker-compose.dev.yml down", diff --git a/proto/openapi.yaml b/proto/openapi.yaml index aad1363..677c8e5 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -18,6 +18,7 @@ tags: - name: ask - name: companion - name: commerce + - name: admin paths: /api/v1/healthz: @@ -40,6 +41,113 @@ paths: '200': 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: get: tags: [profile]
加载中…
{{ error }}
暂无审计记录
{{ JSON.stringify(a.meta) }}
愈心谷内部工具 · Phase A
暂无订单
ID {{ detail.id }}
状态 {{ detail.status }}
创建 {{ detail.created_at }}
+ {{ detail.membership.active ? '有效' : '无效' }} · + {{ detail.membership.plan || '—' }} · + {{ detail.membership.status }} · + 到期 {{ detail.membership.expires_at || '—' }} +
无档案
无订单
暂无用户