This commit is contained in:
eric
2026-03-10 01:35:37 -05:00
parent ff51622bb6
commit be7a298604
69 changed files with 11016 additions and 57 deletions

101
lib/sdk/README.md Normal file
View File

@@ -0,0 +1,101 @@
# SDK 使用说明
ZPay 支付网关、PocketBase 数据库、MinIO 对象存储,配置成 SDK 随时调用。复制自 digital 项目。
## 环境变量
`.env.local` 中配置:
```bash
# PocketBase
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn
POCKETBASE_JOIN_COLLECTION=solan
POCKETBASE_EMAIL=admin@example.com # Admin 登录(可选,用于 createRecord 403 时重试)
POCKETBASE_PASSWORD=xxx
# ZPay 支付
PAYMENT_API_URL=http://127.0.0.1:8700 # 开发;生产用 https://api.hackrobot.cn
PAYMENT_PROVIDER=zpay
PAYMENT_DEFAULT_AMOUNT=80
PAYMENT_DEFAULT_TYPE=meetup
ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php
# MinIO
MINIO_ENDPOINT=minioweb.hackrobot.cn
MINIO_PORT=9035
MINIO_USE_SSL=false
MINIO_BUCKET=hackrobot
MINIO_ACCESS_KEY=xxx
MINIO_SECRET_KEY=xxx
MINIO_UPLOAD_PREFIX=joins
```
## 使用示例
### PocketBase
```ts
import {
pbLogin,
pbRegister,
pbSaveAuth,
pbLogout,
getStoredUserEmail,
createRecord,
} from "@/lib/sdk";
// 客户端:登录
const { token, record } = await pbLogin("user@example.com", "password");
pbSaveAuth(token, record);
// 客户端:注册
const result = await pbRegister("user@example.com", "pass", "pass");
pbSaveAuth(result.token, result.record);
// 客户端:登出
pbLogout();
// 客户端:获取当前用户
const email = getStoredUserEmail();
// 服务端:创建记录
await createRecord("solan", { nickname: "张三", reason: "想加入" });
```
### ZPay
```ts
import { createPayOrder, getOrderStatus, buildPayHtml } from "@/lib/sdk";
// 创建订单
const { params, payUrl } = await createPayOrder({
user_id: "user123",
return_url: "https://yoursite.com/paid",
total_fee: 80, // 分
type: "meetup",
channel: "alipay",
});
// 查询支付状态
const { paid } = await getOrderStatus("out_trade_no_xxx");
// 构建跳转 HTML
const html = buildPayHtml(payUrl, params);
```
### MinIO
```ts
import { uploadBuffer, generateObjectKey } from "@/lib/sdk";
// 生成对象键
const objectKey = generateObjectKey("jpg");
// 上传
const { url } = await uploadBuffer(
Buffer.from(fileArrayBuffer),
objectKey,
"image/jpeg"
);
// url 即公开访问地址
```

85
lib/sdk/config.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* SDK 统一配置 - 从环境变量读取,可随时调用
* 复制自 digital 项目,去除主题依赖
*/
const isDev = process.env.NODE_ENV === "development";
export interface PocketBaseConfig {
enabled: boolean;
url: string;
usersCollection: string;
joinCollection: string;
}
export interface ZPayConfig {
enabled: boolean;
apiUrl: string;
provider: "zpay" | "xorpay";
defaultAmount: number;
defaultType: string;
zpaySubmitUrl: string;
}
export interface MinioConfig {
enabled: boolean;
endPoint: string;
port: number;
useSSL: boolean;
bucket: string;
accessKey: string;
secretKey: string;
region: string;
publicUrl: string;
uploadPrefix: string;
}
/** PocketBase 配置 */
export function getPocketBaseConfig(overrides?: { joinCollection?: string }): PocketBaseConfig {
return {
enabled: true,
url: process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.POCKETBASE_URL || "https://pocketbase.hackrobot.cn",
usersCollection: "users",
joinCollection: overrides?.joinCollection || process.env.POCKETBASE_JOIN_COLLECTION || "solan",
};
}
/** ZPay 支付配置 */
export function getZPayConfig(overrides?: { amount?: number; type?: string }): ZPayConfig {
const base = {
enabled: true,
apiUrl: process.env.PAYMENT_API_URL || (isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"),
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 80,
defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup",
zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
};
if (overrides) {
if (overrides.amount != null) base.defaultAmount = overrides.amount;
if (overrides.type) base.defaultType = overrides.type;
}
return base;
}
/** MinIO 配置 */
export function getMinioConfig(overrides?: { bucket?: string; uploadPrefix?: string }): MinioConfig {
const endPoint = process.env.MINIO_ENDPOINT || "minioweb.hackrobot.cn";
const port = parseInt(process.env.MINIO_PORT || "9035", 10);
const useSSL = process.env.MINIO_USE_SSL === "true";
const bucket = overrides?.bucket || process.env.MINIO_BUCKET || "hackrobot";
const publicUrl = process.env.MINIO_PUBLIC_URL || `https://${endPoint}/${bucket}`;
const uploadPrefix = overrides?.uploadPrefix || process.env.MINIO_UPLOAD_PREFIX || "joins";
return {
enabled: true,
endPoint,
port,
useSSL,
bucket,
accessKey: process.env.MINIO_ACCESS_KEY || "6i56HHfg4zPfYItCZtnp",
secretKey: process.env.MINIO_SECRET_KEY || "vDJCqEit3ejH5UmWKAZnvqhziNfbVsoOlBW12G8Q",
region: process.env.MINIO_REGION || "china",
publicUrl: publicUrl.replace(/\/$/, ""),
uploadPrefix,
};
}

39
lib/sdk/index.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* SDK 统一入口 - ZPay / PocketBase / MinIO 随时调用
* 配置通过环境变量,复制自 digital 项目
*/
// 配置
export {
getPocketBaseConfig,
getZPayConfig,
getMinioConfig,
} from "./config";
export type { PocketBaseConfig, ZPayConfig, MinioConfig } from "./config";
// PocketBase
export {
pbLogin,
pbRegister,
pbSaveAuth,
pbLogout,
getStoredUserEmail,
getStoredToken,
createRecord,
getAdminToken,
PB_STORAGE_KEYS,
} from "./pocketbase";
export type { AuthUser, AuthResult } from "./pocketbase";
// ZPay
export {
createPayOrder,
getOrderStatus,
buildPayHtml,
ZPAY_REQUIRED,
} from "./zpay";
export type { CreateOrderOptions, CreateOrderResult } from "./zpay";
// MinIO
export { getMinioClient, uploadBuffer, generateObjectKey } from "./minio";
export type { UploadResult } from "./minio";

60
lib/sdk/minio/index.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* MinIO SDK - 上传、下载,随时可调用
* 复制自 digital 项目
*/
import * as Minio from "minio";
import { getMinioConfig } from "../config";
export interface UploadResult {
url: string;
objectKey: string;
}
/** 获取 MinIO 客户端实例 */
export function getMinioClient(overrides?: { bucket?: string; uploadPrefix?: string }): Minio.Client {
const cfg = getMinioConfig(overrides);
return new Minio.Client({
endPoint: cfg.endPoint,
port: cfg.port,
useSSL: cfg.useSSL,
accessKey: cfg.accessKey,
secretKey: cfg.secretKey,
pathStyle: true,
region: cfg.region,
});
}
/**
* 上传文件到 MinIO
* @param buffer 文件内容
* @param objectKey 对象键(含路径)
* @param contentType MIME 类型
*/
export async function uploadBuffer(
buffer: Buffer,
objectKey: string,
contentType: string = "application/octet-stream",
overrides?: { bucket?: string; uploadPrefix?: string }
): Promise<UploadResult> {
const cfg = getMinioConfig(overrides);
if (!cfg.enabled) throw new Error("MinIO 存储未启用");
const client = getMinioClient(overrides);
await client.putObject(cfg.bucket, objectKey, buffer, buffer.length, {
"Content-Type": contentType,
});
const url = `${cfg.publicUrl}/${objectKey}`;
return { url, objectKey };
}
/**
* 生成上传用的对象键
* @param ext 文件扩展名
*/
export function generateObjectKey(ext: string, overrides?: { uploadPrefix?: string }): string {
const cfg = getMinioConfig(overrides);
const prefix = cfg.uploadPrefix.replace(/\/$/, "");
return `${prefix}/${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
}

View File

@@ -0,0 +1,91 @@
/**
* PocketBase 认证 - 登录、注册、登出(客户端可用)
*/
import { getPocketBaseConfig } from "../config";
import { PB_STORAGE_KEYS } from "./constants";
export interface AuthUser {
id: string;
email: string;
[key: string]: unknown;
}
export interface AuthResult {
token: string;
record: AuthUser;
}
function getPBUrl(): string {
return getPocketBaseConfig().url;
}
/** 登录 */
export async function pbLogin(identity: string, password: string): Promise<AuthResult> {
const url = getPBUrl();
const res = await fetch(`${url}/api/collections/users/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity, password }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err?.message || "登录失败");
}
const data = await res.json();
if (!data?.token) throw new Error("登录响应异常");
return { token: data.token, record: data.record };
}
/** 注册 */
export async function pbRegister(
email: string,
password: string,
passwordConfirm: string
): Promise<AuthResult> {
const url = getPBUrl();
const config = getPocketBaseConfig();
const res = await fetch(`${url}/api/collections/${config.usersCollection}/records`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, passwordConfirm }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err?.message || "注册失败");
}
return pbLogin(email, password);
}
/** 保存认证到 localStorage */
export function pbSaveAuth(token: string, record: AuthUser): void {
if (typeof window === "undefined") return;
localStorage.setItem(PB_STORAGE_KEYS.token, token);
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
}
/** 登出 */
export function pbLogout(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(PB_STORAGE_KEYS.token);
localStorage.removeItem(PB_STORAGE_KEYS.user);
}
/** 获取存储的用户邮箱 */
export function getStoredUserEmail(): string | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
if (!raw) return null;
const user = JSON.parse(raw);
return user?.email ?? null;
} catch {
return null;
}
}
/** 获取存储的 token */
export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(PB_STORAGE_KEYS.token);
}

View File

@@ -0,0 +1,5 @@
/** localStorage 存储 key */
export const PB_STORAGE_KEYS = {
token: "pb_token",
user: "pb_user",
} as const;

View File

@@ -0,0 +1,12 @@
/**
* PocketBase SDK - 认证与数据操作,随时可调用
* 复制自 digital 项目
*/
import { getPocketBaseConfig } from "../config";
import { PB_STORAGE_KEYS } from "./constants";
export { PB_STORAGE_KEYS } from "./constants";
export type { AuthUser, AuthResult } from "./auth";
export { pbLogin, pbRegister, pbSaveAuth, pbLogout, getStoredUserEmail, getStoredToken } from "./auth";
export { createRecord, getAdminToken } from "./records";

View File

@@ -0,0 +1,52 @@
/**
* PocketBase 记录操作 - 创建、查询(服务端可用)
*/
import { getPocketBaseConfig } from "../config";
/** 获取 Admin Token需配置 POCKETBASE_EMAIL + POCKETBASE_PASSWORD */
export async function getAdminToken(): Promise<string | null> {
const email = process.env.POCKETBASE_EMAIL;
const password = process.env.POCKETBASE_PASSWORD;
if (!email || !password) return null;
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/admins/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (!res.ok) return null;
const data = await res.json();
return data?.token ?? null;
}
/** 创建记录 */
export async function createRecord(
collection: string,
data: Record<string, unknown>,
authToken?: string
): Promise<{ id: string; [key: string]: unknown }> {
const pb = getPocketBaseConfig();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (authToken) headers["Authorization"] = authToken;
const res = await fetch(`${pb.url}/api/collections/${collection}/records`, {
method: "POST",
headers,
body: JSON.stringify(data),
});
if (!res.ok) {
const errText = await res.text();
let errMsg = "创建失败";
try {
const errJson = JSON.parse(errText);
if (errJson?.message) errMsg = errJson.message;
} catch {
if (errText.length < 200) errMsg = errText;
}
throw new Error(errMsg);
}
return res.json();
}

130
lib/sdk/zpay/index.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* ZPay 支付网关 SDK - 创建订单、查询状态,随时可调用
* 复制自 digital 项目 pay API
*/
import { getZPayConfig } from "../config";
const ZPAY_REQUIRED = [
"pid",
"type",
"out_trade_no",
"notify_url",
"return_url",
"name",
"money",
"sign",
"sign_type",
];
function escapeAttr(s: string): string {
return String(s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export interface CreateOrderOptions {
user_id: string;
return_url: string;
total_fee?: number;
type?: string;
channel?: string;
}
export interface CreateOrderResult {
params: Record<string, string>;
payUrl: string;
}
/** 创建支付订单 */
export async function createPayOrder(options: CreateOrderOptions): Promise<CreateOrderResult> {
const config = getZPayConfig();
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
const { user_id, return_url, total_fee, type, channel = "alipay" } = options;
const fee = total_fee ?? config.defaultAmount;
const orderType = type ?? config.defaultType;
const payload = {
user_id,
total_fee: Number(fee) || config.defaultAmount,
type: orderType,
channel,
return_url,
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${config.apiUrl}/payh5`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.status !== "ok") {
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
}
let params = data.params || data.xorpay_params || {};
const payUrl = data.pay_url || data.xorpay_url || config.zpaySubmitUrl;
if (!params || typeof params !== "object") {
throw new Error("payjsapi 未返回支付参数");
}
params = JSON.parse(JSON.stringify(params));
if (data.provider === "xorpay") {
throw new Error("当前 payjsapi 使用 xorpay请设置 PAYMENT_PROVIDER=zpay");
}
return { params, payUrl };
}
/** 查询订单支付状态 */
export async function getOrderStatus(orderId: string): Promise<{ paid: boolean }> {
const config = getZPayConfig();
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
const res = await fetch(
`${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
{ cache: "no-store" }
);
const data = await res.json().catch(() => ({}));
return { paid: !!data?.paid };
}
/** 构建 ZPay 表单跳转 HTML */
export function buildPayHtml(payUrl: string, params: Record<string, unknown>): string {
const flat: Record<string, string> = {};
for (const [k, v] of Object.entries(params)) {
if (v == null) continue;
const s = String(v).trim();
if (s === "") continue;
flat[k] = s;
}
const inputs = Object.entries(flat)
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
.join("\n");
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>跳转支付...</title>
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style>
</head>
<body>
<p>正在跳转到支付页面...</p>
<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">
${inputs}
</form>
<script>document.getElementById("zpayForm").submit();</script>
</body>
</html>`;
}
/** 获取 ZPay 必填参数列表(用于校验) */
export { ZPAY_REQUIRED };