订单和订阅管理
本文档说明如何查询和管理订单、订阅,以及如何使用客户门户管理订阅。
订单管理
订单数据结构
订单记录存储在 orders 表中,包含以下关键字段:
{
id: string; // 订单 ID(UUID)
userId: string; // 用户 ID
provider: 'stripe' | 'creem'; // 支付提供商
providerOrderId: string; // 提供商订单 ID(唯一)
orderType: string; // 订单类型
status: string; // 订单状态
planId: string; // 关联的计划 ID
subscriptionId?: string; // 关联的订阅 ID(如果是订阅订单)
amountTotal: string; // 订单总金额
currency: string; // 货币代码
createdAt: Date; // 创建时间
updatedAt: Date; // 更新时间
metadata: object; // 元数据(JSONB)
}订单类型
系统支持以下订单类型:
one_time_purchase: 一次性购买subscription_initial: 订阅初始支付(Stripe)subscription_renewal: 订阅续费(Stripe)recurring: 订阅支付(Creem)refund: 退款
订单状态
订单可能的状态:
succeeded: 支付成功pending: 待处理failed: 支付失败refunded: 已全额退款partially_refunded: 部分退款
用户查询订单
用户可以通过 getMyOrders 函数查询自己的订单:
actions/orders/user.ts
export async function getMyOrders(params: {
pageIndex?: number;
pageSize?: number;
filter?: string;
provider?: string;
orderType?: string;
status?: string;
}): Promise<GetMyOrdersResult> {
const session = await getSession();
const user = session?.user;
if (!user) return actionResponse.unauthorized();
const { pageIndex = 0, pageSize = 10, filter, provider, orderType, status } = params;
// 构建查询条件
const baseWhere = eq(ordersSchema.userId, user.id);
const optionalConditions: SQL[] = [];
if (provider) {
optionalConditions.push(eq(ordersSchema.provider, provider));
}
if (orderType) {
optionalConditions.push(eq(ordersSchema.orderType, orderType));
}
if (status) {
optionalConditions.push(eq(ordersSchema.status, status));
}
if (filter) {
optionalConditions.push(
or(
ilike(ordersSchema.providerOrderId, `%${filter}%`),
sql`CAST(${ordersSchema.id} AS TEXT) ILIKE ${`%${filter}%`}`
) as SQL
);
}
const whereClause = optionalConditions.length
? (and(baseWhere, ...optionalConditions) as SQL)
: baseWhere;
// 查询订单
const orders = await db
.select()
.from(ordersSchema)
.where(whereClause)
.orderBy(desc(ordersSchema.createdAt))
.offset(pageIndex * pageSize)
.limit(pageSize);
// 查询总数
const totalCountResult = await db
.select({ value: count() })
.from(ordersSchema)
.where(whereClause);
return actionResponse.success({
orders,
totalCount: totalCountResult[0]?.value ?? 0,
});
}管理员查询订单
管理员可以通过 getOrders 函数查询所有订单:
actions/orders/admin.ts
export async function getOrders(params: {
pageIndex?: number;
pageSize?: number;
filter?: string;
provider?: string;
orderType?: string;
status?: string;
}): Promise<GetOrdersResult> {
if (!(await isAdmin())) {
return actionResponse.forbidden('Admin privileges required.');
}
const { pageIndex = 0, pageSize = 10, filter, provider, orderType, status } = params;
const conditions = [];
if (provider) {
conditions.push(eq(ordersSchema.provider, provider));
}
if (orderType) {
conditions.push(eq(ordersSchema.orderType, orderType));
}
if (status) {
conditions.push(eq(ordersSchema.status, status));
}
if (filter) {
conditions.push(
or(
ilike(userSchema.email, `%${filter}%`),
ilike(ordersSchema.providerOrderId, `%${filter}%`),
sql`CAST(${ordersSchema.id} AS TEXT) ILIKE ${`%${filter}%`}`
)
);
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
// 查询订单(包含用户信息)
const results = await db
.select({
order: ordersSchema,
user: { email: userSchema.email, name: userSchema.name },
})
.from(ordersSchema)
.leftJoin(userSchema, eq(ordersSchema.userId, userSchema.id))
.where(whereClause)
.orderBy(desc(ordersSchema.createdAt))
.offset(pageIndex * pageSize)
.limit(pageSize);
// 查询总数
const totalCountResult = await db
.select({ value: count() })
.from(ordersSchema)
.leftJoin(userSchema, eq(ordersSchema.userId, userSchema.id))
.where(whereClause);
const ordersData = results.map((r) => ({
...r.order,
users: r.user,
}));
return actionResponse.success({
orders: ordersData as unknown as OrderWithUser[],
totalCount: totalCountResult[0].value,
});
}订单列表页面
用户订单页面
创建 app/[locale]/(protected)/dashboard/(user)/my-orders/page.tsx:
import { getMyOrders } from '@/actions/orders/user';
import { MyOrdersDataTable } from './DataTable';
export default async function MyOrdersPage({
searchParams
}: {
searchParams: { page?: string; filter?: string; provider?: string; status?: string }
}) {
const pageIndex = parseInt(searchParams.page || '0');
const result = await getMyOrders({
pageIndex,
pageSize: 10,
filter: searchParams.filter,
provider: searchParams.provider,
status: searchParams.status,
});
if (!result.success) {
return <div>Error: {result.error}</div>;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">My Orders</h1>
</div>
<MyOrdersDataTable
orders={result.data.orders}
totalCount={result.data.totalCount}
/>
</div>
);
}管理员订单页面
创建 app/[locale]/(protected)/dashboard/(admin)/orders/page.tsx:
import { getOrders } from '@/actions/orders/admin';
import { OrdersDataTable } from './DataTable';
export default async function OrdersPage({
searchParams
}: {
searchParams: { page?: string; filter?: string; provider?: string; status?: string }
}) {
const pageIndex = parseInt(searchParams.page || '0');
const result = await getOrders({
pageIndex,
pageSize: 10,
filter: searchParams.filter,
provider: searchParams.provider,
status: searchParams.status,
});
if (!result.success) {
return <div>Error: {result.error}</div>;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Orders</h1>
</div>
<OrdersDataTable
orders={result.data.orders}
totalCount={result.data.totalCount}
/>
</div>
);
}订阅管理
订阅数据结构
订阅记录存储在 subscriptions 表中:
{
id: string; // 订阅 ID(UUID)
userId: string; // 用户 ID
planId: string; // 关联的计划 ID
provider: 'stripe' | 'creem'; // 支付提供商
subscriptionId: string; // 提供商订阅 ID(唯一)
customerId: string; // 客户 ID
status: string; // 订阅状态
currentPeriodStart: Date; // 当前周期开始时间
currentPeriodEnd: Date; // 当前周期结束时间
cancelAtPeriodEnd: boolean; // 是否在周期结束时取消
canceledAt?: Date; // 取消时间
endedAt?: Date; // 结束时间
trialStart?: Date; // 试用开始时间
trialEnd?: Date; // 试用结束时间
metadata: object; // 元数据(JSONB)
}订阅状态
订阅可能的状态:
active: 活跃trialing: 试用中past_due: 逾期canceled: 已取消incomplete: 不完整incomplete_expired: 不完整已过期unpaid: 未支付paused: 已暂停(Creem)
订阅同步
系统通过 Webhook 自动同步订阅状态。你也可以手动同步:
Stripe 订阅同步
actions/stripe/index.ts
export async function syncSubscriptionData(
subscriptionId: string,
customerId: string,
initialMetadata?: Record<string, any>
): Promise<void> {
// 1. 从 Stripe 获取订阅信息
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['default_payment_method', 'customer']
});
// 2. 解析用户 ID 和计划 ID
let userId = subscription.metadata?.userId;
let planId = subscription.metadata?.planId;
// 如果元数据中没有,尝试从数据库查询
if (!userId) {
const userData = await db
.select({ id: userSchema.id })
.from(userSchema)
.where(eq(userSchema.stripeCustomerId, customerId))
.limit(1);
userId = userData[0]?.id;
}
if (!planId) {
const priceId = subscription.items.data[0].price.id;
const planData = await db
.select({ id: pricingPlansSchema.id })
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.stripePriceId, priceId))
.limit(1);
planId = planData[0]?.id;
}
// 3. 构建订阅数据
const subscriptionData = {
userId,
planId,
provider: 'stripe',
subscriptionId: subscription.id,
customerId: typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id,
priceId: subscription.items.data[0]?.price.id,
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at
? new Date(subscription.canceled_at * 1000)
: null,
endedAt: subscription.ended_at
? new Date(subscription.ended_at * 1000)
: null,
trialStart: subscription.trial_start
? new Date(subscription.trial_start * 1000)
: null,
trialEnd: subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null,
metadata: {
...subscription.metadata,
...(initialMetadata && { checkoutSessionMetadata: initialMetadata })
},
};
// 4. 插入或更新订阅记录
await db
.insert(subscriptionsSchema)
.values(subscriptionData)
.onConflictDoUpdate({
target: subscriptionsSchema.subscriptionId,
set: subscriptionData,
});
}Creem 订阅同步
actions/creem/index.ts
export async function syncCreemSubscriptionData(
subscriptionId: string,
initialMetadata?: Record<string, any>
): Promise<void> {
// 1. 从 Creem 获取订阅信息
const subscription = await retrieveCreemSubscription(subscriptionId);
// 2. 解析用户 ID 和计划 ID
let userId = subscription.metadata?.userId;
let planId = subscription.metadata?.planId;
let productId = subscription.product.id;
if (!userId) {
const storeSubscription = await db
.select({ userId: subscriptionsSchema.userId })
.from(subscriptionsSchema)
.where(eq(subscriptionsSchema.subscriptionId, subscriptionId))
.limit(1);
userId = storeSubscription[0]?.userId;
}
if (!planId) {
const planRow = await db
.select({ id: pricingPlansSchema.id })
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.creemProductId, productId))
.limit(1);
planId = planRow[0]?.id;
}
// 3. 构建订阅数据
const subscriptionData = {
userId,
planId: planId ?? null,
provider: 'creem',
subscriptionId: subscription.id,
customerId: subscription.customer.id,
priceId: subscription.items?.[0]?.price_id ?? '',
productId: productId,
status: subscription.status,
currentPeriodStart: toDate(subscription.current_period_start_date),
currentPeriodEnd: toDate(subscription.current_period_end_date),
cancelAtPeriodEnd: subscription.status === 'scheduled_cancel',
canceledAt: toDate(subscription.canceled_at),
endedAt: subscription.status === 'canceled'
? toDate(subscription.current_period_end_date)
: null,
trialStart: null,
trialEnd: null,
metadata: {
...metadata,
creemSubscriptionId: subscription.id,
creemCustomerId: subscription.customer.id,
creemProductId: productId,
},
};
// 4. 插入或更新订阅记录
await db
.insert(subscriptionsSchema)
.values(subscriptionData)
.onConflictDoUpdate({
target: subscriptionsSchema.subscriptionId,
set: subscriptionData,
});
}客户门户
客户门户允许用户管理他们的订阅,包括:
- 更新支付方式
- 取消订阅
- 查看发票
- 更新账单信息
Stripe 客户门户
创建 Stripe 客户门户会话:
actions/stripe/index.ts
export async function createStripePortalSession(): Promise<void> {
const session = await getSession();
const user = session?.user;
if (!user) {
redirect('/login');
}
// 1. 获取用户的 Stripe Customer ID
const profile = await db
.select({ stripeCustomerId: userSchema.stripeCustomerId })
.from(userSchema)
.where(eq(userSchema.id, user.id))
.limit(1);
if (!profile?.stripeCustomerId) {
throw new Error('Stripe customer ID not found');
}
// 2. 构建返回 URL
const headersList = await headers();
const domain = headersList.get('x-forwarded-host') || headersList.get('host');
const protocol = headersList.get('x-forwarded-proto') || 'https';
const returnUrl = `${protocol}://${domain}${process.env.STRIPE_CUSTOMER_PORTAL_URL}`;
// 3. 创建门户会话
const portalSession = await stripe.billingPortal.sessions.create({
customer: profile.stripeCustomerId,
return_url: returnUrl,
});
// 4. 重定向到门户
redirect(portalSession.url);
}Creem 客户门户
创建 Creem 客户门户链接:
actions/creem/portal.ts
export async function createCreemPortalSession(): Promise<void> {
const session = await getSession();
const user = session?.user;
if (!user) {
redirect('/login');
}
// 1. 获取用户的 Creem 订阅
const subscriptionResults = await db
.select({
customerId: subscriptionsSchema.customerId,
})
.from(subscriptionsSchema)
.where(
and(
eq(subscriptionsSchema.userId, user.id),
eq(subscriptionsSchema.provider, 'creem')
)
)
.orderBy(desc(subscriptionsSchema.createdAt))
.limit(1);
const subscription = subscriptionResults[0];
if (!subscription) {
throw new Error('Creem subscription not found');
}
// 2. 创建门户链接
const portalUrl = await createCreemCustomerPortalLink(subscription.customerId);
if (!portalUrl) {
throw new Error('Failed to create Creem portal link');
}
// 3. 重定向到门户
redirect(portalUrl);
}订阅管理页面
创建订阅管理页面,显示用户当前的订阅状态和门户入口:
app/[locale]/(protected)/dashboard/(user)/subscription/page.tsx
import { createCreemPortalSession } from '@/actions/creem/portal';
import { createStripePortalSession } from '@/actions/stripe';
import { getUserBenefits } from '@/actions/usage/benefits';
import CurrentUserBenefitsDisplay from '@/components/layout/CurrentUserBenefitsDisplay';
import { Button } from '@/components/ui/button';
import { getSession } from '@/lib/auth/server';
import { db } from '@/lib/db';
import { subscriptions as subscriptionsSchema } from '@/lib/db/schema';
import { desc, eq } from 'drizzle-orm';
import { redirect } from 'next/navigation';
import { PortalButton } from './PortalButton';
export default async function SubscriptionPage() {
const session = await getSession();
const user = session?.user;
if (!user) redirect('/login');
// 获取用户权益
const benefits = await getUserBenefits(user.id);
// 获取用户的订阅提供商
const subscriptionResults = await db
.select({ provider: subscriptionsSchema.provider })
.from(subscriptionsSchema)
.where(eq(subscriptionsSchema.userId, user.id))
.orderBy(desc(subscriptionsSchema.createdAt))
.limit(1);
const subscriptionProvider = subscriptionResults[0]?.provider || null;
const isMember =
benefits.subscriptionStatus === 'active' ||
benefits.subscriptionStatus === 'trialing';
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Subscription</h1>
</div>
<div className="rounded-lg border p-6 space-y-4">
{isMember ? (
<>
<CurrentUserBenefitsDisplay />
{subscriptionProvider === 'stripe' && (
<>
<PortalButton
provider="stripe"
action={createStripePortalSession}
/>
<p className="text-xs text-muted-foreground">
You will be redirected to Stripe to manage your subscription details.
</p>
</>
)}
{subscriptionProvider === 'creem' && (
<>
<PortalButton
provider="creem"
action={createCreemPortalSession}
/>
<p className="text-xs text-muted-foreground">
You will be redirected to manage your subscription details.
</p>
</>
)}
</>
) : (
<>
<p>You are currently not subscribed to any plan.</p>
<Button asChild>
<Link href={process.env.NEXT_PUBLIC_PRICING_PATH!}>
Upgrade Plan
</Link>
</Button>
</>
)}
</div>
</div>
);
}积分历史查询
用户可以查看自己的积分使用历史:
actions/usage/logs.ts
export async function getCreditLogs({
pageIndex = 0,
pageSize = 20,
}: ListCreditLogsParams = {}): Promise<ListCreditLogsResult> {
const session = await getSession();
const user = session?.user;
if (!user) return actionResponse.unauthorized();
const logs = await db
.select()
.from(creditLogsSchema)
.where(eq(creditLogsSchema.userId, user.id))
.orderBy(desc(creditLogsSchema.createdAt))
.offset(pageIndex * pageSize)
.limit(pageSize);
const totalCount = await db
.select({ value: count() })
.from(creditLogsSchema)
.where(eq(creditLogsSchema.userId, user.id));
return actionResponse.success({
logs: logs || [],
count: totalCount[0]?.value ?? 0,
});
}积分历史页面
创建 app/[locale]/(protected)/dashboard/(user)/credit-history/page.tsx:
import { getCreditLogs } from '@/actions/usage/logs';
import { CreditHistoryDataTable } from './CreditHistoryDataTable';
export default async function CreditHistoryPage({
searchParams
}: {
searchParams: { page?: string }
}) {
const pageIndex = parseInt(searchParams.page || '0');
const result = await getCreditLogs({
pageIndex,
pageSize: 20,
});
if (!result.success) {
return <div>Error: {result.error}</div>;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Credit History</h1>
</div>
<CreditHistoryDataTable
logs={result.data.logs}
totalCount={result.data.count}
/>
</div>
);
}常见问题
Q: 如何查询特定用户的订单?
A: 管理员可以使用 getOrders 函数,通过 filter 参数搜索用户邮箱或订单 ID。
Q: 订阅状态是如何更新的?
A: 订阅状态通过 Webhook 自动更新。当支付提供商发送订阅更新事件时,系统会自动同步状态。
Q: 用户可以取消订阅吗?
A: 可以。用户可以通过客户门户取消订阅。取消后,订阅会在当前周期结束时生效。
Q: 如何查看订阅的详细信息?
A: 查询 subscriptions 表获取订阅的详细信息,包括状态、周期、取消时间等。