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 { type IMemberV1 interface {
GetIdByCode(ctx context.Context, req *v1.GetIdByCodeReq) (res *v1.GetIdByCodeRes, err error) 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 }
+1 -1
View File
@@ -3,12 +3,12 @@
// @Copyright Copyright (c) 2023 HotGo CLI // @Copyright Copyright (c) 2023 HotGo CLI
// @Author Ms <133814250@qq.com> // @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE // @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
//
package consts package consts
// 消息队列 // 消息队列
const ( const (
QueueLogTopic = `request_log` // 访问日志 QueueLogTopic = `request_log` // 访问日志
QueueLoginLogTopic = `login_log` // 登录日志 QueueLoginLogTopic = `login_log` // 登录日志
QueueAppMemberLoginLogTopic = `app_member_login_log` // C端会员登录日志
QueueServeLogTopic = `serve_log` // 服务日志 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) { 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() 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"). mod := s.memberModel(ctx).As("m").
LeftJoin(dao.AppMemberProfile.Table()+" p", col("p", pc.MemberId)+"="+col("m", mc.Id)). 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.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.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( Fields(
col("m", mc.Id), col("m", mc.MemberNo), col("m", mc.Username), col("m", mc.Mobile), 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)), 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), 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), 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.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 != "" { if in.Username != "" {
mod = mod.WhereLike(col("m", mc.Username), "%"+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 { } else if in.MemberCategory == consts.AppMemberCategoryFake {
mod = mod.Where(col("m", mc.IsTestAccount), 1) 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 { if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(col("m", mc.CreatedAt), in.CreatedAt[0], in.CreatedAt[1]) 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) { 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() mc, pc, wc := dao.AppMember.Columns(), dao.AppMemberProfile.Columns(), dao.WalletAccount.Columns()
bc, uc := dao.MemberUrlBinding.Columns(), dao.UrlPool.Columns()
res = new(sysin.AppMemberViewModel) 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), 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) ).Where(col("m", mc.Id), in.Id).Scan(res)
if err != nil { if err != nil {
return nil, gerror.Wrap(err, "获取会员详情失败") 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 { func (s *sSysAppMember) Edit(ctx context.Context, in *sysin.AppMemberEditInp) error {
if in.Id == 0 {
return gerror.New("后台不支持创建会员,请由会员在C端完成注册")
}
isTestAccount := 2 isTestAccount := 2
if in.MemberCategory == consts.AppMemberCategoryFake { if in.MemberCategory == consts.AppMemberCategoryFake {
isTestAccount = 1 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:"会员状态"` MemberStatus int `json:"memberStatus" dc:"会员状态"`
AutoDispatch int `json:"autoDispatch" dc:"派单模式"` AutoDispatch int `json:"autoDispatch" dc:"派单模式"`
MemberCategory int `json:"memberCategory" dc:"会员类别"` MemberCategory int `json:"memberCategory" dc:"会员类别"`
RegisterDomain string `json:"registerDomain" dc:"注册域名(模糊查询)"`
DomainStatus int `json:"domainStatus" dc:"域名绑定状态"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"注册时间"` CreatedAt []*gtime.Time `json:"createdAt" dc:"注册时间"`
} }
@@ -47,6 +49,10 @@ type AppMemberListModel struct {
LastLoginRegion string `json:"lastLoginRegion"` LastLoginRegion string `json:"lastLoginRegion"`
TodayTradeCount int `json:"todayTradeCount"` TodayTradeCount int `json:"todayTradeCount"`
CreatedAt *gtime.Time `json:"createdAt"` 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 { type AppMemberViewInp struct {
@@ -85,11 +91,8 @@ type AppMemberEditInp struct {
} }
func (in *AppMemberEditInp) Filter(ctx context.Context) error { func (in *AppMemberEditInp) Filter(ctx context.Context) error {
if in.Id == 0 && strings.TrimSpace(in.Mobile) == "" { if in.Id == 0 {
return gerror.New("新增会员时手机号不能为空") return gerror.New("后台不支持创建会员,请由会员在C端完成注册")
}
if in.Id == 0 && len(in.Password) < 8 {
return gerror.New("新增会员密码不能少于8位")
} }
if in.Password != "" && (len(in.Password) < 8 || !containsLetterAndNumber(in.Password)) { if in.Password != "" && (len(in.Password) < 8 || !containsLetterAndNumber(in.Password)) {
return gerror.New("密码至少8位且必须同时包含字母和数字") 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) }
+53
View File
@@ -12,6 +12,7 @@ import (
"hotgo/internal/model/entity" "hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin" "hotgo/internal/model/input/sysin"
"hotgo/utility/tree" "hotgo/utility/tree"
"io"
"github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/database/gredis" "github.com/gogf/gf/v2/database/gredis"
@@ -118,6 +119,28 @@ type (
NoticeEdit(ctx context.Context, in *sysin.MemberNoticeEditInp) error NoticeEdit(ctx context.Context, in *sysin.MemberNoticeEditInp) error
NoticeRevoke(ctx context.Context, in *sysin.MemberNoticeRevokeInp) 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 { ISysPortal interface {
SiteConfigCurrent(ctx context.Context, in *sysin.SiteConfigCurrentInp) (res *sysin.SiteConfigCurrentModel, err error) 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) SiteConfigHistory(ctx context.Context, in *sysin.SiteConfigHistoryInp) (list []*sysin.SiteConfigHistoryModel, totalCount int, err error)
@@ -557,6 +580,9 @@ var (
localSysTask ISysTask localSysTask ISysTask
localSysTaskSubmission ISysTaskSubmission localSysTaskSubmission ISysTaskSubmission
localSysAppMember ISysAppMember localSysAppMember ISysAppMember
localSysUrlPool ISysUrlPool
localSysAppMemberLoginLog ISysAppMemberLoginLog
localSysAppMemberAuth ISysAppMemberAuth
localSysPortal ISysPortal localSysPortal ISysPortal
localSysDashboard ISysDashboard localSysDashboard ISysDashboard
localSysBizAudit ISysBizAudit localSysBizAudit ISysBizAudit
@@ -658,6 +684,33 @@ func SysAppMember() ISysAppMember {
func RegisterSysAppMember(i ISysAppMember) { localSysAppMember = i } 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 { func SysPortal() ISysPortal {
if localSysPortal == nil { if localSysPortal == nil {
panic("implement not found for interface ISysPortal, forgot register?") panic("implement not found for interface ISysPortal, forgot register?")
+5 -1
View File
@@ -158,7 +158,11 @@ router:
# 前缀 # 前缀
prefix: "/api" prefix: "/api"
# 不需要验证登录的路由地址 # 不需要验证登录的路由地址
exceptLogin: [ ] exceptLogin: [
"/member/register", # C端会员注册
"/member/accountLogin", # C端会员账号密码登录
"/member/mobileLogin", # C端会员手机验证码登录
]
# websocket # websocket
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" :action-column="actionColumn"
:scroll-x="scrollX" :scroll-x="scrollX"
:resize-height-offset="-10000" :resize-height-offset="-10000"
> />
<template #tableTitle>
<n-button v-if="hasPermission(['/appMember/edit'])" type="primary" @click="openEdit()"
>新增会员</n-button
>
</template>
</BasicTable>
<div <div
class="action-column-scrollbar" class="action-column-scrollbar"
aria-label="操作栏横向滚动条" aria-label="操作栏横向滚动条"
@@ -42,7 +36,7 @@
<n-modal <n-modal
v-model:show="showEdit" v-model:show="showEdit"
preset="card" preset="card"
:title="editForm.id ? '编辑会员' : '新增会员'" title="编辑会员"
class="edit-modal" class="edit-modal"
> >
<n-spin :show="editLoading"> <n-spin :show="editLoading">
@@ -63,12 +57,10 @@
<n-form-item label="用户名称" path="username" required <n-form-item label="用户名称" path="username" required
><n-input v-model:value="editForm.username" placeholder="请输入用户名称" ><n-input v-model:value="editForm.username" placeholder="请输入用户名称"
/></n-form-item> /></n-form-item>
<n-form-item label="手机号码" path="mobile" :required="!editForm.id" <n-form-item label="手机号码" path="mobile"
><n-input ><n-input
v-model:value="editForm.mobile" v-model:value="editForm.mobile"
:placeholder=" :placeholder="editForm.currentMobile || '留空不修改'"
editForm.id ? editForm.currentMobile || '留空不修改' : '请输入手机号码'
"
/></n-form-item> /></n-form-item>
<n-form-item label="账户余额" <n-form-item label="账户余额"
><n-input :value="money(editForm.availableAmount)" disabled ><n-input :value="money(editForm.availableAmount)" disabled
@@ -92,12 +84,12 @@
v-model:value="editForm.riskStatus" v-model:value="editForm.riskStatus"
:options="dict.getOptionUnRef('appMemberRiskStatus')" :options="dict.getOptionUnRef('appMemberRiskStatus')"
/></n-form-item> /></n-form-item>
<n-form-item label="登录密码" path="password" :required="!editForm.id" <n-form-item label="登录密码" path="password"
><n-input ><n-input
v-model:value="editForm.password" v-model:value="editForm.password"
type="password" type="password"
show-password-on="click" show-password-on="click"
:placeholder="editForm.id ? '留空不修改' : '至少8位,含字母和数字'" placeholder="留空不修改"
/></n-form-item> /></n-form-item>
<n-form-item label="交易密码" path="tradePassword" <n-form-item label="交易密码" path="tradePassword"
><n-input ><n-input
@@ -151,6 +143,9 @@
<n-descriptions-item label="收货地址">{{ detail.addressCount }} 个</n-descriptions-item> <n-descriptions-item label="收货地址">{{ detail.addressCount }} 个</n-descriptions-item>
<n-descriptions-item label="下级团队">{{ detail.teamCount }} 人</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.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-descriptions>
<n-divider /> <n-divider />
<n-space> <n-space>
@@ -469,14 +464,13 @@
{ min: 3, max: 64, message: '用户名长度为3到64个字符', trigger: ['input', 'blur'] }, { min: 3, max: 64, message: '用户名长度为3到64个字符', trigger: ['input', 'blur'] },
], ],
mobile: { mobile: {
validator: (_rule, value) => validator: () => true,
editForm.id || String(value || '').trim() ? true : new Error('请输入手机号码'),
trigger: ['input', 'blur'], trigger: ['input', 'blur'],
}, },
password: { password: {
validator: (_rule, value) => { validator: (_rule, value) => {
const text = String(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)) if (text.length < 8 || !/[A-Za-z]/.test(text) || !/\d/.test(text))
return new Error('密码至少8位且必须同时包含字母和数字'); return new Error('密码至少8位且必须同时包含字母和数字');
return true; return true;
@@ -511,6 +505,12 @@
h(TableAction as any, { h(TableAction as any, {
style: 'button', style: 'button',
actions: [ actions: [
{
label: '详情',
type: 'default',
onClick: () => openDetail(record.id),
auth: ['/appMember/view'],
},
{ {
label: '编辑', label: '编辑',
type: 'primary', type: 'primary',
@@ -602,7 +602,7 @@
actionScrollLeft = externalMax > 0 ? (el.scrollLeft * actionMax) / externalMax : 0; actionScrollLeft = externalMax > 0 ? (el.scrollLeft * actionMax) / externalMax : 0;
syncActionViewports(actionScrollLeft); syncActionViewports(actionScrollLeft);
} }
async function openEdit(row?: MemberRecord) { async function openEdit(row: MemberRecord) {
reset(editForm, { reset(editForm, {
id: row?.id || 0, id: row?.id || 0,
username: row?.username || '', username: row?.username || '',
@@ -621,7 +621,6 @@
frozenAmount: row?.frozenAmount || 0, frozenAmount: row?.frozenAmount || 0,
}); });
showEdit.value = true; showEdit.value = true;
if (!row?.id) return;
editLoading.value = true; editLoading.value = true;
try { try {
const data: any = await View({ id: row.id }); 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; availableAmount: number; frozenAmount: number; parentId: number; parentUsername: string;
inviteCode: string; memberStatus: number; riskStatus: number; autoDispatch: number; memberCategory: number; inviteCode: string; memberStatus: number; riskStatus: number; autoDispatch: number; memberCategory: number;
isTestAccount: number; lastLoginAt?: string; lastLoginIpMask: string; lastLoginRegion: string; isTestAccount: number; lastLoginAt?: string; lastLoginIpMask: string; lastLoginRegion: string;
urlPoolId: number; registerDomain: string; domainStatus: number; domainBoundAt?: string;
todayTradeCount: number; createdAt: 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: '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: '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: '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() } }, { 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: '今日交易', key: 'todayTradeCount', width: 90 },
{ title: '最后登录IP', key: 'lastLoginIpMask', width: 130, render: (row: MemberRecord) => row.lastLoginIpMask || '-' }, { title: '最后登录IP', key: 'lastLoginIpMask', width: 130, render: (row: MemberRecord) => row.lastLoginIpMask || '-' },
{ title: '最后登录地区', key: 'lastLoginRegion', width: 140, render: (row: MemberRecord) => row.lastLoginRegion || '-' }, { 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 }, { title: '注册时间', key: 'createdAt', width: 180 },
]; ];
export function loadOptions() { 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']);
}