19 KiB
授权系统全面重构计划
摘要
本次重构解决用户反馈的 5 个授权相关问题:
- 续期 bug — 一直提示"续期天数必须大于 0"
- 授权卡片重新设计 — 要有设计感,状态标签中文表示
- 批量授权窗口重新设计
- 客户管理窗口重新设计
- 授权逻辑重大问题 — 一个客户只能有一个授权记录(不论有效/已过期/已取消),且授权卡片可操作,客户卡片显示当前授权状态
当前状态分析
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(数字序列化)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
修改续期端点(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 才续期"改为"任意状态则复用原记录"。
CreateAuthorizationAsync(L132-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(...), "授权成功");
}
// 否则新建(同原逻辑)
BatchAuthorizeAsync(L230-L333)同样改造:对每个 UserId 调用相同复用/新建逻辑。
CancelAuthorizationAsync(L400-L441)添加状态检查:
if (entity.Status == AuthorizationStatus.Cancelled)
{
return ApiResponse.Fail("该授权已取消,无需重复操作");
}
if (entity.Status == AuthorizationStatus.Expired)
{
return ApiResponse.Fail("该授权已过期,请使用重新授权功能");
}
// 仅 Active 可取消
RenewAuthorizationAsync(L338-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
修改 UserDto(L32-L46)添加两个字段:
public AuthorizationStatus? AuthorizationStatus { get; set; }
public DateTime? AuthorizationEndTime { get; set; }
文件: backend/MilliSim.Api/Services/UserService.cs
修改 GetUsersAsync(L29-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 < 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
修改 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?字段,简化前端续期调用。
假设与决策
决策记录
-
重新授权策略:复用原记录(更新 Status=Active + 重置 StartTime/EndTime/AuthorizedBy)。原因:用户确认 + 历史可追溯 + 符合"一个客户一个授权"约束。
-
客户卡片授权状态来源:后端联表查询(UserDto 增加 AuthorizationStatus/AuthorizationEndTime/AuthorizationId 字段)。原因:用户确认 + 一次查询返回所有数据,避免 N+1 请求。
-
续期 vs 重新授权 分工:
- 续期(仅 Active):EndTime += days,状态不变
- 重新授权(Cancelled/Expired):调用 create API,后端自动复用原记录重置 StartTime=now, EndTime=now+days, Status=Active
- 两个独立操作,前端通过不同对话框触发
-
批量授权窗口的"复用"语义:批量授权调用同一 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 文件后自动生效
验证步骤
后端验证
-
构建检查:
Stop-Process -Name "MilliSim.Api" -Force cd d:\TraeProject\MilliSim4\backend\MilliSim.Api dotnet build确认无编译错误。
-
启动后端:通过 background 命令启动
dotnet run --project backend/MilliSim.Api
前端验证(浏览器手动测试)
-
续期 bug 修复:
- 进入授权管理页面
- 对一个 Active 状态的授权点击"续期"
- 输入 30 天,确认提交
- 预期:续期成功,不再提示"续期天数必须大于 0"
-
授权逻辑 — 一个客户一个授权:
- 在客户管理页面,为客户 A 授权(首次创建)
- 取消该授权
- 再次点击"重新授权"为客户 A 授权
- 预期:后端复用原记录(更新状态为 Active),不创建新记录
- 数据库验证:
SELECT * FROM Authorizations WHERE UserId = ?只有一条记录
-
客户卡片授权状态显示:
- 客户管理页面,每个客户卡片应显示授权状态徽章(有效/已过期/已取消/未授权)
- 不同状态显示不同颜色和文案
-
授权卡片操作按钮:
- Active 卡片:显示"续期/取消授权/查看详情"3 个按钮
- Cancelled/Expired 卡片:显示"重新授权/查看详情"2 个按钮
-
批量授权:
- 选择多个客户(含不同状态:未授权、有效、已过期、已取消)
- 设置 30 天
- 预期:所有客户都成功,原有记录被复用
-
状态徽章中文:
- Active → "有效"(绿色)
- Expired → "已过期"(橙色)
- Cancelled → "已取消"(红色)
文件变更清单
后端
backend/MilliSim.Api/Controllers/AuthorizationsController.cs— Renew 端点改 [FromBody]backend/MilliSim.Api/Common/Dtos/Dtos.cs— 添加 RenewAuthorizationRequest;UserDto 增加 3 个授权字段backend/MilliSim.Api/Services/AuthorizationService.cs— 重构 CreateAuthorization/BatchAuthorize(复用原记录);Cancel/Renew 增加状态检查backend/MilliSim.Api/Services/UserService.cs— GetUsersAsync 联表查询 Authorizations
前端
frontend/src/types/index.ts— User 接口增加 authorizationStatus/authorizationEndTime/authorizationId 字段frontend/src/views/admin/Authorizations.vue+Authorizations.css— 卡片重新设计 + 新对话框 + 中文状态徽章frontend/src/views/admin/Customers.vue+Customers.css— 卡片重新设计 + 授权状态徽章 + 动态授权按钮
实施顺序
- 后端 DTO + Controller 修复(续期 bug)
- 后端 AuthorizationService 重构(授权逻辑)
- 后端 UserService + UserDto 修改(联表查询)
- 后端构建 + 重启
- 前端 types/index.ts 修改
- 前端 Authorizations.vue + CSS 重新设计
- 前端 Customers.vue + CSS 重新设计
- 浏览器手动验证全部场景