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:
parent
15e2bfe236
commit
29df9955f9
|
|
@ -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);
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue