feat: enhance billing error handling and upgrade prompts

- Updated error codes in the error-codes enum to better reflect billing scenarios, introducing BILLING_PLAN_QUOTA_EXCEEDED and BILLING_STORAGE_QUOTA_EXCEEDED.
- Modified the PhotoAssetService and BillingPlanService to throw specific BizExceptions for storage and plan quota exceedances.
- Implemented a new BillingUpgradeModal to prompt users for upgrades based on error categories.
- Enhanced error handling in photo upload and sync actions to present upgrade prompts when necessary.
- Updated localization files to include new strings for upgrade prompts in both English and Chinese.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-10 15:36:52 +08:00
parent 883fd37e53
commit 9229ad63dd
11 changed files with 269 additions and 24 deletions

View File

@@ -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<ErrorCode, ErrorDescriptor> = {
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',
},
}

View File

@@ -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(

View File

@@ -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}。升级订阅后即可提升限额。`,
})
}

View File

@@ -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
}

View File

@@ -1,3 +1,4 @@
export * from './api'
export * from './hooks'
export * from './types'
export * from './upgrade-prompts'

View File

@@ -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 (
<div className="flex w-full max-w-[520px] flex-col gap-4">
<DialogHeader>
<DialogTitle className="text-lg font-semibold leading-none tracking-tight">
{t(billingPlanUpgradeKeys.title)}
</DialogTitle>
<DialogDescription className="text-sm text-text-secondary">
{t(billingPlanUpgradeKeys.description)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-1 gap-2">
<Button type="button" variant="ghost" onClick={dismiss}>
{t(billingPlanUpgradeKeys.actionLater)}
</Button>
<Button type="button" variant="primary" onClick={handleUpgrade}>
{t(billingPlanUpgradeKeys.actionUpgrade)}
</Button>
</DialogFooter>
</div>
)
}
BillingPlanUpgradeModal.contentClassName = 'w-[520px] max-w-[92vw]'

View File

@@ -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<string | null> {
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<ServerErrorInfo> {
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<PhotoSyncProgressEvent>(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)

View File

@@ -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,

View File

@@ -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 })

View File

@@ -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",

View File

@@ -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": "显示",