diff --git a/be/apps/core/src/errors/error-codes.ts b/be/apps/core/src/errors/error-codes.ts index 6684f48f..4746c71f 100644 --- a/be/apps/core/src/errors/error-codes.ts +++ b/be/apps/core/src/errors/error-codes.ts @@ -26,7 +26,8 @@ export enum ErrorCode { PHOTO_MANIFEST_GENERATION_FAILED = 31, // Billing / Subscription - BILLING_QUOTA_EXCEEDED = 40, + BILLING_PLAN_QUOTA_EXCEEDED = 40, + BILLING_STORAGE_QUOTA_EXCEEDED = 41, } export interface ErrorDescriptor { @@ -108,8 +109,12 @@ export const ERROR_CODE_DESCRIPTORS: Record = { httpStatus: 500, message: 'Photo manifest generation failed', }, - [ErrorCode.BILLING_QUOTA_EXCEEDED]: { + [ErrorCode.BILLING_PLAN_QUOTA_EXCEEDED]: { httpStatus: 402, message: 'Usage quota exceeded', }, + [ErrorCode.BILLING_STORAGE_QUOTA_EXCEEDED]: { + httpStatus: 402, + message: 'Storage quota exceeded', + }, } diff --git a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts index 3a710dc4..c3dbaf6a 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts @@ -1828,7 +1828,7 @@ export class PhotoAssetService { totalBytes: usage.totalBytes, fileCount: usage.fileCount, }) - throw new BizException(ErrorCode.BILLING_QUOTA_EXCEEDED, { + throw new BizException(ErrorCode.BILLING_STORAGE_QUOTA_EXCEEDED, { message: `托管存储空间已超出套餐上限:当前已用 ${formatBytesForDisplay( usage.totalBytes, )},套餐上限 ${formatBytesForDisplay(capacity)}。请清理空间或升级存储方案后再试。`, @@ -1843,7 +1843,7 @@ export class PhotoAssetService { totalBytes: usage.totalBytes, fileCount: usage.fileCount, }) - throw new BizException(ErrorCode.BILLING_QUOTA_EXCEEDED, { + throw new BizException(ErrorCode.BILLING_STORAGE_QUOTA_EXCEEDED, { message: `托管存储空间不足:当前已用 ${formatBytesForDisplay( usage.totalBytes, )},上传后预计 ${formatBytesForDisplay(projectedBytes)},已超过套餐上限 ${formatBytesForDisplay( diff --git a/be/apps/core/src/modules/platform/billing/billing-plan.service.ts b/be/apps/core/src/modules/platform/billing/billing-plan.service.ts index 22790804..1b5429e0 100644 --- a/be/apps/core/src/modules/platform/billing/billing-plan.service.ts +++ b/be/apps/core/src/modules/platform/billing/billing-plan.service.ts @@ -137,7 +137,7 @@ export class BillingPlanService { if (used + incomingItems > quota.monthlyAssetProcessLimit) { const remaining = Math.max(quota.monthlyAssetProcessLimit - used, 0) - throw new BizException(ErrorCode.BILLING_QUOTA_EXCEEDED, { + throw new BizException(ErrorCode.BILLING_PLAN_QUOTA_EXCEEDED, { message: `当月新增照片额度不足,可用剩余:${remaining},请求新增:${incomingItems}。升级订阅后即可提升限额。`, }) } diff --git a/be/apps/dashboard/src/lib/errors.ts b/be/apps/dashboard/src/lib/errors.ts index fde69ac8..02b9df1c 100644 --- a/be/apps/dashboard/src/lib/errors.ts +++ b/be/apps/dashboard/src/lib/errors.ts @@ -72,3 +72,70 @@ export function getRequestErrorMessage(error: unknown, fallback?: string): strin return fallback ?? getI18n().t('errors.request.generic') } + +const parseNumberLike = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? null : parsed + } + return null +} + +export function getRequestStatusCode(error: unknown): number | null { + if (error instanceof FetchError) { + const status = error.statusCode ?? (error as FetchErrorWithPayload).response?.status + if (typeof status === 'number' && Number.isFinite(status)) { + return status + } + } + + if (typeof error === 'object' && error) { + const candidate = (error as { statusCode?: unknown }).statusCode + const parsedCandidate = parseNumberLike(candidate) + if (parsedCandidate !== null) { + return parsedCandidate + } + + const responseStatus = (error as { response?: { status?: unknown } }).response?.status + const parsedResponse = parseNumberLike(responseStatus) + if (parsedResponse !== null) { + return parsedResponse + } + } + + return null +} + +const extractPayloadCode = (payload: unknown): number | null => { + if (!payload || typeof payload !== 'object') { + return null + } + return parseNumberLike((payload as { code?: unknown }).code) +} + +export function getRequestErrorCode(error: unknown): number | null { + if (!error || typeof error !== 'object') { + return null + } + + const directCode = parseNumberLike((error as { code?: unknown }).code) + if (directCode !== null) { + return directCode + } + + const dataCode = extractPayloadCode((error as { data?: unknown }).data) + if (dataCode !== null) { + return dataCode + } + + const {response} = (error as { response?: { _data?: unknown; data?: unknown } }) + const responseCode = extractPayloadCode(response?._data ?? response?.data) + if (responseCode !== null) { + return responseCode + } + + return null +} diff --git a/be/apps/dashboard/src/modules/billing/index.ts b/be/apps/dashboard/src/modules/billing/index.ts index 1cc707f0..fdf8071d 100644 --- a/be/apps/dashboard/src/modules/billing/index.ts +++ b/be/apps/dashboard/src/modules/billing/index.ts @@ -1,3 +1,4 @@ export * from './api' export * from './hooks' export * from './types' +export * from './upgrade-prompts' diff --git a/be/apps/dashboard/src/modules/billing/upgrade-prompts.tsx b/be/apps/dashboard/src/modules/billing/upgrade-prompts.tsx new file mode 100644 index 00000000..a05132a0 --- /dev/null +++ b/be/apps/dashboard/src/modules/billing/upgrade-prompts.tsx @@ -0,0 +1,77 @@ +import type { ModalComponent } from '@afilmory/ui' +import { Button, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Modal } from '@afilmory/ui' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' + +import { getRequestErrorCode, getRequestStatusCode } from '~/lib/errors' +import { ManagedStoragePlansModal } from '~/modules/storage-providers/components/ManagedStoragePlansModal' + +const PLAN_LIMIT_CODE = 40 +const STORAGE_LIMIT_CODE = 41 + +export type BillingUpgradeCategory = 'plan' | 'storage' + +export function resolveBillingUpgradeCategory(error: unknown): BillingUpgradeCategory | null { + const code = getRequestErrorCode(error) + if (code === PLAN_LIMIT_CODE) { + return 'plan' + } + if (code === STORAGE_LIMIT_CODE) { + return 'storage' + } + + const status = getRequestStatusCode(error) + if (status === 402) { + return 'plan' + } + + return null +} + +export function presentBillingUpgradeModal(category: BillingUpgradeCategory) { + if (category === 'storage') { + Modal.present(ManagedStoragePlansModal, {}, { dismissOnOutsideClick: true }) + return + } + Modal.present(BillingPlanUpgradeModal, {}, { dismissOnOutsideClick: true }) +} + +const billingPlanUpgradeKeys = { + title: 'plan.upgrade-modal.title', + description: 'plan.upgrade-modal.description', + actionUpgrade: 'plan.upgrade-modal.action.upgrade', + actionLater: 'plan.upgrade-modal.action.later', +} as const + +export const BillingPlanUpgradeModal: ModalComponent = ({ dismiss }) => { + const { t } = useTranslation() + const navigate = useNavigate() + + const handleUpgrade = () => { + dismiss?.() + navigate('/plan') + } + + return ( +
+ + + {t(billingPlanUpgradeKeys.title)} + + + {t(billingPlanUpgradeKeys.description)} + + + + + + +
+ ) +} + +BillingPlanUpgradeModal.contentClassName = 'w-[520px] max-w-[92vw]' diff --git a/be/apps/dashboard/src/modules/photos/api.ts b/be/apps/dashboard/src/modules/photos/api.ts index aaf705f9..4a02f97f 100644 --- a/be/apps/dashboard/src/modules/photos/api.ts +++ b/be/apps/dashboard/src/modules/photos/api.ts @@ -42,26 +42,72 @@ function parseRawPayload(raw: string | null | undefined): unknown | null { } } -function extractMessageFromRaw(raw: string | null | undefined): string | null { - const payload = parseRawPayload(raw) - if (payload == null) { - return null - } - return normalizeServerMessage(payload) +type ServerErrorInfo = { + message: string | null + code: number | null + raw: unknown } -async function readResponseErrorMessage(response: Response): Promise { +const extractErrorInfoFromPayload = (payload: unknown): ServerErrorInfo => { + const message = normalizeServerMessage(payload) + const codeValue = typeof payload === 'object' && payload ? (payload as { code?: unknown }).code : null + const code = + typeof codeValue === 'number' && Number.isFinite(codeValue) + ? codeValue + : typeof codeValue === 'string' + ? Number.parseInt(codeValue, 10) + : null + return { + message, + code: Number.isFinite(code ?? Number.NaN) ? (code as number) : null, + raw: payload, + } +} + +function extractErrorInfoFromRaw(raw: string | null | undefined): ServerErrorInfo { + const payload = parseRawPayload(raw) + return extractErrorInfoFromPayload(payload ?? raw ?? null) +} + +async function readResponseErrorInfo(response: Response): Promise { try { const text = await response.text() - return extractMessageFromRaw(text) + return extractErrorInfoFromRaw(text) } catch { - return null + return { message: null, code: null, raw: null } } } -function extractMessageFromXhr(xhr: XMLHttpRequest): string | null { +function extractErrorInfoFromXhr(xhr: XMLHttpRequest): ServerErrorInfo { const raw = typeof xhr.response === 'string' && xhr.response.length > 0 ? xhr.response : (xhr.responseText ?? '') - return extractMessageFromRaw(raw) + return extractErrorInfoFromRaw(raw) +} + +const createApiError = ( + info: ServerErrorInfo, + fallback: string, + status?: number, +): Error & { + statusCode?: number + code?: number + data?: unknown + response?: { status?: number; _data?: unknown } +} => { + const error = new Error(info.message ?? fallback) as Error & { + statusCode?: number + code?: number + data?: unknown + response?: { status?: number; _data?: unknown } + } + if (typeof status === 'number' && Number.isFinite(status)) { + error.statusCode = status + error.response = { status } + } + if (typeof info.code === 'number') { + error.code = info.code + } + error.data = info.raw + return error } type RunPhotoSyncOptions = { @@ -111,8 +157,8 @@ export async function runPhotoSync( if (!response.ok || !response.body) { const fallback = `Sync request failed: ${response.status} ${response.statusText}` - const serverMessage = await readResponseErrorMessage(response) - throw new Error(serverMessage ?? fallback) + const errorInfo = await readResponseErrorInfo(response) + throw createApiError(errorInfo, fallback, response.status) } const reader = response.body.getReader() @@ -120,6 +166,7 @@ export async function runPhotoSync( let buffer = '' let finalResult: PhotoSyncResult | null = null let lastErrorMessage: string | null = null + let lastErrorCode: number | null = null const stageEvent = (rawEvent: string) => { const lines = rawEvent.split(STABLE_NEWLINE) @@ -167,6 +214,15 @@ export async function runPhotoSync( if (event.type === 'error') { lastErrorMessage = event.payload.message + const payloadCode = (event.payload as { code?: unknown }).code + if (typeof payloadCode === 'number' && Number.isFinite(payloadCode)) { + lastErrorCode = payloadCode + } else if (typeof payloadCode === 'string') { + const parsed = Number.parseInt(payloadCode, 10) + if (!Number.isNaN(parsed)) { + lastErrorCode = parsed + } + } } } catch (error) { console.error('Failed to parse sync progress event', error) @@ -200,7 +256,7 @@ export async function runPhotoSync( } if (lastErrorMessage) { - throw new Error(lastErrorMessage) + throw createApiError({ message: lastErrorMessage, code: lastErrorCode, raw: null }, lastErrorMessage) } if (!finalResult) { @@ -413,7 +469,11 @@ export async function uploadPhotoAssets( const event = camelCaseKeys(parsed) options?.onServerEvent?.(event) if (event.type === 'error') { - settle(() => {}, reject, new Error(event.payload.message || 'Server processing failed')) + const error = createApiError( + extractErrorInfoFromPayload(event.payload), + 'Server processing failed', + ) + settle(() => {}, reject, error) xhr.abort() return } @@ -436,7 +496,15 @@ export async function uploadPhotoAssets( } xhr.onerror = () => { - settle(() => {}, reject, new Error('Network error during upload. Please try again later.')) + settle( + () => {}, + reject, + createApiError( + { message: 'Network error during upload. Please try again later.', code: null, raw: null }, + 'Network error during upload. Please try again later.', + xhr.status, + ), + ) } xhr.onabort = () => { @@ -444,7 +512,15 @@ export async function uploadPhotoAssets( } xhr.ontimeout = () => { - settle(() => {}, reject, new Error('Upload timed out. Please try again later.')) + settle( + () => {}, + reject, + createApiError( + { message: 'Upload timed out. Please try again later.', code: null, raw: null }, + 'Upload timed out. Please try again later.', + xhr.status, + ), + ) } xhr.onload = () => { @@ -456,8 +532,8 @@ export async function uploadPhotoAssets( const fallbackMessage = xhr.status >= 200 && xhr.status < 300 ? 'Upload response incomplete' : `Upload failed: ${xhr.status}` - const serverMessage = extractMessageFromXhr(xhr) - settle(() => {}, reject, new Error(serverMessage ?? fallbackMessage)) + const errorInfo = extractErrorInfoFromXhr(xhr) + settle(() => {}, reject, createApiError(errorInfo, fallbackMessage, xhr.status || undefined)) } xhr.send(formData) diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/store.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/store.tsx index 2cc5b34b..7f52e76d 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/store.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/store.tsx @@ -4,6 +4,8 @@ import type { StoreApi } from 'zustand' import { useStore } from 'zustand' import { createStore } from 'zustand/vanilla' +import { presentBillingUpgradeModal, resolveBillingUpgradeCategory } from '~/modules/billing/upgrade-prompts' + import type { PhotoSyncProgressEvent } from '../../../types' import type { PhotoUploadRequestOptions } from '../upload.types' import type { FileProgressEntry, ProcessingLogEntry, ProcessingState, WorkflowPhase } from './types' @@ -319,6 +321,10 @@ export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUpl const currentFiles = get().files updateEntries(() => createFileEntries(currentFiles)) } else { + const upgradeCategory = resolveBillingUpgradeCategory(error) + if (upgradeCategory) { + presentBillingUpgradeModal(upgradeCategory) + } const message = getErrorMessage(error, '上传失败,请稍后再试。') set({ uploadError: message, diff --git a/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncActions.tsx b/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncActions.tsx index c73d4713..d879c39e 100644 --- a/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncActions.tsx +++ b/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncActions.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import { usePhotoSyncAutoRunValue, useSetPhotoSyncAutoRun } from '~/atoms/photo-sync' import { useMainPageLayout } from '~/components/layouts/MainPageLayout' import { getRequestErrorMessage } from '~/lib/errors' +import { presentBillingUpgradeModal, resolveBillingUpgradeCategory } from '~/modules/billing/upgrade-prompts' import { runPhotoSync } from '../../api' import type { RunPhotoSyncPayload } from '../../types' @@ -80,6 +81,10 @@ export function PhotoSyncActions() { }, onError: (error) => { const normalizedError = error instanceof Error ? error : new Error(t(photoSyncActionKeys.toastErrorDescription)) + const upgradeCategory = resolveBillingUpgradeCategory(error) + if (upgradeCategory) { + presentBillingUpgradeModal(upgradeCategory) + } const message = getRequestErrorMessage(error, normalizedError.message) toast.error(t(photoSyncActionKeys.toastErrorTitle), { description: message }) diff --git a/locales/dashboard/en.json b/locales/dashboard/en.json index abba722f..9ff881b0 100644 --- a/locales/dashboard/en.json +++ b/locales/dashboard/en.json @@ -549,6 +549,10 @@ "plan.toast.missing-portal-account": "Subscription account not found. Please try again later.", "plan.toast.missing-portal-url": "Portal link is unavailable. Please try again later.", "plan.toast.portal-failure": "Unable to open subscription portal. Please try again later.", + "plan.upgrade-modal.action.later": "Maybe later", + "plan.upgrade-modal.action.upgrade": "Go to plans", + "plan.upgrade-modal.description": "Uploads and sync need a higher subscription. Upgrade your plan to continue.", + "plan.upgrade-modal.title": "Upgrade required", "schema-form.secret.helper": "For security, provide a new value only when updating.", "schema-form.secret.hide": "Hide", "schema-form.secret.show": "Show", diff --git a/locales/dashboard/zh-CN.json b/locales/dashboard/zh-CN.json index 46cb89ea..c8c664ab 100644 --- a/locales/dashboard/zh-CN.json +++ b/locales/dashboard/zh-CN.json @@ -497,6 +497,10 @@ "plan.toast.missing-portal-account": "找不到订阅账户,请稍后再试。", "plan.toast.missing-portal-url": "Creem 未返回订阅管理地址,请稍后再试。", "plan.toast.portal-failure": "无法打开订阅管理,请稍后再试。", + "plan.upgrade-modal.action.later": "稍后再说", + "plan.upgrade-modal.action.upgrade": "去升级", + "plan.upgrade-modal.description": "当前上传或同步已触达套餐上限,请前往订阅页面升级后继续。", + "plan.upgrade-modal.title": "升级订阅以继续", "schema-form.secret.helper": "出于安全考虑,仅在更新时填写新的值。", "schema-form.secret.hide": "隐藏", "schema-form.secret.show": "显示",