feat(settings,auth): admin open-network config with role visibility

users.role (first registrant admin), app_settings store plaintext AppId/Secret, admin-only settings API and UI section, runtime credential override in @app/danmaku.
This commit is contained in:
noelorin 2026-09-27 11:52:17 +08:00
parent 7fe1236d0d
commit 2e405cd974
23 changed files with 416 additions and 8 deletions

View File

@ -0,0 +1,103 @@
import type { Locale, MessageKey } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, Card, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
function Inner(props: { locale: Locale }) {
const { locale } = props;
const me = trpc.auth.me.useQuery();
const isAdmin = me.data?.user?.role === "admin";
const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin });
const saveOpenNet = trpc.settings.saveOpenNetwork.useMutation();
const [appId, setAppId] = useState("");
const [appSecret, setAppSecret] = useState("");
const [filled, setFilled] = useState(false);
useEffect(() => {
if (!openNet.data || filled) return;
setAppId(openNet.data.appId);
setAppSecret(openNet.data.appSecret);
setFilled(true);
}, [openNet.data, filled]);
if (me.isLoading) {
return <p className="text-sm text-muted-foreground">{t(locale, "watch.loading")}</p>;
}
if (!isAdmin) {
return null;
}
const label = (key: MessageKey) => t(locale, key);
return (
<Card className="space-y-3 p-4">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold">{label("settings.adminSection")}</h2>
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
{label("settings.adminOnly")}
</span>
</div>
<p className="text-sm text-muted-foreground">{label("settings.openNetworkHint")}</p>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{label("settings.openAppId")}</p>
<Input
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="AppId"
autoComplete="off"
/>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{label("settings.openAppSecret")}</p>
<Input
value={appSecret}
onChange={(e) => setAppSecret(e.target.value)}
placeholder="AppSecret"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">{label("settings.secretPlainHint")}</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
{label("settings.openSource")}: {openNet.data?.source ?? "—"}
</span>
{openNet.data?.envConfigured && <span>{label("settings.envConfigured")}</span>}
</div>
<div className="flex gap-2">
<Button
size="sm"
disabled={saveOpenNet.isPending}
onClick={() =>
saveOpenNet.mutate(
{ appId: appId.trim(), appSecret },
{
onSuccess: () => toast.success(t(locale, "settings.openSaved")),
onError: () => toast.error(t(locale, "settings.openSaveFailed")),
},
)
}
>
{t(locale, "settings.save")}
</Button>
</div>
</Card>
);
}
export function SettingsAdminBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
const client = createTRPCReactClient();
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<Inner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}

View File

@ -1,6 +1,15 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import AuthView from "~/views/auth.astro";
import {
COOKIE_LANG,
isLocale,
localePath,
resolvePreferredLocale,
t,
type Locale,
} from "@app/i18n";
import AppLayout from "~/layouts/AppLayout.astro";
import AuthBody from "~/components/pages/AuthBody";
import { SettingsAdminBody } from "~/components/pages/SettingsAdminBody";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
@ -14,6 +23,16 @@ if (!normalized || !isLocale(normalized)) {
}
const locale: Locale = normalized;
const pageTitle = `${t(locale, "auth.settings.title")} · ${t(locale, "meta.brand")}`;
---
<AuthView locale={locale} mode="change" />
<AppLayout
title={pageTitle}
locale={locale}
seo={{ title: pageTitle, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }}
>
<div class="space-y-6">
<AuthBody client:load locale={locale} mode="change" />
<SettingsAdminBody locale={locale} client:load />
</div>
</AppLayout>

View File

@ -52,4 +52,6 @@ test("header 语言下拉显示当前语言并可切换", async ({ page }) => {
await expect(item).toBeVisible();
await item.click();
await expect(page).toHaveURL(/\/en$/);
const enTrigger = page.getByRole("button", { name: "Language" });
await expect(enTrigger).toContainText("English");
});

View File

@ -19,7 +19,18 @@ export function readOpenCredentials(env = process.env): OpenCredentials | null {
}
export function hasOpenCredentials(): boolean {
return readOpenCredentials() !== null;
return effectiveCredentials() !== null;
}
/** 运行时覆盖凭证(管理员在设置里配置);null 表示回退环境变量 */
let runtimeCredentials: OpenCredentials | null = null;
export function setOpenCredentials(creds: OpenCredentials | null): void {
runtimeCredentials = creds;
}
export function effectiveCredentials(): OpenCredentials | null {
return runtimeCredentials ?? readOpenCredentials();
}
export function openHeaders(path: string, creds: OpenCredentials | null): Record<string, string> {
@ -38,7 +49,7 @@ export async function openFetchJson(
path: string,
init?: { method?: "POST"; body?: string } | undefined,
): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> {
const creds = readOpenCredentials();
const creds = effectiveCredentials();
try {
const res = await fetch(`${openApiBase()}${path}`, {
method: init?.method ?? "GET",

View File

@ -15,6 +15,8 @@ export {
openMatch,
openSearchEpisodes,
readOpenCredentials,
setOpenCredentials,
effectiveCredentials,
} from "./client.js";
export type {
OpenAnimeBrief,

View File

@ -0,0 +1,41 @@
import { eq } from "drizzle-orm";
import { appSettings, type AppSettingRow } from "@app/models";
import { db } from "@app/db";
export const appSettingsDao = {
async get(key: string): Promise<AppSettingRow | null> {
const [row] = await db.select().from(appSettings).where(eq(appSettings.key, key)).limit(1);
return row ?? null;
},
async upsert(data: {
key: string;
value: string;
updatedBy?: string | null | undefined;
}): Promise<AppSettingRow> {
const existing = await appSettingsDao.get(data.key);
if (existing) {
const [row] = await db
.update(appSettings)
.set({
value: data.value,
updatedBy: data.updatedBy ?? null,
updatedAt: new Date(),
})
.where(eq(appSettings.id, existing.id))
.returning();
if (!row) throw new Error("Failed to update app setting");
return row;
}
const rows = await db
.insert(appSettings)
.values({
key: data.key,
value: data.value,
updatedBy: data.updatedBy ?? null,
})
.returning();
const row = rows[0];
if (!row) throw new Error("Failed to insert app setting");
return row;
},
};

View File

@ -13,3 +13,5 @@ export { playbackProgressDao } from "./playback-progress.js";
export { danmakuCacheDao } from "./danmaku-cache.js";
export { danmakuPrefsDao } from "./danmaku-prefs.js";
export { danmakuDocsDao } from "./danmaku-docs.js";
export { appSettingsDao } from "./app-settings.js";
export type { AppSettingRow } from "@app/models";

View File

@ -21,6 +21,11 @@ export const userDao = {
return { rows, total: Number(countRow?.["count"] ?? 0) };
},
async countAll(): Promise<number> {
const [countRow] = await db.select({ count: sql<number>`count(*)` }).from(users);
return Number(countRow?.["count"] ?? 0);
},
async getById(id: string): Promise<UserRow | null> {
const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1);
return row ?? null;
@ -49,6 +54,7 @@ export const userDao = {
id?: string;
username: string;
email?: string | null;
role?: string;
},
): Promise<UserRow> {
const payload = {

View File

@ -319,4 +319,16 @@ export const en: Record<MessageKey, string> = {
"error.internal.title": "Something broke",
"error.internal.desc":
"The server hit an unexpected error. Retry later and report the path if it persists.",
"settings.adminSection": "Open danmaku network",
"settings.adminOnly": "Admin only",
"settings.openNetworkHint": "Configure DanDanPlay open network AppId / AppSecret for online danmaku matching and fetch. Admin only.",
"settings.openAppId": "AppId",
"settings.openAppSecret": "AppSecret",
"settings.secretPlainHint": "Secret is stored in plaintext and visible to admins only.",
"settings.openSource": "Current source",
"settings.envConfigured": "Env configured",
"settings.openSaved": "Saved and applied",
"settings.openSaveFailed": "Save failed",
"settings.save": "Save",
};

View File

@ -310,6 +310,17 @@ export const zhCN = {
"error.internal.label": "系统异常",
"error.internal.title": "服务出错了",
"error.internal.desc": "服务器处理请求时发生意外。请稍后重试;若持续出现请反馈路径与时间。",
"settings.adminSection": "开放弹幕网络",
"settings.adminOnly": "仅管理员",
"settings.openNetworkHint": "配置弹弹play 开放网络 AppId / AppSecret,用于在线弹幕匹配与拉取。仅管理员可见可改。",
"settings.openAppId": "AppId",
"settings.openAppSecret": "AppSecret",
"settings.secretPlainHint": "Secret 以明文保存,仅管理员可查看与修改。",
"settings.openSource": "当前来源",
"settings.envConfigured": "环境变量已配置",
"settings.openSaved": "已保存并生效",
"settings.openSaveFailed": "保存失败",
"settings.save": "保存",
} as const;
export type MessageKey = keyof typeof zhCN;

View File

@ -0,0 +1,35 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { users } from "./users.js";
/**
* 应用级配置(跨用户共享)。敏感值存密文。
* 唯一键 `key`;由管理员经 API 写入,服务端启动/读取时解密。
*/
export const appSettings = sqliteTable(
"app_settings",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
key: text("key").notNull(),
/** 明文值;敏感项应写 encryptSecret() 密文 */
value: text("value").notNull().default(""),
/** 由谁改的(审计) */
updatedBy: text("updated_by").references(() => users.id, { onDelete: "set null" }),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(t) => [uniqueIndex("app_settings_key_uq").on(t.key)],
);
export type AppSettingRow = typeof appSettings.$inferSelect;
export type NewAppSettingRow = typeof appSettings.$inferInsert;
/** 开放弹幕网络配置键 */
export const APP_SETTING_OPEN_DANMAKU_APP_ID = "open_danmaku_app_id";
export const APP_SETTING_OPEN_DANMAKU_APP_SECRET = "open_danmaku_app_secret";

View File

@ -17,3 +17,5 @@ export { danmakuPrefs } from "./danmaku-prefs.js";
export type { DanmakuPrefsRow, NewDanmakuPrefsRow } from "./danmaku-prefs.js";
export { danmakuDocs } from "./danmaku-docs.js";
export type { DanmakuDocRow, NewDanmakuDocRow } from "./danmaku-docs.js";
export { appSettings, APP_SETTING_OPEN_DANMAKU_APP_ID, APP_SETTING_OPEN_DANMAKU_APP_SECRET } from "./app-settings.js";
export type { AppSettingRow, NewAppSettingRow } from "./app-settings.js";

View File

@ -17,6 +17,8 @@ export const users = sqliteTable("users", {
email: text("email").unique(),
/** scrypt 输出,格式 `salt:hash`(hex)。default "" 便于给已有行加列。 */
passwordHash: text("password_hash").notNull().default(""),
/** 角色:admin=管理员(可改系统配置)| user=普通用户 */
role: text("role").notNull().default("user"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
});

View File

@ -9,6 +9,7 @@ import { userService } from "../services/user.service.js";
import type { TrpcContext } from "../context.js";
import { authRouter } from "./auth.router.js";
import { mediaRouter } from "./media.router.js";
import { settingsRouter } from "./settings.router.js";
// ─── User router ────────────────────────────────────────────────────────
export const userRouter = t.router({
@ -36,6 +37,7 @@ export const appRouter = t.router({
user: userRouter,
auth: authRouter,
media: mediaRouter,
settings: settingsRouter,
});
// ─── Type re-exports ────────────────────────────────────────────────────

View File

@ -0,0 +1,14 @@
import { settingsSchemas } from "@app/types";
import { adminProcedure } from "../services/procedure.js";
import { settingsService } from "../services/settings.service.js";
import { t } from "../context.js";
export const settingsRouter = t.router({
/** 开放弹幕网络(仅管理员;secret 明文) */
getOpenNetwork: adminProcedure
.input(settingsSchemas.getOpenNetwork)
.query(() => settingsService.getOpenNetworkSettings()),
saveOpenNetwork: adminProcedure
.input(settingsSchemas.saveOpenNetwork)
.mutation(({ ctx, input }) => settingsService.saveOpenNetworkSettings(ctx.userId, input)),
});

View File

@ -54,7 +54,8 @@ export interface ResetPasswordInput {
}
function toAuthUser(row: UserRow): AuthUser {
return { id: row.id, username: row.username, name: row.name, email: row.email };
const role = row.role === "admin" ? "admin" : "user";
return { id: row.id, username: row.username, name: row.name, email: row.email, role };
}
function newToken(): string {
@ -140,11 +141,14 @@ export const authService = {
const emailTaken = await userDao.getByEmail(email);
if (emailTaken) throw new AuthError("EMAIL_TAKEN", "该邮箱已被注册");
}
// 首个注册用户成为管理员,便于初始化开放弹幕等系统配置
const userCount = await userDao.countAll();
const user = await userDao.create({
username,
name: input.name?.trim() || username,
email: email ?? null,
passwordHash: hashPassword(input.password),
role: userCount === 0 ? "admin" : "user",
});
await createSession(user.id, scope.setCookie);
return { user: toAuthUser(user) };

View File

@ -11,6 +11,11 @@ import type {
import { danmakuService } from "./danmaku.service.js";
import { createWebdav } from "./webdav-client.js";
vi.mock("./settings.service.js", () => ({
ensureOpenRuntime: vi.fn(async () => {}),
settingsService: {},
}));
vi.mock("@app/dao", () => ({
danmakuCacheDao: { getValid: vi.fn(), upsert: vi.fn() },
danmakuDocsDao: { get: vi.fn(), upsert: vi.fn(), remove: vi.fn() },

View File

@ -20,6 +20,7 @@ import type {
DanmakuSourceSearchOutput,
} from "@app/types";
import { decryptSecret } from "./secret.js";
import { ensureOpenRuntime } from "./settings.service.js";
import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@ -136,6 +137,7 @@ export const danmakuService = {
},
async fetch(userId: string, mediaItemId: string): Promise<DanmakuFetchOutput> {
await ensureOpenRuntime();
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
@ -219,6 +221,7 @@ export const danmakuService = {
},
async matchCandidates(userId: string, mediaItemId: string): Promise<DanmakuMatchOutput> {
await ensureOpenRuntime();
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!item) {
return {
@ -261,6 +264,7 @@ export const danmakuService = {
mediaItemId: string,
episodeId: number,
): Promise<DanmakuFetchOutput> {
await ensureOpenRuntime();
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" };
await mediaItemDao.setDanmakuMatch(mediaItemId, userId, {
@ -305,6 +309,7 @@ export const danmakuService = {
/** 手动指定弹幕源:搜作品 */
async searchSource(keyword: string): Promise<DanmakuSourceSearchOutput> {
await ensureOpenRuntime();
if (!hasOpenCredentials()) {
return { ok: false, message: "未配置开放弹幕网络", animes: [] };
}
@ -326,6 +331,7 @@ export const danmakuService = {
/** 手动指定弹幕源:列作品集数 */
async sourceEpisodes(animeId: number): Promise<DanmakuSourceEpisodesOutput> {
await ensureOpenRuntime();
if (!hasOpenCredentials()) {
return {
ok: false,

View File

@ -19,3 +19,12 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
},
});
});
/** 需要管理员角色;在 protectedProcedure 基础上校验 role。 */
export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
const user = await authService.getSessionUser(ctx.sessionToken);
if (!user || user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "需要管理员权限" });
}
return next({ ctx: { ...ctx, role: user.role as "admin" } });
});

View File

@ -0,0 +1,88 @@
import {
APP_SETTING_OPEN_DANMAKU_APP_ID,
APP_SETTING_OPEN_DANMAKU_APP_SECRET,
} from "@app/models";
import { appSettingsDao } from "@app/dao";
import type { OpenNetworkSettingsOutput, SaveOpenNetworkSettingsInput } from "@app/types";
import { setOpenCredentials, type OpenCredentials } from "@app/danmaku";
function envCredentials(): OpenCredentials | null {
const appId = process.env["OPEN_DANMAKU_APP_ID"];
const appSecret = process.env["OPEN_DANMAKU_APP_SECRET"];
if (!appId || !appSecret) return null;
return { appId, appSecret };
}
/** 读开放弹幕网络配置(管理员可见明文) */
export async function getOpenNetworkSettings(): Promise<OpenNetworkSettingsOutput> {
const appIdRow = await appSettingsDao.get(APP_SETTING_OPEN_DANMAKU_APP_ID);
const secretRow = await appSettingsDao.get(APP_SETTING_OPEN_DANMAKU_APP_SECRET);
const appId = appIdRow?.value ?? "";
const appSecret = secretRow?.value ?? "";
const env = envCredentials();
if (appId && appSecret) {
return { appId, appSecret, envConfigured: env != null, source: "db" };
}
if (env) {
return {
appId: env.appId,
appSecret: env.appSecret,
envConfigured: true,
source: "env",
};
}
return { appId, appSecret, envConfigured: false, source: "none" };
}
/** 保存开放弹幕网络配置(明文)并热更新运行时凭证 */
export async function saveOpenNetworkSettings(
userId: string,
input: SaveOpenNetworkSettingsInput,
): Promise<OpenNetworkSettingsOutput> {
await appSettingsDao.upsert({
key: APP_SETTING_OPEN_DANMAKU_APP_ID,
value: input.appId.trim(),
updatedBy: userId,
});
await appSettingsDao.upsert({
key: APP_SETTING_OPEN_DANMAKU_APP_SECRET,
value: input.appSecret,
updatedBy: userId,
});
await applyRuntimeOpenCredentials();
runtimeApplied = true;
return getOpenNetworkSettings();
}
/** 启动或保存后:把库内凭证注入 @app/danmaku(无则回退 env) */
export async function applyRuntimeOpenCredentials(): Promise<void> {
const appIdRow = await appSettingsDao.get(APP_SETTING_OPEN_DANMAKU_APP_ID);
const secretRow = await appSettingsDao.get(APP_SETTING_OPEN_DANMAKU_APP_SECRET);
const appId = appIdRow?.value.trim() ?? "";
const appSecret = secretRow?.value ?? "";
if (appId && appSecret) {
setOpenCredentials({ appId, appSecret });
return;
}
setOpenCredentials(null);
}
let runtimeApplied = false;
/** 首次使用前注入库内凭证(幂等;失败不阻断业务) */
export async function ensureOpenRuntime(): Promise<void> {
if (runtimeApplied) return;
try {
await applyRuntimeOpenCredentials();
} catch {
// 表未迁移或 DB 不可用时回退 env
}
runtimeApplied = true;
}
export const settingsService = {
getOpenNetworkSettings,
saveOpenNetworkSettings,
applyRuntimeOpenCredentials,
ensureOpenRuntime,
};

View File

@ -47,6 +47,7 @@ function toPublicUser(row: UserRow): PublicUser {
username: row.username,
name: row.name,
email: row.email,
role: row.role,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@ -78,6 +79,7 @@ export const userService = {
name: input.name?.trim() || username,
email: input.email ?? null,
passwordHash: hashPassword(input.password),
role: "user",
});
return toPublicUser(row);
},

View File

@ -102,6 +102,8 @@ export type User = {
username: string;
name: string;
email: string | null;
/** admin | user */
role: string;
createdAt: Date | null;
updatedAt: Date | null;
};
@ -112,6 +114,8 @@ export type AuthUser = {
username: string;
name: string;
email: string | null;
/** admin | user — 前端据此做权限可见性 */
role: "admin" | "user";
};
export type AuthSessionOutput = {
@ -133,3 +137,28 @@ export type UserListOutput = {
limit: number;
offset: number;
};
// ─── Admin / system settings ────────────────────────────────────────────────
export const settingsSchemas = {
/** 开放弹幕网络(管理员,明文) */
saveOpenNetwork: z.object({
appId: z.string().max(200).default(""),
appSecret: z.string().max(500).default(""),
}),
getOpenNetwork: z.object({}).optional(),
};
/** 开放弹幕网络配置(管理员)。secret 明文仅供管理员读改。 */
export type OpenNetworkSettingsOutput = {
appId: string;
appSecret: string;
/** env 是否提供了兜底凭证 */
envConfigured: boolean;
source: "db" | "env" | "none";
};
export type SaveOpenNetworkSettingsInput = {
appId: string;
appSecret: string;
};

View File

@ -20,7 +20,8 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
reporter: [["list"]],
use: {
baseURL: "http://localhost:4321",
// 本机 4321 可能被 Docker(dandanplay-web)占用而复用到旧镜像,可用 E2E_BASE_URL 指向本地 dev
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:4321",
trace: "on-first-retry",
locale: "en-US",
acceptLanguage: "en-US",
@ -33,7 +34,7 @@ export default defineConfig({
],
webServer: {
command: "yarn dev:web",
url: "http://localhost:4321",
url: process.env.E2E_BASE_URL ?? "http://localhost:4321",
reuseExistingServer: !process.env.CI,
timeout: 300_000,
},