feat(sources): site center backend (credentials, refresh login, user data, health check, delete)

PUT /sites/{id}/credentials by kind (moved from auth-settings, legacy body
accepted), POST refresh-login (async task or wait), POST health-check,
GET user-data history, DELETE /sites/{id} (soft delete, releases kept),
DELETE /site-accounts/{id}, GET /sites?include=accounts; refreshSiteAccount
task with login-failure decisions and auto re-login on auth_expired;
scheduled jobs sites.user_data (6h) and sites.health_check (1h);
settings.sites defaults for rate limit, snapshot interval, UA and proxy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:18:40 +08:00
1 parent d6fca25679
commit 05a7706dde
15 files changed
+1602 -179

No files matched your search

@@ -18,10 +18,7 @@ import {
PathMappingCreate,
PathMappingUpdate,
type SecretPurpose,
SiteCredentialsPut,
SiteCredentialsResult,
} from "@umt/contracts";
import type { DB } from "@umt/db";
import { z } from "zod";
import { ApiException } from "../../http/errors.ts";
import { redact, redactString } from "../../logger.ts";
@@ -41,7 +38,7 @@ import {
} from "./endpoint.ts";
// 实例与路径:library_root / downloader_instance / media_server_instance / path_mapping 的 CRUD + 连通性测试,
// 以及站点账号凭证录入 PUT /sites/{id}/credentials。凭证明文只在请求里出现,存为 secret 后只回 secretRef。
// 站点账号凭证录入已迁到 sources-identity(PUT /sites/{id}/credentials)。
const OkResult = z.object({ ok: z.literal(true) });
const del = (path: string, summary: string, tag: string) => ({
@@ -470,97 +467,5 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
await audit(ctx, actor, "path_mapping.verify", { type: "path_mapping", id: row.id }, { ok });
return toApi(updated);
});
// ── 站点账号凭证 ──
const PURPOSE: Record<SiteCredentialsPut["authKind"], SecretPurpose> = {
cookie: "site_cookie",
passkey: "site_passkey",
api_key: "site_api_key",
password: "site_password",
};
endpoint(
app,
{
method: "put",
path: "/sites/{id}/credentials",
scope: "admin",
summary: "录入站点账号凭证(cookie/passkey/apiKey/密码 → secret_ref,不回显)",
params: IdParam,
body: SiteCredentialsPut,
response: SiteCredentialsResult,
tags: ["sites"],
},
async ({ params, body, actor }) => {
const site = await db().selectFrom("site").select("id").where("id", "=", params.id).executeTakeFirst();
if (!site) throw new ApiException("not_found", "站点不存在");
const newRef = await ctx.secrets.create({ purpose: PURPOSE[body.authKind], plaintext: body.credential });
let oldRef: string | null = null;
let created = false;
let accountId: string;
try {
accountId = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("site_account")
.select(["id", "secret_ref", "user_info"])
.where("site_id", "=", params.id)
.where("label", "=", body.label)
.forUpdate()
.executeTakeFirst();
const userInfo = {
...((cur?.user_info as Record<string, unknown> | undefined) ?? {}),
...(body.username ? { username: body.username } : {}),
};
const common: Partial<Record<keyof DB["site_account"], unknown>> = {
auth_kind: body.authKind,
secret_ref: newRef,
status: "unknown",
user_info: JSON.stringify(userInfo),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
};
if (cur) {
oldRef = cur.secret_ref;
await trx
.updateTable("site_account")
.set(common as never)
.where("id", "=", cur.id)
.execute();
return cur.id;
}
created = true;
const r = await trx
.insertInto("site_account")
.values({ site_id: params.id, label: body.label, ...common } as never)
.returning("id")
.executeTakeFirstOrThrow();
return r.id;
});
} catch (e) {
await ctx.secrets.remove(newRef).catch(() => {});
throw e;
}
if (oldRef) await ctx.secrets.remove(oldRef).catch(() => {});
await audit(
ctx,
actor,
"site_account.credentials_set",
{ type: "site_account", id: accountId },
{
siteId: params.id,
label: body.label,
authKind: body.authKind,
created,
},
);
return {
siteAccountId: accountId,
siteId: params.id,
label: body.label,
authKind: body.authKind,
secretRef: newRef,
created,
};
},
);
// 站点账号凭证 PUT /sites/{id}/credentials 已迁到 sources-identity(W9-A 站点中心,按 kind 判别)
}