mirror of
https://github.com/misskey-dev/misskey.git
synced 2024-12-26 14:50:20 +01:00
enhance: 人気のPlayを10件以上表示できるように (#14443)
Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
This commit is contained in:
parent
d8bf1ff7e9
commit
0d7d1091c8
12 changed files with 262 additions and 25 deletions
|
@ -12,6 +12,7 @@
|
|||
- Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました
|
||||
- Enhance: 依存関係の更新
|
||||
- Enhance: l10nの更新
|
||||
- Enhance: Playの「人気」タブで10件以上表示可能に #14399
|
||||
- Fix: 連合のホワイトリストが正常に登録されない問題を修正
|
||||
|
||||
### Client
|
||||
|
|
|
@ -14,6 +14,7 @@ import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationSe
|
|||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserSearchService } from '@/core/UserSearchService.js';
|
||||
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||
import { FlashService } from '@/core/FlashService.js';
|
||||
import { AccountMoveService } from './AccountMoveService.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||
import { AiService } from './AiService.js';
|
||||
|
@ -217,6 +218,7 @@ const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useEx
|
|||
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
|
||||
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
|
||||
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
|
||||
const $FlashService: Provider = { provide: 'FlashService', useExisting: FlashService };
|
||||
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
|
||||
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
|
||||
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
|
||||
|
@ -367,6 +369,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
WebhookTestService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FlashService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
|
@ -513,6 +516,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$WebhookTestService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$FlashService,
|
||||
$SearchService,
|
||||
$ClipService,
|
||||
$FeaturedService,
|
||||
|
@ -660,6 +664,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
WebhookTestService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FlashService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
|
|
40
packages/backend/src/core/FlashService.ts
Normal file
40
packages/backend/src/core/FlashService.ts
Normal 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();
|
||||
}
|
||||
}
|
|
@ -5,10 +5,8 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { FlashLikesRepository, FlashsRepository } from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiFlash } from '@/models/Flash.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
@ -20,10 +18,8 @@ export class FlashEntityService {
|
|||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
@Inject(DI.flashLikesRepository)
|
||||
private flashLikesRepository: FlashLikesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
|
@ -34,25 +30,36 @@ export class FlashEntityService {
|
|||
src: MiFlash['id'] | MiFlash,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
packedUser?: Packed<'UserLite'>,
|
||||
likedFlashIds?: MiFlash['id'][],
|
||||
},
|
||||
): Promise<Packed<'Flash'>> {
|
||||
const meId = me ? me.id : null;
|
||||
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,
|
||||
createdAt: this.idService.parse(flash.id).date.toISOString(),
|
||||
updatedAt: flash.updatedAt.toISOString(),
|
||||
userId: flash.userId,
|
||||
user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意
|
||||
user: user,
|
||||
title: flash.title,
|
||||
summary: flash.summary,
|
||||
script: flash.script,
|
||||
visibility: flash.visibility,
|
||||
likedCount: flash.likedCount,
|
||||
isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined,
|
||||
});
|
||||
isLiked: isLiked,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -63,7 +70,19 @@ export class FlashEntityService {
|
|||
const _users = flashes.map(({ user, userId }) => user ?? userId);
|
||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||
.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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,9 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typ
|
|||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
export const flashVisibility = ['public', 'private'] as const;
|
||||
export type FlashVisibility = typeof flashVisibility[number];
|
||||
|
||||
@Entity('flash')
|
||||
export class MiFlash {
|
||||
@PrimaryColumn(id())
|
||||
|
@ -63,5 +66,5 @@ export class MiFlash {
|
|||
@Column('varchar', {
|
||||
length: 512, default: 'public',
|
||||
})
|
||||
public visibility: 'public' | 'private';
|
||||
public visibility: FlashVisibility;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import type { FlashsRepository } from '@/models/_.js';
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { FlashService } from '@/core/FlashService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['flash'],
|
||||
|
@ -27,26 +28,25 @@ export const meta = {
|
|||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
properties: {
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
private flashService: FlashService,
|
||||
private flashEntityService: FlashEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.flashsRepository.createQueryBuilder('flash')
|
||||
.andWhere('flash.likedCount > 0')
|
||||
.orderBy('flash.likedCount', 'DESC');
|
||||
|
||||
const flashs = await query.limit(10).getMany();
|
||||
|
||||
return await this.flashEntityService.packMany(flashs, me);
|
||||
const result = await this.flashService.featured({
|
||||
offset: ps.offset,
|
||||
limit: ps.limit,
|
||||
});
|
||||
return await this.flashEntityService.packMany(result, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
152
packages/backend/test/unit/FlashService.ts
Normal file
152
packages/backend/test/unit/FlashService.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -55,7 +55,8 @@ const tab = ref('featured');
|
|||
|
||||
const featuredFlashsPagination = {
|
||||
endpoint: 'flash/featured' as const,
|
||||
noPaging: true,
|
||||
limit: 5,
|
||||
offsetMode: true,
|
||||
};
|
||||
const myFlashsPagination = {
|
||||
endpoint: 'flash/my' as const,
|
||||
|
|
|
@ -1680,6 +1680,7 @@ declare namespace entities {
|
|||
FlashCreateRequest,
|
||||
FlashCreateResponse,
|
||||
FlashDeleteRequest,
|
||||
FlashFeaturedRequest,
|
||||
FlashFeaturedResponse,
|
||||
FlashLikeRequest,
|
||||
FlashShowRequest,
|
||||
|
@ -1929,6 +1930,9 @@ type FlashCreateResponse = operations['flash___create']['responses']['200']['con
|
|||
// @public (undocumented)
|
||||
type FlashDeleteRequest = operations['flash___delete']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type FlashFeaturedRequest = operations['flash___featured']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type FlashFeaturedResponse = operations['flash___featured']['responses']['200']['content']['application/json'];
|
||||
|
||||
|
|
|
@ -465,6 +465,7 @@ import type {
|
|||
FlashCreateRequest,
|
||||
FlashCreateResponse,
|
||||
FlashDeleteRequest,
|
||||
FlashFeaturedRequest,
|
||||
FlashFeaturedResponse,
|
||||
FlashLikeRequest,
|
||||
FlashShowRequest,
|
||||
|
@ -889,7 +890,7 @@ export type Endpoints = {
|
|||
'pages/update': { req: PagesUpdateRequest; res: EmptyResponse };
|
||||
'flash/create': { req: FlashCreateRequest; res: FlashCreateResponse };
|
||||
'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/show': { req: FlashShowRequest; res: FlashShowResponse };
|
||||
'flash/unlike': { req: FlashUnlikeRequest; res: EmptyResponse };
|
||||
|
|
|
@ -468,6 +468,7 @@ export type PagesUpdateRequest = operations['pages___update']['requestBody']['co
|
|||
export type FlashCreateRequest = operations['flash___create']['requestBody']['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 FlashFeaturedRequest = operations['flash___featured']['requestBody']['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 FlashShowRequest = operations['flash___show']['requestBody']['content']['application/json'];
|
||||
|
|
|
@ -23799,6 +23799,16 @@ export type operations = {
|
|||
* **Credential required**: *No*
|
||||
*/
|
||||
flash___featured: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
/** @default 0 */
|
||||
offset?: number;
|
||||
/** @default 10 */
|
||||
limit?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
|
|
Loading…
Reference in a new issue