89 lines
4.0 KiB
TypeScript
89 lines
4.0 KiB
TypeScript
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<InvitationCode>,
|
|
@InjectRepository(InvitationCodeUsage) private readonly usages: Repository<InvitationCodeUsage>,
|
|
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<void> {
|
|
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<InvitationCode> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const result = await this.codes.update({ id: BigInt(id) }, { status: 'revoked' });
|
|
if (!result.affected) throw new BadRequestException('邀请码不存在');
|
|
}
|
|
}
|