fix: resolve storage provider auto-switching issue when switching between managed and custom storage (#202)

When users switched from Tencent COS to managed storage and saved, the UI would
incorrectly show Tencent COS as the active provider on reload, and uploads would
fail because the wrong storage backend was being used.

Root cause: The `getActiveStorageProvider` method had a fallback behavior that
automatically set and persisted the first provider as active when only one
provider existed. This caused the managed storage selection to be overwritten.

Changes:
- Remove auto-persist fallback in getActiveStorageProvider (setting.service.ts)
- Add proper validation for managed storage subscription (storage-setting.service.ts)
- Reorder storage resolution to check managed storage first (photo-storage.service.ts)
- Add getActivePlanSummaryForTenant with Creem subscription validation (storage-plan.service.ts)
- Update dashboard UI to handle provider state correctly (StorageProvidersManager.tsx)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Innei
2026-01-05 15:50:07 +08:00
parent 4a840097e7
commit 7e7f0d4ede
8 changed files with 448 additions and 114 deletions

View File

@@ -133,13 +133,9 @@ export class SettingService {
if (found) return found
}
// Fallback: if there is exactly one provider, automatically set it active and persist the setting
// Fallback: if there is exactly one provider, treat it as active without mutating settings.
if (providers.length === 1) {
const only = providers[0]
// Persist synchronously; ignore schema sensitivity (it's non-sensitive)
const setOptions = options ? { ...options, isSensitive: false } : { isSensitive: false }
await this.set('builder.storage.activeProvider', only.id, setOptions)
return only
return providers[0]
}
return null

View File

@@ -1,11 +1,12 @@
import { Module } from '@afilmory/framework'
import { BillingModule } from 'core/modules/platform/billing/billing.module'
import { SettingModule } from '../setting/setting.module'
import { StorageSettingController } from './storage-setting.controller'
import { StorageSettingService } from './storage-setting.service'
@Module({
imports: [SettingModule],
imports: [SettingModule, BillingModule],
controllers: [StorageSettingController],
providers: [StorageSettingService],
})

View File

@@ -1,15 +1,22 @@
import { BizException, ErrorCode } from 'core/errors'
import { StoragePlanService } from 'core/modules/platform/billing/storage-plan.service'
import { getTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { injectable } from 'tsyringe'
import { getUiSchemaTranslator } from '../../ui/ui-schema/ui-schema.i18n'
import type { SettingEntryInput } from '../setting/setting.service'
import { SettingService } from '../setting/setting.service'
import { parseStorageProviders } from '../setting/storage-provider.utils'
import { createStorageProviderFormSchema } from './storage-provider.ui-schema'
type StorageSettingKey = 'builder.storage.providers' | 'builder.storage.activeProvider' | 'photo.storage.secureAccess'
@injectable()
export class StorageSettingService {
constructor(private readonly settingService: SettingService) {}
constructor(
private readonly settingService: SettingService,
private readonly storagePlanService: StoragePlanService,
) {}
async getUiSchema() {
const schema = await this.settingService.getUiSchema()
@@ -34,7 +41,54 @@ export class StorageSettingService {
}
async setMany(entries: readonly SettingEntryInput[]): Promise<void> {
await this.settingService.setMany(entries)
const normalized = [...entries]
const providersEntry = normalized.find((entry) => entry.key === 'builder.storage.providers')
const activeEntryIndex = normalized.findIndex((entry) => entry.key === 'builder.storage.activeProvider')
const activeEntry = activeEntryIndex !== -1 ? normalized[activeEntryIndex] : null
const activeRaw = activeEntry ? String(activeEntry.value ?? '').trim() : ''
const activeId = activeRaw.length > 0 ? activeRaw : null
if (activeId === 'managed') {
const tenantId = this.resolveTenantId(normalized)
if (!tenantId) {
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
}
const plan = await this.storagePlanService.getActivePlanSummaryForTenant(tenantId)
if (!plan) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '托管存储订阅无效或已过期,无法设为活动存储。',
})
}
}
if (providersEntry) {
const providers = parseStorageProviders(String(providersEntry.value ?? ''))
if (!activeId && providers.length === 1) {
const only = providers[0]
const nextActiveEntry: SettingEntryInput = {
key: 'builder.storage.activeProvider',
value: only.id,
options: activeEntry?.options,
}
if (activeEntryIndex !== -1) {
normalized[activeEntryIndex] = nextActiveEntry
} else {
normalized.push(nextActiveEntry)
}
}
}
await this.settingService.setMany(normalized)
}
private resolveTenantId(entries: readonly SettingEntryInput[]): string | null {
const entryWithTenant = entries.find((entry) => entry.options?.tenantId)
if (entryWithTenant?.options?.tenantId) {
return entryWithTenant.options.tenantId
}
const tenant = getTenantContext()
return tenant?.tenant.id ?? null
}
async delete(key: StorageSettingKey): Promise<void> {

View File

@@ -60,7 +60,6 @@ export class PhotoStorageService {
return { builderConfig: overrides.builderConfig, storageConfig }
}
const activeProvider = await this.settingService.getActiveStorageProvider({ tenantId })
if (activeProviderId === MANAGED_ACTIVE_PROVIDER_ID) {
const managedConfig = await this.tryResolveManagedStorageConfig(tenantId)
if (managedConfig) {
@@ -71,6 +70,7 @@ export class PhotoStorageService {
}
}
const activeProvider = await this.settingService.getActiveStorageProvider({ tenantId })
if (!activeProvider) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: 'Active storage provider is not configured. Configure storage settings before running sync.',
@@ -87,7 +87,7 @@ export class PhotoStorageService {
private async tryResolveManagedStorageConfig(tenantId: string): Promise<ManagedStorageConfig | null> {
const [plan, provider] = await Promise.all([
this.storagePlanService.getPlanSummaryForTenant(tenantId),
this.storagePlanService.getActivePlanSummaryForTenant(tenantId),
this.systemSettingService.getManagedStorageProvider(),
])

View File

@@ -1,9 +1,11 @@
import { tenants } from '@afilmory/db'
import { authUsers, creemSubscriptions, tenants } from '@afilmory/db'
import { createLogger } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { eq } from 'drizzle-orm'
import type { SQL } from 'drizzle-orm'
import { and, desc, eq, inArray, or } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { BillingPlanService } from './billing-plan.service'
@@ -25,6 +27,7 @@ export interface StorageQuotaSummary {
@injectable()
export class StoragePlanService {
private readonly logger = createLogger('StoragePlanService')
constructor(
private readonly dbAccessor: DbAccessor,
private readonly systemSettingService: SystemSettingService,
@@ -103,6 +106,26 @@ export class StoragePlanService {
return plan
}
async getActivePlanSummaryForTenant(tenantId: string): Promise<StoragePlanSummary | null> {
const plan = await this.getPlanSummaryForTenant(tenantId)
if (!plan || plan.isActive === false) {
return null
}
const productId = plan.payment?.creemProductId ?? null
if (!productId) {
return plan
}
const subscription = await this.resolveLatestSubscriptionForTenant(tenantId, productId)
if (!subscription) {
return plan
}
const state = this.resolveSubscriptionState(subscription)
return state === 'inactive' ? null : plan
}
async getOverviewForCurrentTenant(): Promise<StoragePlanOverview> {
const tenant = requireTenantContext()
const [plans, currentPlan, providerKey] = await Promise.all([
@@ -148,6 +171,73 @@ export class StoragePlanService {
return planId && planId.length > 0 ? planId : null
}
private async resolveLatestSubscriptionForTenant(tenantId: string, productId: string) {
const db = this.dbAccessor.get()
const users = await db
.select({ id: authUsers.id, creemCustomerId: authUsers.creemCustomerId })
.from(authUsers)
.where(eq(authUsers.tenantId, tenantId))
const userIds = users.map((user) => user.id).filter((id): id is string => typeof id === 'string' && id.length > 0)
const customerIds = users
.map((user) => user.creemCustomerId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
if (userIds.length === 0 && customerIds.length === 0) {
return null
}
const conditions: SQL[] = []
if (userIds.length > 0) {
conditions.push(inArray(creemSubscriptions.referenceId, userIds))
}
if (customerIds.length > 0) {
conditions.push(inArray(creemSubscriptions.creemCustomerId, customerIds))
}
const where =
conditions.length === 1 ? conditions[0] : and(or(...conditions), eq(creemSubscriptions.productId, productId))
const [record] = await db
.select()
.from(creemSubscriptions)
.where(conditions.length === 1 ? and(where, eq(creemSubscriptions.productId, productId)) : where)
.orderBy(desc(creemSubscriptions.updatedAt))
.limit(1)
return record ?? null
}
private resolveSubscriptionState(
subscription: typeof creemSubscriptions.$inferSelect,
): 'active' | 'inactive' | 'unknown' {
const now = Date.now()
const status = subscription.status?.toLowerCase() ?? null
const periodEndRaw = subscription.periodEnd
const periodEnd = periodEndRaw ? new Date(periodEndRaw).getTime() : null
const hasValidPeriodEnd = periodEnd !== null && !Number.isNaN(periodEnd)
if (hasValidPeriodEnd && periodEnd <= now) {
return 'inactive'
}
const activeStatuses = new Set(['active', 'trialing', 'paid'])
if (status && activeStatuses.has(status)) {
return 'active'
}
if (subscription.cancelAtPeriodEnd && hasValidPeriodEnd && periodEnd > now) {
return 'active'
}
const inactiveStatuses = new Set(['canceled', 'cancelled', 'expired', 'past_due', 'unpaid'])
if (status && inactiveStatuses.has(status)) {
return 'inactive'
}
return 'unknown'
}
private async getPlanCatalog(): Promise<Record<string, StoragePlanDefinition>> {
const config = await this.systemSettingService.getStoragePlanCatalog()
const merged: StoragePlanCatalog = { ...DEFAULT_STORAGE_PLAN_CATALOG, ...config }

View File

@@ -5,11 +5,13 @@ import { m } from 'motion/react'
import { startTransition, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { toast } from 'sonner'
import { useSetPhotoSyncAutoRun } from '~/atoms/photo-sync'
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout'
import { useBlock } from '~/hooks/useBlock'
import { getRequestErrorMessage } from '~/lib/errors'
import { useManagedStoragePlansQuery } from '~/modules/storage-plans'
import { MANAGED_STORAGE_ACTIVE_ID, storageProvidersI18nKeys } from '../constants'
@@ -187,6 +189,10 @@ export function StorageProvidersManager() {
})
}
},
onError: (error) => {
const message = getRequestErrorMessage(error, t('errors.request.generic'))
toast.error(t(storageProvidersI18nKeys.status.error, { reason: message }))
},
},
)
}