feat: integrate invitation access, world NPCs, and deployment
This commit is contained in:
88
src/business/invitation/invitation_codes.service.ts
Normal file
88
src/business/invitation/invitation_codes.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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('邀请码不存在');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user