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 GROUP BY %s", mc.TenantId, dao.AppMember.Table(), mc.DeletedAt, mc.CreatedAt, mc.TenantId), []interface{}{end}},
+ {dashboardDimensionGlobal, "all", "member_new", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,0 metric_amount FROM %s WHERE %s IS NULL AND %s>=? AND %s GROUP BY %s", mc.TenantId, dao.AppMember.Table(), mc.DeletedAt, mc.CreatedAt, mc.CreatedAt, mc.TenantId), []interface{}{start, end}},
+ {dashboardDimensionGlobal, "all", "product_total", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,0 metric_amount FROM %s WHERE %s IS NULL AND %s GROUP BY %s", pc.TenantId, dao.Product.Table(), pc.DeletedAt, pc.CreatedAt, pc.TenantId), []interface{}{end}},
+ {dashboardDimensionGlobal, "all", "trade_order", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,COALESCE(SUM(%s),0) metric_amount FROM %s WHERE %s>=? AND %s GROUP BY %s", oc.TenantId, oc.TotalAmount, dao.TradeOrder.Table(), oc.CreatedAt, oc.CreatedAt, oc.TenantId), []interface{}{start, end}},
+ {dashboardDimensionGlobal, "all", "recharge_member", fmt.Sprintf("SELECT %s tenant_id,COUNT(DISTINCT %s) metric_count,0 metric_amount FROM %s WHERE %s=? AND %s>=? AND %s GROUP BY %s", rc.TenantId, rc.MemberId, dao.RechargeOrder.Table(), rc.RechargeStatus, rc.CreditedAt, rc.CreditedAt, rc.TenantId), []interface{}{consts.RechargeSuccess, start, end}},
+ {dashboardDimensionGlobal, "all", "recharge_success", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,COALESCE(SUM(%s),0) metric_amount FROM %s WHERE %s=? AND %s>=? AND %s GROUP BY %s", rc.TenantId, rc.PaidAmount, dao.RechargeOrder.Table(), rc.RechargeStatus, rc.CreditedAt, rc.CreditedAt, rc.TenantId), []interface{}{consts.RechargeSuccess, start, end}},
+ {dashboardDimensionGlobal, "all", "recharge_pending", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,COALESCE(SUM(%s),0) metric_amount FROM %s WHERE %s IN (?,?) AND %s GROUP BY %s", rc.TenantId, rc.RequestAmount, dao.RechargeOrder.Table(), rc.RechargeStatus, rc.CreatedAt, rc.TenantId), []interface{}{consts.RechargeUnpaid, consts.RechargeReview, end}},
+ {dashboardDimensionGlobal, "all", "withdrawal_success", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,COALESCE(SUM(%s),0) metric_amount FROM %s WHERE %s=? AND %s>=? AND %s GROUP BY %s", wc.TenantId, wc.ActualAmount, dao.WithdrawalOrder.Table(), wc.WithdrawalStatus, wc.CompletedAt, wc.CompletedAt, wc.TenantId), []interface{}{consts.WithdrawalSuccess, start, end}},
+ {dashboardDimensionGlobal, "all", "withdrawal_pending", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,COALESCE(SUM(%s),0) metric_amount FROM %s WHERE %s IN (?,?,?) AND %s GROUP BY %s", wc.TenantId, wc.ActualAmount, dao.WithdrawalOrder.Table(), wc.WithdrawalStatus, wc.CreatedAt, wc.TenantId), []interface{}{consts.WithdrawalPending, consts.WithdrawalReviewing, consts.WithdrawalProcessing, end}},
+ {dashboardDimensionGlobal, "all", "commission", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,COALESCE(SUM(%s),0) metric_amount FROM %s WHERE %s='commission' AND %s>=? AND %s GROUP BY %s", lc.TenantId, lc.Amount, dao.WalletLedger.Table(), lc.BizType, lc.OccurredAt, lc.OccurredAt, lc.TenantId), []interface{}{start, end}},
+ {dashboardDimensionGlobal, "all", "referral_commission", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,COALESCE(SUM(%s),0) metric_amount FROM %s WHERE %s='referral_commission' AND %s>=? AND %s GROUP BY %s", lc.TenantId, lc.Amount, dao.WalletLedger.Table(), lc.BizType, lc.OccurredAt, lc.OccurredAt, lc.TenantId), []interface{}{start, end}},
+ {dashboardDimensionGlobal, "all", "interest_transfer", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,COALESCE(SUM(%s),0) metric_amount FROM %s WHERE %s='interest_transfer' AND %s>=? AND %s GROUP BY %s", lc.TenantId, lc.Amount, dao.WalletLedger.Table(), lc.BizType, lc.OccurredAt, lc.OccurredAt, lc.TenantId), []interface{}{start, end}},
+ {dashboardDimensionGlobal, "all", "wallet_available", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,COALESCE(SUM(%s),0) metric_amount FROM %s WHERE %s='CNY' GROUP BY %s", wac.TenantId, wac.AvailableAmount, dao.WalletAccount.Table(), wac.Currency, wac.TenantId), nil},
+ {dashboardDimensionGlobal, "all", "wallet_frozen", fmt.Sprintf("SELECT %s tenant_id,COUNT(*) metric_count,COALESCE(SUM(%s),0) metric_amount FROM %s WHERE %s='CNY' GROUP BY %s", wac.TenantId, wac.FrozenAmount, dao.WalletAccount.Table(), wac.Currency, wac.TenantId), nil},
+ }
+ for _, spec := range specs {
+ if err := s.insertDashboardAggregate(ctx, start, spec); err != nil {
+ return err
+ }
+ }
+ channelSpecs := []dashboardAggregateSpec{
+ {dashboardDimensionChannel, "", "recharge_success", fmt.Sprintf("SELECT r.%s tenant_id,COUNT(*) metric_count,COALESCE(SUM(r.%s),0) metric_amount,c.%s dimension_value FROM %s r LEFT JOIN %s c ON c.%s=r.%s WHERE r.%s=? AND r.%s>=? AND r.%s GROUP BY r.%s,c.%s", rc.TenantId, rc.PaidAmount, cc.ChannelCode, dao.RechargeOrder.Table(), dao.PaymentChannel.Table(), cc.Id, rc.ChannelId, rc.RechargeStatus, rc.CreditedAt, rc.CreditedAt, rc.TenantId, cc.ChannelCode), []interface{}{consts.RechargeSuccess, start, end}},
+ {dashboardDimensionChannel, "", "withdrawal_success", fmt.Sprintf("SELECT w.%s tenant_id,COUNT(*) metric_count,COALESCE(SUM(w.%s),0) metric_amount,c.%s dimension_value FROM %s w LEFT JOIN %s c ON c.%s=w.%s WHERE w.%s=? AND w.%s>=? AND w.%s GROUP BY w.%s,c.%s", wc.TenantId, wc.ActualAmount, cc.ChannelCode, dao.WithdrawalOrder.Table(), dao.PaymentChannel.Table(), cc.Id, wc.ChannelId, wc.WithdrawalStatus, wc.CompletedAt, wc.CompletedAt, wc.TenantId, cc.ChannelCode), []interface{}{consts.WithdrawalSuccess, start, end}},
+ }
+ for _, spec := range channelSpecs {
+ if err := s.insertDashboardAggregate(ctx, start, spec); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *sSysDashboard) insertDashboardAggregate(ctx context.Context, day time.Time, spec dashboardAggregateSpec) error {
+ type row struct {
+ TenantId uint64 `orm:"tenant_id"`
+ MetricCount int64 `orm:"metric_count"`
+ MetricAmount int64 `orm:"metric_amount"`
+ DimensionValue string `orm:"dimension_value"`
+ }
+ var rows []*row
+ if err := g.DB().Ctx(ctx).Raw(spec.SQL, spec.Args...).Scan(&rows); err != nil {
+ return gerror.Wrapf(err, "汇总看板指标失败 metric:%s", spec.MetricCode)
+ }
+ c := dao.DashboardDailyStat.Columns()
+ now := gtime.Now()
+ for _, item := range rows {
+ dimensionValue := spec.DimensionValue
+ if item.DimensionValue != "" {
+ dimensionValue = item.DimensionValue
+ }
+ if dimensionValue == "" {
+ dimensionValue = "unknown"
+ }
+ _, err := dao.DashboardDailyStat.Ctx(ctx).Fields(c.TenantId, c.StatDate, c.DimensionType, c.DimensionValue, c.MetricCode, c.MetricCount, c.MetricAmount, c.CalculatedAt, c.CreatedAt, c.UpdatedAt).Data(g.Map{c.TenantId: item.TenantId, c.StatDate: day.Format("2006-01-02"), c.DimensionType: spec.DimensionType, c.DimensionValue: dimensionValue, c.MetricCode: spec.MetricCode, c.MetricCount: item.MetricCount, c.MetricAmount: item.MetricAmount, c.CalculatedAt: now, c.CreatedAt: now, c.UpdatedAt: now}).Insert()
+ if err != nil {
+ return gerror.Wrapf(err, "写入看板指标失败 metric:%s", spec.MetricCode)
+ }
+ }
+ return nil
+}
+
func (s *sSysDashboard) Summary(ctx context.Context, in *sysin.DashboardSummaryInp) (res *sysin.DashboardSummaryModel, err error) {
start, end := normalizeDashboardRange(in)
days := int(end.Sub(start).Hours()/24) + 1
diff --git a/server/internal/logic/sys/portal.go b/server/internal/logic/sys/portal.go
index b9dc890..db2ecb4 100644
--- a/server/internal/logic/sys/portal.go
+++ b/server/internal/logic/sys/portal.go
@@ -498,7 +498,6 @@ func (s *sSysPortal) ManualProductEdit(ctx context.Context, in *sysin.ManualProd
c.PriceAmount: in.PriceAmount,
c.CoverAttachmentId: in.CoverAttachmentId,
c.CoverUrl: in.CoverUrl,
- c.PublishStatus: consts.BizPublishPublished,
c.UpdatedBy: userId,
c.Version: gdb.Raw(c.Version + "+1"),
}).Update()
@@ -512,7 +511,7 @@ func (s *sSysPortal) ManualProductEdit(ctx context.Context, in *sysin.ManualProd
return in.Id, nil
}
in.ProductNo = "MP" + gtime.Now().Format("YmdHis") + grand.S(5)
- in.PublishStatus = consts.BizPublishPublished
+ in.PublishStatus = consts.BizPublishDraft
in.CreatedBy = userId
in.UpdatedBy = userId
result, e := s.productModel(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.Version, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(in).Insert()
diff --git a/server/internal/logic/sys/recharge_order.go b/server/internal/logic/sys/recharge_order.go
index 4bb6983..c3802fc 100644
--- a/server/internal/logic/sys/recharge_order.go
+++ b/server/internal/logic/sys/recharge_order.go
@@ -40,8 +40,10 @@ 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)
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), col("p", pc.RealNameMask), 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))
+ 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))
}
func (s *sSysRechargeOrder) filter(ctx context.Context, mod *gdb.Model, in *sysin.RechargeOrderListInp) (*gdb.Model, error) {
diff --git a/server/internal/logic/sys/task.go b/server/internal/logic/sys/task.go
index 01a0a76..67225f8 100644
--- a/server/internal/logic/sys/task.go
+++ b/server/internal/logic/sys/task.go
@@ -31,8 +31,11 @@ func (s *sSysTask) relationModel(ctx context.Context) *gdb.Model {
return handler.Model(dao.TaskLevel.Ctx(ctx), &handler.Option{FilterTenant: true})
}
func (s *sSysTask) listModel(ctx context.Context) *gdb.Model {
- tc, hc := dao.Task.Columns(), dao.TaskHall.Columns()
- return s.Model(ctx).As("t").LeftJoin(dao.TaskHall.Table()+" h", col("h", hc.Id)+"="+col("t", tc.HallId)).Fields("t.*", fmt.Sprintf("COALESCE(%s,'') hall_name", col("h", hc.Name)))
+ tc, hc, pc := dao.Task.Columns(), dao.TaskHall.Columns(), dao.Product.Columns()
+ return s.Model(ctx).As("t").
+ LeftJoin(dao.TaskHall.Table()+" h", col("h", hc.Id)+"="+col("t", tc.HallId)).
+ LeftJoin(dao.Product.Table()+" p", col("p", pc.Id)+"="+col("t", tc.ProductId)).
+ Fields("t.*", fmt.Sprintf("COALESCE(%s,'') hall_name", col("h", hc.Name)), fmt.Sprintf("COALESCE(%s,'') product_name", col("p", pc.Name)))
}
func (s *sSysTask) List(ctx context.Context, in *sysin.TaskListInp) (list []*sysin.TaskListModel, total int, err error) {
tc := dao.Task.Columns()
@@ -106,6 +109,14 @@ func (s *sSysTask) Edit(ctx context.Context, in *sysin.TaskEditInp) (id uint64,
if hallCount == 0 {
return 0, gerror.New("任务大厅不存在")
}
+ pc := dao.Product.Columns()
+ productCount, err := handler.Model(dao.Product.Ctx(ctx), &handler.Option{FilterTenant: true}).WherePri(in.ProductId).WhereIn(pc.PublishStatus, []int{consts.BizPublishPublished, consts.BizPublishOffline, consts.BizPublishDraft}).Count()
+ if err != nil {
+ return 0, err
+ }
+ if productCount == 0 {
+ return 0, gerror.New("关联商品不存在")
+ }
lc := dao.AppMemberLevel.Columns()
levelCount, err := handler.Model(dao.AppMemberLevel.Ctx(ctx), &handler.Option{FilterTenant: true}).WhereIn(lc.Id, in.LevelIds).Where(lc.Status, consts.StatusEnabled).Count()
if err != nil {
@@ -125,7 +136,7 @@ func (s *sSysTask) Edit(ctx context.Context, in *sysin.TaskEditInp) (id uint64,
if before == nil {
return gerror.New("任务不存在")
}
- result, e := s.Model(ctx).WherePri(in.Id).Where(c.Version, in.Version).Fields(sysin.TaskUpdateFields{}).Data(g.Map{c.HallId: in.HallId, c.Title: in.Title, c.Description: in.Description, c.DetailHtml: in.DetailHtml, c.CoverAttachmentId: in.CoverAttachmentId, c.CoverUrl: in.CoverUrl, c.CommissionAmount: in.CommissionAmount, c.CommissionRate: in.CommissionRate, c.DailyLimit: in.DailyLimit, c.PerMemberLimit: in.PerMemberLimit, c.StartAt: in.StartAt, c.EndAt: in.EndAt, c.Sort: in.Sort, c.Remark: in.Remark, c.UpdatedBy: uid, c.Version: gdb.Raw(c.Version + "+1")}).Update()
+ result, e := s.Model(ctx).WherePri(in.Id).Where(c.Version, in.Version).Fields(sysin.TaskUpdateFields{}).Data(g.Map{c.HallId: in.HallId, c.ProductId: in.ProductId, c.Title: in.Title, c.Description: in.Description, c.DetailHtml: in.DetailHtml, c.CoverAttachmentId: in.CoverAttachmentId, c.CoverUrl: in.CoverUrl, c.CommissionAmount: in.CommissionAmount, c.CommissionRate: in.CommissionRate, c.DailyLimit: in.DailyLimit, c.PerMemberLimit: in.PerMemberLimit, c.StartAt: in.StartAt, c.EndAt: in.EndAt, c.Sort: in.Sort, c.Remark: in.Remark, c.UpdatedBy: uid, c.Version: gdb.Raw(c.Version + "+1")}).Update()
if e != nil {
return gerror.Wrap(e, "更新任务失败")
}
@@ -136,7 +147,7 @@ func (s *sSysTask) Edit(ctx context.Context, in *sysin.TaskEditInp) (id uint64,
id = in.Id
} else {
taskNo := "TK" + gtime.Now().Format("YmdHis") + grand.S(5)
- result, e := s.Model(ctx).Fields(c.TaskNo, c.HallId, c.Title, c.Description, c.DetailHtml, c.CoverAttachmentId, c.CoverUrl, c.CommissionAmount, c.CommissionRate, c.DailyLimit, c.PerMemberLimit, c.StartAt, c.EndAt, c.PublishStatus, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(g.Map{c.TaskNo: taskNo, c.HallId: in.HallId, c.Title: in.Title, c.Description: in.Description, c.DetailHtml: in.DetailHtml, c.CoverAttachmentId: in.CoverAttachmentId, c.CoverUrl: in.CoverUrl, c.CommissionAmount: in.CommissionAmount, c.CommissionRate: in.CommissionRate, c.DailyLimit: in.DailyLimit, c.PerMemberLimit: in.PerMemberLimit, c.StartAt: in.StartAt, c.EndAt: in.EndAt, c.PublishStatus: consts.BizPublishDraft, c.Sort: in.Sort, c.Remark: in.Remark, c.CreatedBy: uid, c.UpdatedBy: uid}).Insert()
+ result, e := s.Model(ctx).Fields(c.TaskNo, c.HallId, c.ProductId, c.Title, c.Description, c.DetailHtml, c.CoverAttachmentId, c.CoverUrl, c.CommissionAmount, c.CommissionRate, c.DailyLimit, c.PerMemberLimit, c.StartAt, c.EndAt, c.PublishStatus, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(g.Map{c.TaskNo: taskNo, c.HallId: in.HallId, c.ProductId: in.ProductId, c.Title: in.Title, c.Description: in.Description, c.DetailHtml: in.DetailHtml, c.CoverAttachmentId: in.CoverAttachmentId, c.CoverUrl: in.CoverUrl, c.CommissionAmount: in.CommissionAmount, c.CommissionRate: in.CommissionRate, c.DailyLimit: in.DailyLimit, c.PerMemberLimit: in.PerMemberLimit, c.StartAt: in.StartAt, c.EndAt: in.EndAt, c.PublishStatus: consts.BizPublishDraft, c.Sort: in.Sort, c.Remark: in.Remark, c.CreatedBy: uid, c.UpdatedBy: uid}).Insert()
if e != nil {
return gerror.Wrap(e, "创建任务失败")
}
@@ -152,7 +163,7 @@ func (s *sSysTask) Edit(ctx context.Context, in *sysin.TaskEditInp) (id uint64,
return gerror.Wrap(e, "保存任务等级失败")
}
}
- return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "task", Action: "task.edit", TargetType: "task", TargetId: id, Before: before, After: g.Map{"title": in.Title, "commissionAmount": in.CommissionAmount, "levelIds": in.LevelIds}})
+ return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "task", Action: "task.edit", TargetType: "task", TargetId: id, Before: before, After: g.Map{"title": in.Title, "productId": in.ProductId, "commissionAmount": in.CommissionAmount, "levelIds": in.LevelIds}})
})
return
}
diff --git a/server/internal/logic/sys/task_hall.go b/server/internal/logic/sys/task_hall.go
index f5d5521..9bff7c0 100644
--- a/server/internal/logic/sys/task_hall.go
+++ b/server/internal/logic/sys/task_hall.go
@@ -146,7 +146,7 @@ func (s *sSysTaskHall) Edit(ctx context.Context, in *sysin.TaskHallEditInp) erro
return gerror.New("大厅数据已更新,请刷新后重试")
}
} else {
- data[c.PublishStatus] = consts.BizPublishPublished
+ data[c.PublishStatus] = consts.BizPublishDraft
data[c.CreatedBy] = userId
result, err := s.model(ctx).Fields(c.HallCode, c.Name, c.IconAttachmentId, c.IconUrl, c.Summary, c.CommissionRate, c.RateUnit, c.ClientRoute, c.PublishStatus, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert()
if err != nil {
diff --git a/server/internal/logic/sys/task_submission.go b/server/internal/logic/sys/task_submission.go
index 6da11cf..dcc9f4a 100644
--- a/server/internal/logic/sys/task_submission.go
+++ b/server/internal/logic/sys/task_submission.go
@@ -28,8 +28,13 @@ func (s *sSysTaskSubmission) Model(ctx context.Context) *gdb.Model {
return handler.Model(dao.TaskSubmission.Ctx(ctx), &handler.Option{FilterTenant: true})
}
func (s *sSysTaskSubmission) listModel(ctx context.Context) *gdb.Model {
- sc, tc, mc, lc := dao.TaskSubmission.Columns(), dao.Task.Columns(), dao.AppMember.Columns(), dao.AppMemberLevel.Columns()
- return s.Model(ctx).As("s").LeftJoin(dao.Task.Table()+" t", col("t", tc.Id)+"="+col("s", sc.TaskId)).LeftJoin(dao.AppMember.Table()+" m", col("m", mc.Id)+"="+col("s", sc.MemberId)).LeftJoin(dao.AppMemberLevel.Table()+" l", col("l", lc.Id)+"="+col("s", sc.MemberLevelId)).Fields(col("s", sc.Id), col("s", sc.SubmissionNo), col("s", sc.TradeOrderId), col("s", sc.TaskId), fmt.Sprintf("COALESCE(%s,'') task_title", col("t", tc.Title)), col("s", sc.MemberId), col("m", mc.Username), col("m", mc.Mobile), fmt.Sprintf("COALESCE(%s,'') level_name", col("l", lc.Name)), col("s", sc.EvidenceText), col("s", sc.EvidenceAttachmentIds), col("s", sc.SubmissionStatus), col("s", sc.CommissionAmount), col("s", sc.SuccessRate), col("s", sc.ReviewReasonCode), col("s", sc.ReviewReason), col("s", sc.AllowResubmit), col("s", sc.LedgerId), col("s", sc.ReviewedAt), col("s", sc.SettledAt), col("s", sc.CreatedAt))
+ sc, tc, oc, mc, lc := dao.TaskSubmission.Columns(), dao.Task.Columns(), dao.TradeOrder.Columns(), dao.AppMember.Columns(), dao.AppMemberLevel.Columns()
+ return s.Model(ctx).As("s").
+ LeftJoin(dao.Task.Table()+" t", col("t", tc.Id)+"="+col("s", sc.TaskId)).
+ LeftJoin(dao.TradeOrder.Table()+" o", col("o", oc.Id)+"="+col("s", sc.TradeOrderId)).
+ LeftJoin(dao.AppMember.Table()+" m", col("m", mc.Id)+"="+col("s", sc.MemberId)).
+ LeftJoin(dao.AppMemberLevel.Table()+" l", col("l", lc.Id)+"="+col("s", sc.MemberLevelId)).
+ Fields(col("s", sc.Id), col("s", sc.SubmissionNo), col("s", sc.TradeOrderId), fmt.Sprintf("COALESCE(%s,'') trade_order_no", col("o", oc.OrderNo)), fmt.Sprintf("COALESCE(%s,'') product_name", col("o", oc.ProductName)), col("s", sc.TaskId), fmt.Sprintf("COALESCE(NULLIF(%s,''),NULLIF(%s,''),'') task_title", col("t", tc.Title), col("o", oc.ProductName)), col("s", sc.MemberId), col("m", mc.Username), col("m", mc.Mobile), fmt.Sprintf("COALESCE(%s,'') level_name", col("l", lc.Name)), col("s", sc.EvidenceText), col("s", sc.EvidenceAttachmentIds), col("s", sc.SubmissionStatus), col("s", sc.CommissionAmount), col("s", sc.SuccessRate), col("s", sc.ReviewReasonCode), col("s", sc.ReviewReason), col("s", sc.AllowResubmit), col("s", sc.LedgerId), col("s", sc.ReviewedAt), col("s", sc.SettledAt), col("s", sc.CreatedAt))
}
func (s *sSysTaskSubmission) filter(ctx context.Context, mod *gdb.Model, in *sysin.TaskSubmissionListInp) (*gdb.Model, error) {
sc, mc := dao.TaskSubmission.Columns(), dao.AppMember.Columns()
@@ -82,12 +87,27 @@ func (s *sSysTaskSubmission) Review(ctx context.Context, in *sysin.TaskSubmissio
if submission.SubmissionStatus != consts.TaskSubmissionPending {
return gerror.New("该记录已审核,请勿重复操作")
}
- 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 submission.TaskId > 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 @@
+
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 @@
+
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 @@
+
+ 当前默认显示脱敏资料;留空保存不会覆盖原敏感字段或收款码。
+ 授权查看明文
+
+
-
@@ -743,14 +752,20 @@
memberId,
id: bank?.id || 0,
holder: '',
+ holderMask: bank?.holderMask || '',
+ identityNo: '',
+ identityNoMask: bank?.identityNoMask || '',
bankName: bank?.bankName || '',
mobile: '',
+ mobileMask: bank?.mobileMask || '',
bankAccount: '',
+ accountNoMask: bank?.accountNoMask || '',
digitalWallet: '',
- unionPayAccount: '',
- alipayQrCode: '',
- wechatQrCode: '',
- records: { 1: bank, 2: byType(2), 3: byType(3), 4: byType(4), 5: byType(5) },
+ digitalRmbCodeMask: bank?.digitalRmbCodeMask || '',
+ alipayQrCode: bank?.alipayQrUrl || '',
+ wechatQrCode: bank?.wechatQrUrl || '',
+ alipayQrAttachmentId: bank?.alipayQrAttachmentId || 0,
+ wechatQrAttachmentId: bank?.wechatQrAttachmentId || 0,
});
} else {
const row = relatedRows.value.find((item) => item.isDefault === 1) || relatedRows.value[0];
@@ -811,32 +826,23 @@
relatedSaving.value = true;
try {
if (relatedType.value === 'payment') {
- const common = {
+ await PaymentEdit({
+ id: paymentForm.id,
memberId: paymentForm.memberId,
+ accountType: 1,
+ bankName: paymentForm.bankName,
holder: paymentForm.holder,
+ identityNo: paymentForm.identityNo,
mobile: paymentForm.mobile,
+ accountNo: paymentForm.bankAccount,
+ digitalRmbCode: paymentForm.digitalWallet,
+ alipayQrUrl: paymentForm.alipayQrCode,
+ wechatQrUrl: paymentForm.wechatQrCode,
+ alipayQrAttachmentId: paymentForm.alipayQrAttachmentId,
+ wechatQrAttachmentId: paymentForm.wechatQrAttachmentId,
verifyStatus: 1,
isDefault: 1,
- };
- const accounts = [
- { type: 1, value: paymentForm.bankAccount, bankName: paymentForm.bankName },
- { type: 2, value: paymentForm.digitalWallet, bankName: '' },
- { type: 3, value: paymentForm.unionPayAccount, bankName: '' },
- { type: 4, value: paymentForm.alipayQrCode, bankName: '' },
- { type: 5, value: paymentForm.wechatQrCode, bankName: '' },
- ];
- for (const account of accounts) {
- const old = paymentForm.records?.[account.type];
- if (account.value || old?.id)
- await PaymentEdit({
- ...common,
- id: old?.id || 0,
- accountType: account.type,
- accountNo: account.value,
- bankName: account.bankName,
- isDefault: account.type === 1 ? 1 : 2,
- });
- }
+ });
} else {
const regionChanged = addressForm.region !== addressForm.originalRegion;
await AddressEdit({
diff --git a/web/src/views/app-member/task-hall/index.vue b/web/src/views/app-member/task-hall/index.vue
index 0c1735f..cf28463 100644
--- a/web/src/views/app-member/task-hall/index.vue
+++ b/web/src/views/app-member/task-hall/index.vue
@@ -76,7 +76,7 @@
import { usePermission } from '@/hooks/web/usePermission';
import { useDictStore } from '@/store/modules/dict';
import { adaTableScrollX } from '@/utils/hotgo';
- import { Delete, Edit, List, View } from '@/api/taskHall';
+ import { Delete, Edit, List, Status, View } from '@/api/taskHall';
import { columns, loadOptions } from './model';
const message = useMessage();
@@ -100,7 +100,7 @@
};
const actionColumn = reactive({
- width: 180,
+ width: 260,
title: '操作',
key: 'action',
fixed: 'right',
@@ -114,6 +114,12 @@
auth: ['/taskHall/view'],
onClick: () => openEdit(row),
},
+ {
+ label: row.publishStatus === 3 ? '下线' : '发布',
+ type: row.publishStatus === 3 ? 'warning' : 'success',
+ auth: ['/taskHall/status'],
+ onClick: () => changeStatus(row),
+ },
{
label: '删除',
type: 'error',
@@ -237,6 +243,25 @@
});
}
+ function changeStatus(row) {
+ const publish = row.publishStatus !== 3;
+ dialog.warning({
+ title: publish ? '发布任务大厅' : '下线任务大厅',
+ content: `确定${publish ? '发布' : '下线'}“${row.name}”吗?`,
+ positiveText: '确定',
+ negativeText: '取消',
+ onPositiveClick: async () => {
+ await Status({
+ id: row.id,
+ version: row.version,
+ publishStatus: publish ? 3 : 4,
+ });
+ message.success(`${publish ? '发布' : '下线'}成功`);
+ reload();
+ },
+ });
+ }
+
onMounted(loadOptions);
diff --git a/web/src/views/app-member/task-hall/model.ts b/web/src/views/app-member/task-hall/model.ts
index a83a5ef..f196fbd 100644
--- a/web/src/views/app-member/task-hall/model.ts
+++ b/web/src/views/app-member/task-hall/model.ts
@@ -1,3 +1,5 @@
+import { h } from 'vue';
+import { NTag } from 'naive-ui';
import { renderImage } from '@/utils';
import { useDictStore } from '@/store/modules/dict';
@@ -14,7 +16,18 @@ export const columns = [
render: (row) => `${((row.commissionRate || 0) / 100).toFixed(2)}%`,
},
{ title: '简介', key: 'summary', width: 260, ellipsis: { tooltip: true } },
+ {
+ title: '状态',
+ key: 'publishStatus',
+ width: 100,
+ render: (row) =>
+ h(
+ NTag,
+ { type: dict.getType('bizPublishStatus', row.publishStatus), bordered: false },
+ { default: () => dict.getLabel('bizPublishStatus', row.publishStatus) || '-' }
+ ),
+ },
{ title: '创建时间', key: 'createdAt', width: 180 },
];
-export const loadOptions = () => dict.loadOptions(['memberLevelOption']);
+export const loadOptions = () => dict.loadOptions(['memberLevelOption', 'bizPublishStatus']);
diff --git a/web/src/views/app-member/url-pool/index.vue b/web/src/views/app-member/url-pool/index.vue
index 17e9d5e..34a9816 100644
--- a/web/src/views/app-member/url-pool/index.vue
+++ b/web/src/views/app-member/url-pool/index.vue
@@ -62,6 +62,11 @@
+
+
+ 取消
+
+
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 @@