Commit 5fc0b5b9 authored by nanahira's avatar nanahira

add import-participants-from

parent eb400dd2
Pipeline #36619 passed with stages
in 3 minutes and 21 seconds
......@@ -28,8 +28,9 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.22",
"unzip-stream": "^0.3.4",
"yaml": "^2.7.1",
"ygopro-deck-encode": "^1.0.6"
"ygopro-deck-encode": "^1.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
......@@ -45,6 +46,7 @@
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"@types/unzip-stream": "^0.3.4",
"eslint": "^9.25.1",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
......@@ -3522,6 +3524,16 @@
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/unzip-stream": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@types/unzip-stream/-/unzip-stream-0.3.4.tgz",
"integrity": "sha512-ud0vtsNRF+joUCyvNMyo0j5DKX2Lh/im+xVgRzBEsfHhQYZ+i4fKTveova9XxLzt6Jl6G0e/0mM4aC0gqZYSnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/validator": {
"version": "13.15.0",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.0.tgz",
......@@ -4964,6 +4976,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/binary": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
"license": "MIT",
"dependencies": {
"buffers": "~0.1.1",
"chainsaw": "~0.1.0"
},
"engines": {
"node": "*"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
......@@ -5141,6 +5166,14 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/buffers": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
"engines": {
"node": ">=0.2.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
......@@ -5260,6 +5293,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chainsaw": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
"license": "MIT/X11",
"dependencies": {
"traverse": ">=0.3.0 <0.4"
},
"engines": {
"node": "*"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
......@@ -11331,6 +11376,15 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
"license": "MIT/X11",
"engines": {
"node": "*"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
......@@ -12011,6 +12065,16 @@
"node": ">= 0.8"
}
},
"node_modules/unzip-stream": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.4.tgz",
"integrity": "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw==",
"license": "MIT",
"dependencies": {
"binary": "^0.3.0",
"mkdirp": "^0.5.1"
}
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
......@@ -12565,9 +12629,9 @@
}
},
"node_modules/ygopro-deck-encode": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/ygopro-deck-encode/-/ygopro-deck-encode-1.0.6.tgz",
"integrity": "sha512-oG86JI1So7zIVmgXujGPHay95sl2zDF24W2DVVTxnnmxzi0JMmq1AkBuR7B74Ofx/vpuvLe60OzrprbXtM+LwQ==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/ygopro-deck-encode/-/ygopro-deck-encode-1.0.7.tgz",
"integrity": "sha512-SGlANLsPb9OOY+0ojlt6LtajnL/MFxMHs9sglYxICm6ju0D91M46J6CaxH0DfjmwYWmg5u3sk/TdCvU3MGSBVA==",
"license": "MIT"
},
"node_modules/yn": {
......
......@@ -17,6 +17,7 @@ import { ApiKey } from './api-key/entities/api-key.entity';
import { ApiKeyController } from './api-key/api-key.controller';
import { SrvproService } from './srvpro/srvpro.service';
import { SrvproController } from './srvpro/srvpro.controller';
import { HttpModule } from '@nestjs/axios';
@Module({
imports: [
......@@ -48,6 +49,7 @@ import { SrvproController } from './srvpro/srvpro.controller';
}),
}),
TypeOrmModule.forFeature([Tournament, Match, Participant, ApiKey]),
HttpModule,
],
providers: [
TournamentService,
......
......@@ -4,7 +4,6 @@ import {
BoolColumn,
NotChangeable,
NotColumn,
NotInResult,
NotQueryable,
QueryEqual,
QueryMatchBoolean,
......@@ -16,6 +15,7 @@ import { MycardUser } from 'nestjs-mycard';
import { Match } from '../../match/entities/match.entity';
import { ApiProperty } from '@nestjs/swagger';
import { IsBase64 } from 'class-validator';
import YGOProDeck from 'ygopro-deck-encode';
export class ParticipantScore {
@ApiProperty({ description: '排名' })
......@@ -87,4 +87,22 @@ export class Participant extends NamedBase {
'卡组 base64,影响 srvpro 的卡组。用库 ygopro-deck-encode 生成。',
})
deckbuf?: string;
isValidInCreate() {
if (this.deckbuf) {
try {
const buf = Buffer.from(this.deckbuf, 'base64');
const deck = new YGOProDeck().fromUpdateDeckPayload(buf);
if (!deck.main.length) {
return '卡组无效';
}
} catch (e) {
return '卡组不是有效的 base64 编码';
}
}
}
isValidInUpdate() {
return this.isValidInCreate();
}
}
import { Injectable } from '@nestjs/common';
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { BlankReturnMessageDto, CrudService, Inner } from 'nicot';
import { Participant } from './entities/participant.entity';
import { InjectRepository } from '@nestjs/typeorm';
......@@ -15,6 +15,7 @@ export class ParticipantService extends CrudService(Participant, {
}) {
constructor(
@InjectRepository(Participant) repo,
@Inject(forwardRef(() => TournamentService))
private readonly tournamentService: TournamentService,
) {
super(repo);
......
import {
IsInt,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ParticipantSourceDto {
@IsOptional()
@IsInt()
@IsPositive()
@ApiProperty({
description: '从另一个比赛导入:比赛 id',
})
tournamentId?: number;
@IsOptional()
@IsString()
@IsNotEmpty()
@ApiProperty({
description: '从 event.ygobbs2.com 导入:比赛 id(纯数字)或者比赛链接',
})
ygobbsCompt?: string;
getYgobbsComptId() {
if (!this.ygobbsCompt) {
return undefined;
}
// check pure number
if (/^\d+$/.test(this.ygobbsCompt)) {
return parseInt(this.ygobbsCompt);
}
if (this.ygobbsCompt.startsWith('http')) {
const url = new URL(this.ygobbsCompt);
const id = url.searchParams.get('id');
if (id && /^\d+$/.test(id)) {
return parseInt(id);
}
}
return undefined;
}
}
......@@ -6,7 +6,12 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { TournamentService } from './tournament.service';
import { ApiError, BlankReturnMessageDto, RestfulFactory } from 'nicot';
import {
ApiError,
BlankReturnMessageDto,
DataBody,
RestfulFactory,
} from 'nicot';
import { Tournament } from './entities/Tournament.entity';
import { ApiMycardUser, MycardUser, PutMycardUser } from 'nestjs-mycard';
import {
......@@ -22,6 +27,7 @@ import { Participant } from '../participant/entities/participant.entity';
import { ParticipantService } from '../participant/participant.service';
import { FilesInterceptor } from '@nestjs/platform-express';
import { multerToParticipant } from '../utility/multer-to-participant';
import { ParticipantSourceDto } from './dto/participant-source.dto';
const factory = new RestfulFactory(Tournament, {
relations: [
......@@ -173,4 +179,21 @@ export class TournamentController {
user,
);
}
@Post(':id/import-participants-from')
@HttpCode(200)
@ApiOperation({ summary: '上传 YDK 文件(创建选手)' })
@ApiParam({ name: 'id', description: 'Tournament ID' })
@ApiOkResponse({
type: new RestfulFactory(Participant, {
relations: [],
}).importReturnMessageDto,
})
async importParticipantsFrom(
@factory.idParam() id: number,
@DataBody() dto: ParticipantSourceDto,
@PutMycardUser() user: MycardUser,
) {
return this.tournamentService.importParticipantsFrom(id, dto, user);
}
}
import { Injectable } from '@nestjs/common';
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { BlankReturnMessageDto, CrudService } from 'nicot';
import { Tournament, TournamentStatus } from './entities/Tournament.entity';
import { InjectRepository } from '@nestjs/typeorm';
......@@ -6,6 +6,15 @@ import { MycardUser } from 'nestjs-mycard';
import { Match, MatchStatus } from '../match/entities/match.entity';
import { In, Repository } from 'typeorm';
import { Participant } from '../participant/entities/participant.entity';
import { ParticipantService } from '../participant/participant.service';
import { ConfigService } from '@nestjs/config';
import { ParticipantSourceDto } from './dto/participant-source.dto';
import { pick } from 'lodash';
import { HttpService } from '@nestjs/axios';
import { filter, lastValueFrom, mergeMap, tap } from 'rxjs';
import { Readable } from 'stream';
import { parseZipStream } from '../utility/parse-zip-stream';
import YGOProDeck from 'ygopro-deck-encode';
@Injectable()
export class TournamentService extends CrudService(Tournament, {
......@@ -20,10 +29,21 @@ export class TournamentService extends CrudService(Tournament, {
constructor(
@InjectRepository(Tournament) repo,
@InjectRepository(Match) private readonly matchRepo: Repository<Match>,
@Inject(forwardRef(() => ParticipantService))
private readonly participantService: ParticipantService,
private config: ConfigService,
private http: HttpService,
) {
super(repo);
}
private cmptApiEndpoint = this.config.get<string>(
'CMPT_API_ENDPOINT',
'https://sapi.moecube.com:444/ygobbs-event',
);
private cmptApiToken = this.config.get<string>('CMPT_API_TOKEN', '');
async getTournament(id: number, user: MycardUser | number) {
const result = await this.findOne(id, (qb) =>
Tournament.extraQueryForUser(user, qb, this.entityAliasName),
......@@ -215,4 +235,95 @@ export class TournamentService extends CrudService(Tournament, {
});
}
}
async importParticipantsFrom(
id: number,
dto: ParticipantSourceDto,
user: MycardUser | number,
) {
let participants: Participant[] = [];
if (dto.tournamentId) {
const tournament = await this.getTournament(dto.tournamentId, user);
participants = tournament.data.participants.map((p) =>
Object.assign(new Participant(), {
...pick(p, 'name', 'deckbuf'),
tournamentId: id,
quit: false,
} as Partial<Participant>),
);
} else if (dto.ygobbsCompt) {
const comptId = dto.getYgobbsComptId();
if (!comptId) {
throw new BlankReturnMessageDto(400, '无效的比赛 ID。').toException();
}
const zipStreamRes = await lastValueFrom(
this.http.get<Readable>(
`${this.cmptApiEndpoint}/api/cmpt/${comptId}.zip`,
{
responseType: 'stream',
headers: {
'x-server-token': this.cmptApiToken,
},
validateStatus: () => true,
},
),
);
if (zipStreamRes.status >= 400) {
throw new BlankReturnMessageDto(404, '未找到该比赛。').toException();
}
function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk) =>
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)),
);
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
const deckObs = parseZipStream(
zipStreamRes.data,
(e) => e.type === 'File' && e.path.endsWith('.ydk'),
).pipe(
mergeMap(async (e) => {
try {
const content = await streamToBuffer(e);
// 合法内容,返回路径与内容
return {
path: e.path,
content,
};
} catch (err) {
this.log.error(`Error reading file ${e.path}: ${err}`);
return null;
}
}),
filter((s) => !!s),
);
await lastValueFrom(
deckObs.pipe(
tap((file) => {
const text = file.content.toString('utf-8');
const participant = new Participant();
participant.quit = false;
participant.name = file.path
.split('/')
.pop()
.replace(/(\.ydk)+$/i, '');
if (!participant.name.length) return;
participant.tournamentId = id;
participant.deckbuf = Buffer.from(
YGOProDeck.fromYdkString(text).toUpdateDeckPayload(),
).toString('base64');
participants.push(participant);
}),
),
);
}
return this.participantService.importParticipants(participants, user);
}
}
import unzip, { Entry } from 'unzip-stream';
import { Observable } from 'rxjs';
import { Readable } from 'stream';
export function parseZipStream(
zipStream: Readable,
filter: (entry: Entry) => boolean,
): Observable<Entry> {
return new Observable<Entry>((subscriber) => {
const parser = unzip.Parse();
parser.on('entry', (entry: Entry) => {
if (filter(entry)) {
subscriber.next(entry);
} else {
entry.autodrain(); // 必须 drain 掉,否则会卡住
}
});
parser.on('error', (err) => {
subscriber.error(err);
});
parser.on('close', () => {
subscriber.complete(); // 所有 entry 被处理完
});
zipStream.pipe(parser);
// 处理取消订阅(可选)
return () => {
zipStream.unpipe(parser);
};
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment