用户数据结构变更
This commit is contained in:
+7
-6
@@ -470,14 +470,15 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
||||
return allApiSites;
|
||||
}
|
||||
|
||||
const userConfig = config.UserConfig.Users.find((u) => u.username === user);
|
||||
if (!userConfig) {
|
||||
// 从V2存储中获取用户信息
|
||||
const userInfoV2 = await db.getUserInfoV2(user);
|
||||
if (!userInfoV2) {
|
||||
return allApiSites;
|
||||
}
|
||||
|
||||
// 优先根据用户自己的 enabledApis 配置查找
|
||||
if (userConfig.enabledApis && userConfig.enabledApis.length > 0) {
|
||||
const userApiSitesSet = new Set(userConfig.enabledApis);
|
||||
if (userInfoV2.enabledApis && userInfoV2.enabledApis.length > 0) {
|
||||
const userApiSitesSet = new Set(userInfoV2.enabledApis);
|
||||
return allApiSites.filter((s) => userApiSitesSet.has(s.key)).map((s) => ({
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
@@ -487,11 +488,11 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
||||
}
|
||||
|
||||
// 如果没有 enabledApis 配置,则根据 tags 查找
|
||||
if (userConfig.tags && userConfig.tags.length > 0 && config.UserConfig.Tags) {
|
||||
if (userInfoV2.tags && userInfoV2.tags.length > 0 && config.UserConfig.Tags) {
|
||||
const enabledApisFromTags = new Set<string>();
|
||||
|
||||
// 遍历用户的所有 tags,收集对应的 enabledApis
|
||||
userConfig.tags.forEach(tagName => {
|
||||
userInfoV2.tags.forEach(tagName => {
|
||||
const tagConfig = config.UserConfig.Tags?.find(t => t.name === tagName);
|
||||
if (tagConfig && tagConfig.enabledApis) {
|
||||
tagConfig.enabledApis.forEach(apiKey => enabledApisFromTags.add(apiKey));
|
||||
|
||||
+192
-1
@@ -132,7 +132,7 @@ export class DbManager {
|
||||
return favorite !== null;
|
||||
}
|
||||
|
||||
// ---------- 用户相关 ----------
|
||||
// ---------- 用户相关(旧版本,保持兼容) ----------
|
||||
async registerUser(userName: string, password: string): Promise<void> {
|
||||
await this.storage.registerUser(userName, password);
|
||||
}
|
||||
@@ -154,6 +154,197 @@ export class DbManager {
|
||||
await this.storage.deleteUser(userName);
|
||||
}
|
||||
|
||||
// ---------- 用户相关(新版本) ----------
|
||||
async createUserV2(
|
||||
userName: string,
|
||||
password: string,
|
||||
role: 'owner' | 'admin' | 'user' = 'user',
|
||||
tags?: string[],
|
||||
oidcSub?: string,
|
||||
enabledApis?: string[]
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).createUserV2 === 'function') {
|
||||
await (this.storage as any).createUserV2(userName, password, role, tags, oidcSub, enabledApis);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyUserV2(userName: string, password: string): Promise<boolean> {
|
||||
if (typeof (this.storage as any).verifyUserV2 === 'function') {
|
||||
return (this.storage as any).verifyUserV2(userName, password);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async getUserInfoV2(userName: string): Promise<{
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
banned: boolean;
|
||||
tags?: string[];
|
||||
oidcSub?: string;
|
||||
enabledApis?: string[];
|
||||
created_at: number;
|
||||
} | null> {
|
||||
if (typeof (this.storage as any).getUserInfoV2 === 'function') {
|
||||
return (this.storage as any).getUserInfoV2(userName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateUserInfoV2(
|
||||
userName: string,
|
||||
updates: {
|
||||
role?: 'owner' | 'admin' | 'user';
|
||||
banned?: boolean;
|
||||
tags?: string[];
|
||||
oidcSub?: string;
|
||||
enabledApis?: string[];
|
||||
}
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).updateUserInfoV2 === 'function') {
|
||||
await (this.storage as any).updateUserInfoV2(userName, updates);
|
||||
}
|
||||
}
|
||||
|
||||
async changePasswordV2(userName: string, newPassword: string): Promise<void> {
|
||||
if (typeof (this.storage as any).changePasswordV2 === 'function') {
|
||||
await (this.storage as any).changePasswordV2(userName, newPassword);
|
||||
}
|
||||
}
|
||||
|
||||
async checkUserExistV2(userName: string): Promise<boolean> {
|
||||
if (typeof (this.storage as any).checkUserExistV2 === 'function') {
|
||||
return (this.storage as any).checkUserExistV2(userName);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
||||
if (typeof (this.storage as any).getUserByOidcSub === 'function') {
|
||||
return (this.storage as any).getUserByOidcSub(oidcSub);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getUserListV2(
|
||||
offset: number = 0,
|
||||
limit: number = 20,
|
||||
ownerUsername?: string
|
||||
): Promise<{
|
||||
users: Array<{
|
||||
username: string;
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
banned: boolean;
|
||||
tags?: string[];
|
||||
enabledApis?: string[];
|
||||
created_at: number;
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
if (typeof (this.storage as any).getUserListV2 === 'function') {
|
||||
return (this.storage as any).getUserListV2(offset, limit, ownerUsername);
|
||||
}
|
||||
return { users: [], total: 0 };
|
||||
}
|
||||
|
||||
async deleteUserV2(userName: string): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteUserV2 === 'function') {
|
||||
await (this.storage as any).deleteUserV2(userName);
|
||||
}
|
||||
}
|
||||
|
||||
async getUsersByTag(tagName: string): Promise<string[]> {
|
||||
if (typeof (this.storage as any).getUsersByTag === 'function') {
|
||||
return (this.storage as any).getUsersByTag(tagName);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------- 数据迁移 ----------
|
||||
async migrateUsersFromConfig(adminConfig: AdminConfig): Promise<void> {
|
||||
if (typeof (this.storage as any).createUserV2 !== 'function') {
|
||||
throw new Error('当前存储类型不支持新版用户存储');
|
||||
}
|
||||
|
||||
const users = adminConfig.UserConfig.Users;
|
||||
if (!users || users.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`开始迁移 ${users.length} 个用户...`);
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
// 跳过站长(站长使用环境变量认证,不需要迁移)
|
||||
if (user.role === 'owner') {
|
||||
console.log(`跳过站长 ${user.username} 的迁移`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查用户是否已经迁移
|
||||
const exists = await this.checkUserExistV2(user.username);
|
||||
if (exists) {
|
||||
console.log(`用户 ${user.username} 已存在,跳过迁移`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取密码
|
||||
let password = '';
|
||||
|
||||
// 如果是OIDC用户,生成随机密码(OIDC用户不需要密码登录)
|
||||
if ((user as any).oidcSub) {
|
||||
password = crypto.randomUUID();
|
||||
console.log(`用户 ${user.username} (OIDC用户) 使用随机密码迁移`);
|
||||
}
|
||||
// 如果是站长,使用环境变量中的密码
|
||||
else if (user.username === process.env.USERNAME && process.env.PASSWORD) {
|
||||
password = process.env.PASSWORD;
|
||||
console.log(`用户 ${user.username} (站长) 使用环境变量密码迁移`);
|
||||
}
|
||||
// 尝试从旧的存储中获取密码
|
||||
else {
|
||||
try {
|
||||
if ((this.storage as any).client) {
|
||||
const storedPassword = await (this.storage as any).client.get(`u:${user.username}:pwd`);
|
||||
if (storedPassword) {
|
||||
password = storedPassword;
|
||||
console.log(`用户 ${user.username} 使用旧密码迁移`);
|
||||
} else {
|
||||
// 没有旧密码,使用默认密码
|
||||
password = 'defaultPassword123';
|
||||
console.log(`用户 ${user.username} 没有旧密码,使用默认密码`);
|
||||
}
|
||||
} else {
|
||||
password = 'defaultPassword123';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`获取用户 ${user.username} 的密码失败,使用默认密码`, err);
|
||||
password = 'defaultPassword123';
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
await this.createUserV2(
|
||||
user.username,
|
||||
password,
|
||||
user.role,
|
||||
user.tags,
|
||||
(user as any).oidcSub,
|
||||
user.enabledApis
|
||||
);
|
||||
|
||||
// 如果用户被封禁,更新状态
|
||||
if (user.banned) {
|
||||
await this.updateUserInfoV2(user.username, { banned: true });
|
||||
}
|
||||
|
||||
console.log(`用户 ${user.username} 迁移成功`);
|
||||
} catch (err) {
|
||||
console.error(`迁移用户 ${user.username} 失败:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('用户迁移完成');
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
return this.storage.getSearchHistory(userName);
|
||||
|
||||
+251
-1
@@ -242,7 +242,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
await this.withRetry(() => this.client.del(this.favKey(userName, key)));
|
||||
}
|
||||
|
||||
// ---------- 用户注册 / 登录 ----------
|
||||
// ---------- 用户注册 / 登录(旧版本,保持兼容) ----------
|
||||
private userPwdKey(user: string) {
|
||||
return `u:${user}:pwd`;
|
||||
}
|
||||
@@ -314,6 +314,256 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 新版用户存储(使用Hash和Sorted Set) ----------
|
||||
private userInfoKey(userName: string) {
|
||||
return `user:${userName}:info`;
|
||||
}
|
||||
|
||||
private userListKey() {
|
||||
return 'user:list';
|
||||
}
|
||||
|
||||
private oidcSubKey(oidcSub: string) {
|
||||
return `oidc:sub:${oidcSub}`;
|
||||
}
|
||||
|
||||
// SHA256加密密码
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// 创建新用户(新版本)
|
||||
async createUserV2(
|
||||
userName: string,
|
||||
password: string,
|
||||
role: 'owner' | 'admin' | 'user' = 'user',
|
||||
tags?: string[],
|
||||
oidcSub?: string
|
||||
): Promise<void> {
|
||||
const hashedPassword = await this.hashPassword(password);
|
||||
const createdAt = Date.now();
|
||||
|
||||
// 存储用户信息到Hash
|
||||
const userInfo: Record<string, string> = {
|
||||
role,
|
||||
banned: 'false',
|
||||
password: hashedPassword,
|
||||
created_at: createdAt.toString(),
|
||||
};
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
userInfo.tags = JSON.stringify(tags);
|
||||
}
|
||||
|
||||
if (oidcSub) {
|
||||
userInfo.oidcSub = oidcSub;
|
||||
// 创建OIDC映射
|
||||
await this.withRetry(() => this.client.set(this.oidcSubKey(oidcSub), userName));
|
||||
}
|
||||
|
||||
await this.withRetry(() => this.client.hSet(this.userInfoKey(userName), userInfo));
|
||||
|
||||
// 添加到用户列表(Sorted Set,按注册时间排序)
|
||||
await this.withRetry(() => this.client.zAdd(this.userListKey(), {
|
||||
score: createdAt,
|
||||
value: userName,
|
||||
}));
|
||||
}
|
||||
|
||||
// 验证用户密码(新版本)
|
||||
async verifyUserV2(userName: string, password: string): Promise<boolean> {
|
||||
const userInfo = await this.withRetry(() =>
|
||||
this.client.hGetAll(this.userInfoKey(userName))
|
||||
);
|
||||
|
||||
if (!userInfo || !userInfo.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hashedPassword = await this.hashPassword(password);
|
||||
return userInfo.password === hashedPassword;
|
||||
}
|
||||
|
||||
// 获取用户信息(新版本)
|
||||
async getUserInfoV2(userName: string): Promise<{
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
banned: boolean;
|
||||
tags?: string[];
|
||||
oidcSub?: string;
|
||||
created_at: number;
|
||||
} | null> {
|
||||
const userInfo = await this.withRetry(() =>
|
||||
this.client.hGetAll(this.userInfoKey(userName))
|
||||
);
|
||||
|
||||
if (!userInfo || Object.keys(userInfo).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
role: (userInfo.role as 'owner' | 'admin' | 'user') || 'user',
|
||||
banned: userInfo.banned === 'true',
|
||||
tags: userInfo.tags ? JSON.parse(userInfo.tags) : undefined,
|
||||
oidcSub: userInfo.oidcSub,
|
||||
created_at: parseInt(userInfo.created_at || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
// 更新用户信息(新版本)
|
||||
async updateUserInfoV2(
|
||||
userName: string,
|
||||
updates: {
|
||||
role?: 'owner' | 'admin' | 'user';
|
||||
banned?: boolean;
|
||||
tags?: string[];
|
||||
oidcSub?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const userInfo: Record<string, string> = {};
|
||||
|
||||
if (updates.role !== undefined) {
|
||||
userInfo.role = updates.role;
|
||||
}
|
||||
|
||||
if (updates.banned !== undefined) {
|
||||
userInfo.banned = updates.banned ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (updates.tags !== undefined) {
|
||||
if (updates.tags.length > 0) {
|
||||
userInfo.tags = JSON.stringify(updates.tags);
|
||||
} else {
|
||||
// 删除tags字段
|
||||
await this.withRetry(() => this.client.hDel(this.userInfoKey(userName), 'tags'));
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.oidcSub !== undefined) {
|
||||
const oldInfo = await this.getUserInfoV2(userName);
|
||||
if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) {
|
||||
// 删除旧的OIDC映射
|
||||
await this.withRetry(() => this.client.del(this.oidcSubKey(oldInfo.oidcSub!)));
|
||||
}
|
||||
userInfo.oidcSub = updates.oidcSub;
|
||||
// 创建新的OIDC映射
|
||||
await this.withRetry(() => this.client.set(this.oidcSubKey(updates.oidcSub!), userName));
|
||||
}
|
||||
|
||||
if (Object.keys(userInfo).length > 0) {
|
||||
await this.withRetry(() => this.client.hSet(this.userInfoKey(userName), userInfo));
|
||||
}
|
||||
}
|
||||
|
||||
// 修改用户密码(新版本)
|
||||
async changePasswordV2(userName: string, newPassword: string): Promise<void> {
|
||||
const hashedPassword = await this.hashPassword(newPassword);
|
||||
await this.withRetry(() =>
|
||||
this.client.hSet(this.userInfoKey(userName), 'password', hashedPassword)
|
||||
);
|
||||
}
|
||||
|
||||
// 检查用户是否存在(新版本)
|
||||
async checkUserExistV2(userName: string): Promise<boolean> {
|
||||
const exists = await this.withRetry(() =>
|
||||
this.client.exists(this.userInfoKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
// 通过OIDC Sub查找用户名
|
||||
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
||||
const userName = await this.withRetry(() =>
|
||||
this.client.get(this.oidcSubKey(oidcSub))
|
||||
);
|
||||
return userName ? ensureString(userName) : null;
|
||||
}
|
||||
|
||||
// 获取用户列表(分页,新版本)
|
||||
async getUserListV2(
|
||||
offset: number = 0,
|
||||
limit: number = 20,
|
||||
ownerUsername?: string
|
||||
): Promise<{
|
||||
users: Array<{
|
||||
username: string;
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
banned: boolean;
|
||||
tags?: string[];
|
||||
created_at: number;
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
// 获取总数
|
||||
const total = await this.withRetry(() => this.client.zCard(this.userListKey()));
|
||||
|
||||
// 获取用户列表(按注册时间升序)
|
||||
const usernames = await this.withRetry(() =>
|
||||
this.client.zRange(this.userListKey(), offset, offset + limit - 1)
|
||||
);
|
||||
|
||||
const users = [];
|
||||
|
||||
// 如果有站长,确保站长始终在第一位
|
||||
if (ownerUsername && offset === 0) {
|
||||
const ownerInfo = await this.getUserInfoV2(ownerUsername);
|
||||
if (ownerInfo) {
|
||||
users.push({
|
||||
username: ownerUsername,
|
||||
role: 'owner' as const,
|
||||
banned: ownerInfo.banned,
|
||||
tags: ownerInfo.tags,
|
||||
created_at: ownerInfo.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取其他用户信息
|
||||
for (const username of usernames) {
|
||||
const usernameStr = ensureString(username);
|
||||
// 跳过站长(已经添加)
|
||||
if (ownerUsername && usernameStr === ownerUsername) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userInfo = await this.getUserInfoV2(usernameStr);
|
||||
if (userInfo) {
|
||||
users.push({
|
||||
username: usernameStr,
|
||||
role: userInfo.role,
|
||||
banned: userInfo.banned,
|
||||
tags: userInfo.tags,
|
||||
created_at: userInfo.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { users, total };
|
||||
}
|
||||
|
||||
// 删除用户(新版本)
|
||||
async deleteUserV2(userName: string): Promise<void> {
|
||||
// 获取用户信息
|
||||
const userInfo = await this.getUserInfoV2(userName);
|
||||
|
||||
// 删除OIDC映射
|
||||
if (userInfo?.oidcSub) {
|
||||
await this.withRetry(() => this.client.del(this.oidcSubKey(userInfo.oidcSub!)));
|
||||
}
|
||||
|
||||
// 删除用户信息Hash
|
||||
await this.withRetry(() => this.client.del(this.userInfoKey(userName)));
|
||||
|
||||
// 从用户列表中移除
|
||||
await this.withRetry(() => this.client.zRem(this.userListKey(), userName));
|
||||
|
||||
// 删除用户的其他数据(播放记录、收藏等)
|
||||
await this.deleteUser(userName);
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
private shKey(user: string) {
|
||||
return `u:${user}:sh`; // u:username:sh
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Redis } from '@upstash/redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
import { userInfoCache } from './user-cache';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
@@ -220,6 +221,366 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 新版用户存储(使用Hash和Sorted Set) ----------
|
||||
private userInfoKey(userName: string) {
|
||||
return `user:${userName}:info`;
|
||||
}
|
||||
|
||||
private userListKey() {
|
||||
return 'user:list';
|
||||
}
|
||||
|
||||
private oidcSubKey(oidcSub: string) {
|
||||
return `oidc:sub:${oidcSub}`;
|
||||
}
|
||||
|
||||
// SHA256加密密码
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// 创建新用户(新版本)
|
||||
async createUserV2(
|
||||
userName: string,
|
||||
password: string,
|
||||
role: 'owner' | 'admin' | 'user' = 'user',
|
||||
tags?: string[],
|
||||
oidcSub?: string,
|
||||
enabledApis?: string[]
|
||||
): Promise<void> {
|
||||
const hashedPassword = await this.hashPassword(password);
|
||||
const createdAt = Date.now();
|
||||
|
||||
// 存储用户信息到Hash
|
||||
const userInfo: Record<string, any> = {
|
||||
role,
|
||||
banned: false, // 直接使用布尔值
|
||||
password: hashedPassword,
|
||||
created_at: createdAt.toString(),
|
||||
};
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
userInfo.tags = JSON.stringify(tags);
|
||||
}
|
||||
|
||||
if (oidcSub) {
|
||||
userInfo.oidcSub = oidcSub;
|
||||
// 创建OIDC映射
|
||||
await withRetry(() => this.client.set(this.oidcSubKey(oidcSub), userName));
|
||||
}
|
||||
|
||||
if (enabledApis && enabledApis.length > 0) {
|
||||
userInfo.enabledApis = JSON.stringify(enabledApis);
|
||||
}
|
||||
|
||||
await withRetry(() => this.client.hset(this.userInfoKey(userName), userInfo));
|
||||
|
||||
// 添加到用户列表(Sorted Set,按注册时间排序)
|
||||
await withRetry(() => this.client.zadd(this.userListKey(), {
|
||||
score: createdAt,
|
||||
member: userName,
|
||||
}));
|
||||
}
|
||||
|
||||
// 验证用户密码(新版本)
|
||||
async verifyUserV2(userName: string, password: string): Promise<boolean> {
|
||||
const userInfo = await withRetry(() =>
|
||||
this.client.hgetall(this.userInfoKey(userName))
|
||||
);
|
||||
|
||||
if (!userInfo || !userInfo.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hashedPassword = await this.hashPassword(password);
|
||||
return userInfo.password === hashedPassword;
|
||||
}
|
||||
|
||||
// 获取用户信息(新版本)
|
||||
async getUserInfoV2(userName: string): Promise<{
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
banned: boolean;
|
||||
tags?: string[];
|
||||
oidcSub?: string;
|
||||
enabledApis?: string[];
|
||||
created_at: number;
|
||||
} | null> {
|
||||
// 先从缓存获取
|
||||
const cached = userInfoCache?.get(userName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const userInfo = await withRetry(() =>
|
||||
this.client.hgetall(this.userInfoKey(userName))
|
||||
);
|
||||
|
||||
if (!userInfo || Object.keys(userInfo).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理 banned 字段:可能是字符串 'true'/'false' 或布尔值 true/false
|
||||
let banned = false;
|
||||
if (typeof userInfo.banned === 'boolean') {
|
||||
banned = userInfo.banned;
|
||||
} else if (typeof userInfo.banned === 'string') {
|
||||
banned = userInfo.banned === 'true';
|
||||
}
|
||||
|
||||
// 安全解析 tags 字段
|
||||
let tags: string[] | undefined = undefined;
|
||||
if (userInfo.tags) {
|
||||
if (Array.isArray(userInfo.tags)) {
|
||||
tags = userInfo.tags;
|
||||
} else if (typeof userInfo.tags === 'string') {
|
||||
try {
|
||||
tags = JSON.parse(userInfo.tags);
|
||||
} catch {
|
||||
// 如果解析失败,可能是单个字符串,转换为数组
|
||||
tags = [userInfo.tags];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 安全解析 enabledApis 字段
|
||||
let enabledApis: string[] | undefined = undefined;
|
||||
if (userInfo.enabledApis) {
|
||||
if (Array.isArray(userInfo.enabledApis)) {
|
||||
enabledApis = userInfo.enabledApis;
|
||||
} else if (typeof userInfo.enabledApis === 'string') {
|
||||
try {
|
||||
enabledApis = JSON.parse(userInfo.enabledApis);
|
||||
} catch {
|
||||
// 如果解析失败,可能是单个字符串,转换为数组
|
||||
enabledApis = [userInfo.enabledApis];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
role: (userInfo.role as 'owner' | 'admin' | 'user') || 'user',
|
||||
banned,
|
||||
tags,
|
||||
oidcSub: userInfo.oidcSub as string | undefined,
|
||||
enabledApis,
|
||||
created_at: parseInt((userInfo.created_at as string) || '0', 10),
|
||||
};
|
||||
|
||||
// 存入缓存
|
||||
userInfoCache?.set(userName, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 更新用户信息(新版本)
|
||||
async updateUserInfoV2(
|
||||
userName: string,
|
||||
updates: {
|
||||
role?: 'owner' | 'admin' | 'user';
|
||||
banned?: boolean;
|
||||
tags?: string[];
|
||||
oidcSub?: string;
|
||||
enabledApis?: string[];
|
||||
}
|
||||
): Promise<void> {
|
||||
const userInfo: Record<string, any> = {};
|
||||
|
||||
if (updates.role !== undefined) {
|
||||
userInfo.role = updates.role;
|
||||
}
|
||||
|
||||
if (updates.banned !== undefined) {
|
||||
// 直接存储布尔值,让 Upstash 自动处理序列化
|
||||
userInfo.banned = updates.banned;
|
||||
}
|
||||
|
||||
if (updates.tags !== undefined) {
|
||||
if (updates.tags.length > 0) {
|
||||
userInfo.tags = JSON.stringify(updates.tags);
|
||||
} else {
|
||||
// 删除tags字段
|
||||
await withRetry(() => this.client.hdel(this.userInfoKey(userName), 'tags'));
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.enabledApis !== undefined) {
|
||||
if (updates.enabledApis.length > 0) {
|
||||
userInfo.enabledApis = JSON.stringify(updates.enabledApis);
|
||||
} else {
|
||||
// 删除enabledApis字段
|
||||
await withRetry(() => this.client.hdel(this.userInfoKey(userName), 'enabledApis'));
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.oidcSub !== undefined) {
|
||||
const oldInfo = await this.getUserInfoV2(userName);
|
||||
if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) {
|
||||
// 删除旧的OIDC映射
|
||||
await withRetry(() => this.client.del(this.oidcSubKey(oldInfo.oidcSub!)));
|
||||
}
|
||||
userInfo.oidcSub = updates.oidcSub;
|
||||
// 创建新的OIDC映射
|
||||
await withRetry(() => this.client.set(this.oidcSubKey(updates.oidcSub!), userName));
|
||||
}
|
||||
|
||||
if (Object.keys(userInfo).length > 0) {
|
||||
await withRetry(() => this.client.hset(this.userInfoKey(userName), userInfo));
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
userInfoCache?.delete(userName);
|
||||
}
|
||||
|
||||
// 修改用户密码(新版本)
|
||||
async changePasswordV2(userName: string, newPassword: string): Promise<void> {
|
||||
const hashedPassword = await this.hashPassword(newPassword);
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { password: hashedPassword })
|
||||
);
|
||||
|
||||
// 清除缓存
|
||||
userInfoCache?.delete(userName);
|
||||
}
|
||||
|
||||
// 检查用户是否存在(新版本)
|
||||
async checkUserExistV2(userName: string): Promise<boolean> {
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userInfoKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
// 通过OIDC Sub查找用户名
|
||||
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
||||
const userName = await withRetry(() =>
|
||||
this.client.get(this.oidcSubKey(oidcSub))
|
||||
);
|
||||
return userName ? ensureString(userName) : null;
|
||||
}
|
||||
|
||||
// 获取使用特定用户组的用户列表
|
||||
async getUsersByTag(tagName: string): Promise<string[]> {
|
||||
const affectedUsers: string[] = [];
|
||||
|
||||
// 使用 SCAN 遍历所有用户信息的 key
|
||||
let cursor: number | string = 0;
|
||||
do {
|
||||
const result = await withRetry(() =>
|
||||
this.client.scan(cursor as number, { match: 'user:*:info', count: 100 })
|
||||
);
|
||||
|
||||
cursor = result[0];
|
||||
const keys = result[1];
|
||||
|
||||
// 检查每个用户的 tags
|
||||
for (const key of keys) {
|
||||
const userInfo = await withRetry(() => this.client.hgetall(key));
|
||||
if (userInfo && userInfo.tags) {
|
||||
const tags = JSON.parse(userInfo.tags as string);
|
||||
if (tags.includes(tagName)) {
|
||||
// 从 key 中提取用户名: user:username:info -> username
|
||||
const username = key.replace('user:', '').replace(':info', '');
|
||||
affectedUsers.push(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (typeof cursor === 'number' ? cursor !== 0 : cursor !== '0');
|
||||
|
||||
return affectedUsers;
|
||||
}
|
||||
|
||||
// 获取用户列表(分页,新版本)
|
||||
async getUserListV2(
|
||||
offset: number = 0,
|
||||
limit: number = 20,
|
||||
ownerUsername?: string
|
||||
): Promise<{
|
||||
users: Array<{
|
||||
username: string;
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
banned: boolean;
|
||||
tags?: string[];
|
||||
enabledApis?: string[];
|
||||
created_at: number;
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
// 获取总数
|
||||
const total = await withRetry(() => this.client.zcard(this.userListKey()));
|
||||
|
||||
// 获取用户列表(按注册时间升序)
|
||||
const usernames = await withRetry(() =>
|
||||
this.client.zrange(this.userListKey(), offset, offset + limit - 1)
|
||||
);
|
||||
|
||||
const users = [];
|
||||
|
||||
// 如果有站长,确保站长始终在第一位
|
||||
if (ownerUsername && offset === 0) {
|
||||
const ownerInfo = await this.getUserInfoV2(ownerUsername);
|
||||
if (ownerInfo) {
|
||||
users.push({
|
||||
username: ownerUsername,
|
||||
role: 'owner' as const,
|
||||
banned: ownerInfo.banned,
|
||||
tags: ownerInfo.tags,
|
||||
enabledApis: ownerInfo.enabledApis,
|
||||
created_at: ownerInfo.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取其他用户信息
|
||||
for (const username of usernames) {
|
||||
const usernameStr = ensureString(username);
|
||||
// 跳过站长(已经添加)
|
||||
if (ownerUsername && usernameStr === ownerUsername) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userInfo = await this.getUserInfoV2(usernameStr);
|
||||
if (userInfo) {
|
||||
users.push({
|
||||
username: usernameStr,
|
||||
role: userInfo.role,
|
||||
banned: userInfo.banned,
|
||||
tags: userInfo.tags,
|
||||
enabledApis: userInfo.enabledApis,
|
||||
created_at: userInfo.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { users, total };
|
||||
}
|
||||
|
||||
// 删除用户(新版本)
|
||||
async deleteUserV2(userName: string): Promise<void> {
|
||||
// 获取用户信息
|
||||
const userInfo = await this.getUserInfoV2(userName);
|
||||
|
||||
// 删除OIDC映射
|
||||
if (userInfo?.oidcSub) {
|
||||
await withRetry(() => this.client.del(this.oidcSubKey(userInfo.oidcSub!)));
|
||||
}
|
||||
|
||||
// 删除用户信息Hash
|
||||
await withRetry(() => this.client.del(this.userInfoKey(userName)));
|
||||
|
||||
// 从用户列表中移除
|
||||
await withRetry(() => this.client.zrem(this.userListKey(), userName));
|
||||
|
||||
// 删除用户的其他数据(播放记录、收藏等)
|
||||
await this.deleteUser(userName);
|
||||
|
||||
// 清除缓存
|
||||
userInfoCache?.delete(userName);
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
private shKey(user: string) {
|
||||
return `u:${user}:sh`; // u:username:sh
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// 用户信息缓存
|
||||
interface CachedUserInfo {
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
banned: boolean;
|
||||
tags?: string[];
|
||||
oidcSub?: string;
|
||||
enabledApis?: string[];
|
||||
created_at: number;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
class UserInfoCache {
|
||||
private cache: Map<string, CachedUserInfo> = new Map();
|
||||
private readonly TTL = 6 * 60 * 60 * 1000; // 6小时过期
|
||||
|
||||
get(username: string): CachedUserInfo | null {
|
||||
const cached = this.cache.get(username);
|
||||
if (!cached) return null;
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - cached.cachedAt > this.TTL) {
|
||||
this.cache.delete(username);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
set(username: string, userInfo: Omit<CachedUserInfo, 'cachedAt'>): void {
|
||||
this.cache.set(username, {
|
||||
...userInfo,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
delete(username: string): void {
|
||||
this.cache.delete(username);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
// 清理过期的缓存
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(this.cache.entries());
|
||||
for (const [username, cached] of entries) {
|
||||
if (now - cached.cachedAt > this.TTL) {
|
||||
this.cache.delete(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局单例
|
||||
const globalKey = Symbol.for('__MOONTV_USER_INFO_CACHE__');
|
||||
let userInfoCache: UserInfoCache | undefined = (global as any)[globalKey];
|
||||
|
||||
if (!userInfoCache) {
|
||||
userInfoCache = new UserInfoCache();
|
||||
(global as any)[globalKey] = userInfoCache;
|
||||
|
||||
// 每分钟清理一次过期缓存
|
||||
setInterval(() => {
|
||||
userInfoCache?.cleanup();
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
export { userInfoCache };
|
||||
Reference in New Issue
Block a user