import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { LoginModalComponent } from './login-modal.component'; import QRCode from 'qrcode'; // ═══════════════════════════════════════════════════ // APIG Payment Page — Angular Component // ═══════════════════════════════════════════════════ const API_BASE = 'https://server.fmode.cn'; const PARSE_BASE = API_BASE + '/parse/functions'; const PAY_COMPANY = '1AiWpTEDH9'; const APP_ID = 'ncloudmaster'; const DEFAULT_FUN_ID = 'HOkkX72PMF'; const ORDER_PAGE_SIZE = 10; interface PriceTier { count: number; price: number; } interface ApigData { objectId: string; title: string; content?: string; count?: number; priceStep?: PriceTier[]; [key: string]: any; } interface OrderData { objectId: string; createdAt: string; isPay?: boolean; price?: number; count?: number; detail?: { addCount?: number; oldCount?: number }; [key: string]: any; } @Component({ selector: 'app-root', imports: [CommonModule, LoginModalComponent], templateUrl: './app.html', styleUrl: './app.scss' }) export class App implements OnInit { @ViewChild('qrCanvas', { static: false }) qrCanvasRef!: ElementRef; // URL params authId = ''; userId = ''; apigId = ''; funId = DEFAULT_FUN_ID; // Login state needLogin = false; loggedInUser: string = ''; apigList: ApigData[] = []; apigListLoading = false; // State apig: ApigData | null = null; selectedIndex = 0; errorMsg = ''; paying = false; showQrModal = false; showSuccess = false; paySuccess = false; successDetailHtml = ''; // Order state order: any = null; tradeNo = ''; nonceStr = ''; pollTimer: any = null; // Order history orders: OrderData[] = []; ordersLoading = false; orderError = ''; hasMoreOrders = false; orderSkip = 0; get selectedTier(): PriceTier | null { if (!this.apig?.priceStep || this.apig.priceStep.length === 0) return null; return this.apig.priceStep[this.selectedIndex] || null; } get unitLabel(): string { if (!this.apig) return '次'; return (this.apig.objectId === 'MYM5zJBKgw' || this.apig.objectId === 'FQtTgjcqIZ') ? 'token' : '次'; } ngOnInit(): void { const params = new URLSearchParams(window.location.search); this.authId = params.get('authid') || ''; this.userId = params.get('user') || params.get('userid') || ''; this.apigId = params.get('apigid') || ''; this.funId = params.get('fun_id') || DEFAULT_FUN_ID; // 1. URL 已提供足够参数 → 直接加载 if (this.authId || (this.userId && this.apigId)) { console.log('[AUTH] URL 参数充足,直接加载'); this.loadApig(); return; } // 2. URL 没有 user → 检查 localStorage 缓存 const cachedUserId = localStorage.getItem('apig_user_id'); const cachedToken = localStorage.getItem('apig_session_token'); if (cachedUserId && cachedToken) { console.log('[AUTH] 从 localStorage 恢复用户:', cachedUserId); this.userId = cachedUserId; this.loggedInUser = cachedUserId; // 后台验证 token 是否还有效 this.validateCachedToken(cachedToken, cachedUserId); if (this.apigId) { this.loadApig(); } else { this.loadApigList(); } return; } // 3. 什么都没有 → 显示登录弹窗 console.log('[AUTH] 未检测到用户,显示登录弹窗'); this.needLogin = true; } async validateCachedToken(token: string, userId: string): Promise { try { const resp = await fetch(`${API_BASE}/parse/users/me`, { headers: { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Session-Token': token } }); const data = await resp.json(); if (!data.objectId) { console.warn('[AUTH] 缓存 token 已失效,需要重新登录'); localStorage.removeItem('apig_user_id'); localStorage.removeItem('apig_session_token'); this.needLogin = true; } } catch (e) { console.warn('[AUTH] 验证 token 失败:', e); } } onLoginSuccess(event: { userId: string; sessionToken: string }): void { console.log('[AUTH] 登录成功, userId:', event.userId); this.userId = event.userId; this.loggedInUser = event.userId; this.needLogin = false; this.errorMsg = ''; if (!this.apigId) { this.loadApigList(); return; } this.loadApig(); } // ─── Load available APIG list (when no apigId) ─── async loadApigList(): Promise { this.apigListLoading = true; try { const query: any = { where: JSON.stringify({ priceStep: { $exists: true } }), keys: 'title,content,priceStep', order: '-updatedAt', limit: '20' }; const resp = await fetch(API_BASE + '/parse/classes/APIG?' + new URLSearchParams(query), { headers: { 'X-Parse-Application-Id': APP_ID } }); const data = await resp.json(); if (data.results?.length > 0) { this.apigList = data.results; console.log('[APIG] 获取到', data.results.length, '个可用 API'); } else { console.log('[APIG] 未查询到可用 API'); } } catch (e: any) { console.warn('[APIG] 查询 API 列表失败:', e.message); } finally { this.apigListLoading = false; } } navigateToApig(apigId: string): void { const url = new URL(window.location.href); url.searchParams.set('apigid', apigId); window.location.href = url.toString(); } // ─── Load APIG Info ─── async loadApig(): Promise { try { // 如果没有 authId 但有 user+apigid,先查询/创建 APIGAuth if (!this.authId && this.userId && this.apigId) { console.log('[INIT] 无 authId,通过 user+apigid 查询 APIGAuth...'); const resolved = await this.resolveAuthId(this.userId, this.apigId); if (!resolved) { this.errorMsg = '无法获取或创建计费账套,请联系管理员。'; return; } this.authId = resolved; console.log('[INIT] 获取到 authId:', this.authId); } // 1. Try getApig REST endpoint const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: this.authId }); if (resp.code === 200 && resp.data) { this.apig = resp.data; this.applyTestTier(); this.selectTier(0); this.loadOrderHistory(); return; } // 2. Fallback: cloud function console.log('getApig returned no data, trying cloud function...'); const params = new URLSearchParams(window.location.search); const cfFuncName = params.get('cfName') || 'getApigInfo'; try { const cfResp = await this.parseCloudCall(cfFuncName, { apigId: this.authId }); if (cfResp?.result) { const cfData = cfResp.result.data || cfResp.result; if (cfData?.objectId) { this.apig = cfData; this.applyTestTier(); this.selectTier(0); this.loadOrderHistory(); return; } } } catch (e: any) { console.warn('cloud function error:', e); } this.errorMsg = '无法加载接口信息。请确认参数正确。'; } catch (e: any) { this.errorMsg = '网络错误: ' + e.message; } } // ─── 通过 user+apigid 查询或创建 APIGAuth ─── async resolveAuthId(user: string, apig: string): Promise { // 1. 查询已有 APIGAuth try { const query: any = { _method: 'GET', where: JSON.stringify({ api: { __type: 'Pointer', className: 'APIG', objectId: apig }, company: { __type: 'Pointer', className: 'Company', objectId: user } }), limit: '1' }; const resp = await fetch(API_BASE + '/parse/classes/APIGAuth?' + new URLSearchParams(query), { headers: { 'X-Parse-Application-Id': APP_ID } }); const data = await resp.json(); if (data.results?.length > 0) { console.log('[resolveAuthId] 找到已有 APIGAuth:', data.results[0].objectId); return data.results[0].objectId; } } catch (e: any) { console.warn('[resolveAuthId] 查询失败:', e.message); } // 2. 创建新 APIGAuth try { console.log('[resolveAuthId] 未找到 APIGAuth,创建新记录...'); const createResp = await fetch(API_BASE + '/parse/classes/APIGAuth', { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ api: { __type: 'Pointer', className: 'APIG', objectId: apig }, company: { __type: 'Pointer', className: 'Company', objectId: user }, count: 0, used: 0 }) }); const created = await createResp.json(); if (created.objectId) { console.log('[resolveAuthId] 创建成功:', created.objectId); return created.objectId; } console.error('[resolveAuthId] 创建失败:', JSON.stringify(created)); } catch (e: any) { console.error('[resolveAuthId] 创建异常:', e.message); } return null; } applyTestTier(): void { const params = new URLSearchParams(window.location.search); if (params.get('test') === '1' && this.apig?.priceStep) { this.apig.priceStep.unshift({ count: 1, price: 0.01 }); } } selectTier(idx: number): void { this.selectedIndex = idx; } // ─── Payment Flow ─── async startPayment(): Promise { if (!this.apig?.priceStep || this.paying) return; this.paying = true; this.errorMsg = ''; try { // 1. Generate trade number const now = new Date(); this.tradeNo = 'C' + (this.userId || 'U') + now.getFullYear() + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0') + String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0') + now.getMilliseconds(); const tier = this.apig.priceStep[this.selectedIndex]; // 2. Create order console.log('[PAY] Creating order...', { tradeNo: this.tradeNo, tier: tier.price, funId: this.funId }); await this.createOrder(tier); console.log('[PAY] Order created, order=', this.order); // 3. Call pay_code2 to get QR URL const payParams: any = { _ApplicationId: APP_ID, company: PAY_COMPANY, out_trade_no: this.tradeNo, total_fee: +tier.price, body: this.apig.title + ' 接口充值' }; if (this.funId) { payParams.fun_id = this.funId; } console.log('[PAY] Calling pay_code2 with params:', JSON.stringify(payParams)); let payResp: any = null; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); const rawResp = await fetch(PARSE_BASE + '/pay_code2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payParams), signal: controller.signal }); clearTimeout(timeout); payResp = await rawResp.json(); console.log('[PAY] pay_code2 response:', JSON.stringify(payResp)); } catch (fetchErr: any) { console.error('[PAY] pay_code2 fetch error:', fetchErr.message); if (payParams.fun_id) { console.log('[PAY] Retrying pay_code2 WITHOUT fun_id...'); delete payParams.fun_id; payResp = await this.postJSON(PARSE_BASE + '/pay_code2', payParams); console.log('[PAY] pay_code2 retry response:', JSON.stringify(payResp)); } else { throw fetchErr; } } if (!payResp?.result?.code_url) { throw new Error('获取支付码失败: ' + JSON.stringify(payResp)); } const codeUrl = payResp.result.code_url[0]; this.nonceStr = payResp.result.nonce_str; console.log('[PAY] QR code URL:', codeUrl); // 4. Show QR modal this.showQrModal = true; this.paySuccess = false; // Wait for DOM update, then render QR setTimeout(() => this.renderQR(codeUrl), 100); // 5. Start polling this.startPolling(); } catch (e: any) { this.errorMsg = '支付发起失败: ' + e.message; } finally { this.paying = false; } } async createOrder(tier: PriceTier): Promise { try { const body: any = { type: 'wxpay', authid: this.authId, params: { out_trade_no: this.tradeNo, total_fee: +tier.price, body: this.apig!.title + ' 接口充值' }, apigid: this.apig!.objectId, oldCount: this.apig!.count || 0, count: tier.count, user: this.userId || this.authId, fcompany: this.userId || this.authId }; const resp = await this.postJSON(API_BASE + '/api/apig/created-apigorder', body); if (resp.code === 200 && resp.data) { this.order = resp.data; console.log('Order created:', this.order); } } catch (e: any) { console.warn('Order creation error (non-blocking):', e.message); } } startPolling(): void { if (this.pollTimer) clearInterval(this.pollTimer); this.pollTimer = setInterval(async () => { try { const resp = await this.postJSON(PARSE_BASE + '/order_status2', { _ApplicationId: APP_ID, out_trade_no: this.tradeNo, nonce_str: this.nonceStr, company: PAY_COMPANY }); if (resp.result?.status?.[0] === 'SUCCESS') { clearInterval(this.pollTimer); this.pollTimer = null; this.paySuccess = true; await this.doRecharge(); } } catch (e: any) { console.warn('Poll error:', e.message); } }, 3000); } async doRecharge(): Promise { this.showQrModal = false; this.showSuccess = true; const oldCount = this.apig!.count || 0; const tier = this.apig!.priceStep![this.selectedIndex]; console.log('═══ [RECHARGE] 开始 ═══ oldCount:', oldCount, 'addCount:', tier.count, 'fun_id:', this.funId); // 步骤1: 等待后端微信回调自动执行云函数 for (let i = 1; i <= 8; i++) { console.log('[RECHARGE] 步骤1: 等待后端回调... 第' + i + '/8次 (3秒后)'); await this.sleep(3000); await this.refreshBalance(); if (this.apig!.count! > oldCount) { console.log('[RECHARGE] ✅ 后端回调充值成功! 余额:', oldCount, '→', this.apig!.count); this.loadOrderHistory(); return; } } // 步骤2: 后端回调未生效,调用 saveRecharge 保底 console.log('[RECHARGE] 步骤2: 后端回调未生效,调用 saveRecharge 保底...'); try { const rechargeBody = { user: this.userId || this.authId, authComp: this.userId || this.authId, authid: this.authId, apigid: this.apig!.objectId, oldCount: oldCount, count: tier.count, orderid: this.order?.objectId || '' }; console.log('[RECHARGE] saveRecharge 参数:', JSON.stringify(rechargeBody)); const rechargeResp = await this.postJSON(API_BASE + '/api/apig/saveRecharge', rechargeBody); console.log('[RECHARGE] saveRecharge 响应:', JSON.stringify(rechargeResp)); } catch (e: any) { console.warn('[RECHARGE] saveRecharge 失败:', e.message); } // 步骤3: 再轮询确认 for (let i = 1; i <= 3; i++) { await this.sleep(2000); await this.refreshBalance(); if (this.apig!.count! > oldCount) { console.log('[RECHARGE] ✅ saveRecharge 充值成功! 余额:', oldCount, '→', this.apig!.count); break; } } if (this.apig!.count! <= oldCount) { console.warn('[RECHARGE] ⚠️ 充值可能延迟,请稍后刷新页面查看'); } console.log('═══ [RECHARGE] 结束 ═══ 最终余额:', this.apig!.count); this.loadOrderHistory(); } async refreshBalance(): Promise { try { const resp = await this.postJSON(API_BASE + '/api/apig/getApig', { authid: this.authId }); if (resp.code === 200 && resp.data?.count != null) { this.apig!.count = resp.data.count; const tier = this.apig!.priceStep![this.selectedIndex]; this.successDetailHtml = '已充值 ' + this.apig!.title + ' 接口
' + '获得 ' + tier.count + ' ' + this.unitLabel + ',有效期 730 天
' + '当前余额: ' + this.apig!.count + ' ' + this.unitLabel + ''; console.log('Balance refreshed:', this.apig!.count); } } catch (e: any) { console.warn('Balance refresh failed:', e.message); } } async renderQR(url: string): Promise { const canvas = this.qrCanvasRef?.nativeElement; if (!canvas) { console.warn('QR canvas not found'); return; } canvas.width = 200; canvas.height = 200; try { await QRCode.toCanvas(canvas, url, { width: 200, margin: 0, color: { dark: '#000000', light: '#ffffff' } }); console.log('QR rendered via canvas'); } catch (e) { console.warn('QRCode.toCanvas failed, using img fallback:', e); canvas.style.display = 'none'; const wrapper = canvas.parentElement; if (wrapper) { const img = document.createElement('img'); img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(url); img.width = 200; img.height = 200; img.alt = 'Payment QR Code'; img.style.display = 'block'; wrapper.appendChild(img); } } } cancelPayment(): void { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } this.showQrModal = false; } handleDone(): void { try { const msg = { type: 'apig-payment-done', authid: this.authId, user: this.userId }; if (window.opener) (window.opener as any).postMessage(msg, '*'); if (window.parent !== window) window.parent.postMessage(msg, '*'); } catch (e) { console.warn('postMessage failed:', e); } try { window.close(); } catch (e) {} setTimeout(() => { window.location.reload(); }, 500); } // ─── Order History ─── async loadOrderHistory(append = false): Promise { this.ordersLoading = true; this.orderError = ''; if (!append) { this.orderSkip = 0; this.orders = []; } try { const userVal = this.userId || this.authId; const where = JSON.stringify({ '$or': [ { fromCompany: { __type: 'Pointer', className: 'Company', objectId: userVal } }, { fromUser: { __type: 'Pointer', className: '_User', objectId: userVal } } ] }); const qs = new URLSearchParams({ where, order: '-createdAt', limit: String(ORDER_PAGE_SIZE), skip: String(this.orderSkip) }); const resp = await fetch(API_BASE + '/parse/classes/APIGOrder?' + qs.toString(), { headers: { 'X-Parse-Application-Id': APP_ID } }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); const newOrders: OrderData[] = data.results || []; console.log('Order history loaded:', newOrders.length, 'skip:', this.orderSkip); this.orders = append ? [...this.orders, ...newOrders] : newOrders; this.hasMoreOrders = newOrders.length >= ORDER_PAGE_SIZE; if (this.hasMoreOrders) this.orderSkip += ORDER_PAGE_SIZE; } catch (e: any) { console.warn('Order history error:', e); this.orderError = e.message; } this.ordersLoading = false; } // ─── Order formatting helpers ─── formatTime(createdAt: string): string { if (!createdAt) return '—'; const dt = new Date(createdAt); return dt.getFullYear() + '-' + String(dt.getMonth() + 1).padStart(2, '0') + '-' + String(dt.getDate()).padStart(2, '0') + ' ' + String(dt.getHours()).padStart(2, '0') + ':' + String(dt.getMinutes()).padStart(2, '0'); } formatCount(o: OrderData): string { const count = o.detail?.addCount || o.count || null; if (count == null) return '—'; return count + ' ' + this.unitLabel; } formatAmount(o: OrderData): string { return o.price != null ? '¥' + o.price : '—'; } getStatusClass(o: OrderData): string { if (o.isPay === true) { if (o.detail?.addCount && o.detail?.oldCount != null) return 'status-recharged'; return 'status-paid'; } if (o.isPay === false) { const age = Date.now() - new Date(o.createdAt).getTime(); return age > 30 * 60 * 1000 ? 'status-failed' : 'status-pending'; } return 'status-pending'; } getStatusText(o: OrderData): string { if (o.isPay === true) { if (o.detail?.addCount && o.detail?.oldCount != null) return '已充值'; return '已支付'; } if (o.isPay === false) { const age = Date.now() - new Date(o.createdAt).getTime(); return age > 30 * 60 * 1000 ? '已过期' : '待支付'; } return '未知'; } // ─── Helpers ─── async postJSON(url: string, data: any): Promise { const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); return resp.json(); } async parseCloudCall(funcId: string, params: any): Promise { const url = PARSE_BASE + '/' + funcId; const body = { _ApplicationId: APP_ID, ...params }; const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID }, body: JSON.stringify(body) }); if (!resp.ok) { console.warn('Cloud function ' + funcId + ' returned ' + resp.status); return null; } return resp.json(); } private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } }