import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { createHash, randomBytes } from 'crypto'; import { DataSource, Repository } from 'typeorm'; import { InvitationCode, InvitationCodeUsage } from './invitation_code.entity'; import { GenerateInvitationCodesDto } from './invitation_code.dto'; @Injectable() export class InvitationCodesService { constructor( @InjectRepository(InvitationCode) private readonly codes: Repository, @InjectRepository(InvitationCodeUsage) private readonly usages: Repository, private readonly dataSource: DataSource, ) {} private normalize(code: string): string { return (code || '').trim().toUpperCase(); } private hash(code: string): string { return createHash('sha256').update(this.normalize(code)).digest('hex'); } private invalid(): never { throw new BadRequestException('邀请码无效或已失效'); } async validate(code: string): Promise { if (!code) this.invalid(); const found = await this.codes.findOne({ where: { code_hash: this.hash(code) } }); if (!found || found.status !== 'active' || found.used_count >= found.max_uses || (found.expires_at && found.expires_at <= new Date())) this.invalid(); } async reserve(code: string): Promise { await this.validate(code); const hash = this.hash(code); const result = await this.dataSource.createQueryBuilder() .update(InvitationCode) .set({ used_count: () => 'used_count + 1' }) .where('code_hash = :hash', { hash }) .andWhere("status = 'active'") .andWhere('used_count < max_uses') .andWhere('(expires_at IS NULL OR expires_at > NOW())') .execute(); if (result.affected !== 1) this.invalid(); return (await this.codes.findOneByOrFail({ code_hash: hash })); } async release(id: bigint): Promise { await this.dataSource.createQueryBuilder().update(InvitationCode) .set({ used_count: () => 'GREATEST(used_count - 1, 0)' }).where('id = :id', { id: id.toString() }).execute(); } async recordUsage(invitationCodeId: bigint, userId: bigint, email: string): Promise { await this.usages.save(this.usages.create({ invitation_code_id: invitationCodeId, user_id: userId, email })); } async generate(dto: GenerateInvitationCodesDto, adminId?: string) { const plaintext: string[] = []; const entities: InvitationCode[] = []; for (let i = 0; i < dto.count; i++) { const raw = randomBytes(6).toString('hex').toUpperCase(); const code = `WT-${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`; plaintext.push(code); entities.push(this.codes.create({ code_hash: this.hash(code), code_prefix: code.slice(0, 12), max_uses: dto.max_uses || 1, expires_at: dto.expires_at ? new Date(dto.expires_at) : null, note: dto.note?.trim() || null, created_by: adminId ? BigInt(adminId) : null, })); } const saved = await this.codes.save(entities); return saved.map((item, index) => ({ id: item.id.toString(), code: plaintext[index] })); } async list(limit = 100, offset = 0) { const [items, total] = await this.codes.findAndCount({ order: { created_at: 'DESC' }, take: Math.min(Math.max(limit, 1), 200), skip: Math.max(offset, 0) }); return { total, items: items.map(item => ({ id: item.id.toString(), code: `${item.code_prefix}-****`, max_uses: item.max_uses, used_count: item.used_count, status: item.status, effective_status: item.status === 'revoked' ? 'revoked' : item.used_count >= item.max_uses ? 'exhausted' : item.expires_at && item.expires_at <= new Date() ? 'expired' : 'active', expires_at: item.expires_at, note: item.note, created_at: item.created_at, })) }; } async revoke(id: string): Promise { const result = await this.codes.update({ id: BigInt(id) }, { status: 'revoked' }); if (!result.affected) throw new BadRequestException('邀请码不存在'); } }