fix user list API schema, access checks, and caching

This commit is contained in:
Hazelnoot 2025-10-23 19:22:30 -04:00
parent 2a948b7710
commit 73cc50fe90
22 changed files with 233 additions and 135 deletions

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { In, IsNull, Not } from 'typeorm';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, NoteThreadMutingsRepository, ChannelFollowingsRepository, UserListMembershipsRepository } from '@/models/_.js';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, NoteThreadMutingsRepository, ChannelFollowingsRepository, UserListMembershipsRepository, UserListFavoritesRepository } from '@/models/_.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { MiUserListMembership } from '@/models/UserListMembership.js';
import { isLocalUser, isRemoteUser } from '@/models/User.js';
@ -41,6 +41,20 @@ export class CacheService implements OnApplicationShutdown {
public readonly userBlockedCache: ManagedQuantumKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public readonly userListMembershipsCache: ManagedQuantumKVCache<Map<string, MiUserListMembership>>;
public readonly listUserMembershipsCache: ManagedQuantumKVCache<Map<string, MiUserListMembership>>;
/**
* Maps user IDs (key) to the set of list IDs (value) that are favorited by that user
*/
public readonly userListFavoritesCache: ManagedQuantumKVCache<Set<string>>;
/**
* Maps list IDs (key) to the set of user IDs (value) who have favorited this list.
*/
public readonly listUserFavoritesCache: ManagedQuantumKVCache<Set<string>>;
/**
* Maps user IDs (key) to the set of user IDs (value) who's renotes are muted by that user.
*/
public readonly renoteMutingsCache: ManagedQuantumKVCache<Set<string>>;
public readonly threadMutingsCache: ManagedQuantumKVCache<Set<string>>;
public readonly noteMutingsCache: ManagedQuantumKVCache<Set<string>>;
@ -84,6 +98,9 @@ export class CacheService implements OnApplicationShutdown {
@Inject(DI.userListMembershipsRepository)
private readonly userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.userListFavoritesRepository)
private readonly userListFavoritesRepository: UserListFavoritesRepository,
private readonly internalEventService: InternalEventService,
private readonly cacheManagementService: CacheManagementService,
) {
@ -173,6 +190,32 @@ export class CacheService implements OnApplicationShutdown {
}, new Map<string, Map<string, MiUserListMembership>>)),
});
this.userListFavoritesCache = cacheManagementService.createQuantumKVCache<Set<string>>('userListFavorites', {
lifetime: 1000 * 60 * 30, // 30m
fetcher: async userId => await this.userListFavoritesRepository.findBy({ userId }).then(fs => new Set(fs.map(f => f.userListId))),
bulkFetcher: async userIds => await this.userListFavoritesRepository
.createQueryBuilder('favorite')
.select('"favorite"."userId"', 'userId')
.addSelect('array_agg("favorite"."userListId")', 'userListIds')
.where({ userId: In(userIds) })
.groupBy('favorite.userId')
.getRawMany<{ userId: string, userListIds: string[] }>()
.then(fs => fs.map(f => [f.userId, new Set(f.userListIds)])),
});
this.listUserFavoritesCache = cacheManagementService.createQuantumKVCache<Set<string>>('listUserFavorites', {
lifetime: 1000 * 60 * 30, // 30m
fetcher: async userListId => await this.userListFavoritesRepository.findBy({ userListId }).then(fs => new Set(fs.map(f => f.userId))),
bulkFetcher: async userListIds => await this.userListFavoritesRepository
.createQueryBuilder('favorite')
.select('"favorite"."userListId"', 'userListId')
.addSelect('array_agg("favorite"."userId")', 'userIds')
.where({ userListId: In(userListIds) })
.groupBy('favorite.userListId')
.getRawMany<{ userListId: string, userIds: string[] }>()
.then(fs => fs.map(f => [f.userListId, new Set(f.userIds)])),
});
this.renoteMutingsCache = this.cacheManagementService.createQuantumKVCache<Set<string>>('renoteMutings', {
lifetime: 1000 * 60 * 30, // 30m
fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),

View file

@ -21,11 +21,14 @@ import type { RoleService } from '@/core/RoleService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { InternalEventService } from '@/global/InternalEventService.js';
import { CacheService } from '@/core/CacheService.js';
import { CacheManagementService, type ManagedQuantumKVCache } from '@/global/CacheManagementService.js';
@Injectable()
export class UserListService implements OnModuleInit {
public static TooManyUsersError = class extends Error {};
public readonly userListsCache: ManagedQuantumKVCache<MiUserList>;
private roleService: RoleService;
constructor(
@ -53,7 +56,15 @@ export class UserListService implements OnModuleInit {
private systemAccountService: SystemAccountService,
private readonly internalEventService: InternalEventService,
private readonly cacheService: CacheService,
) {}
cacheManagementService: CacheManagementService,
) {
this.userListsCache = cacheManagementService.createQuantumKVCache('userLists', {
lifetime: 1000 * 60 * 30, // 30m
fetcher: async id => await this.userListsRepository.findOneBy({ id }),
bulkFetcher: async ids => await this.userListsRepository.findBy({ id: In(ids) }).then(ls => ls.map(l => [l.id, l])),
});
}
@bindThis
async onModuleInit() {
@ -120,14 +131,10 @@ export class UserListService implements OnModuleInit {
@bindThis
public async bulkAddMember(target: { id: MiUser['id'] }, memberships: { userListId: MiUserList['id'], withReplies?: boolean }[]): Promise<void> {
const userListIds = memberships.map(m => m.userListId);
const userLists = await this.userListsCache.fetchMany(userListIds);
// Map userListId => userListUserId
const listUserIds = await this.userListsRepository
.find({
where: { id: In(userListIds) },
select: { id: true, userId: true },
})
.then(ls => new Map(ls.map(l => [l.id, l.userId])));
const listUserIds = new Map(userLists.values.map(l => [l.id, l.userId]));
const toInsert = memberships.map(membership => ({
id: this.idService.gen(),

View file

@ -12,6 +12,7 @@ import type { MiUserList } from '@/models/UserList.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import { UserListService } from '@/core/UserListService.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
@ -26,26 +27,36 @@ export class UserListEntityService {
private userEntityService: UserEntityService,
private idService: IdService,
private readonly cacheService: CacheService,
private readonly userListService: UserListService,
) {
}
@bindThis
public async pack(
src: MiUserList['id'] | MiUserList,
meId: string | null | undefined,
): Promise<Packed<'UserList'>> {
const srcId = typeof(src) === 'object' ? src.id : src;
const [userList, users] = await Promise.all([
typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src }),
const [userList, users, favorites] = await Promise.all([
typeof src === 'object' ? src : this.userListService.userListsCache.fetch(src),
this.cacheService.listUserMembershipsCache.fetch(srcId),
this.cacheService.listUserFavoritesCache.fetch(srcId),
]);
return {
id: userList.id,
createdAt: this.idService.parse(userList.id).date.toISOString(),
createdBy: userList.userId,
name: userList.name,
userIds: users.keys().toArray(),
isPublic: userList.isPublic,
isLiked: meId != null
? favorites.has(meId)
: undefined,
likedCount: userList.isPublic || meId === userList.userId
? favorites.size
: undefined,
};
}