Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,37 @@
|
||||
name: Deploy olons-admin (k3s test cluster)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: '镜像 tag,默认 test'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: deploy-host
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: 确定镜像 tag
|
||||
id: vars
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.image_tag }}"
|
||||
[ -z "$TAG" ] && TAG=test
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 登录镜像仓库
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
|
||||
|
||||
- name: 构建并推送前后端一体镜像
|
||||
run: |
|
||||
docker build -t git.118im.com/im_team/olons-admin:${{ steps.vars.outputs.tag }} .
|
||||
docker push git.118im.com/im_team/olons-admin:${{ steps.vars.outputs.tag }}
|
||||
|
||||
- name: 滚动更新测试集群
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
kubectl rollout restart deployment/olons-admin -n olons-admin-system
|
||||
kubectl rollout status deployment/olons-admin -n olons-admin-system --timeout=180s
|
||||
+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,322 @@
|
||||
# Olons 功能测试执行报告(2026-10-16)
|
||||
|
||||
## 1. 执行说明
|
||||
|
||||
- 测试依据:`Olons全功能测试用例.md`(用户补充后的 548 条版本)
|
||||
- 执行重点:优先从页面真实操作验证,其次执行构建与后端自动化检查
|
||||
- C 端环境:`https://olns-web.118im.com/admin/`
|
||||
- 管理后台环境:`https://olons-admin.118im.com/admin`
|
||||
- 执行策略:第一轮仅做不破坏现有业务数据的页面检查;2026-10-16 用户随后授权使用专用测试数据执行新增、删除、审核、资金及配置变更测试
|
||||
- 状态含义:`通过`、`部分通过`、`失败`、`阻塞`、`未执行`
|
||||
|
||||
## 2. 第一轮结论
|
||||
|
||||
第一轮已完成 C 端主要页面的真实加载、数据回显及主要入口检查,并执行了两套前端生产构建和后台 Go 测试。当前发现 1 个明确的页面功能缺陷、1 个测试工程问题;管理后台逐按钮测试因执行机器锁屏暂时中断,锁屏问题不计入产品缺陷。
|
||||
|
||||
| 类别 | 结果 |
|
||||
|---|---|
|
||||
| C 端生产构建 | 通过 |
|
||||
| 管理后台生产构建 | 通过(需绕过 pnpm 的自动安装检查,直接使用现有依赖构建) |
|
||||
| 后台 Go 全量测试 | 失败:Casbin、TCP、存储器用例依赖运行环境或测试数据,出现 panic |
|
||||
| C 端页面冒烟 | 首页、领取、记录、商城、储入、取出、我的、个人信息、客服中心均可加载 |
|
||||
| 明确产品缺陷 | 1 个:已有收款账户未在“绑定信息”页回显 |
|
||||
| 后台页面逐按钮 | 阻塞:执行机器进入锁屏,待解锁续测 |
|
||||
|
||||
## 3. C 端页面执行记录
|
||||
|
||||
| 页面/关联用例 | 状态 | 实际结果 |
|
||||
|---|---|---|
|
||||
| 首页 `/app/home`;UI-C-HOME-001、UI-C-HOME-003、UI-C-HOME-004 | 部分通过 | 页面正常显示会员信息、余额、领取/储入/取出/商城四个入口、排行、平台介绍、规则说明、公司资质、代理合作及底部导航;本轮未执行会产生业务状态变化的领取动作 |
|
||||
| 领取 `/app/claim`;UI-C-CLAIM-001、UI-C-CLAIM-005 | 通过 | 页面正常显示总资产、昨日佣金、今日佣金、今日领取数;当前会员存在未完成订单,页面正确提示“存在未完成订单”并提供“查看任务”入口 |
|
||||
| 订单 `/app/orders`;UI-C-ORDER-001、UI-C-ORDER-002 | 部分通过 | 全部、待处理、已完成标签及订单卡片正常显示;本轮未对订单执行提交或取消 |
|
||||
| 商城 `/app/mall`;UI-C-MALL-001、UI-C-MALL-002 | 部分通过 | 搜索、推荐、限时区域及商品卡片正常显示;“验收临时商品-商品页已编辑”可见 |
|
||||
| 储入 `/app/recharge`;UI-C-RECHARGE-001、UI-C-RECHARGE-002 | 部分通过 | 等级说明、微信/支付宝/银行卡/人工审核渠道、金额、姓名及提交入口正常显示;未执行真实提交 |
|
||||
| 取出 `/app/profile/withdrawal`;UI-C-WITHDRAW-001、UI-C-WITHDRAW-002 | 部分通过 | 可用余额、全部提现、已绑定账户 `icbc 6655******4555` 及申请提现入口正常显示;未执行真实提现 |
|
||||
| 我的 `/app/profile`;UI-C-PROFILE-001、UI-C-PROFILE-002 | 通过 | 会员 potter、邀请码 100005、余额及个人中心入口正常显示 |
|
||||
| 个人信息 `/app/profile/info`;UI-C-FORM-001、UI-C-FORM-002 | 通过 | 头像、手机号、取出信息、密码管理、支付密码管理入口正常显示 |
|
||||
| 绑定信息 `/app/profile/bank-card`;UI-C-FORM-003、UI-C-FORM-010、C-FIN-039 | 失败 | 该会员在取出页已有银行卡,但绑定信息页仍显示空白新增表单,没有回显本人原始账户信息 |
|
||||
| 客服中心 `/app/support`;UI-C-SUPPORT-001 | 通过 | 客服依依、客服琪琪、客服琳琳均显示;在线/离线状态、工作时间和联系电话正常显示 |
|
||||
|
||||
## 4. 缺陷记录
|
||||
|
||||
### DEF-C-001 已绑定收款账户未在“绑定信息”页回显
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 严重级别 | P1 |
|
||||
| 关联用例 | UI-C-FORM-003、UI-C-FORM-010、UI-C-FORM-011、C-FIN-039 |
|
||||
| 前置条件 | 会员已登录,且已有收款账户 |
|
||||
| 复现步骤 | 1. 打开“我的 → 取出”,确认页面显示 `icbc 6655******4555`;2. 打开“我的 → 个人信息 → 取出信息/绑定信息” |
|
||||
| 预期结果 | 页面显示本人姓名、身份证号、银行名、完整卡号,以及已上传的微信/支付宝收款码;允许按业务规则编辑或继续新增 |
|
||||
| 实际结果 | 页面只显示空白的新增绑定表单,已有账户信息未回显 |
|
||||
| 影响 | 用户无法从个人中心核对已有收款资料,容易重复绑定或误判资料丢失 |
|
||||
| 建议检查 | 检查页面是否调用 `/v1/payment-accounts/details`;确认接口返回账户列表后是否被表单初始化逻辑覆盖;多账户场景需逐条展示并保持收款码对应关系 |
|
||||
|
||||
补充诊断:
|
||||
|
||||
- 已核对线上 C 端静态资源,当前发布包包含 `/v1/payment-accounts/details`、`已绑定的取出信息` 和 `添加银行卡`,不是前端漏发版。
|
||||
- 已只读核对测试库:会员 `potter`(member_id=9、tenant_id=0)确有 1 条未删除的 `icbc` 收款账户,脱敏卡号为 `6655******4555`,密文字段及微信/支付宝附件 ID 均非空。
|
||||
- 本地代码的详情接口会解密敏感字段;只要服务端 finance key 未配置、与写入时密钥不一致,或详情接口返回异常,页面就会保留空数组并显示新增表单。下一步应抓取该登录会话下 `/v1/payment-accounts/details` 的真实响应,重点检查 `OLONS_SERVER_FINANCE_MASTER_KEY` 与该账户写入时使用的密钥是否一致。
|
||||
|
||||
## 5. 构建与自动化检查
|
||||
|
||||
### 5.1 C 端前端构建
|
||||
|
||||
- 目录:`olons_server/web`
|
||||
- 命令:`pnpm run build`
|
||||
- 结果:通过
|
||||
- 构建结果:4186 个模块完成转换,生产包成功生成
|
||||
|
||||
### 5.2 管理后台前端构建
|
||||
|
||||
- 目录:`olons_admin/web`
|
||||
- 常规 `pnpm run build`:被 pnpm 的依赖脚本安全检查阻断,提示 `ERR_PNPM_IGNORED_BUILDS`
|
||||
- 使用已有依赖直接执行 Vite 与 HotGo postBuild:通过
|
||||
- 构建结果:6728 个模块完成转换,`dist/app.config.js` 正常生成
|
||||
- 说明:这是本机 pnpm 依赖脚本策略问题,不是业务代码编译失败;CI 应固定 pnpm 版本及 `onlyBuiltDependencies/approve-builds` 配置,避免发布时临时阻断
|
||||
|
||||
### 5.3 后台 Go 测试
|
||||
|
||||
- 目录:`olons_admin/server`
|
||||
- 命令:`go test ./...`
|
||||
- 结果:失败
|
||||
- 失败 1:`internal/library/casbin.TestNew`,错误为 `casbin model file does not exist:manifest/config/casbin.conf`
|
||||
- 失败 2:`internal/library/network/tcp_test.TestSendMsg`,依赖的 `127.0.0.1:8002` 未启动;连接失败后仍调用空连接的 `Send`,触发空指针 panic
|
||||
- 失败 3:`internal/library/storager.TestMergePartFile`,测试数据/参数无效后仍继续使用空对象,触发空指针 panic
|
||||
- 判断:上述均属于测试工程对当前目录、外部服务或本地文件的隐式依赖,同时缺少失败后的安全返回;本轮 `internal/logic/sys` 等核心业务包测试通过
|
||||
- 建议:Casbin 测试使用可确定的项目根路径或 `testdata`;TCP 测试自建临时监听服务;存储测试自建临时文件;所有前置失败后立即 `t.Fatal` 或返回,避免二次 panic
|
||||
|
||||
### 5.4 线上可用性与未登录鉴权
|
||||
|
||||
| 检查项 | 实际结果 | 结论 |
|
||||
|---|---|---|
|
||||
| `GET https://olns-web.118im.com/api/health` | HTTP 200 | 通过 |
|
||||
| 未登录访问 `GET /v1/me` | HTTP 401 | 通过 |
|
||||
| 未登录访问 `GET /v1/payment-accounts/details` | HTTP 401 | 通过 |
|
||||
| 管理后台入口 | HTTP 200 | 通过 |
|
||||
|
||||
## 6. 阻塞与待执行项
|
||||
|
||||
1. 管理后台 URL 地址池、会员登录日志及其他菜单逐按钮验收:执行机器锁屏,待解锁后续测。
|
||||
2. 新增、编辑、删除、发布/下架、审核、充值、提现、任务提交、客服发消息等写操作:需要专用测试数据并确认允许产生或清理测试记录后执行。
|
||||
3. URL 地址池 Excel 合法、重复、错误行导入:需要使用用例中的 XLS-URL 文件执行,会产生数据库写入,暂未提交。
|
||||
4. 多账户详情、收款码图片一致性、敏感信息展示权限:需在专用测试会员上执行,避免暴露或修改真实会员资料。
|
||||
|
||||
## 7. 下一轮计划
|
||||
|
||||
1. 解锁后从后台菜单真实点击进入 URL 地址池,依次验证查询、重置、新增/编辑弹窗、详情、启用/失效、复制链接、Excel 导入弹窗及无删除按钮。
|
||||
2. 验证会员登录日志查询、分页、详情和导出入口。
|
||||
3. 按菜单顺序完成交易、商品、任务、组织、权限、系统应用、系统设置页面的所有可逆操作。
|
||||
4. 建立专用测试会员、商品、订单和 URL 地址数据,再执行会改变业务状态的 P0/P1 用例。
|
||||
5. 修复 DEF-C-001 后,执行绑定信息与提现账户关联回归。
|
||||
|
||||
## 8. 第二轮后台页面执行记录(解锁后)
|
||||
|
||||
### 8.1 URL 地址池与会员登录日志
|
||||
|
||||
| 页面/关联用例 | 状态 | 实际结果 |
|
||||
|---|---|---|
|
||||
| URL 地址池;UI-A-URL-001、002 | 通过 | 汇总卡、筛选、展开/收起、查询、重置、分页和空状态正常;重置可清空条件 |
|
||||
| URL 地址池新增;UI-A-URL-003 | 部分通过 | 新增弹窗包含地址名称、注册入口 URL、状态、备注及保存/取消;未提交写库 |
|
||||
| URL 地址池详情/启停/复制;UI-A-URL-004、005 | 阻塞 | 当前列表 0 条,无可操作数据 |
|
||||
| URL Excel 导入;UI-A-URL-006 | 部分失败 | 格式说明、关闭入口、`.xlsx/.xlsm`、5000 条限制、重复跳过和错误行说明完整,但弹窗没有“取消”按钮 |
|
||||
| 会员登录日志;UI-A-MLOGIN 系列 | 部分通过 | 会员、方式、状态、域名、IP、时间筛选齐全;查询、重置、展开/收起和导出均可用;当前列表 0 条,详情无法验证 |
|
||||
|
||||
### 8.2 交易管理
|
||||
|
||||
| 页面/关联用例 | 状态 | 实际结果 |
|
||||
|---|---|---|
|
||||
| 订单列表;UI-A-ORDER-001 | 部分失败 | 单号、用户名、手机号、状态、时间筛选可用,但缺少用例要求的“派单方式”筛选 |
|
||||
| 订单详情;UI-A-ORDER-002、A-TRADE-002 | 失败 | 仅显示订单号、会员、商品/任务、等级、金额和规则快照;缺少时间线、资金流水等追溯信息 |
|
||||
| 匹配订单;UI-A-ORDER-003、A-TRADE-003 | 通过 | 弹窗可按商品名和价格筛选;发布商品可见,保时捷跑车、新手体验商品等均能查询;清理筛选和取消正常 |
|
||||
| 充值列表;UI-A-RC-001 | 部分失败 | 汇总卡、列表、通过/驳回入口正常;多条真实记录的“真实姓名”列为空,仅在备注中出现登录名 |
|
||||
| 充值通过/驳回;UI-A-RC-002、003 | 部分通过 | 通过弹窗包含实付金额、外部支付单号和审核说明;驳回弹窗包含原因与说明;取消无副作用,未执行最终审核 |
|
||||
| 提现列表;UI-A-WD-001 | 部分通过 | 金额、手续费、实到、状态和银行卡脱敏正常;当前 potter 记录的支付宝/微信收款码均显示“-”,需结合账户附件继续核对 |
|
||||
| 提现通过/驳回;UI-A-WD-002 | 通过 | 合法状态显示按钮,二次确认文案正确,取消无副作用;未执行最终审核 |
|
||||
| 客服管理;UI-A-CS-001、002 | 部分通过 | 查询、新增/编辑表单、IM 账号和备用联系方式完整;启停操作即时生效,测试后已恢复原状态;未执行删除 |
|
||||
|
||||
### 8.3 商品、任务和权限
|
||||
|
||||
| 页面/关联用例 | 状态 | 实际结果 |
|
||||
|---|---|---|
|
||||
| 商品列表;UI-A-PROD-001、006 | 通过 | 名称/分类筛选、添加、批量删除入口完整;编辑、发布/下架、推荐/不推荐、删除均可见且操作列未截断 |
|
||||
| 商品表单;UI-A-PROD-002 | 部分通过 | 名称、分类、店铺、价格、图片、详情字段完整;未保存写库 |
|
||||
| 任务列表;UI-A-TASK-001 | 失败 | 列表、状态操作和任务基本字段正常,但新增任务弹窗没有“商品”选择字段,与用例和任务关联设计不一致 |
|
||||
| 会员任务审核;UI-A-SUB-001、004 | 失败 | 列表“任务标题”全部为空;详情中的“任务”也为空,并且没有订单信息,不能完成会员—订单—任务追溯 |
|
||||
| 角色菜单权限;UI-A-ROLE-002、003 | 部分通过 | 弹窗明确限制为父角色权限子集;主页、组织管理、系统应用、系统设置等根节点均可勾选;勾选组织管理后“后台用户”自动选中,父子级联正常;未提交保存 |
|
||||
| 系统应用菜单;UI-A-APP-001 | 通过 | 日志管理、附件管理、个人中心入口均存在,个人中心已恢复可见 |
|
||||
|
||||
## 9. 第二轮新增缺陷
|
||||
|
||||
### DEF-A-001 URL 地址池 Excel 导入弹窗缺少取消按钮
|
||||
|
||||
- 严重级别:P2
|
||||
- 关联用例:UI-A-URL-006
|
||||
- 实际结果:弹窗仅提供右上角关闭,没有底部取消按钮。
|
||||
- 影响:与后台其他弹窗交互不一致,触屏或窄屏下关闭入口不够明显。
|
||||
|
||||
### DEF-A-002 订单详情缺少完整业务追溯信息
|
||||
|
||||
- 严重级别:P1
|
||||
- 关联用例:UI-A-ORDER-002、A-TRADE-002、A-TRADE-030
|
||||
- 实际结果:详情只展示基础字段和 JSON 规则快照,没有订单状态时间线、付款/冻结/佣金流水、审核记录。
|
||||
- 影响:客服和财务无法从单一页面核对订单状态与资金变化。
|
||||
- 代码定位:`TradeOrderViewModel` 只在列表模型上补充 `taskId/productId/memberLevelId/ruleSnapshot`,`trade_order.go` 的 `View` 也只组装这些字段;前端详情弹窗没有请求或渲染资金流水、审批动作和状态时间线。
|
||||
|
||||
### DEF-A-003 多个后台交易页面直接显示完整手机号
|
||||
|
||||
> 处理结论(2026-10-16):产品方确认会员列表、详情及相关业务页面允许显示完整手机号,本项改判为“已接受设计”,不再作为缺陷,不实施手机号脱敏改造。
|
||||
|
||||
- 严重级别:P1
|
||||
- 涉及页面:会员列表/详情、订单列表/详情、充值列表、会员任务审核列表/详情
|
||||
- 实际结果:显示 `13311111111`、`15122421111` 等完整手机号。
|
||||
- 预期结果:默认脱敏;只有具备单独敏感信息权限的人员才能查看明文,且需留痕。
|
||||
- 代码定位:订单和任务审核查询直接选择 `hg_app_member.mobile`,前端表格直接绑定 `mobile`;当前链路没有脱敏 DTO,也没有敏感字段权限判断。
|
||||
- 补测结果:会员详情明确提供“授权查看明文”按钮,但未授权前手机号已经完整显示,说明当前授权入口只停留在 UI,未形成真正的数据隔离。
|
||||
|
||||
### DEF-A-004 订单列表缺少派单方式筛选
|
||||
|
||||
- 严重级别:P1
|
||||
- 关联用例:UI-A-ORDER-001、A-TRADE-001
|
||||
- 实际结果:高级筛选只有用户名、手机号、订单状态和下单时间,没有自动/手动派单方式。
|
||||
- 代码定位:前端 `trade/order/model.ts` 的 `schemas` 和后端 `TradeOrderListInp` 均没有派单方式查询字段,属于前后端共同缺项。
|
||||
|
||||
### DEF-A-005 新增任务弹窗缺少商品字段
|
||||
|
||||
- 严重级别:P1
|
||||
- 关联用例:UI-A-TASK-001
|
||||
- 实际结果:弹窗包含任务大厅、等级、佣金、数量、周期、封面和详情,但没有关联商品选择。
|
||||
- 影响:无法按设计建立任务与商品的明确关联。
|
||||
- 代码定位:`hg_task` 对应实体和 `TaskEditInp` 均没有 `product_id`,不是单纯前端漏控件,需要先确认任务是否应直接关联商品,再同步迁移、实体、接口和页面。
|
||||
|
||||
### DEF-A-006 会员任务记录缺少任务标题和订单关联
|
||||
|
||||
- 严重级别:P1
|
||||
- 关联用例:UI-A-SUB-001、UI-A-SUB-004
|
||||
- 实际结果:列表的任务标题为空;详情的任务字段为空;详情未显示订单。
|
||||
- 影响:审核记录无法追溯到具体任务和订单,已结算记录也难以审计。
|
||||
- 数据与代码定位:只读核对最新记录,`task_id` 均为 `0`,但 `trade_order_id` 有值;`task_submission.go` 只按 `s.task_id = t.id` 关联任务,没有通过交易订单/商品兜底,因此标题必然为空。审核逻辑还会再次按 `task_id` 查任务,待审核记录可能直接报“关联任务不存在”。
|
||||
|
||||
### DEF-A-007 充值列表真实姓名缺失
|
||||
|
||||
- 严重级别:P1
|
||||
- 关联用例:UI-A-RC-001、A-TRADE-012
|
||||
- 实际结果:多条真实充值记录的“真实姓名”列为空,备注中仅显示 `姓名:potter` 等登录名。
|
||||
- 影响:人工审核无法直接核对付款人真实姓名,容易造成误审。
|
||||
|
||||
## 10. 第三轮后台页面执行记录
|
||||
|
||||
### 10.1 主页与门户配置
|
||||
|
||||
| 页面/关联用例 | 状态 | 实际结果 |
|
||||
|---|---|---|
|
||||
| 商城统计首页;UI-A-HOME-001、002 | 失败 | 今日默认区间无汇总;切换近 30 日后能显示指标和微信/支付宝/银行卡渠道数据,但数据截止时间为 `2026-10-11 13:46:46`,页面提示延迟约 107 小时,汇总任务明显停滞 |
|
||||
| 网站设置;UI-A-SITE-001、002 | 部分失败 | 表单字段和保存入口完整,但线上已保存配置中隐私网址为空,大量标记“必填”的交易阈值、次数、佣金和自动派单值均为 0;字段“未订单的支付等待时长”文案不通顺,疑为“未支付订单” |
|
||||
| 文本管理;UI-A-CONTENT-001~004 | 通过 | 初始化数据共 12 条,与原型数量一致;列表已隐藏内容标识;新增、编辑、版本、预览、上下线和删除入口完整;本轮仅打开新增、版本和预览并取消,未写库 |
|
||||
| Banner 设置;UI-A-BANNER-001、002 | 失败 | 6 条记录状态与操作入口可见,但列表图片列全部为空;打开编辑后,必填 Banner 图片也未回显,现有演示记录不满足接口对图片必填的约束 |
|
||||
| 手工商城;UI-A-MANUAL-001~003 | 失败 | 4 条记录可查询、编辑和删除,但图片列全部为空;编辑弹窗的必填展示图片未回显;页面没有发布/下架入口,也不显示发布状态 |
|
||||
|
||||
### 10.2 会员管理补测
|
||||
|
||||
| 页面/关联用例 | 状态 | 实际结果 |
|
||||
|---|---|---|
|
||||
| 会员列表;UI-A-MEMBER-001、002 | 部分失败 | 17 条会员可查询,类别与派单模式显示正确,操作列横向滚动条可用;但页面声明“会员资料默认脱敏展示”,列表和详情仍直接显示完整手机号 |
|
||||
| 会员详情敏感信息;UI-A-MEMBER-003 | 失败 | 详情存在“授权查看明文”按钮,但尚未授权时手机号已经是明文,授权入口没有起到敏感数据隔离作用 |
|
||||
| 会员银行卡信息;UI-A-MEMBER-004、C-FIN-039 | 失败 | 查询 `potter` 后打开银行卡信息,只有银行名 `icbc` 回显;开户人、开户电话、卡号、支付宝/微信收款码全部为空,与提现页已存在的账户及数据库记录不一致 |
|
||||
| 会员等级;UI-A-LEVEL-001~003 | 部分通过 | 7 个初始化等级、价格、赠送金额、接单/提现次数、佣金比例、会员数和编辑/启停/删除入口正常;图标列均为空;删除有二次确认并提示仅无会员且未被任务大厅引用时可删,本轮取消未提交 |
|
||||
| 任务大厅;UI-A-HALL-001~003 | 失败 | 6 条初始化记录可编辑/删除,但图标列为空;后端已有发布状态和状态接口,页面却不显示状态,也没有发布/下架操作 |
|
||||
| 后台用户;UI-A-ADMIN-001、002 | 部分通过 | 菜单真实路径为 `/org/user`,新增表单字段完整;空表单提交能阻止保存并提示填写用户名/密码;当前角色范围列表为 0 条,编辑、状态和删除无法继续验证 |
|
||||
| 管理后台登录日志;UI-A-LOG-001~003 | 通过 | 共 30 条记录,查询、重置、展开、导出、详情、单条/批量删除入口完整;本轮未删除日志 |
|
||||
|
||||
### 10.3 本轮操作安全说明
|
||||
|
||||
- 文本新增、Banner 编辑、手工商城编辑仅打开弹窗查看字段后取消,没有保存。
|
||||
- 首页只切换统计周期,没有触发交易、审核或资金操作。
|
||||
- 角色权限上一轮的勾选联动验证未提交;客服状态验证后已恢复原状态。
|
||||
|
||||
## 11. 第三轮新增缺陷
|
||||
|
||||
### DEF-A-008 首页汇总任务停滞,统计数据延迟 107 小时
|
||||
|
||||
- 严重级别:P1
|
||||
- 关联用例:UI-A-HOME-001、UI-A-HOME-002
|
||||
- 实际结果:近 30 日指标可以加载,但数据截至 `2026-10-11 13:46:46`,在 `2026-10-16` 验收时页面提示延迟约 107 小时。
|
||||
- 影响:首页经营指标无法反映当前订单、充值、提现和余额情况,不能作为运营决策依据。
|
||||
- 代码定位:仓库只有 `hg_dashboard_daily_stat` 的读取逻辑和演示数据 seed,未检索到生产汇总表的写入/刷新任务;需确认是否由仓库外 ETL 承担,如无则缺少汇总任务实现。
|
||||
|
||||
### DEF-A-009 网站设置存在不完整的线上配置
|
||||
|
||||
- 严重级别:P1
|
||||
- 关联用例:UI-A-SITE-001、UI-A-SITE-002
|
||||
- 实际结果:隐私网址为空;多个必填业务参数为 0;网站名称仍为“演示站点-3”。
|
||||
- 影响:隐私协议入口、订单等待、交易限制、佣金和自动派单等 C 端行为可能失效或采用异常零值。
|
||||
- 建议:发布前建立配置完整性校验和默认值迁移;加载历史无效配置时给出显著告警,不允许无提示继续运行。
|
||||
|
||||
### DEF-A-010 Banner 与手工商城初始化记录缺少必填图片
|
||||
|
||||
- 严重级别:P1
|
||||
- 关联用例:UI-A-BANNER-001、UI-A-BANNER-002、UI-A-MANUAL-001、UI-A-MANUAL-002
|
||||
- 实际结果:两个列表的图片列均为空,编辑弹窗也没有回显图片;后端过滤逻辑却要求 Banner `imageUrl`、手工商品 `coverUrl` 必填。
|
||||
- 影响:C 端可能出现空白横幅/商品图;旧记录打开后可能无法在不重新上传图片的情况下保存。
|
||||
- 建议:修正初始化数据并补数据校验脚本;列表为空图时显示明确占位和“图片缺失”告警。
|
||||
|
||||
### DEF-A-011 手工商城发布状态功能未接入页面
|
||||
|
||||
- 严重级别:P1
|
||||
- 关联用例:UI-A-MANUAL-001、UI-A-MANUAL-003
|
||||
- 实际结果:后端已有 `ManualProductStatus` 接口和发布状态字段,但列表不显示状态、没有发布/下架按钮;编辑接口还会把商品直接改为已发布。
|
||||
- 影响:运营无法在手工商城页面控制上下架,编辑草稿或已下架商品会绕过明确确认直接发布。
|
||||
- 建议:列表增加状态列和发布/下架操作;编辑仅保存内容,不隐式改变发布状态。
|
||||
|
||||
### DEF-A-012 会员银行卡编辑页未回显已有敏感账户资料
|
||||
|
||||
- 严重级别:P0
|
||||
- 关联用例:UI-A-MEMBER-004、UI-C-FORM-003、C-FIN-039、DEF-C-001
|
||||
- 复现步骤:会员列表查询 `potter`,点击“银行卡信息”。
|
||||
- 实际结果:仅开户银行 `icbc` 回显;开户人、开户电话、银行卡号、支付宝与微信收款码均为空。
|
||||
- 预期结果:经过敏感信息授权后完整回显;未授权时至少显示脱敏值和已有附件占位,且禁止空值覆盖密文。
|
||||
- 影响:后台人员无法核对已有账户,直接保存存在把已有敏感字段和附件清空的高风险;同时解释了 C 端绑定信息页为何显示空表单。
|
||||
- 建议:优先检查 finance key 与历史数据加密密钥是否一致、详情接口的解密错误是否被吞掉;编辑接口必须采用“未传不更新”语义,附件 ID 不得因前端未回显而被置空。
|
||||
|
||||
### DEF-A-013 任务大厅发布状态功能未接入页面
|
||||
|
||||
- 严重级别:P1
|
||||
- 关联用例:UI-A-HALL-001、UI-A-HALL-003
|
||||
- 实际结果:列表不显示发布状态,仅有编辑和删除;草稿、待审、已发布、已下线、已归档等状态只能从演示名称猜测。
|
||||
- 代码定位:后端 `task_hall.go` 已实现 `Status`,前端 API 也定义了 `/taskHall/status`,但 `app-member/task-hall/index.vue` 未导入或调用 `Status`,操作列只有编辑/删除;新增大厅还会直接设为已发布。
|
||||
- 影响:运营无法进行清晰的发布/下架流程,新增内容绕过发布确认直接对 C 端生效。
|
||||
- 建议:增加状态列和发布/下架按钮;新增与编辑只保存内容,发布必须走单独状态接口并保留审计记录。
|
||||
|
||||
## 12. 当前阶段结论与修复优先级
|
||||
|
||||
- 当前累计登记 14 项,其中 13 个有效缺陷(P0 1 个、P1 11 个、P2 1 个)和 1 项已接受设计;C 端收款账户回显缺陷与 P0 后台问题属于同一根因链,合并修复与回归。
|
||||
- 第一优先级:DEF-A-012 / DEF-C-001,先恢复历史收款账户的安全解密与回显,并防止空表单覆盖密文和附件。
|
||||
- 第二优先级:DEF-A-006、DEF-A-008,修复任务审核关联和首页汇总任务,否则审核与运营数据均不可信。
|
||||
- 发布前必须处理:网站配置完整性、Banner/商品图片缺失、手工商城与任务大厅发布状态入口。
|
||||
|
||||
## 13. 缺陷修复实施记录
|
||||
|
||||
| 编号 | 处理结果 | 修复内容 |
|
||||
|---|---|---|
|
||||
| DEF-C-001 / DEF-A-012 | 已修复,待发布回归 | C 端详情接口在历史密钥不可用时回退脱敏字段,不再因单条解密失败让整页为空;后台回显姓名、身份证、手机、账号、数字人民币和双收款码脱敏信息;敏感字段和附件采用“未传不更新”语义,并保留授权查看入口 |
|
||||
| DEF-A-001 | 已修复 | URL 地址 Excel 导入弹窗增加底部取消按钮 |
|
||||
| DEF-A-002 | 已修复 | 订单详情增加状态、创建/更新时间、状态时间线、资金流水和审批动作 |
|
||||
| DEF-A-003 | 已接受设计 | 产品方确认允许展示完整手机号,不修改 |
|
||||
| DEF-A-004 | 已修复 | 订单列表新增自动/手动派单模式筛选,后端同步按规则快照查询 |
|
||||
| DEF-A-005 | 已修复 | `hg_task` 增加 `product_id` 迁移并重新生成 DAO/实体;任务新增/编辑增加商品选择、校验和列表回显 |
|
||||
| DEF-A-006 | 已修复 | 审核列表通过交易订单兜底任务标题并显示订单号/商品;`task_id=0` 时按订单归属校验后允许审核,不再误报任务不存在 |
|
||||
| DEF-A-007 | 已修复 | 充值真实姓名优先取实名资料,缺失时回退默认银行卡开户人脱敏值 |
|
||||
| DEF-A-008 | 已修复 | 增加 `dashboard_daily_summary` 汇总任务并注册到 HotGo 定时任务管理;默认每 10 分钟重算今天和昨天,可在后台启停、改表达式和手动执行 |
|
||||
| DEF-A-009 | 已修复 | 后端强制校验隐私地址、交易余额、等待时长、次数、最低充值和自动派单参数;迁移补齐历史零值并修正文案 |
|
||||
| DEF-A-010 | 已修复 | 增加随服务部署的 Banner/商品占位资源,迁移只补齐历史空图片记录 |
|
||||
| DEF-A-011 | 已修复 | 手工商城显示发布状态并增加发布/下架;新增默认草稿,编辑不再隐式发布 |
|
||||
| DEF-A-013 | 已修复 | 任务大厅显示发布状态并增加发布/下线;新增默认草稿 |
|
||||
|
||||
### 13.1 修复后自动化验证
|
||||
|
||||
- 管理后台:`go test ./internal/logic/sys ./internal/crons ./internal/model/input/sysin` 通过。
|
||||
- 管理后台前端:`npm run build` 生产构建通过,6728 个模块完成转换。
|
||||
- C 端:`go test ./internal/finance ./internal/store` 通过;新增“历史密钥不可用时回退脱敏数据”回归用例。
|
||||
- C 端前端:`npm run build` 通过,4186 个模块完成转换。
|
||||
- 新增迁移已在本地 MySQL 8.4 验证;定时任务记录成功初始化,Banner 和商品空图片数量均变为 0。
|
||||
- 页面回归暂未完成:执行机器再次进入锁屏状态,无法继续通过浏览器点击;服务与前端均已在本地启动,解锁后可直接续测。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,9 @@ func (s *sSysAppMember) View(ctx context.Context, in *sysin.AppMemberViewInp) (r
|
||||
}
|
||||
|
||||
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 +163,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 +210,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, "创建会员失败")
|
||||
}
|
||||
@@ -310,7 +321,12 @@ func (s *sSysAppMember) Category(ctx context.Context, in *sysin.AppMemberCategor
|
||||
|
||||
func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaymentListInp) (list []*sysin.AppMemberPaymentModel, err error) {
|
||||
c := dao.AppMemberPaymentAccount.Columns()
|
||||
err = s.paymentModel(ctx).Fields(c.Id, c.MemberId, c.AccountType, c.BankCode, c.BankName, c.HolderMask, c.MobileMask, c.AccountNoMask, c.QrAttachmentId, c.VerifyStatus, c.IsDefault, c.CreatedAt, c.UpdatedAt).Where(c.MemberId, in.MemberId).OrderDesc(c.IsDefault).OrderDesc(c.Id).Scan(&list)
|
||||
ac := dao.SysAttachment.Columns()
|
||||
err = s.paymentModel(ctx).As("p").
|
||||
LeftJoin(dao.SysAttachment.Table()+" wq", col("wq", ac.Id)+"="+col("p", c.WechatQrAttachmentId)).
|
||||
LeftJoin(dao.SysAttachment.Table()+" aq", col("aq", ac.Id)+"="+col("p", c.AlipayQrAttachmentId)).
|
||||
Fields(col("p", c.Id), col("p", c.MemberId), col("p", c.AccountType), col("p", c.BankCode), col("p", c.BankName), col("p", c.HolderMask), col("p", c.IdentityNoMask), col("p", c.MobileMask), col("p", c.AccountNoMask), col("p", c.DigitalRmbCodeMask), col("p", c.QrAttachmentId), col("p", c.WechatQrAttachmentId), col("p", c.AlipayQrAttachmentId), fmt.Sprintf("COALESCE(%s,'') wechat_qr_url", col("wq", ac.FileUrl)), fmt.Sprintf("COALESCE(%s,'') alipay_qr_url", col("aq", ac.FileUrl)), col("p", c.VerifyStatus), col("p", c.IsDefault), col("p", c.CreatedAt), col("p", c.UpdatedAt)).
|
||||
Where(col("p", c.MemberId), in.MemberId).OrderDesc(col("p", c.IsDefault)).OrderDesc(col("p", c.Id)).Scan(&list)
|
||||
if err != nil {
|
||||
err = gerror.Wrap(err, "获取收款账户失败")
|
||||
}
|
||||
@@ -319,7 +335,23 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym
|
||||
|
||||
func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaymentEditInp) error {
|
||||
c := dao.AppMemberPaymentAccount.Columns()
|
||||
data := g.Map{c.MemberId: in.MemberId, c.AccountType: in.AccountType, c.BankCode: in.BankCode, c.BankName: in.BankName, c.QrAttachmentId: in.QrAttachmentId, c.VerifyStatus: in.VerifyStatus, c.IsDefault: in.IsDefault, c.UpdatedBy: contexts.GetUserId(ctx)}
|
||||
attachmentColumns := dao.SysAttachment.Columns()
|
||||
if in.WechatQrAttachmentId == 0 && in.WechatQrUrl != "" {
|
||||
_ = dao.SysAttachment.Ctx(ctx).Fields(attachmentColumns.Id).Where(attachmentColumns.FileUrl, in.WechatQrUrl).OrderDesc(attachmentColumns.Id).Limit(1).Scan(&in.WechatQrAttachmentId)
|
||||
}
|
||||
if in.AlipayQrAttachmentId == 0 && in.AlipayQrUrl != "" {
|
||||
_ = dao.SysAttachment.Ctx(ctx).Fields(attachmentColumns.Id).Where(attachmentColumns.FileUrl, in.AlipayQrUrl).OrderDesc(attachmentColumns.Id).Limit(1).Scan(&in.AlipayQrAttachmentId)
|
||||
}
|
||||
data := g.Map{c.MemberId: in.MemberId, c.AccountType: in.AccountType, c.BankCode: in.BankCode, c.BankName: in.BankName, c.VerifyStatus: in.VerifyStatus, c.IsDefault: in.IsDefault, c.UpdatedBy: contexts.GetUserId(ctx)}
|
||||
if in.QrAttachmentId > 0 {
|
||||
data[c.QrAttachmentId] = in.QrAttachmentId
|
||||
}
|
||||
if in.WechatQrAttachmentId > 0 {
|
||||
data[c.WechatQrAttachmentId] = in.WechatQrAttachmentId
|
||||
}
|
||||
if in.AlipayQrAttachmentId > 0 {
|
||||
data[c.AlipayQrAttachmentId] = in.AlipayQrAttachmentId
|
||||
}
|
||||
if in.Holder != "" {
|
||||
v, e := encrypt.EncryptField(ctx, in.Holder)
|
||||
if e != nil {
|
||||
@@ -336,6 +368,19 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
||||
data[c.MobileCipher] = v
|
||||
data[c.MobileMask] = encrypt.MaskMobile(in.Mobile)
|
||||
}
|
||||
if in.IdentityNo != "" {
|
||||
v, e := encrypt.EncryptField(ctx, in.IdentityNo)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
h, e := encrypt.HashField(ctx, in.IdentityNo)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
data[c.IdentityNoCipher] = v
|
||||
data[c.IdentityNoHash] = h
|
||||
data[c.IdentityNoMask] = encrypt.MaskAccount(in.IdentityNo)
|
||||
}
|
||||
if in.AccountNo != "" {
|
||||
v, e := encrypt.EncryptField(ctx, in.AccountNo)
|
||||
if e != nil {
|
||||
@@ -349,6 +394,19 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
||||
data[c.AccountNoHash] = h
|
||||
data[c.AccountNoMask] = encrypt.MaskAccount(in.AccountNo)
|
||||
}
|
||||
if in.DigitalRMBCode != "" {
|
||||
v, e := encrypt.EncryptField(ctx, in.DigitalRMBCode)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
h, e := encrypt.HashField(ctx, in.DigitalRMBCode)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
data[c.DigitalRmbCodeCipher] = v
|
||||
data[c.DigitalRmbCodeHash] = h
|
||||
data[c.DigitalRmbCodeMask] = encrypt.MaskAccount(in.DigitalRMBCode)
|
||||
}
|
||||
if in.IsDefault == 1 {
|
||||
_, _ = s.paymentModel(ctx).Where(c.MemberId, in.MemberId).Data(g.Map{c.IsDefault: 2}).Update()
|
||||
}
|
||||
@@ -359,7 +417,7 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
||||
}
|
||||
} else {
|
||||
data[c.CreatedBy] = contexts.GetUserId(ctx)
|
||||
_, err := s.paymentModel(ctx).Fields(c.MemberId, c.AccountType, c.BankCode, c.BankName, c.HolderCipher, c.HolderMask, c.MobileCipher, c.MobileMask, c.AccountNoCipher, c.AccountNoHash, c.AccountNoMask, c.QrAttachmentId, c.VerifyStatus, c.IsDefault, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert()
|
||||
_, err := s.paymentModel(ctx).Fields(c.MemberId, c.AccountType, c.BankCode, c.BankName, c.HolderCipher, c.HolderMask, c.IdentityNoCipher, c.IdentityNoHash, c.IdentityNoMask, c.MobileCipher, c.MobileMask, c.AccountNoCipher, c.AccountNoHash, c.AccountNoMask, c.DigitalRmbCodeCipher, c.DigitalRmbCodeHash, c.DigitalRmbCodeMask, c.QrAttachmentId, c.WechatQrAttachmentId, c.AlipayQrAttachmentId, c.VerifyStatus, c.IsDefault, c.CreatedBy, c.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "创建收款账户失败")
|
||||
}
|
||||
@@ -442,7 +500,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 +512,25 @@ func (s *sSysAppMember) Sensitive(ctx context.Context, in *sysin.AppMemberSensit
|
||||
var row *entity.AppMemberPaymentAccount
|
||||
if err = s.paymentModel(ctx).WherePri(in.TargetId).Scan(&row); err == nil && row != nil {
|
||||
res.Fields["holder"], _ = encrypt.DecryptField(ctx, row.HolderCipher)
|
||||
res.Fields["identityNo"], _ = encrypt.DecryptField(ctx, row.IdentityNoCipher)
|
||||
res.Fields["mobile"], _ = encrypt.DecryptField(ctx, row.MobileCipher)
|
||||
res.Fields["accountNo"], err = encrypt.DecryptField(ctx, row.AccountNoCipher)
|
||||
res.Fields["accountNo"], _ = encrypt.DecryptField(ctx, row.AccountNoCipher)
|
||||
res.Fields["digitalRmbCode"], _ = encrypt.DecryptField(ctx, row.DigitalRmbCodeCipher)
|
||||
if res.Fields["holder"] == "" {
|
||||
res.Fields["holder"] = row.HolderMask
|
||||
}
|
||||
if res.Fields["identityNo"] == "" {
|
||||
res.Fields["identityNo"] = row.IdentityNoMask
|
||||
}
|
||||
if res.Fields["mobile"] == "" {
|
||||
res.Fields["mobile"] = row.MobileMask
|
||||
}
|
||||
if res.Fields["accountNo"] == "" {
|
||||
res.Fields["accountNo"] = row.AccountNoMask
|
||||
}
|
||||
if res.Fields["digitalRmbCode"] == "" {
|
||||
res.Fields["digitalRmbCode"] = row.DigitalRmbCodeMask
|
||||
}
|
||||
}
|
||||
case "address":
|
||||
var row *entity.AppMemberAddress
|
||||
@@ -494,7 +569,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 +644,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()
|
||||
|
||||
@@ -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,10 @@ func (s *sSysRechargeOrder) baseModel(ctx context.Context) *gdb.Model {
|
||||
|
||||
func (s *sSysRechargeOrder) listModel(ctx context.Context) *gdb.Model {
|
||||
rc, mc, pc, wc, cc := dao.RechargeOrder.Columns(), dao.AppMember.Columns(), dao.AppMemberProfile.Columns(), dao.WalletAccount.Columns(), dao.PaymentChannel.Columns()
|
||||
ac := dao.AppMemberPaymentAccount.Columns()
|
||||
realName := fmt.Sprintf("COALESCE(NULLIF(%s,''),(SELECT %s FROM %s pa WHERE pa.%s=%s AND pa.%s IS NULL ORDER BY pa.%s ASC,pa.%s DESC LIMIT 1),'') real_name_mask", col("p", pc.RealNameMask), ac.HolderMask, dao.AppMemberPaymentAccount.Table(), ac.MemberId, col("r", rc.MemberId), ac.DeletedAt, ac.IsDefault, ac.Id)
|
||||
return s.baseModel(ctx).
|
||||
Fields(col("r", rc.Id), col("r", rc.RechargeNo), col("r", rc.MemberId), col("m", mc.Username), col("m", mc.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 +55,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,17 @@ 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))
|
||||
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, 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 +54,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,7 +75,11 @@ 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 = row.AutoDispatch == consts.TradeAutoDispatchDisabled && row.OrderStatus == consts.TradeOrderMatching && row.ApprovalRequestId == 0
|
||||
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
|
||||
}
|
||||
@@ -88,7 +98,27 @@ func (s *sSysTradeOrder) View(ctx context.Context, in *sysin.TradeOrderViewInp)
|
||||
if err = s.model(ctx).WherePri(in.Id).Scan(&order); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res = &sysin.TradeOrderViewModel{TradeOrderListModel: *base, TaskId: order.TaskId, ProductId: order.ProductId, MemberLevelId: order.MemberLevelId, RuleSnapshot: order.RuleSnapshot}
|
||||
res = &sysin.TradeOrderViewModel{TradeOrderListModel: *base, TaskId: order.TaskId, ProductId: order.ProductId, MemberLevelId: order.MemberLevelId, RuleSnapshot: order.RuleSnapshot, UpdatedAt: order.UpdatedAt}
|
||||
res.Timeline = []*sysin.TradeOrderTimelineModel{{Title: "订单创建", Time: order.CreatedAt}}
|
||||
if order.MatchedAt != nil {
|
||||
res.Timeline = append(res.Timeline, &sysin.TradeOrderTimelineModel{Title: "订单匹配", Time: order.MatchedAt})
|
||||
}
|
||||
if order.CompletedAt != nil {
|
||||
res.Timeline = append(res.Timeline, &sysin.TradeOrderTimelineModel{Title: "订单完成", Time: order.CompletedAt})
|
||||
}
|
||||
if order.UnfrozenAt != nil {
|
||||
res.Timeline = append(res.Timeline, &sysin.TradeOrderTimelineModel{Title: "订单解冻", Time: order.UnfrozenAt})
|
||||
}
|
||||
lc := dao.WalletLedger.Columns()
|
||||
if err = handler.Model(dao.WalletLedger.Ctx(ctx), &handler.Option{FilterTenant: true}).Fields(lc.LedgerNo, lc.BizType, lc.Direction, lc.Amount, lc.BeforeAmount, lc.AfterAmount, lc.Remark, lc.OccurredAt).Where(lc.BizNo, order.OrderNo).OrderAsc(lc.Id).Scan(&res.Ledgers); err != nil {
|
||||
return nil, gerror.Wrap(err, "获取订单资金流水失败")
|
||||
}
|
||||
if order.ApprovalRequestId > 0 {
|
||||
ac := dao.ApprovalAction.Columns()
|
||||
if err = handler.Model(dao.ApprovalAction.Ctx(ctx), &handler.Option{FilterTenant: true}).Fields(ac.StepNo, ac.ActorId, ac.Action, ac.Comment, ac.CreatedAt).Where(ac.ApprovalRequestId, order.ApprovalRequestId).OrderAsc(ac.StepNo).OrderAsc(ac.Id).Scan(&res.ApprovalActions); err != nil {
|
||||
return nil, gerror.Wrap(err, "获取订单审批记录失败")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,6 +127,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 +174,22 @@ 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 != "manual" {
|
||||
return gerror.New("自动派单订单由系统匹配,不能人工匹配")
|
||||
}
|
||||
var product *entity.Product
|
||||
pc := dao.Product.Columns()
|
||||
if err := handler.Model(dao.Product.Ctx(ctx), &handler.Option{FilterTenant: true}).
|
||||
@@ -153,6 +200,9 @@ 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("商品当前不在可匹配时间范围内")
|
||||
@@ -187,18 +237,25 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
|
||||
}
|
||||
totalAmount := product.PriceAmount * int64(in.Quantity)
|
||||
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["dispatch_mode"] = "admin_manual_match", "manual"
|
||||
snapshotData["product_id"], snapshotData["product_no"] = product.Id, product.ProductNo
|
||||
snapshotData["unit_price_amount"], snapshotData["quantity"] = product.PriceAmount, in.Quantity
|
||||
snapshotData["member_level_id"], snapshotData["level_version_id"] = levelId, levelVersion.Id
|
||||
snapshotData["commission_rate"], snapshotData["manual_payment_status"] = levelVersion.CommissionRate, "pending"
|
||||
snapshotData["confirmation_required"], snapshotData["deadline_at"] = true, ""
|
||||
snapshot := gjson.New(snapshotData)
|
||||
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.RuleSnapshot: snapshot, oc.Message: "后台已匹配大额订单,等待会员确认付款", oc.OrderStatus: consts.TradeOrderPending,
|
||||
oc.FreezeStatus: consts.TradeFreezeNormal, oc.MatchedAt: now, oc.UpdatedBy: contexts.GetUserId(ctx),
|
||||
}
|
||||
result, err := s.model(ctx).WherePri(order.Id).Where(oc.OrderStatus, consts.TradeOrderMatching).Data(data).Update()
|
||||
@@ -217,6 +274,225 @@ func (s *sSysTradeOrder) Match(ctx context.Context, in *sysin.TradeOrderMatchInp
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
lc := dao.WalletLedger.Columns()
|
||||
_, err := dao.WalletLedger.Ctx(ctx).
|
||||
Fields(lc.LedgerNo, lc.AccountId, lc.MemberId, lc.BizType, lc.BizNo, lc.EntryType, lc.Direction,
|
||||
lc.Amount, lc.BeforeAmount, lc.AfterAmount, lc.Currency, lc.IdempotencyKey, lc.Remark, lc.OperatorId, lc.OccurredAt).
|
||||
Hook(hook.SaveTenant).
|
||||
Data(g.Map{lc.LedgerNo: ledgerNo, lc.AccountId: account.Id, lc.MemberId: order.MemberId,
|
||||
lc.BizType: "task_principal_hold", lc.BizNo: order.OrderNo, lc.EntryType: entryType, lc.Direction: direction,
|
||||
lc.Amount: amount, lc.BeforeAmount: beforeAmount, lc.AfterAmount: afterAmount, lc.Currency: account.Currency,
|
||||
lc.IdempotencyKey: fmt.Sprintf("task:%d:%s", order.Id, suffix), lc.Remark: remark,
|
||||
lc.OperatorId: contexts.GetUserId(ctx), lc.OccurredAt: gtime.Now()}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "记录订单扣款流水失败")
|
||||
}
|
||||
return 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,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 // 更新时间
|
||||
}
|
||||
@@ -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:"更新时间"`
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -79,17 +85,14 @@ type AppMemberEditInp struct {
|
||||
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"`
|
||||
AutoDispatch int `json:"autoDispatch" d:"1"`
|
||||
MemberCategory int `json:"memberCategory"`
|
||||
IsTestAccount int `json:"isTestAccount"`
|
||||
}
|
||||
|
||||
func (in *AppMemberEditInp) Filter(ctx context.Context) error {
|
||||
if in.Id == 0 && strings.TrimSpace(in.Mobile) == "" {
|
||||
return gerror.New("新增会员时手机号不能为空")
|
||||
}
|
||||
if in.Id == 0 && len(in.Password) < 8 {
|
||||
return gerror.New("新增会员密码不能少于8位")
|
||||
if in.Id == 0 {
|
||||
return gerror.New("后台不支持创建会员,请由会员在C端完成注册")
|
||||
}
|
||||
if in.Password != "" && (len(in.Password) < 8 || !containsLetterAndNumber(in.Password)) {
|
||||
return gerror.New("密码至少8位且必须同时包含字母和数字")
|
||||
@@ -161,32 +164,44 @@ type AppMemberPaymentListInp struct {
|
||||
func (in *AppMemberPaymentListInp) Filter(ctx context.Context) error { return nil }
|
||||
|
||||
type AppMemberPaymentModel struct {
|
||||
Id uint64 `json:"id"`
|
||||
MemberId uint64 `json:"memberId"`
|
||||
AccountType int `json:"accountType"`
|
||||
BankCode string `json:"bankCode"`
|
||||
BankName string `json:"bankName"`
|
||||
HolderMask string `json:"holderMask"`
|
||||
MobileMask string `json:"mobileMask"`
|
||||
AccountNoMask string `json:"accountNoMask"`
|
||||
QrAttachmentId uint64 `json:"qrAttachmentId"`
|
||||
VerifyStatus int `json:"verifyStatus"`
|
||||
IsDefault int `json:"isDefault"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt"`
|
||||
Id uint64 `json:"id"`
|
||||
MemberId uint64 `json:"memberId"`
|
||||
AccountType int `json:"accountType"`
|
||||
BankCode string `json:"bankCode"`
|
||||
BankName string `json:"bankName"`
|
||||
HolderMask string `json:"holderMask"`
|
||||
IdentityNoMask string `json:"identityNoMask"`
|
||||
MobileMask string `json:"mobileMask"`
|
||||
AccountNoMask string `json:"accountNoMask"`
|
||||
DigitalRMBCodeMask string `json:"digitalRmbCodeMask"`
|
||||
QrAttachmentId uint64 `json:"qrAttachmentId"`
|
||||
WechatQrAttachmentId uint64 `json:"wechatQrAttachmentId"`
|
||||
AlipayQrAttachmentId uint64 `json:"alipayQrAttachmentId"`
|
||||
WechatQrUrl string `json:"wechatQrUrl"`
|
||||
AlipayQrUrl string `json:"alipayQrUrl"`
|
||||
VerifyStatus int `json:"verifyStatus"`
|
||||
IsDefault int `json:"isDefault"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt"`
|
||||
}
|
||||
type AppMemberPaymentEditInp struct {
|
||||
Id uint64 `json:"id"`
|
||||
MemberId uint64 `json:"memberId" v:"required|min:1#会员ID不能为空|会员ID不能为空"`
|
||||
AccountType int `json:"accountType" v:"required#账户类型不能为空"`
|
||||
BankCode string `json:"bankCode" v:"max-length:32#银行编码不能超过32个字符"`
|
||||
BankName string `json:"bankName" v:"max-length:100#银行名称不能超过100个字符"`
|
||||
Holder string `json:"holder"`
|
||||
Mobile string `json:"mobile" v:"phone-loose#手机号格式不正确"`
|
||||
AccountNo string `json:"accountNo"`
|
||||
QrAttachmentId uint64 `json:"qrAttachmentId"`
|
||||
VerifyStatus int `json:"verifyStatus" d:"1"`
|
||||
IsDefault int `json:"isDefault" d:"2"`
|
||||
Id uint64 `json:"id"`
|
||||
MemberId uint64 `json:"memberId" v:"required|min:1#会员ID不能为空|会员ID不能为空"`
|
||||
AccountType int `json:"accountType" v:"required#账户类型不能为空"`
|
||||
BankCode string `json:"bankCode" v:"max-length:32#银行编码不能超过32个字符"`
|
||||
BankName string `json:"bankName" v:"max-length:100#银行名称不能超过100个字符"`
|
||||
Holder string `json:"holder"`
|
||||
IdentityNo string `json:"identityNo"`
|
||||
Mobile string `json:"mobile" v:"phone-loose#手机号格式不正确"`
|
||||
AccountNo string `json:"accountNo"`
|
||||
DigitalRMBCode string `json:"digitalRmbCode"`
|
||||
QrAttachmentId uint64 `json:"qrAttachmentId"`
|
||||
WechatQrAttachmentId uint64 `json:"wechatQrAttachmentId"`
|
||||
AlipayQrAttachmentId uint64 `json:"alipayQrAttachmentId"`
|
||||
WechatQrUrl string `json:"wechatQrUrl"`
|
||||
AlipayQrUrl string `json:"alipayQrUrl"`
|
||||
VerifyStatus int `json:"verifyStatus" d:"1"`
|
||||
IsDefault int `json:"isDefault" d:"2"`
|
||||
}
|
||||
|
||||
func (in *AppMemberPaymentEditInp) Filter(ctx context.Context) error {
|
||||
@@ -280,7 +295,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 +367,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,69 @@
|
||||
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:8,64#密码不能为空|密码长度为8到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位数字")
|
||||
}
|
||||
if !containsLetterAndNumber(in.Password) {
|
||||
return gerror.New("密码至少8位且必须同时包含字母和数字")
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -123,6 +123,7 @@ func (in *ProductDeleteInp) Filter(ctx context.Context) error { return nil }
|
||||
|
||||
type TaskUpdateFields struct {
|
||||
HallId uint64
|
||||
ProductId uint64
|
||||
Title string
|
||||
Description string
|
||||
DetailHtml string
|
||||
@@ -157,10 +158,11 @@ func (in *TaskListInp) Filter(ctx context.Context) error { return nil }
|
||||
|
||||
type TaskListModel struct {
|
||||
entity.Task
|
||||
HallName string `json:"hallName"`
|
||||
LevelIds []uint64 `json:"levelIds"`
|
||||
LevelNames []string `json:"levelNames"`
|
||||
Referenced bool `json:"referenced"`
|
||||
HallName string `json:"hallName"`
|
||||
ProductName string `json:"productName"`
|
||||
LevelIds []uint64 `json:"levelIds"`
|
||||
LevelNames []string `json:"levelNames"`
|
||||
Referenced bool `json:"referenced"`
|
||||
}
|
||||
type TaskViewInp struct {
|
||||
Id uint64 `json:"id" v:"required|min:1#任务ID不能为空|任务ID不能为空"`
|
||||
@@ -178,8 +180,8 @@ func (in *TaskEditInp) Filter(ctx context.Context) error {
|
||||
if strings.TrimSpace(in.Title) == "" || strings.TrimSpace(in.Description) == "" || strings.TrimSpace(in.CoverUrl) == "" {
|
||||
return gerror.New("任务标题、描述和封面不能为空")
|
||||
}
|
||||
if in.HallId == 0 || len(in.LevelIds) == 0 {
|
||||
return gerror.New("请选择任务大厅和会员等级")
|
||||
if in.HallId == 0 || in.ProductId == 0 || len(in.LevelIds) == 0 {
|
||||
return gerror.New("请选择关联商品、任务大厅和会员等级")
|
||||
}
|
||||
if in.PerMemberLimit == 0 {
|
||||
return gerror.New("单会员任务限制必须大于0")
|
||||
@@ -218,6 +220,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 +233,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,12 @@ type TradeOrderListModel struct {
|
||||
UnfrozenAt *gtime.Time `json:"unfrozenAt"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
ApprovalStatus int `json:"approvalStatus"`
|
||||
PaymentStatus string `json:"paymentStatus"`
|
||||
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 +71,36 @@ func (in *TradeOrderViewInp) Filter(ctx context.Context) error { return nil }
|
||||
|
||||
type TradeOrderViewModel struct {
|
||||
TradeOrderListModel
|
||||
TaskId uint64 `json:"taskId"`
|
||||
ProductId uint64 `json:"productId"`
|
||||
MemberLevelId uint64 `json:"memberLevelId"`
|
||||
RuleSnapshot *gjson.Json `json:"ruleSnapshot"`
|
||||
TaskId uint64 `json:"taskId"`
|
||||
ProductId uint64 `json:"productId"`
|
||||
MemberLevelId uint64 `json:"memberLevelId"`
|
||||
RuleSnapshot *gjson.Json `json:"ruleSnapshot"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt"`
|
||||
Timeline []*TradeOrderTimelineModel `json:"timeline"`
|
||||
Ledgers []*TradeOrderLedgerModel `json:"ledgers"`
|
||||
ApprovalActions []*TradeOrderApprovalActionModel `json:"approvalActions"`
|
||||
}
|
||||
|
||||
type TradeOrderTimelineModel struct {
|
||||
Title string `json:"title"`
|
||||
Time *gtime.Time `json:"time"`
|
||||
}
|
||||
type TradeOrderLedgerModel struct {
|
||||
LedgerNo string `json:"ledgerNo"`
|
||||
BizType string `json:"bizType"`
|
||||
Direction int `json:"direction"`
|
||||
Amount int64 `json:"amount"`
|
||||
BeforeAmount int64 `json:"beforeAmount"`
|
||||
AfterAmount int64 `json:"afterAmount"`
|
||||
Remark string `json:"remark"`
|
||||
OccurredAt *gtime.Time `json:"occurredAt"`
|
||||
}
|
||||
type TradeOrderApprovalActionModel struct {
|
||||
StepNo uint `json:"stepNo"`
|
||||
ActorId uint64 `json:"actorId"`
|
||||
Action int `json:"action"`
|
||||
Comment string `json:"comment"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type TradeOrderApplyInp struct {
|
||||
@@ -110,6 +142,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 +179,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 +259,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 +329,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="Olons 平台横幅">
|
||||
<defs><linearGradient id="g" x1="0" x2="1"><stop stop-color="#1ba8ee"/><stop offset="1" stop-color="#3136dc"/></linearGradient></defs>
|
||||
<rect width="1200" height="360" rx="28" fill="url(#g)"/>
|
||||
<circle cx="1010" cy="70" r="180" fill="#fff" opacity=".10"/><circle cx="1120" cy="320" r="220" fill="#fff" opacity=".08"/>
|
||||
<text x="90" y="160" fill="#fff" font-size="66" font-family="Arial, sans-serif" font-weight="700">OLONS</text>
|
||||
<text x="92" y="230" fill="#fff" font-size="34" font-family="Arial, sans-serif" opacity=".9">精选商品 · 安心服务</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 680 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-label="Olons 商品占位图">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#eaf7ff"/><stop offset="1" stop-color="#dfe3ff"/></linearGradient></defs>
|
||||
<rect width="800" height="800" rx="48" fill="url(#g)"/>
|
||||
<path d="M220 300h360l-28 300H248z" fill="#fff" stroke="#2878e8" stroke-width="22"/>
|
||||
<path d="M310 315c0-118 180-118 180 0" fill="none" stroke="#2878e8" stroke-width="22" stroke-linecap="round"/>
|
||||
<text x="400" y="690" text-anchor="middle" fill="#2878e8" font-size="54" font-family="Arial, sans-serif" font-weight="700">OLONS</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 663 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 '演示站点%', 'Olons', JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.siteName'))),
|
||||
'$.privacyPolicyUrl', IF(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.privacyPolicyUrl')),'')='', 'https://olns-web.118im.com/admin/#/app/content/privacy_policy', JSON_UNQUOTE(JSON_EXTRACT(`config_json`,'$.privacyPolicyUrl'))),
|
||||
'$.tradeBalanceAmount', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.tradeBalanceAmount'),0)<=0,10000,JSON_EXTRACT(`config_json`,'$.tradeBalanceAmount')),
|
||||
'$.tradeWaitSeconds', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.tradeWaitSeconds'),0)<=0,1200,JSON_EXTRACT(`config_json`,'$.tradeWaitSeconds')),
|
||||
'$.dailyTradeLimit', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.dailyTradeLimit'),0)<=0,30,JSON_EXTRACT(`config_json`,'$.dailyTradeLimit')),
|
||||
'$.dailyRewardLimit', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.dailyRewardLimit'),0)<=0,3,JSON_EXTRACT(`config_json`,'$.dailyRewardLimit')),
|
||||
'$.minimumRechargeAmount', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.minimumRechargeAmount'),0)<=0,10000,JSON_EXTRACT(`config_json`,'$.minimumRechargeAmount')),
|
||||
'$.autoOrderDelaySeconds', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderDelaySeconds'),0)<=0,10,JSON_EXTRACT(`config_json`,'$.autoOrderDelaySeconds')),
|
||||
'$.autoOrderMinAmount', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderMinAmount'),0)<=0,100,JSON_EXTRACT(`config_json`,'$.autoOrderMinAmount')),
|
||||
'$.autoOrderMaxAmount', IF(COALESCE(JSON_EXTRACT(`config_json`,'$.autoOrderMaxAmount'),0)<=0,10000000,JSON_EXTRACT(`config_json`,'$.autoOrderMaxAmount')),
|
||||
'$.commissionRates', IF(JSON_LENGTH(COALESCE(JSON_EXTRACT(`config_json`,'$.commissionRates'),JSON_ARRAY()))<>6,JSON_ARRAY(0.10,0.05,0.03,0.02,0.01,0),JSON_EXTRACT(`config_json`,'$.commissionRates'))
|
||||
), `updated_at`=NOW()
|
||||
WHERE `config_group`='portal';
|
||||
@@ -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`,'')='';
|
||||
@@ -197,9 +197,9 @@ WHERE l.id=@level_silver_id AND l.current_version_id=0;
|
||||
SET @demo_password_hash = COALESCE((SELECT password_hash FROM hg_app_member ORDER BY id LIMIT 1),'$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.');
|
||||
|
||||
INSERT INTO hg_app_member
|
||||
(`tenant_id`,`member_no`,`username`,`mobile_cipher`,`mobile_hash`,`mobile_mask`,`password_hash`,`trade_password_hash`,`level_id`,`parent_id`,`level`,`tree`,`invite_code`,`member_status`,`risk_status`,`auto_dispatch`,`credit_score`,`is_test_account`,`last_login_at`,`last_login_ip_cipher`,`last_login_ip_mask`,`last_login_region`,`created_by`,`updated_by`,`deleted_by`,`created_at`,`updated_at`,`deleted_at`)
|
||||
(`tenant_id`,`member_no`,`username`,`mobile`,`password_hash`,`trade_password_hash`,`level_id`,`parent_id`,`level`,`tree`,`invite_code`,`member_status`,`risk_status`,`auto_dispatch`,`credit_score`,`is_test_account`,`last_login_at`,`last_login_ip_cipher`,`last_login_ip_mask`,`last_login_region`,`created_by`,`updated_by`,`deleted_by`,`created_at`,`updated_at`,`deleted_at`)
|
||||
SELECT @tenant_id,CONCAT('DEMO-MB-',LPAD(x.seq,2,'0')),CONCAT('demo_user_',LPAD(x.seq,2,'0')),
|
||||
CONCAT('DEMO_MOBILE_CIPHER_',x.seq),SHA2(CONCAT('1380000100',x.seq),256),CONCAT('138****10',LPAD(x.seq,2,'0')),
|
||||
CONCAT('138000010',LPAD(x.seq,2,'0')),
|
||||
@demo_password_hash,'',x.level_id,0,1,'',CONCAT('DM',LPAD(x.seq,6,'0')),x.member_status,x.risk_status,
|
||||
IF(MOD(x.seq,2)=1,1,2),100-x.seq*5,1,DATE_SUB(@now,INTERVAL x.seq HOUR),'DEMO_IP_CIPHER','127.0.*.*','中国 上海',
|
||||
@admin_id,@admin_id,IF(x.member_status=3,@admin_id,0),DATE_SUB(@now,INTERVAL x.seq*3 DAY),@now,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class DeploymentContract(unittest.TestCase):
|
||||
def test_container_builds_web_and_server_in_one_image(self):
|
||||
dockerfile = (ROOT / "Dockerfile").read_text()
|
||||
self.assertIn("pnpm install --frozen-lockfile", dockerfile)
|
||||
self.assertIn("pnpm run build", dockerfile)
|
||||
self.assertIn("gf pack resource internal/packed/packed.go", dockerfile)
|
||||
self.assertIn("go build", dockerfile)
|
||||
self.assertIn("/app/resource/public/admin", dockerfile)
|
||||
self.assertIn('ENTRYPOINT ["/app/hotgo"]', dockerfile)
|
||||
|
||||
def test_container_excludes_local_and_generated_files(self):
|
||||
dockerignore = (ROOT / ".dockerignore").read_text().splitlines()
|
||||
for ignored in (".git", "web/node_modules", "web/dist", "server/temp"):
|
||||
self.assertIn(ignored, dockerignore)
|
||||
|
||||
def test_manual_workflow_builds_and_rolls_out_test_image(self):
|
||||
workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text()
|
||||
self.assertIn("workflow_dispatch:", workflow)
|
||||
self.assertIn("git.118im.com/im_team/olons-admin", workflow)
|
||||
self.assertIn("deployment/olons-admin", workflow)
|
||||
self.assertIn("-n olons-admin-system", workflow)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,7 @@
|
||||
import { http, jumpExport } from '@/utils/http/axios';
|
||||
|
||||
export const List = (params) =>
|
||||
http.request({ url: '/appMemberLoginLog/list', method: 'get', params });
|
||||
export const View = (params) =>
|
||||
http.request({ url: '/appMemberLoginLog/view', method: 'get', params });
|
||||
export const Export = (params) => jumpExport('/appMemberLoginLog/export', params);
|
||||
@@ -4,6 +4,9 @@ export const TradeOrderList = (params) => http.request({ url: '/tradeOrder/list'
|
||||
export const TradeOrderView = (params) => http.request({ url: '/tradeOrder/view', method: 'get', params });
|
||||
export const TradeOrderMatchProducts = (params) => http.request({ url: '/tradeOrder/matchProducts', method: 'get', params });
|
||||
export const TradeOrderMatch = (params) => http.request({ url: '/tradeOrder/match', method: 'POST', params });
|
||||
export const TradeOrderUpdateAmount = (params) => http.request({ url: '/tradeOrder/updateAmount', method: 'POST', params });
|
||||
export const TradeOrderForcePay = (params) => http.request({ url: '/tradeOrder/forcePay', method: 'POST', params });
|
||||
export const TradeOrderFreeze = (params) => http.request({ url: '/tradeOrder/freeze', method: 'POST', params });
|
||||
export const TradeOrderApply = (params) => http.request({ url: '/tradeOrder/apply', method: 'POST', params });
|
||||
export const TradeOrderExecute = (params) => http.request({ url: '/tradeOrder/execute', method: 'POST', params });
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { http } from '@/utils/http/axios';
|
||||
import type { UploadFileParams } from '@/utils/http/axios/types';
|
||||
|
||||
const request = (url: string, method: 'get' | 'POST', params?: any) =>
|
||||
http.request({ url, method, params });
|
||||
|
||||
export const List = (params) => request('/urlPool/list', 'get', params);
|
||||
export const View = (params) => request('/urlPool/view', 'get', params);
|
||||
export const Edit = (params) => request('/urlPool/edit', 'POST', params);
|
||||
export const Status = (params) => request('/urlPool/status', 'POST', params);
|
||||
export const ImportExcel = (params: UploadFileParams) => http.uploadFile({ url: '/urlPool/import', method: 'post' }, params);
|
||||
export const MemberList = (params) => request('/urlPool/memberList', 'get', params);
|
||||
export const Summary = (params = {}) => request('/urlPool/summary', 'get', params);
|
||||
@@ -6,7 +6,7 @@
|
||||
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="r=>r.id" :action-column="actionColumn" :scroll-x="scrollX" :resize-height-offset="-10000"><template #tableTitle><n-button v-if="hasPermission(['/memberLevel/save'])" type="primary" @click="openEdit()">新增等级</n-button></template></BasicTable>
|
||||
</n-card>
|
||||
<n-modal v-model:show="showEdit" preset="card" :title="form.id?'编辑等级':'新增等级'" class="level-modal">
|
||||
<n-alert type="info" :show-icon="false">金额以元录入,服务端按分保存;佣金以百分比录入,服务端按万分比保存。</n-alert>
|
||||
<n-alert type="info" :show-icon="false">金额以元录入,服务端按分保存;佣金以百分比录入;自动派单会在所设时间范围内随机生成倒计时。</n-alert>
|
||||
<n-form ref="formRef" :model="form" :rules="rules" label-placement="left" label-width="110">
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-form-item-gi label="等级编码" path="levelCode" required><n-input v-model:value="form.levelCode" placeholder="如 VIP1" /></n-form-item-gi>
|
||||
@@ -18,6 +18,8 @@
|
||||
<n-form-item-gi label="每日接单次数"><n-input-number v-model:value="form.dailyOrderLimit" :min="0" /></n-form-item-gi>
|
||||
<n-form-item-gi label="佣金比例(%)"><n-input-number v-model:value="form.commissionPercent" :min="0" :max="100" :precision="2" /></n-form-item-gi>
|
||||
<n-form-item-gi label="每日提现次数"><n-input-number v-model:value="form.dailyWithdrawLimit" :min="0" /></n-form-item-gi>
|
||||
<n-form-item-gi label="最短派单(分钟)" path="autoOrderDelayMinMinutes"><n-input-number v-model:value="form.autoOrderDelayMinMinutes" :min="0" :precision="0" /></n-form-item-gi>
|
||||
<n-form-item-gi label="最长派单(分钟)" path="autoOrderDelayMaxMinutes"><n-input-number v-model:value="form.autoOrderDelayMaxMinutes" :min="0" :precision="0" /></n-form-item-gi>
|
||||
<n-form-item-gi label="排序"><n-input-number v-model:value="form.sort" /></n-form-item-gi>
|
||||
<n-form-item-gi label="备注" :span="2"><n-input v-model:value="form.remark" type="textarea" /></n-form-item-gi>
|
||||
</n-grid>
|
||||
@@ -28,11 +30,11 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed,h,onMounted,reactive,ref } from 'vue';import { useDialog,useMessage } from 'naive-ui';import { BasicTable,TableAction } from '@/components/Table';import { BasicForm,useForm } from '@/components/Form';import { usePermission } from '@/hooks/web/usePermission';import { useDictStore } from '@/store/modules/dict';import { adaTableScrollX } from '@/utils/hotgo';import { List,View,Save,Status,Delete } from '@/api/memberLevel';import { columns,schemas,loadOptions } from './model';
|
||||
const message=useMessage(),dialog=useDialog(),dict=useDictStore();const{hasPermission}=usePermission();const actionRef=ref(),searchFormRef=ref<any>({}),formRef=ref<any>(),showEdit=ref(false),saving=ref(false);const form=reactive<any>({});const rules={levelCode:{required:true,message:'请输入等级编码',trigger:['input','blur']},name:{required:true,message:'请输入等级名称',trigger:['input','blur']}};const [register]=useForm({gridProps:{cols:'1 s:1 m:2 l:3'},labelWidth:80,schemas});const loadDataTable=res=>List({...searchFormRef.value?.formModel,...res});const reloadTable=()=>actionRef.value?.reload();const reset=(o,v)=>{Object.keys(o).forEach(k=>delete o[k]);Object.assign(o,v)};
|
||||
const message=useMessage(),dialog=useDialog(),dict=useDictStore();const{hasPermission}=usePermission();const actionRef=ref(),searchFormRef=ref<any>({}),formRef=ref<any>(),showEdit=ref(false),saving=ref(false);const form=reactive<any>({});const rules={levelCode:{required:true,message:'请输入等级编码',trigger:['input','blur']},name:{required:true,message:'请输入等级名称',trigger:['input','blur']},autoOrderDelayMaxMinutes:{validator:()=>Number(form.autoOrderDelayMaxMinutes)>=Number(form.autoOrderDelayMinMinutes),message:'最长派单时间不能小于最短派单时间',trigger:['input','blur']}};const [register]=useForm({gridProps:{cols:'1 s:1 m:2 l:3'},labelWidth:80,schemas});const loadDataTable=res=>List({...searchFormRef.value?.formModel,...res});const reloadTable=()=>actionRef.value?.reload();const reset=(o,v)=>{Object.keys(o).forEach(k=>delete o[k]);Object.assign(o,v)};
|
||||
const actionColumn=reactive({width:210,title:'操作',key:'action',fixed:'right',render:row=>h(TableAction as any,{style:'button',actions:[{label:'编辑',onClick:()=>openEdit(row),auth:['/memberLevel/view']},{label:row.status===1?'停用':'启用',onClick:()=>toggleStatus(row),auth:['/memberLevel/status']},{label:'删除',type:'error',onClick:()=>remove(row),auth:['/memberLevel/delete']}]})});const scrollX=computed(()=>adaTableScrollX(columns,actionColumn.width));
|
||||
function emptyForm(){return{id:0,levelCode:'',name:'',iconAttachmentId:0,iconUrl:'',upgradeYuan:0,giftYuan:0,dailyOrderLimit:0,commissionPercent:0,dailyWithdrawLimit:0,sort:0,status:1,remark:''}}
|
||||
async function openEdit(row?){reset(form,emptyForm());showEdit.value=true;if(!row)return;const r:any=await View({id:row.id});const v=r.published||r.draft;Object.assign(form,{id:r.level.id,levelCode:r.level.levelCode,name:r.level.name,iconAttachmentId:r.level.iconAttachmentId,iconUrl:r.level.iconUrl,sort:r.level.sort,status:r.level.status,remark:r.level.remark||'',upgradeYuan:(v?.upgradeAmount||0)/100,giftYuan:(v?.giftAmount||0)/100,dailyOrderLimit:v?.dailyOrderLimit||0,commissionPercent:(v?.commissionRate||0)/100,dailyWithdrawLimit:v?.dailyWithdrawLimit||0})}
|
||||
async function saveLevel(){try{await formRef.value?.validate()}catch{return}saving.value=true;try{await Save({...form,upgradeAmount:Math.round(form.upgradeYuan*100),giftAmount:Math.round(form.giftYuan*100),commissionRate:Math.round(form.commissionPercent*100)});message.success('保存成功');showEdit.value=false;reloadTable();dict.loadOptions(['memberLevelOption'])}finally{saving.value=false}}
|
||||
function emptyForm(){return{id:0,levelCode:'',name:'',iconAttachmentId:0,iconUrl:'',upgradeYuan:0,giftYuan:0,dailyOrderLimit:0,commissionPercent:0,dailyWithdrawLimit:0,autoOrderDelayMinMinutes:15,autoOrderDelayMaxMinutes:20,sort:0,status:1,remark:''}}
|
||||
async function openEdit(row?){reset(form,emptyForm());showEdit.value=true;if(!row)return;const r:any=await View({id:row.id});const v=r.published||r.draft;const levelRules=v?.ruleJson||{};Object.assign(form,{id:r.level.id,levelCode:r.level.levelCode,name:r.level.name,iconAttachmentId:r.level.iconAttachmentId,iconUrl:r.level.iconUrl,sort:r.level.sort,status:r.level.status,remark:r.level.remark||'',upgradeYuan:(v?.upgradeAmount||0)/100,giftYuan:(v?.giftAmount||0)/100,dailyOrderLimit:v?.dailyOrderLimit||0,commissionPercent:(v?.commissionRate||0)/100,dailyWithdrawLimit:v?.dailyWithdrawLimit||0,autoOrderDelayMinMinutes:Number(levelRules.auto_order_delay_min_seconds??900)/60,autoOrderDelayMaxMinutes:Number(levelRules.auto_order_delay_max_seconds??1200)/60})}
|
||||
async function saveLevel(){try{await formRef.value?.validate()}catch{return}saving.value=true;try{await Save({...form,upgradeAmount:Math.round(form.upgradeYuan*100),giftAmount:Math.round(form.giftYuan*100),commissionRate:Math.round(form.commissionPercent*100),autoOrderDelayMinSeconds:Math.round(form.autoOrderDelayMinMinutes*60),autoOrderDelayMaxSeconds:Math.round(form.autoOrderDelayMaxMinutes*60)});message.success('保存成功');showEdit.value=false;reloadTable();dict.loadOptions(['memberLevelOption'])}finally{saving.value=false}}
|
||||
async function toggleStatus(row){await Status({id:row.id,status:row.status===1?2:1});message.success('状态已更新');reloadTable()}
|
||||
function remove(row){dialog.warning({title:'删除等级',content:'仅无会员且未被任务大厅引用的等级可删除。',positiveText:'确认删除',negativeText:'取消',onPositiveClick:async()=>{await Delete({id:row.id,reason:'后台删除未使用等级'});message.success('删除成功');reloadTable()}})}
|
||||
onMounted(loadOptions);
|
||||
|
||||
+1072
-201
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,11 @@ import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
const dict = useDictStore();
|
||||
|
||||
export interface MemberRecord {
|
||||
id: number; memberNo: string; username: string; mobileMask: string; realNameMask: string;
|
||||
id: number; memberNo: string; username: string; mobile: string; realNameMask: string;
|
||||
availableAmount: number; frozenAmount: number; parentId: number; parentUsername: string;
|
||||
inviteCode: string; memberStatus: number; riskStatus: number; autoDispatch: number; memberCategory: number;
|
||||
isTestAccount: number; lastLoginAt?: string; lastLoginIpMask: string; lastLoginRegion: string;
|
||||
urlPoolId: number; registerDomain: string; domainStatus: number; domainBoundAt?: string;
|
||||
todayTradeCount: number; createdAt: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +21,8 @@ export const schemas = ref<FormSchema[]>([
|
||||
{ field: 'memberStatus', component: 'NSelect', label: '状态', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberStatus')), clearable: true } },
|
||||
{ field: 'autoDispatch', component: 'NSelect', label: '派单模式', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberDispatchMode')), clearable: true } },
|
||||
{ field: 'memberCategory', component: 'NSelect', label: '类别', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberCategory')), clearable: true } },
|
||||
{ field: 'registerDomain', component: 'NInput', label: '注册域名', componentProps: { placeholder: '请输入域名关键词' } },
|
||||
{ field: 'domainStatus', component: 'NSelect', label: '域名状态', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberDomainStatus')), clearable: true } },
|
||||
{ field: 'createdAt', component: 'NDatePicker', label: '注册时间', componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() } },
|
||||
]);
|
||||
|
||||
@@ -30,7 +33,7 @@ export const columns = [
|
||||
{ title: 'ID', key: 'id', width: 80 },
|
||||
{ title: '会员编号', key: 'memberNo', width: 170 },
|
||||
{ title: '账号', key: 'username', width: 130 },
|
||||
{ title: '手机号', key: 'mobileMask', width: 130 },
|
||||
{ title: '手机号', key: 'mobile', width: 130 },
|
||||
{ title: '绑卡姓名', key: 'realNameMask', width: 100 },
|
||||
{ title: '可用余额', key: 'availableAmount', width: 120, render: (row: MemberRecord) => money(row.availableAmount) },
|
||||
{ title: '冻结余额', key: 'frozenAmount', width: 120, render: (row: MemberRecord) => money(row.frozenAmount) },
|
||||
@@ -42,9 +45,12 @@ export const columns = [
|
||||
{ title: '今日交易', key: 'todayTradeCount', width: 90 },
|
||||
{ title: '最后登录IP', key: 'lastLoginIpMask', width: 130, render: (row: MemberRecord) => row.lastLoginIpMask || '-' },
|
||||
{ title: '最后登录地区', key: 'lastLoginRegion', width: 140, render: (row: MemberRecord) => row.lastLoginRegion || '-' },
|
||||
{ title: '注册域名', key: 'registerDomain', width: 220, render: (row: MemberRecord) => row.registerDomain || '-' },
|
||||
{ title: '域名状态', key: 'domainStatus', width: 110, render: (row: MemberRecord) => tag('appMemberDomainStatus', row.domainStatus) },
|
||||
{ title: '绑定时间', key: 'domainBoundAt', width: 180, render: (row: MemberRecord) => row.domainBoundAt || '-' },
|
||||
{ title: '注册时间', key: 'createdAt', width: 180 },
|
||||
];
|
||||
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['appMemberStatus', 'appMemberRiskStatus', 'appMemberDispatchMode', 'appMemberCategory', 'paymentAccountType', 'walletAdjustType', 'walletDirection', 'sys_switch', 'member_disable_reason', 'wallet_adjust_reason']);
|
||||
dict.loadOptions(['appMemberStatus', 'appMemberRiskStatus', 'appMemberDispatchMode', 'appMemberCategory', 'appMemberDomainStatus', 'paymentAccountType', 'walletAdjustType', 'walletDirection', 'sys_switch', 'member_disable_reason', 'wallet_adjust_reason']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="会员登录日志">记录C端会员每次登录成功或失败,敏感信息均脱敏展示。</n-card></div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="row => row.id" :action-column="actionColumn" :scroll-x="scrollX" :resize-height-offset="-10000">
|
||||
<template #tableTitle><n-button v-if="hasPermission(['/appMemberLoginLog/export'])" type="primary" @click="exportRows">导出</n-button></template>
|
||||
</BasicTable>
|
||||
</n-card>
|
||||
<n-modal v-model:show="showDetail" preset="card" title="登录日志详情" class="detail-modal">
|
||||
<n-spin :show="detailLoading">
|
||||
<n-descriptions v-if="detail.id" bordered :column="2" label-placement="left">
|
||||
<n-descriptions-item label="日志ID">{{ detail.id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="请求ID">{{ detail.reqId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="会员编号">{{ detail.memberNo || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="用户名">{{ detail.username || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="手机号">{{ detail.mobileMask || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录方式">{{ dict.getLabel('appMemberLoginTypeOptions', detail.loginType) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录状态">{{ dict.getLabel('sys_login_status', detail.status) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录时间">{{ detail.loginAt }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录IP">{{ detail.loginIpMask || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录地区">{{ detail.loginRegion || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录域名">{{ detail.loginDomain || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="操作系统">{{ detail.os }}</n-descriptions-item>
|
||||
<n-descriptions-item label="浏览器">{{ detail.browser }}</n-descriptions-item>
|
||||
<n-descriptions-item label="失败原因">{{ detail.errMsg || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="User-Agent" :span="2">{{ detail.userAgent || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import { List, View, Export } from '@/api/appMemberLoginLog';
|
||||
import { columns, schemas, loadOptions } from './model';
|
||||
|
||||
const dict = useDictStore();
|
||||
const { hasPermission } = usePermission();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const showDetail = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detail = reactive<any>({});
|
||||
const [register] = useForm({ gridProps: { cols: '1 s:1 m:2 l:3' }, labelWidth: 80, schemas });
|
||||
const loadDataTable = (res) => List({ ...searchFormRef.value?.formModel, ...res });
|
||||
const reloadTable = () => actionRef.value?.reload();
|
||||
const actionColumn = reactive({ width: 100, title: '操作', key: 'action', fixed: 'right', render: (row) => h(TableAction as any, { style: 'button', actions: [{ label: '详情', onClick: () => openDetail(row), auth: ['/appMemberLoginLog/view'] }] }) });
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
async function openDetail(row) { showDetail.value = true; detailLoading.value = true; try { Object.assign(detail, await View({ id: row.id })); } finally { detailLoading.value = false; } }
|
||||
function exportRows() { Export({ ...searchFormRef.value?.formModel }); }
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.detail-modal) { width: min(900px, 94vw); }
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { computed, h, ref } from 'vue';
|
||||
import { NTag } from 'naive-ui';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{ field: 'keyword', component: 'NInput', label: '会员', componentProps: { placeholder: '编号、用户名或手机号' } },
|
||||
{ field: 'loginType', component: 'NSelect', label: '登录方式', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberLoginTypeOptions')), clearable: true } },
|
||||
{ field: 'status', component: 'NSelect', label: '登录状态', componentProps: { options: computed(() => dict.getOptionUnRef('sys_login_status')), clearable: true } },
|
||||
{ field: 'loginDomain', component: 'NInput', label: '登录域名', componentProps: { placeholder: '请输入登录域名' } },
|
||||
{ field: 'loginIp', component: 'NInput', label: '登录IP', componentProps: { placeholder: '请输入IP脱敏值' } },
|
||||
{ field: 'loginAt', component: 'NDatePicker', label: '登录时间', componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() } },
|
||||
]);
|
||||
|
||||
const tag = (type: string, value: number) =>
|
||||
h(NTag, { type: dict.getType(type, value), bordered: false }, { default: () => dict.getLabel(type, value) || '-' });
|
||||
|
||||
export const columns = [
|
||||
{ title: '日志ID', key: 'id', width: 90 },
|
||||
{ title: '会员编号', key: 'memberNo', width: 170, render: (row) => row.memberNo || '-' },
|
||||
{ title: '用户名', key: 'username', width: 130, render: (row) => row.username || '-' },
|
||||
{ title: '手机号', key: 'mobileMask', width: 130, render: (row) => row.mobileMask || '-' },
|
||||
{ title: '登录方式', key: 'loginType', width: 110, render: (row) => tag('appMemberLoginTypeOptions', row.loginType) },
|
||||
{ title: '状态', key: 'status', width: 90, render: (row) => tag('sys_login_status', row.status) },
|
||||
{ title: '登录时间', key: 'loginAt', width: 180 },
|
||||
{ title: '登录IP', key: 'loginIpMask', width: 130, render: (row) => row.loginIpMask || '-' },
|
||||
{ title: '登录地区', key: 'loginRegion', width: 160, render: (row) => row.loginRegion || '-' },
|
||||
{ title: '登录域名', key: 'loginDomain', width: 220, render: (row) => row.loginDomain || '-' },
|
||||
{ title: '操作系统', key: 'os', width: 140 },
|
||||
{ title: '浏览器', key: 'browser', width: 150 },
|
||||
{ title: '失败原因', key: 'errMsg', width: 180, ellipsis: { tooltip: true }, render: (row) => row.errMsg || '-' },
|
||||
];
|
||||
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['appMemberLoginTypeOptions', 'sys_login_status']);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user