feat(authorization): 实现软删除支持撤销后重新授权

- 添加 deletedAt 字段到 AuthorizationRole 聚合根和 Prisma schema
- revoke() 方法同时设置 deletedAt,使撤销的记录被软删除
- Repository 所有查询添加 deletedAt: null 过滤条件
- 创建部分唯一索引,只对未删除记录生效 (大厂通用做法)
- 支持撤销授权后重新创建相同角色

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-17 07:43:30 -08:00
parent 15e2bfe236
commit 29df9955f9
4 changed files with 84 additions and 9 deletions

View File

@ -0,0 +1,25 @@
-- 软删除支持迁移
-- 大厂通用做法: deleted_at 为 NULL 表示未删除,通过部分唯一索引确保有效记录唯一
-- 1. 添加 deleted_at 字段
ALTER TABLE authorization_roles
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
-- 2. 将现有 REVOKED 状态的记录设置 deleted_at
UPDATE authorization_roles
SET deleted_at = revoked_at
WHERE status = 'REVOKED' AND deleted_at IS NULL;
-- 3. 删除原有的唯一约束(如果存在)
ALTER TABLE authorization_roles
DROP CONSTRAINT IF EXISTS authorization_roles_account_sequence_role_type_region_code_key;
-- 4. 创建部分唯一索引(只对未删除的记录生效)
-- 这是大厂的标准做法,支持软删除后重新创建相同记录
CREATE UNIQUE INDEX IF NOT EXISTS uk_authorization_active
ON authorization_roles (account_sequence, role_type, region_code)
WHERE deleted_at IS NULL;
-- 5. 创建 deleted_at 索引用于查询优化
CREATE INDEX IF NOT EXISTS idx_authorization_deleted_at
ON authorization_roles (deleted_at);

View File

@ -28,6 +28,9 @@ model AuthorizationRole {
revokedBy String? @map("revoked_by")
revokeReason String? @map("revoke_reason")
// 软删除 (大厂通用做法: deleted_at 为 NULL 表示未删除)
deletedAt DateTime? @map("deleted_at")
// 考核配置
initialTargetTreeCount Int @map("initial_target_tree_count")
monthlyTargetType MonthlyTargetType @map("monthly_target_type")
@ -58,12 +61,15 @@ model AuthorizationRole {
assessments MonthlyAssessment[]
bypassRecords MonthlyBypass[]
@@unique([accountSequence, roleType, regionCode])
// 注意: 唯一约束通过数据库层的部分索引实现,只对 deleted_at IS NULL 的记录生效
// 需要手动执行迁移 SQL 创建部分唯一索引
// @@unique([accountSequence, roleType, regionCode]) -- 已移除,改用部分索引
@@index([accountSequence])
@@index([userId])
@@index([roleType, regionCode])
@@index([status])
@@index([roleType, status])
@@index([deletedAt])
@@map("authorization_roles")
}

View File

@ -48,6 +48,7 @@ export interface AuthorizationRoleProps {
monthlyTreesAdded: number // 当月新增树数
lastMonthTreesAdded: number // 上月新增树数(考核用存档)
currentMonthIndex: number
deletedAt: Date | null // 软删除时间 (大厂通用做法)
createdAt: Date
updatedAt: Date
}
@ -89,6 +90,9 @@ export class AuthorizationRole extends AggregateRoot {
// 当前考核月份索引
private _currentMonthIndex: number
// 软删除 (大厂通用做法)
private _deletedAt: Date | null
private _createdAt: Date
private _updatedAt: Date
@ -168,6 +172,9 @@ export class AuthorizationRole extends AggregateRoot {
get updatedAt(): Date {
return this._updatedAt
}
get deletedAt(): Date | null {
return this._deletedAt
}
get isActive(): boolean {
return this._status === AuthorizationStatus.AUTHORIZED
}
@ -198,6 +205,7 @@ export class AuthorizationRole extends AggregateRoot {
this._monthlyTreesAdded = props.monthlyTreesAdded
this._lastMonthTreesAdded = props.lastMonthTreesAdded
this._currentMonthIndex = props.currentMonthIndex
this._deletedAt = props.deletedAt
this._createdAt = props.createdAt
this._updatedAt = props.updatedAt
}
@ -259,6 +267,7 @@ export class AuthorizationRole extends AggregateRoot {
monthlyTreesAdded: 0,
lastMonthTreesAdded: 0,
currentMonthIndex: 0,
deletedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
})
@ -307,6 +316,7 @@ export class AuthorizationRole extends AggregateRoot {
monthlyTreesAdded: 0,
lastMonthTreesAdded: 0,
currentMonthIndex: skipAssessment ? 1 : 0,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
@ -354,6 +364,7 @@ export class AuthorizationRole extends AggregateRoot {
monthlyTreesAdded: 0,
lastMonthTreesAdded: 0,
currentMonthIndex: 0,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
@ -404,6 +415,7 @@ export class AuthorizationRole extends AggregateRoot {
monthlyTreesAdded: 0,
lastMonthTreesAdded: 0,
currentMonthIndex: 1, // 从第1个月开始考核
deletedAt: null,
createdAt: now,
updatedAt: now,
})
@ -452,6 +464,7 @@ export class AuthorizationRole extends AggregateRoot {
monthlyTreesAdded: 0,
lastMonthTreesAdded: 0,
currentMonthIndex: 0,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
@ -502,6 +515,7 @@ export class AuthorizationRole extends AggregateRoot {
monthlyTreesAdded: 0,
lastMonthTreesAdded: 0,
currentMonthIndex: 1, // 从第1个月开始考核
deletedAt: null,
createdAt: now,
updatedAt: now,
})
@ -553,6 +567,7 @@ export class AuthorizationRole extends AggregateRoot {
monthlyTreesAdded: 0,
lastMonthTreesAdded: 0,
currentMonthIndex: skipAssessment ? 1 : 0,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
@ -604,6 +619,7 @@ export class AuthorizationRole extends AggregateRoot {
monthlyTreesAdded: 0,
lastMonthTreesAdded: 0,
currentMonthIndex: skipAssessment ? 1 : 0,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
@ -783,19 +799,22 @@ export class AuthorizationRole extends AggregateRoot {
/**
*
* 软删除: 设置 deletedAt 使 ()
*/
revoke(adminId: AdminUserId, reason: string): void {
if (this._status === AuthorizationStatus.REVOKED) {
throw new DomainError('已撤销')
}
const now = new Date()
this._status = AuthorizationStatus.REVOKED
this._revokedAt = new Date()
this._revokedAt = now
this._revokedBy = adminId
this._revokeReason = reason
this._benefitActive = false
this._benefitDeactivatedAt = new Date()
this._updatedAt = new Date()
this._benefitDeactivatedAt = now
this._deletedAt = now // 软删除: 允许将来重新创建相同 accountSequence + roleType + regionCode 的授权
this._updatedAt = now
this.addDomainEvent(
new RoleRevokedEvent({
@ -904,6 +923,7 @@ export class AuthorizationRole extends AggregateRoot {
monthlyTreesAdded: this._monthlyTreesAdded,
lastMonthTreesAdded: this._lastMonthTreesAdded,
currentMonthIndex: this._currentMonthIndex,
deletedAt: this._deletedAt,
createdAt: this._createdAt,
updatedAt: this._updatedAt,
}

View File

@ -18,6 +18,9 @@ import { RoleType, AuthorizationStatus, MonthlyTargetType } from '@/domain/enums
export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleRepository {
constructor(private readonly prisma: PrismaService) {}
// 软删除过滤条件 (大厂通用做法)
private readonly notDeleted = { deletedAt: null }
async save(authorization: AuthorizationRole): Promise<void> {
const data = authorization.toPersistence()
await this.prisma.authorizationRole.upsert({
@ -48,6 +51,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
monthlyTreesAdded: data.monthlyTreesAdded,
lastMonthTreesAdded: data.lastMonthTreesAdded,
currentMonthIndex: data.currentMonthIndex,
deletedAt: data.deletedAt,
},
update: {
status: data.status,
@ -67,13 +71,14 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
monthlyTreesAdded: data.monthlyTreesAdded,
lastMonthTreesAdded: data.lastMonthTreesAdded,
currentMonthIndex: data.currentMonthIndex,
deletedAt: data.deletedAt,
},
})
}
async findById(authorizationId: AuthorizationId): Promise<AuthorizationRole | null> {
const record = await this.prisma.authorizationRole.findUnique({
where: { id: authorizationId.value },
const record = await this.prisma.authorizationRole.findFirst({
where: { id: authorizationId.value, ...this.notDeleted },
})
return record ? this.toDomain(record) : null
}
@ -86,6 +91,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
where: {
userId: userId.value,
roleType: roleType,
...this.notDeleted,
},
})
return record ? this.toDomain(record) : null
@ -101,6 +107,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
userId: userId.value,
roleType: roleType,
regionCode: regionCode.value,
...this.notDeleted,
},
})
return record ? this.toDomain(record) : null
@ -114,6 +121,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
where: {
accountSequence: accountSequence,
roleType: roleType,
...this.notDeleted,
},
})
return record ? this.toDomain(record) : null
@ -121,7 +129,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
async findByUserId(userId: UserId): Promise<AuthorizationRole[]> {
const records = await this.prisma.authorizationRole.findMany({
where: { userId: userId.value },
where: { userId: userId.value, ...this.notDeleted },
orderBy: { createdAt: 'desc' },
})
return records.map((record) => this.toDomain(record))
@ -129,7 +137,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
async findByAccountSequence(accountSequence: string): Promise<AuthorizationRole[]> {
const records = await this.prisma.authorizationRole.findMany({
where: { accountSequence: accountSequence },
where: { accountSequence: accountSequence, ...this.notDeleted },
orderBy: { createdAt: 'desc' },
})
return records.map((record) => this.toDomain(record))
@ -145,6 +153,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
regionCode: regionCode.value,
status: AuthorizationStatus.AUTHORIZED,
benefitActive: true,
...this.notDeleted,
},
})
return records.map((record) => this.toDomain(record))
@ -156,6 +165,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
status: AuthorizationStatus.AUTHORIZED,
benefitActive: true,
...(roleType && { roleType }),
...this.notDeleted,
},
})
return records.map((record) => this.toDomain(record))
@ -166,6 +176,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
where: {
userId: userId.value,
status: AuthorizationStatus.PENDING,
...this.notDeleted,
},
})
return records.map((record) => this.toDomain(record))
@ -173,7 +184,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
async findByStatus(status: AuthorizationStatus): Promise<AuthorizationRole[]> {
const records = await this.prisma.authorizationRole.findMany({
where: { status },
where: { status, ...this.notDeleted },
})
return records.map((record) => this.toDomain(record))
}
@ -198,6 +209,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
accountSequence: { in: accountSequences },
roleType: RoleType.COMMUNITY,
status: AuthorizationStatus.AUTHORIZED,
...this.notDeleted,
},
orderBy: { accountSequence: 'asc' },
})
@ -219,6 +231,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
regionCode: provinceCode,
status: AuthorizationStatus.AUTHORIZED,
benefitActive: true,
...this.notDeleted,
},
orderBy: { accountSequence: 'asc' },
})
@ -240,6 +253,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
regionCode: cityCode,
status: AuthorizationStatus.AUTHORIZED,
benefitActive: true,
...this.notDeleted,
},
orderBy: { accountSequence: 'asc' },
})
@ -258,6 +272,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
accountSequence: { in: accountSequences },
roleType: RoleType.COMMUNITY,
status: AuthorizationStatus.AUTHORIZED,
...this.notDeleted,
// 注意:不过滤 benefitActive用于计算社区权益分配
},
orderBy: { accountSequence: 'asc' },
@ -279,6 +294,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
roleType: RoleType.AUTH_PROVINCE_COMPANY,
regionCode: provinceCode,
status: AuthorizationStatus.AUTHORIZED,
...this.notDeleted,
// 注意:不过滤 benefitActive用于计算省团队权益分配
},
orderBy: { accountSequence: 'asc' },
@ -300,6 +316,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
roleType: RoleType.AUTH_CITY_COMPANY,
regionCode: cityCode,
status: AuthorizationStatus.AUTHORIZED,
...this.notDeleted,
// 注意:不过滤 benefitActive用于计算市团队权益分配
},
orderBy: { accountSequence: 'asc' },
@ -319,6 +336,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
accountSequence: { in: accountSequences },
roleType: RoleType.AUTH_PROVINCE_COMPANY,
status: AuthorizationStatus.AUTHORIZED,
...this.notDeleted,
// 注意:不过滤 benefitActive用于计算省团队权益分配
// 注意:不过滤 regionCode省团队收益不要求省份匹配
},
@ -339,6 +357,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
accountSequence: { in: accountSequences },
roleType: RoleType.AUTH_CITY_COMPANY,
status: AuthorizationStatus.AUTHORIZED,
...this.notDeleted,
// 注意:不过滤 benefitActive用于计算市团队权益分配
// 注意:不过滤 regionCode市团队收益不要求城市匹配
},
@ -353,6 +372,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
roleType: RoleType.PROVINCE_COMPANY,
regionCode: provinceCode,
status: AuthorizationStatus.AUTHORIZED,
...this.notDeleted,
},
})
return record ? this.toDomain(record) : null
@ -364,6 +384,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
roleType: RoleType.CITY_COMPANY,
regionCode: cityCode,
status: AuthorizationStatus.AUTHORIZED,
...this.notDeleted,
},
})
return record ? this.toDomain(record) : null
@ -382,6 +403,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
benefitValidUntil: {
lt: checkDate,
},
...this.notDeleted,
},
take: limit,
orderBy: { benefitValidUntil: 'asc' },
@ -394,6 +416,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
where: {
roleType: RoleType.COMMUNITY,
regionCode: communityName,
...this.notDeleted,
},
})
return record ? this.toDomain(record) : null
@ -427,6 +450,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
monthlyTreesAdded: record.monthlyTreesAdded ?? 0,
lastMonthTreesAdded: record.lastMonthTreesAdded ?? 0,
currentMonthIndex: record.currentMonthIndex,
deletedAt: record.deletedAt,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}