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

View File

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

View File

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