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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
49
app-expo/src/core/api/parseApiError.ts
Normal file
49
app-expo/src/core/api/parseApiError.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
47
app-expo/src/core/auth/refresh-lock.ts
Normal file
47
app-expo/src/core/auth/refresh-lock.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"system": "跟随系统",
|
||||
"zh": "中文"
|
||||
},
|
||||
"name": "岁月时书",
|
||||
"name": "岁月留书",
|
||||
"tabs": {
|
||||
"conversations": "对话",
|
||||
"explore": "探索",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"termsAnd": "和",
|
||||
"termsIntro": "我已阅读并同意",
|
||||
"termsRequired": "请先同意用户协议和隐私政策",
|
||||
"termsRequiredConfirm": "知道了",
|
||||
"termsRequiredAgree": "同意",
|
||||
"termsRequiredCancel": "取消",
|
||||
"termsRequiredTitle": "需要同意协议",
|
||||
"userAgreement": "《用户协议》",
|
||||
"welcomeSubtitle": "有些人生,越嚼越有味道。",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"about": {
|
||||
"aboutUs": "关于我们",
|
||||
"appName": "岁月时书",
|
||||
"appName": "岁月留书",
|
||||
"appSubtitle": "Life Echo",
|
||||
"backend": "连接的后端",
|
||||
"tagline": "记录你的人生故事,让回忆成书。",
|
||||
|
||||
55
app-expo/tests/core/api/parseApiError.test.ts
Normal file
55
app-expo/tests/core/api/parseApiError.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
46
app-expo/tests/core/auth/refresh-lock.test.ts
Normal file
46
app-expo/tests/core/auth/refresh-lock.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user