feat: integrate invitation access, world NPCs, and deployment

This commit is contained in:
2026-09-08 22:22:38 +08:00
parent 2a3125075f
commit 513a3eba31
75 changed files with 9566 additions and 1070 deletions

View 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('邀请码不存在');
}
}