Compare commits

..
24 Commits
Author SHA1 Message Date
dev d35cad4615 Merge remote-tracking branch 'origin/main' into pro
Deploy olons-admin production / deploy (push) Successful in 5m17s
2026-10-19 10:30:26 +08:00
richjohnrambo eead3dd798 feat: add product price range filter 2026-10-19 10:30:14 +08:00
dev 2483d09c39 Merge remote-tracking branch 'origin/main' into pro
Deploy olons-admin production / deploy (push) Successful in 5m27s
2026-10-19 10:15:22 +08:00
richjohnrambo 661486e46e fix: exclude manual products from product list 2026-10-19 10:14:55 +08:00
Your Name 3bc96f5325 test: align deployment contract with production
Deploy olons-admin production / deploy (push) Successful in 32s
2026-10-19 02:54:38 +08:00
richjohnrambo 76032d372f Merge remote-tracking branch 'refs/remotes/origin/main' into HEAD
Deploy olons-admin production / deploy (push) Successful in 8m1s
2026-10-19 00:31:29 +08:00
Your Name 91fc53b20c fix: persist logs without caller path
Deploy olons-admin production / deploy (push) Successful in 6m0s
2026-10-19 00:17:48 +08:00
richjohnrambo 0ce6bca10e feat: persist product claimed counts 2026-10-19 00:02:35 +08:00
Your Name e3898b618c ci: retry transient registry failures
Deploy olons-admin production / deploy (push) Successful in 47s
2026-10-18 23:55:22 +08:00
richjohnrambo ac152d2f10 feat: adjust manual and automatic order matching 2026-10-18 20:17:17 +08:00
Your Name c8d32eb823 ci: deploy pro with docker compose
Deploy olons-admin production / deploy (push) Failing after 40s
2026-10-18 20:11:07 +08:00
richjohnrambo 48f304212e feat: refine member and trade management 2026-10-18 18:11:34 +08:00
richjohnrambo 2b3d855bcc fix: resolve admin acceptance defects 2026-10-16 10:14:41 +08:00
richjohnrambo c6ec45cccf feat: use six-digit numeric member invite codes 2026-10-15 18:41:52 +08:00
richjohnrambo 639c46fd88 feat: add digital RMB payment account fields 2026-10-15 15:03:39 +08:00
richjohnrambo f8b5e4270b feat: add member URL pool and login tracking 2026-10-15 10:41:29 +08:00
richjohnrambo ca0d115e36 fix: wrap product action buttons 2026-10-14 23:20:17 +08:00
richjohnrambo a944241a0e feat: add product publish controls 2026-10-14 19:18:53 +08:00
richjohnrambo 131168c700 feat: add customer service IM bindings 2026-10-14 16:46:20 +08:00
richjohnrambo ad6d0bbe01 feat: 完善手动派单后台操作 2026-10-13 22:13:47 +08:00
richjohnrambo aa4799f2b9 fix: restrict manual order matching 2026-10-13 15:45:55 +08:00
richjohnrambo 044242661c feat: configure member dispatch delay 2026-10-13 15:18:34 +08:00
richjohnrambo 40bfa4cba0 feat: 完善会员交易与任务管理流程 2026-10-13 14:20:27 +08:00
Your Name df95801672 deploy: add k3s test image pipeline 2026-10-11 13:49:39 +08:00
129 changed files with 8115 additions and 623 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.gitea
.idea
.vscode
**/.DS_Store
web/node_modules
web/dist
server/temp
server/logs
server/storage/cache
server/storage/diskqueue
server/resource/public/admin
docs
tools
*.docx
*.pdf
+84
View File
@@ -0,0 +1,84 @@
name: Deploy olons-admin production
on:
push:
branches:
- pro
workflow_dispatch:
concurrency:
group: production-olons-admin
cancel-in-progress: false
permissions:
contents: read
env:
REGISTRY: git.olns.shop
IMAGE: git.olns.shop/pro/olons_admin
SERVICE: olons-admin
DEPLOY_HOST: 145.223.69.9
DEPLOY_USER: olns-deploy
jobs:
deploy:
runs-on: deploy-host
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
- name: Build and push image
env:
GITHUB_SHA: ${{ github.sha }}
run: |
set -euo pipefail
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
docker build -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker push "$IMAGE:pro"
docker push "$IMAGE:$SHORT_SHA"
- name: Deploy Compose service
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
install -d -m 700 "$HOME/.ssh"
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
cleanup() {
shred -u "$KEY_FILE" 2>/dev/null || true
}
trap cleanup EXIT
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
for attempt in 1 2 3; do
if printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"docker login git.olns.shop -u admin --password-stdin"; then
break
fi
if [[ "$attempt" -eq 3 ]]; then
exit 1
fi
sleep "$((attempt * 5))"
done
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
'cd /opt/olns && for attempt in 1 2 3; do
if timeout 180 docker compose pull olons-admin; then
break
fi
if [ "$attempt" -eq 3 ]; then
exit 1
fi
sleep $((attempt * 5))
done && docker compose up -d --no-deps --wait olons-admin'
+36
View File
@@ -0,0 +1,36 @@
FROM node:20-alpine AS web-build
WORKDIR /web
RUN corepack enable && corepack prepare pnpm@10.18.3 --activate
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY web/ ./
RUN pnpm run build
FROM golang:1.24-alpine AS server-build
WORKDIR /src
COPY server/go.mod server/go.sum ./
RUN go mod download
RUN go install github.com/gogf/gf/cmd/gf/v2@v2.10.0
COPY server/ ./
COPY --from=web-build /web/dist/ ./resource/public/admin/
RUN gf pack resource internal/packed/packed.go --keepPath=true \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/hotgo .
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S hotgo \
&& adduser -S -G hotgo hotgo
WORKDIR /app
COPY --from=server-build /out/hotgo /app/hotgo
COPY --from=server-build /src/manifest /app/manifest
COPY --from=server-build /src/resource /app/resource
COPY --from=web-build /web/dist/ /app/resource/public/admin/
RUN mkdir -p /app/storage /app/logs \
&& chown -R hotgo:hotgo /app
USER hotgo
EXPOSE 8000
ENTRYPOINT ["/app/hotgo"]
CMD ["all"]
@@ -0,0 +1,864 @@
# Olons 全功能测试用例
> 文档版本:V1.1
> 编制日期:2026-10-16
> 覆盖范围:Olons C 端、HotGo 管理后台、业务 API、数据库一致性、客服 IM 及跨端业务闭环
> 代码基线:`olons_server` 与 `olons_admin` 当前工作区实现
> 用途:测试执行、联调验收、发布前回归和缺陷复测
## 1. 测试目标与范围
本用例集以当前代码路由、服务端校验、页面按钮和后台菜单为准,重点验证以下内容:
- C 端注册登录、首页、领取、订单、商城、储入、取出、个人中心、内容页和在线客服。
- 后台首页、门户、会员、等级、任务大厅、交易、商品、任务、组织、权限、系统应用和系统设置。
- 后台配置、审核及上下架操作对 C 端展示和业务状态的实时影响。
- 金额、字符长度、文件大小、分页、时间窗口、次数限制等边界值。
- 未登录、越权、重复提交、并发、超时、断网、服务异常及脏数据场景。
- 钱包余额、冻结额、业务单、账变流水、审核记录和状态机的一致性。
- Olons 与 118IM 之间的账号创建、好友关系、会话、文本及媒体消息链路。
本轮不包含第三方真实支付清算、短信供应商实发、压力容量定标和渗透测试,但保留相应接口降级与安全验收项。
当前共 548 条用例,其中页面逐项验收 188 条(通用页面 8 条、C 端页面 90 条、后台页面 90 条),其余 360 条用于补充业务规则、接口、安全、数据一致性和跨端闭环。执行时应先完成第 4 章页面用例,再执行对应模块的深层用例。
## 2. 测试依据
| 类型 | 依据 |
| --- | --- |
| C 端路由 | `olons_server/web/src/router/index.ts` |
| C 端接口 | `olons_server/internal/httpapi/` |
| C 端业务规则 | `olons_server/internal/accounts`、`finance`、`tasks`、`mall`、`support`、`attachments` |
| 后台页面 | `olons_admin/web/src/views/` |
| 后台接口 | `olons_admin/server/api/admin/`、`server/internal/logic/sys/` |
| 菜单与初始化 | `olons_admin/server/storage/data/migrations/`、`seeds/` |
| 产品基线 | `后台管理系统需求文档.md`、`后台管理系统概要设计.md`、`后台功能与数据库表映射.md` |
| IM 对接 | `olons_server/docs/IM-前后端对接文档.md`、`Olons-客服IM系统对接设计.md` |
## 3. 执行约定
### 3.1 优先级
| 优先级 | 定义 | 发布要求 |
| --- | --- | --- |
| P0 | 登录、资金、权限、订单结算、数据泄露等核心链路 | 必须 100% 通过 |
| P1 | 主要业务功能及重要异常分支 | 不得存在阻断性缺陷 |
| P2 | 次要功能、显示、兼容和易用性 | 可评估后带已知问题发布 |
### 3.2 通用前置条件
- 浏览器时间、服务端时间和数据库时间同步,业务时区按 Asia/Shanghai 验证。
- 准备独立测试租户,禁止在生产真实会员上执行资金、删除、禁用和权限用例。
- 每个资金用例执行前记录钱包 `available_amount`、`frozen_amount`、`version` 和最近流水。
- 每个配置联动用例记录配置版本号、发布时间及 C 端接口响应,执行后恢复原配置。
- 所有 API 写请求记录 `request_id`;幂等接口记录幂等键、业务单号及返回结果。
### 3.3 测试账号与数据矩阵
| 数据代号 | 准备要求 |
| --- | --- |
| U-NORMAL | 正常真人会员,已设置登录密码和交易密码,已绑已验证收款账户,余额充足 |
| U-NEW | 新注册会员,无交易密码、无收款账户、无订单 |
| U-LOW | 可用余额低于领取/提现下限 |
| U-LIMIT | 当日领取次数或提现次数已达到等级上限 |
| U-PENDING | 存在匹配中或待处理订单 |
| U-DISABLED | 会员状态禁用或钱包状态禁用 |
| U-FAKE | 假人类别会员,用于统计隔离验证 |
| U-MANUAL | 派单模式为手动 |
| U-AUTO | 派单模式为自动 |
| URL-ENABLED | 地址池中的启用地址,域名未绑定或已绑定少量会员 |
| URL-INVALID | 地址池中的失效地址,保留历史绑定会员 |
| URL-UNKNOWN | 未录入地址池的注册域名,用于验证注册成功但不建立绑定 |
| XLS-URL | 地址池导入文件,包含合法、重复、非法 URL、空值和超长数据 |
| A-SUPER | 超级管理员,拥有全部菜单和按钮权限 |
| A-PARENT | 父角色管理员,只有部分业务菜单 |
| A-CHILD | A-PARENT 的子角色,权限必须为父角色子集 |
| A-CS/A-FIN/A-OPS | 客服、财务、运营最小权限账号 |
| P-ON/P-OFF | 已发布商品/已下架商品 |
| T-ON/T-OFF | 已发布任务/已下线任务 |
| IM-3 | 客服依依、客服琪琪、客服琳琳,均已绑定 IM 用户及头像 |
## 4. 页面逐项验收主用例
本章是测试人员的主要执行入口,按照“进入页面 → 检查默认展示 → 操作页面控件 → 检查弹窗 → 验证成功/失败反馈 → 返回或刷新”的顺序执行。后续章节用于补充接口、状态机、并发和数据一致性,不应替代本章的页面操作。
### 4.1 每个页面都必须执行的八项检查
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-COM-001 | P1 | 从菜单、快捷入口或站内链接进入页面 | 路由、页面标题、面包屑和菜单高亮正确 |
| UI-COM-002 | P1 | 首次进入页面并观察加载过程 | 有加载反馈;加载结束后不会永久转圈或闪烁 |
| UI-COM-003 | P1 | 检查页面全部文字、金额、时间、图片、状态标签 | 无错别字、乱码、单位错误、破图或状态空白 |
| UI-COM-004 | P1 | 逐个点击页面可见按钮、链接、图标和表格行 | 每个控件均有响应,目标页面或弹窗正确 |
| UI-COM-005 | P1 | 打开弹窗后测试右上角关闭、取消、遮罩和 Esc | 按约定关闭;未保存内容不产生后台写入 |
| UI-COM-006 | P1 | 对页面输入框执行空值、合法值、边界值和非法值 | 校验位置与文案明确,错误输入不能提交 |
| UI-COM-007 | P1 | 模拟空数据、接口失败、超时和无权限 | 分别显示空态、错误态、重试或无权限提示 |
| UI-COM-008 | P2 | 刷新、后退、前进、切换标签页和改变窗口宽度 | 页面不白屏;数据和布局按约定恢复 |
### 4.2 C 端页面逐项测试
#### 4.2.1 登录与注册页 `/app/auth`
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-AUTH-001 | P1 | 打开登录页 | 仅显示账号登录/注册业务控件,不出现微信、抖音、手机号登录和演示角色入口 |
| UI-C-AUTH-002 | P1 | 点击“账号登录”“注册账号”“已有账户,马上登录” | 登录与注册表单正确切换,已有输入按页面约定保留或清空 |
| UI-C-AUTH-003 | P1 | 登录模式检查手机号、密码框和密码显隐图标 | 占位符、输入类型、显隐切换和键盘类型正确 |
| UI-C-AUTH-004 | P0 | 输入合法账号并点击“登录” | 按钮显示“请稍候”并禁用,成功进入原目标页 |
| UI-C-AUTH-005 | P1 | 输入错误账号并点击“登录” | 页面停留,错误提示清楚,密码不明文残留在日志或 URL |
| UI-C-AUTH-006 | P1 | 注册模式检查姓名、手机号、密码、邀请码、协议勾选框 | 字段与当前注册规则一致,邀请码和协议为必填 |
| UI-C-AUTH-007 | P0 | 未勾选协议点击“注册” | 页面阻止注册并定位协议区域 |
| UI-C-AUTH-008 | P1 | 点击隐私协议/用户协议链接后返回 | 内容页可读,返回后注册表单状态合理保留 |
#### 4.2.2 首页 `/app/home`
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-HOME-001 | P1 | 打开首页并等待加载完成 | 顶部信息、Banner、快捷功能、滚动信息、内容入口和底部导航完整显示 |
| UI-C-HOME-002 | P1 | 左右滑动 Banner、点击圆点和 Banner 本体 | 切换流畅;有效跳转正确;无跳转 Banner 不误跳 |
| UI-C-HOME-003 | P1 | 点击搜索框 | 进入商城或约定搜索页面,返回首页位置正确 |
| UI-C-HOME-004 | P0 | 依次点击“领取、储入、取出、商城” | 分别进入领取、储入、取出、商城页面 |
| UI-C-HOME-005 | P1 | 后台关闭四个功能后检查对应入口 | 置灰且无法进入;置灰文字仍可辨识 |
| UI-C-HOME-006 | P1 | 点击平台介绍、规则说明、公司资质、代理合作 | 均进入正确内容页,标题和正文相符 |
| UI-C-HOME-007 | P1 | 点击滚动公告/弹窗通知 | 展开对应详情,不跳到错误会员或订单页面 |
| UI-C-HOME-008 | P1 | 点击底部“首页、记录、领取、客服、我的” | 五个页面均可达,当前项高亮正确 |
#### 4.2.3 领取页 `/app/claim`
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-CLAIM-001 | P1 | 打开领取页 | 总资产、昨日佣金、今日佣金、今日领取数和领取主按钮完整显示 |
| UI-C-CLAIM-002 | P0 | 满足条件时点击领取主按钮 | 按钮进入加载态,不能重复点击;随后展示匹配结果 |
| UI-C-CLAIM-003 | P1 | 不满足余额/等级/营业时间/次数条件时点击领取 | 弹层显示对应原因,不出现笼统“失败” |
| UI-C-CLAIM-004 | P1 | 有在途订单时点击“查看任务” | 进入该订单详情,不新建第二张订单 |
| UI-C-CLAIM-005 | P1 | 匹配中观察进度、等待和倒计时区域 | 状态能刷新,离开再返回仍能恢复当前订单 |
| UI-C-CLAIM-006 | P1 | 结果失败时点击“重新领取” | 重新检查资格;按钮加载态和结果更新正确 |
| UI-C-CLAIM-007 | P1 | 点击“稍后去记录查看”“查看记录”“返回首页” | 分别进入订单列表、订单详情或首页 |
#### 4.2.4 订单列表与详情 `/app/orders`、`/app/orders/:id`
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-ORDER-001 | P1 | 打开订单列表 | 默认标签、订单卡片、状态、金额、商品图和时间正确 |
| UI-C-ORDER-002 | P1 | 依次点击“全部、待处理、已完成” | 列表只显示对应订单;无数据时显示空态 |
| UI-C-ORDER-003 | P1 | 点击任一订单卡片 | 进入该订单详情,订单号和列表所选记录一致 |
| UI-C-ORDER-004 | P1 | 详情页检查商品、金额、佣金、状态、时间线和流水 | 字段完整,长文本/图片不撑破布局 |
| UI-C-ORDER-005 | P0 | 可提交的手动订单点击“提交确认” | 打开二次确认弹窗,金额和提示与当前订单一致 |
| UI-C-ORDER-006 | P1 | 在确认弹窗点击“暂不提交”、关闭和遮罩 | 弹窗关闭,订单和余额不变 |
| UI-C-ORDER-007 | P0 | 点击“确认提交” | 按钮防重复;成功提示、状态和余额立即更新 |
| UI-C-ORDER-008 | P1 | 余额不足时查看按钮并尝试提交 | 按钮显示“余额不足,无法提交”或引导储入,不错误扣款 |
| UI-C-ORDER-009 | P1 | 冻结、超时、取消、失败订单进入详情 | 不显示不允许的提交按钮,提供客服入口或明确说明 |
| UI-C-ORDER-010 | P1 | 点击详情返回按钮和浏览器后退 | 返回原列表及原标签位置 |
#### 4.2.5 商城列表与详情 `/app/mall`、`/app/mall/:id`
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-MALL-001 | P1 | 打开商城 | 搜索框、搜索按钮、推荐任务/限时任务标签和商品卡片显示完整 |
| UI-C-MALL-002 | P1 | 输入关键词后点击“搜索”及清空关键词重搜 | 结果按关键词过滤,清空后恢复全部 |
| UI-C-MALL-003 | P1 | 切换“推荐任务”“限时任务” | 商品集合和按钮文字同步变化,无重复或串页 |
| UI-C-MALL-004 | P1 | 点击商品图片、名称、“领取/抢单” | 均进入同一商品详情 |
| UI-C-MALL-005 | P1 | 详情页检查图片、名称、店铺、价格、说明、时限 | 与后台发布内容一致,富文本安全显示 |
| UI-C-MALL-006 | P1 | 商品已下架或过期时刷新详情 | 显示不可用状态,不允许继续领取 |
#### 4.2.6 储入页 `/app/recharge`
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-RC-001 | P1 | 打开储入页 | 储入方式、金额、姓名和提交按钮完整显示 |
| UI-C-RC-002 | P1 | 展开“储入方式”下拉并逐项选择 | 只列启用渠道;所选值和渠道提示同步更新 |
| UI-C-RC-003 | P1 | 金额留空、越界、输入字母后点击提交 | 页面在金额字段提示,不发起有效订单 |
| UI-C-RC-004 | P1 | 姓名留空或输入超长内容 | 阻止提交并显示字段错误 |
| UI-C-RC-005 | P0 | 填写合法数据点击“提交” | 按钮显示提交中并禁用;成功提示后跳转客服中心 |
| UI-C-RC-006 | P1 | 接口失败后观察页面 | 不跳客服;表单可修改并重新提交 |
#### 4.2.7 取出页 `/app/profile/withdrawal`
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-WD-001 | P1 | 打开取出页 | 可用余额、金额输入、全部提现、收款账户和申请按钮显示完整 |
| UI-C-WD-002 | P1 | 点击“全部提现” | 输入框填入当前可用余额;如超过渠道上限,报价时明确提示而不创建取出单 |
| UI-C-WD-003 | P1 | 查看收款账户区域并点击 | 有已核验账户时显示银行及尾号;无账户时进入绑定信息页 |
| UI-C-WD-004 | P1 | 输入合法金额点击“申请提现” | 出现确认弹窗,申请金额、手续费和预计到账金额与报价一致 |
| UI-C-WD-005 | P1 | 未设置支付密码点击“去设置支付密码”,填写 6 位数字密码和正确登录密码后点击“确认设置” | 原弹窗切换为设置表单;设置成功返回确认申请界面并保留报价,不自动提交取出单 |
| UI-C-WD-006 | P1 | 分别在确认取出和设置支付密码界面点击右上角关闭叉号;重新打开后点击“返回确认申请” | 两种界面均可关闭;密码输入被清空、未创建取出单;返回确认时保留报价且仍需再次点击“确认申请” |
| UI-C-WD-007 | P0 | 输入支付密码并点击“确认申请” | 加载态防重复;成功关闭弹层并更新余额/记录 |
| UI-C-WD-008 | P1 | 密码错误、次数受限、报价过期 | 分别显示明确提示,弹窗状态可恢复或重新报价 |
| UI-C-WD-009 | P1 | 未设置支付密码时查看“确认申请”,或设置时输入非 6 位密码/错误登录密码 | 未设置时确认按钮不可提交;格式错误在本地提示,接口失败后保留设置界面且不创建取出单 |
| UI-C-WD-010 | P1 | 设置或提交请求进行中连续点击关闭叉号与主按钮 | 请求期间关闭和重复提交被禁止;请求结束后可按状态继续操作 |
#### 4.2.8 我的页面 `/app/profile`
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-ME-001 | P1 | 打开“我的” | 用户名、邀请码、账户余额及功能菜单完整显示 |
| UI-C-ME-002 | P2 | 点击邀请码复制按钮 | 显示复制成功,剪贴板内容为当前邀请码 |
| UI-C-ME-003 | P0 | 点击“立即取出”“立即储入” | 分别进入取出和储入页面 |
| UI-C-ME-004 | P1 | 点击个人信息、储入记录、取出记录、账户明细、弹窗明细 | 五个入口分别进入正确页面 |
| UI-C-ME-005 | P1 | 点击“退出登录”后点击取消 | 弹窗关闭并保持登录 |
| UI-C-ME-006 | P0 | 点击“退出登录”后点击确定 | 清除会话并进入登录页,后退不能看到有效敏感页面 |
#### 4.2.9 个人信息、银行卡与密码页面
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-FORM-001 | P1 | 打开个人信息页 | 头像、本人手机号、取出信息、密码管理、支付密码管理按原型分组展示;头像行和三个入口均可点击 |
| UI-C-FORM-002 | P1 | 点击头像行选择合法图片,等待上传完成并刷新页面 | 文件选择器可打开;图片先上传附件再更新本人头像,成功提示且刷新后仍显示新头像 |
| UI-C-FORM-003 | P1 | 打开绑定信息页 | 已绑定账户先显示本人原始信息;新增表单的姓名、身份证、卡号、开户行、选填数字人民币编码、微信码、支付宝码字段齐全 |
| UI-C-FORM-004 | P1 | 点击两处“点击上传”并分别选图 | 上传中、预览和失败重试状态正确,两张图不会串位 |
| UI-C-FORM-005 | P0 | 填完银行卡信息点击“确认绑定” | 按钮防重复;成功后从个人信息进入的返回个人信息,从取出进入的返回取出;本人绑定信息页显示原文,通用账户列表仍脱敏 |
| UI-C-FORM-006 | P1 | 打开修改登录密码页并点击“确定修改” | 当前密码、新密码、确认密码校验正确,成功后给出明确反馈 |
| UI-C-FORM-007 | P1 | 新密码和确认密码不一致 | 页面本地拦截,不请求修改接口 |
| UI-C-FORM-008 | P1 | 打开支付密码页 | 新支付密码、登录密码、已设置时的旧支付密码展示逻辑正确 |
| UI-C-FORM-009 | P0 | 填写合法信息点击“确定” | 按钮防重复,成功后返回且支付密码状态更新 |
| UI-C-FORM-010 | P0 | 点击个人信息中的“取出信息”查看已绑定账户 | 本人姓名、身份证号、银行名、完整银行卡号、已填的数字人民币编码和两张收款码图片正确展示,不以星号替代原值 |
| UI-C-FORM-011 | P1 | 无绑定账户或有多张账户时打开取出信息 | 无账户时可直接新增;多账户逐条对应展示,不串姓名、卡号和收款码 |
| UI-C-FORM-012 | P1 | 数字人民币编码留空后提交绑卡 | 页面不把该字段当必填;成功后详情不出现伪造的编码,其他必填项仍正常校验 |
| UI-C-FORM-013 | P1 | 头像选择 GIF、伪装扩展名、超过 5MB 图片,或模拟上传/更新接口失败 | 明确提示失败;旧头像不变,可重新选择文件重试 |
#### 4.2.10 记录、账变、通知和内容页
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-REC-001 | P1 | 从“储入记录”进入并切换记录标签 | 默认定位储入,状态、金额和时间正确 |
| UI-C-REC-002 | P1 | 从“取出记录”进入并切换记录标签 | 默认定位取出,手续费、实到和原因正确 |
| UI-C-REC-003 | P1 | 点击记录行查看详情 | 进入对应订单详情或展开详情,记录 ID 不串行 |
| UI-C-REC-004 | P1 | 账户明细筛选收入/支出并翻页 | 筛选和分页正确,前后余额可读 |
| UI-C-REC-005 | P1 | 弹窗明细打开未读通知并返回 | 正文可见,已读状态更新,列表位置合理保留 |
| UI-C-REC-006 | P1 | 平台介绍、规则、代理、资质和通用内容页点击返回 | 标题、正文和返回路径正确 |
#### 4.2.11 客服列表与聊天页 `/app/support`、`/app/support/:id`
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-C-CS-001 | P1 | 打开客服列表 | 三名客服名称、头像、在线/忙碌状态完整显示 |
| UI-C-CS-002 | P1 | 依次点击三个客服卡片并返回 | 分别进入对应会话,标题和头像不串客服 |
| UI-C-CS-003 | P1 | 连接失败时点击“重新连接” | 显示连接过程,成功恢复输入区;失败可再次操作 |
| UI-C-CS-004 | P1 | 输入文字,检查发送按钮显隐并点击 | 有文本时显示清晰的发送按钮;发送中禁用 |
| UI-C-CS-005 | P1 | 输入框为空时点击“+” | 展开表情、照片、视频工具;再次点击按约定收起 |
| UI-C-CS-006 | P1 | 点击“表情”并切换最近/表情/手势/物品 | 面板紧凑显示,选中表情插入输入框 |
| UI-C-CS-007 | P1 | 点击“照片”“视频”并取消系统文件选择 | 不发送空消息,工具区恢复可用 |
| UI-C-CS-008 | P0 | 选择合法照片或视频 | 显示上传进度,发送后本方气泡和客服端均可见 |
| UI-C-CS-009 | P1 | 点击图片、播放视频、滚动历史消息 | 预览/播放正常,滚动不遮挡底部输入区 |
| UI-C-CS-010 | P1 | 检查双方头像、左右气泡、时间和发送状态 | 客服在左、本人在右;头像及已发送/失败状态清楚 |
### 4.3 后台页面逐项测试
#### 4.3.1 后台登录与全局框架
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-A-FRAME-001 | P1 | 打开后台登录页 | 标题为“企业办公管理系统”,只显示账号登录表单 |
| UI-A-FRAME-002 | P0 | 填写账号密码点击登录 | 加载态防重复;成功进入首页,失败留在登录页 |
| UI-A-FRAME-003 | P1 | 逐个展开一级菜单 | 二级菜单、图标、顺序和折叠状态正确 |
| UI-A-FRAME-004 | P1 | 依次点击主页、会员、交易、商品、任务下全部二级菜单 | 每个菜单可打开,面包屑和标签页标题正确 |
| UI-A-FRAME-005 | P1 | 点击已打开标签、关闭单个、关闭其他、刷新标签 | 标签行为正确,不关闭固定首页 |
| UI-A-FRAME-006 | P1 | 点击全局菜单搜索并搜索页面名称 | 结果正确,点击进入目标页面 |
| UI-A-FRAME-007 | P1 | 点击右上角头像/个人中心/退出 | 个人中心可达;退出确认后回登录页 |
#### 4.3.2 首页与门户管理
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-A-HOME-001 | P1 | 打开主控台,逐个检查指标卡、图表和刷新 | 数据、单位、时间范围、刷新时间正确 |
| UI-A-HOME-002 | P1 | 点击可跳转指标卡 | 进入对应业务列表并携带正确筛选条件 |
| UI-A-SITE-001 | P1 | 打开网站设置,检查所有分组和默认值 | 品牌、交易、佣金、时段、门槛和开关字段完整回显 |
| UI-A-SITE-002 | P1 | 修改字段后点击保存/取消/重置 | 保存有反馈;取消/重置不写入;刷新结果符合选择 |
| UI-A-SITE-003 | P1 | 点击历史、提交、发布、回滚入口 | 对应列表/弹窗完整,按钮只在合法状态可用 |
| UI-A-TEXT-001 | P1 | 打开文本管理,使用查询、重置和分页 | 仅显示文本字段和 12 条初始化数据,内容标识列隐藏 |
| UI-A-TEXT-002 | P1 | 点击新增/编辑,检查弹窗和所有字段 | 弹窗足够宽,无图片字段,标题和正文可编辑 |
| UI-A-TEXT-003 | P1 | 点击确定、取消、关闭、发布、下线、删除 | 每个动作提示和列表刷新正确,危险动作有确认 |
| UI-A-BANNER-001 | P1 | 打开 Banner 设置并测试查询、添加、编辑 | 图片、位置、链接、时段、排序和状态字段齐全 |
| UI-A-BANNER-002 | P1 | 测试上传、预览、更换和移除图片 | 预览正确,取消不保存,失败可重试 |
| UI-A-BANNER-003 | P1 | 测试发布/下线/删除及确认弹窗 | 状态更新且 C 端同步,取消确认无副作用 |
| UI-A-MANUAL-001 | P1 | 打开手工商城并测试查询、添加、编辑 | 名称、价格、展示图片和时间字段完整 |
| UI-A-MANUAL-002 | P1 | 测试发布/下架/删除和分页 | 按钮状态与商品状态匹配,列表刷新正确 |
#### 4.3.3 会员管理页面
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-A-MEMBER-001 | P1 | 逐项填写会员查询条件并点击查询/重置 | 每项条件有效,重置清空并回第一页 |
| UI-A-MEMBER-002 | P1 | 拖动表格横向滚动条和操作列独立滚动条 | 所有数据列和操作按钮可达,固定列不漂移 |
| UI-A-MEMBER-003 | P1 | 点击添加、详情、编辑 | 对应弹窗字段完整,详情只读,编辑正确回显 |
| UI-A-MEMBER-004 | P1 | 点击“更多”或操作弹层 | 银行卡、地址、团队、账变、弹幕、奖励、真人/假人、启禁用、删除均可见 |
| UI-A-MEMBER-005 | P1 | 逐个打开银行卡、地址、团队弹窗并关闭 | 标题、表格、按钮和关闭行为正确 |
| UI-A-MEMBER-006 | P0 | 点击授权查看明文,未勾选确认或未填用途 | 确认按钮不可提交或明确校验 |
| UI-A-MEMBER-007 | P1 | 打开账变、奖励金、弹幕、状态弹窗 | 金额/原因/内容/范围字段和风险提示完整 |
| UI-A-MEMBER-008 | P1 | 点击设为真人/假人、启用/禁用、删除并取消确认 | 取消后列表状态不变 |
| UI-A-MEMBER-009 | P1 | 点击分页、每页条数和跳页 | 总数、页码、选中行和滚动位置合理更新 |
| UI-A-MEMBER-010 | P1 | 输入完整或部分注册域名查询,并组合域名状态筛选 | 支持模糊匹配;待绑定、启用域名、失效域名结果准确 |
| UI-A-MEMBER-011 | P1 | 查看会员列表和详情中的注册域名、域名状态、绑定时间 | 三项信息一致;未绑定显示“待绑定”,不显示伪造域名 |
| UI-A-MEMBER-012 | P1 | 检查会员列表标题栏和编辑弹窗 | 后台不显示新增会员入口;只允许编辑已有会员 |
| UI-A-LEVEL-001 | P1 | 打开会员等级,点击新增和编辑 | 等级、金额、次数、佣金、状态字段完整回显 |
| UI-A-LEVEL-002 | P1 | 点击保存、取消、停用/启用、删除 | 每个动作反馈正确,删除显示引用限制 |
| UI-A-HALL-001 | P1 | 打开任务大厅,测试查询、新增、编辑和等级选择 | 字段、图标、比例和等级选择正确 |
| UI-A-HALL-002 | P1 | 测试单删、勾选多行、批量删除和取消 | 选中态与成功/失败数量提示准确 |
| UI-A-URL-001 | P1 | 打开 URL 地址池页面 | 汇总卡、查询栏、列表及操作栏完整;列表按累计绑定次数升序、ID 升序显示 |
| UI-A-URL-002 | P1 | 按地址名称、域名、状态和创建时间查询及重置 | 每项筛选有效,域名支持模糊匹配,重置恢复第一页 |
| UI-A-URL-003 | P1 | 点击新增/编辑,填写地址名称、注册入口 URL、状态和备注 | URL 被标准化并正确回显;非法协议、查询参数、片段或重复域名不能保存 |
| UI-A-URL-004 | P1 | 点击“详情”,查询绑定会员并翻页 | 显示累计/当前绑定次数、最后绑定时间及全部绑定会员;手机号脱敏 |
| UI-A-URL-005 | P1 | 点击“设为失效”或“启用”,再点击复制链接 | 状态立即更新;复制内容为标准化注册入口;页面无删除按钮 |
| UI-A-URL-006 | P1 | 打开 Excel 批量导入弹窗并检查说明、关闭和取消 | 明确必填表头、格式、大小和 5000 条限制;关闭不上传文件 |
| UI-A-URL-007 | P1 | 分别导入合法、重复和含错误行的 XLS-URL | 合法行导入且均为启用;重复跳过;错误行按 Excel 行号展示;汇总与列表刷新 |
| UI-A-MLOGIN-001 | P1 | 打开会员登录日志,按会员、方式、结果、域名、IP 和时间查询 | 筛选、重置、分页和空态正确,不与后台管理员登录日志混用 |
| UI-A-MLOGIN-002 | P1 | 查看成功和失败日志详情 | 请求 ID、会员、方式、结果、脱敏手机/IP、地区、域名、系统和浏览器完整准确 |
| UI-A-MLOGIN-003 | P1 | 按当前筛选条件导出会员登录日志 | 文件可打开,条数和字段与筛选结果一致,无 IP 密文、Token 或密码 |
#### 4.3.4 交易管理页面
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-A-ORDER-001 | P1 | 订单列表逐项测试筛选、查询、重置和分页 | 条件生效,状态和金额显示正确 |
| UI-A-ORDER-002 | P1 | 点击详情 | 订单、会员、商品、任务、金额、时间线和流水信息完整 |
| UI-A-ORDER-003 | P0 | 点击“匹配订单”并搜索、清理筛选、选择商品、取消 | 弹窗列表和选中态正确;取消不修改订单 |
| UI-A-ORDER-004 | P0 | 选择商品后点击确定 | 加载态防重复,成功关闭并刷新订单 |
| UI-A-ORDER-005 | P1 | 点击“更改金额”,测试输入、取消和确定 | 金额校验清楚,取消无副作用,成功后刷新 |
| UI-A-ORDER-006 | P0 | 点击“强制付款”“冻结订单”并取消/确认 | 风险文案清晰,取消无副作用,确认结果正确 |
| UI-A-ORDER-007 | P1 | 点击“审核任务”或等待会员付款禁用按钮 | 前者跳会员任务审核,后者不可误点 |
| UI-A-RC-001 | P1 | 充值列表测试筛选、汇总卡、详情和凭证预览 | 筛选准确,汇总与列表一致,凭证可查看 |
| UI-A-RC-002 | P0 | 点击“通过”,填写审核说明,取消/确认 | 取消不入账;确认加载态防重复并刷新状态 |
| UI-A-RC-003 | P0 | 点击“驳回”,测试空原因、合法原因和确认 | 必填校验有效,确认后状态和原因正确 |
| UI-A-WD-001 | P1 | 提现列表测试筛选、详情和收款信息展示 | 金额、手续费、实到、状态和脱敏信息正确 |
| UI-A-WD-002 | P0 | 点击通过/驳回,测试确认弹窗 | 按钮仅在合法状态显示,确认后刷新 |
| UI-A-WD-003 | P0 | 处理中记录点击完成并填写外部单号 | 必填校验、加载态和完成结果正确 |
| UI-A-CS-001 | P1 | 客服管理测试查询、新增和编辑 | 名称、头像、工作时间、在线状态、IM 绑定、排序和状态完整 |
| UI-A-CS-002 | P1 | 点击启用/停用 | 开关与状态标签同步,C 端列表同步 |
| UI-A-CS-003 | P1 | 点击删除,测试空原因、取消和确认 | 空原因阻止;取消无副作用;停用记录可删除 |
#### 4.3.5 商品与任务管理页面
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-A-PROD-001 | P1 | 商品列表填写名称/分类并点击查询 | 列表过滤正确,字段及总数完整 |
| UI-A-PROD-002 | P1 | 点击添加/编辑并逐项检查表单 | 商品、分类、价格、店铺、图片、状态、推荐等字段正确 |
| UI-A-PROD-003 | P1 | 点击发布/下架并取消/确认 | 弹窗文案对应当前状态,成功后按钮文字反转 |
| UI-A-PROD-004 | P1 | 点击推荐/不推荐 | 状态和按钮立即更新,不影响发布状态 |
| UI-A-PROD-005 | P1 | 点击删除或勾选后批量删除 | 已引用限制提示清楚,成功/失败数量准确 |
| UI-A-PROD-006 | P2 | 检查操作按钮换行和不同窗口宽度 | 编辑、发布/下架、推荐、删除始终可见可点 |
| UI-A-TASK-001 | P1 | 任务列表测试查询、新增、编辑 | 商品、等级、佣金、详情、时段等字段完整 |
| UI-A-TASK-002 | P1 | 点击发布/下线/删除并取消/确认 | 状态限制和引用提示正确,列表刷新 |
| UI-A-SUB-001 | P1 | 会员任务审核测试查询和详情 | 会员、订单、任务、证据、佣金、状态完整 |
| UI-A-SUB-002 | P0 | 待审记录点击审核,切换通过/驳回 | 提示文字和意见字段随结论变化 |
| UI-A-SUB-003 | P0 | 点击确认审核并连续点击 | 按钮防重复;成功关闭、刷新且只结算一次 |
| UI-A-SUB-004 | P1 | 已审核记录点击详情 | 只读展示结果和意见,不再显示可提交审核按钮 |
#### 4.3.6 组织、权限、系统应用和系统设置页面
| ID | 优先级 | 页面操作 | 预期结果 |
| --- | --- | --- | --- |
| UI-A-ORG-001 | P1 | 后台用户页测试查询、新增、编辑、启停、重置密码、删除 | 每个按钮按权限出现,弹窗和确认流程完整 |
| UI-A-ORG-002 | P1 | 部门页展开/收起树,新增同级/下级、编辑、删除 | 树层级和父节点选择正确 |
| UI-A-ORG-003 | P1 | 岗位页测试查询、新增、编辑、启停、删除 | 表单和状态操作正确 |
| UI-A-MENU-001 | P1 | 菜单页展开树,点击新增、编辑、删除 | 目录/菜单/按钮字段随类型正确显示 |
| UI-A-MENU-002 | P1 | 菜单图标选择、路由、组件、权限标识校验 | 图标预览和字段校验正确 |
| UI-A-ROLE-001 | P1 | 角色页新增/编辑/启停/删除 | 角色树、上级角色和基本字段正确 |
| UI-A-ROLE-002 | P0 | 点击“分配菜单”,勾选父节点、子节点、全选和取消 | 级联/半选正确,保存后重新打开保持一致 |
| UI-A-ROLE-003 | P0 | 子角色打开分配菜单弹窗 | 只能操作父角色权限子集,范围外不可选 |
| UI-A-ROLE-004 | P1 | 点击数据权限并切换范围 | 选项和部门树正确,保存后查询范围生效 |
| UI-A-APP-001 | P1 | 打开系统应用各二级菜单和个人中心 | 页面均可达,个人设置可保存和取消 |
| UI-A-DICT-001 | P1 | 字典类型和字典数据页测试查询、新增、编辑、启停、删除 | 类型与数据联动,弹窗和确认正确 |
| UI-A-CONFIG-001 | P1 | 系统配置逐个切换分组并保存 | 各分组字段无错位,保存反馈和重新加载正确 |
| UI-A-CRON-001 | P1 | 定时任务测试查询、新增、编辑、启停、执行、日志 | 每个按钮可达,执行结果和日志正确 |
| UI-A-BLACK-001 | P1 | 黑名单测试查询、新增、编辑、启停、删除 | 表单、确认、状态和列表刷新正确 |
| UI-A-LOG-001 | P1 | 操作/登录/短信/邮件/服务日志逐页查询、重置、详情 | 筛选、分页、详情和脱敏正确 |
| UI-A-ATT-001 | P1 | 附件页测试预览、下载、筛选和删除确认 | 图片/文件预览正确,被引用附件删除受限 |
## 5. 通用与基础能力测试
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| GEN-001 | P0 | 未登录直接访问任一需要登录的 C 端路由 | 跳转账号登录页,登录后回到原目标路由 |
| GEN-002 | P0 | 未登录调用任一 C 端受保护 API | 返回 401,响应不包含业务数据 |
| GEN-003 | P0 | 未登录直接访问后台业务路由 | 跳转后台登录页,不短暂显示受保护页面 |
| GEN-004 | P0 | 使用失效、伪造、被注销的 Token 调用接口 | 统一拒绝;前端清理会话并引导重新登录 |
| GEN-005 | P1 | 连续快速点击新增、保存、提交、审核按钮 | 按钮加载中禁用;只产生一次有效写入 |
| GEN-006 | P1 | 刷新列表、切换分页、返回上一页 | 查询条件、分页和数据状态符合页面约定,无重复行 |
| GEN-007 | P1 | 请求返回空数组 | 显示明确空状态,不报错、不展示演示假数据 |
| GEN-008 | P1 | API 返回 400/403/404/409/422/429/500 | 展示可理解提示;500 类保留 request_id;页面可重试 |
| GEN-009 | P1 | 请求中断、离线、超时后恢复网络 | 加载态可结束,可重新请求,不产生幽灵提交 |
| GEN-010 | P1 | 输入前后空格、全角字符、Emoji、HTML/脚本片段 | 按字段规则规范化或拒绝;展示时不执行脚本 |
| GEN-011 | P1 | 两个浏览器同时编辑同一带版本号记录 | 后提交者收到版本冲突并要求刷新,不静默覆盖 |
| GEN-012 | P1 | 租户 A 携带租户 B 的资源 ID 请求详情或编辑 | 返回无权限或不存在,绝不返回跨租户数据 |
| GEN-013 | P1 | 列表页横向滚动、窄屏及操作列按钮较多 | 所有按钮可达;操作列不遮挡,滚动条可操作 |
| GEN-014 | P2 | 页面刷新、前进后退、复制带参数 URL 打开 | 路由、标题、筛选参数正确,无白屏 |
| GEN-015 | P2 | 动态资源发版后旧页面加载过期 chunk | 最多自动刷新一次并恢复,10 秒内不循环刷新 |
## 6. C 端功能测试
### 6.1 注册、登录与会话
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| C-AUTH-001 | P0 | 用合法姓名、11 位手机号、合法密码、6 位邀请码并勾选协议注册 | 注册成功并建立会话;会员、钱包及上下级关系正确创建 |
| C-AUTH-002 | P1 | 姓名分别输入 0、1、10、11 个字符 | 1~10 个合法文字通过;0 和 11 拒绝 |
| C-AUTH-003 | P1 | 姓名含汉字、字母、组合音标及间隔点“·” | 允许;数字、空格、符号和脚本字符拒绝 |
| C-AUTH-004 | P1 | 手机号测试空值、10 位、11 位非 1 开头、第二位 0/2、含字母、合法 13~19 段 | 仅 `1[3-9]` 开头的 11 位数字通过 |
| C-AUTH-005 | P1 | 密码测试 5 个字符、6 个字符、72 字节、73 字节和多字节字符 | 少于 6 个字符或超过 72 字节拒绝;边界值按规则处理 |
| C-AUTH-006 | P1 | 邀请码测试空值、5 位、6 位、7 位、含字母及不存在的邀请码 | 仅存在的 6 位数字邀请码通过 |
| C-AUTH-007 | P0 | 未勾选隐私协议提交注册 | 注册被拒绝,数据库无会员和钱包残留 |
| C-AUTH-008 | P0 | 使用已注册手机号再次注册 | 明确提示已存在,不新增重复会员 |
| C-AUTH-009 | P0 | 正确手机号密码登录 | 登录成功,记录登录时间/日志,会话 Token 可访问受保护接口 |
| C-AUTH-010 | P0 | 正确手机号配错误密码、未注册手机号登录 | 统一凭证错误提示,不泄露手机号是否存在 |
| C-AUTH-011 | P0 | 同一手机号 15 分钟内连续错误登录 10 次,再试正确密码 | 第 10 次阈值后限流;返回 429;窗口后或策略解除后恢复 |
| C-AUTH-012 | P1 | 错误数未达阈值时成功登录,再次输错 | 成功登录清除此前失败计数 |
| C-AUTH-013 | P0 | 登出后使用旧 Token 请求 `/v1/me` | 旧 Token 失效;返回登录页 |
| C-AUTH-014 | P1 | 会话自然过期时停留在业务页继续操作 | 不提交业务;提示登录失效并跳转登录页 |
| C-AUTH-015 | P2 | 身份验证/短信找回入口点击获取验证码 | 当前未开放时明确提示暂未开放,不显示伪成功、不修改密码 |
| C-AUTH-016 | P0 | 使用 URL-ENABLED 的注册入口完成注册 | 注册成功;会员、钱包和 `hg_member_url_binding` 在同一事务创建,返回绑定成功及标准化域名 |
| C-AUTH-017 | P0 | 从 URL-UNKNOWN 域名或缺少可识别来源的页面注册 | 会员和钱包仍创建成功,不生成域名绑定,不因地址池无匹配记录阻断注册 |
| C-AUTH-018 | P0 | 从 URL-INVALID 的域名注册 | 注册成功但不建立绑定;失效地址累计绑定次数和最后绑定时间不变化 |
| C-AUTH-019 | P1 | 使用大小写域名、末尾点、默认 80/443 端口注册 | 域名按统一规则规范化并命中同一地址,不产生重复地址或重复绑定 |
| C-AUTH-020 | P0 | 同一注册请求重复提交或事务中模拟绑定写入失败 | 不重复创建会员/钱包/绑定;绑定写入失败时整个注册事务回滚 |
| C-AUTH-021 | P0 | 账号密码登录成功 | 更新会员最后登录时间、IP 密文、IP 脱敏值和地区,并新增一条成功会员登录日志 |
| C-AUTH-022 | P1 | 使用错误密码、禁用会员或风控会员登录 | 登录失败且不更新会员主表最后登录字段;分别记录脱敏的失败日志和可理解原因 |
| C-AUTH-023 | P1 | 手机验证码登录成功、验证码错误或过期 | 成功/失败均按手机登录方式记录独立日志;成功时更新会员最后登录信息 |
### 6.2 首页、内容、通知与排行榜
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| C-HOME-001 | P1 | 打开首页 | Banner、用户摘要、快捷入口、公告/滚动信息及导航正常加载 |
| C-HOME-002 | P1 | 后台关闭领取、储入、取出、商城开关后刷新首页 | 对应入口置灰且不可点击,其他入口不受影响 |
| C-HOME-003 | P1 | 重新开启功能开关 | 对应入口恢复可点击并进入正确页面 |
| C-HOME-004 | P1 | Banner 未到生效时间、已过期、已下线 | C 端不展示;生效期内按排序展示 |
| C-HOME-005 | P1 | Banner 无跳转、内部路由、合法 HTTPS 外链 | 分别不跳转、站内跳转、受控打开外链;非法协议不执行 |
| C-HOME-006 | P2 | Banner 图片失效或加载失败 | 显示占位,不导致页面布局塌陷 |
| C-HOME-007 | P1 | 后台发布平台介绍、规则、代理合作、公司资质文本 | C 端对应内容页显示最新已发布版本 |
| C-HOME-008 | P1 | 后台保存草稿但不发布 | C 端继续显示旧已发布版本 |
| C-HOME-009 | P1 | 下线内容或内容不存在 | C 端显示友好空状态,不泄露草稿正文 |
| C-HOME-010 | P1 | 发布全局通知和指定会员通知 | 目标会员可见;非目标会员不可见;已过有效期不展示 |
| C-HOME-011 | P1 | 标记通知已读并刷新 | 已读状态保持;未读数量正确减少,不影响其他会员 |
| C-HOME-012 | P1 | 排行榜混合真人、假人、禁用用户和同分用户 | 按产品口径展示;禁用/应排除数据不出现;同分排序稳定 |
### 6.3 领取、订单与任务状态机
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| C-TASK-001 | P0 | 正常会员在营业时间进入领取页 | 总资产、昨日/今日佣金、今日领取数与数据库一致 |
| C-TASK-002 | P0 | 后台关闭任务功能后领取 | 返回 `TASK_DISABLED`,不创建订单 |
| C-TASK-003 | P1 | 在营业开始前 1 秒、开始时刻、结束时刻、结束后 1 秒领取 | 两端点允许,窗口外拒绝 |
| C-TASK-004 | P1 | 营业时段配置为跨午夜,如 22:00~02:00 | 22:00~次日 02:00 可领取,其余时间拒绝 |
| C-TASK-005 | P0 | U-AUTO 余额为最低余额减 1 分、等于最低余额、加 1 分 | 小于阈值拒绝;等于和大于阈值通过 |
| C-TASK-006 | P1 | U-MANUAL 余额低于最低余额进入领取资格检查 | 按手动派单规则返回;不错误套用自动派单最低余额校验 |
| C-TASK-007 | P0 | 当日领取数为上限减 1、等于上限 | 前者可领取;等于上限返回 `DAILY_LIMIT_REACHED` |
| C-TASK-008 | P0 | 存在匹配中、待处理、审核中或冻结订单时再次领取 | 返回 `PENDING_ORDER_EXISTS`,并提供查看原订单入口 |
| C-TASK-009 | P1 | 当前会员等级不属于任务大厅允许等级 | 返回 `LEVEL_NOT_ELIGIBLE`,不创建订单 |
| C-TASK-010 | P0 | 禁用会员或禁用钱包领取 | 返回账户受限,不创建订单及流水 |
| C-TASK-011 | P0 | 连续双击领取或并发发起两次领取 | 最多产生一个在途订单,次数只增加一次 |
| C-TASK-012 | P1 | 自动派单延迟配置为 0、正常值、负值、最大值小于最小值 | 0 立即调度;负值归零;反向区间归一;不 panic |
| C-TASK-013 | P0 | 自动派单匹配已发布、CNY、价格在配置区间内的商品 | 匹配成功,订单快照、商品、金额和佣金正确 |
| C-TASK-014 | P0 | 候选商品为下架、删除、非 CNY 或价格越界 | 不被匹配;无候选时订单保持可解释状态 |
| C-TASK-015 | P0 | 手动派单后会员余额足够并确认提交 | 扣除可用余额、结算佣金、订单完成、生成对应流水 |
| C-TASK-016 | P0 | 手动派单后后台改价导致会员余额不足 | 提交被拒并引导储入;余额、冻结额和订单状态不被错误修改 |
| C-TASK-017 | P0 | 同一订单重复点击确认或并发确认 | 只扣一次、只发一次佣金、只生成一组有效流水 |
| C-TASK-018 | P1 | 订单超过截止时间后确认 | 状态转已超时或拒绝提交,不扣款、不结算 |
| C-TASK-019 | P1 | 查询订单列表 `page=0`、`page=1`、`page_size=1/100/101` | 0 使用默认页;1 和 100 有效;101 拒绝 |
| C-TASK-020 | P1 | 使用 all、matching、pending、processing、completed、expired、frozen、cancelled、failed 过滤 | 返回对应状态;未知状态返回校验错误 |
| C-TASK-021 | P1 | 查看本人订单详情和他人订单 ID | 本人可见完整时间线;他人订单返回不存在/无权限 |
| C-TASK-022 | P1 | 提交凭证正文 0、2000、2001 字符 | 0 和 2000 按当前规则通过;2001 拒绝 |
| C-TASK-023 | P1 | 提交附件 0、9、10 个,或包含 0/负数/重复 ID | 0 和最多 9 个合法唯一 ID 通过;其余拒绝 |
| C-TASK-024 | P0 | 使用其他会员上传的附件 ID 提交任务 | 服务端拒绝,不能越权引用附件 |
| C-TASK-025 | P0 | 提交成功后核对订单、任务提交、钱包和流水 | 状态、佣金、前后余额、业务单号及时间在同一事务内一致 |
| C-TASK-026 | P1 | 后台下线任务后查看已领取订单 | 已领取订单仍可按原快照完成;不能新领取该任务 |
### 6.4 储入、收款账户、取出与账变
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| C-FIN-001 | P1 | 打开储入页 | 只展示启用渠道,显示渠道最小/最大金额和手续费信息 |
| C-FIN-002 | P0 | 储入金额测试 0、负数、最小值减 1 分、最小值、最大值、最大值加 1 分 | 仅渠道区间内金额可提交 |
| C-FIN-003 | P1 | 渠道最大值为 0 | 按无上限处理,但仍校验正数和数据库金额范围 |
| C-FIN-004 | P1 | 金额输入空值、字母、多小数位、科学计数法、超大数 | 前端不产生错误分值;服务端拒绝非法或溢出输入 |
| C-FIN-005 | P1 | 储入备注 500 和 501 个字符 | 500 通过,501 拒绝 |
| C-FIN-006 | P1 | 储入凭证附件 0、9、10 个 | 0~9 个合法附件通过;10 个拒绝 |
| C-FIN-007 | P0 | 凭证 ID 含重复、0、负数或他人附件 | 拒绝,订单不创建 |
| C-FIN-008 | P0 | 正常提交储入 | 只创建一张待处理充值单,随后跳转客服中心 |
| C-FIN-009 | P1 | 储入提交失败 | 保留输入并展示错误,不跳客服、不产生半成品订单 |
| C-FIN-010 | P1 | 查看储入记录全部状态 | 待支付、审核中、成功、失败、过期、取消文案及金额正确 |
| C-FIN-011 | P1 | 待支付订单创建后 30 分钟边界 | 过期时间显示正确,状态由服务端规则处理 |
| C-FIN-012 | P0 | 创建收款账户,输入合法姓名、身份证、卡号、开户行、两张不同收款码 | 创建成功;通用账户列表仅返回脱敏值,本人详情接口可还原原始字段 |
| C-FIN-013 | P1 | 持有人姓名长度 1、2、64、65,或含控制字符/尖括号 | 2~64 且无危险字符通过,其余拒绝 |
| C-FIN-014 | P1 | 身份证输入 15 位、18 位、末位 X、小写 x、空格或连字符 | 合法格式规范化后通过;非法位数和首位 0 拒绝 |
| C-FIN-015 | P1 | 银行名长度 1、2、100、101 | 2~100 通过,其余拒绝 |
| C-FIN-016 | P1 | 银行卡号长度 7、8、32、33 或含非数字 | 8~32 位数字通过,其余拒绝 |
| C-FIN-017 | P1 | 数字人民币编码空值、5、6、32、33 位或含非数字 | 空值或 6~32 位数字通过,其余拒绝 |
| C-FIN-018 | P0 | 微信码、支付宝码缺一个或使用同一附件 ID | 拒绝;必须是两个不同的合法图片附件 |
| C-FIN-019 | P0 | 上传收款码:空文件、JPEG/PNG/WebP、GIF、伪造扩展名、5MB、5MB+1B | 仅真实 JPEG/PNG/WebP 且不超过 5MB 通过 |
| C-FIN-020 | P0 | 使用其他会员的附件设置头像/收款账户 | 服务端拒绝附件越权 |
| C-FIN-021 | P1 | 未绑卡点击取出 | 引导添加银行卡,不进入无账户确认页 |
| C-FIN-022 | P1 | 账户待验证、已验证、已拒绝时申请取出 | 仅已验证账户可报价和申请 |
| C-FIN-023 | P0 | 后台关闭取出开关后报价和提交 | 均拒绝,不能只在前端置灰 |
| C-FIN-024 | P0 | 取出金额测试渠道最小值±1 分、最大值±1 分、可用余额±1 分 | 严格按渠道和余额边界处理 |
| C-FIN-025 | P1 | 取出手续费为固定、比例、固定+比例三种 | 手续费向上取整规则正确,实到金额=申请金额-手续费 |
| C-FIN-026 | P0 | 手续费小于 0、费率超过 10000、手续费大于等于金额 | 渠道视为无效,禁止提交 |
| C-FIN-027 | P0 | 未设置交易密码申请取出 | 引导在确认弹窗内设置 6 位数字交易密码;设置成功不自动取出,用户输入新密码并再次点击确认申请后才创建订单 |
| C-FIN-028 | P1 | 新交易密码测试 5/6/7 位、非数字、与旧密码相同 | 仅 6 位数字且不同于旧密码通过 |
| C-FIN-029 | P0 | 交易密码错误 4 次、第 5 次及限流后正确输入 | 第 5 次达到阈值后 15 分钟限流;正确密码不能绕过 |
| C-FIN-030 | P0 | 当日提现次数为上限减 1、等于上限 | 前者可申请,等于上限拒绝 |
| C-FIN-031 | P0 | 同一提现请求使用同一 8~80 位幂等键重复提交 | 返回同一业务结果,余额只冻结一次 |
| C-FIN-032 | P1 | 幂等键长度 7、8、80、81 或含非法字符 | 8~80 且仅允许字母数字及 `._:-` 通过 |
| C-FIN-033 | P0 | 两个不同幂等键并发提取超过总余额 | 最多一单成功;钱包不可出现负余额 |
| C-FIN-034 | P0 | 取出成功创建后核对钱包 | 可用余额减少、冻结额增加、版本递增并生成成对流水 |
| C-FIN-035 | P1 | 查看取出记录所有状态 | 待审核、审核中、处理中、成功、失败、驳回、取消正确显示 |
| C-FIN-036 | P1 | 账变筛选 income、expense、all、未知值 | 前三者正确;未知值拒绝 |
| C-FIN-037 | P1 | 账变 `biz_type` 长度 32 和 33 | 32 通过,33 拒绝 |
| C-FIN-038 | P0 | 查看账变前后余额 | 每笔 `after = before ± amount`;相邻流水首尾连续 |
| C-FIN-039 | P0 | 本人登录后请求 `GET /v1/payment-accounts/details` | 只返回当前租户、当前会员的完整姓名、身份证号、银行卡号、已填的数字人民币编码及本人收款码 URL;无账户返回空数组 |
| C-FIN-040 | P0 | 使用未登录会话、另一会员或另一租户访问本人取出信息详情 | 未登录被拒绝;其他身份只能看到各自绑定信息,不能通过请求参数指定目标会员或租户 |
| C-FIN-041 | P0 | 将微信/支付宝附件换成他人附件、失效附件,或两张附件对应关系交叉 | 详情接口不返回非本人或失效图片 URL;页面不把微信码、支付宝码串位 |
| C-FIN-042 | P0 | 数据库中某账户的必填密文损坏后请求本人详情 | 安全失败,不返回部分原始字段,不输出密文、解密错误细节或堆栈 |
| C-FIN-043 | P1 | 同一账户分别请求通用列表和本人详情 | 通用列表的姓名、身份证、卡号、数字人民币编码保持脱敏;仅本人详情返回原值 |
| C-FIN-044 | P1 | 数字人民币编码留空创建账户,随后读取详情 | 保存时不生成该字段的密文、哈希或掩码;详情返回空值,银行卡及收款码仍正常使用 |
### 6.5 商城、个人中心与安全设置
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| C-MY-001 | P1 | 商城查看推荐和限时标签 | 仅已发布、分类启用、有效期内的手工商城商品出现 |
| C-MY-002 | P1 | 搜索商品名称,输入不存在关键词、前后空格及大小写 | 搜索结果正确;无结果显示空状态 |
| C-MY-003 | P1 | 打开本人可见商品详情及不存在/下架商品 ID | 有效商品正常;无效商品返回友好不存在 |
| C-MY-004 | P1 | 限时商品在开始前、开始时、结束时、结束后 | 仅有效窗口允许领取/展示可用动作 |
| C-MY-005 | P1 | 个人中心展示账号、邀请码、等级、余额 | 与 `/v1/me` 一致,金额单位正确 |
| C-MY-006 | P2 | 点击复制邀请码 | 剪贴板内容准确;失败时给出替代提示 |
| C-MY-007 | P0 | 修改登录密码:旧密码错误、空、新密码 5/6 字符、超过 72 字节、与旧密码相同 | 仅旧密码正确且新密码满足规则并不同于旧密码时成功 |
| C-MY-008 | P0 | 修改登录密码成功后用新旧密码登录 | 新密码可登录,旧密码不可登录;既有会话按当前安全策略处理 |
| C-MY-009 | P1 | 首次设置交易密码时填写旧交易密码 | 拒绝;首次设置不应要求或接受旧交易密码 |
| C-MY-010 | P1 | 已有交易密码时不填/填错旧交易密码 | 分别提示必填/错误;不更新密码 |
| C-MY-011 | P1 | 更换头像上传合法图片并刷新 | 头像更新且只关联本人附件;首页、我的、IM 头像一致 |
| C-MY-012 | P1 | 财务记录、账户明细为空和多页数据 | 空态、分页、金额、状态和时间正确 |
| C-MY-013 | P0 | U-DISABLED 打开个人资料及发起交易 | 可按策略查看必要信息,但所有受限写操作被服务端拒绝 |
| C-MY-014 | P1 | 点击退出并取消/确认 | 取消保留会话;确认清除会话并返回登录页 |
| C-MY-015 | P1 | 从个人信息页修改头像后返回“我的”、首页和客服会话 | 新头像在重新读取资料后保持一致;失败时保留旧头像,不出现仅本页临时预览成功的假象 |
### 6.6 在线客服与 118IM
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| C-IM-001 | P1 | 打开客服列表 | 展示客服依依、客服琪琪、客服琳琳的名称、女性头像及在线状态 |
| C-IM-002 | P0 | 首次点击客服 | 自动创建/恢复会员 IM 身份、建立好友关系和 P2P 会话 |
| C-IM-003 | P0 | 客服未绑定 IM 用户或租户代码不一致 | 明确提示客服暂不可用,不进入可发送假状态 |
| C-IM-004 | P1 | 好友关系为 none、friend、pending、denied | none 发起申请;friend 进入会话;pending/denied 给出对应提示 |
| C-IM-005 | P0 | 本地保存的 IM 密码失效 | 服务端重置并保存凭据后重试一次,成功恢复或明确失败 |
| C-IM-006 | P0 | IM 登录返回的用户 ID 与本地身份不一致 | 拒绝建立会话,防止串号 |
| C-IM-007 | P1 | 会话打开时加载历史消息 | 历史与实时消息分界明确,顺序、发送人、时间正确 |
| C-IM-008 | P1 | 发送空文本、1 字符、1000 字符、1001 字符 | 空文本不发送;1~1000 可发送;超过输入上限不可提交 |
| C-IM-009 | P0 | 快速连续发送文本或网络抖动 | 不丢失、不重复;发送中/失败/成功状态可见 |
| C-IM-010 | P1 | 断开 WebSocket 后点击重新连接 | 恢复会话并续收消息,不重复展示历史消息 |
| C-IM-011 | P1 | 打开表情面板,切换最近/表情/手势/物品并选择 | 分类与布局正确;表情插入输入框,可随文本发送 |
| C-IM-012 | P1 | 上传 JPEG/PNG/WebP/GIF 图片,大小为 10MB 和 10MB+1B | 合法且不超过 10MB 可发送;超限前端拒绝 |
| C-IM-013 | P1 | 上传 MP4/WebM/QuickTime 视频,大小为 50MB 和 50MB+1B | 合法且不超过 50MB 可发送;超限拒绝 |
| C-IM-014 | P0 | 伪造文件后缀或上传脚本/可执行文件 | 客户端和服务端均拒绝,不能作为可执行内容访问 |
| C-IM-015 | P1 | 大文件上传过程取消、断网或接口 500 | 进度结束并提示失败;输入区恢复可用,可重新选择 |
| C-IM-016 | P0 | 会员发图后客服 Web 端查看 | 接收方拥有附件读取权限,图片正常显示,不返回 403 |
| C-IM-017 | P0 | 客服发图后会员端查看 | 会员可读取;无关第三人不能读取附件 |
| C-IM-018 | P0 | 历史绝对地址和同源相对地址媒体混合加载 | 均能通过授权地址显示;不得因 CORS 修复而绕过 ACL |
| C-IM-019 | P1 | 头像 URL 为空、失效、跨域受限 | 使用稳定占位头像;不影响消息发送 |
| C-IM-020 | P1 | 会话 Token 过期后发送 | 自动重连或明确提示重新连接;消息不静默丢失 |
## 7. 管理后台功能测试
### 7.1 后台登录、菜单、组织与 RBAC
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| A-RBAC-001 | P0 | 正确/错误后台账号密码登录 | 正确进入首页;错误统一提示并记录登录日志 |
| A-RBAC-002 | P0 | 登录页检查其他登录方式、手机号登录、注册、演示角色 | 这些入口均不显示,只保留账号登录 |
| A-RBAC-003 | P2 | 检查登录页系统名 | 显示“企业办公管理系统” |
| A-RBAC-004 | P1 | 展开组织管理、权限管理、系统应用、系统设置 | 二级菜单均有合适图标且可点击 |
| A-RBAC-005 | P1 | 检查系统应用下个人中心 | 菜单可见并能进入个人设置 |
| A-RBAC-006 | P0 | 为 A-CHILD 分配菜单 | 弹窗只显示 A-PARENT 已有菜单范围或范围外禁选 |
| A-RBAC-007 | P0 | 通过构造请求给子角色分配父角色没有的菜单 | 服务端拒绝,数据库不写入越权关系 |
| A-RBAC-008 | P1 | 勾选一级菜单 | 可按设计级联勾选全部可选子菜单和按钮权限 |
| A-RBAC-009 | P1 | 取消一级菜单或部分子菜单 | 父子半选状态正确,不残留不可达按钮权限 |
| A-RBAC-010 | P0 | 无页面权限用户直接输入 URL | 页面不可访问;对应接口同样返回 403 |
| A-RBAC-011 | P0 | 有页面权限但无新增/编辑/删除按钮权限 | 按钮不显示;直接调用写接口仍被拒绝 |
| A-RBAC-012 | P1 | 新增、编辑、停用部门和岗位 | 树关系、排序、状态和成员关联正确 |
| A-RBAC-013 | P1 | 删除存在下级或在用成员的部门/角色 | 被阻止并提示依赖关系 |
| A-RBAC-014 | P1 | 修改管理员角色后刷新其会话 | 菜单和接口权限按系统策略及时生效 |
| A-RBAC-015 | P0 | 非超管尝试编辑超管或给自己提权 | 服务端拒绝并记录操作日志 |
| A-RBAC-016 | P1 | 菜单新增、编辑、排序、隐藏、外链和按钮权限 | 路由树刷新正确,隐藏菜单可按直接路由策略访问 |
| A-RBAC-017 | P1 | 字典增删改、启停并刷新业务页面 | 使用字典的下拉和标签同步更新,不在前端残留硬编码 |
| A-RBAC-018 | P1 | 修改个人资料和密码 | 校验正确;密码不回显;修改记录可追踪 |
### 7.2 首页、网站设置、文本、Banner 与手工商城
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| A-PORTAL-001 | P1 | 打开首页看板 | 会员、订单、充值、提现、任务等指标与筛选口径一致 |
| A-PORTAL-002 | P1 | 切换日期、刷新、点击指标卡 | 数据区间正确;跳转明细时继承筛选条件 |
| A-PORTAL-003 | P1 | 无业务数据或统计接口失败 | 分别显示 0/空态或错误提示,不混淆 |
| A-PORTAL-004 | P0 | 网站设置读取、修改并保存 | 字段完整回显;保存生成新版本并立即按当前实现生效 |
| A-PORTAL-005 | P1 | 最小金额大于最大金额、反向时间、负次数、非法 URL | 前后端拒绝并定位字段 |
| A-PORTAL-006 | P1 | 营业时间配置正常窗口、全天和跨午夜 | 保存后 C 端按相同语义执行 |
| A-PORTAL-007 | P0 | 两人同时编辑同一网站配置版本 | 后提交者发生版本冲突,不覆盖先提交者 |
| A-PORTAL-008 | P1 | 提交、发布、回滚网站配置 | 状态流转合法;回滚形成可审计新版本,不篡改历史 |
| A-PORTAL-009 | P1 | 切换领取/储入/取出/商城开关 | C 端入口和服务端行为同步变化 |
| A-PORTAL-010 | P1 | 文本列表初始化数据 | 仅约定的 12 条原始文本;列表不显示“内容标识”列 |
| A-PORTAL-011 | P1 | 新增/编辑文本,正文为空、超长、含脚本和恶意链接 | 必填及长度校验有效;危险 HTML 被拒绝或净化 |
| A-PORTAL-012 | P1 | 文本发布、下线、删除 | C 端只显示已发布版本;有历史引用时保留历史 |
| A-PORTAL-013 | P1 | 编辑文本弹窗 | 宽度适合正文编辑,标题为文本管理语义,不出现商品图片字段 |
| A-PORTAL-014 | P1 | Banner 新增、编辑、排序、发布、下线 | C 端按状态、有效期和排序展示 |
| A-PORTAL-015 | P1 | Banner 上传非图片、超限、异常比例或失效 URL | 阻止或明确提示;列表不出现破损数据 |
| A-PORTAL-016 | P1 | 删除已发布 Banner | 按规则先下线或软删除;C 端立即不可见 |
| A-PORTAL-017 | P1 | 手工商城新增/编辑名称、价格、图片、展示时间 | 保存成功并正确回显,`product_type=2` |
| A-PORTAL-018 | P1 | 手工商品发布/下架/删除 | C 端商城展示同步;已关联历史保留快照 |
| A-PORTAL-019 | P0 | 租户 A 管理门户资源 | 不能查看或修改租户 B 的配置、内容、Banner、商品 |
| A-PORTAL-020 | P1 | 查看配置和内容历史 | 操作者、版本、时间、状态和差异可追溯 |
### 7.3 会员、等级与任务大厅
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| A-MEM-001 | P1 | 会员列表按账号、手机号、时间、状态、派单模式、类别查询 | 筛选准确,重置恢复默认条件 |
| A-MEM-002 | P1 | 列表检查派单模式和类别 | 派单模式显示自动/手动;类别显示真人/假人 |
| A-MEM-003 | P1 | 操作列在 180px 宽度内横向滚动 | 按钮靠左、可滚动查看全部;细滚动条可拖动 |
| A-MEM-004 | P1 | 操作弹层查看编辑、删除、银行卡、地址、团队、账变、弹幕、奖励、类别、状态 | 按权限显示,按钮尺寸和换行不遮挡 |
| A-MEM-005 | P0 | 新增会员使用重复手机号/邀请码冲突 | 拒绝重复;不产生孤立钱包或资料 |
| A-MEM-006 | P1 | 编辑会员基础资料 | 普通编辑不能直接改钱包余额;敏感字段不明文回显 |
| A-MEM-007 | P0 | 切换自动/手动派单和真人/假人 | 保存后 C 端领取规则及统计口径同步更新 |
| A-MEM-008 | P0 | 授权查看会员、银行卡、地址明文 | 必须填写用途并确认;有单独权限;生成敏感访问日志 |
| A-MEM-009 | P0 | 无敏感权限构造明文查看请求 | 返回 403,日志与错误响应不泄露明文 |
| A-MEM-010 | P1 | 新增/编辑/删除银行卡和地址 | 脱敏展示、归属校验、默认项及软删除正确 |
| A-MEM-011 | P1 | 团队查询层级 1、10、11 | 1~10 可查;超过 10 被限制;上下级关系不串租户 |
| A-MEM-012 | P0 | 封禁/解封个人或团队 | 审批、执行、状态日志完整;C 端权限即时受限/恢复 |
| A-MEM-013 | P0 | 同一状态审批重复执行 | 只执行一次,重复请求不重复写状态日志 |
| A-MEM-014 | P0 | 发起账变后未审批直接执行 | 被拒绝;余额和流水不变化 |
| A-MEM-015 | P0 | 审批通过后执行加款/扣款 | 钱包、版本和不可变流水一致,不能扣成负数(除明确授权场景) |
| A-MEM-016 | P0 | 使用相同幂等键重复执行账变/奖励金 | 仅入账一次 |
| A-MEM-017 | P1 | 创建、查看和撤回会员通知/弹幕 | 目标、来源、有效期正确;撤回后 C 端不再展示 |
| A-MEM-018 | P0 | 删除有余额、在途订单或历史流水的会员 | 禁止物理删除;按规则注销/软删除并保留台账 |
| A-MEM-019 | P1 | 会员等级新增/编辑 | 名称、金额、日订单数、佣金率、日提现数校验正确 |
| A-MEM-020 | P1 | 等级金额为负、次数为小数、佣金率越界 | 保存被拒绝,提示具体字段 |
| A-MEM-021 | P1 | 等级编辑、启停、删除 | 操作列仅编辑/停启用/删除;在用等级不能直接删除 |
| A-MEM-022 | P1 | 等级规则变更后检查在途订单 | 在途订单继续使用规则快照,新订单使用新规则 |
| A-MEM-023 | P1 | 任务大厅新增、编辑、启停、单删和批删 | 等级关联、排序和 C 端入口同步;失败项逐条提示 |
| A-MEM-024 | P1 | 删除被任务或历史订单引用的任务大厅 | 被阻止或仅下线,不破坏历史 |
| A-MEM-025 | P1 | 隐藏菜单“资金调账、会员通知” | 左侧菜单不显示,但会员操作入口和授权接口仍正常 |
| A-MEM-026 | P1 | 检查后台会员列表及 `/appMember/edit` | 页面无新增会员按钮;无 ID 的创建请求被拒绝,不创建会员 |
| A-MEM-027 | P1 | 按注册域名输入完整值、前缀、中间片段和大小写关键词 | 均按标准化域名模糊查询,结果不串租户 |
| A-MEM-028 | P1 | 组合待绑定、启用域名、失效域名状态及其他会员条件查询 | 列表、总数、详情的域名状态一致,地址启停后状态实时变化 |
| A-URL-001 | P1 | 新增 HTTP/HTTPS 地址,省略路径或使用自定义端口 | 省略路径时补 `/register`;保留非默认端口;默认 80/443 端口被规范化 |
| A-URL-002 | P1 | 新增 ftp、带账号、查询参数、片段、空域名或超过 512 字符的入口 | 服务端逐项拒绝并返回明确错误,不写入半成品地址 |
| A-URL-003 | P0 | 新增大小写、默认端口或尾点不同但标准化后相同的域名 | 唯一约束和业务校验均阻止重复地址 |
| A-URL-004 | P1 | 编辑未绑定地址的名称、URL、状态和备注 | 合法字段保存;域名哈希、协议和基础 URL 同步更新 |
| A-URL-005 | P0 | 编辑已有绑定会员的地址并尝试更换域名 | 拒绝更换域名;允许修改名称、状态和备注;历史绑定保持不变 |
| A-URL-006 | P1 | 地址池混合 0、1、相同及较大累计绑定次数后查询 | 始终按使用次数升序、ID 升序稳定排列,分页边界不重复不漏行 |
| A-URL-007 | P0 | 将启用地址设为失效后从该域名注册,再重新启用后注册 | 失效期间注册成功但不绑定;重新启用后新会员正常绑定,历史绑定不删除 |
| A-URL-008 | P0 | 两个会员并发从同一启用域名注册 | 每个会员只产生一个绑定;累计绑定次数精确增加,最后绑定时间更新 |
| A-URL-009 | P1 | 查看地址详情并按会员关键词、绑定时间筛选 | 返回该地址全部绑定会员且手机号脱敏;其他地址会员不混入 |
| A-URL-010 | P1 | 导入合法 `.xlsx`/`.xlsm`,表头为中文或支持的英文别名 | 所有合法地址批量写入且状态统一为启用,创建/更新人及租户正确 |
| A-URL-011 | P1 | 导入缺少必填表头、空文件、非 Excel、超过 10MB 或超过 5000 条 | 整体拒绝并给出明确原因,不发生部分未知写入 |
| A-URL-012 | P1 | 导入文件内重复域名、数据库已有域名和多行格式错误 | 重复项计入跳过数;合法行导入;失败数及最多 100 条行号明细准确 |
| A-MLOGIN-001 | P0 | 分别执行账号成功、账号失败、手机成功和手机失败登录 | `hg_app_member_login_log` 每次各写一条,登录方式、结果、会员 ID 和时间准确 |
| A-MLOGIN-002 | P0 | 检查会员登录日志的账号、手机、IP、请求头及错误信息 | 手机和 IP 仅存/展示脱敏值;原始 IP 仅加密保存;密码、验证码和 Token 不落库 |
| A-MLOGIN-003 | P1 | 登录请求携带 Origin、仅 Host、大小写域名或无有效域名 | 优先记录标准化 Origin 域名;回退 Host;无合法来源时域名为空且不影响日志 |
| A-MLOGIN-004 | P1 | IP 地区解析失败或日志队列不可用时登录 | 登录主流程不被地区解析阻断;队列失败降级同步落库并记录警告 |
| A-MLOGIN-005 | P1 | 按会员、账号、脱敏手机、登录方式、结果、域名、IP 和时间组合查询 | 列表总数、分页、详情及倒序排列准确,不能看到其他租户日志 |
| A-MLOGIN-006 | P1 | 导出大于一页的当前筛选结果 | 导出遵循当前筛选和分页约定;字段脱敏,不包含 `login_ip_cipher` |
### 7.4 交易管理
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| A-TRADE-001 | P1 | 订单列表按单号、会员、状态、派单方式、时间查询 | 结果准确,金额和状态字典正确 |
| A-TRADE-002 | P1 | 查看订单详情 | 商品/任务快照、时间线、会员、金额、流水完整且脱敏 |
| A-TRADE-003 | P0 | 打开匹配商品弹窗 | 只显示已发布、未删除、CNY、符合价格规则的商品 |
| A-TRADE-004 | P1 | 新商品保存为草稿/下架后搜索匹配 | 不出现;发布后立即可选 |
| A-TRADE-005 | P0 | 给手动订单匹配商品 | 订单进入待会员处理;快照和金额正确,不提前扣款 |
| A-TRADE-006 | P0 | 对同一订单并发执行两次匹配 | 仅一次成功,后一次状态冲突 |
| A-TRADE-007 | P0 | 更改手动订单金额为 0、负数、余额边界及超大值 | 非法值拒绝;合法值更新快照和后续校验 |
| A-TRADE-008 | P0 | 强制付款导致余额为负 | 仅有专门权限可执行;明确二次确认;流水标记强制操作 |
| A-TRADE-009 | P0 | 冻结订单 | 状态变冻结,C 端不可继续提交并提示联系客服 |
| A-TRADE-010 | P0 | 普通任务订单尝试调用手动订单专用操作 | 服务端按订单类型和状态拒绝 |
| A-TRADE-011 | P0 | 提交人工处理审批并由无权限/同一人执行 | 按审批规则拒绝越权或不合规执行 |
| A-TRADE-012 | P1 | 充值列表和金额汇总 | 总额、已支付、未支付、人工审核、人工驳回口径与明细一致 |
| A-TRADE-013 | P1 | 查看充值凭证 | 仅授权人员可见;附件归属和可访问性正确 |
| A-TRADE-014 | P0 | 审核通过待审充值 | 状态成功,钱包仅增加一次,生成充值流水和审核记录 |
| A-TRADE-015 | P0 | 驳回充值 | 状态失败/驳回,余额不增加,原因回显 C 端 |
| A-TRADE-016 | P0 | 重复点击充值通过或并发审核 | 第二次返回已处理;不重复入账 |
| A-TRADE-017 | P0 | 审核已过期、取消或已成功充值单 | 拒绝非法状态迁移 |
| A-TRADE-018 | P1 | 充值审核说明为空、超长、含脚本 | 按决策必填规则校验;展示时转义 |
| A-TRADE-019 | P1 | 提现列表查看脱敏收款账户和风险信息 | 默认脱敏;明文访问单独授权并留痕 |
| A-TRADE-020 | P0 | 通过待审核提现 | 进入处理中;冻结资金保持,审核记录完整 |
| A-TRADE-021 | P0 | 驳回提现 | 冻结额释放回可用余额,只生成一次冲正流水 |
| A-TRADE-022 | P0 | 完成处理中提现 | 状态成功,冻结额扣除,外部单号及完成时间保存 |
| A-TRADE-023 | P0 | 完成失败的提现或重复完成 | 拒绝非法状态;不重复扣冻结额 |
| A-TRADE-024 | P0 | 审核过程中会员钱包并发变化 | 使用锁/版本控制,余额守恒且不丢更新 |
| A-TRADE-025 | P1 | 新增客服,填写名称、头像、工作时间、在线状态、IM 租户和用户 | 保存成功并在 C 端展示 |
| A-TRADE-026 | P1 | 客服启用/停用 | 停用客服不再出现在 C 端新列表,历史会话不被删除 |
| A-TRADE-027 | P1 | 删除启用客服 | 被阻止;停用并填写删除原因后方可软删除 |
| A-TRADE-028 | P0 | 客服 IM 用户重复绑定或绑定不存在用户 | 拒绝或明确报错,不能产生不可连接配置 |
| A-TRADE-029 | P1 | 交易操作权限分别授予客服、财务、运营 | 只显示职责内操作;财务动作不对运营开放 |
| A-TRADE-030 | P0 | 所有资金审核完成后核对台账 | 业务单状态、钱包、流水、审核和审计记录可相互追溯 |
### 7.5 商品、任务与会员任务审核
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| A-PT-001 | P1 | 商品列表查询、新增和编辑 | 名称、编号、分类、价格、币种、图片、推荐、有效期正确保存 |
| A-PT-002 | P1 | 商品价格测试 0、负数、1 分、超大值、小数精度 | 按分存储;非法或溢出拒绝,页面元/分转换准确 |
| A-PT-003 | P1 | 商品发布/下架按钮 | 状态切换成功;文案、颜色及二次确认正确 |
| A-PT-004 | P0 | 商品发布后打开订单匹配弹窗 | 商品可见;下架后立即不可用于新匹配 |
| A-PT-005 | P1 | 推荐/取消推荐 | 列表状态和 C 端推荐区域同步 |
| A-PT-006 | P1 | 删除未引用商品 | 软删除成功,列表及 C 端不再显示 |
| A-PT-007 | P0 | 删除已被订单引用商品 | 禁止删除,只允许下架;历史订单仍显示商品快照 |
| A-PT-008 | P1 | 批量删除混合可删和被引用商品 | 可删项成功,失败项保留并汇总提示 |
| A-PT-009 | P2 | 商品操作列在常见分辨率下显示 | 按钮自动换行,编辑/发布下架/推荐/删除均可达 |
| A-PT-010 | P1 | 任务新增/编辑并绑定商品和会员等级 | 规则、佣金、详情、有效期和等级关系正确 |
| A-PT-011 | P1 | 任务佣金金额、佣金万分比和数量边界 | 非负、精度和上限校验正确,不发生单位误读 |
| A-PT-012 | P1 | 发布/下线任务 | 发布后目标等级可领取;下线不影响已领取订单 |
| A-PT-013 | P1 | 删除未引用/已引用任务 | 未引用软删除;已引用仅下线并保留历史 |
| A-PT-014 | P1 | 会员任务审核列表按会员、订单、状态筛选 | 返回准确,能从交易订单带条件跳转 |
| A-PT-015 | P1 | 查看提交详情 | 正文、附件、任务/订单、佣金、提交时间完整 |
| A-PT-016 | P0 | 审核通过待审提交 | 在同一事务内更新提交和订单并发放一次佣金 |
| A-PT-017 | P0 | 审核驳回且允许重提 | 保存原因和证据,C 端显示原因并按规则允许重提 |
| A-PT-018 | P1 | 驳回时不填补充说明、通过时不填审核意见 | 页面按当前必填规则阻止提交 |
| A-PT-019 | P0 | 两名管理员并发审核同一提交 | 只有一人成功;佣金只发一次 |
| A-PT-020 | P0 | 已结算提交再次审核或更改结论 | 服务端拒绝,历史结果不可覆盖 |
| A-PT-021 | P0 | 提交所含附件已删除或无权限 | 审核页安全降级,不泄露其他会员附件 |
| A-PT-022 | P1 | 等级规则发布后新旧任务订单 | 老订单使用快照,新订单使用新版本 |
| A-PT-023 | P1 | 任务/商品有效期端点测试 | 开始和结束端点语义统一,前后台结果一致 |
| A-PT-024 | P0 | 将租户 B 商品 ID 绑定到租户 A 任务 | 服务端拒绝跨租户关系 |
| A-PT-025 | P1 | 发布缺商品、缺等级或无有效佣金规则的任务 | 阻止发布并提示缺失项 |
### 7.6 HotGo 系统设置与运维功能
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| A-SYS-001 | P1 | 系统配置各分组读取、编辑、保存 | 保存成功并按缓存策略生效,敏感密钥不明文回显 |
| A-SYS-002 | P1 | 上传设置限制类型、大小和存储驱动 | 前端提示与服务端实际限制一致 |
| A-SYS-003 | P1 | 黑名单新增、启停、删除并从目标 IP 访问 | 生效范围正确,不误封管理员当前地址 |
| A-SYS-004 | P1 | 定时任务新增、修改 cron、启停、手动执行 | 规则校验、执行日志和状态正确 |
| A-SYS-005 | P1 | 配置非法 cron 表达式 | 保存被拒绝,不影响现有任务 |
| A-SYS-006 | P1 | 附件列表预览、下载、删除 | 权限、MIME 和引用检查正确;被引用附件不盲删 |
| A-SYS-007 | P1 | 通知公告新增、发布、撤回和阅读 | 目标后台用户可见,阅读状态正确 |
| A-SYS-008 | P1 | 操作日志按用户、模块、结果和时间筛选 | 可定位本用例产生的关键写操作 |
| A-SYS-009 | P0 | 日志检查密码、Token、银行卡、身份证和密钥 | 不记录明文敏感数据 |
| A-SYS-010 | P1 | 登录日志检查成功、失败、IP 和设备 | 记录准确且符合脱敏规则 |
| A-SYS-011 | P1 | 在线用户强制下线 | 目标会话失效,不影响其他用户 |
| A-SYS-012 | P1 | 地区数据树查询 | 层级、编码和父子关系正确 |
| A-SYS-013 | P1 | 插件管理页面在无操作权限下访问 | 只读或拒绝,不能安装/卸载插件 |
| A-SYS-014 | P1 | 服务/Redis/数据库短暂不可用 | 页面展示可诊断错误,恢复后无需清库即可继续 |
| A-SYS-015 | P2 | 切换主题、语言、侧栏折叠和标签页 | 状态保持,业务表格和弹窗不变形 |
## 8. 跨端端到端业务场景
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| E2E-001 | P0 | 后台建等级→建大厅→建商品并发布→建任务并发布→会员领取完成 | 全链路可用,规则和金额一致 |
| E2E-002 | P0 | C 端提交储入→后台通过→C 端查看余额和记录 | 余额增加一次,状态和流水一致,提交后可进入客服 |
| E2E-003 | P0 | C 端申请取出→后台通过→完成 | 可用转冻结再扣除,状态和三阶段时间正确 |
| E2E-004 | P0 | C 端申请取出→后台驳回 | 冻结资金完整退回,生成冲正流水 |
| E2E-005 | P0 | 手动会员领取→客服匹配商品→会员确认 | 匹配、余额校验、扣款和结算闭环正确 |
| E2E-006 | P0 | 自动会员领取→延迟自动匹配 | 在配置延迟内完成,商品金额不超过余额及配置区间 |
| E2E-007 | P0 | 会员提交任务→后台审核通过 | 订单、提交、钱包和佣金流水一致且幂等 |
| E2E-008 | P1 | 后台发布/下线 Banner、文本、手工商品 | C 端仅展示当前有效发布版本 |
| E2E-009 | P1 | 后台封禁会员→会员继续领取/储入/取出/客服 | 所有受限动作按策略拒绝且已有数据可追溯 |
| E2E-010 | P1 | 后台切换功能开关→C 端首页和直连接口测试 | UI 与服务端双重生效,不能绕过置灰入口 |
| E2E-011 | P0 | 会员点击客服→发文字、表情、图片、视频→客服回复 | 双端实时可见,头像、历史、附件 ACL 均正确 |
| E2E-012 | P0 | 子角色仅有充值查看权限登录后台 | 只见允许菜单和查看能力,不能审核或访问其他业务 API |
| E2E-013 | P0 | 后台新增启用地址→会员从该域名注册→查看会员和地址详情 | 注册成功并建立唯一绑定;会员详情、地址绑定会员和累计次数一致 |
| E2E-014 | P1 | 后台将已绑定地址设为失效→旧会员登录→新会员注册 | 旧会员绑定历史保留且显示失效;登录正常记日志;新会员注册成功但不绑定 |
| E2E-015 | P1 | Excel 导入地址→从其中一个域名注册→按域名筛会员→导出登录日志 | 导入地址为启用,注册绑定、会员筛选和登录日志链路完整可追溯 |
| E2E-016 | P0 | 会员个人信息页绑定银行卡(数字人民币留空)→查看本人原始取出信息→打开取出报价→在弹窗设置支付密码→再次确认申请 | 绑卡、本人详情、收款码、报价和密码状态一致;设置密码本身不创建取出单,仅最后一次明确确认产生一张订单 |
## 9. API、安全、数据一致性与异常测试
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| API-001 | P0 | 请求体增加未知 JSON 字段 | C 端 API 按严格解码返回 400,不忽略可疑字段 |
| API-002 | P0 | 使用 GET 调写接口或 POST 调只读接口 | 返回 405,不执行副作用 |
| API-003 | P0 | 修改请求中的 member_id、tenant_id、operator_id | 服务端以会话身份为准,忽略或拒绝越权字段 |
| API-004 | P0 | 数值传字符串、浮点、null、超出 int64 | 返回字段校验错误,不截断、不溢出 |
| API-005 | P1 | 分页 page=-1、page_size=-1/0/101、极大页码 | 非法值拒绝或按明确默认;极大页返回空数组 |
| API-006 | P0 | 将他人订单、充值、提现、附件、会话 ID 逐项替换 | 均不可读取或修改 |
| API-007 | P0 | 重放充值审核、提现执行、任务结算、账变请求 | 状态机/幂等保护生效,不重复记账 |
| API-008 | P0 | 在事务中途模拟数据库错误 | 整个事务回滚,不出现订单成功但无流水等半状态 |
| API-009 | P0 | 高并发更新同一钱包 | 乐观锁/行锁生效,最终余额守恒,版本连续 |
| API-010 | P0 | 校验每个资金业务单的流水唯一键 | 唯一且可反查业务单;冲正指向原流水 |
| API-011 | P0 | 访问上传文件使用路径穿越、双扩展名和恶意 MIME | 被拒绝或安全下载,不可执行服务器文件 |
| API-012 | P0 | 富文本插入 script、onerror、javascript URL、未知 iframe | 存储或渲染前净化,CSP 下不执行 |
| API-013 | P0 | 搜索框输入 SQL 元字符及超长条件 | 参数化查询,无 SQL 注入、无 500 |
| API-014 | P0 | 检查 API、浏览器缓存和导出中的敏感字段 | 通用列表、导出中的身份证和卡号默认脱敏;仅已认证的本人取出信息详情可返回原值且禁缓存;密码、密钥永不返回 |
| API-015 | P0 | 跨域站点调用带凭证业务接口 | CORS 只允许配置源;不使用通配凭证策略 |
| API-016 | P1 | 同一接口连续触发限流 | 返回 429 和合理重试时间,不永久锁死 |
| API-017 | P1 | 数据库中制造孤立引用后打开列表/详情 | 页面可降级并提示,不 panic;提供数据修复线索 |
| API-018 | P1 | Redis 短暂不可用时登录、限流、资金提交 | 安全失败,不绕过认证/限流/幂等;恢复后可继续 |
| API-019 | P1 | IM 服务不可用、慢响应、证书错误 | 客服页可重试并给备用提示,不影响其他业务页面 |
| API-020 | P1 | 检查所有错误响应 | 结构统一包含 code/reason/message/request_id,生产不返回堆栈和 SQL |
| API-021 | P0 | 无 `/urlPool/import`、`/urlPool/status` 或会员登录日志权限直接调用对应接口 | 返回 403;不能通过隐藏按钮、构造请求或导出绕过权限 |
| API-022 | P0 | 跨租户传入地址池 ID、绑定会员 ID 或会员登录日志 ID | 统一返回无权限或不存在,不泄露域名、会员及登录信息 |
| API-023 | P0 | 未登录请求 `GET /v1/payment-accounts/details`,或使用其他 HTTP 方法调用 | 未登录拒绝、非 GET 返回 405;响应不包含任何原始绑定信息 |
| API-024 | P0 | 登录后请求本人取出信息详情并检查响应头、代理缓存、浏览器缓存和访问日志 | 响应设置 `Cache-Control: no-store`;原始身份证、卡号及数字人民币编码不进入缓存或日志 |
## 10. 兼容性、易用性与可恢复性
| ID | 优先级 | 场景与步骤 | 预期结果 |
| --- | --- | --- | --- |
| UX-001 | P1 | iOS Safari、Android Chrome、桌面 Chrome/Edge 最新两个主版本执行冒烟 | 核心流程一致,无白屏和不可点击控件 |
| UX-002 | P1 | C 端宽度 320、375、390、430px | 无横向溢出;底部导航、弹窗和键盘区域可用 |
| UX-003 | P1 | 后台 1366×768、1440×900、1920×1080 | 表格、固定操作列、弹窗和滚动条可操作 |
| UX-004 | P2 | 浏览器缩放 80%、100%、125%、150% | 关键按钮和文字不被裁切 |
| UX-005 | P2 | 键盘 Tab、Enter、Esc 操作表单和弹窗 | 焦点顺序合理,危险操作不被误触 |
| UX-006 | P2 | 图片慢加载、长用户名、长商品名、长错误信息 | 截断/换行合理,详情仍可查看完整内容 |
| UX-007 | P1 | 提交后刷新或返回 | 已成功业务不重复提交;未成功表单按安全策略保留或清空 |
| UX-008 | P1 | 服务发布期间页面打开旧版本 | 静态资源恢复机制有效,API 兼容或给出刷新提示 |
## 11. 发布前最小回归集
每次发布至少执行以下 P0 链路:
1. 正常登录、错误登录限流、退出后 Token 失效。
2. 首页四个功能开关与服务端直连接口同时生效。
3. 自动领取、手动匹配、余额不足、重复领取和重复确认。
4. 储入提交、后台通过、余额及流水核对。
5. 取出报价、未设支付密码时的弹窗设置与关闭、再次确认申请、幂等提交、后台通过/驳回/完成及余额核对。
6. 商品发布后可匹配、下架后不可匹配、已引用商品不可删除。
7. 任务发布、会员提交、后台审核及佣金只结算一次。
8. 子角色菜单不超过父角色;无按钮权限时接口也不可调用。
9. 客服会话首开、断线重连、文本、表情、图片、视频及双端附件显示。
10. 跨租户、跨会员 IDOR、通用列表脱敏、本人取出信息原文与禁缓存、操作日志检查。
11. 启用域名注册绑定、失效/未知域名注册不绑定,以及会员注册域名筛选。
12. 地址池新增、启停、详情、使用次数排序、Excel 导入及会员登录日志查询导出。
13. 个人信息页头像选图更新、取出信息入口、数字人民币选填和本人绑定信息原文展示。
## 12. 准入、准出与缺陷判定
### 12.1 测试准入
- 前后端构建通过,数据库迁移和种子数据在空库及存量库各验证一次。
- C 端、后台、MySQL、Redis、上传目录及 IM 环境可访问。
- 产品配置、测试账号、支付/提现渠道、商品和任务数据已准备。
- 本次变更清单、数据库脚本、回滚方案和发布版本号明确。
### 12.2 发布准出
- P0 用例 100% 通过,P1 通过率不低于 98%。
- 不存在未关闭的阻断、资金错误、越权、数据泄露或不可逆数据损坏缺陷。
- 所有失败用例均有缺陷单、影响范围、临时措施和修复计划。
- 资金抽样满足:业务单金额 = 钱包变化 = 流水金额;重复请求不重复入账。
- 核心业务日志包含 request_id、业务单号和操作者,且无敏感明文。
### 12.3 缺陷记录模板
| 字段 | 内容 |
| --- | --- |
| 缺陷标题 | `[端/模块][严重级别] 简明现象` |
| 环境与版本 | 前端版本、后端版本、数据库迁移版本、浏览器/设备 |
| 前置数据 | 租户、账号代号、订单号、配置版本;不得粘贴真实敏感信息 |
| 复现步骤 | 可重复的最小步骤 |
| 实际结果 | 页面、接口状态码、reason、request_id、必要截图 |
| 预期结果 | 引用本文件用例 ID |
| 数据影响 | 涉及表、记录数、金额、是否可回滚 |
| 附件 | 脱敏截图、HAR、日志片段、SQL 核对结果 |
## 13. 执行记录模板
| 用例 ID | 执行人 | 日期 | 环境/版本 | 结果(通过/失败/阻塞) | 缺陷号 | 备注 |
| --- | --- | --- | --- | --- | --- | --- |
| 示例:C-FIN-031 | | | | | | |
---
说明:配置型金额、次数和时间不在本文写死具体业务数值。执行时应读取当前已发布配置,并使用“下限减 1、下限、下限加 1;上限减 1、上限、上限加 1”的方法验证。代码固定限制(例如分页最大 100、图片 5MB/10MB、视频 50MB、备注 500 字、任务正文 2000 字、附件最多 9 个)已在对应测试用例中明确列出。
@@ -0,0 +1,349 @@
# 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。
- 页面回归暂未完成:执行机器再次进入锁屏状态,无法继续通过浏览器点击;服务与前端均已在本地启动,解锁后可直接续测。
## 14. 测试环境发布后回归(2026-10-16)
### 14.1 已通过
- URL 地址池 Excel 导入弹窗已显示“取消”,点击后正常关闭。
- 订单列表已显示自动/手动派单模式,展开查询条件后可见派单模式筛选。
- 订单详情已显示状态、冻结状态、创建/更新时间、状态时间线、资金流水和审批记录。
- 会员任务审核列表已回显任务/商品名称和订单号,历史数据不再空白。
- 会员银行卡信息弹窗已回显开户人、身份证、银行卡、数字钱包和两类收款码;C 端“绑定信息”页也正常显示历史账户。
- 手工商城和任务大厅已显示发布状态及发布/下架(下线)操作。
- Banner 和商品列表的现有图片资源均能正常加载。
- 网站设置已回显站点名称、HTTPS 隐私地址、交易余额、等待时长、次数、最低充值和自动派单范围。
- 经营看板用同一套 `RefreshDaily` 逻辑手工触发后,显示 17 会员、9 商品、余额与昨日订单/充值数据,数据延迟 14 秒。
### 14.2 发布过程中已补齐
- 首次进入任务列表时接口报错,根因为测试库未执行 `hg_task.product_id` 迁移;补执后页面恢复。
- 已在测试库初始化 `dashboard_daily_summary` 定时任务,并补齐站点配置及空图片默认值。
- 站点配置迁移原先把数字写成 JSON 字符串,已改为强制写入 JSON 数值,页面回显正常。
### 14.3 需再发布后复测
- 新增任务弹窗请求商品时使用了 `perPage=500`,超过后端上限 200;已改为 200。
- 部分历史充值单没有实名档案和收款账户,但备注中存在“姓名:xxx”;已增加从备注回退显示姓名。
- 定时任务记录是在当前服务进程启动后插入的,现进程尚未加载该任务;再次发布/重启后需等待一个 10 分钟周期,确认 `calculated_at` 自动推进。
- 当前测试账号 `potter`(系统管理员角色)没有“定时任务”菜单权限,无法在页面手动执行;需使用超级管理员或在角色权限中授予该菜单。
@@ -0,0 +1,952 @@
# URL 地址池与会员注册域名绑定设计方案
> 文档状态:方案设计
> 方案版本:v2.1
> 最后更新:2026-10-15
> 适用项目:Olons Admin / HotGo
> 关联模块:会员注册、会员登录、会员管理、URL 地址池管理、会员登录日志
## 1. 方案变更说明
原方案采用“会员注册时从 URL 地址池轮询取出一个 URL”的模式。现调整为“会员通过哪个域名打开注册页面,就与哪个域名建立绑定关系”。
新流程如下:
```text
管理员或推广人员先向用户提供一个注册链接
→ 用户通过该链接打开注册页面
→ 注册页面获取浏览器地址栏中的域名
→ 用户提交注册
→ 后端校验本次请求的真实注册域名
→ 创建会员
→ 建立会员与注册域名的绑定关系
```
本方案不再使用 URL 轮询顺序、分配游标和自动轮询算法。URL 地址池的作用调整为:
1. 维护允许用于会员注册的域名;
2. 管理域名的启用、失效状态;
3. 统计每个域名带来的注册会员;
4. 查询会员与注册域名的绑定关系。
每个地址同时维护累计绑定次数。地址池列表默认把绑定次数较少的地址排在前面;点击地址详情可以查看当前绑定该地址的全部会员。
## 2. 设计目标
1. 用户使用某个域名注册时,自动与该域名绑定。
2. 注册域名由后端根据可信请求信息确认,不能只信任前端传入值。
3. 每个会员只能存在一个当前绑定域名。
4. 域名未配置或已失效时,会员仍允许注册成功,但保持“待绑定”。
5. 后台不提供创建会员、手动绑定或重新绑定域名的入口。
6. 域名失效后不再接受新的自动绑定,但保留原有绑定用于审计。
7. 支持同域部署、前后端跨域部署以及 CDN/反向代理部署。
8. 所有注册自动绑定和地址状态变更都有业务审计记录。
9. 每个地址记录累计绑定次数,地址池列表默认按绑定次数升序排列。
10. 后台可以手动将启用地址切换为失效,也可以将失效地址重新启用。
11. C 端会员登录成功后更新会员最后登录时间、IP脱敏信息和地区。
12. C 端会员每次登录成功或失败均记录独立日志,并可在后台查询、查看和导出。
## 3. 核心概念
### 3.1 注册 URL
管理员或推广人员提供给用户的完整注册链接,例如:
```text
https://register-a.example.com/register?inviteCode=ABC123
```
完整 URL 中可能包含路径、邀请码或其他查询参数,但会员绑定只取域名部分:
```text
register-a.example.com
```
默认端口不作为域名的一部分:
```text
https://register-a.example.com:443 → register-a.example.com
http://register-a.example.com:80 → register-a.example.com
```
非默认端口的测试环境可以保留端口:
```text
http://127.0.0.1:5173 → 127.0.0.1:5173
```
### 3.2 URL 地址池
URL 地址池是允许用于注册绑定的域名白名单。地址池中的域名分为:
| 值 | 状态 | 说明 |
|---:|---|---|
| 1 | 启用 | 允许新注册会员自动绑定 |
| 2 | 失效 | 不允许新注册会员自动绑定,保留原绑定关系 |
### 3.3 会员域名绑定
会员注册成功后,如果识别出的域名在地址池中存在且为启用状态,则创建绑定记录。
一个会员最多有一个绑定域名。绑定只允许在 C 端会员注册事务中自动建立,后台只能查看,不能手动绑定、解除绑定或重新绑定。
### 3.4 待绑定会员
以下情况会员注册成功但不建立域名绑定:
- 请求中无法识别有效注册域名;
- 注册域名没有配置在地址池中;
- 注册域名已经失效;
没有绑定记录的会员统一显示为“待绑定”。
## 4. 业务规则
### 4.1 正常注册绑定
```text
用户打开 https://register-a.example.com/register
→ 前端读取 window.location.origin
→ 用户提交注册
→ 后端确认真实来源域名为 register-a.example.com
→ 查询地址池
→ 地址存在且启用
→ 创建会员及钱包
→ 创建会员与 register-a.example.com 的绑定
→ 注册成功
```
### 4.2 地址池没有该域名
```text
用户通过 unknown.example.com 注册
→ 后端未在地址池找到该域名
→ 会员和钱包正常创建
→ 不创建域名绑定记录
→ 记录“未知注册域名”业务审计
→ 会员显示为待绑定
```
### 4.3 域名已经失效
```text
用户通过已失效域名注册
→ 会员正常创建
→ 不建立自动绑定
→ 记录“通过失效域名注册”业务审计
→ 会员显示为待绑定
```
### 4.4 重复注册请求
同一会员创建请求因网络原因重复提交时,应通过现有手机号、用户名唯一约束和注册幂等机制防止重复创建。
对已经存在的会员重复执行域名绑定:
- 已绑定同一域名:直接返回成功;
- 已绑定其他域名:不能由注册接口覆盖,也不提供后台改绑入口;
- 既有会员尚未绑定:保持待绑定,注册接口不做事后补绑。
## 5. 注册域名识别
### 5.1 前端采集
注册页面提交以下辅助字段:
```ts
const registerOrigin = window.location.origin;
const registerHost = window.location.host;
```
请求示例:
```json
{
"username": "demo_user",
"mobile": "13800000000",
"password": "******",
"registerOrigin": "https://register-a.example.com",
"registerHost": "register-a.example.com"
}
```
前端字段只用于辅助校验和问题排查,不能作为后端绑定的唯一依据,因为普通 HTTP 客户端可以伪造请求参数。
禁止保存完整 `window.location.href`,避免把邀请码、临时令牌或其他查询参数写入数据库和日志。
### 5.2 后端可信来源
后端按照部署方式解析真实注册域名。
#### 同域部署
注册页面和 API 使用同一个域名时,优先读取:
```text
HTTP Host
```
#### 前后端跨域部署
例如:
```text
注册页面:https://register-a.example.com
注册接口:https://api.example.com/api/member/register
```
此时 API 的 `Host` 是 `api.example.com`,不能用于会员绑定。浏览器跨域请求通常携带:
```text
Origin: https://register-a.example.com
```
后端应从经过 CORS 白名单校验的 `Origin` 中提取注册域名。
#### CDN 或反向代理部署
后端可能收到:
```text
X-Forwarded-Host
X-Forwarded-Proto
Forwarded
```
只有请求来自配置的可信代理 IP 时,才允许使用这些请求头。外部客户端直接传入的 `X-Forwarded-Host` 不可信。
### 5.3 建议解析优先级
```text
1. 已通过 CORS 白名单校验的 Origin 域名
2. 来自可信代理的 X-Forwarded-Host / Forwarded
3. HTTP Host
4. 前端 registerHost,仅用于一致性校验,不单独作为可信值
```
当可信来源域名与前端 `registerHost` 不一致时:
- 会员仍可注册;
- 不自动绑定域名;
- 写入安全警告和业务审计;
- 后台显示“注册域名校验异常”。
### 5.4 域名标准化
后端统一执行:
- 转换为小写;
- 移除首尾空格;
- 移除末尾的点;
- 国际化域名转换为 Punycode;
- 移除默认端口 `80` 和 `443`;
- 拒绝包含路径、用户名、密码、Fragment 的值;
- 开发环境允许配置带非默认端口的 Host;
- 生产环境建议只允许 HTTPS 注册入口。
## 6. 数据模型
域名绑定方案使用两张业务表,不再需要轮询游标表。会员登录审计另增一张独立日志表。
```text
hg_url_pool
│ 1
│
│ N
hg_member_url_binding ─── N:1 ─── hg_app_member
hg_app_member ─── 1:N ─── hg_app_member_login_log
```
### 6.1 URL 地址池表 `hg_url_pool`
```sql
CREATE TABLE IF NOT EXISTS `hg_url_pool` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`name` varchar(100) NOT NULL DEFAULT '' COMMENT '地址名称',
`scheme` varchar(10) NOT NULL DEFAULT 'https' COMMENT '协议:http或https',
`domain` varchar(255) NOT NULL COMMENT '标准化域名,可含非默认端口',
`domain_hash` char(64) NOT NULL COMMENT '标准化域名SHA256',
`base_url` varchar(512) NOT NULL COMMENT '注册入口基础地址',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1启用,2失效',
`bind_count` bigint unsigned NOT NULL DEFAULT 0 COMMENT '累计成功绑定次数',
`last_bound_at` datetime(3) DEFAULT NULL COMMENT '最后绑定时间',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
`created_by` bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建者ID',
`updated_by` bigint unsigned NOT NULL DEFAULT 0 COMMENT '更新者ID',
`deleted_by` bigint unsigned NOT NULL DEFAULT 0 COMMENT '删除者ID',
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`deleted_at` datetime(3) DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_url_pool_domain` (`tenant_id`,`domain_hash`),
KEY `idx_url_pool_list` (`tenant_id`,`bind_count`,`id`,`deleted_at`),
KEY `idx_url_pool_status` (`tenant_id`,`status`,`bind_count`,`deleted_at`),
KEY `idx_url_pool_deleted` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='URL地址池';
```
字段说明:
| 字段 | 说明 |
|---|---|
| `scheme` | 注册入口协议,生产环境一般为 HTTPS |
| `domain` | 绑定和匹配使用的标准化域名 |
| `domain_hash` | 用于唯一索引,避免域名大小写或格式差异导致重复 |
| `base_url` | 后台展示、复制链接时使用,例如 `https://a.example.com/register` |
| `bind_count` | C 端注册成功并实际建立域名绑定的累计次数 |
| `last_bound_at` | 最近一次成功建立绑定的时间 |
地址一旦产生会员绑定,禁止直接修改 `domain`。需要使用新域名时应新增地址;已有会员继续保留原绑定关系。
`bind_count` 是累计使用次数。当前绑定会员数通过 `hg_member_url_binding` 按 `url_pool_id` 实时统计。正常情况下两者一致;保留累计字段便于快速排序和审计校验。
### 6.2 会员域名绑定表 `hg_member_url_binding`
```sql
CREATE TABLE IF NOT EXISTS `hg_member_url_binding` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`member_id` bigint unsigned NOT NULL COMMENT '会员ID',
`url_pool_id` bigint unsigned NOT NULL COMMENT 'URL地址池ID',
`domain_snapshot` varchar(255) NOT NULL COMMENT '绑定时域名快照',
`origin_snapshot` varchar(512) NOT NULL DEFAULT '' COMMENT '绑定时Origin快照',
`bound_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '绑定时间',
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_member_url_binding` (`tenant_id`,`member_id`),
KEY `idx_member_url_pool` (`tenant_id`,`url_pool_id`,`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='会员注册域名绑定';
```
设计说明:
- 未绑定会员没有该表记录;
- `member_id` 唯一约束保证一个会员只有一个当前绑定;
- `domain_snapshot` 用于保留绑定时的域名;
- `origin_snapshot` 只保存 Scheme 和 Host,不保存路径及查询参数;
- 绑定记录由 C 端注册事务创建,后台仅提供只读查询。
### 6.3 会员登录日志表 `hg_app_member_login_log`
```sql
CREATE TABLE IF NOT EXISTS `hg_app_member_login_log` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '日志ID',
`tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`req_id` varchar(64) NOT NULL DEFAULT '' COMMENT '请求ID',
`member_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '会员ID,未识别会员时为0',
`username` varchar(64) NOT NULL DEFAULT '' COMMENT '用户名快照',
`mobile_mask` varchar(32) NOT NULL DEFAULT '' COMMENT '手机号脱敏值',
`login_type` tinyint NOT NULL DEFAULT 1 COMMENT '登录方式:1账号密码,2手机验证码',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '登录状态:1成功,2失败',
`err_msg` varchar(255) NOT NULL DEFAULT '' COMMENT '失败原因',
`login_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登录时间',
`login_ip_cipher` text COMMENT '登录IP密文',
`login_ip_mask` varchar(64) NOT NULL DEFAULT '' COMMENT '登录IP脱敏值',
`login_region` varchar(128) NOT NULL DEFAULT '' COMMENT '登录地区',
`login_domain` varchar(255) NOT NULL DEFAULT '' COMMENT '登录页面标准化域名',
`user_agent` varchar(512) NOT NULL DEFAULT '' COMMENT '浏览器UA',
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_member_login_time` (`tenant_id`,`member_id`,`login_at`),
KEY `idx_member_login_status` (`tenant_id`,`status`,`login_at`),
KEY `idx_member_login_username` (`tenant_id`,`username`,`login_at`),
KEY `idx_member_login_domain` (`tenant_id`,`login_domain`,`login_at`),
KEY `idx_member_login_req` (`req_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
COMMENT='C端会员登录日志';
```
设计说明:
- 登录成功和失败都必须写入日志;
- 登录失败且无法识别会员时,`member_id` 为 `0`,仅保存用户名快照或手机号脱敏值;
- 不保存密码、短信验证码、Token、完整手机号及登录响应正文;
- IP 仅保存密文和脱敏值,后台页面不得返回 IP 密文;
- 日志为审计数据,后台只读,不提供人工新增、编辑和删除功能;
- 操作系统和浏览器名称由 `user_agent` 在查询时解析,无需重复存储。
## 7. 字典设计
继续使用 HotGo 后端统一字典,不在前端硬编码。
```go
const (
UrlPoolStatusEnabled = 1 // 启用
UrlPoolStatusInvalid = 2 // 失效
AppMemberLoginTypePassword = 1 // 账号密码
AppMemberLoginTypeSms = 2 // 手机验证码
)
```
建议字典:
| 字典键 | 说明 |
|---|---|
| `urlPoolStatus` | URL 地址状态 |
| `appMemberLoginTypeOptions` | C 端会员登录方式,新建后端枚举字典 |
| `sys_login_status` | 登录成功/失败状态,直接复用 HotGo 已有字典 |
会员绑定状态不需要单独存储:
- 存在绑定记录:已绑定;
- 不存在绑定记录:待绑定;
- 存在绑定但地址池状态为失效:绑定域名已失效。
## 8. 注册事务设计
### 8.1 注册流程
```text
解析可信注册域名
→ 查询 URL 地址池
→ 开启事务
→ 创建会员主档
→ 初始化会员钱包
→ 如果域名存在且启用,创建绑定
→ 地址池 bind_count 加 1,并更新 last_bound_at
→ 写业务审计
→ 提交事务
```
### 8.2 错误边界
以下情况不阻断注册:
- 没有识别到域名;
- 域名未配置;
- 域名已经失效;
- 前端域名与可信来源不一致。
以上情况注册成功但会员保持待绑定,并写入审计。
以下情况必须回滚注册:
- 查询地址池时数据库异常;
- 创建会员失败;
- 初始化钱包失败;
- 已确认需要绑定,但插入绑定记录失败;
- 更新注册统计失败;
不能将数据库错误误判成“域名不存在”。
### 8.3 并发与幂等
- `hg_member_url_binding(tenant_id, member_id)` 唯一,防止重复绑定;
- `hg_url_pool(tenant_id, domain_hash)` 唯一,防止重复域名;
- 创建绑定前先查询会员现有绑定;
- 同域名重复绑定直接返回成功;
- 注册接口不允许覆盖不同域名的已有绑定。
只有 C 端注册事务实际新建绑定时才增加 `bind_count`。同一会员对同一地址的幂等重试不增加次数。
## 9. 后端服务设计
建议新增共享服务:
```go
type RegisterDomainModel struct {
Scheme string
Domain string
Origin string
Trusted bool
Source string
}
type MemberDomainResult struct {
Bound bool `json:"bound"`
UrlPoolId uint64 `json:"urlPoolId"`
Domain string `json:"domain"`
BaseUrl string `json:"baseUrl"`
StatusLabel string `json:"statusLabel"`
}
```
核心方法:
```text
ResolveRegisterDomain(ctx, registerOrigin, registerHost)
解析并校验真实注册域名
BindOnRegister(ctx, memberId, domain)
注册事务内自动绑定
GetMemberDomain(ctx, memberId)
查询会员当前绑定及域名有效状态
```
会员登录日志服务增加:
```text
PushMemberLoginLog(ctx, member, loginType, err)
收集请求上下文并推送会员登录日志
WriteMemberLoginLog(ctx, log)
队列消费或同步兜底写入日志
UpdateMemberLastLogin(ctx, memberId, ipData)
登录成功后更新会员主表最后登录信息
```
项目现有 `/site/register` 是后台管理员账号注册,不是 `hg_app_member` 的 C 端会员注册。新域名绑定应接入真正的 C 端会员注册接口。
后台当前不提供创建 C 端会员的产品功能,本方案也不新增该入口。会员及其域名绑定只能由 C 端注册流程创建。
## 10. 后台地址池管理
### 10.1 菜单位置
```text
会员管理
└── URL地址池
```
### 10.2 汇总数据
- URL 总数;
- 启用 URL 数;
- 失效 URL 数;
- 已绑定会员数;
- 待绑定会员数;
- 绑定域名已失效会员数。
### 10.3 查询条件
- 地址名称;
- 域名关键词;
- URL 状态;
- 创建时间。
### 10.4 列表字段
- 地址名称;
- 注册域名;
- 注册入口 URL;
- 状态;
- 当前绑定会员数;
- 累计绑定次数;
- 最后绑定时间;
- 备注;
- 创建时间;
- 操作。
列表默认排序固定为:
```sql
ORDER BY bind_count ASC, id ASC
```
即累计绑定次数最少的地址排在最前面;绑定次数相同时,先创建的地址排在前面。分页查询必须由后端应用该排序,不能只在前端对当前页排序。
### 10.5 操作按钮
| 按钮 | 条件 | 功能 |
|---|---|---|
| 新增地址 | 有新增权限 | 添加注册域名和入口地址 |
| Excel批量导入 | 有导入权限 | 按“地址名称、注册入口URL、备注”批量导入,导入地址统一启用 |
| 编辑 | 始终可见 | 编辑名称、入口路径和备注 |
| 启用 | 当前失效 | 手动将地址切换为启用,恢复自动注册绑定 |
| 设为失效 | 当前启用 | 手动将地址切换为失效,停止新的自动绑定 |
| 复制注册链接 | 当前启用 | 复制完整注册入口 URL |
| 详情 | 始终可见 | 进入地址详情页,查看地址信息和全部绑定会员 |
地址池不提供删除功能,历史地址统一通过“启用/失效”管理。启用和失效由管理员手动点击切换。状态变更成功后立即刷新列表,并写入业务审计。地址变为失效后:
- 不再接受新的注册自动绑定;
- 已有会员绑定关系保留;
- 详情页仍然可以查看原有绑定会员。
### 10.6 地址详情页
点击列表中的“详情”进入独立详情页。页面分为地址信息和绑定会员两个区域。
地址信息展示:
- 地址名称;
- 注册域名;
- 注册入口 URL;
- 当前状态;
- 累计绑定次数;
- 当前绑定会员数;
- 最后绑定时间;
- 创建人、创建时间;
- 备注。
绑定会员列表展示当前绑定该地址的全部会员,并支持分页。字段包括:
- 会员编号;
- 用户名;
- 手机号脱敏值;
- 会员状态;
- 绑定时间。
详情页会员列表支持按用户名、手机号和绑定时间查询。这里的“全部用户”指绑定该地址的全部会员,列表仅供查看,不提供添加、解绑或改绑操作。
## 11. 会员列表扩展
会员列表增加字段:
- 注册域名;
- 域名绑定状态;
- 绑定时间。
增加筛选:
| 筛选值 | 条件 |
|---|---|
| 全部 | 不限制 |
| 已绑定 | 存在绑定且域名启用 |
| 待绑定 | 不存在绑定记录 |
| 域名已失效 | 存在绑定但域名已失效 |
操作栏不增加绑定、解绑或重新绑定按钮。会员详情可只读查看域名信息。
会员详情增加“注册域名信息”区域,展示:
- 当前域名;
- 注册入口;
- 域名状态;
- 绑定时间。
## 12. 会员登录与日志设计
### 12.1 建设范围
当前项目的登录日志分为两类理解:
- `hg_sys_login_log` 及“系统登录日志”页面记录的是后台管理员登录。现有后台账号、手机号登录均会调用 `SysLoginLog().Push()` 写入成功或失败结果;
- `hg_app_member` 虽然预留了 `last_login_at`、`last_login_ip_cipher`、`last_login_ip_mask`、`last_login_region` 字段,会员列表也展示最后登录信息,但当前代码中没有找到 C 端会员登录接口对这些字段的更新逻辑;
- 当前也没有独立的会员登录历史表或会员登录日志列表。
因此,现状结论是:**后台管理员登录有日志,C 端会员登录目前没有完整可用的日志功能**。
本次将以下两项能力纳入正式实施范围:
1. 登录成功后更新会员主表中的最后登录时间、IP 脱敏值和地区;
2. 使用独立的 `hg_app_member_login_log` 记录每次会员登录成功或失败,避免与后台管理员的 `hg_sys_login_log` 混用。
### 12.2 登录成功后的会员主表更新
会员身份校验通过后、返回登录成功响应前,更新 `hg_app_member`:
| 字段 | 更新内容 |
|---|---|
| `last_login_at` | 当前登录成功时间 |
| `last_login_ip_cipher` | 使用项目统一加密工具保存的客户端 IP 密文 |
| `last_login_ip_mask` | 用于后台展示的 IP 脱敏值 |
| `last_login_region` | 根据客户端 IP 解析的国家/省/市摘要 |
处理规则:
- 只有登录成功才更新上述字段,登录失败不得覆盖会员最后一次成功登录信息;
- 客户端 IP 使用项目现有 `location.GetClientIp()` 识别,并遵守可信代理配置;
- 地区解析失败不阻断登录,地区保存为空,同时记录 Warning 日志;
- 会员主表更新失败时不签发 Token,向客户端返回“登录失败,请稍后重试”;
- 更新字段必须使用 DAO 列名,禁止硬编码字段字符串;
- 不在业务日志中输出 IP 密文、密码、短信验证码或 Token。
### 12.3 会员登录日志写入流程
所有 C 端会员登录入口必须调用同一个会员登录日志服务,不允许每种登录方式自行拼装日志。
```text
收到登录请求
→ 解析请求ID、登录方式、客户端IP、地区、域名和User-Agent
→ 校验会员及登录凭证
→ 失败:写入失败日志,返回统一登录错误
→ 成功:更新会员最后登录信息
→ 签发Token
→ 写入成功日志
→ 返回登录结果
```
为保证失败请求也能记录,登录控制器或登录服务应使用统一的收口逻辑,在方法返回前根据最终 `err` 和会员识别结果生成日志。日志优先通过项目消息队列异步写入;队列推送失败时同步写库兜底,并记录 Warning。日志写入最终失败不得把已经完成的身份认证改成登录失败,但必须留下服务端告警。
失败原因只记录可审计的标准化文案,例如:
- 账号或密码错误;
- 验证码错误或已过期;
- 会员不存在;
- 会员已禁用;
- 风控限制登录;
- 系统异常。
### 12.4 后台会员登录日志页面
菜单位置:
```text
会员管理
└── 会员登录日志
```
查询条件:
- 会员编号、用户名或手机号关键词;
- 登录方式;
- 登录状态;
- 登录域名;
- IP 脱敏值;
- 登录时间范围。
列表默认按照 `login_at DESC, id DESC` 排序,展示:
- 日志ID;
- 会员编号;
- 用户名;
- 手机号脱敏值;
- 登录方式;
- 登录状态;
- 登录时间;
- 登录IP脱敏值;
- 登录地区;
- 登录域名;
- 操作系统;
- 浏览器;
- 失败原因;
- 操作。
操作栏只提供“详情”。列表顶部提供“导出”,导出内容与当前查询条件一致。详情弹窗补充展示请求ID和完整 User-Agent,但不展示 IP 密文及任何登录凭证。
列表查询必须按 `tenant_id` 隔离数据。使用完整手机号查询时,先在会员表中匹配会员ID,再按 `member_id` 查询日志;对无法识别会员的失败记录,只支持按已保存的手机号脱敏值查询。
## 13. 接口设计
### 13.1 后台接口
| 接口 | 方法 | 说明 |
|---|---|---|
| `/urlPool/list` | GET | 地址池分页列表 |
| `/urlPool/view` | GET | 地址详情及绑定统计 |
| `/urlPool/edit` | POST | 新增或编辑地址 |
| `/urlPool/import` | POST | Excel批量导入地址,成功记录统一设为启用 |
| `/urlPool/status` | POST | 启用或设为失效 |
| `/urlPool/memberList` | GET | 分页查看域名当前绑定的全部会员 |
| `/urlPool/summary` | GET | 地址池汇总数据 |
| `/appMemberLoginLog/list` | GET | 会员登录日志分页列表 |
| `/appMemberLoginLog/view` | GET | 会员登录日志详情 |
| `/appMemberLoginLog/export` | GET | 按当前条件导出会员登录日志 |
### 13.2 C 端接口
注册请求增加:
```json
{
"registerOrigin": "https://register-a.example.com",
"registerHost": "register-a.example.com"
}
```
会员查询当前域名响应建议:
```json
{
"bound": true,
"domain": "register-a.example.com",
"baseUrl": "https://register-a.example.com/register",
"domainStatus": 1,
"domainStatusLabel": "启用"
}
```
未绑定时:
```json
{
"bound": false,
"domain": "",
"baseUrl": "",
"domainStatus": 0,
"domainStatusLabel": "待绑定"
}
```
所有 C 端会员登录接口的响应格式保持不变,日志采集由服务端统一完成,不要求前端额外提交 IP、地区或 User-Agent。跨域部署时,前端可提交 `loginHost` 作为辅助校验值,但最终登录域名仍按可信 `Origin`、可信代理转发头和 `Host` 解析。
## 14. HotGo 模块结构
建议作为主模块开发:
```text
server/
├── api/admin/urlpool/urlpool.go
├── api/admin/appmemberloginlog/appmemberloginlog.go
├── internal/consts/url_pool.go
├── internal/consts/app_member_login_log.go
├── internal/controller/admin/sys/url_pool.go
├── internal/controller/admin/sys/app_member_login_log.go
├── internal/logic/sys/url_pool.go
├── internal/logic/sys/app_member_login_log.go
├── internal/model/input/sysin/url_pool.go
├── internal/model/input/sysin/app_member_login_log.go
├── internal/router/genrouter/url_pool.go
├── internal/router/genrouter/app_member_login_log.go
├── internal/queues/app_member_login_log.go
├── internal/service/sys.go # 追加 ISysUrlPool、ISysAppMemberLoginLog
├── internal/dao/url_pool.go # CLI 生成
├── internal/dao/member_url_binding.go # CLI 生成
├── internal/dao/app_member_login_log.go # CLI 生成
├── storage/data/migrations/<date>_url_pool.sql
└── storage/data/migrations/<date>_app_member_login_log.sql
web/src/
├── api/urlPool/index.ts
├── api/appMemberLoginLog/index.ts
├── views/app-member/url-pool/
├── index.vue
├── edit.vue
├── detail.vue
└── model.ts
└── views/app-member/login-log/
├── index.vue
├── detail.vue
└── model.ts
```
DAO、Entity、DO 和 DAO Internal 必须通过 GoFrame CLI 生成,不手工修改。
## 15. 权限设计
```text
/urlPool/list
/urlPool/view
/urlPool/edit
/urlPool/import
/urlPool/status
/urlPool/memberList
/urlPool/summary
/appMemberLoginLog/list
/appMemberLoginLog/view
/appMemberLoginLog/export
```
域名启用/失效和注册自动绑定均写入业务审计。
## 16. 缓存设计
会员当前域名可以使用项目统一缓存:
```text
urlpool:member:{tenantId}:{memberId}
```
地址池域名查询可以缓存:
```text
urlpool:domain:{tenantId}:{domainHash}
```
建议缓存 10~30 分钟。以下操作后主动清除缓存:
- 地址编辑;
- 地址启用或失效;
- 注册自动绑定;
缓存统一使用 `cache.Instance()`,数据库仍是唯一事实来源。
## 17. 安全要求
1. 不得只信任前端 `registerHost` 参数。
2. `Origin` 必须通过地址池或 CORS 白名单校验。
3. 转发请求头只能在可信代理来源下使用。
4. 后台保存域名前必须进行标准化和 URL 解析。
5. 禁止保存包含用户名、密码、Token 的 URL。
6. 日志只记录标准化域名和 Origin,不记录完整注册查询参数。
7. 后台不得提供会员域名绑定、解绑或改绑接口。
8. 生产环境建议只允许 HTTPS 注册入口。
9. 域名变更不得静默覆盖已有会员绑定。
10. 未知或校验异常域名不自动创建地址池记录。
11. 会员登录日志不得保存密码、短信验证码、Token、完整手机号或登录响应正文。
12. 后台日志接口不得返回 `last_login_ip_cipher` 或 `login_ip_cipher`。
13. 失败日志使用统一错误文案,避免暴露账号是否存在等敏感认证细节。
14. 会员登录日志列表、详情和导出必须执行租户隔离,禁止跨租户查询。
## 18. 异常处理
| 场景 | 处理方式 |
|---|---|
| 地址池为空 | 注册成功,会员待绑定 |
| 注册域名未配置 | 注册成功,会员待绑定并写审计 |
| 注册域名失效 | 注册成功,会员待绑定并写审计 |
| 前端域名与可信来源不一致 | 注册成功但不绑定,记录安全警告 |
| 域名重复添加 | 拒绝保存 |
| 已绑定同一域名 | 幂等返回成功 |
| 注册接口试图覆盖其他域名 | 拒绝覆盖,保留原绑定关系 |
| 地址池查询数据库异常 | 注册失败并回滚 |
| 创建绑定失败 | 注册事务回滚 |
| 已产生绑定的域名被直接修改 | 拒绝修改域名字段 |
| 列表绑定次数相同 | 按地址ID升序稳定排序 |
| 登录成功但会员最后登录信息更新失败 | 不签发Token,返回通用系统错误并记录失败日志 |
| 登录IP地区解析失败 | 登录继续,地区留空并记录Warning |
| 登录日志队列推送失败 | 同步写入日志表兜底并记录Warning |
| 队列与同步写入均失败 | 不撤销已完成登录,记录服务端告警供运维处理 |
| 登录失败且无法识别会员 | 以 `member_id=0` 写入脱敏失败日志 |
## 19. 验收标准
### 19.1 注册绑定
- 用户通过 A 域名注册后,会员绑定 A。
- 用户通过 B 域名注册后,会员绑定 B。
- URL 路径和查询参数不同但域名相同,绑定结果相同。
- 默认端口不会造成重复域名。
- 地址池为空时会员仍注册成功并显示待绑定。
- 未配置域名和失效域名不会建立自动绑定。
### 19.2 部署场景
- 前后端同域部署能够从 Host 正确识别域名。
- 前后端跨域部署能够从通过校验的 Origin 正确识别域名。
- CDN/反向代理部署能够从可信转发头正确识别域名。
- 外部客户端伪造 `X-Forwarded-Host` 不会产生错误绑定。
- 前端伪造 `registerHost` 不会覆盖后端可信域名。
### 19.3 并发和幂等
- 并发提交不会为同一会员创建多条绑定。
- 相同注册请求重试不会重复创建会员或绑定。
- 已绑定会员不能通过普通注册接口更换域名。
### 19.4 后台管理
- 可以新增、编辑、启用和失效注册域名。
- 可以使用 Excel 批量导入地址,表头包含“地址名称、注册入口URL、备注”,所有导入地址默认为启用。
- 可以手动点击“启用”或“设为失效”,状态立即生效。
- 地址池列表默认按累计绑定次数升序排列。
- 可以复制完整注册链接。
- 点击地址详情可以分页查看当前绑定的全部会员。
- 每次实际建立新绑定时,地址累计绑定次数准确加一。
- 幂等重试不会重复增加累计绑定次数。
- 后台不存在创建会员入口。
- 后台不存在会员域名绑定、解绑或重新绑定入口。
- 失效域名不允许新增绑定。
- 所有关键操作均可在业务审计中查询。
### 19.5 会员登录日志
- 会员登录成功后,会员主表最后登录时间、IP密文、IP脱敏值和地区正确更新。
- 登录失败不会覆盖会员最后一次成功登录信息。
- 账号密码登录和手机验证码登录均写入独立会员登录日志。
- 登录成功、密码错误、验证码错误、账号禁用和系统异常场景均能生成对应日志。
- 无法识别会员的失败请求以 `member_id=0` 记录,且不保存完整手机号。
- 后台“会员登录日志”可以按会员、方式、状态、域名、IP和时间查询。
- 日志列表默认按登录时间倒序排列,可以查看详情并按筛选条件导出。
- 后台页面和导出文件均不出现IP密文、密码、验证码或Token。
- 登录日志队列异常时会同步写库兜底;两种写入都失败时产生服务端 Warning 告警。
## 20. 待确认事项
1. 生产环境是否强制只允许 HTTPS。
2. 前端页面和注册 API 是同域部署还是跨域部署。
3. 是否存在 App、桌面客户端等没有浏览器 `Origin` 的注册入口。
4. 注册入口是否统一为 `/register`,还是每个域名可以配置不同路径。
5. 地址池是否需要按业务线、渠道或租户进一步分组。
6. 会员登录日志的在线保留时长,以及是否需要定时归档历史日志。
@@ -0,0 +1,31 @@
package appmemberloginlog
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type ListReq struct {
g.Meta `path:"/appMemberLoginLog/list" method:"get" tags:"会员登录日志" summary:"获取会员登录日志列表"`
sysin.AppMemberLoginLogListInp
}
type ListRes struct {
form.PageRes
List []*sysin.AppMemberLoginLogListModel `json:"list"`
}
type ViewReq struct {
g.Meta `path:"/appMemberLoginLog/view" method:"get" tags:"会员登录日志" summary:"获取会员登录日志详情"`
sysin.AppMemberLoginLogViewInp
}
type ViewRes struct {
*sysin.AppMemberLoginLogViewModel
}
type ExportReq struct {
g.Meta `path:"/appMemberLoginLog/export" method:"get" tags:"会员登录日志" summary:"导出会员登录日志"`
sysin.AppMemberLoginLogListInp
}
type ExportRes struct{}
+15
View File
@@ -33,6 +33,21 @@ type MatchReq struct {
sysin.TradeOrderMatchInp
}
type MatchRes struct{}
type UpdateAmountReq struct {
g.Meta `path:"/tradeOrder/updateAmount" method:"post" tags:"交易订单" summary:"更改手动订单金额"`
sysin.TradeOrderUpdateAmountInp
}
type UpdateAmountRes struct{}
type ForcePayReq struct {
g.Meta `path:"/tradeOrder/forcePay" method:"post" tags:"交易订单" summary:"强制支付手动订单"`
sysin.TradeOrderManualActionInp
}
type ForcePayRes struct{}
type FreezeReq struct {
g.Meta `path:"/tradeOrder/freeze" method:"post" tags:"交易订单" summary:"冻结手动订单"`
sysin.TradeOrderManualActionInp
}
type FreezeRes struct{}
type ApplyReq struct {
g.Meta `path:"/tradeOrder/apply" method:"post" tags:"交易订单" summary:"提交订单人工处理审批"`
sysin.TradeOrderApplyInp
+57
View File
@@ -0,0 +1,57 @@
package urlpool
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type ListReq struct {
g.Meta `path:"/urlPool/list" method:"get" tags:"URL地址池" summary:"获取URL地址池列表"`
sysin.UrlPoolListInp
}
type ListRes struct {
form.PageRes
List []*sysin.UrlPoolListModel `json:"list"`
}
type ViewReq struct {
g.Meta `path:"/urlPool/view" method:"get" tags:"URL地址池" summary:"获取URL地址详情"`
sysin.UrlPoolViewInp
}
type ViewRes struct{ *sysin.UrlPoolViewModel }
type EditReq struct {
g.Meta `path:"/urlPool/edit" method:"post" tags:"URL地址池" summary:"新增或编辑URL地址"`
sysin.UrlPoolEditInp
}
type EditRes struct{}
type StatusReq struct {
g.Meta `path:"/urlPool/status" method:"post" tags:"URL地址池" summary:"切换URL地址状态"`
sysin.UrlPoolStatusInp
}
type StatusRes struct{}
type ImportReq struct {
g.Meta `path:"/urlPool/import" method:"post" mime:"multipart/form-data" tags:"URL地址池" summary:"Excel批量导入URL地址"`
File *ghttp.UploadFile `json:"file" type:"file" v:"required#请选择Excel文件" dc:"Excel文件"`
}
type ImportRes struct{ *sysin.UrlPoolImportModel }
type MemberListReq struct {
g.Meta `path:"/urlPool/memberList" method:"get" tags:"URL地址池" summary:"获取地址绑定会员"`
sysin.UrlPoolMemberListInp
}
type MemberListRes struct {
form.PageRes
List []*sysin.UrlPoolMemberListModel `json:"list"`
}
type SummaryReq struct {
g.Meta `path:"/urlPool/summary" method:"get" tags:"URL地址池" summary:"获取URL地址池汇总"`
sysin.UrlPoolSummaryInp
}
type SummaryRes struct{ *sysin.UrlPoolSummaryModel }
+3
View File
@@ -12,4 +12,7 @@ import (
type IMemberV1 interface {
GetIdByCode(ctx context.Context, req *v1.GetIdByCodeReq) (res *v1.GetIdByCodeRes, err error)
Register(ctx context.Context, req *v1.RegisterReq) (res *v1.RegisterRes, err error)
AccountLogin(ctx context.Context, req *v1.AccountLoginReq) (res *v1.AccountLoginRes, err error)
MobileLogin(ctx context.Context, req *v1.MobileLoginReq) (res *v1.MobileLoginRes, err error)
}
+25
View File
@@ -0,0 +1,25 @@
package v1
import (
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type RegisterReq struct {
g.Meta `path:"/member/register" method:"post" tags:"会员认证" summary:"C端会员注册"`
sysin.AppMemberRegisterInp
}
type RegisterRes struct{ *sysin.AppMemberRegisterModel }
type AccountLoginReq struct {
g.Meta `path:"/member/accountLogin" method:"post" tags:"会员认证" summary:"C端会员账号密码登录"`
sysin.AppMemberAccountLoginInp
}
type AccountLoginRes struct{ *sysin.AppMemberLoginModel }
type MobileLoginReq struct {
g.Meta `path:"/member/mobileLogin" method:"post" tags:"会员认证" summary:"C端会员手机验证码登录"`
sysin.AppMemberMobileLoginInp
}
type MobileLoginRes struct{ *sysin.AppMemberLoginModel }
+4 -4
View File
@@ -3,12 +3,12 @@
// @Copyright Copyright (c) 2023 HotGo CLI
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
//
package consts
// 消息队列
const (
QueueLogTopic = `request_log` // 访问日志
QueueLoginLogTopic = `login_log` // 登录日志
QueueServeLogTopic = `serve_log` // 服务日志
QueueLogTopic = `request_log` // 访问日志
QueueLoginLogTopic = `login_log` // 登录日志
QueueAppMemberLoginLogTopic = `app_member_login_log` // C端会员登录日志
QueueServeLogTopic = `serve_log` // 服务日志
)
+1 -1
View File
@@ -106,7 +106,7 @@ var CustomerServiceOnlineOptions = []*model.Option{
}
var TradeAutoDispatchOptions = []*model.Option{
dict.GenSuccessOption(TradeAutoDispatchEnabled, "自动派单"),
dict.GenDefaultOption(TradeAutoDispatchDisabled, "手动领取"),
dict.GenDefaultOption(TradeAutoDispatchDisabled, "手动派单"),
}
func init() {
+40
View File
@@ -0,0 +1,40 @@
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
const (
UrlPoolStatusEnabled = 1
UrlPoolStatusInvalid = 2
AppMemberLoginTypePassword = 1
AppMemberLoginTypeSms = 2
AppMemberDomainBoundEnabled = 1
AppMemberDomainUnbound = 2
AppMemberDomainBoundInvalid = 3
)
var (
UrlPoolStatusOptions = []*model.Option{
dict.GenSuccessOption(UrlPoolStatusEnabled, "启用"),
dict.GenWarningOption(UrlPoolStatusInvalid, "失效"),
}
AppMemberLoginTypeOptions = []*model.Option{
dict.GenPrimaryOption(AppMemberLoginTypePassword, "账号密码"),
dict.GenInfoOption(AppMemberLoginTypeSms, "手机验证码"),
}
AppMemberDomainStatusOptions = []*model.Option{
dict.GenSuccessOption(AppMemberDomainBoundEnabled, "已绑定"),
dict.GenWarningOption(AppMemberDomainUnbound, "待绑定"),
dict.GenErrorOption(AppMemberDomainBoundInvalid, "域名已失效"),
}
)
func init() {
dict.RegisterEnums("urlPoolStatus", "URL地址状态", UrlPoolStatusOptions)
dict.RegisterEnums("appMemberLoginTypeOptions", "会员登录方式", AppMemberLoginTypeOptions)
dict.RegisterEnums("appMemberDomainStatus", "会员域名绑定状态", AppMemberDomainStatusOptions)
}
@@ -0,0 +1,39 @@
package sys
import (
"context"
"hotgo/api/admin/appmemberloginlog"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var AppMemberLoginLog = cAppMemberLoginLog{}
type cAppMemberLoginLog struct{}
func (c *cAppMemberLoginLog) List(ctx context.Context, req *appmemberloginlog.ListReq) (res *appmemberloginlog.ListRes, err error) {
list, total, err := service.SysAppMemberLoginLog().List(ctx, &req.AppMemberLoginLogListInp)
if err != nil {
return nil, err
}
if list == nil {
list = []*sysin.AppMemberLoginLogListModel{}
}
res = new(appmemberloginlog.ListRes)
res.List = list
res.PageRes.Pack(req, total)
return
}
func (c *cAppMemberLoginLog) View(ctx context.Context, req *appmemberloginlog.ViewReq) (*appmemberloginlog.ViewRes, error) {
data, err := service.SysAppMemberLoginLog().View(ctx, &req.AppMemberLoginLogViewInp)
if err != nil {
return nil, err
}
return &appmemberloginlog.ViewRes{AppMemberLoginLogViewModel: data}, nil
}
func (c *cAppMemberLoginLog) Export(ctx context.Context, req *appmemberloginlog.ExportReq) (*appmemberloginlog.ExportRes, error) {
return &appmemberloginlog.ExportRes{}, service.SysAppMemberLoginLog().Export(ctx, &req.AppMemberLoginLogListInp)
}
@@ -45,6 +45,15 @@ func (c *cTradeOrder) MatchProducts(ctx context.Context, req *tradeorder.MatchPr
func (c *cTradeOrder) Match(ctx context.Context, req *tradeorder.MatchReq) (*tradeorder.MatchRes, error) {
return &tradeorder.MatchRes{}, service.SysTradeOrder().Match(ctx, &req.TradeOrderMatchInp)
}
func (c *cTradeOrder) UpdateAmount(ctx context.Context, req *tradeorder.UpdateAmountReq) (*tradeorder.UpdateAmountRes, error) {
return &tradeorder.UpdateAmountRes{}, service.SysTradeOrder().UpdateAmount(ctx, &req.TradeOrderUpdateAmountInp)
}
func (c *cTradeOrder) ForcePay(ctx context.Context, req *tradeorder.ForcePayReq) (*tradeorder.ForcePayRes, error) {
return &tradeorder.ForcePayRes{}, service.SysTradeOrder().ForcePay(ctx, &req.TradeOrderManualActionInp)
}
func (c *cTradeOrder) Freeze(ctx context.Context, req *tradeorder.FreezeReq) (*tradeorder.FreezeRes, error) {
return &tradeorder.FreezeRes{}, service.SysTradeOrder().Freeze(ctx, &req.TradeOrderManualActionInp)
}
func (c *cTradeOrder) Apply(ctx context.Context, req *tradeorder.ApplyReq) (*tradeorder.ApplyRes, error) {
v, err := service.SysTradeOrder().Apply(ctx, &req.TradeOrderApplyInp)
return &tradeorder.ApplyRes{FinanceApprovalModel: v}, err
@@ -0,0 +1,91 @@
package sys
import (
"context"
"strings"
"hotgo/api/admin/urlpool"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/errors/gerror"
)
var UrlPool = cUrlPool{}
type cUrlPool struct{}
func (c *cUrlPool) List(ctx context.Context, req *urlpool.ListReq) (res *urlpool.ListRes, err error) {
list, total, err := service.SysUrlPool().List(ctx, &req.UrlPoolListInp)
if err != nil {
return nil, err
}
if list == nil {
list = []*sysin.UrlPoolListModel{}
}
res = new(urlpool.ListRes)
res.List = list
res.PageRes.Pack(req, total)
return
}
func (c *cUrlPool) View(ctx context.Context, req *urlpool.ViewReq) (*urlpool.ViewRes, error) {
data, err := service.SysUrlPool().View(ctx, &req.UrlPoolViewInp)
if err != nil {
return nil, err
}
return &urlpool.ViewRes{UrlPoolViewModel: data}, nil
}
func (c *cUrlPool) Edit(ctx context.Context, req *urlpool.EditReq) (*urlpool.EditRes, error) {
return &urlpool.EditRes{}, service.SysUrlPool().Edit(ctx, &req.UrlPoolEditInp)
}
func (c *cUrlPool) Status(ctx context.Context, req *urlpool.StatusReq) (*urlpool.StatusRes, error) {
return &urlpool.StatusRes{}, service.SysUrlPool().Status(ctx, &req.UrlPoolStatusInp)
}
func (c *cUrlPool) Import(ctx context.Context, req *urlpool.ImportReq) (*urlpool.ImportRes, error) {
if req.File == nil {
return nil, gerror.New("请选择Excel文件")
}
filename := strings.ToLower(strings.TrimSpace(req.File.Filename))
if !strings.HasSuffix(filename, ".xlsx") && !strings.HasSuffix(filename, ".xlsm") {
return nil, gerror.New("仅支持.xlsx或.xlsm格式的Excel文件")
}
if req.File.Size > 10*1024*1024 {
return nil, gerror.New("Excel文件不能超过10MB")
}
file, err := req.File.Open()
if err != nil {
return nil, gerror.Wrap(err, "读取Excel文件失败")
}
defer file.Close()
result, err := service.SysUrlPool().Import(ctx, file)
if err != nil {
return nil, err
}
return &urlpool.ImportRes{UrlPoolImportModel: result}, nil
}
func (c *cUrlPool) MemberList(ctx context.Context, req *urlpool.MemberListReq) (res *urlpool.MemberListRes, err error) {
list, total, err := service.SysUrlPool().MemberList(ctx, &req.UrlPoolMemberListInp)
if err != nil {
return nil, err
}
if list == nil {
list = []*sysin.UrlPoolMemberListModel{}
}
res = new(urlpool.MemberListRes)
res.List = list
res.PageRes.Pack(req, total)
return
}
func (c *cUrlPool) Summary(ctx context.Context, req *urlpool.SummaryReq) (*urlpool.SummaryRes, error) {
data, err := service.SysUrlPool().Summary(ctx, &req.UrlPoolSummaryInp)
if err != nil {
return nil, err
}
return &urlpool.SummaryRes{UrlPoolSummaryModel: data}, nil
}
@@ -0,0 +1,32 @@
package member
import (
"context"
v1 "hotgo/api/api/member/v1"
"hotgo/internal/service"
)
func (c *ControllerV1) Register(ctx context.Context, req *v1.RegisterReq) (*v1.RegisterRes, error) {
data, err := service.SysAppMemberAuth().Register(ctx, &req.AppMemberRegisterInp)
if err != nil {
return nil, err
}
return &v1.RegisterRes{AppMemberRegisterModel: data}, nil
}
func (c *ControllerV1) AccountLogin(ctx context.Context, req *v1.AccountLoginReq) (*v1.AccountLoginRes, error) {
data, err := service.SysAppMemberAuth().AccountLogin(ctx, &req.AppMemberAccountLoginInp)
if err != nil {
return nil, err
}
return &v1.AccountLoginRes{AppMemberLoginModel: data}, nil
}
func (c *ControllerV1) MobileLogin(ctx context.Context, req *v1.MobileLoginReq) (*v1.MobileLoginRes, error) {
data, err := service.SysAppMemberAuth().MobileLogin(ctx, &req.AppMemberMobileLoginInp)
if err != nil {
return nil, err
}
return &v1.MobileLoginRes{AppMemberLoginModel: data}, nil
}
@@ -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
}
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// appMemberLoginLogDao is the data access object for the table hg_app_member_login_log.
// You can define custom methods on it to extend its functionality as needed.
type appMemberLoginLogDao struct {
*internal.AppMemberLoginLogDao
}
var (
// AppMemberLoginLog is a globally accessible object for table hg_app_member_login_log operations.
AppMemberLoginLog = appMemberLoginLogDao{internal.NewAppMemberLoginLogDao()}
)
// Add your custom methods and functionality below.
+2 -6
View File
@@ -25,9 +25,7 @@ type AppMemberColumns struct {
TenantId string // 租户ID
MemberNo string // 会员编号
Username string // 用户名
MobileCipher string // 手机号密文(AES-GCM)
MobileHash string // 手机号检索哈希(HMAC-SHA256)
MobileMask string // 手机号脱敏值
Mobile string //
PasswordHash string // 登录密码哈希(bcrypt)
TradePasswordHash string // 交易密码哈希
LevelId string // 会员等级ID
@@ -58,9 +56,7 @@ var appMemberColumns = AppMemberColumns{
TenantId: "tenant_id",
MemberNo: "member_no",
Username: "username",
MobileCipher: "mobile_cipher",
MobileHash: "mobile_hash",
MobileMask: "mobile_mask",
Mobile: "mobile",
PasswordHash: "password_hash",
TradePasswordHash: "trade_password_hash",
LevelId: "level_id",
@@ -0,0 +1,109 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// AppMemberLoginLogDao is the data access object for the table hg_app_member_login_log.
type AppMemberLoginLogDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns AppMemberLoginLogColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// AppMemberLoginLogColumns defines and stores column names for the table hg_app_member_login_log.
type AppMemberLoginLogColumns struct {
Id string // 日志ID
TenantId string // 租户ID
ReqId string // 请求ID
MemberId string // 会员ID,未识别会员时为0
Username string // 用户名快照
MobileMask string // 手机号脱敏值
LoginType string // 登录方式:1账号密码,2手机验证码
Status string // 登录状态:1成功,2失败
ErrMsg string // 失败原因
LoginAt string // 登录时间
LoginIpCipher string // 登录IP密文
LoginIpMask string // 登录IP脱敏值
LoginRegion string // 登录地区
LoginDomain string // 登录页面标准化域名
UserAgent string // 浏览器UA
CreatedAt string // 创建时间
}
// appMemberLoginLogColumns holds the columns for the table hg_app_member_login_log.
var appMemberLoginLogColumns = AppMemberLoginLogColumns{
Id: "id",
TenantId: "tenant_id",
ReqId: "req_id",
MemberId: "member_id",
Username: "username",
MobileMask: "mobile_mask",
LoginType: "login_type",
Status: "status",
ErrMsg: "err_msg",
LoginAt: "login_at",
LoginIpCipher: "login_ip_cipher",
LoginIpMask: "login_ip_mask",
LoginRegion: "login_region",
LoginDomain: "login_domain",
UserAgent: "user_agent",
CreatedAt: "created_at",
}
// NewAppMemberLoginLogDao creates and returns a new DAO object for table data access.
func NewAppMemberLoginLogDao(handlers ...gdb.ModelHandler) *AppMemberLoginLogDao {
return &AppMemberLoginLogDao{
group: "default",
table: "hg_app_member_login_log",
columns: appMemberLoginLogColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *AppMemberLoginLogDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *AppMemberLoginLogDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *AppMemberLoginLogDao) Columns() AppMemberLoginLogColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *AppMemberLoginLogDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *AppMemberLoginLogDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *AppMemberLoginLogDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -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.
@@ -31,6 +31,9 @@ type CustomerServiceColumns struct {
ServiceUrl string // 客服跳转URL
BackupContact string // 备用联系方式
SessionMapping string // 第三方会话映射
ImTenantCode string // IM租户代码
ImUserId string // IM客服用户ID
ImUsername string // IM客服登录名
Status string // 状态
Sort string // 排序
Remark string // 备注
@@ -54,6 +57,9 @@ var customerServiceColumns = CustomerServiceColumns{
ServiceUrl: "service_url",
BackupContact: "backup_contact",
SessionMapping: "session_mapping",
ImTenantCode: "im_tenant_code",
ImUserId: "im_user_id",
ImUsername: "im_username",
Status: "status",
Sort: "sort",
Remark: "remark",
@@ -0,0 +1,95 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// MemberUrlBindingDao is the data access object for the table hg_member_url_binding.
type MemberUrlBindingDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns MemberUrlBindingColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// MemberUrlBindingColumns defines and stores column names for the table hg_member_url_binding.
type MemberUrlBindingColumns struct {
Id string // 主键
TenantId string // 租户ID
MemberId string // 会员ID
UrlPoolId string // URL地址池ID
DomainSnapshot string // 绑定时域名快照
OriginSnapshot string // 绑定时Origin快照
BoundAt string // 绑定时间
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
}
// memberUrlBindingColumns holds the columns for the table hg_member_url_binding.
var memberUrlBindingColumns = MemberUrlBindingColumns{
Id: "id",
TenantId: "tenant_id",
MemberId: "member_id",
UrlPoolId: "url_pool_id",
DomainSnapshot: "domain_snapshot",
OriginSnapshot: "origin_snapshot",
BoundAt: "bound_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewMemberUrlBindingDao creates and returns a new DAO object for table data access.
func NewMemberUrlBindingDao(handlers ...gdb.ModelHandler) *MemberUrlBindingDao {
return &MemberUrlBindingDao{
group: "default",
table: "hg_member_url_binding",
columns: memberUrlBindingColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *MemberUrlBindingDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *MemberUrlBindingDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *MemberUrlBindingDao) Columns() MemberUrlBindingColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *MemberUrlBindingDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *MemberUrlBindingDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *MemberUrlBindingDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+2
View File
@@ -35,6 +35,7 @@ type ProductColumns struct {
CoverUrl string // 封面URL
DetailHtml string // 商品详情
IsRecommended string // 是否推荐
ClaimedCount string // 已领取展示数
PublishStatus string // 发布状态
StartAt string // 展示开始时间
EndAt string // 展示结束时间
@@ -65,6 +66,7 @@ var productColumns = ProductColumns{
CoverUrl: "cover_url",
DetailHtml: "detail_html",
IsRecommended: "is_recommended",
ClaimedCount: "claimed_count",
PublishStatus: "publish_status",
StartAt: "start_at",
EndAt: "end_at",
+2
View File
@@ -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",
+111
View File
@@ -0,0 +1,111 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// UrlPoolDao is the data access object for the table hg_url_pool.
type UrlPoolDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns UrlPoolColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// UrlPoolColumns defines and stores column names for the table hg_url_pool.
type UrlPoolColumns struct {
Id string // 主键
TenantId string // 租户ID
Name string // 地址名称
Scheme string // 协议:http或https
Domain string // 标准化域名,可含非默认端口
DomainHash string // 标准化域名SHA256
BaseUrl string // 注册入口基础地址
Status string // 状态:1启用,2失效
BindCount string // 累计成功绑定次数
LastBoundAt string // 最后绑定时间
Remark string // 备注
CreatedBy string // 创建者ID
UpdatedBy string // 更新者ID
DeletedBy string // 删除者ID
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
DeletedAt string // 删除时间
}
// urlPoolColumns holds the columns for the table hg_url_pool.
var urlPoolColumns = UrlPoolColumns{
Id: "id",
TenantId: "tenant_id",
Name: "name",
Scheme: "scheme",
Domain: "domain",
DomainHash: "domain_hash",
BaseUrl: "base_url",
Status: "status",
BindCount: "bind_count",
LastBoundAt: "last_bound_at",
Remark: "remark",
CreatedBy: "created_by",
UpdatedBy: "updated_by",
DeletedBy: "deleted_by",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
DeletedAt: "deleted_at",
}
// NewUrlPoolDao creates and returns a new DAO object for table data access.
func NewUrlPoolDao(handlers ...gdb.ModelHandler) *UrlPoolDao {
return &UrlPoolDao{
group: "default",
table: "hg_url_pool",
columns: urlPoolColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *UrlPoolDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *UrlPoolDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *UrlPoolDao) Columns() UrlPoolColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *UrlPoolDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *UrlPoolDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *UrlPoolDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// memberUrlBindingDao is the data access object for the table hg_member_url_binding.
// You can define custom methods on it to extend its functionality as needed.
type memberUrlBindingDao struct {
*internal.MemberUrlBindingDao
}
var (
// MemberUrlBinding is a globally accessible object for table hg_member_url_binding operations.
MemberUrlBinding = memberUrlBindingDao{internal.NewMemberUrlBindingDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// urlPoolDao is the data access object for the table hg_url_pool.
// You can define custom methods on it to extend its functionality as needed.
type urlPoolDao struct {
*internal.UrlPoolDao
}
var (
// UrlPool is a globally accessible object for table hg_url_pool operations.
UrlPool = urlPoolDao{internal.NewUrlPoolDao()}
)
// Add your custom methods and functionality below.
+9 -1
View File
@@ -111,7 +111,7 @@ func LoggingServeLogHandler(ctx context.Context, in *glog.HandlerInput) {
data.LevelFormat = in.LevelFormat
data.Content = in.Content
data.Stack = gjson.New(charset.ParseStack(in.Stack))
data.Line = strings.TrimRight(in.CallerPath, ":")
data.Line = normalizeServeLogLine(in.CallerPath)
data.TriggerNs = in.Time.UnixNano()
data.Status = consts.StatusEnabled
@@ -135,6 +135,14 @@ func LoggingServeLogHandler(ctx context.Context, in *glog.HandlerInput) {
}
}
func normalizeServeLogLine(callerPath string) string {
line := strings.TrimRight(callerPath, ":")
if line == "" {
return "unknown"
}
return line
}
// InitTrace 初始化链路追踪
func InitTrace(ctx context.Context) {
if !g.Cfg().MustGet(ctx, "jaeger.switch").Bool() {
+23
View File
@@ -0,0 +1,23 @@
package global
import "testing"
func TestNormalizeServeLogLine(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "preserves caller", in: "internal/cmd/cron.go:30:", want: "internal/cmd/cron.go:30"},
{name: "defaults empty caller", in: "", want: "unknown"},
{name: "defaults delimiter only caller", in: ":", want: "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeServeLogLine(tt.in); got != tt.want {
t.Fatalf("normalizeServeLogLine(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
+188 -32
View File
@@ -55,28 +55,30 @@ func col(alias, column string) string { return alias + "." + column }
func (s *sSysAppMember) List(ctx context.Context, in *sysin.AppMemberListInp) (list []*sysin.AppMemberListModel, totalCount int, err error) {
mc, pc, wc := dao.AppMember.Columns(), dao.AppMemberProfile.Columns(), dao.WalletAccount.Columns()
bc, uc := dao.MemberUrlBinding.Columns(), dao.UrlPool.Columns()
mod := s.memberModel(ctx).As("m").
LeftJoin(dao.AppMemberProfile.Table()+" p", col("p", pc.MemberId)+"="+col("m", mc.Id)).
LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("m", mc.Id)+" AND "+col("w", wc.Currency)+"='CNY'").
LeftJoin(dao.AppMember.Table()+" parent", col("parent", mc.Id)+"="+col("m", mc.ParentId)).
LeftJoin(dao.MemberUrlBinding.Table()+" ub", col("ub", bc.MemberId)+"="+col("m", mc.Id)).
LeftJoin(dao.UrlPool.Table()+" up", col("up", uc.Id)+"="+col("ub", bc.UrlPoolId)).
Fields(
col("m", mc.Id), col("m", mc.MemberNo), col("m", mc.Username), col("m", mc.MobileMask),
col("m", mc.Id), col("m", mc.MemberNo), col("m", mc.Username), col("m", mc.Mobile),
col("p", pc.RealNameMask), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)),
fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), col("m", mc.ParentId),
fmt.Sprintf("COALESCE(%s,'') parent_username", col("parent", mc.Username)), col("m", mc.InviteCode),
col("m", mc.MemberStatus), col("m", mc.RiskStatus), col("m", mc.AutoDispatch), col("m", mc.IsTestAccount),
fmt.Sprintf("CASE WHEN %s=1 THEN %d ELSE %d END member_category", col("m", mc.IsTestAccount), consts.AppMemberCategoryFake, consts.AppMemberCategoryReal),
col("m", mc.LastLoginAt), col("m", mc.LastLoginIpMask), col("m", mc.LastLoginRegion), col("m", mc.CreatedAt),
fmt.Sprintf("COALESCE(%s,0) url_pool_id", col("ub", bc.UrlPoolId)),
fmt.Sprintf("COALESCE(%s,'') register_domain", col("ub", bc.DomainSnapshot)), col("ub", bc.BoundAt)+" domain_bound_at",
fmt.Sprintf("CASE WHEN %s IS NULL THEN %d WHEN %s=%d THEN %d ELSE %d END domain_status", col("ub", bc.Id), consts.AppMemberDomainUnbound, col("up", uc.Status), consts.UrlPoolStatusEnabled, consts.AppMemberDomainBoundEnabled, consts.AppMemberDomainBoundInvalid),
)
if in.Username != "" {
mod = mod.WhereLike(col("m", mc.Username), "%"+in.Username+"%")
}
if in.Mobile != "" {
hash, e := encrypt.HashField(ctx, in.Mobile)
if e != nil {
return nil, 0, e
}
mod = mod.Where(col("m", mc.MobileHash), hash)
mod = mod.Where(col("m", mc.Mobile), in.Mobile)
}
if in.MemberStatus > 0 {
mod = mod.Where(col("m", mc.MemberStatus), in.MemberStatus)
@@ -89,6 +91,16 @@ func (s *sSysAppMember) List(ctx context.Context, in *sysin.AppMemberListInp) (l
} else if in.MemberCategory == consts.AppMemberCategoryFake {
mod = mod.Where(col("m", mc.IsTestAccount), 1)
}
if in.RegisterDomain != "" {
mod = mod.WhereLike(col("ub", bc.DomainSnapshot), "%"+strings.ToLower(strings.TrimSpace(in.RegisterDomain))+"%")
}
if in.DomainStatus == consts.AppMemberDomainBoundEnabled {
mod = mod.WhereNotNull(col("ub", bc.Id)).Where(col("up", uc.Status), consts.UrlPoolStatusEnabled)
} else if in.DomainStatus == consts.AppMemberDomainUnbound {
mod = mod.WhereNull(col("ub", bc.Id))
} else if in.DomainStatus == consts.AppMemberDomainBoundInvalid {
mod = mod.WhereNotNull(col("ub", bc.Id)).Where(col("up", uc.Status), consts.UrlPoolStatusInvalid)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(col("m", mc.CreatedAt), in.CreatedAt[0], in.CreatedAt[1])
}
@@ -101,9 +113,11 @@ func (s *sSysAppMember) List(ctx context.Context, in *sysin.AppMemberListInp) (l
func (s *sSysAppMember) View(ctx context.Context, in *sysin.AppMemberViewInp) (res *sysin.AppMemberViewModel, err error) {
mc, pc, wc := dao.AppMember.Columns(), dao.AppMemberProfile.Columns(), dao.WalletAccount.Columns()
bc, uc := dao.MemberUrlBinding.Columns(), dao.UrlPool.Columns()
res = new(sysin.AppMemberViewModel)
err = s.memberModel(ctx).As("m").LeftJoin(dao.AppMemberProfile.Table()+" p", col("p", pc.MemberId)+"="+col("m", mc.Id)).LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("m", mc.Id)+" AND "+col("w", wc.Currency)+"='CNY'").LeftJoin(dao.AppMember.Table()+" parent", col("parent", mc.Id)+"="+col("m", mc.ParentId)).Fields(
col("m", mc.Id), col("m", mc.MemberNo), col("m", mc.Username), col("m", mc.MobileMask), col("p", pc.RealNameMask), col("p", pc.IdNoMask), col("p", pc.KycStatus), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), col("m", mc.ParentId), fmt.Sprintf("COALESCE(%s,'') parent_username", col("parent", mc.Username)), col("m", mc.InviteCode), col("m", mc.MemberStatus), col("m", mc.RiskStatus), col("m", mc.AutoDispatch), col("m", mc.IsTestAccount), fmt.Sprintf("CASE WHEN %s=1 THEN %d ELSE %d END member_category", col("m", mc.IsTestAccount), consts.AppMemberCategoryFake, consts.AppMemberCategoryReal), col("m", mc.LastLoginAt), col("m", mc.LastLoginIpMask), col("m", mc.LastLoginRegion), col("m", mc.CreatedAt), col("m", mc.LevelId), col("m", mc.Level), col("m", mc.CreditScore),
err = s.memberModel(ctx).As("m").LeftJoin(dao.AppMemberProfile.Table()+" p", col("p", pc.MemberId)+"="+col("m", mc.Id)).LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("m", mc.Id)+" AND "+col("w", wc.Currency)+"='CNY'").LeftJoin(dao.AppMember.Table()+" parent", col("parent", mc.Id)+"="+col("m", mc.ParentId)).LeftJoin(dao.MemberUrlBinding.Table()+" ub", col("ub", bc.MemberId)+"="+col("m", mc.Id)).LeftJoin(dao.UrlPool.Table()+" up", col("up", uc.Id)+"="+col("ub", bc.UrlPoolId)).Fields(
col("m", mc.Id), col("m", mc.MemberNo), col("m", mc.Username), col("m", mc.Mobile), col("p", pc.RealNameMask), col("p", pc.IdNoMask), col("p", pc.KycStatus), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), col("m", mc.ParentId), fmt.Sprintf("COALESCE(%s,'') parent_username", col("parent", mc.Username)), col("m", mc.InviteCode), col("m", mc.MemberStatus), col("m", mc.RiskStatus), col("m", mc.AutoDispatch), col("m", mc.IsTestAccount), fmt.Sprintf("CASE WHEN %s=1 THEN %d ELSE %d END member_category", col("m", mc.IsTestAccount), consts.AppMemberCategoryFake, consts.AppMemberCategoryReal), col("m", mc.LastLoginAt), col("m", mc.LastLoginIpMask), col("m", mc.LastLoginRegion), col("m", mc.CreatedAt), col("m", mc.LevelId), col("m", mc.Level), col("m", mc.CreditScore),
fmt.Sprintf("COALESCE(%s,0) url_pool_id", col("ub", bc.UrlPoolId)), fmt.Sprintf("COALESCE(%s,'') register_domain", col("ub", bc.DomainSnapshot)), col("ub", bc.BoundAt)+" domain_bound_at", fmt.Sprintf("CASE WHEN %s IS NULL THEN %d WHEN %s=%d THEN %d ELSE %d END domain_status", col("ub", bc.Id), consts.AppMemberDomainUnbound, col("up", uc.Status), consts.UrlPoolStatusEnabled, consts.AppMemberDomainBoundEnabled, consts.AppMemberDomainBoundInvalid),
).Where(col("m", mc.Id), in.Id).Scan(res)
if err != nil {
return nil, gerror.Wrap(err, "获取会员详情失败")
@@ -119,6 +133,15 @@ func (s *sSysAppMember) View(ctx context.Context, in *sysin.AppMemberViewInp) (r
}
func (s *sSysAppMember) Edit(ctx context.Context, in *sysin.AppMemberEditInp) error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
return s.edit(ctx, in)
})
}
func (s *sSysAppMember) edit(ctx context.Context, in *sysin.AppMemberEditInp) error {
if in.Id == 0 {
return gerror.New("后台不支持创建会员,请由会员在C端完成注册")
}
isTestAccount := 2
if in.MemberCategory == consts.AppMemberCategoryFake {
isTestAccount = 1
@@ -146,24 +169,14 @@ func (s *sSysAppMember) Edit(ctx context.Context, in *sysin.AppMemberEditInp) er
}
data := g.Map{mc.Username: in.Username, mc.LevelId: in.LevelId, mc.ParentId: in.ParentId, mc.RiskStatus: in.RiskStatus, mc.CreditScore: in.CreditScore, mc.AutoDispatch: in.AutoDispatch, mc.IsTestAccount: isTestAccount, mc.UpdatedBy: userId}
if in.Mobile != "" {
cipher, e := encrypt.EncryptField(ctx, in.Mobile)
if e != nil {
return e
}
hash, e := encrypt.HashField(ctx, in.Mobile)
if e != nil {
return e
}
count, e = s.memberModel(ctx).Where(mc.MobileHash, hash).WhereNot(mc.Id, in.Id).Count()
if e != nil {
return e
count, err = s.memberModel(ctx).Where(mc.Mobile, in.Mobile).WhereNot(mc.Id, in.Id).Count()
if err != nil {
return err
}
if count > 0 {
return gerror.New("手机号已存在")
}
data[mc.MobileCipher] = cipher
data[mc.MobileHash] = hash
data[mc.MobileMask] = encrypt.MaskMobile(in.Mobile)
data[mc.Mobile] = in.Mobile
}
if in.Password != "" {
hashed, e := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
@@ -203,10 +216,14 @@ func (s *sSysAppMember) Edit(ctx context.Context, in *sysin.AppMemberEditInp) er
}
} else {
data[mc.MemberNo] = "MB" + gtime.Now().Format("YmdHis") + grand.S(5)
data[mc.InviteCode] = strings.ToUpper(grand.S(8))
inviteCode, e := newAppMemberInviteCode(ctx, s.memberModel(ctx))
if e != nil {
return e
}
data[mc.InviteCode] = inviteCode
data[mc.MemberStatus] = consts.AppMemberStatusNormal
data[mc.CreatedBy] = userId
result, e := s.memberModel(ctx).Fields(mc.MemberNo, mc.Username, mc.MobileCipher, mc.MobileHash, mc.MobileMask, mc.PasswordHash, mc.TradePasswordHash, mc.LevelId, mc.ParentId, mc.Level, mc.Tree, mc.InviteCode, mc.MemberStatus, mc.RiskStatus, mc.AutoDispatch, mc.CreditScore, mc.IsTestAccount, mc.CreatedBy, mc.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert()
result, e := s.memberModel(ctx).Fields(mc.MemberNo, mc.Username, mc.Mobile, mc.PasswordHash, mc.TradePasswordHash, mc.LevelId, mc.ParentId, mc.Level, mc.Tree, mc.InviteCode, mc.MemberStatus, mc.RiskStatus, mc.AutoDispatch, mc.CreditScore, mc.IsTestAccount, mc.CreatedBy, mc.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert()
if e != nil {
return gerror.Wrap(e, "创建会员失败")
}
@@ -222,7 +239,82 @@ func (s *sSysAppMember) Edit(ctx context.Context, in *sysin.AppMemberEditInp) er
return err
}
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "member", Action: "member.edit", TargetType: "app_member", TargetId: in.Id, Before: before, After: g.Map{"username": in.Username, "mobileChanged": in.Mobile != "", "parentId": in.ParentId, "riskStatus": in.RiskStatus, "autoDispatch": in.AutoDispatch, "memberCategory": in.MemberCategory}})
if in.AvailableAmount != nil {
if err = s.setAvailableAmount(ctx, in.Id, *in.AvailableAmount); err != nil {
return err
}
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "member", Action: "member.edit", TargetType: "app_member", TargetId: in.Id, Before: before, After: g.Map{"username": in.Username, "mobileChanged": in.Mobile != "", "parentId": in.ParentId, "riskStatus": in.RiskStatus, "autoDispatch": in.AutoDispatch, "memberCategory": in.MemberCategory, "availableAmount": in.AvailableAmount}})
}
func (s *sSysAppMember) setAvailableAmount(ctx context.Context, memberId uint64, target int64) error {
wc := dao.WalletAccount.Columns()
var account *entity.WalletAccount
if err := s.walletModel(ctx).Where(wc.MemberId, memberId).Where(wc.Currency, "CNY").LockUpdate().Scan(&account); err != nil {
return gerror.Wrap(err, "读取会员钱包失败")
}
if account == nil {
if _, err := s.walletModel(ctx).
Fields(wc.MemberId, wc.Currency).
Hook(hook.SaveTenant).
Data(g.Map{wc.MemberId: memberId, wc.Currency: "CNY"}).
Insert(); err != nil {
return gerror.Wrap(err, "初始化会员钱包失败")
}
if err := s.walletModel(ctx).Where(wc.MemberId, memberId).Where(wc.Currency, "CNY").LockUpdate().Scan(&account); err != nil {
return gerror.Wrap(err, "读取会员钱包失败")
}
}
if account == nil {
return gerror.New("会员钱包不存在")
}
if account.AvailableAmount == target {
return nil
}
direction := consts.WalletDirectionIncome
amount := target - account.AvailableAmount
if amount < 0 {
direction = consts.WalletDirectionExpense
amount = -amount
}
now := gtime.Now()
ledgerNo := "WL" + now.Format("YmdHis") + grand.S(7)
bizNo := "ME" + now.Format("YmdHis") + grand.S(7)
idempotencyKey := fmt.Sprintf("member-edit-%d-%d-%s", memberId, now.UnixMilli(), grand.S(6))
lc := dao.WalletLedger.Columns()
if _, err := dao.WalletLedger.Ctx(ctx).
Fields(lc.LedgerNo, lc.AccountId, lc.MemberId, lc.BizType, lc.BizNo, lc.EntryType, lc.Direction, lc.Amount, lc.BeforeAmount, lc.AfterAmount, lc.Currency, lc.IdempotencyKey, lc.Remark, lc.OperatorId, lc.OccurredAt).
Hook(hook.SaveTenant).
Data(g.Map{
lc.LedgerNo: ledgerNo, lc.AccountId: account.Id, lc.MemberId: memberId,
lc.BizType: "member_edit", lc.BizNo: bizNo, lc.EntryType: fmt.Sprint(consts.WalletAdjustOther),
lc.Direction: direction, lc.Amount: amount, lc.BeforeAmount: account.AvailableAmount,
lc.AfterAmount: target, lc.Currency: account.Currency, lc.IdempotencyKey: idempotencyKey,
lc.Remark: "后台编辑会员余额", lc.OperatorId: contexts.GetUserId(ctx), lc.OccurredAt: now,
}).Insert(); err != nil {
return gerror.Wrap(err, "写入账变流水失败")
}
walletData := g.Map{wc.AvailableAmount: target, wc.Version: gdb.Raw(wc.Version + "+1")}
if direction == consts.WalletDirectionIncome {
walletData[wc.TotalIncome] = gdb.Raw(wc.TotalIncome + "+" + fmt.Sprint(amount))
} else {
walletData[wc.TotalExpense] = gdb.Raw(wc.TotalExpense + "+" + fmt.Sprint(amount))
}
result, err := s.walletModel(ctx).WherePri(account.Id).Where(wc.Version, account.Version).Data(walletData).Update()
if err != nil {
return gerror.Wrap(err, "更新会员余额失败")
}
rows, _ := result.RowsAffected()
if rows != 1 {
return gerror.New("会员余额已变化,请重试")
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{
Module: "wallet", Action: "wallet.balance.edit", TargetType: "wallet_account", TargetId: account.Id,
Before: g.Map{"availableAmount": account.AvailableAmount}, After: g.Map{"availableAmount": target, "ledgerNo": ledgerNo},
Reason: "后台编辑会员余额",
})
}
func (s *sSysAppMember) saveProfile(ctx context.Context, in *sysin.AppMemberEditInp) error {
@@ -310,7 +402,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, "获取收款账户失败")
}
@@ -319,7 +416,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 {
@@ -336,6 +449,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 {
@@ -349,6 +475,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()
}
@@ -359,7 +498,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, "创建收款账户失败")
}
@@ -442,7 +581,7 @@ func (s *sSysAppMember) Sensitive(ctx context.Context, in *sysin.AppMemberSensit
case "member":
var member *entity.AppMember
if err = s.memberModel(ctx).WherePri(in.TargetId).Scan(&member); err == nil && member != nil {
res.Fields["mobile"], err = encrypt.DecryptField(ctx, member.MobileCipher)
res.Fields["mobile"] = member.Mobile
var p *entity.AppMemberProfile
_ = s.profileModel(ctx).Where(dao.AppMemberProfile.Columns().MemberId, in.TargetId).Scan(&p)
if p != nil {
@@ -454,8 +593,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
@@ -494,7 +650,7 @@ func (s *sSysAppMember) Team(ctx context.Context, in *sysin.AppMemberTeamInp) (l
mc, wc := dao.AppMember.Columns(), dao.WalletAccount.Columns()
token := "tr_" + fmt.Sprint(in.MemberId) + " "
maxAbsolute := root.Level + in.MaxLevel
mod := s.memberModel(ctx).As("m").LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("m", mc.Id)+" AND "+col("w", wc.Currency)+"='CNY'").Fields(col("m", mc.Id), col("m", mc.Username), col("m", mc.MobileMask), col("m", mc.ParentId), fmt.Sprintf("%s-%d relative_level", col("m", mc.Level), root.Level), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), col("m", mc.MemberStatus), col("m", mc.CreatedAt)).WhereLike(col("m", mc.Tree), "%"+token+"%").WhereLTE(col("m", mc.Level), maxAbsolute)
mod := s.memberModel(ctx).As("m").LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("m", mc.Id)+" AND "+col("w", wc.Currency)+"='CNY'").Fields(col("m", mc.Id), col("m", mc.Username), col("m", mc.Mobile), col("m", mc.ParentId), fmt.Sprintf("%s-%d relative_level", col("m", mc.Level), root.Level), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), col("m", mc.MemberStatus), col("m", mc.CreatedAt)).WhereLike(col("m", mc.Tree), "%"+token+"%").WhereLTE(col("m", mc.Level), maxAbsolute)
err = mod.Page(in.Page, in.PerPage).OrderAsc(col("m", mc.Level)).OrderAsc(col("m", mc.Id)).ScanAndCount(&list, &total, false)
if err != nil {
err = gerror.Wrap(err, "查询会员团队失败")
@@ -569,7 +725,7 @@ func (s *sSysAppMember) StatusExecute(ctx context.Context, in *sysin.AppMemberSt
func (s *sSysAppMember) WalletList(ctx context.Context, in *sysin.WalletAdjustmentListInp) (list []*sysin.WalletAdjustmentListModel, total int, err error) {
ac, mc := dao.WalletAdjustmentRequest.Columns(), dao.AppMember.Columns()
mod := s.adjustmentModel(ctx).As("a").LeftJoin(dao.AppMember.Table()+" m", col("m", mc.Id)+"="+col("a", ac.MemberId)).Fields(col("a", ac.Id), col("a", ac.RequestNo), col("a", ac.MemberId), col("m", mc.Username), col("m", mc.MobileMask), col("a", ac.AdjustType), col("a", ac.Direction), col("a", ac.Amount), col("a", ac.ReasonCode), col("a", ac.ReasonText), col("a", ac.RequestStatus), col("a", ac.ApprovalRequestId), col("a", ac.LedgerId), col("a", ac.CreatedAt))
mod := s.adjustmentModel(ctx).As("a").LeftJoin(dao.AppMember.Table()+" m", col("m", mc.Id)+"="+col("a", ac.MemberId)).Fields(col("a", ac.Id), col("a", ac.RequestNo), col("a", ac.MemberId), col("m", mc.Username), col("m", mc.Mobile), col("a", ac.AdjustType), col("a", ac.Direction), col("a", ac.Amount), col("a", ac.ReasonCode), col("a", ac.ReasonText), col("a", ac.RequestStatus), col("a", ac.ApprovalRequestId), col("a", ac.LedgerId), col("a", ac.CreatedAt))
if in.MemberId > 0 {
mod = mod.Where(col("a", ac.MemberId), in.MemberId)
}
@@ -0,0 +1,205 @@
package sys
import (
"context"
"fmt"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/hgorm/handler"
"hotgo/internal/library/location"
"hotgo/internal/library/token"
"hotgo/internal/model"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"hotgo/utility/encrypt"
"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/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/grand"
"golang.org/x/crypto/bcrypt"
)
type sSysAppMemberAuth struct{}
func NewSysAppMemberAuth() *sSysAppMemberAuth { return &sSysAppMemberAuth{} }
func init() { service.RegisterSysAppMemberAuth(NewSysAppMemberAuth()) }
func (s *sSysAppMemberAuth) memberModel(ctx context.Context) *gdb.Model {
return handler.Model(dao.AppMember.Ctx(ctx), &handler.Option{FilterTenant: true})
}
func (s *sSysAppMemberAuth) Register(ctx context.Context, in *sysin.AppMemberRegisterInp) (res *sysin.AppMemberRegisterModel, err error) {
mc, wc, lc := dao.AppMember.Columns(), dao.WalletAccount.Columns(), dao.AppMemberLevel.Columns()
res = new(sysin.AppMemberRegisterModel)
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
count, e := s.memberModel(ctx).Where(mc.Username, in.Username).Count()
if e != nil {
return gerror.Wrap(e, "检查用户名失败")
}
if count > 0 {
return gerror.New("用户名已存在")
}
count, e = s.memberModel(ctx).Where(mc.Mobile, in.Mobile).Count()
if e != nil {
return gerror.Wrap(e, "检查手机号失败")
}
if count > 0 {
return gerror.New("手机号已存在")
}
var parent *entity.AppMember
if e = s.memberModel(ctx).Where(mc.InviteCode, in.InviteCode).Where(mc.MemberStatus, consts.AppMemberStatusNormal).Scan(&parent); e != nil {
return gerror.Wrap(e, "查询邀请人失败")
}
if parent == nil {
return gerror.New("邀请码不存在")
}
parentId, level := parent.Id, parent.Level+1
tree := parent.Tree + "tr_" + fmt.Sprint(parent.Id) + " "
levelId := uint64(0)
var firstLevel *entity.AppMemberLevel
if e = handler.Model(dao.AppMemberLevel.Ctx(ctx), &handler.Option{FilterTenant: true}).Where(lc.TenantId, 0).Where(lc.Status, consts.StatusEnabled).OrderAsc(lc.Sort).OrderAsc(lc.Id).Scan(&firstLevel); e != nil {
return gerror.Wrap(e, "查询默认会员等级失败")
}
if firstLevel != nil {
levelId = firstLevel.Id
}
hashed, e := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if e != nil {
return gerror.Wrap(e, "生成密码摘要失败")
}
memberNo := "MB" + gtime.Now().Format("YmdHis") + grand.S(5)
inviteCode, e := newAppMemberInviteCode(ctx, s.memberModel(ctx))
if e != nil {
return e
}
memberData := g.Map{
mc.TenantId: 0,
mc.MemberNo: memberNo, mc.Username: in.Username, mc.Mobile: in.Mobile,
mc.PasswordHash: string(hashed), mc.LevelId: levelId, mc.ParentId: parentId,
mc.Level: level, mc.Tree: tree, mc.InviteCode: inviteCode,
mc.MemberStatus: consts.AppMemberStatusNormal, mc.RiskStatus: consts.AppMemberRiskNormal,
mc.AutoDispatch: consts.AppMemberDispatchManual, mc.CreditScore: 100, mc.IsTestAccount: 2,
}
result, e := dao.AppMember.Ctx(ctx).Fields(
mc.TenantId, mc.MemberNo, mc.Username, mc.Mobile, mc.PasswordHash, mc.LevelId, mc.ParentId, mc.Level,
mc.Tree, mc.InviteCode, mc.MemberStatus, mc.RiskStatus, mc.AutoDispatch, mc.CreditScore, mc.IsTestAccount,
).Data(memberData).Insert()
if e != nil {
return gerror.Wrap(e, "创建会员失败")
}
id, e := result.LastInsertId()
if e != nil {
return gerror.Wrap(e, "读取会员ID失败")
}
res.MemberId = uint64(id)
res.MemberNo = memberNo
if _, e = dao.WalletAccount.Ctx(ctx).Fields(wc.TenantId, wc.MemberId, wc.Currency).Data(g.Map{wc.TenantId: 0, wc.MemberId: res.MemberId, wc.Currency: "CNY"}).Insert(); e != nil {
return gerror.Wrap(e, "初始化会员钱包失败")
}
r := ghttp.RequestFromCtx(ctx)
origin, host := in.RegisterOrigin, in.RegisterHost
if r != nil {
if r.Header.Get("Origin") != "" {
origin = r.Header.Get("Origin")
}
if host == "" {
host = r.Host
}
}
res.Bound, res.Domain, e = service.SysUrlPool().BindOnRegister(ctx, res.MemberId, origin, host)
return e
})
return
}
func (s *sSysAppMemberAuth) AccountLogin(ctx context.Context, in *sysin.AppMemberAccountLoginInp) (res *sysin.AppMemberLoginModel, err error) {
var member *entity.AppMember
defer func() {
service.SysAppMemberLoginLog().Push(ctx, &sysin.AppMemberLoginLogPushInp{Member: member, LoginType: consts.AppMemberLoginTypePassword, Account: in.Account, Err: err})
}()
mc := dao.AppMember.Columns()
if err = s.memberModel(ctx).Where(fmt.Sprintf("(%s=? OR %s=?)", mc.Username, mc.Mobile), in.Account, in.Account).Scan(&member); err != nil {
err = gerror.Wrap(err, "登录失败,请稍后重试")
return
}
if member == nil || bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte(in.Password)) != nil {
err = gerror.New("账号或密码错误")
return
}
return s.finishLogin(ctx, member)
}
func (s *sSysAppMemberAuth) MobileLogin(ctx context.Context, in *sysin.AppMemberMobileLoginInp) (res *sysin.AppMemberLoginModel, err error) {
var member *entity.AppMember
defer func() {
service.SysAppMemberLoginLog().Push(ctx, &sysin.AppMemberLoginLogPushInp{Member: member, LoginType: consts.AppMemberLoginTypeSms, Account: in.Mobile, Err: err})
}()
mc := dao.AppMember.Columns()
if err = s.memberModel(ctx).Where(mc.Mobile, in.Mobile).Scan(&member); err != nil {
err = gerror.Wrap(err, "登录失败,请稍后重试")
return
}
if member == nil {
err = gerror.New("会员不存在")
return
}
if err = service.SysSmsLog().VerifyCode(ctx, &sysin.VerifyCodeInp{Event: consts.SmsTemplateLogin, Mobile: in.Mobile, Code: in.Code}); err != nil {
err = gerror.New("验证码错误或已过期")
return
}
return s.finishLogin(ctx, member)
}
func (s *sSysAppMemberAuth) finishLogin(ctx context.Context, member *entity.AppMember) (res *sysin.AppMemberLoginModel, err error) {
if member.MemberStatus != consts.AppMemberStatusNormal {
return nil, gerror.New("会员已禁用")
}
if member.RiskStatus == consts.AppMemberRiskBlocked {
return nil, gerror.New("风控限制登录")
}
if err = s.updateLastLogin(ctx, member); err != nil {
return nil, err
}
now := gtime.Now()
identity := &model.Identity{Id: int64(member.Id), Username: member.Username, App: consts.AppApi, LoginAt: now}
loginToken, expires, err := token.Login(ctx, identity)
if err != nil {
return nil, gerror.Wrap(err, "登录失败,请稍后重试")
}
res = &sysin.AppMemberLoginModel{Id: member.Id, MemberNo: member.MemberNo, Username: member.Username, Mobile: encrypt.MaskMobile(member.Mobile), Token: loginToken, Expires: expires}
return
}
func (s *sSysAppMemberAuth) updateLastLogin(ctx context.Context, member *entity.AppMember) error {
r := ghttp.RequestFromCtx(ctx)
if r == nil {
return gerror.New("登录失败,请稍后重试")
}
clientIP := location.GetClientIp(r)
cipher, err := encrypt.EncryptField(ctx, clientIP)
if err != nil {
return gerror.Wrap(err, "登录失败,请稍后重试")
}
region := ""
if ipData, locationErr := location.GetLocation(ctx, clientIP); locationErr == nil && ipData != nil {
region = formatLoginRegion(ipData)
} else if locationErr != nil {
g.Log().Warningf(ctx, "解析会员登录地区失败 memberId:%d ip:%s err:%+v", member.Id, maskIP(clientIP), locationErr)
}
mc := dao.AppMember.Columns()
_, err = s.memberModel(ctx).Fields(mc.LastLoginAt, mc.LastLoginIpCipher, mc.LastLoginIpMask, mc.LastLoginRegion).WherePri(member.Id).Data(g.Map{
mc.LastLoginAt: gtime.Now(), mc.LastLoginIpCipher: cipher, mc.LastLoginIpMask: maskIP(clientIP), mc.LastLoginRegion: region,
}).Update()
if err != nil {
return gerror.Wrap(err, "登录失败,请稍后重试")
}
return nil
}
@@ -0,0 +1,31 @@
package sys
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"hotgo/internal/dao"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
)
func newAppMemberInviteCode(ctx context.Context, members *gdb.Model) (string, error) {
for attempt := 0; attempt < 20; attempt++ {
number, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", gerror.Wrap(err, "生成邀请码失败")
}
code := fmt.Sprintf("%06d", number.Int64())
count, err := members.Unscoped().Where(dao.AppMember.Columns().InviteCode, code).Count()
if err != nil {
return "", gerror.Wrap(err, "检查邀请码失败")
}
if count == 0 {
return code, nil
}
}
return "", gerror.New("邀请码暂时无法生成,请稍后重试")
}
@@ -0,0 +1,233 @@
package sys
import (
"context"
"fmt"
"net"
"strings"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/hgorm/handler"
"hotgo/internal/library/location"
"hotgo/internal/library/queue"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"hotgo/utility/convert"
"hotgo/utility/encrypt"
"hotgo/utility/excel"
"hotgo/utility/useragent"
"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/net/ghttp"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
type sSysAppMemberLoginLog struct{}
func NewSysAppMemberLoginLog() *sSysAppMemberLoginLog { return &sSysAppMemberLoginLog{} }
func init() { service.RegisterSysAppMemberLoginLog(NewSysAppMemberLoginLog()) }
func (s *sSysAppMemberLoginLog) Model(ctx context.Context) *gdb.Model {
return handler.Model(dao.AppMemberLoginLog.Ctx(ctx), &handler.Option{FilterTenant: true})
}
func (s *sSysAppMemberLoginLog) List(ctx context.Context, in *sysin.AppMemberLoginLogListInp) (list []*sysin.AppMemberLoginLogListModel, totalCount int, err error) {
lc, mc := dao.AppMemberLoginLog.Columns(), dao.AppMember.Columns()
mod := s.Model(ctx).As("l").LeftJoin(dao.AppMember.Table()+" m", "m."+mc.Id+"=l."+lc.MemberId)
if in.Keyword != "" {
keyword := strings.TrimSpace(in.Keyword)
like := "%" + keyword + "%"
mod = mod.Where(fmt.Sprintf("(m.%s LIKE ? OR l.%s LIKE ? OR l.%s LIKE ? OR l.%s LIKE ?)", mc.MemberNo, lc.Username, lc.MobileMask, lc.MemberId), like, like, like, like)
}
if in.LoginType > 0 {
mod = mod.Where("l."+lc.LoginType, in.LoginType)
}
if in.Status > 0 {
mod = mod.Where("l."+lc.Status, in.Status)
}
if in.LoginDomain != "" {
mod = mod.WhereLike("l."+lc.LoginDomain, "%"+strings.ToLower(strings.TrimSpace(in.LoginDomain))+"%")
}
if in.LoginIp != "" {
mod = mod.WhereLike("l."+lc.LoginIpMask, "%"+strings.TrimSpace(in.LoginIp)+"%")
}
if len(in.LoginAt) == 2 {
mod = mod.WhereBetween("l."+lc.LoginAt, in.LoginAt[0], in.LoginAt[1])
}
totalCount, err = mod.Clone().Count()
if err != nil || totalCount == 0 {
return
}
mod = mod.Fields(
"l."+lc.Id, "l."+lc.ReqId, "l."+lc.MemberId,
fmt.Sprintf("COALESCE(m.%s,'') member_no", mc.MemberNo),
"l."+lc.Username, "l."+lc.MobileMask, "l."+lc.LoginType, "l."+lc.Status,
"l."+lc.ErrMsg, "l."+lc.LoginAt, "l."+lc.LoginIpMask, "l."+lc.LoginRegion,
"l."+lc.LoginDomain, "l."+lc.UserAgent,
)
err = mod.Page(in.Page, in.PerPage).OrderDesc("l." + lc.LoginAt).OrderDesc("l." + lc.Id).Scan(&list)
if err != nil {
return nil, 0, gerror.Wrap(err, "查询会员登录日志失败")
}
for _, row := range list {
row.Os = useragent.GetOs(row.UserAgent)
row.Browser = useragent.GetBrowser(row.UserAgent)
}
return
}
func (s *sSysAppMemberLoginLog) View(ctx context.Context, in *sysin.AppMemberLoginLogViewInp) (res *sysin.AppMemberLoginLogViewModel, err error) {
lc, mc := dao.AppMemberLoginLog.Columns(), dao.AppMember.Columns()
res = new(sysin.AppMemberLoginLogViewModel)
err = s.Model(ctx).As("l").LeftJoin(dao.AppMember.Table()+" m", "m."+mc.Id+"=l."+lc.MemberId).Fields(
"l."+lc.Id, "l."+lc.ReqId, "l."+lc.MemberId,
fmt.Sprintf("COALESCE(m.%s,'') member_no", mc.MemberNo),
"l."+lc.Username, "l."+lc.MobileMask, "l."+lc.LoginType, "l."+lc.Status,
"l."+lc.ErrMsg, "l."+lc.LoginAt, "l."+lc.LoginIpMask, "l."+lc.LoginRegion,
"l."+lc.LoginDomain, "l."+lc.UserAgent,
).Where("l."+lc.Id, in.Id).Scan(res)
if err != nil {
return nil, gerror.Wrap(err, "查询会员登录日志详情失败")
}
if res.Id == 0 {
return nil, gerror.New("会员登录日志不存在")
}
res.Os = useragent.GetOs(res.UserAgent)
res.Browser = useragent.GetBrowser(res.UserAgent)
return
}
func (s *sSysAppMemberLoginLog) Export(ctx context.Context, in *sysin.AppMemberLoginLogListInp) error {
list, totalCount, err := s.List(ctx, in)
if err != nil {
return err
}
var exports []sysin.AppMemberLoginLogExportModel
if err = gconv.Scan(list, &exports); err != nil {
return err
}
tags, err := convert.GetEntityDescTags(sysin.AppMemberLoginLogExportModel{})
if err != nil {
return err
}
fileName := "导出会员登录日志-" + gctx.CtxId(ctx)
sheetName := fmt.Sprintf("共%v行,共%v页,当前第%v页,本页%v行", totalCount, form.CalPageCount(totalCount, in.PerPage), in.Page, len(exports))
return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
}
func (s *sSysAppMemberLoginLog) Push(ctx context.Context, in *sysin.AppMemberLoginLogPushInp) {
r := ghttp.RequestFromCtx(ctx)
if r == nil {
g.Log().Warningf(ctx, "会员登录日志缺少HTTP请求上下文")
return
}
model := entity.AppMemberLoginLog{
ReqId: gctx.CtxId(ctx),
LoginType: in.LoginType,
Status: consts.StatusEnabled,
LoginAt: gtime.Now(),
UserAgent: r.UserAgent(),
}
if in.Member != nil {
model.TenantId = in.Member.TenantId
model.MemberId = in.Member.Id
model.Username = in.Member.Username
model.MobileMask = encrypt.MaskMobile(in.Member.Mobile)
} else if in.LoginType == consts.AppMemberLoginTypeSms {
model.MobileMask = encrypt.MaskMobile(in.Account)
} else {
model.Username = strings.TrimSpace(in.Account)
}
if in.Err != nil {
model.Status = consts.StatusDisable
model.ErrMsg = normalizeLoginError(in.Err)
}
clientIP := location.GetClientIp(r)
model.LoginIpMask = maskIP(clientIP)
if cipher, err := encrypt.EncryptField(ctx, clientIP); err == nil {
model.LoginIpCipher = cipher
} else {
g.Log().Warningf(ctx, "加密会员登录IP失败 memberId:%d err:%+v", model.MemberId, err)
}
if ipData, err := location.GetLocation(ctx, clientIP); err == nil && ipData != nil {
model.LoginRegion = formatLoginRegion(ipData)
} else if err != nil {
g.Log().Warningf(ctx, "解析会员登录地区失败 memberId:%d ip:%s err:%+v", model.MemberId, model.LoginIpMask, err)
}
origin := r.Header.Get("Origin")
if domain, _, _ := resolveRequestDomain(origin, r.Host); domain != "" {
model.LoginDomain = domain
}
if err := queue.Push(consts.QueueAppMemberLoginLogTopic, model); err != nil {
g.Log().Warningf(ctx, "推送会员登录日志失败,改为同步写入 memberId:%d err:%+v", model.MemberId, err)
if writeErr := s.RealWrite(ctx, model); writeErr != nil {
g.Log().Warningf(ctx, "同步写入会员登录日志失败 memberId:%d err:%+v", model.MemberId, writeErr)
}
}
}
func (s *sSysAppMemberLoginLog) RealWrite(ctx context.Context, model entity.AppMemberLoginLog) error {
_, err := dao.AppMemberLoginLog.Ctx(ctx).Data(model).OmitEmptyData().Insert()
if err != nil {
return gerror.Wrap(err, "写入会员登录日志失败")
}
return nil
}
func maskIP(value string) string {
ip := net.ParseIP(strings.TrimSpace(value))
if ip == nil {
return ""
}
if v4 := ip.To4(); v4 != nil {
return fmt.Sprintf("%d.%d.*.*", v4[0], v4[1])
}
parts := strings.Split(ip.String(), ":")
if len(parts) > 4 {
return strings.Join(parts[:4], ":") + ":****"
}
return ip.String()
}
func formatLoginRegion(data *location.IpLocationData) string {
parts := []string{data.Country, data.Province, data.City, data.Area}
seen := make(map[string]struct{})
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if _, ok := seen[part]; ok {
continue
}
seen[part] = struct{}{}
result = append(result, part)
}
if len(result) == 0 {
return strings.TrimSpace(data.Region)
}
return strings.Join(result, " ")
}
func normalizeLoginError(err error) string {
if err == nil {
return ""
}
message := err.Error()
allowed := []string{"账号或密码错误", "验证码错误或已过期", "会员不存在", "会员已禁用", "风控限制登录", "登录失败,请稍后重试"}
for _, item := range allowed {
if strings.Contains(message, item) {
return item
}
}
return "系统异常"
}
+18 -3
View File
@@ -70,21 +70,36 @@ func (s *sSysCustomerService) Edit(ctx context.Context, in *sysin.CustomerServic
if count > 0 {
return gerror.New("客服编码已存在")
}
data := g.Map{c.ServiceCode: in.ServiceCode, c.Name: in.Name, c.AvatarUrl: in.AvatarUrl, c.WorkTime: in.WorkTime, c.OnlineStatus: in.OnlineStatus, c.ServiceUrl: in.ServiceUrl, c.BackupContact: in.BackupContact, c.SessionMapping: in.SessionMapping, c.Status: in.Status, c.Sort: in.Sort, c.Remark: in.Remark, c.UpdatedBy: contexts.GetUserId(ctx)}
if in.ImUserId != "" {
count, err = s.model(ctx).Where(c.ImTenantCode, in.ImTenantCode).Where(c.ImUserId, in.ImUserId).WhereNot(c.Id, in.Id).Count()
if err != nil {
return err
}
if count > 0 {
return gerror.New("IM客服账号已被其他席位绑定")
}
}
data := g.Map{c.ServiceCode: in.ServiceCode, c.Name: in.Name, c.AvatarUrl: in.AvatarUrl, c.WorkTime: in.WorkTime, c.OnlineStatus: in.OnlineStatus, c.ServiceUrl: in.ServiceUrl, c.BackupContact: in.BackupContact, c.SessionMapping: in.SessionMapping, c.ImTenantCode: in.ImTenantCode, c.ImUserId: gdb.Raw("NULL"), c.ImUsername: gdb.Raw("NULL"), c.Status: in.Status, c.Sort: in.Sort, c.Remark: in.Remark, c.UpdatedBy: contexts.GetUserId(ctx)}
if in.ImUserId != "" {
data[c.ImUserId] = in.ImUserId
}
if in.ImUsername != "" {
data[c.ImUsername] = in.ImUsername
}
if in.Id > 0 {
if _, err = s.model(ctx).WherePri(in.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新客服配置失败")
}
} else {
data[c.CreatedBy] = contexts.GetUserId(ctx)
result, e := s.model(ctx).Fields(c.ServiceCode, c.Name, c.AvatarUrl, c.WorkTime, c.OnlineStatus, c.ServiceUrl, c.BackupContact, c.SessionMapping, c.Status, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert()
result, e := s.model(ctx).Fields(c.ServiceCode, c.Name, c.AvatarUrl, c.WorkTime, c.OnlineStatus, c.ServiceUrl, c.BackupContact, c.SessionMapping, c.ImTenantCode, c.ImUserId, c.ImUsername, c.Status, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert()
if e != nil {
return gerror.Wrap(e, "创建客服配置失败")
}
id, _ := result.LastInsertId()
in.Id = uint64(id)
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "customer_service", Action: "customer_service.edit", TargetType: "customer_service", TargetId: in.Id, Before: before, After: g.Map{"name": in.Name, "workTime": in.WorkTime, "onlineStatus": in.OnlineStatus, "serviceUrl": in.ServiceUrl, "status": in.Status}, Reason: in.Remark})
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "customer_service", Action: "customer_service.edit", TargetType: "customer_service", TargetId: in.Id, Before: before, After: g.Map{"name": in.Name, "workTime": in.WorkTime, "onlineStatus": in.OnlineStatus, "serviceUrl": in.ServiceUrl, "imTenantCode": in.ImTenantCode, "imUserId": in.ImUserId, "status": in.Status}, Reason: in.Remark})
}
func (s *sSysCustomerService) Status(ctx context.Context, in *sysin.CustomerServiceStatusInp) error {
+103
View File
@@ -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
+15 -1
View File
@@ -82,6 +82,15 @@ func (s *sSysMemberLevel) View(ctx context.Context, in *sysin.MemberLevelViewInp
}
func (s *sSysMemberLevel) Save(ctx context.Context, in *sysin.MemberLevelSaveInp) (id, versionId uint64, err error) {
rules := g.Map{}
if in.RuleJson != nil {
for key, value := range in.RuleJson.Map() {
rules[key] = value
}
}
rules["auto_order_delay_min_seconds"] = in.AutoOrderDelayMinSeconds
rules["auto_order_delay_max_seconds"] = in.AutoOrderDelayMaxSeconds
in.RuleJson = gjson.New(rules)
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
lc, vc := dao.AppMemberLevel.Columns(), dao.AppMemberLevelVersion.Columns()
count, e := s.model(ctx).Where(lc.LevelCode, in.LevelCode).WhereNot(lc.Id, in.Id).Count()
@@ -253,7 +262,12 @@ func (s *sSysMemberLevel) Rollback(ctx context.Context, in *sysin.MemberLevelRol
if level == nil {
return 0, gerror.New("会员等级不存在")
}
_, versionId, err = s.Save(ctx, &sysin.MemberLevelSaveInp{Id: level.Id, LevelCode: level.LevelCode, Name: level.Name, IconAttachmentId: level.IconAttachmentId, IconUrl: level.IconUrl, UpgradeAmount: source.UpgradeAmount, GiftAmount: source.GiftAmount, DailyOrderLimit: source.DailyOrderLimit, CommissionRate: source.CommissionRate, DailyWithdrawLimit: source.DailyWithdrawLimit, RuleJson: source.RuleJson, EffectiveAt: nil, Sort: level.Sort, Status: level.Status, Remark: "回滚自 V" + fmt.Sprint(source.VersionNo) + ":" + in.Reason})
delayMin, delayMax := 0, 0
if source.RuleJson != nil {
delayMin = source.RuleJson.Get("auto_order_delay_min_seconds").Int()
delayMax = source.RuleJson.Get("auto_order_delay_max_seconds").Int()
}
_, versionId, err = s.Save(ctx, &sysin.MemberLevelSaveInp{Id: level.Id, LevelCode: level.LevelCode, Name: level.Name, IconAttachmentId: level.IconAttachmentId, IconUrl: level.IconUrl, UpgradeAmount: source.UpgradeAmount, GiftAmount: source.GiftAmount, DailyOrderLimit: source.DailyOrderLimit, CommissionRate: source.CommissionRate, DailyWithdrawLimit: source.DailyWithdrawLimit, AutoOrderDelayMinSeconds: delayMin, AutoOrderDelayMaxSeconds: delayMax, RuleJson: source.RuleJson, EffectiveAt: nil, Sort: level.Sort, Status: level.Status, Remark: "回滚自 V" + fmt.Sprint(source.VersionNo) + ":" + in.Reason})
return
}
func (s *sSysMemberLevel) History(ctx context.Context, in *sysin.MemberLevelHistoryInp) (list []*entity.AppMemberLevelVersion, total int, err error) {
+1 -2
View File
@@ -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()
+20 -2
View File
@@ -45,7 +45,7 @@ func (s *sSysProduct) listModel(ctx context.Context) *gdb.Model {
}
func (s *sSysProduct) List(ctx context.Context, in *sysin.ProductListInp) (list []*sysin.ProductListModel, total int, err error) {
pc := dao.Product.Columns()
mod := s.listModel(ctx)
mod := s.listModel(ctx).Where(col("p", pc.ProductType), consts.ProductTypeStandard)
if in.Name != "" {
mod = mod.WhereLike(col("p", pc.Name), "%"+in.Name+"%")
}
@@ -55,6 +55,24 @@ func (s *sSysProduct) List(ctx context.Context, in *sysin.ProductListInp) (list
if in.CategoryId > 0 {
mod = mod.Where(col("p", pc.CategoryId), in.CategoryId)
}
var minAmount, maxAmount int64
if in.PriceMin != "" {
minAmount, err = parseCents(in.PriceMin)
if err != nil {
return nil, 0, gerror.New("最低价格格式不正确")
}
mod = mod.WhereGTE(col("p", pc.PriceAmount), minAmount)
}
if in.PriceMax != "" {
maxAmount, err = parseCents(in.PriceMax)
if err != nil {
return nil, 0, gerror.New("最高价格格式不正确")
}
mod = mod.WhereLTE(col("p", pc.PriceAmount), maxAmount)
}
if in.PriceMin != "" && in.PriceMax != "" && minAmount > maxAmount {
return nil, 0, gerror.New("最低价格不能高于最高价格")
}
if in.PublishStatus > 0 {
mod = mod.Where(col("p", pc.PublishStatus), in.PublishStatus)
}
@@ -124,7 +142,7 @@ func (s *sSysProduct) Edit(ctx context.Context, in *sysin.ProductEditInp) (id ui
id = in.Id
} else {
in.ProductNo = "PD" + gtime.Now().Format("YmdHis") + grand.S(5)
result, e := s.Model(ctx).Fields(c.ProductNo, c.CategoryId, c.ProductType, c.PaymentType, c.Name, c.ShopName, c.PriceAmount, c.Currency, c.CoverAttachmentId, c.CoverUrl, c.DetailHtml, c.IsRecommended, c.PublishStatus, c.StartAt, c.EndAt, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(g.Map{c.ProductNo: in.ProductNo, c.CategoryId: in.CategoryId, c.ProductType: in.ProductType, c.PaymentType: in.PaymentType, c.Name: in.Name, c.ShopName: in.ShopName, c.PriceAmount: in.PriceAmount, c.Currency: in.Currency, c.CoverAttachmentId: in.CoverAttachmentId, c.CoverUrl: in.CoverUrl, c.DetailHtml: in.DetailHtml, c.IsRecommended: in.IsRecommended, c.PublishStatus: consts.BizPublishDraft, c.StartAt: in.StartAt, c.EndAt: in.EndAt, c.Sort: in.Sort, c.Remark: in.Remark, c.CreatedBy: uid, c.UpdatedBy: uid}).Insert()
result, e := s.Model(ctx).Fields(c.ProductNo, c.CategoryId, c.ProductType, c.PaymentType, c.Name, c.ShopName, c.PriceAmount, c.Currency, c.CoverAttachmentId, c.CoverUrl, c.DetailHtml, c.IsRecommended, c.ClaimedCount, c.PublishStatus, c.StartAt, c.EndAt, c.Sort, c.Remark, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(g.Map{c.ProductNo: in.ProductNo, c.CategoryId: in.CategoryId, c.ProductType: in.ProductType, c.PaymentType: in.PaymentType, c.Name: in.Name, c.ShopName: in.ShopName, c.PriceAmount: in.PriceAmount, c.Currency: in.Currency, c.CoverAttachmentId: in.CoverAttachmentId, c.CoverUrl: in.CoverUrl, c.DetailHtml: in.DetailHtml, c.IsRecommended: in.IsRecommended, c.ClaimedCount: grand.N(100, 500), c.PublishStatus: consts.BizPublishDraft, c.StartAt: in.StartAt, c.EndAt: in.EndAt, c.Sort: in.Sort, c.Remark: in.Remark, c.CreatedBy: uid, c.UpdatedBy: uid}).Insert()
if e != nil {
return 0, gerror.Wrap(e, "创建商品失败")
}
+5 -7
View File
@@ -12,7 +12,6 @@ import (
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"hotgo/utility/encrypt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
@@ -41,8 +40,11 @@ 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()
remarkName := fmt.Sprintf("CASE WHEN %s LIKE '姓名:%%' THEN SUBSTRING_INDEX(%s,'姓名:',-1) ELSE '' END", col("r", rc.Remark), col("r", rc.Remark))
realName := fmt.Sprintf("COALESCE(NULLIF(%s,''),(SELECT NULLIF(%s,'') FROM %s pa WHERE pa.%s=%s AND pa.%s IS NULL ORDER BY pa.%s ASC,pa.%s DESC LIMIT 1),NULLIF(%s,''),'') real_name_mask", col("p", pc.RealNameMask), ac.HolderMask, dao.AppMemberPaymentAccount.Table(), ac.MemberId, col("r", rc.MemberId), ac.DeletedAt, ac.IsDefault, ac.Id, remarkName)
return s.baseModel(ctx).
Fields(col("r", rc.Id), col("r", rc.RechargeNo), col("r", rc.MemberId), col("m", mc.Username), col("m", mc.MobileMask), 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) {
@@ -54,11 +56,7 @@ func (s *sSysRechargeOrder) filter(ctx context.Context, mod *gdb.Model, in *sysi
mod = mod.WhereLike(col("m", mc.Username), "%"+in.Username+"%")
}
if in.Mobile != "" {
hash, err := encrypt.HashField(ctx, in.Mobile)
if err != nil {
return nil, err
}
mod = mod.Where(col("m", mc.MobileHash), hash)
mod = mod.Where(col("m", mc.Mobile), in.Mobile)
}
if in.RechargeStatus > 0 {
mod = mod.Where(col("r", rc.RechargeStatus), in.RechargeStatus)
+16 -5
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+31 -13
View File
@@ -12,7 +12,6 @@ import (
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"hotgo/utility/encrypt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
@@ -29,20 +28,24 @@ 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.MobileMask), 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()
if in.TradeOrderId > 0 {
mod = mod.Where(col("s", sc.TradeOrderId), in.TradeOrderId)
}
if in.Username != "" {
mod = mod.WhereLike(col("m", mc.Username), "%"+in.Username+"%")
}
if in.Mobile != "" {
hash, err := encrypt.HashField(ctx, in.Mobile)
if err != nil {
return nil, err
}
mod = mod.Where(col("m", mc.MobileHash), hash)
mod = mod.Where(col("m", mc.Mobile), in.Mobile)
}
if in.SubmissionStatus > 0 {
mod = mod.Where(col("s", sc.SubmissionStatus), in.SubmissionStatus)
@@ -84,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 {
+442 -20
View File
@@ -4,15 +4,16 @@ import (
"context"
"fmt"
"math"
"time"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/hgorm/handler"
"hotgo/internal/library/hgorm/hook"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"hotgo/utility/encrypt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
@@ -30,14 +31,18 @@ func (s *sSysTradeOrder) model(ctx context.Context) *gdb.Model {
}
func (s *sSysTradeOrder) listModel(ctx context.Context) *gdb.Model {
oc, mc, pc, wc, ac := dao.TradeOrder.Columns(), dao.AppMember.Columns(), dao.AppMemberProfile.Columns(), dao.WalletAccount.Columns(), dao.ApprovalRequest.Columns()
oc, mc, pc, wc, ac, sc := dao.TradeOrder.Columns(), dao.AppMember.Columns(), dao.AppMemberProfile.Columns(), dao.WalletAccount.Columns(), dao.ApprovalRequest.Columns(), dao.TaskSubmission.Columns()
todayCount := fmt.Sprintf("(SELECT COUNT(*) FROM %s td WHERE td.%s=%s AND td.%s>=CURDATE()) today_trade_count", dao.TradeOrder.Table(), oc.MemberId, col("o", oc.MemberId), oc.CreatedAt)
canReview := fmt.Sprintf("EXISTS(SELECT 1 FROM %s ts WHERE ts.%s=%s AND ts.%s=%d) can_review", dao.TaskSubmission.Table(), sc.TradeOrderId, col("o", oc.Id), sc.SubmissionStatus, consts.TaskSubmissionPending)
dispatchMode := fmt.Sprintf("CASE COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s,'$.dispatch_mode')),'') WHEN 'auto' THEN %d WHEN 'manual' THEN %d ELSE %s END auto_dispatch", col("o", oc.RuleSnapshot), consts.TradeAutoDispatchEnabled, consts.TradeAutoDispatchDisabled, col("m", mc.AutoDispatch))
paymentStatus := fmt.Sprintf("COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s,'$.manual_payment_status')),'') payment_status", col("o", oc.RuleSnapshot))
scheduledMatchAt := fmt.Sprintf("COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s,'$.scheduled_match_at')),'') scheduled_match_at", col("o", oc.RuleSnapshot))
return s.model(ctx).As("o").
LeftJoin(dao.AppMember.Table()+" m", col("m", mc.Id)+"="+col("o", oc.MemberId)).
LeftJoin(dao.AppMemberProfile.Table()+" p", col("p", pc.MemberId)+"="+col("o", oc.MemberId)).
LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("o", oc.MemberId)+" AND "+col("w", wc.Currency)+"='CNY'").
LeftJoin(dao.ApprovalRequest.Table()+" a", col("a", ac.Id)+"="+col("o", oc.ApprovalRequestId)).
Fields(col("o", oc.Id), col("o", oc.OrderNo), col("o", oc.MemberId), col("m", mc.Username), col("m", mc.MobileMask), col("p", pc.RealNameMask), col("m", mc.AutoDispatch), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), todayCount, col("o", oc.ProductName), col("o", oc.OrderType), col("o", oc.Quantity), col("o", oc.UnitPriceAmount), col("o", oc.TotalAmount), col("o", oc.CommissionAmount), col("o", oc.Currency), col("o", oc.Message), col("o", oc.OrderStatus), col("o", oc.FreezeStatus), col("o", oc.ApprovalRequestId), fmt.Sprintf("COALESCE(%s,0) approval_status", col("a", ac.ApprovalStatus)), col("o", oc.MatchedAt), col("o", oc.CompletedAt), col("o", oc.UnfrozenAt), col("o", oc.CreatedAt))
Fields(col("o", oc.Id), col("o", oc.OrderNo), col("o", oc.MemberId), col("m", mc.Username), col("m", mc.Mobile), col("p", pc.RealNameMask), dispatchMode, paymentStatus, scheduledMatchAt, fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), todayCount, canReview, col("o", oc.ProductName), col("o", oc.OrderType), col("o", oc.Quantity), col("o", oc.UnitPriceAmount), col("o", oc.TotalAmount), col("o", oc.CommissionAmount), col("o", oc.Currency), col("o", oc.Message), col("o", oc.OrderStatus), col("o", oc.FreezeStatus), col("o", oc.ApprovalRequestId), fmt.Sprintf("COALESCE(%s,0) approval_status", col("a", ac.ApprovalStatus)), col("o", oc.MatchedAt), col("o", oc.CompletedAt), col("o", oc.UnfrozenAt), col("o", oc.CreatedAt))
}
func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp) (list []*sysin.TradeOrderListModel, total int, err error) {
@@ -50,11 +55,13 @@ func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp)
mod = mod.WhereLike(col("m", mc.Username), "%"+in.Username+"%")
}
if in.Mobile != "" {
hash, e := encrypt.HashField(ctx, in.Mobile)
if e != nil {
return nil, 0, e
}
mod = mod.Where(col("m", mc.MobileHash), hash)
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)
@@ -69,13 +76,28 @@ func (s *sSysTradeOrder) List(ctx context.Context, in *sysin.TradeOrderListInp)
return
}
for _, row := range list {
row.CanMatch = row.OrderStatus == consts.TradeOrderMatching && row.ApprovalRequestId == 0
row.CanMatch = canAdminMatchOrder(row.AutoDispatch, row.OrderStatus, row.ApprovalRequestId, row.ScheduledMatchAt, time.Now())
manualPending := row.AutoDispatch == consts.TradeAutoDispatchDisabled && row.OrderStatus == consts.TradeOrderPending && row.FreezeStatus == consts.TradeFreezeNormal
row.CanChangeAmount = manualPending
row.CanForcePay = manualPending
row.CanFreeze = manualPending
row.CanApply = row.ApprovalRequestId == 0 && row.OrderStatus != consts.TradeOrderCompleted && row.OrderStatus != consts.TradeOrderCancelled
row.CanExecute = row.ApprovalRequestId > 0 && row.ApprovalStatus == consts.ApprovalStatusApproved
}
return
}
func canAdminMatchOrder(dispatchMode, orderStatus int, approvalRequestId uint64, scheduledMatchAt string, now time.Time) bool {
if orderStatus != consts.TradeOrderMatching || approvalRequestId > 0 {
return false
}
if dispatchMode != consts.TradeAutoDispatchEnabled || scheduledMatchAt == "" {
return true
}
matchAt, err := time.Parse(time.RFC3339, scheduledMatchAt)
return err == nil && now.UTC().Before(matchAt.UTC())
}
func (s *sSysTradeOrder) View(ctx context.Context, in *sysin.TradeOrderViewInp) (res *sysin.TradeOrderViewModel, err error) {
var base *sysin.TradeOrderListModel
if err = s.listModel(ctx).Where(col("o", dao.TradeOrder.Columns().Id), in.Id).Scan(&base); err != nil {
@@ -88,7 +110,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
}
@@ -97,6 +139,7 @@ func (s *sSysTradeOrder) MatchProducts(ctx context.Context, in *sysin.TradeOrder
mod := handler.Model(dao.Product.Ctx(ctx), &handler.Option{FilterTenant: true}).
Fields(pc.Id, pc.Name, pc.CoverUrl, pc.PriceAmount).
Where(pc.PublishStatus, consts.BizPublishPublished).
Where(pc.Currency, "CNY").
WhereNull(pc.DeletedAt).
Where("("+pc.StartAt+" IS NULL OR "+pc.StartAt+" <= ?)", gtime.Now()).
Where("("+pc.EndAt+" IS NULL OR "+pc.EndAt+" >= ?)", gtime.Now())
@@ -143,6 +186,34 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
if order.OrderStatus != consts.TradeOrderMatching || order.ApprovalRequestId > 0 {
return gerror.New("当前订单状态不允许匹配商品")
}
dispatchMode := ""
if order.RuleSnapshot != nil {
dispatchMode = order.RuleSnapshot.Get("dispatch_mode").String()
}
if dispatchMode == "" {
var member *entity.AppMember
if err := handler.Model(dao.AppMember.Ctx(ctx), &handler.Option{FilterTenant: true}).Fields(dao.AppMember.Columns().AutoDispatch).WherePri(order.MemberId).Scan(&member); err != nil {
return gerror.Wrap(err, "读取会员派单模式失败")
}
if member != nil && member.AutoDispatch == consts.TradeAutoDispatchDisabled {
dispatchMode = "manual"
}
}
if dispatchMode == "" {
dispatchMode = "auto"
}
if dispatchMode == "auto" && order.RuleSnapshot != nil {
scheduledMatchAt := order.RuleSnapshot.Get("scheduled_match_at").String()
if scheduledMatchAt != "" {
matchAt, parseErr := time.Parse(time.RFC3339, scheduledMatchAt)
if parseErr != nil {
return gerror.New("自动派单时间格式不正确,请刷新订单后重试")
}
if !time.Now().UTC().Before(matchAt.UTC()) {
return gerror.New("自动派单已到期,订单正在由系统自动匹配")
}
}
}
var product *entity.Product
pc := dao.Product.Columns()
if err := handler.Model(dao.Product.Ctx(ctx), &handler.Option{FilterTenant: true}).
@@ -153,11 +224,14 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
if product == nil {
return gerror.New("商品不存在、已下架或无权使用")
}
if product.Currency != "CNY" {
return gerror.New("任务订单只能匹配 CNY 商品")
}
now := gtime.Now()
if product.StartAt != nil && product.StartAt.After(now) || product.EndAt != nil && product.EndAt.Before(now) {
return gerror.New("商品当前不在可匹配时间范围内")
}
if product.PriceAmount <= 0 || product.PriceAmount > math.MaxInt64/int64(in.Quantity) {
if in.Quantity == 0 || product.PriceAmount <= 0 || product.PriceAmount > math.MaxInt64/int64(in.Quantity) {
return gerror.New("商品价格或数量不合法")
}
levelId := order.MemberLevelId
@@ -186,20 +260,35 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
return gerror.New("会员等级生效版本不存在")
}
totalAmount := product.PriceAmount * int64(in.Quantity)
if levelVersion.CommissionRate > 0 && uint64(levelVersion.CommissionRate) > uint64(math.MaxInt64/totalAmount) {
return gerror.New("佣金规则超出可计算范围")
}
commissionAmount := totalAmount * int64(levelVersion.CommissionRate) / 10000
snapshot := gjson.New(g.Map{
"source": "admin_manual_match", "productId": product.Id, "productNo": product.ProductNo,
"unitPriceAmount": product.PriceAmount, "quantity": in.Quantity,
"memberLevelId": levelId, "levelVersionId": levelVersion.Id,
"commissionRate": levelVersion.CommissionRate,
})
snapshotData := g.Map{}
if order.RuleSnapshot != nil {
for key, value := range order.RuleSnapshot.Map() {
snapshotData[key] = value
}
}
snapshotData["source"], snapshotData["match_source"], snapshotData["dispatch_mode"] = "admin_manual_match", "admin_manual_match", dispatchMode
snapshotData["product_id"], snapshotData["product_no"] = product.Id, product.ProductNo
snapshotData["unit_price_amount"], snapshotData["quantity"] = product.PriceAmount, in.Quantity
snapshotData["member_level_id"], snapshotData["level_version_id"] = levelId, levelVersion.Id
snapshotData["commission_rate"], snapshotData["manual_payment_status"] = levelVersion.CommissionRate, "settled"
snapshotData["confirmation_required"], snapshotData["deadline_at"] = false, ""
snapshotData["settled_by"], snapshotData["settled_at"] = "admin", time.Now().UTC().Format(time.RFC3339)
snapshot := gjson.New(snapshotData)
commissionLedgerId, err := s.settleMatchedOrder(ctx, order, dispatchMode, totalAmount, commissionAmount, now)
if err != nil {
return err
}
oc := dao.TradeOrder.Columns()
data := g.Map{
oc.ProductId: product.Id, oc.ProductName: product.Name, oc.OrderType: consts.TradeOrderTypeManual,
oc.ProductId: product.Id, oc.ProductName: product.Name,
oc.Quantity: in.Quantity, oc.UnitPriceAmount: product.PriceAmount, oc.TotalAmount: totalAmount,
oc.CommissionAmount: commissionAmount, oc.Currency: product.Currency, oc.MemberLevelId: levelId,
oc.RuleSnapshot: snapshot, oc.Message: "后台人工匹配商品", oc.OrderStatus: consts.TradeOrderPending,
oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.UpdatedBy: contexts.GetUserId(ctx),
oc.RuleSnapshot: snapshot, oc.Message: "后台已匹配并完成订单,余额及佣金已结算", oc.OrderStatus: consts.TradeOrderCompleted,
oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.CompletedAt: now, oc.UpdatedBy: contexts.GetUserId(ctx),
}
result, err := s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderMatching).Data(data).Update()
if err != nil {
@@ -209,6 +298,9 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
if rows != 1 {
return gerror.New("订单状态已变化,请刷新后重试")
}
if err = s.insertMatchedSubmission(ctx, order, levelId, commissionAmount, commissionLedgerId, now, dispatchMode); err != nil {
return err
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{
Module: "trade", Action: "trade_order.match", TargetType: "trade_order", TargetId: order.Id,
Before: order, After: data, ChangeJson: gjson.New(g.Map{"productId": product.Id, "quantity": in.Quantity, "totalAmount": totalAmount}),
@@ -217,6 +309,336 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
})
}
func (s *sSysTradeOrder) settleMatchedOrder(ctx context.Context, order *entity.TradeOrder, dispatchMode string, totalAmount, commissionAmount int64, now *gtime.Time) (uint64, error) {
wc := dao.WalletAccount.Columns()
var account *entity.WalletAccount
if err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).
Where(wc.MemberId, order.MemberId).Where(wc.Currency, "CNY").LockUpdate().Scan(&account); err != nil {
return 0, gerror.Wrap(err, "读取会员钱包失败")
}
if account == nil || account.AccountStatus != 1 {
return 0, gerror.New("会员钱包不存在或状态异常")
}
if totalAmount <= 0 || account.AvailableAmount < math.MinInt64+totalAmount {
return 0, gerror.New("订单金额超出钱包处理范围")
}
if commissionAmount < 0 || account.AvailableAmount > math.MaxInt64-commissionAmount {
return 0, gerror.New("订单佣金超出钱包处理范围")
}
if dispatchMode == "auto" && account.AvailableAmount < totalAmount {
return 0, gerror.New("自动派单商品金额不能超过会员可用余额")
}
principalAfter := account.AvailableAmount - totalAmount
settledAfter := principalAfter + commissionAmount
if dispatchMode == "auto" {
settledAfter = account.AvailableAmount + commissionAmount
if account.FrozenAmount > math.MaxInt64-totalAmount {
return 0, gerror.New("订单金额超出钱包冻结范围")
}
}
accountData := g.Map{
wc.AvailableAmount: settledAfter,
wc.TotalIncome: gdb.Raw(wc.TotalIncome + "+" + fmt.Sprint(commissionAmount)),
wc.Version: gdb.Raw(wc.Version + "+1"),
}
if dispatchMode == "manual" {
accountData[wc.TotalExpense] = gdb.Raw(wc.TotalExpense + "+" + fmt.Sprint(totalAmount))
}
result, err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).WherePri(account.Id).Where(wc.Version, account.Version).Data(accountData).Update()
if err != nil {
return 0, gerror.Wrap(err, "结算订单余额失败")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return 0, gerror.New("会员余额已变化,请刷新后重试")
}
if dispatchMode == "auto" {
holdFrozen := account.FrozenAmount + totalAmount
entries := []struct {
code, bizType, entryType string
direction int
before, after int64
suffix, remark string
}{
{"AA", "task_principal_hold", "available", consts.WalletDirectionExpense, account.AvailableAmount, principalAfter, "admin-auto:available", "人工提前匹配自动单,扣除可用余额"},
{"AF", "task_principal_hold", "frozen", consts.WalletDirectionIncome, account.FrozenAmount, holdFrozen, "admin-auto:frozen", "人工提前匹配自动单,冻结订单本金"},
{"RF", "task_principal_release", "frozen", consts.WalletDirectionExpense, holdFrozen, account.FrozenAmount, "admin-auto:release-frozen", "自动单完成释放冻结本金"},
{"RA", "task_principal_return", "available", consts.WalletDirectionIncome, principalAfter, account.AvailableAmount, "admin-auto:return-available", "自动单完成返回本金"},
}
for _, entry := range entries {
if _, err = insertMatchedOrderLedger(ctx, order, account, "WL"+fmt.Sprint(order.Id)+entry.code, entry.bizType, order.OrderNo, entry.entryType, entry.direction, totalAmount, entry.before, entry.after, entry.suffix, entry.remark, now); err != nil {
return 0, err
}
}
} else {
if _, err = insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMP", order.Id), "task_principal_expense", order.OrderNo, "available", consts.WalletDirectionExpense, totalAmount, account.AvailableAmount, principalAfter, "admin-manual:principal", "手动单匹配完成扣除订单金额(允许负余额)", now); err != nil {
return 0, err
}
}
if commissionAmount == 0 {
return 0, nil
}
commissionBefore := principalAfter
if dispatchMode == "auto" {
commissionBefore = account.AvailableAmount
}
return insertMatchedOrderLedger(ctx, order, account, fmt.Sprintf("WL%dMC", order.Id), "commission", fmt.Sprintf("TSADMIN%d", order.Id), "available", consts.WalletDirectionIncome, commissionAmount, commissionBefore, settledAfter, "admin-match:commission", "后台匹配订单完成返佣", now)
}
func (s *sSysTradeOrder) insertMatchedSubmission(ctx context.Context, order *entity.TradeOrder, levelId uint64, commissionAmount int64, ledgerId uint64, now *gtime.Time, dispatchMode string) error {
sc := dao.TaskSubmission.Columns()
uid := contexts.GetUserId(ctx)
reason := "手动派单由后台匹配并直接完成"
if dispatchMode == "auto" {
reason = "自动派单到期前由后台匹配并直接完成"
}
_, err := dao.TaskSubmission.Ctx(ctx).
Fields(sc.SubmissionNo, sc.TradeOrderId, sc.TaskId, sc.MemberId, sc.MemberLevelId, sc.EvidenceText, sc.EvidenceAttachmentIds,
sc.SubmissionStatus, sc.CommissionAmount, sc.SuccessRate, sc.ReviewedBy, sc.ReviewedAt, sc.ReviewReasonCode,
sc.ReviewReason, sc.AllowResubmit, sc.LedgerId, sc.SettledAt, sc.CreatedBy, sc.UpdatedBy).
Hook(hook.SaveTenant).Data(g.Map{
sc.SubmissionNo: fmt.Sprintf("TSADMIN%d", order.Id), sc.TradeOrderId: order.Id, sc.TaskId: order.TaskId,
sc.MemberId: order.MemberId, sc.MemberLevelId: levelId, sc.EvidenceText: "", sc.EvidenceAttachmentIds: gjson.New(g.Array{}),
sc.SubmissionStatus: consts.TaskSubmissionSettled, sc.CommissionAmount: commissionAmount, sc.SuccessRate: 10000,
sc.ReviewedBy: uid, sc.ReviewedAt: now, sc.ReviewReasonCode: "ADMIN_MATCHED", sc.ReviewReason: reason,
sc.AllowResubmit: consts.StatusDisable, sc.LedgerId: ledgerId, sc.SettledAt: now, sc.CreatedBy: uid, sc.UpdatedBy: uid,
}).Insert()
if err != nil {
return gerror.Wrap(err, "创建已结算任务记录失败")
}
return nil
}
func (s *sSysTradeOrder) UpdateAmount(ctx context.Context, in *sysin.TradeOrderUpdateAmountInp) error {
amount, err := parseCents(in.Amount)
if err != nil || amount <= 0 || amount < int64(in.Quantity) {
return gerror.New("更改金额不合法")
}
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var order *entity.TradeOrder
if err := s.model(ctx).WherePri(in.Id).LockUpdate().Scan(&order); err != nil {
return gerror.Wrap(err, "读取手动订单失败")
}
if err := validateManualPaymentOrder(order, false); err != nil {
return err
}
commissionRate := order.RuleSnapshot.Get("commission_rate").Int64()
commissionAmount := amount * commissionRate / 10000
if order.RuleSnapshot.Get("manual_payment_status").String() == "paid" && amount != order.TotalAmount {
if err := s.adjustPaidManualAmount(ctx, order, amount); err != nil {
return err
}
}
snapshot := cloneOrderSnapshot(order)
snapshot["unit_price_amount"] = amount / int64(in.Quantity)
snapshot["quantity"] = in.Quantity
snapshot["manual_order_amount"] = amount
snapshot["amount_updated_at"] = time.Now().UTC().Format(time.RFC3339)
oc := dao.TradeOrder.Columns()
data := g.Map{oc.Quantity: in.Quantity, oc.UnitPriceAmount: amount / int64(in.Quantity), oc.TotalAmount: amount,
oc.CommissionAmount: commissionAmount, oc.RuleSnapshot: gjson.New(snapshot), oc.Message: "后台已更改订单金额,等待会员确认付款", oc.UpdatedBy: contexts.GetUserId(ctx)}
result, err := s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderPending).Data(data).Update()
if err != nil {
return gerror.Wrap(err, "更改订单金额失败")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return gerror.New("订单状态已变化,请刷新后重试")
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "trade", Action: "trade_order.update_amount",
TargetType: "trade_order", TargetId: order.Id, Before: order, After: data,
ChangeJson: gjson.New(g.Map{"amount": amount, "quantity": in.Quantity}), Reason: "后台更改手动订单金额"})
})
}
func (s *sSysTradeOrder) ForcePay(ctx context.Context, in *sysin.TradeOrderManualActionInp) error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var order *entity.TradeOrder
if err := s.model(ctx).WherePri(in.Id).LockUpdate().Scan(&order); err != nil {
return gerror.Wrap(err, "读取手动订单失败")
}
if err := validateManualPaymentOrder(order, false); err != nil {
return err
}
if order.RuleSnapshot.Get("manual_payment_status").String() == "paid" {
return nil
}
if order.TotalAmount <= 0 {
return gerror.New("订单金额不合法")
}
wc := dao.WalletAccount.Columns()
var account *entity.WalletAccount
if err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).
Where(wc.MemberId, order.MemberId).Where(wc.Currency, order.Currency).LockUpdate().Scan(&account); err != nil {
return gerror.Wrap(err, "读取会员钱包失败")
}
if account == nil || account.AccountStatus != 1 {
return gerror.New("会员钱包不存在或状态异常")
}
if account.AvailableAmount < math.MinInt64+order.TotalAmount || account.FrozenAmount > math.MaxInt64-order.TotalAmount {
return gerror.New("订单金额超出钱包处理范围")
}
afterAvailable, afterFrozen := account.AvailableAmount-order.TotalAmount, account.FrozenAmount+order.TotalAmount
result, err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).WherePri(account.Id).Where(wc.Version, account.Version).
Data(g.Map{wc.AvailableAmount: afterAvailable, wc.FrozenAmount: afterFrozen, wc.Version: gdb.Raw(wc.Version + "+1")}).Update()
if err != nil {
return gerror.Wrap(err, "强制付款失败")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return gerror.New("会员余额已变化,请刷新后重试")
}
if err = insertTradeMatchLedger(ctx, order, account, fmt.Sprintf("WL%dMA", order.Id), "available", consts.WalletDirectionExpense,
order.TotalAmount, account.AvailableAmount, afterAvailable, "match:available", "手动订单强制付款扣除可用余额"); err != nil {
return err
}
if err = insertTradeMatchLedger(ctx, order, account, fmt.Sprintf("WL%dMF", order.Id), "frozen", consts.WalletDirectionIncome,
order.TotalAmount, account.FrozenAmount, afterFrozen, "match:frozen", "手动订单强制付款冻结本金"); err != nil {
return err
}
snapshot := cloneOrderSnapshot(order)
snapshot["manual_payment_status"], snapshot["payment_confirmed_by"] = "paid", "admin"
snapshot["payment_confirmed_at"] = time.Now().UTC().Format(time.RFC3339)
oc := dao.TradeOrder.Columns()
data := g.Map{oc.RuleSnapshot: gjson.New(snapshot), oc.Message: "后台已强制付款,等待会员提交任务", oc.UpdatedBy: contexts.GetUserId(ctx)}
result, err = s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderPending).Data(data).Update()
if err != nil {
return gerror.Wrap(err, "更新订单付款状态失败")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return gerror.New("订单状态已变化,请刷新后重试")
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "trade", Action: "trade_order.force_pay",
TargetType: "trade_order", TargetId: order.Id, Before: order, After: data, Reason: "后台强制支付手动订单"})
})
}
func (s *sSysTradeOrder) adjustPaidManualAmount(ctx context.Context, order *entity.TradeOrder, amount int64) error {
delta := amount - order.TotalAmount
if delta == 0 {
return nil
}
wc := dao.WalletAccount.Columns()
var account *entity.WalletAccount
if err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).
Where(wc.MemberId, order.MemberId).Where(wc.Currency, order.Currency).LockUpdate().Scan(&account); err != nil {
return gerror.Wrap(err, "读取会员钱包失败")
}
if account == nil || account.AccountStatus != 1 {
return gerror.New("会员钱包不存在或状态异常")
}
if delta > 0 && (account.AvailableAmount < math.MinInt64+delta || account.FrozenAmount > math.MaxInt64-delta) {
return gerror.New("更改金额超出钱包处理范围")
}
if delta < 0 {
refund := -delta
if account.FrozenAmount < refund || account.AvailableAmount > math.MaxInt64-refund {
return gerror.New("已冻结本金不足,无法减少订单金额")
}
}
afterAvailable, afterFrozen := account.AvailableAmount-delta, account.FrozenAmount+delta
result, err := handler.Model(dao.WalletAccount.Ctx(ctx), &handler.Option{FilterTenant: true}).WherePri(account.Id).Where(wc.Version, account.Version).
Data(g.Map{wc.AvailableAmount: afterAvailable, wc.FrozenAmount: afterFrozen, wc.Version: gdb.Raw(wc.Version + "+1")}).Update()
if err != nil {
return gerror.Wrap(err, "同步订单冻结金额失败")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return gerror.New("会员余额已变化,请刷新后重试")
}
tag := fmt.Sprintf("%x", time.Now().UnixNano())
ledgerAmount := delta
availableDirection, frozenDirection := consts.WalletDirectionExpense, consts.WalletDirectionIncome
if ledgerAmount < 0 {
ledgerAmount = -ledgerAmount
availableDirection, frozenDirection = consts.WalletDirectionIncome, consts.WalletDirectionExpense
}
if err = insertTradeMatchLedger(ctx, order, account, fmt.Sprintf("WL%dUA%s", order.Id, tag), "available", availableDirection,
ledgerAmount, account.AvailableAmount, afterAvailable, "amount:update:"+tag+":available", "手动订单更改金额调整可用余额"); err != nil {
return err
}
if err = insertTradeMatchLedger(ctx, order, account, fmt.Sprintf("WL%dUF%s", order.Id, tag), "frozen", frozenDirection,
ledgerAmount, account.FrozenAmount, afterFrozen, "amount:update:"+tag+":frozen", "手动订单更改金额调整冻结本金"); err != nil {
return err
}
return nil
}
func (s *sSysTradeOrder) Freeze(ctx context.Context, in *sysin.TradeOrderManualActionInp) error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var order *entity.TradeOrder
if err := s.model(ctx).WherePri(in.Id).LockUpdate().Scan(&order); err != nil {
return gerror.Wrap(err, "读取手动订单失败")
}
if err := validateManualPaymentOrder(order, false); err != nil {
return err
}
snapshot := cloneOrderSnapshot(order)
snapshot["frozen_by"], snapshot["frozen_at"] = "admin", time.Now().UTC().Format(time.RFC3339)
oc := dao.TradeOrder.Columns()
data := g.Map{oc.OrderStatus: consts.TradeOrderFrozen, oc.FreezeStatus: consts.TradeFreezeFrozen,
oc.RuleSnapshot: gjson.New(snapshot), oc.Message: "订单已被后台冻结,请联系客服", oc.UpdatedBy: contexts.GetUserId(ctx)}
result, err := s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderPending).Data(data).Update()
if err != nil {
return gerror.Wrap(err, "冻结订单失败")
}
if affected, _ := result.RowsAffected(); affected != 1 {
return gerror.New("订单状态已变化,请刷新后重试")
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "trade", Action: "trade_order.freeze",
TargetType: "trade_order", TargetId: order.Id, Before: order, After: data, Reason: "后台冻结手动订单"})
})
}
func validateManualPaymentOrder(order *entity.TradeOrder, requireUnpaid bool) error {
if order == nil {
return gerror.New("交易订单不存在")
}
if order.OrderStatus != consts.TradeOrderPending || order.FreezeStatus != consts.TradeFreezeNormal || order.RuleSnapshot == nil || order.RuleSnapshot.Get("dispatch_mode").String() != "manual" {
return gerror.New("当前订单状态不允许此操作")
}
if requireUnpaid && order.RuleSnapshot.Get("manual_payment_status").String() == "paid" {
return gerror.New("订单已经付款")
}
return nil
}
func cloneOrderSnapshot(order *entity.TradeOrder) g.Map {
snapshot := g.Map{}
if order != nil && order.RuleSnapshot != nil {
for key, value := range order.RuleSnapshot.Map() {
snapshot[key] = value
}
}
return snapshot
}
func insertTradeMatchLedger(ctx context.Context, order *entity.TradeOrder, account *entity.WalletAccount,
ledgerNo, entryType string, direction int, amount, beforeAmount, afterAmount int64, suffix, remark string) error {
_, err := insertMatchedOrderLedger(ctx, order, account, ledgerNo, "task_principal_hold", order.OrderNo, entryType, direction, amount, beforeAmount, afterAmount, suffix, remark, gtime.Now())
return err
}
func insertMatchedOrderLedger(ctx context.Context, order *entity.TradeOrder, account *entity.WalletAccount,
ledgerNo, bizType, bizNo, entryType string, direction int, amount, beforeAmount, afterAmount int64, suffix, remark string, occurredAt *gtime.Time) (uint64, error) {
lc := dao.WalletLedger.Columns()
result, err := dao.WalletLedger.Ctx(ctx).
Fields(lc.LedgerNo, lc.AccountId, lc.MemberId, lc.BizType, lc.BizNo, lc.EntryType, lc.Direction,
lc.Amount, lc.BeforeAmount, lc.AfterAmount, lc.Currency, lc.IdempotencyKey, lc.Remark, lc.OperatorId, lc.OccurredAt).
Hook(hook.SaveTenant).
Data(g.Map{lc.LedgerNo: ledgerNo, lc.AccountId: account.Id, lc.MemberId: order.MemberId,
lc.BizType: bizType, lc.BizNo: bizNo, lc.EntryType: entryType, lc.Direction: direction,
lc.Amount: amount, lc.BeforeAmount: beforeAmount, lc.AfterAmount: afterAmount, lc.Currency: account.Currency,
lc.IdempotencyKey: fmt.Sprintf("task:%d:%s", order.Id, suffix), lc.Remark: remark,
lc.OperatorId: contexts.GetUserId(ctx), lc.OccurredAt: occurredAt}).Insert()
if err != nil {
return 0, gerror.Wrap(err, "记录订单扣款流水失败")
}
ledgerId, err := result.LastInsertId()
if err != nil {
return 0, gerror.Wrap(err, "读取订单流水ID失败")
}
return uint64(ledgerId), nil
}
func (s *sSysTradeOrder) Apply(ctx context.Context, in *sysin.TradeOrderApplyInp) (res *sysin.FinanceApprovalModel, err error) {
var order *entity.TradeOrder
if err = s.model(ctx).WherePri(in.Id).Scan(&order); err != nil {
@@ -0,0 +1,37 @@
package sys
import (
"testing"
"time"
"hotgo/internal/consts"
)
func TestCanAdminMatchOrder(t *testing.T) {
now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
dispatchMode int
orderStatus int
approvalRequestId uint64
scheduledMatchAt string
want bool
}{
{name: "manual matching order", dispatchMode: consts.TradeAutoDispatchDisabled, orderStatus: consts.TradeOrderMatching, want: true},
{name: "auto before schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: now.Add(time.Minute).Format(time.RFC3339), want: true},
{name: "auto at schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: now.Format(time.RFC3339), want: false},
{name: "auto after schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: now.Add(-time.Minute).Format(time.RFC3339), want: false},
{name: "legacy auto without schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, want: true},
{name: "invalid schedule", dispatchMode: consts.TradeAutoDispatchEnabled, orderStatus: consts.TradeOrderMatching, scheduledMatchAt: "invalid", want: false},
{name: "already completed", dispatchMode: consts.TradeAutoDispatchDisabled, orderStatus: consts.TradeOrderCompleted, want: false},
{name: "approval pending", dispatchMode: consts.TradeAutoDispatchDisabled, orderStatus: consts.TradeOrderMatching, approvalRequestId: 9, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := canAdminMatchOrder(tt.dispatchMode, tt.orderStatus, tt.approvalRequestId, tt.scheduledMatchAt, now)
if got != tt.want {
t.Fatalf("canAdminMatchOrder() = %v, want %v", got, tt.want)
}
})
}
}
+448
View File
@@ -0,0 +1,448 @@
package sys
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net"
"net/url"
"strings"
"unicode/utf8"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/hgorm/handler"
"hotgo/internal/library/hgorm/hook"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"hotgo/utility/encrypt"
"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"
"github.com/xuri/excelize/v2"
)
type sSysUrlPool struct{}
func NewSysUrlPool() *sSysUrlPool { return &sSysUrlPool{} }
func init() { service.RegisterSysUrlPool(NewSysUrlPool()) }
func (s *sSysUrlPool) Model(ctx context.Context) *gdb.Model {
return handler.Model(dao.UrlPool.Ctx(ctx), &handler.Option{FilterTenant: true})
}
func (s *sSysUrlPool) bindingModel(ctx context.Context) *gdb.Model {
return handler.Model(dao.MemberUrlBinding.Ctx(ctx), &handler.Option{FilterTenant: true})
}
func (s *sSysUrlPool) List(ctx context.Context, in *sysin.UrlPoolListInp) (list []*sysin.UrlPoolListModel, totalCount int, err error) {
pc, bc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns()
mod := s.Model(ctx).As("p")
if in.Name != "" {
mod = mod.WhereLike("p."+pc.Name, "%"+in.Name+"%")
}
if in.Domain != "" {
mod = mod.WhereLike("p."+pc.Domain, "%"+strings.ToLower(strings.TrimSpace(in.Domain))+"%")
}
if in.Status > 0 {
mod = mod.Where("p."+pc.Status, in.Status)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween("p."+pc.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
totalCount, err = mod.Clone().Count()
if err != nil || totalCount == 0 {
return
}
mod = mod.Fields(
"p."+pc.Id, "p."+pc.Name, "p."+pc.Scheme, "p."+pc.Domain, "p."+pc.BaseUrl,
"p."+pc.Status, "p."+pc.BindCount, "p."+pc.LastBoundAt, "p."+pc.Remark,
"p."+pc.CreatedAt, "p."+pc.UpdatedAt,
fmt.Sprintf("(SELECT COUNT(*) FROM %s b WHERE b.%s=p.%s) current_bound_count", dao.MemberUrlBinding.Table(), bc.UrlPoolId, pc.Id),
)
err = mod.Page(in.Page, in.PerPage).OrderAsc("p." + pc.BindCount).OrderAsc("p." + pc.Id).Scan(&list)
return
}
func (s *sSysUrlPool) View(ctx context.Context, in *sysin.UrlPoolViewInp) (res *sysin.UrlPoolViewModel, err error) {
pc, bc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns()
res = new(sysin.UrlPoolViewModel)
err = s.Model(ctx).As("p").Fields(
"p.*",
fmt.Sprintf("(SELECT COUNT(*) FROM %s b WHERE b.%s=p.%s) current_bound_count", dao.MemberUrlBinding.Table(), bc.UrlPoolId, pc.Id),
).Where("p."+pc.Id, in.Id).Scan(res)
if err != nil {
return nil, gerror.Wrap(err, "查询URL地址详情失败")
}
if res.Id == 0 {
return nil, gerror.New("URL地址不存在")
}
return
}
func (s *sSysUrlPool) Edit(ctx context.Context, in *sysin.UrlPoolEditInp) (err error) {
scheme, domain, baseURL, err := normalizeRegisterURL(in.BaseUrl)
if err != nil {
return err
}
hash := domainHash(domain)
pc := dao.UrlPool.Columns()
var before *entity.UrlPool
if in.Id > 0 {
if err = s.Model(ctx).WherePri(in.Id).Scan(&before); err != nil {
return gerror.Wrap(err, "读取URL地址失败")
}
if before == nil {
return gerror.New("URL地址不存在")
}
if before.Domain != domain {
count, e := s.bindingModel(ctx).Where(dao.MemberUrlBinding.Columns().UrlPoolId, in.Id).Count()
if e != nil {
return gerror.Wrap(e, "检查URL地址绑定失败")
}
if count > 0 {
return gerror.New("该地址已绑定会员,不能修改域名")
}
}
}
count, err := s.Model(ctx).Where(pc.DomainHash, hash).WhereNot(pc.Id, in.Id).Count()
if err != nil {
return gerror.Wrap(err, "检查URL地址重复失败")
}
if count > 0 {
return gerror.New("该注册域名已存在")
}
data := g.Map{
pc.Name: in.Name, pc.Scheme: scheme, pc.Domain: domain, pc.DomainHash: hash,
pc.BaseUrl: baseURL, pc.Status: in.Status, pc.Remark: in.Remark,
pc.UpdatedBy: contexts.GetUserId(ctx),
}
if in.Id > 0 {
_, err = s.Model(ctx).Fields(pc.Name, pc.Scheme, pc.Domain, pc.DomainHash, pc.BaseUrl, pc.Status, pc.Remark, pc.UpdatedBy).WherePri(in.Id).Data(data).Hook(hook.SaveTenant).Update()
} else {
data[pc.CreatedBy] = contexts.GetUserId(ctx)
result, e := dao.UrlPool.Ctx(ctx).Fields(pc.Name, pc.Scheme, pc.Domain, pc.DomainHash, pc.BaseUrl, pc.Status, pc.Remark, pc.CreatedBy, pc.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert()
if e != nil {
err = e
} else {
id, _ := result.LastInsertId()
in.Id = uint64(id)
}
}
if err != nil {
return gerror.Wrap(err, "保存URL地址失败")
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "url_pool", Action: "url_pool.edit", TargetType: "url_pool", TargetId: in.Id, Before: before, After: data})
}
func (s *sSysUrlPool) Status(ctx context.Context, in *sysin.UrlPoolStatusInp) error {
pc := dao.UrlPool.Columns()
var before *entity.UrlPool
if err := s.Model(ctx).WherePri(in.Id).Scan(&before); err != nil {
return gerror.Wrap(err, "读取URL地址失败")
}
if before == nil {
return gerror.New("URL地址不存在")
}
if before.Status == in.Status {
return nil
}
if _, err := s.Model(ctx).Fields(pc.Status, pc.UpdatedBy).WherePri(in.Id).Data(g.Map{pc.Status: in.Status, pc.UpdatedBy: contexts.GetUserId(ctx)}).Hook(hook.SaveTenant).Update(); err != nil {
return gerror.Wrap(err, "更新URL地址状态失败")
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "url_pool", Action: "url_pool.status", TargetType: "url_pool", TargetId: in.Id, Before: g.Map{"status": before.Status}, After: g.Map{"status": in.Status}})
}
func (s *sSysUrlPool) Import(ctx context.Context, reader io.Reader) (res *sysin.UrlPoolImportModel, err error) {
book, err := excelize.OpenReader(reader)
if err != nil {
return nil, gerror.New("Excel文件无法解析,请确认文件格式正确")
}
defer book.Close()
sheets := book.GetSheetList()
if len(sheets) == 0 {
return nil, gerror.New("Excel文件中没有工作表")
}
rows, err := book.GetRows(sheets[0])
if err != nil {
return nil, gerror.Wrap(err, "读取Excel内容失败")
}
if len(rows) < 2 {
return nil, gerror.New("Excel中没有可导入的数据")
}
nameColumn, urlColumn, remarkColumn := findUrlPoolImportColumns(rows[0])
if nameColumn < 0 || urlColumn < 0 {
return nil, gerror.New("Excel表头必须包含“地址名称”和“注册入口URL”")
}
type importCandidate struct {
domainHash string
data g.Map
}
res = &sysin.UrlPoolImportModel{Issues: make([]*sysin.UrlPoolImportIssue, 0)}
candidates := make([]importCandidate, 0, len(rows)-1)
fileDomains := make(map[string]struct{})
pc := dao.UrlPool.Columns()
for index := 1; index < len(rows); index++ {
row := rows[index]
name := importCell(row, nameColumn)
rawURL := importCell(row, urlColumn)
remark := importCell(row, remarkColumn)
if name == "" && rawURL == "" && remark == "" {
continue
}
res.TotalRows++
rowNumber := index + 1
if name == "" {
appendUrlPoolImportIssue(res, rowNumber, "地址名称不能为空")
continue
}
if utf8.RuneCountInString(name) > 100 {
appendUrlPoolImportIssue(res, rowNumber, "地址名称不能超过100个字符")
continue
}
if utf8.RuneCountInString(remark) > 500 {
appendUrlPoolImportIssue(res, rowNumber, "备注不能超过500个字符")
continue
}
scheme, domain, baseURL, normalizeErr := normalizeRegisterURL(rawURL)
if normalizeErr != nil {
appendUrlPoolImportIssue(res, rowNumber, normalizeErr.Error())
continue
}
if len(baseURL) > 512 {
appendUrlPoolImportIssue(res, rowNumber, "注册入口URL不能超过512个字符")
continue
}
hash := domainHash(domain)
if _, exists := fileDomains[hash]; exists {
res.DuplicateCount++
continue
}
fileDomains[hash] = struct{}{}
candidates = append(candidates, importCandidate{domainHash: hash, data: g.Map{
pc.Name: name, pc.Scheme: scheme, pc.Domain: domain, pc.DomainHash: hash,
pc.BaseUrl: baseURL, pc.Status: consts.UrlPoolStatusEnabled, pc.Remark: remark,
pc.CreatedBy: contexts.GetUserId(ctx), pc.UpdatedBy: contexts.GetUserId(ctx),
}})
}
if res.TotalRows > 5000 {
return nil, gerror.New("单次最多导入5000条地址")
}
if len(candidates) == 0 {
return res, nil
}
hashes := make([]string, 0, len(candidates))
for _, candidate := range candidates {
hashes = append(hashes, candidate.domainHash)
}
existingValues, err := s.Model(ctx).Fields(pc.DomainHash).WhereIn(pc.DomainHash, hashes).Array()
if err != nil {
return nil, gerror.Wrap(err, "检查重复注册域名失败")
}
existing := make(map[string]struct{}, len(existingValues))
for _, value := range existingValues {
existing[value.String()] = struct{}{}
}
insertRows := make(g.List, 0, len(candidates))
for _, candidate := range candidates {
if _, exists := existing[candidate.domainHash]; exists {
res.DuplicateCount++
continue
}
insertRows = append(insertRows, candidate.data)
}
if len(insertRows) == 0 {
return res, nil
}
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, insertErr := dao.UrlPool.Ctx(ctx).Fields(
pc.Name, pc.Scheme, pc.Domain, pc.DomainHash, pc.BaseUrl, pc.Status, pc.Remark, pc.CreatedBy, pc.UpdatedBy,
).Hook(hook.SaveTenant).Data(insertRows).Insert()
return insertErr
})
if err != nil {
return nil, gerror.Wrap(err, "批量导入URL地址失败")
}
res.SuccessCount = len(insertRows)
if auditErr := service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{
Module: "url_pool", Action: "url_pool.import", TargetType: "url_pool", After: g.Map{
"totalRows": res.TotalRows, "successCount": res.SuccessCount,
"duplicateCount": res.DuplicateCount, "failedCount": res.FailedCount,
},
}); auditErr != nil {
return nil, auditErr
}
return res, nil
}
func findUrlPoolImportColumns(header []string) (nameColumn, urlColumn, remarkColumn int) {
nameColumn, urlColumn, remarkColumn = -1, -1, -1
for index, value := range header {
value = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(value, "\ufeff")))
switch value {
case "地址名称", "名称", "name":
nameColumn = index
case "注册入口url", "注册入口", "入口url", "baseurl", "base_url":
urlColumn = index
case "备注", "remark":
remarkColumn = index
}
}
return
}
func importCell(row []string, column int) string {
if column < 0 || column >= len(row) {
return ""
}
return strings.TrimSpace(row[column])
}
func appendUrlPoolImportIssue(res *sysin.UrlPoolImportModel, row int, message string) {
res.FailedCount++
if len(res.Issues) < 100 {
res.Issues = append(res.Issues, &sysin.UrlPoolImportIssue{Row: row, Message: message})
}
}
func (s *sSysUrlPool) MemberList(ctx context.Context, in *sysin.UrlPoolMemberListInp) (list []*sysin.UrlPoolMemberListModel, totalCount int, err error) {
bc, mc := dao.MemberUrlBinding.Columns(), dao.AppMember.Columns()
mod := s.bindingModel(ctx).As("b").LeftJoin(dao.AppMember.Table()+" m", "m."+mc.Id+"=b."+bc.MemberId).Where("b."+bc.UrlPoolId, in.UrlPoolId)
if in.Keyword != "" {
like := "%" + strings.TrimSpace(in.Keyword) + "%"
mod = mod.Where(fmt.Sprintf("(m.%s LIKE ? OR m.%s LIKE ? OR m.%s LIKE ?)", mc.MemberNo, mc.Username, mc.Mobile), like, like, like)
}
if len(in.BoundAt) == 2 {
mod = mod.WhereBetween("b."+bc.BoundAt, in.BoundAt[0], in.BoundAt[1])
}
totalCount, err = mod.Clone().Count()
if err != nil || totalCount == 0 {
return
}
mod = mod.Fields(
"b."+bc.MemberId, "m."+mc.MemberNo, "m."+mc.Username, "m."+mc.Mobile, "m."+mc.MemberStatus, "b."+bc.BoundAt,
)
err = mod.Page(in.Page, in.PerPage).OrderDesc("b." + bc.Id).Scan(&list)
for _, row := range list {
row.Mobile = encrypt.MaskMobile(row.Mobile)
}
return
}
func (s *sSysUrlPool) Summary(ctx context.Context, in *sysin.UrlPoolSummaryInp) (res *sysin.UrlPoolSummaryModel, err error) {
res = new(sysin.UrlPoolSummaryModel)
pc, bc, mc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns(), dao.AppMember.Columns()
if res.TotalCount, err = s.Model(ctx).Count(); err != nil {
return nil, err
}
res.EnabledCount, err = s.Model(ctx).Where(pc.Status, consts.UrlPoolStatusEnabled).Count()
if err != nil {
return nil, err
}
res.InvalidCount, err = s.Model(ctx).Where(pc.Status, consts.UrlPoolStatusInvalid).Count()
if err != nil {
return nil, err
}
res.BoundMemberCount, err = s.bindingModel(ctx).Count()
if err != nil {
return nil, err
}
res.UnboundMemberCount, err = handler.Model(dao.AppMember.Ctx(ctx), &handler.Option{FilterTenant: true}).As("m").LeftJoin(dao.MemberUrlBinding.Table()+" b", "b."+bc.MemberId+"=m."+mc.Id).WhereNull("b." + bc.Id).Count()
if err != nil {
return nil, err
}
res.InvalidMemberCount, err = s.bindingModel(ctx).As("b").InnerJoin(dao.UrlPool.Table()+" p", "p."+pc.Id+"=b."+bc.UrlPoolId).Where("p."+pc.Status, consts.UrlPoolStatusInvalid).Count()
return
}
func (s *sSysUrlPool) BindOnRegister(ctx context.Context, memberId uint64, origin, host string) (bound bool, domain string, err error) {
domain, normalizedOrigin, err := resolveRequestDomain(origin, host)
if err != nil || domain == "" {
return false, "", nil
}
pc, bc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns()
var pool *entity.UrlPool
err = s.Model(ctx).Where(pc.DomainHash, domainHash(domain)).Where(pc.Status, consts.UrlPoolStatusEnabled).Scan(&pool)
if err != nil {
return false, domain, gerror.Wrap(err, "查询注册域名失败")
}
if pool == nil {
return false, domain, nil
}
var existing *entity.MemberUrlBinding
if err = s.bindingModel(ctx).Where(bc.MemberId, memberId).Scan(&existing); err != nil {
return false, domain, gerror.Wrap(err, "查询会员域名绑定失败")
}
if existing != nil {
return existing.UrlPoolId == pool.Id, existing.DomainSnapshot, nil
}
now := gtime.Now()
if _, err = dao.MemberUrlBinding.Ctx(ctx).Fields(bc.TenantId, bc.MemberId, bc.UrlPoolId, bc.DomainSnapshot, bc.OriginSnapshot, bc.BoundAt).Data(g.Map{bc.TenantId: pool.TenantId, bc.MemberId: memberId, bc.UrlPoolId: pool.Id, bc.DomainSnapshot: domain, bc.OriginSnapshot: normalizedOrigin, bc.BoundAt: now}).Insert(); err != nil {
return false, domain, gerror.Wrap(err, "创建会员域名绑定失败")
}
if _, err = s.Model(ctx).Fields(pc.BindCount, pc.LastBoundAt).WherePri(pool.Id).Data(g.Map{pc.BindCount: gdb.Raw(pc.BindCount + "+1"), pc.LastBoundAt: now}).Update(); err != nil {
return false, domain, gerror.Wrap(err, "更新域名绑定次数失败")
}
return true, domain, nil
}
func normalizeRegisterURL(raw string) (scheme, domain, baseURL string, err error) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", "", "", gerror.New("注册入口URL格式不正确")
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", "", "", gerror.New("注册入口只支持HTTP或HTTPS")
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return "", "", "", gerror.New("注册入口不能包含账号、查询参数或片段")
}
hostname := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(u.Hostname())), ".")
if hostname == "" {
return "", "", "", gerror.New("注册域名不能为空")
}
port := u.Port()
if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
port = ""
}
domain = hostname
if port != "" {
domain = net.JoinHostPort(hostname, port)
}
path := strings.TrimSpace(u.EscapedPath())
if path == "" {
path = "/register"
}
return strings.ToLower(u.Scheme), domain, strings.ToLower(u.Scheme) + "://" + domain + path, nil
}
func resolveRequestDomain(origin, host string) (domain, normalizedOrigin string, err error) {
if strings.TrimSpace(origin) != "" {
u, e := url.Parse(strings.TrimSpace(origin))
if e == nil && u.Scheme != "" && u.Host != "" && u.User == nil {
_, domain, normalizedOrigin, e = normalizeRegisterURL(u.Scheme + "://" + u.Host + "/register")
return domain, strings.TrimSuffix(normalizedOrigin, "/register"), e
}
}
host = strings.TrimSpace(host)
if host == "" {
return "", "", nil
}
_, domain, normalizedOrigin, err = normalizeRegisterURL("https://" + host + "/register")
return domain, strings.TrimSuffix(normalizedOrigin, "/register"), err
}
func domainHash(domain string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(domain))))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,58 @@
package sys
import "testing"
func TestNormalizeRegisterURL(t *testing.T) {
tests := []struct {
name string
raw string
domain string
baseURL string
wantErr bool
}{
{name: "default register path", raw: "https://Example.COM", domain: "example.com", baseURL: "https://example.com/register"},
{name: "keep custom port", raw: "http://example.com:8080/signup", domain: "example.com:8080", baseURL: "http://example.com:8080/signup"},
{name: "drop default port", raw: "https://example.com:443/register", domain: "example.com", baseURL: "https://example.com/register"},
{name: "reject query", raw: "https://example.com/register?code=1", wantErr: true},
{name: "reject non http", raw: "ftp://example.com/register", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, domain, baseURL, err := normalizeRegisterURL(tt.raw)
if tt.wantErr {
if err == nil {
t.Fatalf("normalizeRegisterURL(%q) expected error", tt.raw)
}
return
}
if err != nil {
t.Fatalf("normalizeRegisterURL(%q) returned error: %v", tt.raw, err)
}
if domain != tt.domain || baseURL != tt.baseURL {
t.Fatalf("normalizeRegisterURL(%q) = domain %q, baseURL %q", tt.raw, domain, baseURL)
}
})
}
}
func TestResolveRequestDomainPrefersOrigin(t *testing.T) {
domain, origin, err := resolveRequestDomain("https://REGISTER.example.com", "ignored.example.com")
if err != nil {
t.Fatal(err)
}
if domain != "register.example.com" || origin != "https://register.example.com" {
t.Fatalf("unexpected domain resolution: %q %q", domain, origin)
}
}
func TestFindUrlPoolImportColumns(t *testing.T) {
nameColumn, urlColumn, remarkColumn := findUrlPoolImportColumns([]string{"\ufeff地址名称", "注册入口URL", "备注"})
if nameColumn != 0 || urlColumn != 1 || remarkColumn != 2 {
t.Fatalf("unexpected import columns: name=%d url=%d remark=%d", nameColumn, urlColumn, remarkColumn)
}
nameColumn, urlColumn, remarkColumn = findUrlPoolImportColumns([]string{"NAME", "base_url"})
if nameColumn != 0 || urlColumn != 1 || remarkColumn != -1 {
t.Fatalf("unexpected alias columns: name=%d url=%d remark=%d", nameColumn, urlColumn, remarkColumn)
}
}
@@ -12,7 +12,6 @@ import (
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"hotgo/utility/encrypt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
@@ -38,7 +37,7 @@ func (s *sSysWithdrawalOrder) listModel(ctx context.Context) *gdb.Model {
LeftJoin(dao.WalletAccount.Table()+" w", col("w", ac.MemberId)+"="+col("o", wc.MemberId)+" AND "+col("w", ac.Currency)+"='CNY'").
LeftJoin(dao.AppMemberPaymentAccount.Table()+" pa", col("pa", pc.Id)+"="+col("o", wc.PaymentAccountId)).
LeftJoin(dao.SysAttachment.Table()+" sa", col("sa", sc.Id)+"="+col("pa", pc.QrAttachmentId)).
Fields(col("o", wc.Id), col("o", wc.WithdrawalNo), col("o", wc.MemberId), col("m", mc.Username), col("m", mc.MobileMask), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", ac.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", ac.FrozenAmount)), col("o", wc.RequestAmount), col("o", wc.FeeAmount), col("o", wc.ActualAmount), col("o", wc.Currency), col("o", wc.AccountMask), col("o", wc.BankName), col("o", wc.HolderMask), fmt.Sprintf("COALESCE(%s,0) payment_account_type", col("pa", pc.AccountType)), fmt.Sprintf("COALESCE(%s,'') qr_code_url", col("sa", sc.FileUrl)), col("o", wc.RiskResult), col("o", wc.WithdrawalStatus), col("o", wc.ReviewReasonCode), col("o", wc.ReviewReason), col("o", wc.ApprovalRequestId), col("o", wc.ExternalNo), col("o", wc.FreezeLedgerId), col("o", wc.FinalLedgerId), col("o", wc.ReviewedAt), col("o", wc.ProcessedAt), col("o", wc.CompletedAt), col("o", wc.CreatedAt))
Fields(col("o", wc.Id), col("o", wc.WithdrawalNo), col("o", wc.MemberId), col("m", mc.Username), col("m", mc.Mobile), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", ac.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", ac.FrozenAmount)), col("o", wc.RequestAmount), col("o", wc.FeeAmount), col("o", wc.ActualAmount), col("o", wc.Currency), col("o", wc.AccountMask), col("o", wc.BankName), col("o", wc.HolderMask), fmt.Sprintf("COALESCE(%s,0) payment_account_type", col("pa", pc.AccountType)), fmt.Sprintf("COALESCE(%s,'') qr_code_url", col("sa", sc.FileUrl)), col("o", wc.RiskResult), col("o", wc.WithdrawalStatus), col("o", wc.ReviewReasonCode), col("o", wc.ReviewReason), col("o", wc.ApprovalRequestId), col("o", wc.ExternalNo), col("o", wc.FreezeLedgerId), col("o", wc.FinalLedgerId), col("o", wc.ReviewedAt), col("o", wc.ProcessedAt), col("o", wc.CompletedAt), col("o", wc.CreatedAt))
}
func (s *sSysWithdrawalOrder) List(ctx context.Context, in *sysin.WithdrawalOrderListInp) (list []*sysin.WithdrawalOrderListModel, total int, err error) {
@@ -51,11 +50,7 @@ func (s *sSysWithdrawalOrder) List(ctx context.Context, in *sysin.WithdrawalOrde
mod = mod.WhereLike(col("m", mc.Username), "%"+in.Username+"%")
}
if in.Mobile != "" {
hash, e := encrypt.HashField(ctx, in.Mobile)
if e != nil {
return nil, 0, e
}
mod = mod.Where(col("m", mc.MobileHash), hash)
mod = mod.Where(col("m", mc.Mobile), in.Mobile)
}
if in.WithdrawalStatus > 0 {
mod = mod.Where(col("o", wc.WithdrawalStatus), in.WithdrawalStatus)
+1 -3
View File
@@ -16,9 +16,7 @@ type AppMember struct {
TenantId any // 租户ID
MemberNo any // 会员编号
Username any // 用户名
MobileCipher any // 手机号密文(AES-GCM)
MobileHash any // 手机号检索哈希(HMAC-SHA256)
MobileMask any // 手机号脱敏值
Mobile any //
PasswordHash any // 登录密码哈希(bcrypt)
TradePasswordHash any // 交易密码哈希
LevelId any // 会员等级ID
@@ -0,0 +1,31 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// AppMemberLoginLog is the golang structure of table hg_app_member_login_log for DAO operations like Where/Data.
type AppMemberLoginLog struct {
g.Meta `orm:"table:hg_app_member_login_log, do:true"`
Id any // 日志ID
TenantId any // 租户ID
ReqId any // 请求ID
MemberId any // 会员ID,未识别会员时为0
Username any // 用户名快照
MobileMask any // 手机号脱敏值
LoginType any // 登录方式:1账号密码,2手机验证码
Status any // 登录状态:1成功,2失败
ErrMsg any // 失败原因
LoginAt *gtime.Time // 登录时间
LoginIpCipher any // 登录IP密文
LoginIpMask any // 登录IP脱敏值
LoginRegion any // 登录地区
LoginDomain any // 登录页面标准化域名
UserAgent any // 浏览器UA
CreatedAt *gtime.Time // 创建时间
}
@@ -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 // 删除时间
}
@@ -23,6 +23,9 @@ type CustomerService struct {
ServiceUrl any // 客服跳转URL
BackupContact any // 备用联系方式
SessionMapping *gjson.Json // 第三方会话映射
ImTenantCode any // IM租户代码
ImUserId any // IM客服用户ID
ImUsername any // IM客服登录名
Status any // 状态
Sort any // 排序
Remark any // 备注
@@ -0,0 +1,24 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// MemberUrlBinding is the golang structure of table hg_member_url_binding for DAO operations like Where/Data.
type MemberUrlBinding struct {
g.Meta `orm:"table:hg_member_url_binding, do:true"`
Id any // 主键
TenantId any // 租户ID
MemberId any // 会员ID
UrlPoolId any // URL地址池ID
DomainSnapshot any // 绑定时域名快照
OriginSnapshot any // 绑定时Origin快照
BoundAt *gtime.Time // 绑定时间
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
}
+1
View File
@@ -26,6 +26,7 @@ type Product struct {
CoverUrl any // 封面URL
DetailHtml any // 商品详情
IsRecommended any // 是否推荐
ClaimedCount any // 已领取展示数
PublishStatus any // 发布状态
StartAt *gtime.Time // 展示开始时间
EndAt *gtime.Time // 展示结束时间
+1
View File
@@ -16,6 +16,7 @@ type Task struct {
TenantId any // 租户ID
TaskNo any // 任务编号
HallId any // 任务大厅ID
ProductId any // 关联商品ID
Title any // 标题
Description any // 描述
DetailHtml any // 清洗后的任务详情
+32
View File
@@ -0,0 +1,32 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// UrlPool is the golang structure of table hg_url_pool for DAO operations like Where/Data.
type UrlPool struct {
g.Meta `orm:"table:hg_url_pool, do:true"`
Id any // 主键
TenantId any // 租户ID
Name any // 地址名称
Scheme any // 协议:http或https
Domain any // 标准化域名,可含非默认端口
DomainHash any // 标准化域名SHA256
BaseUrl any // 注册入口基础地址
Status any // 状态:1启用,2失效
BindCount any // 累计成功绑定次数
LastBoundAt *gtime.Time // 最后绑定时间
Remark any // 备注
CreatedBy any // 创建者ID
UpdatedBy any // 更新者ID
DeletedBy any // 删除者ID
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
DeletedAt *gtime.Time // 删除时间
}
+1 -3
View File
@@ -14,9 +14,7 @@ type AppMember struct {
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:"租户ID"`
MemberNo string `json:"memberNo" orm:"member_no" description:"会员编号"`
Username string `json:"username" orm:"username" description:"用户名"`
MobileCipher string `json:"mobileCipher" orm:"mobile_cipher" description:"手机号密文(AES-GCM)"`
MobileHash string `json:"mobileHash" orm:"mobile_hash" description:"手机号检索哈希(HMAC-SHA256)"`
MobileMask string `json:"mobileMask" orm:"mobile_mask" description:"手机号脱敏值"`
Mobile string `json:"mobile" orm:"mobile" description:""`
PasswordHash string `json:"passwordHash" orm:"password_hash" description:"登录密码哈希(bcrypt)"`
TradePasswordHash string `json:"tradePasswordHash" orm:"trade_password_hash" description:"交易密码哈希"`
LevelId uint64 `json:"levelId" orm:"level_id" description:"会员等级ID"`
@@ -0,0 +1,29 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// AppMemberLoginLog is the golang structure for table app_member_login_log.
type AppMemberLoginLog struct {
Id uint64 `json:"id" orm:"id" description:"日志ID"`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:"租户ID"`
ReqId string `json:"reqId" orm:"req_id" description:"请求ID"`
MemberId uint64 `json:"memberId" orm:"member_id" description:"会员ID,未识别会员时为0"`
Username string `json:"username" orm:"username" description:"用户名快照"`
MobileMask string `json:"mobileMask" orm:"mobile_mask" description:"手机号脱敏值"`
LoginType int `json:"loginType" orm:"login_type" description:"登录方式:1账号密码,2手机验证码"`
Status int `json:"status" orm:"status" description:"登录状态:1成功,2失败"`
ErrMsg string `json:"errMsg" orm:"err_msg" description:"失败原因"`
LoginAt *gtime.Time `json:"loginAt" orm:"login_at" description:"登录时间"`
LoginIpCipher string `json:"loginIpCipher" orm:"login_ip_cipher" description:"登录IP密文"`
LoginIpMask string `json:"loginIpMask" orm:"login_ip_mask" description:"登录IP脱敏值"`
LoginRegion string `json:"loginRegion" orm:"login_region" description:"登录地区"`
LoginDomain string `json:"loginDomain" orm:"login_domain" description:"登录页面标准化域名"`
UserAgent string `json:"userAgent" orm:"user_agent" description:"浏览器UA"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
}
@@ -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:"删除时间"`
}
@@ -21,6 +21,9 @@ type CustomerService struct {
ServiceUrl string `json:"serviceUrl" orm:"service_url" description:"客服跳转URL"`
BackupContact string `json:"backupContact" orm:"backup_contact" description:"备用联系方式"`
SessionMapping *gjson.Json `json:"sessionMapping" orm:"session_mapping" description:"第三方会话映射"`
ImTenantCode string `json:"imTenantCode" orm:"im_tenant_code" description:"IM租户代码"`
ImUserId string `json:"imUserId" orm:"im_user_id" description:"IM客服用户ID"`
ImUsername string `json:"imUsername" orm:"im_username" description:"IM客服登录名"`
Status int `json:"status" orm:"status" description:"状态"`
Sort int `json:"sort" orm:"sort" description:"排序"`
Remark string `json:"remark" orm:"remark" description:"备注"`
@@ -0,0 +1,22 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// MemberUrlBinding is the golang structure for table member_url_binding.
type MemberUrlBinding 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"`
UrlPoolId uint64 `json:"urlPoolId" orm:"url_pool_id" description:"URL地址池ID"`
DomainSnapshot string `json:"domainSnapshot" orm:"domain_snapshot" description:"绑定时域名快照"`
OriginSnapshot string `json:"originSnapshot" orm:"origin_snapshot" description:"绑定时Origin快照"`
BoundAt *gtime.Time `json:"boundAt" orm:"bound_at" description:"绑定时间"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
+1
View File
@@ -24,6 +24,7 @@ type Product struct {
CoverUrl string `json:"coverUrl" orm:"cover_url" description:"封面URL"`
DetailHtml string `json:"detailHtml" orm:"detail_html" description:"商品详情"`
IsRecommended int `json:"isRecommended" orm:"is_recommended" description:"是否推荐"`
ClaimedCount uint `json:"claimedCount" orm:"claimed_count" description:"已领取展示数"`
PublishStatus int `json:"publishStatus" orm:"publish_status" description:"发布状态"`
StartAt *gtime.Time `json:"startAt" orm:"start_at" description:"展示开始时间"`
EndAt *gtime.Time `json:"endAt" orm:"end_at" description:"展示结束时间"`
+1
View File
@@ -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:"清洗后的任务详情"`
+30
View File
@@ -0,0 +1,30 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// UrlPool is the golang structure for table url_pool.
type UrlPool struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:"租户ID"`
Name string `json:"name" orm:"name" description:"地址名称"`
Scheme string `json:"scheme" orm:"scheme" description:"协议:http或https"`
Domain string `json:"domain" orm:"domain" description:"标准化域名,可含非默认端口"`
DomainHash string `json:"domainHash" orm:"domain_hash" description:"标准化域名SHA256"`
BaseUrl string `json:"baseUrl" orm:"base_url" description:"注册入口基础地址"`
Status int `json:"status" orm:"status" description:"状态:1启用,2失效"`
BindCount uint64 `json:"bindCount" orm:"bind_count" description:"累计成功绑定次数"`
LastBoundAt *gtime.Time `json:"lastBoundAt" orm:"last_bound_at" description:"最后绑定时间"`
Remark string `json:"remark" orm:"remark" description:"备注"`
CreatedBy uint64 `json:"createdBy" orm:"created_by" description:"创建者ID"`
UpdatedBy uint64 `json:"updatedBy" orm:"updated_by" description:"更新者ID"`
DeletedBy uint64 `json:"deletedBy" orm:"deleted_by" description:"删除者ID"`
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:"删除时间"`
}
+64 -60
View File
@@ -21,6 +21,8 @@ type AppMemberListInp struct {
MemberStatus int `json:"memberStatus" dc:"会员状态"`
AutoDispatch int `json:"autoDispatch" dc:"派单模式"`
MemberCategory int `json:"memberCategory" dc:"会员类别"`
RegisterDomain string `json:"registerDomain" dc:"注册域名(模糊查询)"`
DomainStatus int `json:"domainStatus" dc:"域名绑定状态"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"注册时间"`
}
@@ -30,7 +32,7 @@ type AppMemberListModel struct {
Id uint64 `json:"id"`
MemberNo string `json:"memberNo"`
Username string `json:"username"`
MobileMask string `json:"mobileMask"`
Mobile string `json:"mobile"`
RealNameMask string `json:"realNameMask"`
AvailableAmount int64 `json:"availableAmount"`
FrozenAmount int64 `json:"frozenAmount"`
@@ -47,6 +49,10 @@ type AppMemberListModel struct {
LastLoginRegion string `json:"lastLoginRegion"`
TodayTradeCount int `json:"todayTradeCount"`
CreatedAt *gtime.Time `json:"createdAt"`
UrlPoolId uint64 `json:"urlPoolId"`
RegisterDomain string `json:"registerDomain"`
DomainStatus int `json:"domainStatus"`
DomainBoundAt *gtime.Time `json:"domainBoundAt"`
}
type AppMemberViewInp struct {
@@ -68,31 +74,29 @@ type AppMemberViewModel struct {
}
type AppMemberEditInp struct {
Id uint64 `json:"id"`
Username string `json:"username" v:"required|length:3,64#用户名不能为空|用户名长度为3到64个字符"`
Mobile string `json:"mobile" v:"phone-loose#手机号格式不正确"`
Password string `json:"password"`
TradePassword string `json:"tradePassword"`
RealName string `json:"realName" v:"max-length:64#姓名不能超过64个字符"`
IdNo string `json:"idNo" v:"max-length:32#证件号不能超过32个字符"`
ParentId uint64 `json:"parentId"`
LevelId uint64 `json:"levelId"`
RiskStatus int `json:"riskStatus" d:"1"`
CreditScore int `json:"creditScore" d:"100" v:"between:0,1000#信用分范围为0到1000"`
AutoDispatch int `json:"autoDispatch" d:"2"`
MemberCategory int `json:"memberCategory"`
IsTestAccount int `json:"isTestAccount"`
Id uint64 `json:"id"`
Username string `json:"username" v:"required|length:3,64#用户名不能为空|用户名长度为3到64个字符"`
Mobile string `json:"mobile" v:"phone-loose#手机号格式不正确"`
Password string `json:"password"`
TradePassword string `json:"tradePassword"`
RealName string `json:"realName" v:"max-length:64#姓名不能超过64个字符"`
IdNo string `json:"idNo" v:"max-length:32#证件号不能超过32个字符"`
ParentId uint64 `json:"parentId"`
LevelId uint64 `json:"levelId"`
RiskStatus int `json:"riskStatus" d:"1"`
CreditScore int `json:"creditScore" d:"100" v:"between:0,1000#信用分范围为0到1000"`
AutoDispatch int `json:"autoDispatch" d:"1"`
MemberCategory int `json:"memberCategory"`
IsTestAccount int `json:"isTestAccount"`
AvailableAmount *int64 `json:"availableAmount"`
}
func (in *AppMemberEditInp) Filter(ctx context.Context) error {
if in.Id == 0 && strings.TrimSpace(in.Mobile) == "" {
return gerror.New("新增会员时手机号不能为空")
if in.Id == 0 {
return gerror.New("后台不支持创建会员,请由会员在C端完成注册")
}
if in.Id == 0 && len(in.Password) < 8 {
return gerror.New("新增会员密码不能少于8位")
}
if in.Password != "" && (len(in.Password) < 8 || !containsLetterAndNumber(in.Password)) {
return gerror.New("密码至少8位且必须同时包含字母和数字")
if in.Password != "" && (len([]rune(in.Password)) < 6 || len([]byte(in.Password)) > 64) {
return gerror.New("密码长度为6到64个字符")
}
if in.TradePassword != "" && len(in.TradePassword) < 6 {
return gerror.New("交易密码不能少于6位")
@@ -115,18 +119,6 @@ func (in *AppMemberEditInp) Filter(ctx context.Context) error {
}
return nil
}
func containsLetterAndNumber(value string) bool {
letter, number := false, false
for _, r := range value {
if r >= '0' && r <= '9' {
number = true
}
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
letter = true
}
}
return letter && number
}
type AppMemberDeleteInp struct {
Id uint64 `json:"id" v:"required|min:1#会员ID不能为空|会员ID不能为空"`
@@ -161,32 +153,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 {
@@ -280,7 +284,7 @@ func (in *AppMemberTeamInp) Filter(ctx context.Context) error { return nil }
type AppMemberTeamModel struct {
Id uint64 `json:"id"`
Username string `json:"username"`
MobileMask string `json:"mobileMask"`
Mobile string `json:"mobile"`
ParentId uint64 `json:"parentId"`
RelativeLevel int `json:"relativeLevel"`
TeamCount int `json:"teamCount"`
@@ -352,7 +356,7 @@ type WalletAdjustmentListModel struct {
RequestNo string `json:"requestNo"`
MemberId uint64 `json:"memberId"`
Username string `json:"username"`
MobileMask string `json:"mobileMask"`
Mobile string `json:"mobile"`
AdjustType int `json:"adjustType"`
Direction int `json:"direction"`
Amount int64 `json:"amount"`
@@ -0,0 +1,66 @@
package sysin
import (
"context"
"regexp"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
)
type AppMemberRegisterInp struct {
Username string `json:"username" v:"required|length:3,64#用户名不能为空|用户名长度为3到64个字符"`
Mobile string `json:"mobile" v:"required|phone-loose#手机号不能为空|手机号格式不正确"`
Password string `json:"password" v:"required|length:6,64#密码不能为空|密码长度为6到64个字符"`
InviteCode string `json:"inviteCode" v:"required#请输入邀请码"`
RegisterOrigin string `json:"registerOrigin" v:"max-length:512#注册来源不能超过512个字符"`
RegisterHost string `json:"registerHost" v:"max-length:255#注册域名不能超过255个字符"`
}
func (in *AppMemberRegisterInp) Filter(ctx context.Context) error {
in.Username = strings.TrimSpace(in.Username)
in.Mobile = strings.TrimSpace(in.Mobile)
in.InviteCode = strings.TrimSpace(in.InviteCode)
if !regexp.MustCompile(`^[0-9]{6}$`).MatchString(in.InviteCode) {
return gerror.New("邀请码必须为6位数字")
}
return nil
}
type AppMemberRegisterModel struct {
MemberId uint64 `json:"memberId"`
MemberNo string `json:"memberNo"`
Bound bool `json:"bound"`
Domain string `json:"domain"`
}
type AppMemberAccountLoginInp struct {
Account string `json:"account" v:"required|max-length:64#登录账号不能为空|登录账号不能超过64个字符"`
Password string `json:"password" v:"required|max-length:64#登录密码不能为空|登录密码不能超过64个字符"`
LoginHost string `json:"loginHost" v:"max-length:255#登录域名不能超过255个字符"`
}
func (in *AppMemberAccountLoginInp) Filter(ctx context.Context) error {
in.Account = strings.TrimSpace(in.Account)
return nil
}
type AppMemberMobileLoginInp struct {
Mobile string `json:"mobile" v:"required|phone-loose#手机号不能为空|手机号格式不正确"`
Code string `json:"code" v:"required|length:4,8#验证码不能为空|验证码格式不正确"`
LoginHost string `json:"loginHost" v:"max-length:255#登录域名不能超过255个字符"`
}
func (in *AppMemberMobileLoginInp) Filter(ctx context.Context) error {
in.Mobile = strings.TrimSpace(in.Mobile)
return nil
}
type AppMemberLoginModel struct {
Id uint64 `json:"id"`
MemberNo string `json:"memberNo"`
Username string `json:"username"`
Mobile string `json:"mobile"`
Token string `json:"token"`
Expires int64 `json:"expires"`
}
@@ -0,0 +1,85 @@
package sysin
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/library/dict"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type AppMemberLoginLogListInp struct {
form.PageReq
Keyword string `json:"keyword" dc:"会员编号、用户名或手机号"`
LoginType int `json:"loginType" dc:"登录方式"`
Status int `json:"status" dc:"登录状态"`
LoginDomain string `json:"loginDomain" dc:"登录域名"`
LoginIp string `json:"loginIp" dc:"登录IP脱敏值"`
LoginAt []*gtime.Time `json:"loginAt" dc:"登录时间"`
}
func (in *AppMemberLoginLogListInp) Filter(ctx context.Context) error {
if in.LoginType > 0 && !dict.HasOptionKey(consts.AppMemberLoginTypeOptions, in.LoginType) {
return gerror.New("登录方式不合法")
}
if in.Status > 0 && in.Status != consts.StatusEnabled && in.Status != consts.StatusDisable {
return gerror.New("登录状态不合法")
}
return nil
}
type AppMemberLoginLogListModel struct {
Id uint64 `json:"id"`
ReqId string `json:"reqId"`
MemberId uint64 `json:"memberId"`
MemberNo string `json:"memberNo"`
Username string `json:"username"`
MobileMask string `json:"mobileMask"`
LoginType int `json:"loginType"`
Status int `json:"status"`
ErrMsg string `json:"errMsg"`
LoginAt *gtime.Time `json:"loginAt"`
LoginIpMask string `json:"loginIpMask"`
LoginRegion string `json:"loginRegion"`
LoginDomain string `json:"loginDomain"`
UserAgent string `json:"userAgent"`
Os string `json:"os"`
Browser string `json:"browser"`
}
type AppMemberLoginLogViewInp struct {
Id uint64 `json:"id" v:"required|min:1#日志ID不能为空|日志ID不能为空"`
}
func (in *AppMemberLoginLogViewInp) Filter(ctx context.Context) error { return nil }
type AppMemberLoginLogViewModel struct {
AppMemberLoginLogListModel
}
type AppMemberLoginLogExportModel struct {
Id uint64 `json:"id" description:"日志ID"`
MemberNo string `json:"memberNo" description:"会员编号"`
Username string `json:"username" description:"用户名"`
MobileMask string `json:"mobileMask" description:"手机号"`
LoginType int `json:"loginType" description:"登录方式"`
Status int `json:"status" description:"登录状态"`
LoginAt *gtime.Time `json:"loginAt" description:"登录时间"`
LoginIpMask string `json:"loginIpMask" description:"登录IP"`
LoginRegion string `json:"loginRegion" description:"登录地区"`
LoginDomain string `json:"loginDomain" description:"登录域名"`
Os string `json:"os" description:"操作系统"`
Browser string `json:"browser" description:"浏览器"`
ErrMsg string `json:"errMsg" description:"失败原因"`
}
type AppMemberLoginLogPushInp struct {
Member *entity.AppMember
LoginType int
Account string
Err error
}
@@ -55,23 +55,25 @@ type MemberLevelViewModel struct {
Published *entity.AppMemberLevelVersion `json:"published"`
}
type MemberLevelSaveInp struct {
Id uint64 `json:"id"`
VersionId uint64 `json:"versionId"`
Version uint `json:"version"`
LevelCode string `json:"levelCode" v:"required|max-length:32#等级编码不能为空|等级编码不能超过32个字符"`
Name string `json:"name" v:"required|max-length:64#等级名称不能为空|等级名称不能超过64个字符"`
IconAttachmentId uint64 `json:"iconAttachmentId"`
IconUrl string `json:"iconUrl" v:"max-length:1000#图标地址不能超过1000个字符"`
UpgradeAmount int64 `json:"upgradeAmount" v:"min:0#升级金额不能小于0"`
GiftAmount int64 `json:"giftAmount" v:"min:0#赠送金额不能小于0"`
DailyOrderLimit uint `json:"dailyOrderLimit"`
CommissionRate uint `json:"commissionRate" v:"max:10000#佣金比例不能超过100%"`
DailyWithdrawLimit uint `json:"dailyWithdrawLimit"`
RuleJson *gjson.Json `json:"ruleJson"`
EffectiveAt *gtime.Time `json:"effectiveAt"`
Sort int `json:"sort"`
Status int `json:"status" d:"1"`
Remark string `json:"remark" v:"max-length:500#备注不能超过500个字符"`
Id uint64 `json:"id"`
VersionId uint64 `json:"versionId"`
Version uint `json:"version"`
LevelCode string `json:"levelCode" v:"required|max-length:32#等级编码不能为空|等级编码不能超过32个字符"`
Name string `json:"name" v:"required|max-length:64#等级名称不能为空|等级名称不能超过64个字符"`
IconAttachmentId uint64 `json:"iconAttachmentId"`
IconUrl string `json:"iconUrl" v:"max-length:1000#图标地址不能超过1000个字符"`
UpgradeAmount int64 `json:"upgradeAmount" v:"min:0#升级金额不能小于0"`
GiftAmount int64 `json:"giftAmount" v:"min:0#赠送金额不能小于0"`
DailyOrderLimit uint `json:"dailyOrderLimit"`
CommissionRate uint `json:"commissionRate" v:"max:10000#佣金比例不能超过100%"`
DailyWithdrawLimit uint `json:"dailyWithdrawLimit"`
AutoOrderDelayMinSeconds int `json:"autoOrderDelayMinSeconds" v:"min:0#最短派单时间不能小于0"`
AutoOrderDelayMaxSeconds int `json:"autoOrderDelayMaxSeconds" v:"min:0#最长派单时间不能小于0"`
RuleJson *gjson.Json `json:"ruleJson"`
EffectiveAt *gtime.Time `json:"effectiveAt"`
Sort int `json:"sort"`
Status int `json:"status" d:"1"`
Remark string `json:"remark" v:"max-length:500#备注不能超过500个字符"`
}
func (in *MemberLevelSaveInp) Filter(ctx context.Context) error {
@@ -81,6 +83,9 @@ func (in *MemberLevelSaveInp) Filter(ctx context.Context) error {
if in.UpgradeAmount > 0 && in.GiftAmount > in.UpgradeAmount {
return gerror.New("赠送金额不能超过升级金额")
}
if in.AutoOrderDelayMaxSeconds < in.AutoOrderDelayMinSeconds {
return gerror.New("最长派单时间不能小于最短派单时间")
}
return nil
}
+9 -11
View File
@@ -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 {
@@ -36,6 +36,7 @@ type ProductUpdateFields struct {
type ProductInsertFields struct {
ProductUpdateFields
ProductNo string
ClaimedCount uint
PublishStatus int
CreatedBy uint64
}
@@ -44,6 +45,8 @@ type ProductListInp struct {
Name string `json:"name"`
PaymentType string `json:"paymentType"`
CategoryId uint64 `json:"categoryId"`
PriceMin string `json:"priceMin" dc:"最低价格"`
PriceMax string `json:"priceMax" dc:"最高价格"`
PublishStatus int `json:"publishStatus"`
}
@@ -123,6 +126,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 +161,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 +183,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")
@@ -218,6 +223,7 @@ func (in *TaskDeleteInp) Filter(ctx context.Context) error { return nil }
type TaskSubmissionListInp struct {
form.PageReq
TradeOrderId uint64 `json:"tradeOrderId"`
Username string `json:"username"`
Mobile string `json:"mobile"`
SubmissionStatus int `json:"submissionStatus"`
@@ -230,11 +236,13 @@ 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"`
Username string `json:"username"`
MobileMask string `json:"mobileMask"`
Mobile string `json:"mobile"`
LevelName string `json:"levelName"`
EvidenceText string `json:"evidenceText"`
EvidenceAttachmentIds *gjson.Json `json:"evidenceAttachmentIds"`
+71 -16
View File
@@ -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 }
@@ -30,7 +31,7 @@ type TradeOrderListModel struct {
OrderNo string `json:"orderNo"`
MemberId uint64 `json:"memberId"`
Username string `json:"username"`
MobileMask string `json:"mobileMask"`
Mobile string `json:"mobile"`
RealNameMask string `json:"realNameMask"`
AutoDispatch int `json:"autoDispatch"`
AvailableAmount int64 `json:"availableAmount"`
@@ -52,7 +53,13 @@ type TradeOrderListModel struct {
UnfrozenAt *gtime.Time `json:"unfrozenAt"`
CreatedAt *gtime.Time `json:"createdAt"`
ApprovalStatus int `json:"approvalStatus"`
PaymentStatus string `json:"paymentStatus"`
ScheduledMatchAt string `json:"scheduledMatchAt"`
CanMatch bool `json:"canMatch"`
CanChangeAmount bool `json:"canChangeAmount"`
CanForcePay bool `json:"canForcePay"`
CanFreeze bool `json:"canFreeze"`
CanReview bool `json:"canReview"`
CanApply bool `json:"canApply"`
CanExecute bool `json:"canExecute"`
}
@@ -65,10 +72,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 {
@@ -110,6 +143,20 @@ type TradeOrderMatchInp struct {
func (in *TradeOrderMatchInp) Filter(ctx context.Context) error { return nil }
type TradeOrderUpdateAmountInp struct {
Id uint64 `json:"id" v:"required|min:1#订单ID不能为空|订单ID不能为空"`
Amount string `json:"amount" v:"required#更改金额不能为空"`
Quantity uint `json:"quantity" v:"required|min:1|max:999#更改数量不能为空|更改数量最少为1|更改数量不能超过999"`
}
func (in *TradeOrderUpdateAmountInp) Filter(ctx context.Context) error { return nil }
type TradeOrderManualActionInp struct {
Id uint64 `json:"id" v:"required|min:1#订单ID不能为空|订单ID不能为空"`
}
func (in *TradeOrderManualActionInp) Filter(ctx context.Context) error { return nil }
type FinanceApprovalModel struct {
ApprovalId uint64 `json:"approvalId"`
ApprovalNo string `json:"approvalNo"`
@@ -133,7 +180,7 @@ type RechargeOrderListModel struct {
RechargeNo string `json:"rechargeNo"`
MemberId uint64 `json:"memberId"`
Username string `json:"username"`
MobileMask string `json:"mobileMask"`
Mobile string `json:"mobile"`
RealNameMask string `json:"realNameMask"`
AvailableAmount int64 `json:"availableAmount"`
ChannelId uint64 `json:"channelId"`
@@ -213,7 +260,7 @@ type WithdrawalOrderListModel struct {
WithdrawalNo string `json:"withdrawalNo"`
MemberId uint64 `json:"memberId"`
Username string `json:"username"`
MobileMask string `json:"mobileMask"`
Mobile string `json:"mobile"`
AvailableAmount int64 `json:"availableAmount"`
FrozenAmount int64 `json:"frozenAmount"`
RequestAmount int64 `json:"requestAmount"`
@@ -283,19 +330,27 @@ type CustomerServiceEditInp struct {
AvatarUrl string `json:"avatarUrl" v:"max-length:1000#头像地址不能超过1000个字符"`
WorkTime string `json:"workTime" v:"required|max-length:200#工作时间不能为空|工作时间不能超过200个字符"`
OnlineStatus int `json:"onlineStatus" v:"required|in:1,2#在线状态不能为空|在线状态不合法"`
ServiceUrl string `json:"serviceUrl" v:"required|max-length:1000#客服链接不能为空|客服链接不能超过1000个字符"`
ServiceUrl string `json:"serviceUrl" v:"max-length:1000#客服链接不能超过1000个字符"`
BackupContact string `json:"backupContact" v:"required|max-length:500#备用联系方式不能为空|备用联系方式不能超过500个字符"`
SessionMapping *gjson.Json `json:"sessionMapping"`
ImTenantCode string `json:"imTenantCode" v:"required|max-length:64#IM租户代码不能为空|IM租户代码不能超过64个字符"`
ImUserId string `json:"imUserId" v:"max-length:64#IM用户ID不能超过64个字符"`
ImUsername string `json:"imUsername" v:"max-length:64#IM登录名不能超过64个字符"`
Status int `json:"status" d:"1" v:"required|in:1,2#状态不能为空|状态不合法"`
Sort int `json:"sort"`
Remark string `json:"remark" v:"max-length:500#备注不能超过500个字符"`
}
func (in *CustomerServiceEditInp) Filter(ctx context.Context) error {
u, err := url.Parse(in.ServiceUrl)
if err != nil || !strings.EqualFold(u.Scheme, "https") || u.Host == "" {
return gerror.New("客服链接必须是有效的 HTTPS 地址")
if strings.TrimSpace(in.ServiceUrl) != "" {
u, err := url.Parse(in.ServiceUrl)
if err != nil || !strings.EqualFold(u.Scheme, "https") || u.Host == "" {
return gerror.New("客服链接必须是有效的 HTTPS 地址")
}
}
in.ImTenantCode = strings.ToLower(strings.TrimSpace(in.ImTenantCode))
in.ImUserId = strings.TrimSpace(in.ImUserId)
in.ImUsername = strings.ToLower(strings.TrimSpace(in.ImUsername))
if in.OnlineStatus != consts.CustomerServiceOnline && in.OnlineStatus != consts.CustomerServiceOffline {
return gerror.New("客服在线状态不合法")
}
@@ -0,0 +1,128 @@
package sysin
import (
"context"
"strings"
"hotgo/internal/consts"
"hotgo/internal/library/dict"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type UrlPoolListInp struct {
form.PageReq
Name string `json:"name" dc:"地址名称"`
Domain string `json:"domain" dc:"域名关键词"`
Status int `json:"status" dc:"状态"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
}
func (in *UrlPoolListInp) Filter(ctx context.Context) error {
if in.Status > 0 && !dict.HasOptionKey(consts.UrlPoolStatusOptions, in.Status) {
return gerror.New("URL地址状态不合法")
}
return nil
}
type UrlPoolListModel struct {
Id uint64 `json:"id"`
Name string `json:"name"`
Scheme string `json:"scheme"`
Domain string `json:"domain"`
BaseUrl string `json:"baseUrl"`
Status int `json:"status"`
BindCount uint64 `json:"bindCount"`
CurrentBoundCount uint64 `json:"currentBoundCount"`
LastBoundAt *gtime.Time `json:"lastBoundAt"`
Remark string `json:"remark"`
CreatedAt *gtime.Time `json:"createdAt"`
UpdatedAt *gtime.Time `json:"updatedAt"`
}
type UrlPoolViewInp struct {
Id uint64 `json:"id" v:"required|min:1#地址ID不能为空|地址ID不能为空"`
}
func (in *UrlPoolViewInp) Filter(ctx context.Context) error { return nil }
type UrlPoolViewModel struct {
UrlPoolListModel
CreatedBy uint64 `json:"createdBy"`
UpdatedBy uint64 `json:"updatedBy"`
}
type UrlPoolEditInp struct {
Id uint64 `json:"id"`
Name string `json:"name" v:"required|max-length:100#地址名称不能为空|地址名称不能超过100个字符"`
BaseUrl string `json:"baseUrl" v:"required|max-length:512#注册入口不能为空|注册入口不能超过512个字符"`
Status int `json:"status" v:"required#状态不能为空"`
Remark string `json:"remark" v:"max-length:500#备注不能超过500个字符"`
}
func (in *UrlPoolEditInp) Filter(ctx context.Context) error {
in.Name = strings.TrimSpace(in.Name)
in.BaseUrl = strings.TrimSpace(in.BaseUrl)
if !dict.HasOptionKey(consts.UrlPoolStatusOptions, in.Status) {
return gerror.New("URL地址状态不合法")
}
return nil
}
type UrlPoolStatusInp struct {
Id uint64 `json:"id" v:"required|min:1#地址ID不能为空|地址ID不能为空"`
Status int `json:"status" v:"required#状态不能为空"`
}
func (in *UrlPoolStatusInp) Filter(ctx context.Context) error {
if !dict.HasOptionKey(consts.UrlPoolStatusOptions, in.Status) {
return gerror.New("URL地址状态不合法")
}
return nil
}
type UrlPoolImportIssue struct {
Row int `json:"row" dc:"Excel行号"`
Message string `json:"message" dc:"失败原因"`
}
type UrlPoolImportModel struct {
TotalRows int `json:"totalRows" dc:"有效数据行数"`
SuccessCount int `json:"successCount" dc:"成功导入数量"`
DuplicateCount int `json:"duplicateCount" dc:"重复跳过数量"`
FailedCount int `json:"failedCount" dc:"校验失败数量"`
Issues []*UrlPoolImportIssue `json:"issues" dc:"校验失败明细,最多返回100条"`
}
type UrlPoolMemberListInp struct {
form.PageReq
UrlPoolId uint64 `json:"urlPoolId" v:"required|min:1#地址ID不能为空|地址ID不能为空"`
Keyword string `json:"keyword" dc:"会员编号、用户名或手机号"`
BoundAt []*gtime.Time `json:"boundAt" dc:"绑定时间"`
}
func (in *UrlPoolMemberListInp) Filter(ctx context.Context) error { return nil }
type UrlPoolMemberListModel struct {
MemberId uint64 `json:"memberId"`
MemberNo string `json:"memberNo"`
Username string `json:"username"`
Mobile string `json:"mobile"`
MemberStatus int `json:"memberStatus"`
BoundAt *gtime.Time `json:"boundAt"`
}
type UrlPoolSummaryInp struct{}
func (in *UrlPoolSummaryInp) Filter(ctx context.Context) error { return nil }
type UrlPoolSummaryModel struct {
TotalCount int `json:"totalCount"`
EnabledCount int `json:"enabledCount"`
InvalidCount int `json:"invalidCount"`
BoundMemberCount int `json:"boundMemberCount"`
UnboundMemberCount int `json:"unboundMemberCount"`
InvalidMemberCount int `json:"invalidMemberCount"`
}
@@ -0,0 +1,27 @@
package queues
import (
"context"
"encoding/json"
"hotgo/internal/consts"
"hotgo/internal/library/queue"
"hotgo/internal/model/entity"
"hotgo/internal/service"
)
func init() { queue.RegisterConsumer(AppMemberLoginLog) }
var AppMemberLoginLog = &qAppMemberLoginLog{}
type qAppMemberLoginLog struct{}
func (q *qAppMemberLoginLog) GetTopic() string { return consts.QueueAppMemberLoginLogTopic }
func (q *qAppMemberLoginLog) Handle(ctx context.Context, mqMsg queue.MqMsg) error {
var data entity.AppMemberLoginLog
if err := json.Unmarshal(mqMsg.Body, &data); err != nil {
return err
}
return service.SysAppMemberLoginLog().RealWrite(ctx, data)
}
@@ -0,0 +1,5 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.AppMemberLoginLog) }
@@ -0,0 +1,5 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.UrlPool) }
+92 -35
View File
@@ -12,6 +12,7 @@ import (
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/utility/tree"
"io"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/database/gredis"
@@ -45,6 +46,9 @@ type (
View(ctx context.Context, in *sysin.TradeOrderViewInp) (res *sysin.TradeOrderViewModel, err error)
MatchProducts(ctx context.Context, in *sysin.TradeOrderMatchProductListInp) (list []*sysin.TradeOrderMatchProductModel, totalCount int, err error)
Match(ctx context.Context, in *sysin.TradeOrderMatchInp) error
UpdateAmount(ctx context.Context, in *sysin.TradeOrderUpdateAmountInp) error
ForcePay(ctx context.Context, in *sysin.TradeOrderManualActionInp) error
Freeze(ctx context.Context, in *sysin.TradeOrderManualActionInp) error
Apply(ctx context.Context, in *sysin.TradeOrderApplyInp) (res *sysin.FinanceApprovalModel, err error)
Execute(ctx context.Context, in *sysin.TradeOrderExecuteInp) error
}
@@ -115,6 +119,28 @@ type (
NoticeEdit(ctx context.Context, in *sysin.MemberNoticeEditInp) error
NoticeRevoke(ctx context.Context, in *sysin.MemberNoticeRevokeInp) error
}
ISysUrlPool interface {
List(ctx context.Context, in *sysin.UrlPoolListInp) (list []*sysin.UrlPoolListModel, totalCount int, err error)
View(ctx context.Context, in *sysin.UrlPoolViewInp) (res *sysin.UrlPoolViewModel, err error)
Edit(ctx context.Context, in *sysin.UrlPoolEditInp) error
Status(ctx context.Context, in *sysin.UrlPoolStatusInp) error
Import(ctx context.Context, reader io.Reader) (res *sysin.UrlPoolImportModel, err error)
MemberList(ctx context.Context, in *sysin.UrlPoolMemberListInp) (list []*sysin.UrlPoolMemberListModel, totalCount int, err error)
Summary(ctx context.Context, in *sysin.UrlPoolSummaryInp) (res *sysin.UrlPoolSummaryModel, err error)
BindOnRegister(ctx context.Context, memberId uint64, origin, host string) (bound bool, domain string, err error)
}
ISysAppMemberLoginLog interface {
List(ctx context.Context, in *sysin.AppMemberLoginLogListInp) (list []*sysin.AppMemberLoginLogListModel, totalCount int, err error)
View(ctx context.Context, in *sysin.AppMemberLoginLogViewInp) (res *sysin.AppMemberLoginLogViewModel, err error)
Export(ctx context.Context, in *sysin.AppMemberLoginLogListInp) error
Push(ctx context.Context, in *sysin.AppMemberLoginLogPushInp)
RealWrite(ctx context.Context, model entity.AppMemberLoginLog) error
}
ISysAppMemberAuth interface {
Register(ctx context.Context, in *sysin.AppMemberRegisterInp) (res *sysin.AppMemberRegisterModel, err error)
AccountLogin(ctx context.Context, in *sysin.AppMemberAccountLoginInp) (res *sysin.AppMemberLoginModel, err error)
MobileLogin(ctx context.Context, in *sysin.AppMemberMobileLoginInp) (res *sysin.AppMemberLoginModel, err error)
}
ISysPortal interface {
SiteConfigCurrent(ctx context.Context, in *sysin.SiteConfigCurrentInp) (res *sysin.SiteConfigCurrentModel, err error)
SiteConfigHistory(ctx context.Context, in *sysin.SiteConfigHistoryInp) (list []*sysin.SiteConfigHistoryModel, totalCount int, err error)
@@ -141,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
@@ -544,41 +571,44 @@ type (
)
var (
localSysMemberLevel ISysMemberLevel
localSysTaskHall ISysTaskHall
localSysTradeOrder ISysTradeOrder
localSysRechargeOrder ISysRechargeOrder
localSysWithdrawalOrder ISysWithdrawalOrder
localSysCustomerService ISysCustomerService
localSysProduct ISysProduct
localSysTask ISysTask
localSysTaskSubmission ISysTaskSubmission
localSysAppMember ISysAppMember
localSysPortal ISysPortal
localSysDashboard ISysDashboard
localSysBizAudit ISysBizAudit
localSysApproval ISysApproval
localSysAddons ISysAddons
localSysAddonsConfig ISysAddonsConfig
localSysAttachment ISysAttachment
localSysBlacklist ISysBlacklist
localSysConfig ISysConfig
localSysCron ISysCron
localSysCronGroup ISysCronGroup
localSysCurdDemo ISysCurdDemo
localSysDictData ISysDictData
localSysDictType ISysDictType
localSysEmsLog ISysEmsLog
localSysGenCodes ISysGenCodes
localSysLog ISysLog
localSysLoginLog ISysLoginLog
localSysNormalTreeDemo ISysNormalTreeDemo
localSysOptionTreeDemo ISysOptionTreeDemo
localSysProvinces ISysProvinces
localSysServeLicense ISysServeLicense
localSysServeLog ISysServeLog
localSysSmsLog ISysSmsLog
localSysTestCategory ISysTestCategory
localSysMemberLevel ISysMemberLevel
localSysTaskHall ISysTaskHall
localSysTradeOrder ISysTradeOrder
localSysRechargeOrder ISysRechargeOrder
localSysWithdrawalOrder ISysWithdrawalOrder
localSysCustomerService ISysCustomerService
localSysProduct ISysProduct
localSysTask ISysTask
localSysTaskSubmission ISysTaskSubmission
localSysAppMember ISysAppMember
localSysUrlPool ISysUrlPool
localSysAppMemberLoginLog ISysAppMemberLoginLog
localSysAppMemberAuth ISysAppMemberAuth
localSysPortal ISysPortal
localSysDashboard ISysDashboard
localSysBizAudit ISysBizAudit
localSysApproval ISysApproval
localSysAddons ISysAddons
localSysAddonsConfig ISysAddonsConfig
localSysAttachment ISysAttachment
localSysBlacklist ISysBlacklist
localSysConfig ISysConfig
localSysCron ISysCron
localSysCronGroup ISysCronGroup
localSysCurdDemo ISysCurdDemo
localSysDictData ISysDictData
localSysDictType ISysDictType
localSysEmsLog ISysEmsLog
localSysGenCodes ISysGenCodes
localSysLog ISysLog
localSysLoginLog ISysLoginLog
localSysNormalTreeDemo ISysNormalTreeDemo
localSysOptionTreeDemo ISysOptionTreeDemo
localSysProvinces ISysProvinces
localSysServeLicense ISysServeLicense
localSysServeLog ISysServeLog
localSysSmsLog ISysSmsLog
localSysTestCategory ISysTestCategory
)
func SysMemberLevel() ISysMemberLevel {
@@ -655,6 +685,33 @@ func SysAppMember() ISysAppMember {
func RegisterSysAppMember(i ISysAppMember) { localSysAppMember = i }
func SysUrlPool() ISysUrlPool {
if localSysUrlPool == nil {
panic("implement not found for interface ISysUrlPool, forgot register?")
}
return localSysUrlPool
}
func RegisterSysUrlPool(i ISysUrlPool) { localSysUrlPool = i }
func SysAppMemberLoginLog() ISysAppMemberLoginLog {
if localSysAppMemberLoginLog == nil {
panic("implement not found for interface ISysAppMemberLoginLog, forgot register?")
}
return localSysAppMemberLoginLog
}
func RegisterSysAppMemberLoginLog(i ISysAppMemberLoginLog) { localSysAppMemberLoginLog = i }
func SysAppMemberAuth() ISysAppMemberAuth {
if localSysAppMemberAuth == nil {
panic("implement not found for interface ISysAppMemberAuth, forgot register?")
}
return localSysAppMemberAuth
}
func RegisterSysAppMemberAuth(i ISysAppMemberAuth) { localSysAppMemberAuth = i }
func SysPortal() ISysPortal {
if localSysPortal == nil {
panic("implement not found for interface ISysPortal, forgot register?")
+5 -1
View File
@@ -158,7 +158,11 @@ router:
# 前缀
prefix: "/api"
# 不需要验证登录的路由地址
exceptLogin: [ ]
exceptLogin: [
"/member/register", # C端会员注册
"/member/accountLogin", # C端会员账号密码登录
"/member/mobileLogin", # C端会员手机验证码登录
]
# websocket
websocket:
# 前缀
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" role="img" aria-label="悦选平台横幅">
<defs><linearGradient id="g" x1="0" x2="1"><stop stop-color="#1ba8ee"/><stop offset="1" stop-color="#3136dc"/></linearGradient></defs>
<rect width="1200" height="360" rx="28" fill="url(#g)"/>
<circle cx="1010" cy="70" r="180" fill="#fff" opacity=".10"/><circle cx="1120" cy="320" r="220" fill="#fff" opacity=".08"/>
<text x="90" y="160" fill="#fff" font-size="66" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-weight="700">悦选</text>
<text x="92" y="230" fill="#fff" font-size="34" font-family="Arial, sans-serif" opacity=".9">精选商品 · 安心服务</text>
</svg>

After

Width:  |  Height:  |  Size: 704 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-label="悦选商品占位图">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#eaf7ff"/><stop offset="1" stop-color="#dfe3ff"/></linearGradient></defs>
<rect width="800" height="800" rx="48" fill="url(#g)"/>
<path d="M220 300h360l-28 300H248z" fill="#fff" stroke="#2878e8" stroke-width="22"/>
<path d="M310 315c0-118 180-118 180 0" fill="none" stroke="#2878e8" stroke-width="22" stroke-linecap="round"/>
<text x="400" y="690" text-anchor="middle" fill="#2878e8" font-size="54" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-weight="700">悦选</text>
</svg>

After

Width:  |  Height:  |  Size: 687 B

@@ -1,14 +1,12 @@
-- 第六章:会员、敏感资料、团队、钱包调账与会员通知。
-- MySQL 8.0+;金额统一使用分,敏感字段仅保存密文、检索哈希和脱敏值。
-- MySQL 8.0+;金额统一使用分。
CREATE TABLE IF NOT EXISTS `hg_app_member` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`member_no` varchar(32) NOT NULL COMMENT '会员编号',
`username` varchar(64) NOT NULL COMMENT '用户名',
`mobile_cipher` text NOT NULL COMMENT '手机号密文(AES-GCM)',
`mobile_hash` char(64) NOT NULL COMMENT '手机号检索哈希(HMAC-SHA256)',
`mobile_mask` varchar(32) NOT NULL COMMENT '手机号脱敏值',
`mobile` varchar(20) NOT NULL COMMENT '手机号',
`password_hash` varchar(100) NOT NULL COMMENT '登录密码哈希(bcrypt)',
`trade_password_hash` varchar(100) NOT NULL DEFAULT '' COMMENT '交易密码哈希',
`level_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '会员等级ID',
@@ -34,7 +32,7 @@ CREATE TABLE IF NOT EXISTS `hg_app_member` (
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_member_no` (`tenant_id`,`member_no`),
UNIQUE KEY `uk_app_member_username` (`tenant_id`,`username`),
UNIQUE KEY `uk_app_member_mobile_hash` (`tenant_id`,`mobile_hash`),
UNIQUE KEY `uk_app_member_mobile` (`tenant_id`,`mobile`),
UNIQUE KEY `uk_app_member_invite_code` (`tenant_id`,`invite_code`),
KEY `idx_app_member_team` (`tenant_id`,`parent_id`,`level`,`created_at`),
KEY `idx_app_member_status` (`tenant_id`,`member_status`,`auto_dispatch`,`created_at`),
@@ -96,13 +94,18 @@ CREATE TABLE IF NOT EXISTS `hg_app_member_payment_account` (
`bank_name` varchar(100) NOT NULL DEFAULT '' COMMENT '银行名称',
`holder_cipher` text NOT NULL COMMENT '持有人密文',
`holder_mask` varchar(64) NOT NULL COMMENT '持有人脱敏值',
`identity_no_cipher` text COMMENT '身份证号密文',
`identity_no_hash` char(64) NOT NULL DEFAULT '' COMMENT '身份证号检索哈希',
`identity_no_mask` varchar(32) NOT NULL DEFAULT '' COMMENT '身份证号脱敏值',
`mobile_cipher` text COMMENT '预留手机号密文',
`mobile_mask` varchar(32) NOT NULL DEFAULT '' COMMENT '预留手机号脱敏值',
`account_no_cipher` text NOT NULL COMMENT '账号密文',
`account_no_hash` char(64) NOT NULL COMMENT '账号检索哈希',
`account_no_mask` varchar(80) NOT NULL COMMENT '账号脱敏值',
`qr_attachment_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '收款码附件ID',
`verify_status` tinyint NOT NULL DEFAULT 1 COMMENT '核验状态',
`wechat_qr_attachment_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '微信二维码附件ID',
`alipay_qr_attachment_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付宝二维码附件ID',
`verify_status` tinyint NOT NULL DEFAULT 2 COMMENT '核验状态',
`is_default` tinyint NOT NULL DEFAULT 2 COMMENT '默认账户',
`created_by` bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建者',
`updated_by` bigint unsigned NOT NULL DEFAULT 0 COMMENT '更新者',
@@ -0,0 +1,9 @@
-- 当前产品没有银行卡后台审核流程,绑定成功后直接核验通过。
ALTER TABLE `hg_app_member_payment_account`
MODIFY COLUMN `verify_status` tinyint NOT NULL DEFAULT 2 COMMENT '核验状态';
UPDATE `hg_app_member_payment_account`
SET `verify_status` = 2,
`updated_at` = CURRENT_TIMESTAMP(3)
WHERE `verify_status` = 1
AND `deleted_at` IS NULL;
@@ -0,0 +1,7 @@
-- 按移动端原型补齐身份证号和微信/支付宝双二维码字段。
ALTER TABLE `hg_app_member_payment_account`
ADD COLUMN `identity_no_cipher` text NULL COMMENT '身份证号密文' AFTER `holder_mask`,
ADD COLUMN `identity_no_hash` char(64) NOT NULL DEFAULT '' COMMENT '身份证号检索哈希' AFTER `identity_no_cipher`,
ADD COLUMN `identity_no_mask` varchar(32) NOT NULL DEFAULT '' COMMENT '身份证号脱敏值' AFTER `identity_no_hash`,
ADD COLUMN `wechat_qr_attachment_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '微信二维码附件ID' AFTER `qr_attachment_id`,
ADD COLUMN `alipay_qr_attachment_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付宝二维码附件ID' AFTER `wechat_qr_attachment_id`;
@@ -0,0 +1,2 @@
ALTER TABLE `hg_app_member`
MODIFY COLUMN `auto_dispatch` tinyint NOT NULL DEFAULT 1 COMMENT '派单模式:1自动派单 2人工匹配';
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS `hg_app_member_notice_read` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`notice_id` bigint unsigned NOT NULL COMMENT '通知ID',
`member_id` bigint unsigned NOT NULL COMMENT '会员ID',
`read_at` datetime(3) NOT NULL COMMENT '阅读时间',
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_member_notice_read` (`tenant_id`,`notice_id`,`member_id`),
KEY `idx_member_notice_read_member` (`tenant_id`,`member_id`,`read_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='会员_通知阅读记录';
@@ -0,0 +1,9 @@
-- 会员等级自动派单随机等待区间,默认与原型规则一致:15-20 分钟。
UPDATE `hg_app_member_level_version`
SET `rule_json` = JSON_SET(
COALESCE(`rule_json`, JSON_OBJECT()),
'$.auto_order_delay_min_seconds', COALESCE(JSON_EXTRACT(`rule_json`, '$.auto_order_delay_min_seconds'), 900),
'$.auto_order_delay_max_seconds', COALESCE(JSON_EXTRACT(`rule_json`, '$.auto_order_delay_max_seconds'), 1200)
)
WHERE JSON_EXTRACT(`rule_json`, '$.auto_order_delay_min_seconds') IS NULL
OR JSON_EXTRACT(`rule_json`, '$.auto_order_delay_max_seconds') IS NULL;
@@ -0,0 +1,16 @@
-- 手动派单订单操作权限,挂载到订单管理菜单并继承已有角色权限。
SET @now=NOW();
SET @tradeRoot=(SELECT `id` FROM `hg_admin_menu` WHERE `name`='trade_management' LIMIT 1);
SET @orderMenu=(SELECT `id` FROM `hg_admin_menu` WHERE `name`='trade_order' LIMIT 1);
INSERT INTO `hg_admin_menu`
(`pid`,`level`,`tree`,`title`,`name`,`path`,`icon`,`type`,`redirect`,`permissions`,`permission_name`,`component`,`always_show`,`active_menu`,`is_root`,`is_frame`,`frame_src`,`keep_alive`,`hidden`,`affix`,`sort`,`remark`,`status`,`created_at`,`updated_at`)
SELECT @orderMenu,3,CONCAT('tr_',@tradeRoot,' tr_',@orderMenu,' '),'手动订单操作','trade_order_manual_actions','','',3,'','/tradeOrder/updateAmount,/tradeOrder/forcePay,/tradeOrder/freeze','','',1,'',0,0,'',0,1,0,6,'更改金额、强制付款、冻结订单权限',1,@now,@now
WHERE @orderMenu IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name`='trade_order_manual_actions');
SET @actionMenu=(SELECT `id` FROM `hg_admin_menu` WHERE `name`='trade_order_manual_actions' LIMIT 1);
INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`,`menu_id`)
SELECT DISTINCT rm.`role_id`,@actionMenu
FROM `hg_admin_role_menu` rm
WHERE rm.`menu_id`=@orderMenu AND @actionMenu IS NOT NULL;
@@ -0,0 +1,25 @@
-- 客服系统接入自建 IM:客服席位绑定、C 端会员 IM 身份映射。
-- im_user_id 使用 NULL 表示尚未绑定,避免唯一索引把多个空值误判为重复。
ALTER TABLE `hg_customer_service`
ADD COLUMN `im_tenant_code` varchar(64) NOT NULL DEFAULT 'allrich' COMMENT 'IM租户代码' AFTER `session_mapping`,
ADD COLUMN `im_user_id` varchar(64) DEFAULT NULL COMMENT 'IM客服用户ID' AFTER `im_tenant_code`,
ADD COLUMN `im_username` varchar(64) DEFAULT NULL COMMENT 'IM客服登录名' AFTER `im_user_id`,
ADD UNIQUE KEY `uk_customer_service_im_user` (`im_tenant_code`, `im_user_id`);
CREATE TABLE IF NOT EXISTS `hg_app_member_im_identity` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '业务租户ID',
`member_id` bigint unsigned NOT NULL COMMENT '会员ID',
`im_tenant_code` varchar(64) NOT NULL COMMENT 'IM租户代码',
`im_user_id` varchar(64) NOT NULL COMMENT 'IM公开用户ID',
`im_username` varchar(64) NOT NULL COMMENT 'IM登录名',
`sync_status` tinyint NOT NULL DEFAULT 1 COMMENT '同步状态:1正常、2待重试、3失败',
`last_error` varchar(500) NOT NULL DEFAULT '' COMMENT '最近同步错误',
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_member_im_identity_member` (`tenant_id`, `member_id`, `im_tenant_code`),
UNIQUE KEY `uk_member_im_identity_user` (`im_tenant_code`, `im_user_id`),
KEY `idx_member_im_identity_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='会员_IM身份映射';
@@ -0,0 +1,4 @@
-- Olons 复用 IM 现有 Basic 登录协议,随机 IM 密码仅以 AES-GCM 密文保存。
ALTER TABLE `hg_app_member_im_identity`
ADD COLUMN `im_password_cipher` varchar(512) NOT NULL DEFAULT '' COMMENT 'IM登录密码密文' AFTER `im_username`;
@@ -0,0 +1,5 @@
-- 绑卡信息增加数字人民币编码,按敏感账户信息保存密文、检索哈希和脱敏值。
ALTER TABLE `hg_app_member_payment_account`
ADD COLUMN `digital_rmb_code_cipher` text NULL COMMENT '数字人民币编码密文' AFTER `account_no_mask`,
ADD COLUMN `digital_rmb_code_hash` char(64) NOT NULL DEFAULT '' COMMENT '数字人民币编码检索哈希' AFTER `digital_rmb_code_cipher`,
ADD COLUMN `digital_rmb_code_mask` varchar(80) NOT NULL DEFAULT '' COMMENT '数字人民币编码脱敏值' AFTER `digital_rmb_code_hash`;
@@ -0,0 +1,67 @@
-- URL 地址池、会员注册域名绑定与 C 端会员登录日志。
-- MySQL 8.0+。
CREATE TABLE IF NOT EXISTS `hg_url_pool` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`name` varchar(100) NOT NULL DEFAULT '' COMMENT '地址名称',
`scheme` varchar(10) NOT NULL DEFAULT 'https' COMMENT '协议:http或https',
`domain` varchar(255) NOT NULL COMMENT '标准化域名,可含非默认端口',
`domain_hash` char(64) NOT NULL COMMENT '标准化域名SHA256',
`base_url` varchar(512) NOT NULL COMMENT '注册入口基础地址',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1启用,2失效',
`bind_count` bigint unsigned NOT NULL DEFAULT 0 COMMENT '累计成功绑定次数',
`last_bound_at` datetime(3) DEFAULT NULL COMMENT '最后绑定时间',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
`created_by` bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建者ID',
`updated_by` bigint unsigned NOT NULL DEFAULT 0 COMMENT '更新者ID',
`deleted_by` bigint unsigned NOT NULL DEFAULT 0 COMMENT '删除者ID',
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`deleted_at` datetime(3) DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_url_pool_domain` (`tenant_id`,`domain_hash`),
KEY `idx_url_pool_list` (`tenant_id`,`bind_count`,`id`,`deleted_at`),
KEY `idx_url_pool_status` (`tenant_id`,`status`,`bind_count`,`deleted_at`),
KEY `idx_url_pool_deleted` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='URL地址池';
CREATE TABLE IF NOT EXISTS `hg_member_url_binding` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`member_id` bigint unsigned NOT NULL COMMENT '会员ID',
`url_pool_id` bigint unsigned NOT NULL COMMENT 'URL地址池ID',
`domain_snapshot` varchar(255) NOT NULL COMMENT '绑定时域名快照',
`origin_snapshot` varchar(512) NOT NULL DEFAULT '' COMMENT '绑定时Origin快照',
`bound_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '绑定时间',
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_member_url_binding` (`tenant_id`,`member_id`),
KEY `idx_member_url_pool` (`tenant_id`,`url_pool_id`,`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='会员注册域名绑定';
CREATE TABLE IF NOT EXISTS `hg_app_member_login_log` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '日志ID',
`tenant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID',
`req_id` varchar(64) NOT NULL DEFAULT '' COMMENT '请求ID',
`member_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '会员ID,未识别会员时为0',
`username` varchar(64) NOT NULL DEFAULT '' COMMENT '用户名快照',
`mobile_mask` varchar(32) NOT NULL DEFAULT '' COMMENT '手机号脱敏值',
`login_type` tinyint NOT NULL DEFAULT 1 COMMENT '登录方式:1账号密码,2手机验证码',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '登录状态:1成功,2失败',
`err_msg` varchar(255) NOT NULL DEFAULT '' COMMENT '失败原因',
`login_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登录时间',
`login_ip_cipher` text COMMENT '登录IP密文',
`login_ip_mask` varchar(64) NOT NULL DEFAULT '' COMMENT '登录IP脱敏值',
`login_region` varchar(128) NOT NULL DEFAULT '' COMMENT '登录地区',
`login_domain` varchar(255) NOT NULL DEFAULT '' COMMENT '登录页面标准化域名',
`user_agent` varchar(512) NOT NULL DEFAULT '' COMMENT '浏览器UA',
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_member_login_time` (`tenant_id`,`member_id`,`login_at`),
KEY `idx_member_login_status` (`tenant_id`,`status`,`login_at`),
KEY `idx_member_login_username` (`tenant_id`,`username`,`login_at`),
KEY `idx_member_login_domain` (`tenant_id`,`login_domain`,`login_at`),
KEY `idx_member_login_req` (`req_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='C端会员登录日志';
@@ -0,0 +1,80 @@
-- URL 地址池和会员登录日志后台菜单。
SET @now=NOW();
SET @memberRoot=(SELECT `id` FROM `hg_admin_menu` WHERE `name`='app_member' LIMIT 1);
INSERT INTO `hg_admin_menu` (`pid`,`level`,`tree`,`title`,`name`,`path`,`icon`,`type`,`redirect`,`permissions`,`permission_name`,`component`,`always_show`,`active_menu`,`is_root`,`is_frame`,`frame_src`,`keep_alive`,`hidden`,`affix`,`sort`,`remark`,`status`,`created_at`,`updated_at`)
SELECT @memberRoot,2,CONCAT('tr_',@memberRoot,' '),x.title,x.name,x.path,x.icon,2,'',x.permissions,'',x.component,1,'',0,0,'',1,0,0,x.sort,x.remark,1,@now,@now FROM (
SELECT 'URL地址池' title,'app_member_url_pool' name,'url-pool' path,'LinkOutlined' icon,'/urlPool/list,/urlPool/view,/urlPool/memberList,/urlPool/summary' permissions,'/app-member/url-pool/index' component,40 sort,'维护注册域名并查看绑定会员' remark UNION ALL
SELECT '会员登录日志','app_member_login_log','login-log','FileSearchOutlined','/appMemberLoginLog/list,/appMemberLoginLog/view','/app-member/login-log/index',50,'查看与导出C端会员登录日志'
) x WHERE @memberRoot IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` m WHERE m.`name`=x.name);
SET @urlPoolMenu=(SELECT `id` FROM `hg_admin_menu` WHERE `name`='app_member_url_pool' LIMIT 1);
SET @memberLoginLogMenu=(SELECT `id` FROM `hg_admin_menu` WHERE `name`='app_member_login_log' LIMIT 1);
INSERT INTO `hg_admin_menu` (`pid`,`level`,`tree`,`title`,`name`,`path`,`icon`,`type`,`redirect`,`permissions`,`permission_name`,`component`,`always_show`,`active_menu`,`is_root`,`is_frame`,`frame_src`,`keep_alive`,`hidden`,`affix`,`sort`,`remark`,`status`,`created_at`,`updated_at`)
SELECT x.pid,3,x.tree,x.title,x.name,'','',3,'',x.permissions,'','',1,'',0,0,'',0,1,0,x.sort,'URL地址池及会员登录日志权限',1,@now,@now FROM (
SELECT @urlPoolMenu pid,CONCAT('tr_',@memberRoot,' tr_',@urlPoolMenu,' ') tree,'新增编辑地址' title,'url_pool_edit' name,'/urlPool/edit' permissions,10 sort UNION ALL
SELECT @urlPoolMenu,CONCAT('tr_',@memberRoot,' tr_',@urlPoolMenu,' '),'切换地址状态','url_pool_status','/urlPool/status',20 UNION ALL
SELECT @urlPoolMenu,CONCAT('tr_',@memberRoot,' tr_',@urlPoolMenu,' '),'Excel批量导入','url_pool_import','/urlPool/import',30 UNION ALL
SELECT @memberLoginLogMenu,CONCAT('tr_',@memberRoot,' tr_',@memberLoginLogMenu,' '),'导出会员登录日志','app_member_login_log_export','/appMemberLoginLog/export',10
) x WHERE x.pid IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` m WHERE m.`name`=x.name);
INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`,`menu_id`)
SELECT r.id,m.id
FROM `hg_admin_role` r
JOIN `hg_admin_menu` m ON m.name IN (
'app_member_url_pool','url_pool_edit','url_pool_status','url_pool_import',
'app_member_login_log','app_member_login_log_export'
)
WHERE r.`key`='super'
OR EXISTS (
SELECT 1
FROM `hg_admin_role_menu` parent_rm
JOIN `hg_admin_menu` parent_m ON parent_m.id=parent_rm.menu_id
WHERE parent_rm.role_id=r.id AND parent_m.name='app_member'
);
-- HotGo 将运行时鉴权策略持久化在独立 Casbin 表中,迁移时同步补齐,避免必须手动保存一次角色权限。
INSERT INTO `hg_admin_role_casbin` (`p_type`,`v0`,`v1`,`v2`,`v3`,`v4`,`v5`)
SELECT 'p',r.`key`,p.permission,'GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD','','',''
FROM `hg_admin_role` r
JOIN (
SELECT '/urlPool/list' permission UNION ALL
SELECT '/urlPool/view' UNION ALL
SELECT '/urlPool/memberList' UNION ALL
SELECT '/urlPool/summary' UNION ALL
SELECT '/urlPool/edit' UNION ALL
SELECT '/urlPool/status' UNION ALL
SELECT '/urlPool/import' UNION ALL
SELECT '/appMemberLoginLog/list' UNION ALL
SELECT '/appMemberLoginLog/view' UNION ALL
SELECT '/appMemberLoginLog/export'
) p
WHERE r.`status`=1
AND r.`key`<>'super'
AND EXISTS (
SELECT 1
FROM `hg_admin_role_menu` rm
JOIN `hg_admin_menu` m ON m.id=rm.menu_id
WHERE rm.role_id=r.id
AND m.name IN ('app_member_url_pool','app_member_login_log')
)
AND NOT EXISTS (
SELECT 1 FROM `hg_admin_role_casbin` c
WHERE c.`p_type`='p' AND c.`v0`=r.`key` AND c.`v1`=p.permission
AND c.`v2`='GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD'
);
-- 后台会员只允许编辑已有会员,不提供创建会员入口。
UPDATE `hg_admin_menu`
SET `title`='编辑会员', `updated_at`=@now
WHERE `name`='app_member_edit';
-- 地址池仅允许启用或失效,不提供删除能力;清理旧版本可能已写入的菜单和鉴权策略。
DELETE rm
FROM `hg_admin_role_menu` rm
JOIN `hg_admin_menu` m ON m.id=rm.menu_id
WHERE m.name='url_pool_delete';
DELETE FROM `hg_admin_menu` WHERE `name`='url_pool_delete';
DELETE FROM `hg_admin_role_casbin` WHERE `p_type`='p' AND `v1`='/urlPool/delete';
@@ -0,0 +1,28 @@
-- 将现有会员邀请码迁移为唯一的六位数字,保留原码映射供核查和回退。
-- 执行前确认每个租户会员数不超过 900000,且没有占用 100000 起始区间的六位数字码。
-- 本迁移只调整邀请码;团队关系由 parent_id 和 tree 保存,不受影响。
CREATE TABLE IF NOT EXISTS `hg_app_member_invite_code_backup_20261015` (
`tenant_id` bigint unsigned NOT NULL,
`member_id` bigint unsigned NOT NULL,
`old_code` varchar(32) NOT NULL,
`new_code` char(6) NOT NULL,
`migrated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`tenant_id`, `member_id`),
UNIQUE KEY `uk_invite_code_backup_new` (`tenant_id`, `new_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员邀请码六位数字迁移备份';
START TRANSACTION;
INSERT INTO `hg_app_member_invite_code_backup_20261015`
(`tenant_id`, `member_id`, `old_code`, `new_code`)
SELECT `tenant_id`, `id`, `invite_code`,
LPAD(100000 + ROW_NUMBER() OVER (PARTITION BY `tenant_id` ORDER BY `id`) - 1, 6, '0')
FROM `hg_app_member`;
UPDATE `hg_app_member` AS m
JOIN `hg_app_member_invite_code_backup_20261015` AS b
ON b.`tenant_id` = m.`tenant_id` AND b.`member_id` = m.`id`
SET m.`invite_code` = b.`new_code`;
COMMIT;
@@ -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;
@@ -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');
@@ -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 '演示站点%' OR JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.siteName'))='Olons', '悦选', JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.siteName'))),
'$.privacyPolicyUrl', IF(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.privacyPolicyUrl')),'')='', 'https://olns-web.118im.com/admin/#/app/content/privacy_policy', JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.privacyPolicyUrl'))),
'$.tradeBalanceAmount', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.tradeBalanceAmount'),0)<=0,10000,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.tradeBalanceAmount'))) AS UNSIGNED),
'$.tradeWaitSeconds', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.tradeWaitSeconds'),0)<=0,1200,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.tradeWaitSeconds'))) AS UNSIGNED),
'$.dailyTradeLimit', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.dailyTradeLimit'),0)<=0,30,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.dailyTradeLimit'))) AS UNSIGNED),
'$.dailyRewardLimit', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.dailyRewardLimit'),0)<=0,3,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.dailyRewardLimit'))) AS UNSIGNED),
'$.minimumRechargeAmount', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.minimumRechargeAmount'),0)<=0,10000,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.minimumRechargeAmount'))) AS UNSIGNED),
'$.autoOrderDelaySeconds', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderDelaySeconds'),0)<=0,10,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.autoOrderDelaySeconds'))) AS UNSIGNED),
'$.autoOrderMinAmount', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderMinAmount'),0)<=0,100,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.autoOrderMinAmount'))) AS UNSIGNED),
'$.autoOrderMaxAmount', CAST(IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderMaxAmount'),0)<=0,10000000,JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.autoOrderMaxAmount'))) AS UNSIGNED),
'$.commissionRates', IF(JSON_LENGTH(COALESCE(JSON_EXTRACT(`config_json`,'$.commissionRates'),JSON_ARRAY()))<>6,JSON_ARRAY(0.10,0.05,0.03,0.02,0.01,0),JSON_EXTRACT(`config_json`,'$.commissionRates'))
), `updated_at`=NOW()
WHERE `config_group`='portal';
@@ -0,0 +1,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`,'')='';
@@ -0,0 +1,17 @@
-- 手动派单允许后台匹配后直接完成,会员可用余额可因订单扣款变为负数。
SET @wallet_available_check_exists = (
SELECT COUNT(*)
FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'hg_wallet_account'
AND CONSTRAINT_NAME = 'chk_wallet_available_nonnegative'
AND CONSTRAINT_TYPE = 'CHECK'
);
SET @drop_wallet_available_check = IF(
@wallet_available_check_exists > 0,
'ALTER TABLE `hg_wallet_account` DROP CHECK `chk_wallet_available_nonnegative`',
'SELECT 1'
);
PREPARE drop_wallet_available_check_stmt FROM @drop_wallet_available_check;
EXECUTE drop_wallet_available_check_stmt;
DEALLOCATE PREPARE drop_wallet_available_check_stmt;
@@ -0,0 +1,23 @@
-- 手工商城领取数作为固定展示数据保存,页面和接口不再随机计算。
SET @product_claimed_count_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'hg_product'
AND COLUMN_NAME = 'claimed_count'
);
SET @add_product_claimed_count = IF(
@product_claimed_count_exists = 0,
'ALTER TABLE `hg_product` ADD COLUMN `claimed_count` int unsigned NOT NULL DEFAULT 0 COMMENT ''已领取展示数'' AFTER `is_recommended`',
'SELECT 1'
);
PREPARE add_product_claimed_count_stmt FROM @add_product_claimed_count;
EXECUTE add_product_claimed_count_stmt;
DEALLOCATE PREPARE add_product_claimed_count_stmt;
-- 仅为尚未设置展示数的手工商品生成一次,后续只读取数据库中的值。
UPDATE `hg_product`
SET `claimed_count` = FLOOR(100 + RAND() * 401)
WHERE `product_type` = 2
AND `claimed_count` = 0
AND `deleted_at` IS NULL;

Some files were not shown because too many files have changed in this diff Show More