This commit is contained in:
2026-03-13 15:51:59 +08:00
parent 4db2386bbf
commit 4e91f4cede
133 changed files with 19502 additions and 37 deletions

View File

@@ -0,0 +1,209 @@
---
name: code-reviewer
description: Comprehensive code review skill for TypeScript, JavaScript, Python, Swift, Kotlin, Go. Includes automated code analysis, best practice checking, security scanning, and review checklist generation. Use when reviewing pull requests, providing code feedback, identifying issues, or ensuring code quality standards.
---
# Code Reviewer
Complete toolkit for code reviewer with modern tools and best practices.
## Quick Start
### Main Capabilities
This skill provides three core capabilities through automated scripts:
```bash
# Script 1: Pr Analyzer
python scripts/pr_analyzer.py [options]
# Script 2: Code Quality Checker
python scripts/code_quality_checker.py [options]
# Script 3: Review Report Generator
python scripts/review_report_generator.py [options]
```
## Core Capabilities
### 1. Pr Analyzer
Automated tool for pr analyzer tasks.
**Features:**
- Automated scaffolding
- Best practices built-in
- Configurable templates
- Quality checks
**Usage:**
```bash
python scripts/pr_analyzer.py <project-path> [options]
```
### 2. Code Quality Checker
Comprehensive analysis and optimization tool.
**Features:**
- Deep analysis
- Performance metrics
- Recommendations
- Automated fixes
**Usage:**
```bash
python scripts/code_quality_checker.py <target-path> [--verbose]
```
### 3. Review Report Generator
Advanced tooling for specialized tasks.
**Features:**
- Expert-level automation
- Custom configurations
- Integration ready
- Production-grade output
**Usage:**
```bash
python scripts/review_report_generator.py [arguments] [options]
```
## Reference Documentation
### Code Review Checklist
Comprehensive guide available in `references/code_review_checklist.md`:
- Detailed patterns and practices
- Code examples
- Best practices
- Anti-patterns to avoid
- Real-world scenarios
### Coding Standards
Complete workflow documentation in `references/coding_standards.md`:
- Step-by-step processes
- Optimization strategies
- Tool integrations
- Performance tuning
- Troubleshooting guide
### Common Antipatterns
Technical reference guide in `references/common_antipatterns.md`:
- Technology stack details
- Configuration examples
- Integration patterns
- Security considerations
- Scalability guidelines
## Tech Stack
**Languages:** TypeScript, JavaScript, Python, Go, Swift, Kotlin
**Frontend:** React, Next.js, React Native, Flutter
**Backend:** Node.js, Express, GraphQL, REST APIs
**Database:** PostgreSQL, Prisma, NeonDB, Supabase
**DevOps:** Docker, Kubernetes, Terraform, GitHub Actions, CircleCI
**Cloud:** AWS, GCP, Azure
## Development Workflow
### 1. Setup and Configuration
```bash
# Install dependencies
npm install
# or
pip install -r requirements.txt
# Configure environment
cp .env.example .env
```
### 2. Run Quality Checks
```bash
# Use the analyzer script
python scripts/code_quality_checker.py .
# Review recommendations
# Apply fixes
```
### 3. Implement Best Practices
Follow the patterns and practices documented in:
- `references/code_review_checklist.md`
- `references/coding_standards.md`
- `references/common_antipatterns.md`
## Best Practices Summary
### Code Quality
- Follow established patterns
- Write comprehensive tests
- Document decisions
- Review regularly
### Performance
- Measure before optimizing
- Use appropriate caching
- Optimize critical paths
- Monitor in production
### Security
- Validate all inputs
- Use parameterized queries
- Implement proper authentication
- Keep dependencies updated
### Maintainability
- Write clear code
- Use consistent naming
- Add helpful comments
- Keep it simple
## Common Commands
```bash
# Development
npm run dev
npm run build
npm run test
npm run lint
# Analysis
python scripts/code_quality_checker.py .
python scripts/review_report_generator.py --analyze
# Deployment
docker build -t app:latest .
docker-compose up -d
kubectl apply -f k8s/
```
## Troubleshooting
### Common Issues
Check the comprehensive troubleshooting section in `references/common_antipatterns.md`.
### Getting Help
- Review reference documentation
- Check script output messages
- Consult tech stack documentation
- Review error logs
## Resources
- Pattern Reference: `references/code_review_checklist.md`
- Workflow Guide: `references/coding_standards.md`
- Technical Guide: `references/common_antipatterns.md`
- Tool Scripts: `scripts/` directory

View File

@@ -0,0 +1,103 @@
# Code Review Checklist
## Overview
This reference guide provides comprehensive information for code reviewer.
## Patterns and Practices
### Pattern 1: Best Practice Implementation
**Description:**
Detailed explanation of the pattern.
**When to Use:**
- Scenario 1
- Scenario 2
- Scenario 3
**Implementation:**
```typescript
// Example code implementation
export class Example {
// Implementation details
}
```
**Benefits:**
- Benefit 1
- Benefit 2
- Benefit 3
**Trade-offs:**
- Consider 1
- Consider 2
- Consider 3
### Pattern 2: Advanced Technique
**Description:**
Another important pattern for code reviewer.
**Implementation:**
```typescript
// Advanced example
async function advancedExample() {
// Code here
}
```
## Guidelines
### Code Organization
- Clear structure
- Logical separation
- Consistent naming
- Proper documentation
### Performance Considerations
- Optimization strategies
- Bottleneck identification
- Monitoring approaches
- Scaling techniques
### Security Best Practices
- Input validation
- Authentication
- Authorization
- Data protection
## Common Patterns
### Pattern A
Implementation details and examples.
### Pattern B
Implementation details and examples.
### Pattern C
Implementation details and examples.
## Anti-Patterns to Avoid
### Anti-Pattern 1
What not to do and why.
### Anti-Pattern 2
What not to do and why.
## Tools and Resources
### Recommended Tools
- Tool 1: Purpose
- Tool 2: Purpose
- Tool 3: Purpose
### Further Reading
- Resource 1
- Resource 2
- Resource 3
## Conclusion
Key takeaways for using this reference guide effectively.

View File

@@ -0,0 +1,103 @@
# Coding Standards
## Overview
This reference guide provides comprehensive information for code reviewer.
## Patterns and Practices
### Pattern 1: Best Practice Implementation
**Description:**
Detailed explanation of the pattern.
**When to Use:**
- Scenario 1
- Scenario 2
- Scenario 3
**Implementation:**
```typescript
// Example code implementation
export class Example {
// Implementation details
}
```
**Benefits:**
- Benefit 1
- Benefit 2
- Benefit 3
**Trade-offs:**
- Consider 1
- Consider 2
- Consider 3
### Pattern 2: Advanced Technique
**Description:**
Another important pattern for code reviewer.
**Implementation:**
```typescript
// Advanced example
async function advancedExample() {
// Code here
}
```
## Guidelines
### Code Organization
- Clear structure
- Logical separation
- Consistent naming
- Proper documentation
### Performance Considerations
- Optimization strategies
- Bottleneck identification
- Monitoring approaches
- Scaling techniques
### Security Best Practices
- Input validation
- Authentication
- Authorization
- Data protection
## Common Patterns
### Pattern A
Implementation details and examples.
### Pattern B
Implementation details and examples.
### Pattern C
Implementation details and examples.
## Anti-Patterns to Avoid
### Anti-Pattern 1
What not to do and why.
### Anti-Pattern 2
What not to do and why.
## Tools and Resources
### Recommended Tools
- Tool 1: Purpose
- Tool 2: Purpose
- Tool 3: Purpose
### Further Reading
- Resource 1
- Resource 2
- Resource 3
## Conclusion
Key takeaways for using this reference guide effectively.

View File

@@ -0,0 +1,103 @@
# Common Antipatterns
## Overview
This reference guide provides comprehensive information for code reviewer.
## Patterns and Practices
### Pattern 1: Best Practice Implementation
**Description:**
Detailed explanation of the pattern.
**When to Use:**
- Scenario 1
- Scenario 2
- Scenario 3
**Implementation:**
```typescript
// Example code implementation
export class Example {
// Implementation details
}
```
**Benefits:**
- Benefit 1
- Benefit 2
- Benefit 3
**Trade-offs:**
- Consider 1
- Consider 2
- Consider 3
### Pattern 2: Advanced Technique
**Description:**
Another important pattern for code reviewer.
**Implementation:**
```typescript
// Advanced example
async function advancedExample() {
// Code here
}
```
## Guidelines
### Code Organization
- Clear structure
- Logical separation
- Consistent naming
- Proper documentation
### Performance Considerations
- Optimization strategies
- Bottleneck identification
- Monitoring approaches
- Scaling techniques
### Security Best Practices
- Input validation
- Authentication
- Authorization
- Data protection
## Common Patterns
### Pattern A
Implementation details and examples.
### Pattern B
Implementation details and examples.
### Pattern C
Implementation details and examples.
## Anti-Patterns to Avoid
### Anti-Pattern 1
What not to do and why.
### Anti-Pattern 2
What not to do and why.
## Tools and Resources
### Recommended Tools
- Tool 1: Purpose
- Tool 2: Purpose
- Tool 3: Purpose
### Further Reading
- Resource 1
- Resource 2
- Resource 3
## Conclusion
Key takeaways for using this reference guide effectively.

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Code Quality Checker
Automated tool for code reviewer tasks
"""
import os
import sys
import json
import argparse
from pathlib import Path
from typing import Dict, List, Optional
class CodeQualityChecker:
"""Main class for code quality checker functionality"""
def __init__(self, target_path: str, verbose: bool = False):
self.target_path = Path(target_path)
self.verbose = verbose
self.results = {}
def run(self) -> Dict:
"""Execute the main functionality"""
print(f"🚀 Running {self.__class__.__name__}...")
print(f"📁 Target: {self.target_path}")
try:
self.validate_target()
self.analyze()
self.generate_report()
print("✅ Completed successfully!")
return self.results
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)
def validate_target(self):
"""Validate the target path exists and is accessible"""
if not self.target_path.exists():
raise ValueError(f"Target path does not exist: {self.target_path}")
if self.verbose:
print(f"✓ Target validated: {self.target_path}")
def analyze(self):
"""Perform the main analysis or operation"""
if self.verbose:
print("📊 Analyzing...")
# Main logic here
self.results['status'] = 'success'
self.results['target'] = str(self.target_path)
self.results['findings'] = []
# Add analysis results
if self.verbose:
print(f"✓ Analysis complete: {len(self.results.get('findings', []))} findings")
def generate_report(self):
"""Generate and display the report"""
print("\n" + "="*50)
print("REPORT")
print("="*50)
print(f"Target: {self.results.get('target')}")
print(f"Status: {self.results.get('status')}")
print(f"Findings: {len(self.results.get('findings', []))}")
print("="*50 + "\n")
def main():
"""Main entry point"""
parser = argparse.ArgumentParser(
description="Code Quality Checker"
)
parser.add_argument(
'target',
help='Target path to analyze or process'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Enable verbose output'
)
parser.add_argument(
'--json',
action='store_true',
help='Output results as JSON'
)
parser.add_argument(
'--output', '-o',
help='Output file path'
)
args = parser.parse_args()
tool = CodeQualityChecker(
args.target,
verbose=args.verbose
)
results = tool.run()
if args.json:
output = json.dumps(results, indent=2)
if args.output:
with open(args.output, 'w') as f:
f.write(output)
print(f"Results written to {args.output}")
else:
print(output)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Pr Analyzer
Automated tool for code reviewer tasks
"""
import os
import sys
import json
import argparse
from pathlib import Path
from typing import Dict, List, Optional
class PrAnalyzer:
"""Main class for pr analyzer functionality"""
def __init__(self, target_path: str, verbose: bool = False):
self.target_path = Path(target_path)
self.verbose = verbose
self.results = {}
def run(self) -> Dict:
"""Execute the main functionality"""
print(f"🚀 Running {self.__class__.__name__}...")
print(f"📁 Target: {self.target_path}")
try:
self.validate_target()
self.analyze()
self.generate_report()
print("✅ Completed successfully!")
return self.results
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)
def validate_target(self):
"""Validate the target path exists and is accessible"""
if not self.target_path.exists():
raise ValueError(f"Target path does not exist: {self.target_path}")
if self.verbose:
print(f"✓ Target validated: {self.target_path}")
def analyze(self):
"""Perform the main analysis or operation"""
if self.verbose:
print("📊 Analyzing...")
# Main logic here
self.results['status'] = 'success'
self.results['target'] = str(self.target_path)
self.results['findings'] = []
# Add analysis results
if self.verbose:
print(f"✓ Analysis complete: {len(self.results.get('findings', []))} findings")
def generate_report(self):
"""Generate and display the report"""
print("\n" + "="*50)
print("REPORT")
print("="*50)
print(f"Target: {self.results.get('target')}")
print(f"Status: {self.results.get('status')}")
print(f"Findings: {len(self.results.get('findings', []))}")
print("="*50 + "\n")
def main():
"""Main entry point"""
parser = argparse.ArgumentParser(
description="Pr Analyzer"
)
parser.add_argument(
'target',
help='Target path to analyze or process'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Enable verbose output'
)
parser.add_argument(
'--json',
action='store_true',
help='Output results as JSON'
)
parser.add_argument(
'--output', '-o',
help='Output file path'
)
args = parser.parse_args()
tool = PrAnalyzer(
args.target,
verbose=args.verbose
)
results = tool.run()
if args.json:
output = json.dumps(results, indent=2)
if args.output:
with open(args.output, 'w') as f:
f.write(output)
print(f"Results written to {args.output}")
else:
print(output)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Review Report Generator
Automated tool for code reviewer tasks
"""
import os
import sys
import json
import argparse
from pathlib import Path
from typing import Dict, List, Optional
class ReviewReportGenerator:
"""Main class for review report generator functionality"""
def __init__(self, target_path: str, verbose: bool = False):
self.target_path = Path(target_path)
self.verbose = verbose
self.results = {}
def run(self) -> Dict:
"""Execute the main functionality"""
print(f"🚀 Running {self.__class__.__name__}...")
print(f"📁 Target: {self.target_path}")
try:
self.validate_target()
self.analyze()
self.generate_report()
print("✅ Completed successfully!")
return self.results
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)
def validate_target(self):
"""Validate the target path exists and is accessible"""
if not self.target_path.exists():
raise ValueError(f"Target path does not exist: {self.target_path}")
if self.verbose:
print(f"✓ Target validated: {self.target_path}")
def analyze(self):
"""Perform the main analysis or operation"""
if self.verbose:
print("📊 Analyzing...")
# Main logic here
self.results['status'] = 'success'
self.results['target'] = str(self.target_path)
self.results['findings'] = []
# Add analysis results
if self.verbose:
print(f"✓ Analysis complete: {len(self.results.get('findings', []))} findings")
def generate_report(self):
"""Generate and display the report"""
print("\n" + "="*50)
print("REPORT")
print("="*50)
print(f"Target: {self.results.get('target')}")
print(f"Status: {self.results.get('status')}")
print(f"Findings: {len(self.results.get('findings', []))}")
print("="*50 + "\n")
def main():
"""Main entry point"""
parser = argparse.ArgumentParser(
description="Review Report Generator"
)
parser.add_argument(
'target',
help='Target path to analyze or process'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Enable verbose output'
)
parser.add_argument(
'--json',
action='store_true',
help='Output results as JSON'
)
parser.add_argument(
'--output', '-o',
help='Output file path'
)
args = parser.parse_args()
tool = ReviewReportGenerator(
args.target,
verbose=args.verbose
)
results = tool.run()
if args.json:
output = json.dumps(results, indent=2)
if args.output:
with open(args.output, 'w') as f:
f.write(output)
print(f"Results written to {args.output}")
else:
print(output)
if __name__ == '__main__':
main()

13
.claude/settings.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extraKnownMarketplaces": {
"dev-workflow": {
"source": {
"source": "directory",
"path": "/Users/yanmei/Workspace/coding/projects/requirement-workflow"
}
}
},
"enabledPlugins": {
"req@dev-workflow": true
}
}

View File

@@ -0,0 +1 @@
../../.agents/skills/code-reviewer

1
.trae/skills/code-reviewer Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/code-reviewer

View File

@@ -4,21 +4,37 @@
## 项目概述 ## 项目概述
**pay-bridge** 是一个前后端一体的全栈应用,前端使用 React后端使用 Go。目前处于早期/模板阶段 **pay-bridge** 是一个前后端分离的全栈应用,monorepo 结构
- **前端**: React - **前端**: React,位于 `frontend/`
- **后端**: Go (Go 1.25) - **后端**: Go (Go 1.24),位于 `backend/`
## 目录结构
```
pay-bridge/
├── backend/ # Go 后端
│ ├── cmd/server/ # 程序入口
│ ├── internal/ # 业务代码api/service/repository/model/channel
│ ├── pkg/ # 公共库config/crypto/logger/sequence
│ ├── configs/ # 配置文件和数据库迁移脚本
│ ├── go.mod
│ └── go.sum
├── frontend/ # React 前端
├── docs/ # 设计文档
└── CLAUDE.md
```
## 构建与运行命令 ## 构建与运行命令
### 后端 ### 后端(在 `backend/` 目录下执行)
```bash ```bash
# 运行 # 运行
go run main.go go run ./cmd/server
# 构建 # 构建
go build -o pay-bridge . go build -o pay-bridge ./cmd/server
# 测试 # 测试
go test ./... go test ./...
@@ -30,22 +46,20 @@ go test -run TestName ./path/to/package
go vet ./... go vet ./...
``` ```
### 前端 ### 前端(在 `frontend/` 目录下执行)
```bash ```bash
# 安装依赖 # 安装依赖
cd frontend && npm install npm install
# 开发服务器 # 开发服务器
cd frontend && npm run dev npm run dev
# 构建 # 构建
cd frontend && npm run build npm run build
``` ```
## 架构 ## 架构
前后端一体的 monorepo 项目 - **后端**: Go 模块 (`module pay-bridge`)4 层架构API Handler → Service → Repository → DB/Redis渠道适配器插件化注册
- **前端**: React 应用,调用后端 REST API。
- **后端**: Go 模块 (`module pay-bridge`),提供 API 服务并托管前端静态资源。遵循 Go 标准目录结构 (`cmd/`, `internal/`, `pkg/`)。
- **前端**: React 应用,位于 `frontend/` 目录。

View File

@@ -0,0 +1,72 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "pay-bridge/internal/channel/heepay" // 注册 HEEPay 渠道适配器
"pay-bridge/internal/api"
"pay-bridge/internal/app"
"pay-bridge/pkg/config"
"pay-bridge/pkg/logger"
)
func main() {
cfgFile := flag.String("config", "", "config file path (default: configs/config.local.yaml)")
flag.Parse()
cfg, err := config.Load(*cfgFile)
if err != nil {
fmt.Fprintf(os.Stderr, "load config failed: %v\n", err)
os.Exit(1)
}
logger.Init(cfg.Log.Level, cfg.Log.Format)
application, err := app.New(cfg)
if err != nil {
slog.Error("init application failed", "err", err)
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
application.Start(ctx)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: api.SetupRouter(application),
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
go func() {
slog.Info("pay-bridge server started", "port", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
slog.Info("shutting down server...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("server shutdown error", "err", err)
}
application.Shutdown(shutdownCtx)
slog.Info("server exited")
}

View File

@@ -0,0 +1,45 @@
server:
port: 8081
read_timeout: 30s
write_timeout: 30s
database:
dsn: "root:root123@tcp(127.0.0.1:3306)/pay_bridge?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai"
max_open_conns: 10
max_idle_conns: 5
conn_max_lifetime: 1h
redis:
addr: "127.0.0.1:6379"
password: ""
db: 0
pool_size: 10
security:
field_encrypt_key: "0123456789abcdef0123456789abcdef"
app_secret_salt: "pay-bridge-salt"
jwt:
secret: "pay-bridge-admin-jwt-secret-key"
expire_hours: 24
notify:
poller_interval: 5s
poller_batch: 100
http_timeout: 10s
reconciliation:
cron: "0 2 * * *"
bill_retry_times: 3
merchant:
status_check_cron: "0 9 * * *"
log:
level: "debug"
format: "text"
channels:
heepay:
pay_url: "http://openapi.heepaydev.com/gateway"
merchant_url: "http://openapi.heepaydev.com/v1/customer/gateway"

View File

@@ -0,0 +1,41 @@
server:
port: 8080
read_timeout: 30s
write_timeout: 30s
database:
dsn: "${DB_DSN}"
max_open_conns: 50
max_idle_conns: 10
conn_max_lifetime: 1h
redis:
addr: "${REDIS_ADDR}"
password: "${REDIS_PASS}"
db: 0
pool_size: 20
security:
field_encrypt_key: "${FIELD_ENCRYPT_KEY}"
app_secret_salt: "${APP_SECRET_SALT}"
notify:
poller_interval: 5s
poller_batch: 100
http_timeout: 10s
reconciliation:
cron: "0 2 * * *"
bill_retry_times: 3
merchant:
status_check_cron: "0 9 * * *"
log:
level: "info"
format: "json"
channels:
heepay:
pay_url: "https://openapi.heepay.com/gateway"
merchant_url: "https://openapi.heepay.com/v1/customer/gateway"

View File

@@ -0,0 +1,118 @@
-- pay-bridge 初始化数据库脚本
-- MySQL 8.0, utf8mb4, InnoDB
-- 金额字段统一使用 BIGINT单位
CREATE DATABASE IF NOT EXISTS pay_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE pay_bridge;
-- 接入应用
CREATE TABLE IF NOT EXISTS `app` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`app_id` VARCHAR(32) NOT NULL COMMENT '应用ID下游系统鉴权凭证',
`app_secret` VARCHAR(128) NOT NULL COMMENT '应用密钥AES加密存储',
`app_name` VARCHAR(64) NOT NULL COMMENT '应用名称',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_id` (`app_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接入应用';
-- 交易订单
CREATE TABLE IF NOT EXISTS `trade_order` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`trade_no` VARCHAR(32) NOT NULL COMMENT 'pay-bridge生成的交易号',
`merchant_order_no` VARCHAR(64) NOT NULL COMMENT '下游系统的商户订单号',
`app_id` VARCHAR(32) NOT NULL COMMENT '所属应用ID',
`channel_code` VARCHAR(32) NOT NULL COMMENT '支付渠道编码',
`channel_trade_no` VARCHAR(64) DEFAULT NULL COMMENT '上游渠道交易号',
`pay_method` VARCHAR(32) NOT NULL COMMENT '支付方式',
`amount` BIGINT NOT NULL COMMENT '订单金额(分)',
`profit_sharing_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '分润金额(分)',
`service_fee_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '服务费金额(分)',
`subject` VARCHAR(256) NOT NULL COMMENT '商品描述',
`notify_url` VARCHAR(512) NOT NULL COMMENT '下游通知地址',
`status` VARCHAR(20) NOT NULL DEFAULT 'CREATING' COMMENT '交易状态',
`extra` JSON DEFAULT NULL COMMENT '支付方式扩展参数',
`channel_extra` JSON DEFAULT NULL COMMENT '渠道返回的支付凭证',
`expire_time` DATETIME(3) NOT NULL COMMENT '订单过期时间',
`pay_time` DATETIME(3) DEFAULT NULL COMMENT '支付成功时间',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_trade_no` (`trade_no`),
UNIQUE KEY `uk_app_merchant_order` (`app_id`, `merchant_order_no`),
KEY `idx_channel_trade_no` (`channel_trade_no`),
KEY `idx_app_status_created` (`app_id`, `status`, `created_at`),
KEY `idx_expire_time` (`expire_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易订单';
-- 退款记录
CREATE TABLE IF NOT EXISTS `refund_order` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`refund_no` VARCHAR(32) NOT NULL COMMENT 'pay-bridge退款单号',
`trade_no` VARCHAR(32) NOT NULL COMMENT '关联交易号',
`app_id` VARCHAR(32) NOT NULL,
`channel_code` VARCHAR(32) NOT NULL,
`channel_refund_no` VARCHAR(64) DEFAULT NULL COMMENT '上游渠道退款单号',
`refund_amount` BIGINT NOT NULL COMMENT '退款金额(分)',
`reason` VARCHAR(256) DEFAULT NULL COMMENT '退款原因',
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING',
`notify_url` VARCHAR(512) DEFAULT NULL,
`refund_time` DATETIME(3) DEFAULT NULL COMMENT '退款完成时间',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_refund_no` (`refund_no`),
KEY `idx_trade_no` (`trade_no`),
KEY `idx_app_status` (`app_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退款记录';
-- 渠道配置
CREATE TABLE IF NOT EXISTS `channel_config` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`app_id` VARCHAR(32) NOT NULL COMMENT '关联应用ID',
`channel_code` VARCHAR(32) NOT NULL COMMENT '渠道编码',
`merchant_id` VARCHAR(64) NOT NULL COMMENT '渠道商户ID',
`api_key` TEXT DEFAULT NULL COMMENT 'API密钥AES加密',
`private_key` TEXT DEFAULT NULL COMMENT 'RSA私钥AES加密',
`public_key` TEXT DEFAULT NULL COMMENT '渠道公钥(明文)',
`notify_url` VARCHAR(512) NOT NULL COMMENT '上游回调接收地址',
`sandbox` TINYINT NOT NULL DEFAULT 0 COMMENT '1=沙箱 0=生产',
`extra_config` JSON DEFAULT NULL COMMENT '渠道特有扩展配置',
`status` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_channel` (`app_id`, `channel_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道配置';
-- 下游通知记录
CREATE TABLE IF NOT EXISTS `notify_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`trade_no` VARCHAR(32) NOT NULL COMMENT '关联交易号',
`notify_type` VARCHAR(20) NOT NULL COMMENT 'PAYMENT / REFUND',
`notify_url` VARCHAR(512) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING',
`retry_count` INT NOT NULL DEFAULT 0,
`next_retry_time` DATETIME(3) DEFAULT NULL,
`last_response` TEXT DEFAULT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_trade_notify_type` (`trade_no`, `notify_type`),
KEY `idx_status_next_retry` (`status`, `next_retry_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下游通知记录';
-- 订单编码序列
CREATE TABLE IF NOT EXISTS `order_sequence` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`app_id` VARCHAR(32) NOT NULL,
`seq_type` VARCHAR(20) NOT NULL COMMENT 'TRADE / REFUND / SHARING',
`prefix` VARCHAR(8) NOT NULL COMMENT '序号前缀',
`current_value` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`step` INT NOT NULL DEFAULT 1,
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_type` (`app_id`, `seq_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单编码序列';

View File

@@ -0,0 +1,223 @@
-- pay-bridge 扩展功能表
-- 分润、服务费、收款匹配、商户进件、微信通知、对账
USE pay_bridge;
-- 分润配置
CREATE TABLE IF NOT EXISTS `profit_sharing_config` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`app_id` VARCHAR(32) NOT NULL COMMENT '应用ID',
`channel_code` VARCHAR(32) NOT NULL COMMENT '渠道编码',
`receiver_merchant_id` VARCHAR(64) NOT NULL COMMENT '收款商户ID渠道侧',
`max_sharing_ratio` DECIMAL(5,4) NOT NULL DEFAULT 0.3000 COMMENT '最大分润比例如0.3=30%',
`auto_sharing` TINYINT NOT NULL DEFAULT 0 COMMENT '1=支付后自动分润',
`status` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_channel` (`app_id`, `channel_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分润配置';
-- 分润订单
CREATE TABLE IF NOT EXISTS `profit_sharing_order` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`sharing_no` VARCHAR(32) NOT NULL COMMENT 'pay-bridge分润单号',
`trade_no` VARCHAR(32) NOT NULL COMMENT '关联交易号',
`app_id` VARCHAR(32) NOT NULL,
`channel_code` VARCHAR(32) NOT NULL,
`channel_sharing_no` VARCHAR(64) DEFAULT NULL COMMENT '渠道分润单号',
`amount` BIGINT NOT NULL COMMENT '分润金额(分)',
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING',
`fail_reason` VARCHAR(256) DEFAULT NULL,
`finished_at` DATETIME(3) DEFAULT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sharing_no` (`sharing_no`),
UNIQUE KEY `uk_trade_no` (`trade_no`),
KEY `idx_app_status` (`app_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分润订单';
-- 分润流水
CREATE TABLE IF NOT EXISTS `profit_sharing_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`sharing_no` VARCHAR(32) NOT NULL,
`trade_no` VARCHAR(32) NOT NULL,
`action` VARCHAR(20) NOT NULL COMMENT 'SPLIT/ROLLBACK',
`amount` BIGINT NOT NULL,
`remark` VARCHAR(256) DEFAULT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_sharing_no` (`sharing_no`),
KEY `idx_trade_no` (`trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分润流水';
-- 服务费配置
CREATE TABLE IF NOT EXISTS `service_fee_config` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`app_id` VARCHAR(32) NOT NULL COMMENT '应用ID',
`pay_method_group` VARCHAR(32) NOT NULL COMMENT '支付方式分组WECHAT/ALIPAY/BANK',
`fee_type` VARCHAR(20) NOT NULL DEFAULT 'PERCENTAGE' COMMENT 'PERCENTAGE/FIXED',
`fee_rate` DECIMAL(6,5) NOT NULL DEFAULT 0 COMMENT '费率如0.006=0.6%',
`fee_fixed` BIGINT NOT NULL DEFAULT 0 COMMENT '固定费用(分)',
`min_fee` BIGINT NOT NULL DEFAULT 0 COMMENT '最低费用(分)',
`max_fee` BIGINT NOT NULL DEFAULT 0 COMMENT '最高费用0=不限',
`receiver_merchant_id` VARCHAR(64) NOT NULL COMMENT '收款商户ID',
`channel_code` VARCHAR(32) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_method_group` (`app_id`, `pay_method_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务费配置';
-- 服务费流水
CREATE TABLE IF NOT EXISTS `service_fee_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`trade_no` VARCHAR(32) NOT NULL,
`app_id` VARCHAR(32) NOT NULL,
`action` VARCHAR(20) NOT NULL COMMENT 'CHARGE/ROLLBACK',
`fee_amount` BIGINT NOT NULL,
`remark` VARCHAR(256) DEFAULT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_trade_action` (`trade_no`, `action`),
KEY `idx_app` (`app_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务费流水';
-- 子商户收款账户(固定账户收款)
CREATE TABLE IF NOT EXISTS `sub_merchant_account` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`app_id` VARCHAR(32) NOT NULL,
`sub_merchant_id` VARCHAR(64) NOT NULL,
`channel_code` VARCHAR(32) NOT NULL,
`account_type` VARCHAR(20) NOT NULL COMMENT 'BANK_CARD',
`account_no` VARCHAR(64) NOT NULL COMMENT '脱敏账号后4位',
`account_no_enc` TEXT DEFAULT NULL COMMENT 'AES加密完整账号',
`account_name` VARCHAR(128) NOT NULL COMMENT '账户名称',
`bank_name` VARCHAR(64) DEFAULT NULL,
`status` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_app_merchant` (`app_id`, `sub_merchant_id`),
KEY `idx_account_no` (`account_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='子商户收款账户';
-- 收款匹配日志
CREATE TABLE IF NOT EXISTS `payment_match_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`account_id` BIGINT UNSIGNED NOT NULL COMMENT '关联账户ID',
`trade_no` VARCHAR(32) DEFAULT NULL COMMENT '匹配到的交易号',
`incoming_amount` BIGINT NOT NULL COMMENT '入账金额(分)',
`incoming_remark` VARCHAR(256) DEFAULT NULL COMMENT '转账备注',
`payer_name` VARCHAR(128) DEFAULT NULL COMMENT '付款方名称',
`channel_bill_no` VARCHAR(64) NOT NULL COMMENT '渠道流水号',
`match_status` VARCHAR(20) NOT NULL DEFAULT 'PENDING_MANUAL',
`name_diff` TINYINT NOT NULL DEFAULT 0 COMMENT '1=名称不一致',
`match_time` DATETIME(3) DEFAULT NULL,
`operator` VARCHAR(64) DEFAULT NULL COMMENT '人工操作者',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel_bill_no` (`channel_bill_no`),
KEY `idx_account_status` (`account_id`, `match_status`),
KEY `idx_trade_no` (`trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收款匹配日志';
-- 商户信息
CREATE TABLE IF NOT EXISTS `merchant` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_id` VARCHAR(32) NOT NULL COMMENT '商户ID',
`merchant_name` VARCHAR(128) NOT NULL COMMENT '商户名称',
`license_no` VARCHAR(64) DEFAULT NULL COMMENT '营业执照号',
`legal_person` VARCHAR(64) DEFAULT NULL COMMENT '法人姓名',
`bank_account` VARCHAR(64) DEFAULT NULL COMMENT '银行账号(脱敏)',
`channel_merchant_id` VARCHAR(64) DEFAULT NULL COMMENT '渠道商户ID',
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_merchant_id` (`merchant_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户信息';
-- 商户进件申请
CREATE TABLE IF NOT EXISTS `merchant_application` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`application_id` VARCHAR(32) NOT NULL COMMENT '申请ID',
`merchant_id` VARCHAR(32) NOT NULL COMMENT '商户ID',
`channel_code` VARCHAR(32) NOT NULL,
`submit_data` JSON DEFAULT NULL COMMENT '提交的进件数据',
`audit_status` VARCHAR(20) NOT NULL DEFAULT 'SUBMITTING',
`reject_reason` VARCHAR(512) DEFAULT NULL,
`submitted_at` DATETIME(3) DEFAULT NULL,
`audited_at` DATETIME(3) DEFAULT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_application_id` (`application_id`),
KEY `idx_merchant_id` (`merchant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户进件申请';
-- 微信公众号绑定
CREATE TABLE IF NOT EXISTS `wechat_binding` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`app_id` VARCHAR(32) NOT NULL COMMENT '平台应用ID',
`wx_app_id` VARCHAR(32) NOT NULL COMMENT '微信公众号/小程序AppID',
`wx_secret` TEXT NOT NULL COMMENT '微信AppSecretAES加密',
`template_id` VARCHAR(64) NOT NULL COMMENT '消息模板ID',
`status` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_id` (`app_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号绑定';
-- 微信消息发送日志
CREATE TABLE IF NOT EXISTS `wechat_message_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`app_id` VARCHAR(32) NOT NULL,
`trade_no` VARCHAR(32) DEFAULT NULL,
`open_id` VARCHAR(64) NOT NULL,
`template_id` VARCHAR(64) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING',
`err_msg` VARCHAR(256) DEFAULT NULL,
`sent_at` DATETIME(3) DEFAULT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_app_id` (`app_id`),
KEY `idx_trade_no` (`trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信消息发送日志';
-- 对账报告
CREATE TABLE IF NOT EXISTS `reconciliation_report` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`app_id` VARCHAR(32) NOT NULL,
`channel_code` VARCHAR(32) NOT NULL,
`bill_date` VARCHAR(10) NOT NULL COMMENT '账单日期 yyyy-MM-dd',
`total_count` INT NOT NULL DEFAULT 0 COMMENT '渠道账单总笔数',
`total_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '渠道账单总金额',
`matched_count` INT NOT NULL DEFAULT 0 COMMENT '匹配成功笔数',
`exception_count` INT NOT NULL DEFAULT 0 COMMENT '异常笔数',
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_app_date` (`app_id`, `bill_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对账报告';
-- 对账异常明细
CREATE TABLE IF NOT EXISTS `reconciliation_exception` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`report_id` BIGINT UNSIGNED NOT NULL COMMENT '关联对账报告ID',
`trade_no` VARCHAR(32) DEFAULT NULL,
`channel_bill_no` VARCHAR(64) DEFAULT NULL,
`exception_type` VARCHAR(32) NOT NULL COMMENT 'MISSING_LOCAL/MISSING_CHANNEL/AMOUNT_MISMATCH',
`local_amount` BIGINT NOT NULL DEFAULT 0,
`channel_amount` BIGINT NOT NULL DEFAULT 0,
`remark` VARCHAR(256) DEFAULT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_report_id` (`report_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对账异常明细';

View File

@@ -0,0 +1,19 @@
-- 管理后台用户表
USE pay_bridge;
CREATE TABLE IF NOT EXISTS `admin_user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NOT NULL COMMENT '用户名',
`password_hash` VARCHAR(128) NOT NULL COMMENT 'bcrypt hash',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理后台用户';
-- 默认管理员账号: admin / admin123
-- bcrypt hash of "admin123" with cost=10
INSERT IGNORE INTO `admin_user` (`username`, `password_hash`, `status`)
VALUES ('admin', '$2a$10$MdQLRo6pwF9SyWCYU4TTneJoQL22PxLV4eGzQ0bxnqUKIFNaEQ1YO', 1);

View File

@@ -0,0 +1,4 @@
ALTER TABLE `merchant`
ADD COLUMN `app_id` VARCHAR(32) NOT NULL DEFAULT '' AFTER `merchant_id`;
ALTER TABLE `merchant`
ADD INDEX `idx_merchant_app_id` (`app_id`);

View File

@@ -0,0 +1,5 @@
-- merchant_application 记录渠道审核通过后下发的渠道商户ID
-- 支持同一商户在多个渠道分别进件,每条记录对应一个渠道
ALTER TABLE `merchant_application`
ADD COLUMN `channel_merchant_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `channel_code`,
ADD UNIQUE KEY `uk_merchant_channel` (`merchant_id`, `channel_code`);

BIN
backend/e2e_merchant Executable file

Binary file not shown.

65
backend/go.mod Normal file
View File

@@ -0,0 +1,65 @@
module pay-bridge
go 1.24
require (
github.com/gin-gonic/gin v1.10.0
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.23.0
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.25.12
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

161
backend/go.sum Normal file
View File

@@ -0,0 +1,161 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

BIN
backend/handler.test Executable file

Binary file not shown.

View File

@@ -0,0 +1,373 @@
package handler
import (
"io"
"net/http"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
"pay-bridge/internal/service"
)
// AdminHandler 管理后台接口处理器
type AdminHandler struct {
matchSvc *service.PaymentMatchService
merchantSvc *service.MerchantService
reconSvc *service.ReconciliationService
channelSvc *service.ChannelService
appSvc *service.AppService
}
func NewAdminHandler(
matchSvc *service.PaymentMatchService,
merchantSvc *service.MerchantService,
reconSvc *service.ReconciliationService,
channelSvc *service.ChannelService,
appSvc *service.AppService,
) *AdminHandler {
return &AdminHandler{
matchSvc: matchSvc,
merchantSvc: merchantSvc,
reconSvc: reconSvc,
channelSvc: channelSvc,
appSvc: appSvc,
}
}
// --- 请求结构体 ---
type createAppReq struct {
AppName string `json:"app_name" binding:"required"`
}
type manualBindOrderReq struct {
MatchID uint64 `json:"match_id" binding:"required"`
TradeNo string `json:"trade_no" binding:"required"`
Operator string `json:"operator" binding:"required"`
}
type applyMerchantReq struct {
ChannelCode string `json:"channel_code" binding:"required"`
SubmitData map[string]any `json:"submit_data"`
}
// appVO 应用列表视图(不含加密 secret
type appVO struct {
AppID string `json:"app_id"`
AppName string `json:"app_name"`
Status int8 `json:"status"`
CreatedAt any `json:"created_at"`
UpdatedAt any `json:"updated_at"`
}
// --- 应用管理 ---
// CreateApp 创建下游接入应用
func (h *AdminHandler) CreateApp(c *gin.Context) {
var req createAppReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "10001", err.Error())
return
}
result, err := h.appSvc.CreateApp(c.Request.Context(), req.AppName)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
// 明文 secret 仅在创建时返回一次,之后无法再查看
OK(c, gin.H{
"app_id": result.App.AppID,
"app_name": result.App.AppName,
"app_secret": result.PlainSecret,
"status": result.App.Status,
"created_at": result.App.CreatedAt,
"secret_tip": "请妥善保存 app_secret此后将无法再次查看",
})
}
// ListApps 查询应用列表
func (h *AdminHandler) ListApps(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
apps, err := h.appSvc.ListApps(c.Request.Context(), limit, offset)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
list := make([]appVO, 0, len(apps))
for _, a := range apps {
list = append(list, appVO{
AppID: a.AppID,
AppName: a.AppName,
Status: a.Status,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
})
}
OK(c, gin.H{"list": list, "limit": limit, "offset": offset})
}
// DisableApp 禁用应用
func (h *AdminHandler) DisableApp(c *gin.Context) {
appID := c.Param("appID")
if err := h.appSvc.DisableApp(c.Request.Context(), appID); err != nil {
if err.Error() == "20002" {
c.JSON(http.StatusNotFound, Response{Code: "20002", Message: "应用不存在"})
return
}
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// EnableApp 启用应用
func (h *AdminHandler) EnableApp(c *gin.Context) {
appID := c.Param("appID")
if err := h.appSvc.EnableApp(c.Request.Context(), appID); err != nil {
if err.Error() == "20002" {
c.JSON(http.StatusNotFound, Response{Code: "20002", Message: "应用不存在"})
return
}
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// ResetAppSecret 重置应用密钥
func (h *AdminHandler) ResetAppSecret(c *gin.Context) {
appID := c.Param("appID")
plainSecret, err := h.appSvc.ResetSecret(c.Request.Context(), appID)
if err != nil {
if err.Error() == "20002" {
c.JSON(http.StatusNotFound, Response{Code: "20002", Message: "应用不存在"})
return
}
InternalError(c, "50001", err.Error())
return
}
OK(c, gin.H{
"app_id": appID,
"app_secret": plainSecret,
"secret_tip": "请妥善保存 app_secret此后将无法再次查看",
})
}
// --- 收款匹配管理 ---
// ListPendingMatches 查询待人工确认的收款记录
func (h *AdminHandler) ListPendingMatches(c *gin.Context) {
appID := c.Query("app_id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
logs, err := h.matchSvc.ListPendingManual(c.Request.Context(), appID, limit, offset)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, gin.H{"list": logs, "limit": limit, "offset": offset})
}
// ManualBindOrder 人工关联收款与订单
func (h *AdminHandler) ManualBindOrder(c *gin.Context) {
var req manualBindOrderReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "10001", err.Error())
return
}
if err := h.matchSvc.ManualBindOrder(c.Request.Context(), req.MatchID, req.TradeNo, req.Operator); err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// --- 商户管理 ---
// CreateMerchant 创建商户
func (h *AdminHandler) CreateMerchant(c *gin.Context) {
var m model.Merchant
if err := c.ShouldBindJSON(&m); err != nil {
BadRequest(c, "10001", err.Error())
return
}
if err := h.merchantSvc.CreateMerchant(c.Request.Context(), &m); err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, m)
}
// GetMerchant 查询商户信息
func (h *AdminHandler) GetMerchant(c *gin.Context) {
merchantID := c.Param("merchantID")
m, err := h.merchantSvc.GetMerchant(c.Request.Context(), merchantID)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
if m == nil {
c.JSON(http.StatusNotFound, Response{Code: "30001", Message: "merchant not found"})
return
}
OK(c, m)
}
// ListMerchants 查询商户列表
func (h *AdminHandler) ListMerchants(c *gin.Context) {
status := model.MerchantStatus(c.Query("status"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
merchants, err := h.merchantSvc.ListMerchants(c.Request.Context(), status, limit, offset)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, gin.H{"list": merchants, "limit": limit, "offset": offset})
}
// FreezeMerchant 冻结商户
func (h *AdminHandler) FreezeMerchant(c *gin.Context) {
merchantID := c.Param("merchantID")
if err := h.merchantSvc.FreezeMerchant(c.Request.Context(), merchantID); err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// UnfreezeMerchant 解冻商户
func (h *AdminHandler) UnfreezeMerchant(c *gin.Context) {
merchantID := c.Param("merchantID")
if err := h.merchantSvc.UnfreezeMerchant(c.Request.Context(), merchantID); err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// UploadMerchantFile 上传进件所需文件,返回 file_id
// POST /api/v1/admin/merchant/upload-file
// multipart/form-data: file=<binary>, channel_code=HEEPAY, file_media_type=01
func (h *AdminHandler) UploadMerchantFile(c *gin.Context) {
channelCode := c.PostForm("channel_code")
if channelCode == "" {
channelCode = "HEEPAY"
}
fileMediaType := c.PostForm("file_media_type")
if fileMediaType == "" {
BadRequest(c, "10001", "file_media_type is required")
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
BadRequest(c, "10001", "file is required: "+err.Error())
return
}
f, err := fileHeader.Open()
if err != nil {
InternalError(c, "50001", "open file: "+err.Error())
return
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
InternalError(c, "50001", "read file: "+err.Error())
return
}
fileID, err := h.merchantSvc.UploadFile(c.Request.Context(), channelCode, &channel.UploadFileReq{
FileContent: content,
FileName: filepath.Base(fileHeader.Filename),
FileMediaType: fileMediaType,
})
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, gin.H{"file_id": fileID})
}
// ApplyMerchant 商户进件申请
func (h *AdminHandler) ApplyMerchant(c *gin.Context) {
merchantID := c.Param("merchantID")
var req applyMerchantReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, "10001", err.Error())
return
}
applicationID, err := h.merchantSvc.Apply(c.Request.Context(), merchantID, req.ChannelCode, req.SubmitData)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, gin.H{"application_id": applicationID})
}
// QueryAuditStatus 查询进件审核状态
func (h *AdminHandler) QueryAuditStatus(c *gin.Context) {
merchantID := c.Param("merchantID")
app, err := h.merchantSvc.QueryAuditStatus(c.Request.Context(), merchantID)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, app)
}
// --- 对账管理 ---
// TriggerReconciliation 手动触发对账
func (h *AdminHandler) TriggerReconciliation(c *gin.Context) {
if err := h.reconSvc.RunDailyReconciliation(c.Request.Context()); err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, nil)
}
// GetReconciliationReport 查询对账报告
func (h *AdminHandler) GetReconciliationReport(c *gin.Context) {
appID := c.Query("app_id")
billDate := c.Query("bill_date")
channelCode := c.Query("channel_code")
report, err := h.reconSvc.GetReport(c.Request.Context(), appID, billDate, channelCode)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, report)
}
// GetReconciliationExceptions 查询对账异常明细
func (h *AdminHandler) GetReconciliationExceptions(c *gin.Context) {
reportIDStr := c.Param("reportID")
reportID, err := strconv.ParseUint(reportIDStr, 10, 64)
if err != nil {
BadRequest(c, "10001", "invalid report_id")
return
}
exs, err := h.reconSvc.GetExceptions(c.Request.Context(), reportID)
if err != nil {
InternalError(c, "50001", err.Error())
return
}
OK(c, exs)
}

View File

@@ -0,0 +1,49 @@
package handler
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
)
type adminAuthSvc interface {
Login(ctx context.Context, username, password string) (string, error)
}
type AuthHandler struct {
authSvc adminAuthSvc
}
func NewAuthHandler(authSvc adminAuthSvc) *AuthHandler {
return &AuthHandler{authSvc: authSvc}
}
type loginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
func (h *AuthHandler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "400", "message": "参数错误"})
return
}
token, err := h.authSvc.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": "UNAUTHORIZED", "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": "0",
"message": "ok",
"data": gin.H{"token": token},
})
}
func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": "0", "message": "ok"})
}

View File

@@ -0,0 +1,188 @@
package handler
import (
"context"
"io"
"net/http"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
"pay-bridge/internal/channel"
"pay-bridge/internal/errcode"
"pay-bridge/internal/model"
)
// merchantService 定义 MerchantHandler 依赖的 service 方法,便于测试时注入 mock
type merchantService interface {
CreateMerchantForApp(ctx context.Context, appID string, m *model.Merchant) error
GetMerchantForApp(ctx context.Context, appID, merchantID string) (*model.Merchant, error)
ListMerchantsForApp(ctx context.Context, appID string, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error)
ApplyForApp(ctx context.Context, appID, merchantID, channelCode string, bizContent map[string]any) (string, error)
QueryAuditStatusForApp(ctx context.Context, appID, merchantID string) (*model.MerchantApplication, error)
UploadFile(ctx context.Context, channelCode string, req *channel.UploadFileReq) (string, error)
}
// MerchantHandler 业务侧商户进件接口处理器HMAC 鉴权appID 隔离)
type MerchantHandler struct {
merchantSvc merchantService
}
func NewMerchantHandler(svc merchantService) *MerchantHandler {
return &MerchantHandler{merchantSvc: svc}
}
// --- 请求结构体 ---
type createMerchantReq struct {
MerchantID string `json:"merchant_id" binding:"required"`
MerchantName string `json:"merchant_name" binding:"required"`
LicenseNo string `json:"license_no"`
LegalPerson string `json:"legal_person"`
BankAccount string `json:"bank_account"`
}
type merchantApplyReq struct {
ChannelCode string `json:"channel_code" binding:"required"`
SubmitData map[string]any `json:"submit_data"`
}
// --- Handlers ---
// CreateMerchant POST /api/v1/merchant
func (h *MerchantHandler) CreateMerchant(c *gin.Context) {
var req createMerchantReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, errcode.ErrInvalidParam, err.Error())
return
}
appID := c.GetString("app_id")
m := &model.Merchant{
MerchantID: req.MerchantID,
MerchantName: req.MerchantName,
LicenseNo: req.LicenseNo,
LegalPerson: req.LegalPerson,
BankAccount: req.BankAccount,
}
if err := h.merchantSvc.CreateMerchantForApp(c.Request.Context(), appID, m); err != nil {
InternalError(c, errcode.ErrInternalDB, err.Error())
return
}
OK(c, gin.H{"merchant_id": m.MerchantID})
}
// ListMerchants GET /api/v1/merchant
func (h *MerchantHandler) ListMerchants(c *gin.Context) {
appID := c.GetString("app_id")
status := model.MerchantStatus(c.Query("status"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
merchants, err := h.merchantSvc.ListMerchantsForApp(c.Request.Context(), appID, status, limit, offset)
if err != nil {
InternalError(c, errcode.ErrInternalDB, err.Error())
return
}
OK(c, gin.H{"list": merchants, "limit": limit, "offset": offset})
}
// GetMerchant GET /api/v1/merchant/:merchantID
func (h *MerchantHandler) GetMerchant(c *gin.Context) {
appID := c.GetString("app_id")
merchantID := c.Param("merchantID")
m, err := h.merchantSvc.GetMerchantForApp(c.Request.Context(), appID, merchantID)
if err != nil {
if err.Error() == errcode.ErrOrderNotFound {
c.JSON(http.StatusNotFound, Response{Code: errcode.ErrOrderNotFound, Message: "merchant not found"})
return
}
InternalError(c, errcode.ErrInternalDB, err.Error())
return
}
OK(c, m)
}
// UploadFile POST /api/v1/merchant/upload-file
func (h *MerchantHandler) UploadFile(c *gin.Context) {
channelCode := c.PostForm("channel_code")
if channelCode == "" {
channelCode = "HEEPAY"
}
fileMediaType := c.PostForm("file_media_type")
if fileMediaType == "" {
BadRequest(c, errcode.ErrInvalidParam, "file_media_type is required")
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
BadRequest(c, errcode.ErrInvalidParam, "file is required: "+err.Error())
return
}
f, err := fileHeader.Open()
if err != nil {
InternalError(c, errcode.ErrInternalSystem, "open file: "+err.Error())
return
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
InternalError(c, errcode.ErrInternalSystem, "read file: "+err.Error())
return
}
fileID, err := h.merchantSvc.UploadFile(c.Request.Context(), channelCode, &channel.UploadFileReq{
FileContent: content,
FileName: filepath.Base(fileHeader.Filename),
FileMediaType: fileMediaType,
})
if err != nil {
InternalError(c, errcode.ErrInternalSystem, err.Error())
return
}
OK(c, gin.H{"file_id": fileID})
}
// Apply POST /api/v1/merchant/:merchantID/apply
func (h *MerchantHandler) Apply(c *gin.Context) {
appID := c.GetString("app_id")
merchantID := c.Param("merchantID")
var req merchantApplyReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, errcode.ErrInvalidParam, err.Error())
return
}
applicationID, err := h.merchantSvc.ApplyForApp(c.Request.Context(), appID, merchantID, req.ChannelCode, req.SubmitData)
if err != nil {
if err.Error() == errcode.ErrOrderNotFound {
c.JSON(http.StatusNotFound, Response{Code: errcode.ErrOrderNotFound, Message: "merchant not found"})
return
}
InternalError(c, errcode.ErrInternalSystem, err.Error())
return
}
OK(c, gin.H{"application_id": applicationID})
}
// QueryAuditStatus GET /api/v1/merchant/:merchantID/audit
func (h *MerchantHandler) QueryAuditStatus(c *gin.Context) {
appID := c.GetString("app_id")
merchantID := c.Param("merchantID")
app, err := h.merchantSvc.QueryAuditStatusForApp(c.Request.Context(), appID, merchantID)
if err != nil {
if err.Error() == errcode.ErrOrderNotFound {
c.JSON(http.StatusNotFound, Response{Code: errcode.ErrOrderNotFound, Message: "merchant not found"})
return
}
InternalError(c, errcode.ErrInternalSystem, err.Error())
return
}
OK(c, app)
}

View File

@@ -0,0 +1,216 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
)
func init() {
gin.SetMode(gin.TestMode)
}
// mockMerchantSvc 实现 merchantService interface
type mockMerchantSvc struct {
mock.Mock
}
func (m *mockMerchantSvc) CreateMerchantForApp(ctx context.Context, appID string, merchant *model.Merchant) error {
return m.Called(ctx, appID, merchant).Error(0)
}
func (m *mockMerchantSvc) GetMerchantForApp(ctx context.Context, appID, merchantID string) (*model.Merchant, error) {
args := m.Called(ctx, appID, merchantID)
v, _ := args.Get(0).(*model.Merchant)
return v, args.Error(1)
}
func (m *mockMerchantSvc) ListMerchantsForApp(ctx context.Context, appID string, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error) {
args := m.Called(ctx, appID, status, limit, offset)
return args.Get(0).([]*model.Merchant), args.Error(1)
}
func (m *mockMerchantSvc) ApplyForApp(ctx context.Context, appID, merchantID, channelCode string, bizContent map[string]any) (string, error) {
args := m.Called(ctx, appID, merchantID, channelCode, bizContent)
return args.String(0), args.Error(1)
}
func (m *mockMerchantSvc) QueryAuditStatusForApp(ctx context.Context, appID, merchantID string) (*model.MerchantApplication, error) {
args := m.Called(ctx, appID, merchantID)
v, _ := args.Get(0).(*model.MerchantApplication)
return v, args.Error(1)
}
func (m *mockMerchantSvc) UploadFile(ctx context.Context, channelCode string, req *channel.UploadFileReq) (string, error) {
args := m.Called(ctx, channelCode, req)
return args.String(0), args.Error(1)
}
// newMerchantTestRouter 构建测试路由,注入固定 app_id 模拟鉴权
func newMerchantTestRouter(svc *mockMerchantSvc) *gin.Engine {
r := gin.New()
h := &MerchantHandler{merchantSvc: svc}
auth := func(c *gin.Context) {
c.Set("app_id", "app_test")
c.Next()
}
g := r.Group("/api/v1/merchant", auth)
g.POST("", h.CreateMerchant)
g.GET("", h.ListMerchants)
g.GET("/:merchantID", h.GetMerchant)
g.POST("/:merchantID/apply", h.Apply)
g.GET("/:merchantID/audit", h.QueryAuditStatus)
return r
}
// --- CreateMerchant ---
func TestCreateMerchant_OK(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("CreateMerchantForApp", mock.Anything, "app_test", mock.MatchedBy(func(m *model.Merchant) bool {
return m.MerchantID == "m001" && m.MerchantName == "测试公司"
})).Return(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant",
strings.NewReader(`{"merchant_id":"m001","merchant_name":"测试公司"}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, "0", resp["code"])
assert.Equal(t, "m001", resp["data"].(map[string]any)["merchant_id"])
svc.AssertExpectations(t)
}
func TestCreateMerchant_MissingName(t *testing.T) {
svc := new(mockMerchantSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant",
strings.NewReader(`{"merchant_id":"m001"}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
svc.AssertNotCalled(t, "CreateMerchantForApp")
}
func TestCreateMerchant_MissingID(t *testing.T) {
svc := new(mockMerchantSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant",
strings.NewReader(`{"merchant_name":"测试公司"}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// --- GetMerchant ---
func TestGetMerchant_OK(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("GetMerchantForApp", mock.Anything, "app_test", "m001").
Return(&model.Merchant{MerchantID: "m001", AppID: "app_test"}, nil)
w := httptest.NewRecorder()
newMerchantTestRouter(svc).ServeHTTP(w,
httptest.NewRequest(http.MethodGet, "/api/v1/merchant/m001", nil))
assert.Equal(t, http.StatusOK, w.Code)
svc.AssertExpectations(t)
}
func TestGetMerchant_NotFound(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("GetMerchantForApp", mock.Anything, "app_test", "m999").
Return((*model.Merchant)(nil), errors.New("30001"))
w := httptest.NewRecorder()
newMerchantTestRouter(svc).ServeHTTP(w,
httptest.NewRequest(http.MethodGet, "/api/v1/merchant/m999", nil))
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestGetMerchant_WrongApp(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("GetMerchantForApp", mock.Anything, "app_test", "other_m").
Return((*model.Merchant)(nil), errors.New("30001"))
w := httptest.NewRecorder()
newMerchantTestRouter(svc).ServeHTTP(w,
httptest.NewRequest(http.MethodGet, "/api/v1/merchant/other_m", nil))
// 跨 app 访问应返回 404而不是 403避免信息泄露
assert.Equal(t, http.StatusNotFound, w.Code)
}
// --- ListMerchants ---
func TestListMerchants_DefaultPagination(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("ListMerchantsForApp", mock.Anything, "app_test", model.MerchantStatus(""), 20, 0).
Return([]*model.Merchant{}, nil)
w := httptest.NewRecorder()
newMerchantTestRouter(svc).ServeHTTP(w,
httptest.NewRequest(http.MethodGet, "/api/v1/merchant", nil))
assert.Equal(t, http.StatusOK, w.Code)
svc.AssertExpectations(t)
}
// --- Apply ---
func TestApply_OK(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("ApplyForApp", mock.Anything, "app_test", "m001", "HEEPAY", mock.Anything).
Return("APP123", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant/m001/apply",
strings.NewReader(`{"channel_code":"HEEPAY","submit_data":{"name":"测试公司"}}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, "APP123", resp["data"].(map[string]any)["application_id"])
}
func TestApply_MissingChannelCode(t *testing.T) {
svc := new(mockMerchantSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant/m001/apply",
strings.NewReader(`{"submit_data":{}}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
svc.AssertNotCalled(t, "ApplyForApp")
}
func TestApply_MerchantNotBelongToApp(t *testing.T) {
svc := new(mockMerchantSvc)
svc.On("ApplyForApp", mock.Anything, "app_test", "m_other", "HEEPAY", mock.Anything).
Return("", errors.New("30001"))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/merchant/m_other/apply",
strings.NewReader(`{"channel_code":"HEEPAY"}`))
req.Header.Set("Content-Type", "application/json")
newMerchantTestRouter(svc).ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}

View File

@@ -0,0 +1,242 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"pay-bridge/internal/errcode"
"pay-bridge/internal/model"
"pay-bridge/internal/service"
)
// PayHandler 支付相关 Handler
type PayHandler struct {
tradeSvc *service.TradeService
refundSvc *service.RefundService
}
func NewPayHandler(tradeSvc *service.TradeService, refundSvc *service.RefundService) *PayHandler {
return &PayHandler{tradeSvc: tradeSvc, refundSvc: refundSvc}
}
type unifiedOrderReq struct {
ChannelCode string `json:"channel_code"`
MerchantOrderNo string `json:"merchant_order_no" binding:"required"`
PayMethod model.PayMethod `json:"pay_method" binding:"required"`
Amount int64 `json:"amount" binding:"required,min=1"`
ProfitSharingAmount int64 `json:"profit_sharing_amount"`
Subject string `json:"subject" binding:"required"`
NotifyURL string `json:"notify_url" binding:"required,url"`
ExpireMinutes int `json:"expire_minutes"`
Extra map[string]any `json:"extra"`
MerchantID string `json:"merchant_id"` // 可选,指定收款商户
}
type closeOrderReq struct {
TradeNo string `json:"trade_no" binding:"required"`
}
type refundReq struct {
TradeNo string `json:"trade_no" binding:"required"`
RefundAmount int64 `json:"refund_amount" binding:"required,min=1"`
Reason string `json:"reason"`
NotifyURL string `json:"notify_url"`
}
// UnifiedOrder POST /api/v1/pay/unified-order
func (h *PayHandler) UnifiedOrder(c *gin.Context) {
var req unifiedOrderReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, errcode.ErrInvalidParam, err.Error())
return
}
appID := c.GetString("app_id")
resp, err := h.tradeSvc.CreateOrder(c.Request.Context(), &service.CreateOrderReq{
AppID: appID,
ChannelCode: req.ChannelCode,
MerchantOrderNo: req.MerchantOrderNo,
PayMethod: req.PayMethod,
Amount: req.Amount,
ProfitSharingAmount: req.ProfitSharingAmount,
Subject: req.Subject,
NotifyURL: req.NotifyURL,
ExpireMinutes: req.ExpireMinutes,
Extra: req.Extra,
MerchantID: req.MerchantID,
})
if err != nil {
handleBizError(c, err)
return
}
OK(c, gin.H{
"trade_no": resp.TradeNo,
"pay_credential": resp.PayCredential,
"is_idempotent": resp.IsIdempotent,
})
}
// QueryOrder GET /api/v1/pay/query/:tradeNo
func (h *PayHandler) QueryOrder(c *gin.Context) {
tradeNo := c.Param("tradeNo")
if tradeNo == "" {
BadRequest(c, errcode.ErrMissingParam, "trade_no is required")
return
}
appID := c.GetString("app_id")
order, err := h.tradeSvc.QueryOrder(c.Request.Context(), appID, tradeNo)
if err != nil {
handleBizError(c, err)
return
}
OK(c, gin.H{
"trade_no": order.TradeNo,
"merchant_order_no": order.MerchantOrderNo,
"pay_method": order.PayMethod,
"amount": order.Amount,
"status": order.Status,
"channel_trade_no": order.ChannelTradeNo,
"pay_time": order.PayTime,
"created_at": order.CreatedAt,
})
}
// CloseOrder POST /api/v1/pay/close
func (h *PayHandler) CloseOrder(c *gin.Context) {
var req closeOrderReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, errcode.ErrInvalidParam, err.Error())
return
}
appID := c.GetString("app_id")
if err := h.tradeSvc.CloseOrder(c.Request.Context(), appID, req.TradeNo); err != nil {
handleBizError(c, err)
return
}
OK(c, nil)
}
// Refund POST /api/v1/pay/refund
func (h *PayHandler) Refund(c *gin.Context) {
var req refundReq
if err := c.ShouldBindJSON(&req); err != nil {
BadRequest(c, errcode.ErrInvalidParam, err.Error())
return
}
appID := c.GetString("app_id")
refund, err := h.refundSvc.CreateRefund(c.Request.Context(), &service.CreateRefundReq{
AppID: appID,
TradeNo: req.TradeNo,
RefundAmount: req.RefundAmount,
Reason: req.Reason,
NotifyURL: req.NotifyURL,
})
if err != nil {
handleBizError(c, err)
return
}
OK(c, gin.H{
"refund_no": refund.RefundNo,
"trade_no": refund.TradeNo,
"refund_amount": refund.RefundAmount,
"status": refund.Status,
"channel_refund_no": refund.ChannelRefundNo,
})
}
// QueryRefund GET /api/v1/pay/refund/query/:refundNo
func (h *PayHandler) QueryRefund(c *gin.Context) {
refundNo := c.Param("refundNo")
if refundNo == "" {
BadRequest(c, errcode.ErrMissingParam, "refund_no is required")
return
}
appID := c.GetString("app_id")
refund, err := h.refundSvc.QueryRefund(c.Request.Context(), appID, refundNo)
if err != nil {
handleBizError(c, err)
return
}
OK(c, gin.H{
"refund_no": refund.RefundNo,
"trade_no": refund.TradeNo,
"refund_amount": refund.RefundAmount,
"status": refund.Status,
"channel_refund_no": refund.ChannelRefundNo,
"refund_time": refund.RefundTime,
})
}
// handleBizError 将业务错误映射到 HTTP 响应
func handleBizError(c *gin.Context, err error) {
code := err.Error()
msg := errcode.Message(code)
if msg == "未知错误" {
msg = err.Error()
code = errcode.ErrInternalSystem
}
switch code {
case errcode.ErrInvalidParam, errcode.ErrMissingParam, errcode.ErrInvalidPayMethod, errcode.ErrInvalidAmount:
BadRequest(c, code, msg)
case errcode.ErrUnauthorized, errcode.ErrAppNotFound:
Unauthorized(c, code, msg)
case errcode.ErrPermissionDenied:
Forbidden(c, code, msg)
case errcode.ErrOrderNotFound, errcode.ErrOrderAlreadyPaid, errcode.ErrOrderClosed,
errcode.ErrRefundAmountExceed, errcode.ErrSharingAmountExceed, errcode.ErrOrderNotPaid,
errcode.ErrSharingNotConfig, errcode.ErrSharingFeeExceed, errcode.ErrRefundNotFound:
UnprocessableEntity(c, code, msg)
case errcode.ErrChannelCreateFail, errcode.ErrChannelRefundFail,
errcode.ErrChannelTimeout, errcode.ErrChannelNotSupport, errcode.ErrChannelVerifyFail:
BadGateway(c, code, msg)
default:
_ = errors.New(code) // suppress unused
InternalError(c, errcode.ErrInternalSystem, errcode.Message(errcode.ErrInternalSystem))
}
}
// NotifyHandler 渠道回调 Handler
type NotifyHandler struct {
tradeSvc *service.TradeService
}
func NewNotifyHandler(tradeSvc *service.TradeService) *NotifyHandler {
return &NotifyHandler{tradeSvc: tradeSvc}
}
// PaymentCallback POST /api/v1/notify/payment/:channelCode
func (h *NotifyHandler) PaymentCallback(c *gin.Context) {
channelCode := c.Param("channelCode")
var rawBody []byte
if v, exists := c.Get("raw_body"); exists {
rawBody, _ = v.([]byte)
}
result, err := h.tradeSvc.HandleUpstreamNotify(c.Request.Context(), channelCode, rawBody, headersMap(c))
if err != nil {
c.String(http.StatusOK, "fail")
return
}
c.String(http.StatusOK, result)
}
func headersMap(c *gin.Context) map[string]string {
m := make(map[string]string)
for k, v := range c.Request.Header {
if len(v) > 0 {
m[k] = v[0]
}
}
return m
}

View File

@@ -0,0 +1,73 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Response 统一响应格式
type Response struct {
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
TraceID string `json:"trace_id,omitempty"`
}
// OK 成功响应
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, Response{
Code: "0",
Message: "success",
Data: data,
TraceID: traceID(c),
})
}
// Fail 失败响应
func Fail(c *gin.Context, httpStatus int, code, message string) {
c.JSON(httpStatus, Response{
Code: code,
Message: message,
TraceID: traceID(c),
})
}
// BadRequest 400
func BadRequest(c *gin.Context, code, message string) {
Fail(c, http.StatusBadRequest, code, message)
}
// Unauthorized 401
func Unauthorized(c *gin.Context, code, message string) {
Fail(c, http.StatusUnauthorized, code, message)
}
// Forbidden 403
func Forbidden(c *gin.Context, code, message string) {
Fail(c, http.StatusForbidden, code, message)
}
// UnprocessableEntity 422业务规则错误
func UnprocessableEntity(c *gin.Context, code, message string) {
Fail(c, http.StatusUnprocessableEntity, code, message)
}
// InternalError 500
func InternalError(c *gin.Context, code, message string) {
Fail(c, http.StatusInternalServerError, code, message)
}
// BadGateway 502渠道错误
func BadGateway(c *gin.Context, code, message string) {
Fail(c, http.StatusBadGateway, code, message)
}
func traceID(c *gin.Context) string {
if id, exists := c.Get("trace_id"); exists {
if s, ok := id.(string); ok {
return s
}
}
return ""
}

View File

@@ -0,0 +1,99 @@
package middleware
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"pay-bridge/internal/api/handler"
"pay-bridge/internal/errcode"
)
// AppLoader 根据 appId 加载 app 信息的接口
type AppLoader interface {
GetAppSecret(ctx context.Context, appID string) (string, error)
}
// Auth 鉴权中间件
// 请求头X-App-Id、X-Timestamp、X-Sign
// 签名算法HMAC-SHA256(appId + timestamp + body, appSecret)
func Auth(loader AppLoader) gin.HandlerFunc {
return func(c *gin.Context) {
appID := c.GetHeader("X-App-Id")
timestamp := c.GetHeader("X-Timestamp")
sign := c.GetHeader("X-Sign")
if appID == "" || timestamp == "" || sign == "" {
handler.Unauthorized(c, errcode.ErrUnauthorized, errcode.Message(errcode.ErrUnauthorized))
c.Abort()
return
}
// 时间戳防重放5分钟内有效
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || abs(time.Now().Unix()-ts) > 300 {
handler.Unauthorized(c, errcode.ErrUnauthorized, "请求已过期")
c.Abort()
return
}
appSecret, err := loader.GetAppSecret(c.Request.Context(), appID)
if err != nil {
handler.Unauthorized(c, errcode.ErrAppNotFound, errcode.Message(errcode.ErrAppNotFound))
c.Abort()
return
}
// 读取 body注意body 只能读一次,需要提前 cache
body := bodyFromContext(c)
expectedSign := sign256(appID+timestamp+string(body), appSecret)
if !hmac.Equal([]byte(expectedSign), []byte(sign)) {
handler.Unauthorized(c, errcode.ErrUnauthorized, errcode.Message(errcode.ErrUnauthorized))
c.Abort()
return
}
c.Set("app_id", appID)
c.Next()
}
}
// ChannelCallback 渠道回调鉴权(由渠道适配器验签,此中间件只做基础检查)
func ChannelCallback() gin.HandlerFunc {
return func(c *gin.Context) {
channelCode := c.Param("channelCode")
if channelCode == "" {
c.AbortWithStatus(http.StatusBadRequest)
return
}
c.Next()
}
}
func sign256(payload, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(payload))
return hex.EncodeToString(h.Sum(nil))
}
func abs(n int64) int64 {
if n < 0 {
return -n
}
return n
}
func bodyFromContext(c *gin.Context) []byte {
if v, exists := c.Get("raw_body"); exists {
if b, ok := v.([]byte); ok {
return b
}
}
return nil
}

View File

@@ -0,0 +1,21 @@
package middleware
import (
"bytes"
"io"
"github.com/gin-gonic/gin"
)
// CacheBody 缓存 request bodybody 只能读一次,中间件提前读取并缓存)
func CacheBody() gin.HandlerFunc {
return func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
body = []byte{}
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
c.Set("raw_body", body)
c.Next()
}
}

View File

@@ -0,0 +1,48 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// TokenParser 解析 JWT token 的接口
type TokenParser interface {
ParseToken(tokenStr string) (string, error)
}
// JWTAuth 管理后台 JWT 鉴权中间件
func JWTAuth(parser TokenParser) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": "401",
"message": "未登录,请先登录",
})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": "401",
"message": "Token 格式错误",
})
return
}
username, err := parser.ParseToken(parts[1])
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": "401",
"message": "Token 无效或已过期",
})
return
}
c.Set("username", username)
c.Next()
}
}

View File

@@ -0,0 +1,23 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"github.com/gin-gonic/gin"
)
// Trace 注入 trace_id 中间件
func Trace() gin.HandlerFunc {
return func(c *gin.Context) {
traceID := c.GetHeader("X-Trace-Id")
if traceID == "" {
b := make([]byte, 8)
_, _ = rand.Read(b)
traceID = hex.EncodeToString(b)
}
c.Set("trace_id", traceID)
c.Header("X-Trace-Id", traceID)
c.Next()
}
}

View File

@@ -0,0 +1,107 @@
package api
import (
"github.com/gin-gonic/gin"
"pay-bridge/internal/api/handler"
"pay-bridge/internal/api/middleware"
"pay-bridge/internal/app"
)
// SetupRouter 注册所有路由
func SetupRouter(a *app.App) *gin.Engine {
payHandler := handler.NewPayHandler(a.TradeSvc, a.RefundSvc)
notifyHandler := handler.NewNotifyHandler(a.TradeSvc)
adminHandler := handler.NewAdminHandler(a.MatchSvc, a.MerchantSvc, a.ReconSvc, a.ChannelSvc, a.AppSvc)
authHandler := handler.NewAuthHandler(a.AdminAuthSvc)
merchantHandler := handler.NewMerchantHandler(a.MerchantSvc)
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.Trace())
r.Use(middleware.CacheBody())
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// 上游渠道回调(渠道验签,不走 appId 鉴权)
notify := r.Group("/api/v1/notify")
{
notify.POST("/payment/:channelCode", notifyHandler.PaymentCallback)
}
// 下游系统调用接口appId+appSecret 签名鉴权)
v1 := r.Group("/api/v1", middleware.Auth(a.AppSvc))
{
pay := v1.Group("/pay")
{
pay.POST("/unified-order", payHandler.UnifiedOrder)
pay.GET("/query/:tradeNo", payHandler.QueryOrder)
pay.POST("/close", payHandler.CloseOrder)
pay.POST("/refund", payHandler.Refund)
pay.GET("/refund/query/:refundNo", payHandler.QueryRefund)
}
merchantGroup := v1.Group("/merchant")
{
merchantGroup.POST("", merchantHandler.CreateMerchant)
merchantGroup.GET("", merchantHandler.ListMerchants)
merchantGroup.POST("/upload-file", merchantHandler.UploadFile)
merchantGroup.GET("/:merchantID", merchantHandler.GetMerchant)
merchantGroup.POST("/:merchantID/apply", merchantHandler.Apply)
merchantGroup.GET("/:merchantID/audit", merchantHandler.QueryAuditStatus)
}
}
// 管理后台接口
adminPublic := r.Group("/api/v1/admin")
{
adminPublic.POST("/login", authHandler.Login)
}
admin := r.Group("/api/v1/admin", middleware.JWTAuth(a.AdminAuthSvc))
{
admin.POST("/logout", authHandler.Logout)
// 应用管理
appGroup := admin.Group("/app")
{
appGroup.POST("", adminHandler.CreateApp)
appGroup.GET("", adminHandler.ListApps)
appGroup.POST("/:appID/disable", adminHandler.DisableApp)
appGroup.POST("/:appID/enable", adminHandler.EnableApp)
appGroup.POST("/:appID/reset-secret", adminHandler.ResetAppSecret)
}
// 收款匹配
match := admin.Group("/match")
{
match.GET("/pending", adminHandler.ListPendingMatches)
match.POST("/bind", adminHandler.ManualBindOrder)
}
// 商户管理
merchant := admin.Group("/merchant")
{
merchant.POST("", adminHandler.CreateMerchant)
merchant.GET("", adminHandler.ListMerchants)
merchant.POST("/upload-file", adminHandler.UploadMerchantFile)
merchant.GET("/:merchantID", adminHandler.GetMerchant)
merchant.POST("/:merchantID/freeze", adminHandler.FreezeMerchant)
merchant.POST("/:merchantID/unfreeze", adminHandler.UnfreezeMerchant)
merchant.POST("/:merchantID/apply", adminHandler.ApplyMerchant)
merchant.GET("/:merchantID/audit", adminHandler.QueryAuditStatus)
}
// 对账管理
recon := admin.Group("/reconciliation")
{
recon.POST("/trigger", adminHandler.TriggerReconciliation)
recon.GET("/report", adminHandler.GetReconciliationReport)
recon.GET("/report/:reportID/exceptions", adminHandler.GetReconciliationExceptions)
}
}
return r
}

126
backend/internal/app/app.go Normal file
View File

@@ -0,0 +1,126 @@
package app
import (
"context"
"log/slog"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
"pay-bridge/internal/repository"
"pay-bridge/internal/service"
"pay-bridge/pkg/config"
"pay-bridge/pkg/sequence"
)
// App 应用容器,持有所有初始化完成的 service 实例
type App struct {
Cfg *config.Config
// 对外暴露供 router 使用的 service
AdminAuthSvc *service.AdminAuthService
AppSvc *service.AppService
TradeSvc *service.TradeService
RefundSvc *service.RefundService
NotifySvc *service.NotifyService
MatchSvc *service.PaymentMatchService
MerchantSvc *service.MerchantService
ReconSvc *service.ReconciliationService
ChannelSvc *service.ChannelService
// 内部资源
db *gorm.DB
rdb *redis.Client
}
// New 初始化所有基础设施和 service返回就绪的 App 实例
func New(cfg *config.Config) (*App, error) {
a := &App{Cfg: cfg}
if err := a.initInfra(); err != nil {
return nil, err
}
a.initServices()
return a, nil
}
// Start 启动后台任务notify poller 等)
func (a *App) Start(ctx context.Context) {
a.NotifySvc.StartPoller(ctx, a.Cfg.Notify.PollerInterval, a.Cfg.Notify.PollerBatch)
}
// Shutdown 优雅关闭:关闭 DB 和 Redis 连接
func (a *App) Shutdown(ctx context.Context) {
if a.rdb != nil {
if err := a.rdb.Close(); err != nil {
slog.Error("redis close error", "err", err)
}
}
if a.db != nil {
sqlDB, err := a.db.DB()
if err == nil {
if err := sqlDB.Close(); err != nil {
slog.Error("db close error", "err", err)
}
}
}
}
// initInfra 初始化 DB 和 Redis
func (a *App) initInfra() error {
db, err := config.NewDB(a.Cfg.Database)
if err != nil {
return err
}
a.db = db
rdb, err := config.NewRedis(a.Cfg.Redis)
if err != nil {
return err
}
a.rdb = rdb
return nil
}
// initServices 按依赖顺序构建 repo → service
func (a *App) initServices() {
encKey := a.Cfg.Security.FieldEncryptKey
// Repositories
adminUserRepo := repository.NewAdminUserRepository(a.db)
appRepo := repository.NewAppRepository(a.db)
tradeRepo := repository.NewTradeOrderRepository(a.db)
refundRepo := repository.NewRefundOrderRepository(a.db)
notifyRepo := repository.NewNotifyLogRepository(a.db)
channelCfgRepo := repository.NewChannelConfigRepository(a.db)
seqRepo := repository.NewSequenceRepository(a.db)
profitSharingRepo := repository.NewProfitSharingRepository(a.db)
serviceFeeRepo := repository.NewServiceFeeRepository(a.db)
matchRepo := repository.NewPaymentMatchRepository(a.db)
merchantRepo := repository.NewMerchantRepository(a.db)
wechatRepo := repository.NewWechatRepository(a.db)
reconRepo := repository.NewReconciliationRepository(a.db)
// JWT expire hours
jwtExpireHours := a.Cfg.JWT.ExpireHours
if jwtExpireHours == 0 {
jwtExpireHours = 24
}
// Services
a.AdminAuthSvc = service.NewAdminAuthService(adminUserRepo, a.Cfg.JWT.Secret, jwtExpireHours)
a.ChannelSvc = service.NewChannelService(channelCfgRepo, encKey, a.Cfg.Channels)
seqSvc := sequence.NewService(seqRepo)
a.AppSvc = service.NewAppService(appRepo, encKey)
a.NotifySvc = service.NewNotifyService(notifyRepo, tradeRepo, a.Cfg.Notify.HTTPTimeout)
a.MerchantSvc = service.NewMerchantService(merchantRepo, a.ChannelSvc)
a.TradeSvc = service.NewTradeService(tradeRepo, a.ChannelSvc, seqSvc, a.rdb, a.NotifySvc, a.MerchantSvc)
a.RefundSvc = service.NewRefundService(refundRepo, tradeRepo, a.ChannelSvc, seqSvc, a.NotifySvc)
a.MatchSvc = service.NewPaymentMatchService(matchRepo, tradeRepo, a.NotifySvc, a.TradeSvc)
a.ReconSvc = service.NewReconciliationService(reconRepo, tradeRepo, a.ChannelSvc, appRepo)
// 以下 service 目前未直接暴露给 router但已初始化供将来扩展使用
_ = service.NewProfitSharingService(profitSharingRepo, tradeRepo, a.ChannelSvc, seqSvc, a.rdb)
_ = service.NewServiceFeeService(serviceFeeRepo, tradeRepo, a.ChannelSvc)
_ = service.NewWechatService(wechatRepo, encKey)
}

View File

@@ -0,0 +1,65 @@
package channel
import (
"errors"
"sync"
"pay-bridge/internal/model"
)
var (
ErrChannelNotFound = errors.New("channel not found")
ErrNotSupported = errors.New("operation not supported by this channel")
)
var globalRegistry = &Registry{}
// Registry 渠道注册表
type Registry struct {
mu sync.RWMutex
factories map[string]ChannelFactory
}
// Register 注册渠道工厂(供各渠道包在 init() 中调用)
func Register(channelCode string, factory ChannelFactory) {
globalRegistry.Register(channelCode, factory)
}
func (r *Registry) Register(channelCode string, factory ChannelFactory) {
r.mu.Lock()
defer r.mu.Unlock()
if r.factories == nil {
r.factories = make(map[string]ChannelFactory)
}
r.factories[channelCode] = factory
}
// Get 根据渠道码、商户配置和网关地址获取渠道实例
func Get(channelCode string, config *model.ChannelConfig, urls URLs) (PaymentChannel, error) {
return globalRegistry.Get(channelCode, config, urls)
}
func (r *Registry) Get(channelCode string, config *model.ChannelConfig, urls URLs) (PaymentChannel, error) {
r.mu.RLock()
factory, ok := r.factories[channelCode]
r.mu.RUnlock()
if !ok {
return nil, ErrChannelNotFound
}
return factory(config, urls), nil
}
// List 列出已注册的渠道码
func List() []string {
return globalRegistry.List()
}
func (r *Registry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
codes := make([]string, 0, len(r.factories))
for code := range r.factories {
codes = append(codes, code)
}
return codes
}

View File

@@ -0,0 +1,597 @@
package heepay
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
)
const (
ChannelCode = "HEEPAY"
codeSuccess = "10000"
)
// cst 中国标准时间UTC+8避免依赖系统 time.Local
var cst = time.FixedZone("CST", 8*3600)
// Adapter 汇元支付适配器
type Adapter struct {
config *model.ChannelConfig
payURL string // 支付网关地址
merchantURL string // 进件网关地址
client *http.Client
}
func New(config *model.ChannelConfig, urls channel.URLs) channel.PaymentChannel {
return &Adapter{
config: config,
payURL: urls.PayURL,
merchantURL: urls.MerchantURL,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func init() {
channel.Register(ChannelCode, New)
}
func (a *Adapter) Code() string { return ChannelCode }
// CreateOrder 统一下单
func (a *Adapter) CreateOrder(ctx context.Context, req *channel.CreateOrderReq) (*channel.CreateOrderResp, error) {
tradeType, err := mapPayMethod(req.PayMethod)
if err != nil {
return nil, err
}
biz := map[string]any{
"out_trade_no": req.TradeNo,
"body": req.Subject,
"total_fee": req.Amount,
"notify_url": req.NotifyURL,
"trade_type": tradeType,
}
if req.Extra != nil {
if openid, ok := req.Extra["openid"].(string); ok {
biz["openid"] = openid
}
if subAppID, ok := req.Extra["sub_appid"].(string); ok {
biz["sub_appid"] = subAppID
}
}
resp, err := a.post(ctx, "pay.heepay.trade.create", biz)
if err != nil {
return nil, err
}
channelTradeNo, _ := resp["transaction_id"].(string)
if channelTradeNo == "" {
channelTradeNo, _ = resp["prepay_id"].(string)
}
raw, _ := json.Marshal(resp)
return &channel.CreateOrderResp{
ChannelTradeNo: channelTradeNo,
PayCredential: resp,
RawResponse: raw,
}, nil
}
// QueryOrder 查询订单
func (a *Adapter) QueryOrder(ctx context.Context, req *channel.QueryOrderReq) (*channel.QueryOrderResp, error) {
biz := map[string]any{
"out_trade_no": req.TradeNo,
}
if req.ChannelTradeNo != "" {
biz["transaction_id"] = req.ChannelTradeNo
}
resp, err := a.post(ctx, "pay.heepay.trade.query", biz)
if err != nil {
return nil, err
}
result := &channel.QueryOrderResp{
TradeNo: req.TradeNo,
ChannelTradeNo: strVal(resp, "transaction_id"),
}
switch strVal(resp, "trade_state") {
case "SUCCESS":
result.Status = model.TradeStatusPaid
if s := strVal(resp, "pay_time"); s != "" {
t, _ := time.ParseInLocation("2006-01-02 15:04:05", s, cst)
result.PayTime = &t
}
case "CLOSED", "REVOKED":
result.Status = model.TradeStatusClosed
case "PAYERROR":
result.Status = model.TradeStatusFailed
default:
result.Status = model.TradeStatusPaying
}
if v, ok := resp["total_fee"].(float64); ok {
result.Amount = int64(v)
}
return result, nil
}
// CloseOrder 关闭订单
func (a *Adapter) CloseOrder(ctx context.Context, req *channel.CloseOrderReq) error {
biz := map[string]any{
"out_trade_no": req.TradeNo,
}
_, err := a.post(ctx, "pay.heepay.trade.close", biz)
return err
}
// Refund 发起退款
func (a *Adapter) Refund(ctx context.Context, req *channel.RefundReq) (*channel.RefundResp, error) {
biz := map[string]any{
"out_trade_no": req.TradeNo,
"out_refund_no": req.RefundNo,
"total_fee": req.TotalAmount,
"refund_fee": req.RefundAmount,
"refund_desc": req.Reason,
"notify_url": req.NotifyURL,
}
if req.ChannelTradeNo != "" {
biz["transaction_id"] = req.ChannelTradeNo
}
resp, err := a.post(ctx, "pay.heepay.trade.refund", biz)
if err != nil {
return nil, err
}
return &channel.RefundResp{
RefundNo: req.RefundNo,
ChannelRefundNo: strVal(resp, "refund_id"),
Status: model.RefundStatusProcessing,
}, nil
}
// QueryRefund 查询退款
func (a *Adapter) QueryRefund(ctx context.Context, req *channel.QueryRefundReq) (*channel.QueryRefundResp, error) {
biz := map[string]any{
"out_refund_no": req.RefundNo,
}
if req.ChannelRefundNo != "" {
biz["refund_id"] = req.ChannelRefundNo
}
resp, err := a.post(ctx, "pay.heepay.trade.refund.query", biz)
if err != nil {
return nil, err
}
result := &channel.QueryRefundResp{
RefundNo: req.RefundNo,
ChannelRefundNo: strVal(resp, "refund_id"),
}
switch strVal(resp, "refund_status") {
case "SUCCESS":
result.Status = model.RefundStatusSuccess
if s := strVal(resp, "refund_success_time"); s != "" {
t, _ := time.ParseInLocation("2006-01-02 15:04:05", s, cst)
result.RefundTime = &t
}
case "PROCESSING":
result.Status = model.RefundStatusProcessing
case "FAIL":
result.Status = model.RefundStatusFailed
default:
result.Status = model.RefundStatusPending
}
if v, ok := resp["refund_fee"].(float64); ok {
result.RefundAmount = int64(v)
}
return result, nil
}
// ExtractTradeNo 从回调 body 中提取平台交易号(在验签前调用,仅做 JSON 解析)
func (a *Adapter) ExtractTradeNo(rawBody []byte) (string, error) {
var outer struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rawBody, &outer); err != nil {
return "", fmt.Errorf("heepay ExtractTradeNo: unmarshal body: %w", err)
}
var bizData struct {
OutTradeNo string `json:"out_trade_no"`
}
if err := json.Unmarshal(outer.Data, &bizData); err != nil {
return "", fmt.Errorf("heepay ExtractTradeNo: unmarshal data: %w", err)
}
if bizData.OutTradeNo == "" {
return "", fmt.Errorf("heepay ExtractTradeNo: out_trade_no is empty")
}
return bizData.OutTradeNo, nil
}
// VerifyNotify 验证上游回调签名并解析
// 汇元回调的签名规则与响应验签相同:公共参数+data整体 按字典序排列,用汇元公钥验签
func (a *Adapter) VerifyNotify(ctx context.Context, rawBody []byte, headers map[string]string) (*channel.NotifyData, error) {
// 解析外层公共参数
var outer struct {
Code string `json:"code"`
Msg string `json:"msg"`
TradeID string `json:"trade_id"`
Sign string `json:"sign"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rawBody, &outer); err != nil {
return nil, fmt.Errorf("unmarshal notify body: %w", err)
}
// 验签data 整体作为字符串参与验签)
verifyParams := map[string]string{
"code": outer.Code,
"msg": outer.Msg,
"trade_id": outer.TradeID,
"data": string(outer.Data),
}
if err := VerifyResponse(verifyParams, outer.Sign, a.config.PublicKey); err != nil {
return nil, fmt.Errorf("verify notify sign: %w", err)
}
if outer.Code != codeSuccess {
return nil, fmt.Errorf("notify code not success: %s %s", outer.Code, outer.Msg)
}
// 解析业务数据
var bizData map[string]any
if err := json.Unmarshal(outer.Data, &bizData); err != nil {
return nil, fmt.Errorf("unmarshal notify data: %w", err)
}
notifyType := strVal(bizData, "notify_type")
result := &channel.NotifyData{
TradeNo: strVal(bizData, "out_trade_no"),
ChannelTradeNo: strVal(bizData, "transaction_id"),
RawData: rawBody,
}
switch notifyType {
case "payment":
result.NotifyType = model.NotifyTypePayment
result.Status = model.TradeStatusPaid
if v, ok := bizData["total_fee"].(float64); ok {
result.Amount = int64(v)
}
if s := strVal(bizData, "pay_time"); s != "" {
t, _ := time.ParseInLocation("2006-01-02 15:04:05", s, cst)
result.PayTime = &t
}
case "refund":
result.NotifyType = model.NotifyTypeRefund
result.RefundNo = strVal(bizData, "out_refund_no")
result.RefundStatus = model.RefundStatusSuccess
if v, ok := bizData["refund_fee"].(float64); ok {
result.RefundAmount = int64(v)
}
default:
return nil, fmt.Errorf("unknown notify_type: %s", notifyType)
}
return result, nil
}
// ProfitSharing 分账
func (a *Adapter) ProfitSharing(ctx context.Context, req *channel.ProfitSharingReq) (*channel.ProfitSharingResp, error) {
biz := map[string]any{
"transaction_id": req.ChannelTradeNo,
"out_order_no": req.SharingNo,
"receivers": []map[string]any{
{
"type": "MERCHANT_ID",
"account": req.ReceiverMerchantID,
"amount": req.Amount,
"description": "分润",
},
},
}
resp, err := a.post(ctx, "pay.heepay.trade.profitsharing", biz)
if err != nil {
return nil, err
}
return &channel.ProfitSharingResp{
SharingNo: req.SharingNo,
ChannelSharingNo: strVal(resp, "order_id"),
}, nil
}
// RollbackProfitSharing 回退分账
func (a *Adapter) RollbackProfitSharing(ctx context.Context, req *channel.RollbackSharingReq) error {
biz := map[string]any{
"transaction_id": req.TradeNo,
"out_order_no": req.SharingNo,
"out_return_no": req.SharingNo + "_R",
"return_account": a.config.MerchantID,
"return_amount": 0,
"description": "退款回退",
}
_, err := a.post(ctx, "pay.heepay.trade.profitsharing.return", biz)
return err
}
// DownloadBill 下载对账账单
func (a *Adapter) DownloadBill(ctx context.Context, req *channel.DownloadBillReq) (*channel.BillData, error) {
biz := map[string]any{
"bill_date": req.BillDate,
"bill_type": "ALL",
}
resp, err := a.post(ctx, "pay.heepay.trade.bill.download", biz)
if err != nil {
return nil, err
}
// 账单为 CSV 内容,暂存 raw 由对账服务解析
_ = resp
return &channel.BillData{}, nil
}
// UploadFile 上传文件到汇元进件网关customer.file.upload
func (a *Adapter) UploadFile(ctx context.Context, req *channel.UploadFileReq) (*channel.UploadFileResp, error) {
// 计算 MD5 签名
h := md5.Sum(req.FileContent)
fileSign := hex.EncodeToString(h[:])
// Base64 编码文件内容
fileContentB64 := base64.StdEncoding.EncodeToString(req.FileContent)
biz := map[string]any{
"file_content": fileContentB64,
"file_sign": fileSign,
"file_media_type": req.FileMediaType,
}
resp, err := a.postToMerchant(ctx, "customer.file.upload", biz)
if err != nil {
return nil, err
}
fileID := strVal(resp, "file_id")
if fileID == "" {
return nil, fmt.Errorf("heepay upload file: empty file_id in response")
}
return &channel.UploadFileResp{FileID: fileID}, nil
}
// MerchantApply 企业入网申请customer.enter.enterprise.apply
func (a *Adapter) MerchantApply(ctx context.Context, req *channel.MerchantApplyReq) (*channel.MerchantApplyResp, error) {
resp, err := a.postToMerchant(ctx, "customer.enter.enterprise.apply", req.BizContent)
if err != nil {
return nil, err
}
return &channel.MerchantApplyResp{
RequestNo: strVal(resp, "request_no"),
}, nil
}
// QueryMerchantStatus 查询商户状态customer.enter.query
func (a *Adapter) QueryMerchantStatus(ctx context.Context, channelMerchantID string) (*channel.MerchantStatusResp, error) {
biz := map[string]any{
"request_no": channelMerchantID,
}
resp, err := a.postToMerchant(ctx, "customer.enter.query", biz)
if err != nil {
return nil, err
}
return &channel.MerchantStatusResp{
ChannelMerchantID: strVal(resp, "merch_id"),
Status: strVal(resp, "audit_state"),
FailReason: strVal(resp, "audit_reason"),
}, nil
}
// --- 底层通信 ---
// heepayRequest 公共请求结构
type heepayRequest struct {
AppID string `json:"app_id"`
Method string `json:"method"`
Format string `json:"format"`
Charset string `json:"charset"`
SignType string `json:"sign_type"`
Timestamp string `json:"timestamp"`
Version string `json:"version"`
BizContent string `json:"biz_content"`
Sign string `json:"sign"`
}
// heepayResponse 公共响应结构
// 注意:支付网关 code 为字符串("10000"),进件网关 code 为数字10000
// 使用 json.RawMessage 兼容,再通过 codeStr() 统一转为字符串比较。
type heepayResponse struct {
Code json.RawMessage `json:"code"`
Msg string `json:"msg"`
SubCode string `json:"sub_code"`
SubMsg string `json:"sub_msg"`
Sign string `json:"sign"`
TradeID string `json:"trade_id"`
Data json.RawMessage `json:"data"`
}
// codeStr 将 code 字段统一转为字符串(兼容字符串和数字两种格式)
func (r *heepayResponse) codeStr() string {
if len(r.Code) == 0 {
return ""
}
// 去掉引号(字符串形式)或直接返回数字字符串
raw := string(r.Code)
if len(raw) >= 2 && raw[0] == '"' {
return raw[1 : len(raw)-1]
}
return raw
}
// post 调用汇元支付网关payURL
func (a *Adapter) post(ctx context.Context, method string, bizParams map[string]any) (map[string]any, error) {
return a.postURL(ctx, a.payURL, method, bizParams)
}
// postToMerchant 调用汇元进件网关merchantURL
func (a *Adapter) postToMerchant(ctx context.Context, method string, bizParams map[string]any) (map[string]any, error) {
return a.postURL(ctx, a.merchantURL, method, bizParams)
}
// post 调用汇元支付网关payURL
// 注意:原 post 方法内部调用 postURL
func (a *Adapter) postURL(ctx context.Context, gatewayURL, method string, bizParams map[string]any) (map[string]any, error) {
bizJSON, err := json.Marshal(bizParams)
if err != nil {
return nil, fmt.Errorf("marshal biz_content: %w", err)
}
bizContent := string(bizJSON)
timestamp := time.Now().In(cst).Format("2006-01-02 15:04:05")
signParams := map[string]string{
"app_id": a.config.MerchantID,
"method": method,
"format": "JSON",
"charset": "utf-8",
"sign_type": SignTypeRSA2,
"timestamp": timestamp,
"version": "1.0",
"biz_content": bizContent,
}
sign, err := Sign(signParams, a.config.PrivateKey)
if err != nil {
return nil, fmt.Errorf("sign request: %w", err)
}
reqBody := heepayRequest{
AppID: a.config.MerchantID,
Method: method,
Format: "JSON",
Charset: "utf-8",
SignType: SignTypeRSA2,
Timestamp: timestamp,
Version: "1.0",
BizContent: bizContent,
Sign: sign,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
slog.DebugContext(ctx, "heepay request", "method", method, "url", gatewayURL)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, gatewayURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpResp, err := a.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("heepay http request: %w", err)
}
defer httpResp.Body.Close()
respBytes, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
slog.DebugContext(ctx, "heepay response", "method", method, "body", string(respBytes))
if len(respBytes) == 0 {
return nil, fmt.Errorf("heepay empty response from %s (method=%s)", gatewayURL, method)
}
// 先解析为 raw map确保所有字段都能参与验签
var rawMap map[string]json.RawMessage
if err := json.Unmarshal(respBytes, &rawMap); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
signRaw, _ := rawMap["sign"]
signB64 := strings.Trim(string(signRaw), `"`)
// 构建验签参数:所有非 sign、非空字段
verifyParams := make(map[string]string, len(rawMap))
for k, v := range rawMap {
if k == "sign" {
continue
}
raw := string(v)
if raw == "null" || raw == `""` {
continue
}
// 字符串类型去引号并反转义,数字/对象/数组直接用 raw 值
if len(raw) >= 2 && raw[0] == '"' {
var s string
json.Unmarshal(v, &s)
verifyParams[k] = s
} else {
verifyParams[k] = raw
}
}
if err := VerifyResponse(verifyParams, signB64, a.config.PublicKey); err != nil {
return nil, fmt.Errorf("verify response sign: %w", err)
}
// 再用强类型 struct 方便后续处理
var resp heepayResponse
json.Unmarshal(respBytes, &resp)
codeStr := resp.codeStr()
if codeStr != codeSuccess {
return nil, fmt.Errorf("heepay error [%s] %s: %s %s", codeStr, resp.Msg, resp.SubCode, resp.SubMsg)
}
var bizResult map[string]any
if len(resp.Data) > 0 {
if err := json.Unmarshal(resp.Data, &bizResult); err != nil {
return nil, fmt.Errorf("unmarshal response data: %w", err)
}
}
return bizResult, nil
}
// mapPayMethod 将内部支付方式映射为汇元 trade_type
func mapPayMethod(m model.PayMethod) (string, error) {
mapping := map[model.PayMethod]string{
model.PayMethodWechatJSAPI: "JSAPI",
model.PayMethodWechatH5: "MWEB",
model.PayMethodWechatNative: "NATIVE",
model.PayMethodWechatMini: "MINIAPP",
model.PayMethodAlipay: "ALI_NATIVE",
model.PayMethodQuickPay: "QUICK",
}
t, ok := mapping[m]
if !ok {
return "", fmt.Errorf("unsupported pay method: %s", m)
}
return t, nil
}
func strVal(m map[string]any, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
return ""
}

View File

@@ -0,0 +1,13 @@
package heepay
import "crypto/cipher"
// newCBCEncrypter 创建 CBC 加密器(封装 cipher.NewCBCEncrypter
func newCBCEncrypter(block cipher.Block, iv []byte) cipher.BlockMode {
return cipher.NewCBCEncrypter(block, iv)
}
// newCBCDecrypter 创建 CBC 解密器
func newCBCDecrypter(block cipher.Block, iv []byte) cipher.BlockMode {
return cipher.NewCBCDecrypter(block, iv)
}

View File

@@ -0,0 +1,142 @@
package heepay
import (
"bytes"
"crypto/des"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
)
// EncryptRequest 使用 RSA+3DES 加密请求体
// 1. 生成随机 3DES 密钥24 字节)
// 2. 用 3DES-CBC 加密 JSON 请求体
// 3. 用汇元 RSA 公钥加密 3DES 密钥
func EncryptRequest(plaintext []byte, publicKeyPEM string) (encData string, encKey string, err error) {
pubKey, err := parseRSAPublicKey(publicKeyPEM)
if err != nil {
return "", "", err
}
// 生成 3DES 密钥
desKey := make([]byte, 24)
if _, err = rand.Read(desKey); err != nil {
return "", "", err
}
// 3DES 加密
ciphertext, err := tripleDesEncrypt(plaintext, desKey)
if err != nil {
return "", "", err
}
// RSA 加密 3DES 密钥
encKeyBytes, err := rsa.EncryptPKCS1v15(rand.Reader, pubKey, desKey)
if err != nil {
return "", "", err
}
encData = base64.StdEncoding.EncodeToString(ciphertext)
encKey = base64.StdEncoding.EncodeToString(encKeyBytes)
return
}
// DecryptResponse 使用 RSA 私钥 + 3DES 解密响应
func DecryptResponse(encData, encKey, privateKeyPEM string) ([]byte, error) {
privKey, err := parseRSAPrivateKey(privateKeyPEM)
if err != nil {
return nil, err
}
encKeyBytes, err := base64.StdEncoding.DecodeString(encKey)
if err != nil {
return nil, err
}
desKey, err := rsa.DecryptPKCS1v15(rand.Reader, privKey, encKeyBytes)
if err != nil {
return nil, err
}
ciphertext, err := base64.StdEncoding.DecodeString(encData)
if err != nil {
return nil, err
}
return tripleDesDecrypt(ciphertext, desKey)
}
// tripleDesEncrypt 3DES-CBC 加密PKCS5Padding
func tripleDesEncrypt(plaintext, key []byte) ([]byte, error) {
block, err := des.NewTripleDESCipher(key)
if err != nil {
return nil, err
}
blockSize := block.BlockSize()
plaintext = pkcs5Padding(plaintext, blockSize)
iv := key[:blockSize] // 使用密钥前 8 字节作为 IV
mode := newCBCEncrypter(block, iv)
ciphertext := make([]byte, len(plaintext))
mode.CryptBlocks(ciphertext, plaintext)
return ciphertext, nil
}
// tripleDesDecrypt 3DES-CBC 解密
func tripleDesDecrypt(ciphertext, key []byte) ([]byte, error) {
block, err := des.NewTripleDESCipher(key)
if err != nil {
return nil, err
}
blockSize := block.BlockSize()
iv := key[:blockSize]
mode := newCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext)
return pkcs5Unpadding(plaintext)
}
func pkcs5Padding(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padText...)
}
func pkcs5Unpadding(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, errors.New("empty data")
}
padding := int(data[length-1])
if padding > length {
return nil, errors.New("invalid padding")
}
return data[:length-padding], nil
}
func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, errors.New("failed to decode PEM block")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("not RSA public key")
}
return rsaPub, nil
}
func parseRSAPrivateKey(pemStr string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, errors.New("failed to decode PEM block")
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}

View File

@@ -0,0 +1,355 @@
//go:build sandbox
// 沙盒集成测试:直连汇元沙盒环境,验证真实 API 调用。
// 需要设置以下环境变量后运行:
//
// export HEEPAY_MERCHANT_ID=your_merchant_id
// export HEEPAY_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
// export HEEPAY_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
//
// go test -tags sandbox ./internal/channel/heepay/ -v -timeout 60s
package heepay
import (
"context"
"crypto/x509"
"encoding/base64"
"fmt"
"net/http"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
)
const (
sandboxPayURL = "http://openapi.heepaydev.com/gateway"
sandboxMerchantURL = "http://openapi.heepaydev.com/v1/customer/gateway"
)
// newSandboxAdapter 从环境变量读取凭证,构造沙盒 Adapter
func newSandboxAdapter(t *testing.T) *Adapter {
t.Helper()
merchantID := requireEnv(t, "HEEPAY_MERCHANT_ID")
privateKey := requireEnv(t, "HEEPAY_PRIVATE_KEY")
publicKey := requireEnv(t, "HEEPAY_PUBLIC_KEY")
// 支持 \n 转义shell 传入时换行符可能被转义)
privateKey = strings.ReplaceAll(privateKey, `\n`, "\n")
publicKey = strings.ReplaceAll(publicKey, `\n`, "\n")
cfg := &model.ChannelConfig{
MerchantID: merchantID,
PrivateKey: privateKey,
PublicKey: publicKey,
Sandbox: 1,
}
return &Adapter{
config: cfg,
payURL: sandboxPayURL,
merchantURL: sandboxMerchantURL,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func requireEnv(t *testing.T, key string) string {
t.Helper()
v := os.Getenv(key)
require.NotEmpty(t, v, "环境变量 %s 未设置,无法运行沙盒测试", key)
return v
}
// uniqueOrderNo 生成测试用唯一订单号(避免重复)
func uniqueOrderNo(prefix string) string {
return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano())
}
// --- 下单 ---
func TestSandbox_CreateOrder_JSAPI(t *testing.T) {
a := newSandboxAdapter(t)
ctx := context.Background()
resp, err := a.CreateOrder(ctx, &channel.CreateOrderReq{
AppID: a.config.MerchantID,
TradeNo: uniqueOrderNo("TEST"),
MerchantOrderNo: uniqueOrderNo("ORD"),
PayMethod: model.PayMethodWechatJSAPI,
Amount: 1, // 1 分
Subject: "沙盒测试-JSAPI",
NotifyURL: "https://example.com/notify",
ExpireTime: time.Now().Add(10 * time.Minute),
Extra: map[string]any{"openid": "oBk9Y5YMoAb2UG0L1OWQ_xNoBnE0"}, // 沙盒 openid
})
require.NoError(t, err)
assert.NotEmpty(t, resp.PayCredential, "应返回支付凭证")
t.Logf("pay_credential: %+v", resp.PayCredential)
}
func TestSandbox_CreateOrder_Native(t *testing.T) {
a := newSandboxAdapter(t)
ctx := context.Background()
resp, err := a.CreateOrder(ctx, &channel.CreateOrderReq{
AppID: a.config.MerchantID,
TradeNo: uniqueOrderNo("TEST"),
MerchantOrderNo: uniqueOrderNo("ORD"),
PayMethod: model.PayMethodWechatNative,
Amount: 1,
Subject: "沙盒测试-Native",
NotifyURL: "https://example.com/notify",
ExpireTime: time.Now().Add(10 * time.Minute),
})
require.NoError(t, err)
assert.NotEmpty(t, resp.PayCredential)
t.Logf("pay_credential: %+v", resp.PayCredential)
}
// --- 查询订单 ---
func TestSandbox_QueryOrder_NotExist(t *testing.T) {
a := newSandboxAdapter(t)
ctx := context.Background()
// 查一个不存在的订单,预期渠道返回错误
_, err := a.QueryOrder(ctx, &channel.QueryOrderReq{
TradeNo: "NOT_EXIST_" + uniqueOrderNo(""),
})
// 沙盒对不存在订单会返回错误,确认我们能正确解析
assert.Error(t, err)
t.Logf("expected error: %v", err)
}
// --- 完整流程:下单 → 查询 → 关闭 ---
func TestSandbox_OrderLifecycle(t *testing.T) {
a := newSandboxAdapter(t)
ctx := context.Background()
tradeNo := uniqueOrderNo("LIFE")
// 1. 下单
createResp, err := a.CreateOrder(ctx, &channel.CreateOrderReq{
AppID: a.config.MerchantID,
TradeNo: tradeNo,
MerchantOrderNo: uniqueOrderNo("ORD"),
PayMethod: model.PayMethodWechatNative,
Amount: 1,
Subject: "沙盒-生命周期测试",
NotifyURL: "https://example.com/notify",
ExpireTime: time.Now().Add(10 * time.Minute),
})
require.NoError(t, err, "下单失败")
t.Logf("下单成功channel_trade_no: %s", createResp.ChannelTradeNo)
// 2. 查询(刚下单,应为 PAYING 状态)
queryResp, err := a.QueryOrder(ctx, &channel.QueryOrderReq{
TradeNo: tradeNo,
ChannelTradeNo: createResp.ChannelTradeNo,
})
require.NoError(t, err, "查询订单失败")
assert.Equal(t, model.TradeStatusPaying, queryResp.Status)
t.Logf("订单状态: %s", queryResp.Status)
// 3. 关闭
err = a.CloseOrder(ctx, &channel.CloseOrderReq{
TradeNo: tradeNo,
ChannelTradeNo: createResp.ChannelTradeNo,
})
require.NoError(t, err, "关闭订单失败")
t.Log("关闭订单成功")
// 4. 再次查询,应为 CLOSED
queryResp2, err := a.QueryOrder(ctx, &channel.QueryOrderReq{
TradeNo: tradeNo,
ChannelTradeNo: createResp.ChannelTradeNo,
})
require.NoError(t, err)
assert.Equal(t, model.TradeStatusClosed, queryResp2.Status)
}
// --- 商户进件 ---
// TestSandbox_UploadFile 上传一张最小 JPEG 到沙盒,验证返回 file_id
func TestSandbox_UploadFile(t *testing.T) {
a := newSandboxAdapter(t)
ctx := context.Background()
// 最小合法 JPEG几十字节
minJPEG := []byte{
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9,
}
resp, err := a.UploadFile(ctx, &channel.UploadFileReq{
FileContent: minJPEG,
FileName: "test_license.jpg",
FileMediaType: "image/jpeg",
})
require.NoError(t, err)
assert.NotEmpty(t, resp.FileID, "应返回 file_id")
t.Logf("file_id: %s", resp.FileID)
}
// TestSandbox_MerchantApply 提交企业入网申请,验证返回 request_no
// 沙盒环境审核不会真正处理,但接口应正常响应
func TestSandbox_MerchantApply(t *testing.T) {
a := newSandboxAdapter(t)
ctx := context.Background()
// 沙盒测试用企业信息(使用固定测试数据)
// 字段名以汇元 customer.enter.enterprise.apply 文档为准
bizContent := map[string]any{
"merch_name": "测试科技有限公司",
"merch_short_name": "测试科技",
"merch_type": "ENTERPRISE",
"contact_name": "张三",
"contact_phone": "13800138000",
"contact_email": "test@example.com",
"license_no": "91110000123456789X",
"legal_name": "李四",
"legal_id": "110101199001011234",
"province": "北京市",
"city": "北京市",
"district": "朝阳区",
"address": "朝阳区测试路1号",
"bank_acct_name": "测试科技有限公司",
"bank_acct_no": "6222021234567890123",
"bank_name": "中国工商银行",
}
resp, err := a.MerchantApply(ctx, &channel.MerchantApplyReq{
MerchantID: uniqueOrderNo("M"),
BizContent: bizContent,
})
require.NoError(t, err)
assert.NotEmpty(t, resp.RequestNo, "应返回 request_no")
t.Logf("request_no: %s", resp.RequestNo)
}
// TestSandbox_QueryMerchantStatus 用已有的 request_no 查询进件状态
// 若无真实 request_no跳过防止误报失败
func TestSandbox_QueryMerchantStatus(t *testing.T) {
requestNo := os.Getenv("HEEPAY_TEST_REQUEST_NO")
if requestNo == "" {
t.Skip("未设置 HEEPAY_TEST_REQUEST_NO跳过进件状态查询测试")
}
a := newSandboxAdapter(t)
ctx := context.Background()
resp, err := a.QueryMerchantStatus(ctx, requestNo)
require.NoError(t, err)
t.Logf("audit_state: %s, merch_id: %s, fail_reason: %s",
resp.Status, resp.ChannelMerchantID, resp.FailReason)
}
// TestSandbox_MerchantOnboardingFlow 完整进件流程:上传文件 → 提交申请 → 查询状态
func TestSandbox_MerchantOnboardingFlow(t *testing.T) {
a := newSandboxAdapter(t)
ctx := context.Background()
// 1. 上传营业执照
minJPEG := []byte{
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9,
}
uploadResp, err := a.UploadFile(ctx, &channel.UploadFileReq{
FileContent: minJPEG,
FileName: "license.jpg",
FileMediaType: "image/jpeg",
})
require.NoError(t, err, "上传营业执照失败")
t.Logf("上传成功file_id: %s", uploadResp.FileID)
// 2. 提交进件申请(带上文件 ID
bizContent := map[string]any{
"merch_name": "沙盒流程测试公司",
"merch_short_name": "沙盒测试",
"merch_type": "ENTERPRISE",
"contact_name": "王五",
"contact_phone": "13900139000",
"contact_email": "flow@example.com",
"license_no": "91110000FLOW00001X",
"license_img": uploadResp.FileID,
"legal_name": "赵六",
"legal_id": "110101199001019999",
"province": "北京市",
"city": "北京市",
"district": "海淀区",
"address": "海淀区测试路2号",
"bank_acct_name": "沙盒流程测试公司",
"bank_acct_no": "6222029876543210987",
"bank_name": "中国建设银行",
}
applyResp, err := a.MerchantApply(ctx, &channel.MerchantApplyReq{
MerchantID: uniqueOrderNo("FLOW"),
BizContent: bizContent,
})
require.NoError(t, err, "提交进件申请失败")
t.Logf("申请成功request_no: %s", applyResp.RequestNo)
// 3. 查询进件状态(沙盒可能立即返回状态)
statusResp, err := a.QueryMerchantStatus(ctx, applyResp.RequestNo)
require.NoError(t, err, "查询进件状态失败")
t.Logf("进件状态: audit_state=%s, merch_id=%s", statusResp.Status, statusResp.ChannelMerchantID)
}
// --- 签名验证(本地,不发网络请求)---
// TestSign_And_Verify 本地签名/验签往返测试(不发网络请求)
// 注意汇元双密钥体系:
// - 请求签名:商户私钥签 → 汇元用商户公钥验
// - 响应验签:汇元私钥签 → 商户用汇元公钥验(即 a.config.PublicKey
//
// 本测试只验证商户私钥签名正确,使用从私钥派生的公钥做自验,
// 不混用汇元公钥(两者非同一密钥对)。
func TestSign_And_Verify(t *testing.T) {
a := newSandboxAdapter(t)
params := map[string]string{
"app_id": a.config.MerchantID,
"method": "pay.heepay.trade.create",
"format": "JSON",
"charset": "utf-8",
"sign_type": SignTypeRSA2,
"timestamp": "2026-02-28 10:00:00",
"version": "1.0",
"biz_content": `{"out_trade_no":"TEST001"}`,
}
sign, err := Sign(params, a.config.PrivateKey)
require.NoError(t, err)
assert.NotEmpty(t, sign)
t.Logf("sign (first 32 chars): %s...", sign[:32])
// 从商户私钥提取对应公钥,用于验证我们自己签出来的签名
merchantPubKeyPEM, err := extractPublicKeyFromPrivate(a.config.PrivateKey)
require.NoError(t, err, "从私钥提取公钥失败")
err = VerifyResponse(params, sign, merchantPubKeyPEM)
assert.NoError(t, err, "用商户公钥验证商户私钥签名应通过")
}
// extractPublicKeyFromPrivate 从商户私钥中派生出对应的公钥DER base64 格式)
func extractPublicKeyFromPrivate(privKeyStr string) (string, error) {
privKey, err := parsePrivateKey(privKeyStr)
if err != nil {
return "", err
}
derBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(derBytes), nil
}

View File

@@ -0,0 +1,124 @@
package heepay
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"sort"
"strings"
)
const SignTypeRSA2 = "RSA2"
// Sign 对请求参数签名(商户私钥)
// params 为公共参数(不含 signbiz_content 已作为整体字符串放入 params["biz_content"]
func Sign(params map[string]string, privateKeyPEM string) (string, error) {
payload := sortAndJoin(params)
return signRSA2(payload, privateKeyPEM)
}
// VerifyResponse 验证汇元响应签名(汇元公钥)
// params 为响应公共参数(不含 signdata 已作为整体 JSON 字符串放入 params["data"]
func VerifyResponse(params map[string]string, sign, publicKeyPEM string) error {
payload := sortAndJoin(params)
return verifyRSA2(payload, sign, publicKeyPEM)
}
// sortAndJoin 按参数名 A-Z 排序后拼接 key=value&...(排除 sign 和空值字段)
func sortAndJoin(params map[string]string) string {
keys := make([]string, 0, len(params))
for k := range params {
if k == "sign" || params[k] == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+params[k])
}
return strings.Join(parts, "&")
}
// signRSA2 SHA256WithRSA 签名Base64 编码
func signRSA2(payload, privateKeyPEM string) (string, error) {
privKey, err := parsePrivateKey(privateKeyPEM)
if err != nil {
return "", fmt.Errorf("parse private key: %w", err)
}
hash := sha256.Sum256([]byte(payload))
sig, err := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, hash[:])
if err != nil {
return "", fmt.Errorf("rsa sign: %w", err)
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// verifyRSA2 验证 SHA256WithRSA 签名
func verifyRSA2(payload, signB64, publicKeyPEM string) error {
pubKey, err := parsePublicKey(publicKeyPEM)
if err != nil {
return fmt.Errorf("parse public key: %w", err)
}
sig, err := base64.StdEncoding.DecodeString(signB64)
if err != nil {
return fmt.Errorf("decode sign base64: %w", err)
}
hash := sha256.Sum256([]byte(payload))
return rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hash[:], sig)
}
func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) {
var der []byte
if block, _ := pem.Decode([]byte(pemStr)); block != nil {
der = block.Bytes
} else {
// 汇元文档提供的是裸 Base64无 PEM header直接 base64 解码
cleaned := strings.ReplaceAll(strings.TrimSpace(pemStr), "\n", "")
var err error
der, err = base64.StdEncoding.DecodeString(cleaned)
if err != nil {
return nil, fmt.Errorf("private key is neither PEM nor valid base64: %w", err)
}
}
// 优先尝试 PKCS8再尝试 PKCS1
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
return rsaKey, nil
}
return nil, errors.New("not an RSA private key")
}
return x509.ParsePKCS1PrivateKey(der)
}
func parsePublicKey(pemStr string) (*rsa.PublicKey, error) {
var der []byte
if block, _ := pem.Decode([]byte(pemStr)); block != nil {
der = block.Bytes
} else {
// 汇元文档提供的是裸 Base64无 PEM header直接 base64 解码
cleaned := strings.ReplaceAll(strings.TrimSpace(pemStr), "\n", "")
var err error
der, err = base64.StdEncoding.DecodeString(cleaned)
if err != nil {
return nil, fmt.Errorf("public key is neither PEM nor valid base64: %w", err)
}
}
pub, err := x509.ParsePKIXPublicKey(der)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("not an RSA public key")
}
return rsaPub, nil
}

View File

@@ -0,0 +1,231 @@
package channel
import (
"context"
"time"
"pay-bridge/internal/model"
)
// PaymentChannel 支付渠道统一接口,所有渠道适配器必须实现此接口
type PaymentChannel interface {
// Code 返回渠道编码,如 "HEEPAY"
Code() string
// CreateOrder 统一下单,返回支付凭证
CreateOrder(ctx context.Context, req *CreateOrderReq) (*CreateOrderResp, error)
// QueryOrder 查询订单状态
QueryOrder(ctx context.Context, req *QueryOrderReq) (*QueryOrderResp, error)
// CloseOrder 关闭订单
CloseOrder(ctx context.Context, req *CloseOrderReq) error
// Refund 发起退款
Refund(ctx context.Context, req *RefundReq) (*RefundResp, error)
// QueryRefund 查询退款状态
QueryRefund(ctx context.Context, req *QueryRefundReq) (*QueryRefundResp, error)
// ExtractTradeNo 从上游回调 body 中提取平台交易号(用于回调路由,在验签前调用)
ExtractTradeNo(rawBody []byte) (string, error)
// VerifyNotify 验证上游回调签名,返回解析后的通知数据
VerifyNotify(ctx context.Context, rawBody []byte, headers map[string]string) (*NotifyData, error)
// ProfitSharing 发起分账(渠道不支持时返回 ErrNotSupported
ProfitSharing(ctx context.Context, req *ProfitSharingReq) (*ProfitSharingResp, error)
// RollbackProfitSharing 回退分账(退款场景使用)
RollbackProfitSharing(ctx context.Context, req *RollbackSharingReq) error
// DownloadBill 下载对账账单
DownloadBill(ctx context.Context, req *DownloadBillReq) (*BillData, error)
// UploadFile 上传文件,返回 file_id进件图片/视频上传)
UploadFile(ctx context.Context, req *UploadFileReq) (*UploadFileResp, error)
// MerchantApply 商户进件
MerchantApply(ctx context.Context, req *MerchantApplyReq) (*MerchantApplyResp, error)
// QueryMerchantStatus 查询商户审核状态
QueryMerchantStatus(ctx context.Context, channelMerchantID string) (*MerchantStatusResp, error)
}
// URLs 渠道网关地址(由配置文件注入,与商户无关)
type URLs struct {
PayURL string // 支付网关
MerchantURL string // 进件网关
}
// ChannelFactory 渠道工厂函数类型
type ChannelFactory func(config *model.ChannelConfig, urls URLs) PaymentChannel
// --- 请求/响应类型 ---
// CreateOrderReq 下单请求
type CreateOrderReq struct {
AppID string
TradeNo string
MerchantOrderNo string
PayMethod model.PayMethod
Amount int64 // 分
Subject string
NotifyURL string
ExpireTime time.Time
Extra map[string]any // 支付方式特有参数openid 等)
}
// CreateOrderResp 下单响应
type CreateOrderResp struct {
ChannelTradeNo string
PayCredential map[string]any // 支付凭证,各方式格式不同
RawResponse []byte
}
// QueryOrderReq 查询订单请求
type QueryOrderReq struct {
TradeNo string
ChannelTradeNo string
}
// QueryOrderResp 查询订单响应
type QueryOrderResp struct {
TradeNo string
ChannelTradeNo string
Status model.TradeStatus
Amount int64
PayTime *time.Time
}
// CloseOrderReq 关闭订单请求
type CloseOrderReq struct {
TradeNo string
ChannelTradeNo string
}
// RefundReq 退款请求
type RefundReq struct {
TradeNo string
ChannelTradeNo string
RefundNo string
RefundAmount int64
TotalAmount int64
Reason string
NotifyURL string
}
// RefundResp 退款响应
type RefundResp struct {
RefundNo string
ChannelRefundNo string
Status model.RefundStatus
}
// QueryRefundReq 查询退款请求
type QueryRefundReq struct {
RefundNo string
ChannelRefundNo string
}
// QueryRefundResp 查询退款响应
type QueryRefundResp struct {
RefundNo string
ChannelRefundNo string
Status model.RefundStatus
RefundAmount int64
RefundTime *time.Time
}
// NotifyData 上游回调解析结果
type NotifyData struct {
TradeNo string
ChannelTradeNo string
Status model.TradeStatus
Amount int64
PayTime *time.Time
NotifyType model.NotifyType
RefundNo string
RefundStatus model.RefundStatus
RefundAmount int64
RawData []byte
}
// ProfitSharingReq 分账请求
type ProfitSharingReq struct {
TradeNo string
ChannelTradeNo string
SharingNo string
ReceiverMerchantID string
Amount int64
}
// ProfitSharingResp 分账响应
type ProfitSharingResp struct {
SharingNo string
ChannelSharingNo string
}
// RollbackSharingReq 回退分账请求
type RollbackSharingReq struct {
SharingNo string
ChannelSharingNo string
TradeNo string
}
// DownloadBillReq 下载账单请求
type DownloadBillReq struct {
BillDate string // YYYY-MM-DD
}
// BillData 账单数据
type BillData struct {
Records []BillRecord
TotalAmount int64
}
// BillRecord 账单记录
type BillRecord struct {
TradeNo string // 平台交易号(渠道账单中携带时填充)
ChannelBillNo string // 渠道账单流水号
ChannelTradeNo string
Amount int64
Status string
TradeTime time.Time
}
// UploadFileReq 文件上传请求
type UploadFileReq struct {
FileContent []byte // 原始文件二进制内容
FileName string // 文件名(须含扩展名,如 license.jpg
FileMediaType string // 文件类型编码01=营业执照 等)
}
// UploadFileResp 文件上传响应
type UploadFileResp struct {
FileID string // 上传成功后返回,供进件接口使用
}
// MerchantApplyReq 商户进件请求customer.enter.enterprise.apply
type MerchantApplyReq struct {
MerchantID string
// BizContent 为完整的 biz_content JSON 对象,按照 001 文档结构直接传入
// 包含 base_info / settlement_info / subject_info / identity_info / contact_info /
// business_info 等顶层字段
BizContent map[string]any
}
// MerchantApplyResp 商户进件响应
type MerchantApplyResp struct {
RequestNo string // 汇元返回的申请流水号,用于后续查询/修改
ChannelMerchantID string
AuditStatus string
}
// MerchantStatusResp 商户状态响应
type MerchantStatusResp struct {
ChannelMerchantID string
Status string
RejectReason string
FailReason string
}

View File

@@ -0,0 +1,79 @@
package errcode
// 错误码常量
const (
OK = "0"
// 参数错误
ErrInvalidParam = "10001"
ErrMissingParam = "10002"
ErrInvalidPayMethod = "10003"
ErrInvalidAmount = "10004"
// 鉴权错误
ErrUnauthorized = "20001"
ErrAppNotFound = "20002"
ErrPermissionDenied = "20003"
// 业务规则错误
ErrOrderNotFound = "30001"
ErrOrderAlreadyPaid = "30002"
ErrOrderClosed = "30003"
ErrRefundAmountExceed = "30004"
ErrSharingAmountExceed = "30005"
ErrSharingNotConfig = "30006"
ErrSharingFeeExceed = "30007"
ErrOrderIdempotent = "30008"
ErrOrderNotPaid = "30009"
ErrRefundNotFound = "30010"
// 渠道错误
ErrChannelCreateFail = "40001"
ErrChannelRefundFail = "40002"
ErrChannelTimeout = "40003"
ErrChannelNotSupport = "40004"
ErrChannelVerifyFail = "40005"
// 系统错误
ErrInternalDB = "50001"
ErrInternalRedis = "50002"
ErrInternalSystem = "50099"
)
// messages 错误码对应的默认消息
var messages = map[string]string{
OK: "success",
ErrInvalidParam: "参数校验失败",
ErrMissingParam: "缺少必填参数",
ErrInvalidPayMethod: "不支持的支付方式",
ErrInvalidAmount: "金额非法",
ErrUnauthorized: "签名验证失败",
ErrAppNotFound: "应用不存在或已禁用",
ErrPermissionDenied: "无权操作该资源",
ErrOrderNotFound: "订单不存在",
ErrOrderAlreadyPaid: "订单已支付",
ErrOrderClosed: "订单已关闭",
ErrRefundAmountExceed: "退款金额超过可退金额",
ErrSharingAmountExceed: "分润金额超过最大比例",
ErrSharingNotConfig: "未配置分润接收方",
ErrSharingFeeExceed: "分润与服务费之和超过订单金额",
ErrOrderIdempotent: "幂等请求,返回已有订单",
ErrOrderNotPaid: "订单未支付,无法退款",
ErrRefundNotFound: "退款单不存在",
ErrChannelCreateFail: "渠道下单失败",
ErrChannelRefundFail: "渠道退款失败",
ErrChannelTimeout: "渠道调用超时",
ErrChannelNotSupport: "渠道不支持该功能",
ErrChannelVerifyFail: "回调验签失败",
ErrInternalDB: "数据库错误",
ErrInternalRedis: "Redis 错误",
ErrInternalSystem: "系统内部错误",
}
// Message 返回错误码对应的消息
func Message(code string) string {
if msg, ok := messages[code]; ok {
return msg
}
return "未知错误"
}

View File

@@ -0,0 +1,15 @@
package model
import "time"
// AdminUser 管理后台用户
func (AdminUser) TableName() string { return "admin_user" }
type AdminUser struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"uniqueIndex;size:64;not null"`
PasswordHash string `gorm:"size:128;not null"`
Status int8 `gorm:"not null;default:1"` // 1=启用
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@@ -0,0 +1,16 @@
package model
import "time"
// App 接入应用
type App struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
AppID string `gorm:"column:app_id;uniqueIndex;size:32;not null"`
AppSecret string `gorm:"column:app_secret;size:128;not null"` // AES 加密存储
AppName string `gorm:"column:app_name;size:64;not null"`
Status int8 `gorm:"column:status;not null;default:1"` // 1=启用 0=禁用
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (App) TableName() string { return "app" }

View File

@@ -0,0 +1,22 @@
package model
import "time"
// ChannelConfig 渠道配置
type ChannelConfig struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
AppID string `gorm:"column:app_id;size:32;not null;uniqueIndex:uk_app_channel"`
ChannelCode string `gorm:"column:channel_code;size:32;not null;uniqueIndex:uk_app_channel"`
MerchantID string `gorm:"column:merchant_id;size:64;not null"`
APIKey string `gorm:"column:api_key;type:text"` // AES 加密
PrivateKey string `gorm:"column:private_key;type:text"` // AES 加密
PublicKey string `gorm:"column:public_key;type:text"` // 渠道公钥(明文)
NotifyURL string `gorm:"column:notify_url;size:512;not null"`
Sandbox int8 `gorm:"column:sandbox;not null;default:0"` // 1=沙箱 0=生产
ExtraConfig JSONMap `gorm:"column:extra_config;type:json"`
Status int8 `gorm:"column:status;not null;default:1"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (ChannelConfig) TableName() string { return "channel_config" }

View File

@@ -0,0 +1,59 @@
package model
import "time"
// MerchantStatus 商户状态
type MerchantStatus string
const (
MerchantStatusPending MerchantStatus = "PENDING"
MerchantStatusActive MerchantStatus = "ACTIVE"
MerchantStatusFrozen MerchantStatus = "FROZEN"
MerchantStatusRejected MerchantStatus = "REJECTED"
)
// AuditStatus 进件审核状态
type AuditStatus string
const (
AuditStatusSubmitting AuditStatus = "SUBMITTING"
AuditStatusReviewing AuditStatus = "REVIEWING"
AuditStatusApproved AuditStatus = "APPROVED"
AuditStatusRejected AuditStatus = "REJECTED"
)
// Merchant 商户
type Merchant struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
MerchantID string `gorm:"column:merchant_id;uniqueIndex;size:32;not null"`
AppID string `gorm:"column:app_id;size:32;not null;default:'';index"`
MerchantName string `gorm:"column:merchant_name;size:128;not null"`
LicenseNo string `gorm:"column:license_no;size:64"`
LegalPerson string `gorm:"column:legal_person;size:64"`
BankAccount string `gorm:"column:bank_account;size:64"` // 脱敏
ChannelMerchantID string `gorm:"column:channel_merchant_id;size:64"`
Status MerchantStatus `gorm:"column:status;size:20;not null;default:PENDING;index"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (Merchant) TableName() string { return "merchant" }
// MerchantApplication 商户进件申请
// 一条记录对应一个商户在一个渠道的进件,(merchant_id, channel_code) 唯一
type MerchantApplication struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
ApplicationID string `gorm:"column:application_id;uniqueIndex;size:32;not null"`
MerchantID string `gorm:"column:merchant_id;size:32;not null;index"`
ChannelCode string `gorm:"column:channel_code;size:32;not null"`
ChannelMerchantID string `gorm:"column:channel_merchant_id;size:64;not null;default:''"`
SubmitData JSONMap `gorm:"column:submit_data;type:json"`
AuditStatus AuditStatus `gorm:"column:audit_status;size:20;not null;default:SUBMITTING"`
RejectReason string `gorm:"column:reject_reason;size:512"`
SubmittedAt time.Time `gorm:"column:submitted_at"`
AuditedAt *time.Time `gorm:"column:audited_at"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (MerchantApplication) TableName() string { return "merchant_application" }

View File

@@ -0,0 +1,37 @@
package model
import "time"
// NotifyStatus 通知状态
type NotifyStatus string
const (
NotifyStatusPending NotifyStatus = "PENDING"
NotifyStatusSuccess NotifyStatus = "SUCCESS"
NotifyStatusRetry NotifyStatus = "RETRY"
NotifyStatusGiveup NotifyStatus = "GIVEUP"
)
// NotifyType 通知类型
type NotifyType string
const (
NotifyTypePayment NotifyType = "PAYMENT"
NotifyTypeRefund NotifyType = "REFUND"
)
// NotifyLog 下游通知记录
type NotifyLog struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
TradeNo string `gorm:"column:trade_no;size:32;not null;uniqueIndex:uk_trade_notify_type"`
NotifyType NotifyType `gorm:"column:notify_type;size:20;not null;uniqueIndex:uk_trade_notify_type"`
NotifyURL string `gorm:"column:notify_url;size:512;not null"`
Status NotifyStatus `gorm:"column:status;size:20;not null;default:PENDING"`
RetryCount int `gorm:"column:retry_count;not null;default:0"`
NextRetryTime *time.Time `gorm:"column:next_retry_time;index:idx_status_next_retry"`
LastResponse string `gorm:"column:last_response;type:text"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (NotifyLog) TableName() string { return "notify_log" }

View File

@@ -0,0 +1,25 @@
package model
import "time"
// SeqType 序列类型
type SeqType string
const (
SeqTypeTrade SeqType = "TRADE"
SeqTypeRefund SeqType = "REFUND"
SeqTypeSharing SeqType = "SHARING"
)
// OrderSequence 订单编码序列
type OrderSequence struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
AppID string `gorm:"column:app_id;size:32;not null;uniqueIndex:uk_app_type"`
SeqType SeqType `gorm:"column:seq_type;size:20;not null;uniqueIndex:uk_app_type"`
Prefix string `gorm:"column:prefix;size:8;not null"`
CurrentValue uint64 `gorm:"column:current_value;not null;default:0"`
Step int `gorm:"column:step;not null;default:1"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (OrderSequence) TableName() string { return "order_sequence" }

View File

@@ -0,0 +1,49 @@
package model
import "time"
// MatchStatus 匹配状态
type MatchStatus string
const (
MatchStatusMatched MatchStatus = "MATCHED"
MatchStatusPendingManual MatchStatus = "PENDING_MANUAL"
MatchStatusNameDiff MatchStatus = "NAME_DIFF" // 匹配成功但名称不一致
)
// SubMerchantAccount 子商户固定收款账户
type SubMerchantAccount struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
AppID string `gorm:"column:app_id;size:32;not null;index:idx_app_merchant"`
SubMerchantID string `gorm:"column:sub_merchant_id;size:64;not null;index:idx_app_merchant"`
ChannelCode string `gorm:"column:channel_code;size:32;not null"`
AccountType string `gorm:"column:account_type;size:20;not null"` // BANK_CARD
AccountNo string `gorm:"column:account_no;size:64;not null;index"` // 脱敏
AccountNoEnc string `gorm:"column:account_no_enc;type:text"` // AES 加密完整账号
AccountName string `gorm:"column:account_name;size:128;not null"`
BankName string `gorm:"column:bank_name;size:64"`
Status int8 `gorm:"column:status;not null;default:1"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (SubMerchantAccount) TableName() string { return "sub_merchant_account" }
// PaymentMatchLog 收款匹配记录
type PaymentMatchLog struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
AccountID uint64 `gorm:"column:account_id;not null;index:idx_account_status"`
TradeNo string `gorm:"column:trade_no;size:32;index"`
IncomingAmount int64 `gorm:"column:incoming_amount;not null"`
IncomingRemark string `gorm:"column:incoming_remark;size:256"`
PayerName string `gorm:"column:payer_name;size:128"`
ChannelBillNo string `gorm:"column:channel_bill_no;size:64;index"`
MatchStatus MatchStatus `gorm:"column:match_status;size:20;not null;index:idx_account_status"`
NameDiff int8 `gorm:"column:name_diff;not null;default:0"` // 1=名称不一致
MatchTime *time.Time `gorm:"column:match_time"`
Operator string `gorm:"column:operator;size:64"` // 人工操作者
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (PaymentMatchLog) TableName() string { return "payment_match_log" }

View File

@@ -0,0 +1,58 @@
package model
import "time"
// ProfitSharingStatus 分润状态
type ProfitSharingStatus string
const (
ProfitSharingStatusPending ProfitSharingStatus = "PENDING"
ProfitSharingStatusProcessing ProfitSharingStatus = "PROCESSING"
ProfitSharingStatusSuccess ProfitSharingStatus = "SUCCESS"
ProfitSharingStatusFailed ProfitSharingStatus = "FAILED"
ProfitSharingStatusRollback ProfitSharingStatus = "ROLLBACK"
)
// ProfitSharingConfig 分润配置(应用级)
type ProfitSharingConfig struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
AppID string `gorm:"column:app_id;size:32;not null;uniqueIndex"`
ReceiverMerchantID string `gorm:"column:receiver_merchant_id;size:64;not null"`
ReceiverType string `gorm:"column:receiver_type;size:20;not null"` // PLATFORM / SUB_MERCHANT
MaxSharingRatio float64 `gorm:"column:max_sharing_ratio;type:decimal(5,4);not null"`
Status int8 `gorm:"column:status;not null;default:1"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (ProfitSharingConfig) TableName() string { return "profit_sharing_config" }
// ProfitSharingOrder 分润记录
type ProfitSharingOrder struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
SharingNo string `gorm:"column:sharing_no;uniqueIndex;size:32;not null"`
TradeNo string `gorm:"column:trade_no;uniqueIndex;size:32;not null"`
AppID string `gorm:"column:app_id;size:32;not null"`
ReceiverMerchantID string `gorm:"column:receiver_merchant_id;size:64;not null"`
SharingAmount int64 `gorm:"column:sharing_amount;not null"`
Status ProfitSharingStatus `gorm:"column:status;size:20;not null;default:PENDING"`
ChannelSharingNo string `gorm:"column:channel_sharing_no;size:64"`
FailReason string `gorm:"column:fail_reason;size:256"`
SharingTime *time.Time `gorm:"column:sharing_time"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (ProfitSharingOrder) TableName() string { return "profit_sharing_order" }
// ProfitSharingLog 分润流水
type ProfitSharingLog struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
SharingNo string `gorm:"column:sharing_no;size:32;not null;index"`
Action string `gorm:"column:action;size:20;not null"` // SPLIT / ROLLBACK
Amount int64 `gorm:"column:amount;not null"`
Status string `gorm:"column:status;size:20;not null"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
}
func (ProfitSharingLog) TableName() string { return "profit_sharing_log" }

View File

@@ -0,0 +1,44 @@
package model
import "time"
// ReconciliationStatus 对账单状态
type ReconciliationStatus string
const (
ReconciliationStatusPending ReconciliationStatus = "PENDING"
ReconciliationStatusMatched ReconciliationStatus = "MATCHED"
ReconciliationStatusException ReconciliationStatus = "EXCEPTION"
)
// ReconciliationReport 对账报告
type ReconciliationReport struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
AppID string `gorm:"column:app_id;size:32;not null;index:idx_app_date"`
ChannelCode string `gorm:"column:channel_code;size:32;not null"`
BillDate string `gorm:"column:bill_date;size:10;not null;index:idx_app_date"` // yyyy-MM-dd
TotalCount int `gorm:"column:total_count;not null;default:0"`
TotalAmount int64 `gorm:"column:total_amount;not null;default:0"` // 分
MatchedCount int `gorm:"column:matched_count;not null;default:0"`
ExceptionCount int `gorm:"column:exception_count;not null;default:0"`
Status ReconciliationStatus `gorm:"column:status;size:20;not null;default:PENDING"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (ReconciliationReport) TableName() string { return "reconciliation_report" }
// ReconciliationException 对账异常明细
type ReconciliationException struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
ReportID uint64 `gorm:"column:report_id;not null;index"`
TradeNo string `gorm:"column:trade_no;size:32;index"`
ChannelBillNo string `gorm:"column:channel_bill_no;size:64"`
ExceptionType string `gorm:"column:exception_type;size:32;not null"` // MISSING_LOCAL/MISSING_CHANNEL/AMOUNT_MISMATCH
LocalAmount int64 `gorm:"column:local_amount"`
ChannelAmount int64 `gorm:"column:channel_amount"`
Remark string `gorm:"column:remark;size:256"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
}
func (ReconciliationException) TableName() string { return "reconciliation_exception" }

View File

@@ -0,0 +1,32 @@
package model
import "time"
// RefundStatus 退款状态
type RefundStatus string
const (
RefundStatusPending RefundStatus = "PENDING"
RefundStatusProcessing RefundStatus = "PROCESSING"
RefundStatusSuccess RefundStatus = "SUCCESS"
RefundStatusFailed RefundStatus = "FAILED"
)
// RefundOrder 退款记录
type RefundOrder struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
RefundNo string `gorm:"column:refund_no;uniqueIndex;size:32;not null"`
TradeNo string `gorm:"column:trade_no;size:32;not null;index"`
AppID string `gorm:"column:app_id;size:32;not null"`
ChannelCode string `gorm:"column:channel_code;size:32;not null"`
ChannelRefundNo string `gorm:"column:channel_refund_no;size:64"`
RefundAmount int64 `gorm:"column:refund_amount;not null"`
Reason string `gorm:"column:reason;size:256"`
Status RefundStatus `gorm:"column:status;size:20;not null;default:PENDING"`
NotifyURL string `gorm:"column:notify_url;size:512"`
RefundTime *time.Time `gorm:"column:refund_time"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (RefundOrder) TableName() string { return "refund_order" }

View File

@@ -0,0 +1,56 @@
package model
import "time"
// PayMethodGroup 支付方式分组(用于服务费配置)
type PayMethodGroup string
const (
PayMethodGroupScan PayMethodGroup = "SCAN" // 扫码支付(微信/支付宝)
PayMethodGroupTransfer PayMethodGroup = "TRANSFER" // 对公转账
PayMethodGroupBalance PayMethodGroup = "BALANCE" // 余额支付
)
// ServiceFeeConfig 服务费配置
type ServiceFeeConfig struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
AppID string `gorm:"column:app_id;size:32;not null;uniqueIndex:uk_app_method"`
PayMethodGroup PayMethodGroup `gorm:"column:pay_method_group;size:20;not null;uniqueIndex:uk_app_method"`
FeeRate float64 `gorm:"column:fee_rate;type:decimal(6,4);not null"` // 0.0000 ~ 9.9999%
FeeReceiverMerchantID string `gorm:"column:fee_receiver_merchant_id;size:64;not null"`
Status int8 `gorm:"column:status;not null;default:1"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (ServiceFeeConfig) TableName() string { return "service_fee_config" }
// ServiceFeeLog 服务费流水
type ServiceFeeLog struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
TradeNo string `gorm:"column:trade_no;size:32;not null;uniqueIndex:uk_trade_action"`
ConfigID uint64 `gorm:"column:config_id;not null"`
FeeAmount int64 `gorm:"column:fee_amount;not null"`
FeeRate float64 `gorm:"column:fee_rate;type:decimal(6,4);not null"`
ReceiverMerchantID string `gorm:"column:receiver_merchant_id;size:64;not null"`
Action string `gorm:"column:action;size:20;not null;uniqueIndex:uk_trade_action"` // CHARGE / ROLLBACK
Status string `gorm:"column:status;size:20;not null;default:PENDING"`
ChannelSharingNo string `gorm:"column:channel_sharing_no;size:64"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (ServiceFeeLog) TableName() string { return "service_fee_log" }
// PayMethodToGroup 将支付方式映射到服务费分组
func PayMethodToGroup(m PayMethod) PayMethodGroup {
switch m {
case PayMethodWechatJSAPI, PayMethodWechatH5, PayMethodWechatNative,
PayMethodWechatMini, PayMethodAlipay, PayMethodQuickPay:
return PayMethodGroupScan
case PayMethodTransfer:
return PayMethodGroupTransfer
default:
return PayMethodGroupBalance
}
}

View File

@@ -0,0 +1,103 @@
package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// TradeStatus 交易状态
type TradeStatus string
const (
TradeStatusCreating TradeStatus = "CREATING"
TradeStatusPaying TradeStatus = "PAYING"
TradeStatusPaid TradeStatus = "PAID"
TradeStatusClosed TradeStatus = "CLOSED"
TradeStatusFailed TradeStatus = "FAILED"
TradeStatusCreateFailed TradeStatus = "CREATE_FAILED"
TradeStatusRefunded TradeStatus = "REFUNDED"
)
// PayMethod 支付方式
type PayMethod string
const (
PayMethodWechatJSAPI PayMethod = "WECHAT_JSAPI"
PayMethodWechatH5 PayMethod = "WECHAT_H5"
PayMethodWechatNative PayMethod = "WECHAT_NATIVE"
PayMethodWechatMini PayMethod = "WECHAT_MINI"
PayMethodAlipay PayMethod = "ALIPAY"
PayMethodQuickPay PayMethod = "QUICK_PAY"
PayMethodTransfer PayMethod = "TRANSFER" // 对公转账
)
// JSONMap JSON 字段类型
type JSONMap map[string]any
func (j JSONMap) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
b, err := json.Marshal(j)
return string(b), err
}
func (j *JSONMap) Scan(value any) error {
if value == nil {
*j = nil
return nil
}
var bytes []byte
switch v := value.(type) {
case string:
bytes = []byte(v)
case []byte:
bytes = v
default:
return fmt.Errorf("unsupported type: %T", value)
}
return json.Unmarshal(bytes, j)
}
// TradeOrder 交易订单
type TradeOrder struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
TradeNo string `gorm:"column:trade_no;uniqueIndex;size:32;not null"`
MerchantOrderNo string `gorm:"column:merchant_order_no;size:64;not null"`
AppID string `gorm:"column:app_id;size:32;not null;index:idx_app_merchant,unique"`
ChannelCode string `gorm:"column:channel_code;size:32;not null"`
ChannelTradeNo string `gorm:"column:channel_trade_no;size:64;index"`
PayMethod PayMethod `gorm:"column:pay_method;size:32;not null"`
Amount int64 `gorm:"column:amount;not null"`
ProfitSharingAmount int64 `gorm:"column:profit_sharing_amount;not null;default:0"`
ServiceFeeAmount int64 `gorm:"column:service_fee_amount;not null;default:0"`
Subject string `gorm:"column:subject;size:256;not null"`
NotifyURL string `gorm:"column:notify_url;size:512;not null"`
Status TradeStatus `gorm:"column:status;size:20;not null;default:CREATING"`
Extra JSONMap `gorm:"column:extra;type:json"`
ChannelExtra JSONMap `gorm:"column:channel_extra;type:json"`
ExpireTime time.Time `gorm:"column:expire_time;not null"`
PayTime *time.Time `gorm:"column:pay_time"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (TradeOrder) TableName() string { return "trade_order" }
// CanTransitTo 校验状态流转是否合法
func (t TradeStatus) CanTransitTo(next TradeStatus) bool {
allowed := map[TradeStatus][]TradeStatus{
TradeStatusCreating: {TradeStatusPaying, TradeStatusCreateFailed},
TradeStatusPaying: {TradeStatusPaid, TradeStatusClosed, TradeStatusFailed},
TradeStatusPaid: {TradeStatusRefunded},
TradeStatusCreateFailed: {TradeStatusPaying},
}
for _, s := range allowed[t] {
if s == next {
return true
}
}
return false
}

View File

@@ -0,0 +1,41 @@
package model
import "time"
// WechatBinding 商户微信公众号绑定
type WechatBinding struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
AppID string `gorm:"column:app_id;size:32;not null;uniqueIndex"`
WxAppID string `gorm:"column:wx_app_id;size:32;not null"` // 微信公众号/小程序 AppID
WxSecret string `gorm:"column:wx_secret;type:text;not null"` // AES 加密存储
TemplateID string `gorm:"column:template_id;size:64;not null"` // 消息模板 ID
Status int8 `gorm:"column:status;not null;default:1"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime:milli"`
}
func (WechatBinding) TableName() string { return "wechat_binding" }
// WechatMessageStatus 微信消息发送状态
type WechatMessageStatus string
const (
WechatMessageStatusPending WechatMessageStatus = "PENDING"
WechatMessageStatusSuccess WechatMessageStatus = "SUCCESS"
WechatMessageStatusFailed WechatMessageStatus = "FAILED"
)
// WechatMessageLog 微信消息发送日志
type WechatMessageLog struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
AppID string `gorm:"column:app_id;size:32;not null;index"`
TradeNo string `gorm:"column:trade_no;size:32;index"`
OpenID string `gorm:"column:open_id;size:64;not null"`
TemplateID string `gorm:"column:template_id;size:64;not null"`
Status WechatMessageStatus `gorm:"column:status;size:20;not null;default:PENDING"`
ErrMsg string `gorm:"column:err_msg;size:256"`
SentAt *time.Time `gorm:"column:sent_at"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime:milli"`
}
func (WechatMessageLog) TableName() string { return "wechat_message_log" }

View File

@@ -0,0 +1,30 @@
package repository
import (
"context"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
type AdminUserRepository struct {
db *gorm.DB
}
func NewAdminUserRepository(db *gorm.DB) *AdminUserRepository {
return &AdminUserRepository{db: db}
}
func (r *AdminUserRepository) GetByUsername(ctx context.Context, username string) (*model.AdminUser, error) {
var user model.AdminUser
err := r.db.WithContext(ctx).Where("username = ? AND status = 1", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *AdminUserRepository) Create(ctx context.Context, user *model.AdminUser) error {
return r.db.WithContext(ctx).Create(user).Error
}

View File

@@ -0,0 +1,71 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// AppRepository app 数据访问
type AppRepository struct {
db *gorm.DB
}
func NewAppRepository(db *gorm.DB) *AppRepository {
return &AppRepository{db: db}
}
// GetByAppID 根据 appId 查询
func (r *AppRepository) GetByAppID(ctx context.Context, appID string) (*model.App, error) {
var app model.App
err := r.db.WithContext(ctx).Where("app_id = ? AND status = 1", appID).First(&app).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &app, err
}
// Create 创建应用
func (r *AppRepository) Create(ctx context.Context, app *model.App) error {
return r.db.WithContext(ctx).Create(app).Error
}
// ListActive 查询所有启用的应用
func (r *AppRepository) ListActive(ctx context.Context) ([]*model.App, error) {
var apps []*model.App
err := r.db.WithContext(ctx).Where("status = 1").Find(&apps).Error
return apps, err
}
// List 分页查询所有应用(不过滤状态)
func (r *AppRepository) List(ctx context.Context, limit, offset int) ([]*model.App, error) {
var apps []*model.App
err := r.db.WithContext(ctx).Order("id DESC").Limit(limit).Offset(offset).Find(&apps).Error
return apps, err
}
// GetByAppIDUnscoped 不过滤状态地查询(用于管理接口)
func (r *AppRepository) GetByAppIDUnscoped(ctx context.Context, appID string) (*model.App, error) {
var app model.App
err := r.db.WithContext(ctx).Where("app_id = ?", appID).First(&app).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &app, err
}
// UpdateStatus 更新应用状态
func (r *AppRepository) UpdateStatus(ctx context.Context, appID string, status int8) error {
return r.db.WithContext(ctx).Model(&model.App{}).
Where("app_id = ?", appID).
Update("status", status).Error
}
// UpdateSecret 更新应用密钥
func (r *AppRepository) UpdateSecret(ctx context.Context, appID, encSecret string) error {
return r.db.WithContext(ctx).Model(&model.App{}).
Where("app_id = ?", appID).
Update("app_secret", encSecret).Error
}

View File

@@ -0,0 +1,45 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// ChannelConfigRepository 渠道配置数据访问
type ChannelConfigRepository struct {
db *gorm.DB
}
func NewChannelConfigRepository(db *gorm.DB) *ChannelConfigRepository {
return &ChannelConfigRepository{db: db}
}
// GetByAppChannel 按 app_id + channel_code 查询
func (r *ChannelConfigRepository) GetByAppChannel(ctx context.Context, appID, channelCode string) (*model.ChannelConfig, error) {
var cfg model.ChannelConfig
err := r.db.WithContext(ctx).Where("app_id = ? AND channel_code = ? AND status = 1", appID, channelCode).First(&cfg).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &cfg, err
}
// Create 创建渠道配置
func (r *ChannelConfigRepository) Create(ctx context.Context, cfg *model.ChannelConfig) error {
return r.db.WithContext(ctx).Create(cfg).Error
}
// Update 更新渠道配置
func (r *ChannelConfigRepository) Update(ctx context.Context, id uint64, updates map[string]any) error {
return r.db.WithContext(ctx).Model(&model.ChannelConfig{}).Where("id = ?", id).Updates(updates).Error
}
// ListByApp 查询应用下所有启用的渠道配置
func (r *ChannelConfigRepository) ListByApp(ctx context.Context, appID string) ([]*model.ChannelConfig, error) {
var cfgs []*model.ChannelConfig
err := r.db.WithContext(ctx).Where("app_id = ? AND status = 1", appID).Find(&cfgs).Error
return cfgs, err
}

View File

@@ -0,0 +1,115 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// MerchantRepository 商户数据访问
type MerchantRepository struct {
db *gorm.DB
}
func NewMerchantRepository(db *gorm.DB) *MerchantRepository {
return &MerchantRepository{db: db}
}
func (r *MerchantRepository) Create(ctx context.Context, m *model.Merchant) error {
return r.db.WithContext(ctx).Create(m).Error
}
func (r *MerchantRepository) GetByMerchantID(ctx context.Context, merchantID string) (*model.Merchant, error) {
var m model.Merchant
err := r.db.WithContext(ctx).Where("merchant_id = ?", merchantID).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &m, err
}
func (r *MerchantRepository) UpdateStatus(ctx context.Context, merchantID string, status model.MerchantStatus, updates map[string]any) error {
if updates == nil {
updates = make(map[string]any)
}
updates["status"] = status
return r.db.WithContext(ctx).Model(&model.Merchant{}).Where("merchant_id = ?", merchantID).Updates(updates).Error
}
func (r *MerchantRepository) List(ctx context.Context, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error) {
var merchants []*model.Merchant
q := r.db.WithContext(ctx)
if status != "" {
q = q.Where("status = ?", status)
}
err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&merchants).Error
return merchants, err
}
// ListAnomalous 查询状态异常的商户Frozen/Rejected
func (r *MerchantRepository) ListAnomalous(ctx context.Context) ([]*model.Merchant, error) {
var merchants []*model.Merchant
err := r.db.WithContext(ctx).
Where("status IN ?", []model.MerchantStatus{
model.MerchantStatusFrozen,
model.MerchantStatusRejected,
}).Find(&merchants).Error
return merchants, err
}
// GetByMerchantIDAndAppID 带 appID 隔离查询(业务侧用)
func (r *MerchantRepository) GetByMerchantIDAndAppID(ctx context.Context, merchantID, appID string) (*model.Merchant, error) {
var m model.Merchant
err := r.db.WithContext(ctx).Where("merchant_id = ? AND app_id = ?", merchantID, appID).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &m, err
}
// ListByAppID 按 appID 分页查询(业务侧用)
func (r *MerchantRepository) ListByAppID(ctx context.Context, appID string, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error) {
var merchants []*model.Merchant
q := r.db.WithContext(ctx).Where("app_id = ?", appID)
if status != "" {
q = q.Where("status = ?", status)
}
err := q.Limit(limit).Offset(offset).Order("id DESC").Find(&merchants).Error
return merchants, err
}
// CreateApplication 创建进件申请
func (r *MerchantRepository) CreateApplication(ctx context.Context, app *model.MerchantApplication) error {
return r.db.WithContext(ctx).Create(app).Error
}
// GetLatestApplication 获取商户最新进件申请
func (r *MerchantRepository) GetLatestApplication(ctx context.Context, merchantID string) (*model.MerchantApplication, error) {
var app model.MerchantApplication
err := r.db.WithContext(ctx).Where("merchant_id = ?", merchantID).
Order("created_at DESC").First(&app).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &app, err
}
// GetApprovedApplicationByChannel 查询指定商户在指定渠道已审核通过的进件记录
func (r *MerchantRepository) GetApprovedApplicationByChannel(ctx context.Context, merchantID, channelCode string) (*model.MerchantApplication, error) {
var app model.MerchantApplication
err := r.db.WithContext(ctx).
Where("merchant_id = ? AND channel_code = ? AND audit_status = ?", merchantID, channelCode, model.AuditStatusApproved).
First(&app).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &app, err
}
// UpdateApplication 更新进件申请状态
func (r *MerchantRepository) UpdateApplication(ctx context.Context, applicationID string, updates map[string]any) error {
return r.db.WithContext(ctx).Model(&model.MerchantApplication{}).
Where("application_id = ?", applicationID).Updates(updates).Error
}

View File

@@ -0,0 +1,75 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"pay-bridge/internal/model"
)
// NotifyLogRepository 通知记录数据访问
type NotifyLogRepository struct {
db *gorm.DB
}
func NewNotifyLogRepository(db *gorm.DB) *NotifyLogRepository {
return &NotifyLogRepository{db: db}
}
// Upsert 创建或更新通知记录
func (r *NotifyLogRepository) Upsert(ctx context.Context, log *model.NotifyLog) error {
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "trade_no"}, {Name: "notify_type"}},
DoUpdates: clause.AssignmentColumns([]string{"notify_url", "status", "retry_count", "next_retry_time", "last_response"}),
}).Create(log).Error
}
// GetByTradeNo 按 trade_no + notify_type 查询
func (r *NotifyLogRepository) GetByTradeNo(ctx context.Context, tradeNo string, notifyType model.NotifyType) (*model.NotifyLog, error) {
var log model.NotifyLog
err := r.db.WithContext(ctx).Where("trade_no = ? AND notify_type = ?", tradeNo, notifyType).First(&log).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &log, err
}
// ListPendingRetry 查询到期需要重试的通知
func (r *NotifyLogRepository) ListPendingRetry(ctx context.Context, before time.Time, limit int) ([]*model.NotifyLog, error) {
var logs []*model.NotifyLog
err := r.db.WithContext(ctx).
Where("status IN ? AND next_retry_time <= ?",
[]model.NotifyStatus{model.NotifyStatusPending, model.NotifyStatusRetry},
before).
Order("next_retry_time ASC").
Limit(limit).
Find(&logs).Error
return logs, err
}
// IncrRetryCount 重试次数+1更新下次重试时间和最后响应
func (r *NotifyLogRepository) IncrRetryCount(ctx context.Context, id uint64, status model.NotifyStatus, nextRetryTime *time.Time, lastResponse string) error {
return r.db.WithContext(ctx).Model(&model.NotifyLog{}).Where("id = ?", id).Updates(map[string]any{
"retry_count": gorm.Expr("retry_count + 1"),
"status": status,
"next_retry_time": nextRetryTime,
"last_response": lastResponse,
}).Error
}
// MarkSuccess 标记通知成功
func (r *NotifyLogRepository) MarkSuccess(ctx context.Context, id uint64, lastResponse string) error {
return r.db.WithContext(ctx).Model(&model.NotifyLog{}).Where("id = ?", id).Updates(map[string]any{
"status": model.NotifyStatusSuccess,
"last_response": lastResponse,
}).Error
}
// MarkGiveup 标记放弃通知
func (r *NotifyLogRepository) MarkGiveup(ctx context.Context, id uint64) error {
return r.db.WithContext(ctx).Model(&model.NotifyLog{}).Where("id = ?", id).
Update("status", model.NotifyStatusGiveup).Error
}

View File

@@ -0,0 +1,95 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// PaymentMatchRepository 收款匹配数据访问
type PaymentMatchRepository struct {
db *gorm.DB
}
func NewPaymentMatchRepository(db *gorm.DB) *PaymentMatchRepository {
return &PaymentMatchRepository{db: db}
}
// GetAccountByNo 按账号查询子商户收款账户
func (r *PaymentMatchRepository) GetAccountByNo(ctx context.Context, accountNo string) (*model.SubMerchantAccount, error) {
var acc model.SubMerchantAccount
err := r.db.WithContext(ctx).Where("account_no = ? AND status = 1", accountNo).First(&acc).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &acc, err
}
// GetAccountByID 按 id 查询
func (r *PaymentMatchRepository) GetAccountByID(ctx context.Context, id uint64) (*model.SubMerchantAccount, error) {
var acc model.SubMerchantAccount
err := r.db.WithContext(ctx).Where("id = ?", id).First(&acc).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &acc, err
}
// ListAccountsByApp 查询应用下所有收款账户
func (r *PaymentMatchRepository) ListAccountsByApp(ctx context.Context, appID string) ([]*model.SubMerchantAccount, error) {
var accs []*model.SubMerchantAccount
err := r.db.WithContext(ctx).Where("app_id = ? AND status = 1", appID).Find(&accs).Error
return accs, err
}
// CreateAccount 创建收款账户
func (r *PaymentMatchRepository) CreateAccount(ctx context.Context, acc *model.SubMerchantAccount) error {
return r.db.WithContext(ctx).Create(acc).Error
}
// CreateMatchLog 创建匹配记录
func (r *PaymentMatchRepository) CreateMatchLog(ctx context.Context, log *model.PaymentMatchLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
// GetMatchLogByBillNo 按渠道流水号查询(幂等检查)
func (r *PaymentMatchRepository) GetMatchLogByBillNo(ctx context.Context, channelBillNo string) (*model.PaymentMatchLog, error) {
var log model.PaymentMatchLog
err := r.db.WithContext(ctx).Where("channel_bill_no = ?", channelBillNo).First(&log).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &log, err
}
// UpdateMatchLog 更新匹配记录
func (r *PaymentMatchRepository) UpdateMatchLog(ctx context.Context, id uint64, updates map[string]any) error {
return r.db.WithContext(ctx).Model(&model.PaymentMatchLog{}).Where("id = ?", id).Updates(updates).Error
}
// ListPendingManual 查询待人工确认的记录
func (r *PaymentMatchRepository) ListPendingManual(ctx context.Context, appID string, limit, offset int) ([]*model.PaymentMatchLog, error) {
var logs []*model.PaymentMatchLog
err := r.db.WithContext(ctx).
Joins("JOIN sub_merchant_account ON sub_merchant_account.id = payment_match_log.account_id").
Where("sub_merchant_account.app_id = ? AND payment_match_log.match_status = ?",
appID, model.MatchStatusPendingManual).
Order("payment_match_log.created_at DESC").
Limit(limit).Offset(offset).
Find(&logs).Error
return logs, err
}
// ListPayingByAmount 按金额查询指定时间窗口内的待支付订单(用于收款匹配降级)
func (r *PaymentMatchRepository) ListPayingByAmount(ctx context.Context, appID string, amount int64, window time.Duration) ([]*model.TradeOrder, error) {
var orders []*model.TradeOrder
since := time.Now().Add(-window)
err := r.db.WithContext(ctx).
Where("app_id = ? AND amount = ? AND status = ? AND created_at >= ?",
appID, amount, model.TradeStatusPaying, since).
Find(&orders).Error
return orders, err
}

View File

@@ -0,0 +1,90 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// ProfitSharingRepository 分润数据访问
type ProfitSharingRepository struct {
db *gorm.DB
}
func NewProfitSharingRepository(db *gorm.DB) *ProfitSharingRepository {
return &ProfitSharingRepository{db: db}
}
// GetConfigByAppID 按 app_id 获取分润配置
func (r *ProfitSharingRepository) GetConfigByAppID(ctx context.Context, appID string) (*model.ProfitSharingConfig, error) {
var cfg model.ProfitSharingConfig
err := r.db.WithContext(ctx).Where("app_id = ? AND status = 1", appID).First(&cfg).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &cfg, err
}
// SaveConfig 创建或更新分润配置
func (r *ProfitSharingRepository) SaveConfig(ctx context.Context, cfg *model.ProfitSharingConfig) error {
return r.db.WithContext(ctx).Save(cfg).Error
}
// CreateOrder 创建分润记录
func (r *ProfitSharingRepository) CreateOrder(ctx context.Context, order *model.ProfitSharingOrder) error {
return r.db.WithContext(ctx).Create(order).Error
}
// GetOrderByTradeNo 按 trade_no 查询分润记录
func (r *ProfitSharingRepository) GetOrderByTradeNo(ctx context.Context, tradeNo string) (*model.ProfitSharingOrder, error) {
var order model.ProfitSharingOrder
err := r.db.WithContext(ctx).Where("trade_no = ?", tradeNo).First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &order, err
}
// GetOrderBySharingNo 按 sharing_no 查询
func (r *ProfitSharingRepository) GetOrderBySharingNo(ctx context.Context, sharingNo string) (*model.ProfitSharingOrder, error) {
var order model.ProfitSharingOrder
err := r.db.WithContext(ctx).Where("sharing_no = ?", sharingNo).First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &order, err
}
// UpdateOrderStatus 更新分润状态
func (r *ProfitSharingRepository) UpdateOrderStatus(ctx context.Context, sharingNo string, fromStatus, toStatus model.ProfitSharingStatus, updates map[string]any) (bool, error) {
if updates == nil {
updates = make(map[string]any)
}
updates["status"] = toStatus
result := r.db.WithContext(ctx).Model(&model.ProfitSharingOrder{}).
Where("sharing_no = ? AND status = ?", sharingNo, fromStatus).
Updates(updates)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
// CreateLog 记录分润流水
func (r *ProfitSharingRepository) CreateLog(ctx context.Context, log *model.ProfitSharingLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
// ListPendingOrders 查询需要补偿的分润单PROCESSING 超时)
func (r *ProfitSharingRepository) ListPendingOrders(ctx context.Context, limit int) ([]*model.ProfitSharingOrder, error) {
var orders []*model.ProfitSharingOrder
err := r.db.WithContext(ctx).
Where("status IN ?", []model.ProfitSharingStatus{
model.ProfitSharingStatusPending,
model.ProfitSharingStatusProcessing,
}).
Limit(limit).Find(&orders).Error
return orders, err
}

View File

@@ -0,0 +1,62 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// ReconciliationRepository 对账数据访问
type ReconciliationRepository struct {
db *gorm.DB
}
func NewReconciliationRepository(db *gorm.DB) *ReconciliationRepository {
return &ReconciliationRepository{db: db}
}
// CreateReport 创建对账报告
func (r *ReconciliationRepository) CreateReport(ctx context.Context, report *model.ReconciliationReport) error {
return r.db.WithContext(ctx).Create(report).Error
}
// GetReport 查询对账报告
func (r *ReconciliationRepository) GetReport(ctx context.Context, appID, billDate, channelCode string) (*model.ReconciliationReport, error) {
var report model.ReconciliationReport
err := r.db.WithContext(ctx).
Where("app_id = ? AND bill_date = ? AND channel_code = ?", appID, billDate, channelCode).
First(&report).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &report, err
}
// UpdateReport 更新对账报告
func (r *ReconciliationRepository) UpdateReport(ctx context.Context, id uint64, updates map[string]any) error {
return r.db.WithContext(ctx).Model(&model.ReconciliationReport{}).Where("id = ?", id).Updates(updates).Error
}
// CreateException 创建对账异常记录
func (r *ReconciliationRepository) CreateException(ctx context.Context, ex *model.ReconciliationException) error {
return r.db.WithContext(ctx).Create(ex).Error
}
// ListExceptions 查询报告下的异常明细
func (r *ReconciliationRepository) ListExceptions(ctx context.Context, reportID uint64) ([]*model.ReconciliationException, error) {
var exs []*model.ReconciliationException
err := r.db.WithContext(ctx).Where("report_id = ?", reportID).Find(&exs).Error
return exs, err
}
// ListPaidOrdersByDate 查询指定日期的已支付订单(用于对账)
func (r *ReconciliationRepository) ListPaidOrdersByDate(ctx context.Context, appID, date string) ([]*model.TradeOrder, error) {
var orders []*model.TradeOrder
err := r.db.WithContext(ctx).
Where("app_id = ? AND status = ? AND DATE(pay_time) = ?",
appID, model.TradeStatusPaid, date).
Find(&orders).Error
return orders, err
}

View File

@@ -0,0 +1,63 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// RefundOrderRepository 退款记录数据访问
type RefundOrderRepository struct {
db *gorm.DB
}
func NewRefundOrderRepository(db *gorm.DB) *RefundOrderRepository {
return &RefundOrderRepository{db: db}
}
// Create 创建退款单
func (r *RefundOrderRepository) Create(ctx context.Context, refund *model.RefundOrder) error {
return r.db.WithContext(ctx).Create(refund).Error
}
// GetByRefundNo 按 refund_no 查询
func (r *RefundOrderRepository) GetByRefundNo(ctx context.Context, refundNo string) (*model.RefundOrder, error) {
var refund model.RefundOrder
err := r.db.WithContext(ctx).Where("refund_no = ?", refundNo).First(&refund).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &refund, err
}
// SumRefundedAmount 统计某笔交易已退款总额(成功+处理中)
func (r *RefundOrderRepository) SumRefundedAmount(ctx context.Context, tradeNo string) (int64, error) {
var total int64
err := r.db.WithContext(ctx).Model(&model.RefundOrder{}).
Where("trade_no = ? AND status IN ?", tradeNo, []model.RefundStatus{
model.RefundStatusPending,
model.RefundStatusProcessing,
model.RefundStatusSuccess,
}).
Select("COALESCE(SUM(refund_amount), 0)").
Scan(&total).Error
return total, err
}
// UpdateStatus 更新退款状态
func (r *RefundOrderRepository) UpdateStatus(ctx context.Context, refundNo string, fromStatus, toStatus model.RefundStatus, updates map[string]any) (bool, error) {
if updates == nil {
updates = make(map[string]any)
}
updates["status"] = toStatus
result := r.db.WithContext(ctx).Model(&model.RefundOrder{}).
Where("refund_no = ? AND status = ?", refundNo, fromStatus).
Updates(updates)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}

View File

@@ -0,0 +1,90 @@
package repository
import (
"context"
"errors"
"fmt"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// SequenceRepository 序列数据访问
type SequenceRepository struct {
db *gorm.DB
}
func NewSequenceRepository(db *gorm.DB) *SequenceRepository {
return &SequenceRepository{db: db}
}
// IncrAndGet 原子自增并返回新值(行级锁)
func (r *SequenceRepository) IncrAndGet(ctx context.Context, appID string, seqType model.SeqType) (uint64, error) {
var seq model.OrderSequence
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 加行锁读取
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("app_id = ? AND seq_type = ?", appID, seqType).
First(&seq).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// 自动初始化序列
prefix := defaultPrefix(seqType)
seq = model.OrderSequence{
AppID: appID,
SeqType: seqType,
Prefix: prefix,
CurrentValue: 0,
Step: 1,
}
if err := tx.Create(&seq).Error; err != nil {
return err
}
// 重新加锁读
return tx.Set("gorm:query_option", "FOR UPDATE").
Where("app_id = ? AND seq_type = ?", appID, seqType).
First(&seq).Error
}
return nil
})
if err != nil {
return 0, err
}
// 自增
newVal := seq.CurrentValue + uint64(seq.Step)
if err := r.db.WithContext(ctx).Model(&model.OrderSequence{}).
Where("id = ?", seq.ID).
Update("current_value", newVal).Error; err != nil {
return 0, err
}
return newVal, nil
}
func defaultPrefix(t model.SeqType) string {
switch t {
case model.SeqTypeTrade:
return "PAY"
case model.SeqTypeRefund:
return "REF"
case model.SeqTypeSharing:
return "SHA"
default:
return "ORD"
}
}
// GetPrefix 获取序列前缀
func (r *SequenceRepository) GetPrefix(ctx context.Context, appID string, seqType model.SeqType) (string, error) {
var seq model.OrderSequence
err := r.db.WithContext(ctx).Where("app_id = ? AND seq_type = ?", appID, seqType).First(&seq).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Sprintf("%s", defaultPrefix(seqType)), nil
}
if err != nil {
return "", err
}
return seq.Prefix, nil
}

View File

@@ -0,0 +1,66 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// ServiceFeeRepository 服务费数据访问
type ServiceFeeRepository struct {
db *gorm.DB
}
func NewServiceFeeRepository(db *gorm.DB) *ServiceFeeRepository {
return &ServiceFeeRepository{db: db}
}
// GetConfig 按 app_id + 支付方式分组查询配置
func (r *ServiceFeeRepository) GetConfig(ctx context.Context, appID string, group model.PayMethodGroup) (*model.ServiceFeeConfig, error) {
var cfg model.ServiceFeeConfig
err := r.db.WithContext(ctx).
Where("app_id = ? AND pay_method_group = ? AND status = 1", appID, group).
First(&cfg).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &cfg, err
}
// ListConfigs 查询应用所有服务费配置
func (r *ServiceFeeRepository) ListConfigs(ctx context.Context, appID string) ([]*model.ServiceFeeConfig, error) {
var cfgs []*model.ServiceFeeConfig
err := r.db.WithContext(ctx).Where("app_id = ? AND status = 1", appID).Find(&cfgs).Error
return cfgs, err
}
// SaveConfig 保存配置(创建或更新)
func (r *ServiceFeeRepository) SaveConfig(ctx context.Context, cfg *model.ServiceFeeConfig) error {
return r.db.WithContext(ctx).Save(cfg).Error
}
// CreateLog 创建服务费流水
func (r *ServiceFeeRepository) CreateLog(ctx context.Context, log *model.ServiceFeeLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
// GetLog 按 trade_no + action 查询流水
func (r *ServiceFeeRepository) GetLog(ctx context.Context, tradeNo, action string) (*model.ServiceFeeLog, error) {
var log model.ServiceFeeLog
err := r.db.WithContext(ctx).Where("trade_no = ? AND action = ?", tradeNo, action).First(&log).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &log, err
}
// UpdateLogStatus 更新流水状态
func (r *ServiceFeeRepository) UpdateLogStatus(ctx context.Context, id uint64, status, channelSharingNo string) error {
updates := map[string]any{"status": status}
if channelSharingNo != "" {
updates["channel_sharing_no"] = channelSharingNo
}
return r.db.WithContext(ctx).Model(&model.ServiceFeeLog{}).Where("id = ?", id).Updates(updates).Error
}

View File

@@ -0,0 +1,80 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// TradeOrderRepository 交易订单数据访问
type TradeOrderRepository struct {
db *gorm.DB
}
func NewTradeOrderRepository(db *gorm.DB) *TradeOrderRepository {
return &TradeOrderRepository{db: db}
}
// Create 创建订单
func (r *TradeOrderRepository) Create(ctx context.Context, order *model.TradeOrder) error {
return r.db.WithContext(ctx).Create(order).Error
}
// GetByTradeNo 按 trade_no 查询
func (r *TradeOrderRepository) GetByTradeNo(ctx context.Context, tradeNo string) (*model.TradeOrder, error) {
var order model.TradeOrder
err := r.db.WithContext(ctx).Where("trade_no = ?", tradeNo).First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &order, err
}
// GetByMerchantOrderNo 按 app_id + merchant_order_no 查询
func (r *TradeOrderRepository) GetByMerchantOrderNo(ctx context.Context, appID, merchantOrderNo string) (*model.TradeOrder, error) {
var order model.TradeOrder
err := r.db.WithContext(ctx).Where("app_id = ? AND merchant_order_no = ?", appID, merchantOrderNo).First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &order, err
}
// GetByChannelTradeNo 按渠道交易号查询
func (r *TradeOrderRepository) GetByChannelTradeNo(ctx context.Context, channelTradeNo string) (*model.TradeOrder, error) {
var order model.TradeOrder
err := r.db.WithContext(ctx).Where("channel_trade_no = ?", channelTradeNo).First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &order, err
}
// UpdateStatus 乐观锁更新状态(只允许从 fromStatus 流转到 toStatus
// 返回 bool 表示是否更新成功false = 已被其他 goroutine 更新)
func (r *TradeOrderRepository) UpdateStatus(ctx context.Context, tradeNo string, fromStatus, toStatus model.TradeStatus, updates map[string]any) (bool, error) {
if updates == nil {
updates = make(map[string]any)
}
updates["status"] = toStatus
result := r.db.WithContext(ctx).Model(&model.TradeOrder{}).
Where("trade_no = ? AND status = ?", tradeNo, fromStatus).
Updates(updates)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
// ListPayingExpired 查询已过期的 PAYING 订单(用于定时关单补偿)
func (r *TradeOrderRepository) ListPayingExpired(ctx context.Context, before time.Time, limit int) ([]*model.TradeOrder, error) {
var orders []*model.TradeOrder
err := r.db.WithContext(ctx).
Where("status = ? AND expire_time < ?", model.TradeStatusPaying, before).
Limit(limit).Find(&orders).Error
return orders, err
}

View File

@@ -0,0 +1,43 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"pay-bridge/internal/model"
)
// WechatRepository 微信通知数据访问
type WechatRepository struct {
db *gorm.DB
}
func NewWechatRepository(db *gorm.DB) *WechatRepository {
return &WechatRepository{db: db}
}
// GetBinding 查询应用微信绑定配置
func (r *WechatRepository) GetBinding(ctx context.Context, appID string) (*model.WechatBinding, error) {
var b model.WechatBinding
err := r.db.WithContext(ctx).Where("app_id = ? AND status = 1", appID).First(&b).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &b, err
}
// UpsertBinding 创建或更新绑定
func (r *WechatRepository) UpsertBinding(ctx context.Context, b *model.WechatBinding) error {
return r.db.WithContext(ctx).Save(b).Error
}
// CreateMessageLog 记录消息发送日志
func (r *WechatRepository) CreateMessageLog(ctx context.Context, log *model.WechatMessageLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
// UpdateMessageLog 更新消息日志状态
func (r *WechatRepository) UpdateMessageLog(ctx context.Context, id uint64, updates map[string]any) error {
return r.db.WithContext(ctx).Model(&model.WechatMessageLog{}).Where("id = ?", id).Updates(updates).Error
}

View File

@@ -0,0 +1,74 @@
package service
import (
"context"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"pay-bridge/internal/repository"
)
type AdminAuthService struct {
repo *repository.AdminUserRepository
jwtSecret []byte
expireHrs int
}
func NewAdminAuthService(repo *repository.AdminUserRepository, jwtSecret string, expireHours int) *AdminAuthService {
return &AdminAuthService{
repo: repo,
jwtSecret: []byte(jwtSecret),
expireHrs: expireHours,
}
}
// Login 验证用户名密码,成功返回 JWT token
func (s *AdminAuthService) Login(ctx context.Context, username, password string) (string, error) {
user, err := s.repo.GetByUsername(ctx, username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", errors.New("用户名或密码错误")
}
return "", err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return "", errors.New("用户名或密码错误")
}
claims := jwt.MapClaims{
"username": user.Username,
"exp": time.Now().Add(time.Duration(s.expireHrs) * time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
// ParseToken 验证并解析 JWT返回用户名
func (s *AdminAuthService) ParseToken(tokenStr string) (string, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing method")
}
return s.jwtSecret, nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
return "", err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return "", errors.New("invalid token")
}
username, ok := claims["username"].(string)
if !ok {
return "", errors.New("invalid token claims")
}
return username, nil
}

View File

@@ -0,0 +1,141 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"pay-bridge/internal/errcode"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
"pay-bridge/pkg/crypto"
)
// AppService 应用服务
type AppService struct {
repo *repository.AppRepository
encKey string
}
func NewAppService(repo *repository.AppRepository, encKey string) *AppService {
return &AppService{repo: repo, encKey: encKey}
}
// GetAppSecret 获取 appSecret用于鉴权中间件
func (s *AppService) GetAppSecret(ctx context.Context, appID string) (string, error) {
app, err := s.repo.GetByAppID(ctx, appID)
if err != nil {
return "", err
}
if app == nil {
return "", errors.New(errcode.ErrAppNotFound)
}
secret, err := crypto.Decrypt(app.AppSecret, s.encKey)
if err != nil {
return "", fmt.Errorf("decrypt app secret: %w", err)
}
return secret, nil
}
// GetApp 获取应用信息
func (s *AppService) GetApp(ctx context.Context, appID string) (*model.App, error) {
return s.repo.GetByAppID(ctx, appID)
}
// CreateAppResult 创建应用的返回,包含明文 secret仅展示一次
type CreateAppResult struct {
App *model.App
PlainSecret string
}
// CreateApp 创建应用,自动生成 app_id 和 app_secret
func (s *AppService) CreateApp(ctx context.Context, appName string) (*CreateAppResult, error) {
appID := generateAppID()
plainSecret := generateSecret()
encSecret, err := crypto.Encrypt(plainSecret, s.encKey)
if err != nil {
return nil, err
}
app := &model.App{
AppID: appID,
AppSecret: encSecret,
AppName: appName,
Status: 1,
}
if err := s.repo.Create(ctx, app); err != nil {
return nil, err
}
return &CreateAppResult{App: app, PlainSecret: plainSecret}, nil
}
// ListApps 分页查询应用列表
func (s *AppService) ListApps(ctx context.Context, limit, offset int) ([]*model.App, error) {
return s.repo.List(ctx, limit, offset)
}
// DisableApp 禁用应用
func (s *AppService) DisableApp(ctx context.Context, appID string) error {
app, err := s.repo.GetByAppIDUnscoped(ctx, appID)
if err != nil {
return err
}
if app == nil {
return errors.New(errcode.ErrAppNotFound)
}
return s.repo.UpdateStatus(ctx, appID, 0)
}
// EnableApp 启用应用
func (s *AppService) EnableApp(ctx context.Context, appID string) error {
app, err := s.repo.GetByAppIDUnscoped(ctx, appID)
if err != nil {
return err
}
if app == nil {
return errors.New(errcode.ErrAppNotFound)
}
return s.repo.UpdateStatus(ctx, appID, 1)
}
// ResetSecret 重置应用密钥,返回新的明文 secret仅此一次
func (s *AppService) ResetSecret(ctx context.Context, appID string) (string, error) {
app, err := s.repo.GetByAppIDUnscoped(ctx, appID)
if err != nil {
return "", err
}
if app == nil {
return "", errors.New(errcode.ErrAppNotFound)
}
plainSecret := generateSecret()
encSecret, err := crypto.Encrypt(plainSecret, s.encKey)
if err != nil {
return "", err
}
if err := s.repo.UpdateSecret(ctx, appID, encSecret); err != nil {
return "", err
}
return plainSecret, nil
}
// generateAppID 生成 app_idapp_ + yyMMdd + 8位随机hex
func generateAppID() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
date := time.Now().Format("060102")
return "app_" + date + hex.EncodeToString(b)
}
// generateSecret 生成 32 字节随机 secret64位hex
func generateSecret() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return strings.ToUpper(hex.EncodeToString(b))
}

View File

@@ -0,0 +1,140 @@
package service
import (
"context"
"fmt"
"sync"
"time"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
"pay-bridge/pkg/config"
"pay-bridge/pkg/crypto"
)
const channelCacheTTL = 5 * time.Minute
type cachedChannel struct {
ch channel.PaymentChannel
expiresAt time.Time
}
// ChannelService 渠道服务(负责加载渠道配置并获取渠道实例)
type ChannelService struct {
repo *repository.ChannelConfigRepository
encKey string
urlsCfg config.ChannelsConfig
mu sync.Mutex
cache map[string]*cachedChannel
}
func NewChannelService(repo *repository.ChannelConfigRepository, encKey string, urlsCfg config.ChannelsConfig) *ChannelService {
return &ChannelService{
repo: repo,
encKey: encKey,
urlsCfg: urlsCfg,
cache: make(map[string]*cachedChannel),
}
}
// GetChannel 根据 appID 和渠道码获取渠道适配器实例5 分钟内存缓存)
func (s *ChannelService) GetChannel(ctx context.Context, appID, channelCode string) (channel.PaymentChannel, error) {
cacheKey := appID + ":" + channelCode
s.mu.Lock()
if entry, ok := s.cache[cacheKey]; ok && time.Now().Before(entry.expiresAt) {
ch := entry.ch
s.mu.Unlock()
return ch, nil
}
s.mu.Unlock()
cfg, err := s.repo.GetByAppChannel(ctx, appID, channelCode)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, fmt.Errorf("channel config not found: app=%s channel=%s", appID, channelCode)
}
decCfg, err := s.decryptConfig(cfg)
if err != nil {
return nil, err
}
ch, err := channel.Get(channelCode, decCfg, s.urlsFor(channelCode))
if err != nil {
return nil, err
}
s.mu.Lock()
s.cache[cacheKey] = &cachedChannel{ch: ch, expiresAt: time.Now().Add(channelCacheTTL)}
s.mu.Unlock()
return ch, nil
}
// InvalidateCache 使指定渠道的缓存失效(配置变更时调用)
func (s *ChannelService) InvalidateCache(appID, channelCode string) {
s.mu.Lock()
delete(s.cache, appID+":"+channelCode)
s.mu.Unlock()
}
// ListChannelCodes 获取应用下所有渠道码
func (s *ChannelService) ListChannelCodes(ctx context.Context, appID string) ([]string, error) {
cfgs, err := s.repo.ListByApp(ctx, appID)
if err != nil {
return nil, err
}
codes := make([]string, 0, len(cfgs))
for _, c := range cfgs {
codes = append(codes, c.ChannelCode)
}
return codes, nil
}
// GetChannelConfig 获取渠道配置(已解密)
func (s *ChannelService) GetChannelConfig(ctx context.Context, appID, channelCode string) (*model.ChannelConfig, error) {
cfg, err := s.repo.GetByAppChannel(ctx, appID, channelCode)
if err != nil || cfg == nil {
return cfg, err
}
return s.decryptConfig(cfg)
}
// urlsFor 根据渠道码返回对应的网关地址配置
func (s *ChannelService) urlsFor(channelCode string) channel.URLs {
switch channelCode {
case "HEEPAY":
return channel.URLs{
PayURL: s.urlsCfg.Heepay.PayURL,
MerchantURL: s.urlsCfg.Heepay.MerchantURL,
}
default:
return channel.URLs{}
}
}
func (s *ChannelService) decryptConfig(cfg *model.ChannelConfig) (*model.ChannelConfig, error) {
copied := *cfg
if cfg.APIKey != "" {
dec, err := crypto.Decrypt(cfg.APIKey, s.encKey)
if err != nil {
return nil, fmt.Errorf("decrypt api_key: %w", err)
}
copied.APIKey = dec
}
if cfg.PrivateKey != "" {
dec, err := crypto.Decrypt(cfg.PrivateKey, s.encKey)
if err != nil {
return nil, fmt.Errorf("decrypt private_key: %w", err)
}
copied.PrivateKey = dec
}
return &copied, nil
}

View File

@@ -0,0 +1,268 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"time"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
)
// merchantRepo 定义 MerchantService 所需的数据访问方法,便于测试时注入 mock
type merchantRepo interface {
Create(ctx context.Context, m *model.Merchant) error
GetByMerchantID(ctx context.Context, merchantID string) (*model.Merchant, error)
GetByMerchantIDAndAppID(ctx context.Context, merchantID, appID string) (*model.Merchant, error)
UpdateStatus(ctx context.Context, merchantID string, status model.MerchantStatus, updates map[string]any) error
List(ctx context.Context, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error)
ListByAppID(ctx context.Context, appID string, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error)
ListAnomalous(ctx context.Context) ([]*model.Merchant, error)
CreateApplication(ctx context.Context, app *model.MerchantApplication) error
GetLatestApplication(ctx context.Context, merchantID string) (*model.MerchantApplication, error)
GetApprovedApplicationByChannel(ctx context.Context, merchantID, channelCode string) (*model.MerchantApplication, error)
UpdateApplication(ctx context.Context, applicationID string, updates map[string]any) error
}
// MerchantService 商户进件与管理服务
type MerchantService struct {
merchantRepo merchantRepo
channelSvc *ChannelService
}
func NewMerchantService(
merchantRepo *repository.MerchantRepository,
channelSvc *ChannelService,
) *MerchantService {
return &MerchantService{
merchantRepo: merchantRepo,
channelSvc: channelSvc,
}
}
func genApplicationID() string {
b := make([]byte, 16)
rand.Read(b)
return "APP" + hex.EncodeToString(b)[:16]
}
// Apply 提交商户进件申请
// bizContent 为完整的入网申请业务参数(对应 001 文档的 biz_content 结构)
func (s *MerchantService) Apply(ctx context.Context, merchantID, channelCode string, bizContent map[string]any) (string, error) {
merchant, err := s.merchantRepo.GetByMerchantID(ctx, merchantID)
if err != nil {
return "", err
}
if merchant == nil {
return "", errors.New("merchant not found")
}
if merchant.Status == model.MerchantStatusFrozen {
return "", errors.New("merchant is frozen")
}
ch, err := s.channelSvc.GetChannel(ctx, "", channelCode)
if err != nil {
return "", err
}
resp, err := ch.MerchantApply(ctx, &channel.MerchantApplyReq{
MerchantID: merchantID,
BizContent: bizContent,
})
if err != nil {
return "", err
}
applicationID := genApplicationID()
app := &model.MerchantApplication{
ApplicationID: applicationID,
MerchantID: merchantID,
ChannelCode: channelCode,
SubmitData: model.JSONMap(bizContent),
AuditStatus: model.AuditStatusSubmitting,
SubmittedAt: time.Now(),
}
// 持久化渠道返回的 request_no用于后续查询/修改
if resp.RequestNo != "" {
app.SubmitData["_channel_request_no"] = resp.RequestNo
}
if err := s.merchantRepo.CreateApplication(ctx, app); err != nil {
return "", err
}
slog.InfoContext(ctx, "merchant application submitted",
"merchant_id", merchantID,
"application_id", applicationID,
"channel_code", channelCode,
"channel_request_no", resp.RequestNo,
)
return applicationID, nil
}
// UploadFile 上传文件到指定渠道,返回渠道 file_id
func (s *MerchantService) UploadFile(ctx context.Context, channelCode string, req *channel.UploadFileReq) (string, error) {
ch, err := s.channelSvc.GetChannel(ctx, "", channelCode)
if err != nil {
return "", err
}
resp, err := ch.UploadFile(ctx, req)
if err != nil {
return "", err
}
return resp.FileID, nil
}
// QueryAuditStatus 查询进件审核状态
func (s *MerchantService) QueryAuditStatus(ctx context.Context, merchantID string) (*model.MerchantApplication, error) {
app, err := s.merchantRepo.GetLatestApplication(ctx, merchantID)
if err != nil {
return nil, err
}
if app == nil {
return nil, nil
}
// 如果仍在审核中,向渠道查询最新状态
if app.AuditStatus == model.AuditStatusSubmitting || app.AuditStatus == model.AuditStatusReviewing {
// 从 submit_data 中读取渠道返回的 request_no
channelRequestNo, _ := app.SubmitData["_channel_request_no"].(string)
if channelRequestNo != "" {
ch, err := s.channelSvc.GetChannel(ctx, "", app.ChannelCode)
if err == nil {
resp, err := ch.QueryMerchantStatus(ctx, channelRequestNo)
if err == nil {
merchant, _ := s.merchantRepo.GetByMerchantID(ctx, merchantID)
s.syncMerchantStatus(ctx, merchantID, app.ApplicationID, merchant, resp)
app, _ = s.merchantRepo.GetLatestApplication(ctx, merchantID)
}
}
}
}
return app, nil
}
// syncMerchantStatus 同步渠道返回的审核状态到本地
func (s *MerchantService) syncMerchantStatus(ctx context.Context, merchantID, applicationID string,
merchant *model.Merchant, resp *channel.MerchantStatusResp) {
now := time.Now()
appUpdates := map[string]any{}
switch resp.Status {
case "APPROVED":
appUpdates["audit_status"] = model.AuditStatusApproved
appUpdates["audited_at"] = now
if resp.ChannelMerchantID != "" {
appUpdates["channel_merchant_id"] = resp.ChannelMerchantID
}
s.merchantRepo.UpdateStatus(ctx, merchantID, model.MerchantStatusActive, nil)
case "REJECTED":
appUpdates["audit_status"] = model.AuditStatusRejected
appUpdates["reject_reason"] = resp.RejectReason
appUpdates["audited_at"] = now
s.merchantRepo.UpdateStatus(ctx, merchantID, model.MerchantStatusRejected, nil)
case "REVIEWING":
appUpdates["audit_status"] = model.AuditStatusReviewing
case "FROZEN":
s.merchantRepo.UpdateStatus(ctx, merchantID, model.MerchantStatusFrozen, nil)
}
if len(appUpdates) > 0 {
s.merchantRepo.UpdateApplication(ctx, applicationID, appUpdates)
}
}
// GetChannelMerchantID 返回指定商户在指定渠道进件审核通过后的渠道商户ID
// 若该商户未在该渠道进件或审核未通过,返回空字符串
func (s *MerchantService) GetChannelMerchantID(ctx context.Context, merchantID, channelCode string) (string, error) {
app, err := s.merchantRepo.GetApprovedApplicationByChannel(ctx, merchantID, channelCode)
if err != nil {
return "", err
}
if app == nil {
return "", nil
}
return app.ChannelMerchantID, nil
}
// CreateMerchantForApp 业务侧创建商户,强制绑定 appID
func (s *MerchantService) CreateMerchantForApp(ctx context.Context, appID string, m *model.Merchant) error {
m.AppID = appID
return s.merchantRepo.Create(ctx, m)
}
// GetMerchantForApp 业务侧查询,校验 appID 归属
func (s *MerchantService) GetMerchantForApp(ctx context.Context, appID, merchantID string) (*model.Merchant, error) {
m, err := s.merchantRepo.GetByMerchantIDAndAppID(ctx, merchantID, appID)
if err != nil {
return nil, err
}
if m == nil {
return nil, errors.New("30001") // merchant not found
}
return m, nil
}
// ListMerchantsForApp 业务侧列表,只返回该 appID 下的商户
func (s *MerchantService) ListMerchantsForApp(ctx context.Context, appID string, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error) {
return s.merchantRepo.ListByAppID(ctx, appID, status, limit, offset)
}
// ApplyForApp 业务侧进件,校验 appID 归属后委托 Apply
func (s *MerchantService) ApplyForApp(ctx context.Context, appID, merchantID, channelCode string, bizContent map[string]any) (string, error) {
if _, err := s.GetMerchantForApp(ctx, appID, merchantID); err != nil {
return "", err
}
return s.Apply(ctx, merchantID, channelCode, bizContent)
}
// QueryAuditStatusForApp 业务侧查审核状态,校验 appID 归属
func (s *MerchantService) QueryAuditStatusForApp(ctx context.Context, appID, merchantID string) (*model.MerchantApplication, error) {
if _, err := s.GetMerchantForApp(ctx, appID, merchantID); err != nil {
return nil, err
}
return s.QueryAuditStatus(ctx, merchantID)
}
// CheckAnomalies 检查状态异常的商户(由 cron 调用)
func (s *MerchantService) CheckAnomalies(ctx context.Context) error {
merchants, err := s.merchantRepo.ListAnomalous(ctx)
if err != nil {
return err
}
slog.InfoContext(ctx, "anomalous merchants found", "count", len(merchants))
// 实际业务中可在此发送告警通知
return nil
}
// CreateMerchant 创建商户基础信息
func (s *MerchantService) CreateMerchant(ctx context.Context, m *model.Merchant) error {
return s.merchantRepo.Create(ctx, m)
}
// GetMerchant 查询商户信息
func (s *MerchantService) GetMerchant(ctx context.Context, merchantID string) (*model.Merchant, error) {
return s.merchantRepo.GetByMerchantID(ctx, merchantID)
}
// ListMerchants 查询商户列表
func (s *MerchantService) ListMerchants(ctx context.Context, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error) {
return s.merchantRepo.List(ctx, status, limit, offset)
}
// FreezeMerchant 冻结商户
func (s *MerchantService) FreezeMerchant(ctx context.Context, merchantID string) error {
return s.merchantRepo.UpdateStatus(ctx, merchantID, model.MerchantStatusFrozen, nil)
}
// UnfreezeMerchant 解冻商户
func (s *MerchantService) UnfreezeMerchant(ctx context.Context, merchantID string) error {
return s.merchantRepo.UpdateStatus(ctx, merchantID, model.MerchantStatusActive, nil)
}

View File

@@ -0,0 +1,200 @@
package service
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"pay-bridge/internal/model"
)
// mockMerchantRepo 实现 merchantRepo interface
type mockMerchantRepo struct {
mock.Mock
}
func (m *mockMerchantRepo) Create(ctx context.Context, merchant *model.Merchant) error {
return m.Called(ctx, merchant).Error(0)
}
func (m *mockMerchantRepo) GetByMerchantID(ctx context.Context, merchantID string) (*model.Merchant, error) {
args := m.Called(ctx, merchantID)
return args.Get(0).(*model.Merchant), args.Error(1)
}
func (m *mockMerchantRepo) GetByMerchantIDAndAppID(ctx context.Context, merchantID, appID string) (*model.Merchant, error) {
args := m.Called(ctx, merchantID, appID)
v, _ := args.Get(0).(*model.Merchant)
return v, args.Error(1)
}
func (m *mockMerchantRepo) UpdateStatus(ctx context.Context, merchantID string, status model.MerchantStatus, updates map[string]any) error {
return m.Called(ctx, merchantID, status, updates).Error(0)
}
func (m *mockMerchantRepo) List(ctx context.Context, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error) {
args := m.Called(ctx, status, limit, offset)
return args.Get(0).([]*model.Merchant), args.Error(1)
}
func (m *mockMerchantRepo) ListByAppID(ctx context.Context, appID string, status model.MerchantStatus, limit, offset int) ([]*model.Merchant, error) {
args := m.Called(ctx, appID, status, limit, offset)
return args.Get(0).([]*model.Merchant), args.Error(1)
}
func (m *mockMerchantRepo) ListAnomalous(ctx context.Context) ([]*model.Merchant, error) {
args := m.Called(ctx)
return args.Get(0).([]*model.Merchant), args.Error(1)
}
func (m *mockMerchantRepo) CreateApplication(ctx context.Context, app *model.MerchantApplication) error {
return m.Called(ctx, app).Error(0)
}
func (m *mockMerchantRepo) GetLatestApplication(ctx context.Context, merchantID string) (*model.MerchantApplication, error) {
args := m.Called(ctx, merchantID)
v, _ := args.Get(0).(*model.MerchantApplication)
return v, args.Error(1)
}
func (m *mockMerchantRepo) GetApprovedApplicationByChannel(ctx context.Context, merchantID, channelCode string) (*model.MerchantApplication, error) {
args := m.Called(ctx, merchantID, channelCode)
v, _ := args.Get(0).(*model.MerchantApplication)
return v, args.Error(1)
}
func (m *mockMerchantRepo) UpdateApplication(ctx context.Context, applicationID string, updates map[string]any) error {
return m.Called(ctx, applicationID, updates).Error(0)
}
// newTestMerchantService 创建注入了 mock repo 的 servicechannelSvc 为 nil仅测不涉及渠道的方法
func newTestMerchantService(repo merchantRepo) *MerchantService {
return &MerchantService{merchantRepo: repo}
}
var ctx = context.Background()
// --- GetMerchantForApp ---
func TestGetMerchantForApp_OK(t *testing.T) {
repo := new(mockMerchantRepo)
want := &model.Merchant{MerchantID: "m001", AppID: "app1"}
repo.On("GetByMerchantIDAndAppID", ctx, "m001", "app1").Return(want, nil)
svc := newTestMerchantService(repo)
got, err := svc.GetMerchantForApp(ctx, "app1", "m001")
assert.NoError(t, err)
assert.Equal(t, want, got)
repo.AssertExpectations(t)
}
func TestGetMerchantForApp_NotFound(t *testing.T) {
repo := new(mockMerchantRepo)
repo.On("GetByMerchantIDAndAppID", ctx, "m001", "app1").Return((*model.Merchant)(nil), nil)
svc := newTestMerchantService(repo)
_, err := svc.GetMerchantForApp(ctx, "app1", "m001")
assert.EqualError(t, err, "30001")
}
func TestGetMerchantForApp_WrongAppID(t *testing.T) {
repo := new(mockMerchantRepo)
// 商户存在但属于 other_appGetByMerchantIDAndAppID 返回 nil
repo.On("GetByMerchantIDAndAppID", ctx, "m001", "evil_app").Return((*model.Merchant)(nil), nil)
svc := newTestMerchantService(repo)
_, err := svc.GetMerchantForApp(ctx, "evil_app", "m001")
assert.EqualError(t, err, "30001", "跨 appID 访问应返回 not found而不是泄露商户信息")
}
func TestGetMerchantForApp_DBError(t *testing.T) {
repo := new(mockMerchantRepo)
repo.On("GetByMerchantIDAndAppID", ctx, "m001", "app1").Return((*model.Merchant)(nil), errors.New("db error"))
svc := newTestMerchantService(repo)
_, err := svc.GetMerchantForApp(ctx, "app1", "m001")
assert.EqualError(t, err, "db error")
}
// --- CreateMerchantForApp ---
func TestCreateMerchantForApp_SetsAppID(t *testing.T) {
repo := new(mockMerchantRepo)
repo.On("Create", ctx, mock.MatchedBy(func(m *model.Merchant) bool {
return m.AppID == "app1" && m.MerchantID == "m001"
})).Return(nil)
svc := newTestMerchantService(repo)
m := &model.Merchant{MerchantID: "m001"}
err := svc.CreateMerchantForApp(ctx, "app1", m)
assert.NoError(t, err)
assert.Equal(t, "app1", m.AppID, "AppID 应被强制写入")
repo.AssertExpectations(t)
}
// --- ListMerchantsForApp ---
func TestListMerchantsForApp_OnlyReturnsOwnApp(t *testing.T) {
repo := new(mockMerchantRepo)
want := []*model.Merchant{{MerchantID: "m001", AppID: "app1"}}
repo.On("ListByAppID", ctx, "app1", model.MerchantStatus(""), 20, 0).Return(want, nil)
svc := newTestMerchantService(repo)
got, err := svc.ListMerchantsForApp(ctx, "app1", "", 20, 0)
assert.NoError(t, err)
assert.Len(t, got, 1)
repo.AssertExpectations(t)
}
// --- ApplyForApp ---
func TestApplyForApp_MerchantNotBelongToApp(t *testing.T) {
repo := new(mockMerchantRepo)
repo.On("GetByMerchantIDAndAppID", ctx, "m001", "app1").Return((*model.Merchant)(nil), nil)
svc := newTestMerchantService(repo)
_, err := svc.ApplyForApp(ctx, "app1", "m001", "HEEPAY", nil)
assert.EqualError(t, err, "30001", "不属于该 app 的商户不能提交进件")
}
// --- GetChannelMerchantID ---
func TestGetChannelMerchantID_Approved(t *testing.T) {
repo := new(mockMerchantRepo)
app := &model.MerchantApplication{
ChannelMerchantID: "ch_m_999",
}
repo.On("GetApprovedApplicationByChannel", ctx, "m001", "HEEPAY").Return(app, nil)
svc := newTestMerchantService(repo)
id, err := svc.GetChannelMerchantID(ctx, "m001", "HEEPAY")
assert.NoError(t, err)
assert.Equal(t, "ch_m_999", id)
}
func TestGetChannelMerchantID_NotApproved(t *testing.T) {
repo := new(mockMerchantRepo)
repo.On("GetApprovedApplicationByChannel", ctx, "m001", "ALIPAY").Return((*model.MerchantApplication)(nil), nil)
svc := newTestMerchantService(repo)
id, err := svc.GetChannelMerchantID(ctx, "m001", "ALIPAY")
assert.NoError(t, err)
assert.Empty(t, id, "未在该渠道进件时返回空字符串")
}
func TestGetChannelMerchantID_MultiChannel(t *testing.T) {
repo := new(mockMerchantRepo)
repo.On("GetApprovedApplicationByChannel", ctx, "m001", "HEEPAY").
Return(&model.MerchantApplication{ChannelMerchantID: "hee_001"}, nil)
repo.On("GetApprovedApplicationByChannel", ctx, "m001", "ALIPAY").
Return(&model.MerchantApplication{ChannelMerchantID: "ali_001"}, nil)
svc := newTestMerchantService(repo)
heeID, _ := svc.GetChannelMerchantID(ctx, "m001", "HEEPAY")
aliID, _ := svc.GetChannelMerchantID(ctx, "m001", "ALIPAY")
assert.Equal(t, "hee_001", heeID, "不同渠道应返回各自的 channel_merchant_id")
assert.Equal(t, "ali_001", aliID)
}

View File

@@ -0,0 +1,229 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
)
// 重试间隔9 次推送机会第1次立即后续8次重试
var retryIntervals = []time.Duration{
0,
15 * time.Second,
30 * time.Second,
1 * time.Minute,
5 * time.Minute,
30 * time.Minute,
1 * time.Hour,
6 * time.Hour,
12 * time.Hour,
}
const maxRetry = 8
// NotifyService 通知服务
type NotifyService struct {
notifyRepo *repository.NotifyLogRepository
tradeRepo *repository.TradeOrderRepository
httpClient *http.Client
}
func NewNotifyService(
notifyRepo *repository.NotifyLogRepository,
tradeRepo *repository.TradeOrderRepository,
httpTimeout time.Duration,
) *NotifyService {
return &NotifyService{
notifyRepo: notifyRepo,
tradeRepo: tradeRepo,
httpClient: &http.Client{Timeout: httpTimeout},
}
}
// SendNotify 向下游发送通知(首次调用)
func (s *NotifyService) SendNotify(ctx context.Context, tradeNo string, notifyType model.NotifyType, notifyURL string) error {
// 构建通知内容
payload, err := s.buildPayload(ctx, tradeNo, notifyType)
if err != nil {
return err
}
// 创建通知记录
now := time.Now()
log := &model.NotifyLog{
TradeNo: tradeNo,
NotifyType: notifyType,
NotifyURL: notifyURL,
Status: model.NotifyStatusPending,
RetryCount: 0,
}
if err := s.notifyRepo.Upsert(ctx, log); err != nil {
slog.ErrorContext(ctx, "upsert notify log failed", "trade_no", tradeNo, "err", err)
}
// 发送通知
resp, err := s.sendHTTP(ctx, notifyURL, payload)
if err == nil && isSuccessResponse(resp) {
s.notifyRepo.MarkSuccess(ctx, log.ID, resp)
slog.InfoContext(ctx, "notify success", "trade_no", tradeNo, "type", notifyType)
return nil
}
// 首次失败,写入重试队列
errMsg := ""
if err != nil {
errMsg = err.Error()
} else {
errMsg = resp
}
nextTime := now.Add(retryIntervals[1])
s.notifyRepo.IncrRetryCount(ctx, log.ID, model.NotifyStatusRetry, &nextTime, errMsg)
slog.WarnContext(ctx, "notify failed, scheduled retry", "trade_no", tradeNo, "next_retry", nextTime)
return nil
}
// ProcessRetryQueue 处理重试队列(由 Poller 调用)
func (s *NotifyService) ProcessRetryQueue(ctx context.Context, batchSize int) error {
logs, err := s.notifyRepo.ListPendingRetry(ctx, time.Now(), batchSize)
if err != nil {
return err
}
for _, log := range logs {
s.processOne(ctx, log)
}
return nil
}
func (s *NotifyService) processOne(ctx context.Context, log *model.NotifyLog) {
payload, err := s.buildPayload(ctx, log.TradeNo, log.NotifyType)
if err != nil {
slog.ErrorContext(ctx, "build payload failed", "trade_no", log.TradeNo, "err", err)
return
}
resp, err := s.sendHTTP(ctx, log.NotifyURL, payload)
if err == nil && isSuccessResponse(resp) {
s.notifyRepo.MarkSuccess(ctx, log.ID, resp)
slog.InfoContext(ctx, "notify retry success", "trade_no", log.TradeNo, "retry_count", log.RetryCount)
return
}
errMsg := ""
if err != nil {
errMsg = err.Error()
} else {
errMsg = resp
}
nextRetryIdx := log.RetryCount + 1
if nextRetryIdx > maxRetry {
s.notifyRepo.MarkGiveup(ctx, log.ID)
slog.WarnContext(ctx, "notify giveup after max retries", "trade_no", log.TradeNo)
return
}
var nextTime *time.Time
if nextRetryIdx < len(retryIntervals) {
t := time.Now().Add(retryIntervals[nextRetryIdx])
nextTime = &t
}
status := model.NotifyStatusRetry
if nextRetryIdx >= maxRetry {
status = model.NotifyStatusGiveup
}
s.notifyRepo.IncrRetryCount(ctx, log.ID, status, nextTime, errMsg)
}
// buildPayload 构建通知内容
func (s *NotifyService) buildPayload(ctx context.Context, tradeNo string, notifyType model.NotifyType) ([]byte, error) {
order, err := s.tradeRepo.GetByTradeNo(ctx, tradeNo)
if err != nil || order == nil {
return nil, fmt.Errorf("order not found: %s", tradeNo)
}
payload := map[string]any{
"trade_no": order.TradeNo,
"merchant_order_no": order.MerchantOrderNo,
"app_id": order.AppID,
"pay_method": order.PayMethod,
"amount": order.Amount,
"status": order.Status,
"notify_type": notifyType,
"timestamp": time.Now().Unix(),
}
if order.ChannelTradeNo != "" {
payload["channel_trade_no"] = order.ChannelTradeNo
}
if order.PayTime != nil {
payload["pay_time"] = order.PayTime.Unix()
}
return json.Marshal(payload)
}
// sendHTTP 向下游发送 HTTP POST 通知
func (s *NotifyService) sendHTTP(ctx context.Context, notifyURL string, payload []byte) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return string(body), nil
}
// isSuccessResponse 判断下游是否返回成功
// 下游返回 HTTP 200 且 body 包含 "success" 则视为成功
func isSuccessResponse(body string) bool {
return strings.Contains(strings.ToLower(body), "success")
}
// NextRetryTime 计算下次重试时间
func NextRetryTime(retryCount int) (time.Time, bool) {
idx := retryCount + 1
if idx >= len(retryIntervals) {
return time.Time{}, false
}
return time.Now().Add(retryIntervals[idx]), true
}
// StartPoller 启动通知重试 Poller goroutine
func (s *NotifyService) StartPoller(ctx context.Context, interval time.Duration, batchSize int) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.ProcessRetryQueue(ctx, batchSize); err != nil {
slog.Error("notify poller error", "err", err)
}
}
}
}()
slog.Info("notify poller started", "interval", interval)
}

View File

@@ -0,0 +1,280 @@
package service
import (
"context"
"log/slog"
"regexp"
"strings"
"time"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
)
// orderNoPatterns 从备注中提取订单号的正则列表(优先级从高到低)
var orderNoPatterns = []*regexp.Regexp{
regexp.MustCompile(`PAY\d{14}`), // pay-bridge 交易号格式 PAYyyMMddNNNNNNNN
regexp.MustCompile(`REF\d{14}`), // 退款单号
regexp.MustCompile(`[A-Z0-9]{16,32}`), // 通用订单号格式
}
// IncomingPayment 入账通知数据
type IncomingPayment struct {
AccountNo string // 收款账号
Amount int64 // 入账金额(分)
Remark string // 转账备注
PayerName string // 付款方名称
ChannelBillNo string // 渠道流水号
}
// matchWindow 匹配时间窗口7天内的待支付订单
const matchWindow = 7 * 24 * time.Hour
// PaymentMatchService 收款匹配服务
type PaymentMatchService struct {
matchRepo *repository.PaymentMatchRepository
tradeRepo *repository.TradeOrderRepository
notifySvc *NotifyService
tradeSvc *TradeService
}
func NewPaymentMatchService(
matchRepo *repository.PaymentMatchRepository,
tradeRepo *repository.TradeOrderRepository,
notifySvc *NotifyService,
tradeSvc *TradeService,
) *PaymentMatchService {
return &PaymentMatchService{
matchRepo: matchRepo,
tradeRepo: tradeRepo,
notifySvc: notifySvc,
tradeSvc: tradeSvc,
}
}
// HandleIncomingPayment 处理入账通知(核心匹配流程)
func (s *PaymentMatchService) HandleIncomingPayment(ctx context.Context, incoming *IncomingPayment) error {
// 幂等检查
if existing, _ := s.matchRepo.GetMatchLogByBillNo(ctx, incoming.ChannelBillNo); existing != nil {
return nil
}
// 查询收款账户
account, err := s.matchRepo.GetAccountByNo(ctx, incoming.AccountNo)
if err != nil {
return err
}
if account == nil {
slog.WarnContext(ctx, "incoming payment: account not found", "account_no", incoming.AccountNo)
return nil
}
// 执行匹配
result := s.match(ctx, incoming, account)
// 记录匹配结果
now := time.Now()
log := &model.PaymentMatchLog{
AccountID: account.ID,
IncomingAmount: incoming.Amount,
IncomingRemark: incoming.Remark,
PayerName: incoming.PayerName,
ChannelBillNo: incoming.ChannelBillNo,
MatchStatus: result.status,
NameDiff: result.nameDiff,
}
if result.tradeNo != "" {
log.TradeNo = result.tradeNo
log.MatchTime = &now
}
if err := s.matchRepo.CreateMatchLog(ctx, log); err != nil {
return err
}
// 匹配成功:更新订单状态并通知下游
if result.tradeNo != "" {
updates := map[string]any{
"status": model.TradeStatusPaid,
"pay_time": now,
}
ok, err := s.tradeRepo.UpdateStatus(ctx, result.tradeNo, model.TradeStatusPaying, model.TradeStatusPaid, updates)
if err != nil {
return err
}
if ok {
order, _ := s.tradeRepo.GetByTradeNo(ctx, result.tradeNo)
if order != nil && s.notifySvc != nil {
go func() {
bgCtx := context.Background()
s.notifySvc.SendNotify(bgCtx, result.tradeNo, model.NotifyTypePayment, order.NotifyURL)
}()
}
}
slog.InfoContext(ctx, "payment matched",
"trade_no", result.tradeNo,
"amount", incoming.Amount,
"status", result.status,
"name_diff", result.nameDiff,
)
} else {
slog.InfoContext(ctx, "payment pending manual",
"channel_bill_no", incoming.ChannelBillNo,
"amount", incoming.Amount,
)
}
return nil
}
// ManualBindOrder 人工关联入账与订单
func (s *PaymentMatchService) ManualBindOrder(ctx context.Context, matchID uint64, tradeNo, operator string) error {
order, err := s.tradeRepo.GetByTradeNo(ctx, tradeNo)
if err != nil || order == nil {
return err
}
now := time.Now()
updates := map[string]any{
"trade_no": tradeNo,
"match_status": model.MatchStatusMatched,
"match_time": now,
"operator": operator,
}
if err := s.matchRepo.UpdateMatchLog(ctx, matchID, updates); err != nil {
return err
}
// 更新订单状态
s.tradeRepo.UpdateStatus(ctx, tradeNo, model.TradeStatusPaying, model.TradeStatusPaid,
map[string]any{"pay_time": now})
if s.notifySvc != nil {
go func() {
bgCtx := context.Background()
s.notifySvc.SendNotify(bgCtx, tradeNo, model.NotifyTypePayment, order.NotifyURL)
}()
}
return nil
}
// ListPendingManual 查询待人工确认的收款记录
func (s *PaymentMatchService) ListPendingManual(ctx context.Context, appID string, limit, offset int) ([]*model.PaymentMatchLog, error) {
return s.matchRepo.ListPendingManual(ctx, appID, limit, offset)
}
// --- 内部匹配逻辑 ---
type matchResult struct {
tradeNo string
status model.MatchStatus
nameDiff int8
}
func (s *PaymentMatchService) match(ctx context.Context, incoming *IncomingPayment, account *model.SubMerchantAccount) matchResult {
// Step 1: 从备注中提取订单号
candidates := extractOrderNos(incoming.Remark)
var matched *model.TradeOrder
for _, orderNo := range candidates {
// 先按 trade_no 查,再按 merchant_order_no 查
order, _ := s.tradeRepo.GetByTradeNo(ctx, orderNo)
if order == nil {
order, _ = s.tradeRepo.GetByMerchantOrderNo(ctx, account.AppID, orderNo)
}
if order == nil || order.AppID != account.AppID {
continue
}
if order.Status != model.TradeStatusPaying {
continue
}
// Step 2: 金额精确匹配
if order.Amount != incoming.Amount {
continue
}
matched = order
break
}
// 备注匹配失败,降级为金额匹配
if matched == nil {
orders, _ := s.matchRepo.ListPayingByAmount(ctx, account.AppID, incoming.Amount, matchWindow)
if len(orders) == 1 {
matched = orders[0]
} else if len(orders) > 1 {
// Step 3: 用付款方名称缩小范围
matched = filterByPayerName(orders, incoming.PayerName)
if matched == nil {
return matchResult{status: model.MatchStatusPendingManual}
}
} else {
return matchResult{status: model.MatchStatusPendingManual}
}
}
// Step 3: 付款方名称一致性检查
var nameDiff int8 = 0
invoiceName := getInvoiceName(matched)
if invoiceName != "" && incoming.PayerName != "" {
if !strings.EqualFold(strings.TrimSpace(invoiceName), strings.TrimSpace(incoming.PayerName)) {
nameDiff = 1
}
}
status := model.MatchStatusMatched
if nameDiff == 1 {
status = model.MatchStatusNameDiff
}
return matchResult{
tradeNo: matched.TradeNo,
status: status,
nameDiff: nameDiff,
}
}
// extractOrderNos 从备注字符串中提取可能的订单号
func extractOrderNos(remark string) []string {
if remark == "" {
return nil
}
var results []string
seen := map[string]bool{}
for _, re := range orderNoPatterns {
matches := re.FindAllString(remark, -1)
for _, m := range matches {
if !seen[m] {
seen[m] = true
results = append(results, m)
}
}
}
return results
}
// filterByPayerName 从多个候选订单中,选择 invoice_name 与付款方名称匹配的订单
// invoice_name 暂存在 extra 字段中
func filterByPayerName(orders []*model.TradeOrder, payerName string) *model.TradeOrder {
if payerName == "" {
return nil
}
for _, o := range orders {
name := getInvoiceName(o)
if name != "" && strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(payerName)) {
return o
}
}
return nil
}
// getInvoiceName 从 extra 字段获取开票名称
func getInvoiceName(order *model.TradeOrder) string {
if order.Extra == nil {
return ""
}
if v, ok := order.Extra["invoice_name"]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}

View File

@@ -0,0 +1,268 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/go-redis/redis/v8"
"pay-bridge/internal/channel"
"pay-bridge/internal/errcode"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
"pay-bridge/pkg/sequence"
)
const (
sharingLockPrefix = "lock:sharing:"
sharingLockTTL = 30 * time.Second
)
// ProfitSharingService 分润服务
type ProfitSharingService struct {
sharingRepo *repository.ProfitSharingRepository
tradeRepo *repository.TradeOrderRepository
channelSvc *ChannelService
seqSvc *sequence.Service
rdb *redis.Client
}
func NewProfitSharingService(
sharingRepo *repository.ProfitSharingRepository,
tradeRepo *repository.TradeOrderRepository,
channelSvc *ChannelService,
seqSvc *sequence.Service,
rdb *redis.Client,
) *ProfitSharingService {
return &ProfitSharingService{
sharingRepo: sharingRepo,
tradeRepo: tradeRepo,
channelSvc: channelSvc,
seqSvc: seqSvc,
rdb: rdb,
}
}
// TriggerSharing 支付成功后触发分润(幂等)
func (s *ProfitSharingService) TriggerSharing(ctx context.Context, tradeNo string) error {
// 分布式锁防止并发重复触发
lockKey := sharingLockPrefix + tradeNo
ok, err := s.rdb.SetNX(ctx, lockKey, "1", sharingLockTTL).Result()
if err != nil {
return fmt.Errorf("acquire sharing lock: %w", err)
}
if !ok {
return nil // 已有进程在处理
}
defer s.rdb.Del(ctx, lockKey)
// 查询交易
order, err := s.tradeRepo.GetByTradeNo(ctx, tradeNo)
if err != nil || order == nil {
return errors.New(errcode.ErrOrderNotFound)
}
if order.ProfitSharingAmount <= 0 {
return nil // 无需分润
}
// 幂等检查:是否已有分润记录
existing, err := s.sharingRepo.GetOrderByTradeNo(ctx, tradeNo)
if err != nil {
return err
}
if existing != nil {
return nil // 已触发过
}
// 获取应用分润配置
cfg, err := s.sharingRepo.GetConfigByAppID(ctx, order.AppID)
if err != nil {
return err
}
if cfg == nil {
return errors.New(errcode.ErrSharingNotConfig)
}
// 校验分润比例
maxAmount := int64(float64(order.Amount) * cfg.MaxSharingRatio)
if order.ProfitSharingAmount > maxAmount {
return errors.New(errcode.ErrSharingAmountExceed)
}
// 生成分润单号
sharingNo, err := s.seqSvc.NextSharingNo(ctx, order.AppID)
if err != nil {
return err
}
// 创建分润记录
sharingOrder := &model.ProfitSharingOrder{
SharingNo: sharingNo,
TradeNo: tradeNo,
AppID: order.AppID,
ReceiverMerchantID: cfg.ReceiverMerchantID,
SharingAmount: order.ProfitSharingAmount,
Status: model.ProfitSharingStatusPending,
}
if err := s.sharingRepo.CreateOrder(ctx, sharingOrder); err != nil {
return err
}
// 调用渠道分账
ch, err := s.channelSvc.GetChannel(ctx, order.AppID, order.ChannelCode)
if err != nil {
return err
}
resp, err := ch.ProfitSharing(ctx, &channel.ProfitSharingReq{
TradeNo: tradeNo,
ChannelTradeNo: order.ChannelTradeNo,
SharingNo: sharingNo,
ReceiverMerchantID: cfg.ReceiverMerchantID,
Amount: order.ProfitSharingAmount,
})
if err != nil {
s.sharingRepo.UpdateOrderStatus(ctx, sharingNo,
model.ProfitSharingStatusPending,
model.ProfitSharingStatusFailed,
map[string]any{"fail_reason": err.Error()},
)
s.sharingRepo.CreateLog(ctx, &model.ProfitSharingLog{
SharingNo: sharingNo,
Action: "SPLIT",
Amount: order.ProfitSharingAmount,
Status: "FAILED",
})
return fmt.Errorf("profit sharing failed: %w", err)
}
now := time.Now()
s.sharingRepo.UpdateOrderStatus(ctx, sharingNo,
model.ProfitSharingStatusPending,
model.ProfitSharingStatusProcessing,
map[string]any{
"channel_sharing_no": resp.ChannelSharingNo,
"sharing_time": now,
},
)
s.sharingRepo.CreateLog(ctx, &model.ProfitSharingLog{
SharingNo: sharingNo,
Action: "SPLIT",
Amount: order.ProfitSharingAmount,
Status: "PROCESSING",
})
slog.InfoContext(ctx, "profit sharing triggered",
"trade_no", tradeNo,
"sharing_no", sharingNo,
"amount", order.ProfitSharingAmount,
)
return nil
}
// HandleSharingNotify 处理分账回调(上游分账完成通知)
func (s *ProfitSharingService) HandleSharingNotify(ctx context.Context, sharingNo, channelSharingNo string, status model.ProfitSharingStatus) error {
now := time.Now()
updates := map[string]any{
"channel_sharing_no": channelSharingNo,
"sharing_time": now,
}
ok, err := s.sharingRepo.UpdateOrderStatus(ctx, sharingNo,
model.ProfitSharingStatusProcessing, status, updates)
if err != nil {
return err
}
if !ok {
return nil // 幂等
}
logStatus := string(status)
s.sharingRepo.CreateLog(ctx, &model.ProfitSharingLog{
SharingNo: sharingNo,
Action: "SPLIT",
Amount: 0,
Status: logStatus,
})
return nil
}
// RollbackSharing 退款前回退分润
func (s *ProfitSharingService) RollbackSharing(ctx context.Context, tradeNo string) error {
sharingOrder, err := s.sharingRepo.GetOrderByTradeNo(ctx, tradeNo)
if err != nil {
return err
}
if sharingOrder == nil {
return nil // 无分润,直接跳过
}
if sharingOrder.Status == model.ProfitSharingStatusRollback {
return nil // 已回退,幂等
}
if sharingOrder.Status != model.ProfitSharingStatusSuccess {
return fmt.Errorf("sharing not success, cannot rollback, status=%s", sharingOrder.Status)
}
order, err := s.tradeRepo.GetByTradeNo(ctx, tradeNo)
if err != nil || order == nil {
return errors.New(errcode.ErrOrderNotFound)
}
ch, err := s.channelSvc.GetChannel(ctx, order.AppID, order.ChannelCode)
if err != nil {
return err
}
if err := ch.RollbackProfitSharing(ctx, &channel.RollbackSharingReq{
SharingNo: sharingOrder.SharingNo,
ChannelSharingNo: sharingOrder.ChannelSharingNo,
TradeNo: tradeNo,
}); err != nil {
return fmt.Errorf("rollback sharing failed: %w", err)
}
s.sharingRepo.UpdateOrderStatus(ctx, sharingOrder.SharingNo,
model.ProfitSharingStatusSuccess,
model.ProfitSharingStatusRollback,
nil,
)
s.sharingRepo.CreateLog(ctx, &model.ProfitSharingLog{
SharingNo: sharingOrder.SharingNo,
Action: "ROLLBACK",
Amount: sharingOrder.SharingAmount,
Status: "SUCCESS",
})
return nil
}
// QuerySharing 查询分润状态
func (s *ProfitSharingService) QuerySharing(ctx context.Context, sharingNo string) (*model.ProfitSharingOrder, error) {
order, err := s.sharingRepo.GetOrderBySharingNo(ctx, sharingNo)
if err != nil {
return nil, err
}
if order == nil {
return nil, errors.New(errcode.ErrOrderNotFound)
}
return order, nil
}
// ValidateSharingAmount 下单时校验分润金额是否合法
func (s *ProfitSharingService) ValidateSharingAmount(ctx context.Context, appID string, orderAmount, sharingAmount int64) error {
if sharingAmount <= 0 {
return nil
}
cfg, err := s.sharingRepo.GetConfigByAppID(ctx, appID)
if err != nil {
return err
}
if cfg == nil {
return errors.New(errcode.ErrSharingNotConfig)
}
maxAmount := int64(float64(orderAmount) * cfg.MaxSharingRatio)
if sharingAmount > maxAmount {
return errors.New(errcode.ErrSharingAmountExceed)
}
return nil
}

View File

@@ -0,0 +1,221 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
)
// ReconciliationService T+1 自动对账服务
type ReconciliationService struct {
reconRepo *repository.ReconciliationRepository
tradeRepo *repository.TradeOrderRepository
channelSvc *ChannelService
appRepo *repository.AppRepository
}
func NewReconciliationService(
reconRepo *repository.ReconciliationRepository,
tradeRepo *repository.TradeOrderRepository,
channelSvc *ChannelService,
appRepo *repository.AppRepository,
) *ReconciliationService {
return &ReconciliationService{
reconRepo: reconRepo,
tradeRepo: tradeRepo,
channelSvc: channelSvc,
appRepo: appRepo,
}
}
// RunDailyReconciliation 执行 T+1 对账cron 每日触发)
func (s *ReconciliationService) RunDailyReconciliation(ctx context.Context) error {
// 对账日期:昨天
billDate := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
slog.InfoContext(ctx, "reconciliation started", "bill_date", billDate)
apps, err := s.appRepo.ListActive(ctx)
if err != nil {
return err
}
for _, app := range apps {
if err := s.reconcileApp(ctx, app.AppID, billDate); err != nil {
slog.ErrorContext(ctx, "reconciliation failed for app",
"app_id", app.AppID,
"bill_date", billDate,
"error", err,
)
}
}
return nil
}
// reconcileApp 对指定应用执行对账
func (s *ReconciliationService) reconcileApp(ctx context.Context, appID, billDate string) error {
// 获取所有活跃渠道配置
channelCodes, err := s.channelSvc.ListChannelCodes(ctx, appID)
if err != nil {
return err
}
for _, code := range channelCodes {
if err := s.reconcileChannel(ctx, appID, code, billDate); err != nil {
slog.ErrorContext(ctx, "channel reconciliation failed",
"app_id", appID,
"channel", code,
"bill_date", billDate,
"error", err,
)
}
}
return nil
}
// reconcileChannel 对单个渠道执行对账
func (s *ReconciliationService) reconcileChannel(ctx context.Context, appID, channelCode, billDate string) error {
// 幂等检查
existing, err := s.reconRepo.GetReport(ctx, appID, billDate, channelCode)
if err != nil {
return err
}
if existing != nil && existing.Status == model.ReconciliationStatusMatched {
return nil // 已对账完成
}
// 创建对账报告
report := &model.ReconciliationReport{
AppID: appID,
ChannelCode: channelCode,
BillDate: billDate,
Status: model.ReconciliationStatusPending,
}
if existing == nil {
if err := s.reconRepo.CreateReport(ctx, report); err != nil {
return err
}
} else {
report = existing
}
// 下载渠道对账单
ch, err := s.channelSvc.GetChannel(ctx, appID, channelCode)
if err != nil {
return err
}
billData, err := ch.DownloadBill(ctx, &channel.DownloadBillReq{BillDate: billDate})
if err != nil {
return fmt.Errorf("download bill: %w", err)
}
// 查询本地已支付订单
localOrders, err := s.reconRepo.ListPaidOrdersByDate(ctx, appID, billDate)
if err != nil {
return err
}
// 建立本地订单索引
localIndex := make(map[string]*model.TradeOrder, len(localOrders))
for _, o := range localOrders {
localIndex[o.TradeNo] = o
}
// 建立渠道账单索引
channelIndex := make(map[string]*channel.BillRecord, len(billData.Records))
for i := range billData.Records {
channelIndex[billData.Records[i].TradeNo] = &billData.Records[i]
}
matched := 0
exceptions := 0
// 检查渠道账单中有,本地没有的(漏单)
for _, rec := range billData.Records {
local, ok := localIndex[rec.TradeNo]
if !ok {
// 本地缺失
ex := &model.ReconciliationException{
ReportID: report.ID,
TradeNo: rec.TradeNo,
ChannelBillNo: rec.ChannelBillNo,
ExceptionType: "MISSING_LOCAL",
ChannelAmount: rec.Amount,
Remark: "渠道有记录,本地无此订单",
}
s.reconRepo.CreateException(ctx, ex)
exceptions++
continue
}
// 金额比对
if local.Amount != rec.Amount {
ex := &model.ReconciliationException{
ReportID: report.ID,
TradeNo: rec.TradeNo,
ChannelBillNo: rec.ChannelBillNo,
ExceptionType: "AMOUNT_MISMATCH",
LocalAmount: local.Amount,
ChannelAmount: rec.Amount,
Remark: fmt.Sprintf("金额不符:本地%d 渠道%d", local.Amount, rec.Amount),
}
s.reconRepo.CreateException(ctx, ex)
exceptions++
} else {
matched++
}
}
// 检查本地有,渠道账单中没有的(多单)
for tradeNo, local := range localIndex {
if _, ok := channelIndex[tradeNo]; !ok {
ex := &model.ReconciliationException{
ReportID: report.ID,
TradeNo: tradeNo,
ExceptionType: "MISSING_CHANNEL",
LocalAmount: local.Amount,
Remark: "本地已支付,渠道账单无记录",
}
s.reconRepo.CreateException(ctx, ex)
exceptions++
}
}
// 更新对账报告
status := model.ReconciliationStatusMatched
if exceptions > 0 {
status = model.ReconciliationStatusException
}
updates := map[string]any{
"total_count": len(billData.Records),
"total_amount": billData.TotalAmount,
"matched_count": matched,
"exception_count": exceptions,
"status": status,
}
if err := s.reconRepo.UpdateReport(ctx, report.ID, updates); err != nil {
return err
}
slog.InfoContext(ctx, "reconciliation done",
"app_id", appID,
"channel", channelCode,
"bill_date", billDate,
"matched", matched,
"exceptions", exceptions,
)
return nil
}
// GetReport 查询对账报告
func (s *ReconciliationService) GetReport(ctx context.Context, appID, billDate, channelCode string) (*model.ReconciliationReport, error) {
return s.reconRepo.GetReport(ctx, appID, billDate, channelCode)
}
// GetExceptions 查询对账异常明细
func (s *ReconciliationService) GetExceptions(ctx context.Context, reportID uint64) ([]*model.ReconciliationException, error) {
return s.reconRepo.ListExceptions(ctx, reportID)
}

View File

@@ -0,0 +1,213 @@
package service
import (
"context"
"errors"
"log/slog"
"time"
"pay-bridge/internal/channel"
"pay-bridge/internal/errcode"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
"pay-bridge/pkg/sequence"
)
// CreateRefundReq 退款请求
type CreateRefundReq struct {
AppID string
TradeNo string
RefundAmount int64
Reason string
NotifyURL string
}
// RefundService 退款服务
type RefundService struct {
refundRepo *repository.RefundOrderRepository
tradeRepo *repository.TradeOrderRepository
channelSvc *ChannelService
seqSvc *sequence.Service
notifySvc *NotifyService
}
func NewRefundService(
refundRepo *repository.RefundOrderRepository,
tradeRepo *repository.TradeOrderRepository,
channelSvc *ChannelService,
seqSvc *sequence.Service,
notifySvc *NotifyService,
) *RefundService {
return &RefundService{
refundRepo: refundRepo,
tradeRepo: tradeRepo,
channelSvc: channelSvc,
seqSvc: seqSvc,
notifySvc: notifySvc,
}
}
// CreateRefund 发起退款
func (s *RefundService) CreateRefund(ctx context.Context, req *CreateRefundReq) (*model.RefundOrder, error) {
// 查询原交易
order, err := s.tradeRepo.GetByTradeNo(ctx, req.TradeNo)
if err != nil {
return nil, err
}
if order == nil || order.AppID != req.AppID {
return nil, errors.New(errcode.ErrOrderNotFound)
}
if order.Status != model.TradeStatusPaid && order.Status != model.TradeStatusRefunded {
return nil, errors.New(errcode.ErrOrderNotPaid)
}
// 校验可退金额
refunded, err := s.refundRepo.SumRefundedAmount(ctx, req.TradeNo)
if err != nil {
return nil, err
}
if refunded+req.RefundAmount > order.Amount {
return nil, errors.New(errcode.ErrRefundAmountExceed)
}
// 生成退款单号
refundNo, err := s.seqSvc.NextRefundNo(ctx, req.AppID)
if err != nil {
return nil, err
}
// 创建退款记录
refund := &model.RefundOrder{
RefundNo: refundNo,
TradeNo: req.TradeNo,
AppID: req.AppID,
ChannelCode: order.ChannelCode,
RefundAmount: req.RefundAmount,
Reason: req.Reason,
Status: model.RefundStatusPending,
NotifyURL: req.NotifyURL,
}
if err := s.refundRepo.Create(ctx, refund); err != nil {
return nil, err
}
// 调用渠道退款
ch, err := s.channelSvc.GetChannel(ctx, req.AppID, order.ChannelCode)
if err != nil {
return nil, err
}
channelResp, err := ch.Refund(ctx, &channel.RefundReq{
TradeNo: req.TradeNo,
ChannelTradeNo: order.ChannelTradeNo,
RefundNo: refundNo,
RefundAmount: req.RefundAmount,
TotalAmount: order.Amount,
Reason: req.Reason,
NotifyURL: req.NotifyURL,
})
if err != nil {
s.refundRepo.UpdateStatus(ctx, refundNo, model.RefundStatusPending, model.RefundStatusFailed, nil)
return nil, errors.New(errcode.ErrChannelRefundFail)
}
// 更新渠道退款单号
updates := map[string]any{
"channel_refund_no": channelResp.ChannelRefundNo,
"status": model.RefundStatusProcessing,
}
s.refundRepo.UpdateStatus(ctx, refundNo, model.RefundStatusPending, model.RefundStatusProcessing, updates)
refund.ChannelRefundNo = channelResp.ChannelRefundNo
refund.Status = model.RefundStatusProcessing
return refund, nil
}
// QueryRefund 查询退款状态
func (s *RefundService) QueryRefund(ctx context.Context, appID, refundNo string) (*model.RefundOrder, error) {
refund, err := s.refundRepo.GetByRefundNo(ctx, refundNo)
if err != nil {
return nil, err
}
if refund == nil || refund.AppID != appID {
return nil, errors.New(errcode.ErrRefundNotFound)
}
// 如果处于处理中,主动查询渠道
if refund.Status == model.RefundStatusProcessing {
s.syncRefundStatus(ctx, refund)
// 重新查询最新状态
refund, _ = s.refundRepo.GetByRefundNo(ctx, refundNo)
}
return refund, nil
}
// HandleRefundNotify 处理退款回调
func (s *RefundService) HandleRefundNotify(ctx context.Context, refundNo string, channelRefundNo string, status model.RefundStatus) error {
refund, err := s.refundRepo.GetByRefundNo(ctx, refundNo)
if err != nil || refund == nil {
return errors.New(errcode.ErrRefundNotFound)
}
if refund.Status == model.RefundStatusSuccess {
return nil // 幂等
}
updates := map[string]any{
"channel_refund_no": channelRefundNo,
}
if status == model.RefundStatusSuccess {
now := time.Now()
updates["refund_time"] = now
}
ok, err := s.refundRepo.UpdateStatus(ctx, refundNo, model.RefundStatusProcessing, status, updates)
if err != nil {
return err
}
if !ok {
return nil // 幂等
}
// 退款成功后通知下游
if status == model.RefundStatusSuccess && refund.NotifyURL != "" && s.notifySvc != nil {
go func() {
bgCtx := context.Background()
if err := s.notifySvc.SendNotify(bgCtx, refund.TradeNo, model.NotifyTypeRefund, refund.NotifyURL); err != nil {
slog.Error("send refund notify failed", "refund_no", refundNo, "err", err)
}
}()
}
return nil
}
func (s *RefundService) syncRefundStatus(ctx context.Context, refund *model.RefundOrder) {
order, err := s.tradeRepo.GetByTradeNo(ctx, refund.TradeNo)
if err != nil || order == nil {
return
}
ch, err := s.channelSvc.GetChannel(ctx, refund.AppID, refund.ChannelCode)
if err != nil {
return
}
resp, err := ch.QueryRefund(ctx, &channel.QueryRefundReq{
RefundNo: refund.RefundNo,
ChannelRefundNo: refund.ChannelRefundNo,
})
if err != nil {
return
}
if resp.Status != refund.Status {
updates := map[string]any{
"channel_refund_no": resp.ChannelRefundNo,
}
if resp.RefundTime != nil {
updates["refund_time"] = resp.RefundTime
}
s.refundRepo.UpdateStatus(ctx, refund.RefundNo, refund.Status, resp.Status, updates)
}
}

View File

@@ -0,0 +1,189 @@
package service
import (
"context"
"fmt"
"log/slog"
"math"
"pay-bridge/internal/channel"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
)
// ServiceFeeService 服务费服务
type ServiceFeeService struct {
feeRepo *repository.ServiceFeeRepository
tradeRepo *repository.TradeOrderRepository
channelSvc *ChannelService
}
func NewServiceFeeService(
feeRepo *repository.ServiceFeeRepository,
tradeRepo *repository.TradeOrderRepository,
channelSvc *ChannelService,
) *ServiceFeeService {
return &ServiceFeeService{
feeRepo: feeRepo,
tradeRepo: tradeRepo,
channelSvc: channelSvc,
}
}
// ChargeServiceFee 交易完成后扣收服务费
func (s *ServiceFeeService) ChargeServiceFee(ctx context.Context, tradeNo string) error {
order, err := s.tradeRepo.GetByTradeNo(ctx, tradeNo)
if err != nil || order == nil {
return fmt.Errorf("order not found: %s", tradeNo)
}
// 幂等检查
existing, err := s.feeRepo.GetLog(ctx, tradeNo, "CHARGE")
if err != nil {
return err
}
if existing != nil {
return nil // 已扣收
}
// 获取服务费配置
group := model.PayMethodToGroup(order.PayMethod)
cfg, err := s.feeRepo.GetConfig(ctx, order.AppID, group)
if err != nil {
return err
}
if cfg == nil || cfg.FeeRate == 0 {
return nil // 未配置或费率为0
}
// 计算服务费(四舍五入到分)
feeAmount := calculateFee(order.Amount, cfg.FeeRate)
if feeAmount <= 0 {
return nil // 不足1分不扣收
}
// 更新订单服务费金额快照
s.tradeRepo.UpdateStatus(ctx, tradeNo, model.TradeStatusPaid, model.TradeStatusPaid,
map[string]any{"service_fee_amount": feeAmount})
// 创建服务费流水
log := &model.ServiceFeeLog{
TradeNo: tradeNo,
ConfigID: cfg.ID,
FeeAmount: feeAmount,
FeeRate: cfg.FeeRate,
ReceiverMerchantID: cfg.FeeReceiverMerchantID,
Action: "CHARGE",
Status: "PENDING",
}
if err := s.feeRepo.CreateLog(ctx, log); err != nil {
return err
}
// 调用渠道分账
ch, err := s.channelSvc.GetChannel(ctx, order.AppID, order.ChannelCode)
if err != nil {
s.feeRepo.UpdateLogStatus(ctx, log.ID, "FAILED", "")
return err
}
resp, err := ch.ProfitSharing(ctx, &channel.ProfitSharingReq{
TradeNo: tradeNo,
ChannelTradeNo: order.ChannelTradeNo,
SharingNo: fmt.Sprintf("FEE%s", tradeNo),
ReceiverMerchantID: cfg.FeeReceiverMerchantID,
Amount: feeAmount,
})
if err != nil {
s.feeRepo.UpdateLogStatus(ctx, log.ID, "FAILED", "")
slog.WarnContext(ctx, "charge service fee failed", "trade_no", tradeNo, "err", err)
return err
}
s.feeRepo.UpdateLogStatus(ctx, log.ID, "SUCCESS", resp.ChannelSharingNo)
slog.InfoContext(ctx, "service fee charged", "trade_no", tradeNo, "fee_amount", feeAmount)
return nil
}
// RollbackServiceFee 退款时回退服务费
func (s *ServiceFeeService) RollbackServiceFee(ctx context.Context, tradeNo string) error {
// 幂等检查
existing, err := s.feeRepo.GetLog(ctx, tradeNo, "ROLLBACK")
if err != nil {
return err
}
if existing != nil {
return nil // 已回退
}
chargeLog, err := s.feeRepo.GetLog(ctx, tradeNo, "CHARGE")
if err != nil {
return err
}
if chargeLog == nil || chargeLog.Status != "SUCCESS" {
return nil // 没有成功扣收,无需回退
}
order, err := s.tradeRepo.GetByTradeNo(ctx, tradeNo)
if err != nil || order == nil {
return fmt.Errorf("order not found: %s", tradeNo)
}
rollbackLog := &model.ServiceFeeLog{
TradeNo: tradeNo,
ConfigID: chargeLog.ConfigID,
FeeAmount: chargeLog.FeeAmount,
FeeRate: chargeLog.FeeRate,
ReceiverMerchantID: chargeLog.ReceiverMerchantID,
Action: "ROLLBACK",
Status: "PENDING",
}
if err := s.feeRepo.CreateLog(ctx, rollbackLog); err != nil {
return err
}
ch, err := s.channelSvc.GetChannel(ctx, order.AppID, order.ChannelCode)
if err != nil {
return err
}
sharingNo := fmt.Sprintf("FEE%s", tradeNo)
if err := ch.RollbackProfitSharing(ctx, &channel.RollbackSharingReq{
SharingNo: sharingNo,
ChannelSharingNo: chargeLog.ChannelSharingNo,
TradeNo: tradeNo,
}); err != nil {
s.feeRepo.UpdateLogStatus(ctx, rollbackLog.ID, "FAILED", "")
return err
}
s.feeRepo.UpdateLogStatus(ctx, rollbackLog.ID, "SUCCESS", "")
return nil
}
// CalculateAndValidate 下单时校验分润+服务费不超过订单金额
func (s *ServiceFeeService) CalculateAndValidate(ctx context.Context, appID string, payMethod model.PayMethod, orderAmount, sharingAmount int64) (int64, error) {
group := model.PayMethodToGroup(payMethod)
cfg, err := s.feeRepo.GetConfig(ctx, appID, group)
if err != nil {
return 0, err
}
var feeAmount int64
if cfg != nil && cfg.FeeRate > 0 {
feeAmount = calculateFee(orderAmount, cfg.FeeRate)
}
if sharingAmount+feeAmount > orderAmount {
return 0, fmt.Errorf(errSharingFeeExceed)
}
return feeAmount, nil
}
const errSharingFeeExceed = "30007" // errcode.ErrSharingFeeExceed
// calculateFee 计算服务费(四舍五入到分)
func calculateFee(amount int64, rate float64) int64 {
fee := float64(amount) * rate
return int64(math.Round(fee))
}

View File

@@ -0,0 +1,371 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"encoding/json"
"github.com/go-redis/redis/v8"
"pay-bridge/internal/channel"
"pay-bridge/internal/errcode"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
"pay-bridge/pkg/sequence"
)
const (
orderExpireDefault = 30 * time.Minute
idempotentKeyPrefix = "idempotent:"
idempotentTTL = 24 * time.Hour
)
// CreateOrderReq 下单请求
type CreateOrderReq struct {
AppID string
ChannelCode string // 指定渠道,为空时使用 defaultChannelCode
MerchantOrderNo string
PayMethod model.PayMethod
Amount int64
ProfitSharingAmount int64
Subject string
NotifyURL string
ExpireMinutes int
Extra map[string]any
MerchantID string // 可选指定收款商户SaaS 多商户路由)
}
// CreateOrderResp 下单响应
type CreateOrderResp struct {
TradeNo string
PayCredential map[string]any
IsIdempotent bool // true=幂等返回
}
// TradeService 交易服务
type TradeService struct {
tradeRepo *repository.TradeOrderRepository
channelSvc *ChannelService
merchantSvc *MerchantService
seqSvc *sequence.Service
rdb *redis.Client
notifySvc *NotifyService
}
func NewTradeService(
tradeRepo *repository.TradeOrderRepository,
channelSvc *ChannelService,
seqSvc *sequence.Service,
rdb *redis.Client,
notifySvc *NotifyService,
merchantSvc *MerchantService,
) *TradeService {
return &TradeService{
tradeRepo: tradeRepo,
channelSvc: channelSvc,
merchantSvc: merchantSvc,
seqSvc: seqSvc,
rdb: rdb,
notifySvc: notifySvc,
}
}
// CreateOrder 统一下单(含幂等控制)
func (s *TradeService) CreateOrder(ctx context.Context, req *CreateOrderReq) (*CreateOrderResp, error) {
// 参数校验
if req.Amount <= 0 {
return nil, errors.New(errcode.ErrInvalidAmount)
}
// 幂等检查 - Redis SET NX
idempotentKey := fmt.Sprintf("%s%s:%s", idempotentKeyPrefix, req.AppID, req.MerchantOrderNo)
set, err := s.rdb.SetNX(ctx, idempotentKey, "1", idempotentTTL).Result()
if err != nil && !errors.Is(err, redis.Nil) {
slog.WarnContext(ctx, "redis idempotent check failed, fallback to db", "err", err)
}
if !set {
// 幂等命中,查询已有订单
order, err := s.tradeRepo.GetByMerchantOrderNo(ctx, req.AppID, req.MerchantOrderNo)
if err != nil {
return nil, err
}
if order == nil {
// Redis key 存在但 DB 无记录(极端情况),清除 key 重试
s.rdb.Del(ctx, idempotentKey)
return nil, errors.New(errcode.ErrOrderNotFound)
}
if order.Status == model.TradeStatusPaid {
return nil, errors.New(errcode.ErrOrderAlreadyPaid)
}
if order.Status == model.TradeStatusClosed {
return nil, errors.New(errcode.ErrOrderClosed)
}
return &CreateOrderResp{
TradeNo: order.TradeNo,
PayCredential: order.ChannelExtra,
IsIdempotent: true,
}, nil
}
// 生成交易号
tradeNo, err := s.seqSvc.NextTradeNo(ctx, req.AppID)
if err != nil {
s.rdb.Del(ctx, idempotentKey)
return nil, err
}
// 计算过期时间
expireMinutes := req.ExpireMinutes
if expireMinutes <= 0 {
expireMinutes = int(orderExpireDefault.Minutes())
}
expireTime := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
// 确定渠道
channelCode := req.ChannelCode
if channelCode == "" {
channelCode = "HEEPAY" // 向后兼容默认值,建议调用方明确传入
}
// 可选指定收款商户SaaS 多商户路由),校验归属并按渠道注入 sub_merchant_id
if req.MerchantID != "" && s.merchantSvc != nil {
// 校验商户归属(只能使用本 appID 下的商户)
if _, err := s.merchantSvc.GetMerchantForApp(ctx, req.AppID, req.MerchantID); err != nil {
s.rdb.Del(ctx, idempotentKey)
return nil, err
}
// 按实际下单渠道取对应进件记录的 channel_merchant_id
channelMerchantID, err := s.merchantSvc.GetChannelMerchantID(ctx, req.MerchantID, channelCode)
if err != nil {
s.rdb.Del(ctx, idempotentKey)
return nil, err
}
if channelMerchantID != "" {
if req.Extra == nil {
req.Extra = make(map[string]any)
}
req.Extra["sub_merchant_id"] = channelMerchantID
}
}
// 创建本地订单记录CREATING 状态)
order := &model.TradeOrder{
TradeNo: tradeNo,
MerchantOrderNo: req.MerchantOrderNo,
AppID: req.AppID,
ChannelCode: channelCode,
PayMethod: req.PayMethod,
Amount: req.Amount,
ProfitSharingAmount: req.ProfitSharingAmount,
Subject: req.Subject,
NotifyURL: req.NotifyURL,
Status: model.TradeStatusCreating,
Extra: req.Extra,
ExpireTime: expireTime,
}
if err := s.tradeRepo.Create(ctx, order); err != nil {
s.rdb.Del(ctx, idempotentKey)
return nil, err
}
// 调用渠道下单
ch, err := s.channelSvc.GetChannel(ctx, req.AppID, channelCode)
if err != nil {
s.tradeRepo.UpdateStatus(ctx, tradeNo, model.TradeStatusCreating, model.TradeStatusCreateFailed, nil)
return nil, fmt.Errorf("%s: %w", errcode.ErrChannelCreateFail, err)
}
channelReq := &channel.CreateOrderReq{
AppID: req.AppID,
TradeNo: tradeNo,
MerchantOrderNo: req.MerchantOrderNo,
PayMethod: req.PayMethod,
Amount: req.Amount,
Subject: req.Subject,
NotifyURL: req.NotifyURL,
ExpireTime: expireTime,
Extra: req.Extra,
}
channelResp, err := ch.CreateOrder(ctx, channelReq)
if err != nil {
s.tradeRepo.UpdateStatus(ctx, tradeNo, model.TradeStatusCreating, model.TradeStatusCreateFailed, nil)
return nil, fmt.Errorf("%s: %w", errcode.ErrChannelCreateFail, err)
}
// 更新为 PAYING 状态,保存支付凭证
updates := map[string]any{
"channel_trade_no": channelResp.ChannelTradeNo,
"channel_extra": model.JSONMap(channelResp.PayCredential),
}
s.tradeRepo.UpdateStatus(ctx, tradeNo, model.TradeStatusCreating, model.TradeStatusPaying, updates)
return &CreateOrderResp{
TradeNo: tradeNo,
PayCredential: channelResp.PayCredential,
}, nil
}
// QueryOrder 查询交易状态
func (s *TradeService) QueryOrder(ctx context.Context, appID, tradeNo string) (*model.TradeOrder, error) {
order, err := s.tradeRepo.GetByTradeNo(ctx, tradeNo)
if err != nil {
return nil, err
}
if order == nil || order.AppID != appID {
return nil, errors.New(errcode.ErrOrderNotFound)
}
// 如果处于 PAYING 状态,主动查询渠道同步最新状态
if order.Status == model.TradeStatusPaying {
s.syncOrderStatus(ctx, order)
}
return order, nil
}
// CloseOrder 关闭订单
func (s *TradeService) CloseOrder(ctx context.Context, appID, tradeNo string) error {
order, err := s.tradeRepo.GetByTradeNo(ctx, tradeNo)
if err != nil {
return err
}
if order == nil || order.AppID != appID {
return errors.New(errcode.ErrOrderNotFound)
}
if order.Status == model.TradeStatusPaid {
return errors.New(errcode.ErrOrderAlreadyPaid)
}
if order.Status == model.TradeStatusClosed {
return nil // 已关闭,幂等
}
if order.Status != model.TradeStatusPaying {
return errors.New(errcode.ErrOrderClosed)
}
// 调用渠道关单
ch, err := s.channelSvc.GetChannel(ctx, appID, order.ChannelCode)
if err != nil {
return err
}
if err := ch.CloseOrder(ctx, &channel.CloseOrderReq{
TradeNo: tradeNo,
ChannelTradeNo: order.ChannelTradeNo,
}); err != nil {
slog.WarnContext(ctx, "close order on channel failed", "trade_no", tradeNo, "err", err)
}
_, err = s.tradeRepo.UpdateStatus(ctx, tradeNo, model.TradeStatusPaying, model.TradeStatusClosed, nil)
return err
}
// HandleUpstreamNotify 处理上游支付回调(验签 + 状态更新 + 触发通知下游)
//
// 流程:先用临时无配置实例从 body 提取 trade_no → 查 DB 得 appID → 加载完整渠道配置验签
func (s *TradeService) HandleUpstreamNotify(ctx context.Context, channelCode string, rawBody []byte, headers map[string]string) (string, error) {
// 用只负责解析的临时渠道实例提取交易号(不需要密钥配置)
tempCh, err := channel.Get(channelCode, nil, channel.URLs{})
if err != nil {
return "fail", fmt.Errorf("unknown channel: %s", channelCode)
}
tradeNo, err := tempCh.ExtractTradeNo(rawBody)
if err != nil || tradeNo == "" {
return "fail", fmt.Errorf("extract trade_no from notify: %w", err)
}
order, err := s.tradeRepo.GetByTradeNo(ctx, tradeNo)
if err != nil {
return "fail", err
}
if order == nil {
slog.WarnContext(ctx, "notify: order not found", "trade_no", tradeNo)
return "fail", errors.New(errcode.ErrOrderNotFound)
}
// 加载完整渠道配置并验签
ch, err := s.channelSvc.GetChannel(ctx, order.AppID, channelCode)
if err != nil {
return "fail", err
}
notifyData, err := ch.VerifyNotify(ctx, rawBody, headers)
if err != nil {
slog.WarnContext(ctx, "notify: verify sign failed", "trade_no", tradeNo, "err", err)
return "fail", errors.New(errcode.ErrChannelVerifyFail)
}
// 处理支付通知
if notifyData.NotifyType == model.NotifyTypePayment && notifyData.Status == model.TradeStatusPaid {
if err := s.handlePaymentSuccess(ctx, order, notifyData); err != nil {
return "fail", err
}
}
return "success", nil
}
// handlePaymentSuccess 处理支付成功
func (s *TradeService) handlePaymentSuccess(ctx context.Context, order *model.TradeOrder, data *channel.NotifyData) error {
updates := map[string]any{
"channel_trade_no": data.ChannelTradeNo,
}
if data.PayTime != nil {
updates["pay_time"] = data.PayTime
}
ok, err := s.tradeRepo.UpdateStatus(ctx, order.TradeNo, model.TradeStatusPaying, model.TradeStatusPaid, updates)
if err != nil {
return err
}
if !ok {
// 已被处理过(幂等),直接返回成功
slog.InfoContext(ctx, "payment notify idempotent", "trade_no", order.TradeNo)
return nil
}
// 异步触发下游通知
if s.notifySvc != nil {
go func() {
bgCtx := context.Background()
if err := s.notifySvc.SendNotify(bgCtx, order.TradeNo, model.NotifyTypePayment, order.NotifyURL); err != nil {
slog.Error("send notify failed", "trade_no", order.TradeNo, "err", err)
}
}()
}
return nil
}
// syncOrderStatus 主动查询渠道同步订单状态(查询接口兜底)
func (s *TradeService) syncOrderStatus(ctx context.Context, order *model.TradeOrder) {
ch, err := s.channelSvc.GetChannel(ctx, order.AppID, order.ChannelCode)
if err != nil {
slog.WarnContext(ctx, "syncOrderStatus: get channel failed", "trade_no", order.TradeNo, "err", err)
return
}
resp, err := ch.QueryOrder(ctx, &channel.QueryOrderReq{
TradeNo: order.TradeNo,
ChannelTradeNo: order.ChannelTradeNo,
})
if err != nil {
slog.WarnContext(ctx, "syncOrderStatus: query channel failed", "trade_no", order.TradeNo, "err", err)
return
}
if resp.Status == model.TradeStatusPaid {
updates := map[string]any{
"channel_trade_no": resp.ChannelTradeNo,
}
if resp.PayTime != nil {
updates["pay_time"] = resp.PayTime
}
s.tradeRepo.UpdateStatus(ctx, order.TradeNo, model.TradeStatusPaying, model.TradeStatusPaid, updates)
}
}
func parseJSON(data []byte, v any) error {
return json.Unmarshal(data, v)
}

View File

@@ -0,0 +1,161 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
"pay-bridge/pkg/crypto"
)
const (
wxTokenURL = "https://api.weixin.qq.com/cgi-bin/token"
wxSendMsgURL = "https://api.weixin.qq.com/cgi-bin/message/template/send"
accessTokenTTL = 90 * time.Minute // 微信 access_token 有效期 2h提前 30min 刷新
)
// WechatService 微信模板消息服务
type WechatService struct {
wechatRepo *repository.WechatRepository
cryptoKey string
httpClient *http.Client
// 内存缓存 access_token避免频繁调用微信接口
tokenCache map[string]*tokenEntry
}
type tokenEntry struct {
token string
expiresAt time.Time
}
func NewWechatService(wechatRepo *repository.WechatRepository, cryptoKey string) *WechatService {
return &WechatService{
wechatRepo: wechatRepo,
cryptoKey: cryptoKey,
httpClient: &http.Client{Timeout: 10 * time.Second},
tokenCache: make(map[string]*tokenEntry),
}
}
// SendPaymentNotify 发送支付成功通知
func (s *WechatService) SendPaymentNotify(ctx context.Context, appID, tradeNo, openID string, amount int64) error {
binding, err := s.wechatRepo.GetBinding(ctx, appID)
if err != nil || binding == nil {
return nil // 未配置微信通知,跳过
}
data := map[string]any{
"trade_no": map[string]string{"value": tradeNo},
"amount": map[string]string{"value": fmt.Sprintf("%.2f 元", float64(amount)/100)},
"time": map[string]string{"value": time.Now().Format("2006-01-02 15:04:05")},
}
return s.sendTemplate(ctx, appID, binding, openID, tradeNo, data)
}
// sendTemplate 发送模板消息
func (s *WechatService) sendTemplate(ctx context.Context, appID string, binding *model.WechatBinding,
openID, tradeNo string, data map[string]any) error {
log := &model.WechatMessageLog{
AppID: appID,
TradeNo: tradeNo,
OpenID: openID,
TemplateID: binding.TemplateID,
Status: model.WechatMessageStatusPending,
}
if err := s.wechatRepo.CreateMessageLog(ctx, log); err != nil {
return err
}
token, err := s.getAccessToken(ctx, binding)
if err != nil {
updates := map[string]any{"status": model.WechatMessageStatusFailed, "err_msg": err.Error()}
s.wechatRepo.UpdateMessageLog(ctx, log.ID, updates)
return err
}
payload := map[string]any{
"touser": openID,
"template_id": binding.TemplateID,
"data": data,
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("%s?access_token=%s", wxSendMsgURL, token)
resp, err := s.httpClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
updates := map[string]any{"status": model.WechatMessageStatusFailed, "err_msg": err.Error()}
s.wechatRepo.UpdateMessageLog(ctx, log.ID, updates)
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
json.Unmarshal(respBody, &result)
now := time.Now()
if result.ErrCode == 0 {
updates := map[string]any{"status": model.WechatMessageStatusSuccess, "sent_at": now}
s.wechatRepo.UpdateMessageLog(ctx, log.ID, updates)
slog.InfoContext(ctx, "wechat template sent", "trade_no", tradeNo, "open_id", openID)
} else {
errMsg := fmt.Sprintf("errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
updates := map[string]any{"status": model.WechatMessageStatusFailed, "err_msg": errMsg}
s.wechatRepo.UpdateMessageLog(ctx, log.ID, updates)
return fmt.Errorf("wechat send failed: %s", errMsg)
}
return nil
}
// getAccessToken 获取微信 access_token带内存缓存
func (s *WechatService) getAccessToken(ctx context.Context, binding *model.WechatBinding) (string, error) {
if entry, ok := s.tokenCache[binding.WxAppID]; ok && time.Now().Before(entry.expiresAt) {
return entry.token, nil
}
// 解密 secret
secret, err := crypto.Decrypt(binding.WxSecret, s.cryptoKey)
if err != nil {
return "", fmt.Errorf("decrypt wx secret: %w", err)
}
url := fmt.Sprintf("%s?grant_type=client_credential&appid=%s&secret=%s",
wxTokenURL, binding.WxAppID, secret)
resp, err := s.httpClient.Get(url)
if err != nil {
return "", fmt.Errorf("get wx token: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
if result.ErrCode != 0 {
return "", fmt.Errorf("wx token error: %d %s", result.ErrCode, result.ErrMsg)
}
s.tokenCache[binding.WxAppID] = &tokenEntry{
token: result.AccessToken,
expiresAt: time.Now().Add(accessTokenTTL),
}
return result.AccessToken, nil
}

View File

@@ -0,0 +1,107 @@
package config
import (
"time"
"github.com/spf13/viper"
)
// Config 全局配置
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
Security SecurityConfig `mapstructure:"security"`
JWT JWTConfig `mapstructure:"jwt"`
Notify NotifyConfig `mapstructure:"notify"`
Reconciliation ReconciliationConfig `mapstructure:"reconciliation"`
Merchant MerchantConfig `mapstructure:"merchant"`
Log LogConfig `mapstructure:"log"`
Channels ChannelsConfig `mapstructure:"channels"`
}
// ChannelsConfig 各渠道网关地址配置
type ChannelsConfig struct {
Heepay HeepayURLConfig `mapstructure:"heepay"`
}
// HeepayURLConfig 汇元网关地址
type HeepayURLConfig struct {
PayURL string `mapstructure:"pay_url"` // 支付网关
MerchantURL string `mapstructure:"merchant_url"` // 进件网关
}
type ServerConfig struct {
Port int `mapstructure:"port"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
}
type DatabaseConfig struct {
DSN string `mapstructure:"dsn"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"`
}
type RedisConfig struct {
Addr string `mapstructure:"addr"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
}
type SecurityConfig struct {
FieldEncryptKey string `mapstructure:"field_encrypt_key"`
AppSecretSalt string `mapstructure:"app_secret_salt"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
}
type NotifyConfig struct {
PollerInterval time.Duration `mapstructure:"poller_interval"`
PollerBatch int `mapstructure:"poller_batch"`
HTTPTimeout time.Duration `mapstructure:"http_timeout"`
}
type ReconciliationConfig struct {
Cron string `mapstructure:"cron"`
BillRetryTimes int `mapstructure:"bill_retry_times"`
}
type MerchantConfig struct {
StatusCheckCron string `mapstructure:"status_check_cron"`
}
type LogConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
}
// Load 加载配置文件
func Load(cfgFile string) (*Config, error) {
v := viper.New()
if cfgFile != "" {
v.SetConfigFile(cfgFile)
} else {
v.SetConfigName("config.local")
v.SetConfigType("yaml")
v.AddConfigPath("./configs")
v.AddConfigPath(".")
}
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
return nil, err
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}

View File

@@ -0,0 +1,38 @@
package config
import (
"fmt"
"log/slog"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// NewDB 初始化 MySQL 连接
func NewDB(cfg DatabaseConfig) (*gorm.DB, error) {
gormCfg := &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
}
db, err := gorm.Open(mysql.Open(cfg.DSN), gormCfg)
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("ping mysql: %w", err)
}
slog.Info("mysql connected")
return db, nil
}

View File

@@ -0,0 +1,26 @@
package config
import (
"context"
"fmt"
"log/slog"
"github.com/go-redis/redis/v8"
)
// NewRedis 初始化 Redis 客户端
func NewRedis(cfg RedisConfig) (*redis.Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
PoolSize: cfg.PoolSize,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("ping redis: %w", err)
}
slog.Info("redis connected")
return rdb, nil
}

72
backend/pkg/crypto/aes.go Normal file
View File

@@ -0,0 +1,72 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
// Encrypt 使用 AES-256-GCM 加密明文,返回 base64 编码的密文
func Encrypt(plaintext, key string) (string, error) {
keyBytes := []byte(key)
if len(keyBytes) != 32 {
return "", errors.New("encrypt key must be 32 bytes")
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt 使用 AES-256-GCM 解密
func Decrypt(ciphertextB64, key string) (string, error) {
keyBytes := []byte(key)
if len(keyBytes) != 32 {
return "", errors.New("decrypt key must be 32 bytes")
}
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return "", err
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

View File

@@ -0,0 +1,31 @@
package logger
import (
"log/slog"
"os"
)
// Init 初始化全局 logger
func Init(level, format string) {
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: lvl}
var handler slog.Handler
if format == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
slog.SetDefault(slog.New(handler))
}

View File

@@ -0,0 +1,50 @@
package sequence
import (
"context"
"fmt"
"time"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
)
// Service 订单编码服务
type Service struct {
repo *repository.SequenceRepository
}
func NewService(repo *repository.SequenceRepository) *Service {
return &Service{repo: repo}
}
// NextTradeNo 生成下一个交易号格式PAY{yyMMdd}{8位序号}
func (s *Service) NextTradeNo(ctx context.Context, appID string) (string, error) {
return s.next(ctx, appID, model.SeqTypeTrade)
}
// NextRefundNo 生成下一个退款单号格式REF{yyMMdd}{8位序号}
func (s *Service) NextRefundNo(ctx context.Context, appID string) (string, error) {
return s.next(ctx, appID, model.SeqTypeRefund)
}
// NextSharingNo 生成下一个分润单号格式SHA{yyMMdd}{8位序号}
func (s *Service) NextSharingNo(ctx context.Context, appID string) (string, error) {
return s.next(ctx, appID, model.SeqTypeSharing)
}
func (s *Service) next(ctx context.Context, appID string, seqType model.SeqType) (string, error) {
val, err := s.repo.IncrAndGet(ctx, appID, seqType)
if err != nil {
return "", err
}
prefix, err := s.repo.GetPrefix(ctx, appID, seqType)
if err != nil {
return "", err
}
date := time.Now().Format("060102") // yyMMdd
seq := fmt.Sprintf("%08d", val%100_000_000) // 8 位溢出归零每日最多1亿笔
return prefix + date + seq, nil
}

View File

@@ -0,0 +1,326 @@
// e2e_merchant 商户进件端到端测试脚本
//
// 测试链路:本脚本 → pay-bridge 本地服务 → Heepay 沙盒
//
// 前置条件:
// 1. 启动 pay-bridge 服务go run ./cmd/server
// 2. 数据库中已有测试 appappID + appSecret或通过 Admin API 创建
// 3. 设置环境变量(见下方)
//
// 环境变量:
//
// PAY_BRIDGE_URL=http://localhost:8080 服务地址(默认 http://localhost:8080
// APP_ID=your_app_id 测试用 appID
// APP_SECRET=your_app_secret 对应的明文 appSecret
//
// 运行:
//
// APP_ID=app_test APP_SECRET=secret123 go run ./scripts/e2e_merchant/
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// ---- 配置 ----
func baseURL() string {
if v := os.Getenv("PAY_BRIDGE_URL"); v != "" {
return strings.TrimRight(v, "/")
}
return "http://localhost:8080"
}
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
fatalf("环境变量 %s 未设置", key)
}
return v
}
// ---- HTTP 客户端(带 HMAC 签名) ----
type client struct {
appID string
appSecret string
base string
http *http.Client
}
func newClient() *client {
return &client{
appID: mustEnv("APP_ID"),
appSecret: mustEnv("APP_SECRET"),
base: baseURL(),
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *client) do(method, path string, body any) (map[string]any, error) {
var bodyBytes []byte
if body != nil {
var err error
bodyBytes, err = json.Marshal(body)
if err != nil {
return nil, err
}
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
sign := hmacSign(c.appID+ts+string(bodyBytes), c.appSecret)
req, err := http.NewRequest(method, c.base+path, bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-App-Id", c.appID)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Sign", sign)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
var result map[string]any
if err := json.Unmarshal(respBytes, &result); err != nil {
return nil, fmt.Errorf("parse response [%d]: %s", resp.StatusCode, string(respBytes))
}
return result, nil
}
func (c *client) post(path string, body any) (map[string]any, error) {
return c.do("POST", path, body)
}
func (c *client) get(path string) (map[string]any, error) {
return c.do("GET", path, nil)
}
func hmacSign(payload, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(payload))
return hex.EncodeToString(h.Sum(nil))
}
// ---- 辅助函数 ----
func printStep(n int, name string) {
fmt.Printf("\n[步骤 %d] %s\n%s\n", n, name, strings.Repeat("-", 40))
}
func printResult(resp map[string]any) {
b, _ := json.MarshalIndent(resp, " ", " ")
fmt.Printf(" 响应: %s\n", string(b))
}
func checkOK(resp map[string]any, step string) {
code, _ := resp["code"].(string)
if code != "0" {
fatalf("[%s] 接口返回错误: code=%s message=%v", step, code, resp["message"])
}
}
func dataStr(resp map[string]any, key string) string {
data, _ := resp["data"].(map[string]any)
if data == nil {
return ""
}
v, _ := data[key].(string)
return v
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "❌ FAIL: "+format+"\n", args...)
os.Exit(1)
}
// ---- 测试流程 ----
func main() {
c := newClient()
merchantID := fmt.Sprintf("E2E_M_%d", time.Now().UnixNano()%1e9)
fmt.Printf("=== 商户进件 E2E 测试 ===\n")
fmt.Printf("服务地址: %s\n", c.base)
fmt.Printf("App ID: %s\n", c.appID)
fmt.Printf("商户ID: %s\n", merchantID)
// ---- 步骤 1创建商户 ----
printStep(1, "创建商户")
resp, err := c.post("/api/v1/merchant", map[string]any{
"merchant_id": merchantID,
"merchant_name": "E2E测试企业" + merchantID[len(merchantID)-6:],
"license_no": "91110000E2ETEST01X",
"legal_person": "测试法人",
})
if err != nil {
fatalf("创建商户请求失败: %v", err)
}
printResult(resp)
checkOK(resp, "创建商户")
fmt.Printf(" ✓ 商户创建成功: merchant_id=%s\n", dataStr(resp, "merchant_id"))
// ---- 步骤 2查询商户详情 ----
printStep(2, "查询商户详情")
resp, err = c.get("/api/v1/merchant/" + merchantID)
if err != nil {
fatalf("查询商户失败: %v", err)
}
printResult(resp)
checkOK(resp, "查询商户")
fmt.Printf(" ✓ 商户查询成功\n")
// ---- 步骤 3上传营业执照调用 Heepay 沙盒) ----
printStep(3, "上传营业执照到 Heepay 沙盒")
fmt.Printf(" → 调用 POST /api/v1/merchant/upload-file\n")
fmt.Printf(" → 内部转调 Heepay customer.file.upload\n")
// 最小合法 JPEG22字节
minJPEG := []byte{
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9,
}
// 用 multipart 上传
fileID, uploadErr := uploadFile(c, minJPEG, "license.jpg", "image/jpeg")
if uploadErr != nil {
fmt.Printf(" ⚠ 文件上传失败Heepay 沙盒可能返回业务错误): %v\n", uploadErr)
fmt.Printf(" → 继续后续步骤submit_data 不含 file_id\n")
fileID = ""
} else {
fmt.Printf(" ✓ 文件上传成功: file_id=%s\n", fileID)
}
// ---- 步骤 4提交进件申请调用 Heepay 沙盒) ----
printStep(4, "提交进件申请到 Heepay 沙盒")
fmt.Printf(" → 调用 POST /api/v1/merchant/%s/apply\n", merchantID)
fmt.Printf(" → 内部转调 Heepay customer.enter.enterprise.apply\n")
submitData := map[string]any{
"merch_name": "E2E测试企业",
"merch_short_name": "E2E测试",
"merch_type": "ENTERPRISE",
"contact_name": "测试联系人",
"contact_phone": "13800138000",
"contact_email": "e2e@example.com",
"province": "北京市",
"city": "北京市",
"district": "朝阳区",
"address": "朝阳区测试路1号",
}
if fileID != "" {
submitData["license_img"] = fileID
}
resp, err = c.post("/api/v1/merchant/"+merchantID+"/apply", map[string]any{
"channel_code": "HEEPAY",
"submit_data": submitData,
})
if err != nil {
fatalf("提交进件失败: %v", err)
}
printResult(resp)
applicationID := dataStr(resp, "application_id")
if resp["code"] != "0" {
fmt.Printf(" ⚠ 进件接口返回错误Heepay 沙盒字段验证): code=%v message=%v\n",
resp["code"], resp["message"])
fmt.Printf(" → 进件字段名需对照 Heepay 官方文档确认\n")
} else {
fmt.Printf(" ✓ 进件申请提交成功: application_id=%s\n", applicationID)
}
// ---- 步骤 5查询进件审核状态 ----
printStep(5, "查询进件审核状态")
resp, err = c.get("/api/v1/merchant/" + merchantID + "/audit")
if err != nil {
fatalf("查询审核状态失败: %v", err)
}
printResult(resp)
if resp["code"] == "0" {
fmt.Printf(" ✓ 审核状态查询成功\n")
} else {
fmt.Printf(" ⚠ 审核状态查询异常(可能进件未提交成功)\n")
}
// ---- 步骤 6验证商户列表隔离 ----
printStep(6, "验证 appID 隔离:列表只返回本 app 的商户")
resp, err = c.get("/api/v1/merchant?limit=10&offset=0")
if err != nil {
fatalf("查询列表失败: %v", err)
}
checkOK(resp, "商户列表")
data, _ := resp["data"].(map[string]any)
list, _ := data["list"].([]any)
fmt.Printf(" ✓ 返回 %d 个商户(均属于 app_id=%s\n", len(list), c.appID)
fmt.Printf("\n=== 测试完成 ===\n")
fmt.Printf("步骤 1-2本地 DB: ✓ 正常\n")
fmt.Printf("步骤 3-4Heepay 沙盒): 需对照官方文档确认字段名\n")
fmt.Printf("步骤 6数据隔离: ✓ 正常\n")
}
// uploadFile 发送 multipart 文件上传请求
func uploadFile(c *client, content []byte, filename, mediaType string) (string, error) {
var buf bytes.Buffer
boundary := "----PayBridgeE2EBoundary"
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString(fmt.Sprintf(`Content-Disposition: form-data; name="file"; filename="%s"`, filename) + "\r\n")
buf.WriteString("Content-Type: " + mediaType + "\r\n\r\n")
buf.Write(content)
buf.WriteString("\r\n--" + boundary + "\r\n")
buf.WriteString(`Content-Disposition: form-data; name="channel_code"` + "\r\n\r\n")
buf.WriteString("HEEPAY")
buf.WriteString("\r\n--" + boundary + "\r\n")
buf.WriteString(`Content-Disposition: form-data; name="file_media_type"` + "\r\n\r\n")
buf.WriteString(mediaType)
buf.WriteString("\r\n--" + boundary + "--\r\n")
ts := strconv.FormatInt(time.Now().Unix(), 10)
// multipart 请求 body 不参与签名,签名 body 部分为空
sign := hmacSign(c.appID+ts+"", c.appSecret)
req, err := http.NewRequest("POST", c.base+"/api/v1/merchant/upload-file", &buf)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
req.Header.Set("X-App-Id", c.appID)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Sign", sign)
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
var result map[string]any
if err := json.Unmarshal(respBytes, &result); err != nil {
return "", fmt.Errorf("parse upload response: %s", string(respBytes))
}
if result["code"] != "0" {
return "", fmt.Errorf("code=%v message=%v", result["code"], result["message"])
}
data, _ := result["data"].(map[string]any)
fileID, _ := data["file_id"].(string)
return fileID, nil
}

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
mysql:
image: mysql:8.0
container_name: pay-bridge-mysql
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: pay_bridge
MYSQL_USER: pay_bridge
MYSQL_PASSWORD: pay_bridge123
ports:
- "3306:3306"
volumes:
- pay-bridge-mysql:/var/lib/mysql
- ./backend/configs/migrations:/docker-entrypoint-initdb.d
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
redis:
image: redis:7-alpine
container_name: pay-bridge-redis
ports:
- "6379:6379"
volumes:
pay-bridge-mysql:

View File

@@ -0,0 +1,931 @@
# Pay-Bridge 支付网关接入文档
| 版本 | 日期 | 说明 |
|------|------|------|
| v1.2 | 2026-02-28 | 新增商户进件接口SaaS 多商户场景)、统一下单支持 merchant_id |
| v1.1 | 2026-02-28 | 更新接入凭证申请方式 |
| v1.0 | 2026-02-28 | 初始版本 |
---
## 目录
1. [接入准备](#1-接入准备)
2. [请求规范](#2-请求规范)
3. [签名算法](#3-签名算法)
4. [统一响应格式](#4-统一响应格式)
5. [支付接口](#5-支付接口)
- 5.1 [统一下单](#51-统一下单)
- 5.2 [查询订单](#52-查询订单)
- 5.3 [关闭订单](#53-关闭订单)
- 5.4 [申请退款](#54-申请退款)
- 5.5 [查询退款](#55-查询退款)
6. [商户进件接口](#6-商户进件接口)SaaS 多商户场景)
- 6.1 [创建商户](#61-创建商户)
- 6.2 [上传证件文件](#62-上传证件文件)
- 6.3 [提交进件申请](#63-提交进件申请)
- 6.4 [查询审核状态](#64-查询审核状态)
- 6.5 [查询商户详情](#65-查询商户详情)
- 6.6 [查询商户列表](#66-查询商户列表)
7. [异步通知](#7-异步通知)
8. [订单状态说明](#8-订单状态说明)
9. [错误码对照表](#9-错误码对照表)
10. [最佳实践](#10-最佳实践)
11. [签名示例代码](#11-签名示例代码)
---
## 1. 接入准备
### 1.1 获取接入凭证
接入凭证由**平台管理员**在管理后台创建。请联系平台管理员,按以下步骤为你的系统开通接入权限:
1. 管理员登录管理后台,进入「**接入应用**」模块
2. 点击「新建应用」填写应用名称商城系统、ERP 系统)
3. 系统自动生成 `app_id``app_secret`**Secret 仅在创建时展示一次**
4. 管理员将凭证告知接入方
> **重要提示**`app_secret` 创建后无法再次查看,如遗失需由管理员在后台执行「重置密钥」操作,旧密钥立即失效。
获得的凭证如下:
| 参数 | 示例 | 说明 |
|------|------|------|
| `app_id` | `app_2602280a3f1b7c2d` | 应用唯一标识,格式为 `app_` + 日期 + 随机串 |
| `app_secret` | `A3F8C2D1E4B7F9A0...` | 64 位十六进制字符串,用于请求签名,**请妥善保管,切勿泄露或提交至代码仓库** |
### 1.2 接口基础地址
| 环境 | 地址 |
|------|------|
| 生产环境 | `https://pay.your-domain.com` |
| 沙箱环境 | `https://sandbox-pay.your-domain.com` |
---
## 2. 请求规范
### 2.1 基本要求
- 协议HTTPS
- 方法POST下单/退款/关闭、GET查询
- 编码UTF-8
- Content-Type`application/json`
### 2.2 公共请求头
所有请求必须携带以下 Header
| Header | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `X-App-Id` | string | 是 | 平台分配的 app_id |
| `X-Timestamp` | string | 是 | 当前 Unix 时间戳(秒),与服务器时间差须在 **5 分钟**以内 |
| `X-Sign` | string | 是 | 请求签名,见[签名算法](#3-签名算法) |
| `Content-Type` | string | 是 | `application/json` |
**示例:**
```http
POST /api/v1/pay/unified-order HTTP/1.1
Host: pay.your-domain.com
Content-Type: application/json
X-App-Id: app_20260101001
X-Timestamp: 1740700800
X-Sign: a3f8c2d1e4b7f9a0c5d2e8f1b4a7c3d6e9f2b5a8c1d4e7f0a3b6c9d2e5f8a1b4
```
---
## 3. 签名算法
### 3.1 算法说明
签名算法为 **HMAC-SHA256**,签名结果为 **十六进制小写字符串**
### 3.2 签名步骤
**第一步**:拼接签名原文
```
签名原文 = app_id + timestamp + requestBody
```
- `app_id`Header 中的 X-App-Id
- `timestamp`Header 中的 X-Timestamp
- `requestBody`:请求体原始 JSON 字符串GET 请求时为空字符串 `""`
**第二步**HMAC-SHA256 签名
```
sign = HMAC-SHA256(签名原文, app_secret)
```
**第三步**Hex 编码,填入 `X-Sign` Header
### 3.3 注意事项
- 请求体 JSON 中字段顺序不影响签名,但序列化后的**字节内容必须与实际发送的 body 完全一致**
- 时间戳精度为秒,不是毫秒
- GET 请求 body 为空字符串参与签名
---
## 4. 统一响应格式
所有接口均返回 JSONHTTP 状态码为 200业务错误也返回 200通过 `code` 字段区分)。
**成功响应:**
```json
{
"code": "0",
"message": "success",
"data": { ... },
"trace_id": "7f3a2b1c4d5e6f7a"
}
```
**失败响应:**
```json
{
"code": "30004",
"message": "退款金额超过可退金额",
"trace_id": "7f3a2b1c4d5e6f7a"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | string | `"0"` 为成功,其他为错误码,见[错误码表](#9-错误码对照表) |
| `message` | string | 提示信息 |
| `data` | object | 业务数据,仅成功时返回 |
| `trace_id` | string | 请求追踪 ID排查问题时提供给平台 |
---
## 5. 支付接口
### 5.1 统一下单
**接口地址**
```
POST /api/v1/pay/unified-order
```
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `merchant_order_no` | string | 是 | 商户系统订单号,同一 app_id 下唯一,最长 64 位 |
| `pay_method` | string | 是 | 支付方式,见下方枚举 |
| `amount` | int64 | 是 | 订单金额,单位**分**,最小 1 |
| `subject` | string | 是 | 商品描述,最长 256 位 |
| `notify_url` | string | 是 | 支付结果异步通知地址,须为合法 HTTPS URL |
| `expire_minutes` | int | 否 | 订单有效期(分钟),默认 30 分钟 |
| `profit_sharing_amount` | int64 | 否 | 分润金额0 表示不分润 |
| `extra` | object | 否 | 支付方式附加参数,见下方说明 |
| `merchant_id` | string | 否 | 指定收款商户 IDSaaS 多商户场景),须为本 app_id 下已进件审核通过的商户 |
**`pay_method` 枚举值**
| 值 | 说明 |
|----|------|
| `WECHAT_JSAPI` | 微信公众号支付 |
| `WECHAT_H5` | 微信 H5 支付 |
| `WECHAT_NATIVE` | 微信扫码支付 |
| `WECHAT_MINI` | 微信小程序支付 |
| `ALIPAY` | 支付宝扫码支付 |
| `QUICK_PAY` | 快捷支付 |
**`extra` 附加参数说明**
| 支付方式 | 参数 | 说明 |
|---------|------|------|
| `WECHAT_JSAPI` | `openid`(必填) | 用户在公众号的 openid |
| `WECHAT_MINI` | `openid`(必填) | 用户在小程序的 openid |
| `WECHAT_JSAPI` / `WECHAT_MINI` | `sub_appid`(可选) | 子商户公众号/小程序 AppID |
**请求示例**
```json
{
"merchant_order_no": "ORD20260228001",
"pay_method": "WECHAT_JSAPI",
"amount": 9900,
"subject": "会员充值 - 月卡",
"notify_url": "https://your-server.com/callback/pay",
"expire_minutes": 15,
"extra": {
"openid": "oxxxxxxxxxxxxxxxxxxxxxxx"
}
}
```
**成功响应 `data`**
| 字段 | 类型 | 说明 |
|------|------|------|
| `trade_no` | string | 平台交易号,格式 `PAYyyMMddXXXXXXXX` |
| `pay_credential` | object | 支付凭证,透传给前端拉起支付,格式因支付方式不同而异 |
| `is_idempotent` | bool | `true` 表示该订单号已存在,返回的是已有订单信息 |
**响应示例**
```json
{
"code": "0",
"message": "success",
"data": {
"trade_no": "PAY26022800000001",
"pay_credential": {
"appId": "wx1234567890",
"timeStamp": "1740700800",
"nonceStr": "abc123xyz",
"package": "prepay_id=wx28...",
"signType": "RSA",
"paySign": "..."
},
"is_idempotent": false
}
}
```
> **幂等说明**:使用相同的 `merchant_order_no` 重复调用,不会重复下单,会直接返回已有订单的支付凭证,并且 `is_idempotent` 为 `true`。
---
### 5.2 查询订单
**接口地址**
```
GET /api/v1/pay/query/{trade_no}
```
**路径参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| `trade_no` | string | 平台交易号(下单时返回的 `trade_no` |
**请求示例**
```http
GET /api/v1/pay/query/PAY26022800000001
```
**成功响应 `data`**
| 字段 | 类型 | 说明 |
|------|------|------|
| `trade_no` | string | 平台交易号 |
| `merchant_order_no` | string | 商户订单号 |
| `pay_method` | string | 支付方式 |
| `amount` | int64 | 订单金额(分) |
| `status` | string | 订单状态,见[订单状态说明](#8-订单状态说明) |
| `channel_trade_no` | string | 渠道侧交易号 |
| `pay_time` | string | 支付成功时间ISO8601 格式,未支付时为 null |
| `created_at` | string | 订单创建时间ISO8601 格式 |
**响应示例**
```json
{
"code": "0",
"message": "success",
"data": {
"trade_no": "PAY26022800000001",
"merchant_order_no": "ORD20260228001",
"pay_method": "WECHAT_JSAPI",
"amount": 9900,
"status": "PAID",
"channel_trade_no": "4200002345202602280000000001",
"pay_time": "2026-02-28T10:05:00Z",
"created_at": "2026-02-28T10:00:00Z"
}
}
```
---
### 5.3 关闭订单
关闭仍在支付中的订单,已支付订单不可关闭(请使用退款接口)。
**接口地址**
```
POST /api/v1/pay/close
```
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `trade_no` | string | 是 | 平台交易号 |
**请求示例**
```json
{
"trade_no": "PAY26022800000001"
}
```
**成功响应**
```json
{
"code": "0",
"message": "success"
}
```
---
### 5.4 申请退款
**接口地址**
```
POST /api/v1/pay/refund
```
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `trade_no` | string | 是 | 平台交易号 |
| `refund_amount` | int64 | 是 | 退款金额(分),不得超过原订单金额 |
| `reason` | string | 否 | 退款原因,最长 256 位 |
| `notify_url` | string | 否 | 退款结果异步通知地址 |
**请求示例**
```json
{
"trade_no": "PAY26022800000001",
"refund_amount": 9900,
"reason": "用户申请退款",
"notify_url": "https://your-server.com/callback/refund"
}
```
**成功响应 `data`**
| 字段 | 类型 | 说明 |
|------|------|------|
| `refund_no` | string | 平台退款单号 |
| `trade_no` | string | 关联的平台交易号 |
| `refund_amount` | int64 | 退款金额(分) |
| `status` | string | 退款状态:`PENDING` / `PROCESSING` / `SUCCESS` / `FAILED` |
| `channel_refund_no` | string | 渠道侧退款单号 |
**响应示例**
```json
{
"code": "0",
"message": "success",
"data": {
"refund_no": "REF26022800000001",
"trade_no": "PAY26022800000001",
"refund_amount": 9900,
"status": "PROCESSING",
"channel_refund_no": "50300807092026022800000000001"
}
}
```
> **注意**:退款为异步处理,状态 `PROCESSING` 表示已提交渠道,最终结果通过异步通知推送,建议同时通过查询接口轮询确认。
---
### 5.5 查询退款
**接口地址**
```
GET /api/v1/pay/refund/query/{refund_no}
```
**路径参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| `refund_no` | string | 平台退款单号(申请退款时返回的 `refund_no` |
**成功响应 `data`**
| 字段 | 类型 | 说明 |
|------|------|------|
| `refund_no` | string | 平台退款单号 |
| `trade_no` | string | 关联的平台交易号 |
| `refund_amount` | int64 | 退款金额(分) |
| `status` | string | 退款状态 |
| `channel_refund_no` | string | 渠道侧退款单号 |
| `refund_time` | string | 退款成功时间ISO8601 格式,未完成时为 null |
---
## 6. 商户进件接口
> 适用场景:你的系统是 **SaaS 平台**,平台上的客户需要先完成进件(企业入网)才能以自己名义收款。
>
> 所有进件接口与支付接口使用**相同的 HMAC 鉴权**`app_id` + `app_secret`),且数据完全按 `app_id` 隔离——你只能看到自己名下的商户。
### 典型流程
```
1. 创建商户记录(获取 merchant_id
2. 上传营业执照等证件文件(获取 file_id
3. 提交进件申请(将 file_id 填入 submit_data
4. 轮询查询审核状态,直至 APPROVED
5. 统一下单时传入 merchant_id指定该商户收款
```
---
### 6.1 创建商户
**接口地址**
```
POST /api/v1/merchant
```
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `merchant_id` | string | 是 | 你方系统的商户唯一标识,最长 32 位,由调用方自定义 |
| `merchant_name` | string | 是 | 商户名称 |
| `license_no` | string | 否 | 营业执照号 |
| `legal_person` | string | 否 | 法定代表人姓名 |
| `bank_account` | string | 否 | 结算银行账号(脱敏存储) |
**请求示例**
```json
{
"merchant_id": "tenant_0001",
"merchant_name": "示例科技有限公司",
"license_no": "91310000XXXXXXXXXX",
"legal_person": "张三",
"bank_account": "6222021234567890123"
}
```
**成功响应 `data`**
```json
{
"merchant_id": "tenant_0001"
}
```
---
### 6.2 上传证件文件
**接口地址**
```
POST /api/v1/merchant/upload-file
```
**请求格式**`multipart/form-data`
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `file` | file | 是 | 证件图片,支持 JPG / PNG / PDF |
| `file_media_type` | string | 是 | 文件类型代码,由渠道定义,如 `01`=营业执照、`02`=身份证正面 |
| `channel_code` | string | 否 | 渠道代码,默认 `HEEPAY` |
**成功响应 `data`**
```json
{
"file_id": "3000000001234567"
}
```
> 上传返回的 `file_id` 在提交进件时填入 `submit_data`,有效期以渠道规定为准(通常 48 小时内使用)。
---
### 6.3 提交进件申请
**接口地址**
```
POST /api/v1/merchant/{merchant_id}/apply
```
**路径参数**
| 参数 | 说明 |
|------|------|
| `merchant_id` | 创建商户时使用的商户 ID |
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `channel_code` | string | 是 | 渠道代码,如 `HEEPAY` |
| `submit_data` | object | 否 | 渠道要求的进件业务参数,内容因渠道而异,详见各渠道对接手册 |
**请求示例**
```json
{
"channel_code": "HEEPAY",
"submit_data": {
"business_license_no": "91310000XXXXXXXXXX",
"business_license_copy": "3000000001234567",
"id_card_front": "3000000001234568",
"id_card_back": "3000000001234569",
"contact_name": "张三",
"contact_phone": "138XXXXXXXX",
"bank_account_no": "6222021234567890123",
"bank_name": "中国工商银行"
}
}
```
**成功响应 `data`**
```json
{
"application_id": "APP1a2b3c4d5e6f7a8b"
}
```
---
### 6.4 查询审核状态
**接口地址**
```
GET /api/v1/merchant/{merchant_id}/audit
```
**成功响应 `data`**
| 字段 | 类型 | 说明 |
|------|------|------|
| `application_id` | string | 进件申请 ID |
| `merchant_id` | string | 商户 ID |
| `channel_code` | string | 渠道代码 |
| `audit_status` | string | 审核状态,见下方枚举 |
| `reject_reason` | string | 拒绝原因,仅 `REJECTED` 时返回 |
| `submitted_at` | string | 提交时间 |
| `audited_at` | string | 审核完成时间,未完成时为 null |
**`audit_status` 枚举值**
| 值 | 说明 |
|----|------|
| `SUBMITTING` | 提交中,正在调用渠道 |
| `REVIEWING` | 审核中,渠道人工审核 |
| `APPROVED` | 审核通过,商户可正常收款 |
| `REJECTED` | 审核拒绝,查看 `reject_reason` 修改后重新提交 |
**响应示例**
```json
{
"code": "0",
"message": "success",
"data": {
"application_id": "APP1a2b3c4d5e6f7a8b",
"merchant_id": "tenant_0001",
"channel_code": "HEEPAY",
"audit_status": "APPROVED",
"submitted_at": "2026-02-28T10:00:00Z",
"audited_at": "2026-02-28T14:30:00Z"
}
}
```
---
### 6.5 查询商户详情
**接口地址**
```
GET /api/v1/merchant/{merchant_id}
```
**成功响应 `data`**:返回完整商户对象,字段同创建时入参,额外包含:
| 字段 | 类型 | 说明 |
|------|------|------|
| `status` | string | 商户状态:`PENDING` / `ACTIVE` / `FROZEN` / `REJECTED` |
| `channel_merchant_id` | string | 渠道侧商户 ID进件审核通过后由渠道下发 |
| `created_at` | string | 创建时间 |
> 如传入的 `merchant_id` 不属于当前 `app_id`,接口返回 404。
---
### 6.6 查询商户列表
**接口地址**
```
GET /api/v1/merchant
```
**Query 参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `status` | string | 否 | 按状态过滤:`PENDING` / `ACTIVE` / `FROZEN` / `REJECTED` |
| `limit` | int | 否 | 每页数量,默认 20 |
| `offset` | int | 否 | 偏移量,默认 0 |
**响应示例**
```json
{
"code": "0",
"message": "success",
"data": {
"list": [
{
"merchant_id": "tenant_0001",
"merchant_name": "示例科技有限公司",
"status": "ACTIVE",
"created_at": "2026-02-28T10:00:00Z"
}
],
"limit": 20,
"offset": 0
}
}
```
---
### 6.7 指定商户收款(统一下单扩展)
商户进件通过后,在[统一下单](#51-统一下单)时传入 `merchant_id`,平台会将该商户的渠道收款账号注入下单参数,实现**分账到具体商户**
```json
{
"merchant_order_no": "ORD20260228002",
"pay_method": "WECHAT_JSAPI",
"amount": 9900,
"subject": "订单支付",
"notify_url": "https://your-server.com/callback/pay",
"merchant_id": "tenant_0001",
"extra": {
"openid": "oxxxxxxxxxxxxxxxxxxxxxxx"
}
}
```
> `merchant_id` 必须属于当前 `app_id`,且商户状态为 `ACTIVE`,否则返回 `30001`。
---
## 7. 异步通知
### 7.1 支付成功通知
用户支付成功后,平台将向下单时提供的 `notify_url` 发送 POST 请求。
**通知内容**
```json
{
"trade_no": "PAY26022800000001",
"merchant_order_no": "ORD20260228001",
"status": "PAID",
"amount": 9900,
"pay_method": "WECHAT_JSAPI",
"channel_trade_no": "4200002345202602280000000001",
"pay_time": "2026-02-28T10:05:00Z"
}
```
### 7.2 退款结果通知
退款处理完成后,平台将向退款时提供的 `notify_url` 发送 POST 请求。
**通知内容**
```json
{
"refund_no": "REF26022800000001",
"trade_no": "PAY26022800000001",
"merchant_order_no": "ORD20260228001",
"refund_amount": 9900,
"status": "SUCCESS",
"refund_time": "2026-02-28T10:30:00Z"
}
```
### 7.3 通知应答规则
**接收通知后,你的服务器必须在 10 秒内响应以下内容:**
```
HTTP 200
Body: success
```
返回任何其他内容,或超时未响应,平台将认为通知失败并重试。
### 7.4 重试策略
| 第 N 次 | 延迟 |
|--------|------|
| 1 | 立即 |
| 2 | 15 秒后 |
| 3 | 30 秒后 |
| 4 | 1 分钟后 |
| 5 | 5 分钟后 |
| 6 | 30 分钟后 |
| 7 | 1 小时后 |
| 8 | 6 小时后 |
| 9 | 12 小时后 |
超过 9 次仍失败将停止重试,请通过**查询接口**主动同步最终状态。
### 7.5 防重处理
通知可能因网络原因重复发送,请以 `trade_no` 为唯一键做幂等处理,避免重复发货或重复入账。
---
## 8. 订单状态说明
### 8.1 交易订单状态
| 状态 | 说明 |
|------|------|
| `CREATING` | 创建中,正在调用渠道下单 |
| `PAYING` | 待支付,等待用户付款 |
| `PAID` | 支付成功 |
| `CLOSED` | 已关闭 |
| `FAILED` | 支付失败 |
| `CREATE_FAILED` | 下单失败,渠道返回错误 |
| `REFUNDED` | 已全额退款 |
### 8.2 退款状态
| 状态 | 说明 |
|------|------|
| `PENDING` | 待处理 |
| `PROCESSING` | 处理中,已提交渠道 |
| `SUCCESS` | 退款成功 |
| `FAILED` | 退款失败,可联系平台处理 |
---
## 9. 错误码对照表
| 错误码 | HTTP 状态码 | 说明 | 处理建议 |
|--------|------------|------|---------|
| `0` | 200 | 成功 | — |
| `10001` | 400 | 参数校验失败 | 检查请求参数格式和必填项 |
| `10002` | 400 | 缺少必填参数 | 补充缺少的参数 |
| `10003` | 400 | 不支持的支付方式 | 检查 pay_method 是否正确 |
| `10004` | 400 | 金额非法 | 金额须为正整数(分) |
| `20001` | 401 | 签名验证失败 | 检查签名算法和 app_secret |
| `20002` | 401 | 应用不存在或已禁用 | 联系平台确认 app_id 状态 |
| `30001` | 422 | 订单不存在 | 检查 trade_no 是否正确 |
| `30002` | 422 | 订单已支付 | 勿重复支付 |
| `30003` | 422 | 订单已关闭 | 需重新下单 |
| `30004` | 422 | 退款金额超过可退金额 | 检查退款金额 |
| `30009` | 422 | 订单未支付,无法退款 | 确认订单已支付后再退款 |
| `30010` | 422 | 退款单不存在 | 检查 refund_no 是否正确 |
| `40001` | 502 | 渠道下单失败 | 稍后重试,仍失败请联系平台 |
| `40002` | 502 | 渠道退款失败 | 稍后重试,仍失败请联系平台 |
| `40003` | 502 | 渠道调用超时 | 稍后重试 |
| `50099` | 500 | 系统内部错误 | 记录 trace_id 联系平台排查 |
---
## 10. 最佳实践
### 10.1 下单流程建议
```
1. 用户确认支付
2. 你的服务端调用【统一下单】接口
3. 返回 pay_credential 给前端
4. 前端调用微信/支付宝 SDK 拉起支付
5. 接收异步通知 → 更新订单状态 → 返回 "success"
6. (兜底)若通知未收到,定时调用【查询订单】接口轮询
```
### 10.2 安全注意事项
- `app_secret` 只在服务端使用,**禁止出现在前端代码或 App 客户端中**
- 异步通知的 IP 建议加入白名单(联系平台获取出口 IP 段)
- 通知接收后务必**先返回 `success`,再处理业务逻辑**,避免超时导致重复通知
### 10.3 金额单位
所有金额字段均以**分**为单位,整数类型:
- 1 元 = `100`
- 9.9 元 = `990`
- 99.99 元 = `9999`
### 10.4 时区
所有时间字段均为 **UTC 时间**ISO8601 格式,例如 `2026-02-28T10:05:00Z`
---
## 11. 签名示例代码
### Go
```go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"time"
)
func sign(appID, appSecret, body string) (timestamp, sign string) {
ts := strconv.FormatInt(time.Now().Unix(), 10)
raw := appID + ts + body
mac := hmac.New(sha256.New, []byte(appSecret))
mac.Write([]byte(raw))
return ts, hex.EncodeToString(mac.Sum(nil))
}
```
### Python
```python
import hmac
import hashlib
import time
def sign(app_id: str, app_secret: str, body: str) -> tuple[str, str]:
timestamp = str(int(time.time()))
raw = app_id + timestamp + body
signature = hmac.new(
app_secret.encode("utf-8"),
raw.encode("utf-8"),
hashlib.sha256
).hexdigest()
return timestamp, signature
```
### Java
```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
public static String[] sign(String appId, String appSecret, String body) throws Exception {
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
String raw = appId + timestamp + body;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] bytes = mac.doFinal(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b));
return new String[]{timestamp, sb.toString()};
}
```
### PHP
```php
function sign(string $appId, string $appSecret, string $body): array {
$timestamp = (string)time();
$raw = $appId . $timestamp . $body;
$signature = hash_hmac('sha256', $raw, $appSecret);
return [$timestamp, $signature];
}
```
---
如有问题请联系平台技术支持,并提供接口返回的 `trace_id`

344
docs/approach-design.md Normal file
View File

@@ -0,0 +1,344 @@
# pay-bridge Approach Design
| 字段 | 内容 |
|-----|------|
| 文档版本 | v1.0 |
| 创建日期 | 2026-02-27 |
| 状态 | 草稿 |
---
## 1. 整体架构分层
pay-bridge 采用四层架构自上而下分别为API 接入层、业务逻辑层、渠道适配层、基础设施层。
```
┌──────────────────────────────────────────────────────────────┐
│ 下游业务系统 │
│ (电商 / SaaS / 多商户平台) │
└────────────────────────┬─────────────────────────────────────┘
│ REST/JSON over HTTPS
┌────────────────────────▼─────────────────────────────────────┐
│ Layer 1: API 接入层 │
│ • 鉴权中间件appId + appSecret 签名验证) │
│ • 请求路由Gin Router
│ • 参数校验binding/validator
│ • 限流中间件Redis Token Bucket
│ • 统一响应格式封装 / 错误码映射 │
├──────────────────────────────────────────────────────────────┤
│ Layer 2: 业务逻辑层 │
│ • 交易服务(统一下单、查询、退款、关单) │
│ • 通知服务(接收上游回调、通知下游、重试调度) │
│ • 分润服务(分账计算、分润触发、退款回退) │
│ • 服务费服务(费率计算、自动扣收) │
│ • 收款匹配服务(入账通知接收、三维度匹配引擎) │
│ • 对账服务(账单拉取、逐笔比对、差异报告) │
│ • 商户进件服务(进件提交、审核状态跟踪) │
│ • 微信通知服务(模板消息推送、绑定管理) │
│ • 订单编码服务(按 app_id 隔离的序列号生成) │
├──────────────────────────────────────────────────────────────┤
│ Layer 3: 渠道适配层 │
│ • 渠道适配器接口(统一 interface 定义) │
│ • 汇元支付适配器RSA/MD5 签名、RSA+3DES 加解密) │
│ • 渠道工厂(根据 channel_code 动态路由到对应适配器) │
│ • 渠道配置仓储(密钥、证书的加密读取) │
├──────────────────────────────────────────────────────────────┤
│ Layer 4: 基础设施层 │
│ • MySQL 8.0(交易持久化、配置存储) │
│ • Redis幂等 Key 缓存、分布式锁、延迟队列) │
│ • 定时任务调度器cron 对账、状态补偿) │
│ • 结构化日志zerolog / zap
└──────────────────────────────────────────────────────────────┘
│ 渠道私有协议
┌────────────────────────▼─────────────────────────────────────┐
│ 上游支付渠道 │
│ 汇元支付 │ (预留微信直连) │ (预留支付宝直连) │
└──────────────────────────────────────────────────────────────┘
```
**分层理由**
- API 接入层与业务逻辑层分离,使鉴权、限流、协议转换等横切关注点集中处理,业务层保持纯粹。
- 渠道适配层独立为一层,而非嵌入业务层,使新增渠道无需改动任何业务代码,只新增一个适配器实现。
- 基础设施层统一封装,方便后续将 Redis 替换为其他实现,或扩展消息队列。
---
## 2. 核心模块划分
| 模块 | 职责 | 关键依赖 |
|-----|-----|--------|
| 交易核心Trade | 统一下单、查询、关单,维护订单状态机 | MySQL、Redis幂等、渠道适配层 |
| 退款Refund | 退款请求、退款状态跟踪、退款查询 | MySQL、渠道适配层 |
| 异步通知Notify | 接收上游回调验签、更新状态、通知下游、重试调度 | Redis 延迟队列、MySQL |
| 分润ProfitSharing | 分润配置读取、分账调用、退款回退分润 | 渠道适配层、MySQL |
| 服务费ServiceFee | 费率配置读取、服务费计算、分账划转 | 渠道适配层、MySQL |
| 收款匹配PaymentMatch | 接收固定账户入账通知、三维度匹配引擎 | MySQL、通知模块 |
| 对账Reconciliation | T+1 账单拉取、逐笔比对、差异入库 | 渠道适配层、MySQL |
| 商户进件Merchant | 进件提交、审核跟踪、状态异常检测 | 渠道适配层、MySQL |
| 微信通知WechatNotify | 微信绑定管理、模板消息推送 | 微信公众号 API、MySQL |
| 订单编码Sequence | 按 app_id 隔离的订单号生成 | MySQL悲观锁序列或 Redis原子自增 |
| 渠道适配Channel | 统一 interface、汇元适配器实现、渠道工厂 | 无外部依赖(纯逻辑) |
| 应用鉴权Auth | appId + appSecret 签名校验、应用信息缓存 | MySQL、Redis |
---
## 3. 中间件选型及理由
### 3.1 MySQL 8.0
**理由**:交易数据要求强一致性和 ACID 事务金融场景不接受数据丢失。MySQL 在团队熟悉度、生态(如 gorm、成本自建方面综合最优。单表千万级订单通过分区表或归档策略应对不需要引入分布式数据库的复杂性。
选 MySQL 而非 PostgreSQLPRD 明确列出 MySQL 8.0团队已有运维经验MySQL 在国内支付系统中更为普遍,社区案例更多。
### 3.2 Redis
Redis 在本系统中承担三个独立职责,每个职责对应不同的数据结构:
| 职责 | 数据结构 | 说明 |
|-----|---------|-----|
| 幂等 Key 缓存 | StringSET NX EX | 下单去重、回调幂等TTL 与订单超时一致 |
| 分布式锁 | StringRedlock 语义) | 防止并发退款、分润重复触发 |
| 异步通知延迟队列 | Sorted Setscore 为下次重试时间戳) | 实现指数退避重试,无需引入重量级 MQ |
**为什么不引入 RabbitMQ 或 Kafka**MVP 阶段通知 QPS 较低(< 500Redis Sorted Set 延迟队列已足够;引入独立 MQ 会增加运维复杂度和故障点。PRD 也明确将 Redis Stream 或 RabbitMQ 列为备选Redis Sorted Set 是更轻量的起点,后续可迁移。
### 3.3 Gin 框架
**理由**Go 生态最成熟的 HTTP 框架,性能优异(零 gc 路由),中间件生态完善,团队学习成本低。符合 PRD 的 P95 < 200ms 要求。
### 3.4 GORM v2
**理由**:提供类型安全的 ORM减少 SQL 拼接错误支持软删除、Hook 钩子方便审计日志Migration 能力支持版本化数据库变更。对于金融场景中需要精确控制的批量 SQL支持原生 SQL 直接执行。
---
## 4. 模块间交互方式
pay-bridge 采用**同进程函数调用**而非微服务拆分,理由是 MVP 阶段团队规模和交易量均不需要微服务的复杂性,单体部署运维更简单,且 Go 的并发模型goroutine天然支持高并发。
模块间交互规则:
1. **同步调用链**请求主路径API Handler → Service → Repository → DB。Service 层不互相直接调用,通过 interface 注入解耦。
2. **事件驱动**(异步后处理):支付成功后,通知服务、分润服务、服务费服务通过 Go channel 或直接异步 goroutine 触发,不阻塞主请求链路。
3. **重试队列**(延迟执行):通知重试写入 Redis Sorted Set独立的 goroutine 定时扫描到期任务并执行。
4. **定时任务**:对账、商户状态检测等 cron 任务由独立 goroutine 调度,与 HTTP 服务共进程但相互隔离。
```
HTTP Request
API Handler
│ 同步
Trade Service ──── Repository ──── MySQL
│ 异步goroutine
├──► Notify Service ──► Redis Sorted Set延迟重试
├──► ProfitSharing Service ──► Channel Adapter ──► 汇元分账 API
└──► ServiceFee Service ──► Channel Adapter ──► 汇元分账 API
```
---
## 5. 渠道适配器框架设计思路
渠道适配器是整个系统可扩展性的核心。设计原则:**对外暴露统一接口,对内封装渠道差异**。
### 5.1 统一接口抽象
定义 `PaymentChannel` interface所有渠道适配器必须实现此接口
```go
type PaymentChannel interface {
// Code 返回渠道编码
Code() string
// CreateOrder 下单,返回支付凭证
CreateOrder(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error)
// QueryOrder 查询订单状态
QueryOrder(ctx context.Context, req *QueryOrderRequest) (*QueryOrderResponse, error)
// CloseOrder 关闭订单
CloseOrder(ctx context.Context, req *CloseOrderRequest) (*CloseOrderResponse, error)
// Refund 发起退款
Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error)
// QueryRefund 查询退款状态
QueryRefund(ctx context.Context, req *QueryRefundRequest) (*QueryRefundResponse, error)
// VerifyNotify 验证上游回调签名,返回解析后的通知数据
VerifyNotify(ctx context.Context, rawBody []byte, headers map[string]string) (*NotifyData, error)
// ProfitSharing 分账(渠道不支持时返回 ErrNotSupported
ProfitSharing(ctx context.Context, req *ProfitSharingRequest) (*ProfitSharingResponse, error)
// RollbackProfitSharing 回退分账
RollbackProfitSharing(ctx context.Context, req *RollbackSharingRequest) (*RollbackSharingResponse, error)
// DownloadBill 下载对账账单
DownloadBill(ctx context.Context, req *DownloadBillRequest) (*BillData, error)
// MerchantApply 商户进件
MerchantApply(ctx context.Context, req *MerchantApplyRequest) (*MerchantApplyResponse, error)
// QueryMerchantStatus 查询商户审核状态
QueryMerchantStatus(ctx context.Context, channelMerchantID string) (*MerchantStatusResponse, error)
}
```
### 5.2 渠道工厂与注册Registry 模式)
采用注册表Registry模式避免 switch-case 的硬编码,实现插件化设计:
```go
var registry = map[string]ChannelFactory{}
func Register(channelCode string, factory ChannelFactory) {
registry[channelCode] = factory
}
func GetChannel(channelCode string, config *ChannelConfig) (PaymentChannel, error) {
factory, ok := registry[channelCode]
if !ok {
return nil, ErrChannelNotFound
}
return factory(config), nil
}
```
每个渠道适配器包在 `init()` 中调用 `Register()` 完成自注册。新增渠道只需:新建包、实现 interface、调用 Register主程序 `import _ "pay-bridge/internal/channel/newchannel"` 即可接入,无需修改任何现有代码。
### 5.3 汇元适配器特殊处理
汇元支付HePay采用 RSA/MD5 双签名和 RSA+3DES 双层加密,适配器内部封装:
- **签名层**:根据渠道配置决定使用 RSA 还是 MD5对外透明。
- **加密层**:请求体使用 3DES 加密,密钥使用 RSA 公钥加密后随请求发送;响应体同样解密处理。
- **重试安全**:所有加解密操作幂等,同一请求多次加密结果不同但均有效。
---
## 6. 异步通知 + 重试机制方案
### 6.1 整体流程
```
上游回调 → 验签 → 更新交易状态DB 事务)→ 写入通知任务 → 立即第一次推送
推送成功 → 标记完成
推送失败 → 写入 Redis Sorted Set
score = now + 重试间隔
Poller goroutine 定时扫描
到期任务 → 执行推送
成功 → 删除任务
失败 → 更新 score下次重试时间
超过 8 次 → 标记「通知异常」
```
### 6.2 重试间隔设计
共 9 次推送机会(第 1 次立即,后续 8 次重试),重试间隔:
| 第 N 次推送 | 距上次等待时长 | 累计等待时间 |
|-----------|------------|-----------|
| 1 | 立即 | 0 |
| 2 | 15s | 15s |
| 3 | 30s | 45s |
| 4 | 1m | 1m45s |
| 5 | 5m | 6m45s |
| 6 | 30m | 36m45s |
| 7 | 1h | 1h36m45s |
| 8 | 6h | 7h36m45s |
| 9最终 | 12h | 19h36m45s |
重试策略参考微信支付官方规范,前期快速重试应对网络抖动,后期拉长间隔避免无效轰炸,最终兜底是下游主动查询。
### 6.3 幂等保障
通知发送前检查 `notify_log` 中的发送记录,避免 Poller 并发触发重复推送。同一通知任务加 Redis 分布式锁lock key = `notify_lock:{trade_no}`),保证同一时刻只有一个 goroutine 执行推送。
---
## 7. 幂等方案
系统在三个层面保障幂等,形成防重复操作的纵深防御:
### 7.1 下单幂等(防重复创建订单)
- **第一层 - Redis SET NX**:下单时以 `idempotent:{app_id}:{merchant_order_no}` 为 keySET NX EXTTL = 订单超时时间 + 缓冲)。命中则直接返回已有凭证,不进入后续流程。
- **第二层 - DB 唯一索引**`trade_order` 表在 `(app_id, merchant_order_no)` 上建唯一索引,作为 Redis 失效或缓存穿透时的兜底防护。
- **处理逻辑**:若幂等 key 已存在,直接查询并返回已有订单的支付凭证,不重新调用渠道。
### 7.2 回调幂等(防重复处理上游通知)
- 上游回调到达后,先以 `(channel_trade_no, status)` 查询 DB如已处理则直接返回成功。
- 更新 `trade_order.status` 使用**乐观锁**`WHERE status = 'PAYING' AND trade_no = ?`),确保状态只正向流转,并发更新时只有一个成功,其余幂等返回。
### 7.3 分润 / 服务费幂等(防重复分账)
- `profit_sharing_order``service_fee_log``trade_no` 上建唯一索引(同一笔交易只触发一次分账)。
- 调用渠道分账 API 前,以 `sharing_lock:{trade_no}` 加 Redis **分布式锁**TTL = 30s防止并发触发。
---
## 8. 关键技术决策点
### 8.1 单体还是微服务
**决策:单体**。理由团队规模小MVP 阶段交易量 < 500 QPS单体的运维复杂度远低于微服务Go 的 goroutine 模型天然支持高并发,单体完全能满足性能目标。架构上通过分层和 interface 保持模块边界清晰,未来有需要时可以沿模块边界拆分为微服务。
### 8.2 消息队列选型
**决策Redis Sorted Set 作为延迟队列**。理由:避免引入独立 MQ 组件RabbitMQ、Kafka增加运维负担Redis 已是必选基础设施Sorted Set 的 score 天然表达「下次执行时间」,实现简单可靠。当 QPS 超过 5000 或需要多消费者时再迁移到专业 MQ。
### 8.3 订单号策略
**决策:数据库序列(行级锁 + 自增)**,不用 UUID不用雪花 ID。理由UUID 无序,频繁插入导致 B+ 树碎片化;雪花 ID 依赖机器 ID 配置,多实例部署时配置复杂;数据库序列在 MySQL 层保证 app_id 维度的唯一递增,实现最简单,天然满足 PRD 中「不同应用独立编码序列」的要求。生成格式:`{prefix}{yyMMdd}{8位序号}`,例如 `PAY26022700000001`
### 8.4 密钥存储
**决策数据库加密存储AES-256-GCM 加密**。私钥、appSecret 等敏感字段以加密后的 Base64 字符串存入 MySQL加密密钥由环境变量注入不写入代码或配置文件。读取时在渠道配置仓储层解密上层服务不感知加密细节。
### 8.5 对账执行机制
**决策:进程内 cron + 数据库任务锁**。使用 `robfig/cron` 在进程内调度,多实例部署时通过 Redis SETNX 确保同一时刻只有一个实例执行对账,避免重复。不引入外部调度平台(如 XXL-Job保持轻量。
### 8.6 数据隔离粒度
**决策:以 app_id 为核心隔离维度**。订单、分润配置、服务费配置、订单编码序列均以 app_id 隔离。不同 app 的数据物理上在同一张表(通过 app_id 字段区分),通过数据库索引和应用层鉴权确保 A 应用无法访问 B 应用的数据。MVP 阶段不做分库分表,当单 app 订单量超过 5000 万后再考虑分表。
### 8.7 固定账户收款匹配策略
**决策:规则引擎(优先级匹配)而非 ML 模型**。三维度匹配(备注解析 > 金额精确匹配 > 付款方名称相似度)通过确定性规则实现,可解释、可审计、易排查。备注解析采用正则提取订单号,名称比较采用精确相等(不用模糊匹配),避免误匹配导致的资金风险。匹配降级策略:备注匹配优先,失败时降级为金额匹配,多候选时用名称缩小范围,无法确定时标记待人工确认。
---
## 9. 部署架构
```
┌─────────────────┐
│ Nginx / LB │
└────────┬────────┘
┌───────────────┼───────────────┐
│ │ │
┌─────────▼──────┐ ┌──────▼───────┐ ┌────▼──────────┐
│ pay-bridge │ │ pay-bridge │ │ pay-bridge │
│ instance 1 │ │ instance 2 │ │ instance 3 │
└────────┬───────┘ └──────┬───────┘ └────┬──────────┘
│ │ │
┌────────▼────────────────▼──────────────▼──────────┐
│ MySQL 8.0(主从复制) │
│ RedisSentinel / Cluster 模式) │
└───────────────────────────────────────────────────┘
```
**水平扩展能力保障**
- 无本地状态:所有实例状态通过 Redis 和 MySQL 共享,任意实例可处理任意请求。
- 幂等在 Redis/DB 层:多实例并发下单、重复回调均由 Redis SET NX 和 DB 唯一索引保障。
- 分布式锁在 Redis 层:分润、服务费分账操作通过 Redis 锁防止并发执行。
- 定时任务去重cron 任务通过 Redis SETNX 保证多实例中只有一个实例执行。
**高可用方案**
- MySQL 主从复制:写操作走主库,读操作可走从库(按需);主库故障时手动或自动切换。
- Redis Sentinel3 节点 Sentinel 监控,主节点故障自动切换,客户端感知切换。
- Nginx 负载均衡upstream 配置健康检查,实例故障自动摘除。
- 日志聚合:结构化 JSON 日志接入 ELK/Loki多实例日志统一查询。

View File

@@ -0,0 +1,330 @@
# 001 - 汇元企业入网申请 API
**接口名称:** `customer.enter.enterprise.apply`
**更新时间:** 2025-11-28
**状态:** 已实现
---
## 调用地址
| 环境 | 地址 |
|------|------|
| 生产 | `https://openapi.heepay.com/v1/customer/gateway` |
| 沙箱 | `http://openapi.heepaydev.com/v1/customer/gateway` |
> 注意:进件网关地址与支付网关不同,对应 `config.yaml` 中的 `channels.heepay.merchant_url`。
---
## 接口说明
企业商户入网申请。所有 `*_img` 字段均需先调用**文件上传接口**(接口 002获取 `file_id` 后传入。
---
## 请求业务参数biz_content
### 顶层字段
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `base_info` | 基础信息 | 是 | Object | 见下方 |
| `settlement_info` | 结算信息 | 是 | Object | 见下方 |
| `addition_info` | 补充信息 | 否 | Object | 见下方 |
| `subject_info` | 主体信息 | 是 | Object | 见下方 |
| `identity_info` | 法人信息 | 是 | Object | 见下方 |
| `contact_info` | 业务联系人信息 | 是 | Object | 见下方 |
| `ubo_infos` | 受益所有人信息 | 条件必填 | Object[] | 受益人非法人时必传 |
| `business_info` | 业务信息 | 是 | Object | 见下方 |
---
### base_info 基础信息
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `request_no` | 商户入网申请流水号 | 是 | String(64) | 商户自定义,全局唯一 |
| `alias_name` | 商户简称 | 是 | String(20) | 支付宝/微信支付页显示该名称 |
| `mcc` | 经营类目编码 | 是 | String(4) | 参照【经营类目编码】列表 |
| `service_phone` | 客服电话 | 是 | String | 客服电话 |
| `email` | 商户邮箱 | 是 | String(128) | 首次入网默认作为登录账号和联系人邮箱 |
| `notify_url` | 通知地址 | 否 | String(256) | 汇元主动通知的 http/https 回调地址 |
---
### settlement_info 结算信息
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `settle_type` | 结算类型 | 是 | String | `ACCOUNT`=结算到汇付宝账户,`BANK_CARD`=结算到银行卡 |
| `account_type` | 账户类型 | 是 | String | `PUBLIC`=对公,`PRIVATE`=对私;公司只支持对公,小微只支持对私 |
| `account_no` | 银行卡号 | 是 | String(35) | 银行卡号 |
| `account_name` | 开户名称 | 是 | String(32) | 开户名称 |
| `account_branch_no` | 开户支行联行号 | 是 | String(64) | 开户支行联行号 |
| `open_account_img` | 银行开户许可照 | 条件必填 | String(64) | 对公账号必传,传 `file_id` |
| `bank_name` | 银行名称 | 是 | String | 参照【银行列表】 |
| `region_code` | 银行卡省市区县编码 | 是 | String(6) | 参照省市编码列表 |
---
### addition_info 补充信息(选填)
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `open_merch_video` | 开户意愿视频 | 否 | String | 传 `file_id` |
| `sharing_protocol_img` | 分账协议 | 否 | String | 传 `file_id` |
| `business_developer` | 业务开发者(拓展商户的业务员) | 否 | String(20) | 业务员姓名 |
---
### subject_info 主体信息
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `name` | 商户名称 | 是 | String(50) | 须与营业执照名称一致 |
| `cert_no` | 营业执照号 | 是 | String(32) | 营业执照号 |
| `region_code` | 营业执照区划编码 | 是 | String(32) | 传区县编码(通过行政区划查询接口获取) |
| `address` | 营业执照注册地址 | 是 | String(250) | 营业执照注册地址 |
| `cert_img` | 营业执照图片 | 是 | String | 传 `file_id` |
| `cert_expire` | 营业执照有效期 | 是 | String | 格式:`yyyy-MM-dd/yyyy-MM-dd`,长期有效传 `yyyy-MM-dd/forever` |
---
### identity_info 法人信息
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `name` | 姓名 | 是 | String(18) | 法人姓名 |
| `mobile` | 手机号 | 是 | String(11) | 法人手机号 |
| `cert_type` | 证件类型 | 是 | String | `ID_CARD`=大陆身份证,`HK_CARD`=港通行证,`MC_CARD`=澳通行证,`TW_CARD`=台通行证,`PASSPORT`=外籍护照 |
| `cert_no` | 证件号 | 是 | String(32) | 证件号码 |
| `nation` | 国籍类型 | 是 | Int | 参照【国家编码】列表 |
| `cert_front_img` | 证件正面照(人像面) | 是 | String | 传 `file_id` |
| `cert_back_img` | 证件反面照(非人像面) | 是 | String | 传 `file_id` |
| `cert_expire` | 证件有效期 | 是 | String | 格式:`yyyy-MM-dd/yyyy-MM-dd`,长期有效传 `yyyy-MM-dd/forever` |
| `belong_ubo` | 受益人是否是法人 | 是 | Bool | `true`=是,`false`=否;为 false 时 `ubo_infos` 必传 |
---
### contact_info 业务联系人信息
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `name` | 姓名 | 是 | String(32) | 业务联系人姓名 |
| `mobile` | 手机号 | 是 | String(11) | 业务联系人手机号 |
| `cert_type` | 证件类型 | 是 | String | 同 `identity_info.cert_type` 枚举值 |
| `cert_no` | 证件号 | 是 | String(18) | 证件号码 |
| `nation` | 国籍类型 | 是 | Int | 参照【国家编码】列表 |
| `cert_expire` | 证件有效期 | 是 | String | 格式:`yyyy-MM-dd/yyyy-MM-dd`,长期有效传 `yyyy-MM-dd/forever` |
| `cert_front_img` | 证件正面照(人像面) | 是 | String(255) | 传 `file_id` |
| `cert_back_img` | 证件反面照(非人像面) | 是 | String(255) | 传 `file_id` |
---
### ubo_infos 受益所有人信息(受益人非法人时必传,数组)
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `name` | 证件姓名 | 是 | String(64) | 受益所有人证件姓名 |
| `cert_type` | 证件类型 | 是 | String | 同 `identity_info.cert_type` 枚举值 |
| `cert_no` | 证件号码 | 是 | String(32) | 证件号码 |
| `nation` | 国籍类型 | 是 | Int | 参照【国家编码】列表 |
| `cert_expire` | 证件有效期 | 是 | String | 格式:`yyyy-MM-dd/yyyy-MM-dd`,长期有效传 `yyyy-MM-dd/forever` |
| `cert_address` | 证件地址 | 是 | String(255) | 受益所有人证件地址 |
| `rate` | 持股比例 | 是 | String | 以 % 为单位,传 1-100 的数字,如 `10` 表示占股 10% |
---
### business_info 业务信息
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `business_open_info` | 业务开通详情 | 是 | Object | 见下方 |
#### business_open_info
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `business_platform` | 业务平台 | 是 | String | `HEEPAY`=汇付宝,`HEELIFE`=汇生活 |
| `heepay_info` | 汇付宝信息 | 条件必填 | Object | `business_platform=HEEPAY` 时传入 |
| `heelife_info` | 汇生活信息 | 条件必填 | Object | `business_platform=HEELIFE` 时传入 |
#### heepay_info 汇付宝信息
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `operate_type` | 操作类型 | 是 | String | `OPEN_ORDINARY_MERCH`=开通普通户收单账户,`OPEN_BALANCE`=开通子户余额账户,`OPEN_MERCH`=开通子户收单账户,`APPEND_OPEN_PRODUCT`=补充开通产品;小微商户不支持 `OPEN_ORDINARY_MERCH` |
| `scene_info` | 场景信息 | 条件必填 | Object | 收单账户必传 |
| `heepay_ordinary` | 普通商户必须信息 | 条件必填 | Object | `operate_type=OPEN_ORDINARY_MERCH` 时必传 |
| `product_info` | 产品费率信息 | 条件必填 | Object | `operate_type=OPEN_MERCH``APPEND_OPEN_PRODUCT` 时必传 |
| `wechat_config` | 微信配置 | 否 | Object | 见下方 |
| `wechat_enter` | 微信进件参数 | 否 | Object | 需要指定微信进件参数时传入 |
| `alipay_enter` | 支付宝进件参数 | 否 | Object | 需要指定支付宝进件参数时传入 |
#### scene_info 场景信息(收单账户必传)
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `shop_brand_img` | 商户品牌标识图片 | 是 | String | 企业传公司大楼照,个体/小微传门头照,传 `file_id` |
| `customer_service_img` | 客户服务照 | 是 | String | 企业传公司前台照,个体/小微传收银台照,传 `file_id` |
| `work_env_img` | 商户经营照 | 是 | String | 企业传内部办公场所照,个体/小微传店铺经营照,传 `file_id` |
#### heepay_ordinary 普通商户必须信息operate_type=OPEN_ORDINARY_MERCH 时必传)
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `app_type` | 应用类型 | 是 | String | `PC``APP``WAP``WECHAT_MINI`=微信小程序,`ALIPAY_MINI`=支付宝小程序,`WECHAT_PUBLIC_PLATFORM`=微信公众号,`QUICK_PASS`=云闪付,`OTHER`=其他线上场景 |
| `app_name` | 应用名称 | 是 | String | 应用名称 |
| `app_url` | 应用网址 | 条件必填 | String | `app_type` 为 PC/APP/WAP/WECHAT_MINI 时必填 |
| `app_download_url` | 应用下载地址 | 条件必填 | String | `app_type` 为 PC/APP/WAP/WECHAT_MINI 时必填 |
| `mini_app_id` | 小程序 APPID | 条件必填 | String | `app_type` 为 WECHAT_MINI/ALIPAY_MINI 时必填 |
| `app_status` | 应用状态 | 是 | String | `OFFLINE`=未上线,`ONLINE`=已上线公开,`ONLINE_SPECIAL`=已上线特定IP/用户开放 |
| `icp_url` | ICP 备案网址 | 条件必填 | String | `app_type` 为 PC/WAP 时必填 |
| `business_types` | 业务种类 | 是 | Object[] | 支持多个,见下方 `business_types` 结构 |
| `business_note` | 业务场景说明 | 是 | String | 业务场景说明 |
`business_types` 单条结构:
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `business_type` | 业务种类 | 是 | String | 见下方枚举值 |
`business_type` 枚举值:
| 枚举值 | 说明 |
|--------|------|
| `PURCHASE_VIRTUAL_GOODS` | 虚拟商品购买 |
| `PREPAID_ACCOUNT_CHARGE` | 预付费类账户充值 |
| `MATERIAL_CONSUMPTION` | 实物消费 |
| `AVIATION_BUSINESS_TRAVEL_EXPENSES` | 航空商旅消费 |
| `L_C_CONSUMPTION` | 生活及商业消费服务 |
| `OTHER_MERCH_CONSUMPTION` | 其他商户消费 |
| `PAYMENT_PUBLIC_UTILITIES` | 公共事业缴费 |
| `E_MEDICAL_PAYMENT` | 教育医疗缴费 |
| `GOVERNMENT_PAYMENT` | 政府服务缴费 |
| `PUBLIC_WELFARE_DONATION` | 公益捐款 |
| `OTHER_PUBLIC_SERVICES` | 其他公共服务 |
| `OTHER_FINANCIAL_PAYMENTS` | 其他金融付款 |
| `FUND_PURCHASE` | 基金购买 |
| `INSURANCE_PURCHASE` | 保险选购 |
| `WEALTH_MANAGE` | 投资理财 |
| `CREDIT_REPAYMENT` | 信贷偿还 |
| `CREDIT_CARD_REPAYMENTS` | 信用卡还款转出 |
| `RECHARGE_PAYMENT_ACCOUNT` | 支付账户充值 |
| `BANK_ACCOUNT_TRANSFER_OUT` | 银行账户转账转出 |
| `OTHER_ACCOUNT_CHARGE` | 其他账户充值 |
| `SERVICE_TYPE` | 服务类 |
| `SYSTEM_TYPE` | 系统类 |
| `MANAGE_TYPE` | 管理类 |
| `BID_BOND_PAYMENT` | 招投标保证金支付 |
| `OVERSEAS_SHOPPING` | 境外商品购买 |
| `business_img` | 经营服务图片 | 条件必填 | String | `app_type` 为 APP/WECHAT_MINI 时必填,传 `file_id` |
| `ext_a_img` ~ `ext_e_img` | 附件 1-5 | 否 | String | 传 `file_id` |
#### product_info 产品费率信息operate_type=OPEN_MERCH 或 APPEND_OPEN_PRODUCT 时必传)
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `reference_merch_id` | 标杆商户编码 | 条件必填 | Long | 与 `rate_infos` 二选一 |
| `rate_infos` | 费率列表 | 条件必填 | Object[] | 与 `reference_merch_id` 二选一 |
`rate_infos` 单条结构:
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `rate_code` | 支付场景编码 | 是 | String | 参照【支付场景】列表 |
| `rate_type` | 费率类型 | 是 | String(32) | `SINGLE_PERCENT`=单笔百分比,`SINGLE_FIXED`=单笔固定值 |
| `rate` | 费率值 | 是 | String(5) | 费率值 |
#### wechat_config 微信配置(选填)
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `app_ids` | appId 列表 | 否 | Object[] | appId 列表 |
| `pay_urls` | 支付授权目录列表 | 否 | Object[] | 支付授权目录列表 |
#### wechat_enter 微信进件参数(选填)
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `channel_code` | 进件渠道号 | 是 | String | 进件渠道号 |
#### alipay_enter 支付宝进件参数(选填)
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `channel_code` | 进件渠道号 | 是 | String | 进件渠道号 |
#### heelife_info 汇生活信息business_platform=HEELIFE 时传入)
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `operate_type` | 操作类型 | 是 | String | 指明业务类型(具体枚举值待补充) |
---
## 响应业务参数
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `request_no` | 商户申请流水号 | 是 | String | 后续查询(接口 003和修改接口 004时使用 |
---
## 业务错误码
| 错误码 | 说明 | 处理建议 |
|--------|------|---------|
| `invalid_parameter_email_existed` | 汇元已存在此邮箱 | 更换邮箱 |
| `invalid_parameter_email_except` | 邮箱校验异常 | 检查参数 |
| `invalid_parameter_enterprise_type_ex` | 企业用户只支持对公账户 | 检查 `account_type` |
| `invalid_parameter_micro_type_ex` | 小微用户只支持对私账户 | 检查 `account_type` |
| `invalid_parameter_ubo_must_not_empty` | 受益人不能为空 | 传入 `ubo_infos` |
| `invalid_parameter_rate_must_not_empty` | 开通收单账户费率信息不能为空 | 检查 `product_info` |
| `invalid_parameter_common_must_not_empty` | 开通普通户必须信息不能为空 | 检查 `heepay_ordinary` |
| `invalid_parameter_mark_merch_not_existed` | 标杆商户未配置 | 联系汇元配置 |
| `business_fail_customer_has_passed` | 商户已入驻完毕 | 确认 `request_no` 是否已用过 |
| `business_fail_customer_waiting` | 入驻申请处理中 | 等待审核结果 |
| `business_fail_save_his_work_record_fail` | 迁移创建历史入驻工单失败 | 重试 |
| `business_fail_delete_his_work_record_fail` | 迁移删除历史入驻工单失败 | 重试 |
| `business_fail_save_work_record_fail` | 保存入驻记录失败 | 重试 |
| `unknown_exception` | 系统未知问题 | 参考返回描述 |
| `unknown_error_gateway` | 网关系统不可用 | 稍后重试 |
| `invalid_auth` | 授权权限不足 | 检查签名/权限 |
| `missing_parameter` | 缺少参数 | 补充必填参数 |
| `invalid_parameter` | 无效参数 | 检查参数格式 |
| `invalid_request` | 无效请求 | 参考返回详细信息 |
| `biz_failure` | 业务错误 | 参照具体错误提示 |
| `service_not_exists` | 服务不存在 | 确认服务是否开通 |
| `service_time_out` | 系统繁忙 | 稍后重试 |
| `channel_error` | 通道系统异常 | 用相同参数重新调用 |
---
## 实现要点
1. **网关地址**:使用 `channels.heepay.merchant_url`(进件网关),而非支付网关
2. **图片先传后用**:所有 `*_img` 字段均为 `file_id`,依赖接口 002文件上传
3. **受益人条件必填**`identity_info.belong_ubo=false` 时,`ubo_infos` 数组必传
4. **operate_type 决定必填子结构**
- `OPEN_MERCH` / `APPEND_OPEN_PRODUCT``product_info` 必传
- `OPEN_ORDINARY_MERCH``heepay_ordinary` 必传
- 收单账户 → `scene_info` 必传
5. **费率二选一**`product_info``reference_merch_id``rate_infos` 二选一
6. **返回 request_no**:作为查询(接口 003和修改接口 004的入参需持久化存储
---
## 待补充
- [ ] `heelife_info.operate_type` 枚举值(暂不支持汇生活,低优先级)
- [ ] `wechat_config.app_ids``pay_urls` 单条结构字段
- [ ] 省市区县编码(建议用行政区划查询接口在线获取,无需维护静态列表)
## 附录文档
- MCC 经营类目编码 → `appendix-mcc-codes.md`
- 银行列表 → `appendix-bank-codes.md`
- 国家编码 → `appendix-nation-codes.md`
- 支付场景编码 → `appendix-payment-scene-codes.md`

View File

@@ -0,0 +1,135 @@
# 002 - 汇元商户文件上传 API
**接口名称:** `customer.file.upload`
**更新时间:** 2025-11-28
**状态:** 已实现
---
## 调用地址
| 环境 | 地址 |
|------|------|
| 生产 | `https://openapi.heepay.com/v1/customer/gateway` |
| 沙箱 | `http://openapi.heepaydev.com/v1/customer/gateway` |
与入网接口共用同一进件网关(`channels.heepay.merchant_url`)。
---
## 接口说明
上传图片/视频/文件,返回 `file_id`供入网接口001中所有 `*_img` 字段使用。**必须先上传文件获取 `file_id`,再调用入网接口。**
---
## 请求业务参数biz_content
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `file_content` | 文件内容 | 是 | ByteData | 文件二进制内容Base64 编码后传入) |
| `file_sign` | 文件内容签名 | 是 | String | 文件内容的 MD5 值,用于校验文件完整性 |
| `file_media_type` | 文件类型 | 是 | String | 参照【文件类型编码】列表(待补充) |
---
## 响应业务参数
| 字段 | 名称 | 必填 | 类型 | 说明 |
|------|------|------|------|------|
| `file_id` | 文件 ID | 否 | String | 上传成功后返回,传入入网接口的 `*_img` 字段 |
---
## 支持的文件格式
| 类型 | 格式 |
|------|------|
| 图片 | `png` / `bmp` / `gif` / `jpg` / `jpeg` |
| 视频 | `mp4` / `flv` / `avi` / `rm` / `rmvb` / `wav` |
| 文件 | `zip` / `rar` / `pdf` |
---
## 业务错误码
| 错误码 | 说明 | 处理建议 |
|--------|------|---------|
| `invalid_parameter_file_type_not_correct` | 文件类型不正确 | 检查格式是否在支持列表内 |
| `invalid_parameter_file_size_too_big` | 文件过大 | 压缩后重新上传 |
| `invalid_parameter_file_sign_can_not_null` | 文件签名不能为空 | 传入 `file_sign`MD5 |
| `invalid_parameter_file_sign_err` | 文件签名异常 | 确认文件是否损坏,重新计算 MD5 |
| `invalid_parameter_file_has_err` | file 有误,疑似被修改 | 检查文件完整性 |
| `invalid_parameter_file_need_ext` | 文件名需带扩展名 | 上传时文件名需包含扩展名 |
| `unknown_exception` | 系统未知问题 | 参考返回描述 |
| `unknown_error_gateway` | 网关系统不可用 | 稍后重试 |
| `invalid_auth` | 授权权限不足 | 检查签名/权限 |
| `missing_parameter` | 缺少参数 | 补充必填参数 |
| `invalid_parameter` | 无效参数 | 检查参数格式 |
| `invalid_request` | 无效请求 | 参考返回详细信息 |
| `biz_failure` | 业务错误 | 参照具体错误提示 |
| `service_not_exists` | 服务不存在 | 确认服务是否开通 |
| `service_time_out` | 系统繁忙 | 稍后重试 |
| `channel_error` | 通道系统异常 | 用相同参数重新调用 |
---
## 实现要点
1. **`file_content` 编码方式**ByteData 类型,实现时需将文件二进制内容 Base64 编码后放入 `biz_content` JSON 字符串中
2. **`file_sign` 计算**:对原始文件二进制内容计算 MD5得到 32 位小写十六进制字符串
3. **文件名带扩展名**:请求中需要携带文件名(含扩展名),具体字段名待联调确认
4. **调用顺序**:文件上传 → 获取 `file_id` → 入网申请001`file_id` 需持久化存储备用
5. **复用性**:同一文件可上传一次,`file_id` 在多个字段中复用(如法人正反面同一人时)
---
## 文件类型编码file_media_type
| 文件名称 | 类型值 |
|---------|--------|
| 营业执照 | `01` |
| 开户许可证/开户证明照 | `02` |
| 门头照 | `03` |
| 店内场景照 | `04` |
| 收银台照 | `05` |
| 法人身份证正面照 | `06` |
| 法人身份证反面照 | `07` |
| 联系人身份证正面照 | `08` |
| 联系人身份证反面照 | `09` |
| 公司大楼照 | `10` |
| 公司前台照 | `11` |
| 结算银行卡正面照 | `12` |
| 结算银行卡反面照 | `13` |
| 结算人手持银行卡正面照 | `14` |
| 经营者身份证正面照 | `15` |
| 经营者身份证反面照 | `16` |
| 经营者手持身份证正面照 | `17` |
| 经营者手持银行卡正面照 | `18` |
| 其它图片1 | `19` |
| 其它图片2 | `20` |
| 开户意愿视频 | `50` |
| 分账协议 | `52` |
> 对照入网接口001字段与文件类型的映射关系
>
> | 入网字段 | file_media_type |
> |---------|----------------|
> | `subject_info.cert_img`(营业执照) | `01` |
> | `settlement_info.open_account_img`(开户许可证) | `02` |
> | `scene_info.shop_brand_img`(门头照/公司大楼照) | `03` 或 `10` |
> | `scene_info.customer_service_img`(收银台照/公司前台照) | `05` 或 `11` |
> | `scene_info.work_env_img`(店内场景照) | `04` |
> | `identity_info.cert_front_img`(法人证件正面) | `06` |
> | `identity_info.cert_back_img`(法人证件反面) | `07` |
> | `contact_info.cert_front_img`(联系人证件正面) | `08` |
> | `contact_info.cert_back_img`(联系人证件反面) | `09` |
> | `addition_info.open_merch_video`(开户意愿视频) | `50` |
> | `addition_info.sharing_protocol_img`(分账协议) | `52` |
---
## 待补充
- [ ] 文件大小限制(图片/视频各自上限)
- [ ] 文件名字段名称确认(`file_name`

View File

@@ -0,0 +1,270 @@
# 附录 - 银行列表
使用场景商户入网接口001`settlement_info.bank_name` 字段传入。
> **注意**:请按以下银行名称严格匹配传入。若找不到具体支行名称,按大行名称传入即可。
> 例如:「中国建设银行股份有限公司宿迁宿豫支行」→ 传入「建设银行」
---
## 结算银行列表(入网使用)
| 编码 | 银行名称 |
|------|---------|
| 1 | 工商银行 |
| 2 | 建设银行 |
| 3 | 农业银行 |
| 4 | 邮政储蓄银行 |
| 5 | 中国银行 |
| 6 | 交通银行 |
| 7 | 招商银行 |
| 8 | 光大银行 |
| 9 | 浦发银行 |
| 10 | 华夏银行 |
| 11 | 广东发展银行 |
| 12 | 中信银行 |
| 13 | 兴业银行 |
| 14 | 民生银行 |
| 15 | 杭州银行 |
| 16 | 上海银行 |
| 17 | 宁波银行 |
| 18 | 平安银行 |
| 19 | 东亚银行 |
| 20 | 上海农村商业银行 |
| 21 | 南京银行 |
| 22 | 广州银行 |
| 23 | 渤海银行 |
| 24 | 大连银行 |
| 25 | 徽商银行 |
| 26 | 江苏银行 |
| 27 | 齐鲁银行 |
| 28 | 渣打银行 |
| 29 | 深圳农村商业银行 |
| 30 | 温州银行 |
| 31 | 厦门银行 |
| 32 | 浙商银行 |
| 33 | 北京银行 |
| 34 | 哈尔滨银行 |
| 35 | 湖北银行 |
| 36 | 潍坊银行 |
| 37 | 贵阳银行 |
| 38 | 浙江泰隆商业银行 |
| 39 | 济宁银行 |
| 40 | 台州银行 |
| 41 | 汉口银行 |
| 42 | 安徽省农村信用社联合社 |
| 43 | 郑州银行 |
| 44 | 中原银行 |
| 45 | 宜宾商业银行 |
| 46 | 莱商银行 |
| 47 | 日照银行 |
| 48 | 常熟农商银行 |
| 49 | 北京农商银行 |
| 50 | 福建省农村信用社联合社 |
| 51 | 齐商银行 |
| 52 | 云南省农村信用社联合社 |
| 53 | 山东省农村信用社联合社 |
| 54 | 广东华兴银行 |
| 55 | 江西银行 |
| 56 | 东营银行 |
| 57 | 浙江稠州商业银行 |
| 58 | 重庆农村商业银行 |
| 59 | 晋城银行 |
| 60 | 秦农银行 |
| 61 | 长安银行 |
| 62 | 成都银行 |
| 63 | 恒丰银行 |
| 64 | 承德银行 |
| 65 | 绍兴银行 |
| 66 | 广东南粤银行 |
| 67 | 青岛银行 |
| 68 | 江苏长江商行 |
| 69 | 包商银行 |
| 70 | 富滇银行 |
| 71 | 自贡市商业银行 |
| 72 | 湖北农信 |
| 73 | 浙江农信 |
| 74 | 葫芦岛银行 |
| 75 | 昆仑银行 |
| 76 | 苏州银行 |
| 77 | 湖州银行 |
| 78 | 泉州银行 |
| 79 | 广州农村商业银行 |
| 81 | 太仓农村商业银行 |
| 82 | 烟台银行 |
| 83 | 上饶银行 |
| 84 | 绵阳市商业银行 |
| 85 | 德州银行 |
| 86 | 广西农村信用社 |
| 87 | 柳州银行 |
| 88 | 新韩银行中国 |
| 89 | 长沙银行 |
| 90 | 黄河农村商业银行 |
| 91 | 鞍山银行 |
| 92 | 龙江银行 |
| 93 | 河北银行 |
| 94 | 内蒙古银行 |
| 95 | 吉林农村信用社 |
| 96 | 浙江三门银座村镇银行 |
| 97 | 东莞银行 |
| 98 | 泰安银行 |
| 99 | 桂林银行股份有限公司 |
| 100 | 昆山农村商业银行 |
| 101 | 攀枝花市商业银行 |
| 102 | 西安银行 |
| 103 | 营口银行 |
| 104 | 江苏省农村信用社联合社 |
| 105 | 顺德农村商业银行 |
| 106 | 张家港农村商业银行 |
| 107 | 重庆黔江银座村镇银行 |
| 108 | 临商银行 |
| 109 | 洛阳银行 |
| 110 | 邢台银行 |
| 111 | 韩亚银行 |
| 112 | 广西北部湾银行 |
| 113 | 张家口市商业银行 |
| 114 | 珠海华润银行 |
| 115 | 天津银行 |
| 116 | 阜新银行 |
| 117 | 吴江农村商业银行 |
| 118 | 友利银行 |
| 119 | 北京顺义银座村镇银行 |
| 120 | 晋商银行 |
| 121 | 赣州银行 |
| 122 | 鄞州银行 |
| 123 | 兰州银行 |
| 124 | 锦州银行 |
| 125 | 邯郸市商业银行 |
| 126 | 深圳福田银座村镇银行 |
| 127 | 东莞农村商业银行 |
| 128 | 乌鲁木齐市商业银行 |
| 129 | 浙江景宁银座村镇银行 |
| 130 | 威海市商业银行 |
| 131 | 海南农村商业银行股份有限公司 |
| 132 | 商丘银行 |
| 133 | 鄂尔多斯银行 |
| 134 | 江西赣州银座村镇银行 |
| 135 | 天津农商银行 |
| 136 | 重庆银行 |
| 137 | 宁夏银行 |
| 138 | 浙江民泰商业银行 |
| 140 | 长城华西银行 |
| 141 | 廊坊银行 |
| 142 | 沧州银行 |
| 143 | 福建海峡银行 |
| 144 | 嘉兴银行 |
| 145 | 吉林银行 |
| 146 | 青海银行 |
| 147 | 重庆渝北银座村镇银行 |
| 148 | 枣庄银行 |
| 149 | 武汉农村商业银行 |
| 150 | 重庆三峡银行 |
| 151 | 南洋商业银行 |
| 152 | 恒生银行 |
| 153 | 集友银行 |
| 154 | 大众银行 |
| 155 | 永亨银行 |
| 156 | 上海商业银行 |
| 157 | 永隆银行 |
| 158 | 中信嘉华银行 |
| 159 | 华南商业银行 |
| 161 | 保定银行 |
| 162 | 上海华瑞银行 |
| 163 | 九江银行 |
| 164 | 江西省农村信用社 |
| 165 | 广东省农村信用社联合社 |
| 166 | 河南省农村信用社 |
| 167 | 辽宁省农村信用社 |
| 168 | 黑龙江省农村信用社 |
| 169 | 湖南省农村信用社 |
| 170 | 河北省农村信用社 |
| 171 | 甘肃省农村信用社 |
| 172 | 山西省农村信用社 |
| 173 | 陕西省农村信用社 |
| 174 | 贵州省农村信用社 |
| 175 | 内蒙古自治区农村信用社 |
| 176 | 新疆自治区农村信用社 |
| 177 | 四川省农村信用社 |
| 178 | 成都农商银行 |
| 179 | 长沙农商银行 |
| 180 | 三亚农商银行 |
| 221 | 国家开发银行 |
| 224 | 汇丰银行 |
| 239 | 网商银行 |
| 246 | 武汉众邦银行 |
| 310 | 中国农业发展银行 |
| 432 | 深圳前海微众银行股份有限公司 |
| 473 | 北京中关村银行股份有限公司 |
| 480 | 吉林亿联银行股份有限公司 |
| 483 | 四川新网银行股份有限公司 |
| 492 | 重庆富民银行股份有限公司 |
| 546 | 四川银行股份有限公司 |
| 584 | 江苏苏商银行股份有限公司 |
| 695 | 山西银行股份有限公司 |
| 720 | 花旗银行(中国)有限公司 |
| 777 | 摩根大通银行(中国)有限公司 |
| 948 | 宁波东海银行股份有限公司 |
> 完整列表共 800+ 条,上方仅列出主要银行。如需完整列表请查阅汇元客户中心银行列表 Excel对接群 @客服督导获取),该列表更新更及时。
---
## 网银支持银行列表
用于网银支付场景的银行编码(与结算银行编码体系不同)。
| 编码 | 银行名称 |
|------|---------|
| 001 | 中国工商银行 |
| 003 | 中国建设银行 |
| 005 | 中国农业银行 |
| 006 | 交通银行 |
| 008 | 广东发展银行 |
| 009 | 中信银行 |
| 010 | 中国光大银行 |
| 011 | 兴业银行 |
| 012 | 平安银行 |
| 013 | 中国民生银行 |
| 014 | 华夏银行 |
| 020 | 中国邮政储蓄银行 |
| 022 | 上海银行 |
| 024 | 宁波银行 |
| 029 | 北京农商银行 |
| 035 | 渤海银行 |
| 044 | 浙商银行 |
| 045 | 北京银行 |
### 企业网银
| 编码 | 银行名称 |
|------|---------|
| 030 | 农业银行(企业银行) |
| 046 | 中信银行(企业银行) |
| 047 | 招商银行(企业银行) |
| 048 | 中国建设银行(企业银行) |
| 049 | 上海浦东发展银行(企业银行) |
| 050 | 中国银行(企业银行) |
| 051 | 广发银行(企业银行) |
| 052 | 北京银行(企业银行) |
| 053 | 中国邮政储蓄银行(企业银行) |
| 054 | 中国工商银行(企业银行) |
| 055 | 中国民生银行(企业银行) |
| 056 | 中国光大银行(企业银行) |
| 057 | 上海银行(企业银行) |
| 058 | 平安银行(企业银行) |
| 059 | 宁波银行(企业银行) |
| 060 | 华夏(企业银行) |
| 061 | 浙商银行(企业银行) |
| 062 | 交通银行(企业银行) |
| 063 | 柳州银行(企业银行) |
| 066 | 桂林银行(企业银行) |
| 068 | 杭州银行(企业银行) |
| 071 | 齐鲁银行(企业银行) |
---
## 注意事项
1. **两套编码体系相互独立**:结算银行列表(数字编码 1-950+)用于入网 `bank_name` 字段网银支持列表3位字符编码 001-071用于网银支付场景勿混用
2. **结算银行传名称**`settlement_info.bank_name` 传银行名称字符串,不传编码
3. **编码存在跳号**:如结算列表中无 80、139、160 等编号,属正常现象

View File

@@ -0,0 +1,179 @@
# 附录 - MCC 经营类目编码
使用场景商户入网接口001`base_info.mcc` 字段传入。
> 小微商户:使用企业或个体户对应行业的编码即可。
## 企业 - 线上业务
适用于游戏、网络虚拟、电商团购、财经资讯、众筹、电信运营商、机票、共享服务、网购平台、O2O 平台、信用还款等。
| 行业细分 | 编码 |
|---------|------|
| 线上商超 | 1000 |
| 在线教育培训机构 | 1001 |
| 人才中介机构/招聘/猎头 | 1002 |
| 职业社交/婚介/交友 | 1003 |
| 机票/机票代理 | 1004 |
| 在线图书/视频/音乐 | 1005 |
| 门户/资讯/论坛 | 1006 |
| 网络直播 | 1008 |
| 软件/建站/技术开发 | 1009 |
| 网络推广/网络广告 | 1010 |
| 综合生活服务平台 | 1011 |
| 电信运营商 | 1012 |
| 宽带收费 | 1013 |
| 信用还款 | 1014 |
| 其他 | 1015 |
## 企业 - 保险
适用于保险公司、保险代理公司。
| 行业细分 | 编码 |
|---------|------|
| 保险业务 | 1016 |
## 企业 - 线下实体
适用于餐饮、零售批发、交通出行、生活娱乐服务、民营医疗机构、缴费、加油、物流快递等。
| 行业细分 | 编码 |
|---------|------|
| 餐饮 | 1017 |
| 超市 | 1018 |
| 便利店 | 1019 |
| 百货 | 1020 |
| 食品生鲜 | 1021 |
| 数码电器/电脑办公 | 1022 |
| 家具建材/家居厨具 | 1023 |
| 服饰箱包 | 1024 |
| 运动户外 | 1025 |
| 美妆个护 | 1026 |
| 母婴用品/儿童玩具 | 1027 |
| 图书音像/文具乐器 | 1028 |
| 黄金珠宝 | 1029 |
| 钟表眼镜 | 1030 |
| 宠物/宠物用品 | 1031 |
| 礼品鲜花/农资绿植 | 1032 |
| 物流/快递 | 1033 |
| 咨询/法律咨询/金融咨询等 | 1034 |
| 婚庆/摄影 | 1035 |
| 装饰/设计 | 1036 |
| 家政/维修服务 | 1037 |
| 广告/会展/活动策划 | 1038 |
| 房地产 | 1039 |
| 丧仪殡葬服务 | 1040 |
| 宠物医院 | 1041 |
| 娱乐票务 | 1042 |
| 运动健身场馆 | 1043 |
| 俱乐部/休闲会所 | 1044 |
| 院线影城 | 1045 |
| 演出赛事 | 1046 |
| 美发/美容/美甲店 | 1047 |
| 酒吧 | 1048 |
| 租车 | 1049 |
| 加油 | 1050 |
| 船舶/海运服务 | 1051 |
| 铁路客运 | 1052 |
| 旅行社 | 1053 |
| 汽车用品 | 1054 |
| 汽车美容/维修保养 | 1055 |
| 停车缴费 | 1056 |
| 旅馆/酒店/度假区 | 1057 |
| 景区 | 1058 |
| 宗教 | 1059 |
| 教育/培训/考试缴费/学费 | 1060 |
| 诊所/卫生站/卫生服务中心 | 1061 |
| 私立/民营医院/诊所 | 1062 |
| 有线电视缴费 | 1063 |
| 话费通讯 | 1064 |
| 拍卖/典当 | 1065 |
| 其他 | 1066 |
## 企业 - 民办教育
| 行业细分 | 编码 |
|---------|------|
| 民办大学及学院 | 1067 |
| 民办中小幼 | 1068 |
## 企业 - 民生缴费 / 交通
适用于水电煤暖气民生缴费、交通罚款、公交/高速。
| 行业细分 | 编码 |
|---------|------|
| 高速收费 | 1069 |
| 城市公共交通 | 1070 |
| 公共事业(水、电、燃气、供暖) | 1071 |
| 游戏 | 1073 |
## 个体户 - 线上业务
适用于电商团购、电信运营商/宽带收费、机票/机票代理。
| 行业细分 | 编码 |
|---------|------|
| 机票/机票代理 | 1074 |
| 线上商超 | 1075 |
| 综合生活服务平台 | 1076 |
## 个体户 - 线下实体
适用于餐饮、零售批发、交通出行、生活娱乐服务、民营医疗机构、缴费等。
| 行业细分 | 编码 |
|---------|------|
| 餐饮 | 1077 |
| 超市 | 1078 |
| 便利店 | 1079 |
| 百货 | 1080 |
| 食品生鲜 | 1081 |
| 数码电器/电脑办公 | 1082 |
| 家具建材/家居厨具 | 1083 |
| 服饰箱包 | 1084 |
| 运动户外 | 1085 |
| 美妆个护 | 1086 |
| 母婴用品/儿童玩具 | 1087 |
| 图书音像/文具乐器 | 1088 |
| 黄金珠宝 | 1089 |
| 钟表 | 1090 |
| 宠物/宠物用品 | 1091 |
| 礼品鲜花/农资绿植 | 1092 |
| 眼镜 | 1093 |
| 物流/快递 | 1094 |
| 咨询/法律咨询/金融咨询等 | 1095 |
| 婚庆/摄影 | 1096 |
| 装饰/设计 | 1097 |
| 家政/维修服务 | 1098 |
| 广告/会展/活动策划 | 1099 |
| 丧仪殡葬服务 | 1100 |
| 宠物医院 | 1101 |
| 娱乐票务 | 1102 |
| 运动健身场馆 | 1103 |
| 俱乐部/休闲会所 | 1104 |
| 院线影城 | 1105 |
| 演出赛事 | 1106 |
| 美发/美容/美甲店 | 1107 |
| 酒吧 | 1108 |
| 租车 | 1109 |
| 加油 | 1110 |
| 船舶/海运服务 | 1111 |
| 汽车用品 | 1112 |
| 汽车美容/维修保养 | 1113 |
| 停车缴费 | 1114 |
| 旅馆/酒店/度假区 | 1115 |
| 景区 | 1116 |
| 教育/培训/考试缴费/学费 | 1117 |
| 保健器械/医疗器械/非处方药品 | 1118 |
| 诊所/卫生站/卫生服务中心 | 1119 |
| 私立/民营医院/诊所 | 1120 |
| 有线电视缴费 | 1121 |
| 话费通讯 | 1122 |
| 拍卖/典当 | 1123 |
| 其他 | 1124 |
## 小微商户
使用企业或个体户对应行业编码1000-1124均可。

View File

@@ -0,0 +1,145 @@
# 附录 - 国家编码
使用场景商户入网接口001以下字段传入
- `identity_info.nation`(法人国籍)
- `contact_info.nation`(业务联系人国籍)
- `ubo_infos[].nation`(受益所有人国籍)
| 编码 | 国家/地区名称 |
|------|-------------|
| 1 | 中国大陆 |
| 2 | 中国香港 |
| 3 | 中国澳门 |
| 4 | 中国台湾地区 |
| 5 | 马来西亚 |
| 6 | 新加坡 |
| 7 | 日本 |
| 8 | 韩国 |
| 9 | 美国 |
| 10 | 加拿大 |
| 11 | 澳大利亚 |
| 12 | 新西兰 |
| 13 | 阿尔巴尼亚 |
| 14 | 阿根廷 |
| 15 | 阿联酋 |
| 16 | 阿曼 |
| 17 | 阿塞拜疆 |
| 18 | 爱尔兰 |
| 19 | 埃及 |
| 20 | 爱沙尼亚 |
| 21 | 安哥拉 |
| 22 | 奥地利 |
| 23 | 巴布亚新几内亚 |
| 24 | 巴哈马 |
| 25 | 巴勒斯坦 |
| 26 | 巴林 |
| 27 | 巴拿马 |
| 28 | 巴西 |
| 29 | 白俄罗斯 |
| 30 | 保加利亚 |
| 31 | 贝宁 |
| 32 | 比利时 |
| 33 | 秘鲁 |
| 34 | 波兰 |
| 35 | 玻利维亚 |
| 36 | 伯利兹 |
| 37 | 布基纳法索 |
| 38 | 赤道几内亚 |
| 39 | 丹麦 |
| 40 | 德国 |
| 41 | 多哥 |
| 42 | 俄罗斯 |
| 43 | 法国 |
| 44 | 菲律宾 |
| 45 | 芬兰 |
| 46 | 佛得角 |
| 47 | 冈比亚 |
| 48 | 格林纳达 |
| 49 | 格鲁吉亚 |
| 50 | 哥伦比亚 |
| 51 | 哥斯达黎加 |
| 52 | 圭亚那 |
| 53 | 荷兰 |
| 54 | 洪都拉斯 |
| 55 | 吉布提 |
| 56 | 吉尔吉斯斯坦 |
| 57 | 几内亚 |
| 58 | 几内亚比绍 |
| 59 | 加蓬 |
| 60 | 柬埔寨 |
| 61 | 津巴布韦 |
| 62 | 喀麦隆 |
| 63 | 卡塔尔 |
| 64 | 开曼群岛 |
| 65 | 克罗地亚 |
| 66 | 科摩罗 |
| 67 | 科威特 |
| 68 | 肯尼亚 |
| 69 | 拉脱维亚 |
| 70 | 莱索托 |
| 71 | 立陶宛 |
| 72 | 卢森堡 |
| 73 | 卢旺达 |
| 74 | 罗马尼亚 |
| 75 | 马达加斯加 |
| 76 | 马尔代夫 |
| 77 | 马拉维 |
| 78 | 马里 |
| 79 | 毛里求斯 |
| 80 | 毛里塔尼亚 |
| 81 | 蒙古 |
| 82 | 摩尔多瓦 |
| 83 | 摩洛哥 |
| 84 | 莫桑比克 |
| 85 | 墨西哥 |
| 86 | 纳米比亚 |
| 87 | 南非 |
| 88 | 尼加拉瓜 |
| 89 | 尼日尔 |
| 90 | 尼日利亚 |
| 91 | 挪威 |
| 92 | 葡萄牙 |
| 93 | 瑞典 |
| 94 | 瑞士 |
| 95 | 萨尔瓦多 |
| 96 | 塞尔维亚 |
| 97 | 塞拉利昂 |
| 98 | 塞内加尔 |
| 99 | 塞浦路斯 |
| 100 | 塞舌尔 |
| 101 | 沙特阿拉伯 |
| 102 | 斯里兰卡 |
| 103 | 斯洛伐克 |
| 104 | 斯洛文尼亚 |
| 105 | 斯威士兰 |
| 106 | 苏里南 |
| 107 | 塔吉克斯坦 |
| 108 | 泰国 |
| 109 | 坦桑尼亚 |
| 110 | 特立尼达和多巴哥 |
| 111 | 土耳其 |
| 112 | 土库曼斯坦 |
| 113 | 突尼斯 |
| 114 | 危地马拉 |
| 115 | 委内瑞拉 |
| 116 | 文莱 |
| 117 | 乌干达 |
| 118 | 乌克兰 |
| 119 | 乌拉圭 |
| 120 | 乌兹别克斯坦 |
| 121 | 西班牙 |
| 122 | 希腊 |
| 123 | 匈牙利 |
| 124 | 牙买加 |
| 125 | 也门 |
| 126 | 意大利 |
| 127 | 以色列 |
| 128 | 印度 |
| 129 | 印度尼西亚 |
| 130 | 英国 |
| 131 | 英属维尔京群岛 |
| 132 | 约旦 |
| 133 | 越南 |
| 134 | 赞比亚 |
| 135 | 乍得 |
| 136 | 智利 |

View File

@@ -0,0 +1,175 @@
# 附录 - 支付场景编码
使用场景商户入网接口001`business_info.business_open_info.heepay_info.product_info.rate_infos[].rate_code` 字段传入。
---
## 支付场景列表
### 账户类
| 支付场景名称 | 编码 |
|------------|------|
| 个人账户余额 | `PERSON_BALANCE_PAY` |
| 商家余额支付 | `MERCH_BALANCE_PAY` |
| 商家余额代收 | `MERCH_BALANCE_RECEIVE` |
| 个人卡包支付 | `PERSON_CARD_PAY` |
| 商家卡包支付 | `MERCH_CARD_PAY` |
### 银联控件支付
| 支付场景名称 | 编码 |
|------------|------|
| 手机 Wap 借记卡 | `UNIPAY_WAP_DEBIT` |
| 手机 Wap 贷记卡 | `UNIPAY_WAP_CREDIT` |
### 网银支付B2C
| 支付场景名称 | 编码 |
|------------|------|
| 网银支付 | `BANK_ONLINE_B2C` |
| 网银借记卡 | `BANK_ONLINE_DEBIT` |
| 网银贷记卡 | `BANK_ONLINE_CREDIT` |
| 网银支付(通用) | `BANK_ONLINE` |
### 网银支付B2B 企业银行)
| 支付场景名称 | 编码 |
|------------|------|
| 网银支付 B2B通用 | `BANK_ONLINE_B2B` |
| 平安企业银行 | `BANK_ONLINE_SPABANK` |
| 浦发企业银行 | `BANK_ONLINE_SPDB` |
| 建设企业银行 | `BANK_ONLINE_CCB` |
| 农业企业银行 | `BANK_ONLINE_ABC` |
| 邮储企业银行 | `BANK_ONLINE_PSBC` |
| 中行企业银行 | `BANK_ONLINE_BOC` |
| 招商企业银行 | `BANK_ONLINE_CMB` |
| 广发企业银行 | `BANK_ONLINE_GDB` |
| 中信企业银行 | `BANK_ONLINE_CITIC` |
| 民生企业银行 | `BANK_ONLINE_CMBC` |
| 交通企业银行 | `BANK_ONLINE_COMM` |
| 杭州企业银行 | `BANK_ONLINE_HZCB` |
| 宁波企业银行 | `BANK_ONLINE_NBBANK` |
| 光大企业银行 | `BANK_ONLINE_CEB` |
| 北京企业银行 | `BANK_ONLINE_BJBANK` |
| 工商企业银行 | `BANK_ONLINE_ICBC` |
### 支付宝支付
| 支付场景名称 | 编码 |
|------------|------|
| 支付宝条码支付 | `ALI_BARCODE` |
| 支付宝扫码支付 | `ALI_QRCODE` |
| 支付宝 H5 支付 | `ALI_H5` |
| 支付宝公众号 | `ALI_JSAPI` |
| 支付宝小程序 | `ALI_MINI` |
### 微信支付
| 支付场景名称 | 编码 |
|------------|------|
| 微信公众号支付 | `WX_JSAPI` |
| 微信原生扫码支付 | `WX_QRCODE` |
| 微信 APP 支付 | `WX_APP` |
| 微信刷卡支付 | `WX_BARCODE` |
| 微信 H5 支付 | `WX_H5` |
| 微信小程序支付 | `WX_MINI` |
### 快捷支付
| 支付场景名称 | 编码 |
|------------|------|
| 老快捷支付 | `QUICK_PAY_OLD` |
| 新快捷-借(通用) | `QUICK_PAY_DEBIT` |
| 新快捷-贷(通用) | `QUICK_PAY_CREDIT` |
| 新快捷-上海银行-借 | `QUICK_SH_DEBIT` |
| 新快捷-上海银行-贷 | `QUICK_SH_CREDIT` |
| 新快捷-华夏银行-借 | `QUICK_HX_DEBIT` |
| 新快捷-平安银行-借 | `QUICK_PA_DEBIT` |
| 新快捷-浦发银行-借 | `QUICK_PF_DEBIT` |
| 新快捷-建设银行-借 | `QUICK_CCB_DEBIT` |
| 新快捷-建设银行-贷 | `QUICK_CCB_CREDIT` |
| 新快捷-中信银行-借 | `QUICK_CITIC_DEBIT` |
| 新快捷-中信银行-贷 | `QUICK_CITIC_CREDIT` |
| 新快捷-工商银行-借 | `QUICK_ICBC_DEBIT` |
| 新快捷-工商银行-贷 | `QUICK_ICBC_CREDIT` |
| 新快捷-光大银行-借 | `QUICK_GD_DEBIT` |
| 新快捷-光大银行-贷 | `QUICK_GD_CREDIT` |
| 新快捷-民生银行-借 | `QUICK_MS_DEBIT` |
| 新快捷-民生银行-贷 | `QUICK_MS_CREDIT` |
| 新快捷-中国银行-借 | `QUICK_BOC_DEBIT` |
| 新快捷-中国银行-贷 | `QUICK_BOC_CREDIT` |
| 新快捷-邮政银行-借 | `QUICK_PSB_DEBIT` |
| 新快捷-邮政储蓄银行-借 | `QUICK_PS_SB_DEBIT` |
| 新快捷-农业银行-借 | `QUICK_AB_DEBIT` |
| 新快捷-交通银行-借 | `QUICK_BC_DEBIT` |
| 新快捷-交通银行-贷 | `QUICK_BC_CREDIT` |
| 新快捷-浙商银行-借 | `QUICK_ZSB_DEBIT` |
| 新快捷-渤海银行-借 | `QUICK_BANK_BH_DEBIT` |
| 新快捷-恒丰银行-借 | `QUICK_HFBANK_DEBIT` |
| 新快捷-广东发展银行-借 | `QUICK_GDB_DEBIT` |
| 新快捷-苏州银行-借 | `QUICK_SUZHOUBANK_DEBIT` |
| 新快捷-鄞州银行-借 | `QUICK_BEEBANK_DEBIT` |
| 新快捷-贵州银行-借 | `QUICK_GUIZHOUBANK_DEBIT` |
| 新快捷-上海农村商业银行-借 | `QUICK_SRCB_DEBIT` |
| 新快捷-北京农商银行-借 | `QUICK_BJRCBANK_DEBIT` |
| 新快捷-天津农商银行-借 | `QUICK_TRCBANK_DEBIT` |
| 新快捷-成都农商银行-借 | `QUICK_CDRCBANK_DEBIT` |
| 新快捷-深圳农村商业银行-借 | `QUICK_SZNCSYBANK_DEBIT` |
| 新快捷-重庆农村商业银行-借 | `QUICK_RCB_CQ_DEBIT` |
| 新快捷-武汉农村商业银行-借 | `QUICK_WHRCBANK_DEBIT` |
| 新快捷-广东南海农村商业银行-借 | `QUICK_RCB_GD_SOUTH_SEA_DEBIT` |
| 新快捷-四川省农村信用社-借 | `QUICK_RCC_SC_DEBIT` |
| 新快捷-河南省农村信用社-借 | `QUICK_RCC_HN_DEBIT` |
| 新快捷-广西农村信用社-借 | `QUICK_RCC_GX_DEBIT` |
| 新快捷-甘肃省农村信用社-借 | `QUICK_RCC_GXS_DEBIT` |
| 新快捷-山西省农村信用社-借 | `QUICK_RCC_SX_DEBIT` |
| 新快捷-陕西省农村信用社-借 | `QUICK_SXNXSBANK_DEBIT` |
| 新快捷-河北省农村信用社-借 | `QUICK_HEBNXBANK_DEBIT` |
| 新快捷-内蒙古自治区农村信用社-借 | `QUICK_NMGNXSBANK_DEBIT` |
| 新快捷-青海省农村信用社联合社-借 | `QUICK_QINGHAIRCBANK_DEBIT` |
| 新快捷-黑龙江省农村信用社-借 | `QUICK_HLJRCCBANK_DEBIT` |
| 新快捷-辽宁省农村信用社-借 | `QUICK_LNRCCBANK_DEBIT` |
| 新快捷-云南省农村信用社联合社-借 | `QUICK_RCC_YN_DEBIT` |
| 新快捷-湖南省农村信用社-借 | `QUICK_RCC_HUNS_DEBIT` |
| 新快捷-海南省农村信用社-借 | `QUICK_RCC_HNS_DEBIT` |
| 新快捷-安徽省农村信用社联合社-借 | `QUICK_RCC_AH_DEBIT` |
| 新快捷-吉林农村信用社-借 | `QUICK_RCC_JL_DEBIT` |
### 银联扫码支付
| 支付场景名称 | 编码 |
|------------|------|
| 银行卡扫码支付小额-借 | `BANK_QRCODE_SMALL_DEBIT` |
| 银行卡扫码支付小额-贷 | `BANK_QRCODE_SMALL_CREDIT` |
| 银行卡扫码支付大额-借 | `BANK_QRCODE_LARGE_DEBIT` |
| 银行卡扫码支付大额-贷 | `BANK_QRCODE_LARGE_CREDIT` |
### 银联全渠道
| 支付场景名称 | 编码 |
|------------|------|
| 银联全渠道 APP-借 | `UNIPAY_APP_DEBIT` |
| 银联全渠道 APP-贷 | `UNIPAY_APP_CREDIT` |
| 银联全渠道 H5-借 | `UNIPAY_H5_DEBIT` |
| 银联全渠道 H5-贷 | `UNIPAY_H5_CREDIT` |
---
## 费率参数示例
`rate_infos` 方式(标准结构):
```json
{
"product_info": {
"rate_infos": [
{ "rate_code": "WX_JSAPI", "rate_type": "SINGLE_PERCENT", "rate": "0.006" },
{ "rate_code": "WX_QRCODE", "rate_type": "SINGLE_PERCENT", "rate": "0.006" },
{ "rate_code": "WX_MINI", "rate_type": "SINGLE_PERCENT", "rate": "0.006" }
]
}
}
```
> 注意:文档中也出现了另一种简写格式 `"rate": {"WX_JSAPI": 0.006, ...}`,以实际联调结果为准,建议优先使用标准 `rate_infos` 数组结构。

1140
docs/tech-design.md Normal file

File diff suppressed because it is too large Load Diff

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

Some files were not shown because too many files have changed in this diff Show More