refactor(api): TOML 配置 SSOT、统一错误契约、Auth/事务加固与可观测性 (#33)

配置 SSOT(TOML + .env)
统一错误契约
Auth 与事务边界
Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client
可观测性(OpenTelemetry + LGTM)
This commit is contained in:
Sully
2026-05-22 13:44:50 +08:00
committed by GitHub
parent f09ae248f9
commit 53e0065e3e
298 changed files with 15247 additions and 4344 deletions

View File

@@ -12,7 +12,7 @@
# 变量在构建时注入;修改后需重新 prebuild/打包客户端。
#
# 助手朗读:无独立 EXPO_PUBLIC_* TTS 开关。会话页顶栏在每轮 WebSocket 中带 `tts_this_turn`
# 服务端是否具备合成能力见 api/.env 中 ENABLE_TTS 等(模板见 api/.env.example
# 服务端是否具备合成能力见 api/config/*.toml 中 [deploy] enable_tts密钥见 api/.env.example
# --- development本地关于页显示版本 + API---
# APP_VARIANT=development

View File

@@ -54,12 +54,8 @@ export default function LoginScreen() {
return () => clearInterval(id);
}, [countdown]);
const handleGetCode = useCallback(() => {
if (!termsAccepted) {
setShowTermsDialog(true);
return;
}
if (!canGetCode) return;
const requestSmsCode = useCallback(() => {
if (phone.length !== PHONE_LENGTH || countdown > 0) return;
Keyboard.dismiss();
sendCode.mutate(
{ phone, purpose: 'login' },
@@ -68,7 +64,21 @@ export default function LoginScreen() {
onError: () => {},
},
);
}, [canGetCode, phone, sendCode, termsAccepted]);
}, [phone, countdown, sendCode]);
const handleGetCode = useCallback(() => {
if (!termsAccepted) {
setShowTermsDialog(true);
return;
}
if (!canGetCode) return;
requestSmsCode();
}, [canGetCode, requestSmsCode, termsAccepted]);
const handleTermsAgree = useCallback(() => {
setTermsAccepted(true);
requestSmsCode();
}, [requestSmsCode]);
const handleLogin = useCallback(() => {
if (!canLogin) return;
@@ -294,7 +304,9 @@ export default function LoginScreen() {
onOpenChange={setShowTermsDialog}
title={t('login.termsRequiredTitle')}
description={t('login.termsRequired')}
confirmLabel={t('login.termsRequiredConfirm')}
cancelLabel={t('login.termsRequiredCancel')}
confirmLabel={t('login.termsRequiredAgree')}
onConfirm={handleTermsAgree}
/>
</View>
</ScrollView>

View File

@@ -3,6 +3,7 @@ import React from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
@@ -18,10 +19,12 @@ export interface InfoDialogProps {
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm?: () => void;
}
/**
* Reusable info dialog with a single confirm button.
* Reusable info dialog with a single confirm button, or cancel + confirm pair.
* Use for prompts like "please agree to terms", "please fill in X", etc.
*/
export function InfoDialog({
@@ -30,6 +33,8 @@ export function InfoDialog({
title,
description,
confirmLabel = 'OK',
cancelLabel,
onConfirm,
}: InfoDialogProps) {
const colors = useThemeColors();
@@ -41,11 +46,24 @@ export function InfoDialog({
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>
<Text style={{ color: colors.primaryForeground }}>
{confirmLabel}
</Text>
</AlertDialogAction>
{cancelLabel ? (
<>
<AlertDialogCancel>
<Text>{cancelLabel}</Text>
</AlertDialogCancel>
<AlertDialogAction onPress={onConfirm}>
<Text style={{ color: colors.primaryForeground }}>
{confirmLabel}
</Text>
</AlertDialogAction>
</>
) : (
<AlertDialogAction onPress={onConfirm}>
<Text style={{ color: colors.primaryForeground }}>
{confirmLabel}
</Text>
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@@ -1,5 +1,6 @@
import { config } from '@/core/config';
import { parseApiError } from './parseApiError';
import { ApiError, AuthError, NetworkError, type ApiErrorBody } from './types';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
@@ -32,49 +33,15 @@ export function initApiClient(d: ClientDeps) {
deps = d;
}
let isRefreshing = false;
let refreshQueue: {
resolve: (token: string | null) => void;
reject: (err: unknown) => void;
}[] = [];
function drainQueue(token: string | null, error?: unknown) {
const queue = refreshQueue;
refreshQueue = [];
for (const { resolve, reject } of queue) {
if (error) reject(error);
else resolve(token);
}
}
async function waitForRefresh(): Promise<string | null> {
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject });
});
}
async function handleTokenRefresh(): Promise<string | null> {
if (!deps) throw new AuthError('API client not initialized');
if (isRefreshing) return waitForRefresh();
isRefreshing = true;
try {
const ok = await deps.refreshTokens();
if (!ok) {
deps.onAuthFailure();
drainQueue(null, new AuthError());
throw new AuthError();
}
const newToken = await deps.getAccessToken();
drainQueue(newToken);
return newToken;
} catch (err) {
drainQueue(null, err);
throw err;
} finally {
isRefreshing = false;
const ok = await deps.refreshTokens();
if (!ok) {
deps.onAuthFailure();
throw new AuthError();
}
return deps.getAccessToken();
}
/** FormData detection without relying on global FormData (RN-safe). */
@@ -204,24 +171,12 @@ async function request<T>(
if (!response.ok) {
const body = await parseErrorBody(response);
let message = body?.message ?? `HTTP ${response.status}`;
if (body?.detail != null) {
if (typeof body.detail === 'string') {
message = body.detail;
} else if (Array.isArray(body.detail)) {
const parts = body.detail
.map((d: unknown) =>
typeof d === 'string' ? d : ((d as { msg?: string })?.msg ?? ''),
)
.filter(Boolean);
if (parts.length) message = parts.join('; ');
}
}
const parsed = parseApiError(body, `HTTP ${response.status}`);
throw new ApiError(
message,
parsed.message,
response.status,
body?.error_code,
body?.request_id,
parsed.errorCode,
parsed.requestId,
);
}

View File

@@ -0,0 +1,49 @@
import type { ApiErrorBody } from './types';
export interface ParsedApiError {
message: string;
errorCode?: string;
requestId?: string;
}
function messageFromDetail(detail: ApiErrorBody['detail']): string | null {
if (typeof detail === 'string' && detail.trim()) {
return detail.trim();
}
if (Array.isArray(detail)) {
const parts = detail
.map((item) => {
if (typeof item === 'string') return item;
if (item && typeof item === 'object' && 'msg' in item) {
const msg = (item as { msg?: string }).msg;
return typeof msg === 'string' ? msg : '';
}
return '';
})
.filter(Boolean);
if (parts.length) return parts.join('; ');
}
return null;
}
/** Parse unified `{ message, error_code, request_id }` or legacy FastAPI `{ detail }`. */
export function parseApiError(
body: ApiErrorBody | null | undefined,
fallback: string,
): ParsedApiError {
if (body == null) {
return { message: fallback };
}
const unified =
typeof body.message === 'string' && body.message.trim()
? body.message.trim()
: null;
const message = unified ?? messageFromDetail(body.detail) ?? fallback;
return {
message,
errorCode:
typeof body.error_code === 'string' ? body.error_code : undefined,
requestId:
typeof body.request_id === 'string' ? body.request_id : undefined,
};
}

View File

@@ -42,7 +42,7 @@ export class NetworkError extends Error {
export interface ApiErrorBody {
error_code?: string;
message?: string;
/** FastAPI HTTPException uses "detail" for error message */
detail?: string | string[];
/** Legacy FastAPI HTTPException body */
detail?: string | Array<string | { msg?: string; loc?: unknown[] }>;
request_id?: string;
}

View File

@@ -0,0 +1,47 @@
/** Serialize refresh-token HTTP calls across api client and providers. */
let isRefreshing = false;
const waitQueue: {
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
}[] = [];
function drainQueue(error?: unknown, value?: unknown) {
const queue = waitQueue.splice(0);
for (const { resolve, reject } of queue) {
if (error !== undefined) reject(error);
else resolve(value);
}
}
/**
* Ensures only one refresh runs at a time; concurrent callers await the same result.
*/
export async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
if (isRefreshing) {
return new Promise<T>((resolve, reject) => {
waitQueue.push({
resolve: (value) => resolve(value as T),
reject,
});
});
}
isRefreshing = true;
try {
const result = await fn();
drainQueue(undefined, result);
return result;
} catch (error) {
drainQueue(error);
throw error;
} finally {
isRefreshing = false;
}
}
/** Test-only reset for unit tests. */
export function resetRefreshLockForTests(): void {
isRefreshing = false;
waitQueue.length = 0;
}

View File

@@ -5,6 +5,7 @@ import { AppSettingsProvider } from '@/core/app-settings-context';
import { MemoirReadingSettingsProvider } from '@/core/memoir-reading-settings-context';
import { NetworkError } from '@/core/api/types';
import { tokenManager } from '@/core/auth/token-manager';
import { withRefreshLock } from '@/core/auth/refresh-lock';
import { config } from '@/core/config';
import { authKeys } from '@/features/auth/auth-query-keys';
import { AppQueryProvider, queryClient } from '@/core/query';
@@ -15,7 +16,7 @@ import { AppQueryProvider, queryClient } from '@/core/query';
* Throws NetworkError on transport-level failures so the caller
* can distinguish "session dead" from "network down".
*/
async function refreshTokens(): Promise<boolean> {
async function performRefreshFetch(): Promise<boolean> {
const refreshToken = await tokenManager.getRefreshToken();
if (!refreshToken) return false;
@@ -43,6 +44,10 @@ async function refreshTokens(): Promise<boolean> {
return true;
}
async function refreshTokens(): Promise<boolean> {
return withRefreshLock(performRefreshFetch);
}
/**
* Called by the API client when token refresh is explicitly rejected.
* Must synchronously flip query caches so useSession() immediately

View File

@@ -166,11 +166,7 @@ export class RealtimeSession {
if (!this.assistantTurnTtsSync && this.streamingBuffer.trim().length > 0) {
this.onStreamingText?.(this.streamingBuffer, false);
}
if (
this.uiOwner &&
this.pendingTopicSuggestionsPayload &&
this.onTopicSuggestions
) {
if (this.pendingTopicSuggestionsPayload && this.onTopicSuggestions) {
const p = this.pendingTopicSuggestionsPayload;
this.pendingTopicSuggestionsPayload = null;
this.onTopicSuggestions(p);
@@ -413,7 +409,7 @@ export class RealtimeSession {
stage: event.stage,
suggestions: event.suggestions,
};
if (this.uiOwner && this.onTopicSuggestions) {
if (this.onTopicSuggestions && this.uiOwner) {
this.pendingTopicSuggestionsPayload = null;
this.onTopicSuggestions(payload);
} else {

View File

@@ -31,7 +31,8 @@ interface Resources {
"termsAnd": "and",
"termsIntro": "I agree to the",
"termsRequired": "Please agree to the User Agreement and Privacy Policy first",
"termsRequiredConfirm": "OK",
"termsRequiredAgree": "Agree",
"termsRequiredCancel": "Cancel",
"termsRequiredTitle": "Agreement Required",
"userAgreement": "User Agreement",
"welcomeSubtitle": "Some lives grow richer the more you savor them.",
@@ -177,7 +178,12 @@ interface Resources {
"profile": {
"about": {
"aboutUs": "About Us",
"title": "About"
"appName": "Life Echo",
"appSubtitle": "岁月留书",
"backend": "API endpoint",
"tagline": "Capture your life story and turn memories into a book.",
"title": "About",
"version": "Version {{version}}"
},
"appExperience": {
"language": "Language",

View File

@@ -11,7 +11,8 @@
"termsAnd": "and",
"termsIntro": "I agree to the",
"termsRequired": "Please agree to the User Agreement and Privacy Policy first",
"termsRequiredConfirm": "OK",
"termsRequiredAgree": "Agree",
"termsRequiredCancel": "Cancel",
"termsRequiredTitle": "Agreement Required",
"userAgreement": "User Agreement",
"welcomeSubtitle": "Some lives grow richer the more you savor them.",

View File

@@ -2,7 +2,7 @@
"about": {
"aboutUs": "About Us",
"appName": "Life Echo",
"appSubtitle": "岁月书",
"appSubtitle": "岁月书",
"backend": "API endpoint",
"tagline": "Capture your life story and turn memories into a book.",
"title": "About",

View File

@@ -4,7 +4,7 @@
"system": "跟随系统",
"zh": "中文"
},
"name": "岁月书",
"name": "岁月书",
"tabs": {
"conversations": "对话",
"explore": "探索",

View File

@@ -11,7 +11,8 @@
"termsAnd": "和",
"termsIntro": "我已阅读并同意",
"termsRequired": "请先同意用户协议和隐私政策",
"termsRequiredConfirm": "知道了",
"termsRequiredAgree": "同意",
"termsRequiredCancel": "取消",
"termsRequiredTitle": "需要同意协议",
"userAgreement": "《用户协议》",
"welcomeSubtitle": "有些人生,越嚼越有味道。",

View File

@@ -1,7 +1,7 @@
{
"about": {
"aboutUs": "关于我们",
"appName": "岁月书",
"appName": "岁月书",
"appSubtitle": "Life Echo",
"backend": "连接的后端",
"tagline": "记录你的人生故事,让回忆成书。",

View File

@@ -0,0 +1,55 @@
import { parseApiError } from '@/core/api/parseApiError';
describe('parseApiError', () => {
it('prefers unified message field', () => {
expect(
parseApiError(
{
error_code: 'NOT_FOUND',
message: '资源不存在',
request_id: 'r1',
},
'fallback',
),
).toEqual({
message: '资源不存在',
errorCode: 'NOT_FOUND',
requestId: 'r1',
});
});
it('falls back to legacy detail string', () => {
expect(parseApiError({ detail: '请求无效' }, 'HTTP 400')).toEqual({
message: '请求无效',
errorCode: undefined,
requestId: undefined,
});
});
it('parses legacy detail validation list', () => {
expect(
parseApiError(
{
detail: [{ loc: ['body', 'phone'], msg: 'field required' }],
},
'HTTP 422',
),
).toEqual({
message: 'field required',
errorCode: undefined,
requestId: undefined,
});
});
it('uses fallback when message is missing', () => {
expect(parseApiError({ error_code: 'INTERNAL_ERROR' }, 'HTTP 500')).toEqual({
message: 'HTTP 500',
errorCode: 'INTERNAL_ERROR',
requestId: undefined,
});
});
it('uses fallback when body is null', () => {
expect(parseApiError(null, 'HTTP 502')).toEqual({ message: 'HTTP 502' });
});
});

View File

@@ -0,0 +1,46 @@
import {
resetRefreshLockForTests,
withRefreshLock,
} from '@/core/auth/refresh-lock';
describe('withRefreshLock', () => {
beforeEach(() => {
resetRefreshLockForTests();
});
it('runs concurrent callers through a single in-flight refresh', async () => {
let runs = 0;
const fn = jest.fn(async () => {
runs += 1;
await new Promise((resolve) => setTimeout(resolve, 20));
return 'ok';
});
const [a, b, c] = await Promise.all([
withRefreshLock(fn),
withRefreshLock(fn),
withRefreshLock(fn),
]);
expect(a).toBe('ok');
expect(b).toBe('ok');
expect(c).toBe('ok');
expect(fn).toHaveBeenCalledTimes(1);
expect(runs).toBe(1);
});
it('propagates errors to queued waiters', async () => {
const fn = jest.fn(async () => {
throw new Error('refresh failed');
});
const results = await Promise.allSettled([
withRefreshLock(fn),
withRefreshLock(fn),
]);
expect(fn).toHaveBeenCalledTimes(1);
expect(results[0].status).toBe('rejected');
expect(results[1].status).toBe('rejected');
});
});