Compare commits
24
Commits
779141e2c6
..
pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35cad4615 | ||
|
|
eead3dd798 | ||
|
|
2483d09c39 | ||
|
|
661486e46e | ||
|
|
3bc96f5325 | ||
|
|
76032d372f | ||
|
|
91fc53b20c | ||
|
|
0ce6bca10e | ||
|
|
e3898b618c | ||
|
|
ac152d2f10 | ||
|
|
c8d32eb823 | ||
|
|
48f304212e | ||
|
|
2b3d855bcc | ||
|
|
c6ec45cccf | ||
|
|
639c46fd88 | ||
|
|
f8b5e4270b | ||
|
|
ca0d115e36 | ||
|
|
a944241a0e | ||
|
|
131168c700 | ||
|
|
ad6d0bbe01 | ||
|
|
aa4799f2b9 | ||
|
|
044242661c | ||
|
|
40bfa4cba0 | ||
|
|
df95801672 |
@@ -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
|
||||
@@ -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
@@ -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{}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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` // 服务日志
|
||||
)
|
||||
|
||||
@@ -106,7 +106,7 @@ var CustomerServiceOnlineOptions = []*model.Option{
|
||||
}
|
||||
var TradeAutoDispatchOptions = []*model.Option{
|
||||
dict.GenSuccessOption(TradeAutoDispatchEnabled, "自动派单"),
|
||||
dict.GenDefaultOption(TradeAutoDispatchDisabled, "手动领取"),
|
||||
dict.GenDefaultOption(TradeAutoDispatchDisabled, "手动派单"),
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 "系统异常"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, "创建商品失败")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 // 更新时间
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type Product struct {
|
||||
CoverUrl any // 封面URL
|
||||
DetailHtml any // 商品详情
|
||||
IsRecommended any // 是否推荐
|
||||
ClaimedCount any // 已领取展示数
|
||||
PublishStatus any // 发布状态
|
||||
StartAt *gtime.Time // 展示开始时间
|
||||
EndAt *gtime.Time // 展示结束时间
|
||||
|
||||
@@ -16,6 +16,7 @@ type Task struct {
|
||||
TenantId any // 租户ID
|
||||
TaskNo any // 任务编号
|
||||
HallId any // 任务大厅ID
|
||||
ProductId any // 关联商品ID
|
||||
Title any // 标题
|
||||
Description any // 描述
|
||||
DetailHtml any // 清洗后的任务详情
|
||||
|
||||
@@ -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 // 删除时间
|
||||
}
|
||||
@@ -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:"更新时间"`
|
||||
}
|
||||
@@ -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:"展示结束时间"`
|
||||
|
||||
@@ -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:"清洗后的任务详情"`
|
||||
|
||||
@@ -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:"删除时间"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) }
|
||||
@@ -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?")
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user