+
diff --git a/frontend/src/assets/payments/wechat-pay.svg b/frontend/src/assets/payments/wechat-pay.svg
new file mode 100644
index 00000000..a13e25ad
--- /dev/null
+++ b/frontend/src/assets/payments/wechat-pay.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/frontend/src/entities/bill/api.test.ts b/frontend/src/entities/bill/api.test.ts
new file mode 100644
index 00000000..ea1da9d0
--- /dev/null
+++ b/frontend/src/entities/bill/api.test.ts
@@ -0,0 +1,76 @@
+import { expect, it } from 'vitest'
+import { createBillBackend } from '@/test/bill-backend'
+import { createBillApis } from './api'
+
+it('读取真实商品价格并映射订阅账期与两类余额', async () => {
+ const backend = createBillBackend()
+ backend.products[0].amount_fen = 3100
+ backend.subscription.active_subscription = {
+ tier: 2,
+ status: 1,
+ period_start: '2026-10-01T00:00:00Z',
+ period_end: '2026-11-01T00:00:00Z',
+ }
+ backend.subscription.expiring_credits = 1280
+ const apis = createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn })
+ expect((await apis.listProducts())[0]).toMatchObject({
+ id: 11,
+ amountFen: 3100,
+ credits: 400,
+ type: 2,
+ })
+ expect(await apis.getSubscription()).toEqual({
+ activeSubscription: {
+ tier: 2,
+ status: 1,
+ periodStart: '2026-10-01T00:00:00Z',
+ periodEnd: '2026-11-01T00:00:00Z',
+ },
+ nextSubscription: null,
+ perpetualCredits: 250,
+ expiringCredits: 1280,
+ })
+})
+
+it('自定义下单使用整数分和选中渠道,并复用认证边界', async () => {
+ const backend = createBillBackend()
+ const apis = createBillApis({
+ baseUrl: '/api',
+ fetchFn: backend.fetchFn,
+ getAccessToken: () => 'test-access',
+ })
+ const order = await apis.createOrder({ amountFen: 700, channel: 'wxpay' })
+ expect(order).toMatchObject({
+ orderNo: 'ORD202610150001',
+ amountFen: 700,
+ credits: 88,
+ channel: 'wxpay',
+ status: 0,
+ payUrl: 'https://cashier.example.test/submit.php?out_trade_no=ORD202610150001',
+ })
+ expect(backend.calls[0]).toEqual({
+ path: '/bill/orders',
+ method: 'POST',
+ body: { amount_fen: 700, channel: 'wxpay' },
+ authorization: 'Bearer test-access',
+ })
+})
+
+it('套餐下单只传商品编号,金额和积分采用后端订单快照', async () => {
+ const backend = createBillBackend()
+ const apis = createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn })
+ const order = await apis.createOrder({ productId: 12, channel: 'alipay' })
+ expect(backend.calls[0].body).toEqual({ product_id: 12, channel: 'alipay' })
+ expect(order).toMatchObject({ productId: 12, amountFen: 9900, credits: 1500, name: 'Pro' })
+})
+
+it('历史订单使用后端分页,关闭后再查询权威状态', async () => {
+ const backend = createBillBackend()
+ backend.orders.push(backend.makeOrder())
+ const apis = createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn })
+ const page = await apis.listOrders({ page: 2, pageSize: 5 })
+ expect(page).toEqual({ items: [], total: 1, page: 2, pageSize: 5 })
+ expect(backend.calls[0].path).toBe('/bill/orders?page=2&page_size=5')
+ await apis.closeOrder('ORD202610150001')
+ expect((await apis.getOrder('ORD202610150001')).status).toBe(2)
+})
diff --git a/frontend/src/entities/bill/api.ts b/frontend/src/entities/bill/api.ts
new file mode 100644
index 00000000..f557c0a4
--- /dev/null
+++ b/frontend/src/entities/bill/api.ts
@@ -0,0 +1,162 @@
+import { createApiClient, getApiAccessToken } from '@/shared/api'
+import type { ApiClient, ApiClientOptions } from '@/shared/api'
+import type { BillApis, BillOrder, BillProduct, BillSubscription } from './types'
+
+interface ProductDto {
+ id: number
+ type: number
+ tier: number | null
+ name: string
+ description: string | null
+ amount_fen: number
+ credits: number
+ original_amount_fen: number | null
+ badge: string | null
+}
+
+interface SubscriptionDto {
+ tier: number
+ status: number
+ period_start: string
+ period_end: string
+}
+
+interface SubscriptionStatusDto {
+ active_subscription?: SubscriptionDto | null
+ next_subscription?: SubscriptionDto | null
+ perpetual_credits: number
+ expiring_credits: number
+}
+
+interface OrderDto {
+ order_no: string
+ product_id: number | null
+ product_type: number
+ product_snapshot: Record
+ amount_fen: number
+ credits: number
+ channel: string
+ status: number
+ pay_url: string | null
+ expire_at: string
+ paid_at: string | null
+ closed_at: string | null
+ create_at: string
+}
+
+function toProduct(dto: ProductDto): BillProduct {
+ return {
+ id: dto.id,
+ type: dto.type,
+ tier: dto.tier,
+ name: dto.name,
+ description: dto.description,
+ amountFen: dto.amount_fen,
+ credits: dto.credits,
+ originalAmountFen: dto.original_amount_fen,
+ badge: dto.badge,
+ }
+}
+
+function toSubscription(dto: SubscriptionDto | null | undefined): BillSubscription | null {
+ return dto
+ ? {
+ tier: dto.tier,
+ status: dto.status,
+ periodStart: dto.period_start,
+ periodEnd: dto.period_end,
+ }
+ : null
+}
+
+function toOrder(dto: OrderDto): BillOrder {
+ return {
+ orderNo: dto.order_no,
+ productId: dto.product_id,
+ productType: dto.product_type,
+ name: typeof dto.product_snapshot.name === 'string' ? dto.product_snapshot.name : '积分充值',
+ amountFen: dto.amount_fen,
+ credits: dto.credits,
+ channel: dto.channel,
+ status: dto.status,
+ payUrl: dto.pay_url,
+ expireAt: dto.expire_at,
+ paidAt: dto.paid_at,
+ closedAt: dto.closed_at,
+ createdAt: dto.create_at,
+ }
+}
+
+export interface CreateBillApisOptions extends ApiClientOptions {
+ client?: ApiClient
+}
+
+export function createBillApis(options: CreateBillApisOptions = {}): BillApis {
+ const { client, ...clientOptions } = options
+ const protectedClient =
+ client ??
+ createApiClient({
+ ...clientOptions,
+ getAccessToken: clientOptions.getAccessToken ?? getApiAccessToken,
+ })
+ return {
+ async listProducts() {
+ const result = await protectedClient.requestList('/bill/products')
+ return result.items.map(toProduct)
+ },
+ async getSubscription() {
+ const dto = await protectedClient.request('/bill/subscription')
+ return {
+ activeSubscription: toSubscription(dto.active_subscription),
+ nextSubscription: toSubscription(dto.next_subscription),
+ perpetualCredits: dto.perpetual_credits,
+ expiringCredits: dto.expiring_credits,
+ }
+ },
+ async createOrder(input) {
+ return toOrder(
+ await protectedClient.request('/bill/orders', {
+ method: 'POST',
+ replayAfterAuth: false,
+ json: {
+ ...(input.productId === undefined
+ ? { amount_fen: input.amountFen }
+ : { product_id: input.productId }),
+ channel: input.channel,
+ },
+ }),
+ )
+ },
+ async listOrders(query = {}) {
+ const result = await protectedClient.requestList('/bill/orders', {
+ query: { page: query.page, page_size: query.pageSize },
+ })
+ return { ...result, items: result.items.map(toOrder) }
+ },
+ async getOrder(orderNo) {
+ return toOrder(
+ await protectedClient.request(`/bill/orders/${encodeURIComponent(orderNo)}`),
+ )
+ },
+ async closeOrder(orderNo) {
+ await protectedClient.request(`/bill/orders/${encodeURIComponent(orderNo)}/close`, {
+ method: 'POST',
+ replayAfterAuth: false,
+ })
+ },
+ }
+}
+
+let defaultApis: BillApis | undefined
+function getDefaultApis(): BillApis {
+ return (defaultApis ??= createBillApis())
+}
+
+export const billApis: BillApis = {
+ listProducts: () => getDefaultApis().listProducts(),
+ getSubscription: () => getDefaultApis().getSubscription(),
+ createOrder: (input) => getDefaultApis().createOrder(input),
+ listOrders: (query) => getDefaultApis().listOrders(query),
+ getOrder: (orderNo) => getDefaultApis().getOrder(orderNo),
+ closeOrder: (orderNo) => getDefaultApis().closeOrder(orderNo),
+}
diff --git a/frontend/src/entities/bill/index.ts b/frontend/src/entities/bill/index.ts
new file mode 100644
index 00000000..d988a89c
--- /dev/null
+++ b/frontend/src/entities/bill/index.ts
@@ -0,0 +1,12 @@
+export { billApis, createBillApis } from './api'
+export type { CreateBillApisOptions } from './api'
+export { BILL_ORDER_STATUS, BILL_PRODUCT_TYPE } from './types'
+export type {
+ BillApis,
+ BillOrder,
+ BillProduct,
+ BillSubscription,
+ CreateBillOrder,
+ PaymentChannel,
+ SubscriptionStatus,
+} from './types'
diff --git a/frontend/src/entities/bill/types.ts b/frontend/src/entities/bill/types.ts
new file mode 100644
index 00000000..782221e5
--- /dev/null
+++ b/frontend/src/entities/bill/types.ts
@@ -0,0 +1,69 @@
+import type { Paged, PageQuery } from '@/shared/pagination'
+
+export const BILL_PRODUCT_TYPE = { TOP_UP: 1, SUBSCRIPTION: 2 } as const
+export const BILL_ORDER_STATUS = {
+ PENDING: 0,
+ PAID: 1,
+ CLOSED: 2,
+ REFUNDING: 3,
+ REFUNDED: 4,
+} as const
+export type PaymentChannel = 'alipay' | 'wxpay'
+
+export interface BillProduct {
+ id: number
+ type: number
+ tier: number | null
+ name: string
+ description: string | null
+ /** 人民币整数分;仅在展示时转换为元。 */
+ amountFen: number
+ credits: number
+ originalAmountFen: number | null
+ badge: string | null
+}
+
+export interface BillSubscription {
+ tier: number
+ status: number
+ periodStart: string
+ periodEnd: string
+}
+
+export interface SubscriptionStatus {
+ activeSubscription: BillSubscription | null
+ nextSubscription: BillSubscription | null
+ perpetualCredits: number
+ expiringCredits: number
+}
+
+export interface BillOrder {
+ orderNo: string
+ productId: number | null
+ productType: number
+ name: string
+ amountFen: number
+ credits: number
+ channel: string
+ status: number
+ payUrl: string | null
+ expireAt: string
+ paidAt: string | null
+ closedAt: string | null
+ createdAt: string
+}
+
+/** 商品金额由服务端确定;自定义充值才传 amountFen。 */
+export type CreateBillOrder = (
+ | { productId: number; amountFen?: never }
+ | { amountFen: number; productId?: never }
+) & { channel: PaymentChannel }
+
+export interface BillApis {
+ listProducts(): Promise
+ getSubscription(): Promise
+ createOrder(input: CreateBillOrder): Promise
+ listOrders(query?: PageQuery): Promise>
+ getOrder(orderNo: string): Promise
+ closeOrder(orderNo: string): Promise
+}
diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts
index f3a83d12..8cfd728a 100644
--- a/frontend/src/entities/index.ts
+++ b/frontend/src/entities/index.ts
@@ -1,5 +1,18 @@
/** entities 唯一公开入口。外部不得绕过本文件访问内部文件。 */
+/* 账单 —— 商品、支付订单与订阅账期,以后端账本为准。 */
+export { billApis, createBillApis, BILL_ORDER_STATUS, BILL_PRODUCT_TYPE } from './bill'
+export type {
+ BillApis,
+ BillOrder,
+ BillProduct,
+ BillSubscription,
+ CreateBillOrder,
+ CreateBillApisOptions,
+ PaymentChannel,
+ SubscriptionStatus,
+} from './bill'
+
/* 用户 —— 认证传输与稳定会话身份 */
export { createUserApis, userApis } from './user'
export type { AuthTokens, CreateUserApisOptions, SendCodePurpose, User, UserApis } from './user'
diff --git a/frontend/src/features/billing/index.test.ts b/frontend/src/features/billing/index.test.ts
new file mode 100644
index 00000000..02565d95
--- /dev/null
+++ b/frontend/src/features/billing/index.test.ts
@@ -0,0 +1,88 @@
+// @vitest-environment jsdom
+import { act, cleanup, renderHook } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { createBillApis } from '@/entities'
+import { createBillBackend } from '@/test/bill-backend'
+import { usePaymentOrder } from './index'
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
+
+it('待支付时查询,确认支付后停止轮询且只通知一次', async () => {
+ vi.useFakeTimers()
+ const backend = createBillBackend()
+ backend.orders.push(backend.makeOrder())
+ const apis = createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn })
+ const onPaid = vi.fn()
+ const { result, unmount } = renderHook(() => usePaymentOrder('ORD202610150001', apis, onPaid))
+ await act(async () => {})
+ expect(result.current.order?.status).toBe(0)
+ const detailRequests = () =>
+ backend.calls.filter((call) => call.path === '/bill/orders/ORD202610150001').length
+ expect(detailRequests()).toBe(1)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5000)
+ })
+ expect(detailRequests()).toBe(2)
+ backend.pay('ORD202610150001')
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5000)
+ })
+ expect(result.current.order?.status).toBe(1)
+ expect(onPaid).toHaveBeenCalledTimes(1)
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(15_000)
+ })
+ expect(detailRequests()).toBe(3)
+ unmount()
+})
+
+it('快速切换订单后,迟到的旧订单不能覆盖当前订单或触发成功通知', async () => {
+ const backend = createBillBackend()
+ backend.orders.push(
+ backend.makeOrder({ order_no: 'ORDER_A', status: 1 }),
+ backend.makeOrder({ order_no: 'ORDER_B' }),
+ )
+ const releases: Record void> = {}
+ const fetchFn: typeof fetch = async (input, init) => {
+ const id = String(input).split('/').at(-1)!
+ await new Promise((resolve) => {
+ releases[id] = resolve
+ })
+ return backend.fetchFn(input, init)
+ }
+ const apis = createBillApis({ baseUrl: '/api', fetchFn })
+ const onPaid = vi.fn()
+ const { result, rerender } = renderHook(({ id }) => usePaymentOrder(id, apis, onPaid), {
+ initialProps: { id: 'ORDER_A' },
+ })
+ await act(async () => {})
+ rerender({ id: 'ORDER_B' })
+ await act(async () => {})
+ await act(async () => releases.ORDER_B())
+ expect(result.current.order?.orderNo).toBe('ORDER_B')
+ await act(async () => releases.ORDER_A())
+ expect(result.current.order?.orderNo).toBe('ORDER_B')
+ expect(onPaid).not.toHaveBeenCalled()
+})
+
+it('关单时后端发现已经付款,前端采用 PAID 而非自行标记 CLOSED', async () => {
+ const backend = createBillBackend()
+ backend.orders.push(backend.makeOrder())
+ const fetchFn: typeof fetch = async (input, init) => {
+ if (String(input).endsWith('/close')) {
+ backend.pay('ORD202610150001')
+ return new Response(JSON.stringify({ code: 200, message: 'success', data: null }))
+ }
+ return backend.fetchFn(input, init)
+ }
+ const apis = createBillApis({ baseUrl: '/api', fetchFn })
+ const onPaid = vi.fn()
+ const { result } = renderHook(() => usePaymentOrder('ORD202610150001', apis, onPaid))
+ await act(async () => {})
+ await act(async () => result.current.close())
+ expect(result.current.order?.status).toBe(1)
+ expect(onPaid).toHaveBeenCalledTimes(1)
+})
diff --git a/frontend/src/features/billing/index.ts b/frontend/src/features/billing/index.ts
new file mode 100644
index 00000000..7a44809a
--- /dev/null
+++ b/frontend/src/features/billing/index.ts
@@ -0,0 +1,182 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { BILL_ORDER_STATUS, billApis } from '@/entities'
+import type { BillApis, BillOrder, CreateBillOrder } from '@/entities'
+
+interface Resource {
+ data: T | null
+ status: 'loading' | 'ready' | 'error'
+ error: string | null
+ reload(): void
+}
+
+export function billingError(error: unknown): string {
+ return error instanceof Error && error.message ? error.message : '加载失败,请稍后重试'
+}
+
+/** 账单内的三个只读资源共用失效规则;切页、关闭或更换依赖后忽略旧请求。 */
+function useBillResource(read: () => Promise, enabled = true): Resource {
+ const [attempt, setAttempt] = useState(0)
+ const [result, setResult] = useState, 'reload'>>({
+ data: null,
+ status: 'loading',
+ error: null,
+ })
+ useEffect(() => {
+ if (!enabled) {
+ setResult({ data: null, status: 'loading', error: null })
+ return
+ }
+ let active = true
+ setResult((current) => ({ ...current, status: 'loading', error: null }))
+ void Promise.resolve()
+ .then(read)
+ .then(
+ (data) => {
+ if (active) setResult({ data, status: 'ready', error: null })
+ },
+ (error: unknown) => {
+ if (active)
+ setResult((current) => ({ ...current, status: 'error', error: billingError(error) }))
+ },
+ )
+ return () => {
+ active = false
+ }
+ }, [attempt, enabled, read])
+ const reload = useCallback(() => setAttempt((value) => value + 1), [])
+ return { ...result, reload }
+}
+
+export function useBillProducts(apis: BillApis = billApis) {
+ return useBillResource(useCallback(() => apis.listProducts(), [apis]))
+}
+
+export function useSubscriptionStatus(enabled = true, apis: BillApis = billApis) {
+ return useBillResource(
+ useCallback(() => apis.getSubscription(), [apis]),
+ enabled,
+ )
+}
+
+export function useBillOrders(page: number, apis: BillApis = billApis) {
+ return useBillResource(useCallback(() => apis.listOrders({ page, pageSize: 5 }), [apis, page]))
+}
+
+export function subscriptionName(tier: number | null | undefined): string {
+ return tier == null
+ ? 'Free'
+ : (({ 1: 'Plus', 2: 'Pro', 3: 'Max' } as Record)[tier] ?? '会员')
+}
+
+export function topUpQuote(amount: string): { amountFen: number; credits: number } | null {
+ const amountFen = Number(amount) * 100
+ // 自定义充值与后端采用同一粒度:>=500 分、整元、每 8 分向上取一积分。
+ if (!/^\d+$/.test(amount) || !Number.isSafeInteger(amountFen) || amountFen < 500) return null
+ return { amountFen, credits: Math.ceil(amountFen / 8) }
+}
+
+/** 只有服务端仍待支付的订单可继续付款;回跳参数不参与此判断。 */
+export function paymentUrl(order: BillOrder): string | null {
+ if (
+ order.status !== BILL_ORDER_STATUS.PENDING ||
+ Date.parse(order.expireAt) <= Date.now() ||
+ !Number.isFinite(Date.parse(order.expireAt))
+ )
+ return null
+ try {
+ const url = new URL(order.payUrl ?? '')
+ if (
+ !['http:', 'https:'].includes(url.protocol) ||
+ url.username ||
+ url.password ||
+ url.hostname === 'pay.example.com'
+ )
+ return null
+ return url.href
+ } catch {
+ return null
+ }
+}
+
+export function useBillCheckout(apis: BillApis) {
+ const submitting = useRef(false)
+ const mounted = useRef(true)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+ useEffect(() => {
+ mounted.current = true
+ return () => {
+ mounted.current = false
+ }
+ }, [])
+ const create = async (input: CreateBillOrder): Promise => {
+ if (submitting.current) return null
+ submitting.current = true
+ setBusy(true)
+ setError(null)
+ try {
+ const order = await apis.createOrder(input)
+ return mounted.current ? order : null
+ } catch (cause) {
+ if (mounted.current) setError(billingError(cause))
+ return null
+ } finally {
+ submitting.current = false
+ if (mounted.current) setBusy(false)
+ }
+ }
+ return { create, busy, error }
+}
+
+export function usePaymentOrder(orderNo: string | null, apis: BillApis, onPaid: () => void) {
+ const read = useCallback(() => apis.getOrder(orderNo!), [apis, orderNo])
+ const result = useBillResource(read, !!orderNo)
+ const { reload, status } = result
+ // 更换订单时旧 data 会保留到新请求结束;不能将旧订单误作当前订单展示或发货。
+ const order = result.data?.orderNo === orderNo ? result.data : null
+ const lastPaid = useRef(null)
+ const [closing, setClosing] = useState(false)
+ const [closeError, setCloseError] = useState(null)
+ const closingRef = useRef(false)
+
+ useEffect(() => {
+ if (order?.status === BILL_ORDER_STATUS.PAID && lastPaid.current !== order.orderNo) {
+ lastPaid.current = order.orderNo
+ onPaid()
+ }
+ }, [onPaid, order])
+
+ useEffect(() => {
+ if (!orderNo || order?.status !== BILL_ORDER_STATUS.PENDING || status !== 'ready') return
+ // 每次查询结束才安排下一次;隐藏页面不轮询,回到页面立即核对,避免并发补偿查询。
+ const timer = window.setTimeout(() => {
+ if (document.visibilityState !== 'hidden') reload()
+ }, 5000)
+ const onVisible = () => {
+ if (document.visibilityState !== 'hidden') reload()
+ }
+ document.addEventListener('visibilitychange', onVisible)
+ return () => {
+ window.clearTimeout(timer)
+ document.removeEventListener('visibilitychange', onVisible)
+ }
+ }, [orderNo, order?.status, status, reload])
+
+ const close = async () => {
+ if (!orderNo || closingRef.current) return
+ closingRef.current = true
+ setClosing(true)
+ setCloseError(null)
+ try {
+ await apis.closeOrder(orderNo)
+ } catch (cause) {
+ setCloseError(billingError(cause))
+ } finally {
+ // 关单也可能发现已付款并补发货;无论成功或失败都重新读取,不能在本地直接置 CLOSED。
+ reload()
+ setClosing(false)
+ closingRef.current = false
+ }
+ }
+ return { ...result, order, closing, closeError, close }
+}
diff --git a/frontend/src/pages/subscription/checkout-dialog.tsx b/frontend/src/pages/subscription/checkout-dialog.tsx
new file mode 100644
index 00000000..cbad9296
--- /dev/null
+++ b/frontend/src/pages/subscription/checkout-dialog.tsx
@@ -0,0 +1,268 @@
+import { useEffect, useRef, useState } from 'react'
+import { ArrowUpRight, SpinnerGap, X } from '@phosphor-icons/react'
+import { BILL_ORDER_STATUS } from '@/entities'
+import type { BillApis, BillOrder, PaymentChannel } from '@/entities'
+import alipayLogo from '@/assets/payments/alipay.svg'
+import wechatPayLogo from '@/assets/payments/wechat-pay.svg'
+import { billingError, paymentUrl, useBillCheckout, usePaymentOrder } from '@/features/billing'
+import { credits, money, moment, orderStatus } from './presentation'
+
+export interface PurchaseSelection {
+ name: string
+ amountFen: number
+ credits: number
+ productId?: number
+ kind: 'topup' | 'subscription'
+}
+
+interface CheckoutDialogProps {
+ selection: PurchaseSelection | null
+ orderNo: string | null
+ apis: BillApis
+ onClose(): void
+ onCreated(order: BillOrder): void
+ onPaid(): void
+ onOrderChanged(): void
+ navigateToPayment(url: string): void
+}
+
+export function CheckoutDialog({
+ selection,
+ orderNo,
+ apis,
+ onClose,
+ onCreated,
+ onPaid,
+ onOrderChanged,
+ navigateToPayment,
+}: CheckoutDialogProps) {
+ const dialog = useRef(null)
+ const [channel, setChannel] = useState('alipay')
+ const [navigationError, setNavigationError] = useState(null)
+ const [priceChanged, setPriceChanged] = useState(false)
+ const checkout = useBillCheckout(apis)
+ const payment = usePaymentOrder(orderNo, apis, onPaid)
+ const order = payment.order
+ const pending = order?.status === BILL_ORDER_STATUS.PENDING
+ const busy = checkout.busy || payment.closing
+ const url = order ? paymentUrl(order) : null
+ const error =
+ checkout.error ?? navigationError ?? (pending ? payment.closeError : null) ?? payment.error
+
+ useEffect(() => {
+ const element = dialog.current
+ element?.showModal()
+ return () => element?.close()
+ }, [])
+
+ function navigate(url: string) {
+ try {
+ navigateToPayment(url)
+ } catch (cause) {
+ setNavigationError(billingError(cause))
+ }
+ }
+
+ async function submit() {
+ if (!selection) return
+ const created = await checkout.create({
+ ...(selection.productId === undefined
+ ? { amountFen: selection.amountFen }
+ : { productId: selection.productId }),
+ channel,
+ })
+ if (!created) return
+ const changed =
+ created.amountFen !== selection.amountFen || created.credits !== selection.credits
+ setPriceChanged(changed)
+ // 先保存订单号到当前路由,返回或刷新页面后仍可核对;价格变化时停下来展示真实订单。
+ onCreated(created)
+ const target = paymentUrl(created)
+ if (target && !changed) navigate(target)
+ }
+
+ const title = selection
+ ? selection.name
+ : order?.status === BILL_ORDER_STATUS.PAID
+ ? '支付成功'
+ : order?.status === BILL_ORDER_STATUS.CLOSED
+ ? '订单已关闭'
+ : pending
+ ? '等待支付'
+ : order
+ ? (orderStatus[order.status] ?? '订单状态待确认')
+ : '查询订单'
+
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/subscription/index.test.tsx b/frontend/src/pages/subscription/index.test.tsx
index 53148899..7f08a9b7 100644
--- a/frontend/src/pages/subscription/index.test.tsx
+++ b/frontend/src/pages/subscription/index.test.tsx
@@ -1,46 +1,169 @@
// @vitest-environment jsdom
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, expect, it } from 'vitest'
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { MemoryRouter } from 'react-router'
+import { createBillApis } from '@/entities'
+import { createBillBackend } from '@/test/bill-backend'
import { SubscriptionPage } from './index'
+beforeEach(() => {
+ HTMLDialogElement.prototype.showModal = function () {
+ this.setAttribute('open', '')
+ }
+ HTMLDialogElement.prototype.close = function () {
+ this.removeAttribute('open')
+ }
+})
afterEach(cleanup)
-it('rounds custom credits up and rejects fractional or insufficient payments', () => {
+function setup(path = '/account/subscription', backend = createBillBackend()) {
+ const navigateToPayment = vi.fn()
+ const apis = createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn })
render(
-
-
+
+
,
)
+ return { backend, navigateToPayment }
+}
+
+it('展示后端商品价格和真实订阅,不把已有会员显示成 Free', async () => {
+ const backend = createBillBackend()
+ backend.products[0].amount_fen = 3100
+ backend.subscription.active_subscription = {
+ tier: 2,
+ status: 1,
+ period_start: '2026-10-01T00:00:00Z',
+ period_end: '2026-11-01T00:00:00Z',
+ }
+ backend.subscription.expiring_credits = 1280
+ setup(undefined, backend)
+ expect(await screen.findByText('31')).toBeTruthy()
+ expect(screen.getByLabelText('当前方案').textContent).toBe('Pro')
+ expect(screen.getByLabelText('本期积分').textContent).toBe('1,280')
+ expect(screen.getByLabelText('长期积分').textContent).toBe('250')
+})
+
+it('自定义金额先校验,再按整数分下单;重复点击不重复创建', async () => {
+ const { backend, navigateToPayment } = setup('/account/subscription?tab=credits')
+ await screen.findByLabelText('长期积分')
const input = screen.getByLabelText('自定义充值金额')
- fireEvent.change(input, { target: { value: '7' } })
- expect(screen.getByText('88')).toBeTruthy()
const buy = screen.getByRole('button', { name: '充值积分' }) as HTMLButtonElement
- expect(buy.disabled).toBe(false)
for (const value of ['4', '5.5', '', 'abc']) {
fireEvent.change(input, { target: { value } })
expect(buy.disabled).toBe(true)
- expect(screen.getByRole('alert')).toBeTruthy()
}
+ fireEvent.change(input, { target: { value: '7' } })
+ expect(screen.getByLabelText('本次到账积分').textContent).toContain('88')
+ fireEvent.click(buy)
+ const dialog = screen.getByRole('dialog')
+ fireEvent.click(within(dialog).getByRole('radio', { name: '微信支付' }))
+ const submit = within(dialog).getByRole('button', { name: '前往支付' })
+ fireEvent.click(submit)
+ fireEvent.click(submit)
+ await waitFor(() => expect(navigateToPayment).toHaveBeenCalledTimes(1))
+ expect(backend.calls.filter((call) => call.method === 'POST')).toEqual([
+ expect.objectContaining({ path: '/bill/orders', body: { amount_fen: 700, channel: 'wxpay' } }),
+ ])
+ expect(navigateToPayment).toHaveBeenCalledWith(
+ 'https://cashier.example.test/submit.php?out_trade_no=ORD202610150001',
+ )
})
-it('highlights a selected plan and explains that payment is not connected', () => {
- HTMLDialogElement.prototype.showModal = function () {
- this.setAttribute('open', '')
- }
- render(
-
-
- ,
- )
- fireEvent.click(screen.getByText('Pro'))
- expect(screen.getByText('Pro').closest('article')?.className).toContain('selected')
+it('订阅下单传商品编号,不由页面决定最终价格', async () => {
+ const { backend, navigateToPayment } = setup()
+ fireEvent.click(await screen.findByRole('button', { name: '选择 Pro' }))
+ fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '前往支付' }))
+ await waitFor(() => expect(navigateToPayment).toHaveBeenCalledTimes(1))
+ expect(backend.calls.find((call) => call.method === 'POST')?.body).toEqual({
+ product_id: 12,
+ channel: 'alipay',
+ })
+})
- fireEvent.click(screen.getByRole('button', { name: '已选择' }))
+it('下单时价格变更必须重新确认,不能直接跳转付款', async () => {
+ const { backend, navigateToPayment } = setup()
+ fireEvent.click(await screen.findByRole('button', { name: '选择 Pro' }))
+ backend.products[1].amount_fen = 10500
+ fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '前往支付' }))
const dialog = screen.getByRole('dialog')
- expect(dialog.textContent).toContain('¥99')
- expect(dialog.textContent).toContain('1,500 积分')
- expect(dialog.textContent).toContain('支付还没有接入,请联系管理员')
- expect(dialog.textContent).toContain('不会扣款')
- expect(screen.getByText('当前订阅方案:')).toBeTruthy()
+ expect(await within(dialog).findByText('¥105')).toBeTruthy()
+ expect(navigateToPayment).not.toHaveBeenCalled()
+ expect(await within(dialog).findByText('价格或积分已更新,请核对订单后继续支付。')).toBeTruthy()
+})
+
+it('过期订单不能继续付款,界面等待后端确认终态', async () => {
+ const backend = createBillBackend()
+ backend.orders.push(backend.makeOrder({ expire_at: '2020-01-01T00:00:00Z' }))
+ const { navigateToPayment } = setup('/account/subscription?order_no=ORD202610150001', backend)
+ const dialog = await screen.findByRole('dialog')
+ const button = (await within(dialog).findByRole('button', {
+ name: '继续支付',
+ })) as HTMLButtonElement
+ expect(button.disabled).toBe(true)
+ expect(within(dialog).queryByText('订单已关闭')).toBeNull()
+ expect(navigateToPayment).not.toHaveBeenCalled()
+})
+
+it('回跳参数不能证明付款成功,订单确认支付后才刷新两类余额', async () => {
+ const backend = createBillBackend()
+ backend.orders.push(backend.makeOrder())
+ setup('/account/subscription?out_trade_no=ORD202610150001&trade_status=TRADE_SUCCESS', backend)
+ const dialog = await screen.findByRole('dialog')
+ await within(dialog).findByRole('button', { name: '继续支付' })
+ expect(within(dialog).queryByText('支付成功')).toBeNull()
+ expect(screen.getByLabelText('长期积分').textContent).toBe('250')
+ backend.pay('ORD202610150001')
+ fireEvent.click(within(dialog).getByRole('button', { name: '刷新支付结果' }))
+ expect(await within(dialog).findByText('支付成功')).toBeTruthy()
+ await waitFor(() => expect(screen.getByLabelText('长期积分').textContent).toBe('338'))
+})
+
+it('后端没有在售套餐时展示空态,不填充虚构套餐', async () => {
+ const backend = createBillBackend()
+ backend.products.splice(0)
+ setup(undefined, backend)
+ expect(await screen.findByText('暂无在售套餐')).toBeTruthy()
+ expect(screen.queryByRole('button', { name: '选择 Pro' })).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: '积分充值' }))
+ expect(screen.getByRole('button', { name: '充值积分' }).hasAttribute('disabled')).toBe(false)
+})
+
+it.each(['javascript:alert(1)', null])('无效支付地址不能跳转:%s', async (payUrl) => {
+ const backend = createBillBackend()
+ backend.orders.push(backend.makeOrder({ pay_url: payUrl }))
+ const { navigateToPayment } = setup('/account/subscription?order_no=ORD202610150001', backend)
+ const dialog = await screen.findByRole('dialog')
+ const pay = (await within(dialog).findByRole('button', { name: '继续支付' })) as HTMLButtonElement
+ expect(pay.disabled).toBe(true)
+ expect(navigateToPayment).not.toHaveBeenCalled()
+})
+
+it('关闭待支付订单后采用服务端状态,不能继续付款', async () => {
+ const backend = createBillBackend()
+ backend.orders.push(backend.makeOrder())
+ setup('/account/subscription?order_no=ORD202610150001', backend)
+ const dialog = await screen.findByRole('dialog')
+ fireEvent.click(await within(dialog).findByRole('button', { name: '关闭订单' }))
+ expect(await within(dialog).findByText('订单已关闭')).toBeTruthy()
+ expect(within(dialog).queryByRole('button', { name: '继续支付' })).toBeNull()
+})
+
+it('下单失败可重试,不自动重复请求或伪装成功', async () => {
+ const backend = createBillBackend()
+ const fetchFn = backend.fetchFn
+ backend.fetchFn = async (input, init) =>
+ init?.method === 'POST'
+ ? new Response(JSON.stringify({ code: 500, message: '支付网关暂不可用', data: null }))
+ : fetchFn(input, init)
+ const { navigateToPayment } = setup(undefined, backend)
+ fireEvent.click(await screen.findByRole('button', { name: '选择 Plus' }))
+ await act(async () =>
+ fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '前往支付' })),
+ )
+ expect(await screen.findByRole('alert')).toHaveProperty('textContent', '支付网关暂不可用')
+ expect(navigateToPayment).not.toHaveBeenCalled()
+ expect((screen.getByRole('button', { name: '前往支付' }) as HTMLButtonElement).disabled).toBe(
+ false,
+ )
})
diff --git a/frontend/src/pages/subscription/index.tsx b/frontend/src/pages/subscription/index.tsx
index 58159717..f24ee7f4 100644
--- a/frontend/src/pages/subscription/index.tsx
+++ b/frontend/src/pages/subscription/index.tsx
@@ -1,51 +1,106 @@
-import { useRef, useState } from 'react'
+import { useCallback, useState } from 'react'
import { useSearchParams } from 'react-router'
-import { ArrowUpRight, Check, Plus, X } from '@phosphor-icons/react'
+import { ArrowUpRight, Check, Plus } from '@phosphor-icons/react'
+import { BILL_PRODUCT_TYPE, billApis } from '@/entities'
+import type { BillApis, BillOrder } from '@/entities'
+import {
+ subscriptionName,
+ topUpQuote,
+ useBillOrders,
+ useBillProducts,
+ useSubscriptionStatus,
+} from '@/features/billing'
+import { CheckoutDialog } from './checkout-dialog'
+import type { PurchaseSelection } from './checkout-dialog'
+import { OrderHistory } from './order-history'
+import { credits as formatCredits, moment } from './presentation'
import './subscription.css'
-const plans = [
- {
- name: 'Plus',
- price: 29,
- credits: 400,
- saving: '9.4%',
- unit: '0.0725',
- },
- {
- name: 'Pro',
- price: 99,
- credits: 1500,
- saving: '17.5%',
- unit: '0.0660',
- },
- {
- name: 'Max',
- price: 199,
- credits: 3200,
- saving: '22.3%',
- unit: '0.0622',
- },
-]
const amounts = [5, 10, 20, 30, 40, 100]
-export function SubscriptionPage() {
+interface SubscriptionPageProps {
+ apis?: BillApis
+ navigateToPayment?: (url: string) => void
+}
+
+const navigateToCashier = (url: string) => window.location.assign(url)
+
+export function SubscriptionPage({
+ apis = billApis,
+ navigateToPayment = navigateToCashier,
+}: SubscriptionPageProps = {}) {
const [params, setParams] = useSearchParams()
const recharge = params.get('tab') === 'credits'
+ const orderNo = params.get('order_no') || params.get('out_trade_no')
const [amount, setAmount] = useState('20')
- const [selectedPlan, setSelectedPlan] = useState(null)
- const [selection, setSelection] = useState<{
- name: string
- price: number
- credits: number
- } | null>(null)
- const dialog = useRef(null)
+ const [selectedPlan, setSelectedPlan] = useState(null)
+ const [selection, setSelection] = useState(null)
+ const [page, setPage] = useState(1)
+ const products = useBillProducts(apis)
+ const subscription = useSubscriptionStatus(true, apis)
+ const history = useBillOrders(page, apis)
+ const reloadSubscription = subscription.reload
+ const reloadHistory = history.reload
+ const onPaid = useCallback(() => {
+ reloadSubscription()
+ reloadHistory()
+ }, [reloadSubscription, reloadHistory])
+ const plans = products.data?.filter((item) => item.type === BILL_PRODUCT_TYPE.SUBSCRIPTION) ?? []
+ const current = subscription.data?.activeSubscription
+ const next = subscription.data?.nextSubscription
+ const currentPlan = subscription.data ? subscriptionName(current?.tier) : '—'
+ const quote = topUpQuote(amount)
+ const valid = quote !== null
const value = Number(amount)
- const valid = /^\d+$/.test(amount) && Number.isSafeInteger(value * 100) && value >= 5
- const credits = valid ? Math.ceil((value * 100) / 8) : 0
- function preview(item: { name: string; price: number; credits: number }) {
- setSelection(item)
- dialog.current?.showModal()
+ const credits = quote?.credits ?? 0
+
+ function changeTab(tab: 'plans' | 'credits') {
+ setParams((current) => {
+ const next = new URLSearchParams(current)
+ if (tab === 'credits') next.set('tab', 'credits')
+ else next.delete('tab')
+ return next
+ })
+ }
+
+ function showOrder(order: BillOrder) {
+ // 订单号写回路由后才切换弹窗内容,避免下单结束与路由更新之间卸载弹窗。
+ setParams(
+ (current) => {
+ const next = new URLSearchParams(current)
+ next.delete('out_trade_no')
+ next.set('order_no', order.orderNo)
+ return next
+ },
+ { replace: true },
+ )
+ reloadHistory()
}
+
+ function closeCheckout() {
+ setSelection(null)
+ setParams(
+ (current) => {
+ const next = new URLSearchParams(current)
+ for (const key of [
+ 'order_no',
+ 'out_trade_no',
+ 'trade_no',
+ 'trade_status',
+ 'sign',
+ 'sign_type',
+ 'money',
+ 'type',
+ 'pid',
+ 'name',
+ ])
+ next.delete(key)
+ return next
+ },
+ { replace: true },
+ )
+ }
+
return (
@@ -53,10 +108,10 @@ export function SubscriptionPage() {
-
@@ -101,7 +156,7 @@ export function SubscriptionPage() {
本次将获得
-
+
{valid ? credits.toLocaleString('zh-CN') : '—'}
积分
@@ -112,51 +167,88 @@ export function SubscriptionPage() {
当前方案
- Free
+ {currentPlan}
preview({ name: '积分充值', price: value, credits })}
+ onClick={() => {
+ if (quote) setSelection({ name: '积分充值', ...quote, kind: 'topup' })
+ }}
>
充值积分
充值只增加积分,不改变会员资格。
+ ) : products.error ? (
+