feat: add member URL pool and login tracking
This commit is contained in:
@@ -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{}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package urlpool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hotgo/internal/model/input/form"
|
||||||
|
"hotgo/internal/model/input/sysin"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/net/ghttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListReq struct {
|
||||||
|
g.Meta `path:"/urlPool/list" method:"get" tags:"URL地址池" summary:"获取URL地址池列表"`
|
||||||
|
sysin.UrlPoolListInp
|
||||||
|
}
|
||||||
|
type ListRes struct {
|
||||||
|
form.PageRes
|
||||||
|
List []*sysin.UrlPoolListModel `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewReq struct {
|
||||||
|
g.Meta `path:"/urlPool/view" method:"get" tags:"URL地址池" summary:"获取URL地址详情"`
|
||||||
|
sysin.UrlPoolViewInp
|
||||||
|
}
|
||||||
|
type ViewRes struct{ *sysin.UrlPoolViewModel }
|
||||||
|
|
||||||
|
type EditReq struct {
|
||||||
|
g.Meta `path:"/urlPool/edit" method:"post" tags:"URL地址池" summary:"新增或编辑URL地址"`
|
||||||
|
sysin.UrlPoolEditInp
|
||||||
|
}
|
||||||
|
type EditRes struct{}
|
||||||
|
|
||||||
|
type StatusReq struct {
|
||||||
|
g.Meta `path:"/urlPool/status" method:"post" tags:"URL地址池" summary:"切换URL地址状态"`
|
||||||
|
sysin.UrlPoolStatusInp
|
||||||
|
}
|
||||||
|
type StatusRes struct{}
|
||||||
|
|
||||||
|
type ImportReq struct {
|
||||||
|
g.Meta `path:"/urlPool/import" method:"post" mime:"multipart/form-data" tags:"URL地址池" summary:"Excel批量导入URL地址"`
|
||||||
|
File *ghttp.UploadFile `json:"file" type:"file" v:"required#请选择Excel文件" dc:"Excel文件"`
|
||||||
|
}
|
||||||
|
type ImportRes struct{ *sysin.UrlPoolImportModel }
|
||||||
|
|
||||||
|
type MemberListReq struct {
|
||||||
|
g.Meta `path:"/urlPool/memberList" method:"get" tags:"URL地址池" summary:"获取地址绑定会员"`
|
||||||
|
sysin.UrlPoolMemberListInp
|
||||||
|
}
|
||||||
|
type MemberListRes struct {
|
||||||
|
form.PageRes
|
||||||
|
List []*sysin.UrlPoolMemberListModel `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummaryReq struct {
|
||||||
|
g.Meta `path:"/urlPool/summary" method:"get" tags:"URL地址池" summary:"获取URL地址池汇总"`
|
||||||
|
sysin.UrlPoolSummaryInp
|
||||||
|
}
|
||||||
|
type SummaryRes struct{ *sysin.UrlPoolSummaryModel }
|
||||||
@@ -12,4 +12,7 @@ import (
|
|||||||
|
|
||||||
type IMemberV1 interface {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hotgo/internal/model/input/sysin"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegisterReq struct {
|
||||||
|
g.Meta `path:"/member/register" method:"post" tags:"会员认证" summary:"C端会员注册"`
|
||||||
|
sysin.AppMemberRegisterInp
|
||||||
|
}
|
||||||
|
type RegisterRes struct{ *sysin.AppMemberRegisterModel }
|
||||||
|
|
||||||
|
type AccountLoginReq struct {
|
||||||
|
g.Meta `path:"/member/accountLogin" method:"post" tags:"会员认证" summary:"C端会员账号密码登录"`
|
||||||
|
sysin.AppMemberAccountLoginInp
|
||||||
|
}
|
||||||
|
type AccountLoginRes struct{ *sysin.AppMemberLoginModel }
|
||||||
|
|
||||||
|
type MobileLoginReq struct {
|
||||||
|
g.Meta `path:"/member/mobileLogin" method:"post" tags:"会员认证" summary:"C端会员手机验证码登录"`
|
||||||
|
sysin.AppMemberMobileLoginInp
|
||||||
|
}
|
||||||
|
type MobileLoginRes struct{ *sysin.AppMemberLoginModel }
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
// @Copyright Copyright (c) 2023 HotGo CLI
|
// @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` // 登录日志
|
||||||
QueueServeLogTopic = `serve_log` // 服务日志
|
QueueAppMemberLoginLogTopic = `app_member_login_log` // C端会员登录日志
|
||||||
|
QueueServeLogTopic = `serve_log` // 服务日志
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// ==========================================================================
|
||||||
|
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/database/gdb"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UrlPoolDao is the data access object for the table hg_url_pool.
|
||||||
|
type UrlPoolDao struct {
|
||||||
|
table string // table is the underlying table name of the DAO.
|
||||||
|
group string // group is the database configuration group name of the current DAO.
|
||||||
|
columns UrlPoolColumns // columns contains all the column names of Table for convenient usage.
|
||||||
|
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||||
|
}
|
||||||
|
|
||||||
|
// UrlPoolColumns defines and stores column names for the table hg_url_pool.
|
||||||
|
type UrlPoolColumns struct {
|
||||||
|
Id string // 主键
|
||||||
|
TenantId string // 租户ID
|
||||||
|
Name string // 地址名称
|
||||||
|
Scheme string // 协议:http或https
|
||||||
|
Domain string // 标准化域名,可含非默认端口
|
||||||
|
DomainHash string // 标准化域名SHA256
|
||||||
|
BaseUrl string // 注册入口基础地址
|
||||||
|
Status string // 状态:1启用,2失效
|
||||||
|
BindCount string // 累计成功绑定次数
|
||||||
|
LastBoundAt string // 最后绑定时间
|
||||||
|
Remark string // 备注
|
||||||
|
CreatedBy string // 创建者ID
|
||||||
|
UpdatedBy string // 更新者ID
|
||||||
|
DeletedBy string // 删除者ID
|
||||||
|
CreatedAt string // 创建时间
|
||||||
|
UpdatedAt string // 更新时间
|
||||||
|
DeletedAt string // 删除时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlPoolColumns holds the columns for the table hg_url_pool.
|
||||||
|
var urlPoolColumns = UrlPoolColumns{
|
||||||
|
Id: "id",
|
||||||
|
TenantId: "tenant_id",
|
||||||
|
Name: "name",
|
||||||
|
Scheme: "scheme",
|
||||||
|
Domain: "domain",
|
||||||
|
DomainHash: "domain_hash",
|
||||||
|
BaseUrl: "base_url",
|
||||||
|
Status: "status",
|
||||||
|
BindCount: "bind_count",
|
||||||
|
LastBoundAt: "last_bound_at",
|
||||||
|
Remark: "remark",
|
||||||
|
CreatedBy: "created_by",
|
||||||
|
UpdatedBy: "updated_by",
|
||||||
|
DeletedBy: "deleted_by",
|
||||||
|
CreatedAt: "created_at",
|
||||||
|
UpdatedAt: "updated_at",
|
||||||
|
DeletedAt: "deleted_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUrlPoolDao creates and returns a new DAO object for table data access.
|
||||||
|
func NewUrlPoolDao(handlers ...gdb.ModelHandler) *UrlPoolDao {
|
||||||
|
return &UrlPoolDao{
|
||||||
|
group: "default",
|
||||||
|
table: "hg_url_pool",
|
||||||
|
columns: urlPoolColumns,
|
||||||
|
handlers: handlers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||||
|
func (dao *UrlPoolDao) DB() gdb.DB {
|
||||||
|
return g.DB(dao.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table returns the table name of the current DAO.
|
||||||
|
func (dao *UrlPoolDao) Table() string {
|
||||||
|
return dao.table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns all column names of the current DAO.
|
||||||
|
func (dao *UrlPoolDao) Columns() UrlPoolColumns {
|
||||||
|
return dao.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group returns the database configuration group name of the current DAO.
|
||||||
|
func (dao *UrlPoolDao) Group() string {
|
||||||
|
return dao.group
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||||
|
func (dao *UrlPoolDao) Ctx(ctx context.Context) *gdb.Model {
|
||||||
|
model := dao.DB().Model(dao.table)
|
||||||
|
for _, handler := range dao.handlers {
|
||||||
|
model = handler(model)
|
||||||
|
}
|
||||||
|
return model.Safe().Ctx(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction wraps the transaction logic using function f.
|
||||||
|
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||||
|
// It commits the transaction and returns nil if function f returns nil.
|
||||||
|
//
|
||||||
|
// Note: Do not commit or roll back the transaction in function f,
|
||||||
|
// as it is automatically handled by this function.
|
||||||
|
func (dao *UrlPoolDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||||
|
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// =================================================================================
|
||||||
|
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||||
|
// =================================================================================
|
||||||
|
|
||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hotgo/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memberUrlBindingDao is the data access object for the table hg_member_url_binding.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type memberUrlBindingDao struct {
|
||||||
|
*internal.MemberUrlBindingDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// MemberUrlBinding is a globally accessible object for table hg_member_url_binding operations.
|
||||||
|
MemberUrlBinding = memberUrlBindingDao{internal.NewMemberUrlBindingDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add your custom methods and functionality below.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// =================================================================================
|
||||||
|
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||||
|
// =================================================================================
|
||||||
|
|
||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hotgo/internal/dao/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// urlPoolDao is the data access object for the table hg_url_pool.
|
||||||
|
// You can define custom methods on it to extend its functionality as needed.
|
||||||
|
type urlPoolDao struct {
|
||||||
|
*internal.UrlPoolDao
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// UrlPool is a globally accessible object for table hg_url_pool operations.
|
||||||
|
UrlPool = urlPoolDao{internal.NewUrlPoolDao()}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add your custom methods and functionality below.
|
||||||
@@ -55,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 "系统异常"
|
||||||
|
}
|
||||||
@@ -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 // 更新时间
|
||||||
|
}
|
||||||
@@ -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:"更新时间"`
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
@@ -547,41 +570,44 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
localSysMemberLevel ISysMemberLevel
|
localSysMemberLevel ISysMemberLevel
|
||||||
localSysTaskHall ISysTaskHall
|
localSysTaskHall ISysTaskHall
|
||||||
localSysTradeOrder ISysTradeOrder
|
localSysTradeOrder ISysTradeOrder
|
||||||
localSysRechargeOrder ISysRechargeOrder
|
localSysRechargeOrder ISysRechargeOrder
|
||||||
localSysWithdrawalOrder ISysWithdrawalOrder
|
localSysWithdrawalOrder ISysWithdrawalOrder
|
||||||
localSysCustomerService ISysCustomerService
|
localSysCustomerService ISysCustomerService
|
||||||
localSysProduct ISysProduct
|
localSysProduct ISysProduct
|
||||||
localSysTask ISysTask
|
localSysTask ISysTask
|
||||||
localSysTaskSubmission ISysTaskSubmission
|
localSysTaskSubmission ISysTaskSubmission
|
||||||
localSysAppMember ISysAppMember
|
localSysAppMember ISysAppMember
|
||||||
localSysPortal ISysPortal
|
localSysUrlPool ISysUrlPool
|
||||||
localSysDashboard ISysDashboard
|
localSysAppMemberLoginLog ISysAppMemberLoginLog
|
||||||
localSysBizAudit ISysBizAudit
|
localSysAppMemberAuth ISysAppMemberAuth
|
||||||
localSysApproval ISysApproval
|
localSysPortal ISysPortal
|
||||||
localSysAddons ISysAddons
|
localSysDashboard ISysDashboard
|
||||||
localSysAddonsConfig ISysAddonsConfig
|
localSysBizAudit ISysBizAudit
|
||||||
localSysAttachment ISysAttachment
|
localSysApproval ISysApproval
|
||||||
localSysBlacklist ISysBlacklist
|
localSysAddons ISysAddons
|
||||||
localSysConfig ISysConfig
|
localSysAddonsConfig ISysAddonsConfig
|
||||||
localSysCron ISysCron
|
localSysAttachment ISysAttachment
|
||||||
localSysCronGroup ISysCronGroup
|
localSysBlacklist ISysBlacklist
|
||||||
localSysCurdDemo ISysCurdDemo
|
localSysConfig ISysConfig
|
||||||
localSysDictData ISysDictData
|
localSysCron ISysCron
|
||||||
localSysDictType ISysDictType
|
localSysCronGroup ISysCronGroup
|
||||||
localSysEmsLog ISysEmsLog
|
localSysCurdDemo ISysCurdDemo
|
||||||
localSysGenCodes ISysGenCodes
|
localSysDictData ISysDictData
|
||||||
localSysLog ISysLog
|
localSysDictType ISysDictType
|
||||||
localSysLoginLog ISysLoginLog
|
localSysEmsLog ISysEmsLog
|
||||||
localSysNormalTreeDemo ISysNormalTreeDemo
|
localSysGenCodes ISysGenCodes
|
||||||
localSysOptionTreeDemo ISysOptionTreeDemo
|
localSysLog ISysLog
|
||||||
localSysProvinces ISysProvinces
|
localSysLoginLog ISysLoginLog
|
||||||
localSysServeLicense ISysServeLicense
|
localSysNormalTreeDemo ISysNormalTreeDemo
|
||||||
localSysServeLog ISysServeLog
|
localSysOptionTreeDemo ISysOptionTreeDemo
|
||||||
localSysSmsLog ISysSmsLog
|
localSysProvinces ISysProvinces
|
||||||
localSysTestCategory ISysTestCategory
|
localSysServeLicense ISysServeLicense
|
||||||
|
localSysServeLog ISysServeLog
|
||||||
|
localSysSmsLog ISysSmsLog
|
||||||
|
localSysTestCategory ISysTestCategory
|
||||||
)
|
)
|
||||||
|
|
||||||
func SysMemberLevel() ISysMemberLevel {
|
func SysMemberLevel() ISysMemberLevel {
|
||||||
@@ -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?")
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user