enhance: 人気のPlayを10件以上表示できるように (#14443)

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
This commit is contained in:
おさむのひと 2024-10-05 14:37:52 +09:00 committed by GitHub
parent d8bf1ff7e9
commit 0d7d1091c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 262 additions and 25 deletions

View file

@ -12,6 +12,7 @@
- Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました - Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました
- Enhance: 依存関係の更新 - Enhance: 依存関係の更新
- Enhance: l10nの更新 - Enhance: l10nの更新
- Enhance: Playの「人気」タブで10件以上表示可能に #14399
- Fix: 連合のホワイトリストが正常に登録されない問題を修正 - Fix: 連合のホワイトリストが正常に登録されない問題を修正
### Client ### Client

View file

@ -14,6 +14,7 @@ import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationSe
import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserSearchService } from '@/core/UserSearchService.js'; import { UserSearchService } from '@/core/UserSearchService.js';
import { WebhookTestService } from '@/core/WebhookTestService.js'; import { WebhookTestService } from '@/core/WebhookTestService.js';
import { FlashService } from '@/core/FlashService.js';
import { AccountMoveService } from './AccountMoveService.js'; import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js'; import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js'; import { AiService } from './AiService.js';
@ -217,6 +218,7 @@ const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useEx
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService }; const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
const $FlashService: Provider = { provide: 'FlashService', useExisting: FlashService };
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
@ -367,6 +369,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebhookTestService, WebhookTestService,
UtilityService, UtilityService,
FileInfoService, FileInfoService,
FlashService,
SearchService, SearchService,
ClipService, ClipService,
FeaturedService, FeaturedService,
@ -513,6 +516,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebhookTestService, $WebhookTestService,
$UtilityService, $UtilityService,
$FileInfoService, $FileInfoService,
$FlashService,
$SearchService, $SearchService,
$ClipService, $ClipService,
$FeaturedService, $FeaturedService,
@ -660,6 +664,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebhookTestService, WebhookTestService,
UtilityService, UtilityService,
FileInfoService, FileInfoService,
FlashService,
SearchService, SearchService,
ClipService, ClipService,
FeaturedService, FeaturedService,

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { type FlashsRepository } from '@/models/_.js';
/**
* MisskeyPlay関係のService
*/
@Injectable()
export class FlashService {
constructor(
@Inject(DI.flashsRepository)
private flashRepository: FlashsRepository,
) {
}
/**
* Play一覧を取得する.
*/
public async featured(opts?: { offset?: number, limit: number }) {
const builder = this.flashRepository.createQueryBuilder('flash')
.andWhere('flash.likedCount > 0')
.andWhere('flash.visibility = :visibility', { visibility: 'public' })
.addOrderBy('flash.likedCount', 'DESC')
.addOrderBy('flash.updatedAt', 'DESC')
.addOrderBy('flash.id', 'DESC');
if (opts?.offset) {
builder.skip(opts.offset);
}
builder.take(opts?.limit ?? 10);
return await builder.getMany();
}
}

View file

@ -5,10 +5,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js'; import type { FlashLikesRepository, FlashsRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiFlash } from '@/models/Flash.js'; import type { MiFlash } from '@/models/Flash.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -20,10 +18,8 @@ export class FlashEntityService {
constructor( constructor(
@Inject(DI.flashsRepository) @Inject(DI.flashsRepository)
private flashsRepository: FlashsRepository, private flashsRepository: FlashsRepository,
@Inject(DI.flashLikesRepository) @Inject(DI.flashLikesRepository)
private flashLikesRepository: FlashLikesRepository, private flashLikesRepository: FlashLikesRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private idService: IdService, private idService: IdService,
) { ) {
@ -34,25 +30,36 @@ export class FlashEntityService {
src: MiFlash['id'] | MiFlash, src: MiFlash['id'] | MiFlash,
me?: { id: MiUser['id'] } | null | undefined, me?: { id: MiUser['id'] } | null | undefined,
hint?: { hint?: {
packedUser?: Packed<'UserLite'> packedUser?: Packed<'UserLite'>,
likedFlashIds?: MiFlash['id'][],
}, },
): Promise<Packed<'Flash'>> { ): Promise<Packed<'Flash'>> {
const meId = me ? me.id : null; const meId = me ? me.id : null;
const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src }); const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src });
return await awaitAll({ // { schema: 'UserDetailed' } すると無限ループするので注意
const user = hint?.packedUser ?? await this.userEntityService.pack(flash.user ?? flash.userId, me);
let isLiked = false;
if (meId) {
isLiked = hint?.likedFlashIds
? hint.likedFlashIds.includes(flash.id)
: await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } });
}
return {
id: flash.id, id: flash.id,
createdAt: this.idService.parse(flash.id).date.toISOString(), createdAt: this.idService.parse(flash.id).date.toISOString(),
updatedAt: flash.updatedAt.toISOString(), updatedAt: flash.updatedAt.toISOString(),
userId: flash.userId, userId: flash.userId,
user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 user: user,
title: flash.title, title: flash.title,
summary: flash.summary, summary: flash.summary,
script: flash.script, script: flash.script,
visibility: flash.visibility, visibility: flash.visibility,
likedCount: flash.likedCount, likedCount: flash.likedCount,
isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined, isLiked: isLiked,
}); };
} }
@bindThis @bindThis
@ -63,7 +70,19 @@ export class FlashEntityService {
const _users = flashes.map(({ user, userId }) => user ?? userId); const _users = flashes.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me) const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u]))); .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(flashes.map(flash => this.pack(flash, me, { packedUser: _userMap.get(flash.userId) }))); const _likedFlashIds = me
? await this.flashLikesRepository.createQueryBuilder('flashLike')
.select('flashLike.flashId')
.where('flashLike.userId = :userId', { userId: me.id })
.getRawMany<{ flashLike_flashId: string }>()
.then(likes => [...new Set(likes.map(like => like.flashLike_flashId))])
: [];
return Promise.all(
flashes.map(flash => this.pack(flash, me, {
packedUser: _userMap.get(flash.userId),
likedFlashIds: _likedFlashIds,
})),
);
} }
} }

View file

@ -7,6 +7,9 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typ
import { id } from './util/id.js'; import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
export const flashVisibility = ['public', 'private'] as const;
export type FlashVisibility = typeof flashVisibility[number];
@Entity('flash') @Entity('flash')
export class MiFlash { export class MiFlash {
@PrimaryColumn(id()) @PrimaryColumn(id())
@ -63,5 +66,5 @@ export class MiFlash {
@Column('varchar', { @Column('varchar', {
length: 512, default: 'public', length: 512, default: 'public',
}) })
public visibility: 'public' | 'private'; public visibility: FlashVisibility;
} }

View file

@ -8,6 +8,7 @@ import type { FlashsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { FlashService } from '@/core/FlashService.js';
export const meta = { export const meta = {
tags: ['flash'], tags: ['flash'],
@ -27,26 +28,25 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: {}, properties: {
offset: { type: 'integer', minimum: 0, default: 0 },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
required: [], required: [],
} as const; } as const;
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.flashsRepository) private flashService: FlashService,
private flashsRepository: FlashsRepository,
private flashEntityService: FlashEntityService, private flashEntityService: FlashEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.flashsRepository.createQueryBuilder('flash') const result = await this.flashService.featured({
.andWhere('flash.likedCount > 0') offset: ps.offset,
.orderBy('flash.likedCount', 'DESC'); limit: ps.limit,
});
const flashs = await query.limit(10).getMany(); return await this.flashEntityService.packMany(result, me);
return await this.flashEntityService.packMany(flashs, me);
}); });
} }
} }

View file

@ -0,0 +1,152 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Test, TestingModule } from '@nestjs/testing';
import { FlashService } from '@/core/FlashService.js';
import { IdService } from '@/core/IdService.js';
import { FlashsRepository, MiFlash, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { GlobalModule } from '@/GlobalModule.js';
describe('FlashService', () => {
let app: TestingModule;
let service: FlashService;
// --------------------------------------------------------------------------------------
let flashsRepository: FlashsRepository;
let usersRepository: UsersRepository;
let userProfilesRepository: UserProfilesRepository;
let idService: IdService;
// --------------------------------------------------------------------------------------
let root: MiUser;
let alice: MiUser;
let bob: MiUser;
// --------------------------------------------------------------------------------------
async function createFlash(data: Partial<MiFlash>) {
return flashsRepository.insert({
id: idService.gen(),
updatedAt: new Date(),
userId: root.id,
title: 'title',
summary: 'summary',
script: 'script',
permissions: [],
likedCount: 0,
...data,
}).then(x => flashsRepository.findOneByOrFail(x.identifiers[0]));
}
async function createUser(data: Partial<MiUser> = {}) {
const user = await usersRepository
.insert({
id: idService.gen(),
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
await userProfilesRepository.insert({
userId: user.id,
});
return user;
}
// --------------------------------------------------------------------------------------
beforeEach(async () => {
app = await Test.createTestingModule({
imports: [
GlobalModule,
],
providers: [
FlashService,
IdService,
],
}).compile();
service = app.get(FlashService);
flashsRepository = app.get(DI.flashsRepository);
usersRepository = app.get(DI.usersRepository);
userProfilesRepository = app.get(DI.userProfilesRepository);
idService = app.get(IdService);
root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false });
});
afterEach(async () => {
await usersRepository.delete({});
await userProfilesRepository.delete({});
await flashsRepository.delete({});
});
afterAll(async () => {
await app.close();
});
// --------------------------------------------------------------------------------------
describe('featured', () => {
test('should return featured flashes', async () => {
const flash1 = await createFlash({ likedCount: 1 });
const flash2 = await createFlash({ likedCount: 2 });
const flash3 = await createFlash({ likedCount: 3 });
const result = await service.featured({
offset: 0,
limit: 10,
});
expect(result).toEqual([flash3, flash2, flash1]);
});
test('should return featured flashes public visibility only', async () => {
const flash1 = await createFlash({ likedCount: 1, visibility: 'public' });
const flash2 = await createFlash({ likedCount: 2, visibility: 'public' });
const flash3 = await createFlash({ likedCount: 3, visibility: 'private' });
const result = await service.featured({
offset: 0,
limit: 10,
});
expect(result).toEqual([flash2, flash1]);
});
test('should return featured flashes with offset', async () => {
const flash1 = await createFlash({ likedCount: 1 });
const flash2 = await createFlash({ likedCount: 2 });
const flash3 = await createFlash({ likedCount: 3 });
const result = await service.featured({
offset: 1,
limit: 10,
});
expect(result).toEqual([flash2, flash1]);
});
test('should return featured flashes with limit', async () => {
const flash1 = await createFlash({ likedCount: 1 });
const flash2 = await createFlash({ likedCount: 2 });
const flash3 = await createFlash({ likedCount: 3 });
const result = await service.featured({
offset: 0,
limit: 2,
});
expect(result).toEqual([flash3, flash2]);
});
});
});

View file

@ -55,7 +55,8 @@ const tab = ref('featured');
const featuredFlashsPagination = { const featuredFlashsPagination = {
endpoint: 'flash/featured' as const, endpoint: 'flash/featured' as const,
noPaging: true, limit: 5,
offsetMode: true,
}; };
const myFlashsPagination = { const myFlashsPagination = {
endpoint: 'flash/my' as const, endpoint: 'flash/my' as const,

View file

@ -1680,6 +1680,7 @@ declare namespace entities {
FlashCreateRequest, FlashCreateRequest,
FlashCreateResponse, FlashCreateResponse,
FlashDeleteRequest, FlashDeleteRequest,
FlashFeaturedRequest,
FlashFeaturedResponse, FlashFeaturedResponse,
FlashLikeRequest, FlashLikeRequest,
FlashShowRequest, FlashShowRequest,
@ -1929,6 +1930,9 @@ type FlashCreateResponse = operations['flash___create']['responses']['200']['con
// @public (undocumented) // @public (undocumented)
type FlashDeleteRequest = operations['flash___delete']['requestBody']['content']['application/json']; type FlashDeleteRequest = operations['flash___delete']['requestBody']['content']['application/json'];
// @public (undocumented)
type FlashFeaturedRequest = operations['flash___featured']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
type FlashFeaturedResponse = operations['flash___featured']['responses']['200']['content']['application/json']; type FlashFeaturedResponse = operations['flash___featured']['responses']['200']['content']['application/json'];

View file

@ -465,6 +465,7 @@ import type {
FlashCreateRequest, FlashCreateRequest,
FlashCreateResponse, FlashCreateResponse,
FlashDeleteRequest, FlashDeleteRequest,
FlashFeaturedRequest,
FlashFeaturedResponse, FlashFeaturedResponse,
FlashLikeRequest, FlashLikeRequest,
FlashShowRequest, FlashShowRequest,
@ -889,7 +890,7 @@ export type Endpoints = {
'pages/update': { req: PagesUpdateRequest; res: EmptyResponse }; 'pages/update': { req: PagesUpdateRequest; res: EmptyResponse };
'flash/create': { req: FlashCreateRequest; res: FlashCreateResponse }; 'flash/create': { req: FlashCreateRequest; res: FlashCreateResponse };
'flash/delete': { req: FlashDeleteRequest; res: EmptyResponse }; 'flash/delete': { req: FlashDeleteRequest; res: EmptyResponse };
'flash/featured': { req: EmptyRequest; res: FlashFeaturedResponse }; 'flash/featured': { req: FlashFeaturedRequest; res: FlashFeaturedResponse };
'flash/like': { req: FlashLikeRequest; res: EmptyResponse }; 'flash/like': { req: FlashLikeRequest; res: EmptyResponse };
'flash/show': { req: FlashShowRequest; res: FlashShowResponse }; 'flash/show': { req: FlashShowRequest; res: FlashShowResponse };
'flash/unlike': { req: FlashUnlikeRequest; res: EmptyResponse }; 'flash/unlike': { req: FlashUnlikeRequest; res: EmptyResponse };

View file

@ -468,6 +468,7 @@ export type PagesUpdateRequest = operations['pages___update']['requestBody']['co
export type FlashCreateRequest = operations['flash___create']['requestBody']['content']['application/json']; export type FlashCreateRequest = operations['flash___create']['requestBody']['content']['application/json'];
export type FlashCreateResponse = operations['flash___create']['responses']['200']['content']['application/json']; export type FlashCreateResponse = operations['flash___create']['responses']['200']['content']['application/json'];
export type FlashDeleteRequest = operations['flash___delete']['requestBody']['content']['application/json']; export type FlashDeleteRequest = operations['flash___delete']['requestBody']['content']['application/json'];
export type FlashFeaturedRequest = operations['flash___featured']['requestBody']['content']['application/json'];
export type FlashFeaturedResponse = operations['flash___featured']['responses']['200']['content']['application/json']; export type FlashFeaturedResponse = operations['flash___featured']['responses']['200']['content']['application/json'];
export type FlashLikeRequest = operations['flash___like']['requestBody']['content']['application/json']; export type FlashLikeRequest = operations['flash___like']['requestBody']['content']['application/json'];
export type FlashShowRequest = operations['flash___show']['requestBody']['content']['application/json']; export type FlashShowRequest = operations['flash___show']['requestBody']['content']['application/json'];

View file

@ -23799,6 +23799,16 @@ export type operations = {
* **Credential required**: *No* * **Credential required**: *No*
*/ */
flash___featured: { flash___featured: {
requestBody: {
content: {
'application/json': {
/** @default 0 */
offset?: number;
/** @default 10 */
limit?: number;
};
};
};
responses: { responses: {
/** @description OK (with results) */ /** @description OK (with results) */
200: { 200: {