This commit is contained in:
2026-07-22 17:16:35 +08:00
52 changed files with 6336 additions and 293 deletions

View File

@@ -0,0 +1,204 @@
package esign
import (
"context"
"encoding/json"
"fmt"
"strings"
"hyapi-server/internal/domains/certification/enums"
"hyapi-server/internal/domains/certification/ports"
sharedesign "hyapi-server/internal/shared/esign"
)
type Provider struct {
client *sharedesign.Client
}
func NewProvider(client *sharedesign.Client) *Provider {
return &Provider{client: client}
}
func (p *Provider) Platform() enums.SignPlatform {
return enums.SignPlatformEsign
}
func (p *Provider) GenerateEnterpriseAuth(ctx context.Context, req *ports.EnterpriseAuthRequest) (*ports.AuthLinkResult, error) {
_ = ctx
res, err := p.client.GenerateEnterpriseAuth(&sharedesign.EnterpriseAuthRequest{
CompanyName: req.CompanyName,
UnifiedSocialCode: req.UnifiedSocialCode,
LegalPersonName: req.LegalPersonName,
LegalPersonID: req.LegalPersonID,
TransactorName: req.TransactorName,
TransactorMobile: req.TransactorMobile,
TransactorID: req.TransactorID,
})
if err != nil {
return nil, err
}
return &ports.AuthLinkResult{
AuthFlowID: res.AuthFlowID,
AuthURL: res.AuthURL,
AuthShortURL: firstNonEmpty(res.AuthShortURL, res.AuthURL),
}, nil
}
func (p *Provider) QueryOrgVerified(ctx context.Context, req *ports.OrgIdentityQuery) (bool, error) {
_ = ctx
identity, err := p.client.QueryOrgIdentityInfo(&sharedesign.QueryOrgIdentityRequest{
OrgName: req.OrgName,
OrgIDCardNum: req.OrgIdentNo,
})
if err != nil {
return false, err
}
return identity != nil && identity.Data.RealnameStatus == 1, nil
}
func (p *Provider) GenerateContractFile(ctx context.Context, req *ports.ContractGenerateRequest) (*ports.ContractFileResult, error) {
_ = ctx
components := map[string]string{
"jfqym": req.CompanyName,
"jfqym2": req.CompanyName,
"jfsqdb": req.AuthorizedRepName,
"jftyshxydm": req.UnifiedSocialCode,
"jflxdz": req.EnterpriseAddress,
"xybh": req.AgreementNo,
"qsrq1": req.SignDate,
"qsrq3": req.SignDate,
"qsrq2": req.SignDate,
}
res, err := p.client.FillTemplate(components)
if err != nil {
return nil, err
}
return &ports.ContractFileResult{
FileID: res.FileID,
FileDownloadURL: res.FileDownloadUrl,
}, nil
}
func (p *Provider) CreateSignFlow(ctx context.Context, req *ports.SignFlowCreateRequest) (*ports.SignFlowResult, error) {
_ = ctx
flowID, err := p.client.CreateSignFlow(&sharedesign.CreateSignFlowRequest{
FileID: req.FileID,
SignerAccount: req.PartyAUSCC,
SignerName: req.PartyAName,
TransactorPhone: req.TransactorMobile,
TransactorName: req.TransactorName,
TransactorIDCardNum: req.TransactorID,
})
if err != nil {
return nil, err
}
if req.SkipActorURL {
return &ports.SignFlowResult{SignFlowID: flowID}, nil
}
longURL, shortURL, err := p.client.GetSignURL(flowID, req.TransactorMobile, req.PartyAName)
if err != nil {
return nil, err
}
return &ports.SignFlowResult{
SignFlowID: flowID,
SignURL: firstNonEmpty(longURL, shortURL),
ShortURL: shortURL,
}, nil
}
func (p *Provider) GetActorSignURL(ctx context.Context, req *ports.GetActorSignURLRequest) (*ports.ActorSignURLResult, error) {
_ = ctx
if req == nil || strings.TrimSpace(req.SignFlowID) == "" {
return nil, fmt.Errorf("signFlowId 不能为空")
}
longURL, shortURL, err := p.client.GetSignURL(req.SignFlowID, req.TransactorMobile, req.PartyAName)
if err != nil {
return nil, err
}
return &ports.ActorSignURLResult{
SignFlowID: req.SignFlowID,
EmbedURL: firstNonEmpty(longURL, shortURL),
ShortURL: shortURL,
}, nil
}
func (p *Provider) GetSignTaskPreviewURL(ctx context.Context, signFlowID string) (*ports.SignTaskPreviewURLResult, error) {
_ = ctx
// e签宝无独立预览接口用已签/合同文件下载链作为预览源(前端用 PDF 打开)
files, err := p.DownloadSignedFiles(ctx, signFlowID)
if err != nil {
return nil, err
}
if len(files) == 0 || strings.TrimSpace(files[0].DownloadURL) == "" {
return nil, fmt.Errorf("未获取到可预览的合同文件")
}
return &ports.SignTaskPreviewURLResult{
SignFlowID: signFlowID,
PreviewURL: files[0].DownloadURL,
}, nil
}
func (p *Provider) QuerySignStatus(ctx context.Context, flowID string) (*ports.SignStatusResult, error) {
_ = ctx
detail, err := p.client.QuerySignFlowDetail(flowID)
if err != nil {
return nil, err
}
status := detail.Data.SignFlowStatus
return &ports.SignStatusResult{
SignFlowID: flowID,
Status: fmt.Sprintf("%d", status),
Completed: status == 2,
Rejected: status == 7,
Expired: status == 5,
Message: detail.Data.SignFlowDescription,
}, nil
}
func (p *Provider) DownloadSignedFiles(ctx context.Context, flowID string) ([]*ports.SignedFile, error) {
_ = ctx
res, err := p.client.DownloadSignedFile(flowID)
if err != nil {
return nil, err
}
files := make([]*ports.SignedFile, 0, len(res.Data.Files))
for _, f := range res.Data.Files {
files = append(files, &ports.SignedFile{DownloadURL: f.DownloadUrl})
}
return files, nil
}
func (p *Provider) VerifyCallback(ctx context.Context, headers map[string]string, body []byte) error {
_ = ctx
_ = headers
_ = body
// e签宝现有回调走独立验签逻辑此处保持兼容由原 HandleEsignCallback 处理)
return nil
}
func (p *Provider) ParseCallback(ctx context.Context, headers map[string]string, body []byte) (*ports.CallbackEvent, error) {
_ = ctx
_ = headers
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
action, _ := raw["action"].(string)
ev := &ports.CallbackEvent{Event: action, Raw: raw}
if action == "AUTH_PASS" {
ev.AuthPassed = true
}
if action == "SIGN_FLOW_COMPLETE" || action == "SIGN_MISSON_COMPLETE" {
ev.SignCompleted = true
}
return ev, nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
var _ ports.SignPlatformProvider = (*Provider)(nil)

View File

@@ -0,0 +1,247 @@
package fadada
import (
"context"
"fmt"
"net/url"
"strings"
"hyapi-server/internal/domains/certification/enums"
"hyapi-server/internal/domains/certification/ports"
sharedfadada "hyapi-server/internal/shared/fadada"
)
type Provider struct {
client *sharedfadada.Client
}
func NewProvider(client *sharedfadada.Client) *Provider {
return &Provider{client: client}
}
func (p *Provider) Platform() enums.SignPlatform {
return enums.SignPlatformFadada
}
func (p *Provider) GenerateEnterpriseAuth(ctx context.Context, req *ports.EnterpriseAuthRequest) (*ports.AuthLinkResult, error) {
_ = ctx
res, err := p.client.GenerateEnterpriseAuth(&sharedfadada.EnterpriseAuthRequest{
ClientCorpID: req.ClientCorpID,
ClientUserID: req.ClientUserID,
CompanyName: req.CompanyName,
UnifiedSocialCode: req.UnifiedSocialCode,
LegalPersonName: req.LegalPersonName,
LegalPersonID: req.LegalPersonID,
TransactorName: req.TransactorName,
TransactorMobile: req.TransactorMobile,
TransactorID: req.TransactorID,
})
if err != nil {
return nil, err
}
return &ports.AuthLinkResult{
AuthFlowID: res.AuthFlowID,
AuthURL: res.AuthURL,
AuthShortURL: firstNonEmpty(res.AuthShortURL, res.AuthURL),
}, nil
}
func (p *Provider) QueryOrgVerified(ctx context.Context, req *ports.OrgIdentityQuery) (bool, error) {
_ = ctx
return p.client.QueryOrgVerified(&sharedfadada.QueryOrgIdentityRequest{
CorpName: req.OrgName,
CorpIdentNo: req.OrgIdentNo,
})
}
// GenerateContractFile 法大大走签署任务模板(/sign-task/create-with-template
// 此处返回空结果,实际填单由 CreateSignFlow 通过 /sign-task/field/fill-values 完成。
func (p *Provider) GenerateContractFile(ctx context.Context, req *ports.ContractGenerateRequest) (*ports.ContractFileResult, error) {
_ = ctx
_ = req
return &ports.ContractFileResult{FileID: "", FileDownloadURL: ""}, nil
}
func (p *Provider) CreateSignFlow(ctx context.Context, req *ports.SignFlowCreateRequest) (*ports.SignFlowResult, error) {
_ = ctx
createReq := &sharedfadada.CreateSignFlowRequest{
Subject: req.Subject,
PartyAOpenCorpID: req.PartyAOpenCorpID,
PartyAName: req.PartyAName,
PartyAUSCC: req.PartyAUSCC,
TransactorName: req.TransactorName,
TransactorMobile: req.TransactorMobile,
TransactorID: req.TransactorID,
TransReferenceID: req.TransReferenceID,
}
if req.Fill != nil {
createReq.Fill = &sharedfadada.ContractFillRequest{
AgreementNo: req.Fill.AgreementNo,
CompanyName: req.Fill.CompanyName,
UnifiedSocialCode: req.Fill.UnifiedSocialCode,
EnterpriseAddress: req.Fill.EnterpriseAddress,
AuthorizedRepName: req.Fill.AuthorizedRepName,
SignDate: req.Fill.SignDate,
FileName: req.Fill.FileName,
}
} else {
// 签署模板创建必须填控件
createReq.Fill = &sharedfadada.ContractFillRequest{
CompanyName: req.PartyAName,
UnifiedSocialCode: req.PartyAUSCC,
AuthorizedRepName: req.TransactorName,
}
}
createRes, err := p.client.CreateSignFlow(createReq)
if err != nil {
return nil, err
}
if req.SkipActorURL {
return &ports.SignFlowResult{
SignFlowID: createRes.SignTaskID,
}, nil
}
urlRes, err := p.client.GetSignURL(createRes.SignTaskID, sharedfadada.ActorIDPartyA)
if err != nil {
return nil, err
}
embedURL := strings.TrimSpace(urlRes.EmbedURL)
if embedURL == "" {
return nil, fmt.Errorf("法大大未返回可嵌入签署链接 actorSignTaskEmbedUrl")
}
return &ports.SignFlowResult{
SignFlowID: createRes.SignTaskID,
SignURL: embedURL,
ShortURL: strings.TrimSpace(urlRes.SignURL),
}, nil
}
func (p *Provider) GetActorSignURL(ctx context.Context, req *ports.GetActorSignURLRequest) (*ports.ActorSignURLResult, error) {
_ = ctx
if req == nil || strings.TrimSpace(req.SignFlowID) == "" {
return nil, fmt.Errorf("signFlowId 不能为空")
}
urlRes, err := p.client.GetSignURL(strings.TrimSpace(req.SignFlowID), sharedfadada.ActorIDPartyA)
if err != nil {
return nil, err
}
embedURL := strings.TrimSpace(urlRes.EmbedURL)
if embedURL == "" {
return nil, fmt.Errorf("法大大未返回可嵌入签署链接 actorSignTaskEmbedUrl")
}
return &ports.ActorSignURLResult{
SignFlowID: req.SignFlowID,
EmbedURL: embedURL,
ShortURL: strings.TrimSpace(urlRes.SignURL),
}, nil
}
func (p *Provider) GetSignTaskPreviewURL(ctx context.Context, signFlowID string) (*ports.SignTaskPreviewURLResult, error) {
_ = ctx
signFlowID = strings.TrimSpace(signFlowID)
if signFlowID == "" {
return nil, fmt.Errorf("signFlowId 不能为空")
}
res, err := p.client.GetSignTaskPreviewURL(signFlowID)
if err != nil {
return nil, err
}
return &ports.SignTaskPreviewURLResult{
SignFlowID: signFlowID,
PreviewURL: res.PreviewURL,
}, nil
}
func (p *Provider) QuerySignStatus(ctx context.Context, flowID string) (*ports.SignStatusResult, error) {
_ = ctx
st, err := p.client.QuerySignStatus(flowID)
if err != nil {
return nil, err
}
return &ports.SignStatusResult{
SignFlowID: flowID,
Status: st.SignTaskStatus,
Completed: st.Completed,
Rejected: st.Terminated,
Expired: false,
Message: firstNonEmpty(st.TerminationNote, st.RevokeReason),
}, nil
}
func (p *Provider) DownloadSignedFiles(ctx context.Context, flowID string) ([]*ports.SignedFile, error) {
_ = ctx
res, err := p.client.DownloadSignedFiles(flowID)
if err != nil {
return nil, err
}
files := make([]*ports.SignedFile, 0, len(res.Files))
for _, f := range res.Files {
files = append(files, &ports.SignedFile{
DownloadURL: f.DownloadURL,
DownloadID: f.DownloadID,
})
}
return files, nil
}
func (p *Provider) VerifyCallback(ctx context.Context, headers map[string]string, body []byte) error {
_ = ctx
bizContent := extractBizContent(body)
return p.client.VerifyCallback(headers, bizContent)
}
func (p *Provider) ParseCallback(ctx context.Context, headers map[string]string, body []byte) (*ports.CallbackEvent, error) {
_ = ctx
bizContent := extractBizContent(body)
ev, err := p.client.ParseCallback(headers, bizContent)
if err != nil {
return nil, err
}
out := &ports.CallbackEvent{
Event: ev.Event,
SignFlowID: ev.SignTaskID,
AuthFlowID: firstNonEmpty(ev.ClientCorpID, ev.OpenCorpID),
Raw: ev.Raw,
}
if ev.IsSignCompletedCallback() {
out.SignCompleted = true
}
// 企业授权/认证成功事件
switch strings.ToLower(ev.Event) {
case "corp-authorize", "corp-authorize-success", "corporation_authorize", "auth-pass":
out.AuthPassed = true
}
if strings.EqualFold(ev.AuthResult, "success") || strings.EqualFold(ev.AuthResult, "pass") {
out.AuthPassed = true
}
return out, nil
}
func extractBizContent(body []byte) string {
raw := strings.TrimSpace(string(body))
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "{") {
return raw
}
values, err := url.ParseQuery(raw)
if err == nil {
if v := values.Get("bizContent"); v != "" {
return v
}
}
return raw
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
var _ ports.SignPlatformProvider = (*Provider)(nil)

View File

@@ -0,0 +1,94 @@
package rongxing
import (
"crypto"
"crypto/md5"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"fmt"
"sort"
"strings"
)
// MD5Upper 对明文做 MD5返回 32 位大写十六进制(入参说明要求大写)。
func MD5Upper(plaintext string) string {
sum := md5.Sum([]byte(strings.TrimSpace(plaintext)))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
// EncodePasswordBase64 将明文密码做 Base64 编码(登录接口要求)。
func EncodePasswordBase64(password string) string {
return base64.StdEncoding.EncodeToString([]byte(password))
}
// BuildSignContent 生成登录签名原文:过滤 sign/空值 → 按 key 升序 → key=value&...
func BuildSignContent(params map[string]interface{}) string {
keys := make([]string, 0, len(params))
for k, v := range params {
if strings.EqualFold(k, "sign") || v == nil {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%v", k, params[k]))
}
return strings.Join(parts, "&")
}
// SignSHA256WithRSA 使用私钥对原文做 SHA256withRSA 签名,返回 Base64。
func SignSHA256WithRSA(content string, privateKey *rsa.PrivateKey) (string, error) {
if privateKey == nil {
return "", fmt.Errorf("私钥为空")
}
hashed := sha256.Sum256([]byte(content))
signed, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:])
if err != nil {
return "", fmt.Errorf("RSA 签名失败: %w", err)
}
return base64.StdEncoding.EncodeToString(signed), nil
}
// ParsePrivateKey 解析 RSA 私钥(支持 PEM / PKCS#8 / PKCS#1 原始 Base64
func ParsePrivateKey(raw string) (*rsa.PrivateKey, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("私钥为空")
}
var der []byte
if block, _ := pem.Decode([]byte(raw)); block != nil {
der = block.Bytes
} else {
normalized := strings.ReplaceAll(raw, "\n", "")
normalized = strings.ReplaceAll(normalized, "\r", "")
normalized = strings.ReplaceAll(normalized, " ", "")
decoded, err := base64.StdEncoding.DecodeString(normalized)
if err != nil {
return nil, fmt.Errorf("私钥 Base64 解码失败: %w", err)
}
der = decoded
}
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("私钥不是 RSA 类型")
}
return rsaKey, nil
}
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
return key, nil
}
return nil, fmt.Errorf("无法解析 RSA 私钥(需 PKCS#8 或 PKCS#1")
}

View File

@@ -0,0 +1,66 @@
package rongxing
import (
"crypto/rand"
"crypto/rsa"
"testing"
)
func TestMD5Upper(t *testing.T) {
if got := MD5Upper(""); got != "D41D8CD98F00B204E9800998ECF8427E" {
t.Fatalf("MD5Upper empty = %s", got)
}
if got := MD5Upper(" hello "); got != MD5Upper("hello") {
t.Fatalf("MD5Upper should trim spaces")
}
}
func TestBuildSignContent(t *testing.T) {
params := map[string]interface{}{
"account": "haiyukeji",
"password": "cGFzcw==",
"appId": "hykj",
"timestamp": int64(1710000000000),
"sign": "ignored",
}
got := BuildSignContent(params)
want := "account=haiyukeji&appId=hykj&password=cGFzcw==&timestamp=1710000000000"
if got != want {
t.Fatalf("BuildSignContent mismatch\ngot: %s\nwant: %s", got, want)
}
}
func TestSignSHA256WithRSA(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("generate key: %v", err)
}
sig, err := SignSHA256WithRSA("account=a&appId=b", key)
if err != nil {
t.Fatalf("sign: %v", err)
}
if sig == "" {
t.Fatal("empty signature")
}
}
func TestParsePrivateKeyPKCS8Base64(t *testing.T) {
// 与对接文档一致的 PKCS#8 Base64无 PEM 头)
raw := "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw=="
key, err := ParsePrivateKey(raw)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if key == nil {
t.Fatal("nil key")
}
if _, err := SignSHA256WithRSA("account=haiyukeji&appId=hykj", key); err != nil {
t.Fatalf("sign with parsed key: %v", err)
}
}
func TestEncodePasswordBase64(t *testing.T) {
if got := EncodePasswordBase64("haiyukeji@123"); got != "aGFpeXVrZWppQDEyMw==" {
t.Fatalf("EncodePasswordBase64 = %s", got)
}
}

View File

@@ -0,0 +1,35 @@
package rongxing
import (
"strings"
)
// generateCurlCommand 生成可直接复现的 curl 命令,便于联调排查。
func generateCurlCommand(method, url string, headers map[string]string, body string) string {
var cmd strings.Builder
cmd.WriteString("curl -X ")
cmd.WriteString(method)
cmd.WriteString(" '")
cmd.WriteString(url)
cmd.WriteString("'")
for key, value := range headers {
cmd.WriteString(" \\\n -H '")
cmd.WriteString(key)
cmd.WriteString(": ")
cmd.WriteString(escapeSingleQuotes(value))
cmd.WriteString("'")
}
if body != "" {
cmd.WriteString(" \\\n -d '")
cmd.WriteString(escapeSingleQuotes(body))
cmd.WriteString("'")
}
return cmd.String()
}
func escapeSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", `'\''`)
}

View File

@@ -0,0 +1,50 @@
package rongxing
import (
"hyapi-server/internal/config"
"hyapi-server/internal/shared/external_logger"
)
// NewRongxingServiceWithConfig 使用配置创建戎行服务
func NewRongxingServiceWithConfig(cfg *config.Config) (*RongxingService, error) {
loggingConfig := external_logger.ExternalServiceLoggingConfig{
Enabled: cfg.Rongxing.Logging.Enabled,
LogDir: cfg.Rongxing.Logging.LogDir,
ServiceName: "rongxing",
UseDaily: cfg.Rongxing.Logging.UseDaily,
EnableLevelSeparation: cfg.Rongxing.Logging.EnableLevelSeparation,
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
}
if cfg.Rongxing.Logging.ServiceName != "" {
loggingConfig.ServiceName = cfg.Rongxing.Logging.ServiceName
}
for key, value := range cfg.Rongxing.Logging.LevelConfigs {
loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{
MaxSize: value.MaxSize,
MaxBackups: value.MaxBackups,
MaxAge: value.MaxAge,
Compress: value.Compress,
}
}
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
if err != nil {
return nil, err
}
timeout := cfg.Rongxing.Timeout
if timeout <= 0 {
timeout = defaultRequestTimeout
}
return NewRongxingService(serviceConfig{
BaseURL: cfg.Rongxing.URL,
Account: cfg.Rongxing.Account,
Password: cfg.Rongxing.Password,
AppID: cfg.Rongxing.AppID,
PrivateKey: cfg.Rongxing.PrivateKey,
Timeout: timeout,
}, logger), nil
}

View File

@@ -0,0 +1,396 @@
package rongxing
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"hyapi-server/internal/shared/external_logger"
"go.uber.org/zap"
)
const (
defaultRequestTimeout = 10 * time.Second
apiKeyLogin = "auth_login"
pathAuthLogin = "/auth/login"
headerDmsToken = "dms-token"
)
// serviceConfig 戎行服务运行时配置
type serviceConfig struct {
BaseURL string
Account string
Password string
AppID string
PrivateKey string
Timeout time.Duration
}
// RongxingService 戎行数据源服务
type RongxingService struct {
config serviceConfig
logger *external_logger.ExternalServiceLogger
tokenMu sync.RWMutex
cachedToken string
}
// apiResponse 戎行统一响应。code 可能是数字或字符串;扣费以 consumeFlag 为准。
type apiResponse struct {
Flag bool `json:"flag"`
Code json.RawMessage `json:"code"`
Msg string `json:"msg"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
ConsumeFlag int `json:"consumeFlag"`
}
func (r apiResponse) text() string {
if r.Msg != "" {
return r.Msg
}
return r.Message
}
func (r apiResponse) code() string {
return parseCode(r.Code)
}
// NewRongxingService 创建戎行服务实例
func NewRongxingService(cfg serviceConfig, logger *external_logger.ExternalServiceLogger) *RongxingService {
if cfg.Timeout <= 0 {
cfg.Timeout = defaultRequestTimeout
}
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
return &RongxingService{config: cfg, logger: logger}
}
// GetConfig 获取运行时配置
func (s *RongxingService) GetConfig() serviceConfig {
return s.config
}
// CallAPI 通用业务接口调用。
// apiPath 为相对路径(如 /third/loan/info360reqData 为已组装好的请求体。
// Token 获取与 Header 注入由服务内部处理401/403 时自动刷新 Token 并重试一次。
func (s *RongxingService) CallAPI(ctx context.Context, apiPath string, reqData map[string]interface{}) ([]byte, error) {
apiKey := strings.Trim(apiPath, "/")
var transactionID string
if id, ok := ctx.Value("transaction_id").(string); ok {
transactionID = id
}
if err := s.validateConfig(); err != nil {
err = errors.Join(ErrSystem, err)
s.logError(transactionID, apiKey, "", err, nil)
return nil, err
}
if !strings.HasPrefix(apiPath, "/") {
apiPath = "/" + apiPath
}
requestURL := s.config.BaseURL + apiPath
bodyBytes, err := json.Marshal(reqData)
if err != nil {
err = errors.Join(ErrSystem, err)
s.logError(transactionID, apiKey, "", err, reqData)
return nil, err
}
bodyStr := string(bodyBytes)
for attempt := 0; attempt < 2; attempt++ {
token, err := s.getToken(ctx, transactionID)
if err != nil {
return nil, err
}
headers := map[string]string{
"Content-Type": "application/json",
headerDmsToken: token,
}
curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes))
if err != nil {
err = errors.Join(ErrSystem, err)
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, "")
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(headerDmsToken, token)
respBody, statusCode, err := s.doHTTP(req)
if err != nil {
err = errors.Join(ErrDatasource, err)
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, "")
return nil, err
}
respStr := string(respBody)
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
s.clearToken()
if attempt == 0 {
continue
}
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", statusCode))
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr)
return nil, err
}
if statusCode != http.StatusOK {
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", statusCode))
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr)
return nil, err
}
var resp apiResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w, body=%s", err, respStr))
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr)
return nil, err
}
code := resp.code()
payload := extractBusinessPayload(resp.Data)
// 扣费只看 consumeFlag1 扣费按成功返回0 不扣费
if IsBillable(resp.ConsumeFlag) {
return payload, nil
}
sentinel := MapNonBillableToErr(code)
err = errors.Join(sentinel, NewRongxingError(code, resp.text()))
if !errors.Is(sentinel, ErrNotFound) {
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr)
}
return nil, err
}
return nil, errors.Join(ErrDatasource, errors.New("请求失败"))
}
func (s *RongxingService) getToken(ctx context.Context, transactionID string) (string, error) {
s.tokenMu.RLock()
token := s.cachedToken
s.tokenMu.RUnlock()
if token != "" {
return token, nil
}
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
if s.cachedToken != "" {
return s.cachedToken, nil
}
token, err := s.login(ctx, transactionID)
if err != nil {
return "", err
}
s.cachedToken = token
return token, nil
}
func (s *RongxingService) clearToken() {
s.tokenMu.Lock()
s.cachedToken = ""
s.tokenMu.Unlock()
}
func (s *RongxingService) login(ctx context.Context, transactionID string) (string, error) {
privateKey, err := ParsePrivateKey(s.config.PrivateKey)
if err != nil {
err = errors.Join(ErrSystem, err)
s.logError(transactionID, apiKeyLogin, "", err, nil)
return "", err
}
passwordB64 := EncodePasswordBase64(s.config.Password)
timestamp := time.Now().UnixMilli()
signParams := map[string]interface{}{
"account": s.config.Account,
"password": passwordB64,
"appId": s.config.AppID,
"timestamp": timestamp,
}
content := BuildSignContent(signParams)
sign, err := SignSHA256WithRSA(content, privateKey)
if err != nil {
err = errors.Join(ErrSystem, err)
s.logError(transactionID, apiKeyLogin, "", err, nil)
return "", err
}
payload := map[string]interface{}{
"account": s.config.Account,
"password": passwordB64,
"appId": s.config.AppID,
"timestamp": timestamp,
"sign": sign,
}
requestURL := s.config.BaseURL + pathAuthLogin
bodyBytes, err := json.Marshal(payload)
if err != nil {
err = errors.Join(ErrSystem, err)
s.logError(transactionID, apiKeyLogin, "", err, map[string]interface{}{
"account": s.config.Account,
"appId": s.config.AppID,
})
return "", err
}
bodyStr := string(bodyBytes)
headers := map[string]string{"Content-Type": "application/json"}
curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes))
if err != nil {
err = errors.Join(ErrSystem, err)
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, "")
return "", err
}
req.Header.Set("Content-Type", "application/json")
respBody, statusCode, err := s.doHTTP(req)
if err != nil {
err = errors.Join(ErrDatasource, err)
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, "")
return "", err
}
respStr := string(respBody)
if statusCode != http.StatusOK {
err = errors.Join(ErrDatasource, fmt.Errorf("登录 HTTP状态码 %d, body=%s", statusCode, respStr))
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr)
return "", err
}
var loginResp apiResponse
if err := json.Unmarshal(respBody, &loginResp); err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("登录响应解析失败: %w, body=%s", err, respStr))
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr)
return "", err
}
code := loginResp.code()
if code != CodeSuccess {
rxErr := NewRongxingError(code, loginResp.text())
err = errors.Join(ErrDatasource, rxErr)
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr)
return "", err
}
var token string
if err := json.Unmarshal(loginResp.Data, &token); err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("登录响应 Token 解析失败: %w, body=%s", err, respStr))
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr)
return "", err
}
token = strings.TrimSpace(token)
if token == "" {
err = errors.Join(ErrSystem, fmt.Errorf("登录响应 Token 为空, body=%s", respStr))
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr)
return "", err
}
return token, nil
}
func (s *RongxingService) doHTTP(req *http.Request) ([]byte, int, error) {
client := &http.Client{Timeout: s.config.Timeout}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return body, resp.StatusCode, nil
}
func (s *RongxingService) validateConfig() error {
if s.config.BaseURL == "" {
return errors.New("戎行 url 未配置")
}
if strings.TrimSpace(s.config.Account) == "" {
return errors.New("戎行 account 未配置")
}
if strings.TrimSpace(s.config.Password) == "" {
return errors.New("戎行 password 未配置")
}
if strings.TrimSpace(s.config.AppID) == "" {
return errors.New("戎行 app_id 未配置")
}
if strings.TrimSpace(s.config.PrivateKey) == "" {
return errors.New("戎行 private_key 未配置")
}
return nil
}
func parseCode(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return strings.TrimSpace(s)
}
var n json.Number
if err := json.Unmarshal(raw, &n); err == nil {
return n.String()
}
return strings.Trim(string(raw), `"`)
}
// extractBusinessPayload 提取对外返回的业务 data。
// 若外层 data 内还嵌套 data如 Info360则取内层标签对象。
func extractBusinessPayload(data json.RawMessage) []byte {
if len(data) == 0 || string(data) == "null" {
return []byte("{}")
}
var wrap struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(data, &wrap); err == nil &&
len(wrap.Data) > 0 && string(wrap.Data) != "null" {
return wrap.Data
}
return data
}
func (s *RongxingService) logError(transactionID, apiKey, requestID string, err error, payload interface{}) {
if s.logger == nil {
return
}
s.logger.LogError(requestID, transactionID, apiKey, err, payload)
}
func (s *RongxingService) logErrorWithCurl(transactionID, apiKey string, err error, payload interface{}, curlCmd, respBody string) {
if s.logger == nil {
return
}
s.logger.LogErrorWithFields("rongxing API错误",
zap.String("transaction_id", transactionID),
zap.String("api_code", apiKey),
zap.Error(err),
zap.Any("params", payload),
zap.String("curl", curlCmd),
zap.String("response_body", respBody),
)
}

View File

@@ -0,0 +1,75 @@
package rongxing
import (
"errors"
"fmt"
)
// 业务返回码(仅用于错误描述/分类,不作为扣费依据)
const (
CodeSuccess = "200" // 成功
CodeNoData = "204" // 未查到数据
CodeInternalErr = "503" // 内部服务错误
)
// consumeFlag 扣费标记(对方各业务码场景下均可能返回)
const (
ConsumeFlagNoCharge = 0 // 不扣费
ConsumeFlagCharge = 1 // 扣费
)
var (
ErrDatasource = errors.New("数据源异常")
ErrSystem = errors.New("系统异常")
ErrNotFound = errors.New("查询为空")
)
var codeMessage = map[string]string{
CodeSuccess: "成功",
CodeNoData: "未查到数据",
CodeInternalErr: "内部服务错误",
}
// GetCodeMessage 返回码描述
func GetCodeMessage(code string) string {
if msg, ok := codeMessage[code]; ok {
return msg
}
return ""
}
// IsBillable 按 consumeFlag 判断是否扣费(不以 code 为依据)
func IsBillable(consumeFlag int) bool {
return consumeFlag == ConsumeFlagCharge
}
type rongxingError struct {
Code string
Message string
}
func (e *rongxingError) Error() string {
return fmt.Sprintf("戎行返回错误code: %smessage: %s", e.Code, e.Message)
}
// NewRongxingError 创建戎行业务错误
func NewRongxingError(code, message string) *rongxingError {
if message == "" {
if desc := GetCodeMessage(code); desc != "" {
message = desc
} else {
message = "戎行返回未知错误"
}
}
return &rongxingError{Code: code, Message: message}
}
// MapNonBillableToErr 不扣费场景下,将返回码映射为内部哨兵错误
func MapNonBillableToErr(code string) error {
switch code {
case CodeSuccess, CodeNoData:
return ErrNotFound
default:
return ErrDatasource
}
}

View File

@@ -0,0 +1,52 @@
package signplatform
import (
"fmt"
"hyapi-server/internal/domains/certification/enums"
"hyapi-server/internal/domains/certification/ports"
)
type Registry struct {
providers map[enums.SignPlatform]ports.SignPlatformProvider
order []enums.SignPlatform
}
func NewRegistry(providers ...ports.SignPlatformProvider) *Registry {
r := &Registry{
providers: make(map[enums.SignPlatform]ports.SignPlatformProvider, len(providers)),
order: make([]enums.SignPlatform, 0, len(providers)),
}
for _, p := range providers {
if p == nil {
continue
}
platform := p.Platform()
r.providers[platform] = p
r.order = append(r.order, platform)
}
return r
}
func (r *Registry) Get(platform enums.SignPlatform) (ports.SignPlatformProvider, error) {
if platform == "" {
platform = enums.DefaultSignPlatform()
}
p, ok := r.providers[platform]
if !ok {
return nil, fmt.Errorf("不支持的签署平台: %s", platform)
}
return p, nil
}
func (r *Registry) List() []ports.SignPlatformProvider {
out := make([]ports.SignPlatformProvider, 0, len(r.order))
for _, platform := range r.order {
if p, ok := r.providers[platform]; ok {
out = append(out, p)
}
}
return out
}
var _ ports.SignPlatformRegistry = (*Registry)(nil)

View File

@@ -305,11 +305,10 @@ func (s *QiNiuStorageService) DownloadFile(ctx context.Context, fileURL string)
isTimeout = true
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
isTimeout = true
} else if errStr := err.Error();
errStr == "context deadline exceeded" ||
errStr == "timeout" ||
errStr == "Client.Timeout exceeded" ||
errStr == "net/http: request canceled" {
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
errStr == "timeout" ||
errStr == "Client.Timeout exceeded" ||
errStr == "net/http: request canceled" {
isTimeout = true
}

View File

@@ -154,6 +154,59 @@ func (h *CertificationHandler) ConfirmAuth(c *gin.Context) {
h.response.Success(c, result, "状态确认成功")
}
// ListSignPlatforms 可选签署平台列表
func (h *CertificationHandler) ListSignPlatforms(c *gin.Context) {
result, err := h.appService.ListSignPlatforms(c.Request.Context())
if err != nil {
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, result, "获取签署平台列表成功")
}
// SelectSignPlatform 选择签署平台并生成企业认证链接
func (h *CertificationHandler) SelectSignPlatform(c *gin.Context) {
var cmd commands.SelectSignPlatformCommand
cmd.UserID = h.getCurrentUserID(c)
if cmd.UserID == "" {
h.response.Unauthorized(c, "用户未登录")
return
}
if err := c.ShouldBindJSON(&cmd); err != nil {
h.response.BadRequest(c, "请求参数错误")
return
}
result, err := h.appService.SelectSignPlatform(c.Request.Context(), &cmd)
if err != nil {
h.logger.Error("选择签署平台失败", zap.Error(err), zap.String("user_id", cmd.UserID))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, result, "已选择签署平台")
}
// HandleFadadaCallback 法大大回调
func (h *CertificationHandler) HandleFadadaCallback(c *gin.Context) {
headers := make(map[string]string)
for key, values := range c.Request.Header {
if len(values) > 0 {
headers[key] = values[0]
}
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
h.logger.Error("读取法大大回调失败", zap.Error(err))
c.String(400, "fail")
return
}
if err := h.appService.HandleFadadaCallback(c.Request.Context(), headers, body); err != nil {
h.logger.Error("处理法大大回调失败", zap.Error(err))
c.String(400, "fail")
return
}
c.Data(200, "application/json", []byte(`{"msg":"success"}`))
}
// ConfirmSign 前端确认是否完成签署
// @Summary 前端确认签署状态
// @Description 前端轮询确认合同签署是否完成
@@ -219,6 +272,61 @@ func (h *CertificationHandler) ApplyContract(c *gin.Context) {
h.response.Success(c, result, "合同申请成功")
}
// RefreshContractSignURL 刷新合同签署 iframe 长链
// @Summary 刷新合同签署链接
// @Description 重新获取可供 iframe 嵌入的签署长链(法大大约 10 分钟/单次有效)
// @Tags 认证管理
// @Accept json
// @Produce json
// @Security Bearer
// @Success 200 {object} responses.ContractSignUrlResponse "刷新成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Router /api/v1/certifications/refresh-contract-sign-url [post]
func (h *CertificationHandler) RefreshContractSignURL(c *gin.Context) {
userID := h.getCurrentUserID(c)
if userID == "" {
h.response.Unauthorized(c, "用户未登录")
return
}
result, err := h.appService.RefreshContractSignURL(c.Request.Context(), userID)
if err != nil {
h.logger.Error("刷新签署链接失败", zap.Error(err), zap.String("user_id", userID))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, result, "签署链接已刷新")
}
// RefreshContractPreviewURL 刷新合同预览链接
// @Summary 刷新合同预览链接
// @Description 获取签署任务预览链接(法大大 /sign-task/get-preview-url
// @Tags 认证管理
// @Accept json
// @Produce json
// @Security Bearer
// @Success 200 {object} responses.ContractPreviewUrlResponse "刷新成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Router /api/v1/certifications/refresh-contract-preview-url [post]
func (h *CertificationHandler) RefreshContractPreviewURL(c *gin.Context) {
userID := h.getCurrentUserID(c)
if userID == "" {
h.response.Unauthorized(c, "用户未登录")
return
}
result, err := h.appService.RefreshContractPreviewURL(c.Request.Context(), userID)
if err != nil {
h.logger.Error("刷新合同预览链接失败", zap.Error(err), zap.String("user_id", userID))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, result, "预览链接已刷新")
}
// RecognizeBusinessLicense OCR识别营业执照
// @Summary OCR识别营业执照
// @Description 上传营业执照图片进行OCR识别自动填充企业信息

View File

@@ -65,10 +65,17 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) {
// 3. 申请合同签署
authGroup.POST("/apply-contract", r.handler.ApplyContract)
// 刷新签署 iframe 长链(法大大 EmbedURL 短期有效)
authGroup.POST("/refresh-contract-sign-url", r.handler.RefreshContractSignURL)
authGroup.POST("/refresh-contract-preview-url", r.handler.RefreshContractPreviewURL)
// 前端确认是否完成认证
authGroup.POST("/confirm-auth", r.handler.ConfirmAuth)
// 签署平台选择(审核通过后、企业认证前)
authGroup.GET("/sign-platforms", r.handler.ListSignPlatforms)
authGroup.POST("/select-sign-platform", r.handler.SelectSignPlatform)
// 前端确认是否完成签署
authGroup.POST("/confirm-sign", r.handler.ConfirmSign)
@@ -95,7 +102,8 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) {
// 回调路由(不需要认证,但需要验证签名)
callbackGroup := certificationGroup.Group("/callbacks")
{
callbackGroup.POST("/esign", r.handler.HandleEsignCallback) // e签宝回调(统一处理企业认证和合同签署回调)
callbackGroup.POST("/esign", r.handler.HandleEsignCallback) // e签宝回调
callbackGroup.POST("/fadada", r.handler.HandleFadadaCallback) // 法大大回调
}
}