Skip to content

Commit

Permalink
fix: サジェストされるユーザのリストアップ方法を見直し (#14180)
Browse files Browse the repository at this point in the history
* fix: サジェストされるユーザのリストアップ方法を見直し

* fix comment

* fix CHANGELOG.md

* ノートの無いユーザ(updatedAtが無いユーザ)は含めないらしい

* fix test
  • Loading branch information
samunohito authored Jul 12, 2024
1 parent 76b1c74 commit 6cd1527
Show file tree
Hide file tree
Showing 5 changed files with 492 additions and 90 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
- Fix: リノートにリアクションできないように
- Fix: ユーザー名の前後に空白文字列がある場合は省略するように
- Fix: プロフィール編集時に名前を空白文字列のみにできる問題を修正
- Fix: ユーザ名のサジェスト時に表示される内容と順番を調整(以下の順番になります) #14149
1. フォロー中かつアクティブなユーザ
2. フォロー中かつ非アクティブなユーザ
3. フォローしていないアクティブなユーザ
4. フォローしていない非アクティブなユーザ

### Misskey.js
- Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応)
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/core/CoreModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js';
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserSearchService } from '@/core/UserSearchService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
Expand Down Expand Up @@ -202,6 +203,7 @@ const $UserFollowingService: Provider = { provide: 'UserFollowingService', useEx
const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService };
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService };
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
Expand Down Expand Up @@ -348,6 +350,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserKeypairService,
UserListService,
UserMutingService,
UserSearchService,
UserSuspendService,
UserAuthService,
VideoProcessingService,
Expand Down Expand Up @@ -490,6 +493,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserKeypairService,
$UserListService,
$UserMutingService,
$UserSearchService,
$UserSuspendService,
$UserAuthService,
$VideoProcessingService,
Expand Down Expand Up @@ -633,6 +637,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserKeypairService,
UserListService,
UserMutingService,
UserSearchService,
UserSuspendService,
UserAuthService,
VideoProcessingService,
Expand Down Expand Up @@ -774,6 +779,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserKeypairService,
$UserListService,
$UserMutingService,
$UserSearchService,
$UserSuspendService,
$UserAuthService,
$VideoProcessingService,
Expand Down
205 changes: 205 additions & 0 deletions packages/backend/src/core/UserSearchService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Inject, Injectable } from '@nestjs/common';
import { Brackets, SelectQueryBuilder } from 'typeorm';
import { DI } from '@/di-symbols.js';
import { type FollowingsRepository, MiUser, type UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import type { Config } from '@/config.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { Packed } from '@/misc/json-schema.js';

function defaultActiveThreshold() {
return new Date(Date.now() - 1000 * 60 * 60 * 24 * 30);
}

@Injectable()
export class UserSearchService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
) {
}

/**
* ユーザ名とホスト名によるユーザ検索を行う.
*
* - 検索結果には優先順位がつけられており、以下の順序で検索が行われる.
* 1. フォローしているユーザのうち、一定期間以内(※)に更新されたユーザ
* 2. フォローしているユーザのうち、一定期間以内に更新されていないユーザ
* 3. フォローしていないユーザのうち、一定期間以内に更新されたユーザ
* 4. フォローしていないユーザのうち、一定期間以内に更新されていないユーザ
* - ログインしていない場合は、以下の順序で検索が行われる.
* 1. 一定期間以内に更新されたユーザ
* 2. 一定期間以内に更新されていないユーザ
* - それぞれの検索結果はユーザ名の昇順でソートされる.
* - 動作的には先に登場した検索結果の登場位置が優先される(条件的にユーザIDが重複することはないが).
* (1で既にヒットしていた場合、2, 3, 4でヒットしても無視される)
* - ユーザ名とホスト名の検索条件はそれぞれ前方一致で検索される.
* - ユーザ名の検索は大文字小文字を区別しない.
* - ホスト名の検索は大文字小文字を区別しない.
* - 検索結果は最大で {@link opts.limit} 件までとなる.
*
* ※一定期間とは {@link params.activeThreshold} で指定された日時から現在までの期間を指す.
*
* @param params 検索条件.
* @param opts 関数の動作を制御するオプション.
* @param me 検索を実行するユーザの情報. 未ログインの場合は指定しない.
* @see {@link UserSearchService#buildSearchUserQueries}
* @see {@link UserSearchService#buildSearchUserNoLoginQueries}
*/
@bindThis
public async search(
params: {
username?: string | null,
host?: string | null,
activeThreshold?: Date,
},
opts?: {
limit?: number,
detail?: boolean,
},
me?: MiUser | null,
): Promise<Packed<'User'>[]> {
const queries = me ? this.buildSearchUserQueries(me, params) : this.buildSearchUserNoLoginQueries(params);

let resultSet = new Set<MiUser['id']>();
const limit = opts?.limit ?? 10;
for (const query of queries) {
const ids = await query
.select('user.id')
.limit(limit - resultSet.size)
.orderBy('user.usernameLower', 'ASC')
.getRawMany<{ user_id: MiUser['id'] }>()
.then(res => res.map(x => x.user_id));

resultSet = new Set([...resultSet, ...ids]);
if (resultSet.size >= limit) {
break;
}
}

return this.userEntityService.packMany<'UserLite' | 'UserDetailed'>(
[...resultSet].slice(0, limit),
me,
{ schema: opts?.detail ? 'UserDetailed' : 'UserLite' },
);
}

/**
* ログイン済みユーザによる検索実行時のクエリ一覧を構築する.
* @param me
* @param params
* @private
*/
@bindThis
private buildSearchUserQueries(
me: MiUser,
params: {
username?: string | null,
host?: string | null,
activeThreshold?: Date,
},
) {
// デフォルト30日以内に更新されたユーザーをアクティブユーザーとする
const activeThreshold = params.activeThreshold ?? defaultActiveThreshold();

const followingUserQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });

const activeFollowingUsersQuery = this.generateUserQueryBuilder(params)
.andWhere(`user.id IN (${followingUserQuery.getQuery()})`)
.andWhere('user.updatedAt > :activeThreshold', { activeThreshold });
activeFollowingUsersQuery.setParameters(followingUserQuery.getParameters());

const inactiveFollowingUsersQuery = this.generateUserQueryBuilder(params)
.andWhere(`user.id IN (${followingUserQuery.getQuery()})`)
.andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt <= :activeThreshold', { activeThreshold });
}));
inactiveFollowingUsersQuery.setParameters(followingUserQuery.getParameters());

// 自分自身がヒットするとしたらここ
const activeUserQuery = this.generateUserQueryBuilder(params)
.andWhere(`user.id NOT IN (${followingUserQuery.getQuery()})`)
.andWhere('user.updatedAt > :activeThreshold', { activeThreshold });
activeUserQuery.setParameters(followingUserQuery.getParameters());

const inactiveUserQuery = this.generateUserQueryBuilder(params)
.andWhere(`user.id NOT IN (${followingUserQuery.getQuery()})`)
.andWhere('user.updatedAt <= :activeThreshold', { activeThreshold });
inactiveUserQuery.setParameters(followingUserQuery.getParameters());

return [activeFollowingUsersQuery, inactiveFollowingUsersQuery, activeUserQuery, inactiveUserQuery];
}

/**
* ログインしていないユーザによる検索実行時のクエリ一覧を構築する.
* @param params
* @private
*/
@bindThis
private buildSearchUserNoLoginQueries(params: {
username?: string | null,
host?: string | null,
activeThreshold?: Date,
}) {
// デフォルト30日以内に更新されたユーザーをアクティブユーザーとする
const activeThreshold = params.activeThreshold ?? defaultActiveThreshold();

const activeUserQuery = this.generateUserQueryBuilder(params)
.andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold });
}));

const inactiveUserQuery = this.generateUserQueryBuilder(params)
.andWhere('user.updatedAt <= :activeThreshold', { activeThreshold });

return [activeUserQuery, inactiveUserQuery];
}

/**
* ユーザ検索クエリで共通する抽出条件をあらかじめ設定したクエリビルダを生成する.
* @param params
* @private
*/
@bindThis
private generateUserQueryBuilder(params: {
username?: string | null,
host?: string | null,
}): SelectQueryBuilder<MiUser> {
const userQuery = this.usersRepository.createQueryBuilder('user');

if (params.username) {
userQuery.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(params.username.toLowerCase()) + '%' });
}

if (params.host) {
if (params.host === this.config.hostname || params.host === '.') {
userQuery.andWhere('user.host IS NULL');
} else {
userQuery.andWhere('user.host LIKE :host', {
host: sqlLikeEscape(params.host.toLowerCase()) + '%',
});
}
}

userQuery.andWhere('user.isSuspended = FALSE');

return userQuery;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, FollowingsRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js';
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import { UserSearchService } from '@/core/UserSearchService.js';

export const meta = {
tags: ['users'],
Expand Down Expand Up @@ -49,89 +43,16 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.config)
private config: Config,

@Inject(DI.usersRepository)
private usersRepository: UsersRepository,

@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,

private userEntityService: UserEntityService,
private userSearchService: UserSearchService,
) {
super(meta, paramDef, async (ps, me) => {
const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => {
if (ps.username) {
query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' });
}

if (ps.host) {
if (ps.host === this.config.hostname || ps.host === '.') {
query.andWhere('user.host IS NULL');
} else {
query.andWhere('user.host LIKE :host', {
host: sqlLikeEscape(ps.host.toLowerCase()) + '%',
});
}
}

return query;
};

const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日

let users: MiUser[] = [];

if (me) {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });

const query = setUsernameAndHostQuery()
.andWhere(`user.id IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE')
.andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}));

query.setParameters(followingQuery.getParameters());

users = await query
.orderBy('user.usernameLower', 'ASC')
.limit(ps.limit)
.getMany();

if (users.length < ps.limit) {
const otherQuery = setUsernameAndHostQuery()
.andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`)
.andWhere('user.isSuspended = FALSE')
.andWhere('user.updatedAt IS NOT NULL');

otherQuery.setParameters(followingQuery.getParameters());

const otherUsers = await otherQuery
.orderBy('user.updatedAt', 'DESC')
.limit(ps.limit - users.length)
.getMany();

users = users.concat(otherUsers);
}
} else {
const query = setUsernameAndHostQuery()
.andWhere('user.isSuspended = FALSE')
.andWhere('user.updatedAt IS NOT NULL');

users = await query
.orderBy('user.updatedAt', 'DESC')
.limit(ps.limit - users.length)
.getMany();
}

return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
super(meta, paramDef, (ps, me) => {
return this.userSearchService.search({
username: ps.username,
host: ps.host,
}, {
limit: ps.limit,
detail: ps.detail,
}, me);
});
}
}
Loading

0 comments on commit 6cd1527

Please sign in to comment.