Просмотр исходного кода

test: 管理后台Web页面测试用例 + 后端增量测试覆盖

- 新增 admin-web-test-cases.md (56个测试用例,覆盖8个页面+权限+边界)
- 新增 AdminControllerApiTest.java (22个Admin API MockMvc测试)
- ControllerIntegrationTest 新增6个测试(chartList/Interpret/Regenerate, profileComplete/Update/ShareTrack)
- 修复2个Annotation测试Mock签名不匹配
- 修复WebMvcConfig拦截器未排除/admin/login的bug
- Tests: 172 run, 0 failures, 5 skipped
liaoxg 3 месяцев назад
Родитель
Сommit
231325102a

+ 556 - 0
.opencode/admin-web-test-cases.md

@@ -0,0 +1,556 @@
+# 管理后台 Web 页面测试用例
+
+## 测试范围
+
+| 模块 | 页面 | 路由 | 优先级 |
+|------|------|------|--------|
+| 登录认证 | 管理员登录 | `/login` | P0 |
+| 仪表盘 | 数据概览 | `/dashboard` | P0 |
+| 用户管理 | 用户列表与操作 | `/users` | P0 |
+| 订单管理 | 订单查询与筛选 | `/orders` | P1 |
+| 佣金管理 | 佣金明细与统计 | `/commissions` | P1 |
+| 提现管理 | 提现审核处理 | `/withdraw` | P1 |
+| 系统配置 | 定价与佣金参数编辑 | `/settings` | P0 |
+| 分析记录 | 命盘数据查看 | `/charts` | P2 |
+
+---
+
+## 测试环境与前置条件
+
+- 后端服务正常运行(`num-server`)
+- 管理后台开发服务器正常(`admin/`)
+- 测试数据库已初始化种子数据(包含若干用户、订单、佣金、提现记录)
+- 浏览器:Chrome / Edge 最新版
+- 屏幕分辨率:≥ 1280×720
+
+---
+
+## 1. 登录认证(Login)
+
+### TC-LOGIN-001:正常登录成功
+| 字段 | 内容 |
+|------|------|
+| **前置** | 管理后台未登录,访问 `/login` |
+| **步骤** | 1. 输入有效用户名 `admin`<br>2. 输入有效密码 `admin123`<br>3. 点击「登录」按钮 |
+| **预期** | ① 按钮显示 loading 状态<br>② 请求 `POST /api/admin/login` 返回 code=0<br>③ 弹出「登录成功」提示<br>④ 自动跳转至 `/dashboard`<br>⑤ `localStorage` 中存入 `admin_token` |
+| **优先级** | P0 |
+
+### TC-LOGIN-002:密码错误
+| 字段 | 内容 |
+|------|------|
+| **前置** | 未登录状态,访问 `/login` |
+| **步骤** | 1. 输入用户名 `admin`<br>2. 输入错误密码 `wrongpass`<br>3. 点击「登录」 |
+| **预期** | ① 请求返回 code=1001 "Invalid admin credentials"<br>② 页面停留在登录页,不跳转<br>③ 弹出错误提示 |
+| **优先级** | P0 |
+
+### TC-LOGIN-003:用户名不存在
+| 字段 | 内容 |
+|------|------|
+| **前置** | 未登录 |
+| **步骤** | 1. 输入不存在的用户名 `hacker`<br>2. 输入任意密码<br>3. 点击「登录」 |
+| **预期** | 同 TC-LOGIN-002,返回 code=1001,停留在登录页 |
+| **优先级** | P0 |
+
+### TC-LOGIN-004:空表单提交
+| 字段 | 内容 |
+|------|------|
+| **前置** | 未登录 |
+| **步骤** | 1. 用户名和密码均为空<br>2. 点击「登录」 |
+| **预期** | ① 表单校验阻止提交<br>② 用户名和密码输入框下方显示「请输入用户名」「请输入密码」提示<br>③ 不发出 API 请求 |
+| **优先级** | P0 |
+
+### TC-LOGIN-005:仅填写用户名
+| 字段 | 内容 |
+|------|------|
+| **前置** | 未登录 |
+| **步骤** | 1. 输入用户名 `admin`,密码为空<br>2. 点击「登录」 |
+| **预期** | 表单校验阻止提交,密码框提示「请输入密码」,不发出 API 请求 |
+| **优先级** | P0 |
+
+### TC-LOGIN-006:密码可见切换
+| 字段 | 内容 |
+|------|------|
+| **前置** | 未登录 |
+| **步骤** | 1. 输入密码<br>2. 点击密码框右侧的「眼睛」图标切换可见性 |
+| **预期** | ① 密码明文/密文可正确切换<br>② 按钮图标同步变化 |
+| **优先级** | P2 |
+
+### TC-LOGIN-007:登录后直接访问 /login
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录(localStorage 有有效 token) |
+| **步骤** | 1. 浏览器地址栏直接输入 `/login`<br>2. 回车访问 |
+| **预期** | 路由守卫检测到已登录,自动重定向至 `/dashboard` |
+| **优先级** | P1 |
+
+---
+
+## 2. 仪表盘(Dashboard)
+
+### TC-DASH-001:仪表盘数据正常加载
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录,数据库中存在用户/订单/佣金数据 |
+| **步骤** | 1. 登录后自动跳转至 `/dashboard`<br>2. 观察页面内容 |
+| **预期** | ① 请求 `POST /api/admin/stats` 返回 code=0<br>② 4个统计卡片显示正确数值(总用户数、VIP用户、订单总数、总收入)<br>③ 月新增用户折线图渲染(ECharts)<br>④ 订单状态分布饼图渲染<br>⑤ 最近订单表格显示最近10条订单记录<br>⑥ 表格含订单号、金额(¥格式化)、状态标签、创建时间列 |
+| **优先级** | P0 |
+
+### TC-DASH-002:无数据时的仪表盘
+| 字段 | 内容 |
+|------|------|
+| **前置** | 数据库为空(无用户/订单) |
+| **步骤** | 同 TC-DASH-001 |
+| **预期** | ① 统计卡片显示为 `-` 或 `0`<br>② 图表渲染空白状态<br>③ 订单表格显示「暂无数据」<br>④ 页面不报错 |
+| **优先级** | P1 |
+
+### TC-DASH-003:仪表盘数据加载失败
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录,后端服务异常或网络断开 |
+| **步骤** | 1. 访问 `/dashboard` |
+| **预期** | ① 页面显示 loading 后停止<br>② 弹出错误提示「网络错误」或后端返回的错误信息<br>③ 页面不崩溃,表格和图表区域保持空白 |
+| **优先级** | P1 |
+
+### TC-DASH-004:统计中的金额格式化
+| 字段 | 内容 |
+|------|------|
+| **前置** | 数据库中有订单:totalFee 分别为 100(¥1.00)、131400(¥1,314.00)、0 |
+| **步骤** | 1. 访问 `/dashboard`<br>2. 查看总收入卡片 |
+| **预期** | 总收入显示为 ¥1,315.00(分转元,两位小数) |
+| **优先级** | P1 |
+
+---
+
+## 3. 用户管理(Users)
+
+### TC-USER-001:用户列表正常加载
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录,数据库中有 ≥3 个用户(含普通用户、VIP用户、管理员) |
+| **步骤** | 1. 点击左侧菜单「用户管理」<br>2. 观察页面 |
+| **预期** | ① 请求 `POST /api/admin/users` 返回 code=0<br>② 表格显示所有用户<br>③ 每行包含:ID、头像、昵称、手机号、推广码、VIP标签、注册时间<br>④ VIP用户标签为绿色「是」,非VIP为灰色「否」<br>⑤ 分页组件显示总条数 |
+| **优先级** | P0 |
+
+### TC-USER-002:用户搜索功能
+| 字段 | 内容 |
+|------|------|
+| **前置** | 用户列表已加载,存在昵称含"张三"、手机号含"138"的用户 |
+| **步骤** | 1. 在搜索框输入 `张三`<br>2. 观察表格筛选结果<br>3. 清空搜索框<br>4. 输入 `138` |
+| **预期** | ① 输入「张三」→ 表格仅显示昵称包含「张三」的用户<br>② 清空搜索 → 恢复显示全部用户<br>③ 输入「138」→ 表格仅显示手机号包含「138」的用户<br>④ 搜索无匹配时显示「暂无数据」 |
+| **优先级** | P0 |
+
+### TC-USER-003:升级 VIP(已有VIP到期)
+| 字段 | 内容 |
+|------|------|
+| **前置** | 用户 A 的 VIP 已过期(vipEndTime < now) |
+| **步骤** | 1. 找到用户 A<br>2. 点击「升级VIP」按钮 |
+| **预期** | ① 请求 `POST /api/admin/user/upgrade` 带 `{userId: A.id}`<br>② 返回 code=0<br>③ 弹出「已升级VIP」成功提示<br>④ 表格自动刷新,用户A的VIP标签变为绿色「是」<br>⑤ 升级VIP按钮变为 disabled 状态 |
+| **优先级** | P0 |
+
+### TC-USER-004:升级 VIP(已是VIP)
+| 字段 | 内容 |
+|------|------|
+| **前置** | 用户 B 是 VIP(vipEndTime > now) |
+| **步骤** | 1. 找到用户 B<br>2. 观察「升级VIP」按钮 |
+| **预期** | 「升级VIP」按钮为 disabled 状态,hover 提示或置灰不可点击 |
+| **优先级** | P0 |
+
+### TC-USER-005:设为管理员
+| 字段 | 内容 |
+|------|------|
+| **前置** | 用户 C 非管理员(isAdmin=false) |
+| **步骤** | 1. 找到用户 C<br>2. 点击「设为管理」按钮 |
+| **预期** | ① 请求 `POST /api/admin/user/set-admin` 带 `{userId: C.id, isAdmin: true}`<br>② 返回 code=0<br>③ 弹出「已设为管理员」成功提示<br>④ 表格刷新,按钮文字变为「取消管理」 |
+| **优先级** | P0 |
+
+### TC-USER-006:取消管理员
+| 字段 | 内容 |
+|------|------|
+| **前置** | 用户 D 是管理员(isAdmin=true) |
+| **步骤** | 1. 找到用户 D<br>2. 点击「取消管理」按钮 |
+| **预期** | ① 请求带 `{userId: D.id, isAdmin: false}`<br>② 返回 code=0<br>③ 弹出「已取消管理权限」<br>④ 按钮文字变为「设为管理」 |
+| **优先级** | P0 |
+
+### TC-USER-007:升级不存在的用户
+| 字段 | 内容 |
+|------|------|
+| **前置** | 手动构造请求或通过接口测试 |
+| **步骤** | 直接调用 API `POST /api/admin/user/upgrade` 带不存在的 userId |
+| **预期** | 返回 code=1002 "User not found" |
+| **优先级** | P1 |
+
+### TC-USER-008:分页功能
+| 字段 | 内容 |
+|------|------|
+| **前置** | 数据库中有 >20 个用户 |
+| **步骤** | 1. 访问用户管理<br>2. 点击第2页 |
+| **预期** | ① 分页组件跳转至第2页<br>② 表格显示第21-40条数据<br>③ URL 不变(前端分页) |
+| **优先级** | P1 |
+
+---
+
+## 4. 订单管理(Orders)
+
+### TC-ORDER-001:订单列表加载
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录,数据库中有多种状态的订单 |
+| **步骤** | 1. 点击左侧菜单「订单管理」 |
+| **预期** | ① 请求 `POST /api/admin/orders` 返回 code=0<br>② 表格显示所有订单<br>③ 每行包含:ID、订单号、用户ID、金额(¥x.xx)、状态标签、支付方式、创建时间、支付时间<br>④ 已支付订单标签为绿色,待支付为黄色 |
+| **优先级** | P0 |
+
+### TC-ORDER-002:按状态筛选
+| 字段 | 内容 |
+|------|------|
+| **前置** | 订单列表已加载 |
+| **步骤** | 1. 在状态筛选下拉选择「已支付」<br>2. 观察表格<br>3. 切换为「待支付」<br>4. 选择「全部」 |
+| **预期** | ① 选「已支付」→ 仅显示 status=paid 的订单<br>② 选「待支付」→ 仅显示 status=pending 的订单<br>③ 选「全部」→ 恢复显示全部订单 |
+| **优先级** | P0 |
+
+### TC-ORDER-003:金额格式化
+| 字段 | 内容 |
+|------|------|
+| **前置** | 订单 totalFee=500(5.00元) |
+| **步骤** | 查看金额列 |
+| **预期** | 显示 `5.00元`,两位小数,保留分精度 |
+| **优先级** | P1 |
+
+### TC-ORDER-004:空订单列表
+| 字段 | 内容 |
+|------|------|
+| **前置** | 数据库中无订单 |
+| **步骤** | 访问订单管理 |
+| **预期** | 表格显示「暂无数据」,筛选下拉可用,不报错 |
+| **优先级** | P1 |
+
+---
+
+## 5. 佣金管理(Commissions)
+
+### TC-COMM-001:佣金列表加载与统计摘要
+| 字段 | 内容 |
+|------|------|
+| **前置** | 数据库中有多条佣金记录(含已结算和待结算) |
+| **步骤** | 1. 点击左侧菜单「佣金管理」 |
+| **预期** | ① 请求 `POST /api/admin/commissions` 返回 code=0<br>② 顶部3个统计卡片显示正确数值:「已结算佣金」(绿色)、「待结算佣金」(黄色)、「总佣金」<br>③ 金额格式为 ¥x.xx(两位小数)<br>④ 下方表格显示佣金明细:ID、订单ID、来源用户、获得用户、级别(L1/L2)、金额、状态、时间<br>⑤ 已结算标签为绿色「已结算」,待结算为灰色「待结算」 |
+| **优先级** | P0 |
+
+### TC-COMM-002:分页功能
+| 字段 | 内容 |
+|------|------|
+| **前置** | 有 >20 条佣金记录 |
+| **步骤** | 翻页操作 |
+| **预期** | 分页组件工作正常,每页20条 |
+| **优先级** | P2 |
+
+### TC-COMM-003:统计金额一致性
+| 字段 | 内容 |
+|------|------|
+| **前置** | 有3条已结算各100元、2条待结算各50元 |
+| **步骤** | 核对统计数字 |
+| **预期** | 已结算=¥300.00、待结算=¥100.00、总佣金=¥400.00 |
+| **优先级** | P1 |
+
+### TC-COMM-004:空佣金列表
+| 字段 | 内容 |
+|------|------|
+| **前置** | 数据库中无佣金记录 |
+| **步骤** | 访问佣金管理 |
+| **预期** | 统计卡片均显示 ¥0.00,表格显示「暂无数据」 |
+| **优先级** | P1 |
+
+---
+
+## 6. 提现管理(Withdraw)
+
+### TC-WDRAW-001:提现列表加载
+| 字段 | 内容 |
+|------|------|
+| **前置** | 数据库中有多种状态的提现记录(待审核、已通过、已拒绝) |
+| **步骤** | 1. 点击左侧菜单「提现管理」 |
+| **预期** | ① 请求 `POST /api/admin/withdraws` 返回 code=0<br>② 表格显示 ID、用户ID、金额(¥x.xx)、状态标签、创建时间<br>③ 待审核=黄、已通过=绿、已拒绝=红 |
+
+### TC-WDRAW-002:通过提现申请
+| 字段 | 内容 |
+|------|------|
+| **前置** | 存在一条「待审核」提现记录 |
+| **步骤** | 1. 找到待审核记录<br>2. 点击「通过」按钮 |
+| **预期** | ① 请求 `POST /api/admin/withdraw/approve` 带 `{id: row.id}`<br>② 返回 code=0<br>③ 弹出「提现已通过」成功提示<br>④ 表格自动刷新,状态变为绿色「已通过」<br>⑤ 通过和拒绝按钮消失 |
+
+### TC-WDRAW-003:拒绝提现申请
+| 字段 | 内容 |
+|------|------|
+| **前置** | 存在一条「待审核」提现记录 |
+| **步骤** | 1. 点击「拒绝」按钮 |
+| **预期** | ① 请求 `POST /api/admin/withdraw/reject` 带 `{id: row.id}`<br>② 返回 code=0<br>③ 弹出「已拒绝该提现」<br>④ 表格刷新,状态变为红色「已拒绝」 |
+
+### TC-WDRAW-004:已处理提现的按钮状态
+| 字段 | 内容 |
+|------|------|
+| **前置** | 存在「已通过」「已拒绝」的提现记录 |
+| **步骤** | 观察操作列 |
+| **预期** | 已审核的提现记录操作列不显示「通过」「拒绝」按钮 |
+
+### TC-WDRAW-005:刷新按钮
+| 字段 | 内容 |
+|------|------|
+| **前置** | 提现列表已加载 |
+| **步骤** | 1. 点击「刷新」按钮 |
+| **预期** | ① 表格重新加载<br>② 显示 loading 状态 |
+
+### TC-WDRAW-006:提现操作失败处理
+| 字段 | 内容 |
+|------|------|
+| **前置** | 通过/拒绝 API 返回非0 code |
+| **步骤** | 模拟服务端拒绝(如重复审核) |
+| **预期** | ① 弹出错误提示「操作失败」<br>② 页面状态不发生错误变更 |
+
+---
+
+## 7. 系统配置(Settings)
+
+### TC-SET-001:配置加载
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录,数据库 sys_config 表有配置数据 |
+| **步骤** | 1. 点击左侧菜单「系统配置」 |
+| **预期** | ① 请求 `POST /api/admin/config/list` 返回 code=0<br>② 页面显示 6 个配置卡片组:<br> - 定价配置(能量师种子价/标准价/种子价上限/C端年费)<br> - B端佣金配置(L1能量师推荐/L1 C端推荐/L2)<br> - C端佣金配置(直接佣金比例/上级佣金比例)<br> - 人工方案配置(平台佣金率/分销占比/最低价/最高价/最大协商轮次)<br> - 配额配置(每日AI解读/AI问答/分享次数)<br> - 其他参数(推广码前缀/咨询最短间隔)<br>③ 每个字段显示当前值并可编辑 |
+
+### TC-SET-002:修改配置并保存
+| 字段 | 内容 |
+|------|------|
+| **前置** | 配置加载完毕 |
+| **步骤** | 1. 修改能量师种子价从 1314 改为 1500<br>2. 点击「保存全部配置」 |
+| **预期** | ① 请求 `POST /api/admin/config/update` 携带所有配置键值对<br>② 返回 code=0<br>③ 弹出「配置已保存,已实时生效」<br>④ 刷新页面后,修改值保持为 1500 |
+
+### TC-SET-003:配置项输入验证
+| 字段 | 内容 |
+|------|------|
+| **前置** | 配置加载完毕 |
+| **步骤** | 1. 尝试输入负数到「能量师种子价」<br>2. 尝试输入 >100 到「直接佣金比例」<br>3. 尝试输入小数到整数配置项 |
+| **预期** | ① el-input-number 阻止非法输入(min/max限制生效)<br>② 比例字段 max=100,不能超过<br>③ 整数字段不接受小数 |
+
+### TC-SET-004:恢复初始值
+| 字段 | 内容 |
+|------|------|
+| **前置** | 某个配置项的值已被修改 |
+| **步骤** | 1. 点击「定价配置」卡片右上角的「恢复初始值」 |
+| **预期** | ① 请求 `POST /api/admin/config/reset` 针对配置键<br>② 返回 code=0<br>③ 该卡片内所有字段恢复为初始默认值<br>④ 字段值刷新 |
+
+### TC-SET-005:实时比例计算
+| 字段 | 内容 |
+|------|------|
+| **前置** | 定价配置和佣金配置已加载 |
+| **步骤** | 修改能量师种子价或L1佣金值 |
+| **预期** | ① 修改种子价时,下方佣金比例提示实时更新<br>② 修改佣金值时,百分比提示实时更新<br>③ 计算公式正确(如 L1/种子价 = x%) |
+
+### TC-SET-006:保存失败处理
+| 字段 | 内容 |
+|------|------|
+| **前置** | 配置已加载 |
+| **步骤** | 1. 修改配置<br>2. 服务器返回错误 |
+| **预期** | ① 保存按钮结束 loading<br>② 弹出错误提示<br>③ 页面保持修改后的值,不自动回滚 |
+
+### TC-SET-007:必填字段空值
+| 字段 | 内容 |
+|------|------|
+| **前置** | 配置加载完毕 |
+| **步骤** | 尝试通过手动构造将某个必填配置置空后保存 |
+| **预期** | 后端应校验非空或保存默认值,配置不丢失 |
+
+---
+
+## 8. 分析记录(Charts)
+
+### TC-CHART-001:分析记录列表加载
+| 字段 | 内容 |
+|------|------|
+| **前置** | 数据库中有多条命盘记录 |
+| **步骤** | 1. 点击左侧菜单「分析记录」 |
+| **预期** | ① 请求 `POST /api/admin/charts` 返回 code=0<br>② 表格显示 ID、用户ID、手机号、姓名、已解读标签、创建时间<br>③ 已解读(difyResponse 不为空)标签为绿色「是」,否则为灰色「否」 |
+
+### TC-CHART-002:查看详情弹窗
+| 字段 | 内容 |
+|------|------|
+| **前置** | 有一条包含 chartData 和 difyResponse 的记录 |
+| **步骤** | 1. 点击某条记录的「查看」按钮 |
+| **预期** | ① 弹出详情弹窗<br>② 弹窗显示手机号、姓名、生日、创建时间的描述列表<br>③ 下方显示「图表数据」区域,格式化的 JSON 数据<br>④ 下方显示「AI 解读」区域,展示解读文本 |
+
+### TC-CHART-003:查看无 AI 解读的记录
+| 字段 | 内容 |
+|------|------|
+| **前置** | 有一条有 chartData 但无 difyResponse 的记录 |
+| **步骤** | 点击「查看」 |
+| **预期** | 弹窗显示图表数据,AI 解读区域不显示或显示为空 |
+
+### TC-CHART-004:查看无图表数据的记录
+| 字段 | 内容 |
+|------|------|
+| **前置** | 有一条 chartData 为空的记录 |
+| **步骤** | 点击「查看」 |
+| **预期** | 弹窗不显示图表数据区域,AI 解读区域按需显示 |
+
+### TC-CHART-005:分页功能
+| 字段 | 内容 |
+|------|------|
+| **前置** | 有 >20 条记录 |
+| **步骤** | 翻页操作 |
+| **预期** | 分页组件工作正常 |
+
+---
+
+## 9. 公共功能与权限
+
+### TC-COMMON-001:未登录访问保护
+| 字段 | 内容 |
+|------|------|
+| **前置** | 清除 localStorage,未登录状态 |
+| **步骤** | 1. 直接访问 `/dashboard`、`/users`、`/orders` 等受保护路由 |
+| **预期** | 路由守卫自动重定向至 `/login`,且不显示目标页面内容 |
+
+### TC-COMMON-002:Token 过期/无效
+| 字段 | 内容 |
+|------|------|
+| **前置** | localStorage 中存储过期或无效 token |
+| **步骤** | 1. 刷新页面<br>2. 访问任意受保护页面 |
+| **预期** | ① 请求 API 返回 code=1001<br>② 自动清除 localStorage 中的 token<br>③ 页面跳转至 `/login`<br>④ 弹出错误提示(如 Token invalid) |
+
+### TC-COMMON-003:退出登录
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录状态 |
+| **步骤** | 1. 点击右上角用户头像/退出按钮(如有) |
+| **预期** | ① localStorage 中 token 被清除<br>② 跳转至 `/login` |
+
+### TC-COMMON-004:侧边菜单导航
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录 |
+| **步骤** | 1. 依次点击左侧菜单每个条目 |
+| **预期** | ① 每个菜单项可点击<br>② 当前页面对应的菜单项高亮<br>③ 页面内容正常加载<br>④ 浏览器前进后退按钮可正常工作 |
+
+### TC-COMMON-005:页面标题
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录 |
+| **步骤** | 依次访问各页面,查看浏览器 tab 标题 |
+| **预期** | 每个页面有正确的浏览器标题,如「数字能量学管理平台 - 用户管理」 |
+
+---
+
+## 10. 边界与异常场景
+
+### TC-EDGE-001:重复操作
+| 字段 | 内容 |
+|------|------|
+| **前置** | 待审核的提现记录 |
+| **步骤** | 1. 快速连续点击两次「通过」按钮 |
+| **预期** | ① 第二次点击不触发请求或请求被拒绝<br>② 不出现重复成功提示或数据不一致 |
+
+### TC-EDGE-002:批量修改配置后页面刷新
+| 字段 | 内容 |
+|------|------|
+| **前置** | 配置已加载 |
+| **步骤** | 1. 修改配置多处值<br>2. 修改后不保存,直接刷新页面 |
+| **预期** | ① 页面重新加载后,所有配置值恢复为数据库中的值<br>② 未保存的修改丢失 |
+
+### TC-EDGE-003:Token 在 localStorage 中被篡改
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录 |
+| **步骤** | 1. 手动修改 localStorage 中的 admin_token 为任意字符串<br>2. 刷新页面 |
+| **预期** | API 请求返回 1001,页面跳转至登录页 |
+
+### TC-EDGE-004:网络超时
+| 字段 | 内容 |
+|------|------|
+| **前置** | 已登录,网络延迟极高或断开 |
+| **步骤** | 1. 访问任意页面 |
+| **预期** | ① 页面显示 loading 状态后超时<br>② 弹出「网络错误」提示<br>③ 页面不崩溃 |
+
+### TC-EDGE-005:空数据库状态
+| 字段 | 内容 |
+|------|------|
+| **前置** | 数据库为空(全新安装) |
+| **步骤** | 依次访问所有页面 |
+| **预期** | 每个页面均正常渲染,显示空状态(「暂无数据」/ 0值),不报错 |
+
+---
+
+## 11. 配置项完整清单校验
+
+### TC-CONFIG-ALL:全部配置字段覆盖检查
+| 字段 | 内容 |
+|------|------|
+| **前置** | sys_config 表有完整种子数据 |
+| **步骤** | 1. 打开系统配置页面<br>2. 对照下方清单逐一核对 |
+| **预期** | 以下所有配置项均存在且值正确: |
+
+```
+定价配置:
+  - pricing.practitioner.seed      能量师种子价 (default: 1314)
+  - pricing.practitioner.standard  能量师标准价 (default: 1986)
+  - pricing.practitioner.seed_limit 种子价上限 (default: 300)
+  - pricing.annual                 C端年费 (default: 131)
+
+B端佣金(固定金额):
+  - commission.practitioner.l1        L1 能量师推荐 (default: 300)
+  - commission.practitioner.l1_cend   L1 C端推荐 (default: 100)
+  - commission.practitioner.l2        L2 (default: 50)
+
+C端佣金(百分比):
+  - commission.annual.direct_rate    直接佣金比例 (default: 40)
+  - commission.annual.upstream_rate  上级佣金比例 (default: 5)
+
+人工方案:
+  - commerce.category.practitioner_plan.commission_rate  平台佣金率 (default: 10)
+  - commission.practitioner_plan.referral_rate           分销直接占比 (default: 50)
+  - commission.practitioner_plan.upstream_rate           分销上级占比 (default: 20)
+  - plan_request.min_price   方案最低价 (default: 99)
+  - plan_request.max_price   方案最高价 (default: 9999)
+  - plan_request.max_rounds  最大协商轮次 (default: 3)
+
+配额:
+  - quota.chart  每日AI解读 (default: 1)
+  - quota.chat   每日AI问答 (default: 3)
+  - quota.share  每日分享 (default: 3)
+
+其他参数:
+  - system.referral_prefix  推广码前缀 (default: NUM)
+  - system.chat_interval    咨询最短间隔(秒) (default: 5)
+```
+
+---
+
+## 12. 回归测试要点
+
+每次后端 API 或前端组件变更后,以下核心流程需回归验证:
+
+| 流程 | 涉及页面 | 关键验证点 |
+|------|----------|-----------|
+| 登录 → 仪表盘 | Login → Dashboard | token 获取、跳转、统计加载 |
+| 用户操作 | Users | 升级VIP、设/取消管理员后数据一致 |
+| 提现审核 | Withdraw | 通过/拒绝后状态正确,按钮消失 |
+| 配置保存 | Settings | 修改→保存→刷新→值保持 |
+| 权限拦截 | 全部 | 未登录重定向、token过期处理 |
+
+---
+
+## 附录:API 依赖关系
+
+| 前端功能 | 依赖的后端 API | 请求方式 |
+|----------|---------------|----------|
+| 登录 | `POST /api/admin/login` | 无 token |
+| 仪表盘 | `POST /api/admin/stats` | 需 admin token |
+| 用户列表 | `POST /api/admin/users` | 需 admin token |
+| 升级VIP | `POST /api/admin/user/upgrade` | 需 admin token |
+| 设/取消管理 | `POST /api/admin/user/set-admin` | 需 admin token |
+| 订单列表 | `POST /api/admin/orders` | 需 admin token |
+| 佣金列表 | `POST /api/admin/commissions` | 需 admin token |
+| 提现列表 | `POST /api/admin/withdraws` | 需 admin token |
+| 通过提现 | `POST /api/admin/withdraw/approve` | 需 admin token |
+| 拒绝提现 | `POST /api/admin/withdraw/reject` | 需 admin token |
+| 配置列表 | `POST /api/admin/config/list` | 需 admin token |
+| 配置保存 | `POST /api/admin/config/update` | 需 admin token |
+| 配置重置 | `POST /api/admin/config/reset` | 需 admin token |
+| 分析记录 | `POST /api/admin/charts` | 需 admin token |

+ 216 - 0
.opencode/test-strategy-report.md

@@ -0,0 +1,216 @@
+# 数字能量小程序测试策略评估报告
+
+**日期**: 2026-05-31
+**评估人**: 数字能量小程序测试工程师
+
+---
+
+## 1. 现有测试资产总览
+
+### 1.1 后端测试(10个JUnit类,共 2,683 行)
+
+| 测试类 | 被测模块 | 行数 | 覆盖程度 | 质量评估 |
+|--------|---------|------|---------|---------|
+| `CalculatorServiceTest` | 命盘计算引擎 | 401 | ⭐⭐⭐ 高 | 7位置+16位置+卓越数+30位置方案文档 |
+| `UserServiceTest` | 用户/登录/配额 | 369 | ⭐⭐⭐ 高 | 注册、推广码、VIP、额度、跨天重置 |
+| `OrderServiceTest` | 订单/支付回调 | 365 | ⭐⭐⭐ 高 | 幂等、B端固定佣金、C端比例佣金、多级 |
+| `ChartServiceTest` | 命盘CRUD/咨询 | 287 | ⭐⭐⭐ 高 | 创建/配额/Dify降级/查重/删除权限 |
+| `ControllerIntegrationTest` | 控制器集成 | 683 | ⭐⭐⭐ 高 | Auth/Chart/Chat/Pay/Admin/Profile/Commission |
+| `ChatServiceTest` | AI问答 | 166 | ⭐⭐⭐ 高 | 消息保存/配额/权限/Dify降级/顺序验证 |
+| `ConfigServiceTest` | 系统配置 | 155 | ⭐⭐⭐ 高 | 缓存/CRUD/类型转换/缺省值 |
+| `CommissionServiceTest` | 佣金查询 | 147 | ⭐⭐ 中 | 基础查询覆盖,**状态流转测试 @Disabled** |
+| `WithdrawServiceTest` | 提现管理 | 125 | ⭐⭐ 中 | Phase 2功能,基础覆盖 |
+| `DifyServiceTest` | AI调用 | 83 | ⭐⭐ 中 | 成功/异常/空响应,工具类覆盖有限 |
+
+### 1.2 测试资产分布
+
+```
+后端总代码量: ~2,897 行(12 services + 10 controllers)
+后端测试代码: ~2,683 行(含 ControllerIntegrationTest)
+测试/代码比: ~92%(仅看有测试的模块)
+```
+
+### 1.3 前端测试
+
+| 类型 | 状态 |
+|------|------|
+| 单元测试框架 | ❌ 未安装 |
+| 组件测试 | ❌ 无 |
+| Store 测试 | ❌ 无 |
+| E2E 测试 | ❌ 无 |
+| 真机测试方案 | ❌ 无 |
+
+---
+
+## 2. 覆盖缺口矩阵
+
+### 2.1 后端完全未覆盖
+
+| 服务 | 行数 | 风险等级 | 说明 |
+|------|------|---------|------|
+| **WxPayService** | 189 行 | 🔴 高 | 微信支付统一下单、回调验证、签名。**支付是资金核心** |
+| **WeChatService** | 67 行 | 🟡 中 | codeToOpenid、消息模板等 |
+| **AnnotationService** | 64 行 | 🟢 低 | 标注/标签CRUD,逻辑简单 |
+
+### 2.2 控制器分层覆盖
+
+ControllerIntegrationTest 覆盖了 API 层级的集成,但缺少:
+- **逐控制器粒度测试**(不依赖MockMvc的场景测试)
+- **AdminController** 的配置管理具体功能验证(仅测了 token 拦截)
+- **ProfileController** updateProfile 的字段级验证
+
+### 2.3 已知缺陷(标记 @Disabled)
+
+| 位置 | 问题 | 影响 |
+|------|------|------|
+| `CommissionServiceTest.testCommissionStatusFlow_pendingToSettled` | pending→settled 状态流转断链 | 佣金状态显示可能不正确 |
+| `CommissionServiceTest.testCommissionStatusFlow_settledToAvailable` | settled→available 自动流转未实现 | 提现管理 Phase 2 依赖此功能 |
+| `CalculatorServiceTest.testExternalPosition_Formulas_Documented` | 外部位置独立公式待实现 | P-X 位置计算可能沿用旧方案 |
+| `CalculatorServiceTest.testExternalPosition_30PositionNaming` | 30位置命名方案待实现 | A-H input层命名不完整 |
+
+### 2.4 前端测试缺口
+
+| 模块 | 风险 | 理由 |
+|------|------|------|
+| **TriangleChart.vue** (930行) | 🔴 高 | 复杂Canvas/position定位/交互/批注/Tag。**视觉呈现无自动化保障** |
+| **calculator.js** (172行) | 🔴 高 | 前端保留计算逻辑,需与后端`CalculatorService`一致性验证 |
+| **stores/user.js** (132行) | 🟡 中 | 登录、状态管理、配额、分销数据的state变动 |
+| **stores/chart.js** (72行) | 🟡 中 | 咨询流程状态管理 |
+| **API层 utils/api.js** | 🟡 中 | 请求拦截、错误处理、token刷新 |
+| **10个页面** | 🟢→🟡 | 页面间导航、参数传递、生命周期 |
+
+---
+
+## 3. 分层测试策略建议
+
+### 3.1 优先级 P0 — 阻塞级(立刻行动)
+
+| # | 测试项 | 类型 | 预估工作量 |
+|---|--------|------|-----------|
+| 1 | **WxPayService** 单元测试 | 后端 | 0.5天 |
+| 2 | **calculator.js 与 CalculatorService 一致性验证** | 前端+后端 | 0.5天 |
+| 3 | **前端单元测试框架搭建** (vitest + @vue/test-utils) | 基础设施 | 0.5天 |
+
+### 3.2 优先级 P1 — 关键级(当前 Phase 1)
+
+| # | 测试项 | 类型 | 预估工作量 |
+|---|--------|------|-----------|
+| 4 | **TriangleChart.vue** 组件测试(渲染+交互) | 前端 | 1天 |
+| 5 | **stores/user.js** Pinia store 测试 | 前端 | 0.5天 |
+| 6 | **stores/chart.js** 咨询流程测试 | 前端 | 0.5天 |
+| 7 | **WeChatService** 单元测试 | 后端 | 0.25天 |
+| 8 | **CommissionService 状态流转修复验证** | 后端 | 0.25天 |
+| 9 | **AnnotationService** 单元测试 | 后端 | 0.25天 |
+| 10 | **分销链路E2E测试**(推广码→绑定→付费→佣金→面板) | 集成 | 1天 |
+
+### 3.3 优先级 P2 — 重要级
+
+| # | 测试项 | 类型 | 预估工作量 |
+|---|--------|------|-----------|
+| 11 | **AdminController** 管理后台API测试 | 后端 | 0.5天 |
+| 12 | **ProfileController** 资料更新字段级验证 | 后端 | 0.25天 |
+| 13 | **页面导航流测试**(首页→登录→命盘→支付→记录→个人) | 前端集成 | 0.5天 |
+| 14 | **真机兼容测试矩阵**(Top 10设备 + iOS/Android) | 真机 | 1天 |
+| 15 | **Canvas截图比对回归方案** | 视觉 | 1天 |
+
+### 3.4 优先级 P3 — 增强级
+
+| # | 测试项 | 类型 | 预估工作量 |
+|---|--------|------|-----------|
+| 16 | 额度控制边界测试(0/1/3/999 边界) | 后端 | 0.25天 |
+| 17 | API错误码全覆盖验证(每个controller的异常路径) | 后端 | 0.5天 |
+| 18 | 云函数冷启动性能测试 | 性能 | 0.5天 |
+| 19 | 分销多级(3+层)佣金结算准确性 | 后端 | 0.5天 |
+| 20 | 微信审核合规检查自动化 | 合规 | 0.5天 |
+
+---
+
+## 4. 测试架构建议
+
+### 4.1 前端测试技术选型
+
+```
+单元测试: vitest + @vue/test-utils(与 Vite 生态兼容)
+组件测试: vitest + @vue/test-utils(渲染 + 交互 + Emits)
+Store测试: pinia 测试助手(isolate store from component)
+E2E:     Playwright(web版)/ 小程序自动化SDK(真机)
+覆盖率:   c8/istanbul(vitest内置)
+```
+
+### 4.2 后端测试增强
+
+```
+现有体系: JUnit 5 + Mockito + SpringBootTest + MockMvc
+建议补充:
+  - WxPayService: Mock RestTemplate, 验证签名/参数/回调
+  - WeChatService: Mock HttpClient, 验证codeToOpenid异常
+  - AnnotationService: 与已存在的ControllerIntegrationTest互补
+  - 参数化测试: @ParameterizedTest 覆盖更多生日边界
+```
+
+### 4.3 calculator.js 一致性验证方案
+
+使用 **数据驱动参数化测试**,取 N=50+ 组生日分别在前端和后端计算,对比结果:
+
+```js
+// 前端数据生成(Node环境运行calculator.js)
+const result = calculateTriangle(1990, 6, 15)
+// 输出: {I:6, J:6, K:1, L:9, M:3, N:1, O:4, P:9, ...}
+```
+
+```java
+// 后端验证(JUnit ParameterizedTest)
+@CsvSource({
+  "1990,6,15,6,6,1,9,3,1,4,9,9,9,2,1,3,7,5,3",
+  "2009,5,26,5,8,2,9,4,11,6,4,3,7,3,2,5,1,8,9"
+})
+void testConsistency(int y, int m, int d, ...int expected) {
+  assertEquals(expected, calculatorService.calculateFullTriangle(y, m, d));
+}
+```
+
+### 4.4 可视化测试方案
+
+TriangleChart 组件验证分为三层:
+
+```
+Layer 1: 数据层 — chart prop 传入不同数据,验证matrix computed正确
+Layer 2: 渲染层 — vitest + jsdom 验证 DOM 结构(数字位置、CSS class)
+Layer 3: 视觉层 — Playwright 截图比对(需web版),或真机手动
+```
+
+---
+
+## 5. 风险矩阵
+
+| 风险 | 概率 | 影响 | 缓解措施 |
+|------|------|------|---------|
+| 支付接口Mock与实际微信行为不一致 | 中 | 高 | 沙箱验证+手工回归+日志审计 |
+| 小程序Canvas在真机和模拟器渲染差异 | 高 | 中 | 截面对比+真机抽查清单 |
+| calculator.js与后端计算逻辑不同步 | 中 | 高 | 一致性参数化测试+CI门禁 |
+| 分销佣金计算出现精度问题 | 低 | 高 | 大数据量随机测试+边界验证 |
+| 微信审核政策变更导致下线 | 低 | 极高 | 合规检查清单+审核预检流程 |
+| AI解读降级策略未被触发 | 中 | 中 | Dify异常注入测试+Dify健康监控 |
+
+---
+
+## 6. 量化目标
+
+| 指标 | 当前 | 目标 (Phase 1) |
+|------|------|---------------|
+| 后端Service测试覆盖率 | ~75% (9/12) | **100%** (12/12) |
+| 后端Controller测试覆盖率 | ~100% (1集成) | **逐控制器+集成** |
+| 前端单元测试覆盖率 | 0% | **>60%** (关键模块) |
+| 前端组件测试 | 0 | **至少3个** (TriangleChart + 2 stores) |
+| E2E链路覆盖 | 0 | **3条核心链路** (咨询+支付+分销) |
+| 已知@Disabled测试 | 4个 | **全部修复或明确理由** |
+| 真机兼容测试 | 未执行 | **Top 10设备每版本** |
+
+---
+
+## 7. 后续行动建议
+
+1. **本周**: 修复 CommissionService 状态流转、启动 WxPayService 测试
+2. **下周**: 搭建前端测试框架 + calculator.js 一致性验证
+3. **Phase 1 过半**: TriangleChart 组件测试 + 分销E2E
+4. **Phase 1 末**: 真机兼容矩阵 + 灰度发布前全回归

+ 3 - 2
num-server/src/main/java/com/etotem/num/config/WebMvcConfig.java

@@ -21,8 +21,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
     public void addInterceptors(InterceptorRegistry registry) {
         registry.addInterceptor(authInterceptor)
                 .addPathPatterns("/api/**")
-                .excludePathPatterns("/api/auth/login");
+                .excludePathPatterns("/api/auth/login", "/api/admin/login");
         registry.addInterceptor(adminInterceptor)
-                .addPathPatterns("/api/admin/**");
+                .addPathPatterns("/api/admin/**")
+                .excludePathPatterns("/api/admin/login");
     }
 }

+ 486 - 0
num-server/src/test/java/com/etotem/num/controller/AdminControllerApiTest.java

@@ -0,0 +1,486 @@
+package com.etotem.num.controller;
+
+import com.etotem.num.common.Result;
+import com.etotem.num.config.JwtConfig;
+import com.etotem.num.entity.*;
+import com.etotem.num.repository.*;
+import com.etotem.num.service.ConfigService;
+import com.etotem.num.service.WithdrawService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.LocalDateTime;
+import java.util.*;
+
+import static org.hamcrest.Matchers.*;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+class AdminControllerApiTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private JwtConfig jwtConfig;
+
+    @MockBean
+    private UserRepository userRepository;
+
+    @MockBean
+    private OrderRepository orderRepository;
+
+    @MockBean
+    private ChartRecordRepository chartRecordRepository;
+
+    @MockBean
+    private CommissionRepository commissionRepository;
+
+    @MockBean
+    private WithdrawService withdrawService;
+
+    @MockBean
+    private ConfigService configService;
+
+    private String adminToken;
+    private String userToken;
+
+    @BeforeEach
+    void setUp() {
+        adminToken = jwtConfig.generate(0L, "admin");
+        userToken = jwtConfig.generate(100L, "user");
+    }
+
+    // ==================== POST /api/admin/login ====================
+
+    @Test
+    void testAdminLogin_success() throws Exception {
+        mockMvc.perform(post("/api/admin/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"username\":\"admin\",\"password\":\"admin123\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.token").exists());
+    }
+
+    @Test
+    void testAdminLogin_wrongPassword() throws Exception {
+        mockMvc.perform(post("/api/admin/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"username\":\"admin\",\"password\":\"wrong\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1001))
+                .andExpect(jsonPath("$.message").value("Invalid admin credentials"));
+    }
+
+    @Test
+    void testAdminLogin_wrongUsername() throws Exception {
+        mockMvc.perform(post("/api/admin/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"username\":\"hacker\",\"password\":\"admin123\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1001))
+                .andExpect(jsonPath("$.message").value("Invalid admin credentials"));
+    }
+
+    // ==================== Auth Interceptor for Admin Endpoints ====================
+
+    @Test
+    void testAdminEndpoint_withoutToken_rejected() throws Exception {
+        mockMvc.perform(post("/api/admin/users")
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1001));
+    }
+
+    @Test
+    void testAdminEndpoint_withUserToken_rejected() throws Exception {
+        mockMvc.perform(post("/api/admin/users")
+                        .header("Authorization", "Bearer " + userToken)
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1001))
+                .andExpect(jsonPath("$.message").value("Admin access required"));
+    }
+
+    // ==================== POST /api/admin/users ====================
+
+    @Test
+    void testListUsers() throws Exception {
+        User u1 = new User();
+        u1.setId(1L);
+        u1.setNickname("用户A");
+        User u2 = new User();
+        u2.setId(2L);
+        u2.setNickname("用户B");
+        when(userRepository.findAll()).thenReturn(Arrays.asList(u1, u2));
+
+        mockMvc.perform(post("/api/admin/users")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.length()").value(2))
+                .andExpect(jsonPath("$.data[0].nickname").value("用户A"));
+    }
+
+    // ==================== POST /api/admin/user/upgrade ====================
+
+    @Test
+    void testUpgradeUserVip_success() throws Exception {
+        User user = new User();
+        user.setId(1L);
+        user.setVipEndTime(null);
+        when(userRepository.findById(1L)).thenReturn(Optional.of(user));
+        when(userRepository.save(any())).thenReturn(user);
+
+        mockMvc.perform(post("/api/admin/user/upgrade")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\":1}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(userRepository).save(argThat(u -> u.getVipEndTime() != null));
+    }
+
+    @Test
+    void testUpgradeUserVip_notFound() throws Exception {
+        when(userRepository.findById(999L)).thenReturn(Optional.empty());
+
+        mockMvc.perform(post("/api/admin/user/upgrade")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\":999}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1002))
+                .andExpect(jsonPath("$.message").value("User not found"));
+    }
+
+    // ==================== POST /api/admin/user/downgrade ====================
+
+    @Test
+    void testDowngradeUserVip_success() throws Exception {
+        User user = new User();
+        user.setId(1L);
+        user.setVipEndTime(LocalDateTime.now().plusDays(30));
+        when(userRepository.findById(1L)).thenReturn(Optional.of(user));
+        when(userRepository.save(any())).thenReturn(user);
+
+        mockMvc.perform(post("/api/admin/user/downgrade")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\":1}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(userRepository).save(argThat(u -> u.getVipEndTime() == null));
+    }
+
+    @Test
+    void testDowngradeUserVip_notFound() throws Exception {
+        when(userRepository.findById(999L)).thenReturn(Optional.empty());
+
+        mockMvc.perform(post("/api/admin/user/downgrade")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\":999}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1002));
+    }
+
+    // ==================== POST /api/admin/user/set-admin ====================
+
+    @Test
+    void testSetAdmin_grant() throws Exception {
+        User user = new User();
+        user.setId(1L);
+        user.setIsAdmin(false);
+        when(userRepository.findById(1L)).thenReturn(Optional.of(user));
+        when(userRepository.save(any())).thenReturn(user);
+
+        mockMvc.perform(post("/api/admin/user/set-admin")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\":1,\"isAdmin\":true}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(userRepository).save(argThat(u -> u.getIsAdmin() == true));
+    }
+
+    @Test
+    void testSetAdmin_revoke() throws Exception {
+        User user = new User();
+        user.setId(1L);
+        user.setIsAdmin(true);
+        when(userRepository.findById(1L)).thenReturn(Optional.of(user));
+        when(userRepository.save(any())).thenReturn(user);
+
+        mockMvc.perform(post("/api/admin/user/set-admin")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\":1,\"isAdmin\":false}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(userRepository).save(argThat(u -> u.getIsAdmin() == false));
+    }
+
+    @Test
+    void testSetAdmin_userNotFound() throws Exception {
+        when(userRepository.findById(999L)).thenReturn(Optional.empty());
+
+        mockMvc.perform(post("/api/admin/user/set-admin")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"userId\":999,\"isAdmin\":true}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1002));
+    }
+
+    // ==================== POST /api/admin/orders ====================
+
+    @Test
+    void testListOrders() throws Exception {
+        Order o = new Order();
+        o.setId(1L);
+        o.setTotalFee(39800);
+        when(orderRepository.findAll()).thenReturn(Collections.singletonList(o));
+
+        mockMvc.perform(post("/api/admin/orders")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data[0].totalFee").value(39800));
+    }
+
+    // ==================== POST /api/admin/commissions ====================
+
+    @Test
+    void testListAllCommissions() throws Exception {
+        Commission c = new Commission();
+        c.setId(1L);
+        c.setAmount(5000);
+        when(commissionRepository.findAll()).thenReturn(Collections.singletonList(c));
+
+        mockMvc.perform(post("/api/admin/commissions")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data[0].amount").value(5000));
+    }
+
+    // ==================== POST /api/admin/charts ====================
+
+    @Test
+    void testListAllCharts() throws Exception {
+        ChartRecord r = new ChartRecord();
+        r.setId(1L);
+        r.setBirthday("1990-01-01");
+        when(chartRecordRepository.findAll()).thenReturn(Collections.singletonList(r));
+
+        mockMvc.perform(post("/api/admin/charts")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data[0].birthday").value("1990-01-01"));
+    }
+
+    // ==================== POST /api/admin/withdraws ====================
+
+    @Test
+    void testListWithdraws() throws Exception {
+        Withdraw w = new Withdraw();
+        w.setId(1L);
+        w.setStatus("pending");
+        when(withdrawService.getAllPending()).thenReturn(Collections.singletonList(w));
+
+        mockMvc.perform(post("/api/admin/withdraws")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data[0].status").value("pending"));
+    }
+
+    // ==================== POST /api/admin/withdraw/approve ====================
+
+    @Test
+    void testApproveWithdraw() throws Exception {
+        doNothing().when(withdrawService).approve(1L);
+
+        mockMvc.perform(post("/api/admin/withdraw/approve")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"id\":1}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(withdrawService).approve(1L);
+    }
+
+    // ==================== POST /api/admin/withdraw/reject ====================
+
+    @Test
+    void testRejectWithdraw() throws Exception {
+        doNothing().when(withdrawService).reject(1L);
+
+        mockMvc.perform(post("/api/admin/withdraw/reject")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"id\":1}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(withdrawService).reject(1L);
+    }
+
+    // ==================== POST /api/admin/stats ====================
+
+    @Test
+    void testStats() throws Exception {
+        when(userRepository.count()).thenReturn(10L);
+        when(userRepository.countByVipEndTimeAfterAndVipEndTimeIsNotNull(any())).thenReturn(3L);
+        when(orderRepository.count()).thenReturn(5L);
+        when(chartRecordRepository.count()).thenReturn(20L);
+        when(commissionRepository.count()).thenReturn(8L);
+        when(orderRepository.findAll()).thenReturn(Collections.emptyList());
+
+        mockMvc.perform(post("/api/admin/stats")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.totalUsers").value(10))
+                .andExpect(jsonPath("$.data.vipUsers").value(3))
+                .andExpect(jsonPath("$.data.totalOrders").value(5))
+                .andExpect(jsonPath("$.data.totalCharts").value(20))
+                .andExpect(jsonPath("$.data.totalCommissions").value(8))
+                .andExpect(jsonPath("$.data.totalRevenue").value(0));
+    }
+
+    // ==================== POST /api/admin/config/list ====================
+
+    @Test
+    void testListConfigs() throws Exception {
+        SystemConfig cfg = new SystemConfig();
+        cfg.setConfigKey("quota_chart");
+        cfg.setConfigValue("3");
+        when(configService.getAllConfigs()).thenReturn(Collections.singletonList(cfg));
+
+        mockMvc.perform(post("/api/admin/config/list")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data[0].configKey").value("quota_chart"))
+                .andExpect(jsonPath("$.data[0].configValue").value("3"));
+    }
+
+    // ==================== POST /api/admin/config (flat map) ====================
+
+    @Test
+    void testGetConfigMap() throws Exception {
+        Map<String, String> configMap = new HashMap<>();
+        configMap.put("quota_chart", "3");
+        configMap.put("quota_chat", "3");
+        when(configService.getAll()).thenReturn(configMap);
+
+        mockMvc.perform(post("/api/admin/config")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.quota_chart").value("3"));
+    }
+
+    // ==================== POST /api/admin/config/update ====================
+
+    @Test
+    void testUpdateConfig_singleKeyValue() throws Exception {
+        doNothing().when(configService).setValue("quota_chart", "5");
+
+        mockMvc.perform(post("/api/admin/config/update")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"key\":\"quota_chart\",\"value\":\"5\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(configService).setValue("quota_chart", "5");
+    }
+
+    @Test
+    void testUpdateConfig_batch() throws Exception {
+        Map<String, String> batch = new HashMap<>();
+        batch.put("quota_chart", "10");
+        batch.put("quota_chat", "5");
+        doNothing().when(configService).batchUpdate(batch);
+
+        mockMvc.perform(post("/api/admin/config/update")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"quota_chart\":\"10\",\"quota_chat\":\"5\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(configService).batchUpdate(argThat(m ->
+                "10".equals(m.get("quota_chart")) && "5".equals(m.get("quota_chat"))));
+    }
+
+    // ==================== POST /api/admin/config/reset ====================
+
+    @Test
+    void testResetConfig_success() throws Exception {
+        when(configService.resetToDefault("quota_chart")).thenReturn(true);
+
+        mockMvc.perform(post("/api/admin/config/reset")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"configKey\":\"quota_chart\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(configService).resetToDefault("quota_chart");
+    }
+
+    @Test
+    void testResetConfig_missingKey() throws Exception {
+        mockMvc.perform(post("/api/admin/config/reset")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1003))
+                .andExpect(jsonPath("$.message").value("configKey is required"));
+
+        verify(configService, never()).resetToDefault(anyString());
+    }
+
+    @Test
+    void testResetConfig_unknownKey() throws Exception {
+        when(configService.resetToDefault("bad_key")).thenReturn(false);
+
+        mockMvc.perform(post("/api/admin/config/reset")
+                        .header("Authorization", "Bearer " + adminToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"configKey\":\"bad_key\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1004))
+                .andExpect(jsonPath("$.message").value("Unknown config key: bad_key"));
+    }
+}

+ 107 - 3
num-server/src/test/java/com/etotem/num/controller/ControllerIntegrationTest.java

@@ -21,6 +21,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.http.MediaType;
 import org.springframework.test.web.servlet.MockMvc;
 
+import java.time.LocalDate;
 import java.util.*;
 
 import static org.hamcrest.Matchers.*;
@@ -228,6 +229,61 @@ class ControllerIntegrationTest {
                 .andExpect(jsonPath("$.code").value(0));
     }
 
+    @Test
+    void testChartList() throws Exception {
+        ChartRecord r1 = new ChartRecord();
+        r1.setId(1L);
+        r1.setUserId(100L);
+        r1.setBirthday("1990-01-01");
+        ChartRecord r2 = new ChartRecord();
+        r2.setId(2L);
+        r2.setUserId(100L);
+        r2.setBirthday("1995-05-05");
+        when(chartService.getByUserId(100L))
+                .thenReturn(Arrays.asList(r1, r2));
+
+        mockMvc.perform(post("/api/chart/list")
+                        .header("Authorization", "Bearer " + userToken))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.length()").value(2))
+                .andExpect(jsonPath("$.data[0].id").value(1));
+    }
+
+    @Test
+    void testChartInterpret() throws Exception {
+        Map<String, Object> interpretation = new HashMap<>();
+        interpretation.put("summary", "命盘解读内容");
+        interpretation.put("positions", Collections.emptyList());
+        when(chartService.getInterpretation(100L, 10L))
+                .thenReturn(interpretation);
+
+        mockMvc.perform(post("/api/chart/interpret")
+                        .header("Authorization", "Bearer " + userToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"chartRecordId\":10}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.summary").value("命盘解读内容"));
+    }
+
+    @Test
+    void testChartRegenerate() throws Exception {
+        ChatMessage msg = new ChatMessage();
+        msg.setRole("ai");
+        msg.setContent("重新生成回复");
+        when(chartService.regenerate(10L, 100L))
+                .thenReturn(Collections.singletonList(msg));
+
+        mockMvc.perform(post("/api/consultation/regenerate")
+                        .header("Authorization", "Bearer " + userToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"chartRecordId\":10}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data[0].content").value("重新生成回复"));
+    }
+
     // ==================== ChatController ====================
 
     @Test
@@ -445,6 +501,54 @@ class ControllerIntegrationTest {
                 .andExpect(jsonPath("$.data.count").value(1));
     }
 
+    @Test
+    void testProfileComplete() throws Exception {
+        doNothing().when(userService).completeProfile(eq(100L), eq(1), any(), eq("热爱数字能量"), eq("北京"), eq("易经,星座"), eq(2), eq("新昵称"), eq("http://new.avatar"));
+
+        mockMvc.perform(post("/api/profile/complete")
+                        .header("Authorization", "Bearer " + userToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"gender\":1,\"birthDate\":\"1990-06-15\",\"bio\":\"热爱数字能量\",\"city\":\"北京\",\"tags\":\"易经,星座\",\"lookingFor\":2,\"nickname\":\"新昵称\",\"avatarUrl\":\"http://new.avatar\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(userService).completeProfile(eq(100L), eq(1), eq(LocalDate.parse("1990-06-15")), eq("热爱数字能量"), eq("北京"), eq("易经,星座"), eq(2), eq("新昵称"), eq("http://new.avatar"));
+    }
+
+    @Test
+    void testProfileUpdate() throws Exception {
+        doNothing().when(userService).completeProfile(eq(100L), eq(1), eq(LocalDate.parse("1990-06-15")), eq("新bio"), eq("上海"), eq("测试"), eq(1), eq("更新的昵称"), eq("http://new.avatar"));
+
+        mockMvc.perform(post("/api/profile/update")
+                        .header("Authorization", "Bearer " + userToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"gender\":1,\"birthDate\":\"1990-06-15\",\"bio\":\"新bio\",\"city\":\"上海\",\"tags\":\"测试\",\"lookingFor\":1,\"nickname\":\"更新的昵称\",\"avatarUrl\":\"http://new.avatar\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        verify(userService).completeProfile(eq(100L), eq(1), eq(LocalDate.parse("1990-06-15")), eq("新bio"), eq("上海"), eq("测试"), eq(1), eq("更新的昵称"), eq("http://new.avatar"));
+    }
+
+    @Test
+    void testProfileShareTrack() throws Exception {
+        User mockUser = new User();
+        mockUser.setId(100L);
+        mockUser.setDailyShareCount(1);
+        doNothing().when(userService).consumeQuota(100L, "share");
+        when(userService.isVip(100L)).thenReturn(false);
+        when(userService.getById(100L)).thenReturn(mockUser);
+        when(configService.getQuota("share")).thenReturn(3);
+
+        mockMvc.perform(post("/api/profile/share/track")
+                        .header("Authorization", "Bearer " + userToken))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.dailyShareCount").value(1))
+                .andExpect(jsonPath("$.data.exceeded").value(false));
+
+        verify(userService).consumeQuota(100L, "share");
+    }
+
     // ==================== CommissionController ====================
 
     @Test
@@ -547,7 +651,7 @@ class ControllerIntegrationTest {
         annotation.setUserId(100L);
         annotation.setPosition("O");
         annotation.setContent("测试标注");
-        when(annotationService.addAnnotation(10L, 100L, "O", "测试标注")).thenReturn(annotation);
+        when(annotationService.addAnnotation(10L, 100L, "O", "测试标注", null)).thenReturn(annotation);
 
         mockMvc.perform(post("/api/annotation/add")
                         .header("Authorization", "Bearer " + userToken)
@@ -593,12 +697,12 @@ class ControllerIntegrationTest {
         tag.setId(1L);
         tag.setRecordId(10L);
         tag.setTag("重要");
-        when(annotationService.addTag(10L, 100L, "重要")).thenReturn(tag);
+        when(annotationService.addTag(10L, 100L, "重要", null)).thenReturn(tag);
 
         mockMvc.perform(post("/api/annotation/tag/add")
                         .header("Authorization", "Bearer " + userToken)
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"recordId\":10,\"tag\":\"重要\"}"))
+                        .content("{\"recordId\":10,\"name\":\"重要\"}"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(0))
                 .andExpect(jsonPath("$.data.id").value(1));