taiwan-payment-skill 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/assets/taiwan-payment/CLAUDE.md +297 -297
- package/assets/taiwan-payment/EXAMPLES.md +1425 -1425
- package/assets/taiwan-payment/README.md +2 -2
- package/assets/taiwan-payment/SKILL.md +857 -857
- package/assets/taiwan-payment/references/ecpay-payment-api.md +880 -880
- package/assets/taiwan-payment/references/newebpay-payment-api.md +677 -677
- package/assets/taiwan-payment/references/payuni-payment-api.md +997 -997
- package/assets/templates/base/quick-reference.md +60 -345
- package/assets/templates/base/skill-content.md +248 -738
- package/assets/templates/platforms/agent.json +26 -0
- package/assets/templates/platforms/claude.json +26 -26
- package/assets/templates/platforms/codebuddy.json +25 -25
- package/assets/templates/platforms/codex.json +26 -25
- package/assets/templates/platforms/continue.json +25 -25
- package/assets/templates/platforms/copilot.json +25 -25
- package/assets/templates/platforms/cursor.json +26 -25
- package/assets/templates/platforms/gemini.json +25 -25
- package/assets/templates/platforms/kiro.json +25 -25
- package/assets/templates/platforms/opencode.json +25 -25
- package/assets/templates/platforms/qoder.json +25 -25
- package/assets/templates/platforms/roocode.json +25 -25
- package/assets/templates/platforms/trae.json +25 -25
- package/assets/templates/platforms/windsurf.json +25 -25
- package/dist/index.js +127 -29
- package/package.json +1 -1
|
@@ -1,1425 +1,1425 @@
|
|
|
1
|
-
# 台灣金流 Skill - 完整範例集
|
|
2
|
-
|
|
3
|
-
這份文件包含使用 `taiwan-payment` skill 的完整實作範例。
|
|
4
|
-
|
|
5
|
-
## 目錄
|
|
6
|
-
|
|
7
|
-
1. [基礎範例](#基礎範例)
|
|
8
|
-
2. [進階範例](#進階範例)
|
|
9
|
-
3. [實戰場景](#實戰場景)
|
|
10
|
-
4. [常見錯誤與修正](#常見錯誤與修正)
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## 基礎範例
|
|
15
|
-
|
|
16
|
-
### 範例 1: ECPay 信用卡一次付清
|
|
17
|
-
|
|
18
|
-
**場景:** 客戶購買商品,金額 1050 元,使用綠界信用卡付款
|
|
19
|
-
|
|
20
|
-
**Claude 提示詞:**
|
|
21
|
-
```
|
|
22
|
-
使用 ECPay 測試環境建立信用卡付款
|
|
23
|
-
- 訂單金額: 1050 元
|
|
24
|
-
- 付款方式: 信用卡一次付清
|
|
25
|
-
- 商品名稱: 測試商品
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
**生成的程式碼:**
|
|
29
|
-
|
|
30
|
-
```typescript
|
|
31
|
-
import { ECPayPaymentService } from '@/lib/services/ecpay-payment-service'
|
|
32
|
-
import crypto from 'crypto'
|
|
33
|
-
|
|
34
|
-
async function createECPayOrder() {
|
|
35
|
-
const service = new ECPayPaymentService(false) // 測試環境
|
|
36
|
-
|
|
37
|
-
// 產生唯一訂單編號
|
|
38
|
-
const merchantTradeNo = `ORD${Date.now()}${Math.random().toString(36).substring(2, 8).toUpperCase()}`
|
|
39
|
-
|
|
40
|
-
const orderData = {
|
|
41
|
-
MerchantTradeNo: merchantTradeNo,
|
|
42
|
-
MerchantTradeDate: new Date().toLocaleString('zh-TW', {
|
|
43
|
-
year: 'numeric',
|
|
44
|
-
month: '2-digit',
|
|
45
|
-
day: '2-digit',
|
|
46
|
-
hour: '2-digit',
|
|
47
|
-
minute: '2-digit',
|
|
48
|
-
second: '2-digit',
|
|
49
|
-
hour12: false
|
|
50
|
-
}).replace(/\//g, '/'),
|
|
51
|
-
TotalAmount: 1050,
|
|
52
|
-
TradeDesc: '測試商品購買',
|
|
53
|
-
ItemName: '測試商品 x1',
|
|
54
|
-
ReturnURL: 'https://yourdomain.com/api/payment/callback',
|
|
55
|
-
ChoosePayment: 'Credit',
|
|
56
|
-
EncryptType: 1,
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const result = await service.createOrder('user-123', orderData)
|
|
61
|
-
|
|
62
|
-
if (result.success) {
|
|
63
|
-
console.log('[OK] 訂單建立成功')
|
|
64
|
-
console.log('訂單編號:', result.merchantTradeNo)
|
|
65
|
-
console.log('請導向付款頁:', result.formAction)
|
|
66
|
-
|
|
67
|
-
// 儲存訂單
|
|
68
|
-
await prisma.order.create({
|
|
69
|
-
data: {
|
|
70
|
-
userId: 'user-123',
|
|
71
|
-
merchantTradeNo: result.merchantTradeNo,
|
|
72
|
-
amount: 1050,
|
|
73
|
-
status: 'PENDING',
|
|
74
|
-
paymentProvider: 'ECPAY',
|
|
75
|
-
paymentMethod: 'Credit',
|
|
76
|
-
}
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
return result
|
|
80
|
-
} else {
|
|
81
|
-
console.error('[ERROR] 建立失敗:', result.msg)
|
|
82
|
-
}
|
|
83
|
-
} catch (error) {
|
|
84
|
-
console.error('付款建立異常:', error)
|
|
85
|
-
throw error
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// 前端提交表單
|
|
90
|
-
function submitToECPay(formData: { action: string, params: Record<string, string> }) {
|
|
91
|
-
const form = document.createElement('form')
|
|
92
|
-
form.method = 'POST'
|
|
93
|
-
form.action = formData.action
|
|
94
|
-
form.target = '_self'
|
|
95
|
-
|
|
96
|
-
Object.entries(formData.params).forEach(([key, value]) => {
|
|
97
|
-
const input = document.createElement('input')
|
|
98
|
-
input.type = 'hidden'
|
|
99
|
-
input.name = key
|
|
100
|
-
input.value = value
|
|
101
|
-
form.appendChild(input)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
document.body.appendChild(form)
|
|
105
|
-
form.submit()
|
|
106
|
-
}
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
**預期輸出:**
|
|
110
|
-
```
|
|
111
|
-
[OK] 訂單建立成功
|
|
112
|
-
訂單編號: ORD1738123456ABC7
|
|
113
|
-
請導向付款頁: https://payment-stage.ecpay.com.tw/Cashier/AioCheckOut/V5
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
### 範例 2: NewebPay MPG 整合支付
|
|
119
|
-
|
|
120
|
-
**場景:** 使用藍新金流整合支付頁,支援信用卡、ATM、超商代碼
|
|
121
|
-
|
|
122
|
-
**Claude 提示詞:**
|
|
123
|
-
```
|
|
124
|
-
使用 NewebPay MPG 建立整合付款
|
|
125
|
-
- 金額: 2500 元
|
|
126
|
-
- 付款方式: 信用卡、ATM、超商代碼
|
|
127
|
-
- 使用 AES 加密
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
**生成的程式碼:**
|
|
131
|
-
|
|
132
|
-
```typescript
|
|
133
|
-
import crypto from 'crypto'
|
|
134
|
-
|
|
135
|
-
class NewebPayService {
|
|
136
|
-
private merchantID: string
|
|
137
|
-
private hashKey: string
|
|
138
|
-
private hashIV: string
|
|
139
|
-
private apiUrl: string
|
|
140
|
-
|
|
141
|
-
constructor(isProd: boolean = false) {
|
|
142
|
-
this.merchantID = isProd
|
|
143
|
-
? process.env.NEWEBPAY_MERCHANT_ID_PROD!
|
|
144
|
-
: process.env.NEWEBPAY_MERCHANT_ID_TEST!
|
|
145
|
-
this.hashKey = isProd
|
|
146
|
-
? process.env.NEWEBPAY_HASH_KEY_PROD!
|
|
147
|
-
: process.env.NEWEBPAY_HASH_KEY_TEST!
|
|
148
|
-
this.hashIV = isProd
|
|
149
|
-
? process.env.NEWEBPAY_HASH_IV_PROD!
|
|
150
|
-
: process.env.NEWEBPAY_HASH_IV_TEST!
|
|
151
|
-
this.apiUrl = isProd
|
|
152
|
-
? 'https://core.newebpay.com/MPG/mpg_gateway'
|
|
153
|
-
: 'https://ccore.newebpay.com/MPG/mpg_gateway'
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
private encrypt(data: Record<string, any>): { TradeInfo: string, TradeSha: string } {
|
|
157
|
-
// 1. 轉換為查詢字串
|
|
158
|
-
const queryString = new URLSearchParams(data).toString()
|
|
159
|
-
|
|
160
|
-
// 2. AES-256-CBC 加密
|
|
161
|
-
const cipher = crypto.createCipheriv('aes-256-cbc', this.hashKey, this.hashIV)
|
|
162
|
-
cipher.setAutoPadding(true)
|
|
163
|
-
let encrypted = cipher.update(queryString, 'utf8', 'hex')
|
|
164
|
-
encrypted += cipher.final('hex')
|
|
165
|
-
|
|
166
|
-
// 3. 計算 SHA256
|
|
167
|
-
const tradeSha = crypto
|
|
168
|
-
.createHash('sha256')
|
|
169
|
-
.update(`HashKey=${this.hashKey}&${encrypted}&HashIV=${this.hashIV}`)
|
|
170
|
-
.digest('hex')
|
|
171
|
-
.toUpperCase()
|
|
172
|
-
|
|
173
|
-
return {
|
|
174
|
-
TradeInfo: encrypted,
|
|
175
|
-
TradeSha: tradeSha
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async createMPGOrder(userId: string, orderData: any) {
|
|
180
|
-
const merchantOrderNo = `MPG${Date.now()}`
|
|
181
|
-
|
|
182
|
-
const tradeInfo = {
|
|
183
|
-
MerchantID: this.merchantID,
|
|
184
|
-
RespondType: 'JSON',
|
|
185
|
-
TimeStamp: Math.floor(Date.now() / 1000).toString(),
|
|
186
|
-
Version: '2.0',
|
|
187
|
-
MerchantOrderNo: merchantOrderNo,
|
|
188
|
-
Amt: orderData.amount,
|
|
189
|
-
ItemDesc: orderData.itemDesc || '商品購買',
|
|
190
|
-
ReturnURL: orderData.returnURL,
|
|
191
|
-
NotifyURL: orderData.notifyURL,
|
|
192
|
-
Email: orderData.email,
|
|
193
|
-
// 啟用付款方式
|
|
194
|
-
CREDIT: 1, // 信用卡
|
|
195
|
-
VACC: 1, // ATM
|
|
196
|
-
CVS: 1, // 超商代碼
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// 加密
|
|
200
|
-
const { TradeInfo, TradeSha } = this.encrypt(tradeInfo)
|
|
201
|
-
|
|
202
|
-
console.log('[OK] NewebPay MPG 訂單建立')
|
|
203
|
-
console.log('訂單編號:', merchantOrderNo)
|
|
204
|
-
console.log('加密 TradeInfo 長度:', TradeInfo.length)
|
|
205
|
-
|
|
206
|
-
return {
|
|
207
|
-
success: true,
|
|
208
|
-
formAction: this.apiUrl,
|
|
209
|
-
formMethod: 'POST',
|
|
210
|
-
formParams: {
|
|
211
|
-
MerchantID: this.merchantID,
|
|
212
|
-
TradeInfo: TradeInfo,
|
|
213
|
-
TradeSha: TradeSha,
|
|
214
|
-
Version: '2.0'
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// 使用範例
|
|
221
|
-
async function createNewebPayMPG() {
|
|
222
|
-
const service = new NewebPayService(false) // 測試環境
|
|
223
|
-
|
|
224
|
-
const result = await service.createMPGOrder('user-123', {
|
|
225
|
-
amount: 2500,
|
|
226
|
-
itemDesc: '網站年費方案',
|
|
227
|
-
returnURL: 'https://yourdomain.com/payment/return',
|
|
228
|
-
notifyURL: 'https://yourdomain.com/api/payment/callback',
|
|
229
|
-
email: 'test@example.com'
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
return result
|
|
233
|
-
}
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
**重要說明:**
|
|
237
|
-
- NewebPay 使用 AES-256-CBC 雙層加密(TradeInfo + TradeSha)
|
|
238
|
-
- HashKey 必須 32 bytes,HashIV 必須 16 bytes
|
|
239
|
-
- MPG 整合支付頁會根據啟用的付款方式顯示選項
|
|
240
|
-
|
|
241
|
-
**預期輸出:**
|
|
242
|
-
```
|
|
243
|
-
[OK] NewebPay MPG 訂單建立
|
|
244
|
-
訂單編號: MPG1738123456789
|
|
245
|
-
加密 TradeInfo 長度: 384
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
---
|
|
249
|
-
|
|
250
|
-
### 範例 3: PAYUNi RESTful API
|
|
251
|
-
|
|
252
|
-
**場景:** 使用統一金流 RESTful JSON API 建立付款
|
|
253
|
-
|
|
254
|
-
**Claude 提示詞:**
|
|
255
|
-
```
|
|
256
|
-
使用 PAYUNi RESTful API 建立信用卡付款
|
|
257
|
-
- 金額: 3000 元
|
|
258
|
-
- JSON 格式
|
|
259
|
-
- AES-256-GCM 加密
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
**生成的程式碼:**
|
|
263
|
-
|
|
264
|
-
```typescript
|
|
265
|
-
import crypto from 'crypto'
|
|
266
|
-
import axios from 'axios'
|
|
267
|
-
|
|
268
|
-
class PAYUNiService {
|
|
269
|
-
private merchantID: string
|
|
270
|
-
private hashKey: string
|
|
271
|
-
private hashIV: string
|
|
272
|
-
private apiUrl: string
|
|
273
|
-
|
|
274
|
-
constructor(isProd: boolean = false) {
|
|
275
|
-
this.merchantID = isProd
|
|
276
|
-
? process.env.PAYUNI_MERCHANT_ID_PROD!
|
|
277
|
-
: process.env.PAYUNI_MERCHANT_ID_TEST!
|
|
278
|
-
this.hashKey = isProd
|
|
279
|
-
? process.env.PAYUNI_HASH_KEY_PROD!
|
|
280
|
-
: process.env.PAYUNI_HASH_KEY_TEST!
|
|
281
|
-
this.hashIV = isProd
|
|
282
|
-
? process.env.PAYUNI_HASH_IV_PROD!
|
|
283
|
-
: process.env.PAYUNI_HASH_IV_TEST!
|
|
284
|
-
this.apiUrl = isProd
|
|
285
|
-
? 'https://api.payuni.com.tw/api/upp'
|
|
286
|
-
: 'https://sandbox-api.payuni.com.tw/api/upp'
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
private encrypt(data: Record<string, any>): { EncryptInfo: string, HashInfo: string } {
|
|
290
|
-
// 1. JSON 字串化
|
|
291
|
-
const jsonString = JSON.stringify(data)
|
|
292
|
-
|
|
293
|
-
// 2. AES-256-GCM 加密
|
|
294
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', this.hashKey, this.hashIV)
|
|
295
|
-
let encrypted = cipher.update(jsonString, 'utf8', 'hex')
|
|
296
|
-
encrypted += cipher.final('hex')
|
|
297
|
-
|
|
298
|
-
// 3. 取得 Auth Tag
|
|
299
|
-
const authTag = cipher.getAuthTag().toString('hex')
|
|
300
|
-
|
|
301
|
-
// 4. 組合加密資料
|
|
302
|
-
const encryptInfo = encrypted + authTag
|
|
303
|
-
|
|
304
|
-
// 5. SHA256 簽章
|
|
305
|
-
const hashInfo = crypto
|
|
306
|
-
.createHash('sha256')
|
|
307
|
-
.update(`HashKey=${this.hashKey}&${encryptInfo}&HashIV=${this.hashIV}`)
|
|
308
|
-
.digest('hex')
|
|
309
|
-
.toUpperCase()
|
|
310
|
-
|
|
311
|
-
return {
|
|
312
|
-
EncryptInfo: encryptInfo,
|
|
313
|
-
HashInfo: hashInfo
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
async createOrder(userId: string, orderData: any) {
|
|
318
|
-
const merchantOrderNo = `UNI${Date.now()}`
|
|
319
|
-
|
|
320
|
-
const tradeData = {
|
|
321
|
-
MerchantID: this.merchantID,
|
|
322
|
-
MerchantOrderNo: merchantOrderNo,
|
|
323
|
-
Amount: orderData.amount,
|
|
324
|
-
ItemDescription: orderData.itemDesc || '商品購買',
|
|
325
|
-
ReturnURL: orderData.returnURL,
|
|
326
|
-
NotifyURL: orderData.notifyURL,
|
|
327
|
-
Email: orderData.email,
|
|
328
|
-
PaymentMethod: 'CREDIT', // 信用卡
|
|
329
|
-
TimeStamp: Math.floor(Date.now() / 1000)
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// 加密
|
|
333
|
-
const { EncryptInfo, HashInfo } = this.encrypt(tradeData)
|
|
334
|
-
|
|
335
|
-
try {
|
|
336
|
-
// RESTful POST 請求
|
|
337
|
-
const response = await axios.post(this.apiUrl, {
|
|
338
|
-
MerchantID: this.merchantID,
|
|
339
|
-
EncryptInfo: EncryptInfo,
|
|
340
|
-
HashInfo: HashInfo
|
|
341
|
-
}, {
|
|
342
|
-
headers: {
|
|
343
|
-
'Content-Type': 'application/json'
|
|
344
|
-
}
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
console.log('[OK] PAYUNi 訂單建立')
|
|
348
|
-
console.log('訂單編號:', merchantOrderNo)
|
|
349
|
-
console.log('回應狀態:', response.data.Status)
|
|
350
|
-
|
|
351
|
-
return {
|
|
352
|
-
success: response.data.Status === 'SUCCESS',
|
|
353
|
-
merchantTradeNo: merchantOrderNo,
|
|
354
|
-
paymentUrl: response.data.Data?.PaymentURL,
|
|
355
|
-
message: response.data.Message
|
|
356
|
-
}
|
|
357
|
-
} catch (error) {
|
|
358
|
-
console.error('[ERROR] PAYUNi 請求失敗:', error)
|
|
359
|
-
throw error
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// 使用範例
|
|
365
|
-
async function createPAYUNiOrder() {
|
|
366
|
-
const service = new PAYUNiService(false)
|
|
367
|
-
|
|
368
|
-
const result = await service.createOrder('user-123', {
|
|
369
|
-
amount: 3000,
|
|
370
|
-
itemDesc: '會員升級方案',
|
|
371
|
-
returnURL: 'https://yourdomain.com/payment/return',
|
|
372
|
-
notifyURL: 'https://yourdomain.com/api/payment/callback',
|
|
373
|
-
email: 'test@example.com'
|
|
374
|
-
})
|
|
375
|
-
|
|
376
|
-
if (result.success) {
|
|
377
|
-
console.log('請導向:', result.paymentUrl)
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
return result
|
|
381
|
-
}
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
**重要說明:**
|
|
385
|
-
- PAYUNi 使用 RESTful JSON API(與 ECPay/NewebPay 的 Form POST 不同)
|
|
386
|
-
- AES-256-GCM 加密需要附加 Auth Tag(最後 32 字元)
|
|
387
|
-
- 回應為 JSON 格式,包含 PaymentURL 導向連結
|
|
388
|
-
|
|
389
|
-
**預期輸出:**
|
|
390
|
-
```
|
|
391
|
-
[OK] PAYUNi 訂單建立
|
|
392
|
-
訂單編號: UNI1738123456789
|
|
393
|
-
回應狀態: SUCCESS
|
|
394
|
-
請導向: https://sandbox-api.payuni.com.tw/trade/payment?token=xxx
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
---
|
|
398
|
-
|
|
399
|
-
## 進階範例
|
|
400
|
-
|
|
401
|
-
### 範例 4: 服務工廠模式
|
|
402
|
-
|
|
403
|
-
**場景:** 建立金流服務工廠,動態切換三家服務商
|
|
404
|
-
|
|
405
|
-
**Claude 提示詞:**
|
|
406
|
-
```
|
|
407
|
-
建立金流服務工廠,支援 ECPay、NewebPay、PAYUNi 動態切換
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
**生成的程式碼:**
|
|
411
|
-
|
|
412
|
-
```typescript
|
|
413
|
-
// lib/services/payment-provider.ts - 介面定義
|
|
414
|
-
|
|
415
|
-
export interface PaymentService {
|
|
416
|
-
createOrder(userId: string, data: PaymentOrderData): Promise<PaymentOrderResponse>
|
|
417
|
-
queryOrder(userId: string, merchantTradeNo: string): Promise<PaymentQueryResponse>
|
|
418
|
-
refundOrder(userId: string, tradeNo: string, amount: number): Promise<PaymentRefundResponse>
|
|
419
|
-
verifyCallback(params: Record<string, any>): boolean
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
export interface PaymentOrderData {
|
|
423
|
-
merchantTradeNo: string
|
|
424
|
-
totalAmount: number
|
|
425
|
-
itemName: string
|
|
426
|
-
returnURL: string
|
|
427
|
-
notifyURL?: string
|
|
428
|
-
email?: string
|
|
429
|
-
paymentMethod?: string
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
export interface PaymentOrderResponse {
|
|
433
|
-
success: boolean
|
|
434
|
-
merchantTradeNo: string
|
|
435
|
-
formAction: string
|
|
436
|
-
formMethod: string
|
|
437
|
-
formParams: Record<string, string>
|
|
438
|
-
msg?: string
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// lib/services/payment-service-factory.ts - 工廠類別
|
|
442
|
-
|
|
443
|
-
import { PaymentService } from './payment-provider'
|
|
444
|
-
import { ECPayPaymentService } from './ecpay-payment-service'
|
|
445
|
-
import { NewebPayPaymentService } from './newebpay-payment-service'
|
|
446
|
-
import { PAYUNiPaymentService } from './payuni-payment-service'
|
|
447
|
-
import { prisma } from '@/lib/prisma'
|
|
448
|
-
|
|
449
|
-
type PaymentProvider = 'ECPAY' | 'NEWEBPAY' | 'PAYUNI'
|
|
450
|
-
|
|
451
|
-
export class PaymentServiceFactory {
|
|
452
|
-
/**
|
|
453
|
-
* 根據服務商名稱取得服務實例
|
|
454
|
-
*/
|
|
455
|
-
static getService(
|
|
456
|
-
provider: PaymentProvider,
|
|
457
|
-
isProd: boolean = false
|
|
458
|
-
): PaymentService {
|
|
459
|
-
switch (provider) {
|
|
460
|
-
case 'ECPAY':
|
|
461
|
-
return new ECPayPaymentService(isProd)
|
|
462
|
-
case 'NEWEBPAY':
|
|
463
|
-
return new NewebPayPaymentService(isProd)
|
|
464
|
-
case 'PAYUNI':
|
|
465
|
-
return new PAYUNiPaymentService(isProd)
|
|
466
|
-
default:
|
|
467
|
-
throw new Error(`不支援的金流服務商: ${provider}`)
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* 根據使用者設定取得服務實例
|
|
473
|
-
*/
|
|
474
|
-
static async getServiceForUser(userId: string): Promise<PaymentService> {
|
|
475
|
-
const settings = await prisma.paymentSettings.findUnique({
|
|
476
|
-
where: { userId },
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
if (!settings || !settings.defaultProvider) {
|
|
480
|
-
throw new Error('未設定預設金流服務商')
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return this.getService(
|
|
484
|
-
settings.defaultProvider as PaymentProvider,
|
|
485
|
-
settings.isProduction
|
|
486
|
-
)
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* 根據訂單取得服務實例(用於查詢/退款)
|
|
491
|
-
*/
|
|
492
|
-
static async getServiceForOrder(merchantTradeNo: string): Promise<PaymentService> {
|
|
493
|
-
const order = await prisma.order.findUnique({
|
|
494
|
-
where: { merchantTradeNo },
|
|
495
|
-
})
|
|
496
|
-
|
|
497
|
-
if (!order || !order.paymentProvider) {
|
|
498
|
-
throw new Error('訂單不存在或未記錄金流服務商')
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
return this.getService(
|
|
502
|
-
order.paymentProvider as PaymentProvider,
|
|
503
|
-
order.isProduction
|
|
504
|
-
)
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* 取得所有可用的服務商
|
|
509
|
-
*/
|
|
510
|
-
static getAvailableProviders(): PaymentProvider[] {
|
|
511
|
-
return ['ECPAY', 'NEWEBPAY', 'PAYUNI']
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
* 自動偵測服務商(根據回呼參數)
|
|
516
|
-
*/
|
|
517
|
-
static detectProvider(params: Record<string, any>): PaymentProvider {
|
|
518
|
-
if (params.CheckMacValue && params.MerchantTradeNo) {
|
|
519
|
-
return 'ECPAY'
|
|
520
|
-
}
|
|
521
|
-
if (params.TradeInfo && params.TradeSha) {
|
|
522
|
-
return 'NEWEBPAY'
|
|
523
|
-
}
|
|
524
|
-
if (params.EncryptInfo && params.HashInfo) {
|
|
525
|
-
return 'PAYUNI'
|
|
526
|
-
}
|
|
527
|
-
throw new Error('無法識別金流服務商')
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
```
|
|
531
|
-
|
|
532
|
-
**使用範例:**
|
|
533
|
-
|
|
534
|
-
```typescript
|
|
535
|
-
// API Route: app/api/payment/create/route.ts
|
|
536
|
-
|
|
537
|
-
import { PaymentServiceFactory } from '@/lib/services/payment-service-factory'
|
|
538
|
-
|
|
539
|
-
export async function POST(request: Request) {
|
|
540
|
-
const { userId, amount, itemName, provider } = await request.json()
|
|
541
|
-
|
|
542
|
-
// 方式 1: 使用指定服務商
|
|
543
|
-
const service = PaymentServiceFactory.getService(provider || 'ECPAY')
|
|
544
|
-
|
|
545
|
-
// 方式 2: 使用使用者預設服務商
|
|
546
|
-
// const service = await PaymentServiceFactory.getServiceForUser(userId)
|
|
547
|
-
|
|
548
|
-
const result = await service.createOrder(userId, {
|
|
549
|
-
merchantTradeNo: `ORD${Date.now()}`,
|
|
550
|
-
totalAmount: amount,
|
|
551
|
-
itemName: itemName,
|
|
552
|
-
returnURL: 'https://yourdomain.com/payment/return',
|
|
553
|
-
notifyURL: 'https://yourdomain.com/api/payment/callback',
|
|
554
|
-
})
|
|
555
|
-
|
|
556
|
-
return Response.json(result)
|
|
557
|
-
}
|
|
558
|
-
```
|
|
559
|
-
|
|
560
|
-
---
|
|
561
|
-
|
|
562
|
-
### 範例 5: 加密輔助函數
|
|
563
|
-
|
|
564
|
-
**場景:** 建立三種加密方式的 helper functions
|
|
565
|
-
|
|
566
|
-
**Claude 提示詞:**
|
|
567
|
-
```
|
|
568
|
-
建立加密工具,包含 ECPay SHA256、NewebPay AES-CBC、PAYUNi AES-GCM
|
|
569
|
-
```
|
|
570
|
-
|
|
571
|
-
**生成的程式碼:**
|
|
572
|
-
|
|
573
|
-
```typescript
|
|
574
|
-
// lib/utils/payment-encryption.ts
|
|
575
|
-
|
|
576
|
-
import crypto from 'crypto'
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* ECPay CheckMacValue 計算(SHA256)
|
|
580
|
-
*/
|
|
581
|
-
export function generateECPayCheckMacValue(
|
|
582
|
-
params: Record<string, any>,
|
|
583
|
-
hashKey: string,
|
|
584
|
-
hashIV: string
|
|
585
|
-
): string {
|
|
586
|
-
// 1. 移除 CheckMacValue 本身
|
|
587
|
-
const { CheckMacValue, ...cleanParams } = params
|
|
588
|
-
|
|
589
|
-
// 2. 依照 key 排序(字母順序)
|
|
590
|
-
const sortedKeys = Object.keys(cleanParams).sort()
|
|
591
|
-
|
|
592
|
-
// 3. 組合參數字串
|
|
593
|
-
const paramString = sortedKeys
|
|
594
|
-
.map(key => `${key}=${cleanParams[key]}`)
|
|
595
|
-
.join('&')
|
|
596
|
-
|
|
597
|
-
// 4. 前後加上 HashKey 和 HashIV
|
|
598
|
-
const rawString = `HashKey=${hashKey}&${paramString}&HashIV=${hashIV}`
|
|
599
|
-
|
|
600
|
-
// 5. URL Encode (lowercase)
|
|
601
|
-
const encoded = encodeURIComponent(rawString).toLowerCase()
|
|
602
|
-
|
|
603
|
-
// 6. SHA256 雜湊
|
|
604
|
-
const hash = crypto.createHash('sha256').update(encoded).digest('hex')
|
|
605
|
-
|
|
606
|
-
// 7. 轉大寫
|
|
607
|
-
return hash.toUpperCase()
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* NewebPay AES-256-CBC 加密
|
|
612
|
-
*/
|
|
613
|
-
export function encryptNewebPay(
|
|
614
|
-
data: Record<string, any>,
|
|
615
|
-
hashKey: string,
|
|
616
|
-
hashIV: string
|
|
617
|
-
): { TradeInfo: string; TradeSha: string } {
|
|
618
|
-
// 1. 轉換為查詢字串
|
|
619
|
-
const queryString = new URLSearchParams(data).toString()
|
|
620
|
-
|
|
621
|
-
// 2. AES-256-CBC 加密
|
|
622
|
-
const cipher = crypto.createCipheriv('aes-256-cbc', hashKey, hashIV)
|
|
623
|
-
cipher.setAutoPadding(true)
|
|
624
|
-
let encrypted = cipher.update(queryString, 'utf8', 'hex')
|
|
625
|
-
encrypted += cipher.final('hex')
|
|
626
|
-
|
|
627
|
-
// 3. 計算 SHA256
|
|
628
|
-
const tradeSha = crypto
|
|
629
|
-
.createHash('sha256')
|
|
630
|
-
.update(`HashKey=${hashKey}&${encrypted}&HashIV=${hashIV}`)
|
|
631
|
-
.digest('hex')
|
|
632
|
-
.toUpperCase()
|
|
633
|
-
|
|
634
|
-
return {
|
|
635
|
-
TradeInfo: encrypted,
|
|
636
|
-
TradeSha: tradeSha,
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* NewebPay AES-256-CBC 解密
|
|
642
|
-
*/
|
|
643
|
-
export function decryptNewebPay(
|
|
644
|
-
encryptedData: string,
|
|
645
|
-
hashKey: string,
|
|
646
|
-
hashIV: string
|
|
647
|
-
): Record<string, any> {
|
|
648
|
-
const decipher = crypto.createDecipheriv('aes-256-cbc', hashKey, hashIV)
|
|
649
|
-
decipher.setAutoPadding(true)
|
|
650
|
-
let decrypted = decipher.update(encryptedData, 'hex', 'utf8')
|
|
651
|
-
decrypted += decipher.final('utf8')
|
|
652
|
-
|
|
653
|
-
return Object.fromEntries(new URLSearchParams(decrypted))
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/**
|
|
657
|
-
* PAYUNi AES-256-GCM 加密
|
|
658
|
-
*/
|
|
659
|
-
export function encryptPAYUNi(
|
|
660
|
-
data: Record<string, any>,
|
|
661
|
-
hashKey: string,
|
|
662
|
-
hashIV: string
|
|
663
|
-
): { EncryptInfo: string; HashInfo: string } {
|
|
664
|
-
// 1. JSON 字串化
|
|
665
|
-
const jsonString = JSON.stringify(data)
|
|
666
|
-
|
|
667
|
-
// 2. AES-256-GCM 加密
|
|
668
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', hashKey, hashIV)
|
|
669
|
-
let encrypted = cipher.update(jsonString, 'utf8', 'hex')
|
|
670
|
-
encrypted += cipher.final('hex')
|
|
671
|
-
|
|
672
|
-
// 3. 取得 Auth Tag (16 bytes)
|
|
673
|
-
const authTag = cipher.getAuthTag().toString('hex')
|
|
674
|
-
|
|
675
|
-
// 4. 組合加密資料
|
|
676
|
-
const encryptInfo = encrypted + authTag
|
|
677
|
-
|
|
678
|
-
// 5. SHA256 簽章
|
|
679
|
-
const hashInfo = crypto
|
|
680
|
-
.createHash('sha256')
|
|
681
|
-
.update(`HashKey=${hashKey}&${encryptInfo}&HashIV=${hashIV}`)
|
|
682
|
-
.digest('hex')
|
|
683
|
-
.toUpperCase()
|
|
684
|
-
|
|
685
|
-
return {
|
|
686
|
-
EncryptInfo: encryptInfo,
|
|
687
|
-
HashInfo: hashInfo,
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* PAYUNi AES-256-GCM 解密
|
|
693
|
-
*/
|
|
694
|
-
export function decryptPAYUNi(
|
|
695
|
-
encryptedData: string,
|
|
696
|
-
hashKey: string,
|
|
697
|
-
hashIV: string
|
|
698
|
-
): Record<string, any> {
|
|
699
|
-
// 1. 分離加密內容和 Auth Tag(最後 32 個字元)
|
|
700
|
-
const encryptedContent = encryptedData.slice(0, -32)
|
|
701
|
-
const authTag = Buffer.from(encryptedData.slice(-32), 'hex')
|
|
702
|
-
|
|
703
|
-
// 2. AES-256-GCM 解密
|
|
704
|
-
const decipher = crypto.createDecipheriv('aes-256-gcm', hashKey, hashIV)
|
|
705
|
-
decipher.setAuthTag(authTag)
|
|
706
|
-
let decrypted = decipher.update(encryptedContent, 'hex', 'utf8')
|
|
707
|
-
decrypted += decipher.final('utf8')
|
|
708
|
-
|
|
709
|
-
return JSON.parse(decrypted)
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/**
|
|
713
|
-
* 驗證簽章
|
|
714
|
-
*/
|
|
715
|
-
export function verifySignature(
|
|
716
|
-
params: Record<string, any>,
|
|
717
|
-
signature: string,
|
|
718
|
-
hashKey: string,
|
|
719
|
-
hashIV: string,
|
|
720
|
-
provider: 'ECPAY' | 'NEWEBPAY' | 'PAYUNI'
|
|
721
|
-
): boolean {
|
|
722
|
-
switch (provider) {
|
|
723
|
-
case 'ECPAY':
|
|
724
|
-
const calculatedECPay = generateECPayCheckMacValue(params, hashKey, hashIV)
|
|
725
|
-
return calculatedECPay === signature
|
|
726
|
-
case 'NEWEBPAY':
|
|
727
|
-
const { TradeSha } = encryptNewebPay(params, hashKey, hashIV)
|
|
728
|
-
return TradeSha === signature
|
|
729
|
-
case 'PAYUNI':
|
|
730
|
-
const { HashInfo } = encryptPAYUNi(params, hashKey, hashIV)
|
|
731
|
-
return HashInfo === signature
|
|
732
|
-
default:
|
|
733
|
-
return false
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
```
|
|
737
|
-
|
|
738
|
-
**使用範例:**
|
|
739
|
-
|
|
740
|
-
```typescript
|
|
741
|
-
import { generateECPayCheckMacValue, encryptNewebPay, encryptPAYUNi } from '@/lib/utils/payment-encryption'
|
|
742
|
-
|
|
743
|
-
// ECPay 簽章
|
|
744
|
-
const ecpayParams = {
|
|
745
|
-
MerchantID: '3002607',
|
|
746
|
-
MerchantTradeNo: 'ORD123456',
|
|
747
|
-
TotalAmount: 1050,
|
|
748
|
-
}
|
|
749
|
-
const checkMacValue = generateECPayCheckMacValue(
|
|
750
|
-
ecpayParams,
|
|
751
|
-
'pwFHCqoQZGmho4w6',
|
|
752
|
-
'EkRm7iFT261dpevs'
|
|
753
|
-
)
|
|
754
|
-
console.log('ECPay CheckMacValue:', checkMacValue)
|
|
755
|
-
|
|
756
|
-
// NewebPay 加密
|
|
757
|
-
const newebpayData = {
|
|
758
|
-
MerchantID: 'MS12345678',
|
|
759
|
-
MerchantOrderNo: 'MPG123456',
|
|
760
|
-
Amt: 2500,
|
|
761
|
-
}
|
|
762
|
-
const { TradeInfo, TradeSha } = encryptNewebPay(
|
|
763
|
-
newebpayData,
|
|
764
|
-
'your32BytesHashKeyHere123456',
|
|
765
|
-
'your16BytesIV123'
|
|
766
|
-
)
|
|
767
|
-
console.log('NewebPay TradeInfo:', TradeInfo.substring(0, 50) + '...')
|
|
768
|
-
console.log('NewebPay TradeSha:', TradeSha)
|
|
769
|
-
|
|
770
|
-
// PAYUNi 加密
|
|
771
|
-
const payuniData = {
|
|
772
|
-
MerchantID: 'UNI12345',
|
|
773
|
-
MerchantOrderNo: 'UNI123456',
|
|
774
|
-
Amount: 3000,
|
|
775
|
-
}
|
|
776
|
-
const { EncryptInfo, HashInfo } = encryptPAYUNi(
|
|
777
|
-
payuniData,
|
|
778
|
-
'your32BytesHashKey',
|
|
779
|
-
'your16BytesIV'
|
|
780
|
-
)
|
|
781
|
-
console.log('PAYUNi EncryptInfo:', EncryptInfo.substring(0, 50) + '...')
|
|
782
|
-
console.log('PAYUNi HashInfo:', HashInfo)
|
|
783
|
-
```
|
|
784
|
-
|
|
785
|
-
---
|
|
786
|
-
|
|
787
|
-
## 實戰場景
|
|
788
|
-
|
|
789
|
-
### 場景 1: 電商結帳流程整合
|
|
790
|
-
|
|
791
|
-
**需求:** 完整的訂單建立 → 金流付款 → 付款通知 → 訂單查詢流程
|
|
792
|
-
|
|
793
|
-
**步驟 1: 建立訂單並導向付款**
|
|
794
|
-
|
|
795
|
-
```typescript
|
|
796
|
-
// app/api/checkout/route.ts
|
|
797
|
-
|
|
798
|
-
import { PaymentServiceFactory } from '@/lib/services/payment-service-factory'
|
|
799
|
-
import { prisma } from '@/lib/prisma'
|
|
800
|
-
|
|
801
|
-
export async function POST(request: Request) {
|
|
802
|
-
const { userId, cartItems, shippingInfo } = await request.json()
|
|
803
|
-
|
|
804
|
-
// 1. 計算訂單金額
|
|
805
|
-
const totalAmount = cartItems.reduce((sum, item) => {
|
|
806
|
-
return sum + (item.price * item.quantity)
|
|
807
|
-
}, 0)
|
|
808
|
-
|
|
809
|
-
// 2. 產生訂單編號
|
|
810
|
-
const merchantTradeNo = `ORD${Date.now()}${Math.random().toString(36).substring(2, 6).toUpperCase()}`
|
|
811
|
-
|
|
812
|
-
// 3. 建立資料庫訂單
|
|
813
|
-
const order = await prisma.order.create({
|
|
814
|
-
data: {
|
|
815
|
-
userId: userId,
|
|
816
|
-
merchantTradeNo: merchantTradeNo,
|
|
817
|
-
totalAmount: totalAmount,
|
|
818
|
-
status: 'PENDING',
|
|
819
|
-
shippingName: shippingInfo.name,
|
|
820
|
-
shippingAddress: shippingInfo.address,
|
|
821
|
-
shippingPhone: shippingInfo.phone,
|
|
822
|
-
items: {
|
|
823
|
-
create: cartItems.map(item => ({
|
|
824
|
-
productId: item.productId,
|
|
825
|
-
productName: item.name,
|
|
826
|
-
quantity: item.quantity,
|
|
827
|
-
price: item.price,
|
|
828
|
-
}))
|
|
829
|
-
}
|
|
830
|
-
},
|
|
831
|
-
include: { items: true }
|
|
832
|
-
})
|
|
833
|
-
|
|
834
|
-
// 4. 取得金流服務(使用者預設或指定)
|
|
835
|
-
const service = await PaymentServiceFactory.getServiceForUser(userId)
|
|
836
|
-
|
|
837
|
-
// 5. 建立付款訂單
|
|
838
|
-
const itemNames = order.items.map(item => `${item.productName} x${item.quantity}`).join('|')
|
|
839
|
-
|
|
840
|
-
const paymentResult = await service.createOrder(userId, {
|
|
841
|
-
merchantTradeNo: merchantTradeNo,
|
|
842
|
-
totalAmount: totalAmount,
|
|
843
|
-
itemName: itemNames.substring(0, 200), // 限制長度
|
|
844
|
-
returnURL: `${process.env.NEXT_PUBLIC_BASE_URL}/payment/return`,
|
|
845
|
-
notifyURL: `${process.env.NEXT_PUBLIC_BASE_URL}/api/payment/callback`,
|
|
846
|
-
email: shippingInfo.email,
|
|
847
|
-
})
|
|
848
|
-
|
|
849
|
-
// 6. 更新訂單記錄金流服務商
|
|
850
|
-
await prisma.order.update({
|
|
851
|
-
where: { id: order.id },
|
|
852
|
-
data: {
|
|
853
|
-
paymentProvider: paymentResult.provider || 'ECPAY',
|
|
854
|
-
}
|
|
855
|
-
})
|
|
856
|
-
|
|
857
|
-
// 7. 回傳付款表單資料
|
|
858
|
-
return Response.json({
|
|
859
|
-
success: true,
|
|
860
|
-
orderId: order.id,
|
|
861
|
-
merchantTradeNo: merchantTradeNo,
|
|
862
|
-
paymentForm: {
|
|
863
|
-
action: paymentResult.formAction,
|
|
864
|
-
method: paymentResult.formMethod,
|
|
865
|
-
params: paymentResult.formParams,
|
|
866
|
-
}
|
|
867
|
-
})
|
|
868
|
-
}
|
|
869
|
-
```
|
|
870
|
-
|
|
871
|
-
**步驟 2: 處理付款通知回呼**
|
|
872
|
-
|
|
873
|
-
```typescript
|
|
874
|
-
// app/api/payment/callback/route.ts
|
|
875
|
-
|
|
876
|
-
import { PaymentServiceFactory } from '@/lib/services/payment-service-factory'
|
|
877
|
-
import { prisma } from '@/lib/prisma'
|
|
878
|
-
|
|
879
|
-
export async function POST(request: Request) {
|
|
880
|
-
const formData = await request.formData()
|
|
881
|
-
const params = Object.fromEntries(formData)
|
|
882
|
-
|
|
883
|
-
console.log('[INFO] 收到付款通知:', params)
|
|
884
|
-
|
|
885
|
-
try {
|
|
886
|
-
// 1. 自動偵測服務商
|
|
887
|
-
const provider = PaymentServiceFactory.detectProvider(params)
|
|
888
|
-
const service = PaymentServiceFactory.getService(provider)
|
|
889
|
-
|
|
890
|
-
// 2. 驗證簽章
|
|
891
|
-
const isValid = service.verifyCallback(params)
|
|
892
|
-
if (!isValid) {
|
|
893
|
-
console.error('[ERROR] 簽章驗證失敗')
|
|
894
|
-
return new Response('0|CheckMacValue Error', { status: 400 })
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
// 3. 取得訂單編號(各服務商欄位不同)
|
|
898
|
-
const merchantTradeNo = params.MerchantTradeNo || params.MerchantOrderNo
|
|
899
|
-
|
|
900
|
-
// 4. 更新訂單狀態
|
|
901
|
-
const isPaid = params.RtnCode === '1' || params.Status === 'SUCCESS'
|
|
902
|
-
|
|
903
|
-
await prisma.order.update({
|
|
904
|
-
where: { merchantTradeNo },
|
|
905
|
-
data: {
|
|
906
|
-
status: isPaid ? 'PAID' : 'FAILED',
|
|
907
|
-
paidAt: isPaid ? new Date() : null,
|
|
908
|
-
tradeNo: params.TradeNo || params.TradeID, // 金流商訂單號
|
|
909
|
-
paymentMethod: params.PaymentType || params.PaymentMethod,
|
|
910
|
-
paymentDetails: JSON.stringify(params),
|
|
911
|
-
failureReason: isPaid ? null : params.RtnMsg || params.Message,
|
|
912
|
-
}
|
|
913
|
-
})
|
|
914
|
-
|
|
915
|
-
console.log(`[OK] 訂單 ${merchantTradeNo} 狀態更新為 ${isPaid ? 'PAID' : 'FAILED'}`)
|
|
916
|
-
|
|
917
|
-
// 5. 付款成功後續處理
|
|
918
|
-
if (isPaid) {
|
|
919
|
-
// 發送通知郵件、扣減庫存、開立發票等
|
|
920
|
-
// await sendOrderConfirmationEmail(merchantTradeNo)
|
|
921
|
-
// await reduceInventory(merchantTradeNo)
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// 6. 回應固定格式
|
|
925
|
-
return new Response('1|OK', {
|
|
926
|
-
status: 200,
|
|
927
|
-
headers: { 'Content-Type': 'text/plain' }
|
|
928
|
-
})
|
|
929
|
-
|
|
930
|
-
} catch (error) {
|
|
931
|
-
console.error('[ERROR] 付款通知處理失敗:', error)
|
|
932
|
-
return new Response('0|Error', { status: 500 })
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
```
|
|
936
|
-
|
|
937
|
-
**步驟 3: 查詢訂單狀態**
|
|
938
|
-
|
|
939
|
-
```typescript
|
|
940
|
-
// app/api/payment/query/route.ts
|
|
941
|
-
|
|
942
|
-
import { PaymentServiceFactory } from '@/lib/services/payment-service-factory'
|
|
943
|
-
import { prisma } from '@/lib/prisma'
|
|
944
|
-
|
|
945
|
-
export async function GET(request: Request) {
|
|
946
|
-
const { searchParams } = new URL(request.url)
|
|
947
|
-
const merchantTradeNo = searchParams.get('merchantTradeNo')
|
|
948
|
-
|
|
949
|
-
if (!merchantTradeNo) {
|
|
950
|
-
return Response.json({ error: '缺少訂單編號' }, { status: 400 })
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
try {
|
|
954
|
-
// 1. 從資料庫取得訂單
|
|
955
|
-
const order = await prisma.order.findUnique({
|
|
956
|
-
where: { merchantTradeNo }
|
|
957
|
-
})
|
|
958
|
-
|
|
959
|
-
if (!order) {
|
|
960
|
-
return Response.json({ error: '訂單不存在' }, { status: 404 })
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
// 2. 使用訂單記錄的服務商查詢
|
|
964
|
-
const service = await PaymentServiceFactory.getServiceForOrder(merchantTradeNo)
|
|
965
|
-
const queryResult = await service.queryOrder(order.userId, merchantTradeNo)
|
|
966
|
-
|
|
967
|
-
// 3. 同步訂單狀態
|
|
968
|
-
if (queryResult.success && queryResult.status !== order.status) {
|
|
969
|
-
await prisma.order.update({
|
|
970
|
-
where: { merchantTradeNo },
|
|
971
|
-
data: {
|
|
972
|
-
status: queryResult.status,
|
|
973
|
-
paidAt: queryResult.paidAt,
|
|
974
|
-
}
|
|
975
|
-
})
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
return Response.json({
|
|
979
|
-
success: true,
|
|
980
|
-
order: {
|
|
981
|
-
merchantTradeNo: order.merchantTradeNo,
|
|
982
|
-
amount: order.totalAmount,
|
|
983
|
-
status: queryResult.status || order.status,
|
|
984
|
-
paidAt: queryResult.paidAt || order.paidAt,
|
|
985
|
-
provider: order.paymentProvider,
|
|
986
|
-
}
|
|
987
|
-
})
|
|
988
|
-
|
|
989
|
-
} catch (error) {
|
|
990
|
-
console.error('[ERROR] 查詢訂單失敗:', error)
|
|
991
|
-
return Response.json({ error: '查詢失敗' }, { status: 500 })
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
```
|
|
995
|
-
|
|
996
|
-
**步驟 4: 前端整合**
|
|
997
|
-
|
|
998
|
-
```typescript
|
|
999
|
-
// components/CheckoutButton.tsx
|
|
1000
|
-
|
|
1001
|
-
'use client'
|
|
1002
|
-
|
|
1003
|
-
import { useState } from 'react'
|
|
1004
|
-
import { Button } from '@/components/ui/button'
|
|
1005
|
-
|
|
1006
|
-
export function CheckoutButton({ cartItems, shippingInfo }) {
|
|
1007
|
-
const [loading, setLoading] = useState(false)
|
|
1008
|
-
|
|
1009
|
-
const handleCheckout = async () => {
|
|
1010
|
-
setLoading(true)
|
|
1011
|
-
try {
|
|
1012
|
-
// 1. 建立訂單
|
|
1013
|
-
const response = await fetch('/api/checkout', {
|
|
1014
|
-
method: 'POST',
|
|
1015
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1016
|
-
body: JSON.stringify({
|
|
1017
|
-
userId: 'user-123',
|
|
1018
|
-
cartItems,
|
|
1019
|
-
shippingInfo,
|
|
1020
|
-
})
|
|
1021
|
-
})
|
|
1022
|
-
|
|
1023
|
-
const result = await response.json()
|
|
1024
|
-
|
|
1025
|
-
if (!result.success) {
|
|
1026
|
-
alert('結帳失敗')
|
|
1027
|
-
return
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
// 2. 提交付款表單
|
|
1031
|
-
const form = document.createElement('form')
|
|
1032
|
-
form.method = result.paymentForm.method
|
|
1033
|
-
form.action = result.paymentForm.action
|
|
1034
|
-
form.target = '_self'
|
|
1035
|
-
|
|
1036
|
-
Object.entries(result.paymentForm.params).forEach(([key, value]) => {
|
|
1037
|
-
const input = document.createElement('input')
|
|
1038
|
-
input.type = 'hidden'
|
|
1039
|
-
input.name = key
|
|
1040
|
-
input.value = value as string
|
|
1041
|
-
form.appendChild(input)
|
|
1042
|
-
})
|
|
1043
|
-
|
|
1044
|
-
document.body.appendChild(form)
|
|
1045
|
-
form.submit()
|
|
1046
|
-
|
|
1047
|
-
} catch (error) {
|
|
1048
|
-
console.error('結帳失敗:', error)
|
|
1049
|
-
alert('結帳失敗')
|
|
1050
|
-
} finally {
|
|
1051
|
-
setLoading(false)
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
return (
|
|
1056
|
-
<Button onClick={handleCheckout} disabled={loading} size="lg">
|
|
1057
|
-
{loading ? '處理中...' : '前往付款'}
|
|
1058
|
-
</Button>
|
|
1059
|
-
)
|
|
1060
|
-
}
|
|
1061
|
-
```
|
|
1062
|
-
|
|
1063
|
-
---
|
|
1064
|
-
|
|
1065
|
-
### 場景 2: 定期定額訂閱
|
|
1066
|
-
|
|
1067
|
-
**需求:** 實作週期扣款功能(會員訂閱制)
|
|
1068
|
-
|
|
1069
|
-
**步驟 1: 建立定期定額訂單**
|
|
1070
|
-
|
|
1071
|
-
```typescript
|
|
1072
|
-
// app/api/subscription/create/route.ts
|
|
1073
|
-
|
|
1074
|
-
import { ECPayPaymentService } from '@/lib/services/ecpay-payment-service'
|
|
1075
|
-
import { prisma } from '@/lib/prisma'
|
|
1076
|
-
|
|
1077
|
-
export async function POST(request: Request) {
|
|
1078
|
-
const { userId, planId, email } = await request.json()
|
|
1079
|
-
|
|
1080
|
-
// 1. 取得訂閱方案
|
|
1081
|
-
const plan = await prisma.subscriptionPlan.findUnique({
|
|
1082
|
-
where: { id: planId }
|
|
1083
|
-
})
|
|
1084
|
-
|
|
1085
|
-
if (!plan) {
|
|
1086
|
-
return Response.json({ error: '方案不存在' }, { status: 404 })
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// 2. 產生訂閱編號
|
|
1090
|
-
const merchantTradeNo = `SUB${Date.now()}`
|
|
1091
|
-
|
|
1092
|
-
// 3. 建立訂閱記錄
|
|
1093
|
-
const subscription = await prisma.subscription.create({
|
|
1094
|
-
data: {
|
|
1095
|
-
userId: userId,
|
|
1096
|
-
planId: planId,
|
|
1097
|
-
merchantTradeNo: merchantTradeNo,
|
|
1098
|
-
status: 'PENDING',
|
|
1099
|
-
amount: plan.price,
|
|
1100
|
-
frequency: plan.frequency, // 'M' = 月, 'Y' = 年
|
|
1101
|
-
totalTimes: plan.totalTimes || 999, // 999 = 無限次
|
|
1102
|
-
}
|
|
1103
|
-
})
|
|
1104
|
-
|
|
1105
|
-
// 4. 建立 ECPay 定期定額訂單
|
|
1106
|
-
const service = new ECPayPaymentService(false) // 測試環境
|
|
1107
|
-
|
|
1108
|
-
const periodicData = {
|
|
1109
|
-
MerchantTradeNo: merchantTradeNo,
|
|
1110
|
-
MerchantTradeDate: new Date().toLocaleString('zh-TW', {
|
|
1111
|
-
year: 'numeric',
|
|
1112
|
-
month: '2-digit',
|
|
1113
|
-
day: '2-digit',
|
|
1114
|
-
hour: '2-digit',
|
|
1115
|
-
minute: '2-digit',
|
|
1116
|
-
second: '2-digit',
|
|
1117
|
-
hour12: false
|
|
1118
|
-
}).replace(/\//g, '/'),
|
|
1119
|
-
TotalAmount: plan.price,
|
|
1120
|
-
TradeDesc: `訂閱方案:${plan.name}`,
|
|
1121
|
-
ItemName: plan.name,
|
|
1122
|
-
ReturnURL: `${process.env.NEXT_PUBLIC_BASE_URL}/api/subscription/callback`,
|
|
1123
|
-
PeriodAmount: plan.price, // 每期金額
|
|
1124
|
-
PeriodType: plan.frequency, // 'M' = 月, 'Y' = 年
|
|
1125
|
-
Frequency: 1, // 每 1 個週期
|
|
1126
|
-
ExecTimes: plan.totalTimes, // 執行次數
|
|
1127
|
-
PeriodReturnURL: `${process.env.NEXT_PUBLIC_BASE_URL}/api/subscription/periodic-callback`,
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
const result = await service.createPeriodicOrder(userId, periodicData)
|
|
1131
|
-
|
|
1132
|
-
return Response.json({
|
|
1133
|
-
success: result.success,
|
|
1134
|
-
subscriptionId: subscription.id,
|
|
1135
|
-
paymentForm: {
|
|
1136
|
-
action: result.formAction,
|
|
1137
|
-
method: result.formMethod,
|
|
1138
|
-
params: result.formParams,
|
|
1139
|
-
}
|
|
1140
|
-
})
|
|
1141
|
-
}
|
|
1142
|
-
```
|
|
1143
|
-
|
|
1144
|
-
**步驟 2: 處理首次授權回呼**
|
|
1145
|
-
|
|
1146
|
-
```typescript
|
|
1147
|
-
// app/api/subscription/callback/route.ts
|
|
1148
|
-
|
|
1149
|
-
import { prisma } from '@/lib/prisma'
|
|
1150
|
-
|
|
1151
|
-
export async function POST(request: Request) {
|
|
1152
|
-
const formData = await request.formData()
|
|
1153
|
-
const params = Object.fromEntries(formData)
|
|
1154
|
-
|
|
1155
|
-
console.log('[INFO] 收到訂閱授權通知:', params)
|
|
1156
|
-
|
|
1157
|
-
const merchantTradeNo = params.MerchantTradeNo
|
|
1158
|
-
const isPaid = params.RtnCode === '1'
|
|
1159
|
-
|
|
1160
|
-
// 更新訂閱狀態
|
|
1161
|
-
await prisma.subscription.update({
|
|
1162
|
-
where: { merchantTradeNo },
|
|
1163
|
-
data: {
|
|
1164
|
-
status: isPaid ? 'ACTIVE' : 'FAILED',
|
|
1165
|
-
gwsr: params.gwsr, // 綠界週期編號(重要:後續扣款需要)
|
|
1166
|
-
firstPaidAt: isPaid ? new Date() : null,
|
|
1167
|
-
}
|
|
1168
|
-
})
|
|
1169
|
-
|
|
1170
|
-
console.log(`[OK] 訂閱 ${merchantTradeNo} 授權${isPaid ? '成功' : '失敗'}`)
|
|
1171
|
-
|
|
1172
|
-
return new Response('1|OK')
|
|
1173
|
-
}
|
|
1174
|
-
```
|
|
1175
|
-
|
|
1176
|
-
**步驟 3: 處理週期扣款通知**
|
|
1177
|
-
|
|
1178
|
-
```typescript
|
|
1179
|
-
// app/api/subscription/periodic-callback/route.ts
|
|
1180
|
-
|
|
1181
|
-
import { prisma } from '@/lib/prisma'
|
|
1182
|
-
|
|
1183
|
-
export async function POST(request: Request) {
|
|
1184
|
-
const formData = await request.formData()
|
|
1185
|
-
const params = Object.fromEntries(formData)
|
|
1186
|
-
|
|
1187
|
-
console.log('[INFO] 收到週期扣款通知:', params)
|
|
1188
|
-
|
|
1189
|
-
const gwsr = params.gwsr // 綠界週期編號
|
|
1190
|
-
const isPaid = params.RtnCode === '1'
|
|
1191
|
-
const execTimes = parseInt(params.ExecTimes) // 當前第幾次扣款
|
|
1192
|
-
|
|
1193
|
-
// 1. 更新訂閱記錄
|
|
1194
|
-
const subscription = await prisma.subscription.findFirst({
|
|
1195
|
-
where: { gwsr }
|
|
1196
|
-
})
|
|
1197
|
-
|
|
1198
|
-
if (subscription) {
|
|
1199
|
-
// 2. 建立扣款記錄
|
|
1200
|
-
await prisma.subscriptionPayment.create({
|
|
1201
|
-
data: {
|
|
1202
|
-
subscriptionId: subscription.id,
|
|
1203
|
-
merchantTradeNo: params.MerchantTradeNo,
|
|
1204
|
-
tradeNo: params.TradeNo,
|
|
1205
|
-
amount: parseInt(params.amount),
|
|
1206
|
-
execTimes: execTimes,
|
|
1207
|
-
status: isPaid ? 'PAID' : 'FAILED',
|
|
1208
|
-
paidAt: isPaid ? new Date() : null,
|
|
1209
|
-
failureReason: isPaid ? null : params.RtnMsg,
|
|
1210
|
-
}
|
|
1211
|
-
})
|
|
1212
|
-
|
|
1213
|
-
// 3. 更新訂閱狀態
|
|
1214
|
-
await prisma.subscription.update({
|
|
1215
|
-
where: { id: subscription.id },
|
|
1216
|
-
data: {
|
|
1217
|
-
currentExecTimes: execTimes,
|
|
1218
|
-
lastPaidAt: isPaid ? new Date() : subscription.lastPaidAt,
|
|
1219
|
-
}
|
|
1220
|
-
})
|
|
1221
|
-
|
|
1222
|
-
console.log(`[OK] 訂閱 ${subscription.merchantTradeNo} 第 ${execTimes} 次扣款${isPaid ? '成功' : '失敗'}`)
|
|
1223
|
-
|
|
1224
|
-
// 4. 扣款成功後續處理
|
|
1225
|
-
if (isPaid) {
|
|
1226
|
-
// 延長會員期限、發送通知等
|
|
1227
|
-
// await extendMembershipPeriod(subscription.userId)
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
return new Response('1|OK')
|
|
1232
|
-
}
|
|
1233
|
-
```
|
|
1234
|
-
|
|
1235
|
-
---
|
|
1236
|
-
|
|
1237
|
-
## 常見錯誤與修正
|
|
1238
|
-
|
|
1239
|
-
### 錯誤 1: CheckMacValue 計算錯誤
|
|
1240
|
-
|
|
1241
|
-
**錯誤訊息:** ECPay 回傳 `10100058: 請確認檢查碼是否正確`
|
|
1242
|
-
|
|
1243
|
-
**原因:** 參數排序錯誤或 URL Encode 不正確
|
|
1244
|
-
|
|
1245
|
-
**修正前:**
|
|
1246
|
-
```typescript
|
|
1247
|
-
//
|
|
1248
|
-
function generateCheckMacValue(params: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1249
|
-
const paramString = Object.entries(params)
|
|
1250
|
-
.map(([k, v]) => `${k}=${v}`)
|
|
1251
|
-
.join('&')
|
|
1252
|
-
|
|
1253
|
-
const rawString = `HashKey=${hashKey}&${paramString}&HashIV=${hashIV}`
|
|
1254
|
-
const hash = crypto.createHash('sha256').update(rawString).digest('hex')
|
|
1255
|
-
return hash.toUpperCase()
|
|
1256
|
-
}
|
|
1257
|
-
```
|
|
1258
|
-
|
|
1259
|
-
**修正後:**
|
|
1260
|
-
```typescript
|
|
1261
|
-
//
|
|
1262
|
-
function generateCheckMacValue(params: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1263
|
-
// 1. 移除 CheckMacValue 本身
|
|
1264
|
-
const { CheckMacValue, ...cleanParams } = params
|
|
1265
|
-
|
|
1266
|
-
// 2. 排序 keys
|
|
1267
|
-
const sortedKeys = Object.keys(cleanParams).sort()
|
|
1268
|
-
|
|
1269
|
-
// 3. 組合參數字串
|
|
1270
|
-
const paramString = sortedKeys
|
|
1271
|
-
.map(key => `${key}=${cleanParams[key]}`)
|
|
1272
|
-
.join('&')
|
|
1273
|
-
|
|
1274
|
-
// 4. 前後加上 HashKey/HashIV
|
|
1275
|
-
const rawString = `HashKey=${hashKey}&${paramString}&HashIV=${hashIV}`
|
|
1276
|
-
|
|
1277
|
-
// 5. URL Encode (lowercase)
|
|
1278
|
-
const encoded = encodeURIComponent(rawString).toLowerCase()
|
|
1279
|
-
|
|
1280
|
-
// 6. SHA256 + 大寫
|
|
1281
|
-
const hash = crypto.createHash('sha256').update(encoded).digest('hex')
|
|
1282
|
-
return hash.toUpperCase()
|
|
1283
|
-
}
|
|
1284
|
-
```
|
|
1285
|
-
|
|
1286
|
-
**驗證方法:**
|
|
1287
|
-
```typescript
|
|
1288
|
-
// 測試範例
|
|
1289
|
-
const params = {
|
|
1290
|
-
MerchantID: '3002607',
|
|
1291
|
-
MerchantTradeNo: 'ORD123456',
|
|
1292
|
-
MerchantTradeDate: '2024/01/29 12:00:00',
|
|
1293
|
-
TotalAmount: 1050,
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
const checkMacValue = generateCheckMacValue(
|
|
1297
|
-
params,
|
|
1298
|
-
'pwFHCqoQZGmho4w6',
|
|
1299
|
-
'EkRm7iFT261dpevs'
|
|
1300
|
-
)
|
|
1301
|
-
|
|
1302
|
-
console.log('計算結果:', checkMacValue)
|
|
1303
|
-
// 應該與 ECPay 要求的值一致
|
|
1304
|
-
```
|
|
1305
|
-
|
|
1306
|
-
---
|
|
1307
|
-
|
|
1308
|
-
### 錯誤 2: AES 加密錯誤
|
|
1309
|
-
|
|
1310
|
-
**錯誤訊息:** NewebPay 回傳 `TradeSha 錯誤` 或 PAYUNi 回傳 `HashInfo 錯誤`
|
|
1311
|
-
|
|
1312
|
-
**原因:** Key/IV 長度錯誤或未附加 Auth Tag
|
|
1313
|
-
|
|
1314
|
-
**修正前 (NewebPay):**
|
|
1315
|
-
```typescript
|
|
1316
|
-
//
|
|
1317
|
-
function encryptNewebPay(data: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1318
|
-
const queryString = new URLSearchParams(data).toString()
|
|
1319
|
-
|
|
1320
|
-
// 錯誤:未檢查 Key/IV 長度
|
|
1321
|
-
const cipher = crypto.createCipheriv('aes-256-cbc', hashKey, hashIV)
|
|
1322
|
-
let encrypted = cipher.update(queryString, 'utf8', 'hex')
|
|
1323
|
-
encrypted += cipher.final('hex')
|
|
1324
|
-
|
|
1325
|
-
return encrypted
|
|
1326
|
-
}
|
|
1327
|
-
```
|
|
1328
|
-
|
|
1329
|
-
**修正後 (NewebPay):**
|
|
1330
|
-
```typescript
|
|
1331
|
-
//
|
|
1332
|
-
function encryptNewebPay(data: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1333
|
-
// 確認長度
|
|
1334
|
-
if (hashKey.length !== 32) throw new Error('HashKey 必須 32 bytes')
|
|
1335
|
-
if (hashIV.length !== 16) throw new Error('HashIV 必須 16 bytes')
|
|
1336
|
-
|
|
1337
|
-
const queryString = new URLSearchParams(data).toString()
|
|
1338
|
-
|
|
1339
|
-
// AES-256-CBC 加密
|
|
1340
|
-
const cipher = crypto.createCipheriv('aes-256-cbc', hashKey, hashIV)
|
|
1341
|
-
cipher.setAutoPadding(true)
|
|
1342
|
-
let encrypted = cipher.update(queryString, 'utf8', 'hex')
|
|
1343
|
-
encrypted += cipher.final('hex')
|
|
1344
|
-
|
|
1345
|
-
// 計算 TradeSha
|
|
1346
|
-
const tradeSha = crypto
|
|
1347
|
-
.createHash('sha256')
|
|
1348
|
-
.update(`HashKey=${hashKey}&${encrypted}&HashIV=${hashIV}`)
|
|
1349
|
-
.digest('hex')
|
|
1350
|
-
.toUpperCase()
|
|
1351
|
-
|
|
1352
|
-
return {
|
|
1353
|
-
TradeInfo: encrypted,
|
|
1354
|
-
TradeSha: tradeSha
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
```
|
|
1358
|
-
|
|
1359
|
-
**修正前 (PAYUNi):**
|
|
1360
|
-
```typescript
|
|
1361
|
-
//
|
|
1362
|
-
function encryptPAYUNi(data: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1363
|
-
const jsonString = JSON.stringify(data)
|
|
1364
|
-
|
|
1365
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', hashKey, hashIV)
|
|
1366
|
-
let encrypted = cipher.update(jsonString, 'utf8', 'hex')
|
|
1367
|
-
encrypted += cipher.final('hex')
|
|
1368
|
-
|
|
1369
|
-
// 錯誤:忘記取得 Auth Tag
|
|
1370
|
-
return encrypted
|
|
1371
|
-
}
|
|
1372
|
-
```
|
|
1373
|
-
|
|
1374
|
-
**修正後 (PAYUNi):**
|
|
1375
|
-
```typescript
|
|
1376
|
-
//
|
|
1377
|
-
function encryptPAYUNi(data: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1378
|
-
const jsonString = JSON.stringify(data)
|
|
1379
|
-
|
|
1380
|
-
// AES-256-GCM 加密
|
|
1381
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', hashKey, hashIV)
|
|
1382
|
-
let encrypted = cipher.update(jsonString, 'utf8', 'hex')
|
|
1383
|
-
encrypted += cipher.final('hex')
|
|
1384
|
-
|
|
1385
|
-
// 取得 Auth Tag(16 bytes = 32 hex chars)
|
|
1386
|
-
const authTag = cipher.getAuthTag().toString('hex')
|
|
1387
|
-
|
|
1388
|
-
// 組合:encrypted + authTag
|
|
1389
|
-
const encryptInfo = encrypted + authTag
|
|
1390
|
-
|
|
1391
|
-
// 計算 HashInfo
|
|
1392
|
-
const hashInfo = crypto
|
|
1393
|
-
.createHash('sha256')
|
|
1394
|
-
.update(`HashKey=${hashKey}&${encryptInfo}&HashIV=${hashIV}`)
|
|
1395
|
-
.digest('hex')
|
|
1396
|
-
.toUpperCase()
|
|
1397
|
-
|
|
1398
|
-
return {
|
|
1399
|
-
EncryptInfo: encryptInfo,
|
|
1400
|
-
HashInfo: hashInfo
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
```
|
|
1404
|
-
|
|
1405
|
-
**測試工具:**
|
|
1406
|
-
```typescript
|
|
1407
|
-
// 測試 NewebPay 加密/解密
|
|
1408
|
-
const testData = { test: 'hello', amount: 1000 }
|
|
1409
|
-
const { TradeInfo, TradeSha } = encryptNewebPay(testData, hashKey, hashIV)
|
|
1410
|
-
const decrypted = decryptNewebPay(TradeInfo, hashKey, hashIV)
|
|
1411
|
-
console.log('原始:', testData)
|
|
1412
|
-
console.log('解密:', decrypted)
|
|
1413
|
-
console.log('一致:', JSON.stringify(testData) === JSON.stringify(decrypted))
|
|
1414
|
-
|
|
1415
|
-
// 測試 PAYUNi 加密/解密
|
|
1416
|
-
const { EncryptInfo, HashInfo } = encryptPAYUNi(testData, hashKey, hashIV)
|
|
1417
|
-
const decryptedPAYUNi = decryptPAYUNi(EncryptInfo, hashKey, hashIV)
|
|
1418
|
-
console.log('原始:', testData)
|
|
1419
|
-
console.log('解密:', decryptedPAYUNi)
|
|
1420
|
-
console.log('一致:', JSON.stringify(testData) === JSON.stringify(decryptedPAYUNi))
|
|
1421
|
-
```
|
|
1422
|
-
|
|
1423
|
-
---
|
|
1424
|
-
|
|
1425
|
-
**更多範例持續更新中...**
|
|
1
|
+
# 台灣金流 Skill - 完整範例集
|
|
2
|
+
|
|
3
|
+
這份文件包含使用 `taiwan-payment` skill 的完整實作範例。
|
|
4
|
+
|
|
5
|
+
## 目錄
|
|
6
|
+
|
|
7
|
+
1. [基礎範例](#基礎範例)
|
|
8
|
+
2. [進階範例](#進階範例)
|
|
9
|
+
3. [實戰場景](#實戰場景)
|
|
10
|
+
4. [常見錯誤與修正](#常見錯誤與修正)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 基礎範例
|
|
15
|
+
|
|
16
|
+
### 範例 1: ECPay 信用卡一次付清
|
|
17
|
+
|
|
18
|
+
**場景:** 客戶購買商品,金額 1050 元,使用綠界信用卡付款
|
|
19
|
+
|
|
20
|
+
**Claude 提示詞:**
|
|
21
|
+
```
|
|
22
|
+
使用 ECPay 測試環境建立信用卡付款
|
|
23
|
+
- 訂單金額: 1050 元
|
|
24
|
+
- 付款方式: 信用卡一次付清
|
|
25
|
+
- 商品名稱: 測試商品
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**生成的程式碼:**
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { ECPayPaymentService } from '@/lib/services/ecpay-payment-service'
|
|
32
|
+
import crypto from 'crypto'
|
|
33
|
+
|
|
34
|
+
async function createECPayOrder() {
|
|
35
|
+
const service = new ECPayPaymentService(false) // 測試環境
|
|
36
|
+
|
|
37
|
+
// 產生唯一訂單編號
|
|
38
|
+
const merchantTradeNo = `ORD${Date.now()}${Math.random().toString(36).substring(2, 8).toUpperCase()}`
|
|
39
|
+
|
|
40
|
+
const orderData = {
|
|
41
|
+
MerchantTradeNo: merchantTradeNo,
|
|
42
|
+
MerchantTradeDate: new Date().toLocaleString('zh-TW', {
|
|
43
|
+
year: 'numeric',
|
|
44
|
+
month: '2-digit',
|
|
45
|
+
day: '2-digit',
|
|
46
|
+
hour: '2-digit',
|
|
47
|
+
minute: '2-digit',
|
|
48
|
+
second: '2-digit',
|
|
49
|
+
hour12: false
|
|
50
|
+
}).replace(/\//g, '/'),
|
|
51
|
+
TotalAmount: 1050,
|
|
52
|
+
TradeDesc: '測試商品購買',
|
|
53
|
+
ItemName: '測試商品 x1',
|
|
54
|
+
ReturnURL: 'https://yourdomain.com/api/payment/callback',
|
|
55
|
+
ChoosePayment: 'Credit',
|
|
56
|
+
EncryptType: 1,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const result = await service.createOrder('user-123', orderData)
|
|
61
|
+
|
|
62
|
+
if (result.success) {
|
|
63
|
+
console.log('[OK] 訂單建立成功')
|
|
64
|
+
console.log('訂單編號:', result.merchantTradeNo)
|
|
65
|
+
console.log('請導向付款頁:', result.formAction)
|
|
66
|
+
|
|
67
|
+
// 儲存訂單
|
|
68
|
+
await prisma.order.create({
|
|
69
|
+
data: {
|
|
70
|
+
userId: 'user-123',
|
|
71
|
+
merchantTradeNo: result.merchantTradeNo,
|
|
72
|
+
amount: 1050,
|
|
73
|
+
status: 'PENDING',
|
|
74
|
+
paymentProvider: 'ECPAY',
|
|
75
|
+
paymentMethod: 'Credit',
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
} else {
|
|
81
|
+
console.error('[ERROR] 建立失敗:', result.msg)
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('付款建立異常:', error)
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 前端提交表單
|
|
90
|
+
function submitToECPay(formData: { action: string, params: Record<string, string> }) {
|
|
91
|
+
const form = document.createElement('form')
|
|
92
|
+
form.method = 'POST'
|
|
93
|
+
form.action = formData.action
|
|
94
|
+
form.target = '_self'
|
|
95
|
+
|
|
96
|
+
Object.entries(formData.params).forEach(([key, value]) => {
|
|
97
|
+
const input = document.createElement('input')
|
|
98
|
+
input.type = 'hidden'
|
|
99
|
+
input.name = key
|
|
100
|
+
input.value = value
|
|
101
|
+
form.appendChild(input)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
document.body.appendChild(form)
|
|
105
|
+
form.submit()
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**預期輸出:**
|
|
110
|
+
```
|
|
111
|
+
[OK] 訂單建立成功
|
|
112
|
+
訂單編號: ORD1738123456ABC7
|
|
113
|
+
請導向付款頁: https://payment-stage.ecpay.com.tw/Cashier/AioCheckOut/V5
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### 範例 2: NewebPay MPG 整合支付
|
|
119
|
+
|
|
120
|
+
**場景:** 使用藍新金流整合支付頁,支援信用卡、ATM、超商代碼
|
|
121
|
+
|
|
122
|
+
**Claude 提示詞:**
|
|
123
|
+
```
|
|
124
|
+
使用 NewebPay MPG 建立整合付款
|
|
125
|
+
- 金額: 2500 元
|
|
126
|
+
- 付款方式: 信用卡、ATM、超商代碼
|
|
127
|
+
- 使用 AES 加密
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**生成的程式碼:**
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import crypto from 'crypto'
|
|
134
|
+
|
|
135
|
+
class NewebPayService {
|
|
136
|
+
private merchantID: string
|
|
137
|
+
private hashKey: string
|
|
138
|
+
private hashIV: string
|
|
139
|
+
private apiUrl: string
|
|
140
|
+
|
|
141
|
+
constructor(isProd: boolean = false) {
|
|
142
|
+
this.merchantID = isProd
|
|
143
|
+
? process.env.NEWEBPAY_MERCHANT_ID_PROD!
|
|
144
|
+
: process.env.NEWEBPAY_MERCHANT_ID_TEST!
|
|
145
|
+
this.hashKey = isProd
|
|
146
|
+
? process.env.NEWEBPAY_HASH_KEY_PROD!
|
|
147
|
+
: process.env.NEWEBPAY_HASH_KEY_TEST!
|
|
148
|
+
this.hashIV = isProd
|
|
149
|
+
? process.env.NEWEBPAY_HASH_IV_PROD!
|
|
150
|
+
: process.env.NEWEBPAY_HASH_IV_TEST!
|
|
151
|
+
this.apiUrl = isProd
|
|
152
|
+
? 'https://core.newebpay.com/MPG/mpg_gateway'
|
|
153
|
+
: 'https://ccore.newebpay.com/MPG/mpg_gateway'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private encrypt(data: Record<string, any>): { TradeInfo: string, TradeSha: string } {
|
|
157
|
+
// 1. 轉換為查詢字串
|
|
158
|
+
const queryString = new URLSearchParams(data).toString()
|
|
159
|
+
|
|
160
|
+
// 2. AES-256-CBC 加密
|
|
161
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', this.hashKey, this.hashIV)
|
|
162
|
+
cipher.setAutoPadding(true)
|
|
163
|
+
let encrypted = cipher.update(queryString, 'utf8', 'hex')
|
|
164
|
+
encrypted += cipher.final('hex')
|
|
165
|
+
|
|
166
|
+
// 3. 計算 SHA256
|
|
167
|
+
const tradeSha = crypto
|
|
168
|
+
.createHash('sha256')
|
|
169
|
+
.update(`HashKey=${this.hashKey}&${encrypted}&HashIV=${this.hashIV}`)
|
|
170
|
+
.digest('hex')
|
|
171
|
+
.toUpperCase()
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
TradeInfo: encrypted,
|
|
175
|
+
TradeSha: tradeSha
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async createMPGOrder(userId: string, orderData: any) {
|
|
180
|
+
const merchantOrderNo = `MPG${Date.now()}`
|
|
181
|
+
|
|
182
|
+
const tradeInfo = {
|
|
183
|
+
MerchantID: this.merchantID,
|
|
184
|
+
RespondType: 'JSON',
|
|
185
|
+
TimeStamp: Math.floor(Date.now() / 1000).toString(),
|
|
186
|
+
Version: '2.0',
|
|
187
|
+
MerchantOrderNo: merchantOrderNo,
|
|
188
|
+
Amt: orderData.amount,
|
|
189
|
+
ItemDesc: orderData.itemDesc || '商品購買',
|
|
190
|
+
ReturnURL: orderData.returnURL,
|
|
191
|
+
NotifyURL: orderData.notifyURL,
|
|
192
|
+
Email: orderData.email,
|
|
193
|
+
// 啟用付款方式
|
|
194
|
+
CREDIT: 1, // 信用卡
|
|
195
|
+
VACC: 1, // ATM
|
|
196
|
+
CVS: 1, // 超商代碼
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 加密
|
|
200
|
+
const { TradeInfo, TradeSha } = this.encrypt(tradeInfo)
|
|
201
|
+
|
|
202
|
+
console.log('[OK] NewebPay MPG 訂單建立')
|
|
203
|
+
console.log('訂單編號:', merchantOrderNo)
|
|
204
|
+
console.log('加密 TradeInfo 長度:', TradeInfo.length)
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
success: true,
|
|
208
|
+
formAction: this.apiUrl,
|
|
209
|
+
formMethod: 'POST',
|
|
210
|
+
formParams: {
|
|
211
|
+
MerchantID: this.merchantID,
|
|
212
|
+
TradeInfo: TradeInfo,
|
|
213
|
+
TradeSha: TradeSha,
|
|
214
|
+
Version: '2.0'
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 使用範例
|
|
221
|
+
async function createNewebPayMPG() {
|
|
222
|
+
const service = new NewebPayService(false) // 測試環境
|
|
223
|
+
|
|
224
|
+
const result = await service.createMPGOrder('user-123', {
|
|
225
|
+
amount: 2500,
|
|
226
|
+
itemDesc: '網站年費方案',
|
|
227
|
+
returnURL: 'https://yourdomain.com/payment/return',
|
|
228
|
+
notifyURL: 'https://yourdomain.com/api/payment/callback',
|
|
229
|
+
email: 'test@example.com'
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
return result
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**重要說明:**
|
|
237
|
+
- NewebPay 使用 AES-256-CBC 雙層加密(TradeInfo + TradeSha)
|
|
238
|
+
- HashKey 必須 32 bytes,HashIV 必須 16 bytes
|
|
239
|
+
- MPG 整合支付頁會根據啟用的付款方式顯示選項
|
|
240
|
+
|
|
241
|
+
**預期輸出:**
|
|
242
|
+
```
|
|
243
|
+
[OK] NewebPay MPG 訂單建立
|
|
244
|
+
訂單編號: MPG1738123456789
|
|
245
|
+
加密 TradeInfo 長度: 384
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
### 範例 3: PAYUNi RESTful API
|
|
251
|
+
|
|
252
|
+
**場景:** 使用統一金流 RESTful JSON API 建立付款
|
|
253
|
+
|
|
254
|
+
**Claude 提示詞:**
|
|
255
|
+
```
|
|
256
|
+
使用 PAYUNi RESTful API 建立信用卡付款
|
|
257
|
+
- 金額: 3000 元
|
|
258
|
+
- JSON 格式
|
|
259
|
+
- AES-256-GCM 加密
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**生成的程式碼:**
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
import crypto from 'crypto'
|
|
266
|
+
import axios from 'axios'
|
|
267
|
+
|
|
268
|
+
class PAYUNiService {
|
|
269
|
+
private merchantID: string
|
|
270
|
+
private hashKey: string
|
|
271
|
+
private hashIV: string
|
|
272
|
+
private apiUrl: string
|
|
273
|
+
|
|
274
|
+
constructor(isProd: boolean = false) {
|
|
275
|
+
this.merchantID = isProd
|
|
276
|
+
? process.env.PAYUNI_MERCHANT_ID_PROD!
|
|
277
|
+
: process.env.PAYUNI_MERCHANT_ID_TEST!
|
|
278
|
+
this.hashKey = isProd
|
|
279
|
+
? process.env.PAYUNI_HASH_KEY_PROD!
|
|
280
|
+
: process.env.PAYUNI_HASH_KEY_TEST!
|
|
281
|
+
this.hashIV = isProd
|
|
282
|
+
? process.env.PAYUNI_HASH_IV_PROD!
|
|
283
|
+
: process.env.PAYUNI_HASH_IV_TEST!
|
|
284
|
+
this.apiUrl = isProd
|
|
285
|
+
? 'https://api.payuni.com.tw/api/upp'
|
|
286
|
+
: 'https://sandbox-api.payuni.com.tw/api/upp'
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private encrypt(data: Record<string, any>): { EncryptInfo: string, HashInfo: string } {
|
|
290
|
+
// 1. JSON 字串化
|
|
291
|
+
const jsonString = JSON.stringify(data)
|
|
292
|
+
|
|
293
|
+
// 2. AES-256-GCM 加密
|
|
294
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', this.hashKey, this.hashIV)
|
|
295
|
+
let encrypted = cipher.update(jsonString, 'utf8', 'hex')
|
|
296
|
+
encrypted += cipher.final('hex')
|
|
297
|
+
|
|
298
|
+
// 3. 取得 Auth Tag
|
|
299
|
+
const authTag = cipher.getAuthTag().toString('hex')
|
|
300
|
+
|
|
301
|
+
// 4. 組合加密資料
|
|
302
|
+
const encryptInfo = encrypted + authTag
|
|
303
|
+
|
|
304
|
+
// 5. SHA256 簽章
|
|
305
|
+
const hashInfo = crypto
|
|
306
|
+
.createHash('sha256')
|
|
307
|
+
.update(`HashKey=${this.hashKey}&${encryptInfo}&HashIV=${this.hashIV}`)
|
|
308
|
+
.digest('hex')
|
|
309
|
+
.toUpperCase()
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
EncryptInfo: encryptInfo,
|
|
313
|
+
HashInfo: hashInfo
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async createOrder(userId: string, orderData: any) {
|
|
318
|
+
const merchantOrderNo = `UNI${Date.now()}`
|
|
319
|
+
|
|
320
|
+
const tradeData = {
|
|
321
|
+
MerchantID: this.merchantID,
|
|
322
|
+
MerchantOrderNo: merchantOrderNo,
|
|
323
|
+
Amount: orderData.amount,
|
|
324
|
+
ItemDescription: orderData.itemDesc || '商品購買',
|
|
325
|
+
ReturnURL: orderData.returnURL,
|
|
326
|
+
NotifyURL: orderData.notifyURL,
|
|
327
|
+
Email: orderData.email,
|
|
328
|
+
PaymentMethod: 'CREDIT', // 信用卡
|
|
329
|
+
TimeStamp: Math.floor(Date.now() / 1000)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 加密
|
|
333
|
+
const { EncryptInfo, HashInfo } = this.encrypt(tradeData)
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
// RESTful POST 請求
|
|
337
|
+
const response = await axios.post(this.apiUrl, {
|
|
338
|
+
MerchantID: this.merchantID,
|
|
339
|
+
EncryptInfo: EncryptInfo,
|
|
340
|
+
HashInfo: HashInfo
|
|
341
|
+
}, {
|
|
342
|
+
headers: {
|
|
343
|
+
'Content-Type': 'application/json'
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
console.log('[OK] PAYUNi 訂單建立')
|
|
348
|
+
console.log('訂單編號:', merchantOrderNo)
|
|
349
|
+
console.log('回應狀態:', response.data.Status)
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
success: response.data.Status === 'SUCCESS',
|
|
353
|
+
merchantTradeNo: merchantOrderNo,
|
|
354
|
+
paymentUrl: response.data.Data?.PaymentURL,
|
|
355
|
+
message: response.data.Message
|
|
356
|
+
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error('[ERROR] PAYUNi 請求失敗:', error)
|
|
359
|
+
throw error
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 使用範例
|
|
365
|
+
async function createPAYUNiOrder() {
|
|
366
|
+
const service = new PAYUNiService(false)
|
|
367
|
+
|
|
368
|
+
const result = await service.createOrder('user-123', {
|
|
369
|
+
amount: 3000,
|
|
370
|
+
itemDesc: '會員升級方案',
|
|
371
|
+
returnURL: 'https://yourdomain.com/payment/return',
|
|
372
|
+
notifyURL: 'https://yourdomain.com/api/payment/callback',
|
|
373
|
+
email: 'test@example.com'
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
if (result.success) {
|
|
377
|
+
console.log('請導向:', result.paymentUrl)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return result
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
**重要說明:**
|
|
385
|
+
- PAYUNi 使用 RESTful JSON API(與 ECPay/NewebPay 的 Form POST 不同)
|
|
386
|
+
- AES-256-GCM 加密需要附加 Auth Tag(最後 32 字元)
|
|
387
|
+
- 回應為 JSON 格式,包含 PaymentURL 導向連結
|
|
388
|
+
|
|
389
|
+
**預期輸出:**
|
|
390
|
+
```
|
|
391
|
+
[OK] PAYUNi 訂單建立
|
|
392
|
+
訂單編號: UNI1738123456789
|
|
393
|
+
回應狀態: SUCCESS
|
|
394
|
+
請導向: https://sandbox-api.payuni.com.tw/trade/payment?token=xxx
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## 進階範例
|
|
400
|
+
|
|
401
|
+
### 範例 4: 服務工廠模式
|
|
402
|
+
|
|
403
|
+
**場景:** 建立金流服務工廠,動態切換三家服務商
|
|
404
|
+
|
|
405
|
+
**Claude 提示詞:**
|
|
406
|
+
```
|
|
407
|
+
建立金流服務工廠,支援 ECPay、NewebPay、PAYUNi 動態切換
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**生成的程式碼:**
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
// lib/services/payment-provider.ts - 介面定義
|
|
414
|
+
|
|
415
|
+
export interface PaymentService {
|
|
416
|
+
createOrder(userId: string, data: PaymentOrderData): Promise<PaymentOrderResponse>
|
|
417
|
+
queryOrder(userId: string, merchantTradeNo: string): Promise<PaymentQueryResponse>
|
|
418
|
+
refundOrder(userId: string, tradeNo: string, amount: number): Promise<PaymentRefundResponse>
|
|
419
|
+
verifyCallback(params: Record<string, any>): boolean
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export interface PaymentOrderData {
|
|
423
|
+
merchantTradeNo: string
|
|
424
|
+
totalAmount: number
|
|
425
|
+
itemName: string
|
|
426
|
+
returnURL: string
|
|
427
|
+
notifyURL?: string
|
|
428
|
+
email?: string
|
|
429
|
+
paymentMethod?: string
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export interface PaymentOrderResponse {
|
|
433
|
+
success: boolean
|
|
434
|
+
merchantTradeNo: string
|
|
435
|
+
formAction: string
|
|
436
|
+
formMethod: string
|
|
437
|
+
formParams: Record<string, string>
|
|
438
|
+
msg?: string
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// lib/services/payment-service-factory.ts - 工廠類別
|
|
442
|
+
|
|
443
|
+
import { PaymentService } from './payment-provider'
|
|
444
|
+
import { ECPayPaymentService } from './ecpay-payment-service'
|
|
445
|
+
import { NewebPayPaymentService } from './newebpay-payment-service'
|
|
446
|
+
import { PAYUNiPaymentService } from './payuni-payment-service'
|
|
447
|
+
import { prisma } from '@/lib/prisma'
|
|
448
|
+
|
|
449
|
+
type PaymentProvider = 'ECPAY' | 'NEWEBPAY' | 'PAYUNI'
|
|
450
|
+
|
|
451
|
+
export class PaymentServiceFactory {
|
|
452
|
+
/**
|
|
453
|
+
* 根據服務商名稱取得服務實例
|
|
454
|
+
*/
|
|
455
|
+
static getService(
|
|
456
|
+
provider: PaymentProvider,
|
|
457
|
+
isProd: boolean = false
|
|
458
|
+
): PaymentService {
|
|
459
|
+
switch (provider) {
|
|
460
|
+
case 'ECPAY':
|
|
461
|
+
return new ECPayPaymentService(isProd)
|
|
462
|
+
case 'NEWEBPAY':
|
|
463
|
+
return new NewebPayPaymentService(isProd)
|
|
464
|
+
case 'PAYUNI':
|
|
465
|
+
return new PAYUNiPaymentService(isProd)
|
|
466
|
+
default:
|
|
467
|
+
throw new Error(`不支援的金流服務商: ${provider}`)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* 根據使用者設定取得服務實例
|
|
473
|
+
*/
|
|
474
|
+
static async getServiceForUser(userId: string): Promise<PaymentService> {
|
|
475
|
+
const settings = await prisma.paymentSettings.findUnique({
|
|
476
|
+
where: { userId },
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
if (!settings || !settings.defaultProvider) {
|
|
480
|
+
throw new Error('未設定預設金流服務商')
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return this.getService(
|
|
484
|
+
settings.defaultProvider as PaymentProvider,
|
|
485
|
+
settings.isProduction
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 根據訂單取得服務實例(用於查詢/退款)
|
|
491
|
+
*/
|
|
492
|
+
static async getServiceForOrder(merchantTradeNo: string): Promise<PaymentService> {
|
|
493
|
+
const order = await prisma.order.findUnique({
|
|
494
|
+
where: { merchantTradeNo },
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
if (!order || !order.paymentProvider) {
|
|
498
|
+
throw new Error('訂單不存在或未記錄金流服務商')
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return this.getService(
|
|
502
|
+
order.paymentProvider as PaymentProvider,
|
|
503
|
+
order.isProduction
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* 取得所有可用的服務商
|
|
509
|
+
*/
|
|
510
|
+
static getAvailableProviders(): PaymentProvider[] {
|
|
511
|
+
return ['ECPAY', 'NEWEBPAY', 'PAYUNI']
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* 自動偵測服務商(根據回呼參數)
|
|
516
|
+
*/
|
|
517
|
+
static detectProvider(params: Record<string, any>): PaymentProvider {
|
|
518
|
+
if (params.CheckMacValue && params.MerchantTradeNo) {
|
|
519
|
+
return 'ECPAY'
|
|
520
|
+
}
|
|
521
|
+
if (params.TradeInfo && params.TradeSha) {
|
|
522
|
+
return 'NEWEBPAY'
|
|
523
|
+
}
|
|
524
|
+
if (params.EncryptInfo && params.HashInfo) {
|
|
525
|
+
return 'PAYUNI'
|
|
526
|
+
}
|
|
527
|
+
throw new Error('無法識別金流服務商')
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
**使用範例:**
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
// API Route: app/api/payment/create/route.ts
|
|
536
|
+
|
|
537
|
+
import { PaymentServiceFactory } from '@/lib/services/payment-service-factory'
|
|
538
|
+
|
|
539
|
+
export async function POST(request: Request) {
|
|
540
|
+
const { userId, amount, itemName, provider } = await request.json()
|
|
541
|
+
|
|
542
|
+
// 方式 1: 使用指定服務商
|
|
543
|
+
const service = PaymentServiceFactory.getService(provider || 'ECPAY')
|
|
544
|
+
|
|
545
|
+
// 方式 2: 使用使用者預設服務商
|
|
546
|
+
// const service = await PaymentServiceFactory.getServiceForUser(userId)
|
|
547
|
+
|
|
548
|
+
const result = await service.createOrder(userId, {
|
|
549
|
+
merchantTradeNo: `ORD${Date.now()}`,
|
|
550
|
+
totalAmount: amount,
|
|
551
|
+
itemName: itemName,
|
|
552
|
+
returnURL: 'https://yourdomain.com/payment/return',
|
|
553
|
+
notifyURL: 'https://yourdomain.com/api/payment/callback',
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
return Response.json(result)
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
### 範例 5: 加密輔助函數
|
|
563
|
+
|
|
564
|
+
**場景:** 建立三種加密方式的 helper functions
|
|
565
|
+
|
|
566
|
+
**Claude 提示詞:**
|
|
567
|
+
```
|
|
568
|
+
建立加密工具,包含 ECPay SHA256、NewebPay AES-CBC、PAYUNi AES-GCM
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
**生成的程式碼:**
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
// lib/utils/payment-encryption.ts
|
|
575
|
+
|
|
576
|
+
import crypto from 'crypto'
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* ECPay CheckMacValue 計算(SHA256)
|
|
580
|
+
*/
|
|
581
|
+
export function generateECPayCheckMacValue(
|
|
582
|
+
params: Record<string, any>,
|
|
583
|
+
hashKey: string,
|
|
584
|
+
hashIV: string
|
|
585
|
+
): string {
|
|
586
|
+
// 1. 移除 CheckMacValue 本身
|
|
587
|
+
const { CheckMacValue, ...cleanParams } = params
|
|
588
|
+
|
|
589
|
+
// 2. 依照 key 排序(字母順序)
|
|
590
|
+
const sortedKeys = Object.keys(cleanParams).sort()
|
|
591
|
+
|
|
592
|
+
// 3. 組合參數字串
|
|
593
|
+
const paramString = sortedKeys
|
|
594
|
+
.map(key => `${key}=${cleanParams[key]}`)
|
|
595
|
+
.join('&')
|
|
596
|
+
|
|
597
|
+
// 4. 前後加上 HashKey 和 HashIV
|
|
598
|
+
const rawString = `HashKey=${hashKey}&${paramString}&HashIV=${hashIV}`
|
|
599
|
+
|
|
600
|
+
// 5. URL Encode (lowercase)
|
|
601
|
+
const encoded = encodeURIComponent(rawString).toLowerCase()
|
|
602
|
+
|
|
603
|
+
// 6. SHA256 雜湊
|
|
604
|
+
const hash = crypto.createHash('sha256').update(encoded).digest('hex')
|
|
605
|
+
|
|
606
|
+
// 7. 轉大寫
|
|
607
|
+
return hash.toUpperCase()
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* NewebPay AES-256-CBC 加密
|
|
612
|
+
*/
|
|
613
|
+
export function encryptNewebPay(
|
|
614
|
+
data: Record<string, any>,
|
|
615
|
+
hashKey: string,
|
|
616
|
+
hashIV: string
|
|
617
|
+
): { TradeInfo: string; TradeSha: string } {
|
|
618
|
+
// 1. 轉換為查詢字串
|
|
619
|
+
const queryString = new URLSearchParams(data).toString()
|
|
620
|
+
|
|
621
|
+
// 2. AES-256-CBC 加密
|
|
622
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', hashKey, hashIV)
|
|
623
|
+
cipher.setAutoPadding(true)
|
|
624
|
+
let encrypted = cipher.update(queryString, 'utf8', 'hex')
|
|
625
|
+
encrypted += cipher.final('hex')
|
|
626
|
+
|
|
627
|
+
// 3. 計算 SHA256
|
|
628
|
+
const tradeSha = crypto
|
|
629
|
+
.createHash('sha256')
|
|
630
|
+
.update(`HashKey=${hashKey}&${encrypted}&HashIV=${hashIV}`)
|
|
631
|
+
.digest('hex')
|
|
632
|
+
.toUpperCase()
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
TradeInfo: encrypted,
|
|
636
|
+
TradeSha: tradeSha,
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* NewebPay AES-256-CBC 解密
|
|
642
|
+
*/
|
|
643
|
+
export function decryptNewebPay(
|
|
644
|
+
encryptedData: string,
|
|
645
|
+
hashKey: string,
|
|
646
|
+
hashIV: string
|
|
647
|
+
): Record<string, any> {
|
|
648
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', hashKey, hashIV)
|
|
649
|
+
decipher.setAutoPadding(true)
|
|
650
|
+
let decrypted = decipher.update(encryptedData, 'hex', 'utf8')
|
|
651
|
+
decrypted += decipher.final('utf8')
|
|
652
|
+
|
|
653
|
+
return Object.fromEntries(new URLSearchParams(decrypted))
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* PAYUNi AES-256-GCM 加密
|
|
658
|
+
*/
|
|
659
|
+
export function encryptPAYUNi(
|
|
660
|
+
data: Record<string, any>,
|
|
661
|
+
hashKey: string,
|
|
662
|
+
hashIV: string
|
|
663
|
+
): { EncryptInfo: string; HashInfo: string } {
|
|
664
|
+
// 1. JSON 字串化
|
|
665
|
+
const jsonString = JSON.stringify(data)
|
|
666
|
+
|
|
667
|
+
// 2. AES-256-GCM 加密
|
|
668
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', hashKey, hashIV)
|
|
669
|
+
let encrypted = cipher.update(jsonString, 'utf8', 'hex')
|
|
670
|
+
encrypted += cipher.final('hex')
|
|
671
|
+
|
|
672
|
+
// 3. 取得 Auth Tag (16 bytes)
|
|
673
|
+
const authTag = cipher.getAuthTag().toString('hex')
|
|
674
|
+
|
|
675
|
+
// 4. 組合加密資料
|
|
676
|
+
const encryptInfo = encrypted + authTag
|
|
677
|
+
|
|
678
|
+
// 5. SHA256 簽章
|
|
679
|
+
const hashInfo = crypto
|
|
680
|
+
.createHash('sha256')
|
|
681
|
+
.update(`HashKey=${hashKey}&${encryptInfo}&HashIV=${hashIV}`)
|
|
682
|
+
.digest('hex')
|
|
683
|
+
.toUpperCase()
|
|
684
|
+
|
|
685
|
+
return {
|
|
686
|
+
EncryptInfo: encryptInfo,
|
|
687
|
+
HashInfo: hashInfo,
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* PAYUNi AES-256-GCM 解密
|
|
693
|
+
*/
|
|
694
|
+
export function decryptPAYUNi(
|
|
695
|
+
encryptedData: string,
|
|
696
|
+
hashKey: string,
|
|
697
|
+
hashIV: string
|
|
698
|
+
): Record<string, any> {
|
|
699
|
+
// 1. 分離加密內容和 Auth Tag(最後 32 個字元)
|
|
700
|
+
const encryptedContent = encryptedData.slice(0, -32)
|
|
701
|
+
const authTag = Buffer.from(encryptedData.slice(-32), 'hex')
|
|
702
|
+
|
|
703
|
+
// 2. AES-256-GCM 解密
|
|
704
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', hashKey, hashIV)
|
|
705
|
+
decipher.setAuthTag(authTag)
|
|
706
|
+
let decrypted = decipher.update(encryptedContent, 'hex', 'utf8')
|
|
707
|
+
decrypted += decipher.final('utf8')
|
|
708
|
+
|
|
709
|
+
return JSON.parse(decrypted)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 驗證簽章
|
|
714
|
+
*/
|
|
715
|
+
export function verifySignature(
|
|
716
|
+
params: Record<string, any>,
|
|
717
|
+
signature: string,
|
|
718
|
+
hashKey: string,
|
|
719
|
+
hashIV: string,
|
|
720
|
+
provider: 'ECPAY' | 'NEWEBPAY' | 'PAYUNI'
|
|
721
|
+
): boolean {
|
|
722
|
+
switch (provider) {
|
|
723
|
+
case 'ECPAY':
|
|
724
|
+
const calculatedECPay = generateECPayCheckMacValue(params, hashKey, hashIV)
|
|
725
|
+
return calculatedECPay === signature
|
|
726
|
+
case 'NEWEBPAY':
|
|
727
|
+
const { TradeSha } = encryptNewebPay(params, hashKey, hashIV)
|
|
728
|
+
return TradeSha === signature
|
|
729
|
+
case 'PAYUNI':
|
|
730
|
+
const { HashInfo } = encryptPAYUNi(params, hashKey, hashIV)
|
|
731
|
+
return HashInfo === signature
|
|
732
|
+
default:
|
|
733
|
+
return false
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
**使用範例:**
|
|
739
|
+
|
|
740
|
+
```typescript
|
|
741
|
+
import { generateECPayCheckMacValue, encryptNewebPay, encryptPAYUNi } from '@/lib/utils/payment-encryption'
|
|
742
|
+
|
|
743
|
+
// ECPay 簽章
|
|
744
|
+
const ecpayParams = {
|
|
745
|
+
MerchantID: '3002607',
|
|
746
|
+
MerchantTradeNo: 'ORD123456',
|
|
747
|
+
TotalAmount: 1050,
|
|
748
|
+
}
|
|
749
|
+
const checkMacValue = generateECPayCheckMacValue(
|
|
750
|
+
ecpayParams,
|
|
751
|
+
'pwFHCqoQZGmho4w6',
|
|
752
|
+
'EkRm7iFT261dpevs'
|
|
753
|
+
)
|
|
754
|
+
console.log('ECPay CheckMacValue:', checkMacValue)
|
|
755
|
+
|
|
756
|
+
// NewebPay 加密
|
|
757
|
+
const newebpayData = {
|
|
758
|
+
MerchantID: 'MS12345678',
|
|
759
|
+
MerchantOrderNo: 'MPG123456',
|
|
760
|
+
Amt: 2500,
|
|
761
|
+
}
|
|
762
|
+
const { TradeInfo, TradeSha } = encryptNewebPay(
|
|
763
|
+
newebpayData,
|
|
764
|
+
'your32BytesHashKeyHere123456',
|
|
765
|
+
'your16BytesIV123'
|
|
766
|
+
)
|
|
767
|
+
console.log('NewebPay TradeInfo:', TradeInfo.substring(0, 50) + '...')
|
|
768
|
+
console.log('NewebPay TradeSha:', TradeSha)
|
|
769
|
+
|
|
770
|
+
// PAYUNi 加密
|
|
771
|
+
const payuniData = {
|
|
772
|
+
MerchantID: 'UNI12345',
|
|
773
|
+
MerchantOrderNo: 'UNI123456',
|
|
774
|
+
Amount: 3000,
|
|
775
|
+
}
|
|
776
|
+
const { EncryptInfo, HashInfo } = encryptPAYUNi(
|
|
777
|
+
payuniData,
|
|
778
|
+
'your32BytesHashKey',
|
|
779
|
+
'your16BytesIV'
|
|
780
|
+
)
|
|
781
|
+
console.log('PAYUNi EncryptInfo:', EncryptInfo.substring(0, 50) + '...')
|
|
782
|
+
console.log('PAYUNi HashInfo:', HashInfo)
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
---
|
|
786
|
+
|
|
787
|
+
## 實戰場景
|
|
788
|
+
|
|
789
|
+
### 場景 1: 電商結帳流程整合
|
|
790
|
+
|
|
791
|
+
**需求:** 完整的訂單建立 → 金流付款 → 付款通知 → 訂單查詢流程
|
|
792
|
+
|
|
793
|
+
**步驟 1: 建立訂單並導向付款**
|
|
794
|
+
|
|
795
|
+
```typescript
|
|
796
|
+
// app/api/checkout/route.ts
|
|
797
|
+
|
|
798
|
+
import { PaymentServiceFactory } from '@/lib/services/payment-service-factory'
|
|
799
|
+
import { prisma } from '@/lib/prisma'
|
|
800
|
+
|
|
801
|
+
export async function POST(request: Request) {
|
|
802
|
+
const { userId, cartItems, shippingInfo } = await request.json()
|
|
803
|
+
|
|
804
|
+
// 1. 計算訂單金額
|
|
805
|
+
const totalAmount = cartItems.reduce((sum, item) => {
|
|
806
|
+
return sum + (item.price * item.quantity)
|
|
807
|
+
}, 0)
|
|
808
|
+
|
|
809
|
+
// 2. 產生訂單編號
|
|
810
|
+
const merchantTradeNo = `ORD${Date.now()}${Math.random().toString(36).substring(2, 6).toUpperCase()}`
|
|
811
|
+
|
|
812
|
+
// 3. 建立資料庫訂單
|
|
813
|
+
const order = await prisma.order.create({
|
|
814
|
+
data: {
|
|
815
|
+
userId: userId,
|
|
816
|
+
merchantTradeNo: merchantTradeNo,
|
|
817
|
+
totalAmount: totalAmount,
|
|
818
|
+
status: 'PENDING',
|
|
819
|
+
shippingName: shippingInfo.name,
|
|
820
|
+
shippingAddress: shippingInfo.address,
|
|
821
|
+
shippingPhone: shippingInfo.phone,
|
|
822
|
+
items: {
|
|
823
|
+
create: cartItems.map(item => ({
|
|
824
|
+
productId: item.productId,
|
|
825
|
+
productName: item.name,
|
|
826
|
+
quantity: item.quantity,
|
|
827
|
+
price: item.price,
|
|
828
|
+
}))
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
include: { items: true }
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
// 4. 取得金流服務(使用者預設或指定)
|
|
835
|
+
const service = await PaymentServiceFactory.getServiceForUser(userId)
|
|
836
|
+
|
|
837
|
+
// 5. 建立付款訂單
|
|
838
|
+
const itemNames = order.items.map(item => `${item.productName} x${item.quantity}`).join('|')
|
|
839
|
+
|
|
840
|
+
const paymentResult = await service.createOrder(userId, {
|
|
841
|
+
merchantTradeNo: merchantTradeNo,
|
|
842
|
+
totalAmount: totalAmount,
|
|
843
|
+
itemName: itemNames.substring(0, 200), // 限制長度
|
|
844
|
+
returnURL: `${process.env.NEXT_PUBLIC_BASE_URL}/payment/return`,
|
|
845
|
+
notifyURL: `${process.env.NEXT_PUBLIC_BASE_URL}/api/payment/callback`,
|
|
846
|
+
email: shippingInfo.email,
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
// 6. 更新訂單記錄金流服務商
|
|
850
|
+
await prisma.order.update({
|
|
851
|
+
where: { id: order.id },
|
|
852
|
+
data: {
|
|
853
|
+
paymentProvider: paymentResult.provider || 'ECPAY',
|
|
854
|
+
}
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
// 7. 回傳付款表單資料
|
|
858
|
+
return Response.json({
|
|
859
|
+
success: true,
|
|
860
|
+
orderId: order.id,
|
|
861
|
+
merchantTradeNo: merchantTradeNo,
|
|
862
|
+
paymentForm: {
|
|
863
|
+
action: paymentResult.formAction,
|
|
864
|
+
method: paymentResult.formMethod,
|
|
865
|
+
params: paymentResult.formParams,
|
|
866
|
+
}
|
|
867
|
+
})
|
|
868
|
+
}
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
**步驟 2: 處理付款通知回呼**
|
|
872
|
+
|
|
873
|
+
```typescript
|
|
874
|
+
// app/api/payment/callback/route.ts
|
|
875
|
+
|
|
876
|
+
import { PaymentServiceFactory } from '@/lib/services/payment-service-factory'
|
|
877
|
+
import { prisma } from '@/lib/prisma'
|
|
878
|
+
|
|
879
|
+
export async function POST(request: Request) {
|
|
880
|
+
const formData = await request.formData()
|
|
881
|
+
const params = Object.fromEntries(formData)
|
|
882
|
+
|
|
883
|
+
console.log('[INFO] 收到付款通知:', params)
|
|
884
|
+
|
|
885
|
+
try {
|
|
886
|
+
// 1. 自動偵測服務商
|
|
887
|
+
const provider = PaymentServiceFactory.detectProvider(params)
|
|
888
|
+
const service = PaymentServiceFactory.getService(provider)
|
|
889
|
+
|
|
890
|
+
// 2. 驗證簽章
|
|
891
|
+
const isValid = service.verifyCallback(params)
|
|
892
|
+
if (!isValid) {
|
|
893
|
+
console.error('[ERROR] 簽章驗證失敗')
|
|
894
|
+
return new Response('0|CheckMacValue Error', { status: 400 })
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// 3. 取得訂單編號(各服務商欄位不同)
|
|
898
|
+
const merchantTradeNo = params.MerchantTradeNo || params.MerchantOrderNo
|
|
899
|
+
|
|
900
|
+
// 4. 更新訂單狀態
|
|
901
|
+
const isPaid = params.RtnCode === '1' || params.Status === 'SUCCESS'
|
|
902
|
+
|
|
903
|
+
await prisma.order.update({
|
|
904
|
+
where: { merchantTradeNo },
|
|
905
|
+
data: {
|
|
906
|
+
status: isPaid ? 'PAID' : 'FAILED',
|
|
907
|
+
paidAt: isPaid ? new Date() : null,
|
|
908
|
+
tradeNo: params.TradeNo || params.TradeID, // 金流商訂單號
|
|
909
|
+
paymentMethod: params.PaymentType || params.PaymentMethod,
|
|
910
|
+
paymentDetails: JSON.stringify(params),
|
|
911
|
+
failureReason: isPaid ? null : params.RtnMsg || params.Message,
|
|
912
|
+
}
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
console.log(`[OK] 訂單 ${merchantTradeNo} 狀態更新為 ${isPaid ? 'PAID' : 'FAILED'}`)
|
|
916
|
+
|
|
917
|
+
// 5. 付款成功後續處理
|
|
918
|
+
if (isPaid) {
|
|
919
|
+
// 發送通知郵件、扣減庫存、開立發票等
|
|
920
|
+
// await sendOrderConfirmationEmail(merchantTradeNo)
|
|
921
|
+
// await reduceInventory(merchantTradeNo)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// 6. 回應固定格式
|
|
925
|
+
return new Response('1|OK', {
|
|
926
|
+
status: 200,
|
|
927
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
} catch (error) {
|
|
931
|
+
console.error('[ERROR] 付款通知處理失敗:', error)
|
|
932
|
+
return new Response('0|Error', { status: 500 })
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
**步驟 3: 查詢訂單狀態**
|
|
938
|
+
|
|
939
|
+
```typescript
|
|
940
|
+
// app/api/payment/query/route.ts
|
|
941
|
+
|
|
942
|
+
import { PaymentServiceFactory } from '@/lib/services/payment-service-factory'
|
|
943
|
+
import { prisma } from '@/lib/prisma'
|
|
944
|
+
|
|
945
|
+
export async function GET(request: Request) {
|
|
946
|
+
const { searchParams } = new URL(request.url)
|
|
947
|
+
const merchantTradeNo = searchParams.get('merchantTradeNo')
|
|
948
|
+
|
|
949
|
+
if (!merchantTradeNo) {
|
|
950
|
+
return Response.json({ error: '缺少訂單編號' }, { status: 400 })
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
try {
|
|
954
|
+
// 1. 從資料庫取得訂單
|
|
955
|
+
const order = await prisma.order.findUnique({
|
|
956
|
+
where: { merchantTradeNo }
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
if (!order) {
|
|
960
|
+
return Response.json({ error: '訂單不存在' }, { status: 404 })
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// 2. 使用訂單記錄的服務商查詢
|
|
964
|
+
const service = await PaymentServiceFactory.getServiceForOrder(merchantTradeNo)
|
|
965
|
+
const queryResult = await service.queryOrder(order.userId, merchantTradeNo)
|
|
966
|
+
|
|
967
|
+
// 3. 同步訂單狀態
|
|
968
|
+
if (queryResult.success && queryResult.status !== order.status) {
|
|
969
|
+
await prisma.order.update({
|
|
970
|
+
where: { merchantTradeNo },
|
|
971
|
+
data: {
|
|
972
|
+
status: queryResult.status,
|
|
973
|
+
paidAt: queryResult.paidAt,
|
|
974
|
+
}
|
|
975
|
+
})
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return Response.json({
|
|
979
|
+
success: true,
|
|
980
|
+
order: {
|
|
981
|
+
merchantTradeNo: order.merchantTradeNo,
|
|
982
|
+
amount: order.totalAmount,
|
|
983
|
+
status: queryResult.status || order.status,
|
|
984
|
+
paidAt: queryResult.paidAt || order.paidAt,
|
|
985
|
+
provider: order.paymentProvider,
|
|
986
|
+
}
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
} catch (error) {
|
|
990
|
+
console.error('[ERROR] 查詢訂單失敗:', error)
|
|
991
|
+
return Response.json({ error: '查詢失敗' }, { status: 500 })
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
**步驟 4: 前端整合**
|
|
997
|
+
|
|
998
|
+
```typescript
|
|
999
|
+
// components/CheckoutButton.tsx
|
|
1000
|
+
|
|
1001
|
+
'use client'
|
|
1002
|
+
|
|
1003
|
+
import { useState } from 'react'
|
|
1004
|
+
import { Button } from '@/components/ui/button'
|
|
1005
|
+
|
|
1006
|
+
export function CheckoutButton({ cartItems, shippingInfo }) {
|
|
1007
|
+
const [loading, setLoading] = useState(false)
|
|
1008
|
+
|
|
1009
|
+
const handleCheckout = async () => {
|
|
1010
|
+
setLoading(true)
|
|
1011
|
+
try {
|
|
1012
|
+
// 1. 建立訂單
|
|
1013
|
+
const response = await fetch('/api/checkout', {
|
|
1014
|
+
method: 'POST',
|
|
1015
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1016
|
+
body: JSON.stringify({
|
|
1017
|
+
userId: 'user-123',
|
|
1018
|
+
cartItems,
|
|
1019
|
+
shippingInfo,
|
|
1020
|
+
})
|
|
1021
|
+
})
|
|
1022
|
+
|
|
1023
|
+
const result = await response.json()
|
|
1024
|
+
|
|
1025
|
+
if (!result.success) {
|
|
1026
|
+
alert('結帳失敗')
|
|
1027
|
+
return
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// 2. 提交付款表單
|
|
1031
|
+
const form = document.createElement('form')
|
|
1032
|
+
form.method = result.paymentForm.method
|
|
1033
|
+
form.action = result.paymentForm.action
|
|
1034
|
+
form.target = '_self'
|
|
1035
|
+
|
|
1036
|
+
Object.entries(result.paymentForm.params).forEach(([key, value]) => {
|
|
1037
|
+
const input = document.createElement('input')
|
|
1038
|
+
input.type = 'hidden'
|
|
1039
|
+
input.name = key
|
|
1040
|
+
input.value = value as string
|
|
1041
|
+
form.appendChild(input)
|
|
1042
|
+
})
|
|
1043
|
+
|
|
1044
|
+
document.body.appendChild(form)
|
|
1045
|
+
form.submit()
|
|
1046
|
+
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
console.error('結帳失敗:', error)
|
|
1049
|
+
alert('結帳失敗')
|
|
1050
|
+
} finally {
|
|
1051
|
+
setLoading(false)
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
return (
|
|
1056
|
+
<Button onClick={handleCheckout} disabled={loading} size="lg">
|
|
1057
|
+
{loading ? '處理中...' : '前往付款'}
|
|
1058
|
+
</Button>
|
|
1059
|
+
)
|
|
1060
|
+
}
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
---
|
|
1064
|
+
|
|
1065
|
+
### 場景 2: 定期定額訂閱
|
|
1066
|
+
|
|
1067
|
+
**需求:** 實作週期扣款功能(會員訂閱制)
|
|
1068
|
+
|
|
1069
|
+
**步驟 1: 建立定期定額訂單**
|
|
1070
|
+
|
|
1071
|
+
```typescript
|
|
1072
|
+
// app/api/subscription/create/route.ts
|
|
1073
|
+
|
|
1074
|
+
import { ECPayPaymentService } from '@/lib/services/ecpay-payment-service'
|
|
1075
|
+
import { prisma } from '@/lib/prisma'
|
|
1076
|
+
|
|
1077
|
+
export async function POST(request: Request) {
|
|
1078
|
+
const { userId, planId, email } = await request.json()
|
|
1079
|
+
|
|
1080
|
+
// 1. 取得訂閱方案
|
|
1081
|
+
const plan = await prisma.subscriptionPlan.findUnique({
|
|
1082
|
+
where: { id: planId }
|
|
1083
|
+
})
|
|
1084
|
+
|
|
1085
|
+
if (!plan) {
|
|
1086
|
+
return Response.json({ error: '方案不存在' }, { status: 404 })
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// 2. 產生訂閱編號
|
|
1090
|
+
const merchantTradeNo = `SUB${Date.now()}`
|
|
1091
|
+
|
|
1092
|
+
// 3. 建立訂閱記錄
|
|
1093
|
+
const subscription = await prisma.subscription.create({
|
|
1094
|
+
data: {
|
|
1095
|
+
userId: userId,
|
|
1096
|
+
planId: planId,
|
|
1097
|
+
merchantTradeNo: merchantTradeNo,
|
|
1098
|
+
status: 'PENDING',
|
|
1099
|
+
amount: plan.price,
|
|
1100
|
+
frequency: plan.frequency, // 'M' = 月, 'Y' = 年
|
|
1101
|
+
totalTimes: plan.totalTimes || 999, // 999 = 無限次
|
|
1102
|
+
}
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
// 4. 建立 ECPay 定期定額訂單
|
|
1106
|
+
const service = new ECPayPaymentService(false) // 測試環境
|
|
1107
|
+
|
|
1108
|
+
const periodicData = {
|
|
1109
|
+
MerchantTradeNo: merchantTradeNo,
|
|
1110
|
+
MerchantTradeDate: new Date().toLocaleString('zh-TW', {
|
|
1111
|
+
year: 'numeric',
|
|
1112
|
+
month: '2-digit',
|
|
1113
|
+
day: '2-digit',
|
|
1114
|
+
hour: '2-digit',
|
|
1115
|
+
minute: '2-digit',
|
|
1116
|
+
second: '2-digit',
|
|
1117
|
+
hour12: false
|
|
1118
|
+
}).replace(/\//g, '/'),
|
|
1119
|
+
TotalAmount: plan.price,
|
|
1120
|
+
TradeDesc: `訂閱方案:${plan.name}`,
|
|
1121
|
+
ItemName: plan.name,
|
|
1122
|
+
ReturnURL: `${process.env.NEXT_PUBLIC_BASE_URL}/api/subscription/callback`,
|
|
1123
|
+
PeriodAmount: plan.price, // 每期金額
|
|
1124
|
+
PeriodType: plan.frequency, // 'M' = 月, 'Y' = 年
|
|
1125
|
+
Frequency: 1, // 每 1 個週期
|
|
1126
|
+
ExecTimes: plan.totalTimes, // 執行次數
|
|
1127
|
+
PeriodReturnURL: `${process.env.NEXT_PUBLIC_BASE_URL}/api/subscription/periodic-callback`,
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const result = await service.createPeriodicOrder(userId, periodicData)
|
|
1131
|
+
|
|
1132
|
+
return Response.json({
|
|
1133
|
+
success: result.success,
|
|
1134
|
+
subscriptionId: subscription.id,
|
|
1135
|
+
paymentForm: {
|
|
1136
|
+
action: result.formAction,
|
|
1137
|
+
method: result.formMethod,
|
|
1138
|
+
params: result.formParams,
|
|
1139
|
+
}
|
|
1140
|
+
})
|
|
1141
|
+
}
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
**步驟 2: 處理首次授權回呼**
|
|
1145
|
+
|
|
1146
|
+
```typescript
|
|
1147
|
+
// app/api/subscription/callback/route.ts
|
|
1148
|
+
|
|
1149
|
+
import { prisma } from '@/lib/prisma'
|
|
1150
|
+
|
|
1151
|
+
export async function POST(request: Request) {
|
|
1152
|
+
const formData = await request.formData()
|
|
1153
|
+
const params = Object.fromEntries(formData)
|
|
1154
|
+
|
|
1155
|
+
console.log('[INFO] 收到訂閱授權通知:', params)
|
|
1156
|
+
|
|
1157
|
+
const merchantTradeNo = params.MerchantTradeNo
|
|
1158
|
+
const isPaid = params.RtnCode === '1'
|
|
1159
|
+
|
|
1160
|
+
// 更新訂閱狀態
|
|
1161
|
+
await prisma.subscription.update({
|
|
1162
|
+
where: { merchantTradeNo },
|
|
1163
|
+
data: {
|
|
1164
|
+
status: isPaid ? 'ACTIVE' : 'FAILED',
|
|
1165
|
+
gwsr: params.gwsr, // 綠界週期編號(重要:後續扣款需要)
|
|
1166
|
+
firstPaidAt: isPaid ? new Date() : null,
|
|
1167
|
+
}
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
console.log(`[OK] 訂閱 ${merchantTradeNo} 授權${isPaid ? '成功' : '失敗'}`)
|
|
1171
|
+
|
|
1172
|
+
return new Response('1|OK')
|
|
1173
|
+
}
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
**步驟 3: 處理週期扣款通知**
|
|
1177
|
+
|
|
1178
|
+
```typescript
|
|
1179
|
+
// app/api/subscription/periodic-callback/route.ts
|
|
1180
|
+
|
|
1181
|
+
import { prisma } from '@/lib/prisma'
|
|
1182
|
+
|
|
1183
|
+
export async function POST(request: Request) {
|
|
1184
|
+
const formData = await request.formData()
|
|
1185
|
+
const params = Object.fromEntries(formData)
|
|
1186
|
+
|
|
1187
|
+
console.log('[INFO] 收到週期扣款通知:', params)
|
|
1188
|
+
|
|
1189
|
+
const gwsr = params.gwsr // 綠界週期編號
|
|
1190
|
+
const isPaid = params.RtnCode === '1'
|
|
1191
|
+
const execTimes = parseInt(params.ExecTimes) // 當前第幾次扣款
|
|
1192
|
+
|
|
1193
|
+
// 1. 更新訂閱記錄
|
|
1194
|
+
const subscription = await prisma.subscription.findFirst({
|
|
1195
|
+
where: { gwsr }
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
if (subscription) {
|
|
1199
|
+
// 2. 建立扣款記錄
|
|
1200
|
+
await prisma.subscriptionPayment.create({
|
|
1201
|
+
data: {
|
|
1202
|
+
subscriptionId: subscription.id,
|
|
1203
|
+
merchantTradeNo: params.MerchantTradeNo,
|
|
1204
|
+
tradeNo: params.TradeNo,
|
|
1205
|
+
amount: parseInt(params.amount),
|
|
1206
|
+
execTimes: execTimes,
|
|
1207
|
+
status: isPaid ? 'PAID' : 'FAILED',
|
|
1208
|
+
paidAt: isPaid ? new Date() : null,
|
|
1209
|
+
failureReason: isPaid ? null : params.RtnMsg,
|
|
1210
|
+
}
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
// 3. 更新訂閱狀態
|
|
1214
|
+
await prisma.subscription.update({
|
|
1215
|
+
where: { id: subscription.id },
|
|
1216
|
+
data: {
|
|
1217
|
+
currentExecTimes: execTimes,
|
|
1218
|
+
lastPaidAt: isPaid ? new Date() : subscription.lastPaidAt,
|
|
1219
|
+
}
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
console.log(`[OK] 訂閱 ${subscription.merchantTradeNo} 第 ${execTimes} 次扣款${isPaid ? '成功' : '失敗'}`)
|
|
1223
|
+
|
|
1224
|
+
// 4. 扣款成功後續處理
|
|
1225
|
+
if (isPaid) {
|
|
1226
|
+
// 延長會員期限、發送通知等
|
|
1227
|
+
// await extendMembershipPeriod(subscription.userId)
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
return new Response('1|OK')
|
|
1232
|
+
}
|
|
1233
|
+
```
|
|
1234
|
+
|
|
1235
|
+
---
|
|
1236
|
+
|
|
1237
|
+
## 常見錯誤與修正
|
|
1238
|
+
|
|
1239
|
+
### 錯誤 1: CheckMacValue 計算錯誤
|
|
1240
|
+
|
|
1241
|
+
**錯誤訊息:** ECPay 回傳 `10100058: 請確認檢查碼是否正確`
|
|
1242
|
+
|
|
1243
|
+
**原因:** 參數排序錯誤或 URL Encode 不正確
|
|
1244
|
+
|
|
1245
|
+
**修正前:**
|
|
1246
|
+
```typescript
|
|
1247
|
+
// * 錯誤:未排序參數
|
|
1248
|
+
function generateCheckMacValue(params: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1249
|
+
const paramString = Object.entries(params)
|
|
1250
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
1251
|
+
.join('&')
|
|
1252
|
+
|
|
1253
|
+
const rawString = `HashKey=${hashKey}&${paramString}&HashIV=${hashIV}`
|
|
1254
|
+
const hash = crypto.createHash('sha256').update(rawString).digest('hex')
|
|
1255
|
+
return hash.toUpperCase()
|
|
1256
|
+
}
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
**修正後:**
|
|
1260
|
+
```typescript
|
|
1261
|
+
// *! 正確:排序 + URL Encode (lowercase)
|
|
1262
|
+
function generateCheckMacValue(params: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1263
|
+
// 1. 移除 CheckMacValue 本身
|
|
1264
|
+
const { CheckMacValue, ...cleanParams } = params
|
|
1265
|
+
|
|
1266
|
+
// 2. 排序 keys
|
|
1267
|
+
const sortedKeys = Object.keys(cleanParams).sort()
|
|
1268
|
+
|
|
1269
|
+
// 3. 組合參數字串
|
|
1270
|
+
const paramString = sortedKeys
|
|
1271
|
+
.map(key => `${key}=${cleanParams[key]}`)
|
|
1272
|
+
.join('&')
|
|
1273
|
+
|
|
1274
|
+
// 4. 前後加上 HashKey/HashIV
|
|
1275
|
+
const rawString = `HashKey=${hashKey}&${paramString}&HashIV=${hashIV}`
|
|
1276
|
+
|
|
1277
|
+
// 5. URL Encode (lowercase)
|
|
1278
|
+
const encoded = encodeURIComponent(rawString).toLowerCase()
|
|
1279
|
+
|
|
1280
|
+
// 6. SHA256 + 大寫
|
|
1281
|
+
const hash = crypto.createHash('sha256').update(encoded).digest('hex')
|
|
1282
|
+
return hash.toUpperCase()
|
|
1283
|
+
}
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
**驗證方法:**
|
|
1287
|
+
```typescript
|
|
1288
|
+
// 測試範例
|
|
1289
|
+
const params = {
|
|
1290
|
+
MerchantID: '3002607',
|
|
1291
|
+
MerchantTradeNo: 'ORD123456',
|
|
1292
|
+
MerchantTradeDate: '2024/01/29 12:00:00',
|
|
1293
|
+
TotalAmount: 1050,
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const checkMacValue = generateCheckMacValue(
|
|
1297
|
+
params,
|
|
1298
|
+
'pwFHCqoQZGmho4w6',
|
|
1299
|
+
'EkRm7iFT261dpevs'
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
console.log('計算結果:', checkMacValue)
|
|
1303
|
+
// 應該與 ECPay 要求的值一致
|
|
1304
|
+
```
|
|
1305
|
+
|
|
1306
|
+
---
|
|
1307
|
+
|
|
1308
|
+
### 錯誤 2: AES 加密錯誤
|
|
1309
|
+
|
|
1310
|
+
**錯誤訊息:** NewebPay 回傳 `TradeSha 錯誤` 或 PAYUNi 回傳 `HashInfo 錯誤`
|
|
1311
|
+
|
|
1312
|
+
**原因:** Key/IV 長度錯誤或未附加 Auth Tag
|
|
1313
|
+
|
|
1314
|
+
**修正前 (NewebPay):**
|
|
1315
|
+
```typescript
|
|
1316
|
+
// * 錯誤:Key/IV 長度不正確
|
|
1317
|
+
function encryptNewebPay(data: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1318
|
+
const queryString = new URLSearchParams(data).toString()
|
|
1319
|
+
|
|
1320
|
+
// 錯誤:未檢查 Key/IV 長度
|
|
1321
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', hashKey, hashIV)
|
|
1322
|
+
let encrypted = cipher.update(queryString, 'utf8', 'hex')
|
|
1323
|
+
encrypted += cipher.final('hex')
|
|
1324
|
+
|
|
1325
|
+
return encrypted
|
|
1326
|
+
}
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
**修正後 (NewebPay):**
|
|
1330
|
+
```typescript
|
|
1331
|
+
// *! 正確:確認 Key/IV 長度 + 計算 TradeSha
|
|
1332
|
+
function encryptNewebPay(data: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1333
|
+
// 確認長度
|
|
1334
|
+
if (hashKey.length !== 32) throw new Error('HashKey 必須 32 bytes')
|
|
1335
|
+
if (hashIV.length !== 16) throw new Error('HashIV 必須 16 bytes')
|
|
1336
|
+
|
|
1337
|
+
const queryString = new URLSearchParams(data).toString()
|
|
1338
|
+
|
|
1339
|
+
// AES-256-CBC 加密
|
|
1340
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', hashKey, hashIV)
|
|
1341
|
+
cipher.setAutoPadding(true)
|
|
1342
|
+
let encrypted = cipher.update(queryString, 'utf8', 'hex')
|
|
1343
|
+
encrypted += cipher.final('hex')
|
|
1344
|
+
|
|
1345
|
+
// 計算 TradeSha
|
|
1346
|
+
const tradeSha = crypto
|
|
1347
|
+
.createHash('sha256')
|
|
1348
|
+
.update(`HashKey=${hashKey}&${encrypted}&HashIV=${hashIV}`)
|
|
1349
|
+
.digest('hex')
|
|
1350
|
+
.toUpperCase()
|
|
1351
|
+
|
|
1352
|
+
return {
|
|
1353
|
+
TradeInfo: encrypted,
|
|
1354
|
+
TradeSha: tradeSha
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
```
|
|
1358
|
+
|
|
1359
|
+
**修正前 (PAYUNi):**
|
|
1360
|
+
```typescript
|
|
1361
|
+
// * 錯誤:忘記附加 Auth Tag
|
|
1362
|
+
function encryptPAYUNi(data: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1363
|
+
const jsonString = JSON.stringify(data)
|
|
1364
|
+
|
|
1365
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', hashKey, hashIV)
|
|
1366
|
+
let encrypted = cipher.update(jsonString, 'utf8', 'hex')
|
|
1367
|
+
encrypted += cipher.final('hex')
|
|
1368
|
+
|
|
1369
|
+
// 錯誤:忘記取得 Auth Tag
|
|
1370
|
+
return encrypted
|
|
1371
|
+
}
|
|
1372
|
+
```
|
|
1373
|
+
|
|
1374
|
+
**修正後 (PAYUNi):**
|
|
1375
|
+
```typescript
|
|
1376
|
+
// *! 正確:附加 Auth Tag
|
|
1377
|
+
function encryptPAYUNi(data: Record<string, any>, hashKey: string, hashIV: string) {
|
|
1378
|
+
const jsonString = JSON.stringify(data)
|
|
1379
|
+
|
|
1380
|
+
// AES-256-GCM 加密
|
|
1381
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', hashKey, hashIV)
|
|
1382
|
+
let encrypted = cipher.update(jsonString, 'utf8', 'hex')
|
|
1383
|
+
encrypted += cipher.final('hex')
|
|
1384
|
+
|
|
1385
|
+
// 取得 Auth Tag(16 bytes = 32 hex chars)
|
|
1386
|
+
const authTag = cipher.getAuthTag().toString('hex')
|
|
1387
|
+
|
|
1388
|
+
// 組合:encrypted + authTag
|
|
1389
|
+
const encryptInfo = encrypted + authTag
|
|
1390
|
+
|
|
1391
|
+
// 計算 HashInfo
|
|
1392
|
+
const hashInfo = crypto
|
|
1393
|
+
.createHash('sha256')
|
|
1394
|
+
.update(`HashKey=${hashKey}&${encryptInfo}&HashIV=${hashIV}`)
|
|
1395
|
+
.digest('hex')
|
|
1396
|
+
.toUpperCase()
|
|
1397
|
+
|
|
1398
|
+
return {
|
|
1399
|
+
EncryptInfo: encryptInfo,
|
|
1400
|
+
HashInfo: hashInfo
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
```
|
|
1404
|
+
|
|
1405
|
+
**測試工具:**
|
|
1406
|
+
```typescript
|
|
1407
|
+
// 測試 NewebPay 加密/解密
|
|
1408
|
+
const testData = { test: 'hello', amount: 1000 }
|
|
1409
|
+
const { TradeInfo, TradeSha } = encryptNewebPay(testData, hashKey, hashIV)
|
|
1410
|
+
const decrypted = decryptNewebPay(TradeInfo, hashKey, hashIV)
|
|
1411
|
+
console.log('原始:', testData)
|
|
1412
|
+
console.log('解密:', decrypted)
|
|
1413
|
+
console.log('一致:', JSON.stringify(testData) === JSON.stringify(decrypted))
|
|
1414
|
+
|
|
1415
|
+
// 測試 PAYUNi 加密/解密
|
|
1416
|
+
const { EncryptInfo, HashInfo } = encryptPAYUNi(testData, hashKey, hashIV)
|
|
1417
|
+
const decryptedPAYUNi = decryptPAYUNi(EncryptInfo, hashKey, hashIV)
|
|
1418
|
+
console.log('原始:', testData)
|
|
1419
|
+
console.log('解密:', decryptedPAYUNi)
|
|
1420
|
+
console.log('一致:', JSON.stringify(testData) === JSON.stringify(decryptedPAYUNi))
|
|
1421
|
+
```
|
|
1422
|
+
|
|
1423
|
+
---
|
|
1424
|
+
|
|
1425
|
+
**更多範例持續更新中...**
|