mirror of
https://github.com/freestylefly/awesome-gpt-image-2.git
synced 2026-09-14 14:00:36 +08:00
318 lines
10 KiB
JavaScript
318 lines
10 KiB
JavaScript
import { parseAlipayAmount } from './alipay.js';
|
|
|
|
export const COMMUNITY_PRICE_CENTS = 990;
|
|
export const COMMUNITY_CURRENCY = 'CNY';
|
|
export const COMMUNITY_SUBJECT = 'GPT-Image2 付费交流群长期资格';
|
|
export const COMMUNITY_TERMS_VERSION = '2026-07-22';
|
|
export const COMMUNITY_PENDING_MINUTES = 30;
|
|
export const COMMUNITY_QR_MAX_BYTES = 2 * 1024 * 1024;
|
|
export const COMMUNITY_ORDER_STATUSES = Object.freeze([
|
|
'PENDING',
|
|
'PAID',
|
|
'CLOSED',
|
|
'REFUNDED',
|
|
'REVOKED'
|
|
]);
|
|
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
const communityRateBuckets = new Map();
|
|
|
|
export function isUuid(value) {
|
|
return UUID_PATTERN.test(String(value || '').trim());
|
|
}
|
|
|
|
export function isCommunityPaymentEnabled(env = process.env) {
|
|
return String(env.COMMUNITY_PAYMENT_ENABLED || '').trim().toLowerCase() === 'true';
|
|
}
|
|
|
|
export function communityPublicConfig(env = process.env) {
|
|
return {
|
|
priceCents: COMMUNITY_PRICE_CENTS,
|
|
priceLabel: '¥9.90',
|
|
currency: COMMUNITY_CURRENCY,
|
|
paymentEnabled: isCommunityPaymentEnabled(env),
|
|
support: String(env.COMMUNITY_SUPPORT_TEXT || '微信搜索苍何').trim(),
|
|
refundPolicy: '人工审核后原路退款',
|
|
termsVersion: COMMUNITY_TERMS_VERSION
|
|
};
|
|
}
|
|
|
|
export function communityOrderPayload(userId, termsVersion = COMMUNITY_TERMS_VERSION) {
|
|
return {
|
|
userId,
|
|
amountCents: COMMUNITY_PRICE_CENTS,
|
|
currency: COMMUNITY_CURRENCY,
|
|
subject: COMMUNITY_SUBJECT,
|
|
termsVersion
|
|
};
|
|
}
|
|
|
|
export function serializeCommunityOrder(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
status: row.status,
|
|
amountCents: Number(row.amount_cents || 0),
|
|
currency: String(row.currency || '').toUpperCase(),
|
|
refundStatus: row.refund_status || 'NONE',
|
|
createdAt: row.created_at || '',
|
|
paidAt: row.paid_at || '',
|
|
refundedAt: row.refunded_at || '',
|
|
revokedAt: row.revoked_at || ''
|
|
};
|
|
}
|
|
|
|
export function canAccessCommunityQr(status) {
|
|
return status === 'PAID';
|
|
}
|
|
|
|
export function isOrderOwnedBy(order, userId) {
|
|
return Boolean(order?.user_id && userId && order.user_id === userId);
|
|
}
|
|
|
|
export function isCommunityAdmin(auth) {
|
|
return Boolean(auth?.profile?.isSuperAdmin || auth?.profile?.role === 'super_admin');
|
|
}
|
|
|
|
export function takeCommunityRateLimit(key, { limit = 30, windowMs = 60_000, now = Date.now() } = {}) {
|
|
const bucketKey = String(key || 'anonymous');
|
|
const current = communityRateBuckets.get(bucketKey);
|
|
if (!current || now >= current.resetAt) {
|
|
communityRateBuckets.set(bucketKey, { count: 1, resetAt: now + windowMs });
|
|
return { allowed: true, remaining: limit - 1, retryAfterSeconds: 0 };
|
|
}
|
|
current.count += 1;
|
|
if (current.count > limit) {
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000))
|
|
};
|
|
}
|
|
return { allowed: true, remaining: Math.max(0, limit - current.count), retryAfterSeconds: 0 };
|
|
}
|
|
|
|
export function communityRequestRateKey(req, scope, userId = '') {
|
|
const forwarded = String(req?.headers?.['x-forwarded-for'] || '').split(',')[0].trim();
|
|
const remote = forwarded || String(req?.socket?.remoteAddress || 'unknown');
|
|
return `${scope}:${userId || remote}`;
|
|
}
|
|
|
|
export function applyCommunityRateLimit(res, result) {
|
|
if (result.allowed) return true;
|
|
res.setHeader('Retry-After', String(result.retryAfterSeconds));
|
|
res.status(429).json({ ok: false, error: 'RATE_LIMITED' });
|
|
return false;
|
|
}
|
|
|
|
export function shouldExpireCommunityOrder(order, now = Date.now()) {
|
|
if (order?.status !== 'PENDING') return false;
|
|
const createdAt = Date.parse(order.created_at || '');
|
|
if (!Number.isFinite(createdAt)) return true;
|
|
return now - createdAt >= COMMUNITY_PENDING_MINUTES * 60 * 1000;
|
|
}
|
|
|
|
export function communityRefundRequestNo(orderId) {
|
|
if (!isUuid(orderId)) throw new Error('INVALID_COMMUNITY_ORDER');
|
|
return `cg_${String(orderId).replace(/-/g, '')}`;
|
|
}
|
|
|
|
export function isPaidTradeStatus(status) {
|
|
return status === 'TRADE_SUCCESS' || status === 'TRADE_FINISHED';
|
|
}
|
|
|
|
export function validateCommunityTradeResult(order, result) {
|
|
if (!order || !result) return { ok: false, error: 'ORDER_RESULT_REQUIRED' };
|
|
if (String(result.out_trade_no || '') !== order.id) {
|
|
return { ok: false, error: 'OUT_TRADE_NO_MISMATCH' };
|
|
}
|
|
const amountCents = parseAlipayAmount(result.total_amount);
|
|
if (amountCents !== Number(order.amount_cents) || amountCents !== COMMUNITY_PRICE_CENTS) {
|
|
return { ok: false, error: 'AMOUNT_MISMATCH' };
|
|
}
|
|
if (String(order.currency || '').toUpperCase() !== COMMUNITY_CURRENCY) {
|
|
return { ok: false, error: 'CURRENCY_MISMATCH' };
|
|
}
|
|
if (result.currency && String(result.currency).toUpperCase() !== COMMUNITY_CURRENCY) {
|
|
return { ok: false, error: 'CURRENCY_MISMATCH' };
|
|
}
|
|
if (!String(result.trade_no || '').trim()) {
|
|
return { ok: false, error: 'TRADE_NO_REQUIRED' };
|
|
}
|
|
return { ok: true, amountCents };
|
|
}
|
|
|
|
export function validateCommunityPaidNotification(order, params, runtime) {
|
|
if (params?.notify_type !== 'trade_status_sync') {
|
|
return { ok: false, error: 'NOTIFY_TYPE_MISMATCH' };
|
|
}
|
|
if (params.app_id !== runtime?.appId) {
|
|
return { ok: false, error: 'APP_ID_MISMATCH' };
|
|
}
|
|
if (params.seller_id !== runtime?.sellerId) {
|
|
return { ok: false, error: 'SELLER_ID_MISMATCH' };
|
|
}
|
|
const result = validateCommunityTradeResult(order, params);
|
|
if (!result.ok) return result;
|
|
if (!isPaidTradeStatus(params.trade_status)) {
|
|
return { ok: false, error: 'TRADE_NOT_PAID' };
|
|
}
|
|
if (params.out_biz_no || params.gmt_refund || params.refund_fee) {
|
|
return { ok: false, error: 'NON_PAYMENT_EVENT' };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function sanitizeAlipayNotifyParams(params = {}) {
|
|
const safe = {};
|
|
for (const key of [
|
|
'notify_type',
|
|
'notify_id',
|
|
'sign_type',
|
|
'trade_no',
|
|
'app_id',
|
|
'out_trade_no',
|
|
'trade_status',
|
|
'total_amount',
|
|
'currency',
|
|
'seller_id',
|
|
'seller_email',
|
|
'out_biz_no',
|
|
'gmt_refund',
|
|
'refund_fee'
|
|
]) {
|
|
if (params[key] != null && params[key] !== '') safe[key] = params[key];
|
|
}
|
|
return safe;
|
|
}
|
|
|
|
export function validateSameOrigin(req) {
|
|
const originText = String(req?.headers?.origin || '').trim();
|
|
if (!originText) return false;
|
|
|
|
try {
|
|
const origin = new URL(originText).origin;
|
|
const host = req?.headers?.['x-forwarded-host'] || req?.headers?.host;
|
|
const protocol = req?.headers?.['x-forwarded-proto'] || 'https';
|
|
const requestUrl = host ? new URL(`${protocol}://${host}`).origin : '';
|
|
const configuredUrl = process.env.APP_URL ? new URL(process.env.APP_URL).origin : '';
|
|
return Boolean(
|
|
(requestUrl && origin === requestUrl)
|
|
|| (configuredUrl && origin === configuredUrl)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function detectCommunityQrMediaType(buffer) {
|
|
if (!Buffer.isBuffer(buffer) || !buffer.length) return '';
|
|
if (
|
|
buffer.length >= 8
|
|
&& buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
) return 'image/png';
|
|
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
return 'image/jpeg';
|
|
}
|
|
if (
|
|
buffer.length >= 12
|
|
&& buffer.subarray(0, 4).toString('ascii') === 'RIFF'
|
|
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
|
) return 'image/webp';
|
|
return '';
|
|
}
|
|
|
|
export function validateCommunityQrUpload(buffer, declaredType = '') {
|
|
if (!Buffer.isBuffer(buffer) || !buffer.length) {
|
|
return { ok: false, error: 'COMMUNITY_QR_REQUIRED' };
|
|
}
|
|
if (buffer.length > COMMUNITY_QR_MAX_BYTES) {
|
|
return { ok: false, error: 'COMMUNITY_QR_TOO_LARGE' };
|
|
}
|
|
const mediaType = detectCommunityQrMediaType(buffer);
|
|
if (!mediaType) return { ok: false, error: 'COMMUNITY_QR_INVALID_TYPE' };
|
|
const normalizedDeclaredType = String(declaredType || '').split(';')[0].trim().toLowerCase();
|
|
if (normalizedDeclaredType && normalizedDeclaredType !== mediaType) {
|
|
return { ok: false, error: 'COMMUNITY_QR_SIGNATURE_MISMATCH' };
|
|
}
|
|
return { ok: true, mediaType, sizeBytes: buffer.length };
|
|
}
|
|
|
|
export function encodePostgresBytea(buffer) {
|
|
return `\\x${buffer.toString('hex')}`;
|
|
}
|
|
|
|
export function decodePostgresBytea(value) {
|
|
if (Buffer.isBuffer(value)) return value;
|
|
const text = String(value || '');
|
|
if (/^\\x[0-9a-f]+$/i.test(text)) return Buffer.from(text.slice(2), 'hex');
|
|
return Buffer.from(text, 'base64');
|
|
}
|
|
|
|
export function noStore(res) {
|
|
res.setHeader('Cache-Control', 'private, no-store, max-age=0');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
}
|
|
|
|
export async function getLatestCommunityOrder(client, userId) {
|
|
const { data, error } = await client
|
|
.from('community_orders')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle();
|
|
if (error) throw error;
|
|
return data || null;
|
|
}
|
|
|
|
export async function getActiveCommunityOrder(client, userId) {
|
|
const { data, error } = await client
|
|
.from('community_orders')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.in('status', ['PENDING', 'PAID'])
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle();
|
|
if (error) throw error;
|
|
return data || null;
|
|
}
|
|
|
|
export async function getCurrentCommunityQr(client, { includeBytes = false } = {}) {
|
|
const columns = includeBytes
|
|
? 'id,media_type,size_bytes,qr_bytes,created_at'
|
|
: 'id,media_type,size_bytes,created_at';
|
|
const { data, error } = await client
|
|
.from('community_group_qr_assets')
|
|
.select(columns)
|
|
.eq('is_current', true)
|
|
.maybeSingle();
|
|
if (error) throw error;
|
|
return data || null;
|
|
}
|
|
|
|
export async function markCommunityOrderPaid(client, order, result, options = {}) {
|
|
const validation = validateCommunityTradeResult(order, result);
|
|
if (!validation.ok || !isPaidTradeStatus(result.trade_status)) {
|
|
throw new Error(validation.error || 'TRADE_NOT_PAID');
|
|
}
|
|
const { data, error } = await client.rpc('mark_community_order_paid', {
|
|
p_order_id: order.id,
|
|
p_trade_no: String(result.trade_no),
|
|
p_paid_amount_cents: validation.amountCents,
|
|
p_paid_currency: COMMUNITY_CURRENCY,
|
|
p_notify_id: options.notifyId || null,
|
|
p_notify_payload: options.notifyPayload || {}
|
|
});
|
|
if (error) throw error;
|
|
return Array.isArray(data) ? data[0] : data;
|
|
}
|
|
|
|
export async function closeCommunityOrderLocally(client, orderId) {
|
|
const { data, error } = await client.rpc('close_community_order', { p_order_id: orderId });
|
|
if (error) throw error;
|
|
return Array.isArray(data) ? data[0] : data;
|
|
}
|