feat: add member URL pool and login tracking

This commit is contained in:
richjohnrambo
2026-10-15 10:41:29 +08:00
parent ca0d115e36
commit f8b5e4270b
46 changed files with 3733 additions and 66 deletions
@@ -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{}
+57
View File
@@ -0,0 +1,57 @@
package urlpool
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type ListReq struct {
g.Meta `path:"/urlPool/list" method:"get" tags:"URL地址池" summary:"获取URL地址池列表"`
sysin.UrlPoolListInp
}
type ListRes struct {
form.PageRes
List []*sysin.UrlPoolListModel `json:"list"`
}
type ViewReq struct {
g.Meta `path:"/urlPool/view" method:"get" tags:"URL地址池" summary:"获取URL地址详情"`
sysin.UrlPoolViewInp
}
type ViewRes struct{ *sysin.UrlPoolViewModel }
type EditReq struct {
g.Meta `path:"/urlPool/edit" method:"post" tags:"URL地址池" summary:"新增或编辑URL地址"`
sysin.UrlPoolEditInp
}
type EditRes struct{}
type StatusReq struct {
g.Meta `path:"/urlPool/status" method:"post" tags:"URL地址池" summary:"切换URL地址状态"`
sysin.UrlPoolStatusInp
}
type StatusRes struct{}
type ImportReq struct {
g.Meta `path:"/urlPool/import" method:"post" mime:"multipart/form-data" tags:"URL地址池" summary:"Excel批量导入URL地址"`
File *ghttp.UploadFile `json:"file" type:"file" v:"required#请选择Excel文件" dc:"Excel文件"`
}
type ImportRes struct{ *sysin.UrlPoolImportModel }
type MemberListReq struct {
g.Meta `path:"/urlPool/memberList" method:"get" tags:"URL地址池" summary:"获取地址绑定会员"`
sysin.UrlPoolMemberListInp
}
type MemberListRes struct {
form.PageRes
List []*sysin.UrlPoolMemberListModel `json:"list"`
}
type SummaryReq struct {
g.Meta `path:"/urlPool/summary" method:"get" tags:"URL地址池" summary:"获取URL地址池汇总"`
sysin.UrlPoolSummaryInp
}
type SummaryRes struct{ *sysin.UrlPoolSummaryModel }
+3
View File
@@ -12,4 +12,7 @@ import (
type IMemberV1 interface {
GetIdByCode(ctx context.Context, req *v1.GetIdByCodeReq) (res *v1.GetIdByCodeRes, err error)
Register(ctx context.Context, req *v1.RegisterReq) (res *v1.RegisterRes, err error)
AccountLogin(ctx context.Context, req *v1.AccountLoginReq) (res *v1.AccountLoginRes, err error)
MobileLogin(ctx context.Context, req *v1.MobileLoginReq) (res *v1.MobileLoginRes, err error)
}
+25
View File
@@ -0,0 +1,25 @@
package v1
import (
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type RegisterReq struct {
g.Meta `path:"/member/register" method:"post" tags:"会员认证" summary:"C端会员注册"`
sysin.AppMemberRegisterInp
}
type RegisterRes struct{ *sysin.AppMemberRegisterModel }
type AccountLoginReq struct {
g.Meta `path:"/member/accountLogin" method:"post" tags:"会员认证" summary:"C端会员账号密码登录"`
sysin.AppMemberAccountLoginInp
}
type AccountLoginRes struct{ *sysin.AppMemberLoginModel }
type MobileLoginReq struct {
g.Meta `path:"/member/mobileLogin" method:"post" tags:"会员认证" summary:"C端会员手机验证码登录"`
sysin.AppMemberMobileLoginInp
}
type MobileLoginRes struct{ *sysin.AppMemberLoginModel }
+4 -4
View File
@@ -3,12 +3,12 @@
// @Copyright Copyright (c) 2023 HotGo CLI
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
//
package consts
// 消息队列
const (
QueueLogTopic = `request_log` // 访问日志
QueueLoginLogTopic = `login_log` // 登录日志
QueueServeLogTopic = `serve_log` // 服务日志
QueueLogTopic = `request_log` // 访问日志
QueueLoginLogTopic = `login_log` // 登录日志
QueueAppMemberLoginLogTopic = `app_member_login_log` // C端会员登录日志
QueueServeLogTopic = `serve_log` // 服务日志
)
+40
View File
@@ -0,0 +1,40 @@
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
const (
UrlPoolStatusEnabled = 1
UrlPoolStatusInvalid = 2
AppMemberLoginTypePassword = 1
AppMemberLoginTypeSms = 2
AppMemberDomainBoundEnabled = 1
AppMemberDomainUnbound = 2
AppMemberDomainBoundInvalid = 3
)
var (
UrlPoolStatusOptions = []*model.Option{
dict.GenSuccessOption(UrlPoolStatusEnabled, "启用"),
dict.GenWarningOption(UrlPoolStatusInvalid, "失效"),
}
AppMemberLoginTypeOptions = []*model.Option{
dict.GenPrimaryOption(AppMemberLoginTypePassword, "账号密码"),
dict.GenInfoOption(AppMemberLoginTypeSms, "手机验证码"),
}
AppMemberDomainStatusOptions = []*model.Option{
dict.GenSuccessOption(AppMemberDomainBoundEnabled, "已绑定"),
dict.GenWarningOption(AppMemberDomainUnbound, "待绑定"),
dict.GenErrorOption(AppMemberDomainBoundInvalid, "域名已失效"),
}
)
func init() {
dict.RegisterEnums("urlPoolStatus", "URL地址状态", UrlPoolStatusOptions)
dict.RegisterEnums("appMemberLoginTypeOptions", "会员登录方式", AppMemberLoginTypeOptions)
dict.RegisterEnums("appMemberDomainStatus", "会员域名绑定状态", AppMemberDomainStatusOptions)
}
@@ -0,0 +1,39 @@
package sys
import (
"context"
"hotgo/api/admin/appmemberloginlog"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var AppMemberLoginLog = cAppMemberLoginLog{}
type cAppMemberLoginLog struct{}
func (c *cAppMemberLoginLog) List(ctx context.Context, req *appmemberloginlog.ListReq) (res *appmemberloginlog.ListRes, err error) {
list, total, err := service.SysAppMemberLoginLog().List(ctx, &req.AppMemberLoginLogListInp)
if err != nil {
return nil, err
}
if list == nil {
list = []*sysin.AppMemberLoginLogListModel{}
}
res = new(appmemberloginlog.ListRes)
res.List = list
res.PageRes.Pack(req, total)
return
}
func (c *cAppMemberLoginLog) View(ctx context.Context, req *appmemberloginlog.ViewReq) (*appmemberloginlog.ViewRes, error) {
data, err := service.SysAppMemberLoginLog().View(ctx, &req.AppMemberLoginLogViewInp)
if err != nil {
return nil, err
}
return &appmemberloginlog.ViewRes{AppMemberLoginLogViewModel: data}, nil
}
func (c *cAppMemberLoginLog) Export(ctx context.Context, req *appmemberloginlog.ExportReq) (*appmemberloginlog.ExportRes, error) {
return &appmemberloginlog.ExportRes{}, service.SysAppMemberLoginLog().Export(ctx, &req.AppMemberLoginLogListInp)
}
@@ -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,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.
@@ -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)
}
@@ -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)
}
+111
View File
@@ -0,0 +1,111 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// UrlPoolDao is the data access object for the table hg_url_pool.
type UrlPoolDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns UrlPoolColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// UrlPoolColumns defines and stores column names for the table hg_url_pool.
type UrlPoolColumns struct {
Id string // 主键
TenantId string // 租户ID
Name string // 地址名称
Scheme string // 协议:http或https
Domain string // 标准化域名,可含非默认端口
DomainHash string // 标准化域名SHA256
BaseUrl string // 注册入口基础地址
Status string // 状态:1启用,2失效
BindCount string // 累计成功绑定次数
LastBoundAt string // 最后绑定时间
Remark string // 备注
CreatedBy string // 创建者ID
UpdatedBy string // 更新者ID
DeletedBy string // 删除者ID
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
DeletedAt string // 删除时间
}
// urlPoolColumns holds the columns for the table hg_url_pool.
var urlPoolColumns = UrlPoolColumns{
Id: "id",
TenantId: "tenant_id",
Name: "name",
Scheme: "scheme",
Domain: "domain",
DomainHash: "domain_hash",
BaseUrl: "base_url",
Status: "status",
BindCount: "bind_count",
LastBoundAt: "last_bound_at",
Remark: "remark",
CreatedBy: "created_by",
UpdatedBy: "updated_by",
DeletedBy: "deleted_by",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
DeletedAt: "deleted_at",
}
// NewUrlPoolDao creates and returns a new DAO object for table data access.
func NewUrlPoolDao(handlers ...gdb.ModelHandler) *UrlPoolDao {
return &UrlPoolDao{
group: "default",
table: "hg_url_pool",
columns: urlPoolColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *UrlPoolDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *UrlPoolDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *UrlPoolDao) Columns() UrlPoolColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *UrlPoolDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *UrlPoolDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *UrlPoolDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// memberUrlBindingDao is the data access object for the table hg_member_url_binding.
// You can define custom methods on it to extend its functionality as needed.
type memberUrlBindingDao struct {
*internal.MemberUrlBindingDao
}
var (
// MemberUrlBinding is a globally accessible object for table hg_member_url_binding operations.
MemberUrlBinding = memberUrlBindingDao{internal.NewMemberUrlBindingDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// urlPoolDao is the data access object for the table hg_url_pool.
// You can define custom methods on it to extend its functionality as needed.
type urlPoolDao struct {
*internal.UrlPoolDao
}
var (
// UrlPool is a globally accessible object for table hg_url_pool operations.
UrlPool = urlPoolDao{internal.NewUrlPoolDao()}
)
// Add your custom methods and functionality below.
+22 -1
View File
@@ -55,10 +55,13 @@ 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.Mobile),
col("p", pc.RealNameMask), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)),
@@ -67,6 +70,9 @@ func (s *sSysAppMember) List(ctx context.Context, in *sysin.AppMemberListInp) (l
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+"%")
@@ -85,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])
}
@@ -97,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(
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, "获取会员详情失败")
@@ -115,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
@@ -0,0 +1,206 @@
package sys
import (
"context"
"fmt"
"strings"
"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("手机号已存在")
}
parentId, level, tree := uint64(0), 1, ""
if in.InviteCode != "" {
var parent *entity.AppMember
if e = s.memberModel(ctx).Where(mc.InviteCode, in.InviteCode).Scan(&parent); e != nil {
return gerror.Wrap(e, "查询邀请人失败")
}
if parent == nil {
return gerror.New("邀请码不存在")
}
parentId = parent.Id
level = 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)
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: strings.ToUpper(grand.S(8)),
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,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 "系统异常"
}
+448
View File
@@ -0,0 +1,448 @@
package sys
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net"
"net/url"
"strings"
"unicode/utf8"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/hgorm/handler"
"hotgo/internal/library/hgorm/hook"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"hotgo/utility/encrypt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/xuri/excelize/v2"
)
type sSysUrlPool struct{}
func NewSysUrlPool() *sSysUrlPool { return &sSysUrlPool{} }
func init() { service.RegisterSysUrlPool(NewSysUrlPool()) }
func (s *sSysUrlPool) Model(ctx context.Context) *gdb.Model {
return handler.Model(dao.UrlPool.Ctx(ctx), &handler.Option{FilterTenant: true})
}
func (s *sSysUrlPool) bindingModel(ctx context.Context) *gdb.Model {
return handler.Model(dao.MemberUrlBinding.Ctx(ctx), &handler.Option{FilterTenant: true})
}
func (s *sSysUrlPool) List(ctx context.Context, in *sysin.UrlPoolListInp) (list []*sysin.UrlPoolListModel, totalCount int, err error) {
pc, bc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns()
mod := s.Model(ctx).As("p")
if in.Name != "" {
mod = mod.WhereLike("p."+pc.Name, "%"+in.Name+"%")
}
if in.Domain != "" {
mod = mod.WhereLike("p."+pc.Domain, "%"+strings.ToLower(strings.TrimSpace(in.Domain))+"%")
}
if in.Status > 0 {
mod = mod.Where("p."+pc.Status, in.Status)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween("p."+pc.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
totalCount, err = mod.Clone().Count()
if err != nil || totalCount == 0 {
return
}
mod = mod.Fields(
"p."+pc.Id, "p."+pc.Name, "p."+pc.Scheme, "p."+pc.Domain, "p."+pc.BaseUrl,
"p."+pc.Status, "p."+pc.BindCount, "p."+pc.LastBoundAt, "p."+pc.Remark,
"p."+pc.CreatedAt, "p."+pc.UpdatedAt,
fmt.Sprintf("(SELECT COUNT(*) FROM %s b WHERE b.%s=p.%s) current_bound_count", dao.MemberUrlBinding.Table(), bc.UrlPoolId, pc.Id),
)
err = mod.Page(in.Page, in.PerPage).OrderAsc("p." + pc.BindCount).OrderAsc("p." + pc.Id).Scan(&list)
return
}
func (s *sSysUrlPool) View(ctx context.Context, in *sysin.UrlPoolViewInp) (res *sysin.UrlPoolViewModel, err error) {
pc, bc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns()
res = new(sysin.UrlPoolViewModel)
err = s.Model(ctx).As("p").Fields(
"p.*",
fmt.Sprintf("(SELECT COUNT(*) FROM %s b WHERE b.%s=p.%s) current_bound_count", dao.MemberUrlBinding.Table(), bc.UrlPoolId, pc.Id),
).Where("p."+pc.Id, in.Id).Scan(res)
if err != nil {
return nil, gerror.Wrap(err, "查询URL地址详情失败")
}
if res.Id == 0 {
return nil, gerror.New("URL地址不存在")
}
return
}
func (s *sSysUrlPool) Edit(ctx context.Context, in *sysin.UrlPoolEditInp) (err error) {
scheme, domain, baseURL, err := normalizeRegisterURL(in.BaseUrl)
if err != nil {
return err
}
hash := domainHash(domain)
pc := dao.UrlPool.Columns()
var before *entity.UrlPool
if in.Id > 0 {
if err = s.Model(ctx).WherePri(in.Id).Scan(&before); err != nil {
return gerror.Wrap(err, "读取URL地址失败")
}
if before == nil {
return gerror.New("URL地址不存在")
}
if before.Domain != domain {
count, e := s.bindingModel(ctx).Where(dao.MemberUrlBinding.Columns().UrlPoolId, in.Id).Count()
if e != nil {
return gerror.Wrap(e, "检查URL地址绑定失败")
}
if count > 0 {
return gerror.New("该地址已绑定会员,不能修改域名")
}
}
}
count, err := s.Model(ctx).Where(pc.DomainHash, hash).WhereNot(pc.Id, in.Id).Count()
if err != nil {
return gerror.Wrap(err, "检查URL地址重复失败")
}
if count > 0 {
return gerror.New("该注册域名已存在")
}
data := g.Map{
pc.Name: in.Name, pc.Scheme: scheme, pc.Domain: domain, pc.DomainHash: hash,
pc.BaseUrl: baseURL, pc.Status: in.Status, pc.Remark: in.Remark,
pc.UpdatedBy: contexts.GetUserId(ctx),
}
if in.Id > 0 {
_, err = s.Model(ctx).Fields(pc.Name, pc.Scheme, pc.Domain, pc.DomainHash, pc.BaseUrl, pc.Status, pc.Remark, pc.UpdatedBy).WherePri(in.Id).Data(data).Hook(hook.SaveTenant).Update()
} else {
data[pc.CreatedBy] = contexts.GetUserId(ctx)
result, e := dao.UrlPool.Ctx(ctx).Fields(pc.Name, pc.Scheme, pc.Domain, pc.DomainHash, pc.BaseUrl, pc.Status, pc.Remark, pc.CreatedBy, pc.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert()
if e != nil {
err = e
} else {
id, _ := result.LastInsertId()
in.Id = uint64(id)
}
}
if err != nil {
return gerror.Wrap(err, "保存URL地址失败")
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "url_pool", Action: "url_pool.edit", TargetType: "url_pool", TargetId: in.Id, Before: before, After: data})
}
func (s *sSysUrlPool) Status(ctx context.Context, in *sysin.UrlPoolStatusInp) error {
pc := dao.UrlPool.Columns()
var before *entity.UrlPool
if err := s.Model(ctx).WherePri(in.Id).Scan(&before); err != nil {
return gerror.Wrap(err, "读取URL地址失败")
}
if before == nil {
return gerror.New("URL地址不存在")
}
if before.Status == in.Status {
return nil
}
if _, err := s.Model(ctx).Fields(pc.Status, pc.UpdatedBy).WherePri(in.Id).Data(g.Map{pc.Status: in.Status, pc.UpdatedBy: contexts.GetUserId(ctx)}).Hook(hook.SaveTenant).Update(); err != nil {
return gerror.Wrap(err, "更新URL地址状态失败")
}
return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "url_pool", Action: "url_pool.status", TargetType: "url_pool", TargetId: in.Id, Before: g.Map{"status": before.Status}, After: g.Map{"status": in.Status}})
}
func (s *sSysUrlPool) Import(ctx context.Context, reader io.Reader) (res *sysin.UrlPoolImportModel, err error) {
book, err := excelize.OpenReader(reader)
if err != nil {
return nil, gerror.New("Excel文件无法解析,请确认文件格式正确")
}
defer book.Close()
sheets := book.GetSheetList()
if len(sheets) == 0 {
return nil, gerror.New("Excel文件中没有工作表")
}
rows, err := book.GetRows(sheets[0])
if err != nil {
return nil, gerror.Wrap(err, "读取Excel内容失败")
}
if len(rows) < 2 {
return nil, gerror.New("Excel中没有可导入的数据")
}
nameColumn, urlColumn, remarkColumn := findUrlPoolImportColumns(rows[0])
if nameColumn < 0 || urlColumn < 0 {
return nil, gerror.New("Excel表头必须包含“地址名称”和“注册入口URL”")
}
type importCandidate struct {
domainHash string
data g.Map
}
res = &sysin.UrlPoolImportModel{Issues: make([]*sysin.UrlPoolImportIssue, 0)}
candidates := make([]importCandidate, 0, len(rows)-1)
fileDomains := make(map[string]struct{})
pc := dao.UrlPool.Columns()
for index := 1; index < len(rows); index++ {
row := rows[index]
name := importCell(row, nameColumn)
rawURL := importCell(row, urlColumn)
remark := importCell(row, remarkColumn)
if name == "" && rawURL == "" && remark == "" {
continue
}
res.TotalRows++
rowNumber := index + 1
if name == "" {
appendUrlPoolImportIssue(res, rowNumber, "地址名称不能为空")
continue
}
if utf8.RuneCountInString(name) > 100 {
appendUrlPoolImportIssue(res, rowNumber, "地址名称不能超过100个字符")
continue
}
if utf8.RuneCountInString(remark) > 500 {
appendUrlPoolImportIssue(res, rowNumber, "备注不能超过500个字符")
continue
}
scheme, domain, baseURL, normalizeErr := normalizeRegisterURL(rawURL)
if normalizeErr != nil {
appendUrlPoolImportIssue(res, rowNumber, normalizeErr.Error())
continue
}
if len(baseURL) > 512 {
appendUrlPoolImportIssue(res, rowNumber, "注册入口URL不能超过512个字符")
continue
}
hash := domainHash(domain)
if _, exists := fileDomains[hash]; exists {
res.DuplicateCount++
continue
}
fileDomains[hash] = struct{}{}
candidates = append(candidates, importCandidate{domainHash: hash, data: g.Map{
pc.Name: name, pc.Scheme: scheme, pc.Domain: domain, pc.DomainHash: hash,
pc.BaseUrl: baseURL, pc.Status: consts.UrlPoolStatusEnabled, pc.Remark: remark,
pc.CreatedBy: contexts.GetUserId(ctx), pc.UpdatedBy: contexts.GetUserId(ctx),
}})
}
if res.TotalRows > 5000 {
return nil, gerror.New("单次最多导入5000条地址")
}
if len(candidates) == 0 {
return res, nil
}
hashes := make([]string, 0, len(candidates))
for _, candidate := range candidates {
hashes = append(hashes, candidate.domainHash)
}
existingValues, err := s.Model(ctx).Fields(pc.DomainHash).WhereIn(pc.DomainHash, hashes).Array()
if err != nil {
return nil, gerror.Wrap(err, "检查重复注册域名失败")
}
existing := make(map[string]struct{}, len(existingValues))
for _, value := range existingValues {
existing[value.String()] = struct{}{}
}
insertRows := make(g.List, 0, len(candidates))
for _, candidate := range candidates {
if _, exists := existing[candidate.domainHash]; exists {
res.DuplicateCount++
continue
}
insertRows = append(insertRows, candidate.data)
}
if len(insertRows) == 0 {
return res, nil
}
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, insertErr := dao.UrlPool.Ctx(ctx).Fields(
pc.Name, pc.Scheme, pc.Domain, pc.DomainHash, pc.BaseUrl, pc.Status, pc.Remark, pc.CreatedBy, pc.UpdatedBy,
).Hook(hook.SaveTenant).Data(insertRows).Insert()
return insertErr
})
if err != nil {
return nil, gerror.Wrap(err, "批量导入URL地址失败")
}
res.SuccessCount = len(insertRows)
if auditErr := service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{
Module: "url_pool", Action: "url_pool.import", TargetType: "url_pool", After: g.Map{
"totalRows": res.TotalRows, "successCount": res.SuccessCount,
"duplicateCount": res.DuplicateCount, "failedCount": res.FailedCount,
},
}); auditErr != nil {
return nil, auditErr
}
return res, nil
}
func findUrlPoolImportColumns(header []string) (nameColumn, urlColumn, remarkColumn int) {
nameColumn, urlColumn, remarkColumn = -1, -1, -1
for index, value := range header {
value = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(value, "\ufeff")))
switch value {
case "地址名称", "名称", "name":
nameColumn = index
case "注册入口url", "注册入口", "入口url", "baseurl", "base_url":
urlColumn = index
case "备注", "remark":
remarkColumn = index
}
}
return
}
func importCell(row []string, column int) string {
if column < 0 || column >= len(row) {
return ""
}
return strings.TrimSpace(row[column])
}
func appendUrlPoolImportIssue(res *sysin.UrlPoolImportModel, row int, message string) {
res.FailedCount++
if len(res.Issues) < 100 {
res.Issues = append(res.Issues, &sysin.UrlPoolImportIssue{Row: row, Message: message})
}
}
func (s *sSysUrlPool) MemberList(ctx context.Context, in *sysin.UrlPoolMemberListInp) (list []*sysin.UrlPoolMemberListModel, totalCount int, err error) {
bc, mc := dao.MemberUrlBinding.Columns(), dao.AppMember.Columns()
mod := s.bindingModel(ctx).As("b").LeftJoin(dao.AppMember.Table()+" m", "m."+mc.Id+"=b."+bc.MemberId).Where("b."+bc.UrlPoolId, in.UrlPoolId)
if in.Keyword != "" {
like := "%" + strings.TrimSpace(in.Keyword) + "%"
mod = mod.Where(fmt.Sprintf("(m.%s LIKE ? OR m.%s LIKE ? OR m.%s LIKE ?)", mc.MemberNo, mc.Username, mc.Mobile), like, like, like)
}
if len(in.BoundAt) == 2 {
mod = mod.WhereBetween("b."+bc.BoundAt, in.BoundAt[0], in.BoundAt[1])
}
totalCount, err = mod.Clone().Count()
if err != nil || totalCount == 0 {
return
}
mod = mod.Fields(
"b."+bc.MemberId, "m."+mc.MemberNo, "m."+mc.Username, "m."+mc.Mobile, "m."+mc.MemberStatus, "b."+bc.BoundAt,
)
err = mod.Page(in.Page, in.PerPage).OrderDesc("b." + bc.Id).Scan(&list)
for _, row := range list {
row.Mobile = encrypt.MaskMobile(row.Mobile)
}
return
}
func (s *sSysUrlPool) Summary(ctx context.Context, in *sysin.UrlPoolSummaryInp) (res *sysin.UrlPoolSummaryModel, err error) {
res = new(sysin.UrlPoolSummaryModel)
pc, bc, mc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns(), dao.AppMember.Columns()
if res.TotalCount, err = s.Model(ctx).Count(); err != nil {
return nil, err
}
res.EnabledCount, err = s.Model(ctx).Where(pc.Status, consts.UrlPoolStatusEnabled).Count()
if err != nil {
return nil, err
}
res.InvalidCount, err = s.Model(ctx).Where(pc.Status, consts.UrlPoolStatusInvalid).Count()
if err != nil {
return nil, err
}
res.BoundMemberCount, err = s.bindingModel(ctx).Count()
if err != nil {
return nil, err
}
res.UnboundMemberCount, err = handler.Model(dao.AppMember.Ctx(ctx), &handler.Option{FilterTenant: true}).As("m").LeftJoin(dao.MemberUrlBinding.Table()+" b", "b."+bc.MemberId+"=m."+mc.Id).WhereNull("b." + bc.Id).Count()
if err != nil {
return nil, err
}
res.InvalidMemberCount, err = s.bindingModel(ctx).As("b").InnerJoin(dao.UrlPool.Table()+" p", "p."+pc.Id+"=b."+bc.UrlPoolId).Where("p."+pc.Status, consts.UrlPoolStatusInvalid).Count()
return
}
func (s *sSysUrlPool) BindOnRegister(ctx context.Context, memberId uint64, origin, host string) (bound bool, domain string, err error) {
domain, normalizedOrigin, err := resolveRequestDomain(origin, host)
if err != nil || domain == "" {
return false, "", nil
}
pc, bc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns()
var pool *entity.UrlPool
err = s.Model(ctx).Where(pc.DomainHash, domainHash(domain)).Where(pc.Status, consts.UrlPoolStatusEnabled).Scan(&pool)
if err != nil {
return false, domain, gerror.Wrap(err, "查询注册域名失败")
}
if pool == nil {
return false, domain, nil
}
var existing *entity.MemberUrlBinding
if err = s.bindingModel(ctx).Where(bc.MemberId, memberId).Scan(&existing); err != nil {
return false, domain, gerror.Wrap(err, "查询会员域名绑定失败")
}
if existing != nil {
return existing.UrlPoolId == pool.Id, existing.DomainSnapshot, nil
}
now := gtime.Now()
if _, err = dao.MemberUrlBinding.Ctx(ctx).Fields(bc.TenantId, bc.MemberId, bc.UrlPoolId, bc.DomainSnapshot, bc.OriginSnapshot, bc.BoundAt).Data(g.Map{bc.TenantId: pool.TenantId, bc.MemberId: memberId, bc.UrlPoolId: pool.Id, bc.DomainSnapshot: domain, bc.OriginSnapshot: normalizedOrigin, bc.BoundAt: now}).Insert(); err != nil {
return false, domain, gerror.Wrap(err, "创建会员域名绑定失败")
}
if _, err = s.Model(ctx).Fields(pc.BindCount, pc.LastBoundAt).WherePri(pool.Id).Data(g.Map{pc.BindCount: gdb.Raw(pc.BindCount + "+1"), pc.LastBoundAt: now}).Update(); err != nil {
return false, domain, gerror.Wrap(err, "更新域名绑定次数失败")
}
return true, domain, nil
}
func normalizeRegisterURL(raw string) (scheme, domain, baseURL string, err error) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", "", "", gerror.New("注册入口URL格式不正确")
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", "", "", gerror.New("注册入口只支持HTTP或HTTPS")
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return "", "", "", gerror.New("注册入口不能包含账号、查询参数或片段")
}
hostname := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(u.Hostname())), ".")
if hostname == "" {
return "", "", "", gerror.New("注册域名不能为空")
}
port := u.Port()
if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
port = ""
}
domain = hostname
if port != "" {
domain = net.JoinHostPort(hostname, port)
}
path := strings.TrimSpace(u.EscapedPath())
if path == "" {
path = "/register"
}
return strings.ToLower(u.Scheme), domain, strings.ToLower(u.Scheme) + "://" + domain + path, nil
}
func resolveRequestDomain(origin, host string) (domain, normalizedOrigin string, err error) {
if strings.TrimSpace(origin) != "" {
u, e := url.Parse(strings.TrimSpace(origin))
if e == nil && u.Scheme != "" && u.Host != "" && u.User == nil {
_, domain, normalizedOrigin, e = normalizeRegisterURL(u.Scheme + "://" + u.Host + "/register")
return domain, strings.TrimSuffix(normalizedOrigin, "/register"), e
}
}
host = strings.TrimSpace(host)
if host == "" {
return "", "", nil
}
_, domain, normalizedOrigin, err = normalizeRegisterURL("https://" + host + "/register")
return domain, strings.TrimSuffix(normalizedOrigin, "/register"), err
}
func domainHash(domain string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(domain))))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,58 @@
package sys
import "testing"
func TestNormalizeRegisterURL(t *testing.T) {
tests := []struct {
name string
raw string
domain string
baseURL string
wantErr bool
}{
{name: "default register path", raw: "https://Example.COM", domain: "example.com", baseURL: "https://example.com/register"},
{name: "keep custom port", raw: "http://example.com:8080/signup", domain: "example.com:8080", baseURL: "http://example.com:8080/signup"},
{name: "drop default port", raw: "https://example.com:443/register", domain: "example.com", baseURL: "https://example.com/register"},
{name: "reject query", raw: "https://example.com/register?code=1", wantErr: true},
{name: "reject non http", raw: "ftp://example.com/register", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, domain, baseURL, err := normalizeRegisterURL(tt.raw)
if tt.wantErr {
if err == nil {
t.Fatalf("normalizeRegisterURL(%q) expected error", tt.raw)
}
return
}
if err != nil {
t.Fatalf("normalizeRegisterURL(%q) returned error: %v", tt.raw, err)
}
if domain != tt.domain || baseURL != tt.baseURL {
t.Fatalf("normalizeRegisterURL(%q) = domain %q, baseURL %q", tt.raw, domain, baseURL)
}
})
}
}
func TestResolveRequestDomainPrefersOrigin(t *testing.T) {
domain, origin, err := resolveRequestDomain("https://REGISTER.example.com", "ignored.example.com")
if err != nil {
t.Fatal(err)
}
if domain != "register.example.com" || origin != "https://register.example.com" {
t.Fatalf("unexpected domain resolution: %q %q", domain, origin)
}
}
func TestFindUrlPoolImportColumns(t *testing.T) {
nameColumn, urlColumn, remarkColumn := findUrlPoolImportColumns([]string{"\ufeff地址名称", "注册入口URL", "备注"})
if nameColumn != 0 || urlColumn != 1 || remarkColumn != 2 {
t.Fatalf("unexpected import columns: name=%d url=%d remark=%d", nameColumn, urlColumn, remarkColumn)
}
nameColumn, urlColumn, remarkColumn = findUrlPoolImportColumns([]string{"NAME", "base_url"})
if nameColumn != 0 || urlColumn != 1 || remarkColumn != -1 {
t.Fatalf("unexpected alias columns: name=%d url=%d remark=%d", nameColumn, urlColumn, remarkColumn)
}
}
@@ -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 // 创建时间
}
@@ -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 // 更新时间
}
+32
View File
@@ -0,0 +1,32 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// UrlPool is the golang structure of table hg_url_pool for DAO operations like Where/Data.
type UrlPool struct {
g.Meta `orm:"table:hg_url_pool, do:true"`
Id any // 主键
TenantId any // 租户ID
Name any // 地址名称
Scheme any // 协议:http或https
Domain any // 标准化域名,可含非默认端口
DomainHash any // 标准化域名SHA256
BaseUrl any // 注册入口基础地址
Status any // 状态:1启用,2失效
BindCount any // 累计成功绑定次数
LastBoundAt *gtime.Time // 最后绑定时间
Remark any // 备注
CreatedBy any // 创建者ID
UpdatedBy any // 更新者ID
DeletedBy any // 删除者ID
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
DeletedAt *gtime.Time // 删除时间
}
@@ -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:"创建时间"`
}
@@ -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:"更新时间"`
}
+30
View File
@@ -0,0 +1,30 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// UrlPool is the golang structure for table url_pool.
type UrlPool struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
TenantId uint64 `json:"tenantId" orm:"tenant_id" description:"租户ID"`
Name string `json:"name" orm:"name" description:"地址名称"`
Scheme string `json:"scheme" orm:"scheme" description:"协议:http或https"`
Domain string `json:"domain" orm:"domain" description:"标准化域名,可含非默认端口"`
DomainHash string `json:"domainHash" orm:"domain_hash" description:"标准化域名SHA256"`
BaseUrl string `json:"baseUrl" orm:"base_url" description:"注册入口基础地址"`
Status int `json:"status" orm:"status" description:"状态:1启用,2失效"`
BindCount uint64 `json:"bindCount" orm:"bind_count" description:"累计成功绑定次数"`
LastBoundAt *gtime.Time `json:"lastBoundAt" orm:"last_bound_at" description:"最后绑定时间"`
Remark string `json:"remark" orm:"remark" description:"备注"`
CreatedBy uint64 `json:"createdBy" orm:"created_by" description:"创建者ID"`
UpdatedBy uint64 `json:"updatedBy" orm:"updated_by" description:"更新者ID"`
DeletedBy uint64 `json:"deletedBy" orm:"deleted_by" description:"删除者ID"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:"删除时间"`
}
@@ -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:"注册时间"`
}
@@ -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 {
@@ -85,11 +91,8 @@ type AppMemberEditInp struct {
}
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位且必须同时包含字母和数字")
@@ -0,0 +1,65 @@
package sysin
import (
"context"
"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:"max-length:32#邀请码不能超过32个字符"`
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 !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
}
@@ -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) }
+88 -35
View File
@@ -12,6 +12,7 @@ import (
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/utility/tree"
"io"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/database/gredis"
@@ -118,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)
@@ -547,41 +570,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 {
@@ -658,6 +684,33 @@ func SysAppMember() ISysAppMember {
func RegisterSysAppMember(i ISysAppMember) { localSysAppMember = i }
func SysUrlPool() ISysUrlPool {
if localSysUrlPool == nil {
panic("implement not found for interface ISysUrlPool, forgot register?")
}
return localSysUrlPool
}
func RegisterSysUrlPool(i ISysUrlPool) { localSysUrlPool = i }
func SysAppMemberLoginLog() ISysAppMemberLoginLog {
if localSysAppMemberLoginLog == nil {
panic("implement not found for interface ISysAppMemberLoginLog, forgot register?")
}
return localSysAppMemberLoginLog
}
func RegisterSysAppMemberLoginLog(i ISysAppMemberLoginLog) { localSysAppMemberLoginLog = i }
func SysAppMemberAuth() ISysAppMemberAuth {
if localSysAppMemberAuth == nil {
panic("implement not found for interface ISysAppMemberAuth, forgot register?")
}
return localSysAppMemberAuth
}
func RegisterSysAppMemberAuth(i ISysAppMemberAuth) { localSysAppMemberAuth = i }
func SysPortal() ISysPortal {
if localSysPortal == nil {
panic("implement not found for interface ISysPortal, forgot register?")
+5 -1
View File
@@ -158,7 +158,11 @@ router:
# 前缀
prefix: "/api"
# 不需要验证登录的路由地址
exceptLogin: [ ]
exceptLogin: [
"/member/register", # C端会员注册
"/member/accountLogin", # C端会员账号密码登录
"/member/mobileLogin", # C端会员手机验证码登录
]
# websocket
websocket:
# 前缀
@@ -0,0 +1,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';
+7
View File
@@ -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);
+13
View File
@@ -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);
+18 -19
View File
@@ -22,13 +22,7 @@
:action-column="actionColumn"
:scroll-x="scrollX"
:resize-height-offset="-10000"
>
<template #tableTitle>
<n-button v-if="hasPermission(['/appMember/edit'])" type="primary" @click="openEdit()"
>新增会员</n-button
>
</template>
</BasicTable>
/>
<div
class="action-column-scrollbar"
aria-label="操作栏横向滚动条"
@@ -42,7 +36,7 @@
<n-modal
v-model:show="showEdit"
preset="card"
:title="editForm.id ? '编辑会员' : '新增会员'"
title="编辑会员"
class="edit-modal"
>
<n-spin :show="editLoading">
@@ -63,12 +57,10 @@
<n-form-item label="用户名称" path="username" required
><n-input v-model:value="editForm.username" placeholder="请输入用户名称"
/></n-form-item>
<n-form-item label="手机号码" path="mobile" :required="!editForm.id"
<n-form-item label="手机号码" path="mobile"
><n-input
v-model:value="editForm.mobile"
:placeholder="
editForm.id ? editForm.currentMobile || '留空不修改' : '请输入手机号码'
"
:placeholder="editForm.currentMobile || '留空不修改'"
/></n-form-item>
<n-form-item label="账户余额"
><n-input :value="money(editForm.availableAmount)" disabled
@@ -92,12 +84,12 @@
v-model:value="editForm.riskStatus"
:options="dict.getOptionUnRef('appMemberRiskStatus')"
/></n-form-item>
<n-form-item label="登录密码" path="password" :required="!editForm.id"
<n-form-item label="登录密码" path="password"
><n-input
v-model:value="editForm.password"
type="password"
show-password-on="click"
:placeholder="editForm.id ? '留空不修改' : '至少8位,含字母和数字'"
placeholder="留空不修改"
/></n-form-item>
<n-form-item label="交易密码" path="tradePassword"
><n-input
@@ -151,6 +143,9 @@
<n-descriptions-item label="收货地址">{{ detail.addressCount }} 个</n-descriptions-item>
<n-descriptions-item label="下级团队">{{ detail.teamCount }} 人</n-descriptions-item>
<n-descriptions-item label="注册时间">{{ detail.createdAt }}</n-descriptions-item>
<n-descriptions-item label="注册域名">{{ detail.registerDomain || '待绑定' }}</n-descriptions-item>
<n-descriptions-item label="域名状态">{{ dict.getLabel('appMemberDomainStatus', detail.domainStatus) }}</n-descriptions-item>
<n-descriptions-item label="绑定时间">{{ detail.domainBoundAt || '-' }}</n-descriptions-item>
</n-descriptions>
<n-divider />
<n-space>
@@ -469,14 +464,13 @@
{ min: 3, max: 64, message: '用户名长度为3到64个字符', trigger: ['input', 'blur'] },
],
mobile: {
validator: (_rule, value) =>
editForm.id || String(value || '').trim() ? true : new Error('请输入手机号码'),
validator: () => true,
trigger: ['input', 'blur'],
},
password: {
validator: (_rule, value) => {
const text = String(value || '');
if (editForm.id && !text) return true;
if (!text) return true;
if (text.length < 8 || !/[A-Za-z]/.test(text) || !/\d/.test(text))
return new Error('密码至少8位且必须同时包含字母和数字');
return true;
@@ -511,6 +505,12 @@
h(TableAction as any, {
style: 'button',
actions: [
{
label: '详情',
type: 'default',
onClick: () => openDetail(record.id),
auth: ['/appMember/view'],
},
{
label: '编辑',
type: 'primary',
@@ -602,7 +602,7 @@
actionScrollLeft = externalMax > 0 ? (el.scrollLeft * actionMax) / externalMax : 0;
syncActionViewports(actionScrollLeft);
}
async function openEdit(row?: MemberRecord) {
async function openEdit(row: MemberRecord) {
reset(editForm, {
id: row?.id || 0,
username: row?.username || '',
@@ -621,7 +621,6 @@
frozenAmount: row?.frozenAmount || 0,
});
showEdit.value = true;
if (!row?.id) return;
editLoading.value = true;
try {
const data: any = await View({ id: row.id });
+7 -1
View File
@@ -11,6 +11,7 @@ export interface MemberRecord {
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() } },
]);
@@ -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']);
}
+194
View File
@@ -0,0 +1,194 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="URL地址池">维护会员注册入口;会员仅在C端注册时自动绑定域名。</n-card>
</div>
<n-grid :cols="6" :x-gap="12" class="summary-grid">
<n-gi v-for="item in summaryItems" :key="item.label">
<n-card size="small"><n-statistic :label="item.label" :value="item.value" /></n-card>
</n-gi>
</n-grid>
<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-space>
<n-button v-if="hasPermission(['/urlPool/edit'])" type="primary" @click="openEdit()">新增地址</n-button>
<n-button v-if="hasPermission(['/urlPool/import'])" @click="openImport">Excel批量导入</n-button>
</n-space>
</template>
</BasicTable>
</n-card>
<n-modal v-model:show="showEdit" preset="card" :title="editForm.id ? '编辑地址' : '新增地址'" class="edit-modal">
<n-form ref="editFormRef" :model="editForm" :rules="editRules" label-placement="left" label-width="110">
<n-form-item label="地址名称" path="name"><n-input v-model:value="editForm.name" placeholder="例如:推广域名A" /></n-form-item>
<n-form-item label="注册入口URL" path="baseUrl"><n-input v-model:value="editForm.baseUrl" placeholder="https://register.example.com/register" /></n-form-item>
<n-form-item label="状态" path="status"><n-select v-model:value="editForm.status" :options="dict.getOptionUnRef('urlPoolStatus')" /></n-form-item>
<n-form-item label="备注"><n-input v-model:value="editForm.remark" type="textarea" maxlength="500" show-count /></n-form-item>
</n-form>
<template #action><n-space justify="end"><n-button @click="showEdit=false">取消</n-button><n-button type="primary" :loading="saving" @click="saveAddress">保存</n-button></n-space></template>
</n-modal>
<n-modal v-model:show="showImport" preset="card" title="Excel批量导入地址" class="import-modal">
<n-alert type="info" title="Excel格式说明" class="import-tip">
第一行为表头,必须包含“地址名称”和“注册入口URL”,可选“备注”。所有成功导入的地址统一为启用状态;单次最多5000条,仅支持 .xlsx 或 .xlsm 文件。
</n-alert>
<n-upload
directory-dnd
name="file"
:max="1"
:disabled="importing"
:custom-request="handleImport"
accept=".xlsx,.xlsm,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel.sheet.macroEnabled.12"
>
<n-upload-dragger>
<n-icon size="46" :depth="3"><CloudUploadOutlined /></n-icon>
<n-text class="import-title">点击或拖动Excel文件到此处</n-text>
<n-p depth="3">重复域名将自动跳过,格式错误的行会在导入结果中列出。</n-p>
</n-upload-dragger>
</n-upload>
<n-spin :show="importing">
<n-result v-if="importResult" :status="importResult.failedCount ? 'warning' : 'success'" title="导入完成">
<template #description>
共读取 {{ importResult.totalRows }} 条,成功 {{ importResult.successCount }} 条,重复跳过 {{ importResult.duplicateCount }} 条,失败 {{ importResult.failedCount }} 条。
</template>
<template v-if="importResult.issues?.length" #footer>
<n-list bordered class="import-issues">
<n-list-item v-for="issue in importResult.issues" :key="`${issue.row}-${issue.message}`">
第 {{ issue.row }} 行:{{ issue.message }}
</n-list-item>
</n-list>
</template>
</n-result>
</n-spin>
</n-modal>
<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="地址名称">{{ detail.name }}</n-descriptions-item>
<n-descriptions-item label="注册域名">{{ detail.domain }}</n-descriptions-item>
<n-descriptions-item label="注册入口URL" :span="2">{{ detail.baseUrl }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ dict.getLabel('urlPoolStatus', detail.status) }}</n-descriptions-item>
<n-descriptions-item label="累计绑定">{{ detail.bindCount }}</n-descriptions-item>
<n-descriptions-item label="当前绑定">{{ detail.currentBoundCount }}</n-descriptions-item>
<n-descriptions-item label="最后绑定">{{ detail.lastBoundAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ detail.createdAt }}</n-descriptions-item>
<n-descriptions-item label="备注">{{ detail.remark || '-' }}</n-descriptions-item>
</n-descriptions>
<n-divider title-placement="left">绑定会员</n-divider>
<BasicTable v-if="detail.id" ref="memberTableRef" :columns="memberColumns" :request="loadMemberData" :row-key="row => row.memberId" :scroll-x="800" :resize-height-offset="-10000" />
</n-spin>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { UploadCustomRequestOptions, useMessage } from 'naive-ui';
import { CloudUploadOutlined } from '@vicons/antd';
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, Edit, Status, ImportExcel, MemberList, Summary } from '@/api/urlPool';
import type { UploadFileParams } from '@/utils/http/axios/types';
import { columns, memberColumns, schemas, loadOptions } from './model';
const message = useMessage();
const dict = useDictStore();
const { hasPermission } = usePermission();
const actionRef = ref();
const memberTableRef = ref();
const searchFormRef = ref<any>({});
const editFormRef = ref<any>();
const showEdit = ref(false);
const showDetail = ref(false);
const showImport = ref(false);
const saving = ref(false);
const importing = ref(false);
const importResult = ref<any>(null);
const detailLoading = ref(false);
const summary = reactive<any>({ totalCount: 0, enabledCount: 0, invalidCount: 0, boundMemberCount: 0, unboundMemberCount: 0, invalidMemberCount: 0 });
const detail = reactive<any>({});
const editForm = reactive<any>({ id: 0, name: '', baseUrl: '', status: 1, remark: '' });
const editRules = {
name: { required: true, message: '请输入地址名称', trigger: ['input', 'blur'] },
baseUrl: { required: true, message: '请输入注册入口URL', trigger: ['input', 'blur'] },
status: { required: true, type: 'number', message: '请选择状态', trigger: ['change'] },
};
const summaryItems = computed(() => [
{ label: 'URL总数', value: summary.totalCount },
{ label: '启用URL', value: summary.enabledCount },
{ label: '失效URL', value: summary.invalidCount },
{ label: '已绑定会员', value: summary.boundMemberCount },
{ label: '待绑定会员', value: summary.unboundMemberCount },
{ label: '失效域名会员', value: summary.invalidMemberCount },
]);
const [register] = useForm({ gridProps: { cols: '1 s:1 m:2 l:4' }, labelWidth: 80, schemas });
const loadDataTable = (res) => List({ ...searchFormRef.value?.formModel, ...res });
const loadMemberData = (res) => MemberList({ urlPoolId: detail.id, ...res });
const reloadTable = () => { actionRef.value?.reload(); loadSummary(); };
const reset = (target, value) => { Object.keys(target).forEach(key => delete target[key]); Object.assign(target, value); };
const actionColumn = reactive({
width: 290,
title: '操作',
key: 'action',
fixed: 'right',
render: (row) => h(TableAction as any, { style: 'button', actions: [
{ label: '详情', onClick: () => openDetail(row), auth: ['/urlPool/view'] },
{ label: '编辑', onClick: () => openEdit(row), auth: ['/urlPool/edit'] },
{ label: row.status === 1 ? '设为失效' : '启用', type: row.status === 1 ? 'warning' : 'success', onClick: () => toggleStatus(row), auth: ['/urlPool/status'] },
{ label: '复制链接', onClick: () => copyLink(row) },
] }),
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
async function loadSummary() { Object.assign(summary, await Summary()); }
async function openEdit(row?) {
reset(editForm, { id: 0, name: '', baseUrl: '', status: 1, remark: '' });
if (row) Object.assign(editForm, await View({ id: row.id }));
showEdit.value = true;
}
async function saveAddress() {
try { await editFormRef.value?.validate(); } catch { return; }
saving.value = true;
try { await Edit(editForm); message.success('保存成功'); showEdit.value = false; reloadTable(); } finally { saving.value = false; }
}
async function openDetail(row) {
showDetail.value = true; detailLoading.value = true;
try { reset(detail, await View({ id: row.id })); setTimeout(() => memberTableRef.value?.reload(), 0); } finally { detailLoading.value = false; }
}
async function toggleStatus(row) { await Status({ id: row.id, status: row.status === 1 ? 2 : 1 }); message.success('状态已更新'); reloadTable(); }
async function copyLink(row) { await navigator.clipboard.writeText(row.baseUrl); message.success('注册链接已复制'); }
function openImport() { importResult.value = null; showImport.value = true; }
async function handleImport(options: UploadCustomRequestOptions) {
if (!options.file.file) return;
importing.value = true;
try {
const params: UploadFileParams = { file: options.file.file as File };
importResult.value = await ImportExcel(params);
options.onFinish();
message.success(`成功导入 ${importResult.value.successCount} 条地址`);
reloadTable();
} catch {
options.onError();
} finally {
importing.value = false;
}
}
onMounted(() => { loadOptions(); loadSummary(); });
</script>
<style lang="less" scoped>
.summary-grid { margin-bottom: 12px; }
:deep(.edit-modal) { width: min(680px, 94vw); }
:deep(.detail-modal) { width: min(1100px, 96vw); }
:deep(.import-modal) { width: min(720px, 94vw); }
.import-tip { margin-bottom: 16px; }
.import-title { display: block; margin-top: 8px; font-size: 16px; }
.import-issues { max-height: 240px; overflow-y: auto; text-align: left; }
</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: 'name', component: 'NInput', label: '地址名称', componentProps: { placeholder: '请输入地址名称' } },
{ field: 'domain', component: 'NInput', label: '注册域名', componentProps: { placeholder: '请输入域名关键词' } },
{ field: 'status', component: 'NSelect', label: '状态', componentProps: { options: computed(() => dict.getOptionUnRef('urlPoolStatus')), clearable: true } },
{ field: 'createdAt', component: 'NDatePicker', label: '创建时间', componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() } },
]);
export const columns = [
{ title: 'ID', key: 'id', width: 80 },
{ title: '地址名称', key: 'name', width: 150 },
{ title: '注册域名', key: 'domain', width: 220 },
{ title: '注册入口URL', key: 'baseUrl', width: 300, ellipsis: { tooltip: true } },
{ title: '状态', key: 'status', width: 90, render: (row) => h(NTag, { type: dict.getType('urlPoolStatus', row.status), bordered: false }, { default: () => dict.getLabel('urlPoolStatus', row.status) }) },
{ title: '当前绑定会员', key: 'currentBoundCount', width: 120 },
{ title: '累计绑定次数', key: 'bindCount', width: 120 },
{ title: '最后绑定时间', key: 'lastBoundAt', width: 180, render: (row) => row.lastBoundAt || '-' },
{ title: '备注', key: 'remark', width: 180, ellipsis: { tooltip: true } },
{ title: '创建时间', key: 'createdAt', width: 180 },
];
export const memberColumns = [
{ title: '会员编号', key: 'memberNo', width: 170 },
{ title: '用户名', key: 'username', width: 140 },
{ title: '手机号', key: 'mobile', width: 130 },
{ title: '会员状态', key: 'memberStatus', width: 100, render: (row) => h(NTag, { type: dict.getType('appMemberStatus', row.memberStatus), bordered: false }, { default: () => dict.getLabel('appMemberStatus', row.memberStatus) }) },
{ title: '绑定时间', key: 'boundAt', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['urlPoolStatus', 'appMemberStatus']);
}