diff --git a/docs/admin_reference_prd/URL地址池设计方案.md b/docs/admin_reference_prd/URL地址池设计方案.md new file mode 100644 index 0000000..572276d --- /dev/null +++ b/docs/admin_reference_prd/URL地址池设计方案.md @@ -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/_url_pool.sql +└── storage/data/migrations/_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. 会员登录日志的在线保留时长,以及是否需要定时归档历史日志。 diff --git a/server/api/admin/appmemberloginlog/appmemberloginlog.go b/server/api/admin/appmemberloginlog/appmemberloginlog.go new file mode 100644 index 0000000..89a2813 --- /dev/null +++ b/server/api/admin/appmemberloginlog/appmemberloginlog.go @@ -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{} diff --git a/server/api/admin/urlpool/urlpool.go b/server/api/admin/urlpool/urlpool.go new file mode 100644 index 0000000..b116bd3 --- /dev/null +++ b/server/api/admin/urlpool/urlpool.go @@ -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 } diff --git a/server/api/api/member/member.go b/server/api/api/member/member.go index d335001..3e5540d 100644 --- a/server/api/api/member/member.go +++ b/server/api/api/member/member.go @@ -12,4 +12,7 @@ import ( type IMemberV1 interface { GetIdByCode(ctx context.Context, req *v1.GetIdByCodeReq) (res *v1.GetIdByCodeRes, err error) + Register(ctx context.Context, req *v1.RegisterReq) (res *v1.RegisterRes, err error) + AccountLogin(ctx context.Context, req *v1.AccountLoginReq) (res *v1.AccountLoginRes, err error) + MobileLogin(ctx context.Context, req *v1.MobileLoginReq) (res *v1.MobileLoginRes, err error) } diff --git a/server/api/api/member/v1/auth.go b/server/api/api/member/v1/auth.go new file mode 100644 index 0000000..4da9168 --- /dev/null +++ b/server/api/api/member/v1/auth.go @@ -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 } diff --git a/server/internal/consts/queue.go b/server/internal/consts/queue.go index d105f6b..8110319 100644 --- a/server/internal/consts/queue.go +++ b/server/internal/consts/queue.go @@ -3,12 +3,12 @@ // @Copyright Copyright (c) 2023 HotGo CLI // @Author Ms <133814250@qq.com> // @License https://github.com/bufanyun/hotgo/blob/master/LICENSE -// package consts // 消息队列 const ( - QueueLogTopic = `request_log` // 访问日志 - QueueLoginLogTopic = `login_log` // 登录日志 - QueueServeLogTopic = `serve_log` // 服务日志 + QueueLogTopic = `request_log` // 访问日志 + QueueLoginLogTopic = `login_log` // 登录日志 + QueueAppMemberLoginLogTopic = `app_member_login_log` // C端会员登录日志 + QueueServeLogTopic = `serve_log` // 服务日志 ) diff --git a/server/internal/consts/url_pool.go b/server/internal/consts/url_pool.go new file mode 100644 index 0000000..323d058 --- /dev/null +++ b/server/internal/consts/url_pool.go @@ -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) +} diff --git a/server/internal/controller/admin/sys/app_member_login_log.go b/server/internal/controller/admin/sys/app_member_login_log.go new file mode 100644 index 0000000..777be79 --- /dev/null +++ b/server/internal/controller/admin/sys/app_member_login_log.go @@ -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) +} diff --git a/server/internal/controller/admin/sys/url_pool.go b/server/internal/controller/admin/sys/url_pool.go new file mode 100644 index 0000000..3fb876a --- /dev/null +++ b/server/internal/controller/admin/sys/url_pool.go @@ -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 +} diff --git a/server/internal/controller/api/member/member_v1_auth.go b/server/internal/controller/api/member/member_v1_auth.go new file mode 100644 index 0000000..1e669cd --- /dev/null +++ b/server/internal/controller/api/member/member_v1_auth.go @@ -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 +} diff --git a/server/internal/dao/app_member_login_log.go b/server/internal/dao/app_member_login_log.go new file mode 100644 index 0000000..fd939c9 --- /dev/null +++ b/server/internal/dao/app_member_login_log.go @@ -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. diff --git a/server/internal/dao/internal/app_member_login_log.go b/server/internal/dao/internal/app_member_login_log.go new file mode 100644 index 0000000..e705412 --- /dev/null +++ b/server/internal/dao/internal/app_member_login_log.go @@ -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) +} diff --git a/server/internal/dao/internal/member_url_binding.go b/server/internal/dao/internal/member_url_binding.go new file mode 100644 index 0000000..016c93c --- /dev/null +++ b/server/internal/dao/internal/member_url_binding.go @@ -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) +} diff --git a/server/internal/dao/internal/url_pool.go b/server/internal/dao/internal/url_pool.go new file mode 100644 index 0000000..1934f3e --- /dev/null +++ b/server/internal/dao/internal/url_pool.go @@ -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) +} diff --git a/server/internal/dao/member_url_binding.go b/server/internal/dao/member_url_binding.go new file mode 100644 index 0000000..7498bfa --- /dev/null +++ b/server/internal/dao/member_url_binding.go @@ -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. diff --git a/server/internal/dao/url_pool.go b/server/internal/dao/url_pool.go new file mode 100644 index 0000000..dac8cfa --- /dev/null +++ b/server/internal/dao/url_pool.go @@ -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. diff --git a/server/internal/logic/sys/app_member.go b/server/internal/logic/sys/app_member.go index 0c287e2..4158f70 100644 --- a/server/internal/logic/sys/app_member.go +++ b/server/internal/logic/sys/app_member.go @@ -55,10 +55,13 @@ func col(alias, column string) string { return alias + "." + column } func (s *sSysAppMember) List(ctx context.Context, in *sysin.AppMemberListInp) (list []*sysin.AppMemberListModel, totalCount int, err error) { mc, pc, wc := dao.AppMember.Columns(), dao.AppMemberProfile.Columns(), dao.WalletAccount.Columns() + bc, uc := dao.MemberUrlBinding.Columns(), dao.UrlPool.Columns() mod := s.memberModel(ctx).As("m"). LeftJoin(dao.AppMemberProfile.Table()+" p", col("p", pc.MemberId)+"="+col("m", mc.Id)). LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("m", mc.Id)+" AND "+col("w", wc.Currency)+"='CNY'"). LeftJoin(dao.AppMember.Table()+" parent", col("parent", mc.Id)+"="+col("m", mc.ParentId)). + LeftJoin(dao.MemberUrlBinding.Table()+" ub", col("ub", bc.MemberId)+"="+col("m", mc.Id)). + LeftJoin(dao.UrlPool.Table()+" up", col("up", uc.Id)+"="+col("ub", bc.UrlPoolId)). Fields( col("m", mc.Id), col("m", mc.MemberNo), col("m", mc.Username), col("m", mc.Mobile), col("p", pc.RealNameMask), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), @@ -67,6 +70,9 @@ func (s *sSysAppMember) List(ctx context.Context, in *sysin.AppMemberListInp) (l col("m", mc.MemberStatus), col("m", mc.RiskStatus), col("m", mc.AutoDispatch), col("m", mc.IsTestAccount), fmt.Sprintf("CASE WHEN %s=1 THEN %d ELSE %d END member_category", col("m", mc.IsTestAccount), consts.AppMemberCategoryFake, consts.AppMemberCategoryReal), col("m", mc.LastLoginAt), col("m", mc.LastLoginIpMask), col("m", mc.LastLoginRegion), col("m", mc.CreatedAt), + fmt.Sprintf("COALESCE(%s,0) url_pool_id", col("ub", bc.UrlPoolId)), + fmt.Sprintf("COALESCE(%s,'') register_domain", col("ub", bc.DomainSnapshot)), col("ub", bc.BoundAt)+" domain_bound_at", + fmt.Sprintf("CASE WHEN %s IS NULL THEN %d WHEN %s=%d THEN %d ELSE %d END domain_status", col("ub", bc.Id), consts.AppMemberDomainUnbound, col("up", uc.Status), consts.UrlPoolStatusEnabled, consts.AppMemberDomainBoundEnabled, consts.AppMemberDomainBoundInvalid), ) if in.Username != "" { mod = mod.WhereLike(col("m", mc.Username), "%"+in.Username+"%") @@ -85,6 +91,16 @@ func (s *sSysAppMember) List(ctx context.Context, in *sysin.AppMemberListInp) (l } else if in.MemberCategory == consts.AppMemberCategoryFake { mod = mod.Where(col("m", mc.IsTestAccount), 1) } + if in.RegisterDomain != "" { + mod = mod.WhereLike(col("ub", bc.DomainSnapshot), "%"+strings.ToLower(strings.TrimSpace(in.RegisterDomain))+"%") + } + if in.DomainStatus == consts.AppMemberDomainBoundEnabled { + mod = mod.WhereNotNull(col("ub", bc.Id)).Where(col("up", uc.Status), consts.UrlPoolStatusEnabled) + } else if in.DomainStatus == consts.AppMemberDomainUnbound { + mod = mod.WhereNull(col("ub", bc.Id)) + } else if in.DomainStatus == consts.AppMemberDomainBoundInvalid { + mod = mod.WhereNotNull(col("ub", bc.Id)).Where(col("up", uc.Status), consts.UrlPoolStatusInvalid) + } if len(in.CreatedAt) == 2 { mod = mod.WhereBetween(col("m", mc.CreatedAt), in.CreatedAt[0], in.CreatedAt[1]) } @@ -97,9 +113,11 @@ func (s *sSysAppMember) List(ctx context.Context, in *sysin.AppMemberListInp) (l func (s *sSysAppMember) View(ctx context.Context, in *sysin.AppMemberViewInp) (res *sysin.AppMemberViewModel, err error) { mc, pc, wc := dao.AppMember.Columns(), dao.AppMemberProfile.Columns(), dao.WalletAccount.Columns() + bc, uc := dao.MemberUrlBinding.Columns(), dao.UrlPool.Columns() res = new(sysin.AppMemberViewModel) - err = s.memberModel(ctx).As("m").LeftJoin(dao.AppMemberProfile.Table()+" p", col("p", pc.MemberId)+"="+col("m", mc.Id)).LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("m", mc.Id)+" AND "+col("w", wc.Currency)+"='CNY'").LeftJoin(dao.AppMember.Table()+" parent", col("parent", mc.Id)+"="+col("m", mc.ParentId)).Fields( + err = s.memberModel(ctx).As("m").LeftJoin(dao.AppMemberProfile.Table()+" p", col("p", pc.MemberId)+"="+col("m", mc.Id)).LeftJoin(dao.WalletAccount.Table()+" w", col("w", wc.MemberId)+"="+col("m", mc.Id)+" AND "+col("w", wc.Currency)+"='CNY'").LeftJoin(dao.AppMember.Table()+" parent", col("parent", mc.Id)+"="+col("m", mc.ParentId)).LeftJoin(dao.MemberUrlBinding.Table()+" ub", col("ub", bc.MemberId)+"="+col("m", mc.Id)).LeftJoin(dao.UrlPool.Table()+" up", col("up", uc.Id)+"="+col("ub", bc.UrlPoolId)).Fields( col("m", mc.Id), col("m", mc.MemberNo), col("m", mc.Username), col("m", mc.Mobile), col("p", pc.RealNameMask), col("p", pc.IdNoMask), col("p", pc.KycStatus), fmt.Sprintf("COALESCE(%s,0) available_amount", col("w", wc.AvailableAmount)), fmt.Sprintf("COALESCE(%s,0) frozen_amount", col("w", wc.FrozenAmount)), col("m", mc.ParentId), fmt.Sprintf("COALESCE(%s,'') parent_username", col("parent", mc.Username)), col("m", mc.InviteCode), col("m", mc.MemberStatus), col("m", mc.RiskStatus), col("m", mc.AutoDispatch), col("m", mc.IsTestAccount), fmt.Sprintf("CASE WHEN %s=1 THEN %d ELSE %d END member_category", col("m", mc.IsTestAccount), consts.AppMemberCategoryFake, consts.AppMemberCategoryReal), col("m", mc.LastLoginAt), col("m", mc.LastLoginIpMask), col("m", mc.LastLoginRegion), col("m", mc.CreatedAt), col("m", mc.LevelId), col("m", mc.Level), col("m", mc.CreditScore), + fmt.Sprintf("COALESCE(%s,0) url_pool_id", col("ub", bc.UrlPoolId)), fmt.Sprintf("COALESCE(%s,'') register_domain", col("ub", bc.DomainSnapshot)), col("ub", bc.BoundAt)+" domain_bound_at", fmt.Sprintf("CASE WHEN %s IS NULL THEN %d WHEN %s=%d THEN %d ELSE %d END domain_status", col("ub", bc.Id), consts.AppMemberDomainUnbound, col("up", uc.Status), consts.UrlPoolStatusEnabled, consts.AppMemberDomainBoundEnabled, consts.AppMemberDomainBoundInvalid), ).Where(col("m", mc.Id), in.Id).Scan(res) if err != nil { return nil, gerror.Wrap(err, "获取会员详情失败") @@ -115,6 +133,9 @@ func (s *sSysAppMember) View(ctx context.Context, in *sysin.AppMemberViewInp) (r } func (s *sSysAppMember) Edit(ctx context.Context, in *sysin.AppMemberEditInp) error { + if in.Id == 0 { + return gerror.New("后台不支持创建会员,请由会员在C端完成注册") + } isTestAccount := 2 if in.MemberCategory == consts.AppMemberCategoryFake { isTestAccount = 1 diff --git a/server/internal/logic/sys/app_member_auth.go b/server/internal/logic/sys/app_member_auth.go new file mode 100644 index 0000000..1e60c37 --- /dev/null +++ b/server/internal/logic/sys/app_member_auth.go @@ -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 +} diff --git a/server/internal/logic/sys/app_member_login_log.go b/server/internal/logic/sys/app_member_login_log.go new file mode 100644 index 0000000..8950e49 --- /dev/null +++ b/server/internal/logic/sys/app_member_login_log.go @@ -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 "系统异常" +} diff --git a/server/internal/logic/sys/url_pool.go b/server/internal/logic/sys/url_pool.go new file mode 100644 index 0000000..93dc937 --- /dev/null +++ b/server/internal/logic/sys/url_pool.go @@ -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[:]) +} diff --git a/server/internal/logic/sys/url_pool_test.go b/server/internal/logic/sys/url_pool_test.go new file mode 100644 index 0000000..238c403 --- /dev/null +++ b/server/internal/logic/sys/url_pool_test.go @@ -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) + } +} diff --git a/server/internal/model/do/app_member_login_log.go b/server/internal/model/do/app_member_login_log.go new file mode 100644 index 0000000..e80a674 --- /dev/null +++ b/server/internal/model/do/app_member_login_log.go @@ -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 // 创建时间 +} diff --git a/server/internal/model/do/member_url_binding.go b/server/internal/model/do/member_url_binding.go new file mode 100644 index 0000000..a9b5716 --- /dev/null +++ b/server/internal/model/do/member_url_binding.go @@ -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 // 更新时间 +} diff --git a/server/internal/model/do/url_pool.go b/server/internal/model/do/url_pool.go new file mode 100644 index 0000000..0d83806 --- /dev/null +++ b/server/internal/model/do/url_pool.go @@ -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 // 删除时间 +} diff --git a/server/internal/model/entity/app_member_login_log.go b/server/internal/model/entity/app_member_login_log.go new file mode 100644 index 0000000..3aaffa7 --- /dev/null +++ b/server/internal/model/entity/app_member_login_log.go @@ -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:"创建时间"` +} diff --git a/server/internal/model/entity/member_url_binding.go b/server/internal/model/entity/member_url_binding.go new file mode 100644 index 0000000..fd86d39 --- /dev/null +++ b/server/internal/model/entity/member_url_binding.go @@ -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:"更新时间"` +} diff --git a/server/internal/model/entity/url_pool.go b/server/internal/model/entity/url_pool.go new file mode 100644 index 0000000..cb711a2 --- /dev/null +++ b/server/internal/model/entity/url_pool.go @@ -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:"删除时间"` +} diff --git a/server/internal/model/input/sysin/app_member.go b/server/internal/model/input/sysin/app_member.go index 5e5d88f..c1707ec 100644 --- a/server/internal/model/input/sysin/app_member.go +++ b/server/internal/model/input/sysin/app_member.go @@ -21,6 +21,8 @@ type AppMemberListInp struct { MemberStatus int `json:"memberStatus" dc:"会员状态"` AutoDispatch int `json:"autoDispatch" dc:"派单模式"` MemberCategory int `json:"memberCategory" dc:"会员类别"` + RegisterDomain string `json:"registerDomain" dc:"注册域名(模糊查询)"` + DomainStatus int `json:"domainStatus" dc:"域名绑定状态"` CreatedAt []*gtime.Time `json:"createdAt" dc:"注册时间"` } @@ -47,6 +49,10 @@ type AppMemberListModel struct { LastLoginRegion string `json:"lastLoginRegion"` TodayTradeCount int `json:"todayTradeCount"` CreatedAt *gtime.Time `json:"createdAt"` + UrlPoolId uint64 `json:"urlPoolId"` + RegisterDomain string `json:"registerDomain"` + DomainStatus int `json:"domainStatus"` + DomainBoundAt *gtime.Time `json:"domainBoundAt"` } type AppMemberViewInp struct { @@ -85,11 +91,8 @@ type AppMemberEditInp struct { } func (in *AppMemberEditInp) Filter(ctx context.Context) error { - if in.Id == 0 && strings.TrimSpace(in.Mobile) == "" { - return gerror.New("新增会员时手机号不能为空") - } - if in.Id == 0 && len(in.Password) < 8 { - return gerror.New("新增会员密码不能少于8位") + if in.Id == 0 { + return gerror.New("后台不支持创建会员,请由会员在C端完成注册") } if in.Password != "" && (len(in.Password) < 8 || !containsLetterAndNumber(in.Password)) { return gerror.New("密码至少8位且必须同时包含字母和数字") diff --git a/server/internal/model/input/sysin/app_member_auth.go b/server/internal/model/input/sysin/app_member_auth.go new file mode 100644 index 0000000..782d07d --- /dev/null +++ b/server/internal/model/input/sysin/app_member_auth.go @@ -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"` +} diff --git a/server/internal/model/input/sysin/app_member_login_log.go b/server/internal/model/input/sysin/app_member_login_log.go new file mode 100644 index 0000000..c37d540 --- /dev/null +++ b/server/internal/model/input/sysin/app_member_login_log.go @@ -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 +} diff --git a/server/internal/model/input/sysin/url_pool.go b/server/internal/model/input/sysin/url_pool.go new file mode 100644 index 0000000..f5f49fa --- /dev/null +++ b/server/internal/model/input/sysin/url_pool.go @@ -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"` +} diff --git a/server/internal/queues/app_member_login_log.go b/server/internal/queues/app_member_login_log.go new file mode 100644 index 0000000..e39157c --- /dev/null +++ b/server/internal/queues/app_member_login_log.go @@ -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) +} diff --git a/server/internal/router/genrouter/app_member_login_log.go b/server/internal/router/genrouter/app_member_login_log.go new file mode 100644 index 0000000..b3386ed --- /dev/null +++ b/server/internal/router/genrouter/app_member_login_log.go @@ -0,0 +1,5 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.AppMemberLoginLog) } diff --git a/server/internal/router/genrouter/url_pool.go b/server/internal/router/genrouter/url_pool.go new file mode 100644 index 0000000..f5983a3 --- /dev/null +++ b/server/internal/router/genrouter/url_pool.go @@ -0,0 +1,5 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { LoginRequiredRouter = append(LoginRequiredRouter, sys.UrlPool) } diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index 8e586bd..967230a 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -12,6 +12,7 @@ import ( "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" "hotgo/utility/tree" + "io" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/database/gredis" @@ -118,6 +119,28 @@ type ( NoticeEdit(ctx context.Context, in *sysin.MemberNoticeEditInp) error NoticeRevoke(ctx context.Context, in *sysin.MemberNoticeRevokeInp) error } + ISysUrlPool interface { + List(ctx context.Context, in *sysin.UrlPoolListInp) (list []*sysin.UrlPoolListModel, totalCount int, err error) + View(ctx context.Context, in *sysin.UrlPoolViewInp) (res *sysin.UrlPoolViewModel, err error) + Edit(ctx context.Context, in *sysin.UrlPoolEditInp) error + Status(ctx context.Context, in *sysin.UrlPoolStatusInp) error + Import(ctx context.Context, reader io.Reader) (res *sysin.UrlPoolImportModel, err error) + MemberList(ctx context.Context, in *sysin.UrlPoolMemberListInp) (list []*sysin.UrlPoolMemberListModel, totalCount int, err error) + Summary(ctx context.Context, in *sysin.UrlPoolSummaryInp) (res *sysin.UrlPoolSummaryModel, err error) + BindOnRegister(ctx context.Context, memberId uint64, origin, host string) (bound bool, domain string, err error) + } + ISysAppMemberLoginLog interface { + List(ctx context.Context, in *sysin.AppMemberLoginLogListInp) (list []*sysin.AppMemberLoginLogListModel, totalCount int, err error) + View(ctx context.Context, in *sysin.AppMemberLoginLogViewInp) (res *sysin.AppMemberLoginLogViewModel, err error) + Export(ctx context.Context, in *sysin.AppMemberLoginLogListInp) error + Push(ctx context.Context, in *sysin.AppMemberLoginLogPushInp) + RealWrite(ctx context.Context, model entity.AppMemberLoginLog) error + } + ISysAppMemberAuth interface { + Register(ctx context.Context, in *sysin.AppMemberRegisterInp) (res *sysin.AppMemberRegisterModel, err error) + AccountLogin(ctx context.Context, in *sysin.AppMemberAccountLoginInp) (res *sysin.AppMemberLoginModel, err error) + MobileLogin(ctx context.Context, in *sysin.AppMemberMobileLoginInp) (res *sysin.AppMemberLoginModel, err error) + } ISysPortal interface { SiteConfigCurrent(ctx context.Context, in *sysin.SiteConfigCurrentInp) (res *sysin.SiteConfigCurrentModel, err error) SiteConfigHistory(ctx context.Context, in *sysin.SiteConfigHistoryInp) (list []*sysin.SiteConfigHistoryModel, totalCount int, err error) @@ -547,41 +570,44 @@ type ( ) var ( - localSysMemberLevel ISysMemberLevel - localSysTaskHall ISysTaskHall - localSysTradeOrder ISysTradeOrder - localSysRechargeOrder ISysRechargeOrder - localSysWithdrawalOrder ISysWithdrawalOrder - localSysCustomerService ISysCustomerService - localSysProduct ISysProduct - localSysTask ISysTask - localSysTaskSubmission ISysTaskSubmission - localSysAppMember ISysAppMember - localSysPortal ISysPortal - localSysDashboard ISysDashboard - localSysBizAudit ISysBizAudit - localSysApproval ISysApproval - localSysAddons ISysAddons - localSysAddonsConfig ISysAddonsConfig - localSysAttachment ISysAttachment - localSysBlacklist ISysBlacklist - localSysConfig ISysConfig - localSysCron ISysCron - localSysCronGroup ISysCronGroup - localSysCurdDemo ISysCurdDemo - localSysDictData ISysDictData - localSysDictType ISysDictType - localSysEmsLog ISysEmsLog - localSysGenCodes ISysGenCodes - localSysLog ISysLog - localSysLoginLog ISysLoginLog - localSysNormalTreeDemo ISysNormalTreeDemo - localSysOptionTreeDemo ISysOptionTreeDemo - localSysProvinces ISysProvinces - localSysServeLicense ISysServeLicense - localSysServeLog ISysServeLog - localSysSmsLog ISysSmsLog - localSysTestCategory ISysTestCategory + localSysMemberLevel ISysMemberLevel + localSysTaskHall ISysTaskHall + localSysTradeOrder ISysTradeOrder + localSysRechargeOrder ISysRechargeOrder + localSysWithdrawalOrder ISysWithdrawalOrder + localSysCustomerService ISysCustomerService + localSysProduct ISysProduct + localSysTask ISysTask + localSysTaskSubmission ISysTaskSubmission + localSysAppMember ISysAppMember + localSysUrlPool ISysUrlPool + localSysAppMemberLoginLog ISysAppMemberLoginLog + localSysAppMemberAuth ISysAppMemberAuth + localSysPortal ISysPortal + localSysDashboard ISysDashboard + localSysBizAudit ISysBizAudit + localSysApproval ISysApproval + localSysAddons ISysAddons + localSysAddonsConfig ISysAddonsConfig + localSysAttachment ISysAttachment + localSysBlacklist ISysBlacklist + localSysConfig ISysConfig + localSysCron ISysCron + localSysCronGroup ISysCronGroup + localSysCurdDemo ISysCurdDemo + localSysDictData ISysDictData + localSysDictType ISysDictType + localSysEmsLog ISysEmsLog + localSysGenCodes ISysGenCodes + localSysLog ISysLog + localSysLoginLog ISysLoginLog + localSysNormalTreeDemo ISysNormalTreeDemo + localSysOptionTreeDemo ISysOptionTreeDemo + localSysProvinces ISysProvinces + localSysServeLicense ISysServeLicense + localSysServeLog ISysServeLog + localSysSmsLog ISysSmsLog + localSysTestCategory ISysTestCategory ) func SysMemberLevel() ISysMemberLevel { @@ -658,6 +684,33 @@ func SysAppMember() ISysAppMember { func RegisterSysAppMember(i ISysAppMember) { localSysAppMember = i } +func SysUrlPool() ISysUrlPool { + if localSysUrlPool == nil { + panic("implement not found for interface ISysUrlPool, forgot register?") + } + return localSysUrlPool +} + +func RegisterSysUrlPool(i ISysUrlPool) { localSysUrlPool = i } + +func SysAppMemberLoginLog() ISysAppMemberLoginLog { + if localSysAppMemberLoginLog == nil { + panic("implement not found for interface ISysAppMemberLoginLog, forgot register?") + } + return localSysAppMemberLoginLog +} + +func RegisterSysAppMemberLoginLog(i ISysAppMemberLoginLog) { localSysAppMemberLoginLog = i } + +func SysAppMemberAuth() ISysAppMemberAuth { + if localSysAppMemberAuth == nil { + panic("implement not found for interface ISysAppMemberAuth, forgot register?") + } + return localSysAppMemberAuth +} + +func RegisterSysAppMemberAuth(i ISysAppMemberAuth) { localSysAppMemberAuth = i } + func SysPortal() ISysPortal { if localSysPortal == nil { panic("implement not found for interface ISysPortal, forgot register?") diff --git a/server/manifest/config/config.example.yaml b/server/manifest/config/config.example.yaml index f642187..126b1d7 100644 --- a/server/manifest/config/config.example.yaml +++ b/server/manifest/config/config.example.yaml @@ -158,7 +158,11 @@ router: # 前缀 prefix: "/api" # 不需要验证登录的路由地址 - exceptLogin: [ ] + exceptLogin: [ + "/member/register", # C端会员注册 + "/member/accountLogin", # C端会员账号密码登录 + "/member/mobileLogin", # C端会员手机验证码登录 + ] # websocket websocket: # 前缀 diff --git a/server/storage/data/migrations/20261015_001_url_pool_member_login_log.sql b/server/storage/data/migrations/20261015_001_url_pool_member_login_log.sql new file mode 100644 index 0000000..74293f5 --- /dev/null +++ b/server/storage/data/migrations/20261015_001_url_pool_member_login_log.sql @@ -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端会员登录日志'; diff --git a/server/storage/data/migrations/20261015_002_url_pool_member_login_menu.sql b/server/storage/data/migrations/20261015_002_url_pool_member_login_menu.sql new file mode 100644 index 0000000..a0ab6d1 --- /dev/null +++ b/server/storage/data/migrations/20261015_002_url_pool_member_login_menu.sql @@ -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'; diff --git a/web/src/api/appMemberLoginLog/index.ts b/web/src/api/appMemberLoginLog/index.ts new file mode 100644 index 0000000..cc92244 --- /dev/null +++ b/web/src/api/appMemberLoginLog/index.ts @@ -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); diff --git a/web/src/api/urlPool/index.ts b/web/src/api/urlPool/index.ts new file mode 100644 index 0000000..9fca726 --- /dev/null +++ b/web/src/api/urlPool/index.ts @@ -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); diff --git a/web/src/views/app-member/list/index.vue b/web/src/views/app-member/list/index.vue index a3ea5c7..38d06ec 100644 --- a/web/src/views/app-member/list/index.vue +++ b/web/src/views/app-member/list/index.vue @@ -22,13 +22,7 @@ :action-column="actionColumn" :scroll-x="scrollX" :resize-height-offset="-10000" - > - - + />
@@ -63,12 +57,10 @@ - - {{ detail.addressCount }} 个 {{ detail.teamCount }} 人 {{ detail.createdAt }} + {{ detail.registerDomain || '待绑定' }} + {{ dict.getLabel('appMemberDomainStatus', detail.domainStatus) }} + {{ detail.domainBoundAt || '-' }} @@ -469,14 +464,13 @@ { min: 3, max: 64, message: '用户名长度为3到64个字符', trigger: ['input', 'blur'] }, ], mobile: { - validator: (_rule, value) => - editForm.id || String(value || '').trim() ? true : new Error('请输入手机号码'), + validator: () => true, trigger: ['input', 'blur'], }, password: { validator: (_rule, value) => { const text = String(value || ''); - if (editForm.id && !text) return true; + if (!text) return true; if (text.length < 8 || !/[A-Za-z]/.test(text) || !/\d/.test(text)) return new Error('密码至少8位且必须同时包含字母和数字'); return true; @@ -511,6 +505,12 @@ h(TableAction as any, { style: 'button', actions: [ + { + label: '详情', + type: 'default', + onClick: () => openDetail(record.id), + auth: ['/appMember/view'], + }, { label: '编辑', type: 'primary', @@ -602,7 +602,7 @@ actionScrollLeft = externalMax > 0 ? (el.scrollLeft * actionMax) / externalMax : 0; syncActionViewports(actionScrollLeft); } - async function openEdit(row?: MemberRecord) { + async function openEdit(row: MemberRecord) { reset(editForm, { id: row?.id || 0, username: row?.username || '', @@ -621,7 +621,6 @@ frozenAmount: row?.frozenAmount || 0, }); showEdit.value = true; - if (!row?.id) return; editLoading.value = true; try { const data: any = await View({ id: row.id }); diff --git a/web/src/views/app-member/list/model.ts b/web/src/views/app-member/list/model.ts index e1eac4b..191311d 100644 --- a/web/src/views/app-member/list/model.ts +++ b/web/src/views/app-member/list/model.ts @@ -11,6 +11,7 @@ export interface MemberRecord { availableAmount: number; frozenAmount: number; parentId: number; parentUsername: string; inviteCode: string; memberStatus: number; riskStatus: number; autoDispatch: number; memberCategory: number; isTestAccount: number; lastLoginAt?: string; lastLoginIpMask: string; lastLoginRegion: string; + urlPoolId: number; registerDomain: string; domainStatus: number; domainBoundAt?: string; todayTradeCount: number; createdAt: string; } @@ -20,6 +21,8 @@ export const schemas = ref([ { field: 'memberStatus', component: 'NSelect', label: '状态', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberStatus')), clearable: true } }, { field: 'autoDispatch', component: 'NSelect', label: '派单模式', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberDispatchMode')), clearable: true } }, { field: 'memberCategory', component: 'NSelect', label: '类别', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberCategory')), clearable: true } }, + { field: 'registerDomain', component: 'NInput', label: '注册域名', componentProps: { placeholder: '请输入域名关键词' } }, + { field: 'domainStatus', component: 'NSelect', label: '域名状态', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberDomainStatus')), clearable: true } }, { field: 'createdAt', component: 'NDatePicker', label: '注册时间', componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() } }, ]); @@ -42,9 +45,12 @@ export const columns = [ { title: '今日交易', key: 'todayTradeCount', width: 90 }, { title: '最后登录IP', key: 'lastLoginIpMask', width: 130, render: (row: MemberRecord) => row.lastLoginIpMask || '-' }, { title: '最后登录地区', key: 'lastLoginRegion', width: 140, render: (row: MemberRecord) => row.lastLoginRegion || '-' }, + { title: '注册域名', key: 'registerDomain', width: 220, render: (row: MemberRecord) => row.registerDomain || '-' }, + { title: '域名状态', key: 'domainStatus', width: 110, render: (row: MemberRecord) => tag('appMemberDomainStatus', row.domainStatus) }, + { title: '绑定时间', key: 'domainBoundAt', width: 180, render: (row: MemberRecord) => row.domainBoundAt || '-' }, { title: '注册时间', key: 'createdAt', width: 180 }, ]; export function loadOptions() { - dict.loadOptions(['appMemberStatus', 'appMemberRiskStatus', 'appMemberDispatchMode', 'appMemberCategory', 'paymentAccountType', 'walletAdjustType', 'walletDirection', 'sys_switch', 'member_disable_reason', 'wallet_adjust_reason']); + dict.loadOptions(['appMemberStatus', 'appMemberRiskStatus', 'appMemberDispatchMode', 'appMemberCategory', 'appMemberDomainStatus', 'paymentAccountType', 'walletAdjustType', 'walletDirection', 'sys_switch', 'member_disable_reason', 'wallet_adjust_reason']); } diff --git a/web/src/views/app-member/login-log/index.vue b/web/src/views/app-member/login-log/index.vue new file mode 100644 index 0000000..18d7057 --- /dev/null +++ b/web/src/views/app-member/login-log/index.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/web/src/views/app-member/login-log/model.ts b/web/src/views/app-member/login-log/model.ts new file mode 100644 index 0000000..713f898 --- /dev/null +++ b/web/src/views/app-member/login-log/model.ts @@ -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([ + { 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']); +} diff --git a/web/src/views/app-member/url-pool/index.vue b/web/src/views/app-member/url-pool/index.vue new file mode 100644 index 0000000..17e9d5e --- /dev/null +++ b/web/src/views/app-member/url-pool/index.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/web/src/views/app-member/url-pool/model.ts b/web/src/views/app-member/url-pool/model.ts new file mode 100644 index 0000000..5fe1217 --- /dev/null +++ b/web/src/views/app-member/url-pool/model.ts @@ -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([ + { 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']); +}