mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 04:59:47 +08:00
268 lines
8.2 KiB
TypeScript
268 lines
8.2 KiB
TypeScript
import { asc, eq, inArray } from 'drizzle-orm'
|
|
import { nanoid } from 'nanoid'
|
|
import { z } from 'zod'
|
|
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
|
import { cloudTrafficReports } from '../db/schema'
|
|
import { loadActiveLicenseBinding } from '../licensing/license-state'
|
|
import type { Database, Platform } from '../platform/interface'
|
|
import { currentTrafficPeriod } from './effective-quota'
|
|
import { postBoundCloudJson } from './licensing-cloud'
|
|
|
|
export type TrafficReportSource =
|
|
| 'object_download'
|
|
| 'direct_share'
|
|
| 'landing_share'
|
|
| 'image_hosting'
|
|
| 'custom_domain_image'
|
|
| 'webdav_download'
|
|
|
|
export class CloudTrafficBlockedError extends Error {
|
|
constructor() {
|
|
super('cloud_traffic_blocked')
|
|
this.name = 'CloudTrafficBlockedError'
|
|
}
|
|
}
|
|
|
|
const usageResponseSchema = z.object({
|
|
accepted: z.boolean(),
|
|
duplicate: z.boolean(),
|
|
eventId: z.string().min(1),
|
|
})
|
|
|
|
type ReportStatus = 'pending' | 'reported' | 'skipped_unbound' | 'blocked' | 'failed'
|
|
type TrafficReport = typeof cloudTrafficReports.$inferSelect
|
|
|
|
export async function reportTrafficEgress(params: {
|
|
platform: Platform
|
|
orgId: string
|
|
bytes: number
|
|
storageId?: string | null
|
|
egressCreditBillingEnabled?: boolean
|
|
egressCreditUnitBytes?: number | null
|
|
egressCreditPerUnit?: number | null
|
|
source: TrafficReportSource
|
|
sourceId: string
|
|
eventId?: string
|
|
now?: Date
|
|
}): Promise<{ status: ReportStatus; eventId: string; duplicate: boolean }> {
|
|
const { platform, orgId, bytes, source, sourceId, now = new Date() } = params
|
|
if (bytes <= 0) return { status: 'reported', eventId: params.eventId ?? '', duplicate: false }
|
|
if (!params.egressCreditBillingEnabled) return { status: 'reported', eventId: params.eventId ?? '', duplicate: false }
|
|
if (!params.storageId || !params.egressCreditUnitBytes || !params.egressCreditPerUnit) {
|
|
throw new Error('storage_egress_pricing_missing')
|
|
}
|
|
|
|
const eventId = params.eventId ?? `traffic_${nanoid()}`
|
|
const existing = await loadTrafficReport(platform.db, eventId)
|
|
const period = existing?.period ?? currentTrafficPeriod(now)
|
|
if (existing) {
|
|
assertSameReport(existing, {
|
|
orgId,
|
|
period,
|
|
source,
|
|
sourceId,
|
|
bytes,
|
|
storageId: params.storageId,
|
|
unitBytes: params.egressCreditUnitBytes,
|
|
creditsPerUnit: params.egressCreditPerUnit,
|
|
})
|
|
if (existing.status !== 'blocked') {
|
|
return { status: existing.status as ReportStatus, eventId, duplicate: true }
|
|
}
|
|
if (existing.status === 'blocked') throw new CloudTrafficBlockedError()
|
|
} else {
|
|
await insertTrafficReport(platform, {
|
|
orgId,
|
|
period,
|
|
source,
|
|
sourceId,
|
|
eventId,
|
|
bytes,
|
|
storageId: params.storageId,
|
|
unitBytes: params.egressCreditUnitBytes,
|
|
creditsPerUnit: params.egressCreditPerUnit,
|
|
status: 'pending',
|
|
now,
|
|
})
|
|
}
|
|
|
|
const binding = await loadActiveLicenseBinding(platform.db)
|
|
if (!binding?.refreshToken || !binding.cloudStoreId) {
|
|
await updateTrafficReport(platform.db, eventId, 'skipped_unbound', null, now)
|
|
return { status: 'skipped_unbound', eventId, duplicate: false }
|
|
}
|
|
|
|
const status = await syncTrafficReport({
|
|
db: platform.db,
|
|
cloudBaseUrl: platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT,
|
|
refreshToken: binding.refreshToken,
|
|
storeId: binding.cloudStoreId,
|
|
report: (await loadTrafficReport(platform.db, eventId))!,
|
|
now,
|
|
})
|
|
if (status === 'blocked') throw new CloudTrafficBlockedError()
|
|
return { status, eventId, duplicate: false }
|
|
}
|
|
|
|
export async function syncPendingCloudTrafficReports(params: {
|
|
db: Database
|
|
cloudBaseUrl: string
|
|
limit?: number
|
|
now?: Date
|
|
}): Promise<{ attempted: number; reported: number; blocked: number; failed: number }> {
|
|
const { db, cloudBaseUrl, limit = 100, now = new Date() } = params
|
|
const binding = await loadActiveLicenseBinding(db)
|
|
if (!binding?.refreshToken || !binding.cloudStoreId) return { attempted: 0, reported: 0, blocked: 0, failed: 0 }
|
|
|
|
const reports = await db
|
|
.select()
|
|
.from(cloudTrafficReports)
|
|
.where(inArray(cloudTrafficReports.status, ['pending', 'failed']))
|
|
.orderBy(asc(cloudTrafficReports.createdAt))
|
|
.limit(limit)
|
|
|
|
const result = { attempted: reports.length, reported: 0, blocked: 0, failed: 0 }
|
|
for (const report of reports) {
|
|
const status = await syncTrafficReport({
|
|
db,
|
|
cloudBaseUrl,
|
|
refreshToken: binding.refreshToken,
|
|
storeId: binding.cloudStoreId,
|
|
report,
|
|
now,
|
|
})
|
|
result[status] += 1
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function syncTrafficReport(params: {
|
|
db: Database
|
|
cloudBaseUrl: string
|
|
refreshToken: string
|
|
storeId: string
|
|
report: TrafficReport
|
|
now: Date
|
|
}): Promise<'reported' | 'blocked' | 'failed'> {
|
|
const { db, cloudBaseUrl, refreshToken, storeId, report, now } = params
|
|
try {
|
|
const isStorageEgress = Boolean(report.storageId && report.unitBytes && report.creditsPerUnit)
|
|
const data = await postBoundCloudJson(
|
|
cloudBaseUrl,
|
|
`/api/stores/${encodeURIComponent(storeId)}/billing/usage-events`,
|
|
refreshToken,
|
|
isStorageEgress
|
|
? {
|
|
resource: 'storage_egress',
|
|
unit: 'byte',
|
|
bytes: report.bytes,
|
|
eventId: report.eventId,
|
|
idempotencyKey: report.eventId,
|
|
customerId: report.orgId,
|
|
source: report.source,
|
|
sourceId: report.sourceId,
|
|
usageContext: { storageId: report.storageId },
|
|
pricing: { unitQuantity: report.unitBytes!, creditsPerUnit: report.creditsPerUnit! },
|
|
}
|
|
: {
|
|
resource: 'traffic_egress',
|
|
bytes: report.bytes,
|
|
eventId: report.eventId,
|
|
idempotencyKey: report.eventId,
|
|
customerId: report.orgId,
|
|
},
|
|
)
|
|
const response = usageResponseSchema.parse(data)
|
|
if (!response.accepted || response.eventId !== report.eventId) throw new Error('cloud_usage_report_rejected')
|
|
await updateTrafficReport(db, report.eventId, 'reported', null, now)
|
|
return 'reported'
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'cloud_usage_report_failed'
|
|
if (message === 'insufficient_credits' || message === 'overage_cap_exceeded') {
|
|
await updateTrafficReport(db, report.eventId, 'blocked', message, now)
|
|
return 'blocked'
|
|
}
|
|
await updateTrafficReport(db, report.eventId, 'failed', message, now)
|
|
return 'failed'
|
|
}
|
|
}
|
|
|
|
async function loadTrafficReport(db: Database, eventId: string) {
|
|
const rows = await db.select().from(cloudTrafficReports).where(eq(cloudTrafficReports.eventId, eventId)).limit(1)
|
|
return rows[0]
|
|
}
|
|
|
|
function assertSameReport(
|
|
report: TrafficReport,
|
|
params: {
|
|
orgId: string
|
|
period: string
|
|
source: TrafficReportSource
|
|
sourceId: string
|
|
bytes: number
|
|
storageId: string
|
|
unitBytes: number
|
|
creditsPerUnit: number
|
|
},
|
|
) {
|
|
if (
|
|
report.orgId !== params.orgId ||
|
|
report.period !== params.period ||
|
|
report.source !== params.source ||
|
|
report.sourceId !== params.sourceId ||
|
|
report.bytes !== params.bytes ||
|
|
report.storageId !== params.storageId ||
|
|
report.unitBytes !== params.unitBytes ||
|
|
report.creditsPerUnit !== params.creditsPerUnit
|
|
) {
|
|
throw new Error('traffic_report_idempotency_conflict')
|
|
}
|
|
}
|
|
|
|
async function insertTrafficReport(
|
|
platform: Platform,
|
|
params: {
|
|
orgId: string
|
|
period: string
|
|
source: TrafficReportSource
|
|
sourceId: string
|
|
eventId: string
|
|
bytes: number
|
|
storageId: string
|
|
unitBytes: number
|
|
creditsPerUnit: number
|
|
status: ReportStatus
|
|
now: Date
|
|
},
|
|
) {
|
|
await platform.db.insert(cloudTrafficReports).values({
|
|
id: nanoid(),
|
|
orgId: params.orgId,
|
|
period: params.period,
|
|
source: params.source,
|
|
sourceId: params.sourceId,
|
|
eventId: params.eventId,
|
|
bytes: params.bytes,
|
|
storageId: params.storageId,
|
|
unitBytes: params.unitBytes,
|
|
creditsPerUnit: params.creditsPerUnit,
|
|
status: params.status,
|
|
error: null,
|
|
createdAt: params.now,
|
|
updatedAt: params.now,
|
|
})
|
|
}
|
|
|
|
async function updateTrafficReport(
|
|
db: Database,
|
|
eventId: string,
|
|
status: ReportStatus,
|
|
error: string | null,
|
|
now: Date,
|
|
) {
|
|
await db
|
|
.update(cloudTrafficReports)
|
|
.set({ status, error, updatedAt: now })
|
|
.where(eq(cloudTrafficReports.eventId, eventId))
|
|
}
|