Merge remote-tracking branch 'refs/remotes/origin/main' into HEAD
Deploy olons-admin production / deploy (push) Successful in 8m1s
Deploy olons-admin production / deploy (push) Successful in 8m1s
This commit is contained in:
@@ -0,0 +1,864 @@
|
||||
# Olons 全功能测试用例
|
||||
|
||||
> 文档版本:V1.1
|
||||
> 编制日期:2026-10-16
|
||||
> 覆盖范围:Olons C 端、HotGo 管理后台、业务 API、数据库一致性、客服 IM 及跨端业务闭环
|
||||
> 代码基线:`olons_server` 与 `olons_admin` 当前工作区实现
|
||||
> 用途:测试执行、联调验收、发布前回归和缺陷复测
|
||||
|
||||
## 1. 测试目标与范围
|
||||
|
||||
本用例集以当前代码路由、服务端校验、页面按钮和后台菜单为准,重点验证以下内容:
|
||||
|
||||
- C 端注册登录、首页、领取、订单、商城、储入、取出、个人中心、内容页和在线客服。
|
||||
- 后台首页、门户、会员、等级、任务大厅、交易、商品、任务、组织、权限、系统应用和系统设置。
|
||||
- 后台配置、审核及上下架操作对 C 端展示和业务状态的实时影响。
|
||||
- 金额、字符长度、文件大小、分页、时间窗口、次数限制等边界值。
|
||||
- 未登录、越权、重复提交、并发、超时、断网、服务异常及脏数据场景。
|
||||
- 钱包余额、冻结额、业务单、账变流水、审核记录和状态机的一致性。
|
||||
- Olons 与 118IM 之间的账号创建、好友关系、会话、文本及媒体消息链路。
|
||||
|
||||
本轮不包含第三方真实支付清算、短信供应商实发、压力容量定标和渗透测试,但保留相应接口降级与安全验收项。
|
||||
|
||||
当前共 548 条用例,其中页面逐项验收 188 条(通用页面 8 条、C 端页面 90 条、后台页面 90 条),其余 360 条用于补充业务规则、接口、安全、数据一致性和跨端闭环。执行时应先完成第 4 章页面用例,再执行对应模块的深层用例。
|
||||
|
||||
## 2. 测试依据
|
||||
|
||||
| 类型 | 依据 |
|
||||
| --- | --- |
|
||||
| C 端路由 | `olons_server/web/src/router/index.ts` |
|
||||
| C 端接口 | `olons_server/internal/httpapi/` |
|
||||
| C 端业务规则 | `olons_server/internal/accounts`、`finance`、`tasks`、`mall`、`support`、`attachments` |
|
||||
| 后台页面 | `olons_admin/web/src/views/` |
|
||||
| 后台接口 | `olons_admin/server/api/admin/`、`server/internal/logic/sys/` |
|
||||
| 菜单与初始化 | `olons_admin/server/storage/data/migrations/`、`seeds/` |
|
||||
| 产品基线 | `后台管理系统需求文档.md`、`后台管理系统概要设计.md`、`后台功能与数据库表映射.md` |
|
||||
| IM 对接 | `olons_server/docs/IM-前后端对接文档.md`、`Olons-客服IM系统对接设计.md` |
|
||||
|
||||
## 3. 执行约定
|
||||
|
||||
### 3.1 优先级
|
||||
|
||||
| 优先级 | 定义 | 发布要求 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 登录、资金、权限、订单结算、数据泄露等核心链路 | 必须 100% 通过 |
|
||||
| P1 | 主要业务功能及重要异常分支 | 不得存在阻断性缺陷 |
|
||||
| P2 | 次要功能、显示、兼容和易用性 | 可评估后带已知问题发布 |
|
||||
|
||||
### 3.2 通用前置条件
|
||||
|
||||
- 浏览器时间、服务端时间和数据库时间同步,业务时区按 Asia/Shanghai 验证。
|
||||
- 准备独立测试租户,禁止在生产真实会员上执行资金、删除、禁用和权限用例。
|
||||
- 每个资金用例执行前记录钱包 `available_amount`、`frozen_amount`、`version` 和最近流水。
|
||||
- 每个配置联动用例记录配置版本号、发布时间及 C 端接口响应,执行后恢复原配置。
|
||||
- 所有 API 写请求记录 `request_id`;幂等接口记录幂等键、业务单号及返回结果。
|
||||
|
||||
### 3.3 测试账号与数据矩阵
|
||||
|
||||
| 数据代号 | 准备要求 |
|
||||
| --- | --- |
|
||||
| U-NORMAL | 正常真人会员,已设置登录密码和交易密码,已绑已验证收款账户,余额充足 |
|
||||
| U-NEW | 新注册会员,无交易密码、无收款账户、无订单 |
|
||||
| U-LOW | 可用余额低于领取/提现下限 |
|
||||
| U-LIMIT | 当日领取次数或提现次数已达到等级上限 |
|
||||
| U-PENDING | 存在匹配中或待处理订单 |
|
||||
| U-DISABLED | 会员状态禁用或钱包状态禁用 |
|
||||
| U-FAKE | 假人类别会员,用于统计隔离验证 |
|
||||
| U-MANUAL | 派单模式为手动 |
|
||||
| U-AUTO | 派单模式为自动 |
|
||||
| URL-ENABLED | 地址池中的启用地址,域名未绑定或已绑定少量会员 |
|
||||
| URL-INVALID | 地址池中的失效地址,保留历史绑定会员 |
|
||||
| URL-UNKNOWN | 未录入地址池的注册域名,用于验证注册成功但不建立绑定 |
|
||||
| XLS-URL | 地址池导入文件,包含合法、重复、非法 URL、空值和超长数据 |
|
||||
| A-SUPER | 超级管理员,拥有全部菜单和按钮权限 |
|
||||
| A-PARENT | 父角色管理员,只有部分业务菜单 |
|
||||
| A-CHILD | A-PARENT 的子角色,权限必须为父角色子集 |
|
||||
| A-CS/A-FIN/A-OPS | 客服、财务、运营最小权限账号 |
|
||||
| P-ON/P-OFF | 已发布商品/已下架商品 |
|
||||
| T-ON/T-OFF | 已发布任务/已下线任务 |
|
||||
| IM-3 | 客服依依、客服琪琪、客服琳琳,均已绑定 IM 用户及头像 |
|
||||
|
||||
## 4. 页面逐项验收主用例
|
||||
|
||||
本章是测试人员的主要执行入口,按照“进入页面 → 检查默认展示 → 操作页面控件 → 检查弹窗 → 验证成功/失败反馈 → 返回或刷新”的顺序执行。后续章节用于补充接口、状态机、并发和数据一致性,不应替代本章的页面操作。
|
||||
|
||||
### 4.1 每个页面都必须执行的八项检查
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-COM-001 | P1 | 从菜单、快捷入口或站内链接进入页面 | 路由、页面标题、面包屑和菜单高亮正确 |
|
||||
| UI-COM-002 | P1 | 首次进入页面并观察加载过程 | 有加载反馈;加载结束后不会永久转圈或闪烁 |
|
||||
| UI-COM-003 | P1 | 检查页面全部文字、金额、时间、图片、状态标签 | 无错别字、乱码、单位错误、破图或状态空白 |
|
||||
| UI-COM-004 | P1 | 逐个点击页面可见按钮、链接、图标和表格行 | 每个控件均有响应,目标页面或弹窗正确 |
|
||||
| UI-COM-005 | P1 | 打开弹窗后测试右上角关闭、取消、遮罩和 Esc | 按约定关闭;未保存内容不产生后台写入 |
|
||||
| UI-COM-006 | P1 | 对页面输入框执行空值、合法值、边界值和非法值 | 校验位置与文案明确,错误输入不能提交 |
|
||||
| UI-COM-007 | P1 | 模拟空数据、接口失败、超时和无权限 | 分别显示空态、错误态、重试或无权限提示 |
|
||||
| UI-COM-008 | P2 | 刷新、后退、前进、切换标签页和改变窗口宽度 | 页面不白屏;数据和布局按约定恢复 |
|
||||
|
||||
### 4.2 C 端页面逐项测试
|
||||
|
||||
#### 4.2.1 登录与注册页 `/app/auth`
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-AUTH-001 | P1 | 打开登录页 | 仅显示账号登录/注册业务控件,不出现微信、抖音、手机号登录和演示角色入口 |
|
||||
| UI-C-AUTH-002 | P1 | 点击“账号登录”“注册账号”“已有账户,马上登录” | 登录与注册表单正确切换,已有输入按页面约定保留或清空 |
|
||||
| UI-C-AUTH-003 | P1 | 登录模式检查手机号、密码框和密码显隐图标 | 占位符、输入类型、显隐切换和键盘类型正确 |
|
||||
| UI-C-AUTH-004 | P0 | 输入合法账号并点击“登录” | 按钮显示“请稍候”并禁用,成功进入原目标页 |
|
||||
| UI-C-AUTH-005 | P1 | 输入错误账号并点击“登录” | 页面停留,错误提示清楚,密码不明文残留在日志或 URL |
|
||||
| UI-C-AUTH-006 | P1 | 注册模式检查姓名、手机号、密码、邀请码、协议勾选框 | 字段与当前注册规则一致,邀请码和协议为必填 |
|
||||
| UI-C-AUTH-007 | P0 | 未勾选协议点击“注册” | 页面阻止注册并定位协议区域 |
|
||||
| UI-C-AUTH-008 | P1 | 点击隐私协议/用户协议链接后返回 | 内容页可读,返回后注册表单状态合理保留 |
|
||||
|
||||
#### 4.2.2 首页 `/app/home`
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-HOME-001 | P1 | 打开首页并等待加载完成 | 顶部信息、Banner、快捷功能、滚动信息、内容入口和底部导航完整显示 |
|
||||
| UI-C-HOME-002 | P1 | 左右滑动 Banner、点击圆点和 Banner 本体 | 切换流畅;有效跳转正确;无跳转 Banner 不误跳 |
|
||||
| UI-C-HOME-003 | P1 | 点击搜索框 | 进入商城或约定搜索页面,返回首页位置正确 |
|
||||
| UI-C-HOME-004 | P0 | 依次点击“领取、储入、取出、商城” | 分别进入领取、储入、取出、商城页面 |
|
||||
| UI-C-HOME-005 | P1 | 后台关闭四个功能后检查对应入口 | 置灰且无法进入;置灰文字仍可辨识 |
|
||||
| UI-C-HOME-006 | P1 | 点击平台介绍、规则说明、公司资质、代理合作 | 均进入正确内容页,标题和正文相符 |
|
||||
| UI-C-HOME-007 | P1 | 点击滚动公告/弹窗通知 | 展开对应详情,不跳到错误会员或订单页面 |
|
||||
| UI-C-HOME-008 | P1 | 点击底部“首页、记录、领取、客服、我的” | 五个页面均可达,当前项高亮正确 |
|
||||
|
||||
#### 4.2.3 领取页 `/app/claim`
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-CLAIM-001 | P1 | 打开领取页 | 总资产、昨日佣金、今日佣金、今日领取数和领取主按钮完整显示 |
|
||||
| UI-C-CLAIM-002 | P0 | 满足条件时点击领取主按钮 | 按钮进入加载态,不能重复点击;随后展示匹配结果 |
|
||||
| UI-C-CLAIM-003 | P1 | 不满足余额/等级/营业时间/次数条件时点击领取 | 弹层显示对应原因,不出现笼统“失败” |
|
||||
| UI-C-CLAIM-004 | P1 | 有在途订单时点击“查看任务” | 进入该订单详情,不新建第二张订单 |
|
||||
| UI-C-CLAIM-005 | P1 | 匹配中观察进度、等待和倒计时区域 | 状态能刷新,离开再返回仍能恢复当前订单 |
|
||||
| UI-C-CLAIM-006 | P1 | 结果失败时点击“重新领取” | 重新检查资格;按钮加载态和结果更新正确 |
|
||||
| UI-C-CLAIM-007 | P1 | 点击“稍后去记录查看”“查看记录”“返回首页” | 分别进入订单列表、订单详情或首页 |
|
||||
|
||||
#### 4.2.4 订单列表与详情 `/app/orders`、`/app/orders/:id`
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-ORDER-001 | P1 | 打开订单列表 | 默认标签、订单卡片、状态、金额、商品图和时间正确 |
|
||||
| UI-C-ORDER-002 | P1 | 依次点击“全部、待处理、已完成” | 列表只显示对应订单;无数据时显示空态 |
|
||||
| UI-C-ORDER-003 | P1 | 点击任一订单卡片 | 进入该订单详情,订单号和列表所选记录一致 |
|
||||
| UI-C-ORDER-004 | P1 | 详情页检查商品、金额、佣金、状态、时间线和流水 | 字段完整,长文本/图片不撑破布局 |
|
||||
| UI-C-ORDER-005 | P0 | 可提交的手动订单点击“提交确认” | 打开二次确认弹窗,金额和提示与当前订单一致 |
|
||||
| UI-C-ORDER-006 | P1 | 在确认弹窗点击“暂不提交”、关闭和遮罩 | 弹窗关闭,订单和余额不变 |
|
||||
| UI-C-ORDER-007 | P0 | 点击“确认提交” | 按钮防重复;成功提示、状态和余额立即更新 |
|
||||
| UI-C-ORDER-008 | P1 | 余额不足时查看按钮并尝试提交 | 按钮显示“余额不足,无法提交”或引导储入,不错误扣款 |
|
||||
| UI-C-ORDER-009 | P1 | 冻结、超时、取消、失败订单进入详情 | 不显示不允许的提交按钮,提供客服入口或明确说明 |
|
||||
| UI-C-ORDER-010 | P1 | 点击详情返回按钮和浏览器后退 | 返回原列表及原标签位置 |
|
||||
|
||||
#### 4.2.5 商城列表与详情 `/app/mall`、`/app/mall/:id`
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-MALL-001 | P1 | 打开商城 | 搜索框、搜索按钮、推荐任务/限时任务标签和商品卡片显示完整 |
|
||||
| UI-C-MALL-002 | P1 | 输入关键词后点击“搜索”及清空关键词重搜 | 结果按关键词过滤,清空后恢复全部 |
|
||||
| UI-C-MALL-003 | P1 | 切换“推荐任务”“限时任务” | 商品集合和按钮文字同步变化,无重复或串页 |
|
||||
| UI-C-MALL-004 | P1 | 点击商品图片、名称、“领取/抢单” | 均进入同一商品详情 |
|
||||
| UI-C-MALL-005 | P1 | 详情页检查图片、名称、店铺、价格、说明、时限 | 与后台发布内容一致,富文本安全显示 |
|
||||
| UI-C-MALL-006 | P1 | 商品已下架或过期时刷新详情 | 显示不可用状态,不允许继续领取 |
|
||||
|
||||
#### 4.2.6 储入页 `/app/recharge`
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-RC-001 | P1 | 打开储入页 | 储入方式、金额、姓名和提交按钮完整显示 |
|
||||
| UI-C-RC-002 | P1 | 展开“储入方式”下拉并逐项选择 | 只列启用渠道;所选值和渠道提示同步更新 |
|
||||
| UI-C-RC-003 | P1 | 金额留空、越界、输入字母后点击提交 | 页面在金额字段提示,不发起有效订单 |
|
||||
| UI-C-RC-004 | P1 | 姓名留空或输入超长内容 | 阻止提交并显示字段错误 |
|
||||
| UI-C-RC-005 | P0 | 填写合法数据点击“提交” | 按钮显示提交中并禁用;成功提示后跳转客服中心 |
|
||||
| UI-C-RC-006 | P1 | 接口失败后观察页面 | 不跳客服;表单可修改并重新提交 |
|
||||
|
||||
#### 4.2.7 取出页 `/app/profile/withdrawal`
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-WD-001 | P1 | 打开取出页 | 可用余额、金额输入、全部提现、收款账户和申请按钮显示完整 |
|
||||
| UI-C-WD-002 | P1 | 点击“全部提现” | 输入框填入当前可用余额;如超过渠道上限,报价时明确提示而不创建取出单 |
|
||||
| UI-C-WD-003 | P1 | 查看收款账户区域并点击 | 有已核验账户时显示银行及尾号;无账户时进入绑定信息页 |
|
||||
| UI-C-WD-004 | P1 | 输入合法金额点击“申请提现” | 出现确认弹窗,申请金额、手续费和预计到账金额与报价一致 |
|
||||
| UI-C-WD-005 | P1 | 未设置支付密码点击“去设置支付密码”,填写 6 位数字密码和正确登录密码后点击“确认设置” | 原弹窗切换为设置表单;设置成功返回确认申请界面并保留报价,不自动提交取出单 |
|
||||
| UI-C-WD-006 | P1 | 分别在确认取出和设置支付密码界面点击右上角关闭叉号;重新打开后点击“返回确认申请” | 两种界面均可关闭;密码输入被清空、未创建取出单;返回确认时保留报价且仍需再次点击“确认申请” |
|
||||
| UI-C-WD-007 | P0 | 输入支付密码并点击“确认申请” | 加载态防重复;成功关闭弹层并更新余额/记录 |
|
||||
| UI-C-WD-008 | P1 | 密码错误、次数受限、报价过期 | 分别显示明确提示,弹窗状态可恢复或重新报价 |
|
||||
| UI-C-WD-009 | P1 | 未设置支付密码时查看“确认申请”,或设置时输入非 6 位密码/错误登录密码 | 未设置时确认按钮不可提交;格式错误在本地提示,接口失败后保留设置界面且不创建取出单 |
|
||||
| UI-C-WD-010 | P1 | 设置或提交请求进行中连续点击关闭叉号与主按钮 | 请求期间关闭和重复提交被禁止;请求结束后可按状态继续操作 |
|
||||
|
||||
#### 4.2.8 我的页面 `/app/profile`
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-ME-001 | P1 | 打开“我的” | 用户名、邀请码、账户余额及功能菜单完整显示 |
|
||||
| UI-C-ME-002 | P2 | 点击邀请码复制按钮 | 显示复制成功,剪贴板内容为当前邀请码 |
|
||||
| UI-C-ME-003 | P0 | 点击“立即取出”“立即储入” | 分别进入取出和储入页面 |
|
||||
| UI-C-ME-004 | P1 | 点击个人信息、储入记录、取出记录、账户明细、弹窗明细 | 五个入口分别进入正确页面 |
|
||||
| UI-C-ME-005 | P1 | 点击“退出登录”后点击取消 | 弹窗关闭并保持登录 |
|
||||
| UI-C-ME-006 | P0 | 点击“退出登录”后点击确定 | 清除会话并进入登录页,后退不能看到有效敏感页面 |
|
||||
|
||||
#### 4.2.9 个人信息、银行卡与密码页面
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-FORM-001 | P1 | 打开个人信息页 | 头像、本人手机号、取出信息、密码管理、支付密码管理按原型分组展示;头像行和三个入口均可点击 |
|
||||
| UI-C-FORM-002 | P1 | 点击头像行选择合法图片,等待上传完成并刷新页面 | 文件选择器可打开;图片先上传附件再更新本人头像,成功提示且刷新后仍显示新头像 |
|
||||
| UI-C-FORM-003 | P1 | 打开绑定信息页 | 已绑定账户先显示本人原始信息;新增表单的姓名、身份证、卡号、开户行、选填数字人民币编码、微信码、支付宝码字段齐全 |
|
||||
| UI-C-FORM-004 | P1 | 点击两处“点击上传”并分别选图 | 上传中、预览和失败重试状态正确,两张图不会串位 |
|
||||
| UI-C-FORM-005 | P0 | 填完银行卡信息点击“确认绑定” | 按钮防重复;成功后从个人信息进入的返回个人信息,从取出进入的返回取出;本人绑定信息页显示原文,通用账户列表仍脱敏 |
|
||||
| UI-C-FORM-006 | P1 | 打开修改登录密码页并点击“确定修改” | 当前密码、新密码、确认密码校验正确,成功后给出明确反馈 |
|
||||
| UI-C-FORM-007 | P1 | 新密码和确认密码不一致 | 页面本地拦截,不请求修改接口 |
|
||||
| UI-C-FORM-008 | P1 | 打开支付密码页 | 新支付密码、登录密码、已设置时的旧支付密码展示逻辑正确 |
|
||||
| UI-C-FORM-009 | P0 | 填写合法信息点击“确定” | 按钮防重复,成功后返回且支付密码状态更新 |
|
||||
| UI-C-FORM-010 | P0 | 点击个人信息中的“取出信息”查看已绑定账户 | 本人姓名、身份证号、银行名、完整银行卡号、已填的数字人民币编码和两张收款码图片正确展示,不以星号替代原值 |
|
||||
| UI-C-FORM-011 | P1 | 无绑定账户或有多张账户时打开取出信息 | 无账户时可直接新增;多账户逐条对应展示,不串姓名、卡号和收款码 |
|
||||
| UI-C-FORM-012 | P1 | 数字人民币编码留空后提交绑卡 | 页面不把该字段当必填;成功后详情不出现伪造的编码,其他必填项仍正常校验 |
|
||||
| UI-C-FORM-013 | P1 | 头像选择 GIF、伪装扩展名、超过 5MB 图片,或模拟上传/更新接口失败 | 明确提示失败;旧头像不变,可重新选择文件重试 |
|
||||
|
||||
#### 4.2.10 记录、账变、通知和内容页
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-REC-001 | P1 | 从“储入记录”进入并切换记录标签 | 默认定位储入,状态、金额和时间正确 |
|
||||
| UI-C-REC-002 | P1 | 从“取出记录”进入并切换记录标签 | 默认定位取出,手续费、实到和原因正确 |
|
||||
| UI-C-REC-003 | P1 | 点击记录行查看详情 | 进入对应订单详情或展开详情,记录 ID 不串行 |
|
||||
| UI-C-REC-004 | P1 | 账户明细筛选收入/支出并翻页 | 筛选和分页正确,前后余额可读 |
|
||||
| UI-C-REC-005 | P1 | 弹窗明细打开未读通知并返回 | 正文可见,已读状态更新,列表位置合理保留 |
|
||||
| UI-C-REC-006 | P1 | 平台介绍、规则、代理、资质和通用内容页点击返回 | 标题、正文和返回路径正确 |
|
||||
|
||||
#### 4.2.11 客服列表与聊天页 `/app/support`、`/app/support/:id`
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-C-CS-001 | P1 | 打开客服列表 | 三名客服名称、头像、在线/忙碌状态完整显示 |
|
||||
| UI-C-CS-002 | P1 | 依次点击三个客服卡片并返回 | 分别进入对应会话,标题和头像不串客服 |
|
||||
| UI-C-CS-003 | P1 | 连接失败时点击“重新连接” | 显示连接过程,成功恢复输入区;失败可再次操作 |
|
||||
| UI-C-CS-004 | P1 | 输入文字,检查发送按钮显隐并点击 | 有文本时显示清晰的发送按钮;发送中禁用 |
|
||||
| UI-C-CS-005 | P1 | 输入框为空时点击“+” | 展开表情、照片、视频工具;再次点击按约定收起 |
|
||||
| UI-C-CS-006 | P1 | 点击“表情”并切换最近/表情/手势/物品 | 面板紧凑显示,选中表情插入输入框 |
|
||||
| UI-C-CS-007 | P1 | 点击“照片”“视频”并取消系统文件选择 | 不发送空消息,工具区恢复可用 |
|
||||
| UI-C-CS-008 | P0 | 选择合法照片或视频 | 显示上传进度,发送后本方气泡和客服端均可见 |
|
||||
| UI-C-CS-009 | P1 | 点击图片、播放视频、滚动历史消息 | 预览/播放正常,滚动不遮挡底部输入区 |
|
||||
| UI-C-CS-010 | P1 | 检查双方头像、左右气泡、时间和发送状态 | 客服在左、本人在右;头像及已发送/失败状态清楚 |
|
||||
|
||||
### 4.3 后台页面逐项测试
|
||||
|
||||
#### 4.3.1 后台登录与全局框架
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-A-FRAME-001 | P1 | 打开后台登录页 | 标题为“企业办公管理系统”,只显示账号登录表单 |
|
||||
| UI-A-FRAME-002 | P0 | 填写账号密码点击登录 | 加载态防重复;成功进入首页,失败留在登录页 |
|
||||
| UI-A-FRAME-003 | P1 | 逐个展开一级菜单 | 二级菜单、图标、顺序和折叠状态正确 |
|
||||
| UI-A-FRAME-004 | P1 | 依次点击主页、会员、交易、商品、任务下全部二级菜单 | 每个菜单可打开,面包屑和标签页标题正确 |
|
||||
| UI-A-FRAME-005 | P1 | 点击已打开标签、关闭单个、关闭其他、刷新标签 | 标签行为正确,不关闭固定首页 |
|
||||
| UI-A-FRAME-006 | P1 | 点击全局菜单搜索并搜索页面名称 | 结果正确,点击进入目标页面 |
|
||||
| UI-A-FRAME-007 | P1 | 点击右上角头像/个人中心/退出 | 个人中心可达;退出确认后回登录页 |
|
||||
|
||||
#### 4.3.2 首页与门户管理
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-A-HOME-001 | P1 | 打开主控台,逐个检查指标卡、图表和刷新 | 数据、单位、时间范围、刷新时间正确 |
|
||||
| UI-A-HOME-002 | P1 | 点击可跳转指标卡 | 进入对应业务列表并携带正确筛选条件 |
|
||||
| UI-A-SITE-001 | P1 | 打开网站设置,检查所有分组和默认值 | 品牌、交易、佣金、时段、门槛和开关字段完整回显 |
|
||||
| UI-A-SITE-002 | P1 | 修改字段后点击保存/取消/重置 | 保存有反馈;取消/重置不写入;刷新结果符合选择 |
|
||||
| UI-A-SITE-003 | P1 | 点击历史、提交、发布、回滚入口 | 对应列表/弹窗完整,按钮只在合法状态可用 |
|
||||
| UI-A-TEXT-001 | P1 | 打开文本管理,使用查询、重置和分页 | 仅显示文本字段和 12 条初始化数据,内容标识列隐藏 |
|
||||
| UI-A-TEXT-002 | P1 | 点击新增/编辑,检查弹窗和所有字段 | 弹窗足够宽,无图片字段,标题和正文可编辑 |
|
||||
| UI-A-TEXT-003 | P1 | 点击确定、取消、关闭、发布、下线、删除 | 每个动作提示和列表刷新正确,危险动作有确认 |
|
||||
| UI-A-BANNER-001 | P1 | 打开 Banner 设置并测试查询、添加、编辑 | 图片、位置、链接、时段、排序和状态字段齐全 |
|
||||
| UI-A-BANNER-002 | P1 | 测试上传、预览、更换和移除图片 | 预览正确,取消不保存,失败可重试 |
|
||||
| UI-A-BANNER-003 | P1 | 测试发布/下线/删除及确认弹窗 | 状态更新且 C 端同步,取消确认无副作用 |
|
||||
| UI-A-MANUAL-001 | P1 | 打开手工商城并测试查询、添加、编辑 | 名称、价格、展示图片和时间字段完整 |
|
||||
| UI-A-MANUAL-002 | P1 | 测试发布/下架/删除和分页 | 按钮状态与商品状态匹配,列表刷新正确 |
|
||||
|
||||
#### 4.3.3 会员管理页面
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-A-MEMBER-001 | P1 | 逐项填写会员查询条件并点击查询/重置 | 每项条件有效,重置清空并回第一页 |
|
||||
| UI-A-MEMBER-002 | P1 | 拖动表格横向滚动条和操作列独立滚动条 | 所有数据列和操作按钮可达,固定列不漂移 |
|
||||
| UI-A-MEMBER-003 | P1 | 点击添加、详情、编辑 | 对应弹窗字段完整,详情只读,编辑正确回显 |
|
||||
| UI-A-MEMBER-004 | P1 | 点击“更多”或操作弹层 | 银行卡、地址、团队、账变、弹幕、奖励、真人/假人、启禁用、删除均可见 |
|
||||
| UI-A-MEMBER-005 | P1 | 逐个打开银行卡、地址、团队弹窗并关闭 | 标题、表格、按钮和关闭行为正确 |
|
||||
| UI-A-MEMBER-006 | P0 | 点击授权查看明文,未勾选确认或未填用途 | 确认按钮不可提交或明确校验 |
|
||||
| UI-A-MEMBER-007 | P1 | 打开账变、奖励金、弹幕、状态弹窗 | 金额/原因/内容/范围字段和风险提示完整 |
|
||||
| UI-A-MEMBER-008 | P1 | 点击设为真人/假人、启用/禁用、删除并取消确认 | 取消后列表状态不变 |
|
||||
| UI-A-MEMBER-009 | P1 | 点击分页、每页条数和跳页 | 总数、页码、选中行和滚动位置合理更新 |
|
||||
| UI-A-MEMBER-010 | P1 | 输入完整或部分注册域名查询,并组合域名状态筛选 | 支持模糊匹配;待绑定、启用域名、失效域名结果准确 |
|
||||
| UI-A-MEMBER-011 | P1 | 查看会员列表和详情中的注册域名、域名状态、绑定时间 | 三项信息一致;未绑定显示“待绑定”,不显示伪造域名 |
|
||||
| UI-A-MEMBER-012 | P1 | 检查会员列表标题栏和编辑弹窗 | 后台不显示新增会员入口;只允许编辑已有会员 |
|
||||
| UI-A-LEVEL-001 | P1 | 打开会员等级,点击新增和编辑 | 等级、金额、次数、佣金、状态字段完整回显 |
|
||||
| UI-A-LEVEL-002 | P1 | 点击保存、取消、停用/启用、删除 | 每个动作反馈正确,删除显示引用限制 |
|
||||
| UI-A-HALL-001 | P1 | 打开任务大厅,测试查询、新增、编辑和等级选择 | 字段、图标、比例和等级选择正确 |
|
||||
| UI-A-HALL-002 | P1 | 测试单删、勾选多行、批量删除和取消 | 选中态与成功/失败数量提示准确 |
|
||||
| UI-A-URL-001 | P1 | 打开 URL 地址池页面 | 汇总卡、查询栏、列表及操作栏完整;列表按累计绑定次数升序、ID 升序显示 |
|
||||
| UI-A-URL-002 | P1 | 按地址名称、域名、状态和创建时间查询及重置 | 每项筛选有效,域名支持模糊匹配,重置恢复第一页 |
|
||||
| UI-A-URL-003 | P1 | 点击新增/编辑,填写地址名称、注册入口 URL、状态和备注 | URL 被标准化并正确回显;非法协议、查询参数、片段或重复域名不能保存 |
|
||||
| UI-A-URL-004 | P1 | 点击“详情”,查询绑定会员并翻页 | 显示累计/当前绑定次数、最后绑定时间及全部绑定会员;手机号脱敏 |
|
||||
| UI-A-URL-005 | P1 | 点击“设为失效”或“启用”,再点击复制链接 | 状态立即更新;复制内容为标准化注册入口;页面无删除按钮 |
|
||||
| UI-A-URL-006 | P1 | 打开 Excel 批量导入弹窗并检查说明、关闭和取消 | 明确必填表头、格式、大小和 5000 条限制;关闭不上传文件 |
|
||||
| UI-A-URL-007 | P1 | 分别导入合法、重复和含错误行的 XLS-URL | 合法行导入且均为启用;重复跳过;错误行按 Excel 行号展示;汇总与列表刷新 |
|
||||
| UI-A-MLOGIN-001 | P1 | 打开会员登录日志,按会员、方式、结果、域名、IP 和时间查询 | 筛选、重置、分页和空态正确,不与后台管理员登录日志混用 |
|
||||
| UI-A-MLOGIN-002 | P1 | 查看成功和失败日志详情 | 请求 ID、会员、方式、结果、脱敏手机/IP、地区、域名、系统和浏览器完整准确 |
|
||||
| UI-A-MLOGIN-003 | P1 | 按当前筛选条件导出会员登录日志 | 文件可打开,条数和字段与筛选结果一致,无 IP 密文、Token 或密码 |
|
||||
|
||||
#### 4.3.4 交易管理页面
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-A-ORDER-001 | P1 | 订单列表逐项测试筛选、查询、重置和分页 | 条件生效,状态和金额显示正确 |
|
||||
| UI-A-ORDER-002 | P1 | 点击详情 | 订单、会员、商品、任务、金额、时间线和流水信息完整 |
|
||||
| UI-A-ORDER-003 | P0 | 点击“匹配订单”并搜索、清理筛选、选择商品、取消 | 弹窗列表和选中态正确;取消不修改订单 |
|
||||
| UI-A-ORDER-004 | P0 | 选择商品后点击确定 | 加载态防重复,成功关闭并刷新订单 |
|
||||
| UI-A-ORDER-005 | P1 | 点击“更改金额”,测试输入、取消和确定 | 金额校验清楚,取消无副作用,成功后刷新 |
|
||||
| UI-A-ORDER-006 | P0 | 点击“强制付款”“冻结订单”并取消/确认 | 风险文案清晰,取消无副作用,确认结果正确 |
|
||||
| UI-A-ORDER-007 | P1 | 点击“审核任务”或等待会员付款禁用按钮 | 前者跳会员任务审核,后者不可误点 |
|
||||
| UI-A-RC-001 | P1 | 充值列表测试筛选、汇总卡、详情和凭证预览 | 筛选准确,汇总与列表一致,凭证可查看 |
|
||||
| UI-A-RC-002 | P0 | 点击“通过”,填写审核说明,取消/确认 | 取消不入账;确认加载态防重复并刷新状态 |
|
||||
| UI-A-RC-003 | P0 | 点击“驳回”,测试空原因、合法原因和确认 | 必填校验有效,确认后状态和原因正确 |
|
||||
| UI-A-WD-001 | P1 | 提现列表测试筛选、详情和收款信息展示 | 金额、手续费、实到、状态和脱敏信息正确 |
|
||||
| UI-A-WD-002 | P0 | 点击通过/驳回,测试确认弹窗 | 按钮仅在合法状态显示,确认后刷新 |
|
||||
| UI-A-WD-003 | P0 | 处理中记录点击完成并填写外部单号 | 必填校验、加载态和完成结果正确 |
|
||||
| UI-A-CS-001 | P1 | 客服管理测试查询、新增和编辑 | 名称、头像、工作时间、在线状态、IM 绑定、排序和状态完整 |
|
||||
| UI-A-CS-002 | P1 | 点击启用/停用 | 开关与状态标签同步,C 端列表同步 |
|
||||
| UI-A-CS-003 | P1 | 点击删除,测试空原因、取消和确认 | 空原因阻止;取消无副作用;停用记录可删除 |
|
||||
|
||||
#### 4.3.5 商品与任务管理页面
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-A-PROD-001 | P1 | 商品列表填写名称/分类并点击查询 | 列表过滤正确,字段及总数完整 |
|
||||
| UI-A-PROD-002 | P1 | 点击添加/编辑并逐项检查表单 | 商品、分类、价格、店铺、图片、状态、推荐等字段正确 |
|
||||
| UI-A-PROD-003 | P1 | 点击发布/下架并取消/确认 | 弹窗文案对应当前状态,成功后按钮文字反转 |
|
||||
| UI-A-PROD-004 | P1 | 点击推荐/不推荐 | 状态和按钮立即更新,不影响发布状态 |
|
||||
| UI-A-PROD-005 | P1 | 点击删除或勾选后批量删除 | 已引用限制提示清楚,成功/失败数量准确 |
|
||||
| UI-A-PROD-006 | P2 | 检查操作按钮换行和不同窗口宽度 | 编辑、发布/下架、推荐、删除始终可见可点 |
|
||||
| UI-A-TASK-001 | P1 | 任务列表测试查询、新增、编辑 | 商品、等级、佣金、详情、时段等字段完整 |
|
||||
| UI-A-TASK-002 | P1 | 点击发布/下线/删除并取消/确认 | 状态限制和引用提示正确,列表刷新 |
|
||||
| UI-A-SUB-001 | P1 | 会员任务审核测试查询和详情 | 会员、订单、任务、证据、佣金、状态完整 |
|
||||
| UI-A-SUB-002 | P0 | 待审记录点击审核,切换通过/驳回 | 提示文字和意见字段随结论变化 |
|
||||
| UI-A-SUB-003 | P0 | 点击确认审核并连续点击 | 按钮防重复;成功关闭、刷新且只结算一次 |
|
||||
| UI-A-SUB-004 | P1 | 已审核记录点击详情 | 只读展示结果和意见,不再显示可提交审核按钮 |
|
||||
|
||||
#### 4.3.6 组织、权限、系统应用和系统设置页面
|
||||
|
||||
| ID | 优先级 | 页面操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UI-A-ORG-001 | P1 | 后台用户页测试查询、新增、编辑、启停、重置密码、删除 | 每个按钮按权限出现,弹窗和确认流程完整 |
|
||||
| UI-A-ORG-002 | P1 | 部门页展开/收起树,新增同级/下级、编辑、删除 | 树层级和父节点选择正确 |
|
||||
| UI-A-ORG-003 | P1 | 岗位页测试查询、新增、编辑、启停、删除 | 表单和状态操作正确 |
|
||||
| UI-A-MENU-001 | P1 | 菜单页展开树,点击新增、编辑、删除 | 目录/菜单/按钮字段随类型正确显示 |
|
||||
| UI-A-MENU-002 | P1 | 菜单图标选择、路由、组件、权限标识校验 | 图标预览和字段校验正确 |
|
||||
| UI-A-ROLE-001 | P1 | 角色页新增/编辑/启停/删除 | 角色树、上级角色和基本字段正确 |
|
||||
| UI-A-ROLE-002 | P0 | 点击“分配菜单”,勾选父节点、子节点、全选和取消 | 级联/半选正确,保存后重新打开保持一致 |
|
||||
| UI-A-ROLE-003 | P0 | 子角色打开分配菜单弹窗 | 只能操作父角色权限子集,范围外不可选 |
|
||||
| UI-A-ROLE-004 | P1 | 点击数据权限并切换范围 | 选项和部门树正确,保存后查询范围生效 |
|
||||
| UI-A-APP-001 | P1 | 打开系统应用各二级菜单和个人中心 | 页面均可达,个人设置可保存和取消 |
|
||||
| UI-A-DICT-001 | P1 | 字典类型和字典数据页测试查询、新增、编辑、启停、删除 | 类型与数据联动,弹窗和确认正确 |
|
||||
| UI-A-CONFIG-001 | P1 | 系统配置逐个切换分组并保存 | 各分组字段无错位,保存反馈和重新加载正确 |
|
||||
| UI-A-CRON-001 | P1 | 定时任务测试查询、新增、编辑、启停、执行、日志 | 每个按钮可达,执行结果和日志正确 |
|
||||
| UI-A-BLACK-001 | P1 | 黑名单测试查询、新增、编辑、启停、删除 | 表单、确认、状态和列表刷新正确 |
|
||||
| UI-A-LOG-001 | P1 | 操作/登录/短信/邮件/服务日志逐页查询、重置、详情 | 筛选、分页、详情和脱敏正确 |
|
||||
| UI-A-ATT-001 | P1 | 附件页测试预览、下载、筛选和删除确认 | 图片/文件预览正确,被引用附件删除受限 |
|
||||
|
||||
## 5. 通用与基础能力测试
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| GEN-001 | P0 | 未登录直接访问任一需要登录的 C 端路由 | 跳转账号登录页,登录后回到原目标路由 |
|
||||
| GEN-002 | P0 | 未登录调用任一 C 端受保护 API | 返回 401,响应不包含业务数据 |
|
||||
| GEN-003 | P0 | 未登录直接访问后台业务路由 | 跳转后台登录页,不短暂显示受保护页面 |
|
||||
| GEN-004 | P0 | 使用失效、伪造、被注销的 Token 调用接口 | 统一拒绝;前端清理会话并引导重新登录 |
|
||||
| GEN-005 | P1 | 连续快速点击新增、保存、提交、审核按钮 | 按钮加载中禁用;只产生一次有效写入 |
|
||||
| GEN-006 | P1 | 刷新列表、切换分页、返回上一页 | 查询条件、分页和数据状态符合页面约定,无重复行 |
|
||||
| GEN-007 | P1 | 请求返回空数组 | 显示明确空状态,不报错、不展示演示假数据 |
|
||||
| GEN-008 | P1 | API 返回 400/403/404/409/422/429/500 | 展示可理解提示;500 类保留 request_id;页面可重试 |
|
||||
| GEN-009 | P1 | 请求中断、离线、超时后恢复网络 | 加载态可结束,可重新请求,不产生幽灵提交 |
|
||||
| GEN-010 | P1 | 输入前后空格、全角字符、Emoji、HTML/脚本片段 | 按字段规则规范化或拒绝;展示时不执行脚本 |
|
||||
| GEN-011 | P1 | 两个浏览器同时编辑同一带版本号记录 | 后提交者收到版本冲突并要求刷新,不静默覆盖 |
|
||||
| GEN-012 | P1 | 租户 A 携带租户 B 的资源 ID 请求详情或编辑 | 返回无权限或不存在,绝不返回跨租户数据 |
|
||||
| GEN-013 | P1 | 列表页横向滚动、窄屏及操作列按钮较多 | 所有按钮可达;操作列不遮挡,滚动条可操作 |
|
||||
| GEN-014 | P2 | 页面刷新、前进后退、复制带参数 URL 打开 | 路由、标题、筛选参数正确,无白屏 |
|
||||
| GEN-015 | P2 | 动态资源发版后旧页面加载过期 chunk | 最多自动刷新一次并恢复,10 秒内不循环刷新 |
|
||||
|
||||
## 6. C 端功能测试
|
||||
|
||||
### 6.1 注册、登录与会话
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| C-AUTH-001 | P0 | 用合法姓名、11 位手机号、合法密码、6 位邀请码并勾选协议注册 | 注册成功并建立会话;会员、钱包及上下级关系正确创建 |
|
||||
| C-AUTH-002 | P1 | 姓名分别输入 0、1、10、11 个字符 | 1~10 个合法文字通过;0 和 11 拒绝 |
|
||||
| C-AUTH-003 | P1 | 姓名含汉字、字母、组合音标及间隔点“·” | 允许;数字、空格、符号和脚本字符拒绝 |
|
||||
| C-AUTH-004 | P1 | 手机号测试空值、10 位、11 位非 1 开头、第二位 0/2、含字母、合法 13~19 段 | 仅 `1[3-9]` 开头的 11 位数字通过 |
|
||||
| C-AUTH-005 | P1 | 密码测试 5 个字符、6 个字符、72 字节、73 字节和多字节字符 | 少于 6 个字符或超过 72 字节拒绝;边界值按规则处理 |
|
||||
| C-AUTH-006 | P1 | 邀请码测试空值、5 位、6 位、7 位、含字母及不存在的邀请码 | 仅存在的 6 位数字邀请码通过 |
|
||||
| C-AUTH-007 | P0 | 未勾选隐私协议提交注册 | 注册被拒绝,数据库无会员和钱包残留 |
|
||||
| C-AUTH-008 | P0 | 使用已注册手机号再次注册 | 明确提示已存在,不新增重复会员 |
|
||||
| C-AUTH-009 | P0 | 正确手机号密码登录 | 登录成功,记录登录时间/日志,会话 Token 可访问受保护接口 |
|
||||
| C-AUTH-010 | P0 | 正确手机号配错误密码、未注册手机号登录 | 统一凭证错误提示,不泄露手机号是否存在 |
|
||||
| C-AUTH-011 | P0 | 同一手机号 15 分钟内连续错误登录 10 次,再试正确密码 | 第 10 次阈值后限流;返回 429;窗口后或策略解除后恢复 |
|
||||
| C-AUTH-012 | P1 | 错误数未达阈值时成功登录,再次输错 | 成功登录清除此前失败计数 |
|
||||
| C-AUTH-013 | P0 | 登出后使用旧 Token 请求 `/v1/me` | 旧 Token 失效;返回登录页 |
|
||||
| C-AUTH-014 | P1 | 会话自然过期时停留在业务页继续操作 | 不提交业务;提示登录失效并跳转登录页 |
|
||||
| C-AUTH-015 | P2 | 身份验证/短信找回入口点击获取验证码 | 当前未开放时明确提示暂未开放,不显示伪成功、不修改密码 |
|
||||
| C-AUTH-016 | P0 | 使用 URL-ENABLED 的注册入口完成注册 | 注册成功;会员、钱包和 `hg_member_url_binding` 在同一事务创建,返回绑定成功及标准化域名 |
|
||||
| C-AUTH-017 | P0 | 从 URL-UNKNOWN 域名或缺少可识别来源的页面注册 | 会员和钱包仍创建成功,不生成域名绑定,不因地址池无匹配记录阻断注册 |
|
||||
| C-AUTH-018 | P0 | 从 URL-INVALID 的域名注册 | 注册成功但不建立绑定;失效地址累计绑定次数和最后绑定时间不变化 |
|
||||
| C-AUTH-019 | P1 | 使用大小写域名、末尾点、默认 80/443 端口注册 | 域名按统一规则规范化并命中同一地址,不产生重复地址或重复绑定 |
|
||||
| C-AUTH-020 | P0 | 同一注册请求重复提交或事务中模拟绑定写入失败 | 不重复创建会员/钱包/绑定;绑定写入失败时整个注册事务回滚 |
|
||||
| C-AUTH-021 | P0 | 账号密码登录成功 | 更新会员最后登录时间、IP 密文、IP 脱敏值和地区,并新增一条成功会员登录日志 |
|
||||
| C-AUTH-022 | P1 | 使用错误密码、禁用会员或风控会员登录 | 登录失败且不更新会员主表最后登录字段;分别记录脱敏的失败日志和可理解原因 |
|
||||
| C-AUTH-023 | P1 | 手机验证码登录成功、验证码错误或过期 | 成功/失败均按手机登录方式记录独立日志;成功时更新会员最后登录信息 |
|
||||
|
||||
### 6.2 首页、内容、通知与排行榜
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| C-HOME-001 | P1 | 打开首页 | Banner、用户摘要、快捷入口、公告/滚动信息及导航正常加载 |
|
||||
| C-HOME-002 | P1 | 后台关闭领取、储入、取出、商城开关后刷新首页 | 对应入口置灰且不可点击,其他入口不受影响 |
|
||||
| C-HOME-003 | P1 | 重新开启功能开关 | 对应入口恢复可点击并进入正确页面 |
|
||||
| C-HOME-004 | P1 | Banner 未到生效时间、已过期、已下线 | C 端不展示;生效期内按排序展示 |
|
||||
| C-HOME-005 | P1 | Banner 无跳转、内部路由、合法 HTTPS 外链 | 分别不跳转、站内跳转、受控打开外链;非法协议不执行 |
|
||||
| C-HOME-006 | P2 | Banner 图片失效或加载失败 | 显示占位,不导致页面布局塌陷 |
|
||||
| C-HOME-007 | P1 | 后台发布平台介绍、规则、代理合作、公司资质文本 | C 端对应内容页显示最新已发布版本 |
|
||||
| C-HOME-008 | P1 | 后台保存草稿但不发布 | C 端继续显示旧已发布版本 |
|
||||
| C-HOME-009 | P1 | 下线内容或内容不存在 | C 端显示友好空状态,不泄露草稿正文 |
|
||||
| C-HOME-010 | P1 | 发布全局通知和指定会员通知 | 目标会员可见;非目标会员不可见;已过有效期不展示 |
|
||||
| C-HOME-011 | P1 | 标记通知已读并刷新 | 已读状态保持;未读数量正确减少,不影响其他会员 |
|
||||
| C-HOME-012 | P1 | 排行榜混合真人、假人、禁用用户和同分用户 | 按产品口径展示;禁用/应排除数据不出现;同分排序稳定 |
|
||||
|
||||
### 6.3 领取、订单与任务状态机
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| C-TASK-001 | P0 | 正常会员在营业时间进入领取页 | 总资产、昨日/今日佣金、今日领取数与数据库一致 |
|
||||
| C-TASK-002 | P0 | 后台关闭任务功能后领取 | 返回 `TASK_DISABLED`,不创建订单 |
|
||||
| C-TASK-003 | P1 | 在营业开始前 1 秒、开始时刻、结束时刻、结束后 1 秒领取 | 两端点允许,窗口外拒绝 |
|
||||
| C-TASK-004 | P1 | 营业时段配置为跨午夜,如 22:00~02:00 | 22:00~次日 02:00 可领取,其余时间拒绝 |
|
||||
| C-TASK-005 | P0 | U-AUTO 余额为最低余额减 1 分、等于最低余额、加 1 分 | 小于阈值拒绝;等于和大于阈值通过 |
|
||||
| C-TASK-006 | P1 | U-MANUAL 余额低于最低余额进入领取资格检查 | 按手动派单规则返回;不错误套用自动派单最低余额校验 |
|
||||
| C-TASK-007 | P0 | 当日领取数为上限减 1、等于上限 | 前者可领取;等于上限返回 `DAILY_LIMIT_REACHED` |
|
||||
| C-TASK-008 | P0 | 存在匹配中、待处理、审核中或冻结订单时再次领取 | 返回 `PENDING_ORDER_EXISTS`,并提供查看原订单入口 |
|
||||
| C-TASK-009 | P1 | 当前会员等级不属于任务大厅允许等级 | 返回 `LEVEL_NOT_ELIGIBLE`,不创建订单 |
|
||||
| C-TASK-010 | P0 | 禁用会员或禁用钱包领取 | 返回账户受限,不创建订单及流水 |
|
||||
| C-TASK-011 | P0 | 连续双击领取或并发发起两次领取 | 最多产生一个在途订单,次数只增加一次 |
|
||||
| C-TASK-012 | P1 | 自动派单延迟配置为 0、正常值、负值、最大值小于最小值 | 0 立即调度;负值归零;反向区间归一;不 panic |
|
||||
| C-TASK-013 | P0 | 自动派单匹配已发布、CNY、价格在配置区间内的商品 | 匹配成功,订单快照、商品、金额和佣金正确 |
|
||||
| C-TASK-014 | P0 | 候选商品为下架、删除、非 CNY 或价格越界 | 不被匹配;无候选时订单保持可解释状态 |
|
||||
| C-TASK-015 | P0 | 手动派单后会员余额足够并确认提交 | 扣除可用余额、结算佣金、订单完成、生成对应流水 |
|
||||
| C-TASK-016 | P0 | 手动派单后后台改价导致会员余额不足 | 提交被拒并引导储入;余额、冻结额和订单状态不被错误修改 |
|
||||
| C-TASK-017 | P0 | 同一订单重复点击确认或并发确认 | 只扣一次、只发一次佣金、只生成一组有效流水 |
|
||||
| C-TASK-018 | P1 | 订单超过截止时间后确认 | 状态转已超时或拒绝提交,不扣款、不结算 |
|
||||
| C-TASK-019 | P1 | 查询订单列表 `page=0`、`page=1`、`page_size=1/100/101` | 0 使用默认页;1 和 100 有效;101 拒绝 |
|
||||
| C-TASK-020 | P1 | 使用 all、matching、pending、processing、completed、expired、frozen、cancelled、failed 过滤 | 返回对应状态;未知状态返回校验错误 |
|
||||
| C-TASK-021 | P1 | 查看本人订单详情和他人订单 ID | 本人可见完整时间线;他人订单返回不存在/无权限 |
|
||||
| C-TASK-022 | P1 | 提交凭证正文 0、2000、2001 字符 | 0 和 2000 按当前规则通过;2001 拒绝 |
|
||||
| C-TASK-023 | P1 | 提交附件 0、9、10 个,或包含 0/负数/重复 ID | 0 和最多 9 个合法唯一 ID 通过;其余拒绝 |
|
||||
| C-TASK-024 | P0 | 使用其他会员上传的附件 ID 提交任务 | 服务端拒绝,不能越权引用附件 |
|
||||
| C-TASK-025 | P0 | 提交成功后核对订单、任务提交、钱包和流水 | 状态、佣金、前后余额、业务单号及时间在同一事务内一致 |
|
||||
| C-TASK-026 | P1 | 后台下线任务后查看已领取订单 | 已领取订单仍可按原快照完成;不能新领取该任务 |
|
||||
|
||||
### 6.4 储入、收款账户、取出与账变
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| C-FIN-001 | P1 | 打开储入页 | 只展示启用渠道,显示渠道最小/最大金额和手续费信息 |
|
||||
| C-FIN-002 | P0 | 储入金额测试 0、负数、最小值减 1 分、最小值、最大值、最大值加 1 分 | 仅渠道区间内金额可提交 |
|
||||
| C-FIN-003 | P1 | 渠道最大值为 0 | 按无上限处理,但仍校验正数和数据库金额范围 |
|
||||
| C-FIN-004 | P1 | 金额输入空值、字母、多小数位、科学计数法、超大数 | 前端不产生错误分值;服务端拒绝非法或溢出输入 |
|
||||
| C-FIN-005 | P1 | 储入备注 500 和 501 个字符 | 500 通过,501 拒绝 |
|
||||
| C-FIN-006 | P1 | 储入凭证附件 0、9、10 个 | 0~9 个合法附件通过;10 个拒绝 |
|
||||
| C-FIN-007 | P0 | 凭证 ID 含重复、0、负数或他人附件 | 拒绝,订单不创建 |
|
||||
| C-FIN-008 | P0 | 正常提交储入 | 只创建一张待处理充值单,随后跳转客服中心 |
|
||||
| C-FIN-009 | P1 | 储入提交失败 | 保留输入并展示错误,不跳客服、不产生半成品订单 |
|
||||
| C-FIN-010 | P1 | 查看储入记录全部状态 | 待支付、审核中、成功、失败、过期、取消文案及金额正确 |
|
||||
| C-FIN-011 | P1 | 待支付订单创建后 30 分钟边界 | 过期时间显示正确,状态由服务端规则处理 |
|
||||
| C-FIN-012 | P0 | 创建收款账户,输入合法姓名、身份证、卡号、开户行、两张不同收款码 | 创建成功;通用账户列表仅返回脱敏值,本人详情接口可还原原始字段 |
|
||||
| C-FIN-013 | P1 | 持有人姓名长度 1、2、64、65,或含控制字符/尖括号 | 2~64 且无危险字符通过,其余拒绝 |
|
||||
| C-FIN-014 | P1 | 身份证输入 15 位、18 位、末位 X、小写 x、空格或连字符 | 合法格式规范化后通过;非法位数和首位 0 拒绝 |
|
||||
| C-FIN-015 | P1 | 银行名长度 1、2、100、101 | 2~100 通过,其余拒绝 |
|
||||
| C-FIN-016 | P1 | 银行卡号长度 7、8、32、33 或含非数字 | 8~32 位数字通过,其余拒绝 |
|
||||
| C-FIN-017 | P1 | 数字人民币编码空值、5、6、32、33 位或含非数字 | 空值或 6~32 位数字通过,其余拒绝 |
|
||||
| C-FIN-018 | P0 | 微信码、支付宝码缺一个或使用同一附件 ID | 拒绝;必须是两个不同的合法图片附件 |
|
||||
| C-FIN-019 | P0 | 上传收款码:空文件、JPEG/PNG/WebP、GIF、伪造扩展名、5MB、5MB+1B | 仅真实 JPEG/PNG/WebP 且不超过 5MB 通过 |
|
||||
| C-FIN-020 | P0 | 使用其他会员的附件设置头像/收款账户 | 服务端拒绝附件越权 |
|
||||
| C-FIN-021 | P1 | 未绑卡点击取出 | 引导添加银行卡,不进入无账户确认页 |
|
||||
| C-FIN-022 | P1 | 账户待验证、已验证、已拒绝时申请取出 | 仅已验证账户可报价和申请 |
|
||||
| C-FIN-023 | P0 | 后台关闭取出开关后报价和提交 | 均拒绝,不能只在前端置灰 |
|
||||
| C-FIN-024 | P0 | 取出金额测试渠道最小值±1 分、最大值±1 分、可用余额±1 分 | 严格按渠道和余额边界处理 |
|
||||
| C-FIN-025 | P1 | 取出手续费为固定、比例、固定+比例三种 | 手续费向上取整规则正确,实到金额=申请金额-手续费 |
|
||||
| C-FIN-026 | P0 | 手续费小于 0、费率超过 10000、手续费大于等于金额 | 渠道视为无效,禁止提交 |
|
||||
| C-FIN-027 | P0 | 未设置交易密码申请取出 | 引导在确认弹窗内设置 6 位数字交易密码;设置成功不自动取出,用户输入新密码并再次点击确认申请后才创建订单 |
|
||||
| C-FIN-028 | P1 | 新交易密码测试 5/6/7 位、非数字、与旧密码相同 | 仅 6 位数字且不同于旧密码通过 |
|
||||
| C-FIN-029 | P0 | 交易密码错误 4 次、第 5 次及限流后正确输入 | 第 5 次达到阈值后 15 分钟限流;正确密码不能绕过 |
|
||||
| C-FIN-030 | P0 | 当日提现次数为上限减 1、等于上限 | 前者可申请,等于上限拒绝 |
|
||||
| C-FIN-031 | P0 | 同一提现请求使用同一 8~80 位幂等键重复提交 | 返回同一业务结果,余额只冻结一次 |
|
||||
| C-FIN-032 | P1 | 幂等键长度 7、8、80、81 或含非法字符 | 8~80 且仅允许字母数字及 `._:-` 通过 |
|
||||
| C-FIN-033 | P0 | 两个不同幂等键并发提取超过总余额 | 最多一单成功;钱包不可出现负余额 |
|
||||
| C-FIN-034 | P0 | 取出成功创建后核对钱包 | 可用余额减少、冻结额增加、版本递增并生成成对流水 |
|
||||
| C-FIN-035 | P1 | 查看取出记录所有状态 | 待审核、审核中、处理中、成功、失败、驳回、取消正确显示 |
|
||||
| C-FIN-036 | P1 | 账变筛选 income、expense、all、未知值 | 前三者正确;未知值拒绝 |
|
||||
| C-FIN-037 | P1 | 账变 `biz_type` 长度 32 和 33 | 32 通过,33 拒绝 |
|
||||
| C-FIN-038 | P0 | 查看账变前后余额 | 每笔 `after = before ± amount`;相邻流水首尾连续 |
|
||||
| C-FIN-039 | P0 | 本人登录后请求 `GET /v1/payment-accounts/details` | 只返回当前租户、当前会员的完整姓名、身份证号、银行卡号、已填的数字人民币编码及本人收款码 URL;无账户返回空数组 |
|
||||
| C-FIN-040 | P0 | 使用未登录会话、另一会员或另一租户访问本人取出信息详情 | 未登录被拒绝;其他身份只能看到各自绑定信息,不能通过请求参数指定目标会员或租户 |
|
||||
| C-FIN-041 | P0 | 将微信/支付宝附件换成他人附件、失效附件,或两张附件对应关系交叉 | 详情接口不返回非本人或失效图片 URL;页面不把微信码、支付宝码串位 |
|
||||
| C-FIN-042 | P0 | 数据库中某账户的必填密文损坏后请求本人详情 | 安全失败,不返回部分原始字段,不输出密文、解密错误细节或堆栈 |
|
||||
| C-FIN-043 | P1 | 同一账户分别请求通用列表和本人详情 | 通用列表的姓名、身份证、卡号、数字人民币编码保持脱敏;仅本人详情返回原值 |
|
||||
| C-FIN-044 | P1 | 数字人民币编码留空创建账户,随后读取详情 | 保存时不生成该字段的密文、哈希或掩码;详情返回空值,银行卡及收款码仍正常使用 |
|
||||
|
||||
### 6.5 商城、个人中心与安全设置
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| C-MY-001 | P1 | 商城查看推荐和限时标签 | 仅已发布、分类启用、有效期内的手工商城商品出现 |
|
||||
| C-MY-002 | P1 | 搜索商品名称,输入不存在关键词、前后空格及大小写 | 搜索结果正确;无结果显示空状态 |
|
||||
| C-MY-003 | P1 | 打开本人可见商品详情及不存在/下架商品 ID | 有效商品正常;无效商品返回友好不存在 |
|
||||
| C-MY-004 | P1 | 限时商品在开始前、开始时、结束时、结束后 | 仅有效窗口允许领取/展示可用动作 |
|
||||
| C-MY-005 | P1 | 个人中心展示账号、邀请码、等级、余额 | 与 `/v1/me` 一致,金额单位正确 |
|
||||
| C-MY-006 | P2 | 点击复制邀请码 | 剪贴板内容准确;失败时给出替代提示 |
|
||||
| C-MY-007 | P0 | 修改登录密码:旧密码错误、空、新密码 5/6 字符、超过 72 字节、与旧密码相同 | 仅旧密码正确且新密码满足规则并不同于旧密码时成功 |
|
||||
| C-MY-008 | P0 | 修改登录密码成功后用新旧密码登录 | 新密码可登录,旧密码不可登录;既有会话按当前安全策略处理 |
|
||||
| C-MY-009 | P1 | 首次设置交易密码时填写旧交易密码 | 拒绝;首次设置不应要求或接受旧交易密码 |
|
||||
| C-MY-010 | P1 | 已有交易密码时不填/填错旧交易密码 | 分别提示必填/错误;不更新密码 |
|
||||
| C-MY-011 | P1 | 更换头像上传合法图片并刷新 | 头像更新且只关联本人附件;首页、我的、IM 头像一致 |
|
||||
| C-MY-012 | P1 | 财务记录、账户明细为空和多页数据 | 空态、分页、金额、状态和时间正确 |
|
||||
| C-MY-013 | P0 | U-DISABLED 打开个人资料及发起交易 | 可按策略查看必要信息,但所有受限写操作被服务端拒绝 |
|
||||
| C-MY-014 | P1 | 点击退出并取消/确认 | 取消保留会话;确认清除会话并返回登录页 |
|
||||
| C-MY-015 | P1 | 从个人信息页修改头像后返回“我的”、首页和客服会话 | 新头像在重新读取资料后保持一致;失败时保留旧头像,不出现仅本页临时预览成功的假象 |
|
||||
|
||||
### 6.6 在线客服与 118IM
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| C-IM-001 | P1 | 打开客服列表 | 展示客服依依、客服琪琪、客服琳琳的名称、女性头像及在线状态 |
|
||||
| C-IM-002 | P0 | 首次点击客服 | 自动创建/恢复会员 IM 身份、建立好友关系和 P2P 会话 |
|
||||
| C-IM-003 | P0 | 客服未绑定 IM 用户或租户代码不一致 | 明确提示客服暂不可用,不进入可发送假状态 |
|
||||
| C-IM-004 | P1 | 好友关系为 none、friend、pending、denied | none 发起申请;friend 进入会话;pending/denied 给出对应提示 |
|
||||
| C-IM-005 | P0 | 本地保存的 IM 密码失效 | 服务端重置并保存凭据后重试一次,成功恢复或明确失败 |
|
||||
| C-IM-006 | P0 | IM 登录返回的用户 ID 与本地身份不一致 | 拒绝建立会话,防止串号 |
|
||||
| C-IM-007 | P1 | 会话打开时加载历史消息 | 历史与实时消息分界明确,顺序、发送人、时间正确 |
|
||||
| C-IM-008 | P1 | 发送空文本、1 字符、1000 字符、1001 字符 | 空文本不发送;1~1000 可发送;超过输入上限不可提交 |
|
||||
| C-IM-009 | P0 | 快速连续发送文本或网络抖动 | 不丢失、不重复;发送中/失败/成功状态可见 |
|
||||
| C-IM-010 | P1 | 断开 WebSocket 后点击重新连接 | 恢复会话并续收消息,不重复展示历史消息 |
|
||||
| C-IM-011 | P1 | 打开表情面板,切换最近/表情/手势/物品并选择 | 分类与布局正确;表情插入输入框,可随文本发送 |
|
||||
| C-IM-012 | P1 | 上传 JPEG/PNG/WebP/GIF 图片,大小为 10MB 和 10MB+1B | 合法且不超过 10MB 可发送;超限前端拒绝 |
|
||||
| C-IM-013 | P1 | 上传 MP4/WebM/QuickTime 视频,大小为 50MB 和 50MB+1B | 合法且不超过 50MB 可发送;超限拒绝 |
|
||||
| C-IM-014 | P0 | 伪造文件后缀或上传脚本/可执行文件 | 客户端和服务端均拒绝,不能作为可执行内容访问 |
|
||||
| C-IM-015 | P1 | 大文件上传过程取消、断网或接口 500 | 进度结束并提示失败;输入区恢复可用,可重新选择 |
|
||||
| C-IM-016 | P0 | 会员发图后客服 Web 端查看 | 接收方拥有附件读取权限,图片正常显示,不返回 403 |
|
||||
| C-IM-017 | P0 | 客服发图后会员端查看 | 会员可读取;无关第三人不能读取附件 |
|
||||
| C-IM-018 | P0 | 历史绝对地址和同源相对地址媒体混合加载 | 均能通过授权地址显示;不得因 CORS 修复而绕过 ACL |
|
||||
| C-IM-019 | P1 | 头像 URL 为空、失效、跨域受限 | 使用稳定占位头像;不影响消息发送 |
|
||||
| C-IM-020 | P1 | 会话 Token 过期后发送 | 自动重连或明确提示重新连接;消息不静默丢失 |
|
||||
|
||||
## 7. 管理后台功能测试
|
||||
|
||||
### 7.1 后台登录、菜单、组织与 RBAC
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| A-RBAC-001 | P0 | 正确/错误后台账号密码登录 | 正确进入首页;错误统一提示并记录登录日志 |
|
||||
| A-RBAC-002 | P0 | 登录页检查其他登录方式、手机号登录、注册、演示角色 | 这些入口均不显示,只保留账号登录 |
|
||||
| A-RBAC-003 | P2 | 检查登录页系统名 | 显示“企业办公管理系统” |
|
||||
| A-RBAC-004 | P1 | 展开组织管理、权限管理、系统应用、系统设置 | 二级菜单均有合适图标且可点击 |
|
||||
| A-RBAC-005 | P1 | 检查系统应用下个人中心 | 菜单可见并能进入个人设置 |
|
||||
| A-RBAC-006 | P0 | 为 A-CHILD 分配菜单 | 弹窗只显示 A-PARENT 已有菜单范围或范围外禁选 |
|
||||
| A-RBAC-007 | P0 | 通过构造请求给子角色分配父角色没有的菜单 | 服务端拒绝,数据库不写入越权关系 |
|
||||
| A-RBAC-008 | P1 | 勾选一级菜单 | 可按设计级联勾选全部可选子菜单和按钮权限 |
|
||||
| A-RBAC-009 | P1 | 取消一级菜单或部分子菜单 | 父子半选状态正确,不残留不可达按钮权限 |
|
||||
| A-RBAC-010 | P0 | 无页面权限用户直接输入 URL | 页面不可访问;对应接口同样返回 403 |
|
||||
| A-RBAC-011 | P0 | 有页面权限但无新增/编辑/删除按钮权限 | 按钮不显示;直接调用写接口仍被拒绝 |
|
||||
| A-RBAC-012 | P1 | 新增、编辑、停用部门和岗位 | 树关系、排序、状态和成员关联正确 |
|
||||
| A-RBAC-013 | P1 | 删除存在下级或在用成员的部门/角色 | 被阻止并提示依赖关系 |
|
||||
| A-RBAC-014 | P1 | 修改管理员角色后刷新其会话 | 菜单和接口权限按系统策略及时生效 |
|
||||
| A-RBAC-015 | P0 | 非超管尝试编辑超管或给自己提权 | 服务端拒绝并记录操作日志 |
|
||||
| A-RBAC-016 | P1 | 菜单新增、编辑、排序、隐藏、外链和按钮权限 | 路由树刷新正确,隐藏菜单可按直接路由策略访问 |
|
||||
| A-RBAC-017 | P1 | 字典增删改、启停并刷新业务页面 | 使用字典的下拉和标签同步更新,不在前端残留硬编码 |
|
||||
| A-RBAC-018 | P1 | 修改个人资料和密码 | 校验正确;密码不回显;修改记录可追踪 |
|
||||
|
||||
### 7.2 首页、网站设置、文本、Banner 与手工商城
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| A-PORTAL-001 | P1 | 打开首页看板 | 会员、订单、充值、提现、任务等指标与筛选口径一致 |
|
||||
| A-PORTAL-002 | P1 | 切换日期、刷新、点击指标卡 | 数据区间正确;跳转明细时继承筛选条件 |
|
||||
| A-PORTAL-003 | P1 | 无业务数据或统计接口失败 | 分别显示 0/空态或错误提示,不混淆 |
|
||||
| A-PORTAL-004 | P0 | 网站设置读取、修改并保存 | 字段完整回显;保存生成新版本并立即按当前实现生效 |
|
||||
| A-PORTAL-005 | P1 | 最小金额大于最大金额、反向时间、负次数、非法 URL | 前后端拒绝并定位字段 |
|
||||
| A-PORTAL-006 | P1 | 营业时间配置正常窗口、全天和跨午夜 | 保存后 C 端按相同语义执行 |
|
||||
| A-PORTAL-007 | P0 | 两人同时编辑同一网站配置版本 | 后提交者发生版本冲突,不覆盖先提交者 |
|
||||
| A-PORTAL-008 | P1 | 提交、发布、回滚网站配置 | 状态流转合法;回滚形成可审计新版本,不篡改历史 |
|
||||
| A-PORTAL-009 | P1 | 切换领取/储入/取出/商城开关 | C 端入口和服务端行为同步变化 |
|
||||
| A-PORTAL-010 | P1 | 文本列表初始化数据 | 仅约定的 12 条原始文本;列表不显示“内容标识”列 |
|
||||
| A-PORTAL-011 | P1 | 新增/编辑文本,正文为空、超长、含脚本和恶意链接 | 必填及长度校验有效;危险 HTML 被拒绝或净化 |
|
||||
| A-PORTAL-012 | P1 | 文本发布、下线、删除 | C 端只显示已发布版本;有历史引用时保留历史 |
|
||||
| A-PORTAL-013 | P1 | 编辑文本弹窗 | 宽度适合正文编辑,标题为文本管理语义,不出现商品图片字段 |
|
||||
| A-PORTAL-014 | P1 | Banner 新增、编辑、排序、发布、下线 | C 端按状态、有效期和排序展示 |
|
||||
| A-PORTAL-015 | P1 | Banner 上传非图片、超限、异常比例或失效 URL | 阻止或明确提示;列表不出现破损数据 |
|
||||
| A-PORTAL-016 | P1 | 删除已发布 Banner | 按规则先下线或软删除;C 端立即不可见 |
|
||||
| A-PORTAL-017 | P1 | 手工商城新增/编辑名称、价格、图片、展示时间 | 保存成功并正确回显,`product_type=2` |
|
||||
| A-PORTAL-018 | P1 | 手工商品发布/下架/删除 | C 端商城展示同步;已关联历史保留快照 |
|
||||
| A-PORTAL-019 | P0 | 租户 A 管理门户资源 | 不能查看或修改租户 B 的配置、内容、Banner、商品 |
|
||||
| A-PORTAL-020 | P1 | 查看配置和内容历史 | 操作者、版本、时间、状态和差异可追溯 |
|
||||
|
||||
### 7.3 会员、等级与任务大厅
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| A-MEM-001 | P1 | 会员列表按账号、手机号、时间、状态、派单模式、类别查询 | 筛选准确,重置恢复默认条件 |
|
||||
| A-MEM-002 | P1 | 列表检查派单模式和类别 | 派单模式显示自动/手动;类别显示真人/假人 |
|
||||
| A-MEM-003 | P1 | 操作列在 180px 宽度内横向滚动 | 按钮靠左、可滚动查看全部;细滚动条可拖动 |
|
||||
| A-MEM-004 | P1 | 操作弹层查看编辑、删除、银行卡、地址、团队、账变、弹幕、奖励、类别、状态 | 按权限显示,按钮尺寸和换行不遮挡 |
|
||||
| A-MEM-005 | P0 | 新增会员使用重复手机号/邀请码冲突 | 拒绝重复;不产生孤立钱包或资料 |
|
||||
| A-MEM-006 | P1 | 编辑会员基础资料 | 普通编辑不能直接改钱包余额;敏感字段不明文回显 |
|
||||
| A-MEM-007 | P0 | 切换自动/手动派单和真人/假人 | 保存后 C 端领取规则及统计口径同步更新 |
|
||||
| A-MEM-008 | P0 | 授权查看会员、银行卡、地址明文 | 必须填写用途并确认;有单独权限;生成敏感访问日志 |
|
||||
| A-MEM-009 | P0 | 无敏感权限构造明文查看请求 | 返回 403,日志与错误响应不泄露明文 |
|
||||
| A-MEM-010 | P1 | 新增/编辑/删除银行卡和地址 | 脱敏展示、归属校验、默认项及软删除正确 |
|
||||
| A-MEM-011 | P1 | 团队查询层级 1、10、11 | 1~10 可查;超过 10 被限制;上下级关系不串租户 |
|
||||
| A-MEM-012 | P0 | 封禁/解封个人或团队 | 审批、执行、状态日志完整;C 端权限即时受限/恢复 |
|
||||
| A-MEM-013 | P0 | 同一状态审批重复执行 | 只执行一次,重复请求不重复写状态日志 |
|
||||
| A-MEM-014 | P0 | 发起账变后未审批直接执行 | 被拒绝;余额和流水不变化 |
|
||||
| A-MEM-015 | P0 | 审批通过后执行加款/扣款 | 钱包、版本和不可变流水一致,不能扣成负数(除明确授权场景) |
|
||||
| A-MEM-016 | P0 | 使用相同幂等键重复执行账变/奖励金 | 仅入账一次 |
|
||||
| A-MEM-017 | P1 | 创建、查看和撤回会员通知/弹幕 | 目标、来源、有效期正确;撤回后 C 端不再展示 |
|
||||
| A-MEM-018 | P0 | 删除有余额、在途订单或历史流水的会员 | 禁止物理删除;按规则注销/软删除并保留台账 |
|
||||
| A-MEM-019 | P1 | 会员等级新增/编辑 | 名称、金额、日订单数、佣金率、日提现数校验正确 |
|
||||
| A-MEM-020 | P1 | 等级金额为负、次数为小数、佣金率越界 | 保存被拒绝,提示具体字段 |
|
||||
| A-MEM-021 | P1 | 等级编辑、启停、删除 | 操作列仅编辑/停启用/删除;在用等级不能直接删除 |
|
||||
| A-MEM-022 | P1 | 等级规则变更后检查在途订单 | 在途订单继续使用规则快照,新订单使用新规则 |
|
||||
| A-MEM-023 | P1 | 任务大厅新增、编辑、启停、单删和批删 | 等级关联、排序和 C 端入口同步;失败项逐条提示 |
|
||||
| A-MEM-024 | P1 | 删除被任务或历史订单引用的任务大厅 | 被阻止或仅下线,不破坏历史 |
|
||||
| A-MEM-025 | P1 | 隐藏菜单“资金调账、会员通知” | 左侧菜单不显示,但会员操作入口和授权接口仍正常 |
|
||||
| A-MEM-026 | P1 | 检查后台会员列表及 `/appMember/edit` | 页面无新增会员按钮;无 ID 的创建请求被拒绝,不创建会员 |
|
||||
| A-MEM-027 | P1 | 按注册域名输入完整值、前缀、中间片段和大小写关键词 | 均按标准化域名模糊查询,结果不串租户 |
|
||||
| A-MEM-028 | P1 | 组合待绑定、启用域名、失效域名状态及其他会员条件查询 | 列表、总数、详情的域名状态一致,地址启停后状态实时变化 |
|
||||
| A-URL-001 | P1 | 新增 HTTP/HTTPS 地址,省略路径或使用自定义端口 | 省略路径时补 `/register`;保留非默认端口;默认 80/443 端口被规范化 |
|
||||
| A-URL-002 | P1 | 新增 ftp、带账号、查询参数、片段、空域名或超过 512 字符的入口 | 服务端逐项拒绝并返回明确错误,不写入半成品地址 |
|
||||
| A-URL-003 | P0 | 新增大小写、默认端口或尾点不同但标准化后相同的域名 | 唯一约束和业务校验均阻止重复地址 |
|
||||
| A-URL-004 | P1 | 编辑未绑定地址的名称、URL、状态和备注 | 合法字段保存;域名哈希、协议和基础 URL 同步更新 |
|
||||
| A-URL-005 | P0 | 编辑已有绑定会员的地址并尝试更换域名 | 拒绝更换域名;允许修改名称、状态和备注;历史绑定保持不变 |
|
||||
| A-URL-006 | P1 | 地址池混合 0、1、相同及较大累计绑定次数后查询 | 始终按使用次数升序、ID 升序稳定排列,分页边界不重复不漏行 |
|
||||
| A-URL-007 | P0 | 将启用地址设为失效后从该域名注册,再重新启用后注册 | 失效期间注册成功但不绑定;重新启用后新会员正常绑定,历史绑定不删除 |
|
||||
| A-URL-008 | P0 | 两个会员并发从同一启用域名注册 | 每个会员只产生一个绑定;累计绑定次数精确增加,最后绑定时间更新 |
|
||||
| A-URL-009 | P1 | 查看地址详情并按会员关键词、绑定时间筛选 | 返回该地址全部绑定会员且手机号脱敏;其他地址会员不混入 |
|
||||
| A-URL-010 | P1 | 导入合法 `.xlsx`/`.xlsm`,表头为中文或支持的英文别名 | 所有合法地址批量写入且状态统一为启用,创建/更新人及租户正确 |
|
||||
| A-URL-011 | P1 | 导入缺少必填表头、空文件、非 Excel、超过 10MB 或超过 5000 条 | 整体拒绝并给出明确原因,不发生部分未知写入 |
|
||||
| A-URL-012 | P1 | 导入文件内重复域名、数据库已有域名和多行格式错误 | 重复项计入跳过数;合法行导入;失败数及最多 100 条行号明细准确 |
|
||||
| A-MLOGIN-001 | P0 | 分别执行账号成功、账号失败、手机成功和手机失败登录 | `hg_app_member_login_log` 每次各写一条,登录方式、结果、会员 ID 和时间准确 |
|
||||
| A-MLOGIN-002 | P0 | 检查会员登录日志的账号、手机、IP、请求头及错误信息 | 手机和 IP 仅存/展示脱敏值;原始 IP 仅加密保存;密码、验证码和 Token 不落库 |
|
||||
| A-MLOGIN-003 | P1 | 登录请求携带 Origin、仅 Host、大小写域名或无有效域名 | 优先记录标准化 Origin 域名;回退 Host;无合法来源时域名为空且不影响日志 |
|
||||
| A-MLOGIN-004 | P1 | IP 地区解析失败或日志队列不可用时登录 | 登录主流程不被地区解析阻断;队列失败降级同步落库并记录警告 |
|
||||
| A-MLOGIN-005 | P1 | 按会员、账号、脱敏手机、登录方式、结果、域名、IP 和时间组合查询 | 列表总数、分页、详情及倒序排列准确,不能看到其他租户日志 |
|
||||
| A-MLOGIN-006 | P1 | 导出大于一页的当前筛选结果 | 导出遵循当前筛选和分页约定;字段脱敏,不包含 `login_ip_cipher` |
|
||||
|
||||
### 7.4 交易管理
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| A-TRADE-001 | P1 | 订单列表按单号、会员、状态、派单方式、时间查询 | 结果准确,金额和状态字典正确 |
|
||||
| A-TRADE-002 | P1 | 查看订单详情 | 商品/任务快照、时间线、会员、金额、流水完整且脱敏 |
|
||||
| A-TRADE-003 | P0 | 打开匹配商品弹窗 | 只显示已发布、未删除、CNY、符合价格规则的商品 |
|
||||
| A-TRADE-004 | P1 | 新商品保存为草稿/下架后搜索匹配 | 不出现;发布后立即可选 |
|
||||
| A-TRADE-005 | P0 | 给手动订单匹配商品 | 订单进入待会员处理;快照和金额正确,不提前扣款 |
|
||||
| A-TRADE-006 | P0 | 对同一订单并发执行两次匹配 | 仅一次成功,后一次状态冲突 |
|
||||
| A-TRADE-007 | P0 | 更改手动订单金额为 0、负数、余额边界及超大值 | 非法值拒绝;合法值更新快照和后续校验 |
|
||||
| A-TRADE-008 | P0 | 强制付款导致余额为负 | 仅有专门权限可执行;明确二次确认;流水标记强制操作 |
|
||||
| A-TRADE-009 | P0 | 冻结订单 | 状态变冻结,C 端不可继续提交并提示联系客服 |
|
||||
| A-TRADE-010 | P0 | 普通任务订单尝试调用手动订单专用操作 | 服务端按订单类型和状态拒绝 |
|
||||
| A-TRADE-011 | P0 | 提交人工处理审批并由无权限/同一人执行 | 按审批规则拒绝越权或不合规执行 |
|
||||
| A-TRADE-012 | P1 | 充值列表和金额汇总 | 总额、已支付、未支付、人工审核、人工驳回口径与明细一致 |
|
||||
| A-TRADE-013 | P1 | 查看充值凭证 | 仅授权人员可见;附件归属和可访问性正确 |
|
||||
| A-TRADE-014 | P0 | 审核通过待审充值 | 状态成功,钱包仅增加一次,生成充值流水和审核记录 |
|
||||
| A-TRADE-015 | P0 | 驳回充值 | 状态失败/驳回,余额不增加,原因回显 C 端 |
|
||||
| A-TRADE-016 | P0 | 重复点击充值通过或并发审核 | 第二次返回已处理;不重复入账 |
|
||||
| A-TRADE-017 | P0 | 审核已过期、取消或已成功充值单 | 拒绝非法状态迁移 |
|
||||
| A-TRADE-018 | P1 | 充值审核说明为空、超长、含脚本 | 按决策必填规则校验;展示时转义 |
|
||||
| A-TRADE-019 | P1 | 提现列表查看脱敏收款账户和风险信息 | 默认脱敏;明文访问单独授权并留痕 |
|
||||
| A-TRADE-020 | P0 | 通过待审核提现 | 进入处理中;冻结资金保持,审核记录完整 |
|
||||
| A-TRADE-021 | P0 | 驳回提现 | 冻结额释放回可用余额,只生成一次冲正流水 |
|
||||
| A-TRADE-022 | P0 | 完成处理中提现 | 状态成功,冻结额扣除,外部单号及完成时间保存 |
|
||||
| A-TRADE-023 | P0 | 完成失败的提现或重复完成 | 拒绝非法状态;不重复扣冻结额 |
|
||||
| A-TRADE-024 | P0 | 审核过程中会员钱包并发变化 | 使用锁/版本控制,余额守恒且不丢更新 |
|
||||
| A-TRADE-025 | P1 | 新增客服,填写名称、头像、工作时间、在线状态、IM 租户和用户 | 保存成功并在 C 端展示 |
|
||||
| A-TRADE-026 | P1 | 客服启用/停用 | 停用客服不再出现在 C 端新列表,历史会话不被删除 |
|
||||
| A-TRADE-027 | P1 | 删除启用客服 | 被阻止;停用并填写删除原因后方可软删除 |
|
||||
| A-TRADE-028 | P0 | 客服 IM 用户重复绑定或绑定不存在用户 | 拒绝或明确报错,不能产生不可连接配置 |
|
||||
| A-TRADE-029 | P1 | 交易操作权限分别授予客服、财务、运营 | 只显示职责内操作;财务动作不对运营开放 |
|
||||
| A-TRADE-030 | P0 | 所有资金审核完成后核对台账 | 业务单状态、钱包、流水、审核和审计记录可相互追溯 |
|
||||
|
||||
### 7.5 商品、任务与会员任务审核
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| A-PT-001 | P1 | 商品列表查询、新增和编辑 | 名称、编号、分类、价格、币种、图片、推荐、有效期正确保存 |
|
||||
| A-PT-002 | P1 | 商品价格测试 0、负数、1 分、超大值、小数精度 | 按分存储;非法或溢出拒绝,页面元/分转换准确 |
|
||||
| A-PT-003 | P1 | 商品发布/下架按钮 | 状态切换成功;文案、颜色及二次确认正确 |
|
||||
| A-PT-004 | P0 | 商品发布后打开订单匹配弹窗 | 商品可见;下架后立即不可用于新匹配 |
|
||||
| A-PT-005 | P1 | 推荐/取消推荐 | 列表状态和 C 端推荐区域同步 |
|
||||
| A-PT-006 | P1 | 删除未引用商品 | 软删除成功,列表及 C 端不再显示 |
|
||||
| A-PT-007 | P0 | 删除已被订单引用商品 | 禁止删除,只允许下架;历史订单仍显示商品快照 |
|
||||
| A-PT-008 | P1 | 批量删除混合可删和被引用商品 | 可删项成功,失败项保留并汇总提示 |
|
||||
| A-PT-009 | P2 | 商品操作列在常见分辨率下显示 | 按钮自动换行,编辑/发布下架/推荐/删除均可达 |
|
||||
| A-PT-010 | P1 | 任务新增/编辑并绑定商品和会员等级 | 规则、佣金、详情、有效期和等级关系正确 |
|
||||
| A-PT-011 | P1 | 任务佣金金额、佣金万分比和数量边界 | 非负、精度和上限校验正确,不发生单位误读 |
|
||||
| A-PT-012 | P1 | 发布/下线任务 | 发布后目标等级可领取;下线不影响已领取订单 |
|
||||
| A-PT-013 | P1 | 删除未引用/已引用任务 | 未引用软删除;已引用仅下线并保留历史 |
|
||||
| A-PT-014 | P1 | 会员任务审核列表按会员、订单、状态筛选 | 返回准确,能从交易订单带条件跳转 |
|
||||
| A-PT-015 | P1 | 查看提交详情 | 正文、附件、任务/订单、佣金、提交时间完整 |
|
||||
| A-PT-016 | P0 | 审核通过待审提交 | 在同一事务内更新提交和订单并发放一次佣金 |
|
||||
| A-PT-017 | P0 | 审核驳回且允许重提 | 保存原因和证据,C 端显示原因并按规则允许重提 |
|
||||
| A-PT-018 | P1 | 驳回时不填补充说明、通过时不填审核意见 | 页面按当前必填规则阻止提交 |
|
||||
| A-PT-019 | P0 | 两名管理员并发审核同一提交 | 只有一人成功;佣金只发一次 |
|
||||
| A-PT-020 | P0 | 已结算提交再次审核或更改结论 | 服务端拒绝,历史结果不可覆盖 |
|
||||
| A-PT-021 | P0 | 提交所含附件已删除或无权限 | 审核页安全降级,不泄露其他会员附件 |
|
||||
| A-PT-022 | P1 | 等级规则发布后新旧任务订单 | 老订单使用快照,新订单使用新版本 |
|
||||
| A-PT-023 | P1 | 任务/商品有效期端点测试 | 开始和结束端点语义统一,前后台结果一致 |
|
||||
| A-PT-024 | P0 | 将租户 B 商品 ID 绑定到租户 A 任务 | 服务端拒绝跨租户关系 |
|
||||
| A-PT-025 | P1 | 发布缺商品、缺等级或无有效佣金规则的任务 | 阻止发布并提示缺失项 |
|
||||
|
||||
### 7.6 HotGo 系统设置与运维功能
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| A-SYS-001 | P1 | 系统配置各分组读取、编辑、保存 | 保存成功并按缓存策略生效,敏感密钥不明文回显 |
|
||||
| A-SYS-002 | P1 | 上传设置限制类型、大小和存储驱动 | 前端提示与服务端实际限制一致 |
|
||||
| A-SYS-003 | P1 | 黑名单新增、启停、删除并从目标 IP 访问 | 生效范围正确,不误封管理员当前地址 |
|
||||
| A-SYS-004 | P1 | 定时任务新增、修改 cron、启停、手动执行 | 规则校验、执行日志和状态正确 |
|
||||
| A-SYS-005 | P1 | 配置非法 cron 表达式 | 保存被拒绝,不影响现有任务 |
|
||||
| A-SYS-006 | P1 | 附件列表预览、下载、删除 | 权限、MIME 和引用检查正确;被引用附件不盲删 |
|
||||
| A-SYS-007 | P1 | 通知公告新增、发布、撤回和阅读 | 目标后台用户可见,阅读状态正确 |
|
||||
| A-SYS-008 | P1 | 操作日志按用户、模块、结果和时间筛选 | 可定位本用例产生的关键写操作 |
|
||||
| A-SYS-009 | P0 | 日志检查密码、Token、银行卡、身份证和密钥 | 不记录明文敏感数据 |
|
||||
| A-SYS-010 | P1 | 登录日志检查成功、失败、IP 和设备 | 记录准确且符合脱敏规则 |
|
||||
| A-SYS-011 | P1 | 在线用户强制下线 | 目标会话失效,不影响其他用户 |
|
||||
| A-SYS-012 | P1 | 地区数据树查询 | 层级、编码和父子关系正确 |
|
||||
| A-SYS-013 | P1 | 插件管理页面在无操作权限下访问 | 只读或拒绝,不能安装/卸载插件 |
|
||||
| A-SYS-014 | P1 | 服务/Redis/数据库短暂不可用 | 页面展示可诊断错误,恢复后无需清库即可继续 |
|
||||
| A-SYS-015 | P2 | 切换主题、语言、侧栏折叠和标签页 | 状态保持,业务表格和弹窗不变形 |
|
||||
|
||||
## 8. 跨端端到端业务场景
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| E2E-001 | P0 | 后台建等级→建大厅→建商品并发布→建任务并发布→会员领取完成 | 全链路可用,规则和金额一致 |
|
||||
| E2E-002 | P0 | C 端提交储入→后台通过→C 端查看余额和记录 | 余额增加一次,状态和流水一致,提交后可进入客服 |
|
||||
| E2E-003 | P0 | C 端申请取出→后台通过→完成 | 可用转冻结再扣除,状态和三阶段时间正确 |
|
||||
| E2E-004 | P0 | C 端申请取出→后台驳回 | 冻结资金完整退回,生成冲正流水 |
|
||||
| E2E-005 | P0 | 手动会员领取→客服匹配商品→会员确认 | 匹配、余额校验、扣款和结算闭环正确 |
|
||||
| E2E-006 | P0 | 自动会员领取→延迟自动匹配 | 在配置延迟内完成,商品金额不超过余额及配置区间 |
|
||||
| E2E-007 | P0 | 会员提交任务→后台审核通过 | 订单、提交、钱包和佣金流水一致且幂等 |
|
||||
| E2E-008 | P1 | 后台发布/下线 Banner、文本、手工商品 | C 端仅展示当前有效发布版本 |
|
||||
| E2E-009 | P1 | 后台封禁会员→会员继续领取/储入/取出/客服 | 所有受限动作按策略拒绝且已有数据可追溯 |
|
||||
| E2E-010 | P1 | 后台切换功能开关→C 端首页和直连接口测试 | UI 与服务端双重生效,不能绕过置灰入口 |
|
||||
| E2E-011 | P0 | 会员点击客服→发文字、表情、图片、视频→客服回复 | 双端实时可见,头像、历史、附件 ACL 均正确 |
|
||||
| E2E-012 | P0 | 子角色仅有充值查看权限登录后台 | 只见允许菜单和查看能力,不能审核或访问其他业务 API |
|
||||
| E2E-013 | P0 | 后台新增启用地址→会员从该域名注册→查看会员和地址详情 | 注册成功并建立唯一绑定;会员详情、地址绑定会员和累计次数一致 |
|
||||
| E2E-014 | P1 | 后台将已绑定地址设为失效→旧会员登录→新会员注册 | 旧会员绑定历史保留且显示失效;登录正常记日志;新会员注册成功但不绑定 |
|
||||
| E2E-015 | P1 | Excel 导入地址→从其中一个域名注册→按域名筛会员→导出登录日志 | 导入地址为启用,注册绑定、会员筛选和登录日志链路完整可追溯 |
|
||||
| E2E-016 | P0 | 会员个人信息页绑定银行卡(数字人民币留空)→查看本人原始取出信息→打开取出报价→在弹窗设置支付密码→再次确认申请 | 绑卡、本人详情、收款码、报价和密码状态一致;设置密码本身不创建取出单,仅最后一次明确确认产生一张订单 |
|
||||
|
||||
## 9. API、安全、数据一致性与异常测试
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| API-001 | P0 | 请求体增加未知 JSON 字段 | C 端 API 按严格解码返回 400,不忽略可疑字段 |
|
||||
| API-002 | P0 | 使用 GET 调写接口或 POST 调只读接口 | 返回 405,不执行副作用 |
|
||||
| API-003 | P0 | 修改请求中的 member_id、tenant_id、operator_id | 服务端以会话身份为准,忽略或拒绝越权字段 |
|
||||
| API-004 | P0 | 数值传字符串、浮点、null、超出 int64 | 返回字段校验错误,不截断、不溢出 |
|
||||
| API-005 | P1 | 分页 page=-1、page_size=-1/0/101、极大页码 | 非法值拒绝或按明确默认;极大页返回空数组 |
|
||||
| API-006 | P0 | 将他人订单、充值、提现、附件、会话 ID 逐项替换 | 均不可读取或修改 |
|
||||
| API-007 | P0 | 重放充值审核、提现执行、任务结算、账变请求 | 状态机/幂等保护生效,不重复记账 |
|
||||
| API-008 | P0 | 在事务中途模拟数据库错误 | 整个事务回滚,不出现订单成功但无流水等半状态 |
|
||||
| API-009 | P0 | 高并发更新同一钱包 | 乐观锁/行锁生效,最终余额守恒,版本连续 |
|
||||
| API-010 | P0 | 校验每个资金业务单的流水唯一键 | 唯一且可反查业务单;冲正指向原流水 |
|
||||
| API-011 | P0 | 访问上传文件使用路径穿越、双扩展名和恶意 MIME | 被拒绝或安全下载,不可执行服务器文件 |
|
||||
| API-012 | P0 | 富文本插入 script、onerror、javascript URL、未知 iframe | 存储或渲染前净化,CSP 下不执行 |
|
||||
| API-013 | P0 | 搜索框输入 SQL 元字符及超长条件 | 参数化查询,无 SQL 注入、无 500 |
|
||||
| API-014 | P0 | 检查 API、浏览器缓存和导出中的敏感字段 | 通用列表、导出中的身份证和卡号默认脱敏;仅已认证的本人取出信息详情可返回原值且禁缓存;密码、密钥永不返回 |
|
||||
| API-015 | P0 | 跨域站点调用带凭证业务接口 | CORS 只允许配置源;不使用通配凭证策略 |
|
||||
| API-016 | P1 | 同一接口连续触发限流 | 返回 429 和合理重试时间,不永久锁死 |
|
||||
| API-017 | P1 | 数据库中制造孤立引用后打开列表/详情 | 页面可降级并提示,不 panic;提供数据修复线索 |
|
||||
| API-018 | P1 | Redis 短暂不可用时登录、限流、资金提交 | 安全失败,不绕过认证/限流/幂等;恢复后可继续 |
|
||||
| API-019 | P1 | IM 服务不可用、慢响应、证书错误 | 客服页可重试并给备用提示,不影响其他业务页面 |
|
||||
| API-020 | P1 | 检查所有错误响应 | 结构统一包含 code/reason/message/request_id,生产不返回堆栈和 SQL |
|
||||
| API-021 | P0 | 无 `/urlPool/import`、`/urlPool/status` 或会员登录日志权限直接调用对应接口 | 返回 403;不能通过隐藏按钮、构造请求或导出绕过权限 |
|
||||
| API-022 | P0 | 跨租户传入地址池 ID、绑定会员 ID 或会员登录日志 ID | 统一返回无权限或不存在,不泄露域名、会员及登录信息 |
|
||||
| API-023 | P0 | 未登录请求 `GET /v1/payment-accounts/details`,或使用其他 HTTP 方法调用 | 未登录拒绝、非 GET 返回 405;响应不包含任何原始绑定信息 |
|
||||
| API-024 | P0 | 登录后请求本人取出信息详情并检查响应头、代理缓存、浏览器缓存和访问日志 | 响应设置 `Cache-Control: no-store`;原始身份证、卡号及数字人民币编码不进入缓存或日志 |
|
||||
|
||||
## 10. 兼容性、易用性与可恢复性
|
||||
|
||||
| ID | 优先级 | 场景与步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| UX-001 | P1 | iOS Safari、Android Chrome、桌面 Chrome/Edge 最新两个主版本执行冒烟 | 核心流程一致,无白屏和不可点击控件 |
|
||||
| UX-002 | P1 | C 端宽度 320、375、390、430px | 无横向溢出;底部导航、弹窗和键盘区域可用 |
|
||||
| UX-003 | P1 | 后台 1366×768、1440×900、1920×1080 | 表格、固定操作列、弹窗和滚动条可操作 |
|
||||
| UX-004 | P2 | 浏览器缩放 80%、100%、125%、150% | 关键按钮和文字不被裁切 |
|
||||
| UX-005 | P2 | 键盘 Tab、Enter、Esc 操作表单和弹窗 | 焦点顺序合理,危险操作不被误触 |
|
||||
| UX-006 | P2 | 图片慢加载、长用户名、长商品名、长错误信息 | 截断/换行合理,详情仍可查看完整内容 |
|
||||
| UX-007 | P1 | 提交后刷新或返回 | 已成功业务不重复提交;未成功表单按安全策略保留或清空 |
|
||||
| UX-008 | P1 | 服务发布期间页面打开旧版本 | 静态资源恢复机制有效,API 兼容或给出刷新提示 |
|
||||
|
||||
## 11. 发布前最小回归集
|
||||
|
||||
每次发布至少执行以下 P0 链路:
|
||||
|
||||
1. 正常登录、错误登录限流、退出后 Token 失效。
|
||||
2. 首页四个功能开关与服务端直连接口同时生效。
|
||||
3. 自动领取、手动匹配、余额不足、重复领取和重复确认。
|
||||
4. 储入提交、后台通过、余额及流水核对。
|
||||
5. 取出报价、未设支付密码时的弹窗设置与关闭、再次确认申请、幂等提交、后台通过/驳回/完成及余额核对。
|
||||
6. 商品发布后可匹配、下架后不可匹配、已引用商品不可删除。
|
||||
7. 任务发布、会员提交、后台审核及佣金只结算一次。
|
||||
8. 子角色菜单不超过父角色;无按钮权限时接口也不可调用。
|
||||
9. 客服会话首开、断线重连、文本、表情、图片、视频及双端附件显示。
|
||||
10. 跨租户、跨会员 IDOR、通用列表脱敏、本人取出信息原文与禁缓存、操作日志检查。
|
||||
11. 启用域名注册绑定、失效/未知域名注册不绑定,以及会员注册域名筛选。
|
||||
12. 地址池新增、启停、详情、使用次数排序、Excel 导入及会员登录日志查询导出。
|
||||
13. 个人信息页头像选图更新、取出信息入口、数字人民币选填和本人绑定信息原文展示。
|
||||
|
||||
## 12. 准入、准出与缺陷判定
|
||||
|
||||
### 12.1 测试准入
|
||||
|
||||
- 前后端构建通过,数据库迁移和种子数据在空库及存量库各验证一次。
|
||||
- C 端、后台、MySQL、Redis、上传目录及 IM 环境可访问。
|
||||
- 产品配置、测试账号、支付/提现渠道、商品和任务数据已准备。
|
||||
- 本次变更清单、数据库脚本、回滚方案和发布版本号明确。
|
||||
|
||||
### 12.2 发布准出
|
||||
|
||||
- P0 用例 100% 通过,P1 通过率不低于 98%。
|
||||
- 不存在未关闭的阻断、资金错误、越权、数据泄露或不可逆数据损坏缺陷。
|
||||
- 所有失败用例均有缺陷单、影响范围、临时措施和修复计划。
|
||||
- 资金抽样满足:业务单金额 = 钱包变化 = 流水金额;重复请求不重复入账。
|
||||
- 核心业务日志包含 request_id、业务单号和操作者,且无敏感明文。
|
||||
|
||||
### 12.3 缺陷记录模板
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 缺陷标题 | `[端/模块][严重级别] 简明现象` |
|
||||
| 环境与版本 | 前端版本、后端版本、数据库迁移版本、浏览器/设备 |
|
||||
| 前置数据 | 租户、账号代号、订单号、配置版本;不得粘贴真实敏感信息 |
|
||||
| 复现步骤 | 可重复的最小步骤 |
|
||||
| 实际结果 | 页面、接口状态码、reason、request_id、必要截图 |
|
||||
| 预期结果 | 引用本文件用例 ID |
|
||||
| 数据影响 | 涉及表、记录数、金额、是否可回滚 |
|
||||
| 附件 | 脱敏截图、HAR、日志片段、SQL 核对结果 |
|
||||
|
||||
## 13. 执行记录模板
|
||||
|
||||
| 用例 ID | 执行人 | 日期 | 环境/版本 | 结果(通过/失败/阻塞) | 缺陷号 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 示例:C-FIN-031 | | | | | | |
|
||||
|
||||
---
|
||||
|
||||
说明:配置型金额、次数和时间不在本文写死具体业务数值。执行时应读取当前已发布配置,并使用“下限减 1、下限、下限加 1;上限减 1、上限、上限加 1”的方法验证。代码固定限制(例如分页最大 100、图片 5MB/10MB、视频 50MB、备注 500 字、任务正文 2000 字、附件最多 9 个)已在对应测试用例中明确列出。
|
||||
@@ -320,3 +320,30 @@
|
||||
- C 端前端:`npm run build` 通过,4186 个模块完成转换。
|
||||
- 新增迁移已在本地 MySQL 8.4 验证;定时任务记录成功初始化,Banner 和商品空图片数量均变为 0。
|
||||
- 页面回归暂未完成:执行机器再次进入锁屏状态,无法继续通过浏览器点击;服务与前端均已在本地启动,解锁后可直接续测。
|
||||
|
||||
## 14. 测试环境发布后回归(2026-10-16)
|
||||
|
||||
### 14.1 已通过
|
||||
|
||||
- URL 地址池 Excel 导入弹窗已显示“取消”,点击后正常关闭。
|
||||
- 订单列表已显示自动/手动派单模式,展开查询条件后可见派单模式筛选。
|
||||
- 订单详情已显示状态、冻结状态、创建/更新时间、状态时间线、资金流水和审批记录。
|
||||
- 会员任务审核列表已回显任务/商品名称和订单号,历史数据不再空白。
|
||||
- 会员银行卡信息弹窗已回显开户人、身份证、银行卡、数字钱包和两类收款码;C 端“绑定信息”页也正常显示历史账户。
|
||||
- 手工商城和任务大厅已显示发布状态及发布/下架(下线)操作。
|
||||
- Banner 和商品列表的现有图片资源均能正常加载。
|
||||
- 网站设置已回显站点名称、HTTPS 隐私地址、交易余额、等待时长、次数、最低充值和自动派单范围。
|
||||
- 经营看板用同一套 `RefreshDaily` 逻辑手工触发后,显示 17 会员、9 商品、余额与昨日订单/充值数据,数据延迟 14 秒。
|
||||
|
||||
### 14.2 发布过程中已补齐
|
||||
|
||||
- 首次进入任务列表时接口报错,根因为测试库未执行 `hg_task.product_id` 迁移;补执后页面恢复。
|
||||
- 已在测试库初始化 `dashboard_daily_summary` 定时任务,并补齐站点配置及空图片默认值。
|
||||
- 站点配置迁移原先把数字写成 JSON 字符串,已改为强制写入 JSON 数值,页面回显正常。
|
||||
|
||||
### 14.3 需再发布后复测
|
||||
|
||||
- 新增任务弹窗请求商品时使用了 `perPage=500`,超过后端上限 200;已改为 200。
|
||||
- 部分历史充值单没有实名档案和收款账户,但备注中存在“姓名:xxx”;已增加从备注回退显示姓名。
|
||||
- 定时任务记录是在当前服务进程启动后插入的,现进程尚未加载该任务;再次发布/重启后需等待一个 10 分钟周期,确认 `calculated_at` 自动推进。
|
||||
- 当前测试账号 `potter`(系统管理员角色)没有“定时任务”菜单权限,无法在页面手动执行;需使用超级管理员或在角色权限中授予该菜单。
|
||||
|
||||
@@ -35,6 +35,7 @@ type ProductColumns struct {
|
||||
CoverUrl string // 封面URL
|
||||
DetailHtml string // 商品详情
|
||||
IsRecommended string // 是否推荐
|
||||
ClaimedCount string // 已领取展示数
|
||||
PublishStatus string // 发布状态
|
||||
StartAt string // 展示开始时间
|
||||
EndAt string // 展示结束时间
|
||||
@@ -65,6 +66,7 @@ var productColumns = ProductColumns{
|
||||
CoverUrl: "cover_url",
|
||||
DetailHtml: "detail_html",
|
||||
IsRecommended: "is_recommended",
|
||||
ClaimedCount: "claimed_count",
|
||||
PublishStatus: "publish_status",
|
||||
StartAt: "start_at",
|
||||
EndAt: "end_at",
|
||||
|
||||
@@ -133,6 +133,12 @@ func (s *sSysAppMember) View(ctx context.Context, in *sysin.AppMemberViewInp) (r
|
||||
}
|
||||
|
||||
func (s *sSysAppMember) Edit(ctx context.Context, in *sysin.AppMemberEditInp) error {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return s.edit(ctx, in)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sSysAppMember) edit(ctx context.Context, in *sysin.AppMemberEditInp) error {
|
||||
if in.Id == 0 {
|
||||
return gerror.New("后台不支持创建会员,请由会员在C端完成注册")
|
||||
}
|
||||
@@ -233,7 +239,82 @@ func (s *sSysAppMember) Edit(ctx context.Context, in *sysin.AppMemberEditInp) er
|
||||
return err
|
||||
}
|
||||
}
|
||||
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "member", Action: "member.edit", TargetType: "app_member", TargetId: in.Id, Before: before, After: g.Map{"username": in.Username, "mobileChanged": in.Mobile != "", "parentId": in.ParentId, "riskStatus": in.RiskStatus, "autoDispatch": in.AutoDispatch, "memberCategory": in.MemberCategory}})
|
||||
if in.AvailableAmount != nil {
|
||||
if err = s.setAvailableAmount(ctx, in.Id, *in.AvailableAmount); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "member", Action: "member.edit", TargetType: "app_member", TargetId: in.Id, Before: before, After: g.Map{"username": in.Username, "mobileChanged": in.Mobile != "", "parentId": in.ParentId, "riskStatus": in.RiskStatus, "autoDispatch": in.AutoDispatch, "memberCategory": in.MemberCategory, "availableAmount": in.AvailableAmount}})
|
||||
}
|
||||
|
||||
func (s *sSysAppMember) setAvailableAmount(ctx context.Context, memberId uint64, target int64) error {
|
||||
wc := dao.WalletAccount.Columns()
|
||||
var account *entity.WalletAccount
|
||||
if err := s.walletModel(ctx).Where(wc.MemberId, memberId).Where(wc.Currency, "CNY").LockUpdate().Scan(&account); err != nil {
|
||||
return gerror.Wrap(err, "读取会员钱包失败")
|
||||
}
|
||||
if account == nil {
|
||||
if _, err := s.walletModel(ctx).
|
||||
Fields(wc.MemberId, wc.Currency).
|
||||
Hook(hook.SaveTenant).
|
||||
Data(g.Map{wc.MemberId: memberId, wc.Currency: "CNY"}).
|
||||
Insert(); err != nil {
|
||||
return gerror.Wrap(err, "初始化会员钱包失败")
|
||||
}
|
||||
if err := s.walletModel(ctx).Where(wc.MemberId, memberId).Where(wc.Currency, "CNY").LockUpdate().Scan(&account); err != nil {
|
||||
return gerror.Wrap(err, "读取会员钱包失败")
|
||||
}
|
||||
}
|
||||
if account == nil {
|
||||
return gerror.New("会员钱包不存在")
|
||||
}
|
||||
if account.AvailableAmount == target {
|
||||
return nil
|
||||
}
|
||||
|
||||
direction := consts.WalletDirectionIncome
|
||||
amount := target - account.AvailableAmount
|
||||
if amount < 0 {
|
||||
direction = consts.WalletDirectionExpense
|
||||
amount = -amount
|
||||
}
|
||||
now := gtime.Now()
|
||||
ledgerNo := "WL" + now.Format("YmdHis") + grand.S(7)
|
||||
bizNo := "ME" + now.Format("YmdHis") + grand.S(7)
|
||||
idempotencyKey := fmt.Sprintf("member-edit-%d-%d-%s", memberId, now.UnixMilli(), grand.S(6))
|
||||
lc := dao.WalletLedger.Columns()
|
||||
if _, err := dao.WalletLedger.Ctx(ctx).
|
||||
Fields(lc.LedgerNo, lc.AccountId, lc.MemberId, lc.BizType, lc.BizNo, lc.EntryType, lc.Direction, lc.Amount, lc.BeforeAmount, lc.AfterAmount, lc.Currency, lc.IdempotencyKey, lc.Remark, lc.OperatorId, lc.OccurredAt).
|
||||
Hook(hook.SaveTenant).
|
||||
Data(g.Map{
|
||||
lc.LedgerNo: ledgerNo, lc.AccountId: account.Id, lc.MemberId: memberId,
|
||||
lc.BizType: "member_edit", lc.BizNo: bizNo, lc.EntryType: fmt.Sprint(consts.WalletAdjustOther),
|
||||
lc.Direction: direction, lc.Amount: amount, lc.BeforeAmount: account.AvailableAmount,
|
||||
lc.AfterAmount: target, lc.Currency: account.Currency, lc.IdempotencyKey: idempotencyKey,
|
||||
lc.Remark: "后台编辑会员余额", lc.OperatorId: contexts.GetUserId(ctx), lc.OccurredAt: now,
|
||||
}).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "写入账变流水失败")
|
||||
}
|
||||
|
||||
walletData := g.Map{wc.AvailableAmount: target, wc.Version: gdb.Raw(wc.Version + "+1")}
|
||||
if direction == consts.WalletDirectionIncome {
|
||||
walletData[wc.TotalIncome] = gdb.Raw(wc.TotalIncome + "+" + fmt.Sprint(amount))
|
||||
} else {
|
||||
walletData[wc.TotalExpense] = gdb.Raw(wc.TotalExpense + "+" + fmt.Sprint(amount))
|
||||
}
|
||||
result, err := s.walletModel(ctx).WherePri(account.Id).Where(wc.Version, account.Version).Data(walletData).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新会员余额失败")
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows != 1 {
|
||||
return gerror.New("会员余额已变化,请重试")
|
||||
}
|
||||
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{
|
||||
Module: "wallet", Action: "wallet.balance.edit", TargetType: "wallet_account", TargetId: account.Id,
|
||||
Before: g.Map{"availableAmount": account.AvailableAmount}, After: g.Map{"availableAmount": target, "ledgerNo": ledgerNo},
|
||||
Reason: "后台编辑会员余额",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sSysAppMember) saveProfile(ctx context.Context, in *sysin.AppMemberEditInp) error {
|
||||
|
||||
@@ -124,7 +124,7 @@ func (s *sSysProduct) Edit(ctx context.Context, in *sysin.ProductEditInp) (id ui
|
||||
id = in.Id
|
||||
} else {
|
||||
in.ProductNo = "PD" + gtime.Now().Format("YmdHis") + grand.S(5)
|
||||
result, e := s.Model(ctx).Fields(c.ProductNo, c.CategoryId, c.ProductType, c.PaymentType, c.Name, c.ShopName, c.PriceAmount, c.Currency, c.CoverAttachmentId, c.CoverUrl, c.DetailHtml, c.IsRecommended, c.PublishStatus, c.StartAt, c.EndAt, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(g.Map{c.ProductNo: in.ProductNo, c.CategoryId: in.CategoryId, c.ProductType: in.ProductType, c.PaymentType: in.PaymentType, c.Name: in.Name, c.ShopName: in.ShopName, c.PriceAmount: in.PriceAmount, c.Currency: in.Currency, c.CoverAttachmentId: in.CoverAttachmentId, c.CoverUrl: in.CoverUrl, c.DetailHtml: in.DetailHtml, c.IsRecommended: in.IsRecommended, c.PublishStatus: consts.BizPublishDraft, c.StartAt: in.StartAt, c.EndAt: in.EndAt, c.Sort: in.Sort, c.Remark: in.Remark, c.CreatedBy: uid, c.UpdatedBy: uid}).Insert()
|
||||
result, e := s.Model(ctx).Fields(c.ProductNo, c.CategoryId, c.ProductType, c.PaymentType, c.Name, c.ShopName, c.PriceAmount, c.Currency, c.CoverAttachmentId, c.CoverUrl, c.DetailHtml, c.IsRecommended, c.ClaimedCount, c.PublishStatus, c.StartAt, c.EndAt, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(g.Map{c.ProductNo: in.ProductNo, c.CategoryId: in.CategoryId, c.ProductType: in.ProductType, c.PaymentType: in.PaymentType, c.Name: in.Name, c.ShopName: in.ShopName, c.PriceAmount: in.PriceAmount, c.Currency: in.Currency, c.CoverAttachmentId: in.CoverAttachmentId, c.CoverUrl: in.CoverUrl, c.DetailHtml: in.DetailHtml, c.IsRecommended: in.IsRecommended, c.ClaimedCount: grand.N(100, 500), c.PublishStatus: consts.BizPublishDraft, c.StartAt: in.StartAt, c.EndAt: in.EndAt, c.Sort: in.Sort, c.Remark: in.Remark, c.CreatedBy: uid, c.UpdatedBy: uid}).Insert()
|
||||
if e != nil {
|
||||
return 0, gerror.Wrap(e, "创建商品失败")
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ func (s *sSysRechargeOrder) baseModel(ctx context.Context) *gdb.Model {
|
||||
func (s *sSysRechargeOrder) listModel(ctx context.Context) *gdb.Model {
|
||||
rc, mc, pc, wc, cc := dao.RechargeOrder.Columns(), dao.AppMember.Columns(), dao.AppMemberProfile.Columns(), dao.WalletAccount.Columns(), dao.PaymentChannel.Columns()
|
||||
ac := dao.AppMemberPaymentAccount.Columns()
|
||||
realName := fmt.Sprintf("COALESCE(NULLIF(%s,''),(SELECT %s FROM %s pa WHERE pa.%s=%s AND pa.%s IS NULL ORDER BY pa.%s ASC,pa.%s DESC LIMIT 1),'') real_name_mask", col("p", pc.RealNameMask), ac.HolderMask, dao.AppMemberPaymentAccount.Table(), ac.MemberId, col("r", rc.MemberId), ac.DeletedAt, ac.IsDefault, ac.Id)
|
||||
remarkName := fmt.Sprintf("CASE WHEN %s LIKE '姓名:%%' THEN SUBSTRING_INDEX(%s,'姓名:',-1) ELSE '' END", col("r", rc.Remark), col("r", rc.Remark))
|
||||
realName := fmt.Sprintf("COALESCE(NULLIF(%s,''),(SELECT NULLIF(%s,'') FROM %s pa WHERE pa.%s=%s AND pa.%s IS NULL ORDER BY pa.%s ASC,pa.%s DESC LIMIT 1),NULLIF(%s,''),'') real_name_mask", col("p", pc.RealNameMask), ac.HolderMask, dao.AppMemberPaymentAccount.Table(), ac.MemberId, col("r", rc.MemberId), ac.DeletedAt, ac.IsDefault, ac.Id, remarkName)
|
||||
return s.baseModel(ctx).
|
||||
Fields(col("r", rc.Id), col("r", rc.RechargeNo), col("r", rc.MemberId), col("m", mc.Username), col("m", mc.Mobile), realName, fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), col("r", rc.ChannelId), fmt.Sprintf("COALESCE(%s,'') channel_name", col("c", cc.Name)), col("r", rc.RequestAmount), col("r", rc.PaidAmount), col("r", rc.Currency), col("r", rc.ExternalNo), col("r", rc.VoucherAttachmentIds), col("r", rc.RechargeType), col("r", rc.RechargeStatus), col("r", rc.Remark), col("r", rc.ReviewReasonCode), col("r", rc.ReviewReason), col("r", rc.ApprovalRequestId), col("r", rc.LedgerId), col("r", rc.PaidAt), col("r", rc.ReviewedAt), col("r", rc.CreditedAt), col("r", rc.CreatedAt))
|
||||
}
|
||||
|
||||
@@ -36,12 +36,13 @@ func (s *sSysTradeOrder) listModel(ctx context.Context) *gdb.Model {
|
||||
canReview := fmt.Sprintf("EXISTS(SELECT 1 FROM %s ts WHERE ts.%s=%s AND ts.%s=%d) can_review", dao.TaskSubmission.Table(), sc.TradeOrderId, col("o", oc.Id), sc.SubmissionStatus, consts.TaskSubmissionPending)
|
||||
dispatchMode := fmt.Sprintf("CASE COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s,'$.dispatch_mode')),'') WHEN 'auto' THEN %d WHEN 'manual' THEN %d ELSE %s END auto_dispatch", col("o", oc.RuleSnapshot), consts.TradeAutoDispatchEnabled, consts.TradeAutoDispatchDisabled, col("m", mc.AutoDispatch))
|
||||
paymentStatus := fmt.Sprintf("COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s,'$.manual_payment_status')),'') payment_status", col("o", oc.RuleSnapshot))
|
||||
scheduledMatchAt := fmt.Sprintf("COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s,'$.scheduled_match_at')),'') scheduled_match_at", col("o", oc.RuleSnapshot))
|
||||
return s.model(ctx).As("o").
|
||||
LeftJoin(dao.AppMember.Table()+" m", col("m", mc.Id)+"="+col("o", oc.MemberId)).
|
||||
LeftJoin(dao.AppMemberProfile.Table()+" p", col("p", pc.MemberId)+"="+col("o", oc.MemberId)).
|
||||
LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("o", oc.MemberId)+" AND "+col("w", wc.Currency)+"='CNY'").
|
||||
LeftJoin(dao.ApprovalRequest.Table()+" a", col("a", ac.Id)+"="+col("o", oc.ApprovalRequestId)).
|
||||
Fields(col("o", oc.Id), col("o", oc.OrderNo), col("o", oc.MemberId), col("m", mc.Username), col("m", mc.Mobile), col("p", pc.RealNameMask), dispatchMode, paymentStatus, fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), todayCount, canReview, col("o", oc.ProductName), col("o", oc.OrderType), col("o", oc.Quantity), col("o", oc.UnitPriceAmount), col("o", oc.TotalAmount), col("o", oc.CommissionAmount), col("o", oc.Currency), col("o", oc.Message), col("o", oc.OrderStatus), col("o", oc.FreezeStatus), col("o", oc.ApprovalRequestId), fmt.Sprintf("COALESCE(%s,0) approval_status", col("a", ac.ApprovalStatus)), col("o", oc.MatchedAt), col("o", oc.CompletedAt), col("o", oc.UnfrozenAt), col("o", oc.CreatedAt))
|
||||
Fields(col("o", oc.Id), col("o", oc.OrderNo), col("o", oc.MemberId), col("m", mc.Username), col("m", mc.Mobile), col("p", pc.RealNameMask), dispatchMode, paymentStatus, scheduledMatchAt, fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), todayCount, canReview, col("o", oc.ProductName), col("o", oc.OrderType), col("o", oc.Quantity), col("o", oc.UnitPriceAmount), col("o", oc.TotalAmount), col("o", oc.CommissionAmount), col("o", oc.Currency), col("o", oc.Message), col("o", oc.OrderStatus), col("o", oc.FreezeStatus), col("o", oc.ApprovalRequestId), fmt.Sprintf("COALESCE(%s,0) approval_status", col("a", ac.ApprovalStatus)), col("o", oc.MatchedAt), col("o", oc.CompletedAt), col("o", oc.UnfrozenAt), col("o", oc.CreatedAt))
|
||||
}
|
||||
|
||||
func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp) (list []*sysin.TradeOrderListModel, total int, err error) {
|
||||
@@ -75,7 +76,7 @@ func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp)
|
||||
return
|
||||
}
|
||||
for _, row := range list {
|
||||
row.CanMatch = row.AutoDispatch == consts.TradeAutoDispatchDisabled && row.OrderStatus == consts.TradeOrderMatching && row.ApprovalRequestId == 0
|
||||
row.CanMatch = canAdminMatchOrder(row.AutoDispatch, row.OrderStatus, row.ApprovalRequestId, row.ScheduledMatchAt, time.Now())
|
||||
manualPending := row.AutoDispatch == consts.TradeAutoDispatchDisabled && row.OrderStatus == consts.TradeOrderPending && row.FreezeStatus == consts.TradeFreezeNormal
|
||||
row.CanChangeAmount = manualPending
|
||||
row.CanForcePay = manualPending
|
||||
@@ -86,6 +87,17 @@ func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp)
|
||||
return
|
||||
}
|
||||
|
||||
func canAdminMatchOrder(dispatchMode, orderStatus int, approvalRequestId uint64, scheduledMatchAt string, now time.Time) bool {
|
||||
if orderStatus != consts.TradeOrderMatching || approvalRequestId > 0 {
|
||||
return false
|
||||
}
|
||||
if dispatchMode != consts.TradeAutoDispatchEnabled || scheduledMatchAt == "" {
|
||||
return true
|
||||
}
|
||||
matchAt, err := time.Parse(time.RFC3339, scheduledMatchAt)
|
||||
return err == nil && now.UTC().Before(matchAt.UTC())
|
||||
}
|
||||
|
||||
func (s *sSysTradeOrder) View(ctx context.Context, in *sysin.TradeOrderViewInp) (res *sysin.TradeOrderViewModel, err error) {
|
||||
var base *sysin.TradeOrderListModel
|
||||
if err = s.listModel(ctx).Where(col("o", dao.TradeOrder.Columns().Id), in.Id).Scan(&base); err != nil {
|
||||
@@ -187,8 +199,20 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
|
||||
dispatchMode = "manual"
|
||||
}
|
||||
}
|
||||
if dispatchMode != "manual" {
|
||||
return gerror.New("自动派单订单由系统匹配,不能人工匹配")
|
||||
if dispatchMode == "" {
|
||||
dispatchMode = "auto"
|
||||
}
|
||||
if dispatchMode == "auto" && order.RuleSnapshot != nil {
|
||||
scheduledMatchAt := order.RuleSnapshot.Get("scheduled_match_at").String()
|
||||
if scheduledMatchAt != "" {
|
||||
matchAt, parseErr := time.Parse(time.RFC3339, scheduledMatchAt)
|
||||
if parseErr != nil {
|
||||
return gerror.New("自动派单时间格式不正确,请刷新订单后重试")
|
||||
}
|
||||
if !time.Now().UTC().Before(matchAt.UTC()) {
|
||||
return gerror.New("自动派单已到期,订单正在由系统自动匹配")
|
||||
}
|
||||
}
|
||||
}
|
||||
var product *entity.Product
|
||||
pc := dao.Product.Columns()
|
||||
@@ -207,7 +231,7 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
|
||||
if product.StartAt != nil && product.StartAt.After(now) || product.EndAt != nil && product.EndAt.Before(now) {
|
||||
return gerror.New("商品当前不在可匹配时间范围内")
|
||||
}
|
||||
if product.PriceAmount <= 0 || product.PriceAmount > math.MaxInt64/int64(in.Quantity) {
|
||||
if in.Quantity == 0 || product.PriceAmount <= 0 || product.PriceAmount > math.MaxInt64/int64(in.Quantity) {
|
||||
return gerror.New("商品价格或数量不合法")
|
||||
}
|
||||
levelId := order.MemberLevelId
|
||||
@@ -236,6 +260,9 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
|
||||
return gerror.New("会员等级生效版本不存在")
|
||||
}
|
||||
totalAmount := product.PriceAmount * int64(in.Quantity)
|
||||
if levelVersion.CommissionRate > 0 && uint64(levelVersion.CommissionRate) > uint64(math.MaxInt64/totalAmount) {
|
||||
return gerror.New("佣金规则超出可计算范围")
|
||||
}
|
||||
commissionAmount := totalAmount * int64(levelVersion.CommissionRate) / 10000
|
||||
snapshotData := g.Map{}
|
||||
if order.RuleSnapshot != nil {
|
||||
@@ -243,20 +270,25 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
|
||||
snapshotData[key] = value
|
||||
}
|
||||
}
|
||||
snapshotData["source"], snapshotData["dispatch_mode"] = "admin_manual_match", "manual"
|
||||
snapshotData["source"], snapshotData["match_source"], snapshotData["dispatch_mode"] = "admin_manual_match", "admin_manual_match", dispatchMode
|
||||
snapshotData["product_id"], snapshotData["product_no"] = product.Id, product.ProductNo
|
||||
snapshotData["unit_price_amount"], snapshotData["quantity"] = product.PriceAmount, in.Quantity
|
||||
snapshotData["member_level_id"], snapshotData["level_version_id"] = levelId, levelVersion.Id
|
||||
snapshotData["commission_rate"], snapshotData["manual_payment_status"] = levelVersion.CommissionRate, "pending"
|
||||
snapshotData["confirmation_required"], snapshotData["deadline_at"] = true, ""
|
||||
snapshotData["commission_rate"], snapshotData["manual_payment_status"] = levelVersion.CommissionRate, "settled"
|
||||
snapshotData["confirmation_required"], snapshotData["deadline_at"] = false, ""
|
||||
snapshotData["settled_by"], snapshotData["settled_at"] = "admin", time.Now().UTC().Format(time.RFC3339)
|
||||
snapshot := gjson.New(snapshotData)
|
||||
commissionLedgerId, err := s.settleMatchedOrder(ctx, order, dispatchMode, totalAmount, commissionAmount, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oc := dao.TradeOrder.Columns()
|
||||
data := g.Map{
|
||||
oc.ProductId: product.Id, oc.ProductName: product.Name,
|
||||
oc.Quantity: in.Quantity, oc.UnitPriceAmount: product.PriceAmount, oc.TotalAmount: totalAmount,
|
||||
oc.CommissionAmount: commissionAmount, oc.Currency: product.Currency, oc.MemberLevelId: levelId,
|
||||
oc.RuleSnapshot: snapshot, oc.Message: "后台已匹配大额订单,等待会员确认付款", oc.OrderStatus: consts.TradeOrderPending,
|
||||
oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.UpdatedBy: contexts.GetUserId(ctx),
|
||||
oc.RuleSnapshot: snapshot, oc.Message: "后台已匹配并完成订单,余额及佣金已结算", oc.OrderStatus: consts.TradeOrderCompleted,
|
||||
oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.CompletedAt: now, oc.UpdatedBy: contexts.GetUserId(ctx),
|
||||
}
|
||||
result, err := s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderMatching).Data(data).Update()
|
||||
if err != nil {
|
||||
@@ -266,6 +298,9 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
|
||||
if rows != 1 {
|
||||
return gerror.New("订单状态已变化,请刷新后重试")
|
||||
}
|
||||
if err = s.insertMatchedSubmission(ctx, order, levelId, commissionAmount, commissionLedgerId, now, dispatchMode); err != nil {
|
||||
return err
|
||||
}
|
||||
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{
|
||||
Module: "trade", Action: "trade_order.match", TargetType: "trade_order", TargetId: order.Id,
|
||||
Before: order, After: data, ChangeJson: gjson.New(g.Map{"productId": product.Id, "quantity": in.Quantity, "totalAmount": totalAmount}),
|
||||
@@ -274,6 +309,107 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.TradeOrder, dispatchMode string, totalAmount, commissionAmount int64, now *gtime.Time) (uint64, error) {
|
||||
wc := dao.WalletAccount.Columns()
|
||||
var account *entity.WalletAccount
|
||||
if err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).
|
||||
Where(wc.MemberId, order.MemberId).Where(wc.Currency, "CNY").LockUpdate().Scan(&account); err != nil {
|
||||
return 0, gerror.Wrap(err, "读取会员钱包失败")
|
||||
}
|
||||
if account == nil || account.AccountStatus != 1 {
|
||||
return 0, gerror.New("会员钱包不存在或状态异常")
|
||||
}
|
||||
if totalAmount <= 0 || account.AvailableAmount < math.MinInt64+totalAmount {
|
||||
return 0, gerror.New("订单金额超出钱包处理范围")
|
||||
}
|
||||
if commissionAmount < 0 || account.AvailableAmount > math.MaxInt64-commissionAmount {
|
||||
return 0, gerror.New("订单佣金超出钱包处理范围")
|
||||
}
|
||||
if dispatchMode == "auto" && account.AvailableAmount < totalAmount {
|
||||
return 0, gerror.New("自动派单商品金额不能超过会员可用余额")
|
||||
}
|
||||
|
||||
principalAfter := account.AvailableAmount - totalAmount
|
||||
settledAfter := principalAfter + commissionAmount
|
||||
if dispatchMode == "auto" {
|
||||
settledAfter = account.AvailableAmount + commissionAmount
|
||||
if account.FrozenAmount > math.MaxInt64-totalAmount {
|
||||
return 0, gerror.New("订单金额超出钱包冻结范围")
|
||||
}
|
||||
}
|
||||
accountData := g.Map{
|
||||
wc.AvailableAmount: settledAfter,
|
||||
wc.TotalIncome: gdb.Raw(wc.TotalIncome + "+" + fmt.Sprint(commissionAmount)),
|
||||
wc.Version: gdb.Raw(wc.Version + "+1"),
|
||||
}
|
||||
if dispatchMode == "manual" {
|
||||
accountData[wc.TotalExpense] = gdb.Raw(wc.TotalExpense + "+" + fmt.Sprint(totalAmount))
|
||||
}
|
||||
result, err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).WherePri(account.Id).Where(wc.Version, account.Version).Data(accountData).Update()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "结算订单余额失败")
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return 0, gerror.New("会员余额已变化,请刷新后重试")
|
||||
}
|
||||
|
||||
if dispatchMode == "auto" {
|
||||
holdFrozen := account.FrozenAmount + totalAmount
|
||||
entries := []struct {
|
||||
code, bizType, entryType string
|
||||
direction int
|
||||
before, after int64
|
||||
suffix, remark string
|
||||
}{
|
||||
{"AA", "task_principal_hold", "available", consts.WalletDirectionExpense, account.AvailableAmount, principalAfter, "admin-auto:available", "人工提前匹配自动单,扣除可用余额"},
|
||||
{"AF", "task_principal_hold", "frozen", consts.WalletDirectionIncome, account.FrozenAmount, holdFrozen, "admin-auto:frozen", "人工提前匹配自动单,冻结订单本金"},
|
||||
{"RF", "task_principal_release", "frozen", consts.WalletDirectionExpense, holdFrozen, account.FrozenAmount, "admin-auto:release-frozen", "自动单完成释放冻结本金"},
|
||||
{"RA", "task_principal_return", "available", consts.WalletDirectionIncome, principalAfter, account.AvailableAmount, "admin-auto:return-available", "自动单完成返回本金"},
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if _, err = insertMatchedOrderLedger(ctx, order, account, "WL"+fmt.Sprint(order.Id)+entry.code, entry.bizType, order.OrderNo, entry.entryType, entry.direction, totalAmount, entry.before, entry.after, entry.suffix, entry.remark, now); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, err = insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMP", order.Id), "task_principal_expense", order.OrderNo, "available", consts.WalletDirectionExpense, totalAmount, account.AvailableAmount, principalAfter, "admin-manual:principal", "手动单匹配完成扣除订单金额(允许负余额)", now); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if commissionAmount == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
commissionBefore := principalAfter
|
||||
if dispatchMode == "auto" {
|
||||
commissionBefore = account.AvailableAmount
|
||||
}
|
||||
return insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMC", order.Id), "commission", fmt.Sprintf("TSADMIN%d", order.Id), "available", consts.WalletDirectionIncome, commissionAmount, commissionBefore, settledAfter, "admin-match:commission", "后台匹配订单完成返佣", now)
|
||||
}
|
||||
|
||||
func (s *sSysTradeOrder) insertMatchedSubmission(ctx context.Context, order *entity.TradeOrder, levelId uint64, commissionAmount int64, ledgerId uint64, now *gtime.Time, dispatchMode string) error {
|
||||
sc := dao.TaskSubmission.Columns()
|
||||
uid := contexts.GetUserId(ctx)
|
||||
reason := "手动派单由后台匹配并直接完成"
|
||||
if dispatchMode == "auto" {
|
||||
reason = "自动派单到期前由后台匹配并直接完成"
|
||||
}
|
||||
_, err := dao.TaskSubmission.Ctx(ctx).
|
||||
Fields(sc.SubmissionNo, sc.TradeOrderId, sc.TaskId, sc.MemberId, sc.MemberLevelId, sc.EvidenceText, sc.EvidenceAttachmentIds,
|
||||
sc.SubmissionStatus, sc.CommissionAmount, sc.SuccessRate, sc.ReviewedBy, sc.ReviewedAt, sc.ReviewReasonCode,
|
||||
sc.ReviewReason, sc.AllowResubmit, sc.LedgerId, sc.SettledAt, sc.CreatedBy, sc.UpdatedBy).
|
||||
Hook(hook.SaveTenant).Data(g.Map{
|
||||
sc.SubmissionNo: fmt.Sprintf("TSADMIN%d", order.Id), sc.TradeOrderId: order.Id, sc.TaskId: order.TaskId,
|
||||
sc.MemberId: order.MemberId, sc.MemberLevelId: levelId, sc.EvidenceText: "", sc.EvidenceAttachmentIds: gjson.New(g.Array{}),
|
||||
sc.SubmissionStatus: consts.TaskSubmissionSettled, sc.CommissionAmount: commissionAmount, sc.SuccessRate: 10000,
|
||||
sc.ReviewedBy: uid, sc.ReviewedAt: now, sc.ReviewReasonCode: "ADMIN_MATCHED", sc.ReviewReason: reason,
|
||||
sc.AllowResubmit: consts.StatusDisable, sc.LedgerId: ledgerId, sc.SettledAt: now, sc.CreatedBy: uid, sc.UpdatedBy: uid,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "创建已结算任务记录失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysTradeOrder) UpdateAmount(ctx context.Context, in *sysin.TradeOrderUpdateAmountInp) error {
|
||||
amount, err := parseCents(in.Amount)
|
||||
if err != nil || amount <= 0 || amount < int64(in.Quantity) {
|
||||
@@ -477,20 +613,30 @@ func cloneOrderSnapshot(order *entity.TradeOrder) g.Map {
|
||||
|
||||
func insertTradeMatchLedger(ctx context.Context, order *entity.TradeOrder, account *entity.WalletAccount,
|
||||
ledgerNo, entryType string, direction int, amount, beforeAmount, afterAmount int64, suffix, remark string) error {
|
||||
_, err := insertMatchedOrderLedger(ctx, order, account, ledgerNo, "task_principal_hold", order.OrderNo, entryType, direction, amount, beforeAmount, afterAmount, suffix, remark, gtime.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func insertMatchedOrderLedger(ctx context.Context, order *entity.TradeOrder, account *entity.WalletAccount,
|
||||
ledgerNo, bizType, bizNo, entryType string, direction int, amount, beforeAmount, afterAmount int64, suffix, remark string, occurredAt *gtime.Time) (uint64, error) {
|
||||
lc := dao.WalletLedger.Columns()
|
||||
_, err := dao.WalletLedger.Ctx(ctx).
|
||||
result, err := dao.WalletLedger.Ctx(ctx).
|
||||
Fields(lc.LedgerNo, lc.AccountId, lc.MemberId, lc.BizType, lc.BizNo, lc.EntryType, lc.Direction,
|
||||
lc.Amount, lc.BeforeAmount, lc.AfterAmount, lc.Currency, lc.IdempotencyKey, lc.Remark, lc.OperatorId, lc.OccurredAt).
|
||||
Hook(hook.SaveTenant).
|
||||
Data(g.Map{lc.LedgerNo: ledgerNo, lc.AccountId: account.Id, lc.MemberId: order.MemberId,
|
||||
lc.BizType: "task_principal_hold", lc.BizNo: order.OrderNo, lc.EntryType: entryType, lc.Direction: direction,
|
||||
lc.BizType: bizType, lc.BizNo: bizNo, lc.EntryType: entryType, lc.Direction: direction,
|
||||
lc.Amount: amount, lc.BeforeAmount: beforeAmount, lc.AfterAmount: afterAmount, lc.Currency: account.Currency,
|
||||
lc.IdempotencyKey: fmt.Sprintf("task:%d:%s", order.Id, suffix), lc.Remark: remark,
|
||||
lc.OperatorId: contexts.GetUserId(ctx), lc.OccurredAt: gtime.Now()}).Insert()
|
||||
lc.OperatorId: contexts.GetUserId(ctx), lc.OccurredAt: occurredAt}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "记录订单扣款流水失败")
|
||||
return 0, gerror.Wrap(err, "记录订单扣款流水失败")
|
||||
}
|
||||
return nil
|
||||
ledgerId, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "读取订单流水ID失败")
|
||||
}
|
||||
return uint64(ledgerId), nil
|
||||
}
|
||||
|
||||
func (s *sSysTradeOrder) Apply(ctx context.Context, in *sysin.TradeOrderApplyInp) (res *sysin.FinanceApprovalModel, err error) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hotgo/internal/consts"
|
||||
)
|
||||
|
||||
func TestCanAdminMatchOrder(t *testing.T) {
|
||||
now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
dispatchMode int
|
||||
orderStatus int
|
||||
approvalRequestId uint64
|
||||
scheduledMatchAt string
|
||||
want bool
|
||||
}{
|
||||
{name: "manual matching order", dispatchMode: consts.TradeAutoDispatchDisabled, orderStatus: consts.TradeOrderMatching, want: true},
|
||||
{name: "auto before schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: now.Add(time.Minute).Format(time.RFC3339), want: true},
|
||||
{name: "auto at schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: now.Format(time.RFC3339), want: false},
|
||||
{name: "auto after schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: now.Add(-time.Minute).Format(time.RFC3339), want: false},
|
||||
{name: "legacy auto without schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, want: true},
|
||||
{name: "invalid schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: "invalid", want: false},
|
||||
{name: "already completed", dispatchMode: consts.TradeAutoDispatchDisabled, orderStatus: consts.TradeOrderCompleted, want: false},
|
||||
{name: "approval pending", dispatchMode: consts.TradeAutoDispatchDisabled, orderStatus: consts.TradeOrderMatching, approvalRequestId: 9, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := canAdminMatchOrder(tt.dispatchMode, tt.orderStatus, tt.approvalRequestId, tt.scheduledMatchAt, now)
|
||||
if got != tt.want {
|
||||
t.Fatalf("canAdminMatchOrder() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type Product struct {
|
||||
CoverUrl any // 封面URL
|
||||
DetailHtml any // 商品详情
|
||||
IsRecommended any // 是否推荐
|
||||
ClaimedCount any // 已领取展示数
|
||||
PublishStatus any // 发布状态
|
||||
StartAt *gtime.Time // 展示开始时间
|
||||
EndAt *gtime.Time // 展示结束时间
|
||||
|
||||
@@ -24,6 +24,7 @@ type Product struct {
|
||||
CoverUrl string `json:"coverUrl" orm:"cover_url" description:"封面URL"`
|
||||
DetailHtml string `json:"detailHtml" orm:"detail_html" description:"商品详情"`
|
||||
IsRecommended int `json:"isRecommended" orm:"is_recommended" description:"是否推荐"`
|
||||
ClaimedCount uint `json:"claimedCount" orm:"claimed_count" description:"已领取展示数"`
|
||||
PublishStatus int `json:"publishStatus" orm:"publish_status" description:"发布状态"`
|
||||
StartAt *gtime.Time `json:"startAt" orm:"start_at" description:"展示开始时间"`
|
||||
EndAt *gtime.Time `json:"endAt" orm:"end_at" description:"展示结束时间"`
|
||||
|
||||
@@ -74,28 +74,29 @@ type AppMemberViewModel struct {
|
||||
}
|
||||
|
||||
type AppMemberEditInp struct {
|
||||
Id uint64 `json:"id"`
|
||||
Username string `json:"username" v:"required|length:3,64#用户名不能为空|用户名长度为3到64个字符"`
|
||||
Mobile string `json:"mobile" v:"phone-loose#手机号格式不正确"`
|
||||
Password string `json:"password"`
|
||||
TradePassword string `json:"tradePassword"`
|
||||
RealName string `json:"realName" v:"max-length:64#姓名不能超过64个字符"`
|
||||
IdNo string `json:"idNo" v:"max-length:32#证件号不能超过32个字符"`
|
||||
ParentId uint64 `json:"parentId"`
|
||||
LevelId uint64 `json:"levelId"`
|
||||
RiskStatus int `json:"riskStatus" d:"1"`
|
||||
CreditScore int `json:"creditScore" d:"100" v:"between:0,1000#信用分范围为0到1000"`
|
||||
AutoDispatch int `json:"autoDispatch" d:"1"`
|
||||
MemberCategory int `json:"memberCategory"`
|
||||
IsTestAccount int `json:"isTestAccount"`
|
||||
Id uint64 `json:"id"`
|
||||
Username string `json:"username" v:"required|length:3,64#用户名不能为空|用户名长度为3到64个字符"`
|
||||
Mobile string `json:"mobile" v:"phone-loose#手机号格式不正确"`
|
||||
Password string `json:"password"`
|
||||
TradePassword string `json:"tradePassword"`
|
||||
RealName string `json:"realName" v:"max-length:64#姓名不能超过64个字符"`
|
||||
IdNo string `json:"idNo" v:"max-length:32#证件号不能超过32个字符"`
|
||||
ParentId uint64 `json:"parentId"`
|
||||
LevelId uint64 `json:"levelId"`
|
||||
RiskStatus int `json:"riskStatus" d:"1"`
|
||||
CreditScore int `json:"creditScore" d:"100" v:"between:0,1000#信用分范围为0到1000"`
|
||||
AutoDispatch int `json:"autoDispatch" d:"1"`
|
||||
MemberCategory int `json:"memberCategory"`
|
||||
IsTestAccount int `json:"isTestAccount"`
|
||||
AvailableAmount *int64 `json:"availableAmount"`
|
||||
}
|
||||
|
||||
func (in *AppMemberEditInp) Filter(ctx context.Context) error {
|
||||
if in.Id == 0 {
|
||||
return gerror.New("后台不支持创建会员,请由会员在C端完成注册")
|
||||
}
|
||||
if in.Password != "" && (len(in.Password) < 8 || !containsLetterAndNumber(in.Password)) {
|
||||
return gerror.New("密码至少8位且必须同时包含字母和数字")
|
||||
if in.Password != "" && (len([]rune(in.Password)) < 6 || len([]byte(in.Password)) > 64) {
|
||||
return gerror.New("密码长度为6到64个字符")
|
||||
}
|
||||
if in.TradePassword != "" && len(in.TradePassword) < 6 {
|
||||
return gerror.New("交易密码不能少于6位")
|
||||
@@ -118,18 +119,6 @@ func (in *AppMemberEditInp) Filter(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func containsLetterAndNumber(value string) bool {
|
||||
letter, number := false, false
|
||||
for _, r := range value {
|
||||
if r >= '0' && r <= '9' {
|
||||
number = true
|
||||
}
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
letter = true
|
||||
}
|
||||
}
|
||||
return letter && number
|
||||
}
|
||||
|
||||
type AppMemberDeleteInp struct {
|
||||
Id uint64 `json:"id" v:"required|min:1#会员ID不能为空|会员ID不能为空"`
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
type AppMemberRegisterInp struct {
|
||||
Username string `json:"username" v:"required|length:3,64#用户名不能为空|用户名长度为3到64个字符"`
|
||||
Mobile string `json:"mobile" v:"required|phone-loose#手机号不能为空|手机号格式不正确"`
|
||||
Password string `json:"password" v:"required|length:8,64#密码不能为空|密码长度为8到64个字符"`
|
||||
Password string `json:"password" v:"required|length:6,64#密码不能为空|密码长度为6到64个字符"`
|
||||
InviteCode string `json:"inviteCode" v:"required#请输入邀请码"`
|
||||
RegisterOrigin string `json:"registerOrigin" v:"max-length:512#注册来源不能超过512个字符"`
|
||||
RegisterHost string `json:"registerHost" v:"max-length:255#注册域名不能超过255个字符"`
|
||||
@@ -24,9 +24,6 @@ func (in *AppMemberRegisterInp) Filter(ctx context.Context) error {
|
||||
if !regexp.MustCompile(`^[0-9]{6}$`).MatchString(in.InviteCode) {
|
||||
return gerror.New("邀请码必须为6位数字")
|
||||
}
|
||||
if !containsLetterAndNumber(in.Password) {
|
||||
return gerror.New("密码至少8位且必须同时包含字母和数字")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ type ProductUpdateFields struct {
|
||||
type ProductInsertFields struct {
|
||||
ProductUpdateFields
|
||||
ProductNo string
|
||||
ClaimedCount uint
|
||||
PublishStatus int
|
||||
CreatedBy uint64
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ type TradeOrderListModel struct {
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
ApprovalStatus int `json:"approvalStatus"`
|
||||
PaymentStatus string `json:"paymentStatus"`
|
||||
ScheduledMatchAt string `json:"scheduledMatchAt"`
|
||||
CanMatch bool `json:"canMatch"`
|
||||
CanChangeAmount bool `json:"canChangeAmount"`
|
||||
CanForcePay bool `json:"canForcePay"`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" role="img" aria-label="Olons 平台横幅">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" role="img" aria-label="悦选平台横幅">
|
||||
<defs><linearGradient id="g" x1="0" x2="1"><stop stop-color="#1ba8ee"/><stop offset="1" stop-color="#3136dc"/></linearGradient></defs>
|
||||
<rect width="1200" height="360" rx="28" fill="url(#g)"/>
|
||||
<circle cx="1010" cy="70" r="180" fill="#fff" opacity=".10"/><circle cx="1120" cy="320" r="220" fill="#fff" opacity=".08"/>
|
||||
<text x="90" y="160" fill="#fff" font-size="66" font-family="Arial, sans-serif" font-weight="700">OLONS</text>
|
||||
<text x="90" y="160" fill="#fff" font-size="66" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-weight="700">悦选</text>
|
||||
<text x="92" y="230" fill="#fff" font-size="34" font-family="Arial, sans-serif" opacity=".9">精选商品 · 安心服务</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 680 B After Width: | Height: | Size: 704 B |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-label="Olons 商品占位图">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-label="悦选商品占位图">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#eaf7ff"/><stop offset="1" stop-color="#dfe3ff"/></linearGradient></defs>
|
||||
<rect width="800" height="800" rx="48" fill="url(#g)"/>
|
||||
<path d="M220 300h360l-28 300H248z" fill="#fff" stroke="#2878e8" stroke-width="22"/>
|
||||
<path d="M310 315c0-118 180-118 180 0" fill="none" stroke="#2878e8" stroke-width="22" stroke-linecap="round"/>
|
||||
<text x="400" y="690" text-anchor="middle" fill="#2878e8" font-size="54" font-family="Arial, sans-serif" font-weight="700">OLONS</text>
|
||||
<text x="400" y="690" text-anchor="middle" fill="#2878e8" font-size="54" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-weight="700">悦选</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 663 B After Width: | Height: | Size: 687 B |
@@ -2,16 +2,16 @@
|
||||
UPDATE `hg_site_config_version`
|
||||
SET `config_json` = JSON_SET(
|
||||
`config_json`,
|
||||
'$.siteName', IF(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.siteName')),'')='' OR JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.siteName')) LIKE '演示站点%', 'Olons', JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.siteName'))),
|
||||
'$.siteName', IF(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.siteName')),'')='' OR JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.siteName')) LIKE '演示站点%' OR JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.siteName'))='Olons', '悦选', JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.siteName'))),
|
||||
'$.privacyPolicyUrl', IF(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.privacyPolicyUrl')),'')='', 'https://olns-web.118im.com/admin/#/app/content/privacy_policy', JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.privacyPolicyUrl'))),
|
||||
'$.tradeBalanceAmount', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.tradeBalanceAmount'),0)<=0,10000,JSON_EXTRACT(`config_json`,'$.tradeBalanceAmount')),
|
||||
'$.tradeWaitSeconds', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.tradeWaitSeconds'),0)<=0,1200,JSON_EXTRACT(`config_json`,'$.tradeWaitSeconds')),
|
||||
'$.dailyTradeLimit', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.dailyTradeLimit'),0)<=0,30,JSON_EXTRACT(`config_json`,'$.dailyTradeLimit')),
|
||||
'$.dailyRewardLimit', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.dailyRewardLimit'),0)<=0,3,JSON_EXTRACT(`config_json`,'$.dailyRewardLimit')),
|
||||
'$.minimumRechargeAmount', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.minimumRechargeAmount'),0)<=0,10000,JSON_EXTRACT(`config_json`,'$.minimumRechargeAmount')),
|
||||
'$.autoOrderDelaySeconds', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderDelaySeconds'),0)<=0,10,JSON_EXTRACT(`config_json`,'$.autoOrderDelaySeconds')),
|
||||
'$.autoOrderMinAmount', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderMinAmount'),0)<=0,100,JSON_EXTRACT(`config_json`,'$.autoOrderMinAmount')),
|
||||
'$.autoOrderMaxAmount', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderMaxAmount'),0)<=0,10000000,JSON_EXTRACT(`config_json`,'$.autoOrderMaxAmount')),
|
||||
'$.tradeBalanceAmount', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.tradeBalanceAmount'),0)<=0,10000,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.tradeBalanceAmount'))) AS UNSIGNED),
|
||||
'$.tradeWaitSeconds', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.tradeWaitSeconds'),0)<=0,1200,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.tradeWaitSeconds'))) AS UNSIGNED),
|
||||
'$.dailyTradeLimit', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.dailyTradeLimit'),0)<=0,30,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.dailyTradeLimit'))) AS UNSIGNED),
|
||||
'$.dailyRewardLimit', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.dailyRewardLimit'),0)<=0,3,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.dailyRewardLimit'))) AS UNSIGNED),
|
||||
'$.minimumRechargeAmount', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.minimumRechargeAmount'),0)<=0,10000,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.minimumRechargeAmount'))) AS UNSIGNED),
|
||||
'$.autoOrderDelaySeconds', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderDelaySeconds'),0)<=0,10,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.autoOrderDelaySeconds'))) AS UNSIGNED),
|
||||
'$.autoOrderMinAmount', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderMinAmount'),0)<=0,100,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.autoOrderMinAmount'))) AS UNSIGNED),
|
||||
'$.autoOrderMaxAmount', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderMaxAmount'),0)<=0,10000000,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.autoOrderMaxAmount'))) AS UNSIGNED),
|
||||
'$.commissionRates', IF(JSON_LENGTH(COALESCE(JSON_EXTRACT(`config_json`,'$.commissionRates'),JSON_ARRAY()))<>6,JSON_ARRAY(0.10,0.05,0.03,0.02,0.01,0),JSON_EXTRACT(`config_json`,'$.commissionRates'))
|
||||
), `updated_at`=NOW()
|
||||
WHERE `config_group`='portal';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 手动派单允许后台匹配后直接完成,会员可用余额可因订单扣款变为负数。
|
||||
SET @wallet_available_check_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'hg_wallet_account'
|
||||
AND CONSTRAINT_NAME = 'chk_wallet_available_nonnegative'
|
||||
AND CONSTRAINT_TYPE = 'CHECK'
|
||||
);
|
||||
SET @drop_wallet_available_check = IF(
|
||||
@wallet_available_check_exists > 0,
|
||||
'ALTER TABLE `hg_wallet_account` DROP CHECK `chk_wallet_available_nonnegative`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE drop_wallet_available_check_stmt FROM @drop_wallet_available_check;
|
||||
EXECUTE drop_wallet_available_check_stmt;
|
||||
DEALLOCATE PREPARE drop_wallet_available_check_stmt;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 手工商城领取数作为固定展示数据保存,页面和接口不再随机计算。
|
||||
SET @product_claimed_count_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'hg_product'
|
||||
AND COLUMN_NAME = 'claimed_count'
|
||||
);
|
||||
SET @add_product_claimed_count = IF(
|
||||
@product_claimed_count_exists = 0,
|
||||
'ALTER TABLE `hg_product` ADD COLUMN `claimed_count` int unsigned NOT NULL DEFAULT 0 COMMENT ''已领取展示数'' AFTER `is_recommended`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_product_claimed_count_stmt FROM @add_product_claimed_count;
|
||||
EXECUTE add_product_claimed_count_stmt;
|
||||
DEALLOCATE PREPARE add_product_claimed_count_stmt;
|
||||
|
||||
-- 仅为尚未设置展示数的手工商品生成一次,后续只读取数据库中的值。
|
||||
UPDATE `hg_product`
|
||||
SET `claimed_count` = FLOOR(100 + RAND() * 401)
|
||||
WHERE `product_type` = 2
|
||||
AND `claimed_count` = 0
|
||||
AND `deleted_at` IS NULL;
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="会员列表"
|
||||
>会员资料默认脱敏展示;余额、禁用与奖励均通过审批流程处理。</n-card
|
||||
>会员资料默认脱敏展示;余额修改会自动保留账变流水。</n-card
|
||||
>
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
@@ -45,15 +45,9 @@
|
||||
:model="editForm"
|
||||
:rules="editRules"
|
||||
label-placement="left"
|
||||
label-width="100"
|
||||
label-width="88"
|
||||
>
|
||||
<n-form-item label="标记">
|
||||
<n-checkbox
|
||||
:checked="editForm.isTestAccount === 1"
|
||||
@update:checked="editForm.isTestAccount = $event ? 1 : 2"
|
||||
>标记</n-checkbox
|
||||
>
|
||||
</n-form-item>
|
||||
<div class="edit-form-grid">
|
||||
<n-form-item label="用户名称" path="username" required
|
||||
><n-input v-model:value="editForm.username" placeholder="请输入用户名称"
|
||||
/></n-form-item>
|
||||
@@ -62,8 +56,12 @@
|
||||
v-model:value="editForm.mobile"
|
||||
:placeholder="editForm.currentMobile || '留空不修改'"
|
||||
/></n-form-item>
|
||||
<n-form-item label="账户余额"
|
||||
><n-input :value="money(editForm.availableAmount)" disabled
|
||||
<n-form-item label="账户余额" path="availableAmountYuan"
|
||||
><n-input-number
|
||||
v-model:value="editForm.availableAmountYuan"
|
||||
:precision="2"
|
||||
class="form-number"
|
||||
placeholder="请输入账户余额"
|
||||
/></n-form-item>
|
||||
<n-form-item label="冻结金额"
|
||||
><n-input :value="money(editForm.frozenAmount)" disabled
|
||||
@@ -108,6 +106,17 @@
|
||||
:max="1000"
|
||||
class="form-number"
|
||||
/></n-form-item>
|
||||
<n-form-item class="edit-mark-item">
|
||||
<n-alert type="warning" :show-icon="true" class="edit-mark-alert">
|
||||
<n-checkbox
|
||||
:checked="editForm.isTestAccount === 1"
|
||||
@update:checked="editForm.isTestAccount = $event ? 1 : 2"
|
||||
>标记为假人账号</n-checkbox
|
||||
>
|
||||
<div class="edit-mark-hint">标记后该会员将按模拟账号处理,请确认后再保存。</div>
|
||||
</n-alert>
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
<template #action
|
||||
@@ -476,12 +485,19 @@
|
||||
validator: () => true,
|
||||
trigger: ['input', 'blur'],
|
||||
},
|
||||
availableAmountYuan: {
|
||||
validator: (_rule, value) =>
|
||||
value !== null && Number.isFinite(Number(value))
|
||||
? true
|
||||
: new Error('账户余额不能为空'),
|
||||
trigger: ['input', 'blur'],
|
||||
},
|
||||
password: {
|
||||
validator: (_rule, value) => {
|
||||
const text = String(value || '');
|
||||
if (!text) return true;
|
||||
if (text.length < 8 || !/[A-Za-z]/.test(text) || !/\d/.test(text))
|
||||
return new Error('密码至少8位且必须同时包含字母和数字');
|
||||
if (text.length < 6 || text.length > 64)
|
||||
return new Error('密码长度为6到64个字符');
|
||||
return true;
|
||||
},
|
||||
trigger: ['input', 'blur'],
|
||||
@@ -627,6 +643,7 @@
|
||||
memberCategory: row?.memberCategory || 1,
|
||||
isTestAccount: row?.isTestAccount || 2,
|
||||
availableAmount: row?.availableAmount || 0,
|
||||
availableAmountYuan: (row?.availableAmount || 0) / 100,
|
||||
frozenAmount: row?.frozenAmount || 0,
|
||||
});
|
||||
showEdit.value = true;
|
||||
@@ -638,6 +655,7 @@
|
||||
riskStatus: data.riskStatus || 1,
|
||||
creditScore: data.creditScore ?? 100,
|
||||
availableAmount: data.availableAmount || 0,
|
||||
availableAmountYuan: (data.availableAmount || 0) / 100,
|
||||
frozenAmount: data.frozenAmount || 0,
|
||||
currentMobile: data.mobile || row.mobile,
|
||||
});
|
||||
@@ -654,7 +672,10 @@
|
||||
saving.value = true;
|
||||
try {
|
||||
editForm.memberCategory = editForm.isTestAccount === 1 ? 2 : 1;
|
||||
await Edit(editForm);
|
||||
await Edit({
|
||||
...editForm,
|
||||
availableAmount: Math.round(Number(editForm.availableAmountYuan) * 100),
|
||||
});
|
||||
message.success('保存成功');
|
||||
showEdit.value = false;
|
||||
reloadTable();
|
||||
@@ -1090,7 +1111,7 @@
|
||||
max-width: 94vw;
|
||||
}
|
||||
:global(.edit-modal) {
|
||||
width: 720px;
|
||||
width: 700px;
|
||||
max-width: 92vw;
|
||||
}
|
||||
:global(.related-modal) {
|
||||
@@ -1100,6 +1121,23 @@
|
||||
.form-number {
|
||||
width: 100%;
|
||||
}
|
||||
.edit-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 20px;
|
||||
}
|
||||
.edit-mark-item {
|
||||
grid-column: 1 / -1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.edit-mark-alert {
|
||||
width: 100%;
|
||||
}
|
||||
.edit-mark-hint {
|
||||
margin-top: 4px;
|
||||
color: #d97706;
|
||||
font-size: 12px;
|
||||
}
|
||||
.upload-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--n-label-text-color);
|
||||
@@ -1160,4 +1198,12 @@
|
||||
.action-column-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #8c94a1;
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.edit-form-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.edit-mark-item {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
show.value = true;
|
||||
productLoading.value = true;
|
||||
try {
|
||||
const result: any = await ProductList({ page: 1, perPage: 500 });
|
||||
const result: any = await ProductList({ page: 1, perPage: 200 });
|
||||
productOptions.value = (result.list || []).map((item) => ({
|
||||
label: `${item.name}(${item.productNo})`,
|
||||
value: item.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="订单列表">手动派单模式由客服匹配订单,会员确认时校验余额;自动派单模式仅匹配余额内订单并直接完成。</n-card></div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="订单列表">手动单匹配后立即完成并允许余额为负;自动单可在预约时间前人工匹配,逾期未处理则由系统自动匹配。</n-card></div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="(row)=>row.id" :action-column="actionColumn" :scroll-x="scrollX" :resize-height-offset="-10000">
|
||||
@@ -123,7 +123,7 @@ function clearProductFilter(){Object.assign(productFilter,{name:'',priceMin:null
|
||||
function selectProduct(row){selectedProduct.value=row;matchForm.productId=row.id}
|
||||
async function confirmMatch(){
|
||||
if(!matchForm.productId){message.error('请先选择商品');return}
|
||||
matchSubmitting.value=true;try{await TradeOrderMatch(matchForm);message.success('订单匹配成功');showMatch.value=false;reloadTable()}finally{matchSubmitting.value=false}
|
||||
matchSubmitting.value=true;try{await TradeOrderMatch(matchForm);message.success('订单已匹配并完成');showMatch.value=false;reloadTable()}finally{matchSubmitting.value=false}
|
||||
}
|
||||
async function view(row){detail.value=await TradeOrderView({id:row.id});showDetail.value=true}
|
||||
function openAmount(row){Object.assign(amountMember,{username:row.username,availableAmount:row.availableAmount});Object.assign(amountForm,{id:row.id,amount:Number(row.totalAmount||0)/100,quantity:row.quantity||1});showAmount.value=true}
|
||||
|
||||
@@ -22,6 +22,13 @@ const yuan = (value) => `¥${((value || 0) / 100).toFixed(2)}`;
|
||||
export const columns = [
|
||||
{ title: '订单号', key: 'rechargeNo', width: 210 },
|
||||
{ title: '真实姓名', key: 'realNameMask', width: 110 },
|
||||
{
|
||||
title: '备注',
|
||||
key: 'reviewReason',
|
||||
width: 180,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.reviewReason || row.remark || dict.getLabel('rechargeStatus', row.rechargeStatus) || '-',
|
||||
},
|
||||
{ title: '手机号', key: 'mobile', width: 130 },
|
||||
{ title: '用户余额', key: 'availableAmount', width: 120, render: (row) => yuan(row.availableAmount) },
|
||||
{ title: '交易金额', key: 'requestAmount', width: 120, render: (row) => yuan(row.requestAmount) },
|
||||
@@ -35,13 +42,6 @@ export const columns = [
|
||||
{ title: '支付方式', key: 'channelName', width: 120 },
|
||||
{ title: '类型', key: 'rechargeType', width: 100, render: (row) => tag('rechargeType', row.rechargeType) },
|
||||
{ title: '处理时间', key: 'reviewedAt', width: 180 },
|
||||
{
|
||||
title: '备注',
|
||||
key: 'reviewReason',
|
||||
width: 180,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.reviewReason || row.remark || dict.getLabel('rechargeStatus', row.rechargeStatus) || '-',
|
||||
},
|
||||
];
|
||||
|
||||
export const loadOptions = () =>
|
||||
|
||||
Reference in New Issue
Block a user