diff --git a/app/web/src/components/pages/SettingsAdminBody.tsx b/app/web/src/components/pages/SettingsAdminBody.tsx
new file mode 100644
index 0000000..71de85a
--- /dev/null
+++ b/app/web/src/components/pages/SettingsAdminBody.tsx
@@ -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
{t(locale, "watch.loading")}
;
+ }
+ if (!isAdmin) {
+ return null;
+ }
+
+ const label = (key: MessageKey) => t(locale, key);
+
+ return (
+
+
+
{label("settings.adminSection")}
+
+ {label("settings.adminOnly")}
+
+
+ {label("settings.openNetworkHint")}
+
+
+
{label("settings.openAppId")}
+
setAppId(e.target.value)}
+ placeholder="AppId"
+ autoComplete="off"
+ />
+
+
+
{label("settings.openAppSecret")}
+
setAppSecret(e.target.value)}
+ placeholder="AppSecret"
+ autoComplete="off"
+ />
+
{label("settings.secretPlainHint")}
+
+
+
+
+ {label("settings.openSource")}: {openNet.data?.source ?? "—"}
+
+ {openNet.data?.envConfigured && {label("settings.envConfigured")}}
+
+
+
+
+
+
+ );
+}
+
+export function SettingsAdminBody(props: { locale: Locale }) {
+ const [qc] = useState(() => new QueryClient());
+ const client = createTRPCReactClient();
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/web/src/pages/[locale]/settings.astro b/app/web/src/pages/[locale]/settings.astro
index bf4b041..7a3597f 100644
--- a/app/web/src/pages/[locale]/settings.astro
+++ b/app/web/src/pages/[locale]/settings.astro
@@ -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")}`;
---
-
+
+
+
diff --git a/e2e/i18n.spec.ts b/e2e/i18n.spec.ts
index 6fe7c07..1404e7f 100644
--- a/e2e/i18n.spec.ts
+++ b/e2e/i18n.spec.ts
@@ -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");
});
diff --git a/packages/danmaku/src/client.ts b/packages/danmaku/src/client.ts
index 0f1b47b..5bc6b63 100644
--- a/packages/danmaku/src/client.ts
+++ b/packages/danmaku/src/client.ts
@@ -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 {
@@ -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",
diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts
index 91ffd31..bf69249 100644
--- a/packages/danmaku/src/index.ts
+++ b/packages/danmaku/src/index.ts
@@ -15,6 +15,8 @@ export {
openMatch,
openSearchEpisodes,
readOpenCredentials,
+ setOpenCredentials,
+ effectiveCredentials,
} from "./client.js";
export type {
OpenAnimeBrief,
diff --git a/packages/dao/src/app-settings.ts b/packages/dao/src/app-settings.ts
new file mode 100644
index 0000000..ded3258
--- /dev/null
+++ b/packages/dao/src/app-settings.ts
@@ -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 {
+ 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 {
+ 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;
+ },
+};
diff --git a/packages/dao/src/index.ts b/packages/dao/src/index.ts
index a537aaf..8a240f3 100644
--- a/packages/dao/src/index.ts
+++ b/packages/dao/src/index.ts
@@ -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";
diff --git a/packages/dao/src/users.ts b/packages/dao/src/users.ts
index bb19fcb..31ae477 100644
--- a/packages/dao/src/users.ts
+++ b/packages/dao/src/users.ts
@@ -21,6 +21,11 @@ export const userDao = {
return { rows, total: Number(countRow?.["count"] ?? 0) };
},
+ async countAll(): Promise {
+ const [countRow] = await db.select({ count: sql`count(*)` }).from(users);
+ return Number(countRow?.["count"] ?? 0);
+ },
+
async getById(id: string): Promise {
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 {
const payload = {
diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts
index 89c698e..0fe52c1 100644
--- a/packages/i18n/src/messages/en.ts
+++ b/packages/i18n/src/messages/en.ts
@@ -319,4 +319,16 @@ export const en: Record = {
"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",
};
diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts
index c0ebf41..7e0340e 100644
--- a/packages/i18n/src/messages/zh-CN.ts
+++ b/packages/i18n/src/messages/zh-CN.ts
@@ -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;
diff --git a/packages/models/src/app-settings.ts b/packages/models/src/app-settings.ts
new file mode 100644
index 0000000..a2fd894
--- /dev/null
+++ b/packages/models/src/app-settings.ts
@@ -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";
diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts
index 91cbf33..98117b2 100644
--- a/packages/models/src/index.ts
+++ b/packages/models/src/index.ts
@@ -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";
diff --git a/packages/models/src/users.ts b/packages/models/src/users.ts
index 03b049e..0260eff 100644
--- a/packages/models/src/users.ts
+++ b/packages/models/src/users.ts
@@ -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())`),
});
diff --git a/packages/trpc/src/router/index.ts b/packages/trpc/src/router/index.ts
index c9197fb..dea4646 100644
--- a/packages/trpc/src/router/index.ts
+++ b/packages/trpc/src/router/index.ts
@@ -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 ────────────────────────────────────────────────────
diff --git a/packages/trpc/src/router/settings.router.ts b/packages/trpc/src/router/settings.router.ts
new file mode 100644
index 0000000..b9990b2
--- /dev/null
+++ b/packages/trpc/src/router/settings.router.ts
@@ -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)),
+});
diff --git a/packages/trpc/src/services/auth.service.ts b/packages/trpc/src/services/auth.service.ts
index c4efbd8..eb2d269 100644
--- a/packages/trpc/src/services/auth.service.ts
+++ b/packages/trpc/src/services/auth.service.ts
@@ -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) };
diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts
index 453d077..3d96616 100644
--- a/packages/trpc/src/services/danmaku.service.test.ts
+++ b/packages/trpc/src/services/danmaku.service.test.ts
@@ -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() },
diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts
index 324971e..4f695c6 100644
--- a/packages/trpc/src/services/danmaku.service.ts
+++ b/packages/trpc/src/services/danmaku.service.ts
@@ -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 {
+ 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 {
+ await ensureOpenRuntime();
const item = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!item) {
return {
@@ -261,6 +264,7 @@ export const danmakuService = {
mediaItemId: string,
episodeId: number,
): Promise {
+ 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 {
+ await ensureOpenRuntime();
if (!hasOpenCredentials()) {
return { ok: false, message: "未配置开放弹幕网络", animes: [] };
}
@@ -326,6 +331,7 @@ export const danmakuService = {
/** 手动指定弹幕源:列作品集数 */
async sourceEpisodes(animeId: number): Promise {
+ await ensureOpenRuntime();
if (!hasOpenCredentials()) {
return {
ok: false,
diff --git a/packages/trpc/src/services/procedure.ts b/packages/trpc/src/services/procedure.ts
index 8e54945..8514990 100644
--- a/packages/trpc/src/services/procedure.ts
+++ b/packages/trpc/src/services/procedure.ts
@@ -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" } });
+});
diff --git a/packages/trpc/src/services/settings.service.ts b/packages/trpc/src/services/settings.service.ts
new file mode 100644
index 0000000..891b4d4
--- /dev/null
+++ b/packages/trpc/src/services/settings.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ if (runtimeApplied) return;
+ try {
+ await applyRuntimeOpenCredentials();
+ } catch {
+ // 表未迁移或 DB 不可用时回退 env
+ }
+ runtimeApplied = true;
+}
+
+export const settingsService = {
+ getOpenNetworkSettings,
+ saveOpenNetworkSettings,
+ applyRuntimeOpenCredentials,
+ ensureOpenRuntime,
+};
diff --git a/packages/trpc/src/services/user.service.ts b/packages/trpc/src/services/user.service.ts
index bde5a38..fd724e4 100644
--- a/packages/trpc/src/services/user.service.ts
+++ b/packages/trpc/src/services/user.service.ts
@@ -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);
},
diff --git a/packages/types/src/schemas.ts b/packages/types/src/schemas.ts
index 8af1c45..6a9da4a 100644
--- a/packages/types/src/schemas.ts
+++ b/packages/types/src/schemas.ts
@@ -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;
+};
diff --git a/playwright.config.ts b/playwright.config.ts
index a2dcb1c..07be8a0 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -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,
},