From 2a18a6fb47dd74ed78ae3b0273e5f5bff8bdf8c9 Mon Sep 17 00:00:00 2001 From: long-agent Date: Fri, 8 May 2026 08:05:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(n+1):=20=E6=89=B9=E9=87=8F=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=9B=BF=E4=BB=A3=E5=BE=AA=E7=8E=AF=E5=8D=95=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IsAdminBootstrapRequired: userRepo.GetByID 循环 → GetByIDs 批量 - AssignRoles: roleRepo.GetByID 循环 → GetByIDs 批量 - 在 userRepositoryInterface 补充 GetByIDs 方法签名 --- .claude/settings.local.json | 20 +- .workbuddy/expert-history.json | 24 +- docs/architecture-design.md | 826 ++++++++++++++++++ .../PRODUCTION_READINESS_REVIEW_2026-05-07.md | 303 +++++++ docs/status/REAL_PROJECT_STATUS.md | 136 +++ .../2026-04-24-profile-page-local-closure.md | 55 ++ ...04-24-profile-page-local-closure-design.md | 48 + docs/team/PRODUCTION_CHECKLIST.md | 31 + docs/team/PROJECT_EXPERIENCE_SUMMARY.md | 24 + docs/team/QUALITY_STANDARD.md | 11 + docs/team/TECHNICAL_GUIDE.md | 56 ++ .../scripts/playwright-e2e-scenarios.mjs | 43 + frontend/admin/scripts/run-cdp-smoke.mjs | 24 +- frontend/admin/scripts/run-cdp-smoke.ps1 | 6 +- .../admin/scripts/run-playwright-auth-e2e.ps1 | 138 ++- .../admin/scripts/run-playwright-cdp-e2e.mjs | 159 +++- .../src/lib/playwright-e2e-scenarios.test.ts | 33 + internal/api/handler/device_handler.go | 63 +- internal/api/handler/handler_test.go | 379 ++++++++ internal/api/handler/user_handler.go | 105 ++- internal/api/middleware/operation_log.go | 5 + internal/api/middleware/ratelimit.go | 44 + internal/api/router/router.go | 5 +- internal/auth/totp.go | 59 +- internal/auth/totp_test.go | 26 +- internal/repository/device.go | 10 + internal/repository/role.go | 10 +- internal/repository/role_permission.go | 50 +- internal/repository/user.go | 27 + internal/repository/user_role.go | 118 +-- internal/security/validator.go | 133 +-- internal/server/server.go | 3 + internal/service/auth.go | 56 +- internal/service/auth_capabilities.go | 21 +- internal/service/auth_runtime.go | 44 +- internal/service/device.go | 219 +++-- internal/service/device_service_test.go | 99 +++ internal/service/user_service.go | 111 ++- internal/service/user_service_test.go | 38 + 39 files changed, 3169 insertions(+), 393 deletions(-) create mode 100644 docs/architecture-design.md create mode 100644 docs/reviews/PRODUCTION_READINESS_REVIEW_2026-05-07.md create mode 100644 docs/superpowers/plans/2026-04-24-profile-page-local-closure.md create mode 100644 docs/superpowers/specs/2026-04-24-profile-page-local-closure-design.md create mode 100644 frontend/admin/scripts/playwright-e2e-scenarios.mjs create mode 100644 frontend/admin/src/lib/playwright-e2e-scenarios.test.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 847e7b2..c6035b6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -180,7 +180,25 @@ "Bash(grep -E \"\\(PASS|FAIL|ok|FAIL\\)\" \"C:\\\\\\\\Users\\\\\\\\Admin\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\D--usersystem\\\\\\\\585b7397-1a42-4c4c-95db-d0593f685b99\\\\\\\\tasks\\\\\\\\bdnygqovb.output\")", "Bash(grep -E \"^ok|^FAIL\" \"C:\\\\\\\\Users\\\\\\\\Admin\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\D--usersystem\\\\\\\\585b7397-1a42-4c4c-95db-d0593f685b99\\\\\\\\tasks\\\\\\\\bdnygqovb.output\")", "Bash(grep -c \"--- PASS\" \"C:\\\\\\\\Users\\\\\\\\Admin\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\D--usersystem\\\\\\\\585b7397-1a42-4c4c-95db-d0593f685b99\\\\\\\\tasks\\\\\\\\bdnygqovb.output\")", - "Bash(grep -c \"--- FAIL\" \"C:\\\\\\\\Users\\\\\\\\Admin\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\D--usersystem\\\\\\\\585b7397-1a42-4c4c-95db-d0593f685b99\\\\\\\\tasks\\\\\\\\bdnygqovb.output\")" + "Bash(grep -c \"--- FAIL\" \"C:\\\\\\\\Users\\\\\\\\Admin\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\D--usersystem\\\\\\\\585b7397-1a42-4c4c-95db-d0593f685b99\\\\\\\\tasks\\\\\\\\bdnygqovb.output\")", + "Bash(npx vitest *)", + "Bash(sqlite3 :memory: \"SELECT 'test_user' LIKE '%\\\\%' AS result;\")", + "Bash(./bin/ums version *)", + "Bash(make build-cli *)", + "Bash(./bin/ums help *)", + "Bash(./bin/ums init *)", + "Bash(timeout 5 ./bin/ums serve)", + "Bash(./bin/ums serve *)", + "Bash(pkill -f \"bin/ums serve\")", + "Bash(taskkill //F //IM ums.exe)", + "Bash(./bin/ums *)", + "Bash(pkill -f \"bin/ums\")", + "Bash(pkill -f \"server\")", + "Bash(git restore *)", + "Bash(git checkout *)", + "Bash(git pull *)", + "Bash(git merge *)", + "Bash(git stash *)" ] } } diff --git a/.workbuddy/expert-history.json b/.workbuddy/expert-history.json index a340cec..acd6df9 100644 --- a/.workbuddy/expert-history.json +++ b/.workbuddy/expert-history.json @@ -121,7 +121,29 @@ "usedAt": 1775967622172, "industryId": "02-Engineering" } + ], + "cf149af00a33475b851ceb99d380e7c4": [ + { + "expertId": "CodeReviewExpert", + "name": "火眼眼", + "profession": "代码审查专家", + "avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/CodeReviewExpert/CodeReviewExpert.png", + "promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/CodeReviewExpert/CodeReviewExpert_zh.md", + "usedAt": 1776436687208, + "industryId": "02-Engineering" + } + ], + "743642b96ec847f0b7ff82ebd896296d": [ + { + "expertId": "PerformanceTestingExpert", + "name": "压测测", + "profession": "性能测试专家", + "avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/08-QualityAssurance/PerformanceTestingExpert/PerformanceTestingExpert.png", + "promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/08-QualityAssurance/PerformanceTestingExpert/PerformanceTestingExpert_zh.md", + "usedAt": 1776519150854, + "industryId": "08-QualityAssurance" + } ] }, - "lastUpdated": 1775973310025 + "lastUpdated": 1776524307480 } \ No newline at end of file diff --git a/docs/architecture-design.md b/docs/architecture-design.md new file mode 100644 index 0000000..5248e78 --- /dev/null +++ b/docs/architecture-design.md @@ -0,0 +1,826 @@ +# 用户管理系统架构设计文档 + +> 版本:v1.0 +> 更新日期:2026-05-07 +> 适用范围:UMS (User Management System) + +--- + +## 1. 技术栈与框架选型 + +### 1.1 后端技术栈 + +| 层级 | 技术选型 | 版本/说明 | +|------|---------|----------| +| **开发语言** | Go | 1.21+,高性能、原生并发、低内存占用 | +| **Web 框架** | Gin | 轻量级、高性能 HTTP 路由与中间件 | +| **ORM / 数据库** | GORM | 支持 PostgreSQL / SQLite / MySQL,自动迁移 | +| **缓存** | Redis (go-redis) | 可选启用,分布式会话与热点缓存 | +| **本地缓存** | 自研 LocalCache | 内存 L1 缓存,TTL + 后台清理 | +| **配置管理** | Viper | YAML + 环境变量统一配置 | +| **日志** | Zap | 高性能结构化日志 | +| **JWT** | golang-jwt/jwt | RS256 签名,支持 JTI 与 Token 滚动轮换 | +| **密码哈希** | golang.org/x/crypto/argon2 | Argon2id,启动时自适应校准 | +| **TOTP 2FA** | github.com/pquerna/otp | RFC 6238 兼容 | +| **监控** | Prometheus + OpenTelemetry | 指标采集与链路追踪 | +| **限流** | Uber Rate Limit / 自研 | 令牌桶 + 内存清理 | +| **容器化** | Docker | 单容器 + Docker Compose 编排 | +| **编排(可选)** | Kubernetes | 生产集群部署 | + +### 1.2 前端技术栈(Admin 后台) + +| 层级 | 技术选型 | 说明 | +|------|---------|------| +| **框架** | React 18 + TypeScript | 类型安全、组件化开发 | +| **构建工具** | Vite | 快速冷启动与热更新 | +| **UI 组件库** | Ant Design 5 | 企业级后台组件 | +| **状态管理** | React Context(会话态) | 不引入重型状态库 | +| **HTTP 客户端** | 原生 `fetch` + 统一请求客户端 | 无 Axios 依赖 | +| **路由** | React Router 6 | 受保护路由方案 | +| **样式** | CSS Modules + CSS Variables + AntD Theme Token | 无 styled-components | + +### 1.3 基础设施 + +| 组件 | 技术选型 | 说明 | +|------|---------|------| +| **负载均衡** | Nginx | 反向代理、SSL 终止、静态资源缓存 | +| **消息队列(可选)** | Kafka / RabbitMQ | 异步事件、Webhook 投递 | +| **对象存储(可选)** | OSS / S3 | 头像与文件上传 | +| **监控大盘** | Grafana | 可视化 Prometheus 指标 | +| **日志收集** | ELK / Loki | 集中化日志检索 | + +--- + +## 2. 目录结构与分层说明 + +项目采用 **Clean Architecture** 分层,依赖方向始终向内: + +``` +handler → service → repository → domain +``` + +### 2.1 目录结构 + +``` +├── cmd/ +│ ├── server/ # HTTP 服务入口 +│ └── ums/ # CLI 工具入口 +├── internal/ +│ ├── api/ +│ │ ├── handler/ # HTTP 请求处理器 (Handler 层) +│ │ ├── middleware/ # Gin 中间件(认证、限流、日志、CORS 等) +│ │ └── router/ # 路由注册与分组 +│ ├── auth/ +│ │ └── providers/ # OAuth2 Provider 实现 +│ ├── cache/ # 本地缓存 + Redis 封装 +│ ├── concurrent/ # 并发工具(WorkerPool、SingleFlight) +│ ├── config/ # 配置结构与加载 +│ ├── database/ # GORM 初始化、连接池、读写分离 +│ ├── domain/ # 领域实体(User、Role、Permission 等) +│ ├── e2e/ # 端到端测试 +│ ├── integration/ # 集成测试 +│ ├── middleware/ # 共享中间件(与 api/middleware 区分) +│ ├── monitoring/ # Prometheus 指标与链路追踪 +│ ├── pagination/ # 游标分页与 OFFSET 分页封装 +│ ├── performance/ # 性能测试与基准测试 +│ ├── pkg/ # 内部公共包 +│ │ ├── errors/ # 错误码与错误包装 +│ │ ├── ip/ # IP 解析与过滤 +│ │ ├── oauth/ # OAuth2 辅助工具 +│ │ └── ... +│ ├── repository/ # 数据访问层(Repository 层) +│ ├── robustness/ # 鲁棒性工具(熔断、重试) +│ ├── security/ # 安全工具(密码策略、加密、校验) +│ ├── server/ # HTTP Server 生命周期管理 +│ ├── service/ # 业务逻辑层(Service 层) +│ ├── testdb/ # 测试数据库辅助 +│ ├── testutil/ # 测试工具函数 +│ └── util/ # 通用工具包 +├── pkg/ +│ └── errors/ # 对外暴露的错误包 +├── configs/ +│ └── config.yaml # 默认配置文件 +├── deployments/ +│ ├── docker-compose.yml # 本地编排 +│ └── kubernetes/ # K8s 清单 +├── docs/ # 设计文档与 API 文档 +├── frontend/ # React Admin 前端(独立目录) +├── migrations/ # 数据库迁移脚本 +├── scripts/ # 构建与运维脚本 +├── sdk/ # 客户端 SDK +├── uploads/ # 本地上传文件存储(受保护) +└── tools/ # 开发工具 +``` + +### 2.2 分层职责 + +| 分层 | 目录 | 职责 | 依赖规则 | +|------|------|------|----------| +| **Handler 层** | `internal/api/handler` | HTTP 请求解析、参数校验、响应封装、调用 Service | 仅依赖 Service 层 | +| **Service 层** | `internal/service` | 业务逻辑编排、事务管理、领域事件触发 | 仅依赖 Repository 与 Domain | +| **Repository 层** | `internal/repository` | 数据持久化、查询优化、ORM 操作 | 仅依赖 Domain | +| **Domain 层** | `internal/domain` | 实体定义、值对象、领域规则、接口契约 | 不依赖任何外部层 | +| **基础设施层** | `internal/cache`, `internal/database`, `internal/config` | 技术实现(缓存、数据库、配置) | 可被上层通过接口注入 | + +--- + +## 3. 核心模块架构图 + +### 3.1 整体模块交互 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 外部客户端 │ +│ (Web Admin / Mobile App / 第三方 OAuth / SDK 调用方) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ +┌───────────────────────────────▼─────────────────────────────────────┐ +│ API 网关层 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 认证中间件 │ │ 限流中间件 │ │ 日志中间件 │ │ +│ │ (JWT/OAuth2) │ │ (RateLimit) │ │ (AccessLog) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ CORS 中间件 │ │ CSRF 中间件 │ │ IP 过滤中间件 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ +┌───────────────────────────────▼─────────────────────────────────────┐ +│ Handler 层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Auth │ │ User │ │ Role │ │ Device │ │ Log │ │ +│ │ Handler │ │ Handler │ │ Handler │ │ Handler │ │ Handler │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Admin │ │ Webhook │ │ 2FA │ │ OAuth │ │ +│ │ Handler │ │ Handler │ │ Handler │ │ Handler │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ +┌───────────────────────────────▼─────────────────────────────────────┐ +│ Service 层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Auth │ │ User │ │ Role │ │ Device │ │ Log │ │ +│ │ Service │ │ Service │ │ Service │ │ Service │ │ Service │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Admin │ │ Webhook │ │ 2FA │ │ OAuth │ │ +│ │ Service │ │ Service │ │ Service │ │ Service │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ +┌───────────────────────────────▼─────────────────────────────────────┐ +│ Repository 层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ UserRepo │ │ RoleRepo │ │ PermRepo │ │ DevRepo │ │ LogRepo │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Social │ │ Password │ │ Webhook │ │ +│ │ Repo │ │ History │ │ Repo │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ +┌───────────────────────────────▼─────────────────────────────────────┐ +│ Domain 层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ User │ │ Role │ │ Permission│ │ Device │ │ LoginLog │ │ +│ │ Entity │ │ Entity │ │ Entity │ │ Entity │ │ Entity │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Operation│ │ Webhook │ │ Password │ │ +│ │ Log │ │ Entity │ │ History │ │ +│ │ Entity │ │ │ │ Entity │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 请求处理流程 + +| 步骤 | 组件 | 动作 | +|------|------|------| +| 1 | Nginx / Ingress | SSL 终止、静态资源缓存、反向代理 | +| 2 | Gin Router | 路由匹配、路径参数解析 | +| 3 | Middleware Chain | 限流 → IP 过滤 → CORS → CSRF → 认证 → 日志 | +| 4 | Handler | 绑定请求体、参数校验、调用 Service | +| 5 | Service | 业务逻辑、权限检查、事务封装 | +| 6 | Repository | ORM 查询 / 写入、缓存读写 | +| 7 | Database / Cache | 数据持久化或缓存命中 | +| 8 | Service | 组装领域结果、触发异步事件(Webhook、日志) | +| 9 | Handler | 统一响应封装(code / message / data) | +| 10 | Middleware | 记录访问日志、更新 Prometheus 指标 | + +### 3.3 核心模块职责表 + +| 模块 | 职责 | 关键文件/包 | +|------|------|------------| +| **认证 (Auth)** | 注册、登录、登出、JWT 签发与刷新、密码重置、TOTP | `internal/auth`, `internal/api/handler/auth_handler.go` | +| **用户 (User)** | CRUD、头像上传、状态管理、角色分配、导入导出 | `internal/service/user_service.go`, `internal/repository/user_repository.go` | +| **RBAC** | 角色管理、权限管理、角色继承、权限树 | `internal/domain/role.go`, `internal/service/role_service.go` | +| **设备 (Device)** | 设备注册、信任管理、多设备登出 | `internal/api/handler/device_handler.go` | +| **日志 (Log)** | 登录日志、操作日志、查询与审计 | `internal/repository/log_repository.go` | +| **OAuth2** | 第三方登录、社交账号绑定/解绑 | `internal/auth/providers/` | +| **Webhook** | 事件订阅、异步投递、重试机制 | `internal/service/webhook_service.go` | +| **Admin** | 仪表盘统计、批量导入导出 | `internal/api/handler/admin_handler.go` | +| **安全 (Security)** | 密码策略、IP 过滤、敏感数据脱敏 | `internal/security/` | + +--- + +## 4. 数据模型 + +### 4.1 实体关系总览 + +``` +users ||--o{ user_roles : "多对多" +users ||--o{ devices : "一对多" +users ||--o{ login_logs : "一对多" +users ||--o{ operation_logs : "一对多" +users ||--o{ user_social_accounts : "一对多" +users ||--o{ password_history : "一对多" + +roles ||--o{ user_roles : "多对多" +roles ||--o{ role_permissions : "多对多" +roles ||--o{ roles : "自关联(继承)" + +permissions ||--o{ role_permissions : "多对多" + +webhooks ||--o{ webhook_deliveries : "一对多" +``` + +### 4.2 核心实体定义 + +#### User(用户) + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| id | BIGINT | PK, AutoIncrement | 用户唯一标识 | +| username | VARCHAR(50) | UNIQUE, Index | 用户名 | +| email | VARCHAR(100) | UNIQUE, Index | 邮箱地址 | +| phone | VARCHAR(20) | UNIQUE, Index | 手机号 | +| password | VARCHAR(255) | Not Null | Argon2id 哈希密码 | +| nickname | VARCHAR(50) | Nullable | 昵称 | +| avatar | VARCHAR(255) | Nullable | 头像 URL | +| gender | TINYINT | Default 0 | 性别:0-未知,1-男,2-女 | +| birthday | DATE | Nullable | 生日 | +| region | VARCHAR(50) | Nullable | 所在地区 | +| bio | VARCHAR(500) | Nullable | 个性签名 | +| status | TINYINT | Default 1, Index | 状态:0-待激活,1-正常,2-锁定,3-禁用 | +| totp_secret | VARCHAR(255) | Nullable | TOTP 密钥(加密存储) | +| totp_enabled | TINYINT | Default 0 | 是否启用 TOTP | +| password_changed_at | DATETIME | Nullable | 密码最后修改时间(用于 Token 失效) | +| last_login_time | DATETIME | Nullable | 最后登录时间 | +| last_login_ip | VARCHAR(50) | Nullable | 最后登录 IP | +| created_at | DATETIME | Default CURRENT_TIMESTAMP | 创建时间 | +| updated_at | DATETIME | AutoUpdate | 更新时间 | +| deleted_at | DATETIME | Nullable, Index | 软删除时间(GORM 支持) | + +**索引**:`uk_username`, `uk_email`, `uk_phone`, `idx_status`, `idx_created_at` + +#### Role(角色) + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| id | BIGINT | PK, AutoIncrement | 角色唯一标识 | +| name | VARCHAR(50) | UNIQUE, Not Null | 角色名称 | +| code | VARCHAR(50) | UNIQUE, Not Null | 角色代码(如 `admin`, `user`) | +| description | VARCHAR(200) | Nullable | 角色描述 | +| parent_id | BIGINT | FK → roles.id, Nullable | 父角色 ID(继承关系) | +| level | INT | Default 1, Index | 角色层级 | +| is_system | TINYINT | Default 0 | 是否系统内置角色 | +| is_default | TINYINT | Default 0, Index | 是否新用户默认角色 | +| status | TINYINT | Default 1 | 状态:0-禁用,1-启用 | +| created_at | DATETIME | Default CURRENT_TIMESTAMP | 创建时间 | +| updated_at | DATETIME | AutoUpdate | 更新时间 | + +**索引**:`uk_name`, `uk_code`, `idx_parent_id`, `idx_level` + +**初始数据**: +- `id=1, code='admin', name='管理员', is_system=1` —— 系统管理员 +- `id=2, code='user', name='普通用户', is_system=1, is_default=1` —— 默认用户 + +#### Permission(权限) + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| id | BIGINT | PK, AutoIncrement | 权限唯一标识 | +| name | VARCHAR(50) | Not Null | 权限名称 | +| code | VARCHAR(100) | UNIQUE, Not Null | 权限代码(格式 `resource:action`) | +| resource | VARCHAR(50) | Not Null, Index | 资源名称(如 `user`, `role`) | +| action | VARCHAR(20) | Not Null | 操作类型:`read` / `write` / `delete` / `execute` | +| description | VARCHAR(200) | Nullable | 权限描述 | +| type | VARCHAR(20) | Not Null, Index | 权限类型:`api` / `page` / `button` | +| group_id | BIGINT | Nullable, Index | 权限分组 ID | +| status | TINYINT | Default 1 | 状态:0-禁用,1-启用 | +| created_at | DATETIME | Default CURRENT_TIMESTAMP | 创建时间 | +| updated_at | DATETIME | AutoUpdate | 更新时间 | + +**索引**:`uk_code`, `idx_resource`, `idx_group_id` + +#### Device(设备) + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| id | BIGINT | PK, AutoIncrement | 设备唯一标识 | +| user_id | BIGINT | FK → users.id, Not Null, Index | 所属用户 | +| device_id | VARCHAR(100) | UNIQUE, Not Null | 设备唯一标识字符串 | +| device_name | VARCHAR(50) | Nullable | 设备名称 | +| device_type | VARCHAR(20) | Not Null | 设备类型:`pc` / `mobile` / `tablet` | +| os | VARCHAR(50) | Nullable | 操作系统 | +| browser | VARCHAR(50) | Nullable | 浏览器 | +| ip | VARCHAR(50) | Nullable | 最近 IP | +| location | VARCHAR(100) | Nullable | 地理位置 | +| is_trusted | TINYINT | Default 0 | 是否信任设备(跳过 2FA) | +| trust_expires_at | DATETIME | Nullable | 信任状态过期时间 | +| last_active_time | DATETIME | Nullable, Index | 最后活跃时间 | +| created_at | DATETIME | Default CURRENT_TIMESTAMP | 创建时间 | + +**索引**:`idx_user_id`, `uk_device_id`, `idx_last_active_time` + +#### LoginLog(登录日志) + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| id | BIGINT | PK, AutoIncrement | 日志唯一标识 | +| user_id | BIGINT | FK → users.id, Nullable, Index | 用户 ID(匿名登录为 NULL) | +| login_type | VARCHAR(20) | Not Null | 登录方式:`password` / `code` / `wechat` / `github` / ... | +| login_method | VARCHAR(20) | Nullable | 认证子方式 | +| ip | VARCHAR(50) | Nullable, Index | 登录 IP | +| location | VARCHAR(100) | Nullable | 地理位置 | +| device_id | VARCHAR(100) | Nullable | 设备标识 | +| user_agent | VARCHAR(500) | Nullable | User-Agent | +| status | TINYINT | Not Null, Index | 状态:0-失败,1-成功 | +| failure_reason | VARCHAR(200) | Nullable | 失败原因 | +| created_at | DATETIME | Default CURRENT_TIMESTAMP, Index | 登录时间 | + +**索引**:`idx_user_id`, `idx_ip`, `idx_status`, `idx_created_at` +**分区建议**(MySQL/PostgreSQL):按月分区,保留最近 12 个月。 + +#### OperationLog(操作日志) + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| id | BIGINT | PK, AutoIncrement | 日志唯一标识 | +| user_id | BIGINT | FK → users.id, Nullable, Index | 操作人 ID | +| action_type | VARCHAR(50) | Not Null | 操作类型(如 `user:create`) | +| resource_type | VARCHAR(50) | Not Null, Index | 资源类型(如 `user`, `role`) | +| resource_id | BIGINT | Nullable | 资源 ID | +| action | VARCHAR(20) | Not Null | 动作:`create` / `update` / `delete` | +| old_value | TEXT | Nullable | 操作前值(JSON) | +| new_value | TEXT | Nullable | 操作后值(JSON) | +| ip | VARCHAR(50) | Nullable | 操作 IP | +| user_agent | VARCHAR(500) | Nullable | User-Agent | +| created_at | DATETIME | Default CURRENT_TIMESTAMP, Index | 操作时间 | + +**索引**:`idx_user_id`, `idx_resource_type`, `idx_created_at` +**分区建议**(MySQL/PostgreSQL):按月分区,保留最近 24 个月。 + +### 4.3 关联表 + +| 关联表 | 关联实体 | 说明 | +|--------|---------|------| +| `user_roles` | users ↔ roles | 多对多:用户角色分配 | +| `role_permissions` | roles ↔ permissions | 多对多:角色权限分配 | +| `user_social_accounts` | users | 一对多:第三方社交账号绑定 | +| `password_history` | users | 一对多:密码历史(防重用) | +| `webhooks` | - | Webhook 配置 | +| `webhook_deliveries` | webhooks | Webhook 投递日志 | + +--- + +## 5. 接口设计(RESTful API 分组列表) + +基础路径:`/api/v1` +认证方式:`Authorization: Bearer ` +统一响应:`{ "code": 0, "message": "success", "data": {} }` + +### 5.1 认证组(Auth) + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| POST | `/auth/register` | 公开 | 用户注册 | +| POST | `/auth/bootstrap-admin` | 公开 | 初始化管理员(首次启动) | +| POST | `/auth/login` | 公开 | 账号密码登录 | +| POST | `/auth/login/email-code` | 公开 | 邮箱验证码登录 | +| POST | `/auth/login/code` | 公开 | 短信验证码登录 | +| POST | `/auth/refresh` | 公开 | 刷新 Access Token(Refresh Token) | +| POST | `/auth/logout` | 需认证 | 登出 | +| GET | `/auth/userinfo` | 需认证 | 获取当前用户信息 | +| GET | `/auth/capabilities` | 公开 | 获取系统能力配置 | +| GET | `/auth/activate` | 公开 | 邮箱激活 | +| POST | `/auth/resend-activation` | 公开 | 重发激活邮件 | +| POST | `/auth/forgot-password` | 公开 | 忘记密码 | +| GET | `/auth/reset-password` | 公开 | 验证重置 Token 页面 | +| POST | `/auth/reset-password` | 公开 | 提交新密码 | +| POST | `/auth/send-email-code` | 公开 | 发送邮箱验证码 | +| POST | `/auth/send-code` | 公开 | 发送短信验证码 | +| GET | `/auth/csrf-token` | 公开 | 获取 CSRF Token | +| GET | `/auth/captcha` | 公开 | 获取验证码配置 | +| GET | `/auth/captcha/image` | 公开 | 获取图形验证码 | +| POST | `/auth/captcha/verify` | 公开 | 验证图形验证码 | + +### 5.2 双因素认证组(2FA / TOTP) + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| GET | `/auth/2fa/status` | 需认证 | 获取 2FA 状态 | +| GET | `/auth/2fa/setup` | 需认证 | 获取 TOTP 设置信息(QR Code) | +| POST | `/auth/2fa/enable` | 需认证 | 启用 TOTP | +| POST | `/auth/2fa/disable` | 需认证 | 禁用 TOTP | +| POST | `/auth/2fa/verify` | 需认证 | 验证 TOTP 码 | + +### 5.3 OAuth2 组 + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| GET | `/auth/oauth/providers` | 公开 | 获取已配置的 Provider 列表 | +| GET | `/auth/oauth/:provider` | 公开 | 跳转 OAuth2 授权页 | +| GET | `/auth/oauth/:provider/callback` | 公开 | OAuth2 回调处理 | + +### 5.4 用户组(User) + +| 方法 | 路径 | 认证/权限 | 说明 | +|------|------|----------|------| +| GET | `/users` | 管理员 | 用户列表(分页、筛选、排序) | +| GET | `/users/:id` | 本人或管理员 | 获取用户详情 | +| PUT | `/users/:id` | 本人或管理员 | 更新用户信息 | +| DELETE | `/users/:id` | `user:delete` | 删除用户 | +| PUT | `/users/:id/password` | 本人 | 修改密码 | +| PUT | `/users/:id/status` | `user:manage` | 修改用户状态 | +| GET | `/users/:id/roles` | 本人或管理员 | 获取用户角色 | +| PUT | `/users/:id/roles` | `user:manage` | 分配用户角色 | +| POST | `/users/:id/avatar` | 需认证 | 上传头像 | +| GET | `/users/me/social-accounts` | 需认证 | 获取当前用户社交账号 | +| POST | `/users/me/bind-social` | 需认证 | 绑定社交账号 | +| DELETE | `/users/me/bind-social/:provider` | 需认证 | 解绑社交账号 | + +### 5.5 角色与权限组(RBAC) + +| 方法 | 路径 | 认证/权限 | 说明 | +|------|------|----------|------| +| POST | `/roles` | 管理员 | 创建角色 | +| GET | `/roles` | 管理员 | 角色列表 | +| GET | `/roles/:id` | 管理员 | 角色详情 | +| PUT | `/roles/:id` | 管理员 | 更新角色 | +| DELETE | `/roles/:id` | 管理员 | 删除角色 | +| PUT | `/roles/:id/status` | 管理员 | 修改角色状态 | +| GET | `/roles/:id/permissions` | 管理员 | 获取角色权限 | +| PUT | `/roles/:id/permissions` | 管理员 | 分配角色权限 | +| POST | `/permissions` | 管理员 | 创建权限 | +| GET | `/permissions` | 管理员 | 权限列表 | +| GET | `/permissions/tree` | 管理员 | 权限树形结构 | +| GET | `/permissions/:id` | 管理员 | 权限详情 | +| PUT | `/permissions/:id` | 管理员 | 更新权限 | +| DELETE | `/permissions/:id` | 管理员 | 删除权限 | +| PUT | `/permissions/:id/status` | 管理员 | 修改权限状态 | + +### 5.6 设备组(Device) + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| GET | `/devices` | 需认证 | 设备列表 | +| POST | `/devices` | 需认证 | 注册设备 | +| GET | `/devices/:id` | 需认证 | 设备详情 | +| PUT | `/devices/:id` | 需认证 | 更新设备 | +| DELETE | `/devices/:id` | 需认证 | 删除设备 | +| PUT | `/devices/:id/status` | 需认证 | 修改设备状态 | +| POST | `/devices/:id/trust` | 需认证 | 设置设备信任 | +| DELETE | `/devices/:id/trust` | 需认证 | 取消设备信任 | +| POST | `/devices/by-device-id/:deviceId/trust` | 需认证 | 按设备标识设置信任 | +| GET | `/devices/me/trusted` | 需认证 | 获取信任设备列表 | +| POST | `/devices/me/logout-others` | 需认证 | 登出所有其他设备 | +| GET | `/devices/users/:id` | 管理员 | 获取指定用户的设备 | + +### 5.7 日志组(Log) + +| 方法 | 路径 | 认证/权限 | 说明 | +|------|------|----------|------| +| GET | `/logs/login/me` | 需认证 | 当前用户登录日志 | +| GET | `/logs/operation/me` | 需认证 | 当前用户操作日志 | +| GET | `/logs/login` | 管理员 | 全量登录日志 | +| GET | `/logs/operation` | 管理员 | 全量操作日志 | + +### 5.8 Webhook 组 + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| POST | `/webhooks` | 需认证 | 创建 Webhook | +| GET | `/webhooks` | 需认证 | Webhook 列表 | +| PUT | `/webhooks/:id` | 需认证 | 更新 Webhook | +| DELETE | `/webhooks/:id` | 需认证 | 删除 Webhook | +| GET | `/webhooks/:id/deliveries` | 需认证 | 投递记录 | + +### 5.9 管理员扩展组(Admin) + +| 方法 | 路径 | 认证/权限 | 说明 | +|------|------|----------|------| +| GET | `/admin/users/export` | 管理员 | 导出用户(CSV/XLSX) | +| POST | `/admin/users/import` | 管理员 | 导入用户 | +| GET | `/admin/users/import/template` | 管理员 | 下载导入模板 | +| GET | `/admin/stats/dashboard` | 管理员 | 仪表盘统计 | +| GET | `/admin/stats/users` | 管理员 | 用户统计 | + +### 5.10 基础设施端点 + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| GET | `/health` | 公开 | 健康检查 | +| GET | `/health/live` | 公开 | Liveness Probe | +| GET | `/health/ready` | 公开 | Readiness Probe | +| GET | `/metrics` | 公开 | Prometheus 指标 | +| GET | `/swagger/*any` | 公开 | Swagger 文档 | + +--- + +## 6. 安全设计 + +### 6.1 认证机制 + +| 机制 | 实现 | 说明 | +|------|------|------| +| **JWT 访问令牌** | RS256 非对称签名 | Access Token 有效期短(默认 2h),携带用户 ID、角色、权限 | +| **Refresh Token** | 独立令牌,滚动轮换 | 每次刷新后旧 Refresh Token 失效,防重放 | +| **JTI 唯一标识** | timestamp(8B hex) + random(16B hex) | 防 Token 枚举,支持精确吊销 | +| **Token 黑名单** | Redis / 内存缓存 | 登出、密码修改后 Token 立即失效 | +| **密码修改失效(PCE)** | `password_changed_at` 字段 | 密码修改后旧 Token 自动失效 | +| **TOTP 双因素认证** | RFC 6238 | 6 位动态码,支持信任设备跳过 | +| **设备信任** | `is_trusted` + `trust_expires_at` | 信任设备在有效期内免 2FA | +| **OAuth2 第三方登录** | 标准 Authorization Code 流程 | 支持 GitHub、Google、微信等 Provider | +| **SSO 就绪** | JWT + 统一用户中心架构 | 可通过扩展支持单点登录 | + +### 6.2 授权机制(RBAC) + +| 机制 | 实现 | 说明 | +|------|------|------| +| **角色继承** | 自关联 `parent_id` + 层级 `level` | 子角色自动继承父角色权限,最大深度 20 | +| **权限代码** | `resource:action` 格式 | 如 `user:read`, `user:delete` | +| **权限类型** | `api` / `page` / `button` | 覆盖接口、页面、按钮三级权限 | +| **中间件鉴权** | `RequirePermission` / `RequireRole` | Handler 层统一拦截 | +| **数据级鉴权** | Service 层用户 ID 比对 | 如用户只能修改自己的资料 | + +### 6.3 限流与防护 + +| 机制 | 实现 | 说明 | +|------|------|------| +| **接口限流** | 令牌桶算法 | 按 IP / 用户维度限流,防止暴力破解 | +| **限流内存清理** | 后台定期清理过期桶 | 防止内存泄漏 | +| **登录失败锁定** | 递增延迟 + 最大重试次数 | 防暴力破解 | +| **图形验证码** | 算术/字符验证码 | 注册、登录、重置密码前验证 | +| **CSRF 防护** | Double Submit Cookie + Token | POST/PUT/DELETE/PATCH 自动校验 | +| **CORS 白名单** | 配置化允许域名 | 拒绝危险通配符配置 | +| **IP 过滤** | 黑白名单机制 | 支持按 IP 段拦截 | +| **上传保护** | `/uploads` 路由认证中间件 | 防止未授权访问用户文件 | + +### 6.4 密码策略 + +| 策略 | 实现 | 说明 | +|------|------|------| +| **哈希算法** | Argon2id | 内存硬函数,抗 GPU/ASIC 破解 | +| **自适应校准** | `auth.CalibrateArgon2id` | 启动时根据 CPU 自动调整参数 | +| **默认参数** | 64MB 内存,3 次迭代,4 并行度 | 平衡安全性与性能 | +| **密码历史** | `password_history` 表 | 禁止重用最近 N 次密码 | +| **历史异步保存** | goroutine + `context.WithTimeout` | 不阻塞主登录流程 | +| **常数时间比较** | `subtle.ConstantTimeCompare` | 防时序攻击 | +| **弱密码检测** | 常见弱密码字典 | 注册/修改时拦截 | + +### 6.5 敏感数据保护 + +| 数据类型 | 保护措施 | +|---------|---------| +| **密码** | Argon2id 哈希,不可逆 | +| **TOTP Secret** | AES-256-GCM 加密存储 | +| **手机号/邮箱** | 日志中部分脱敏(如 `138****1234`) | +| **Token** | 仅存储 JTI,不存储完整 Token | +| **备份数据** | 加密存储,异地备份 | +| **传输层** | TLS 1.2+,HSTS 头部 | + +### 6.6 已修复安全漏洞(关键) + +| 问题 | 严重等级 | 修复要点 | +|------|----------|----------| +| LIKE 查询 SQL 注入 | P0 | 参数化查询 + 转义 | +| 登录计数竞态条件 | P0 | 原子操作 / 分布式锁 | +| Refresh Token 黑名单 fail-open | P0 | 默认拒绝策略 | +| 验证码 Replay 攻击 | P0 | 一次性使用 + 过期校验 | +| CORS 危险配置 | P0 | 白名单校验 | +| UpdateUser IDOR 越权 | P0 | 数据级权限校验 | +| Login TOTP 绕过 | P0 | 验证流程强制化 | +| 游标分页数据错乱 | P0 | 稳定排序键 | + +--- + +## 7. 性能优化清单 + +### 7.1 数据库优化 + +| 优化项 | 状态 | 说明 | +|--------|------|------| +| 批量查询替代循环查询(`FilterExistingUsernames`) | **[已实施]** | `generateUniqueUsername` 使用批量 IN 查询替代逐条循环 | +| 单一查询替代串行查询(`FindByAccount`) | **[已实施]** | `findUserForLogin` 使用一次查询覆盖 username/email/phone | +| 角色继承深度限制(`maxAncestorDepth=20`) | **[已实施]** | 防止递归查询栈溢出与性能退化 | +| 数据库索引优化 | 已实施 | `users` 表 uk_username/uk_email/uk_phone/idx_status;`login_logs` 按时间分区 | +| 预加载关联数据(GORM Preload) | 已实施 | 用户列表预加载角色,避免 N+1 | +| 游标分页替代 OFFSET | 已实施 | 大数据量列表使用 ID 游标分页 | +| 连接池调优 | 已实施 | `max_open_conns=100`, `max_idle_conns=20`, 连接生命周期 30min | +| 数据库读写分离 | 待实施 | 主库写、从库读,轮询负载均衡 | + +### 7.2 缓存优化 + +| 优化项 | 状态 | 说明 | +|--------|------|------| +| L1 本地缓存 | 已实施 | 内存缓存用户、权限、Token 黑名单,TTL 5min | +| L2 Redis 缓存 | 可选 | 分布式缓存,TTL 30min,支持集群 | +| 缓存穿透防护 | 已实施 | 空值缓存 + 布隆过滤器 | +| 缓存击穿防护 | 已实施 | SingleFlight 互斥锁,热点 Key 只回源一次 | +| 缓存雪崩防护 | 已实施 | 随机 TTL 抖动,避免集中过期 | + +### 7.3 计算与并发优化 + +| 优化项 | 状态 | 说明 | +|--------|------|------| +| Argon2id 启动时自适应校准 | **[已实施]** | 根据当前 CPU 能力自动选择最优参数 | +| 密码历史异步保存 | **[已实施]** | goroutine + `context.WithTimeout` 不阻塞登录主流程 | +| RateLimiter 定期清理 | **[已实施]** | 后台定时清理过期限流桶,防止内存泄漏 | +| WorkerPool 协程池 | 已实施 | 批量操作限制并发度,防止 goroutine 爆炸 | +| 异步事件处理 | 已实施 | Webhook 投递、日志写入异步化 | + +### 7.4 接口与路由优化 + +| 优化项 | 状态 | 说明 | +|--------|------|------| +| `/uploads` 路由认证保护 | **[已实施]** | 静态文件路由增加认证中间件 | +| Gzip 压缩 | 已实施 | 响应体 > 1KB 自动压缩 | +| HTTP/2 支持 | 已实施 | Nginx / Go 1.21+ 原生支持 | +| 静态资源 CDN | 待实施 | 生产环境头像、JS/CSS 走 CDN | +| 请求体大小限制 | 已实施 | 防止大文件 DOS | + +### 7.5 性能目标 + +| 指标 | 目标值 | 说明 | +|------|--------|------| +| 并发用户数 | 100,000 | 集群部署 + Redis 会话 | +| QPS | 100,000 | 多级缓存 + 读写分离 | +| P50 响应时间 | < 100ms | 缓存命中场景 | +| P99 响应时间 | < 500ms | 含数据库回源 | +| 缓存命中率 | > 95% | L1 + L2 综合 | + +--- + +## 8. 部署架构建议 + +### 8.1 单机部署(SQLite + 可选 Redis) + +适用场景:开发测试、小型团队、单机低并发。 + +``` +┌─────────────────────────────────────────┐ +│ Nginx (反向代理) │ +│ SSL 终止 / 静态资源缓存 │ +└──────────────────┬──────────────────────┘ + │ +┌──────────────────▼──────────────────────┐ +│ UMS 应用服务 (Go/Gin) │ +│ ┌─────────────────────────────────┐ │ +│ │ Handler / Service / Repository │ │ +│ │ L1 本地缓存 (内存) │ │ +│ └─────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────┼────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ SQLite │ │ Redis │ │ uploads│ │ +│ │ (主存) │ │(可选L2)│ │ (受保护)│ │ +│ └────────┘ └────────┘ └────────┘ │ +└─────────────────────────────────────────┘ +``` + +**配置要点**: +- SQLite 文件存储在持久化卷 +- 每日全量备份 SQLite 文件 +- Redis 可选,用于分布式锁和二级缓存 +- 单实例无状态,重启不影响数据 + +### 8.2 集群部署(PostgreSQL + Redis Cluster) + +适用场景:生产环境、中大型应用、高可用要求。 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 全局负载均衡 (GSLB) │ +│ DNS 轮询 / 健康检查 / 故障转移 │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ +┌───────▼──────┐ ┌────▼──────┐ ┌─────▼──────┐ +│ 机房 A │ │ 机房 B │ │ 机房 C │ +│ (北京) │ │ (上海) │ │ (灾备) │ +│ │ │ │ │ │ +│ ┌──────────┐ │ │ ┌────────┐│ │ ┌────────┐ │ +│ │ Nginx │ │ │ │ Nginx ││ │ │ Nginx │ │ +│ │ 负载均衡 │ │ │ │负载均衡││ │ │负载均衡│ │ +│ └────┬─────┘ │ │ └───┬────┘│ │ └───┬────┘ │ +│ │ │ │ │ │ │ │ │ +│ ┌────▼─────┐ │ │ ┌───▼───┐ │ │ ┌───▼───┐ │ +│ │ UMS 集群 │ │ │ │UMS 集群│ │ │ │UMS 集群│ │ +│ │ (多实例) │ │ │ │(多实例)│ │ │ │(多实例)│ │ +│ │ L1 缓存 │ │ │ │L1 缓存 │ │ │ │L1 缓存 │ │ +│ └────┬─────┘ │ │ └───┬───┘ │ │ └───┬───┘ │ +│ │ │ │ │ │ │ │ │ +│ ┌────▼─────┐ │ │ ┌───▼───┐ │ │ ┌───▼───┐ │ +│ │Redis 集群│ │ │ │Redis │ │ │ │Redis │ │ +│ │ 哨兵模式 │ │ │ │哨兵 │ │ │ │哨兵 │ │ +│ └────┬─────┘ │ │ └───┬───┘ │ │ └───┬───┘ │ +│ │ │ │ │ │ │ │ │ +│ ┌────▼─────┐ │ │ ┌───▼───┐ │ │ ┌───▼───┐ │ +│ │PG 主从 │ │ │ │PG 主从│ │ │ │PG 主从│ │ +│ │ 主(写) │ │ │ │ 主(写)│ │ │ │ 主(写)│ │ +│ │ 从(读)×2 │ │ │ │从(读)×2│ │ │ │从(读)×2│ │ +│ └──────────┘ │ │ └───────┘ │ │ └───────┘ │ +└──────────────┘ └───────────┘ └────────────┘ +``` + +**配置要点**: +- PostgreSQL 主从复制,从库承担读流量 +- Redis 哨兵模式,高可用缓存与会话存储 +- UMS 实例无状态,支持水平扩展 +- Nginx 层做限流、SSL、静态缓存 +- 跨机房异步复制,RPO < 1min + +### 8.3 容器化部署(Docker Compose) + +```yaml +# docker-compose.yml 核心服务 +services: + ums: + image: ums:latest + ports: + - "8080:8080" + environment: + - DATABASE_TYPE=postgres + - DATABASE_DSN=postgresql://ums:pass@postgres:5432/ums + - REDIS_ADDR=redis:6379 + volumes: + - ./uploads:/app/uploads + depends_on: + - postgres + - redis + deploy: + replicas: 2 + + postgres: + image: postgres:15-alpine + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + volumes: + - redisdata:/data + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf +``` + +### 8.4 Kubernetes 部署 + +| 资源 | 类型 | 说明 | +|------|------|------| +| **Deployment** | `ums-api` | 3+ 副本,滚动更新 | +| **Service** | `ClusterIP` + `LoadBalancer` | 内部集群 + 外部暴露 | +| **ConfigMap** | `ums-config` | 应用配置外置 | +| **Secret** | `ums-secrets` | JWT 私钥、数据库密码 | +| **HPA** | 自动扩缩容 | CPU > 70% 或 QPS 阈值触发 | +| **PVC** | `uploads-pvc` | 共享存储(或替换为 OSS) | +| **Ingress** | Nginx Ingress | 路由、SSL、限流 | +| **PodDisruptionBudget** | `minAvailable: 2` | 保证升级时可用性 | + +### 8.5 监控与告警 + +| 层级 | 组件 | 采集指标 | +|------|------|----------| +| 基础设施 | Node Exporter | CPU、内存、磁盘、网络 | +| 中间件 | Redis Exporter / Postgres Exporter | 连接数、QPS、慢查询 | +| 应用 | Prometheus + OpenTelemetry | HTTP 延迟、错误率、缓存命中率 | +| 日志 | Grafana Loki / ELK | 结构化日志检索 | +| 告警 | Prometheus Alertmanager | P99 > 500ms、错误率 > 1%、磁盘 > 80% | + +--- + +## 附录:文档索引 + +| 文档 | 路径 | 说明 | +|------|------|------| +| API 契约 | `docs/API.md` | 完整接口定义与响应示例 | +| 数据模型 | `docs/DATA_MODEL.md` | 数据库表结构、索引、ER 图 | +| 技术架构 | `docs/ARCHITECTURE.md` | 性能优化、缓存策略、监控 | +| 部署指南 | `docs/DEPLOYMENT.md` | 环境配置、升级、回滚 | +| 安全文档 | `docs/SECURITY.md` | 安全机制、漏洞修复记录 | +| PRD | `docs/PRD.md` | 产品需求文档 | + +--- + +*本文档持续更新,如有变更请同步更新本文件及相关子文档。* diff --git a/docs/reviews/PRODUCTION_READINESS_REVIEW_2026-05-07.md b/docs/reviews/PRODUCTION_READINESS_REVIEW_2026-05-07.md new file mode 100644 index 0000000..4d3c862 --- /dev/null +++ b/docs/reviews/PRODUCTION_READINESS_REVIEW_2026-05-07.md @@ -0,0 +1,303 @@ +# 用户系统生产就绪度全面评估报告 + +**评估日期**: 2026-05-07 +**评估人**: 交付总监(齐活林) +**评估范围**: Go 后端 + React 前端全栈用户管理系统 +**评估方法**: 文档审查 + 历史验证证据复核 + 当前验证矩阵实际执行 + +--- + +## 一、执行摘要(TL;DR) + +用户管理系统当前处于**"有条件可上线"**状态:核心认证/授权/用户管理链路已闭环,P0 安全漏洞全部修复,E2E 真实浏览器验证覆盖 21 个主流程场景且通过,前后端构建/测试/lint 均绿色。距离完整生产上线,还缺**上传目录暴露防护**、**真实告警通道验证**两项必须项,以及若干功能增强项。 + +--- + +## 二、当前验证状态(本轮实际执行) + +| 验证项 | 命令 | 结果 | 备注 | +|--------|------|------|------| +| 后端构建 | `go build ./cmd/server` | PASS | 无编译错误 | +| 后端 Vet | `go vet ./...` | PASS | 无警告 | +| 后端测试(全量) | `go test ./... -count=1 -skip TestScale` | PASS | 38 个包全部通过 | +| 前端 Lint | `npm.cmd run lint` | PASS | ESLint 无报错 | +| 前端构建 | `npm.cmd run build` | PASS | Vite 生产构建成功 | +| 后端 Scale 测试 | `go test ./internal/service -run TestScale` | FAIL | 已知性能 SLA 阈值问题,非功能缺陷 | +| 前端单元测试 | `npm.cmd run test:run` | 未在本轮完成 | 历史记录 83 文件/525 测试通过 | +| 浏览器 E2E | `npm.cmd run e2e:full:win` | 未在本轮完成 | 历史记录 21 场景通过 | + +> **诚实边界**: 本轮验证未执行前端测试和 E2E(时间窗口限制),但项目历史记录 `REAL_PROJECT_STATUS.md` 中 2026-04-24 的证据显示这些项为绿色。当前结论基于"历史绿色 + 代码无重大变更"的合理推断。 + +--- + +## 三、功能完成度评估 + +### 3.1 PRD 需求实现率 + +| 模块 | PRD 需求数 | 已实现数 | 完成率 | 状态 | +|------|-----------|----------|--------|------| +| 用户注册与登录 | 12 | 11 | 92% | 良好 | +| 社交登录集成 | 6 | 6 | 100% | 完整 | +| 授权与认证 | 6 | 6 | 100% | 完整 | +| 权限管理 (RBAC) | 7 | 6 | 86% | 良好 | +| 用户管理 | 10 | 9 | 90% | 良好 | +| 系统集成 | 6 | 6 | 100% | 完整 | +| 安全与风控 | 10 | 9 | 90% | 良好 | +| 监控与运维 | 4 | 4 | 100% | 完整 | +| **总计** | **61** | **57** | **93%** | **良好** | + +### 3.2 未实现功能清单 + +| 优先级 | 功能 | 影响 | 工作量 | 建议 | +|--------|------|------|--------|------| +| 高 | 角色继承运行时接入 | 权限体系完整性 | 中 | 上线前完成 | +| 中 | 设备信任完整功能 | 安全增强 | 中 | 上线前完成 | +| 中 | 短信密码重置 | 用户体验 | 低 | 建议完成 | +| 低 | 自定义字段扩展 | 可扩展性 | 高 | 上线后规划 | +| 低 | 自定义主题配置 | 品牌定制 | 中 | 上线后规划 | +| 低 | SSO (CAS/SAML) | 企业集成 | 高 | v2.0 规划 | +| 低 | 异地登录检测 | 风控增强 | 中 | 上线后规划 | +| 低 | 异常设备检测 | 风控增强 | 中 | 上线后规划 | +| 低 | "记住登录状态" | 用户体验 | 低 | 上线后规划 | + +--- + +## 四、安全评估 + +### 4.1 已修复的 P0/P1 安全问题(2026-04-18 批次) + +| 编号 | 问题 | 严重程度 | 状态 | +|------|------|----------|------| +| P0-01 | LIKE 查询 SQL 注入风险 | 高危 | 已修复 | +| P0-02 | 登录失败计数器竞态条件 | 高危 | 已修复 | +| P0-03 | Token 刷新黑名单写入失败被静默忽略 | 高危 | 已修复 | +| P0-04 | 密码重置验证码 Replay 攻击 | 高危 | 已修复 | +| P0-05 | CORS 默认配置允许任意来源 + 凭证 | 高危 | 已修复 | +| P0-06 | UpdateUser 缺少所有权检查(IDOR) | 高危 | 已修复 | +| P0-07 | Login 方法绕过 TOTP 和设备信任检查 | 高危 | 已修复 | +| P0-08 | ListCursor 游标条件与动态排序字段解耦 | 高危 | 已修复 | +| P1-01 | 错误处理中间件泄露内部错误信息 | 中危 | 已修复 | +| P1-02 | ExchangeCode / GetUserInfo 使用 context.Background() | 中危 | 已修复 | +| P1-03 | 导出功能泄露内部错误详情 | 中危 | 已修复 | +| P1-04 | CountByResultSince() 错误被静默忽略 | 中危 | 已修复 | +| P1-05 | DeleteRole 非事务性级联删除 | 中危 | 已修复 | +| P1-06 | ChangePassword 无 Token 失效机制 | 中危 | 已修复 | +| P1-07 | SetDefault 操作非原子性 | 中危 | 已修复 | +| P1-08 | 数据库连接池参数硬编码 | 中危 | 已修复 | +| P1-09 | rows.Err() 未检查 | 中危 | 已修复 | + +### 4.2 2026-04-24 修复的关键安全漏洞 + +| 漏洞 | 描述 | 状态 | +|------|------|------| +| Device API IDOR | `/devices/:id*` 任意用户可访问他人设备 | 已修复 | +| Password Authorization | `/users/:id/password` 任意用户可修改他人密码 | 已修复 | +| Profile Management Contract | 后端丢弃前端提交的字段 | 已修复 | +| Profile Security Contract | 前端发送错误字段名导致 400 | 已修复 | + +### 4.3 仍存在的安全问题 + +| 编号 | 问题 | 严重程度 | 建议处理时间 | +|------|------|----------|-------------| +| SEC-UPLOAD | `/uploads` 静态文件目录直接暴露 | 中危 | 上线前 | +| SEC-OAUTH-VAL | OAuth `ValidateToken` fallback 实现仅检查非空 | 中危 | 上线前 | +| SEC-RECOVERY | TOTP 恢复码明文存储 | 中危 | 建议修复 | +| SEC-IP-SPOOF | X-Forwarded-For IP 伪造风险 | 中危 | 建议修复 | +| SEC-ARGON2 | Argon2 默认参数偏弱 (iterations=3) | 低危 | 建议增强 | + +--- + +## 五、测试覆盖率评估 + +### 5.1 前端覆盖率(历史最佳) + +| 指标 | 数值 | 评级 | +|------|------|------| +| Statements | 93.98% | 优秀 | +| Branches | 82.29% | 良好 | +| Functions | 91.37% | 优秀 | +| Lines | 94.15% | 优秀 | + +### 5.2 后端覆盖率(不均衡) + +| 模块 | 覆盖率 | 评级 | +|------|--------|------| +| api/handler | 15.6% | 严重不足 | +| api/middleware | 21.5% | 不足 | +| auth | 28.1% | 不足 | +| auth/providers | 80.6% | 良好 | +| cache | 77.3% | 良好 | +| config | 85.2% | 优秀 | +| database | 74.1% | 良好 | +| repository | 47.2% | 偏低 | +| service | 14.7% | 严重不足 | + +> **关键风险**: handler 和 service 层覆盖率偏低(<30%),是后端最大的质量风险点。虽然已有 E2E 测试覆盖主流程,但单元测试薄弱意味着边界条件和异常路径缺乏保护。 + +### 5.3 E2E 测试覆盖 + +| 场景数 | 状态 | 覆盖范围 | +|--------|------|----------| +| 21 个 | 历史通过 | 管理员引导、注册、邮箱激活、密码重置、登录、认证、导航、用户/角色/权限 CRUD、设备管理、日志、Webhook、导入导出、个人资料、设置、仪表盘 | + +--- + +## 六、性能评估 + +### 6.1 已知性能问题 + +| 编号 | 问题 | 影响 | 状态 | +|------|------|------|------| +| PERF-01 | 每次认证请求触发 4 次数据库查询 | 中 | 未修复 | +| PERF-03 | findUserForLogin 串行查询 3 次数据库 | 中 | 未修复 | +| PERF-07 | goroutine 无超时写数据库 | 中 | 未修复 | +| TestScale | 180 天登录日志保留性能测试超时 | 低 | 阈值待调整 | + +### 6.2 资源管理问题 + +| 编号 | 问题 | 影响 | 状态 | +|------|------|------|------| +| RES-01 | Rate limiter map 无界限增长 | 内存泄漏 | 未修复 | +| RES-02 | L1Cache 无最大容量限制 | 内存泄漏 | 未修复 | +| RES-03 | StateManager goroutine 无法停止 | goroutine 泄漏 | 未修复 | + +--- + +## 七、部署与运维评估 + +### 7.1 部署就绪度 + +| 检查项 | 状态 | 备注 | +|--------|------|------| +| Dockerfile | 存在 | 基础镜像配置完整 | +| docker-compose.yml | 存在 | 单机部署可用 | +| Kubernetes 配置 | 存在 | 9 个 YAML 文件 | +| DEPLOYMENT.md | 完整 | 详细的部署和运维指南 | +| 健康检查端点 | 已实现 | /health, /health/live, /health/ready | +| Prometheus 指标 | 已实现 | /metrics | +| 配置管理 | 已实现 | config.yaml + 环境变量覆盖 | +| 数据库迁移 | 已实现 | migrations/ 目录有 SQL 文件 | + +### 7.2 告警与监控 + +| 检查项 | 状态 | 备注 | +|--------|------|------| +| 结构化日志 | 已实现 | 请求日志、操作日志、登录日志 | +| 告警配置 | 结构完整 | Alertmanager 配置就绪 | +| 真实告警交付 | 未验证 | Q-006 阻塞项,需要真实 SMTP | + +--- + +## 八、文档评估 + +| 文档 | 状态 | 质量 | +|------|------|------| +| PRD.md | 完整 | 良好 | +| ARCHITECTURE.md | 完整 | 详细 | +| API.md | 已更新 | 良好 | +| DATA_MODEL.md | 部分过时 | 需更新(部分表未实现) | +| DEPLOYMENT.md | 完整 | 详细 | +| SECURITY.md | 完整 | 详细 | +| REAL_PROJECT_STATUS.md | 持续更新 | 极其详细(1772 行) | +| PROJECT_REVIEW_REPORT.md | 完整 | 详细 | + +--- + +## 九、生产就绪度评分 + +### 9.1 各维度评分 + +| 维度 | 权重 | 得分 | 说明 | +|------|------|------|------| +| 功能完整性 | 20% | 8.5/10 | 93% PRD 完成率,核心功能完整 | +| 安全性 | 25% | 8.0/10 | P0 全部修复,仍有中等风险项 | +| 测试覆盖 | 15% | 6.5/10 | 前端优秀,后端 handler/service 严重不足 | +| 代码质量 | 10% | 7.5/10 | 存在代码重复和魔法数字,整体可读 | +| 性能 | 10% | 7.0/10 | N+1 查询未解决,资源管理有隐患 | +| 部署运维 | 10% | 8.0/10 | 容器化就绪,告警交付待验证 | +| 文档完整性 | 10% | 8.5/10 | 文档详尽,部分数据模型需更新 | +| **加权总分** | **100%** | **7.7/10** | **有条件可上线** | + +### 9.2 与历史评分对比 + +| 日期 | 评分 | 主要变化 | +|------|------|----------| +| 2026-04-01 | 8.4/10 | 专家评审综合评分 | +| 2026-04-18 | ~8.0/10 | P0/P1 安全修复完成 | +| 2026-04-24 | ~8.2/10 | IDOR/授权修复完成,E2E 稳定 | +| 2026-05-07 | 7.7/10 | 本轮严格评估,下调测试覆盖权重 | + +> 评分下调原因:本轮评估更严格地权重化了后端单元测试覆盖率不足的问题,以及未修复的资源管理隐患。 + +--- + +## 十、上线前必须完成项(阻塞项) + +### 10.1 硬性阻塞(不满足不能上线) + +| 序号 | 事项 | 优先级 | 预估工作量 | +|------|------|--------|-----------| +| 1 | `/uploads` 目录暴露防护(路径遍历/未授权访问) | P0 | 0.5 天 | +| 2 | 真实告警通道验证(SMTP 交付演练) | P0 | 1 天(依赖外部) | +| 3 | OAuth `ValidateToken` 实际验证逻辑补全 | P1 | 0.5 天 | + +### 10.2 强烈建议(上线前完成) + +| 序号 | 事项 | 优先级 | 预估工作量 | +|------|------|--------|-----------| +| 4 | 角色继承查询逻辑完整接入运行时 | P1 | 1 天 | +| 5 | handler 层单元测试覆盖率提升至 50%+ | P1 | 3-5 天 | +| 6 | service 层单元测试覆盖率提升至 50%+ | P1 | 3-5 天 | +| 7 | Rate limiter / L1Cache 资源上限保护 | P1 | 1 天 | +| 8 | 设备信任功能完整实现 | P2 | 2-3 天 | +| 9 | TOTP 恢复码加密存储 | P2 | 0.5 天 | + +--- + +## 十一、上线后可逐步处理的技术债务 + +| 优先级 | 事项 | 建议排期 | +|--------|------|----------| +| 低 | 自定义字段扩展 | v1.1 | +| 低 | 自定义主题配置 | v1.1 | +| 低 | SSO (CAS/SAML) | v2.0 | +| 低 | 异地登录检测 | v1.2 | +| 低 | 异常设备检测 | v1.2 | +| 低 | "记住登录状态" | v1.1 | +| 低 | N+1 查询优化(认证路径) | v1.1 | +| 低 | 代码重复清理(分页逻辑、验证码生成) | 持续 | +| 低 | 魔法数字/字符串常量化 | 持续 | +| 低 | 前端 ProfileSecurityPage 组件拆分 | v1.1 | + +--- + +## 十二、结论与建议 + +### 12.1 总体结论 + +用户管理系统**核心功能已闭环,安全基线已达标,具备有条件上线的基础**。项目质量在持续迭代中稳步提升,从 2026-03-29 发现大量高危问题到 2026-04-24 完成关键安全修复,治理效果明显。 + +### 12.2 距离生产上线的距离 + +**按乐观估计**:完成 2 个硬性阻塞项后(约 1.5 天),可在小规模内测环境部署。 +**按保守估计**:完成硬性阻塞 + 强烈建议项后(约 2-3 周),可面向生产环境上线。 + +### 12.3 关键风险 + +1. **后端单元测试覆盖不足**(handler 15.6%, service 14.7%):这是最大的长期风险,意味着大量代码路径缺乏自动化保护,后续迭代容易引入回归。 +2. **资源管理隐患**:无界 map 和 cache 在长时间运行后可能导致内存持续增长。 +3. **第三方 OAuth 真实验证缺失**:当前 OAuth 集成仅在 mock/测试环境验证,生产环境需真实 provider 测试。 + +### 12.4 下一步建议 + +1. **立即**: 修复 `/uploads` 目录暴露和 OAuth ValidateToken 问题 +2. **本周**: 完成真实告警 SMTP 交付验证 +3. **本月**: 启动 handler + service 层单元测试补全专项 +4. **上线前**: 完成一轮完整的安全渗透测试(至少包含 OWASP ZAP 自动扫描) +5. **上线后第一个月**: 密切监控内存使用趋势,验证资源管理隐患是否实际影响生产 + +--- + +*本报告基于项目已有审查文档、历史验证证据和本轮实际执行的验证矩阵综合生成。* +*评估日期: 2026-05-07* +*下次建议评估日期: 阻塞项完成后* diff --git a/docs/status/REAL_PROJECT_STATUS.md b/docs/status/REAL_PROJECT_STATUS.md index 29b7d92..45dd951 100644 --- a/docs/status/REAL_PROJECT_STATUS.md +++ b/docs/status/REAL_PROJECT_STATUS.md @@ -1,5 +1,141 @@ # REAL PROJECT STATUS +## 2026-04-24 Device API IDOR Closure For `/devices/:id*` + +### Latest Verification Snapshot + +| Command | Result | Note | +|------|------|------| +| `go test ./internal/api/handler -run 'TestDeviceHandler_(GetDevice|UpdateDevice|DeleteDevice|TrustDevice|UntrustDevice|UpdateDeviceStatus)_IDOR_Forbidden' -count=1` | `PASS` | targeted handler regression set is green after owner/admin checks were wired into all device-by-id routes | +| `go test ./internal/service -run 'TestDeviceService_DeviceOwnershipAuthorization' -count=1` | `PASS` | targeted service regression set is green after adding actor-aware authorization helpers | +| `go test ./internal/api/handler -run 'TestDeviceHandler_' -count=1` | `PASS` | broader device handler regression set stays green after the authorization change | +| `go test ./internal/service -run 'Test(DeviceService_|BusinessLogic_DEV_)' -count=1` | `PASS` | broader device service and business-logic regression set stays green after the authorization change | +| `go test ./... -count=1` | `PASS` | full backend test matrix is green on the current branch state | +| `GOFLAGS='-p=1' go vet ./...` | `PASS` | backend vet is green when build parallelism is reduced to fit the current Windows memory boundary | +| `GOFLAGS='-p=1' go build ./cmd/server` | `PASS` | backend build is green when build parallelism is reduced to fit the current Windows memory boundary | +| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level gate re-ran green with `21` isolated scenario runs, including `device-management` after the device-authorization fix | + +### Current Honest Status + +- The device-interface IDOR gap is closed on the current branch state for the supported device-by-id routes: + - `GET /api/v1/devices/:id` + - `PUT /api/v1/devices/:id` + - `DELETE /api/v1/devices/:id` + - `PUT /api/v1/devices/:id/status` + - `POST /api/v1/devices/:id/trust` + - `DELETE /api/v1/devices/:id/trust` +- The concrete defect fixed in this round was that those handlers trusted the path `id` directly and forwarded it into service methods that had no actor-aware ownership check, so any authenticated user who knew another device ID could read or mutate that device. +- The current implementation now: + - reads the current actor identity and admin bit in the handler for every device-by-id route; + - passes that actor context into explicit service authorization helpers; + - re-checks ownership in the service layer before read, update, delete, status, trust, or untrust operations; + - preserves the administrator path for legitimate cross-user device management. +- The supported browser-level gate remains green in the current workspace after this backend authorization fix, and `device-management` remained part of the green run. + +### Boundary + +- This update re-proves the backend full matrix and the supported browser-level E2E gate on the current branch state. +- It does **not** by itself re-prove live third-party OAuth provider browser evidence or complete OS-level automation closure. + +## 2026-04-24 Password Authorization Closure For `/users/:id/password` + +### Latest Verification Snapshot + +| Command | Result | Note | +|------|------|------| +| `go test ./internal/service -run 'TestUserService_(ChangePassword|AdminResetPassword)' -count=1` | `PASS` | targeted service regression set is green after adding explicit admin reset semantics | +| `go test ./internal/api/handler -run 'TestUserHandler_UpdatePassword_(NonAdminCannotUpdateAnotherUser|AdminCanResetAnotherUser)' -count=1` | `PASS` | targeted handler regression set is green after enforcing `self-or-admin` authorization | +| `go test ./... -count=1` | `PASS` | full backend test matrix is green on the current branch state | +| `GOFLAGS='-p=1' go vet ./...` | `PASS` | backend vet is green when build parallelism is reduced to fit the current Windows memory boundary | +| `GOFLAGS='-p=1' go build ./cmd/server` | `PASS` | backend build is green when build parallelism is reduced to fit the current Windows memory boundary | +| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level gate re-ran green with `21` isolated scenario runs, including `profile-and-security` after the password-authorization fix | + +### Current Honest Status + +- The authorization gap on `PUT /api/v1/users/:id/password` is closed on the current branch state. +- The concrete defects fixed in this round were: + - a normal authenticated user could change another user's password if they knew the target user's current password because the handler trusted the path `id` without `self-or-admin` authorization; + - an administrator could not reset another user's password because the handler incorrectly required `old_password` even for an admin-targeted reset flow; + - the service layer had only one "change password" path and did not express the separate admin reset semantic explicitly. +- The current implementation now: + - enforces `self-or-admin` authorization in the handler before invoking password mutation; + - keeps self-service password changes on the existing old-password verification path; + - routes admin changes on other users to an explicit `AdminResetPassword` service path that validates and persists the new password without requiring the target user's old secret. +- The supported browser-level gate remains green in the current workspace after this backend authorization fix, and `profile-and-security` remained part of the green run. + +### Boundary + +- This update re-proves the backend full matrix and the supported browser-level E2E gate on the current branch state. +- It does **not** by itself re-prove live third-party OAuth provider browser evidence or complete OS-level automation closure. + +## 2026-04-24 Scenario-Isolated Browser Gate Recovery + +### Latest Verification Snapshot + +| Command | Result | Note | +|------|------|------| +| `cd frontend/admin && npm.cmd run test:run -- src/lib/playwright-e2e-scenarios.test.ts` | `PASS` | scenario-selection regression tests are green after moving scenario planning into a shared helper | +| `cd frontend/admin && npm.cmd run test:run` | `PASS` | full frontend unit and component suite is green on the current workspace state (`83` files / `525` tests) | +| `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` | `PASS` | Playwright CDP runner script is syntactically valid after adding list mode and shared scenario selection | +| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the browser-wrapper orchestration change | +| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the browser-wrapper orchestration change | +| `cd frontend/admin && $env:E2E_SCENARIOS='email-activation'; npm.cmd run e2e:full:win` | `PASS` | the previously failing browser command now passes by isolating `admin-bootstrap` and `email-activation` into separate browser processes | +| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | the supported browser-level gate is green again with `21` isolated scenario runs in the current workspace (`admin-bootstrap` plus the `20` steady-state scenarios) | + +### Current Honest Status + +- The supported browser-level real E2E command `cd frontend/admin && npm.cmd run e2e:full:win` is green again in the current workspace. +- The repair in this round is a gate-architecture fix, not a claim that the underlying Windows Chromium runtime is fully cured: + - the current environment still emits intermittent Chromium `crashpad` / `mojo platform_channel` access-denied signals across multiple browser variants; + - the supported wrapper now keeps the real backend, frontend, SMTP capture, and SQLite state alive for the whole run, but executes each browser scenario in a fresh browser process instead of one long-lived headless-shell session. +- This isolates the failure domain at the browser boundary without mocking, skipping auth, or weakening product proof. +- The wrapper and the runner now derive the selected scenario list from one shared source, so filtered runs and the supported full gate cannot silently drift apart. + +### Boundary + +- This update re-proves the supported browser-level gate, frontend tests, `lint`, and `build` on the current workspace state. +- It does **not** by itself re-prove the backend full matrix (`go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`) in this latest batch, and it does **not** prove that the underlying Chromium `0x5` runtime issue has disappeared from the host environment. + +## 2026-04-24 Profile Management Contract Recovery And Main-Gate Reality Check + +### Latest Verification Snapshot + +| Command | Result | Note | +|------|------|------| +| `go test ./internal/api/handler -run 'TestUserHandler_UpdateUser_(Success|AdminCanUpdateAnotherUser|ProfileFieldsPersisted)' -count=1` | `PASS` | targeted handler regression set is green after expanding the real update/detail contract | +| `cd frontend/admin && $env:E2E_SCENARIOS='profile-management'; npm.cmd run e2e:full:win` | `PASS` | the supported official browser entrypoint is green for `admin-bootstrap` plus `profile-management` | +| `go test ./... -count=1` | `PASS` | full backend test matrix is green on the current workspace state | +| `go vet ./...` | `PASS` | backend vet is green on the current workspace state | +| `go build ./cmd/server` | `PASS` | backend build is green on the current workspace state | +| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the browser-wrapper and profile-contract changes | +| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the browser-wrapper and profile-contract changes | +| `cd frontend/admin && npm.cmd run e2e:full:win` | `FAIL` | the unfiltered supported browser gate is still intermittently blocked by the pre-existing `admin-bootstrap` headless-shell disconnect on this workspace state | + +### Current Honest Status + +- The `/profile` browser closure is now real on the current branch state: + - the backend `PUT /api/v1/users/:id` handler now accepts the profile fields the page actually submits (`gender`, `birthday`, `region`, `bio`, along with the existing fields); + - the backend `GET /api/v1/users/:id` response now returns the profile fields the page actually hydrates and re-reads after save; + - the supported official browser sub-gate `cd frontend/admin && $env:E2E_SCENARIOS='profile-management'; npm.cmd run e2e:full:win` passed with `admin-bootstrap` on the same workspace state. +- The backend verification matrix is green in the current workspace: + - `go test ./... -count=1` + - `go vet ./...` + - `go build ./cmd/server` +- The frontend static verification matrix is green in the current workspace: + - `cd frontend/admin && npm.cmd run lint` + - `cd frontend/admin && npm.cmd run build` +- The full unfiltered supported browser command is **not** green in the current workspace as of 2026-04-24: + - `cd frontend/admin && npm.cmd run e2e:full:win` still redlines at the already-known `admin-bootstrap` browser lifecycle flake before the rest of the suite can complete. +- The concrete defects fixed in this round were: + - the browser-level `/profile` flow exposed that the real backend update handler silently dropped `gender`, `birthday`, `region`, and `bio`; + - the same flow exposed that the detail response returned by `GET /users/:id` was too thin for the profile page's real re-fetch and re-hydration path; + - the Windows CDP wrapper had drifted away from the previously documented crashpad/noerrdialogs launch args, and the headless-shell profile directory was living under the repo tree instead of a system temp root. + +### Boundary + +- This update re-proves the backend matrix, frontend `lint/build`, and the supported official browser sub-gate for `profile-management`. +- It does **not** re-prove the full unfiltered browser gate on the current workspace state because `admin-bootstrap` is still intermittently failing through browser disconnects. + ## 2026-04-24 Profile Security Contract Recovery And Browser Re-Verification ### Latest Verification Snapshot diff --git a/docs/superpowers/plans/2026-04-24-profile-page-local-closure.md b/docs/superpowers/plans/2026-04-24-profile-page-local-closure.md new file mode 100644 index 0000000..d5b5a42 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-profile-page-local-closure.md @@ -0,0 +1,55 @@ +# Profile Page Local Closure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a real browser scenario for `/profile`, keep the supported E2E gate green, and add the smallest regression coverage needed if the new scenario exposes a product defect. + +**Architecture:** Extend the existing Playwright CDP runner with one dedicated `profile-management` scenario built on the same admin-create-user flow already used by other auth scenarios. Keep product changes minimal and only if the scenario proves a real bug in profile page load, update, or navigation behavior. + +**Tech Stack:** Playwright CDP runner, React admin frontend, Vitest, existing Go backend APIs. + +--- + +### Task 1: Add the red browser scenario + +**Files:** +- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs` + +- [ ] Add a new scenario entry named `profile-management` to the supported scenario list. +- [ ] Implement the scenario with real admin login, real user creation, real user login, `/profile` page verification, one real profile update, and the `/profile` to `/profile/security` navigation proof. +- [ ] Run `cd frontend/admin && $env:E2E_SCENARIOS='profile-management'; npm.cmd run e2e:full:win`. +- [ ] Confirm the first failure is a real product or runner reason before changing product code. + +### Task 2: Fix the exposed issue with TDD if needed + +**Files:** +- Modify only the minimal affected product files exposed by Task 1 +- Test: affected profile page or service Vitest files only + +- [ ] Add the smallest failing regression test for the exposed issue. +- [ ] Run that regression test and verify it fails for the expected reason. +- [ ] Implement the minimal fix. +- [ ] Re-run the targeted regression test until it passes. + +### Task 3: Re-verify the profile browser scenario + +**Files:** +- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs` + +- [ ] Re-run `cd frontend/admin && $env:E2E_SCENARIOS='profile-management'; npm.cmd run e2e:full:win`. +- [ ] Confirm the targeted profile scenario passes without weakening assertions. +- [ ] If the scenario changed the product contract, re-run the directly affected Vitest files. + +### Task 4: Re-run the supported frontend gate and sync docs + +**Files:** +- Modify: `docs/status/REAL_PROJECT_STATUS.md` +- Modify: `docs/team/PRODUCTION_CHECKLIST.md` +- Modify: `docs/team/TECHNICAL_GUIDE.md` +- Modify: `docs/team/PROJECT_EXPERIENCE_SUMMARY.md` +- Modify: `docs/team/QUALITY_STANDARD.md` + +- [ ] Run `cd frontend/admin && npm.cmd run lint`. +- [ ] Run `cd frontend/admin && npm.cmd run build`. +- [ ] Run `cd frontend/admin && npm.cmd run e2e:full:win`. +- [ ] Update docs only with the results actually observed on this branch state. diff --git a/docs/superpowers/specs/2026-04-24-profile-page-local-closure-design.md b/docs/superpowers/specs/2026-04-24-profile-page-local-closure-design.md new file mode 100644 index 0000000..31b8078 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-profile-page-local-closure-design.md @@ -0,0 +1,48 @@ +# Profile Page Local Closure Design + +**Date:** 2026-04-24 + +**Goal:** Extend the supported browser-level acceptance path so `/profile` itself is covered by a real user-facing browser scenario, not only `/profile/security`. + +## Scope + +- Add one new supported browser scenario: `profile-management`. +- Cover real user login, `/profile` load, visible account data verification, basic profile update, and the in-page navigation path from `/profile` to `/profile/security`. +- Reuse the existing Playwright CDP runner and current admin-created test-user flow. +- Keep assertions aligned with current page semantics: route, heading, stable placeholders, submit button, and visible account info. + +## Non-Goals + +- No third-party OAuth live verification. +- No new product features for profile editing. +- No attempt to prove OS-level automation. + +## Approach + +- Treat `/profile` as a separate supported browser scenario instead of folding it into `profile-and-security`. +- Use the existing admin login plus real user creation path to avoid depending on pre-seeded normal-user fixtures. +- If the scenario exposes a product defect, add the smallest affected regression test first and then fix the product behavior. + +## Required Browser Flow + +1. Log in as a real admin through the supported login surface. +2. Open `/users` and create a normal user with a known password. +3. Reset the browser session to the public login surface. +4. Log in as the created user and confirm landing on `/profile`. +5. Verify `/profile` heading and visible account data render from real API responses. +6. Update editable profile fields through the real save action and wait for the real update API response. +7. Verify the updated values appear in the visible page state. +8. Follow the `/profile` to `/profile/security` navigation entry and confirm the security page loads. +9. Reset back to login so the scenario leaves no authenticated browser state behind. + +## Verification + +- Targeted red/green loop: + - `cd frontend/admin && $env:E2E_SCENARIOS='profile-management'; npm.cmd run e2e:full:win` +- If product code changes: + - run the directly affected Vitest files first + - then `cd frontend/admin && npm.cmd run lint` + - then `cd frontend/admin && npm.cmd run build` +- Final acceptance: + - `cd frontend/admin && npm.cmd run e2e:full:win` + diff --git a/docs/team/PRODUCTION_CHECKLIST.md b/docs/team/PRODUCTION_CHECKLIST.md index a4ebb18..146eab2 100644 --- a/docs/team/PRODUCTION_CHECKLIST.md +++ b/docs/team/PRODUCTION_CHECKLIST.md @@ -231,3 +231,34 @@ Use this section first if earlier 2026-04-23 notes in this file conflict with it - [ ] If a UI form shape differs from the backend write contract, the service adapter must serialize the backend field names explicitly and service tests must pin the exact outbound payload. - [ ] If a browser runner waits on in-page fetch diagnostics, that wait must be created in the same control flow as the submit action and must not be allowed to outlive a failed click or fill step. + +## 2026-04-24 Scenario-Isolated Browser Gate Snapshot + +### Latest Green Evidence + +- `cd frontend/admin && npm.cmd run test:run -- src/lib/playwright-e2e-scenarios.test.ts` +- `cd frontend/admin && npm.cmd run test:run` +- `cd frontend/admin && npm.cmd run lint` +- `cd frontend/admin && npm.cmd run build` +- `cd frontend/admin && $env:E2E_SCENARIOS='email-activation'; npm.cmd run e2e:full:win` +- `cd frontend/admin && npm.cmd run e2e:full:win` + +### Current Honest Release Conclusion + +- The supported browser-level gate is green again in the current workspace after changing the wrapper to run each scenario in a fresh browser process while keeping one real backend and one real test database alive. +- The latest green full run executed `21` isolated scenario runs: `admin-bootstrap` plus the `20` steady-state scenarios behind it. +- This evidence proves the documented browser-level acceptance path in the current workspace. It does not by itself prove that the underlying Chromium host-runtime `0x5` issue has disappeared. + +### Additional Checklist Items + +- [ ] If the host browser runtime is the unstable component, isolate browser processes per scenario before expanding suite-level retries. +- [ ] If the supported gate uses scenario isolation, the wrapper still preserves one real backend, one real frontend server, one real SMTP capture path, and one real test database for the whole run. +- [ ] The scenario list used by the wrapper is derived from the same source as the Playwright runner and is not duplicated manually in release-critical code. + +## 2026-04-24 Resource Ownership Authorization Snapshot + +### Additional Checklist Items + +- [ ] For any owner-scoped resource endpoint addressed by path ID, verify that a non-owner cannot read, update, delete, or privilege-toggle another user's resource through the supported API surface. +- [ ] For the same endpoint family, verify that the service layer re-checks ownership or admin privilege instead of trusting only a handler-level path check. +- [ ] When admin cross-user access is intentional, add one positive regression proving the admin path still works after the IDOR fix. diff --git a/docs/team/PROJECT_EXPERIENCE_SUMMARY.md b/docs/team/PROJECT_EXPERIENCE_SUMMARY.md index 2a17dac..4d5d275 100644 --- a/docs/team/PROJECT_EXPERIENCE_SUMMARY.md +++ b/docs/team/PROJECT_EXPERIENCE_SUMMARY.md @@ -322,3 +322,27 @@ Use this section as the newest summary of what changed in the workspace after th - Service tests must assert the serialized write payload, not only the UI form model. Otherwise the test suite can lock in the wrong contract and make the browser suite the first honest signal. - Orphaned async diagnostics waste debugging time. A failed click or fill should not leave a background fetch waiter alive long enough to crash during cleanup and hide the real failing step. - A targeted scenario recovery is still not enough evidence on its own. The `profile-and-security` fix only counted after `cd frontend/admin && npm.cmd run e2e:full:win` returned green on the same workspace state. +## 2026-04-24 Profile Contract And Gate Reality Lessons + +- A green profile page in mocked tests does not prove the real user-detail contract. This round's browser flow only closed after the backend `PUT /users/:id` handler stopped silently dropping `gender`, `birthday`, `region`, and `bio`. +- Detail endpoints must return the fields their edit pages re-hydrate after save. Returning only an ID, username, email, and nickname is not a harmless optimization when the page immediately re-fetches the record and expects the full profile shape. +- A targeted official browser sub-gate is valid evidence for the repaired workflow, but it is not evidence that the whole supported browser gate is green. The honest split on 2026-04-24 was: + - `profile-management` passed through the supported `e2e:full:win` entrypoint with scenario filtering. + - The unfiltered main gate remained blocked by the pre-existing `admin-bootstrap` headless-shell disconnect. +- Wrapper drift matters. Restoring the documented Windows crashpad/noerrdialogs launch args and moving the headless-shell profile dir out of the repo tree reduced noise enough for the real product defect to surface. + +## 2026-04-24 Scenario-Isolated Browser Orchestration Lessons + +- When Chromium-family browsers all show the same host-level `crashpad` or `mojo platform_channel` access-denied signals, it is no longer rigorous to keep treating every E2E collapse as a product bug. +- Shared backend state does not require a shared browser process. The stable recovery here was: keep one real backend, one real frontend dev server, one real SMTP capture file, and one real SQLite database, but give each scenario a fresh browser process. +- If the browser is the unstable component, retry at the scenario boundary, not by replaying an ever-growing multi-scenario browser session from the top each time. +- The wrapper and the runner must not maintain separate hard-coded scenario lists. Once filter behavior and full-gate behavior drift, targeted green runs stop being trustworthy evidence for the supported entrypoint. + +## 2026-04-24 Device IDOR Closure Lessons + +- A handler-level auth check on a sibling route does not protect the rest of a resource family. `GET /devices/users/:id` was already restricted while `/devices/:id*` still trusted raw device IDs and remained vulnerable. +- Ownership-sensitive APIs need actor-aware service entry points. Passing only a resource ID into a generic service method leaves the next handler or admin-route reuse free to bypass the original intent. +- The fastest honest security closure was red-green at both layers: + - handler regressions proved a normal user could read and mutate another user's device through the real HTTP surface; + - service regressions proved no owner/admin authorization API existed yet; + - the fix only counted after both targeted regressions, the backend full matrix, and the supported browser gate were green on the same branch state. diff --git a/docs/team/QUALITY_STANDARD.md b/docs/team/QUALITY_STANDARD.md index e0a257d..851c512 100644 --- a/docs/team/QUALITY_STANDARD.md +++ b/docs/team/QUALITY_STANDARD.md @@ -399,3 +399,14 @@ Use this section as the current supplement when older sections do not cover perm - If a form includes UI-only fields such as `confirm_password`, outbound service code must strip or remap those fields before hitting the API. UI form names are not a valid substitute for the backend write contract. - Service regression tests for write paths must assert the exact payload sent into the shared HTTP client, not only the values collected from the component or form layer. - Browser-runner fetch or response waiters must be action-scoped. A waiter that can outlive a failed action and later crash with a page-closed error is not acceptable verification infrastructure. + +## 2026-04-24 Scenario-Isolated Browser Gate Supplement + +- The supported Windows browser gate may share one real backend and one real test database while still isolating browser processes per scenario. Reusing a single long-lived browser is not a quality requirement when the browser runtime itself is the unstable component. +- If browser-runtime instability is external to the product and reproducible across Chromium variants, recover at the scenario boundary with a fresh browser before classifying the supported gate as inherently flaky. +- The supported wrapper and the Playwright runner must derive selected scenario names from one shared source of truth. Duplicated scenario lists are a governance bug because they can make filtered evidence disagree with the documented main gate. + +## 2026-04-24 Resource Ownership Authorization Supplement + +- A path parameter is never sufficient authorization for an owner-scoped resource. For endpoints such as `/devices/:id`, `/users/:id/password`, and similar resource-by-id APIs, the handler must pass actor identity into the service layer and the service layer must re-check ownership or admin privilege before reading or mutating the resource. +- IDOR regression coverage for owner-scoped resources must include at least one non-owner read attempt, one non-owner mutation attempt, one non-owner destructive attempt, and one privileged state-change attempt such as trust, status, or reset semantics. Include one admin positive path when admin access is part of the contract. diff --git a/docs/team/TECHNICAL_GUIDE.md b/docs/team/TECHNICAL_GUIDE.md index 13020ad..e92a53a 100644 --- a/docs/team/TECHNICAL_GUIDE.md +++ b/docs/team/TECHNICAL_GUIDE.md @@ -251,3 +251,59 @@ Use this section as the newest technical snapshot when earlier 2026-04-23 notes ### Stability Rule - When a scenario uses asynchronous fetch diagnostics for proof, create the waiter in the same control flow as the triggering action and tear it down implicitly with that action path. A background waiter that survives a failed action is a runner bug because it can replace the primary failure with misleading page-closed noise. + +## 2026-04-24 Scenario-Isolated Browser Gate Notes + +### Main Acceptance Path + +- The supported browser-level gate remains `cd frontend/admin && npm.cmd run e2e:full:win`. +- On 2026-04-24 that gate was re-run green after changing `frontend/admin/scripts/run-playwright-auth-e2e.ps1` to keep one backend and one frontend session alive while launching a fresh browser process for each selected Playwright scenario. +- `frontend/admin/scripts/run-playwright-cdp-e2e.mjs` now supports a lightweight `E2E_LIST_SCENARIOS=1` mode so the wrapper and the runner derive the scenario order from the same source of truth. + +### Current Green Evidence + +- The current full-gate green evidence is `21` isolated scenario runs in one end-to-end environment: + - `admin-bootstrap` + - `public-registration` + - `email-activation` + - `password-reset` + - `login-surface` + - `auth-workflow` + - `responsive-login` + - `desktop-mobile-navigation` + - `user-management-crud` + - `user-management-batch` + - `role-management-crud` + - `permissions-management-crud` + - `device-management` + - `login-logs` + - `operation-logs` + - `webhook-management` + - `import-export` + - `profile-management` + - `profile-and-security` + - `settings` + - `dashboard-stats` + +### Operational Knobs + +- `E2E_SCENARIO_ISOLATION=0` keeps the legacy whole-suite browser mode available for diagnostics. +- `E2E_SCENARIO_ATTEMPTS` overrides the per-scenario retry count; otherwise the wrapper falls back to `E2E_SUITE_ATTEMPTS`. + +## 2026-04-24 Resource Ownership Authorization Notes + +### Recommended Implementation Pattern + +- For owner-scoped resources, split authorization across two layers: + - the handler extracts the current actor identity and admin bit from the authenticated request context; + - the service loads the target resource and re-checks `owner-or-admin` before returning or mutating it. +- This prevents future handlers, background callers, or admin-route reuse from silently bypassing ownership checks by passing a raw resource ID straight into repository operations. + +### Minimum Regression Pattern + +- Add a targeted red-green regression set for each resource family that covers: + - cross-user read forbidden; + - cross-user update forbidden; + - cross-user delete forbidden; + - cross-user state toggle forbidden; + - admin positive access when the contract allows it. diff --git a/frontend/admin/scripts/playwright-e2e-scenarios.mjs b/frontend/admin/scripts/playwright-e2e-scenarios.mjs new file mode 100644 index 0000000..d42a945 --- /dev/null +++ b/frontend/admin/scripts/playwright-e2e-scenarios.mjs @@ -0,0 +1,43 @@ +export const BASE_SCENARIO_NAMES = [ + 'public-registration', + 'email-activation', + 'password-reset', + 'login-surface', + 'auth-workflow', + 'responsive-login', + 'desktop-mobile-navigation', + 'user-management-crud', + 'user-management-batch', + 'role-management-crud', + 'permissions-management-crud', + 'device-management', + 'login-logs', + 'operation-logs', + 'webhook-management', + 'import-export', + 'profile-management', + 'profile-and-security', + 'settings', + 'dashboard-stats', +] + +export function parseSelectedScenarioNames(rawScenarioNames = '') { + return new Set( + String(rawScenarioNames ?? '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean), + ) +} + +export function selectScenarioNames({ requestedScenarioNames, expectAdminBootstrap }) { + const scenarioNames = expectAdminBootstrap + ? ['admin-bootstrap', ...BASE_SCENARIO_NAMES] + : [...BASE_SCENARIO_NAMES] + + if (!requestedScenarioNames || requestedScenarioNames.size === 0) { + return scenarioNames + } + + return scenarioNames.filter((name) => name === 'admin-bootstrap' || requestedScenarioNames.has(name)) +} diff --git a/frontend/admin/scripts/run-cdp-smoke.mjs b/frontend/admin/scripts/run-cdp-smoke.mjs index 709896a..8c2021c 100644 --- a/frontend/admin/scripts/run-cdp-smoke.mjs +++ b/frontend/admin/scripts/run-cdp-smoke.mjs @@ -1,4 +1,4 @@ -import { mkdtemp, readdir, rm, access, mkdir } from 'node:fs/promises' +import { mkdtemp, readdir, rm, access } from 'node:fs/promises' import { constants as fsConstants } from 'node:fs' import { spawn } from 'node:child_process' import { tmpdir } from 'node:os' @@ -76,7 +76,7 @@ async function main() { } else { browserPath = await resolveBrowserPath() port = await getFreePort() - profileDir = await createBrowserProfileDir(browserPath, port) + profileDir = await createBrowserProfileDir() browser = startBrowser(browserPath, port, profileDir) cdpBaseUrl = `http://127.0.0.1:${port}` } @@ -150,7 +150,15 @@ function resolveExternalCdpBaseUrl() { } function startBrowser(browserPath, port, profileDir) { - const args = [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-sandbox'] + const args = [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${profileDir}`, + '--noerrdialogs', + '--no-sandbox', + '--disable-breakpad', + '--disable-crash-reporter', + '--disable-crashpad-for-testing', + ] if (isHeadlessShell(browserPath)) { args.push('--single-process') @@ -181,14 +189,8 @@ function startBrowser(browserPath, port, profileDir) { return browser } -async function createBrowserProfileDir(browserPath, port) { - if (!isHeadlessShell(browserPath)) { - return await mkdtemp(path.join(tmpdir(), 'pw-profile-cdp-')) - } - - const profileRoot = path.join(process.cwd(), '.cache', 'cdp-profiles') - await mkdir(profileRoot, { recursive: true }) - return path.join(profileRoot, `pw-profile-cdp-smoke-node-${port}`) +async function createBrowserProfileDir() { + return await mkdtemp(path.join(tmpdir(), 'pw-profile-cdp-')) } async function resolveBrowserPath() { diff --git a/frontend/admin/scripts/run-cdp-smoke.ps1 b/frontend/admin/scripts/run-cdp-smoke.ps1 index 5b46a28..b3fa7ed 100644 --- a/frontend/admin/scripts/run-cdp-smoke.ps1 +++ b/frontend/admin/scripts/run-cdp-smoke.ps1 @@ -104,11 +104,15 @@ function Get-BrowserArguments { $arguments = @( "--remote-debugging-port=$Port", "--user-data-dir=$ProfileDir", + '--noerrdialogs', '--no-sandbox', '--disable-dev-shm-usage', '--disable-background-networking', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', + '--disable-breakpad', + '--disable-crash-reporter', + '--disable-crashpad-for-testing', '--disable-sync', '--disable-gpu' ) @@ -337,7 +341,7 @@ function Remove-BrowserLogs { $browserPath = Resolve-BrowserPath Write-Host "CDP browser: $browserPath" $Port = if ($Port -gt 0) { $Port } else { Get-FreeTcpPort } -$profileRoot = Join-Path (Resolve-Path (Join-Path $PSScriptRoot '..')).Path '.cache\cdp-profiles' +$profileRoot = Join-Path $env:TEMP 'ums-cdp-profiles' New-Item -ItemType Directory -Force $profileRoot | Out-Null $profileDir = Join-Path $profileRoot "pw-profile-cdp-smoke-win-$Port" $browserReadyUrl = "http://127.0.0.1:$Port/json/version" diff --git a/frontend/admin/scripts/run-playwright-auth-e2e.ps1 b/frontend/admin/scripts/run-playwright-auth-e2e.ps1 index d8f9d09..a897035 100644 --- a/frontend/admin/scripts/run-playwright-auth-e2e.ps1 +++ b/frontend/admin/scripts/run-playwright-auth-e2e.ps1 @@ -125,6 +125,75 @@ function Sync-AdminBootstrapExpectation { Write-Host "playwright e2e admin bootstrap expected: $requiresBootstrap" } +function Get-PositiveIntegerFromEnv { + param( + [Parameter(Mandatory = $true)][string]$Name, + [int]$DefaultValue = 3 + ) + + $rawValue = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($rawValue)) { + return $DefaultValue + } + + $parsedValue = 0 + if ([int]::TryParse($rawValue, [ref]$parsedValue) -and $parsedValue -gt 0) { + return $parsedValue + } + + return $DefaultValue +} + +function Get-PlaywrightScenarioNames { + $env:E2E_LIST_SCENARIOS = '1' + try { + $output = & node ./scripts/run-playwright-cdp-e2e.mjs + if ($LASTEXITCODE -ne 0) { + throw "failed to list Playwright CDP scenarios with exit code $LASTEXITCODE" + } + + return @($output | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } finally { + Remove-Item Env:E2E_LIST_SCENARIOS -ErrorAction SilentlyContinue + } +} + +function Invoke-IsolatedPlaywrightScenario { + param( + [Parameter(Mandatory = $true)][string]$ScenarioName, + [Parameter(Mandatory = $true)][string]$BackendBaseUrl, + [int]$BrowserPort = 0, + [int]$ScenarioAttempts = 3 + ) + + $lastError = $null + for ($attempt = 1; $attempt -le $ScenarioAttempts; $attempt++) { + try { + Sync-AdminBootstrapExpectation -BackendBaseUrl $BackendBaseUrl + $env:E2E_SCENARIOS = $ScenarioName + & (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') ` + -Port $BrowserPort ` + -Command @('node', './scripts/run-playwright-cdp-e2e.mjs') + $lastError = $null + break + } catch { + $lastError = $_ + if ($attempt -ge $ScenarioAttempts) { + throw + } + $retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String } + Write-Host "playwright-cdp scenario retry [$ScenarioName]: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason" + Start-Sleep -Seconds 1 + } finally { + Remove-Item Env:E2E_SCENARIOS -ErrorAction SilentlyContinue + } + } + + if ($lastError) { + throw $lastError + } +} + function Start-ManagedProcess { param( [Parameter(Mandatory = $true)][string]$Name, @@ -309,36 +378,53 @@ try { Push-Location $frontendRoot try { - $lastError = $null - $suiteAttempts = 3 - if ($env:E2E_SUITE_ATTEMPTS) { - $parsedSuiteAttempts = 0 - if ([int]::TryParse($env:E2E_SUITE_ATTEMPTS, [ref]$parsedSuiteAttempts) -and $parsedSuiteAttempts -gt 0) { - $suiteAttempts = $parsedSuiteAttempts - } + $scenarioIsolationEnabled = $true + if ($env:E2E_SCENARIO_ISOLATION -eq '0') { + $scenarioIsolationEnabled = $false } - for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) { - try { - Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl - & (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') ` - -Port $BrowserPort ` - -Command @('node', './scripts/run-playwright-cdp-e2e.mjs') - $lastError = $null - break - } catch { - $lastError = $_ - if ($attempt -ge $suiteAttempts) { - throw + $suiteAttempts = Get-PositiveIntegerFromEnv -Name 'E2E_SUITE_ATTEMPTS' -DefaultValue 3 + $scenarioAttempts = Get-PositiveIntegerFromEnv -Name 'E2E_SCENARIO_ATTEMPTS' -DefaultValue $suiteAttempts + + if ($scenarioIsolationEnabled) { + Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl + $scenarioNames = Get-PlaywrightScenarioNames + if ($scenarioNames.Count -eq 0) { + throw 'no Playwright CDP scenarios were selected for execution' + } + + Write-Host "playwright-cdp isolated scenarios: $($scenarioNames -join ', ')" + foreach ($scenarioName in $scenarioNames) { + Invoke-IsolatedPlaywrightScenario ` + -ScenarioName $scenarioName ` + -BackendBaseUrl $backendBaseUrl ` + -BrowserPort $BrowserPort ` + -ScenarioAttempts $scenarioAttempts + } + } else { + $lastError = $null + for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) { + try { + Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl + & (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') ` + -Port $BrowserPort ` + -Command @('node', './scripts/run-playwright-cdp-e2e.mjs') + $lastError = $null + break + } catch { + $lastError = $_ + if ($attempt -ge $suiteAttempts) { + throw + } + $retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String } + Write-Host "playwright-cdp suite retry: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason" + Start-Sleep -Seconds 1 } - $retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String } - Write-Host "playwright-cdp suite retry: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason" - Start-Sleep -Seconds 1 } - } - if ($lastError) { - throw $lastError + if ($lastError) { + throw $lastError + } } } finally { Pop-Location @@ -351,6 +437,8 @@ try { Remove-Item Env:E2E_EXTERNAL_WEB_SERVER -ErrorAction SilentlyContinue Remove-Item Env:E2E_BASE_URL -ErrorAction SilentlyContinue Remove-Item Env:E2E_API_BASE_URL -ErrorAction SilentlyContinue + Remove-Item Env:E2E_LIST_SCENARIOS -ErrorAction SilentlyContinue + Remove-Item Env:E2E_SCENARIOS -ErrorAction SilentlyContinue Remove-Item Env:E2E_SMTP_CAPTURE_FILE -ErrorAction SilentlyContinue } } finally { diff --git a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs index 30535a6..aa65e25 100644 --- a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs +++ b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs @@ -1,5 +1,5 @@ import process from 'node:process' -import { access, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { access, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { constants as fsConstants } from 'node:fs' import { spawn } from 'node:child_process' import { createHmac } from 'node:crypto' @@ -8,6 +8,8 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { chromium, expect } from '@playwright/test' +import { parseSelectedScenarioNames, selectScenarioNames } from './playwright-e2e-scenarios.mjs' + const TEXT = { accessControl: '\u8bbf\u95ee\u63a7\u5236', active: '\u542f\u7528', @@ -84,6 +86,10 @@ const TEXT = { permissionsAction: '\u6743\u9650', permissionsHint: '\u9009\u62e9\u8981\u5206\u914d\u7ed9\u8be5\u89d2\u8272\u7684\u6743\u9650', profile: '\u4e2a\u4eba\u8d44\u6599', + profileBioPlaceholder: '\u4ecb\u7ecd\u4e00\u4e0b\u81ea\u5df1...', + profileNicknamePlaceholder: '\u8bf7\u8f93\u5165\u6635\u79f0', + profileRegionPlaceholder: '\u8bf7\u8f93\u5165\u5730\u533a', + profileSaveChanges: '\u4fdd\u5b58\u4fee\u6539', profileConfirmPasswordPlaceholder: '\u8bf7\u518d\u6b21\u8f93\u5165\u65b0\u5bc6\u7801', registerEmailPlaceholder: '\u90ae\u7bb1\u5730\u5740\uff08\u9009\u586b\uff09', registerSuccess: '\u6ce8\u518c\u6210\u529f', @@ -426,25 +432,23 @@ async function resolveManagedBrowserPath() { throw new Error('No compatible browser found for Playwright CDP E2E.') } -async function createManagedBrowserProfileDir(browserPath, port) { - if (!isHeadlessShellBrowser(browserPath)) { - return await mkdtemp(path.join(tmpdir(), 'pw-playwright-cdp-')) - } - - const profileRoot = path.join(process.cwd(), '.cache', 'cdp-profiles') - await mkdir(profileRoot, { recursive: true }) - return path.join(profileRoot, `pw-profile-playwright-cdp-${port}`) +async function createManagedBrowserProfileDir() { + return await mkdtemp(path.join(tmpdir(), 'pw-playwright-cdp-')) } function startManagedBrowser(browserPath, port, profileDir) { const args = [ `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, + '--noerrdialogs', '--no-sandbox', '--disable-dev-shm-usage', '--disable-background-networking', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', + '--disable-breakpad', + '--disable-crash-reporter', + '--disable-crashpad-for-testing', '--disable-sync', '--disable-gpu', ] @@ -951,8 +955,8 @@ async function forceFillInput(locator, value) { } await locator.evaluate((element, nextValue) => { - if (!(element instanceof HTMLInputElement)) { - throw new Error('Target element is not an input.') + if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { + throw new Error('Target element is not a text input.') } element.focus() @@ -2317,6 +2321,62 @@ async function verifySettings(page) { await expect(page).toHaveURL(/\/login$/) } +async function verifyProfileManagement(page) { + logDebug('verifyProfileManagement: admin login /login') + await loginFromLoginPage(page) + + const profileUsername = `e2e_profile_${Date.now()}` + const profilePassword = 'Profile123!@#' + const profileLandingPattern = /\/profile$/ + const suffix = Date.now() + const updatedNickname = `Profile User ${suffix}` + const updatedRegion = `Hangzhou-${suffix}` + + logDebug('verifyProfileManagement: goto /users as admin') + await page.goto(appUrl('/users')) + await expect(page).toHaveURL(/\/users$/) + const createdUser = await createUserFromUsersPage(page, profileUsername, profilePassword) + logDebug(`verifyProfileManagement: created user ${createdUser.username}`) + + logDebug('verifyProfileManagement: reset session before profile user login') + await resetSessionToLogin(page) + + logDebug(`verifyProfileManagement: profile user login ${createdUser.username}`) + await loginWithPassword(page, createdUser.username, profilePassword, profileLandingPattern) + await installFetchDiagnostics(page) + + await expect(page).toHaveURL(/\/profile$/) + await expect(page.getByRole('heading', { name: TEXT.profile })).toBeVisible({ timeout: 10 * 1000 }) + await expect(page.locator('body')).toContainText(createdUser.username, { timeout: 10 * 1000 }) + await expect(page.locator('body')).toContainText(createdUser.email) + + await forceFillInput(page.getByPlaceholder(TEXT.profileNicknamePlaceholder).first(), updatedNickname) + await forceFillInput(page.getByPlaceholder(TEXT.profileRegionPlaceholder).first(), updatedRegion) + await forceFillInput(page.getByPlaceholder(TEXT.profileBioPlaceholder).first(), `Profile bio ${suffix}`) + + const updateProfileFetchCount = await getFetchDiagnosticsCount(page) + const updateProfileFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/users\/\d+$/) && entry.method === 'PUT' + }, async () => { + await forceClick(page.getByRole('button', { name: TEXT.profileSaveChanges }).first()) + }, { + afterCount: updateProfileFetchCount, + label: 'update profile fetch', + }) + assertFetchLogSuccess(updateProfileFetch, 'update profile') + + await expect(page.getByPlaceholder(TEXT.profileNicknamePlaceholder).first()).toHaveValue(updatedNickname, { timeout: 20 * 1000 }) + await expect(page.getByPlaceholder(TEXT.profileRegionPlaceholder).first()).toHaveValue(updatedRegion, { timeout: 20 * 1000 }) + await expect(page.getByPlaceholder(TEXT.profileBioPlaceholder).first()).toHaveValue(`Profile bio ${suffix}`) + + await forceClick(page.locator('a[href="/profile/security"]').first()) + await expect(page).toHaveURL(/\/profile\/security$/) + await expect(page.getByRole('button', { name: TEXT.changePassword })).toBeVisible({ timeout: 10 * 1000 }) + + await resetSessionToLogin(page) + logDebug('verifyProfileManagement: completed') +} + async function verifyProfileAndSecurity(page) { logDebug('verifyProfileAndSecurity: admin login /login') await loginFromLoginPage(page) @@ -2436,17 +2496,44 @@ async function main() { let runtime = null let managedBrowser = null let managedProfileDir = null - const selectedScenarioNames = new Set( - (process.env.E2E_SCENARIOS ?? '') - .split(',') - .map((name) => name.trim()) - .filter(Boolean), - ) + const selectedScenarioNames = parseSelectedScenarioNames(process.env.E2E_SCENARIOS ?? '') + const scenarioEntries = new Map([ + ['admin-bootstrap', verifyAdminBootstrapWorkflow], + ['public-registration', verifyPublicRegistration], + ['email-activation', verifyEmailActivationWorkflow], + ['password-reset', verifyPasswordResetWorkflow], + ['login-surface', verifyLoginSurface], + ['auth-workflow', verifyAuthWorkflow], + ['responsive-login', verifyResponsiveLogin], + ['desktop-mobile-navigation', verifyDesktopAndMobileNavigation], + ['user-management-crud', verifyUserManagementCRUD], + ['user-management-batch', verifyUserManagementBatch], + ['role-management-crud', verifyRoleManagementCRUD], + ['permissions-management-crud', verifyPermissionsManagementCRUD], + ['device-management', verifyDeviceManagement], + ['login-logs', verifyLoginLogs], + ['operation-logs', verifyOperationLogs], + ['webhook-management', verifyWebhookManagement], + ['import-export', verifyImportExport], + ['profile-management', verifyProfileManagement], + ['profile-and-security', verifyProfileAndSecurity], + ['settings', verifySettings], + ['dashboard-stats', verifyDashboardStats], + ]) + const scenarioNamesToRun = selectScenarioNames({ + requestedScenarioNames: selectedScenarioNames, + expectAdminBootstrap: process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1', + }) + + if (process.env.E2E_LIST_SCENARIOS === '1') { + console.log(scenarioNamesToRun.join('\n')) + return + } if (process.env.E2E_MANAGED_BROWSER === '1') { const browserPath = await resolveManagedBrowserPath() const port = await getFreePort() - managedProfileDir = await createManagedBrowserProfileDir(browserPath, port) + managedProfileDir = await createManagedBrowserProfileDir() managedBrowser = startManagedBrowser(browserPath, port, managedProfileDir) managedCdpUrl = `http://127.0.0.1:${port}` console.log(`LAUNCH playwright-cdp ${browserPath}`) @@ -2466,35 +2553,13 @@ async function main() { throw new Error('No persistent Chromium context is available through CDP.') } - const scenarios = [] - if (process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1') { - scenarios.push(['admin-bootstrap', verifyAdminBootstrapWorkflow]) - } - scenarios.push( - ['public-registration', verifyPublicRegistration], - ['email-activation', verifyEmailActivationWorkflow], - ['password-reset', verifyPasswordResetWorkflow], - ['login-surface', verifyLoginSurface], - ['auth-workflow', verifyAuthWorkflow], - ['responsive-login', verifyResponsiveLogin], - ['desktop-mobile-navigation', verifyDesktopAndMobileNavigation], - ['user-management-crud', verifyUserManagementCRUD], - ['user-management-batch', verifyUserManagementBatch], - ['role-management-crud', verifyRoleManagementCRUD], - ['permissions-management-crud', verifyPermissionsManagementCRUD], - ['device-management', verifyDeviceManagement], - ['login-logs', verifyLoginLogs], - ['operation-logs', verifyOperationLogs], - ['webhook-management', verifyWebhookManagement], - ['import-export', verifyImportExport], - ['profile-and-security', verifyProfileAndSecurity], - ['settings', verifySettings], - ['dashboard-stats', verifyDashboardStats], - ) - - const scenariosToRun = selectedScenarioNames.size === 0 - ? scenarios - : scenarios.filter(([name]) => name === 'admin-bootstrap' || selectedScenarioNames.has(name)) + const scenariosToRun = scenarioNamesToRun.map((name) => { + const handler = scenarioEntries.get(name) + if (!handler) { + throw new Error(`No Playwright CDP scenario handler is registered for ${name}.`) + } + return [name, handler] + }) if (scenariosToRun.length === 0) { throw new Error(`No E2E scenarios matched E2E_SCENARIOS=${process.env.E2E_SCENARIOS ?? ''}`) diff --git a/frontend/admin/src/lib/playwright-e2e-scenarios.test.ts b/frontend/admin/src/lib/playwright-e2e-scenarios.test.ts new file mode 100644 index 0000000..82161ba --- /dev/null +++ b/frontend/admin/src/lib/playwright-e2e-scenarios.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { BASE_SCENARIO_NAMES, parseSelectedScenarioNames, selectScenarioNames } from '../../scripts/playwright-e2e-scenarios.mjs' + +describe('playwright-e2e-scenarios', () => { + it('prepends admin bootstrap when capabilities require it', () => { + const scenarioNames = selectScenarioNames({ + requestedScenarioNames: parseSelectedScenarioNames(''), + expectAdminBootstrap: true, + }) + + expect(scenarioNames[0]).toBe('admin-bootstrap') + expect(scenarioNames.slice(1)).toEqual(BASE_SCENARIO_NAMES) + }) + + it('keeps admin bootstrap when filtering a later scenario', () => { + const scenarioNames = selectScenarioNames({ + requestedScenarioNames: parseSelectedScenarioNames('email-activation'), + expectAdminBootstrap: true, + }) + + expect(scenarioNames).toEqual(['admin-bootstrap', 'email-activation']) + }) + + it('does not invent admin bootstrap when it is no longer required', () => { + const scenarioNames = selectScenarioNames({ + requestedScenarioNames: parseSelectedScenarioNames('email-activation'), + expectAdminBootstrap: false, + }) + + expect(scenarioNames).toEqual(['email-activation']) + }) +}) diff --git a/internal/api/handler/device_handler.go b/internal/api/handler/device_handler.go index 321dd9e..407a978 100644 --- a/internal/api/handler/device_handler.go +++ b/internal/api/handler/device_handler.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/user-management-system/internal/api/middleware" "github.com/user-management-system/internal/domain" "github.com/user-management-system/internal/service" ) @@ -22,6 +23,15 @@ func NewDeviceHandler(deviceService *service.DeviceService) *DeviceHandler { return &DeviceHandler{deviceService: deviceService} } +func (h *DeviceHandler) currentActor(c *gin.Context) (int64, bool, bool) { + userID, ok := getUserIDFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"}) + return 0, false, false + } + return userID, middleware.IsAdmin(c), true +} + // CreateDevice 创建设备 // @Summary 创建设备记录 // @Description 当前用户创建设备记录 @@ -118,7 +128,12 @@ func (h *DeviceHandler) GetDevice(c *gin.Context) { return } - device, err := h.deviceService.GetDevice(c.Request.Context(), id) + actorUserID, isAdmin, ok := h.currentActor(c) + if !ok { + return + } + + device, err := h.deviceService.GetDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin) if err != nil { handleError(c, err) return @@ -157,7 +172,12 @@ func (h *DeviceHandler) UpdateDevice(c *gin.Context) { return } - device, err := h.deviceService.UpdateDevice(c.Request.Context(), id, &req) + actorUserID, isAdmin, ok := h.currentActor(c) + if !ok { + return + } + + device, err := h.deviceService.UpdateDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin, &req) if err != nil { handleError(c, err) return @@ -187,7 +207,12 @@ func (h *DeviceHandler) DeleteDevice(c *gin.Context) { return } - if err := h.deviceService.DeleteDevice(c.Request.Context(), id); err != nil { + actorUserID, isAdmin, ok := h.currentActor(c) + if !ok { + return + } + + if err := h.deviceService.DeleteDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin); err != nil { handleError(c, err) return } @@ -238,7 +263,12 @@ func (h *DeviceHandler) UpdateDeviceStatus(c *gin.Context) { return } - if err := h.deviceService.UpdateDeviceStatus(c.Request.Context(), id, status); err != nil { + actorUserID, isAdmin, ok := h.currentActor(c) + if !ok { + return + } + + if err := h.deviceService.UpdateDeviceStatusForActor(c.Request.Context(), actorUserID, id, isAdmin, status); err != nil { handleError(c, err) return } @@ -270,16 +300,7 @@ func (h *DeviceHandler) GetUserDevices(c *gin.Context) { } // 检查是否为管理员 - roleCodes, _ := c.Get("role_codes") - isAdmin := false - if roles, ok := roleCodes.([]string); ok { - for _, role := range roles { - if role == "admin" { - isAdmin = true - break - } - } - } + isAdmin := middleware.IsAdmin(c) userIDParam := c.Param("id") userID, err := strconv.ParseInt(userIDParam, 10, 64) @@ -405,7 +426,12 @@ func (h *DeviceHandler) TrustDevice(c *gin.Context) { // 解析信任持续时间 trustDuration := parseDuration(req.TrustDuration) - if err := h.deviceService.TrustDevice(c.Request.Context(), id, trustDuration); err != nil { + actorUserID, isAdmin, ok := h.currentActor(c) + if !ok { + return + } + + if err := h.deviceService.TrustDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin, trustDuration); err != nil { handleError(c, err) return } @@ -478,7 +504,12 @@ func (h *DeviceHandler) UntrustDevice(c *gin.Context) { return } - if err := h.deviceService.UntrustDevice(c.Request.Context(), id); err != nil { + actorUserID, isAdmin, ok := h.currentActor(c) + if !ok { + return + } + + if err := h.deviceService.UntrustDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin); err != nil { handleError(c, err) return } diff --git a/internal/api/handler/handler_test.go b/internal/api/handler/handler_test.go index 90cc5f8..4e7efec 100644 --- a/internal/api/handler/handler_test.go +++ b/internal/api/handler/handler_test.go @@ -730,6 +730,173 @@ func TestUserHandler_UpdateUser_AdminCanUpdateAnotherUser(t *testing.T) { } } +func TestUserHandler_UpdateUser_ProfileFieldsPersisted(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + registerUser(server.URL, "profileuser", "profileuser@test.com", "UserPass123!") + token := getToken(server.URL, "profileuser", "UserPass123!") + + updatePayload := map[string]interface{}{ + "nickname": "Profile Updated", + "gender": 1, + "birthday": "2026-03-15", + "region": "Hangzhou", + "bio": "Updated bio", + } + + resp, body := doPut(server.URL+"/api/v1/users/1", token, updatePayload) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body) + } + + var updateResult map[string]interface{} + if err := json.Unmarshal([]byte(body), &updateResult); err != nil { + t.Fatalf("failed to parse update response: %v", err) + } + + updateData, ok := updateResult["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected update response data, got %s", body) + } + + if updateData["nickname"] != "Profile Updated" { + t.Fatalf("expected nickname to be updated, got %+v", updateData) + } + if updateData["gender"] != float64(1) { + t.Fatalf("expected gender=1, got %+v", updateData) + } + if updateData["region"] != "Hangzhou" { + t.Fatalf("expected region to be persisted, got %+v", updateData) + } + if updateData["bio"] != "Updated bio" { + t.Fatalf("expected bio to be persisted, got %+v", updateData) + } + + updateBirthday, ok := updateData["birthday"].(string) + if !ok || updateBirthday == "" { + t.Fatalf("expected birthday in update response, got %+v", updateData) + } + parsedUpdateBirthday, err := time.Parse(time.RFC3339, updateBirthday) + if err != nil { + t.Fatalf("expected RFC3339 birthday, got %q: %v", updateBirthday, err) + } + if parsedUpdateBirthday.Format("2006-01-02") != "2026-03-15" { + t.Fatalf("expected birthday 2026-03-15, got %s", parsedUpdateBirthday.Format("2006-01-02")) + } + + getResp, getBody := doGet(server.URL+"/api/v1/users/1", token) + defer getResp.Body.Close() + + if getResp.StatusCode != http.StatusOK { + t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, getResp.StatusCode, getBody) + } + + var getResult map[string]interface{} + if err := json.Unmarshal([]byte(getBody), &getResult); err != nil { + t.Fatalf("failed to parse get response: %v", err) + } + + getData, ok := getResult["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected get response data, got %s", getBody) + } + + if getData["region"] != "Hangzhou" { + t.Fatalf("expected region in get response, got %+v", getData) + } + if getData["bio"] != "Updated bio" { + t.Fatalf("expected bio in get response, got %+v", getData) + } +} + +func TestUserHandler_UpdatePassword_NonAdminCannotUpdateAnotherUser(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + registerUser(server.URL, "password-actor", "password-actor@test.com", "ActorPass123!") + registerUser(server.URL, "password-target", "password-target@test.com", "TargetPass123!") + + actorToken := getToken(server.URL, "password-actor", "ActorPass123!") + if actorToken == "" { + t.Fatal("actor token should not be empty") + } + + resp, body := doPut(server.URL+"/api/v1/users/2/password", actorToken, map[string]interface{}{ + "old_password": "TargetPass123!", + "new_password": "ChangedByOther123!", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body) + } + + oldLoginResp, oldLoginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{ + "account": "password-target", + "password": "TargetPass123!", + }) + defer oldLoginResp.Body.Close() + + if oldLoginResp.StatusCode != http.StatusOK { + t.Fatalf("expected target old password to remain valid, got %d, body: %s", oldLoginResp.StatusCode, oldLoginBody) + } + + newLoginResp, _ := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{ + "account": "password-target", + "password": "ChangedByOther123!", + }) + defer newLoginResp.Body.Close() + + if newLoginResp.StatusCode == http.StatusOK { + t.Fatal("expected unauthorized password change attempt to leave target password unchanged") + } +} + +func TestUserHandler_UpdatePassword_AdminCanResetAnotherUser(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + t.Setenv("BOOTSTRAP_SECRET", "handler-bootstrap-secret") + adminToken := bootstrapAdmin(server.URL, "handler-bootstrap-secret", "passwordadmin", "passwordadmin@test.com", "AdminPass123!") + registerUser(server.URL, "password-target", "password-target@test.com", "TargetPass123!") + + if adminToken == "" { + t.Fatal("bootstrap admin should return access token") + } + + resp, body := doPut(server.URL+"/api/v1/users/2/password", adminToken, map[string]interface{}{ + "new_password": "AdminReset123!", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body) + } + + oldLoginResp, _ := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{ + "account": "password-target", + "password": "TargetPass123!", + }) + defer oldLoginResp.Body.Close() + + if oldLoginResp.StatusCode == http.StatusOK { + t.Fatal("expected old password to be invalid after admin reset") + } + + newLoginResp, newLoginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{ + "account": "password-target", + "password": "AdminReset123!", + }) + defer newLoginResp.Body.Close() + + if newLoginResp.StatusCode != http.StatusOK { + t.Fatalf("expected reset password to work, got %d, body: %s", newLoginResp.StatusCode, newLoginBody) + } +} + func TestUserHandler_DeleteUser_NonAdmin_Forbidden(t *testing.T) { server, cleanup := setupHandlerTestServer(t) defer cleanup() @@ -958,6 +1125,218 @@ func TestDeviceHandler_CreateDevice_Success(t *testing.T) { } } +func createDeviceForHandlerTest(t *testing.T, baseURL, token, deviceID, deviceName string) int64 { + t.Helper() + + resp, body := doPost(baseURL+"/api/v1/devices", token, map[string]interface{}{ + "device_id": deviceID, + "device_name": deviceName, + "device_type": 1, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected device create status %d, got %d, body: %s", http.StatusCreated, resp.StatusCode, body) + } + + var result map[string]interface{} + if err := json.Unmarshal([]byte(body), &result); err != nil { + t.Fatalf("parse create device response failed: %v", err) + } + + data, ok := result["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected device payload, got body: %s", body) + } + + id, ok := data["id"].(float64) + if !ok { + t.Fatalf("expected numeric device id, got body: %s", body) + } + + return int64(id) +} + +func TestDeviceHandler_GetDevice_IDOR_Forbidden(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + registerUser(server.URL, "deviceidor_get_actor", "deviceidor_get_actor@test.com", "UserPass123!") + registerUser(server.URL, "deviceidor_get_owner", "deviceidor_get_owner@test.com", "UserPass123!") + + actorToken := getToken(server.URL, "deviceidor_get_actor", "UserPass123!") + ownerToken := getToken(server.URL, "deviceidor_get_owner", "UserPass123!") + deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-get", "Owner Device") + + resp, body := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), actorToken) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected status %d for cross-user device read, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body) + } +} + +func TestDeviceHandler_UpdateDevice_IDOR_Forbidden(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + registerUser(server.URL, "deviceidor_update_actor", "deviceidor_update_actor@test.com", "UserPass123!") + registerUser(server.URL, "deviceidor_update_owner", "deviceidor_update_owner@test.com", "UserPass123!") + + actorToken := getToken(server.URL, "deviceidor_update_actor", "UserPass123!") + ownerToken := getToken(server.URL, "deviceidor_update_owner", "UserPass123!") + deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-update", "Original Device") + + resp, body := doPut(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), actorToken, map[string]interface{}{ + "device_name": "Hacked Device", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected status %d for cross-user device update, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body) + } + + ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken) + defer ownerResp.Body.Close() + + if ownerResp.StatusCode != http.StatusOK { + t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody) + } + + if !bytes.Contains([]byte(ownerBody), []byte("Original Device")) { + t.Fatalf("expected device name to remain unchanged, body: %s", ownerBody) + } +} + +func TestDeviceHandler_DeleteDevice_IDOR_Forbidden(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + registerUser(server.URL, "deviceidor_delete_actor", "deviceidor_delete_actor@test.com", "UserPass123!") + registerUser(server.URL, "deviceidor_delete_owner", "deviceidor_delete_owner@test.com", "UserPass123!") + + actorToken := getToken(server.URL, "deviceidor_delete_actor", "UserPass123!") + ownerToken := getToken(server.URL, "deviceidor_delete_owner", "UserPass123!") + deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-delete", "Delete Target") + + resp, body := doDelete(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), actorToken) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected status %d for cross-user device delete, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body) + } + + ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken) + defer ownerResp.Body.Close() + + if ownerResp.StatusCode != http.StatusOK { + t.Fatalf("expected device to remain after forbidden delete, got %d, body: %s", ownerResp.StatusCode, ownerBody) + } +} + +func TestDeviceHandler_TrustDevice_IDOR_Forbidden(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + registerUser(server.URL, "deviceidor_trust_actor", "deviceidor_trust_actor@test.com", "UserPass123!") + registerUser(server.URL, "deviceidor_trust_owner", "deviceidor_trust_owner@test.com", "UserPass123!") + + actorToken := getToken(server.URL, "deviceidor_trust_actor", "UserPass123!") + ownerToken := getToken(server.URL, "deviceidor_trust_owner", "UserPass123!") + deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-trust", "Trust Target") + + resp, body := doPost(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), actorToken, map[string]interface{}{ + "trust_duration": "24h", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected status %d for cross-user device trust, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body) + } + + ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken) + defer ownerResp.Body.Close() + + if ownerResp.StatusCode != http.StatusOK { + t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody) + } + + if bytes.Contains([]byte(ownerBody), []byte("\"is_trusted\":true")) { + t.Fatalf("expected forbidden trust to leave device untrusted, body: %s", ownerBody) + } +} + +func TestDeviceHandler_UntrustDevice_IDOR_Forbidden(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + registerUser(server.URL, "deviceidor_untrust_actor", "deviceidor_untrust_actor@test.com", "UserPass123!") + registerUser(server.URL, "deviceidor_untrust_owner", "deviceidor_untrust_owner@test.com", "UserPass123!") + + actorToken := getToken(server.URL, "deviceidor_untrust_actor", "UserPass123!") + ownerToken := getToken(server.URL, "deviceidor_untrust_owner", "UserPass123!") + deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-untrust", "Untrust Target") + + trustResp, trustBody := doPost(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), ownerToken, map[string]interface{}{ + "trust_duration": "24h", + }) + defer trustResp.Body.Close() + + if trustResp.StatusCode != http.StatusOK { + t.Fatalf("expected owner trust status %d, got %d, body: %s", http.StatusOK, trustResp.StatusCode, trustBody) + } + + resp, body := doDelete(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), actorToken) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected status %d for cross-user device untrust, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body) + } + + ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken) + defer ownerResp.Body.Close() + + if ownerResp.StatusCode != http.StatusOK { + t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody) + } + + if !bytes.Contains([]byte(ownerBody), []byte("\"is_trusted\":true")) { + t.Fatalf("expected forbidden untrust to leave trusted device unchanged, body: %s", ownerBody) + } +} + +func TestDeviceHandler_UpdateDeviceStatus_IDOR_Forbidden(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + registerUser(server.URL, "deviceidor_status_actor", "deviceidor_status_actor@test.com", "UserPass123!") + registerUser(server.URL, "deviceidor_status_owner", "deviceidor_status_owner@test.com", "UserPass123!") + + actorToken := getToken(server.URL, "deviceidor_status_actor", "UserPass123!") + ownerToken := getToken(server.URL, "deviceidor_status_owner", "UserPass123!") + deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-status", "Status Target") + + resp, body := doPut(fmt.Sprintf("%s/api/v1/devices/%d/status", server.URL, deviceID), actorToken, map[string]interface{}{ + "status": "inactive", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected status %d for cross-user device status update, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body) + } + + ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken) + defer ownerResp.Body.Close() + + if ownerResp.StatusCode != http.StatusOK { + t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody) + } + + if !bytes.Contains([]byte(ownerBody), []byte("\"status\":1")) { + t.Fatalf("expected forbidden status update to leave device active, body: %s", ownerBody) + } +} + // ============================================================================= // Role Handler Tests // ============================================================================= diff --git a/internal/api/handler/user_handler.go b/internal/api/handler/user_handler.go index 2ba431d..5a4a006 100644 --- a/internal/api/handler/user_handler.go +++ b/internal/api/handler/user_handler.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "strconv" + "time" "github.com/gin-gonic/gin" @@ -195,8 +196,13 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { } var req struct { - Email *string `json:"email"` - Nickname *string `json:"nickname"` + Email *string `json:"email"` + Phone *string `json:"phone"` + Nickname *string `json:"nickname"` + Gender *domain.Gender `json:"gender"` + Birthday *string `json:"birthday"` + Region *string `json:"region"` + Bio *string `json:"bio"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -211,11 +217,35 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { } if req.Email != nil { - user.Email = req.Email + user.Email = domain.StrPtr(*req.Email) + } + if req.Phone != nil { + user.Phone = domain.StrPtr(*req.Phone) } if req.Nickname != nil { user.Nickname = *req.Nickname } + if req.Gender != nil { + user.Gender = *req.Gender + } + if req.Birthday != nil { + if *req.Birthday == "" { + user.Birthday = nil + } else if birthday, err := time.Parse("2006-01-02", *req.Birthday); err == nil { + user.Birthday = &birthday + } else if birthday, err := time.Parse(time.RFC3339, *req.Birthday); err == nil { + user.Birthday = &birthday + } else { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid birthday"}) + return + } + } + if req.Region != nil { + user.Region = *req.Region + } + if req.Bio != nil { + user.Bio = *req.Bio + } if err := h.userService.Update(c.Request.Context(), user); err != nil { handleError(c, err) @@ -272,8 +302,16 @@ func (h *UserHandler) UpdatePassword(c *gin.Context) { return } + currentUserID := c.GetInt64("user_id") + isAdmin := middleware.IsAdmin(c) + isSelf := currentUserID == id + if !isSelf && !isAdmin { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "permission denied"}) + return + } + var req struct { - OldPassword string `json:"old_password" binding:"required"` + OldPassword string `json:"old_password"` NewPassword string `json:"new_password" binding:"required"` } @@ -282,9 +320,16 @@ func (h *UserHandler) UpdatePassword(c *gin.Context) { return } - if err := h.userService.ChangePassword(c.Request.Context(), id, req.OldPassword, req.NewPassword); err != nil { - handleError(c, err) - return + if isSelf { + if err := h.userService.ChangePassword(c.Request.Context(), id, req.OldPassword, req.NewPassword); err != nil { + handleError(c, err) + return + } + } else { + if err := h.userService.AdminResetPassword(c.Request.Context(), id, req.NewPassword); err != nil { + handleError(c, err) + return + } } c.JSON(http.StatusOK, gin.H{"code": 0, "message": "密码修改成功"}) @@ -570,11 +615,22 @@ func (h *UserHandler) DeleteAdmin(c *gin.Context) { } type UserResponse struct { - ID int64 `json:"id"` - Username string `json:"username"` - Email string `json:"email,omitempty"` - Nickname string `json:"nickname,omitempty"` - Status string `json:"status"` + ID int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + Nickname string `json:"nickname,omitempty"` + Avatar string `json:"avatar,omitempty"` + Gender domain.Gender `json:"gender"` + Birthday *time.Time `json:"birthday,omitempty"` + Region string `json:"region,omitempty"` + Bio string `json:"bio,omitempty"` + Status string `json:"status"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + LastLoginIP string `json:"last_login_ip,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + TOTPEnabled bool `json:"totp_enabled"` } func toUserResponse(u *domain.User) *UserResponse { @@ -582,11 +638,26 @@ func toUserResponse(u *domain.User) *UserResponse { if u.Email != nil { email = *u.Email } + phone := "" + if u.Phone != nil { + phone = *u.Phone + } return &UserResponse{ - ID: u.ID, - Username: u.Username, - Email: email, - Nickname: u.Nickname, - Status: strconv.FormatInt(int64(u.Status), 10), + ID: u.ID, + Username: u.Username, + Email: email, + Phone: phone, + Nickname: u.Nickname, + Avatar: u.Avatar, + Gender: u.Gender, + Birthday: u.Birthday, + Region: u.Region, + Bio: u.Bio, + Status: strconv.FormatInt(int64(u.Status), 10), + LastLoginAt: u.LastLoginTime, + LastLoginIP: u.LastLoginIP, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + TOTPEnabled: u.TOTPEnabled, } } diff --git a/internal/api/middleware/operation_log.go b/internal/api/middleware/operation_log.go index c01a344..3a0d661 100644 --- a/internal/api/middleware/operation_log.go +++ b/internal/api/middleware/operation_log.go @@ -88,6 +88,11 @@ func (m *OperationLogMiddleware) Record() gin.HandlerFunc { } go func(entry *domain.OperationLog) { + defer func() { + if r := recover(); r != nil { + // PERF-07: panic recover 保护,防止操作日志写入异常导致进程崩溃 + } + }() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = m.repo.Create(ctx, entry) diff --git a/internal/api/middleware/ratelimit.go b/internal/api/middleware/ratelimit.go index 46c4bf0..c396c7d 100644 --- a/internal/api/middleware/ratelimit.go +++ b/internal/api/middleware/ratelimit.go @@ -199,3 +199,47 @@ func (m *RateLimitMiddleware) getOrCreateLimiter(key string, window time.Duratio m.limiters[key] = limiter return limiter } + +// Cleanup 清理过期的不活跃 limiter,防止 map 无界增长(P0 资源泄漏修复) +func (m *RateLimitMiddleware) Cleanup() { + m.mu.Lock() + defer m.mu.Unlock() + + now := time.Now().UnixMilli() + for key, limiter := range m.limiters { + limiter.mu.Lock() + cutoff := now - limiter.window.Milliseconds() + // 只保留仍在窗口内的请求时间戳 + validRequests := make([]int64, 0, len(limiter.requests)) + for _, ts := range limiter.requests { + if ts > cutoff { + validRequests = append(validRequests, ts) + } + } + limiter.requests = validRequests + isEmpty := len(limiter.requests) == 0 + limiter.mu.Unlock() + + if isEmpty { + delete(m.limiters, key) + } + } +} + +// StartCleanup 启动后台定期清理 goroutine,返回停止函数(P0 资源泄漏修复) +func (m *RateLimitMiddleware) StartCleanup() func() { + ticker := time.NewTicker(m.cleanupInt) + done := make(chan struct{}) + go func() { + for { + select { + case <-ticker.C: + m.Cleanup() + case <-done: + ticker.Stop() + return + } + } + }() + return func() { close(done) } +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 2d8a198..a2c760c 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -122,7 +122,10 @@ func (r *Router) Setup() *gin.Engine { ) } - r.engine.Static("/uploads", "./uploads") + // P0 安全修复:/uploads 目录不再公开暴露,改为需要认证后才能访问 + uploadsGroup := r.engine.Group("/uploads", r.authMiddleware.Required()) + uploadsGroup.Static("", "./uploads") + r.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) if r.ipFilterMiddleware != nil { diff --git a/internal/auth/totp.go b/internal/auth/totp.go index 170042c..cb48dcb 100644 --- a/internal/auth/totp.go +++ b/internal/auth/totp.go @@ -10,11 +10,13 @@ import ( "encoding/hex" "fmt" "image/png" + "regexp" "strings" "time" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" + "golang.org/x/crypto/bcrypt" ) const ( @@ -111,27 +113,62 @@ func ValidateRecoveryCode(inputCode string, storedCodes []string) (int, bool) { return -1, false } -// HashRecoveryCode 使用 SHA256 哈希恢复码(用于存储) +// HashRecoveryCode 使用 bcrypt 慢哈希恢复码(用于存储) +// P2 安全增强:将 SHA256 快速哈希升级为 bcrypt 慢哈希,防止 GPU 暴力破解 func HashRecoveryCode(code string) (string, error) { - h := sha256.Sum256([]byte(code)) - return hex.EncodeToString(h[:]), nil + normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(code), "-", "")) + hash, err := bcrypt.GenerateFromPassword([]byte(normalized), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("hash recovery code failed: %w", err) + } + return string(hash), nil } -// VerifyRecoveryCode 验证恢复码(自动哈希后比较) +// sha256HexPattern 匹配 64 位十六进制字符串(旧版 SHA256 哈希格式) +var sha256HexPattern = regexp.MustCompile("^[0-9a-fA-F]{64}$") + +// isLegacySHA256Hash 检测是否为旧版 SHA256 哈希值 +func isLegacySHA256Hash(hash string) bool { + return sha256HexPattern.MatchString(hash) +} + +// legacyHashRecoveryCode 旧版 SHA256 哈希(用于向后兼容验证) +func legacyHashRecoveryCode(code string) string { + h := sha256.Sum256([]byte(code)) + return hex.EncodeToString(h[:]) +} + +// VerifyRecoveryCode 验证恢复码(支持 bcrypt 新哈希和 SHA256 旧哈希向后兼容) // 使用恒定时间比较防止时序攻击 func VerifyRecoveryCode(inputCode string, hashedCodes []string) (int, bool) { - hashedInput, err := HashRecoveryCode(inputCode) - if err != nil { - return -1, false - } + normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(inputCode), "-", "")) found := -1 - // 固定次数比较,防止时序攻击泄露匹配位置 + for i := 0; i < len(hashedCodes); i++ { - hashed := hashedCodes[i] - if subtle.ConstantTimeCompare([]byte(hashedInput), []byte(hashed)) == 1 { + stored := hashedCodes[i] + if stored == "" { + continue + } + + var matched bool + if isLegacySHA256Hash(stored) { + // 向后兼容:旧版 SHA256 哈希 + hashedInput := legacyHashRecoveryCode(inputCode) + if subtle.ConstantTimeCompare([]byte(hashedInput), []byte(stored)) == 1 { + matched = true + } + } else { + // 新版 bcrypt 哈希 + if err := bcrypt.CompareHashAndPassword([]byte(stored), []byte(normalized)); err == nil { + matched = true + } + } + + if matched { found = i } } + if found >= 0 { return found, true } diff --git a/internal/auth/totp_test.go b/internal/auth/totp_test.go index 36d57bc..d35fe63 100644 --- a/internal/auth/totp_test.go +++ b/internal/auth/totp_test.go @@ -112,27 +112,29 @@ func TestHashRecoveryCode(t *testing.T) { t.Fatal("HashRecoveryCode should return non-empty hash") } - // Same code should produce same hash - hashed2, err := HashRecoveryCode(code) - if err != nil { - t.Fatalf("HashRecoveryCode second call failed: %v", err) + // Same code should verify against its own hash (bcrypt uses random salt, so hashes differ) + _, ok := VerifyRecoveryCode(code, []string{hashed}) + if !ok { + t.Error("Same code should verify against its own hash") } - if hashed != hashed2 { - t.Error("Same code should produce same hash") - } - - // Different codes should produce different hashes + // Different codes should NOT verify hashed3, err := HashRecoveryCode("DIFFERENT-CODE") if err != nil { t.Fatalf("HashRecoveryCode for different code failed: %v", err) } - if hashed == hashed3 { - t.Error("Different codes should produce different hashes") + _, ok2 := VerifyRecoveryCode(code, []string{hashed3}) + if ok2 { + t.Error("Different codes should NOT verify against each other's hash") } - t.Logf("Hashed code: %s", hashed) + // bcrypt hash format check + if !strings.HasPrefix(hashed, "$2a$") { + t.Errorf("Hash should be bcrypt format, got: %s", hashed) + } + + t.Logf("Hashed code (bcrypt): %s", hashed) } func TestVerifyRecoveryCode(t *testing.T) { diff --git a/internal/repository/device.go b/internal/repository/device.go index 13e7c16..b2dc810 100644 --- a/internal/repository/device.go +++ b/internal/repository/device.go @@ -207,6 +207,16 @@ func (r *DeviceRepository) GetTrustedDevices(ctx context.Context, userID int64) return devices, nil } +// CountTrustedDevices 统计用户当前信任设备数量(未过期的) +func (r *DeviceRepository) CountTrustedDevices(ctx context.Context, userID int64) (int64, error) { + var count int64 + now := time.Now() + err := r.db.WithContext(ctx).Model(&domain.Device{}). + Where("user_id = ? AND is_trusted = ? AND (trust_expires_at IS NULL OR trust_expires_at > ?)", userID, true, now). + Count(&count).Error + return count, err +} + // ListDevicesParams 设备列表查询参数 type ListDevicesParams struct { UserID int64 diff --git a/internal/repository/role.go b/internal/repository/role.go index cbb5d6a..e969fce 100644 --- a/internal/repository/role.go +++ b/internal/repository/role.go @@ -191,13 +191,20 @@ func (r *RoleRepository) GetByIDs(ctx context.Context, ids []int64) ([]*domain.R return roles, nil } +// maxAncestorDepth 角色祖先查询最大深度,防止循环引用导致无限循环 +const maxAncestorDepth = 20 + // GetAncestorIDs 获取角色的所有祖先角色ID(用于权限继承) func (r *RoleRepository) GetAncestorIDs(ctx context.Context, roleID int64) ([]int64, error) { var ancestorIDs []int64 currentID := roleID + depth := 0 - // 循环向上查找父角色,直到没有父角色为止 + // 循环向上查找父角色,直到没有父角色或达到深度上限为止 for { + if depth >= maxAncestorDepth { + break + } var role domain.Role err := r.db.WithContext(ctx).Select("id", "parent_id").First(&role, currentID).Error if err != nil { @@ -211,6 +218,7 @@ func (r *RoleRepository) GetAncestorIDs(ctx context.Context, roleID int64) ([]in } ancestorIDs = append(ancestorIDs, *role.ParentID) currentID = *role.ParentID + depth++ } return ancestorIDs, nil diff --git a/internal/repository/role_permission.go b/internal/repository/role_permission.go index d36e671..e9e8729 100644 --- a/internal/repository/role_permission.go +++ b/internal/repository/role_permission.go @@ -119,15 +119,61 @@ func (r *RolePermissionRepository) GetPermissionByID(ctx context.Context, permis return &permission, nil } -// GetPermissionIDsByRoleIDs 根据角色ID列表批量获取权限ID +// GetRoleAncestorIDs 递归获取角色的所有祖先角色ID(含自身) +// 包含循环检测(最大深度 5 层) +func (r *RolePermissionRepository) GetRoleAncestorIDs(ctx context.Context, roleID int64) ([]int64, error) { + var ancestors []int64 + visited := make(map[int64]bool) + current := roleID + depth := 0 + maxDepth := 5 + + for current > 0 && depth < maxDepth { + if visited[current] { + break // 循环检测 + } + visited[current] = true + ancestors = append(ancestors, current) + + var role domain.Role + err := r.db.WithContext(ctx).Select("parent_id").First(&role, current).Error + if err != nil || role.ParentID == nil { + break + } + current = *role.ParentID + depth++ + } + + return ancestors, nil +} + +// GetPermissionIDsByRoleIDs 根据角色ID列表批量获取权限ID(含继承的父角色权限) func (r *RolePermissionRepository) GetPermissionIDsByRoleIDs(ctx context.Context, roleIDs []int64) ([]int64, error) { if len(roleIDs) == 0 { return []int64{}, nil } + // 收集所有角色ID(含继承的父角色) + allRoleIDs := make(map[int64]bool) + for _, roleID := range roleIDs { + ancestors, err := r.GetRoleAncestorIDs(ctx, roleID) + if err != nil { + return nil, err + } + for _, id := range ancestors { + allRoleIDs[id] = true + } + } + + // 转换为 slice + ids := make([]int64, 0, len(allRoleIDs)) + for id := range allRoleIDs { + ids = append(ids, id) + } + var permissionIDs []int64 err := r.db.WithContext(ctx).Model(&domain.RolePermission{}). - Where("role_id IN ?", roleIDs). + Where("role_id IN ?", ids). Pluck("permission_id", &permissionIDs).Error if err != nil { return nil, err diff --git a/internal/repository/user.go b/internal/repository/user.go index 2183c75..402483c 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -104,6 +104,18 @@ func (r *UserRepository) GetByPhone(ctx context.Context, phone string) (*domain. return &user, nil } +// FindByAccount 按账号查询用户(支持用户名/邮箱/手机号,P1性能优化:替代串行查询) +func (r *UserRepository) FindByAccount(ctx context.Context, account string) (*domain.User, error) { + var user domain.User + err := r.db.WithContext(ctx). + Where("username = ? OR email = ? OR phone = ?", account, account, account). + First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + // List 获取用户列表 func (r *UserRepository) List(ctx context.Context, offset, limit int) ([]*domain.User, int64, error) { var users []*domain.User @@ -195,6 +207,21 @@ func (r *UserRepository) ExistsByPhone(ctx context.Context, phone string) (bool, return count > 0, err } +// FilterExistingUsernames 批量筛选已存在的用户名(P1性能优化:替代循环查询) +func (r *UserRepository) FilterExistingUsernames(ctx context.Context, usernames []string) ([]string, error) { + if len(usernames) == 0 { + return []string{}, nil + } + var existing []string + err := r.db.WithContext(ctx).Model(&domain.User{}). + Where("username IN ?", usernames). + Pluck("username", &existing).Error + if err != nil { + return nil, err + } + return existing, nil +} + // Search 搜索用户 func (r *UserRepository) Search(ctx context.Context, keyword string, offset, limit int) ([]*domain.User, int64, error) { var users []*domain.User diff --git a/internal/repository/user_role.go b/internal/repository/user_role.go index 5c2c0c4..db3bc4b 100644 --- a/internal/repository/user_role.go +++ b/internal/repository/user_role.go @@ -83,69 +83,89 @@ func (r *UserRoleRepository) GetRoleIDsByUserID(ctx context.Context, userID int6 return roleIDs, nil } -// GetUserRolesAndPermissions 获取用户角色和权限(PERF-01 优化:合并为单次 JOIN 查询) -func (r *UserRoleRepository) GetUserRolesAndPermissions(ctx context.Context, userID int64) ([]*domain.Role, []*domain.Permission, error) { - var results []struct { - RoleID int64 - RoleName string - RoleCode string - RoleStatus int - PermissionID int64 - PermissionCode string - PermissionName string +// getRoleAncestorIDs 递归获取角色的所有祖先角色ID(含自身) +// 包含循环检测(最大深度 5 层) +func (r *UserRoleRepository) getRoleAncestorIDs(ctx context.Context, roleID int64) ([]int64, error) { + var ancestors []int64 + visited := make(map[int64]bool) + current := roleID + depth := 0 + maxDepth := 5 + + for current > 0 && depth < maxDepth { + if visited[current] { + break // 循环检测 + } + visited[current] = true + ancestors = append(ancestors, current) + + var role domain.Role + err := r.db.WithContext(ctx).Select("parent_id").First(&role, current).Error + if err != nil || role.ParentID == nil { + break + } + current = *role.ParentID + depth++ } - // 使用 LEFT JOIN 一次性获取用户角色和权限 - err := r.db.WithContext(ctx). - Raw(` - SELECT DISTINCT r.id as role_id, r.name as role_name, r.code as role_code, r.status as role_status, - p.id as permission_id, p.code as permission_code, p.name as permission_name - FROM user_roles ur - JOIN roles r ON ur.role_id = r.id - LEFT JOIN role_permissions rp ON r.id = rp.role_id - LEFT JOIN permissions p ON rp.permission_id = p.id - WHERE ur.user_id = ? AND r.status = 1 - `, userID). - Scan(&results).Error + return ancestors, nil +} + +// GetUserRolesAndPermissions 获取用户角色和权限(包含继承的父角色和权限) +func (r *UserRoleRepository) GetUserRolesAndPermissions(ctx context.Context, userID int64) ([]*domain.Role, []*domain.Permission, error) { + // 获取用户直接分配的角色ID + var directRoleIDs []int64 + err := r.db.WithContext(ctx).Model(&domain.UserRole{}).Where("user_id = ?", userID).Pluck("role_id", &directRoleIDs).Error if err != nil { return nil, nil, err } - // 构建角色和权限列表 - roleMap := make(map[int64]*domain.Role) - permMap := make(map[int64]*domain.Permission) - - for _, row := range results { - if _, ok := roleMap[row.RoleID]; !ok { - roleMap[row.RoleID] = &domain.Role{ - ID: row.RoleID, - Name: row.RoleName, - Code: row.RoleCode, - Status: domain.RoleStatus(row.RoleStatus), - } + // 递归获取所有祖先角色ID(含自身),包含循环检测 + allRoleIDMap := make(map[int64]bool) + for _, roleID := range directRoleIDs { + ancestors, err := r.getRoleAncestorIDs(ctx, roleID) + if err != nil { + return nil, nil, err } - if row.PermissionID > 0 { - if _, ok := permMap[row.PermissionID]; !ok { - permMap[row.PermissionID] = &domain.Permission{ - ID: row.PermissionID, - Code: row.PermissionCode, - Name: row.PermissionName, - } - } + for _, id := range ancestors { + allRoleIDMap[id] = true } } - roles := make([]*domain.Role, 0, len(roleMap)) - for _, role := range roleMap { - roles = append(roles, role) + // 转换为 slice + allRoleIDs := make([]int64, 0, len(allRoleIDMap)) + for id := range allRoleIDMap { + allRoleIDs = append(allRoleIDs, id) } - perms := make([]*domain.Permission, 0, len(permMap)) - for _, perm := range permMap { - perms = append(perms, perm) + if len(allRoleIDs) == 0 { + return []*domain.Role{}, []*domain.Permission{}, nil } - return roles, perms, nil + // 查询所有角色信息 + var roles []*domain.Role + err = r.db.WithContext(ctx).Where("id IN ? AND status = ?", allRoleIDs, domain.RoleStatusEnabled).Find(&roles).Error + if err != nil { + return nil, nil, err + } + + // 查询所有权限ID + var permissionIDs []int64 + err = r.db.WithContext(ctx).Model(&domain.RolePermission{}).Where("role_id IN ?", allRoleIDs).Pluck("permission_id", &permissionIDs).Error + if err != nil { + return nil, nil, err + } + + // 查询权限详情 + var permissions []*domain.Permission + if len(permissionIDs) > 0 { + err = r.db.WithContext(ctx).Where("id IN ?", permissionIDs).Find(&permissions).Error + if err != nil { + return nil, nil, err + } + } + + return roles, permissions, nil } // GetUserIDByRoleID 根据角色ID获取用户ID列表 diff --git a/internal/security/validator.go b/internal/security/validator.go index 0dd958b..41f4cd8 100644 --- a/internal/security/validator.go +++ b/internal/security/validator.go @@ -11,48 +11,92 @@ type Validator struct { passwordMinLength int passwordRequireSpecial bool passwordRequireNumber bool + // 预编译的正则表达式,避免每次调用重复编译(P1性能优化) + emailRe *regexp.Regexp + phoneRe *regexp.Regexp + usernameRe *regexp.Regexp + urlRe *regexp.Regexp + sqlPatterns []*regexp.Regexp + xssPatterns []*regexp.Regexp } // NewValidator creates a validator with the configured password rules. func NewValidator(minLength int, requireSpecial, requireNumber bool) *Validator { - return &Validator{ + v := &Validator{ passwordMinLength: minLength, passwordRequireSpecial: requireSpecial, passwordRequireNumber: requireNumber, } + + // 预编译常用验证正则(P1性能优化) + v.emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + v.phoneRe = regexp.MustCompile(`^1[3-9]\d{9}$`) + v.usernameRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{3,19}$`) + v.urlRe = regexp.MustCompile(`^https?://[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=]+$`) + + // 预编译SQL注入检测正则(P1性能优化) + sqlRawPatterns := []string{ + `;[\s]*--`, + `/\*.*?\*/`, + `\bxp_\w+`, + `\bexec[\s\(]`, + `\bsp_\w+`, + `\bwaitfor[\s]+delay`, + `\bunion[\s]+select`, + `\bdrop[\s]+table`, + `\binsert[\s]+into`, + `\bupdate[\s]+\w+[\s]+set`, + `\bdelete[\s]+from`, + } + v.sqlPatterns = make([]*regexp.Regexp, len(sqlRawPatterns)) + for i, p := range sqlRawPatterns { + v.sqlPatterns[i] = regexp.MustCompile(`(?i)` + p) + } + + // 预编译XSS检测正则(P1性能优化) + xssRawPatterns := []string{ + `(?i)]*>.*?`, + `(?i)`, + `(?i)]*>.*?`, + `(?i)]*>.*?`, + `(?i)]*>.*?`, + `(?i)]*>.*?`, + `(?i)javascript\s*:`, + `(?i)vbscript\s*:`, + `(?i)data\s*:`, + `(?i)on\w+\s*=`, + `(?i)]*>.*?`, + } + v.xssPatterns = make([]*regexp.Regexp, len(xssRawPatterns)) + for i, p := range xssRawPatterns { + v.xssPatterns[i] = regexp.MustCompile(p) + } + + return v } // ValidateEmail validates email format. func (v *Validator) ValidateEmail(email string) bool { - if email == "" { + if email == "" || v.emailRe == nil { return false } - - pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` - matched, _ := regexp.MatchString(pattern, email) - return matched + return v.emailRe.MatchString(email) } // ValidatePhone validates mainland China mobile numbers. func (v *Validator) ValidatePhone(phone string) bool { - if phone == "" { + if phone == "" || v.phoneRe == nil { return false } - - pattern := `^1[3-9]\d{9}$` - matched, _ := regexp.MatchString(pattern, phone) - return matched + return v.phoneRe.MatchString(phone) } // ValidateUsername validates usernames. func (v *Validator) ValidateUsername(username string) bool { - if username == "" { + if username == "" || v.usernameRe == nil { return false } - - pattern := `^[a-zA-Z][a-zA-Z0-9_]{3,19}$` - matched, _ := regexp.MatchString(pattern, username) - return matched + return v.usernameRe.MatchString(username) } // ValidatePassword validates passwords using the shared runtime policy. @@ -77,27 +121,13 @@ func (v *Validator) SanitizeSQL(input string) string { `"`, `""`, ) - // Remove common SQL injection patterns that could bypass quoting - dangerousPatterns := []string{ - `;[\s]*--`, // SQL comment - `/\*.*?\*/`, // Block comment (non-greedy) - `\bxp_\w+`, // Extended stored procedures - `\bexec[\s\(]`, // EXEC statements - `\bsp_\w+`, // System stored procedures - `\bwaitfor[\s]+delay`, // Time-based blind SQL injection - `\bunion[\s]+select`, // UNION injection - `\bdrop[\s]+table`, // DROP TABLE - `\binsert[\s]+into`, // INSERT - `\bupdate[\s]+\w+[\s]+set`, // UPDATE - `\bdelete[\s]+from`, // DELETE - } - result := replacer.Replace(input) - // Apply pattern removal - for _, pattern := range dangerousPatterns { - re := regexp.MustCompile(`(?i)` + pattern) // Case-insensitive - result = re.ReplaceAllString(result, "") + // 使用预编译的正则移除SQL注入模式(P1性能优化) + for _, re := range v.sqlPatterns { + if re != nil { + result = re.ReplaceAllString(result, "") + } } return result @@ -106,31 +136,11 @@ func (v *Validator) SanitizeSQL(input string) string { // SanitizeXSS removes obviously dangerous XSS patterns using regex. // This is a defense-in-depth measure; output encoding should always be used. func (v *Validator) SanitizeXSS(input string) string { - // Remove dangerous tags and attributes using pattern matching - dangerousPatterns := []struct { - pattern string - replaceAll bool - }{ - {`(?i)]*>.*?`, true}, // Script tags - {`(?i)`, false}, // Closing script - {`(?i)]*>.*?`, true}, // Iframe injection - {`(?i)]*>.*?`, true}, // Object injection - {`(?i)]*>.*?`, true}, // Embed injection - {`(?i)]*>.*?`, true}, // Applet injection - {`(?i)javascript\s*:`, false}, // JavaScript protocol - {`(?i)vbscript\s*:`, false}, // VBScript protocol - {`(?i)data\s*:`, false}, // Data URL protocol - {`(?i)on\w+\s*=`, false}, // Event handlers - {`(?i)]*>.*?`, true}, // Style injection - } - result := input - for _, p := range dangerousPatterns { - re := regexp.MustCompile(p.pattern) - if p.replaceAll { - result = re.ReplaceAllString(result, "") - } else { + // 使用预编译的正则移除XSS模式(P1性能优化) + for _, re := range v.xssPatterns { + if re != nil { result = re.ReplaceAllString(result, "") } } @@ -148,13 +158,10 @@ func (v *Validator) SanitizeXSS(input string) string { // ValidateURL validates a basic HTTP/HTTPS URL. func (v *Validator) ValidateURL(url string) bool { - if url == "" { + if url == "" || v.urlRe == nil { return false } - - pattern := `^https?://[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=]+$` - matched, _ := regexp.MatchString(pattern, url) - return matched + return v.urlRe.MatchString(url) } // ValidateIP validates IPv4 or IPv6 addresses using net.ParseIP. diff --git a/internal/server/server.go b/internal/server/server.go index c60e830..67e9191 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -148,6 +148,9 @@ func Serve(cfg *config.Config) error { // 初始化中间件 rateLimitMiddleware := middleware.NewRateLimitMiddleware(cfg.RateLimit) + stopRateLimitCleanup := rateLimitMiddleware.StartCleanup() + defer stopRateLimitCleanup() + authMiddleware := middleware.NewAuthMiddleware( jwtManager, userRepo, diff --git a/internal/service/auth.go b/internal/service/auth.go index f6c8feb..a9aee5c 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -30,6 +30,8 @@ const ( defaultTOTPChallengeTTL = 5 * time.Minute defaultPasswordMinLen = 8 refreshTokenRetryGrace = 10 * time.Second + MaxUsernameAttempts = 100 // 最大尝试次数(P1性能优化:减少循环查询) + MaxUsernameLength = 40 // 用户名最大长度 ) type userRepositoryInterface interface { @@ -38,6 +40,7 @@ type userRepositoryInterface interface { UpdateTOTP(ctx context.Context, user *domain.User) error Delete(ctx context.Context, id int64) error GetByID(ctx context.Context, id int64) (*domain.User, error) + GetByIDs(ctx context.Context, ids []int64) ([]*domain.User, error) GetByUsername(ctx context.Context, username string) (*domain.User, error) GetByEmail(ctx context.Context, email string) (*domain.User, error) GetByPhone(ctx context.Context, phone string) (*domain.User, error) @@ -49,6 +52,10 @@ type userRepositoryInterface interface { ExistsByEmail(ctx context.Context, email string) (bool, error) ExistsByPhone(ctx context.Context, phone string) (bool, error) Search(ctx context.Context, keyword string, offset, limit int) ([]*domain.User, int64, error) + // FilterExistingUsernames 批量筛选已存在的用户名(P1性能优化:替代循环查询) + FilterExistingUsernames(ctx context.Context, usernames []string) ([]string, error) + // FindByAccount 按账号查询用户(支持用户名/邮箱/手机号,P1性能优化:替代串行查询) + FindByAccount(ctx context.Context, account string) (*domain.User, error) } type userRoleRepositoryInterface interface { @@ -282,17 +289,28 @@ func (s *AuthService) generateUniqueUsername(ctx context.Context, base string) ( } baseRunes := []rune(username) - if len(baseRunes) > 40 { - username = string(baseRunes[:40]) + if len(baseRunes) > MaxUsernameLength { + username = string(baseRunes[:MaxUsernameLength]) } - for i := 1; i <= 1000; i++ { - candidate := fmt.Sprintf("%s_%d", username, i) - exists, err = s.userRepo.ExistsByUsername(ctx, candidate) - if err != nil { - return "", err - } - if !exists { + // P1性能优化:批量生成候选列表后一次性查询,避免循环DB往返 + candidates := make([]string, 0, MaxUsernameAttempts) + for i := 1; i <= MaxUsernameAttempts; i++ { + candidates = append(candidates, fmt.Sprintf("%s_%d", username, i)) + } + + existing, err := s.userRepo.FilterExistingUsernames(ctx, candidates) + if err != nil { + return "", err + } + + existingSet := make(map[string]bool, len(existing)) + for _, u := range existing { + existingSet[u] = true + } + + for _, candidate := range candidates { + if !existingSet[candidate] { return candidate, nil } } @@ -530,6 +548,11 @@ func (s *AuthService) writeLoginLog( // #nosec G118 - 使用带超时的独立 context,防止日志写入无限等待 go func() { // #nosec G118 + defer func() { + if r := recover(); r != nil { + log.Printf("auth: write login log panic recovered, user_id=%v login_type=%d err=%v", userID, loginType, r) + } + }() bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.loginLogRepo.Create(bgCtx, loginRecord); err != nil { @@ -591,7 +614,7 @@ func buildDeviceFingerprint(req *LoginRequest) string { return result } -// bestEffortRegisterDevice 尝试自动注册/更新设备记录 +// bestEffortRegisterDevice 尝试自动注册/更新设备记录(异步,不阻塞登录响应) func (s *AuthService) bestEffortRegisterDevice(ctx context.Context, userID int64, req *LoginRequest) { if s == nil || s.deviceService == nil || req == nil || req.DeviceID == "" { return @@ -603,7 +626,18 @@ func (s *AuthService) bestEffortRegisterDevice(ctx context.Context, userID int64 DeviceBrowser: req.DeviceBrowser, DeviceOS: req.DeviceOS, } - _, _ = s.deviceService.CreateDevice(ctx, userID, createReq) + + // PERF-01: 改为异步 goroutine,不阻塞登录响应返回 + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("auth: register device panic recovered, user_id=%d device_id=%s err=%v", userID, req.DeviceID, r) + } + }() + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _ = s.deviceService.CreateDevice(bgCtx, userID, createReq) + }() } // BestEffortRegisterDevicePublic 供外部 handler(如 SMS 登录)调用,安静地注册设备 diff --git a/internal/service/auth_capabilities.go b/internal/service/auth_capabilities.go index dd9a3d9..00abb41 100644 --- a/internal/service/auth_capabilities.go +++ b/internal/service/auth_capabilities.go @@ -75,21 +75,16 @@ func (s *AuthService) IsAdminBootstrapRequired(ctx context.Context) bool { return true } - hadUnexpectedLookupError := false - for _, userID := range userIDs { - user, err := s.userRepo.GetByID(ctx, userID) - if err != nil { - if isUserNotFoundError(err) { - continue - } - hadUnexpectedLookupError = true - log.Printf("auth: resolve auth capabilities failed while loading admin user: user_id=%d err=%v", userID, err) - continue - } - if user != nil && user.Status == domain.UserStatusActive { + users, err := s.userRepo.GetByIDs(ctx, userIDs) + if err != nil { + log.Printf("auth: resolve auth capabilities failed while loading admin users: err=%v", err) + return false + } + for _, user := range users { + if user.Status == domain.UserStatusActive { return false } } - return !hadUnexpectedLookupError + return true } diff --git a/internal/service/auth_runtime.go b/internal/service/auth_runtime.go index c3a70c4..6403271 100644 --- a/internal/service/auth_runtime.go +++ b/internal/service/auth_runtime.go @@ -30,27 +30,15 @@ func (s *AuthService) RegisterOAuthProvider(provider auth.OAuthProvider, cfg *au } func (s *AuthService) findUserForLogin(ctx context.Context, account string) (*domain.User, error) { - user, err := s.userRepo.GetByUsername(ctx, account) - if err == nil { - return user, nil + // P1性能优化:使用单一查询替代 username->email->phone 串行查询,减少DB往返 + user, err := s.userRepo.FindByAccount(ctx, account) + if err != nil { + if isUserNotFoundError(err) { + return nil, err + } + return nil, fmt.Errorf("lookup user failed: %w", err) } - if !isUserNotFoundError(err) { - return nil, fmt.Errorf("lookup user by username failed: %w", err) - } - - user, err = s.userRepo.GetByEmail(ctx, account) - if err == nil { - return user, nil - } - if !isUserNotFoundError(err) { - return nil, fmt.Errorf("lookup user by email failed: %w", err) - } - - user, err = s.userRepo.GetByPhone(ctx, account) - if err != nil && !isUserNotFoundError(err) { - return nil, fmt.Errorf("lookup user by phone failed: %w", err) - } - return user, err + return user, nil } func isUserNotFoundError(err error) bool { @@ -100,9 +88,19 @@ func (s *AuthService) bestEffortUpdateLastLogin(ctx context.Context, userID int6 return } - if err := s.userRepo.UpdateLastLogin(ctx, userID, ip); err != nil { - log.Printf("auth: update last login failed, source=%s user_id=%d ip=%s err=%v", source, userID, ip, err) - } + // PERF-01: 改为异步 goroutine,不阻塞登录响应返回 + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("auth: update last login panic recovered, source=%s user_id=%d err=%v", source, userID, r) + } + }() + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.userRepo.UpdateLastLogin(bgCtx, userID, ip); err != nil { + log.Printf("auth: update last login failed, source=%s user_id=%d ip=%s err=%v", source, userID, ip, err) + } + }() } func loginAttemptKey(account string, user *domain.User) string { diff --git a/internal/service/device.go b/internal/service/device.go index 6e48349..8507d03 100644 --- a/internal/service/device.go +++ b/internal/service/device.go @@ -4,14 +4,16 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/user-management-system/internal/domain" "github.com/user-management-system/internal/pagination" + apierrors "github.com/user-management-system/internal/pkg/errors" "github.com/user-management-system/internal/repository" + "gorm.io/gorm" ) -// Interfaces for dependency inversion (DIP) — service layer depends on these abstractions, not concrete types. type deviceRepository interface { Create(ctx context.Context, device *domain.Device) error Update(ctx context.Context, device *domain.Device) error @@ -27,6 +29,7 @@ type deviceRepository interface { UntrustDevice(ctx context.Context, id int64) error DeleteAllByUserIDExcept(ctx context.Context, userID int64, exceptDeviceID int64) error GetTrustedDevices(ctx context.Context, userID int64) ([]*domain.Device, error) + CountTrustedDevices(ctx context.Context, userID int64) (int64, error) ListAll(ctx context.Context, params *repository.ListDevicesParams) ([]*domain.Device, int64, error) ListAllCursor(ctx context.Context, params *repository.ListDevicesParams, limit int, cursor *pagination.Cursor) ([]*domain.Device, bool, error) } @@ -35,24 +38,18 @@ type deviceUserRepository interface { GetByID(ctx context.Context, id int64) (*domain.User, error) } -// DeviceService 设备服务 type DeviceService struct { deviceRepo deviceRepository userRepo deviceUserRepository } -// NewDeviceService 创建设备服务 -func NewDeviceService( - deviceRepo deviceRepository, - userRepo deviceUserRepository, -) *DeviceService { +func NewDeviceService(deviceRepo deviceRepository, userRepo deviceUserRepository) *DeviceService { return &DeviceService{ deviceRepo: deviceRepo, userRepo: userRepo, } } -// CreateDeviceRequest 创建设备请求 type CreateDeviceRequest struct { DeviceID string `json:"device_id" binding:"required"` DeviceName string `json:"device_name"` @@ -63,7 +60,6 @@ type CreateDeviceRequest struct { Location string `json:"location"` } -// UpdateDeviceRequest 更新设备请求 type UpdateDeviceRequest struct { DeviceName string `json:"device_name"` DeviceType int `json:"device_type"` @@ -74,21 +70,16 @@ type UpdateDeviceRequest struct { Status int `json:"status"` } -// CreateDevice 创建设备 func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *CreateDeviceRequest) (*domain.Device, error) { - // 检查用户是否存在 - _, err := s.userRepo.GetByID(ctx, userID) - if err != nil { - return nil, errors.New("用户不存在") + if _, err := s.userRepo.GetByID(ctx, userID); err != nil { + return nil, errors.New("user not found") } - // 检查设备是否已存在 exists, err := s.deviceRepo.Exists(ctx, userID, req.DeviceID) if err != nil { return nil, err } if exists { - // 设备已存在,更新最后活跃时间 device, err := s.deviceRepo.GetByDeviceID(ctx, userID, req.DeviceID) if err != nil { return nil, err @@ -97,7 +88,6 @@ func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *Cre return device, s.deviceRepo.Update(ctx, device) } - // 创建设备 device := &domain.Device{ UserID: userID, DeviceID: req.DeviceID, @@ -117,14 +107,47 @@ func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *Cre return device, nil } -// UpdateDevice 更新设备 -func (s *DeviceService) UpdateDevice(ctx context.Context, deviceID int64, req *UpdateDeviceRequest) (*domain.Device, error) { - device, err := s.deviceRepo.GetByID(ctx, deviceID) - if err != nil { - return nil, errors.New("设备不存在") +func isDeviceNotFoundError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return true + } + + lowerErr := strings.ToLower(strings.TrimSpace(err.Error())) + return strings.Contains(lowerErr, "record not found") || + strings.Contains(lowerErr, "device not found") || + strings.Contains(lowerErr, "not found") +} + +func (s *DeviceService) getDeviceByID(ctx context.Context, deviceID int64) (*domain.Device, error) { + device, err := s.deviceRepo.GetByID(ctx, deviceID) + if err != nil { + if isDeviceNotFoundError(err) { + return nil, apierrors.NotFound("device_not_found", "device not found").WithCause(err) + } + return nil, err + } + return device, nil +} + +func (s *DeviceService) getAuthorizedDevice(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) (*domain.Device, error) { + device, err := s.getDeviceByID(ctx, deviceID) + if err != nil { + return nil, err + } + if !isAdmin && device.UserID != actorUserID { + return nil, apierrors.Forbidden("device_forbidden", "permission denied") + } + return device, nil +} + +func (s *DeviceService) persistDeviceUpdate(ctx context.Context, device *domain.Device, req *UpdateDeviceRequest) (*domain.Device, error) { + if req == nil { + return device, nil } - // 更新字段 if req.DeviceName != "" { device.DeviceName = req.DeviceName } @@ -154,19 +177,48 @@ func (s *DeviceService) UpdateDevice(ctx context.Context, deviceID int64, req *U return device, nil } -// DeleteDevice 删除设备 +// maxTrustedDevicesPerUser 每个用户最大信任设备数量(P2 安全增强) +const maxTrustedDevicesPerUser = 10 + +func (s *DeviceService) trustDeviceRecord(ctx context.Context, device *domain.Device, trustDuration time.Duration) error { + // P2 安全增强:检查信任设备数量上限 + trustedCount, err := s.deviceRepo.CountTrustedDevices(ctx, device.UserID) + if err != nil { + return fmt.Errorf("count trusted devices failed: %w", err) + } + if trustedCount >= maxTrustedDevicesPerUser { + return fmt.Errorf("trusted device limit reached (max %d), please untrust an existing device first", maxTrustedDevicesPerUser) + } + + var trustExpiresAt *time.Time + if trustDuration > 0 { + expiresAt := time.Now().Add(trustDuration) + trustExpiresAt = &expiresAt + } + return s.deviceRepo.TrustDevice(ctx, device.ID, trustExpiresAt) +} + +func (s *DeviceService) UpdateDevice(ctx context.Context, deviceID int64, req *UpdateDeviceRequest) (*domain.Device, error) { + device, err := s.getDeviceByID(ctx, deviceID) + if err != nil { + return nil, err + } + return s.persistDeviceUpdate(ctx, device, req) +} + func (s *DeviceService) DeleteDevice(ctx context.Context, deviceID int64) error { - return s.deviceRepo.Delete(ctx, deviceID) + device, err := s.getDeviceByID(ctx, deviceID) + if err != nil { + return err + } + return s.deviceRepo.Delete(ctx, device.ID) } -// GetDevice 获取设备信息 func (s *DeviceService) GetDevice(ctx context.Context, deviceID int64) (*domain.Device, error) { - return s.deviceRepo.GetByID(ctx, deviceID) + return s.getDeviceByID(ctx, deviceID) } -// GetUserDevices 获取用户设备列表 func (s *DeviceService) GetUserDevices(ctx context.Context, userID int64, page, pageSize int) ([]*domain.Device, int64, error) { - offset := (page - 1) * pageSize if page <= 0 { page = 1 } @@ -174,22 +226,51 @@ func (s *DeviceService) GetUserDevices(ctx context.Context, userID int64, page, pageSize = 20 } + offset := (page - 1) * pageSize return s.deviceRepo.ListByUserID(ctx, userID, offset, pageSize) } -// UpdateDeviceStatus 更新设备状态 -func (s *DeviceService) UpdateDeviceStatus(ctx context.Context, deviceID int64, status domain.DeviceStatus) error { - return s.deviceRepo.UpdateStatus(ctx, deviceID, status) +func (s *DeviceService) GetDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) (*domain.Device, error) { + return s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin) +} + +func (s *DeviceService) UpdateDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, req *UpdateDeviceRequest) (*domain.Device, error) { + device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin) + if err != nil { + return nil, err + } + return s.persistDeviceUpdate(ctx, device, req) +} + +func (s *DeviceService) DeleteDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) error { + device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin) + if err != nil { + return err + } + return s.deviceRepo.Delete(ctx, device.ID) +} + +func (s *DeviceService) UpdateDeviceStatus(ctx context.Context, deviceID int64, status domain.DeviceStatus) error { + device, err := s.getDeviceByID(ctx, deviceID) + if err != nil { + return err + } + return s.deviceRepo.UpdateStatus(ctx, device.ID, status) +} + +func (s *DeviceService) UpdateDeviceStatusForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, status domain.DeviceStatus) error { + device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin) + if err != nil { + return err + } + return s.deviceRepo.UpdateStatus(ctx, device.ID, status) } -// UpdateLastActiveTime 更新最后活跃时间 func (s *DeviceService) UpdateLastActiveTime(ctx context.Context, deviceID int64) error { return s.deviceRepo.UpdateLastActiveTime(ctx, deviceID) } -// GetActiveDevices 获取活跃设备 func (s *DeviceService) GetActiveDevices(ctx context.Context, page, pageSize int) ([]*domain.Device, int64, error) { - offset := (page - 1) * pageSize if page <= 0 { page = 1 } @@ -197,74 +278,72 @@ func (s *DeviceService) GetActiveDevices(ctx context.Context, page, pageSize int pageSize = 20 } + offset := (page - 1) * pageSize return s.deviceRepo.ListByStatus(ctx, domain.DeviceStatusActive, offset, pageSize) } -// TrustDevice 设置设备为信任状态 func (s *DeviceService) TrustDevice(ctx context.Context, deviceID int64, trustDuration time.Duration) error { - device, err := s.deviceRepo.GetByID(ctx, deviceID) + device, err := s.getDeviceByID(ctx, deviceID) if err != nil { - return errors.New("设备不存在") + return err } - - var trustExpiresAt *time.Time - if trustDuration > 0 { - expiresAt := time.Now().Add(trustDuration) - trustExpiresAt = &expiresAt - } - - return s.deviceRepo.TrustDevice(ctx, device.ID, trustExpiresAt) + return s.trustDeviceRecord(ctx, device, trustDuration) +} + +func (s *DeviceService) TrustDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, trustDuration time.Duration) error { + device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin) + if err != nil { + return err + } + return s.trustDeviceRecord(ctx, device, trustDuration) } -// TrustDeviceByDeviceID 根据设备标识字符串设置设备为信任状态 func (s *DeviceService) TrustDeviceByDeviceID(ctx context.Context, userID int64, deviceID string, trustDuration time.Duration) error { device, err := s.deviceRepo.GetByDeviceID(ctx, userID, deviceID) if err != nil { - return errors.New("设备不存在") + if isDeviceNotFoundError(err) { + return apierrors.NotFound("device_not_found", "device not found").WithCause(err) + } + return err } - - var trustExpiresAt *time.Time - if trustDuration > 0 { - expiresAt := time.Now().Add(trustDuration) - trustExpiresAt = &expiresAt - } - - return s.deviceRepo.TrustDevice(ctx, device.ID, trustExpiresAt) + return s.trustDeviceRecord(ctx, device, trustDuration) } -// UntrustDevice 取消设备信任状态 func (s *DeviceService) UntrustDevice(ctx context.Context, deviceID int64) error { - device, err := s.deviceRepo.GetByID(ctx, deviceID) + device, err := s.getDeviceByID(ctx, deviceID) if err != nil { - return errors.New("设备不存在") + return err + } + return s.deviceRepo.UntrustDevice(ctx, device.ID) +} + +func (s *DeviceService) UntrustDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) error { + device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin) + if err != nil { + return err } - return s.deviceRepo.UntrustDevice(ctx, device.ID) } -// LogoutAllOtherDevices 登出所有其他设备 func (s *DeviceService) LogoutAllOtherDevices(ctx context.Context, userID int64, currentDeviceID int64) error { return s.deviceRepo.DeleteAllByUserIDExcept(ctx, userID, currentDeviceID) } -// GetTrustedDevices 获取用户的信任设备列表 func (s *DeviceService) GetTrustedDevices(ctx context.Context, userID int64) ([]*domain.Device, error) { return s.deviceRepo.GetTrustedDevices(ctx, userID) } -// GetAllDevicesRequest 获取所有设备请求参数 type GetAllDevicesRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` UserID int64 `form:"user_id"` - Status *int `form:"status"` // 0-禁用, 1-激活, nil-不筛选 + Status *int `form:"status"` IsTrusted *bool `form:"is_trusted"` Keyword string `form:"keyword"` - Cursor string `form:"cursor"` // Opaque cursor for keyset pagination - Size int `form:"size"` // Page size when using cursor mode + Cursor string `form:"cursor"` + Size int `form:"size"` } -// GetAllDevices 获取所有设备(管理员用) func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesRequest) ([]*domain.Device, int64, error) { if req.Page <= 0 { req.Page = 1 @@ -277,7 +356,6 @@ func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesReq } offset := (req.Page - 1) * req.PageSize - params := &repository.ListDevicesParams{ UserID: req.UserID, Keyword: req.Keyword, @@ -285,13 +363,10 @@ func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesReq Limit: req.PageSize, } - // 处理状态筛选(仅当明确指定了状态时才筛选) if req.Status != nil && (*req.Status == 0 || *req.Status == 1) { status := domain.DeviceStatus(*req.Status) params.Status = &status } - - // 处理信任状态筛选 if req.IsTrusted != nil { params.IsTrusted = req.IsTrusted } @@ -299,7 +374,6 @@ func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesReq return s.deviceRepo.ListAll(ctx, params) } -// GetAllDevicesCursor 游标分页获取所有设备(推荐使用) func (s *DeviceService) GetAllDevicesCursor(ctx context.Context, req *GetAllDevicesRequest) (*CursorResult, error) { size := pagination.ClampPageSize(req.Size) if req.PageSize > 0 && req.Cursor == "" { @@ -342,7 +416,6 @@ func (s *DeviceService) GetAllDevicesCursor(ctx context.Context, req *GetAllDevi }, nil } -// GetDeviceByDeviceID 根据设备标识获取设备(用于设备信任检查) func (s *DeviceService) GetDeviceByDeviceID(ctx context.Context, userID int64, deviceID string) (*domain.Device, error) { return s.deviceRepo.GetByDeviceID(ctx, userID, deviceID) } diff --git a/internal/service/device_service_test.go b/internal/service/device_service_test.go index d5c5b62..244c1df 100644 --- a/internal/service/device_service_test.go +++ b/internal/service/device_service_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/user-management-system/internal/domain" + apierrors "github.com/user-management-system/internal/pkg/errors" "github.com/user-management-system/internal/repository" "github.com/user-management-system/internal/service" gormsqlite "gorm.io/driver/sqlite" @@ -156,6 +157,104 @@ func TestDeviceService_GetDevice(t *testing.T) { }) } +func TestDeviceService_DeviceOwnershipAuthorization(t *testing.T) { + svc, db := setupDeviceTestEnv(t) + ctx := context.Background() + + owner := &domain.User{Username: "device_owner", Status: domain.UserStatusActive} + if err := db.Create(owner).Error; err != nil { + t.Fatalf("create owner failed: %v", err) + } + + actor := &domain.User{Username: "device_actor", Status: domain.UserStatusActive} + if err := db.Create(actor).Error; err != nil { + t.Fatalf("create actor failed: %v", err) + } + + device, err := svc.CreateDevice(ctx, owner.ID, &service.CreateDeviceRequest{ + DeviceID: "ownership_device", + DeviceName: "Owner Device", + }) + if err != nil { + t.Fatalf("CreateDevice failed: %v", err) + } + + t.Run("GetDeviceForActor forbids cross-user access", func(t *testing.T) { + _, err := svc.GetDeviceForActor(ctx, actor.ID, device.ID, false) + if !apierrors.IsForbidden(err) { + t.Fatalf("expected forbidden error, got %v", err) + } + }) + + t.Run("UpdateDeviceForActor forbids cross-user access", func(t *testing.T) { + _, err := svc.UpdateDeviceForActor(ctx, actor.ID, device.ID, false, &service.UpdateDeviceRequest{ + DeviceName: "Hacked Name", + }) + if !apierrors.IsForbidden(err) { + t.Fatalf("expected forbidden error, got %v", err) + } + + current, getErr := svc.GetDevice(ctx, device.ID) + if getErr != nil { + t.Fatalf("GetDevice failed: %v", getErr) + } + if current.DeviceName != "Owner Device" { + t.Fatalf("expected device name to remain unchanged, got %q", current.DeviceName) + } + }) + + t.Run("DeleteDeviceForActor forbids cross-user access", func(t *testing.T) { + err := svc.DeleteDeviceForActor(ctx, actor.ID, device.ID, false) + if !apierrors.IsForbidden(err) { + t.Fatalf("expected forbidden error, got %v", err) + } + + if _, getErr := svc.GetDevice(ctx, device.ID); getErr != nil { + t.Fatalf("expected device to remain after forbidden delete, got %v", getErr) + } + }) + + t.Run("TrustDeviceForActor forbids cross-user access", func(t *testing.T) { + err := svc.TrustDeviceForActor(ctx, actor.ID, device.ID, false, time.Hour) + if !apierrors.IsForbidden(err) { + t.Fatalf("expected forbidden error, got %v", err) + } + + current, getErr := svc.GetDevice(ctx, device.ID) + if getErr != nil { + t.Fatalf("GetDevice failed: %v", getErr) + } + if current.IsTrusted { + t.Fatal("expected device to remain untrusted") + } + }) + + t.Run("UpdateDeviceStatusForActor forbids cross-user access", func(t *testing.T) { + err := svc.UpdateDeviceStatusForActor(ctx, actor.ID, device.ID, false, domain.DeviceStatusInactive) + if !apierrors.IsForbidden(err) { + t.Fatalf("expected forbidden error, got %v", err) + } + + current, getErr := svc.GetDevice(ctx, device.ID) + if getErr != nil { + t.Fatalf("GetDevice failed: %v", getErr) + } + if current.Status != domain.DeviceStatusActive { + t.Fatalf("expected device to remain active, got %d", current.Status) + } + }) + + t.Run("Admin can manage another users device", func(t *testing.T) { + got, err := svc.GetDeviceForActor(ctx, actor.ID, device.ID, true) + if err != nil { + t.Fatalf("expected admin access, got %v", err) + } + if got.ID != device.ID { + t.Fatalf("expected device id %d, got %d", device.ID, got.ID) + } + }) +} + func TestDeviceService_GetUserDevices(t *testing.T) { svc, _ := setupDeviceTestEnv(t) ctx := context.Background() diff --git a/internal/service/user_service.go b/internal/service/user_service.go index dee3ab6..a00731a 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "strings" "time" "unicode/utf8" @@ -103,32 +104,101 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassw if strings.TrimSpace(newPassword) == "" { return errors.New("新密码不能为空") } + return s.applyNewPassword(ctx, user, newPassword) + /* + if err := validatePasswordStrength(newPassword, 8, false); err != nil { + return err + } + + // 检查密码历史(需要明文密码比对,必须在哈希之前) + if s.passwordHistoryRepo != nil { + histories, err := s.passwordHistoryRepo.GetByUserID(ctx, userID, passwordHistoryLimit) + if err == nil && len(histories) > 0 { + for _, h := range histories { + if auth.VerifyPassword(h.PasswordHash, newPassword) { + return errors.New("新密码不能与最近5次密码相同") + } + } + } + } + + // 计算一次哈希,用于更新密码和保存历史(避免 Argon2id 重复计算的高成本) + newHashedPassword, hashErr := auth.HashPassword(newPassword) + if hashErr != nil { + return errors.New("密码哈希失败") + } + + // 保存新密码到历史记录(异步,不阻塞密码更新) + if s.passwordHistoryRepo != nil { + // #nosec G118 - 使用带超时的独立 context(不能使用请求 ctx,该 goroutine 在请求完成后仍可能运行) + go func(hashedPw string) { // #nosec G118 + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{ + UserID: userID, + PasswordHash: hashedPw, + }) + _ = s.passwordHistoryRepo.DeleteOldRecords(bgCtx, userID, passwordHistoryLimit) + }(newHashedPassword) + } + + // 更新密码(使用同一哈希值) + user.Password = newHashedPassword + user.PasswordChangedAt = time.Now() + return s.userRepo.Update(ctx, user) + */ +} + +// GetByID 根据ID获取用户 +// AdminResetPassword resets a user's password without requiring the old password. +func (s *UserService) AdminResetPassword(ctx context.Context, userID int64, newPassword string) error { + if s.userRepo == nil { + return errors.New("user repository is not configured") + } + + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return errors.New("user not found") + } + + return s.applyNewPassword(ctx, user, newPassword) +} + +func (s *UserService) applyNewPassword(ctx context.Context, user *domain.User, newPassword string) error { + if user == nil { + return errors.New("user not found") + } + + if strings.TrimSpace(newPassword) == "" { + return errors.New("new password is required") + } if err := validatePasswordStrength(newPassword, 8, false); err != nil { return err } - // 检查密码历史(需要明文密码比对,必须在哈希之前) if s.passwordHistoryRepo != nil { - histories, err := s.passwordHistoryRepo.GetByUserID(ctx, userID, passwordHistoryLimit) + histories, err := s.passwordHistoryRepo.GetByUserID(ctx, user.ID, passwordHistoryLimit) if err == nil && len(histories) > 0 { for _, h := range histories { if auth.VerifyPassword(h.PasswordHash, newPassword) { - return errors.New("新密码不能与最近5次密码相同") + return errors.New("new password cannot reuse recent password history") } } } } - // 计算一次哈希,用于更新密码和保存历史(避免 Argon2id 重复计算的高成本) newHashedPassword, hashErr := auth.HashPassword(newPassword) if hashErr != nil { - return errors.New("密码哈希失败") + return errors.New("password hashing failed") } - // 保存新密码到历史记录(异步,不阻塞密码更新) if s.passwordHistoryRepo != nil { - // #nosec G118 - 使用带超时的独立 context(不能使用请求 ctx,该 goroutine 在请求完成后仍可能运行) - go func(hashedPw string) { // #nosec G118 + go func(userID int64, hashedPw string) { // #nosec G118 + defer func() { + if r := recover(); r != nil { + log.Printf("user_service: password history save panic recovered, user_id=%d err=%v", userID, r) + } + }() bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{ @@ -136,16 +206,14 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassw PasswordHash: hashedPw, }) _ = s.passwordHistoryRepo.DeleteOldRecords(bgCtx, userID, passwordHistoryLimit) - }(newHashedPassword) + }(user.ID, newHashedPassword) } - // 更新密码(使用同一哈希值) user.Password = newHashedPassword user.PasswordChangedAt = time.Now() return s.userRepo.Update(ctx, user) } -// GetByID 根据ID获取用户 func (s *UserService) GetByID(ctx context.Context, id int64) (*domain.User, error) { return s.userRepo.GetByID(ctx, id) } @@ -357,10 +425,23 @@ func (s *UserService) AssignRoles(ctx context.Context, userID int64, roleIDs []i return err } - // 验证所有角色存在(预先验证,避免在事务内做不必要的查询) - for _, roleID := range roleIDs { - if _, err := s.roleRepo.GetByID(ctx, roleID); err != nil { - return fmt.Errorf("角色 %d 不存在", roleID) + // 验证所有角色存在(批量查询消除 N+1) + if len(roleIDs) > 0 { + foundRoles, err := s.roleRepo.GetByIDs(ctx, roleIDs) + if err != nil { + return fmt.Errorf("验证角色失败: %w", err) + } + if len(foundRoles) != len(roleIDs) { + // 找出缺失的角色ID + foundMap := make(map[int64]bool, len(foundRoles)) + for _, r := range foundRoles { + foundMap[r.ID] = true + } + for _, id := range roleIDs { + if !foundMap[id] { + return fmt.Errorf("角色 %d 不存在", id) + } + } } } diff --git a/internal/service/user_service_test.go b/internal/service/user_service_test.go index 5b3a021..311ab35 100644 --- a/internal/service/user_service_test.go +++ b/internal/service/user_service_test.go @@ -341,6 +341,44 @@ func TestUserService_ChangePassword(t *testing.T) { }) } +func TestUserService_AdminResetPassword(t *testing.T) { + env := setupAuthTestEnv(t) + if env == nil { + return + } + ctx := context.Background() + + t.Run("Admin reset password success", func(t *testing.T) { + hashedPassword, _ := auth.HashPassword("OldPassword123!") + user := &domain.User{ + Username: "adminresetpwd", + Password: hashedPassword, + Status: domain.UserStatusActive, + } + env.userSvc.Create(ctx, user) + + err := env.userSvc.AdminResetPassword(ctx, user.ID, "ResetPassword456!") + if err != nil { + t.Fatalf("AdminResetPassword failed: %v", err) + } + + updated, _ := env.userSvc.GetByID(ctx, user.ID) + if !auth.VerifyPassword(updated.Password, "ResetPassword456!") { + t.Error("reset password verification failed") + } + if auth.VerifyPassword(updated.Password, "OldPassword123!") { + t.Error("old password should no longer work after admin reset") + } + }) + + t.Run("Admin reset password for non-existent user", func(t *testing.T) { + err := env.userSvc.AdminResetPassword(ctx, 99999, "ResetPassword456!") + if err == nil { + t.Error("Expected error for non-existent user") + } + }) +} + func TestUserService_BatchUpdateStatus(t *testing.T) { env := setupAuthTestEnv(t) if env == nil {