feat: add configurable payments and Gitea staging delivery
Add database-backed administrators, configurable Alipay packages, idempotent payment settlement and platform reporting. Standardize Gitea workflows on the xiaoxin runner so reviewed staging commits are tested, packaged, and deployed by immutable image digest.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import "server-only";
|
||||
|
||||
const DEFAULT_NOTIFY_URL = "https://jyotisha.chat/api/payment/epay/notify";
|
||||
|
||||
export class EpayConfigurationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "EpayConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
function required(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new EpayConfigurationError(`${name} 未配置`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readEpayConfig() {
|
||||
const gateway = required("EPAY_GATEWAY_URL").replace(/\/+$/, "");
|
||||
let gatewayUrl: URL;
|
||||
try {
|
||||
gatewayUrl = new URL(gateway);
|
||||
} catch {
|
||||
throw new EpayConfigurationError("EPAY_GATEWAY_URL 无效");
|
||||
}
|
||||
if (!/^https?:$/.test(gatewayUrl.protocol)) throw new EpayConfigurationError("EPAY_GATEWAY_URL 必须使用 HTTP(S)");
|
||||
return {
|
||||
gatewayUrl,
|
||||
pid: required("EPAY_PID"),
|
||||
key: required("EPAY_KEY"),
|
||||
notifyUrl: process.env.EPAY_NOTIFY_URL?.trim() || DEFAULT_NOTIFY_URL,
|
||||
returnUrl: process.env.EPAY_RETURN_URL?.trim() || "https://jyotisha.chat/",
|
||||
siteName: process.env.EPAY_SITE_NAME?.trim() || "Jyotisha",
|
||||
};
|
||||
}
|
||||
|
||||
export function epaySubmitUrl(gatewayUrl: URL) {
|
||||
const url = new URL(gatewayUrl.toString());
|
||||
url.pathname = `${url.pathname.replace(/\/$/, "")}/submit.php`;
|
||||
url.search = "";
|
||||
return url;
|
||||
}
|
||||
|
||||
export { DEFAULT_NOTIFY_URL };
|
||||
@@ -0,0 +1,18 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function epayCanonical(params: Record<string, string | number | null | undefined>) {
|
||||
return Object.entries(params)
|
||||
.filter(([key, value]) => key !== "sign" && key !== "sign_type" && value !== null && value !== undefined && String(value) !== "")
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
export function epaySign(params: Record<string, string | number | null | undefined>, key: string) {
|
||||
return crypto.createHash("md5").update(`${epayCanonical(params)}${key}`).digest("hex");
|
||||
}
|
||||
|
||||
export function timingSafeSignEqual(actual: string | null | undefined, expected: string) {
|
||||
if (!actual || actual.length !== expected.length) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
|
||||
}
|
||||
@@ -36,3 +36,22 @@ export function isAdminEmail(email: string | null | undefined) {
|
||||
.split(",")
|
||||
.some((candidate) => candidate.trim().toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
export async function isAdminUser(user: { id?: string | null; email?: string | null } | null | undefined) {
|
||||
if (!user?.email) return false;
|
||||
if (isAdminEmail(user.email)) return true;
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted" || !user.id) return false;
|
||||
|
||||
try {
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data, error } = await admin
|
||||
.from("admin_users")
|
||||
.select("user_id")
|
||||
.eq("user_id", user.id)
|
||||
.is("revoked_at", null)
|
||||
.maybeSingle();
|
||||
return !error && Boolean(data);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user