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:
parent
7fe1236d0d
commit
2e405cd974
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export {
|
|||
openMatch,
|
||||
openSearchEpisodes,
|
||||
readOpenCredentials,
|
||||
setOpenCredentials,
|
||||
effectiveCredentials,
|
||||
} from "./client.js";
|
||||
export type {
|
||||
OpenAnimeBrief,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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())`),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
});
|
||||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue