diff --git a/backend/services/authorization-service/prisma/migrations/20241217_add_soft_delete/migration.sql b/backend/services/authorization-service/prisma/migrations/20241217_add_soft_delete/migration.sql new file mode 100644 index 00000000..6c66c32f --- /dev/null +++ b/backend/services/authorization-service/prisma/migrations/20241217_add_soft_delete/migration.sql @@ -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); diff --git a/backend/services/authorization-service/prisma/schema.prisma b/backend/services/authorization-service/prisma/schema.prisma index af9c1d67..87461901 100644 --- a/backend/services/authorization-service/prisma/schema.prisma +++ b/backend/services/authorization-service/prisma/schema.prisma @@ -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") } diff --git a/backend/services/authorization-service/src/domain/aggregates/authorization-role.aggregate.ts b/backend/services/authorization-service/src/domain/aggregates/authorization-role.aggregate.ts index 49cc5fd2..96933dfe 100644 --- a/backend/services/authorization-service/src/domain/aggregates/authorization-role.aggregate.ts +++ b/backend/services/authorization-service/src/domain/aggregates/authorization-role.aggregate.ts @@ -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, } diff --git a/backend/services/authorization-service/src/infrastructure/persistence/repositories/authorization-role.repository.impl.ts b/backend/services/authorization-service/src/infrastructure/persistence/repositories/authorization-role.repository.impl.ts index d4069a58..037aec79 100644 --- a/backend/services/authorization-service/src/infrastructure/persistence/repositories/authorization-role.repository.impl.ts +++ b/backend/services/authorization-service/src/infrastructure/persistence/repositories/authorization-role.repository.impl.ts @@ -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 { 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 { - 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 { 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 { 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 { 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, }