# 授权系统全面重构计划 ## 摘要 本次重构解决用户反馈的 5 个授权相关问题: 1. **续期 bug** — 一直提示"续期天数必须大于 0" 2. **授权卡片重新设计** — 要有设计感,状态标签中文表示 3. **批量授权窗口重新设计** 4. **客户管理窗口重新设计** 5. **授权逻辑重大问题** — 一个客户只能有一个授权记录(不论有效/已过期/已取消),且授权卡片可操作,客户卡片显示当前授权状态 ## 当前状态分析 ### Bug 根因(续期失败) **后端** [`backend/MilliSim.Api/Controllers/AuthorizationsController.cs#L67-L73`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Controllers/AuthorizationsController.cs#L67-L73): ```csharp [HttpPost("{id:long}/renew")] public async Task Renew(long id, [FromQuery] int days) ``` - 控制器使用 `[FromQuery] int days` 期望 query string **前端** `frontend/src/api/index.ts`: ```typescript renew: (id: number, days: number) => http.post(`/authorizations/${id}/renew`, { days }) ``` - 前端发送 POST body `{ days: 30 }` → 后端 days 绑定为 0,触发 `if (days <= 0) return Fail("续期天数必须大于 0")`。 ### 授权逻辑缺陷 **后端** [`backend/MilliSim.Api/Services/AuthorizationService.cs#L155-L157`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/AuthorizationService.cs#L155-L157): ```csharp var existing = await _db.Authorizations .FirstOrDefaultAsync(a => a.UserId == request.UserId && a.Status == AuthorizationStatus.Active); // 只查 Active ``` - 现有授权检查只查 Active 状态,对 Cancelled/Expired 状态会创建新记录 - 这导致一个客户可能有多条授权记录,违反"一个客户一个授权"的约束 ### 关键代码位置 - `AuthorizationStatus` 枚举:`ApiResponse.cs` — Cancelled=0, Active=1, Expired=2(数字序列化) - `UserDto`:`Dtos.cs#L32-L46` — 不含授权信息字段 - `UserService.GetUsersAsync`:`UserService.cs#L29-L90` — 不联表 Authorizations - 前端 `Authorizations.vue`(418 行):卡片含头像+名称+时间+授权人+状态徽章+续期/取消按钮 - 前端 `Customers.vue`(539 行):卡片含头像+用户状态徽章+用户名/手机/邮箱/注册时间+操作按钮,**不显示授权状态** ## 提议的变更 ### 后端修改 #### 1. 修复续期 bug — 改用 [FromBody] + DTO **文件**: [`backend/MilliSim.Api/Controllers/AuthorizationsController.cs`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Controllers/AuthorizationsController.cs) 修改续期端点(L67-L73): ```csharp [HttpPost("{id:long}/renew")] [Authorize(Policy = "Staff")] public async Task Renew(long id, [FromBody] RenewAuthorizationRequest request) { var result = await _authorizationService.RenewAuthorizationAsync(id, request.Days); return Ok(result); } ``` **文件**: [`backend/MilliSim.Api/Common/Dtos/Dtos.cs`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Common/Dtos/Dtos.cs) 在 `BatchAuthorizationRequest` 之后(约 L276 位置)添加新 DTO: ```csharp public class RenewAuthorizationRequest { public int Days { get; set; } = 30; } ``` #### 2. 重构授权逻辑 — 一个客户一个授权记录 **文件**: [`backend/MilliSim.Api/Services/AuthorizationService.cs`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/AuthorizationService.cs) **关键变更**:将"检查 Active 才续期"改为"任意状态则复用原记录"。 `CreateAuthorizationAsync`(L132-L225)重构逻辑: ```csharp // 查询客户任意状态的现有授权(一个客户只能有一个授权记录) var existing = await _db.Authorizations .FirstOrDefaultAsync(a => a.UserId == request.UserId); if (existing != null) { // 复用原记录:重置 StartTime/EndTime/Status/AuthorizedBy var now = DateTime.Now; existing.StartTime = now; existing.EndTime = now.AddDays(request.Days); existing.Status = AuthorizationStatus.Active; existing.AuthorizedBy = _currentUser.UserId; existing.ProjectId = null; // 客户级授权 existing.UpdatedAt = now; await _db.SaveChangesAsync(); // 操作日志:标记"重新授权"或"开通"(根据原状态) var actionLabel = existing.Status == AuthorizationStatus.Cancelled ? "重新激活" : "重新授权"; // ... 通知逻辑(区分续期/重新授权) return ApiResponse.Success(MapToDto(...), "授权成功"); } // 否则新建(同原逻辑) ``` `BatchAuthorizeAsync`(L230-L333)同样改造:对每个 UserId 调用相同复用/新建逻辑。 `CancelAuthorizationAsync`(L400-L441)添加状态检查: ```csharp if (entity.Status == AuthorizationStatus.Cancelled) { return ApiResponse.Fail("该授权已取消,无需重复操作"); } if (entity.Status == AuthorizationStatus.Expired) { return ApiResponse.Fail("该授权已过期,请使用重新授权功能"); } // 仅 Active 可取消 ``` `RenewAuthorizationAsync`(L338-L395)添加状态检查(仅 Active 可续期): ```csharp if (entity.Status != AuthorizationStatus.Active) { return ApiResponse.Fail( entity.Status == AuthorizationStatus.Cancelled ? "已取消的授权请使用重新授权功能" : "已过期的授权请使用重新授权功能"); } // 续期:EndTime += days ``` **关键决策**:取消 `CreateAuthorizationAsync` 内部"已有 Active 就续期"的特殊分支 — 改为统一逻辑:客户有任何状态的授权都复用原记录(重置 StartTime + EndTime + Status=Active)。这样从 Customers.vue 点击"为客户授权"或"重新授权"都调用同一接口,后端自动处理复用。 #### 3. 客户列表返回授权信息 **文件**: [`backend/MilliSim.Api/Common/Dtos/Dtos.cs`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Common/Dtos/Dtos.cs) 修改 `UserDto`(L32-L46)添加两个字段: ```csharp public AuthorizationStatus? AuthorizationStatus { get; set; } public DateTime? AuthorizationEndTime { get; set; } ``` **文件**: [`backend/MilliSim.Api/Services/UserService.cs`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/UserService.cs) 修改 `GetUsersAsync`(L29-L90),在取出 `users` 列表后联表查询 Authorizations: ```csharp // 一个客户只有一个授权记录,按 UserId 一次性取出 var userIds = users.Select(u => u.Id).ToList(); var authDict = await _db.Authorizations .Where(a => userIds.Contains(a.UserId)) .ToDictionaryAsync(a => a.UserId, a => a); // 一个客户一条记录 var items = users.Select(u => { var dto = AuthService.MapToDto(u); if (u.CreatedBy.HasValue && creators.TryGetValue(u.CreatedBy.Value, out var name)) { dto.CreatedByName = name; } if (authDict.TryGetValue(u.Id, out var auth)) { dto.AuthorizationStatus = auth.Status; dto.AuthorizationEndTime = auth.EndTime; } return dto; }).ToList(); ``` > 注意:`MarkExpiredAsync` 不在 UserService 调用,授权状态可能有"过期未刷新"的窗口期。前端展示时若 `AuthorizationEndTime < now` 且 `AuthorizationStatus == Active`,UI 显示为"已过期"(前端容错)。 #### 4. 后端重启注意事项 后端运行在 `http://localhost:5041`(background job: `job-a1f30a33998f4065abe22b34018dcc7a`)。 - 修改代码后需 `Stop-Process -Name "MilliSim.Api" -Force`,再 `dotnet build` 重新构建 - 启动新进程 ### 前端修改 #### 5. 类型定义 **文件**: [`frontend/src/types/index.ts`](file:///d:/TraeProject/MilliSim4/frontend/src/types/index.ts) 修改 `User` 接口(L61-L74)添加授权字段: ```typescript export interface User { // ... 原有字段 authorizationStatus?: AuthorizationStatus | null authorizationEndTime?: string | null } ``` #### 6. 授权卡片重新设计 + 批量授权窗口重新设计 **文件**: [`frontend/src/views/admin/Authorizations.vue`](file:///d:/TraeProject/MilliSim4/frontend/src/views/admin/Authorizations.vue) + `Authorizations.css` 参考 `Categories.vue` / `Platforms.vue` 的设计语言(蓝紫渐变 + 卡片渐变背景层 + 状态胶囊)。 ##### 授权卡片新结构 ``` ┌────────────────────────────────────┐ │ [渐变背景层] │ │ [头像 56x56] [状态胶囊 中文] │ │ │ │ 客户昵称 (15px 600) │ │ @用户名 │ │ │ │ 有效期:2025-01-01 至 2025-12-31 │ │ 剩余天数:180 天 │ │ 授权人:销售员 │ │ 创建时间:2025-01-01 │ │ ───────────────────────────────── │ │ [按钮组 - 按状态显示] │ └────────────────────────────────────┘ ``` ##### 状态徽章中文映射 新增工具函数(在 Authorizations.vue 的 script 中): ```typescript const statusConfig: Record = { [AuthorizationStatus.Active]: { label: '有效', class: 'active' }, [AuthorizationStatus.Expired]: { label: '已过期', class: 'expired' }, [AuthorizationStatus.Cancelled]: { label: '已取消', class: 'cancelled' }, } ``` CSS 新增 `.status-pill.expired`(橙色)和 `.status-pill.cancelled`(红色)样式: ```css .status-pill.expired { color: #FA8C16; background: rgba(250, 140, 22, 0.12); } .status-pill.expired .status-dot { background: #FA8C16; } .status-pill.cancelled { color: #F5222D; background: rgba(245, 34, 45, 0.12); } .status-pill.cancelled .status-dot { background: #F5222D; } ``` ##### 按状态显示操作按钮 ```typescript function getCardActions(status: AuthorizationStatus) { if (status === AuthorizationStatus.Active) { return [ { key: 'renew', label: '续期', icon: 'time', onClick: openRenewDialog }, { key: 'cancel', label: '取消授权', icon: 'close', onClick: handleCancel, danger: true }, { key: 'detail', label: '查看详情', icon: 'eye', onClick: openDetailDialog }, ] } // Cancelled / Expired return [ { key: 'reactivate', label: '重新授权', icon: 'refresh', onClick: openReactivateDialog }, { key: 'detail', label: '查看详情', icon: 'eye', onClick: openDetailDialog }, ] } ``` ##### 新增对话框 - **续期对话框**(已有,复用):天数输入,调用 `authorizationApi.renew(id, days)` - **重新授权对话框**(新增):天数输入(默认 365),调用 `authorizationApi.create({ userId, days })` — 后端自动复用原记录 - **查看详情对话框**(新增):展示完整授权信息(客户、授权人、起止时间、状态、剩余天数等) ##### 批量授权窗口重新设计 参考设计: ``` ┌── 批量授权 ──────────────────────────┐ │ [图标] 批量授权 │ │ 为多个客户一次性开通授权 │ │ │ │ ┌─ 选择客户 ─────────┐ ┌─ 授权配置 ─┐│ │ │ [搜索框] │ │ 授权天数: ││ │ │ ☐ 用户A 未授权 │ │ [输入框] ││ │ │ ☐ 用户B 有效 │ │ 默认 365 ││ │ │ ☐ 用户C 已过期 │ │ ││ │ │ ... │ │ ││ │ └────────────────────┘ └────────────┘│ │ │ │ 已选 3 个客户:用户A、用户B、用户C │ │ [取消] [确认授权] │ └────────────────────────────────────┘ ``` 实现要点: - 左侧客户列表(显示客户名 + 当前授权状态徽章),支持多选 - 右侧授权配置(仅天数输入,客户级授权不关联项目) - 底部显示已选客户数量 + 确认按钮 - 调用 `authorizationApi.batch({ userIds, days })` — 后端批量处理复用/新建 #### 7. 客户管理卡片重新设计 **文件**: [`frontend/src/views/admin/Customers.vue`](file:///d:/TraeProject/MilliSim4/frontend/src/views/admin/Customers.vue) + `Customers.css` ##### 客户卡片新结构 ``` ┌────────────────────────────────────┐ │ [渐变背景层] │ │ [头像 56x56] [用户状态胶囊] │ │ │ │ 客户昵称 (15px 600) │ │ @用户名 │ │ │ │ 授权状态:[徽章 - 中文] │ │ 到期时间:2025-12-31 (剩余 180天) │ │ │ │ 手机:138****0000 │ │ 邮箱:user@example.com │ │ 注册时间:2025-01-01 │ │ ───────────────────────────────── │ │ [按钮组] │ └────────────────────────────────────┘ ``` ##### 授权状态徽章(中文) ```typescript const authStatusConfig: Record = { [AuthorizationStatus.Active]: { label: '授权有效', class: 'active', showExpiry: true }, [AuthorizationStatus.Expired]: { label: '已过期', class: 'expired', showExpiry: true }, [AuthorizationStatus.Cancelled]: { label: '已取消', class: 'cancelled', showExpiry: false }, } // 无授权 const noAuthConfig = { label: '未授权', class: 'none', showExpiry: false } ``` ##### 授权按钮文案动态化 ```typescript function getAuthButton(status?: AuthorizationStatus | null) { if (status === undefined || status === null) return { label: '为客户授权', icon: 'plus' } if (status === AuthorizationStatus.Active) return { label: '续期授权', icon: 'time' } return { label: '重新授权', icon: 'refresh' } // Cancelled / Expired } ``` ##### 操作按钮(卡片底部) - 编辑(编辑客户信息) - 授权按钮(动态文案) - 重置密码 - 删除(红色 danger) 授权按钮点击行为: - 无授权 / Cancelled / Expired → 打开授权对话框(天数输入),调用 `authorizationApi.create({ userId, days })` - Active → 打开续期对话框,调用 `authorizationApi.renew(authId, days)` > 注:需要先调用 `authorizationApi.check(userId)` 或在卡片数据中带 `authorizationId`。考虑在 UserDto 额外返回 `authorizationId?: long?`,这样前端续期可直接用。**决策**:UserDto 也增加 `AuthorizationId?: long?` 字段,简化前端续期调用。 ## 假设与决策 ### 决策记录 1. **重新授权策略**:复用原记录(更新 Status=Active + 重置 StartTime/EndTime/AuthorizedBy)。原因:用户确认 + 历史可追溯 + 符合"一个客户一个授权"约束。 2. **客户卡片授权状态来源**:后端联表查询(UserDto 增加 AuthorizationStatus/AuthorizationEndTime/AuthorizationId 字段)。原因:用户确认 + 一次查询返回所有数据,避免 N+1 请求。 3. **续期 vs 重新授权 分工**: - **续期**(仅 Active):EndTime += days,状态不变 - **重新授权**(Cancelled/Expired):调用 create API,后端自动复用原记录重置 StartTime=now, EndTime=now+days, Status=Active - 两个独立操作,前端通过不同对话框触发 4. **批量授权窗口的"复用"语义**:批量授权调用同一 API,对已有 Cancelled/Expired 状态的客户自动复用原记录,对已有 Active 状态的客户自动续期(EndTime += days)。 ### 假设 - AuthorizationStatus 数字序列化已配置(项目记忆显示 ApiResponse.cs 已移除 [JsonConverter]) - Authorizations.ProjectId 已通过 DbSeeder 改为可空(已完成) - 前端 `authorizationApi.renew(id, days)` 已发送 `{ days }` body(无需修改前端 API 调用,仅需后端 controller 接收方式调整) - 前端 Vite dev server 热更新中,修改 .vue 文件后自动生效 ## 验证步骤 ### 后端验证 1. **构建检查**: ```powershell Stop-Process -Name "MilliSim.Api" -Force cd d:\TraeProject\MilliSim4\backend\MilliSim.Api dotnet build ``` 确认无编译错误。 2. **启动后端**:通过 background 命令启动 `dotnet run --project backend/MilliSim.Api` ### 前端验证(浏览器手动测试) 1. **续期 bug 修复**: - 进入授权管理页面 - 对一个 Active 状态的授权点击"续期" - 输入 30 天,确认提交 - 预期:续期成功,不再提示"续期天数必须大于 0" 2. **授权逻辑 — 一个客户一个授权**: - 在客户管理页面,为客户 A 授权(首次创建) - 取消该授权 - 再次点击"重新授权"为客户 A 授权 - 预期:后端复用原记录(更新状态为 Active),不创建新记录 - 数据库验证:`SELECT * FROM Authorizations WHERE UserId = ?` 只有一条记录 3. **客户卡片授权状态显示**: - 客户管理页面,每个客户卡片应显示授权状态徽章(有效/已过期/已取消/未授权) - 不同状态显示不同颜色和文案 4. **授权卡片操作按钮**: - Active 卡片:显示"续期/取消授权/查看详情"3 个按钮 - Cancelled/Expired 卡片:显示"重新授权/查看详情"2 个按钮 5. **批量授权**: - 选择多个客户(含不同状态:未授权、有效、已过期、已取消) - 设置 30 天 - 预期:所有客户都成功,原有记录被复用 6. **状态徽章中文**: - Active → "有效"(绿色) - Expired → "已过期"(橙色) - Cancelled → "已取消"(红色) ## 文件变更清单 ### 后端 1. `backend/MilliSim.Api/Controllers/AuthorizationsController.cs` — Renew 端点改 [FromBody] 2. `backend/MilliSim.Api/Common/Dtos/Dtos.cs` — 添加 RenewAuthorizationRequest;UserDto 增加 3 个授权字段 3. `backend/MilliSim.Api/Services/AuthorizationService.cs` — 重构 CreateAuthorization/BatchAuthorize(复用原记录);Cancel/Renew 增加状态检查 4. `backend/MilliSim.Api/Services/UserService.cs` — GetUsersAsync 联表查询 Authorizations ### 前端 5. `frontend/src/types/index.ts` — User 接口增加 authorizationStatus/authorizationEndTime/authorizationId 字段 6. `frontend/src/views/admin/Authorizations.vue` + `Authorizations.css` — 卡片重新设计 + 新对话框 + 中文状态徽章 7. `frontend/src/views/admin/Customers.vue` + `Customers.css` — 卡片重新设计 + 授权状态徽章 + 动态授权按钮 ## 实施顺序 1. 后端 DTO + Controller 修复(续期 bug) 2. 后端 AuthorizationService 重构(授权逻辑) 3. 后端 UserService + UserDto 修改(联表查询) 4. 后端构建 + 重启 5. 前端 types/index.ts 修改 6. 前端 Authorizations.vue + CSS 重新设计 7. 前端 Customers.vue + CSS 重新设计 8. 浏览器手动验证全部场景