feat(payment): expand Stripe methods and checkout handoff
Docker Image / Build and Push Docker Image (push) Successful in 2m10s

This commit is contained in:
Codex
2026-07-28 16:27:03 +08:00
parent 2e402de15b
commit 59e4eac736
42 changed files with 1151 additions and 92 deletions
+28
View File
@@ -1,6 +1,10 @@
import { ExceptionHandler } from "@/core/errors";
import type { IAuthRepository } from "@/data/repositories/interfaces";
import {
CheckoutHandoffConsumeRequestSchema,
CheckoutHandoffCreateRequestSchema,
type CheckoutHandoffCreateResponse,
type CheckoutIntent,
FacebookIdentityRequestSchema,
FacebookLoginRequestSchema,
FbIdLoginRequestSchema,
@@ -276,6 +280,30 @@ export class AuthRepository implements IAuthRepository {
});
}
/** 创建订单前生成一次性外部浏览器支付交接链接。 */
async createCheckoutHandoff(
checkoutIntent: CheckoutIntent,
): Promise<Result<CheckoutHandoffCreateResponse>> {
return Result.wrap(() =>
this.api.createCheckoutHandoff(
CheckoutHandoffCreateRequestSchema.parse(checkoutIntent),
),
);
}
/** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */
async consumeCheckoutHandoff(
handoffToken: string,
): Promise<Result<CheckoutIntent>> {
return Result.wrap(async () => {
const response = await this.api.consumeCheckoutHandoff(
CheckoutHandoffConsumeRequestSchema.parse({ handoffToken }),
);
await this._saveLoginData(response, response.loginStatus);
return response.checkoutIntent;
});
}
/**
* 刷新 token:先读本地的 refresh token,空则直接返回错误;
* 调用 API 成功后写回新的 login token + refresh token。
@@ -13,6 +13,8 @@
import type { Result } from "@/utils/result";
import type {
CheckoutHandoffCreateResponse,
CheckoutIntent,
GuestLoginResponse,
LoginStatus,
LoginResponse,
@@ -74,6 +76,16 @@ export interface IAuthRepository {
/** 消费一次性充值登录凭证并建立正式会话。 */
consumeTopUpHandoff(handoffToken: string): Promise<Result<LoginStatus>>;
/** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */
createCheckoutHandoff(
checkoutIntent: CheckoutIntent,
): Promise<Result<CheckoutHandoffCreateResponse>>;
/** 消费单次支付交接凭证并保存正式登录态。 */
consumeCheckoutHandoff(
handoffToken: string,
): Promise<Result<CheckoutIntent>>;
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
refreshToken(): Promise<Result<RefreshTokenResponse>>;
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
CheckoutHandoffConsumeResponseSchema,
CheckoutHandoffCreateRequestSchema,
} from "../checkout_handoff";
describe("checkout handoff schemas", () => {
it("keeps only the minimal order-free purchase intent", () => {
expect(
CheckoutHandoffCreateRequestSchema.parse({
planId: "vip_monthly",
autoRenew: true,
recipientCharacterId: "elio",
price: 9.99,
}),
).toEqual({
planId: "vip_monthly",
autoRenew: true,
recipientCharacterId: "elio",
});
});
it("parses the formal session and checkout intent returned after consumption", () => {
const parsed = CheckoutHandoffConsumeResponseSchema.parse({
user: {
id: "00000000-0000-0000-0000-000000000123",
username: "checkout-user",
email: "checkout@example.com",
platform: "web",
},
token: "access-token",
refreshToken: "refresh-token",
loginStatus: "email",
checkoutIntent: {
planId: "vip_monthly",
autoRenew: true,
},
});
expect(parsed.checkoutIntent.planId).toBe("vip_monthly");
expect(parsed.loginStatus).toBe("email");
});
});
+58
View File
@@ -0,0 +1,58 @@
import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults";
import { UserSchema } from "../user/user";
export const CheckoutIntentSchema = z
.object({
planId: z.string().min(1).max(120),
autoRenew: z.boolean(),
recipientCharacterId: z.string().min(1).max(80).optional(),
commercialOfferId: z.string().min(1).max(80).optional(),
chatActionId: z.uuid().optional(),
})
.readonly();
export const CheckoutHandoffCreateRequestSchema = CheckoutIntentSchema;
export const CheckoutHandoffCreateResponseSchema = z
.object({
externalUrl: z.url(),
expiresAt: z.string().min(1),
})
.readonly();
export const CheckoutHandoffConsumeRequestSchema = z
.object({
handoffToken: z.string().min(32).max(512),
})
.readonly();
export const CheckoutHandoffConsumeResponseSchema = z
.object({
user: UserSchema,
token: z.string().min(1),
refreshToken: stringOrEmpty,
loginStatus: z.enum([
"email",
"google",
"facebook",
"facebookMessenger",
]),
checkoutIntent: CheckoutIntentSchema,
})
.readonly();
export type CheckoutIntent = z.output<typeof CheckoutIntentSchema>;
export type CheckoutHandoffCreateRequest = z.output<
typeof CheckoutHandoffCreateRequestSchema
>;
export type CheckoutHandoffCreateResponse = z.output<
typeof CheckoutHandoffCreateResponseSchema
>;
export type CheckoutHandoffConsumeRequest = z.output<
typeof CheckoutHandoffConsumeRequestSchema
>;
export type CheckoutHandoffConsumeResponse = z.output<
typeof CheckoutHandoffConsumeResponseSchema
>;
+1
View File
@@ -3,6 +3,7 @@
*/
export * from "./facebook_user_data";
export * from "./checkout_handoff";
export * from "./login_status";
export * from "./request/facebook_identity_request";
export * from "./request/facebook_login_request";
+2
View File
@@ -6,6 +6,8 @@
"facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" },
"facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" },
"topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" },
"checkoutHandoffCreate": { "method": "post", "path": "/api/auth/handoff/checkout" },
"checkoutHandoffConsume": { "method": "post", "path": "/api/auth/handoff/checkout/consume" },
"refresh": { "method": "post", "path": "/api/auth/refresh" },
"logout": { "method": "post", "path": "/api/auth/logout" },
"getCurrentUser": { "method": "get", "path": "/api/auth/me" },
+6
View File
@@ -30,6 +30,12 @@ export class ApiPath {
/** 消费一次性充值登录凭证 */
static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path;
/** 创建一次性外部浏览器支付交接链接(创建订单前)。 */
static readonly checkoutHandoffCreate = apiContract.checkoutHandoffCreate.path;
/** 消费一次性外部浏览器支付交接凭证。 */
static readonly checkoutHandoffConsume = apiContract.checkoutHandoffConsume.path;
/** 刷新 Token */
static readonly refresh = apiContract.refresh.path;
+32
View File
@@ -7,6 +7,12 @@
import { z } from "zod";
import {
CheckoutHandoffConsumeRequest,
CheckoutHandoffConsumeResponse,
CheckoutHandoffConsumeResponseSchema,
CheckoutHandoffCreateRequest,
CheckoutHandoffCreateResponse,
CheckoutHandoffCreateResponseSchema,
FacebookIdentityRequest,
FacebookIdentityResponse,
FacebookIdentityResponseSchema,
@@ -104,6 +110,32 @@ export class AuthApi {
);
}
/** 在创建支付订单前生成一次性外部浏览器交接链接。 */
async createCheckoutHandoff(
body: CheckoutHandoffCreateRequest,
): Promise<CheckoutHandoffCreateResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.checkoutHandoffCreate,
{ method: "POST", body },
);
return CheckoutHandoffCreateResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
/** 消费一次性支付交接凭证并恢复正式登录态与购买意图。 */
async consumeCheckoutHandoff(
body: CheckoutHandoffConsumeRequest,
): Promise<CheckoutHandoffConsumeResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.checkoutHandoffConsume,
{ method: "POST", body },
);
return CheckoutHandoffConsumeResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
/**
* 绑定 Facebook ASID / PSID 到当前用户
*/