From fde296f50fb310015de406017b1f46a01c0ff5f9 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 15 Oct 2026 21:05:02 +0800 Subject: [PATCH 1/9] feat(billing): add the billing API boundary The checkout needs the existing backend billing contract. Map products, subscriptions and orders through the shared API client. Keep money in integer fen and disable automatic mutation replay. --- frontend/src/entities/bill/api.ts | 162 ++++++++++++++++++++++++++++ frontend/src/entities/bill/index.ts | 12 +++ frontend/src/entities/bill/types.ts | 69 ++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 frontend/src/entities/bill/api.ts create mode 100644 frontend/src/entities/bill/index.ts create mode 100644 frontend/src/entities/bill/types.ts 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 +} From 16c4b3a53331f4ed7d93995910b44a3c4b33baae Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 15 Oct 2026 21:05:10 +0800 Subject: [PATCH 2/9] feat(billing): manage server-confirmed checkout state Payment state must follow the backend order lifecycle. Add billing resources with stale-response guards and sequential polling. Prevent duplicate submissions and re-read orders after close attempts. --- frontend/src/entities/index.ts | 13 ++ frontend/src/features/billing/index.ts | 182 +++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 frontend/src/features/billing/index.ts 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.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 } +} From 5be90500389c1b673d782a86eaf88666e4f9885f Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 15 Oct 2026 21:05:16 +0800 Subject: [PATCH 3/9] feat(billing): add authentic payment channel marks Checkout needs recognizable Alipay and WeChat Pay choices. Bundle the official source artwork with its provenance and license notice. Render payment branding locally without an external image request. --- frontend/src/assets/payments/NOTICE.md | 32 +++++++++++++++++++++ frontend/src/assets/payments/alipay.svg | 4 +++ frontend/src/assets/payments/wechat-pay.svg | 2 ++ 3 files changed, 38 insertions(+) create mode 100644 frontend/src/assets/payments/NOTICE.md create mode 100644 frontend/src/assets/payments/alipay.svg create mode 100644 frontend/src/assets/payments/wechat-pay.svg diff --git a/frontend/src/assets/payments/NOTICE.md b/frontend/src/assets/payments/NOTICE.md new file mode 100644 index 00000000..8013c5d9 --- /dev/null +++ b/frontend/src/assets/payments/NOTICE.md @@ -0,0 +1,32 @@ +# Payment brand artwork + +Alipay: ant-design/ant-design-icons, `alipay-circle.svg`. +Source: https://github.com/ant-design/ant-design-icons/blob/master/packages/icons-svg/svg/filled/alipay-circle.svg + +MIT LICENSE + +Copyright (c) 2018-present Ant UED, https://xtech.antfin.com/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +WeChat Pay: the unchanged mark from the official RGB artwork in wepayui/wepayui-design. +Source: https://github.com/wepayui/wepayui-design/tree/master/微信支付LOGO/线上RGB模式 +Copyright Tencent. Brand rights remain with Tencent; use follows the official brand guidance. diff --git a/frontend/src/assets/payments/alipay.svg b/frontend/src/assets/payments/alipay.svg new file mode 100644 index 00000000..d39da829 --- /dev/null +++ b/frontend/src/assets/payments/alipay.svg @@ -0,0 +1,4 @@ + + + + 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 @@ + + From 9160fa4630a368eef926e7eedb9a40ad013d2895 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 15 Oct 2026 21:05:23 +0800 Subject: [PATCH 4/9] feat(billing): add checkout order views Purchases need channel selection and a resumable order view. Add checkout and history components using the existing dialog styling. Require server-confirmed status before presenting a completed payment. --- .../pages/subscription/checkout-dialog.tsx | 268 ++++++++++++++++++ .../src/pages/subscription/order-history.tsx | 86 ++++++ .../src/pages/subscription/presentation.ts | 38 +++ 3 files changed, 392 insertions(+) create mode 100644 frontend/src/pages/subscription/checkout-dialog.tsx create mode 100644 frontend/src/pages/subscription/order-history.tsx create mode 100644 frontend/src/pages/subscription/presentation.ts 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 ( + { + event.preventDefault() + if (!busy) onClose() + }} + onClick={(event) => { + if (event.target !== event.currentTarget || busy) return + const rect = event.currentTarget.getBoundingClientRect() + if ( + event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom + ) + onClose() + }} + className="subscription-dialog max-h-[calc(100dvh-2rem)] overflow-y-auto" + > + +

{title}

+ {selection || order ? ( +
+ {money(selection?.amountFen ?? order!.amountFen)} + {credits(selection?.credits ?? order!.credits)} 积分 +
+ ) : ( +

正在查询订单…

+ )} + + {selection ? ( + <> +
+ 支付方式 +
+ {( + [ + { value: 'alipay', name: '支付宝', logo: alipayLogo }, + { value: 'wxpay', name: '微信支付', logo: wechatPayLogo }, + ] as const + ).map((item) => ( + + ))} +
+
+

+ {selection.kind === 'subscription' + ? '按月手动续购。续购接续当前账期,积分在对应账期开始时发放。' + : '充值积分长期有效,不改变会员资格。'} +

+ + + ) : order ? ( + <> + {pending ? ( + <> +

+ {priceChanged + ? '价格或积分已更新,请核对订单后继续支付。' + : url + ? `请在 ${moment(order.expireAt)} 前完成付款,支付结果会自动更新。` + : Date.parse(order.expireAt) <= Date.now() + ? '此订单已超时,请刷新支付结果确认状态。' + : '暂时无法打开收银台,请联系管理员。'} +

+
+ + +
+ + + ) : ( + <> +

+ {order.status === BILL_ORDER_STATUS.PAID + ? '支付已确认,可查看最新积分与订阅状态。' + : order.status === BILL_ORDER_STATUS.CLOSED + ? '此订单无法继续付款,可以重新选择套餐或充值金额。' + : '请以此处查询到的订单状态为准。'} +

+ + + )} +

订单号 {order.orderNo}

+ + ) : error ? ( + + ) : null} + {error && ( +
+ {error} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/subscription/order-history.tsx b/frontend/src/pages/subscription/order-history.tsx new file mode 100644 index 00000000..a8e155cc --- /dev/null +++ b/frontend/src/pages/subscription/order-history.tsx @@ -0,0 +1,86 @@ +import type { BillOrder } from '@/entities' +import type { useBillOrders } from '@/features/billing' +import { Pagination } from '@/shared/ui' +import { credits, moment, money, orderStatus, secondaryButton } from './presentation' + +export function OrderHistory({ + resource, + onSelect, + onPageChange, +}: { + resource: ReturnType + onSelect(order: BillOrder): void + onPageChange(page: number): void +}) { + return ( +
+
+

+ 订单记录 +

+ +
+ {resource.error ? ( +
+

+ {resource.error} +

+ +
+ ) : !resource.data ? ( +

+ 正在加载订单… +

+ ) : resource.data.items.length === 0 ? ( +

还没有订单。

+ ) : ( + <> +
    + {resource.data.items.map((order) => ( +
  • + +
  • + ))} +
+ + + )} +
+ ) +} diff --git a/frontend/src/pages/subscription/presentation.ts b/frontend/src/pages/subscription/presentation.ts new file mode 100644 index 00000000..c99b79d0 --- /dev/null +++ b/frontend/src/pages/subscription/presentation.ts @@ -0,0 +1,38 @@ +export function money(amountFen: number): string { + return `¥${(amountFen / 100).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}` +} + +export function credits(value: number): string { + return value.toLocaleString('zh-CN') +} + +export function moment(value: string): string { + const date = new Date(value) + return Number.isFinite(date.getTime()) + ? new Intl.DateTimeFormat('zh-CN', { + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date) + : '时间未知' +} + +export const orderStatus: Record = { + 0: '待支付', + 1: '已支付', + 2: '已关闭', + 3: '退款中', + 4: '已退款', +} +import { productControlClass } from '@/shared/ui' + +export const primaryButton = productControlClass( + 'primary', + 'justify-center disabled:cursor-not-allowed disabled:opacity-50', +) +export const secondaryButton = productControlClass( + 'secondary', + 'justify-center disabled:cursor-not-allowed disabled:opacity-50', +) From 9533ad502503203731921395b00103da1768f6a8 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 15 Oct 2026 21:05:30 +0800 Subject: [PATCH 5/9] feat(billing): connect the existing subscription page The purchase controls currently stop at a placeholder dialog. Bind the original page to products, checkout, subscription balances and order history. Preserve the stylesheet and recover orders from return parameters. --- frontend/src/pages/subscription/index.tsx | 289 +++++++++++++++------- 1 file changed, 206 insertions(+), 83 deletions(-) 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}
充值只增加积分,不改变会员资格。 + ) : products.error ? ( +
+

+ {products.error} +

+ +
+ ) : products.data === null ? ( +

+ 正在加载套餐… +

+ ) : plans.length === 0 ? ( +

暂无在售套餐

) : (
- {plans.map((plan, index) => ( + {plans.map((plan) => (
setSelectedPlan(plan.name)} + key={plan.id} + className={`subscription-plan ${plan.badge ? 'recommended' : ''} ${selectedPlan === plan.id ? 'selected' : ''}`} + onClick={() => setSelectedPlan(plan.id)} onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { + if ( + event.target === event.currentTarget && + (event.key === 'Enter' || event.key === ' ') + ) { event.preventDefault() - setSelectedPlan(plan.name) + setSelectedPlan(plan.id) } }} role="button" tabIndex={0} - aria-pressed={selectedPlan === plan.name} + aria-pressed={selectedPlan === plan.id} >
- {index === 1 && 最受欢迎} + {plan.badge && {plan.badge}}

{plan.name}

¥ - {plan.price} + {plan.amountFen / 100} / 月
{plan.credits.toLocaleString('zh-CN')} 积分 - 省 {plan.saving} + {plan.amountFen < plan.credits * 8 && ( + 省 {((1 - plan.amountFen / (plan.credits * 8)) * 100).toFixed(1)}% + )}
-
@@ -173,7 +265,10 @@ export function SubscriptionPage() { 会员专属能力筹备中

-

约 ¥{plan.unit} / 积分

+

+ 约 ¥{plan.credits > 0 ? (plan.amountFen / 100 / plan.credits).toFixed(4) : '—'} / + 积分 +

))}
@@ -181,43 +276,71 @@ export function SubscriptionPage() {

- 当前订阅方案:Free + 当前订阅方案:{currentPlan}

+ {subscription.data && ( + <> +

+ 长期积分{' '} + + {formatCredits(subscription.data.perpetualCredits)} + {' '} + · 本期积分{' '} + + {formatCredits(subscription.data.expiringCredits)} + +

+ {current && ( +

+ 当前账期:{moment(current.periodStart)} 至 {moment(current.periodEnd)} +

+ )} + {next && ( +

+ 已续购 {subscriptionName(next.tier)}:{moment(next.periodStart)} 至{' '} + {moment(next.periodEnd)} +

+ )} + + )} + {subscription.error && ( +
+ {subscription.error} + +
+ )}

Free 包含的功能

Free 同样支持标准制作、局部重做与透明素材导出;生成任务按实际报价消耗积分。

暂未开放的会员权益

- 更好的模型、高质量处理、三渲二暂未开放。会员期限、积分有效期与续购规则将在开售前说明。 + 更好的模型、高质量处理、三渲二暂未开放。订阅按月手动续购,本期积分到期失效;充值积分长期有效。

- { - if (event.target === event.currentTarget) dialog.current?.close() - }} - aria-labelledby="purchase-title" - > - -

{selection?.name}

-
- ¥{selection?.price} - {selection?.credits.toLocaleString('zh-CN')} 积分 -
-

支付还没有接入,请联系管理员。当前不会扣款,也不会变更积分或会员资格。

- -
+
+ 订单记录 + +
+ {(selection || orderNo) && ( + + )}
) } From 85160f823aa6e42ab44a04f9ce5adec9cf2aa5ad Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 15 Oct 2026 21:05:38 +0800 Subject: [PATCH 6/9] feat(account): show the current billing subscription The account menu always labels the subscription as Free. Read the subscription when the authenticated account menu opens. Display the actual tier and link existing members to subscription management. --- frontend/src/app/layout/app-header.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 26c41579..5bc09d53 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -4,10 +4,11 @@ import { Link, useLocation, useNavigate } from 'react-router' import defaultAccountAvatar from '@/assets/account/avatars/windup-witch-avatar.webp' import { quotaApis as defaultQuotaApis } from '@/entities' -import type { QuotaApis } from '@/entities' +import type { BillApis, QuotaApis } from '@/entities' import { readActiveRun, subscribeActiveRun } from '@/features/active-run' import { AUTH_SESSION_STORAGE_PREFIX, useAuthSession } from '@/features/auth-session' import { useQuotaBalance } from '@/features/quota' +import { subscriptionName, useSubscriptionStatus } from '@/features/billing' import { productControlClass, productMenuItemClass, @@ -28,6 +29,7 @@ interface ProductNavigationItem { export interface AppHeaderProps { quotaApis?: QuotaApis + billApis?: BillApis variant?: 'product' | 'marketing' } @@ -117,6 +119,7 @@ function WaveText({ playId, text }: { playId: number; text: string }) { */ export function AppHeader({ quotaApis = defaultQuotaApis, + billApis, variant = 'product', }: AppHeaderProps = {}) { const { pathname, search, hash } = useLocation() @@ -131,6 +134,10 @@ export function AppHeader({ accountMenu.mounted && session.state.status === 'authenticated', quotaApis, ) + const subscription = useSubscriptionStatus( + accountMenu.mounted && session.state.status === 'authenticated', + billApis, + ) const [wave, setWave] = useState({ entry: '', playId: 0 }) const activeRunUserId = session.state.status === 'authenticated' ? session.state.user.id : null const [activeRunId, setActiveRunId] = useState(null) @@ -460,9 +467,13 @@ export function AppHeader({ >
-

Free

+

+ {subscription.data + ? subscriptionName(subscription.data.activeSubscription?.tier) + : '—'} +

- 当前订阅 · 标准创作 + {subscription.error ? '订阅信息暂不可用' : '当前订阅 · 标准创作'}

- 升级 + {subscription.data?.activeSubscription ? '管理订阅' : '升级'}
Date: Thu, 15 Oct 2026 21:05:44 +0800 Subject: [PATCH 7/9] test(billing): verify the backend transport contract The frontend adapter must match the existing billing responses. Exercise request payloads and response mapping through an isolated HTTP fixture. Cover authenticated creation, server prices, pagination and close requests. --- frontend/src/entities/bill/api.test.ts | 76 +++++++++++ frontend/src/test/bill-backend.ts | 176 +++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 frontend/src/entities/bill/api.test.ts create mode 100644 frontend/src/test/bill-backend.ts 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/test/bill-backend.ts b/frontend/src/test/bill-backend.ts new file mode 100644 index 00000000..e84b9c44 --- /dev/null +++ b/frontend/src/test/bill-backend.ts @@ -0,0 +1,176 @@ +/** HTTP 替身复刻 openapi.json;生产入口不引用此模块。 */ +export function createBillBackend() { + const products = [ + { + id: 11, + type: 2, + tier: 1, + name: 'Plus', + description: null, + amount_fen: 2900, + credits: 400, + original_amount_fen: null, + badge: null, + sort_order: 1, + extra_meta: {}, + }, + { + id: 12, + type: 2, + tier: 2, + name: 'Pro', + description: null, + amount_fen: 9900, + credits: 1500, + original_amount_fen: null, + badge: '最受欢迎', + sort_order: 2, + extra_meta: {}, + }, + { + id: 13, + type: 2, + tier: 3, + name: 'Max', + description: null, + amount_fen: 19900, + credits: 3200, + original_amount_fen: null, + badge: null, + sort_order: 3, + extra_meta: {}, + }, + ] + const subscription = { + active_subscription: null as null | { + tier: number + status: number + period_start: string + period_end: string + }, + next_subscription: null as null | { + tier: number + status: number + period_start: string + period_end: string + }, + perpetual_credits: 250, + expiring_credits: 0, + } + const calls: { + path: string + method: string + body: Record | null + authorization: string | null + }[] = [] + const makeOrder = ( + overrides: Partial<{ + order_no: string + product_id: number | null + product_type: number + amount_fen: number + credits: number + channel: string + status: number + pay_url: string | null + expire_at: string + product_snapshot: Record + paid_at: string | null + closed_at: string | null + }> = {}, + ) => ({ + order_no: 'ORD202610150001', + product_id: null, + product_type: 1, + product_snapshot: { name: '积分充值', amount_fen: 700, credits: 88 }, + amount_fen: 700, + credits: 88, + channel: 'alipay', + provider: 1, + status: 0, + pay_url: 'https://cashier.example.test/submit.php?out_trade_no=ORD202610150001', + expire_at: new Date(Date.now() + 900_000).toISOString(), + paid_at: null as string | null, + closed_at: null as string | null, + create_at: '2026-10-15T10:00:00Z', + ...overrides, + }) + const orders: ReturnType[] = [] + const response = (data: unknown, extra = {}) => + new Response(JSON.stringify({ code: 200, message: 'success', data, ...extra }), { + headers: { 'Content-Type': 'application/json' }, + }) + const fetchFn: typeof fetch = async (input, init) => { + const url = new URL(String(input), 'https://windup.test') + const path = url.pathname.replace(/^\/api/, '') + const method = init?.method ?? 'GET' + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : null + calls.push({ + path: path + url.search, + method, + body, + authorization: new Headers(init?.headers).get('Authorization'), + }) + if (path === '/bill/products') + return response(products, { total: products.length, page: 1, page_size: 20 }) + if (path === '/bill/subscription') return response(subscription) + if (path === '/bill/orders' && method === 'POST') { + const product = products.find((item) => item.id === body?.product_id) + const amount = product?.amount_fen ?? Number(body?.amount_fen) + const order = makeOrder({ + order_no: `ORD20261015${String(orders.length + 1).padStart(4, '0')}`, + product_id: product?.id ?? null, + product_type: product?.type ?? 1, + product_snapshot: { + name: product?.name ?? '积分充值', + tier: product?.tier, + amount_fen: amount, + credits: product?.credits ?? Math.ceil(amount / 8), + }, + amount_fen: amount, + credits: product?.credits ?? Math.ceil(amount / 8), + channel: String(body?.channel), + }) + orders.unshift(order) + return response(order) + } + if (path === '/bill/orders') { + const page = Number(url.searchParams.get('page') ?? 1) + const size = Number(url.searchParams.get('page_size') ?? 5) + return response(orders.slice((page - 1) * size, page * size), { + total: orders.length, + page, + page_size: size, + }) + } + const match = path.match(/^\/bill\/orders\/([^/]+)(\/close)?$/) + if (match) { + const order = orders.find((item) => item.order_no === decodeURIComponent(match[1])) + if (!order) + return new Response(JSON.stringify({ code: 404, message: '订单不存在', data: null })) + if (match[2] && method === 'POST') { + order.status = 2 + order.closed_at = new Date().toISOString() + return response(null) + } + return response(order) + } + throw new Error(`Unexpected billing request: ${method} ${path}`) + } + function pay(orderNo: string) { + const order = orders.find((item) => item.order_no === orderNo)! + order.status = 1 + order.paid_at = new Date().toISOString() + if (order.product_type === 1) subscription.perpetual_credits += order.credits + else { + subscription.active_subscription = { + tier: Number(order.product_snapshot.tier), + status: 1, + period_start: new Date().toISOString(), + period_end: new Date(Date.now() + 30 * 86400_000).toISOString(), + } + subscription.expiring_credits += order.credits + } + } + return { products, subscription, orders, calls, fetchFn, makeOrder, pay } +} From dd04f26804c643d42c441fd4e9fdcf3c06032a84 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 15 Oct 2026 21:05:49 +0800 Subject: [PATCH 8/9] test(billing): cover checkout state transitions Payment returns and delayed requests must not create false success. Exercise polling, stale responses, duplicate clicks, price changes and close compensation. Verify the page follows server state across checkout failures and returns. --- frontend/src/features/billing/index.test.ts | 88 +++++++++ .../src/pages/subscription/index.test.tsx | 175 +++++++++++++++--- 2 files changed, 237 insertions(+), 26 deletions(-) create mode 100644 frontend/src/features/billing/index.test.ts 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/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, + ) }) From 84f8642af38dff3058f64814d40ded5351159bcf Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 15 Oct 2026 21:05:56 +0800 Subject: [PATCH 9/9] test(account): verify the billing subscription tier The account menu must reflect existing paid membership. Inject the billing HTTP fixture and assert the Pro management link. Keep unrelated account tests isolated from live billing requests. --- frontend/src/app/layout/app-header.test.tsx | 30 ++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index ebcafaec..e87066d8 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router' import type { AuthTokens, CreditAccount, QuotaApis, UserApis } from '@/entities' +import { createBillApis } from '@/entities' +import { createBillBackend } from '@/test/bill-backend' import { forgetActiveRun, rememberActiveRun } from '@/features/active-run' import { AuthSessionProvider } from '@/features/auth-session' import defaultAccountAvatar from '@/assets/account/avatars/windup-witch-avatar.webp' @@ -88,6 +90,7 @@ function renderHeader( apis = createApis(), previousEntry?: string, quota = createQuotaMock(), + bill = createBillApis({ baseUrl: '/api', fetchFn: createBillBackend().fetchFn }), ) { return { apis, @@ -103,7 +106,7 @@ function renderHeader( path="*" element={ <> - + } @@ -120,6 +123,31 @@ function renderAuthenticatedHeader(entry = '/') { return renderHeader(entry) } +it('账号菜单显示后端真实会员档位,不固定显示 Free', async () => { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + const backend = createBillBackend() + backend.subscription.active_subscription = { + tier: 2, + status: 1, + period_start: '2026-10-01T00:00:00Z', + period_end: '2026-11-01T00:00:00Z', + } + renderHeader( + '/workspace', + undefined, + undefined, + undefined, + createBillApis({ baseUrl: '/api', fetchFn: backend.fetchFn }), + ) + fireEvent.click(await screen.findByRole('button', { name: '打开账号菜单' })) + const menu = screen.getByTestId('account-menu') + expect(await within(menu).findByText('Pro')).toBeTruthy() + expect(within(menu).queryByText('Free')).toBeNull() + expect(within(menu).getByRole('link', { name: '管理订阅' }).getAttribute('href')).toBe( + '/account/subscription', + ) +}) + function finishBackAnimation() { act(() => vi.advanceTimersByTime(240)) }