MilliSim/.trae/documents/authorization-system-refactor.md

19 KiB
Raw Blame History

授权系统全面重构计划

摘要

本次重构解决用户反馈的 5 个授权相关问题:

  1. 续期 bug — 一直提示"续期天数必须大于 0"
  2. 授权卡片重新设计 — 要有设计感,状态标签中文表示
  3. 批量授权窗口重新设计
  4. 客户管理窗口重新设计
  5. 授权逻辑重大问题 — 一个客户只能有一个授权记录(不论有效/已过期/已取消),且授权卡片可操作,客户卡片显示当前授权状态

当前状态分析

Bug 根因(续期失败)

后端 backend/MilliSim.Api/Controllers/AuthorizationsController.cs#L67-L73

[HttpPost("{id:long}/renew")]
public async Task<IActionResult> Renew(long id, [FromQuery] int days)
  • 控制器使用 [FromQuery] int days 期望 query string

前端 frontend/src/api/index.ts

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

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数字序列化
  • UserDtoDtos.cs#L32-L46 — 不含授权信息字段
  • UserService.GetUsersAsyncUserService.cs#L29-L90 — 不联表 Authorizations
  • 前端 Authorizations.vue418 行):卡片含头像+名称+时间+授权人+状态徽章+续期/取消按钮
  • 前端 Customers.vue539 行):卡片含头像+用户状态徽章+用户名/手机/邮箱/注册时间+操作按钮,不显示授权状态

提议的变更

后端修改

1. 修复续期 bug — 改用 [FromBody] + DTO

文件: backend/MilliSim.Api/Controllers/AuthorizationsController.cs

修改续期端点L67-L73

[HttpPost("{id:long}/renew")]
[Authorize(Policy = "Staff")]
public async Task<IActionResult> Renew(long id, [FromBody] RenewAuthorizationRequest request)
{
    var result = await _authorizationService.RenewAuthorizationAsync(id, request.Days);
    return Ok(result);
}

文件: backend/MilliSim.Api/Common/Dtos/Dtos.cs

BatchAuthorizationRequest 之后(约 L276 位置)添加新 DTO

public class RenewAuthorizationRequest
{
    public int Days { get; set; } = 30;
}

2. 重构授权逻辑 — 一个客户一个授权记录

文件: backend/MilliSim.Api/Services/AuthorizationService.cs

关键变更:将"检查 Active 才续期"改为"任意状态则复用原记录"。

CreateAuthorizationAsyncL132-L225重构逻辑

// 查询客户任意状态的现有授权(一个客户只能有一个授权记录)
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<AuthorizationDto>.Success(MapToDto(...), "授权成功");
}
// 否则新建(同原逻辑)

BatchAuthorizeAsyncL230-L333同样改造对每个 UserId 调用相同复用/新建逻辑。

CancelAuthorizationAsyncL400-L441添加状态检查

if (entity.Status == AuthorizationStatus.Cancelled)
{
    return ApiResponse.Fail("该授权已取消,无需重复操作");
}
if (entity.Status == AuthorizationStatus.Expired)
{
    return ApiResponse.Fail("该授权已过期,请使用重新授权功能");
}
// 仅 Active 可取消

RenewAuthorizationAsyncL338-L395添加状态检查仅 Active 可续期):

if (entity.Status != AuthorizationStatus.Active)
{
    return ApiResponse<AuthorizationDto>.Fail(
        entity.Status == AuthorizationStatus.Cancelled ? "已取消的授权请使用重新授权功能" 
        : "已过期的授权请使用重新授权功能");
}
// 续期EndTime += days

关键决策:取消 CreateAuthorizationAsync 内部"已有 Active 就续期"的特殊分支 — 改为统一逻辑:客户有任何状态的授权都复用原记录(重置 StartTime + EndTime + Status=Active。这样从 Customers.vue 点击"为客户授权"或"重新授权"都调用同一接口,后端自动处理复用。

3. 客户列表返回授权信息

文件: backend/MilliSim.Api/Common/Dtos/Dtos.cs

修改 UserDtoL32-L46添加两个字段

public AuthorizationStatus? AuthorizationStatus { get; set; }
public DateTime? AuthorizationEndTime { get; set; }

文件: backend/MilliSim.Api/Services/UserService.cs

修改 GetUsersAsyncL29-L90在取出 users 列表后联表查询 Authorizations

// 一个客户只有一个授权记录,按 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 < nowAuthorizationStatus == ActiveUI 显示为"已过期"(前端容错)。

4. 后端重启注意事项

后端运行在 http://localhost:5041background job: job-a1f30a33998f4065abe22b34018dcc7a)。

  • 修改代码后需 Stop-Process -Name "MilliSim.Api" -Force,再 dotnet build 重新构建
  • 启动新进程

前端修改

5. 类型定义

文件: frontend/src/types/index.ts

修改 User 接口L61-L74添加授权字段

export interface User {
  // ... 原有字段
  authorizationStatus?: AuthorizationStatus | null
  authorizationEndTime?: string | null
}

6. 授权卡片重新设计 + 批量授权窗口重新设计

文件: 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 中):

const statusConfig: Record<AuthorizationStatus, { label: string; class: string }> = {
  [AuthorizationStatus.Active]: { label: '有效', class: 'active' },
  [AuthorizationStatus.Expired]: { label: '已过期', class: 'expired' },
  [AuthorizationStatus.Cancelled]: { label: '已取消', class: 'cancelled' },
}

CSS 新增 .status-pill.expired(橙色)和 .status-pill.cancelled(红色)样式:

.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; }
按状态显示操作按钮
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 + Customers.css

客户卡片新结构
┌────────────────────────────────────┐
│ [渐变背景层]                       │
│  [头像 56x56]    [用户状态胶囊]    │
│                                   │
│  客户昵称 (15px 600)               │
│  @用户名                          │
│                                   │
│  授权状态:[徽章 - 中文]           │
│  到期时间2025-12-31 (剩余 180天) │
│                                   │
│  手机138****0000                │
│  邮箱user@example.com           │
│  注册时间2025-01-01             │
│ ───────────────────────────────── │
│           [按钮组]                │
└────────────────────────────────────┘
授权状态徽章(中文)
const authStatusConfig: Record<number, { label: string; class: string; showExpiry: boolean }> = {
  [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 }
授权按钮文案动态化
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 重新授权 分工

    • 续期(仅 ActiveEndTime += 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. 构建检查

    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 — 添加 RenewAuthorizationRequestUserDto 增加 3 个授权字段
  3. backend/MilliSim.Api/Services/AuthorizationService.cs — 重构 CreateAuthorization/BatchAuthorize复用原记录Cancel/Renew 增加状态检查
  4. backend/MilliSim.Api/Services/UserService.cs — GetUsersAsync 联表查询 Authorizations

前端

  1. frontend/src/types/index.ts — User 接口增加 authorizationStatus/authorizationEndTime/authorizationId 字段
  2. frontend/src/views/admin/Authorizations.vue + Authorizations.css — 卡片重新设计 + 新对话框 + 中文状态徽章
  3. 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. 浏览器手动验证全部场景