448 lines
19 KiB
Markdown
448 lines
19 KiB
Markdown
# 授权系统全面重构计划
|
||
|
||
## 摘要
|
||
|
||
本次重构解决用户反馈的 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<IActionResult> 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<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`](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<AuthorizationDto>.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<AuthorizationDto>.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, { 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`(红色)样式:
|
||
```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<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 }
|
||
```
|
||
|
||
##### 授权按钮文案动态化
|
||
|
||
```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. 浏览器手动验证全部场景
|