From 92434aec174fb8eff9d3f8aa35230bde795c030a Mon Sep 17 00:00:00 2001 From: suyx Date: Wed, 5 Aug 2026 14:53:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(POSTV1-03):=20=E5=A2=9E=E5=8A=A0=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=E6=B5=8B=E8=AF=95=E7=9B=B4=E8=BE=BE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/app.ts | 40 ++++++++ apps/api/src/main.ts | 3 + apps/api/src/registration.ts | 55 +++++++++++ apps/api/src/supervisor-channel.ts | 3 +- apps/web/src/user-auth.css | 19 ++++ apps/web/src/user-auth.tsx | 44 +++++++++ tests/api/postv1-local-test-session.test.ts | 95 +++++++++++++++++++ .../wp7-04-amap-production-adapter.test.ts | 17 +++- tests/e2e/user-registration.spec.ts | 23 +++++ 9 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 tests/api/postv1-local-test-session.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9f350aa..9bf977f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -263,6 +263,7 @@ export interface CreateAppOptions { eventHub?: EventHub; generations?: GenerationSubmissionService; latestExports?: LatestExportService; + localTestAuth?: boolean; models?: ModelConfigurationService; networkBoundary?: NetworkBoundaryOptions; publicAssets?: PublicAssetResolver; @@ -2075,6 +2076,45 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + if (options.localTestAuth && options.registration) { + app.get( + "/api/v1/auth/local-test", + { schema: { hide: true } }, + async () => ({ available: true }), + ); + app.post( + "/api/v1/auth/local-test", + { schema: { hide: true } }, + async (request, reply) => { + try { + const result = options.registration!.createLocalTestSession(); + reply.header( + "Set-Cookie", + `${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + return { + audience: result.audience, + credits: { + available_balance: result.credits.availableBalance, + reserved_balance: result.credits.reservedBalance, + }, + session_expires_at: new Date(result.sessionExpiresAt).toISOString(), + status: result.status, + user: { + creator_name: result.user.creatorName, + role: result.user.role, + social_id: result.user.socialId, + status: result.user.status, + user_id: result.user.userId, + }, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + } + app.post( "/api/v1/auth/login/complete", { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 63add52..4f3ca41 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -31,11 +31,13 @@ let models: ModelConfigurationService | undefined; let recentAssets: RecentAssetService | undefined; let stickers: StickerReleaseService | undefined; let amap: AmapAdapter = new MockAmapAdapter(); +let localTestAuth = false; const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); if (credentialChannelEnabled) { const clients = initializeApiCredentialClients(await receiveApiCredentials()); try { amap = clients.amap; + localTestAuth = !clients.resendConfigured; const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper) .update(`Dada/P0A/${purpose}/v1`, "utf8") .digest(); @@ -101,6 +103,7 @@ const app = await createApp({ ...(browserSupportRelease ? { browserSupportRelease } : {}), ...(credits ? { credits } : {}), ...(latestExports ? { latestExports } : {}), + ...(registration && localTestAuth ? { localTestAuth: true } : {}), ...(models ? { models } : {}), ...(projects ? { projects } : {}), ...(registration ? { registration } : {}), diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts index 73c3540..d44cb9a 100644 --- a/apps/api/src/registration.ts +++ b/apps/api/src/registration.ts @@ -38,6 +38,7 @@ export interface RegistrationTransactionEvent { | "registration_send" | "registration_complete" | "registration_send_compensation" + | "local_test_session" | "login_send" | "login_complete" | "admin_login_send" @@ -654,6 +655,60 @@ export class RegistrationService { return outcome; } + createLocalTestSession(): LoginCompleteResult { + const now = this.options.clock(); + return this.runImmediate("local_test_session", () => { + const registrationId = "local-test-user-v1"; + const existing = this.database.prepare(` + SELECT user_id, role, status FROM users WHERE registration_id = ? + `).get(registrationId) as { + role: "user" | "super_admin"; + status: "active" | "suspended" | "deleted"; + user_id: string; + } | undefined; + + if (existing) { + if (existing.role !== "user" || existing.status !== "active") { + throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended"); + } + const session = this.insertSession(existing.user_id, "user", now); + return { + outcome: "committed", + value: this.loginResult(this.readCompletedRegistration(existing.user_id, session.sessionId)), + }; + } + + const userId = randomUUID(); + this.database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, 'local-test-user@dada.invalid', 'user', 'active', 0, ?, ?) + `).run(userId, registrationId, now); + this.database.prepare(` + INSERT INTO user_profiles ( + user_id, creator_name, social_id, private_content_notice_version, + private_content_notice_acknowledged_at + ) VALUES (?, '本机测试用户', '@dada_local_test', NULL, NULL) + `).run(userId); + this.database.prepare(` + INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) + VALUES (?, 10, 0, ?) + `).run(userId, now); + this.database.prepare(` + INSERT INTO credit_ledger ( + ledger_id, user_id, operation_key, entry_type, amount, + available_before, available_after, reserved_before, reserved_after, created_at + ) VALUES (?, ?, 'local-test-registration:v1', 'registration_grant', 10, 0, 10, 0, 0, ?) + `).run(randomUUID(), userId, now); + const session = this.insertSession(userId, "user", now); + return { + outcome: "committed", + value: this.loginResult(this.readCompletedRegistration(userId, session.sessionId)), + }; + }); + } + applySecureConfig(candidate: SecureConfigCandidate) { const now = this.options.clock(); const fail = (reason: string): never => { diff --git a/apps/api/src/supervisor-channel.ts b/apps/api/src/supervisor-channel.ts index 9a109b1..69ffd4d 100644 --- a/apps/api/src/supervisor-channel.ts +++ b/apps/api/src/supervisor-channel.ts @@ -27,12 +27,13 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce } export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) { - try { + try { const adminPepper = credentials["Dada/P0A/admin/pepper"]; if (!adminPepper) throw new Error("admin_pepper_not_configured"); return { adminAllowlistPepper: Buffer.from(adminPepper, "utf8"), amap: credentials["Dada/P0A/api/amap"] ? new RealAmapAdapter(credentials["Dada/P0A/api/amap"]) : new MockAmapAdapter(), + resendConfigured: Boolean(credentials["Dada/P0A/api/resend"]), }; } finally { for (const name of API_CREDENTIALS) credentials[name] = ""; diff --git a/apps/web/src/user-auth.css b/apps/web/src/user-auth.css index 05c3f29..e4e11d2 100644 --- a/apps/web/src/user-auth.css +++ b/apps/web/src/user-auth.css @@ -124,6 +124,25 @@ button { margin-inline: auto; } +.auth-test-entry { + margin-bottom: 18px; + border-bottom: 1px solid #b4b4af; + padding-bottom: 18px; +} + +.auth-test-entry .auth-primary { + margin-top: 0; + border-color: #111111; + background: #111111; + color: #f2f500; +} + +.auth-test-entry .auth-primary:disabled { + border-color: #777773; + background: #deded9; + color: #777773; +} + .auth-tabs { display: grid; grid-template-columns: 1fr 1fr; diff --git a/apps/web/src/user-auth.tsx b/apps/web/src/user-auth.tsx index d9fd197..cbceb97 100644 --- a/apps/web/src/user-auth.tsx +++ b/apps/web/src/user-auth.tsx @@ -77,6 +77,9 @@ export function UserAuthPage() { const [sendState, setSendState] = useState("idle"); const [countdown, setCountdown] = useState(0); const [error, setError] = useState(); + const [localTestAvailable, setLocalTestAvailable] = useState(false); + const [localTestError, setLocalTestError] = useState(); + const [localTestSubmitting, setLocalTestSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false); const emailValid = /^[^@\s]+@[^@\s]+$/.test(email); const registrationReady = Boolean( @@ -93,6 +96,18 @@ export function UserAuthPage() { return () => window.clearInterval(timer); }, [countdown]); + useEffect(() => { + const controller = new AbortController(); + void fetch("/api/v1/auth/local-test", { credentials: "same-origin", signal: controller.signal }) + .then(async (response) => { + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return; + const body = await response.json() as { available?: boolean }; + if (body.available === true) setLocalTestAvailable(true); + }) + .catch(() => undefined); + return () => controller.abort(); + }, []); + useEffect(() => { if (!noticeOpen) return; const previousOverflow = document.body.style.overflow; @@ -255,6 +270,27 @@ export function UserAuthPage() { } } + async function enterLocalTest() { + if (localTestSubmitting) return; + setLocalTestSubmitting(true); + setLocalTestError(undefined); + try { + const response = await fetch("/api/v1/auth/local-test", { + credentials: "same-origin", + method: "POST", + }); + if (!response.ok) { + setLocalTestError("本机测试会话未能建立,请重试。"); + return; + } + window.location.assign("/app"); + } catch { + setLocalTestError("本机测试会话未能建立,请重试。"); + } finally { + setLocalTestSubmitting(false); + } + } + return ( <>
@@ -271,6 +307,14 @@ export function UserAuthPage() {
管理员登录
+ {localTestAvailable ? ( +
+ + {localTestError ?

{localTestError}

: null} +
+ ) : null}