From 2b3d855bccfa800fc21b42ece9b7fe60d34f6afb Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 16 Oct 2026 10:14:41 +0800 Subject: [PATCH] fix: resolve admin acceptance defects --- .../Olons功能测试执行报告-2026-10-16.md | 322 ++++++++++++++++++ .../internal/crons/dashboard_daily_summary.go | 34 ++ .../internal/app_member_payment_account.go | 104 +++--- server/internal/dao/internal/task.go | 2 + server/internal/logic/sys/app_member.go | 72 +++- server/internal/logic/sys/dashboard.go | 103 ++++++ server/internal/logic/sys/portal.go | 3 +- server/internal/logic/sys/recharge_order.go | 4 +- server/internal/logic/sys/task.go | 21 +- server/internal/logic/sys/task_hall.go | 2 +- server/internal/logic/sys/task_submission.go | 34 +- server/internal/logic/sys/trade_order.go | 28 +- .../model/do/app_member_payment_account.go | 54 +-- server/internal/model/do/task.go | 1 + .../entity/app_member_payment_account.go | 52 +-- server/internal/model/entity/task.go | 1 + .../internal/model/input/sysin/app_member.go | 60 ++-- server/internal/model/input/sysin/portal.go | 20 +- .../model/input/sysin/product_task.go | 16 +- server/internal/model/input/sysin/trade.go | 45 ++- server/internal/service/sys.go | 1 + .../resource/public/images/olons-banner.svg | 7 + .../resource/public/images/olons-product.svg | 7 + .../20261016_001_task_product_relation.sql | 15 + .../20261016_002_dashboard_summary_cron.sql | 6 + .../20261016_003_site_config_defaults.sql | 17 + .../20261016_004_portal_required_images.sql | 8 + web/src/views/app-member/list/index.vue | 70 ++-- web/src/views/app-member/task-hall/index.vue | 29 +- web/src/views/app-member/task-hall/model.ts | 15 +- web/src/views/app-member/url-pool/index.vue | 5 + web/src/views/portal/manual-product/index.vue | 48 ++- web/src/views/portal/site-config/index.vue | 2 +- .../views/product-task/submission/index.vue | 4 + .../views/product-task/submission/model.ts | 1 + web/src/views/product-task/task/edit.vue | 25 +- web/src/views/product-task/task/model.ts | 28 +- web/src/views/trade/order/index.vue | 16 +- web/src/views/trade/order/model.ts | 1 + 39 files changed, 1079 insertions(+), 204 deletions(-) create mode 100644 docs/admin_reference_prd/Olons功能测试执行报告-2026-10-16.md create mode 100644 server/internal/crons/dashboard_daily_summary.go create mode 100644 server/resource/public/images/olons-banner.svg create mode 100644 server/resource/public/images/olons-product.svg create mode 100644 server/storage/data/migrations/20261016_001_task_product_relation.sql create mode 100644 server/storage/data/migrations/20261016_002_dashboard_summary_cron.sql create mode 100644 server/storage/data/migrations/20261016_003_site_config_defaults.sql create mode 100644 server/storage/data/migrations/20261016_004_portal_required_images.sql diff --git a/docs/admin_reference_prd/Olons功能测试执行报告-2026-10-16.md b/docs/admin_reference_prd/Olons功能测试执行报告-2026-10-16.md new file mode 100644 index 0000000..851daa6 --- /dev/null +++ b/docs/admin_reference_prd/Olons功能测试执行报告-2026-10-16.md @@ -0,0 +1,322 @@ +# Olons 功能测试执行报告(2026-10-16) + +## 1. 执行说明 + +- 测试依据:`Olons全功能测试用例.md`(用户补充后的 548 条版本) +- 执行重点:优先从页面真实操作验证,其次执行构建与后端自动化检查 +- C 端环境:`https://olns-web.118im.com/admin/` +- 管理后台环境:`https://olons-admin.118im.com/admin` +- 执行策略:第一轮仅做不破坏现有业务数据的页面检查;2026-10-16 用户随后授权使用专用测试数据执行新增、删除、审核、资金及配置变更测试 +- 状态含义:`通过`、`部分通过`、`失败`、`阻塞`、`未执行` + +## 2. 第一轮结论 + +第一轮已完成 C 端主要页面的真实加载、数据回显及主要入口检查,并执行了两套前端生产构建和后台 Go 测试。当前发现 1 个明确的页面功能缺陷、1 个测试工程问题;管理后台逐按钮测试因执行机器锁屏暂时中断,锁屏问题不计入产品缺陷。 + +| 类别 | 结果 | +|---|---| +| C 端生产构建 | 通过 | +| 管理后台生产构建 | 通过(需绕过 pnpm 的自动安装检查,直接使用现有依赖构建) | +| 后台 Go 全量测试 | 失败:Casbin、TCP、存储器用例依赖运行环境或测试数据,出现 panic | +| C 端页面冒烟 | 首页、领取、记录、商城、储入、取出、我的、个人信息、客服中心均可加载 | +| 明确产品缺陷 | 1 个:已有收款账户未在“绑定信息”页回显 | +| 后台页面逐按钮 | 阻塞:执行机器进入锁屏,待解锁续测 | + +## 3. C 端页面执行记录 + +| 页面/关联用例 | 状态 | 实际结果 | +|---|---|---| +| 首页 `/app/home`;UI-C-HOME-001、UI-C-HOME-003、UI-C-HOME-004 | 部分通过 | 页面正常显示会员信息、余额、领取/储入/取出/商城四个入口、排行、平台介绍、规则说明、公司资质、代理合作及底部导航;本轮未执行会产生业务状态变化的领取动作 | +| 领取 `/app/claim`;UI-C-CLAIM-001、UI-C-CLAIM-005 | 通过 | 页面正常显示总资产、昨日佣金、今日佣金、今日领取数;当前会员存在未完成订单,页面正确提示“存在未完成订单”并提供“查看任务”入口 | +| 订单 `/app/orders`;UI-C-ORDER-001、UI-C-ORDER-002 | 部分通过 | 全部、待处理、已完成标签及订单卡片正常显示;本轮未对订单执行提交或取消 | +| 商城 `/app/mall`;UI-C-MALL-001、UI-C-MALL-002 | 部分通过 | 搜索、推荐、限时区域及商品卡片正常显示;“验收临时商品-商品页已编辑”可见 | +| 储入 `/app/recharge`;UI-C-RECHARGE-001、UI-C-RECHARGE-002 | 部分通过 | 等级说明、微信/支付宝/银行卡/人工审核渠道、金额、姓名及提交入口正常显示;未执行真实提交 | +| 取出 `/app/profile/withdrawal`;UI-C-WITHDRAW-001、UI-C-WITHDRAW-002 | 部分通过 | 可用余额、全部提现、已绑定账户 `icbc 6655******4555` 及申请提现入口正常显示;未执行真实提现 | +| 我的 `/app/profile`;UI-C-PROFILE-001、UI-C-PROFILE-002 | 通过 | 会员 potter、邀请码 100005、余额及个人中心入口正常显示 | +| 个人信息 `/app/profile/info`;UI-C-FORM-001、UI-C-FORM-002 | 通过 | 头像、手机号、取出信息、密码管理、支付密码管理入口正常显示 | +| 绑定信息 `/app/profile/bank-card`;UI-C-FORM-003、UI-C-FORM-010、C-FIN-039 | 失败 | 该会员在取出页已有银行卡,但绑定信息页仍显示空白新增表单,没有回显本人原始账户信息 | +| 客服中心 `/app/support`;UI-C-SUPPORT-001 | 通过 | 客服依依、客服琪琪、客服琳琳均显示;在线/离线状态、工作时间和联系电话正常显示 | + +## 4. 缺陷记录 + +### DEF-C-001 已绑定收款账户未在“绑定信息”页回显 + +| 项目 | 内容 | +|---|---| +| 严重级别 | P1 | +| 关联用例 | UI-C-FORM-003、UI-C-FORM-010、UI-C-FORM-011、C-FIN-039 | +| 前置条件 | 会员已登录,且已有收款账户 | +| 复现步骤 | 1. 打开“我的 → 取出”,确认页面显示 `icbc 6655******4555`;2. 打开“我的 → 个人信息 → 取出信息/绑定信息” | +| 预期结果 | 页面显示本人姓名、身份证号、银行名、完整卡号,以及已上传的微信/支付宝收款码;允许按业务规则编辑或继续新增 | +| 实际结果 | 页面只显示空白的新增绑定表单,已有账户信息未回显 | +| 影响 | 用户无法从个人中心核对已有收款资料,容易重复绑定或误判资料丢失 | +| 建议检查 | 检查页面是否调用 `/v1/payment-accounts/details`;确认接口返回账户列表后是否被表单初始化逻辑覆盖;多账户场景需逐条展示并保持收款码对应关系 | + +补充诊断: + +- 已核对线上 C 端静态资源,当前发布包包含 `/v1/payment-accounts/details`、`已绑定的取出信息` 和 `添加银行卡`,不是前端漏发版。 +- 已只读核对测试库:会员 `potter`(member_id=9、tenant_id=0)确有 1 条未删除的 `icbc` 收款账户,脱敏卡号为 `6655******4555`,密文字段及微信/支付宝附件 ID 均非空。 +- 本地代码的详情接口会解密敏感字段;只要服务端 finance key 未配置、与写入时密钥不一致,或详情接口返回异常,页面就会保留空数组并显示新增表单。下一步应抓取该登录会话下 `/v1/payment-accounts/details` 的真实响应,重点检查 `OLONS_SERVER_FINANCE_MASTER_KEY` 与该账户写入时使用的密钥是否一致。 + +## 5. 构建与自动化检查 + +### 5.1 C 端前端构建 + +- 目录:`olons_server/web` +- 命令:`pnpm run build` +- 结果:通过 +- 构建结果:4186 个模块完成转换,生产包成功生成 + +### 5.2 管理后台前端构建 + +- 目录:`olons_admin/web` +- 常规 `pnpm run build`:被 pnpm 的依赖脚本安全检查阻断,提示 `ERR_PNPM_IGNORED_BUILDS` +- 使用已有依赖直接执行 Vite 与 HotGo postBuild:通过 +- 构建结果:6728 个模块完成转换,`dist/app.config.js` 正常生成 +- 说明:这是本机 pnpm 依赖脚本策略问题,不是业务代码编译失败;CI 应固定 pnpm 版本及 `onlyBuiltDependencies/approve-builds` 配置,避免发布时临时阻断 + +### 5.3 后台 Go 测试 + +- 目录:`olons_admin/server` +- 命令:`go test ./...` +- 结果:失败 +- 失败 1:`internal/library/casbin.TestNew`,错误为 `casbin model file does not exist:manifest/config/casbin.conf` +- 失败 2:`internal/library/network/tcp_test.TestSendMsg`,依赖的 `127.0.0.1:8002` 未启动;连接失败后仍调用空连接的 `Send`,触发空指针 panic +- 失败 3:`internal/library/storager.TestMergePartFile`,测试数据/参数无效后仍继续使用空对象,触发空指针 panic +- 判断:上述均属于测试工程对当前目录、外部服务或本地文件的隐式依赖,同时缺少失败后的安全返回;本轮 `internal/logic/sys` 等核心业务包测试通过 +- 建议:Casbin 测试使用可确定的项目根路径或 `testdata`;TCP 测试自建临时监听服务;存储测试自建临时文件;所有前置失败后立即 `t.Fatal` 或返回,避免二次 panic + +### 5.4 线上可用性与未登录鉴权 + +| 检查项 | 实际结果 | 结论 | +|---|---|---| +| `GET https://olns-web.118im.com/api/health` | HTTP 200 | 通过 | +| 未登录访问 `GET /v1/me` | HTTP 401 | 通过 | +| 未登录访问 `GET /v1/payment-accounts/details` | HTTP 401 | 通过 | +| 管理后台入口 | HTTP 200 | 通过 | + +## 6. 阻塞与待执行项 + +1. 管理后台 URL 地址池、会员登录日志及其他菜单逐按钮验收:执行机器锁屏,待解锁后续测。 +2. 新增、编辑、删除、发布/下架、审核、充值、提现、任务提交、客服发消息等写操作:需要专用测试数据并确认允许产生或清理测试记录后执行。 +3. URL 地址池 Excel 合法、重复、错误行导入:需要使用用例中的 XLS-URL 文件执行,会产生数据库写入,暂未提交。 +4. 多账户详情、收款码图片一致性、敏感信息展示权限:需在专用测试会员上执行,避免暴露或修改真实会员资料。 + +## 7. 下一轮计划 + +1. 解锁后从后台菜单真实点击进入 URL 地址池,依次验证查询、重置、新增/编辑弹窗、详情、启用/失效、复制链接、Excel 导入弹窗及无删除按钮。 +2. 验证会员登录日志查询、分页、详情和导出入口。 +3. 按菜单顺序完成交易、商品、任务、组织、权限、系统应用、系统设置页面的所有可逆操作。 +4. 建立专用测试会员、商品、订单和 URL 地址数据,再执行会改变业务状态的 P0/P1 用例。 +5. 修复 DEF-C-001 后,执行绑定信息与提现账户关联回归。 + +## 8. 第二轮后台页面执行记录(解锁后) + +### 8.1 URL 地址池与会员登录日志 + +| 页面/关联用例 | 状态 | 实际结果 | +|---|---|---| +| URL 地址池;UI-A-URL-001、002 | 通过 | 汇总卡、筛选、展开/收起、查询、重置、分页和空状态正常;重置可清空条件 | +| URL 地址池新增;UI-A-URL-003 | 部分通过 | 新增弹窗包含地址名称、注册入口 URL、状态、备注及保存/取消;未提交写库 | +| URL 地址池详情/启停/复制;UI-A-URL-004、005 | 阻塞 | 当前列表 0 条,无可操作数据 | +| URL Excel 导入;UI-A-URL-006 | 部分失败 | 格式说明、关闭入口、`.xlsx/.xlsm`、5000 条限制、重复跳过和错误行说明完整,但弹窗没有“取消”按钮 | +| 会员登录日志;UI-A-MLOGIN 系列 | 部分通过 | 会员、方式、状态、域名、IP、时间筛选齐全;查询、重置、展开/收起和导出均可用;当前列表 0 条,详情无法验证 | + +### 8.2 交易管理 + +| 页面/关联用例 | 状态 | 实际结果 | +|---|---|---| +| 订单列表;UI-A-ORDER-001 | 部分失败 | 单号、用户名、手机号、状态、时间筛选可用,但缺少用例要求的“派单方式”筛选 | +| 订单详情;UI-A-ORDER-002、A-TRADE-002 | 失败 | 仅显示订单号、会员、商品/任务、等级、金额和规则快照;缺少时间线、资金流水等追溯信息 | +| 匹配订单;UI-A-ORDER-003、A-TRADE-003 | 通过 | 弹窗可按商品名和价格筛选;发布商品可见,保时捷跑车、新手体验商品等均能查询;清理筛选和取消正常 | +| 充值列表;UI-A-RC-001 | 部分失败 | 汇总卡、列表、通过/驳回入口正常;多条真实记录的“真实姓名”列为空,仅在备注中出现登录名 | +| 充值通过/驳回;UI-A-RC-002、003 | 部分通过 | 通过弹窗包含实付金额、外部支付单号和审核说明;驳回弹窗包含原因与说明;取消无副作用,未执行最终审核 | +| 提现列表;UI-A-WD-001 | 部分通过 | 金额、手续费、实到、状态和银行卡脱敏正常;当前 potter 记录的支付宝/微信收款码均显示“-”,需结合账户附件继续核对 | +| 提现通过/驳回;UI-A-WD-002 | 通过 | 合法状态显示按钮,二次确认文案正确,取消无副作用;未执行最终审核 | +| 客服管理;UI-A-CS-001、002 | 部分通过 | 查询、新增/编辑表单、IM 账号和备用联系方式完整;启停操作即时生效,测试后已恢复原状态;未执行删除 | + +### 8.3 商品、任务和权限 + +| 页面/关联用例 | 状态 | 实际结果 | +|---|---|---| +| 商品列表;UI-A-PROD-001、006 | 通过 | 名称/分类筛选、添加、批量删除入口完整;编辑、发布/下架、推荐/不推荐、删除均可见且操作列未截断 | +| 商品表单;UI-A-PROD-002 | 部分通过 | 名称、分类、店铺、价格、图片、详情字段完整;未保存写库 | +| 任务列表;UI-A-TASK-001 | 失败 | 列表、状态操作和任务基本字段正常,但新增任务弹窗没有“商品”选择字段,与用例和任务关联设计不一致 | +| 会员任务审核;UI-A-SUB-001、004 | 失败 | 列表“任务标题”全部为空;详情中的“任务”也为空,并且没有订单信息,不能完成会员—订单—任务追溯 | +| 角色菜单权限;UI-A-ROLE-002、003 | 部分通过 | 弹窗明确限制为父角色权限子集;主页、组织管理、系统应用、系统设置等根节点均可勾选;勾选组织管理后“后台用户”自动选中,父子级联正常;未提交保存 | +| 系统应用菜单;UI-A-APP-001 | 通过 | 日志管理、附件管理、个人中心入口均存在,个人中心已恢复可见 | + +## 9. 第二轮新增缺陷 + +### DEF-A-001 URL 地址池 Excel 导入弹窗缺少取消按钮 + +- 严重级别:P2 +- 关联用例:UI-A-URL-006 +- 实际结果:弹窗仅提供右上角关闭,没有底部取消按钮。 +- 影响:与后台其他弹窗交互不一致,触屏或窄屏下关闭入口不够明显。 + +### DEF-A-002 订单详情缺少完整业务追溯信息 + +- 严重级别:P1 +- 关联用例:UI-A-ORDER-002、A-TRADE-002、A-TRADE-030 +- 实际结果:详情只展示基础字段和 JSON 规则快照,没有订单状态时间线、付款/冻结/佣金流水、审核记录。 +- 影响:客服和财务无法从单一页面核对订单状态与资金变化。 +- 代码定位:`TradeOrderViewModel` 只在列表模型上补充 `taskId/productId/memberLevelId/ruleSnapshot`,`trade_order.go` 的 `View` 也只组装这些字段;前端详情弹窗没有请求或渲染资金流水、审批动作和状态时间线。 + +### DEF-A-003 多个后台交易页面直接显示完整手机号 + +> 处理结论(2026-10-16):产品方确认会员列表、详情及相关业务页面允许显示完整手机号,本项改判为“已接受设计”,不再作为缺陷,不实施手机号脱敏改造。 + +- 严重级别:P1 +- 涉及页面:会员列表/详情、订单列表/详情、充值列表、会员任务审核列表/详情 +- 实际结果:显示 `13311111111`、`15122421111` 等完整手机号。 +- 预期结果:默认脱敏;只有具备单独敏感信息权限的人员才能查看明文,且需留痕。 +- 代码定位:订单和任务审核查询直接选择 `hg_app_member.mobile`,前端表格直接绑定 `mobile`;当前链路没有脱敏 DTO,也没有敏感字段权限判断。 +- 补测结果:会员详情明确提供“授权查看明文”按钮,但未授权前手机号已经完整显示,说明当前授权入口只停留在 UI,未形成真正的数据隔离。 + +### DEF-A-004 订单列表缺少派单方式筛选 + +- 严重级别:P1 +- 关联用例:UI-A-ORDER-001、A-TRADE-001 +- 实际结果:高级筛选只有用户名、手机号、订单状态和下单时间,没有自动/手动派单方式。 +- 代码定位:前端 `trade/order/model.ts` 的 `schemas` 和后端 `TradeOrderListInp` 均没有派单方式查询字段,属于前后端共同缺项。 + +### DEF-A-005 新增任务弹窗缺少商品字段 + +- 严重级别:P1 +- 关联用例:UI-A-TASK-001 +- 实际结果:弹窗包含任务大厅、等级、佣金、数量、周期、封面和详情,但没有关联商品选择。 +- 影响:无法按设计建立任务与商品的明确关联。 +- 代码定位:`hg_task` 对应实体和 `TaskEditInp` 均没有 `product_id`,不是单纯前端漏控件,需要先确认任务是否应直接关联商品,再同步迁移、实体、接口和页面。 + +### DEF-A-006 会员任务记录缺少任务标题和订单关联 + +- 严重级别:P1 +- 关联用例:UI-A-SUB-001、UI-A-SUB-004 +- 实际结果:列表的任务标题为空;详情的任务字段为空;详情未显示订单。 +- 影响:审核记录无法追溯到具体任务和订单,已结算记录也难以审计。 +- 数据与代码定位:只读核对最新记录,`task_id` 均为 `0`,但 `trade_order_id` 有值;`task_submission.go` 只按 `s.task_id = t.id` 关联任务,没有通过交易订单/商品兜底,因此标题必然为空。审核逻辑还会再次按 `task_id` 查任务,待审核记录可能直接报“关联任务不存在”。 + +### DEF-A-007 充值列表真实姓名缺失 + +- 严重级别:P1 +- 关联用例:UI-A-RC-001、A-TRADE-012 +- 实际结果:多条真实充值记录的“真实姓名”列为空,备注中仅显示 `姓名:potter` 等登录名。 +- 影响:人工审核无法直接核对付款人真实姓名,容易造成误审。 + +## 10. 第三轮后台页面执行记录 + +### 10.1 主页与门户配置 + +| 页面/关联用例 | 状态 | 实际结果 | +|---|---|---| +| 商城统计首页;UI-A-HOME-001、002 | 失败 | 今日默认区间无汇总;切换近 30 日后能显示指标和微信/支付宝/银行卡渠道数据,但数据截止时间为 `2026-10-11 13:46:46`,页面提示延迟约 107 小时,汇总任务明显停滞 | +| 网站设置;UI-A-SITE-001、002 | 部分失败 | 表单字段和保存入口完整,但线上已保存配置中隐私网址为空,大量标记“必填”的交易阈值、次数、佣金和自动派单值均为 0;字段“未订单的支付等待时长”文案不通顺,疑为“未支付订单” | +| 文本管理;UI-A-CONTENT-001~004 | 通过 | 初始化数据共 12 条,与原型数量一致;列表已隐藏内容标识;新增、编辑、版本、预览、上下线和删除入口完整;本轮仅打开新增、版本和预览并取消,未写库 | +| Banner 设置;UI-A-BANNER-001、002 | 失败 | 6 条记录状态与操作入口可见,但列表图片列全部为空;打开编辑后,必填 Banner 图片也未回显,现有演示记录不满足接口对图片必填的约束 | +| 手工商城;UI-A-MANUAL-001~003 | 失败 | 4 条记录可查询、编辑和删除,但图片列全部为空;编辑弹窗的必填展示图片未回显;页面没有发布/下架入口,也不显示发布状态 | + +### 10.2 会员管理补测 + +| 页面/关联用例 | 状态 | 实际结果 | +|---|---|---| +| 会员列表;UI-A-MEMBER-001、002 | 部分失败 | 17 条会员可查询,类别与派单模式显示正确,操作列横向滚动条可用;但页面声明“会员资料默认脱敏展示”,列表和详情仍直接显示完整手机号 | +| 会员详情敏感信息;UI-A-MEMBER-003 | 失败 | 详情存在“授权查看明文”按钮,但尚未授权时手机号已经是明文,授权入口没有起到敏感数据隔离作用 | +| 会员银行卡信息;UI-A-MEMBER-004、C-FIN-039 | 失败 | 查询 `potter` 后打开银行卡信息,只有银行名 `icbc` 回显;开户人、开户电话、卡号、支付宝/微信收款码全部为空,与提现页已存在的账户及数据库记录不一致 | +| 会员等级;UI-A-LEVEL-001~003 | 部分通过 | 7 个初始化等级、价格、赠送金额、接单/提现次数、佣金比例、会员数和编辑/启停/删除入口正常;图标列均为空;删除有二次确认并提示仅无会员且未被任务大厅引用时可删,本轮取消未提交 | +| 任务大厅;UI-A-HALL-001~003 | 失败 | 6 条初始化记录可编辑/删除,但图标列为空;后端已有发布状态和状态接口,页面却不显示状态,也没有发布/下架操作 | +| 后台用户;UI-A-ADMIN-001、002 | 部分通过 | 菜单真实路径为 `/org/user`,新增表单字段完整;空表单提交能阻止保存并提示填写用户名/密码;当前角色范围列表为 0 条,编辑、状态和删除无法继续验证 | +| 管理后台登录日志;UI-A-LOG-001~003 | 通过 | 共 30 条记录,查询、重置、展开、导出、详情、单条/批量删除入口完整;本轮未删除日志 | + +### 10.3 本轮操作安全说明 + +- 文本新增、Banner 编辑、手工商城编辑仅打开弹窗查看字段后取消,没有保存。 +- 首页只切换统计周期,没有触发交易、审核或资金操作。 +- 角色权限上一轮的勾选联动验证未提交;客服状态验证后已恢复原状态。 + +## 11. 第三轮新增缺陷 + +### DEF-A-008 首页汇总任务停滞,统计数据延迟 107 小时 + +- 严重级别:P1 +- 关联用例:UI-A-HOME-001、UI-A-HOME-002 +- 实际结果:近 30 日指标可以加载,但数据截至 `2026-10-11 13:46:46`,在 `2026-10-16` 验收时页面提示延迟约 107 小时。 +- 影响:首页经营指标无法反映当前订单、充值、提现和余额情况,不能作为运营决策依据。 +- 代码定位:仓库只有 `hg_dashboard_daily_stat` 的读取逻辑和演示数据 seed,未检索到生产汇总表的写入/刷新任务;需确认是否由仓库外 ETL 承担,如无则缺少汇总任务实现。 + +### DEF-A-009 网站设置存在不完整的线上配置 + +- 严重级别:P1 +- 关联用例:UI-A-SITE-001、UI-A-SITE-002 +- 实际结果:隐私网址为空;多个必填业务参数为 0;网站名称仍为“演示站点-3”。 +- 影响:隐私协议入口、订单等待、交易限制、佣金和自动派单等 C 端行为可能失效或采用异常零值。 +- 建议:发布前建立配置完整性校验和默认值迁移;加载历史无效配置时给出显著告警,不允许无提示继续运行。 + +### DEF-A-010 Banner 与手工商城初始化记录缺少必填图片 + +- 严重级别:P1 +- 关联用例:UI-A-BANNER-001、UI-A-BANNER-002、UI-A-MANUAL-001、UI-A-MANUAL-002 +- 实际结果:两个列表的图片列均为空,编辑弹窗也没有回显图片;后端过滤逻辑却要求 Banner `imageUrl`、手工商品 `coverUrl` 必填。 +- 影响:C 端可能出现空白横幅/商品图;旧记录打开后可能无法在不重新上传图片的情况下保存。 +- 建议:修正初始化数据并补数据校验脚本;列表为空图时显示明确占位和“图片缺失”告警。 + +### DEF-A-011 手工商城发布状态功能未接入页面 + +- 严重级别:P1 +- 关联用例:UI-A-MANUAL-001、UI-A-MANUAL-003 +- 实际结果:后端已有 `ManualProductStatus` 接口和发布状态字段,但列表不显示状态、没有发布/下架按钮;编辑接口还会把商品直接改为已发布。 +- 影响:运营无法在手工商城页面控制上下架,编辑草稿或已下架商品会绕过明确确认直接发布。 +- 建议:列表增加状态列和发布/下架操作;编辑仅保存内容,不隐式改变发布状态。 + +### DEF-A-012 会员银行卡编辑页未回显已有敏感账户资料 + +- 严重级别:P0 +- 关联用例:UI-A-MEMBER-004、UI-C-FORM-003、C-FIN-039、DEF-C-001 +- 复现步骤:会员列表查询 `potter`,点击“银行卡信息”。 +- 实际结果:仅开户银行 `icbc` 回显;开户人、开户电话、银行卡号、支付宝与微信收款码均为空。 +- 预期结果:经过敏感信息授权后完整回显;未授权时至少显示脱敏值和已有附件占位,且禁止空值覆盖密文。 +- 影响:后台人员无法核对已有账户,直接保存存在把已有敏感字段和附件清空的高风险;同时解释了 C 端绑定信息页为何显示空表单。 +- 建议:优先检查 finance key 与历史数据加密密钥是否一致、详情接口的解密错误是否被吞掉;编辑接口必须采用“未传不更新”语义,附件 ID 不得因前端未回显而被置空。 + +### DEF-A-013 任务大厅发布状态功能未接入页面 + +- 严重级别:P1 +- 关联用例:UI-A-HALL-001、UI-A-HALL-003 +- 实际结果:列表不显示发布状态,仅有编辑和删除;草稿、待审、已发布、已下线、已归档等状态只能从演示名称猜测。 +- 代码定位:后端 `task_hall.go` 已实现 `Status`,前端 API 也定义了 `/taskHall/status`,但 `app-member/task-hall/index.vue` 未导入或调用 `Status`,操作列只有编辑/删除;新增大厅还会直接设为已发布。 +- 影响:运营无法进行清晰的发布/下架流程,新增内容绕过发布确认直接对 C 端生效。 +- 建议:增加状态列和发布/下架按钮;新增与编辑只保存内容,发布必须走单独状态接口并保留审计记录。 + +## 12. 当前阶段结论与修复优先级 + +- 当前累计登记 14 项,其中 13 个有效缺陷(P0 1 个、P1 11 个、P2 1 个)和 1 项已接受设计;C 端收款账户回显缺陷与 P0 后台问题属于同一根因链,合并修复与回归。 +- 第一优先级:DEF-A-012 / DEF-C-001,先恢复历史收款账户的安全解密与回显,并防止空表单覆盖密文和附件。 +- 第二优先级:DEF-A-006、DEF-A-008,修复任务审核关联和首页汇总任务,否则审核与运营数据均不可信。 +- 发布前必须处理:网站配置完整性、Banner/商品图片缺失、手工商城与任务大厅发布状态入口。 + +## 13. 缺陷修复实施记录 + +| 编号 | 处理结果 | 修复内容 | +|---|---|---| +| DEF-C-001 / DEF-A-012 | 已修复,待发布回归 | C 端详情接口在历史密钥不可用时回退脱敏字段,不再因单条解密失败让整页为空;后台回显姓名、身份证、手机、账号、数字人民币和双收款码脱敏信息;敏感字段和附件采用“未传不更新”语义,并保留授权查看入口 | +| DEF-A-001 | 已修复 | URL 地址 Excel 导入弹窗增加底部取消按钮 | +| DEF-A-002 | 已修复 | 订单详情增加状态、创建/更新时间、状态时间线、资金流水和审批动作 | +| DEF-A-003 | 已接受设计 | 产品方确认允许展示完整手机号,不修改 | +| DEF-A-004 | 已修复 | 订单列表新增自动/手动派单模式筛选,后端同步按规则快照查询 | +| DEF-A-005 | 已修复 | `hg_task` 增加 `product_id` 迁移并重新生成 DAO/实体;任务新增/编辑增加商品选择、校验和列表回显 | +| DEF-A-006 | 已修复 | 审核列表通过交易订单兜底任务标题并显示订单号/商品;`task_id=0` 时按订单归属校验后允许审核,不再误报任务不存在 | +| DEF-A-007 | 已修复 | 充值真实姓名优先取实名资料,缺失时回退默认银行卡开户人脱敏值 | +| DEF-A-008 | 已修复 | 增加 `dashboard_daily_summary` 汇总任务并注册到 HotGo 定时任务管理;默认每 10 分钟重算今天和昨天,可在后台启停、改表达式和手动执行 | +| DEF-A-009 | 已修复 | 后端强制校验隐私地址、交易余额、等待时长、次数、最低充值和自动派单参数;迁移补齐历史零值并修正文案 | +| DEF-A-010 | 已修复 | 增加随服务部署的 Banner/商品占位资源,迁移只补齐历史空图片记录 | +| DEF-A-011 | 已修复 | 手工商城显示发布状态并增加发布/下架;新增默认草稿,编辑不再隐式发布 | +| DEF-A-013 | 已修复 | 任务大厅显示发布状态并增加发布/下线;新增默认草稿 | + +### 13.1 修复后自动化验证 + +- 管理后台:`go test ./internal/logic/sys ./internal/crons ./internal/model/input/sysin` 通过。 +- 管理后台前端:`npm run build` 生产构建通过,6728 个模块完成转换。 +- C 端:`go test ./internal/finance ./internal/store` 通过;新增“历史密钥不可用时回退脱敏数据”回归用例。 +- C 端前端:`npm run build` 通过,4186 个模块完成转换。 +- 新增迁移已在本地 MySQL 8.4 验证;定时任务记录成功初始化,Banner 和商品空图片数量均变为 0。 +- 页面回归暂未完成:执行机器再次进入锁屏状态,无法继续通过浏览器点击;服务与前端均已在本地启动,解锁后可直接续测。 diff --git a/server/internal/crons/dashboard_daily_summary.go b/server/internal/crons/dashboard_daily_summary.go new file mode 100644 index 0000000..b3ae955 --- /dev/null +++ b/server/internal/crons/dashboard_daily_summary.go @@ -0,0 +1,34 @@ +package crons + +import ( + "context" + "strconv" + + "hotgo/internal/library/cron" + "hotgo/internal/service" +) + +func init() { + cron.Register(DashboardDailySummary) +} + +var DashboardDailySummary = &cDashboardDailySummary{name: "dashboard_daily_summary"} + +type cDashboardDailySummary struct{ name string } + +func (c *cDashboardDailySummary) GetName() string { return c.name } + +func (c *cDashboardDailySummary) Execute(ctx context.Context, parser *cron.Parser) error { + days := 2 + if len(parser.Args) > 0 { + if value, err := strconv.Atoi(parser.Args[0]); err == nil { + days = value + } + } + if err := service.SysDashboard().RefreshDaily(ctx, days); err != nil { + parser.Logger.Warningf(ctx, "dashboard daily summary failed days:%d err:%+v", days, err) + return err + } + parser.Logger.Infof(ctx, "dashboard daily summary completed days:%d", days) + return nil +} diff --git a/server/internal/dao/internal/app_member_payment_account.go b/server/internal/dao/internal/app_member_payment_account.go index 6f3f3ac..6d36ece 100644 --- a/server/internal/dao/internal/app_member_payment_account.go +++ b/server/internal/dao/internal/app_member_payment_account.go @@ -21,54 +21,70 @@ type AppMemberPaymentAccountDao struct { // AppMemberPaymentAccountColumns defines and stores column names for the table hg_app_member_payment_account. type AppMemberPaymentAccountColumns struct { - Id string // 主键 - TenantId string // 租户ID - MemberId string // 会员ID - AccountType string // 账户类型 - BankCode string // 银行编码 - BankName string // 银行名称 - HolderCipher string // 持有人密文 - HolderMask string // 持有人脱敏值 - MobileCipher string // 预留手机号密文 - MobileMask string // 预留手机号脱敏值 - AccountNoCipher string // 账号密文 - AccountNoHash string // 账号检索哈希 - AccountNoMask string // 账号脱敏值 - QrAttachmentId string // 收款码附件ID - VerifyStatus string // 核验状态 - IsDefault string // 默认账户 - CreatedBy string // 创建者 - UpdatedBy string // 更新者 - DeletedBy string // 删除者 - CreatedAt string // 创建时间 - UpdatedAt string // 更新时间 - DeletedAt string // 删除时间 + Id string // 主键 + TenantId string // 租户ID + MemberId string // 会员ID + AccountType string // 账户类型 + BankCode string // 银行编码 + BankName string // 银行名称 + HolderCipher string // 持有人密文 + HolderMask string // 持有人脱敏值 + IdentityNoCipher string // 身份证号密文 + IdentityNoHash string // 身份证号检索哈希 + IdentityNoMask string // 身份证号脱敏值 + MobileCipher string // 预留手机号密文 + MobileMask string // 预留手机号脱敏值 + AccountNoCipher string // 账号密文 + AccountNoHash string // 账号检索哈希 + AccountNoMask string // 账号脱敏值 + DigitalRmbCodeCipher string // 数字人民币编码密文 + DigitalRmbCodeHash string // 数字人民币编码检索哈希 + DigitalRmbCodeMask string // 数字人民币编码脱敏值 + QrAttachmentId string // 收款码附件ID + WechatQrAttachmentId string // 微信二维码附件ID + AlipayQrAttachmentId string // 支付宝二维码附件ID + VerifyStatus string // 核验状态 + IsDefault string // 默认账户 + CreatedBy string // 创建者 + UpdatedBy string // 更新者 + DeletedBy string // 删除者 + CreatedAt string // 创建时间 + UpdatedAt string // 更新时间 + DeletedAt string // 删除时间 } // appMemberPaymentAccountColumns holds the columns for the table hg_app_member_payment_account. var appMemberPaymentAccountColumns = AppMemberPaymentAccountColumns{ - Id: "id", - TenantId: "tenant_id", - MemberId: "member_id", - AccountType: "account_type", - BankCode: "bank_code", - BankName: "bank_name", - HolderCipher: "holder_cipher", - HolderMask: "holder_mask", - MobileCipher: "mobile_cipher", - MobileMask: "mobile_mask", - AccountNoCipher: "account_no_cipher", - AccountNoHash: "account_no_hash", - AccountNoMask: "account_no_mask", - QrAttachmentId: "qr_attachment_id", - VerifyStatus: "verify_status", - IsDefault: "is_default", - CreatedBy: "created_by", - UpdatedBy: "updated_by", - DeletedBy: "deleted_by", - CreatedAt: "created_at", - UpdatedAt: "updated_at", - DeletedAt: "deleted_at", + Id: "id", + TenantId: "tenant_id", + MemberId: "member_id", + AccountType: "account_type", + BankCode: "bank_code", + BankName: "bank_name", + HolderCipher: "holder_cipher", + HolderMask: "holder_mask", + IdentityNoCipher: "identity_no_cipher", + IdentityNoHash: "identity_no_hash", + IdentityNoMask: "identity_no_mask", + MobileCipher: "mobile_cipher", + MobileMask: "mobile_mask", + AccountNoCipher: "account_no_cipher", + AccountNoHash: "account_no_hash", + AccountNoMask: "account_no_mask", + DigitalRmbCodeCipher: "digital_rmb_code_cipher", + DigitalRmbCodeHash: "digital_rmb_code_hash", + DigitalRmbCodeMask: "digital_rmb_code_mask", + QrAttachmentId: "qr_attachment_id", + WechatQrAttachmentId: "wechat_qr_attachment_id", + AlipayQrAttachmentId: "alipay_qr_attachment_id", + VerifyStatus: "verify_status", + IsDefault: "is_default", + CreatedBy: "created_by", + UpdatedBy: "updated_by", + DeletedBy: "deleted_by", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + DeletedAt: "deleted_at", } // NewAppMemberPaymentAccountDao creates and returns a new DAO object for table data access. diff --git a/server/internal/dao/internal/task.go b/server/internal/dao/internal/task.go index e4611ad..30d9b99 100644 --- a/server/internal/dao/internal/task.go +++ b/server/internal/dao/internal/task.go @@ -25,6 +25,7 @@ type TaskColumns struct { TenantId string // 租户ID TaskNo string // 任务编号 HallId string // 任务大厅ID + ProductId string // 关联商品ID Title string // 标题 Description string // 描述 DetailHtml string // 清洗后的任务详情 @@ -54,6 +55,7 @@ var taskColumns = TaskColumns{ TenantId: "tenant_id", TaskNo: "task_no", HallId: "hall_id", + ProductId: "product_id", Title: "title", Description: "description", DetailHtml: "detail_html", diff --git a/server/internal/logic/sys/app_member.go b/server/internal/logic/sys/app_member.go index 10ea9c3..a8169ad 100644 --- a/server/internal/logic/sys/app_member.go +++ b/server/internal/logic/sys/app_member.go @@ -321,7 +321,12 @@ func (s *sSysAppMember) Category(ctx context.Context, in *sysin.AppMemberCategor func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaymentListInp) (list []*sysin.AppMemberPaymentModel, err error) { c := dao.AppMemberPaymentAccount.Columns() - err = s.paymentModel(ctx).Fields(c.Id, c.MemberId, c.AccountType, c.BankCode, c.BankName, c.HolderMask, c.MobileMask, c.AccountNoMask, c.QrAttachmentId, c.VerifyStatus, c.IsDefault, c.CreatedAt, c.UpdatedAt).Where(c.MemberId, in.MemberId).OrderDesc(c.IsDefault).OrderDesc(c.Id).Scan(&list) + ac := dao.SysAttachment.Columns() + err = s.paymentModel(ctx).As("p"). + LeftJoin(dao.SysAttachment.Table()+" wq", col("wq", ac.Id)+"="+col("p", c.WechatQrAttachmentId)). + LeftJoin(dao.SysAttachment.Table()+" aq", col("aq", ac.Id)+"="+col("p", c.AlipayQrAttachmentId)). + Fields(col("p", c.Id), col("p", c.MemberId), col("p", c.AccountType), col("p", c.BankCode), col("p", c.BankName), col("p", c.HolderMask), col("p", c.IdentityNoMask), col("p", c.MobileMask), col("p", c.AccountNoMask), col("p", c.DigitalRmbCodeMask), col("p", c.QrAttachmentId), col("p", c.WechatQrAttachmentId), col("p", c.AlipayQrAttachmentId), fmt.Sprintf("COALESCE(%s,'') wechat_qr_url", col("wq", ac.FileUrl)), fmt.Sprintf("COALESCE(%s,'') alipay_qr_url", col("aq", ac.FileUrl)), col("p", c.VerifyStatus), col("p", c.IsDefault), col("p", c.CreatedAt), col("p", c.UpdatedAt)). + Where(col("p", c.MemberId), in.MemberId).OrderDesc(col("p", c.IsDefault)).OrderDesc(col("p", c.Id)).Scan(&list) if err != nil { err = gerror.Wrap(err, "获取收款账户失败") } @@ -330,7 +335,23 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaymentEditInp) error { c := dao.AppMemberPaymentAccount.Columns() - data := g.Map{c.MemberId: in.MemberId, c.AccountType: in.AccountType, c.BankCode: in.BankCode, c.BankName: in.BankName, c.QrAttachmentId: in.QrAttachmentId, c.VerifyStatus: in.VerifyStatus, c.IsDefault: in.IsDefault, c.UpdatedBy: contexts.GetUserId(ctx)} + attachmentColumns := dao.SysAttachment.Columns() + if in.WechatQrAttachmentId == 0 && in.WechatQrUrl != "" { + _ = dao.SysAttachment.Ctx(ctx).Fields(attachmentColumns.Id).Where(attachmentColumns.FileUrl, in.WechatQrUrl).OrderDesc(attachmentColumns.Id).Limit(1).Scan(&in.WechatQrAttachmentId) + } + if in.AlipayQrAttachmentId == 0 && in.AlipayQrUrl != "" { + _ = dao.SysAttachment.Ctx(ctx).Fields(attachmentColumns.Id).Where(attachmentColumns.FileUrl, in.AlipayQrUrl).OrderDesc(attachmentColumns.Id).Limit(1).Scan(&in.AlipayQrAttachmentId) + } + data := g.Map{c.MemberId: in.MemberId, c.AccountType: in.AccountType, c.BankCode: in.BankCode, c.BankName: in.BankName, c.VerifyStatus: in.VerifyStatus, c.IsDefault: in.IsDefault, c.UpdatedBy: contexts.GetUserId(ctx)} + if in.QrAttachmentId > 0 { + data[c.QrAttachmentId] = in.QrAttachmentId + } + if in.WechatQrAttachmentId > 0 { + data[c.WechatQrAttachmentId] = in.WechatQrAttachmentId + } + if in.AlipayQrAttachmentId > 0 { + data[c.AlipayQrAttachmentId] = in.AlipayQrAttachmentId + } if in.Holder != "" { v, e := encrypt.EncryptField(ctx, in.Holder) if e != nil { @@ -347,6 +368,19 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym data[c.MobileCipher] = v data[c.MobileMask] = encrypt.MaskMobile(in.Mobile) } + if in.IdentityNo != "" { + v, e := encrypt.EncryptField(ctx, in.IdentityNo) + if e != nil { + return e + } + h, e := encrypt.HashField(ctx, in.IdentityNo) + if e != nil { + return e + } + data[c.IdentityNoCipher] = v + data[c.IdentityNoHash] = h + data[c.IdentityNoMask] = encrypt.MaskAccount(in.IdentityNo) + } if in.AccountNo != "" { v, e := encrypt.EncryptField(ctx, in.AccountNo) if e != nil { @@ -360,6 +394,19 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym data[c.AccountNoHash] = h data[c.AccountNoMask] = encrypt.MaskAccount(in.AccountNo) } + if in.DigitalRMBCode != "" { + v, e := encrypt.EncryptField(ctx, in.DigitalRMBCode) + if e != nil { + return e + } + h, e := encrypt.HashField(ctx, in.DigitalRMBCode) + if e != nil { + return e + } + data[c.DigitalRmbCodeCipher] = v + data[c.DigitalRmbCodeHash] = h + data[c.DigitalRmbCodeMask] = encrypt.MaskAccount(in.DigitalRMBCode) + } if in.IsDefault == 1 { _, _ = s.paymentModel(ctx).Where(c.MemberId, in.MemberId).Data(g.Map{c.IsDefault: 2}).Update() } @@ -370,7 +417,7 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym } } else { data[c.CreatedBy] = contexts.GetUserId(ctx) - _, err := s.paymentModel(ctx).Fields(c.MemberId, c.AccountType, c.BankCode, c.BankName, c.HolderCipher, c.HolderMask, c.MobileCipher, c.MobileMask, c.AccountNoCipher, c.AccountNoHash, c.AccountNoMask, c.QrAttachmentId, c.VerifyStatus, c.IsDefault, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert() + _, err := s.paymentModel(ctx).Fields(c.MemberId, c.AccountType, c.BankCode, c.BankName, c.HolderCipher, c.HolderMask, c.IdentityNoCipher, c.IdentityNoHash, c.IdentityNoMask, c.MobileCipher, c.MobileMask, c.AccountNoCipher, c.AccountNoHash, c.AccountNoMask, c.DigitalRmbCodeCipher, c.DigitalRmbCodeHash, c.DigitalRmbCodeMask, c.QrAttachmentId, c.WechatQrAttachmentId, c.AlipayQrAttachmentId, c.VerifyStatus, c.IsDefault, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert() if err != nil { return gerror.Wrap(err, "创建收款账户失败") } @@ -465,8 +512,25 @@ func (s *sSysAppMember) Sensitive(ctx context.Context, in *sysin.AppMemberSensit var row *entity.AppMemberPaymentAccount if err = s.paymentModel(ctx).WherePri(in.TargetId).Scan(&row); err == nil && row != nil { res.Fields["holder"], _ = encrypt.DecryptField(ctx, row.HolderCipher) + res.Fields["identityNo"], _ = encrypt.DecryptField(ctx, row.IdentityNoCipher) res.Fields["mobile"], _ = encrypt.DecryptField(ctx, row.MobileCipher) - res.Fields["accountNo"], err = encrypt.DecryptField(ctx, row.AccountNoCipher) + res.Fields["accountNo"], _ = encrypt.DecryptField(ctx, row.AccountNoCipher) + res.Fields["digitalRmbCode"], _ = encrypt.DecryptField(ctx, row.DigitalRmbCodeCipher) + if res.Fields["holder"] == "" { + res.Fields["holder"] = row.HolderMask + } + if res.Fields["identityNo"] == "" { + res.Fields["identityNo"] = row.IdentityNoMask + } + if res.Fields["mobile"] == "" { + res.Fields["mobile"] = row.MobileMask + } + if res.Fields["accountNo"] == "" { + res.Fields["accountNo"] = row.AccountNoMask + } + if res.Fields["digitalRmbCode"] == "" { + res.Fields["digitalRmbCode"] = row.DigitalRmbCodeMask + } } case "address": var row *entity.AppMemberAddress diff --git a/server/internal/logic/sys/dashboard.go b/server/internal/logic/sys/dashboard.go index ee254f7..54006fe 100644 --- a/server/internal/logic/sys/dashboard.go +++ b/server/internal/logic/sys/dashboard.go @@ -2,6 +2,8 @@ package sys import ( "context" + "fmt" + "hotgo/internal/consts" "hotgo/internal/dao" "hotgo/internal/library/hgorm/handler" "hotgo/internal/model/entity" @@ -11,10 +13,20 @@ import ( "sort" "time" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) +type dashboardAggregateSpec struct { + DimensionType string + DimensionValue string + MetricCode string + SQL string + Args []interface{} +} + const ( dashboardDimensionGlobal = "global" dashboardDimensionChannel = "channel" @@ -54,6 +66,97 @@ func NewSysDashboard() *sSysDashboard { return &sSysDashboard{} } func init() { service.RegisterSysDashboard(NewSysDashboard()) } +// RefreshDaily rebuilds recent dashboard snapshots from business source tables. +func (s *sSysDashboard) RefreshDaily(ctx context.Context, days int) error { + if days < 1 { + days = 1 + } + if days > 90 { + days = 90 + } + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + for offset := days - 1; offset >= 0; offset-- { + day := time.Now().AddDate(0, 0, -offset) + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()) + end := start.AddDate(0, 0, 1) + if err := s.refreshDashboardDay(ctx, start, end); err != nil { + return err + } + } + return nil + }) +} + +func (s *sSysDashboard) refreshDashboardDay(ctx context.Context, start, end time.Time) error { + dc := dao.DashboardDailyStat.Columns() + if _, err := dao.DashboardDailyStat.Ctx(ctx).Where(dc.StatDate, start.Format("2006-01-02")).Delete(); err != nil { + return gerror.Wrap(err, "清理看板日汇总失败") + } + mc, pc, oc := dao.AppMember.Columns(), dao.Product.Columns(), dao.TradeOrder.Columns() + rc, wc, lc := dao.RechargeOrder.Columns(), dao.WithdrawalOrder.Columns(), dao.WalletLedger.Columns() + wac, cc := dao.WalletAccount.Columns(), dao.PaymentChannel.Columns() + specs := []dashboardAggregateSpec{ + {dashboardDimensionGlobal, "all", "member_total", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,0 metric_amount FROM %s WHERE %s IS NULL AND %s=? AND %s=? AND %s=? AND %s=? AND %s=? AND %s=? AND %s=? AND %s=? AND %s=? AND r.%s=? AND w.%s 0 { + var task *entity.Task + if err := handler.Model(dao.Task.Ctx(ctx), &handler.Option{FilterTenant: true}).WherePri(submission.TaskId).Scan(&task); err != nil { + return err + } + if task == nil { + return gerror.New("关联任务不存在") + } } - if task == nil { - return gerror.New("关联任务不存在") + if submission.TradeOrderId > 0 { + oc := dao.TradeOrder.Columns() + count, err := handler.Model(dao.TradeOrder.Ctx(ctx), &handler.Option{FilterTenant: true}).WherePri(submission.TradeOrderId).Where(oc.MemberId, submission.MemberId).Count() + if err != nil { + return err + } + if count == 0 { + return gerror.New("关联订单不存在或不属于该会员") + } + } + if submission.TaskId == 0 && submission.TradeOrderId == 0 { + return gerror.New("提交记录缺少任务和订单关联") } var member *entity.AppMember if err := handler.Model(dao.AppMember.Ctx(ctx), &handler.Option{FilterTenant: true}).WherePri(submission.MemberId).Scan(&member); err != nil { diff --git a/server/internal/logic/sys/trade_order.go b/server/internal/logic/sys/trade_order.go index 3a83358..ed805aa 100644 --- a/server/internal/logic/sys/trade_order.go +++ b/server/internal/logic/sys/trade_order.go @@ -56,6 +56,12 @@ func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp) if in.Mobile != "" { mod = mod.Where(col("m", mc.Mobile), in.Mobile) } + if in.AutoDispatch == consts.TradeAutoDispatchEnabled { + mod = mod.Where("COALESCE(JSON_UNQUOTE(JSON_EXTRACT("+col("o", oc.RuleSnapshot)+",'$.dispatch_mode')),'') = ?", "auto") + } + if in.AutoDispatch == consts.TradeAutoDispatchDisabled { + mod = mod.Where("COALESCE(JSON_UNQUOTE(JSON_EXTRACT("+col("o", oc.RuleSnapshot)+",'$.dispatch_mode')),'') = ?", "manual") + } if in.OrderStatus > 0 { mod = mod.Where(col("o", oc.OrderStatus), in.OrderStatus) } @@ -92,7 +98,27 @@ func (s *sSysTradeOrder) View(ctx context.Context, in *sysin.TradeOrderViewInp) if err = s.model(ctx).WherePri(in.Id).Scan(&order); err != nil { return nil, err } - res = &sysin.TradeOrderViewModel{TradeOrderListModel: *base, TaskId: order.TaskId, ProductId: order.ProductId, MemberLevelId: order.MemberLevelId, RuleSnapshot: order.RuleSnapshot} + res = &sysin.TradeOrderViewModel{TradeOrderListModel: *base, TaskId: order.TaskId, ProductId: order.ProductId, MemberLevelId: order.MemberLevelId, RuleSnapshot: order.RuleSnapshot, UpdatedAt: order.UpdatedAt} + res.Timeline = []*sysin.TradeOrderTimelineModel{{Title: "订单创建", Time: order.CreatedAt}} + if order.MatchedAt != nil { + res.Timeline = append(res.Timeline, &sysin.TradeOrderTimelineModel{Title: "订单匹配", Time: order.MatchedAt}) + } + if order.CompletedAt != nil { + res.Timeline = append(res.Timeline, &sysin.TradeOrderTimelineModel{Title: "订单完成", Time: order.CompletedAt}) + } + if order.UnfrozenAt != nil { + res.Timeline = append(res.Timeline, &sysin.TradeOrderTimelineModel{Title: "订单解冻", Time: order.UnfrozenAt}) + } + lc := dao.WalletLedger.Columns() + if err = handler.Model(dao.WalletLedger.Ctx(ctx), &handler.Option{FilterTenant: true}).Fields(lc.LedgerNo, lc.BizType, lc.Direction, lc.Amount, lc.BeforeAmount, lc.AfterAmount, lc.Remark, lc.OccurredAt).Where(lc.BizNo, order.OrderNo).OrderAsc(lc.Id).Scan(&res.Ledgers); err != nil { + return nil, gerror.Wrap(err, "获取订单资金流水失败") + } + if order.ApprovalRequestId > 0 { + ac := dao.ApprovalAction.Columns() + if err = handler.Model(dao.ApprovalAction.Ctx(ctx), &handler.Option{FilterTenant: true}).Fields(ac.StepNo, ac.ActorId, ac.Action, ac.Comment, ac.CreatedAt).Where(ac.ApprovalRequestId, order.ApprovalRequestId).OrderAsc(ac.StepNo).OrderAsc(ac.Id).Scan(&res.ApprovalActions); err != nil { + return nil, gerror.Wrap(err, "获取订单审批记录失败") + } + } return } diff --git a/server/internal/model/do/app_member_payment_account.go b/server/internal/model/do/app_member_payment_account.go index 0537bde..ec69839 100644 --- a/server/internal/model/do/app_member_payment_account.go +++ b/server/internal/model/do/app_member_payment_account.go @@ -11,27 +11,35 @@ import ( // AppMemberPaymentAccount is the golang structure of table hg_app_member_payment_account for DAO operations like Where/Data. type AppMemberPaymentAccount struct { - g.Meta `orm:"table:hg_app_member_payment_account, do:true"` - Id any // 主键 - TenantId any // 租户ID - MemberId any // 会员ID - AccountType any // 账户类型 - BankCode any // 银行编码 - BankName any // 银行名称 - HolderCipher any // 持有人密文 - HolderMask any // 持有人脱敏值 - MobileCipher any // 预留手机号密文 - MobileMask any // 预留手机号脱敏值 - AccountNoCipher any // 账号密文 - AccountNoHash any // 账号检索哈希 - AccountNoMask any // 账号脱敏值 - QrAttachmentId any // 收款码附件ID - VerifyStatus any // 核验状态 - IsDefault any // 默认账户 - CreatedBy any // 创建者 - UpdatedBy any // 更新者 - DeletedBy any // 删除者 - CreatedAt *gtime.Time // 创建时间 - UpdatedAt *gtime.Time // 更新时间 - DeletedAt *gtime.Time // 删除时间 + g.Meta `orm:"table:hg_app_member_payment_account, do:true"` + Id any // 主键 + TenantId any // 租户ID + MemberId any // 会员ID + AccountType any // 账户类型 + BankCode any // 银行编码 + BankName any // 银行名称 + HolderCipher any // 持有人密文 + HolderMask any // 持有人脱敏值 + IdentityNoCipher any // 身份证号密文 + IdentityNoHash any // 身份证号检索哈希 + IdentityNoMask any // 身份证号脱敏值 + MobileCipher any // 预留手机号密文 + MobileMask any // 预留手机号脱敏值 + AccountNoCipher any // 账号密文 + AccountNoHash any // 账号检索哈希 + AccountNoMask any // 账号脱敏值 + DigitalRmbCodeCipher any // 数字人民币编码密文 + DigitalRmbCodeHash any // 数字人民币编码检索哈希 + DigitalRmbCodeMask any // 数字人民币编码脱敏值 + QrAttachmentId any // 收款码附件ID + WechatQrAttachmentId any // 微信二维码附件ID + AlipayQrAttachmentId any // 支付宝二维码附件ID + VerifyStatus any // 核验状态 + IsDefault any // 默认账户 + CreatedBy any // 创建者 + UpdatedBy any // 更新者 + DeletedBy any // 删除者 + CreatedAt *gtime.Time // 创建时间 + UpdatedAt *gtime.Time // 更新时间 + DeletedAt *gtime.Time // 删除时间 } diff --git a/server/internal/model/do/task.go b/server/internal/model/do/task.go index 8c85c05..21e2755 100644 --- a/server/internal/model/do/task.go +++ b/server/internal/model/do/task.go @@ -16,6 +16,7 @@ type Task struct { TenantId any // 租户ID TaskNo any // 任务编号 HallId any // 任务大厅ID + ProductId any // 关联商品ID Title any // 标题 Description any // 描述 DetailHtml any // 清洗后的任务详情 diff --git a/server/internal/model/entity/app_member_payment_account.go b/server/internal/model/entity/app_member_payment_account.go index c13221f..776924f 100644 --- a/server/internal/model/entity/app_member_payment_account.go +++ b/server/internal/model/entity/app_member_payment_account.go @@ -10,26 +10,34 @@ import ( // AppMemberPaymentAccount is the golang structure for table app_member_payment_account. type AppMemberPaymentAccount struct { - Id uint64 `json:"id" orm:"id" description:"主键"` - TenantId uint64 `json:"tenantId" orm:"tenant_id" description:"租户ID"` - MemberId uint64 `json:"memberId" orm:"member_id" description:"会员ID"` - AccountType int `json:"accountType" orm:"account_type" description:"账户类型"` - BankCode string `json:"bankCode" orm:"bank_code" description:"银行编码"` - BankName string `json:"bankName" orm:"bank_name" description:"银行名称"` - HolderCipher string `json:"holderCipher" orm:"holder_cipher" description:"持有人密文"` - HolderMask string `json:"holderMask" orm:"holder_mask" description:"持有人脱敏值"` - MobileCipher string `json:"mobileCipher" orm:"mobile_cipher" description:"预留手机号密文"` - MobileMask string `json:"mobileMask" orm:"mobile_mask" description:"预留手机号脱敏值"` - AccountNoCipher string `json:"accountNoCipher" orm:"account_no_cipher" description:"账号密文"` - AccountNoHash string `json:"accountNoHash" orm:"account_no_hash" description:"账号检索哈希"` - AccountNoMask string `json:"accountNoMask" orm:"account_no_mask" description:"账号脱敏值"` - QrAttachmentId uint64 `json:"qrAttachmentId" orm:"qr_attachment_id" description:"收款码附件ID"` - VerifyStatus int `json:"verifyStatus" orm:"verify_status" description:"核验状态"` - IsDefault int `json:"isDefault" orm:"is_default" description:"默认账户"` - CreatedBy uint64 `json:"createdBy" orm:"created_by" description:"创建者"` - UpdatedBy uint64 `json:"updatedBy" orm:"updated_by" description:"更新者"` - DeletedBy uint64 `json:"deletedBy" orm:"deleted_by" description:"删除者"` - CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` - UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` - DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:"删除时间"` + Id uint64 `json:"id" orm:"id" description:"主键"` + TenantId uint64 `json:"tenantId" orm:"tenant_id" description:"租户ID"` + MemberId uint64 `json:"memberId" orm:"member_id" description:"会员ID"` + AccountType int `json:"accountType" orm:"account_type" description:"账户类型"` + BankCode string `json:"bankCode" orm:"bank_code" description:"银行编码"` + BankName string `json:"bankName" orm:"bank_name" description:"银行名称"` + HolderCipher string `json:"holderCipher" orm:"holder_cipher" description:"持有人密文"` + HolderMask string `json:"holderMask" orm:"holder_mask" description:"持有人脱敏值"` + IdentityNoCipher string `json:"identityNoCipher" orm:"identity_no_cipher" description:"身份证号密文"` + IdentityNoHash string `json:"identityNoHash" orm:"identity_no_hash" description:"身份证号检索哈希"` + IdentityNoMask string `json:"identityNoMask" orm:"identity_no_mask" description:"身份证号脱敏值"` + MobileCipher string `json:"mobileCipher" orm:"mobile_cipher" description:"预留手机号密文"` + MobileMask string `json:"mobileMask" orm:"mobile_mask" description:"预留手机号脱敏值"` + AccountNoCipher string `json:"accountNoCipher" orm:"account_no_cipher" description:"账号密文"` + AccountNoHash string `json:"accountNoHash" orm:"account_no_hash" description:"账号检索哈希"` + AccountNoMask string `json:"accountNoMask" orm:"account_no_mask" description:"账号脱敏值"` + DigitalRmbCodeCipher string `json:"digitalRmbCodeCipher" orm:"digital_rmb_code_cipher" description:"数字人民币编码密文"` + DigitalRmbCodeHash string `json:"digitalRmbCodeHash" orm:"digital_rmb_code_hash" description:"数字人民币编码检索哈希"` + DigitalRmbCodeMask string `json:"digitalRmbCodeMask" orm:"digital_rmb_code_mask" description:"数字人民币编码脱敏值"` + QrAttachmentId uint64 `json:"qrAttachmentId" orm:"qr_attachment_id" description:"收款码附件ID"` + WechatQrAttachmentId uint64 `json:"wechatQrAttachmentId" orm:"wechat_qr_attachment_id" description:"微信二维码附件ID"` + AlipayQrAttachmentId uint64 `json:"alipayQrAttachmentId" orm:"alipay_qr_attachment_id" description:"支付宝二维码附件ID"` + VerifyStatus int `json:"verifyStatus" orm:"verify_status" description:"核验状态"` + IsDefault int `json:"isDefault" orm:"is_default" description:"默认账户"` + CreatedBy uint64 `json:"createdBy" orm:"created_by" description:"创建者"` + UpdatedBy uint64 `json:"updatedBy" orm:"updated_by" description:"更新者"` + DeletedBy uint64 `json:"deletedBy" orm:"deleted_by" description:"删除者"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` + DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:"删除时间"` } diff --git a/server/internal/model/entity/task.go b/server/internal/model/entity/task.go index 90e1939..2a83a23 100644 --- a/server/internal/model/entity/task.go +++ b/server/internal/model/entity/task.go @@ -14,6 +14,7 @@ type Task struct { TenantId uint64 `json:"tenantId" orm:"tenant_id" description:"租户ID"` TaskNo string `json:"taskNo" orm:"task_no" description:"任务编号"` HallId uint64 `json:"hallId" orm:"hall_id" description:"任务大厅ID"` + ProductId uint64 `json:"productId" orm:"product_id" description:"关联商品ID"` Title string `json:"title" orm:"title" description:"标题"` Description string `json:"description" orm:"description" description:"描述"` DetailHtml string `json:"detailHtml" orm:"detail_html" description:"清洗后的任务详情"` diff --git a/server/internal/model/input/sysin/app_member.go b/server/internal/model/input/sysin/app_member.go index c1707ec..8c6128c 100644 --- a/server/internal/model/input/sysin/app_member.go +++ b/server/internal/model/input/sysin/app_member.go @@ -164,32 +164,44 @@ type AppMemberPaymentListInp struct { func (in *AppMemberPaymentListInp) Filter(ctx context.Context) error { return nil } type AppMemberPaymentModel struct { - Id uint64 `json:"id"` - MemberId uint64 `json:"memberId"` - AccountType int `json:"accountType"` - BankCode string `json:"bankCode"` - BankName string `json:"bankName"` - HolderMask string `json:"holderMask"` - MobileMask string `json:"mobileMask"` - AccountNoMask string `json:"accountNoMask"` - QrAttachmentId uint64 `json:"qrAttachmentId"` - VerifyStatus int `json:"verifyStatus"` - IsDefault int `json:"isDefault"` - CreatedAt *gtime.Time `json:"createdAt"` - UpdatedAt *gtime.Time `json:"updatedAt"` + Id uint64 `json:"id"` + MemberId uint64 `json:"memberId"` + AccountType int `json:"accountType"` + BankCode string `json:"bankCode"` + BankName string `json:"bankName"` + HolderMask string `json:"holderMask"` + IdentityNoMask string `json:"identityNoMask"` + MobileMask string `json:"mobileMask"` + AccountNoMask string `json:"accountNoMask"` + DigitalRMBCodeMask string `json:"digitalRmbCodeMask"` + QrAttachmentId uint64 `json:"qrAttachmentId"` + WechatQrAttachmentId uint64 `json:"wechatQrAttachmentId"` + AlipayQrAttachmentId uint64 `json:"alipayQrAttachmentId"` + WechatQrUrl string `json:"wechatQrUrl"` + AlipayQrUrl string `json:"alipayQrUrl"` + VerifyStatus int `json:"verifyStatus"` + IsDefault int `json:"isDefault"` + CreatedAt *gtime.Time `json:"createdAt"` + UpdatedAt *gtime.Time `json:"updatedAt"` } type AppMemberPaymentEditInp struct { - Id uint64 `json:"id"` - MemberId uint64 `json:"memberId" v:"required|min:1#会员ID不能为空|会员ID不能为空"` - AccountType int `json:"accountType" v:"required#账户类型不能为空"` - BankCode string `json:"bankCode" v:"max-length:32#银行编码不能超过32个字符"` - BankName string `json:"bankName" v:"max-length:100#银行名称不能超过100个字符"` - Holder string `json:"holder"` - Mobile string `json:"mobile" v:"phone-loose#手机号格式不正确"` - AccountNo string `json:"accountNo"` - QrAttachmentId uint64 `json:"qrAttachmentId"` - VerifyStatus int `json:"verifyStatus" d:"1"` - IsDefault int `json:"isDefault" d:"2"` + Id uint64 `json:"id"` + MemberId uint64 `json:"memberId" v:"required|min:1#会员ID不能为空|会员ID不能为空"` + AccountType int `json:"accountType" v:"required#账户类型不能为空"` + BankCode string `json:"bankCode" v:"max-length:32#银行编码不能超过32个字符"` + BankName string `json:"bankName" v:"max-length:100#银行名称不能超过100个字符"` + Holder string `json:"holder"` + IdentityNo string `json:"identityNo"` + Mobile string `json:"mobile" v:"phone-loose#手机号格式不正确"` + AccountNo string `json:"accountNo"` + DigitalRMBCode string `json:"digitalRmbCode"` + QrAttachmentId uint64 `json:"qrAttachmentId"` + WechatQrAttachmentId uint64 `json:"wechatQrAttachmentId"` + AlipayQrAttachmentId uint64 `json:"alipayQrAttachmentId"` + WechatQrUrl string `json:"wechatQrUrl"` + AlipayQrUrl string `json:"alipayQrUrl"` + VerifyStatus int `json:"verifyStatus" d:"1"` + IsDefault int `json:"isDefault" d:"2"` } func (in *AppMemberPaymentEditInp) Filter(ctx context.Context) error { diff --git a/server/internal/model/input/sysin/portal.go b/server/internal/model/input/sysin/portal.go index af0f5ce..f64ebc1 100644 --- a/server/internal/model/input/sysin/portal.go +++ b/server/internal/model/input/sysin/portal.go @@ -55,14 +55,12 @@ func (in *SiteConfigSaveInp) Filter(ctx context.Context) error { if strings.TrimSpace(in.Config.SiteName) == "" { return gerror.New("站点名称不能为空") } - if in.Config.PrivacyPolicyUrl != "" { - u, err := url.Parse(in.Config.PrivacyPolicyUrl) - if err != nil || u.Scheme != "https" || u.Host == "" { - return gerror.New("隐私政策地址必须是有效的 HTTPS 地址") - } + u, err := url.Parse(in.Config.PrivacyPolicyUrl) + if err != nil || u.Scheme != "https" || u.Host == "" { + return gerror.New("隐私政策地址不能为空且必须是有效的 HTTPS 地址") } - if in.Config.TradeWaitSeconds < 0 || in.Config.DailyTradeLimit < 0 || in.Config.DailyRewardLimit < 0 { - return gerror.New("交易等待时间和每日次数不能为负数") + if in.Config.TradeBalanceAmount <= 0 || in.Config.TradeWaitSeconds <= 0 || in.Config.DailyTradeLimit <= 0 || in.Config.DailyRewardLimit <= 0 || in.Config.MinimumRechargeAmount <= 0 { + return gerror.New("交易余额、支付等待时长、每日次数和最低充值金额必须大于0") } if len(in.Config.CommissionRates) != 6 { return gerror.New("必须配置 0 至 5 级共 6 个佣金比例") @@ -77,11 +75,11 @@ func (in *SiteConfigSaveInp) Filter(ctx context.Context) error { if total > 1 { return gerror.New("各级佣金比例总和不能超过 100%") } - if in.Config.AutoOrderDelaySeconds < 0 { - return gerror.New("自动派单时间不能为负数") + if in.Config.AutoOrderDelaySeconds <= 0 { + return gerror.New("自动派单时间必须大于0") } - if in.Config.AutoOrderMinAmount < 0 || in.Config.AutoOrderMaxAmount < in.Config.AutoOrderMinAmount { - return gerror.New("自动派单最大金额不能小于最小金额") + if in.Config.AutoOrderMinAmount <= 0 || in.Config.AutoOrderMaxAmount < in.Config.AutoOrderMinAmount { + return gerror.New("自动派单金额必须大于0,且最大金额不能小于最小金额") } for _, ranges := range [][]string{in.Config.WithdrawTimeRanges, in.Config.RechargeTimeRanges, in.Config.TradeTimeRanges, in.Config.SignTimeRanges} { for _, value := range ranges { diff --git a/server/internal/model/input/sysin/product_task.go b/server/internal/model/input/sysin/product_task.go index 57c6e1d..d50954f 100644 --- a/server/internal/model/input/sysin/product_task.go +++ b/server/internal/model/input/sysin/product_task.go @@ -123,6 +123,7 @@ func (in *ProductDeleteInp) Filter(ctx context.Context) error { return nil } type TaskUpdateFields struct { HallId uint64 + ProductId uint64 Title string Description string DetailHtml string @@ -157,10 +158,11 @@ func (in *TaskListInp) Filter(ctx context.Context) error { return nil } type TaskListModel struct { entity.Task - HallName string `json:"hallName"` - LevelIds []uint64 `json:"levelIds"` - LevelNames []string `json:"levelNames"` - Referenced bool `json:"referenced"` + HallName string `json:"hallName"` + ProductName string `json:"productName"` + LevelIds []uint64 `json:"levelIds"` + LevelNames []string `json:"levelNames"` + Referenced bool `json:"referenced"` } type TaskViewInp struct { Id uint64 `json:"id" v:"required|min:1#任务ID不能为空|任务ID不能为空"` @@ -178,8 +180,8 @@ func (in *TaskEditInp) Filter(ctx context.Context) error { if strings.TrimSpace(in.Title) == "" || strings.TrimSpace(in.Description) == "" || strings.TrimSpace(in.CoverUrl) == "" { return gerror.New("任务标题、描述和封面不能为空") } - if in.HallId == 0 || len(in.LevelIds) == 0 { - return gerror.New("请选择任务大厅和会员等级") + if in.HallId == 0 || in.ProductId == 0 || len(in.LevelIds) == 0 { + return gerror.New("请选择关联商品、任务大厅和会员等级") } if in.PerMemberLimit == 0 { return gerror.New("单会员任务限制必须大于0") @@ -231,6 +233,8 @@ type TaskSubmissionListModel struct { Id uint64 `json:"id"` SubmissionNo string `json:"submissionNo"` TradeOrderId uint64 `json:"tradeOrderId"` + TradeOrderNo string `json:"tradeOrderNo"` + ProductName string `json:"productName"` TaskId uint64 `json:"taskId"` TaskTitle string `json:"taskTitle"` MemberId uint64 `json:"memberId"` diff --git a/server/internal/model/input/sysin/trade.go b/server/internal/model/input/sysin/trade.go index 345dd51..490acd9 100644 --- a/server/internal/model/input/sysin/trade.go +++ b/server/internal/model/input/sysin/trade.go @@ -16,11 +16,12 @@ import ( type TradeOrderListInp struct { form.PageReq - OrderNo string `json:"orderNo"` - Username string `json:"username"` - Mobile string `json:"mobile"` - OrderStatus int `json:"orderStatus"` - CreatedAt []*gtime.Time `json:"createdAt"` + OrderNo string `json:"orderNo"` + Username string `json:"username"` + Mobile string `json:"mobile"` + AutoDispatch int `json:"autoDispatch"` + OrderStatus int `json:"orderStatus"` + CreatedAt []*gtime.Time `json:"createdAt"` } func (in *TradeOrderListInp) Filter(ctx context.Context) error { return nil } @@ -70,10 +71,36 @@ func (in *TradeOrderViewInp) Filter(ctx context.Context) error { return nil } type TradeOrderViewModel struct { TradeOrderListModel - TaskId uint64 `json:"taskId"` - ProductId uint64 `json:"productId"` - MemberLevelId uint64 `json:"memberLevelId"` - RuleSnapshot *gjson.Json `json:"ruleSnapshot"` + TaskId uint64 `json:"taskId"` + ProductId uint64 `json:"productId"` + MemberLevelId uint64 `json:"memberLevelId"` + RuleSnapshot *gjson.Json `json:"ruleSnapshot"` + UpdatedAt *gtime.Time `json:"updatedAt"` + Timeline []*TradeOrderTimelineModel `json:"timeline"` + Ledgers []*TradeOrderLedgerModel `json:"ledgers"` + ApprovalActions []*TradeOrderApprovalActionModel `json:"approvalActions"` +} + +type TradeOrderTimelineModel struct { + Title string `json:"title"` + Time *gtime.Time `json:"time"` +} +type TradeOrderLedgerModel struct { + LedgerNo string `json:"ledgerNo"` + BizType string `json:"bizType"` + Direction int `json:"direction"` + Amount int64 `json:"amount"` + BeforeAmount int64 `json:"beforeAmount"` + AfterAmount int64 `json:"afterAmount"` + Remark string `json:"remark"` + OccurredAt *gtime.Time `json:"occurredAt"` +} +type TradeOrderApprovalActionModel struct { + StepNo uint `json:"stepNo"` + ActorId uint64 `json:"actorId"` + Action int `json:"action"` + Comment string `json:"comment"` + CreatedAt *gtime.Time `json:"createdAt"` } type TradeOrderApplyInp struct { diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index 967230a..ae1018b 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -167,6 +167,7 @@ type ( } ISysDashboard interface { Summary(ctx context.Context, in *sysin.DashboardSummaryInp) (res *sysin.DashboardSummaryModel, err error) + RefreshDaily(ctx context.Context, days int) (err error) } ISysBizAudit interface { Model(ctx context.Context, option ...*handler.Option) *gdb.Model diff --git a/server/resource/public/images/olons-banner.svg b/server/resource/public/images/olons-banner.svg new file mode 100644 index 0000000..0222394 --- /dev/null +++ b/server/resource/public/images/olons-banner.svg @@ -0,0 +1,7 @@ + + + + + OLONS + 精选商品 · 安心服务 + diff --git a/server/resource/public/images/olons-product.svg b/server/resource/public/images/olons-product.svg new file mode 100644 index 0000000..5e6cf86 --- /dev/null +++ b/server/resource/public/images/olons-product.svg @@ -0,0 +1,7 @@ + + + + + + OLONS + diff --git a/server/storage/data/migrations/20261016_001_task_product_relation.sql b/server/storage/data/migrations/20261016_001_task_product_relation.sql new file mode 100644 index 0000000..e1ca913 --- /dev/null +++ b/server/storage/data/migrations/20261016_001_task_product_relation.sql @@ -0,0 +1,15 @@ +-- 任务明确关联一个已发布商品,便于创建、审核和订单追溯。 +ALTER TABLE `hg_task` + ADD COLUMN `product_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '关联商品ID' AFTER `hall_id`, + ADD INDEX `idx_task_product_id` (`product_id`); + +-- 历史任务从已生成的交易订单中回填最常用的商品,无法判断的保持 0 并在后台补录。 +UPDATE `hg_task` t +LEFT JOIN ( + SELECT `task_id`, MAX(`product_id`) AS `product_id` + FROM `hg_trade_order` + WHERE `task_id` > 0 AND `product_id` > 0 + GROUP BY `task_id` +) o ON o.`task_id` = t.`id` +SET t.`product_id` = COALESCE(o.`product_id`, 0) +WHERE t.`product_id` = 0; diff --git a/server/storage/data/migrations/20261016_002_dashboard_summary_cron.sql b/server/storage/data/migrations/20261016_002_dashboard_summary_cron.sql new file mode 100644 index 0000000..4ad4dfa --- /dev/null +++ b/server/storage/data/migrations/20261016_002_dashboard_summary_cron.sql @@ -0,0 +1,6 @@ +-- 使用 HotGo 自带定时任务管理,每 10 分钟重算今天和昨天的经营看板。 +INSERT INTO `hg_sys_cron` +(`group_id`,`title`,`name`,`params`,`pattern`,`policy`,`count`,`sort`,`remark`,`status`,`created_at`,`updated_at`) +SELECT 1,'经营看板日汇总','dashboard_daily_summary','2','0 */10 * * * *',2,1,20, + '汇总会员、商品、订单、充值、提现和钱包数据;参数为重算天数',1,NOW(),NOW() +WHERE NOT EXISTS (SELECT 1 FROM `hg_sys_cron` WHERE `name`='dashboard_daily_summary'); diff --git a/server/storage/data/migrations/20261016_003_site_config_defaults.sql b/server/storage/data/migrations/20261016_003_site_config_defaults.sql new file mode 100644 index 0000000..1d55a90 --- /dev/null +++ b/server/storage/data/migrations/20261016_003_site_config_defaults.sql @@ -0,0 +1,17 @@ +-- 补全历史站点配置中的无效空值;金额单位均为分。 +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'))), + '$.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')), + '$.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'; diff --git a/server/storage/data/migrations/20261016_004_portal_required_images.sql b/server/storage/data/migrations/20261016_004_portal_required_images.sql new file mode 100644 index 0000000..a75d4c8 --- /dev/null +++ b/server/storage/data/migrations/20261016_004_portal_required_images.sql @@ -0,0 +1,8 @@ +-- 为历史空图片记录提供可随服务部署的本地占位资源,后续运营可正常替换。 +UPDATE `hg_banner` +SET `image_url`='/images/olons-banner.svg', `updated_at`=NOW() +WHERE `deleted_at` IS NULL AND COALESCE(`image_url`,'')=''; + +UPDATE `hg_product` +SET `cover_url`='/images/olons-product.svg', `updated_at`=NOW() +WHERE `deleted_at` IS NULL AND COALESCE(`cover_url`,'')=''; diff --git a/web/src/views/app-member/list/index.vue b/web/src/views/app-member/list/index.vue index 38d06ec..bc6a849 100644 --- a/web/src/views/app-member/list/index.vue +++ b/web/src/views/app-member/list/index.vue @@ -175,23 +175,32 @@ + diff --git a/web/src/views/portal/manual-product/index.vue b/web/src/views/portal/manual-product/index.vue index f66756d..079a879 100644 --- a/web/src/views/portal/manual-product/index.vue +++ b/web/src/views/portal/manual-product/index.vue @@ -37,14 +37,16 @@