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 {
import AuthView from "~/views/auth.astro"; 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 raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw; const normalized = raw === "zh-cn" ? "zh-CN" : raw;
@ -14,6 +23,16 @@ if (!normalized || !isLocale(normalized)) {
} }
const locale: Locale = 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 expect(item).toBeVisible();
await item.click(); await item.click();
await expect(page).toHaveURL(/\/en$/); 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 { 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> { export function openHeaders(path: string, creds: OpenCredentials | null): Record<string, string> {
@ -38,7 +49,7 @@ export async function openFetchJson(
path: string, path: string,
init?: { method?: "POST"; body?: string } | undefined, init?: { method?: "POST"; body?: string } | undefined,
): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> { ): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> {
const creds = readOpenCredentials(); const creds = effectiveCredentials();
try { try {
const res = await fetch(`${openApiBase()}${path}`, { const res = await fetch(`${openApiBase()}${path}`, {
method: init?.method ?? "GET", method: init?.method ?? "GET",

View File

@ -15,6 +15,8 @@ export {
openMatch, openMatch,
openSearchEpisodes, openSearchEpisodes,
readOpenCredentials, readOpenCredentials,
setOpenCredentials,
effectiveCredentials,
} from "./client.js"; } from "./client.js";
export type { export type {
OpenAnimeBrief, 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 { danmakuCacheDao } from "./danmaku-cache.js";
export { danmakuPrefsDao } from "./danmaku-prefs.js"; export { danmakuPrefsDao } from "./danmaku-prefs.js";
export { danmakuDocsDao } from "./danmaku-docs.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) }; 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> { async getById(id: string): Promise<UserRow | null> {
const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1); const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1);
return row ?? null; return row ?? null;
@ -49,6 +54,7 @@ export const userDao = {
id?: string; id?: string;
username: string; username: string;
email?: string | null; email?: string | null;
role?: string;
}, },
): Promise<UserRow> { ): Promise<UserRow> {
const payload = { const payload = {

View File

@ -319,4 +319,16 @@ export const en: Record<MessageKey, string> = {
"error.internal.title": "Something broke", "error.internal.title": "Something broke",
"error.internal.desc": "error.internal.desc":
"The server hit an unexpected error. Retry later and report the path if it persists.", "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.label": "系统异常",
"error.internal.title": "服务出错了", "error.internal.title": "服务出错了",
"error.internal.desc": "服务器处理请求时发生意外。请稍后重试;若持续出现请反馈路径与时间。", "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; } as const;
export type MessageKey = keyof typeof zhCN; 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 type { DanmakuPrefsRow, NewDanmakuPrefsRow } from "./danmaku-prefs.js";
export { danmakuDocs } from "./danmaku-docs.js"; export { danmakuDocs } from "./danmaku-docs.js";
export type { DanmakuDocRow, NewDanmakuDocRow } 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(), email: text("email").unique(),
/** scrypt 输出,格式 `salt:hash`(hex)。default "" 便于给已有行加列。 */ /** scrypt 输出,格式 `salt:hash`(hex)。default "" 便于给已有行加列。 */
passwordHash: text("password_hash").notNull().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())`), createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_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 type { TrpcContext } from "../context.js";
import { authRouter } from "./auth.router.js"; import { authRouter } from "./auth.router.js";
import { mediaRouter } from "./media.router.js"; import { mediaRouter } from "./media.router.js";
import { settingsRouter } from "./settings.router.js";
// ─── User router ──────────────────────────────────────────────────────── // ─── User router ────────────────────────────────────────────────────────
export const userRouter = t.router({ export const userRouter = t.router({
@ -36,6 +37,7 @@ export const appRouter = t.router({
user: userRouter, user: userRouter,
auth: authRouter, auth: authRouter,
media: mediaRouter, media: mediaRouter,
settings: settingsRouter,
}); });
// ─── Type re-exports ──────────────────────────────────────────────────── // ─── 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 { 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 { function newToken(): string {
@ -140,11 +141,14 @@ export const authService = {
const emailTaken = await userDao.getByEmail(email); const emailTaken = await userDao.getByEmail(email);
if (emailTaken) throw new AuthError("EMAIL_TAKEN", "该邮箱已被注册"); if (emailTaken) throw new AuthError("EMAIL_TAKEN", "该邮箱已被注册");
} }
// 首个注册用户成为管理员,便于初始化开放弹幕等系统配置
const userCount = await userDao.countAll();
const user = await userDao.create({ const user = await userDao.create({
username, username,
name: input.name?.trim() || username, name: input.name?.trim() || username,
email: email ?? null, email: email ?? null,
passwordHash: hashPassword(input.password), passwordHash: hashPassword(input.password),
role: userCount === 0 ? "admin" : "user",
}); });
await createSession(user.id, scope.setCookie); await createSession(user.id, scope.setCookie);
return { user: toAuthUser(user) }; return { user: toAuthUser(user) };

View File

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

View File

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

View File

@ -102,6 +102,8 @@ export type User = {
username: string; username: string;
name: string; name: string;
email: string | null; email: string | null;
/** admin | user */
role: string;
createdAt: Date | null; createdAt: Date | null;
updatedAt: Date | null; updatedAt: Date | null;
}; };
@ -112,6 +114,8 @@ export type AuthUser = {
username: string; username: string;
name: string; name: string;
email: string | null; email: string | null;
/** admin | user — 前端据此做权限可见性 */
role: "admin" | "user";
}; };
export type AuthSessionOutput = { export type AuthSessionOutput = {
@ -133,3 +137,28 @@ export type UserListOutput = {
limit: number; limit: number;
offset: 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, retries: process.env.CI ? 2 : 0,
reporter: [["list"]], reporter: [["list"]],
use: { 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", trace: "on-first-retry",
locale: "en-US", locale: "en-US",
acceptLanguage: "en-US", acceptLanguage: "en-US",
@ -33,7 +34,7 @@ export default defineConfig({
], ],
webServer: { webServer: {
command: "yarn dev:web", command: "yarn dev:web",
url: "http://localhost:4321", url: process.env.E2E_BASE_URL ?? "http://localhost:4321",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 300_000, timeout: 300_000,
}, },