314 lines
9.6 KiB
Go
314 lines
9.6 KiB
Go
package paymentplugins
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/csv"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
)
|
|
|
|
const (
|
|
maxTradeBillBytes = 64 << 20
|
|
maxTradeBillRecords = 500_000
|
|
)
|
|
|
|
func parseWeChatTradeBill(raw []byte) ([]BillRecord, error) {
|
|
rows, err := readBillCSV(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析微信交易账单:%w", err)
|
|
}
|
|
headerIndex, columns := findBillHeader(rows, []string{"商户订单号", "微信订单号", "交易状态"})
|
|
if headerIndex < 0 {
|
|
return nil, errors.New("微信交易账单缺少明细表头")
|
|
}
|
|
amountColumn := firstBillColumn(columns, "订单金额", "总金额", "应结订单金额")
|
|
if amountColumn < 0 {
|
|
return nil, errors.New("微信交易账单缺少订单金额")
|
|
}
|
|
records := make([]BillRecord, 0, len(rows)-headerIndex-1)
|
|
for _, row := range rows[headerIndex+1:] {
|
|
if len(records) >= maxTradeBillRecords {
|
|
return nil, errors.New("微信交易账单记录数超过安全限制")
|
|
}
|
|
status := billCell(row, columns["交易状态"])
|
|
if status != "SUCCESS" {
|
|
continue
|
|
}
|
|
merchantOrderNo := billCell(row, columns["商户订单号"])
|
|
providerTradeNo := billCell(row, columns["微信订单号"])
|
|
if merchantOrderNo == "" || providerTradeNo == "" {
|
|
continue
|
|
}
|
|
amountFen, err := parseYuanToFen(billCell(row, amountColumn))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("微信订单 %s 金额无效", merchantOrderNo)
|
|
}
|
|
currency := "CNY"
|
|
if column := firstBillColumn(columns, "货币种类"); column >= 0 {
|
|
if value := billCell(row, column); value != "" {
|
|
currency = value
|
|
}
|
|
}
|
|
paidAt := parseBillTime(billCell(row, firstBillColumn(columns, "交易时间")))
|
|
records = append(records, BillRecord{
|
|
MerchantOrderNo: merchantOrderNo, ProviderTradeNo: providerTradeNo,
|
|
ProviderStatus: status, AmountFen: amountFen, Currency: currency, PaidAt: paidAt,
|
|
})
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
func parseAlipayTradeBill(raw []byte) ([]BillRecord, error) {
|
|
files, err := alipayBillFiles(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
recordsByOrder := make(map[string]BillRecord)
|
|
foundHeader := false
|
|
for _, file := range files {
|
|
rows, err := readBillCSV(decodeAlipayBill(file))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
headerIndex, columns := findBillHeader(rows, []string{"商户订单号", "支付宝交易号"})
|
|
if headerIndex < 0 {
|
|
continue
|
|
}
|
|
foundHeader = true
|
|
amountColumn := firstBillColumn(columns, "订单金额(元)", "订单金额(元)", "订单金额")
|
|
if amountColumn < 0 {
|
|
continue
|
|
}
|
|
businessColumn := firstBillColumn(columns, "业务类型", "交易状态")
|
|
paidAtColumn := firstBillColumn(columns, "完成时间", "交易付款时间", "交易时间")
|
|
for _, row := range rows[headerIndex+1:] {
|
|
if len(recordsByOrder) >= maxTradeBillRecords {
|
|
return nil, errors.New("支付宝交易账单记录数超过安全限制")
|
|
}
|
|
business := billCell(row, businessColumn)
|
|
if strings.Contains(business, "退款") || strings.Contains(business, "撤销") || strings.Contains(business, "关闭") {
|
|
continue
|
|
}
|
|
merchantOrderNo := billCell(row, columns["商户订单号"])
|
|
providerTradeNo := billCell(row, columns["支付宝交易号"])
|
|
if merchantOrderNo == "" || providerTradeNo == "" {
|
|
continue
|
|
}
|
|
amountFen, err := parseYuanToFen(billCell(row, amountColumn))
|
|
if err != nil || amountFen <= 0 {
|
|
continue
|
|
}
|
|
recordsByOrder[merchantOrderNo] = BillRecord{
|
|
MerchantOrderNo: merchantOrderNo, ProviderTradeNo: providerTradeNo,
|
|
ProviderStatus: "SUCCESS", AmountFen: amountFen, Currency: "CNY",
|
|
PaidAt: parseBillTime(billCell(row, paidAtColumn)),
|
|
}
|
|
}
|
|
}
|
|
if !foundHeader {
|
|
return nil, errors.New("支付宝交易账单缺少业务明细表头")
|
|
}
|
|
records := make([]BillRecord, 0, len(recordsByOrder))
|
|
for _, record := range recordsByOrder {
|
|
records = append(records, record)
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
func alipayBillFiles(raw []byte) ([][]byte, error) {
|
|
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
|
if err != nil {
|
|
// A few sandbox environments return an uncompressed CSV. Supporting it
|
|
// also makes the parser tolerant without weakening archive limits.
|
|
return [][]byte{raw}, nil
|
|
}
|
|
files := make([][]byte, 0, len(reader.File))
|
|
var total int64
|
|
for _, file := range reader.File {
|
|
if file.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(file.Name), ".csv") {
|
|
continue
|
|
}
|
|
if file.UncompressedSize64 > maxTradeBillBytes || total+int64(file.UncompressedSize64) > maxTradeBillBytes {
|
|
return nil, errors.New("支付宝账单压缩包超过安全限制")
|
|
}
|
|
stream, err := file.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, readErr := io.ReadAll(io.LimitReader(stream, maxTradeBillBytes+1))
|
|
closeErr := stream.Close()
|
|
if readErr != nil {
|
|
return nil, readErr
|
|
}
|
|
if closeErr != nil {
|
|
return nil, closeErr
|
|
}
|
|
if len(data) > maxTradeBillBytes {
|
|
return nil, errors.New("支付宝账单文件超过安全限制")
|
|
}
|
|
total += int64(len(data))
|
|
files = append(files, data)
|
|
}
|
|
if len(files) == 0 {
|
|
return nil, errors.New("支付宝账单压缩包不包含 CSV")
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func decodeAlipayBill(raw []byte) []byte {
|
|
if utf8.Valid(raw) {
|
|
return raw
|
|
}
|
|
decoded, err := simplifiedchinese.GBK.NewDecoder().Bytes(raw)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
func readBillCSV(raw []byte) ([][]string, error) {
|
|
reader := csv.NewReader(bytes.NewReader(raw))
|
|
reader.FieldsPerRecord = -1
|
|
reader.LazyQuotes = true
|
|
reader.TrimLeadingSpace = true
|
|
return reader.ReadAll()
|
|
}
|
|
|
|
func findBillHeader(rows [][]string, required []string) (int, map[string]int) {
|
|
for rowIndex, row := range rows {
|
|
columns := make(map[string]int, len(row))
|
|
for columnIndex, value := range row {
|
|
columns[normalizeBillCell(value)] = columnIndex
|
|
}
|
|
valid := true
|
|
for _, field := range required {
|
|
if _, ok := columns[field]; !ok {
|
|
valid = false
|
|
break
|
|
}
|
|
}
|
|
if valid {
|
|
return rowIndex, columns
|
|
}
|
|
}
|
|
return -1, nil
|
|
}
|
|
|
|
func firstBillColumn(columns map[string]int, names ...string) int {
|
|
for _, name := range names {
|
|
if column, ok := columns[name]; ok {
|
|
return column
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func billCell(row []string, column int) string {
|
|
if column < 0 || column >= len(row) {
|
|
return ""
|
|
}
|
|
return normalizeBillCell(row[column])
|
|
}
|
|
|
|
func normalizeBillCell(value string) string {
|
|
value = strings.TrimSpace(strings.TrimPrefix(value, "\ufeff"))
|
|
value = strings.TrimSpace(strings.TrimPrefix(value, "`"))
|
|
value = strings.TrimSpace(strings.TrimPrefix(value, "'"))
|
|
return value
|
|
}
|
|
|
|
func parseBillTime(value string) time.Time {
|
|
location := time.FixedZone("CST", 8*60*60)
|
|
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
|
|
if parsed, err := time.ParseInLocation(layout, strings.TrimSpace(value), location); err == nil {
|
|
return parsed
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func trustedDownloadURL(raw, baseURL string, allowedSuffixes ...string) (*url.URL, error) {
|
|
return trustedDownloadURLWithPolicy(raw, baseURL, nil, allowedSuffixes...)
|
|
}
|
|
|
|
func trustedDownloadURLWithPolicy(raw, baseURL string, allowedHTTPHosts []string, allowedSuffixes ...string) (*url.URL, error) {
|
|
target, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || target.Host == "" || target.User != nil || target.Fragment != "" {
|
|
return nil, errors.New("支付渠道返回了无效账单下载地址")
|
|
}
|
|
host := strings.ToLower(target.Hostname())
|
|
base, _ := url.Parse(baseURL)
|
|
baseMatch := base != nil && strings.EqualFold(target.Host, base.Host)
|
|
suffixMatch := false
|
|
for _, suffix := range allowedSuffixes {
|
|
suffix = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(suffix), "."))
|
|
if host == suffix || strings.HasSuffix(host, "."+suffix) {
|
|
suffixMatch = true
|
|
break
|
|
}
|
|
}
|
|
if !baseMatch && !suffixMatch {
|
|
return nil, errors.New("支付渠道账单下载地址不在可信域名内")
|
|
}
|
|
if target.Scheme == "http" {
|
|
if baseMatch && base != nil && base.Scheme == "http" {
|
|
return target, nil
|
|
}
|
|
allowed := false
|
|
for _, allowedHost := range allowedHTTPHosts {
|
|
if strings.EqualFold(host, strings.TrimSpace(allowedHost)) {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed || (target.Port() != "" && target.Port() != "80") {
|
|
return nil, errors.New("支付渠道账单下载地址必须使用 HTTPS")
|
|
}
|
|
return target, nil
|
|
}
|
|
if target.Scheme != "https" {
|
|
return nil, errors.New("支付渠道账单下载地址必须使用 HTTPS")
|
|
}
|
|
if target.Port() != "" && target.Port() != "443" {
|
|
return nil, errors.New("支付渠道账单下载地址使用了非标准端口")
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
// clientWithTrustedRedirects validates every redirect before it is followed.
|
|
// Validating only response.Request would be too late because an HTTP client may
|
|
// already have contacted an attacker-controlled or internal redirect target.
|
|
func clientWithTrustedRedirects(client *http.Client, baseURL string, allowedSuffixes ...string) *http.Client {
|
|
return clientWithTrustedRedirectPolicy(client, baseURL, nil, allowedSuffixes...)
|
|
}
|
|
|
|
func clientWithTrustedRedirectPolicy(client *http.Client, baseURL string, allowedHTTPHosts []string, allowedSuffixes ...string) *http.Client {
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
copyClient := *client
|
|
previous := client.CheckRedirect
|
|
copyClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
|
if len(via) >= 10 {
|
|
return errors.New("支付渠道账单下载重定向过多")
|
|
}
|
|
if _, err := trustedDownloadURLWithPolicy(request.URL.String(), baseURL, allowedHTTPHosts, allowedSuffixes...); err != nil {
|
|
return err
|
|
}
|
|
if previous != nil {
|
|
return previous(request, via)
|
|
}
|
|
return nil
|
|
}
|
|
return ©Client
|
|
}
|