fadd
This commit is contained in:
188
internal/shared/fadada/api_types.go
Normal file
188
internal/shared/fadada/api_types.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package fadada
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
type openID struct {
|
||||
IDType string `json:"idType"`
|
||||
OpenID string `json:"openId"`
|
||||
}
|
||||
|
||||
// ---- accessToken ----
|
||||
|
||||
type accessTokenData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresIn string `json:"expiresIn"`
|
||||
}
|
||||
|
||||
// ---- /corp/get-auth-url ----
|
||||
|
||||
type getCorpAuthURLReq struct {
|
||||
ClientCorpID string `json:"clientCorpId"`
|
||||
ClientUserID string `json:"clientUserId,omitempty"`
|
||||
AccountName string `json:"accountName,omitempty"`
|
||||
CorpIdentInfo *corpIdentInfo `json:"corpIdentInfo,omitempty"`
|
||||
CorpNonEditableInfo []string `json:"corpNonEditableInfo,omitempty"`
|
||||
OprIdentInfo *oprIdentInfo `json:"oprIdentInfo,omitempty"`
|
||||
OprNonEditableInfo []string `json:"oprNonEditableInfo,omitempty"`
|
||||
CorpIdentInfoMatch bool `json:"corpIdentInfoMatch"`
|
||||
AuthScopes []string `json:"authScopes,omitempty"`
|
||||
RedirectURL string `json:"redirectUrl,omitempty"`
|
||||
}
|
||||
|
||||
type corpIdentInfo struct {
|
||||
CorpName string `json:"corpName,omitempty"`
|
||||
CorpIdentType string `json:"corpIdentType,omitempty"`
|
||||
CorpIdentNo string `json:"corpIdentNo,omitempty"`
|
||||
LegalRepName string `json:"legalRepName,omitempty"`
|
||||
CorpIdentMethod []string `json:"corpIdentMethod,omitempty"`
|
||||
}
|
||||
|
||||
type oprIdentInfo struct {
|
||||
UserName string `json:"userName,omitempty"`
|
||||
UserIdentType string `json:"userIdentType,omitempty"`
|
||||
UserIdentNo string `json:"userIdentNo,omitempty"`
|
||||
Mobile string `json:"mobile,omitempty"`
|
||||
OprIdentMethod []string `json:"oprIdentMethod,omitempty"`
|
||||
}
|
||||
|
||||
type getCorpAuthURLData struct {
|
||||
AuthURL string `json:"authUrl"`
|
||||
}
|
||||
|
||||
// ---- /corp/get-identified-status ----
|
||||
|
||||
type getIdentifiedStatusReq struct {
|
||||
CorpName string `json:"corpName,omitempty"`
|
||||
CorpIdentNo string `json:"corpIdentNo,omitempty"`
|
||||
}
|
||||
|
||||
type getIdentifiedStatusData struct {
|
||||
IdentStatus bool `json:"identStatus"`
|
||||
}
|
||||
|
||||
// ---- 模板 / 签署 ----
|
||||
|
||||
type docFieldValue struct {
|
||||
// DocID 签署任务中的文档标识;fill-values 接口必填(缺则报 100012 docId 不能为空)
|
||||
DocID string `json:"docId,omitempty"`
|
||||
// FieldDocID 部分文档/示例使用 fieldDocId,与 DocID 同值一并传以保证兼容
|
||||
FieldDocID string `json:"fieldDocId,omitempty"`
|
||||
FieldID string `json:"fieldId"`
|
||||
FieldName string `json:"fieldName,omitempty"`
|
||||
FieldValue string `json:"fieldValue"`
|
||||
}
|
||||
|
||||
type fillDocTemplateReq struct {
|
||||
OwnerID *openID `json:"ownerId"`
|
||||
DocTemplateID string `json:"docTemplateId"`
|
||||
FileName string `json:"fileName,omitempty"`
|
||||
DocFieldValues []docFieldValue `json:"docFieldValues"`
|
||||
}
|
||||
|
||||
type fillDocTemplateData struct {
|
||||
FileID string `json:"fileId"`
|
||||
FileDownloadURL string `json:"fileDownloadUrl"`
|
||||
}
|
||||
|
||||
type fillSignTaskFieldsReq struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
DocFieldValues []docFieldValue `json:"docFieldValues"`
|
||||
}
|
||||
|
||||
type signTaskActor struct {
|
||||
Actor *actor `json:"actor"`
|
||||
SignConfigInfo *signConfigInfo `json:"signConfigInfo,omitempty"`
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
ActorType string `json:"actorType"`
|
||||
ActorID string `json:"actorId"`
|
||||
ActorName string `json:"actorName,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
ActorOpenID string `json:"actorOpenId,omitempty"`
|
||||
IdentNameForMatch string `json:"identNameForMatch,omitempty"`
|
||||
CertNoForMatch string `json:"certNoForMatch,omitempty"`
|
||||
AccountName string `json:"accountName,omitempty"`
|
||||
ClientUserID string `json:"clientUserId,omitempty"`
|
||||
}
|
||||
|
||||
type signConfigInfo struct {
|
||||
OrderNo int `json:"orderNo"`
|
||||
JoinByLink bool `json:"joinByLink"`
|
||||
RequestVerifyFree bool `json:"requestVerifyFree"`
|
||||
FreeSignType string `json:"freeSignType,omitempty"` // template=按模板免验证签;business=按场景码(需 businessId)
|
||||
FreeLogin bool `json:"freeLogin"`
|
||||
BlockHere bool `json:"blockHere"`
|
||||
}
|
||||
|
||||
type createSignTaskWithTemplateReq struct {
|
||||
SignTaskSubject string `json:"signTaskSubject"`
|
||||
Initiator *openID `json:"initiator"`
|
||||
SignTemplateID string `json:"signTemplateId"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
AutoFillFinalize bool `json:"autoFillFinalize"`
|
||||
AutoFinish bool `json:"autoFinish"`
|
||||
SignInOrder bool `json:"signInOrder,omitempty"`
|
||||
ExpiresTime string `json:"expiresTime,omitempty"`
|
||||
FreeSignType string `json:"freeSignType,omitempty"` // template:按模板;business:按场景码
|
||||
BusinessID string `json:"businessId,omitempty"` // 仅 freeSignType=business 时传场景码
|
||||
TransReferenceID string `json:"transReferenceId,omitempty"`
|
||||
Actors []signTaskActor `json:"actors"`
|
||||
}
|
||||
|
||||
type createSignTaskData struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
}
|
||||
|
||||
type startSignTaskReq struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
}
|
||||
|
||||
type getActorURLReq struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
ActorID string `json:"actorId"`
|
||||
RedirectURL string `json:"redirectUrl,omitempty"`
|
||||
}
|
||||
|
||||
type getActorURLData struct {
|
||||
ActorSignTaskURL string `json:"actorSignTaskUrl"`
|
||||
ActorSignTaskEmbedURL string `json:"actorSignTaskEmbedUrl"`
|
||||
}
|
||||
|
||||
type getSignTaskPreviewURLReq struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
RedirectURL string `json:"redirectUrl,omitempty"`
|
||||
}
|
||||
|
||||
type getSignTaskPreviewURLData struct {
|
||||
SignTaskPreviewURL string `json:"signTaskPreviewUrl"`
|
||||
}
|
||||
|
||||
type getSignTaskDetailReq struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
}
|
||||
|
||||
type getSignTaskDetailData struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
SignTaskStatus string `json:"signTaskStatus"`
|
||||
FinishTime string `json:"finishTime,omitempty"`
|
||||
TerminationNote string `json:"terminationNote,omitempty"`
|
||||
RevokeReason string `json:"revokeReason,omitempty"`
|
||||
Docs []signTaskDocBrief `json:"docs,omitempty"`
|
||||
}
|
||||
|
||||
type signTaskDocBrief struct {
|
||||
DocID string `json:"docId"`
|
||||
DocName string `json:"docName,omitempty"`
|
||||
}
|
||||
|
||||
type getOwnerDownloadURLReq struct {
|
||||
OwnerID *openID `json:"ownerId"`
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
FileType string `json:"fileType,omitempty"`
|
||||
}
|
||||
|
||||
type getOwnerDownloadURLData struct {
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
DownloadID string `json:"downloadId,omitempty"`
|
||||
}
|
||||
184
internal/shared/fadada/callback.go
Normal file
184
internal/shared/fadada/callback.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package fadada
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 常见回调事件名(以官方实际推送为准,解析时兼容多种字段)
|
||||
const (
|
||||
CallbackEventSignTaskSigned = "sign-task-signed"
|
||||
CallbackEventSignTaskFinished = "sign-task-finished"
|
||||
CallbackEventSignTaskCanceled = "sign-task-canceled"
|
||||
CallbackEventCorpAuthorize = "corp-authorize"
|
||||
CallbackEventCorpAuthSuccess = "corp-authorize-success"
|
||||
)
|
||||
|
||||
// SuccessCallbackBody 回调应答,约定返回成功
|
||||
var SuccessCallbackBody = []byte(`{"msg":"success"}`)
|
||||
|
||||
// VerifyCallback 校验法大大回调签名(HMAC-SHA256,与出站请求同一套算法)
|
||||
func (c *Client) VerifyCallback(headers map[string]string, bizContent string) error {
|
||||
if c.config == nil || c.config.AppSecret == "" {
|
||||
return fmt.Errorf("法大大 app_secret 未配置,无法验签")
|
||||
}
|
||||
|
||||
normalized := normalizeHeaderMap(headers)
|
||||
sign := firstHeader(normalized, "X-FASC-Sign", "X-Fasc-Sign")
|
||||
if sign == "" {
|
||||
return fmt.Errorf("缺少回调签名头 X-FASC-Sign")
|
||||
}
|
||||
timestamp := firstHeader(normalized, "X-FASC-Timestamp", "X-Fasc-Timestamp")
|
||||
if timestamp == "" {
|
||||
return fmt.Errorf("缺少回调时间戳头 X-FASC-Timestamp")
|
||||
}
|
||||
|
||||
headMap := map[string]string{
|
||||
"X-FASC-App-Id": firstHeader(normalized, "X-FASC-App-Id", "X-Fasc-App-Id"),
|
||||
"X-FASC-Sign-Type": firstHeader(normalized, "X-FASC-Sign-Type", "X-Fasc-Sign-Type"),
|
||||
"X-FASC-Timestamp": timestamp,
|
||||
"X-FASC-Nonce": firstHeader(normalized, "X-FASC-Nonce", "X-Fasc-Nonce"),
|
||||
"X-FASC-Api-SubVersion": firstHeader(normalized, "X-FASC-Api-SubVersion", "X-Fasc-Api-SubVersion"),
|
||||
"bizContent": bizContent,
|
||||
}
|
||||
if event := firstHeader(normalized, "X-FASC-Event", "X-Fasc-Event"); event != "" {
|
||||
headMap["X-FASC-Event"] = event
|
||||
}
|
||||
if token := firstHeader(normalized, "X-FASC-AccessToken", "X-Fasc-AccessToken"); token != "" {
|
||||
headMap["X-FASC-AccessToken"] = token
|
||||
}
|
||||
|
||||
expected := SignByMap(headMap, timestamp, c.config.AppSecret)
|
||||
if !strings.EqualFold(expected, sign) {
|
||||
return fmt.Errorf("法大大回调签名校验失败")
|
||||
}
|
||||
|
||||
appID := firstHeader(normalized, "X-FASC-App-Id", "X-Fasc-App-Id")
|
||||
if appID != "" && c.config.AppID != "" && appID != c.config.AppID {
|
||||
return fmt.Errorf("法大大回调 AppId 不匹配")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyCallbackFromHTTP 从 http.Header 验签
|
||||
func (c *Client) VerifyCallbackFromHTTP(h http.Header, bizContent string) error {
|
||||
headers := make(map[string]string, len(h))
|
||||
for k, vals := range h {
|
||||
if len(vals) > 0 {
|
||||
headers[k] = vals[0]
|
||||
}
|
||||
}
|
||||
return c.VerifyCallback(headers, bizContent)
|
||||
}
|
||||
|
||||
// ParseCallback 解析 bizContent 为统一 CallbackEvent
|
||||
func (c *Client) ParseCallback(headers map[string]string, bizContent string) (*CallbackEvent, error) {
|
||||
bizContent = strings.TrimSpace(bizContent)
|
||||
if bizContent == "" {
|
||||
return nil, fmt.Errorf("回调 bizContent 不能为空")
|
||||
}
|
||||
|
||||
raw := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(bizContent), &raw); err != nil {
|
||||
return nil, fmt.Errorf("解析回调 bizContent 失败: %w", err)
|
||||
}
|
||||
|
||||
normalized := normalizeHeaderMap(headers)
|
||||
event := firstHeader(normalized, "X-FASC-Event", "X-Fasc-Event")
|
||||
if event == "" {
|
||||
event = stringValue(raw, "event", "eventType", "type")
|
||||
}
|
||||
|
||||
ev := &CallbackEvent{
|
||||
Event: event,
|
||||
SignTaskID: stringValue(raw, "signTaskId", "sign_task_id"),
|
||||
SignTaskStatus: stringValue(raw, "signTaskStatus", "sign_task_status"),
|
||||
ClientCorpID: stringValue(raw, "clientCorpId", "client_corp_id"),
|
||||
OpenCorpID: stringValue(raw, "openCorpId", "open_corp_id"),
|
||||
AuthResult: stringValue(raw, "authResult", "auth_result", "result"),
|
||||
EventTime: stringValue(raw, "eventTime", "event_time", "timestamp"),
|
||||
Raw: raw,
|
||||
}
|
||||
|
||||
if data, ok := raw["data"].(map[string]interface{}); ok {
|
||||
if ev.SignTaskID == "" {
|
||||
ev.SignTaskID = stringValue(data, "signTaskId", "sign_task_id")
|
||||
}
|
||||
if ev.SignTaskStatus == "" {
|
||||
ev.SignTaskStatus = stringValue(data, "signTaskStatus", "sign_task_status")
|
||||
}
|
||||
if ev.ClientCorpID == "" {
|
||||
ev.ClientCorpID = stringValue(data, "clientCorpId", "client_corp_id")
|
||||
}
|
||||
if ev.OpenCorpID == "" {
|
||||
ev.OpenCorpID = stringValue(data, "openCorpId", "open_corp_id")
|
||||
}
|
||||
if ev.AuthResult == "" {
|
||||
ev.AuthResult = stringValue(data, "authResult", "auth_result", "result")
|
||||
}
|
||||
}
|
||||
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
// IsSignCompletedCallback 是否签署完成类回调
|
||||
func (e *CallbackEvent) IsSignCompletedCallback() bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
if IsSignTaskCompletedStatus(e.SignTaskStatus) {
|
||||
return true
|
||||
}
|
||||
switch e.Event {
|
||||
case CallbackEventSignTaskFinished, CallbackEventSignTaskSigned:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHeaderMap(headers map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(headers))
|
||||
for k, v := range headers {
|
||||
out[http.CanonicalHeaderKey(k)] = v
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstHeader(headers map[string]string, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if v := strings.TrimSpace(headers[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(headers[http.CanonicalHeaderKey(key)]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringValue(m map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(t) != "" {
|
||||
return strings.TrimSpace(t)
|
||||
}
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", t)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
default:
|
||||
s := strings.TrimSpace(fmt.Sprint(t))
|
||||
if s != "" && s != "<nil>" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
57
internal/shared/fadada/callback_test.go
Normal file
57
internal/shared/fadada/callback_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package fadada
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVerifyCallback(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
c := NewClient(&Config{
|
||||
AppID: "80005307",
|
||||
AppSecret: secret,
|
||||
ServerURL: "https://uat-api.fadada.com/api/v5/",
|
||||
})
|
||||
|
||||
biz := `{"signTaskId":"st-1","signTaskStatus":"task_finished"}`
|
||||
timestamp := "1720000000000"
|
||||
nonce := "nonce-1"
|
||||
headMap := map[string]string{
|
||||
"X-FASC-App-Id": "80005307",
|
||||
"X-FASC-Sign-Type": "HMAC-SHA256",
|
||||
"X-FASC-Timestamp": timestamp,
|
||||
"X-FASC-Nonce": nonce,
|
||||
"X-FASC-Event": "sign-task-finished",
|
||||
"bizContent": biz,
|
||||
}
|
||||
sign := SignByMap(headMap, timestamp, secret)
|
||||
|
||||
headers := map[string]string{
|
||||
"X-FASC-App-Id": "80005307",
|
||||
"X-FASC-Sign-Type": "HMAC-SHA256",
|
||||
"X-FASC-Timestamp": timestamp,
|
||||
"X-FASC-Nonce": nonce,
|
||||
"X-FASC-Event": "sign-task-finished",
|
||||
"X-FASC-Sign": sign,
|
||||
}
|
||||
if err := c.VerifyCallback(headers, biz); err != nil {
|
||||
t.Fatalf("verify failed: %v", err)
|
||||
}
|
||||
|
||||
headers["X-FASC-Sign"] = "deadbeef"
|
||||
if err := c.VerifyCallback(headers, biz); err == nil {
|
||||
t.Fatal("expected signature failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignByMapStable(t *testing.T) {
|
||||
m := map[string]string{
|
||||
"X-FASC-App-Id": "app",
|
||||
"X-FASC-Nonce": "n1",
|
||||
"X-FASC-Sign-Type": "HMAC-SHA256",
|
||||
"X-FASC-Timestamp": "1720000000000",
|
||||
"bizContent": `{"a":1}`,
|
||||
}
|
||||
s1 := SignByMap(m, "1720000000000", "secret")
|
||||
s2 := SignByMap(m, "1720000000000", "secret")
|
||||
if s1 == "" || s1 != s2 {
|
||||
t.Fatalf("signature unstable: %s vs %s", s1, s2)
|
||||
}
|
||||
}
|
||||
40
internal/shared/fadada/client.go
Normal file
40
internal/shared/fadada/client.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package fadada
|
||||
|
||||
import "sync"
|
||||
|
||||
// API 路径常量(FASC OpenAPI v5.1)
|
||||
const (
|
||||
pathGetAccessToken = "/service/get-access-token"
|
||||
pathGetCorpAuthURL = "/corp/get-auth-url"
|
||||
pathGetCorpIdentifiedStatus = "/corp/get-identified-status"
|
||||
pathFillDocTemplateValues = "/doc-template/fill-values"
|
||||
pathCreateSignTaskTemplate = "/sign-task/create-with-template"
|
||||
pathFillSignTaskFieldValues = "/sign-task/field/fill-values"
|
||||
pathStartSignTask = "/sign-task/start"
|
||||
pathGetSignTaskActorURL = "/sign-task/actor/get-url"
|
||||
pathGetSignTaskDetail = "/sign-task/app/get-detail"
|
||||
pathGetSignTaskPreviewURL = "/sign-task/get-preview-url"
|
||||
pathGetOwnerSignTaskDownload = "/sign-task/owner/get-download-url"
|
||||
)
|
||||
|
||||
// Client 法大大客户端(纯 HTTP,不依赖官方 SDK)
|
||||
type Client struct {
|
||||
config *Config
|
||||
http *HTTPClient
|
||||
tokenCache *tokenCache
|
||||
tokenMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewClient 创建法大大客户端
|
||||
func NewClient(config *Config) *Client {
|
||||
return &Client{
|
||||
config: config,
|
||||
http: NewHTTPClient(config),
|
||||
tokenCache: &tokenCache{},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig 获取当前配置
|
||||
func (c *Client) GetConfig() *Config {
|
||||
return c.config
|
||||
}
|
||||
187
internal/shared/fadada/config.go
Normal file
187
internal/shared/fadada/config.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package fadada
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AuthConfig 认证相关配置
|
||||
type AuthConfig struct {
|
||||
RedirectURL string `json:"redirectUrl" yaml:"redirect_url"`
|
||||
}
|
||||
|
||||
// SignConfig 签署相关配置
|
||||
type SignConfig struct {
|
||||
RedirectURL string `json:"redirectUrl" yaml:"redirect_url"`
|
||||
}
|
||||
|
||||
// ContractConfig 合同相关配置
|
||||
type ContractConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
ExpireDays int `json:"expireDays" yaml:"expire_days"`
|
||||
RetryCount int `json:"retryCount" yaml:"retry_count"`
|
||||
}
|
||||
|
||||
// CallbackConfig 回调配置
|
||||
type CallbackConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"enabled"`
|
||||
}
|
||||
|
||||
// TemplateFields 合作协议模板控件 fieldId
|
||||
type TemplateFields struct {
|
||||
// AgreementNo 协议编号控件编码列表(法大大控件不可复用,多处填同一值)
|
||||
AgreementNo []string `json:"agreementNo" yaml:"agreement_no"`
|
||||
ContractDate string `json:"contractDate" yaml:"contract_date"`
|
||||
// PartyAName 甲方企业名控件编码列表(多处填同一企业名)
|
||||
PartyAName []string `json:"partyAName" yaml:"party_a_name"`
|
||||
PartyAUSCC string `json:"partyAUscc" yaml:"party_a_uscc"`
|
||||
PartyAAddress string `json:"partyAAddress" yaml:"party_a_address"`
|
||||
PartyARep string `json:"partyARep" yaml:"party_a_rep"`
|
||||
PartyASignDate string `json:"partyASignDate" yaml:"party_a_sign_date"`
|
||||
PartyBSignDate string `json:"partyBSignDate" yaml:"party_b_sign_date"`
|
||||
}
|
||||
|
||||
// Config 法大大服务配置
|
||||
type Config struct {
|
||||
AppID string `json:"appId" yaml:"app_id"`
|
||||
AppSecret string `json:"appSecret" yaml:"app_secret"`
|
||||
ServerURL string `json:"serverUrl" yaml:"server_url"`
|
||||
OpenCorpID string `json:"openCorpId" yaml:"open_corp_id"`
|
||||
// NoAuthSceneCode 免验证签场景码;仅 freeSignType=business 时需要。当前按模板免验证签,可留空。
|
||||
NoAuthSceneCode string `json:"noAuthSceneCode" yaml:"no_auth_scene_code"`
|
||||
TemplateID string `json:"templateId" yaml:"template_id"`
|
||||
// PartyAActorID / PartyBActorID 签署模板中的参与方标识(actorId),须与模板角色名一致
|
||||
PartyAActorID string `json:"partyAActorId" yaml:"party_a_actor_id"`
|
||||
PartyBActorID string `json:"partyBActorId" yaml:"party_b_actor_id"`
|
||||
// TemplateDocID 签署模板文档 docId(填单必填);为空则创建任务后从详情自动解析
|
||||
TemplateDocID string `json:"templateDocId" yaml:"template_doc_id"`
|
||||
TemplateFields *TemplateFields `json:"templateFields" yaml:"template_fields"`
|
||||
Contract *ContractConfig `json:"contract" yaml:"contract"`
|
||||
Auth *AuthConfig `json:"auth" yaml:"auth"`
|
||||
Sign *SignConfig `json:"sign" yaml:"sign"`
|
||||
Callback *CallbackConfig `json:"callback" yaml:"callback"`
|
||||
}
|
||||
|
||||
// NewConfig 创建并校验法大大配置
|
||||
func NewConfig(
|
||||
appID, appSecret, serverURL, openCorpID, noAuthSceneCode, templateID string,
|
||||
partyAActorID, partyBActorID, templateDocID string,
|
||||
fields *TemplateFields,
|
||||
contract *ContractConfig,
|
||||
auth *AuthConfig,
|
||||
sign *SignConfig,
|
||||
callback *CallbackConfig,
|
||||
) (*Config, error) {
|
||||
if appID == "" {
|
||||
return nil, fmt.Errorf("法大大应用ID不能为空")
|
||||
}
|
||||
if appSecret == "" {
|
||||
return nil, fmt.Errorf("法大大应用密钥不能为空")
|
||||
}
|
||||
if serverURL == "" {
|
||||
return nil, fmt.Errorf("法大大服务器URL不能为空")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
ServerURL: serverURL,
|
||||
OpenCorpID: openCorpID,
|
||||
NoAuthSceneCode: noAuthSceneCode,
|
||||
TemplateID: templateID,
|
||||
PartyAActorID: partyAActorID,
|
||||
PartyBActorID: partyBActorID,
|
||||
TemplateDocID: templateDocID,
|
||||
TemplateFields: fields,
|
||||
Contract: contract,
|
||||
Auth: auth,
|
||||
Sign: sign,
|
||||
Callback: callback,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate 验证配置完整性
|
||||
func (c *Config) Validate() error {
|
||||
if c.AppID == "" {
|
||||
return fmt.Errorf("法大大应用ID不能为空")
|
||||
}
|
||||
if c.AppSecret == "" {
|
||||
return fmt.Errorf("法大大应用密钥不能为空")
|
||||
}
|
||||
if c.ServerURL == "" {
|
||||
return fmt.Errorf("法大大服务器URL不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTemplateFields 校验模板填单所需配置
|
||||
func (c *Config) ValidateTemplateFields() error {
|
||||
if c.TemplateID == "" {
|
||||
return fmt.Errorf("法大大模板ID不能为空")
|
||||
}
|
||||
if c.OpenCorpID == "" {
|
||||
return fmt.Errorf("法大大 open_corp_id 不能为空")
|
||||
}
|
||||
if c.TemplateFields == nil {
|
||||
return fmt.Errorf("法大大 template_fields 未配置")
|
||||
}
|
||||
f := c.TemplateFields
|
||||
if len(f.AgreementNo) == 0 {
|
||||
return fmt.Errorf("法大大 template_fields.agreement_no 不能为空")
|
||||
}
|
||||
for i, id := range f.AgreementNo {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return fmt.Errorf("法大大 template_fields.agreement_no[%d] 不能为空", i)
|
||||
}
|
||||
}
|
||||
if len(f.PartyAName) == 0 {
|
||||
return fmt.Errorf("法大大 template_fields.party_a_name 不能为空")
|
||||
}
|
||||
for i, id := range f.PartyAName {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return fmt.Errorf("法大大 template_fields.party_a_name[%d] 不能为空", i)
|
||||
}
|
||||
}
|
||||
// party_a/b_sign_date 为签署控件,签署时自动写入,填单可不配
|
||||
required := map[string]string{
|
||||
"contract_date": f.ContractDate,
|
||||
"party_a_uscc": f.PartyAUSCC,
|
||||
"party_a_address": f.PartyAAddress,
|
||||
"party_a_rep": f.PartyARep,
|
||||
}
|
||||
for name, id := range required {
|
||||
if id == "" {
|
||||
return fmt.Errorf("法大大 template_fields.%s 不能为空", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolvePartyAActorID 甲方参与方标识(优先配置,默认与签署模板角色名一致)
|
||||
func (c *Config) ResolvePartyAActorID() string {
|
||||
if c != nil && c.PartyAActorID != "" {
|
||||
return c.PartyAActorID
|
||||
}
|
||||
return ActorIDPartyA
|
||||
}
|
||||
|
||||
// ResolvePartyBActorID 乙方参与方标识(优先配置,默认与签署模板角色名一致)
|
||||
func (c *Config) ResolvePartyBActorID() string {
|
||||
if c != nil && c.PartyBActorID != "" {
|
||||
return c.PartyBActorID
|
||||
}
|
||||
return ActorIDPartyB
|
||||
}
|
||||
|
||||
const (
|
||||
// 接口成功码
|
||||
successCode = "100000"
|
||||
|
||||
// 个人证件类型:身份证(文档 userIdentType)
|
||||
UserIdentTypeIDCard = "id_card"
|
||||
|
||||
// 企业认证方式:法人认证(文档 corpIdentMethod)
|
||||
CorpIdentMethodLegalRep = "legalRep"
|
||||
// 经办人认证方式:手机号(文档 oprIdentMethod)
|
||||
OprIdentMethodMobile = "mobile"
|
||||
)
|
||||
180
internal/shared/fadada/corp_service.go
Normal file
180
internal/shared/fadada/corp_service.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package fadada
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 默认授权范围:认证信息 + 后续签署所需能力
|
||||
var defaultAuthScopes = []string{
|
||||
"ident_info",
|
||||
"seal_info",
|
||||
"signtask_info",
|
||||
"signtask_init",
|
||||
"signtask_file",
|
||||
"organization",
|
||||
"template",
|
||||
}
|
||||
|
||||
// GenerateEnterpriseAuth 获取企业认证/授权链接(POST /corp/get-auth-url)
|
||||
// 文档见 1.md:corpIdentType 置空由用户在页面选择组织类型。
|
||||
func (c *Client) GenerateEnterpriseAuth(req *EnterpriseAuthRequest) (*EnterpriseAuthResult, error) {
|
||||
if err := validateEnterpriseAuthRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessToken, err := c.ensureAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clientCorpID := strings.TrimSpace(req.ClientCorpID)
|
||||
if clientCorpID == "" {
|
||||
clientCorpID = strings.TrimSpace(req.UnifiedSocialCode)
|
||||
}
|
||||
clientUserID := strings.TrimSpace(req.ClientUserID)
|
||||
if clientUserID == "" {
|
||||
clientUserID = strings.TrimSpace(req.TransactorID)
|
||||
}
|
||||
|
||||
redirectURL := ""
|
||||
if c.config.Auth != nil {
|
||||
redirectURL = c.config.Auth.RedirectURL
|
||||
}
|
||||
|
||||
apiReq := &getCorpAuthURLReq{
|
||||
ClientCorpID: clientCorpID,
|
||||
ClientUserID: clientUserID,
|
||||
AccountName: strings.TrimSpace(req.TransactorMobile),
|
||||
CorpIdentInfo: &corpIdentInfo{
|
||||
CorpName: strings.TrimSpace(req.CompanyName),
|
||||
// 按官方文档:不传默认为企业;置空让用户在页面选择组织类型
|
||||
// CorpIdentType: "",
|
||||
CorpIdentNo: strings.TrimSpace(req.UnifiedSocialCode),
|
||||
LegalRepName: strings.TrimSpace(req.LegalPersonName),
|
||||
CorpIdentMethod: []string{CorpIdentMethodLegalRep},
|
||||
},
|
||||
// 文档合法值仅 corpName/corpIdentType/corpIdentNo;不传表示都可修改
|
||||
// CorpNonEditableInfo: ,
|
||||
OprIdentInfo: &oprIdentInfo{
|
||||
UserName: strings.TrimSpace(req.TransactorName),
|
||||
UserIdentType: UserIdentTypeIDCard,
|
||||
UserIdentNo: strings.TrimSpace(req.TransactorID),
|
||||
Mobile: strings.TrimSpace(req.TransactorMobile),
|
||||
OprIdentMethod: []string{OprIdentMethodMobile},
|
||||
},
|
||||
OprNonEditableInfo: nil,
|
||||
AuthScopes: append([]string{}, defaultAuthScopes...),
|
||||
RedirectURL: encodeRedirectURL(redirectURL),
|
||||
}
|
||||
|
||||
res, err := c.http.PostBiz(pathGetCorpAuthURL, accessToken, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return nil, fmt.Errorf("获取法大大企业认证链接失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
|
||||
var data getCorpAuthURLData
|
||||
if err := decodeData(res.Data, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析企业认证链接响应失败: %w", err)
|
||||
}
|
||||
if data.AuthURL == "" {
|
||||
return nil, fmt.Errorf("获取法大大企业认证链接失败: authUrl 为空 requestId=%s", res.RequestID)
|
||||
}
|
||||
|
||||
return &EnterpriseAuthResult{
|
||||
AuthFlowID: clientCorpID,
|
||||
AuthURL: data.AuthURL,
|
||||
AuthShortURL: data.AuthURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryOrgVerified 查询企业是否已在法大大完成实名(POST /corp/get-identified-status)
|
||||
func (c *Client) QueryOrgVerified(req *QueryOrgIdentityRequest) (bool, error) {
|
||||
result, err := c.QueryOrgIdentity(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.Identified, nil
|
||||
}
|
||||
|
||||
// QueryOrgIdentity 查询企业实名认证状态详情
|
||||
func (c *Client) QueryOrgIdentity(req *QueryOrgIdentityRequest) (*QueryOrgIdentityResult, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("查询请求不能为空")
|
||||
}
|
||||
corpName := strings.TrimSpace(req.CorpName)
|
||||
corpIdentNo := strings.TrimSpace(req.CorpIdentNo)
|
||||
if corpName == "" && corpIdentNo == "" {
|
||||
return nil, fmt.Errorf("企业名称与统一社会信用代码不能同时为空")
|
||||
}
|
||||
|
||||
accessToken, err := c.ensureAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := c.http.PostBiz(pathGetCorpIdentifiedStatus, accessToken, &getIdentifiedStatusReq{
|
||||
CorpName: corpName,
|
||||
CorpIdentNo: corpIdentNo,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return nil, fmt.Errorf("查询法大大企业实名状态失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
|
||||
var data getIdentifiedStatusData
|
||||
if err := decodeData(res.Data, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析企业实名状态失败: %w", err)
|
||||
}
|
||||
|
||||
return &QueryOrgIdentityResult{
|
||||
Identified: data.IdentStatus,
|
||||
CorpName: corpName,
|
||||
CorpIdentNo: corpIdentNo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateEnterpriseAuthRequest(req *EnterpriseAuthRequest) error {
|
||||
if req == nil {
|
||||
return fmt.Errorf("认证请求不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.CompanyName) == "" {
|
||||
return fmt.Errorf("企业名称不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.UnifiedSocialCode) == "" {
|
||||
return fmt.Errorf("统一社会信用代码不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.LegalPersonName) == "" {
|
||||
return fmt.Errorf("法人姓名不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.TransactorName) == "" {
|
||||
return fmt.Errorf("经办人姓名不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.TransactorMobile) == "" {
|
||||
return fmt.Errorf("经办人手机号不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.TransactorID) == "" {
|
||||
return fmt.Errorf("经办人身份证号不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeRedirectURL 按官方文档对 redirectUrl 做 URL encode;已编码则原样返回
|
||||
func encodeRedirectURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(raw, "%") {
|
||||
if decoded, err := url.QueryUnescape(raw); err == nil && decoded != raw {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return url.QueryEscape(raw)
|
||||
}
|
||||
35
internal/shared/fadada/corp_service_test.go
Normal file
35
internal/shared/fadada/corp_service_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package fadada
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEnterpriseAuthRequest(t *testing.T) {
|
||||
err := validateEnterpriseAuthRequest(&EnterpriseAuthRequest{
|
||||
CompanyName: "测试企业有限公司",
|
||||
UnifiedSocialCode: "91110000MA01234567",
|
||||
LegalPersonName: "张三",
|
||||
TransactorName: "张三",
|
||||
TransactorMobile: "13800138000",
|
||||
TransactorID: "110101199001011234",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
|
||||
if err := validateEnterpriseAuthRequest(nil); err == nil {
|
||||
t.Fatal("expected error for nil request")
|
||||
}
|
||||
if err := validateEnterpriseAuthRequest(&EnterpriseAuthRequest{}); err == nil {
|
||||
t.Fatal("expected error for empty request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRedirectURL(t *testing.T) {
|
||||
raw := "http://localhost:5173/profile/certification"
|
||||
encoded := encodeRedirectURL(raw)
|
||||
if encoded == "" || encoded == raw {
|
||||
t.Fatalf("expected url-encoded redirect, got %q", encoded)
|
||||
}
|
||||
if encodeRedirectURL(encoded) != encoded {
|
||||
t.Fatalf("already-encoded url should stay unchanged")
|
||||
}
|
||||
}
|
||||
140
internal/shared/fadada/debug_log.go
Normal file
140
internal/shared/fadada/debug_log.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package fadada
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// debug 日志:将每次法大大 OpenAPI 调用的 cURL 命令 + 响应落盘到 logs/fadada/<date>/fadada.log
|
||||
// 用于排查 /sign-task/create-with-template 等接口的实际请求/响应(如免验证签不生效)。
|
||||
// 敏感字段(AppSecret)不会出现在日志里;AccessToken / Sign 会以明文写入以便复现。
|
||||
|
||||
const (
|
||||
debugLogDir = "logs/fadada"
|
||||
debugLogFile = "fadada.log"
|
||||
debugMaxBytes = 10 * 1024 * 1024 // 单文件 10MB 后截断覆盖(轮转保持简单)
|
||||
)
|
||||
|
||||
var (
|
||||
debugLogMu sync.Mutex
|
||||
debugLogFilePtr *os.File
|
||||
debugLogDate string
|
||||
debugLogSize int64
|
||||
)
|
||||
|
||||
// writeDebugLog 把一次 API 调用的请求与响应追加到当日日志文件。
|
||||
// 调用方需保证 headers / body / respBody 不包含 AppSecret(法大大签名里只有时间戳参与,secret 本身不出现在 header/body 中)。
|
||||
func writeDebugLog(method, fullURL string, headers map[string]string, body string, httpStatus int, respBody string, requestID string) {
|
||||
if strings.TrimSpace(fullURL) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
entry := buildDebugEntry(method, fullURL, headers, body, httpStatus, respBody, requestID)
|
||||
|
||||
debugLogMu.Lock()
|
||||
defer debugLogMu.Unlock()
|
||||
|
||||
if err := openDebugLogFileLocked(); err != nil {
|
||||
// 日志失败不影响业务
|
||||
return
|
||||
}
|
||||
|
||||
n, _ := debugLogFilePtr.WriteString(entry)
|
||||
debugLogSize += int64(n)
|
||||
if debugLogSize >= debugMaxBytes {
|
||||
_ = debugLogFilePtr.Close()
|
||||
debugLogFilePtr = nil
|
||||
debugLogSize = 0
|
||||
}
|
||||
}
|
||||
|
||||
func openDebugLogFileLocked() error {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if debugLogFilePtr != nil && debugLogDate == today {
|
||||
return nil
|
||||
}
|
||||
if debugLogFilePtr != nil {
|
||||
_ = debugLogFilePtr.Close()
|
||||
}
|
||||
dateDir := filepath.Join(debugLogDir, today)
|
||||
if err := os.MkdirAll(dateDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
full := filepath.Join(dateDir, debugLogFile)
|
||||
f, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := f.Stat(); err == nil {
|
||||
debugLogSize = info.Size()
|
||||
} else {
|
||||
debugLogSize = 0
|
||||
}
|
||||
debugLogFilePtr = f
|
||||
debugLogDate = today
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDebugEntry 生成一段可读的日志条目(cURL 命令 + 响应)。
|
||||
func buildDebugEntry(method, fullURL string, headers map[string]string, body string, httpStatus int, respBody string, requestID string) string {
|
||||
now := time.Now().Format("2006-01-02 15:04:05.000")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\n==================== ")
|
||||
sb.WriteString(now)
|
||||
sb.WriteString(" ====================\n")
|
||||
|
||||
// ---- cURL ----
|
||||
sb.WriteString("curl -sS -X ")
|
||||
sb.WriteString(strings.ToUpper(strings.TrimSpace(method)))
|
||||
sb.WriteString(" '")
|
||||
sb.WriteString(fullURL)
|
||||
sb.WriteString("'")
|
||||
for _, k := range sortedHeaderKeys(headers) {
|
||||
sb.WriteString(" \\\n -H '")
|
||||
sb.WriteString(k)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(headers[k])
|
||||
sb.WriteString("'")
|
||||
}
|
||||
if strings.TrimSpace(body) != "" {
|
||||
sb.WriteString(" \\\n --data-urlencode 'bizContent=")
|
||||
sb.WriteString(body)
|
||||
sb.WriteString("'")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ---- RESPONSE ----
|
||||
sb.WriteString("--- RESPONSE ---\n")
|
||||
sb.WriteString("HTTP ")
|
||||
sb.WriteString(fmt.Sprintf("%d", httpStatus))
|
||||
if requestID != "" {
|
||||
sb.WriteString(" requestId=")
|
||||
sb.WriteString(requestID)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("body: ")
|
||||
sb.WriteString(respBody)
|
||||
sb.WriteString("\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func sortedHeaderKeys(headers map[string]string) []string {
|
||||
keys := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// 简单冒泡,依赖少
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[i] > keys[j] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
187
internal/shared/fadada/http.go
Normal file
187
internal/shared/fadada/http.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package fadada
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPClient 法大大 OpenAPI HTTP 客户端(不依赖官方 SDK)
|
||||
type HTTPClient struct {
|
||||
config *Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPClient(config *Config) *HTTPClient {
|
||||
return &HTTPClient{
|
||||
config: config,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// apiResponse 通用响应外壳
|
||||
type apiResponse struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
// PostBiz 发送业务 API:POST form bizContent=<JSON>,带 AccessToken 签名
|
||||
func (h *HTTPClient) PostBiz(path, accessToken string, biz any) (*apiResponse, error) {
|
||||
bizJSON, err := marshalBizContent(biz)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headers := h.buildBizHeaders(accessToken, bizJSON)
|
||||
return h.doPost(path, headers, bizJSON)
|
||||
}
|
||||
|
||||
// PostToken 获取 accessToken:无 AccessToken,带 Grant-Type
|
||||
func (h *HTTPClient) PostToken(path string) (*apiResponse, error) {
|
||||
headers := h.buildTokenHeaders()
|
||||
return h.doPost(path, headers, "")
|
||||
}
|
||||
|
||||
func (h *HTTPClient) doPost(path string, headers map[string]string, bizJSON string) (*apiResponse, error) {
|
||||
fullURL := joinURL(h.config.ServerURL, path)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("bizContent", bizJSON)
|
||||
body := form.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, fullURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建法大大请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
// 网络错误也记录一次,便于排查连接问题
|
||||
writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, -1, fmt.Sprintf("HTTP ERROR: %v", err), "")
|
||||
return nil, fmt.Errorf("调用法大大接口失败 %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, resp.StatusCode, fmt.Sprintf("READ BODY ERROR: %v", err), "")
|
||||
return nil, fmt.Errorf("读取法大大响应失败 %s: %w", path, err)
|
||||
}
|
||||
|
||||
requestID := ""
|
||||
if vals := resp.Header.Values("X-FASC-Request-Id"); len(vals) > 0 {
|
||||
requestID = vals[0]
|
||||
}
|
||||
|
||||
// 落盘 cURL + 响应(含 X-FASC-* 头与 bizContent),便于排查免验证签等问题
|
||||
writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, resp.StatusCode, string(respBody), requestID)
|
||||
|
||||
var out apiResponse
|
||||
if err := json.Unmarshal(respBody, &out); err != nil {
|
||||
return nil, fmt.Errorf("解析法大大响应失败 %s: %w body=%s", path, err, truncate(string(respBody), 512))
|
||||
}
|
||||
if out.RequestID == "" {
|
||||
out.RequestID = requestID
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (h *HTTPClient) buildTokenHeaders() map[string]string {
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
nonce := randomNonce()
|
||||
headMap := map[string]string{
|
||||
"X-FASC-App-Id": h.config.AppID,
|
||||
"X-FASC-Sign-Type": signTypeHMACSHA256,
|
||||
"X-FASC-Timestamp": timestamp,
|
||||
"X-FASC-Nonce": nonce,
|
||||
"X-FASC-Grant-Type": "client_credential",
|
||||
"X-FASC-Api-SubVersion": apiSubVersion,
|
||||
}
|
||||
headMap["X-FASC-Sign"] = SignByMap(headMap, timestamp, h.config.AppSecret)
|
||||
return headMap
|
||||
}
|
||||
|
||||
func (h *HTTPClient) buildBizHeaders(accessToken, bizJSON string) map[string]string {
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
nonce := randomNonce()
|
||||
headMap := map[string]string{
|
||||
"X-FASC-App-Id": h.config.AppID,
|
||||
"X-FASC-Sign-Type": signTypeHMACSHA256,
|
||||
"X-FASC-Timestamp": timestamp,
|
||||
"X-FASC-Nonce": nonce,
|
||||
"X-FASC-AccessToken": accessToken,
|
||||
"X-FASC-Api-SubVersion": apiSubVersion,
|
||||
"bizContent": bizJSON,
|
||||
}
|
||||
sign := SignByMap(headMap, timestamp, h.config.AppSecret)
|
||||
delete(headMap, "bizContent")
|
||||
headMap["X-FASC-Sign"] = sign
|
||||
return headMap
|
||||
}
|
||||
|
||||
func marshalBizContent(biz any) (string, error) {
|
||||
if biz == nil {
|
||||
return "", nil
|
||||
}
|
||||
switch v := biz.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
case []byte:
|
||||
return string(v), nil
|
||||
default:
|
||||
b, err := json.Marshal(biz)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化 bizContent 失败: %w", err)
|
||||
}
|
||||
// 紧凑 JSON,避免多余空格影响签名
|
||||
var buf bytes.Buffer
|
||||
if err := json.Compact(&buf, b); err == nil {
|
||||
return buf.String(), nil
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
|
||||
func joinURL(base, path string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return base + path
|
||||
}
|
||||
|
||||
func randomNonce() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// decodeData 将 apiResponse.Data 反序列化到目标结构
|
||||
func decodeData(raw json.RawMessage, dest any) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
57
internal/shared/fadada/sign.go
Normal file
57
internal/shared/fadada/sign.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package fadada
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
signTypeHMACSHA256 = "HMAC-SHA256"
|
||||
apiSubVersion = "5.1"
|
||||
)
|
||||
|
||||
// SignByMap 法大大 HMAC-SHA256 签名(与官方 SDK 算法一致)
|
||||
// 1) 对 map key 字典序排序,拼接 key=value&...(跳过空值)
|
||||
// 2) signText = hex(SHA256(signContent))
|
||||
// 3) signKey = HMAC-SHA256(timestamp, appSecret) 原始字节
|
||||
// 4) signature = lowercase(hex(HMAC-SHA256(signText, signKey)))
|
||||
func SignByMap(headMap map[string]string, timestamp, appSecret string) string {
|
||||
signContent := sortMapForSign(headMap)
|
||||
signText := sha256Hex(signContent)
|
||||
secretSigning := hmacSHA256Bytes(timestamp, []byte(appSecret))
|
||||
return strings.ToLower(hmacSHA256Hex(signText, secretSigning))
|
||||
}
|
||||
|
||||
func sortMapForSign(headMap map[string]string) string {
|
||||
keys := make([]string, 0, len(headMap))
|
||||
for k := range headMap {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if v := headMap[k]; v != "" {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func sha256Hex(src string) string {
|
||||
sum := sha256.Sum256([]byte(src))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func hmacSHA256Bytes(data string, secret []byte) []byte {
|
||||
h := hmac.New(sha256.New, secret)
|
||||
h.Write([]byte(data))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func hmacSHA256Hex(data string, secret []byte) string {
|
||||
return hex.EncodeToString(hmacSHA256Bytes(data, secret))
|
||||
}
|
||||
417
internal/shared/fadada/signtask_service.go
Normal file
417
internal/shared/fadada/signtask_service.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package fadada
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateSignFlow 基于签署任务模板创建签署任务(POST /sign-task/create-with-template)
|
||||
// - autoStart:无填单时创建即自动提交;有填单时创建后填控件再 Start(完成自动提交)
|
||||
// - 签署顺序:甲方 orderNo=1 先签(短信/验证);乙方 orderNo=2 后签,由系统按模板免验证签自动盖章
|
||||
// - 乙方参与方开启免验证签(requestVerifyFree + freeSignType=template)
|
||||
func (c *Client) CreateSignFlow(req *CreateSignFlowRequest) (*CreateSignFlowResult, error) {
|
||||
if err := validateCreateSignFlowRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.config.OpenCorpID == "" {
|
||||
return nil, fmt.Errorf("法大大 open_corp_id 不能为空")
|
||||
}
|
||||
if c.config.TemplateID == "" {
|
||||
return nil, fmt.Errorf("法大大 template_id 不能为空")
|
||||
}
|
||||
|
||||
accessToken, err := c.ensureAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
needFill := req.Fill != nil
|
||||
// 有填单须先填再提交,创建时不能 autoStart;无填单则创建时自动提交
|
||||
autoStart := !needFill
|
||||
|
||||
subject := strings.TrimSpace(req.Subject)
|
||||
if subject == "" {
|
||||
if c.config.Contract != nil && c.config.Contract.Name != "" {
|
||||
subject = c.config.Contract.Name
|
||||
} else {
|
||||
subject = "海宇数据-合作协议"
|
||||
}
|
||||
if req.PartyAName != "" {
|
||||
subject = fmt.Sprintf("%s-%s", subject, req.PartyAName)
|
||||
}
|
||||
}
|
||||
|
||||
actors := c.buildSignActors(req)
|
||||
expiresTime := c.buildExpiresTime()
|
||||
|
||||
res, err := c.http.PostBiz(pathCreateSignTaskTemplate, accessToken, &createSignTaskWithTemplateReq{
|
||||
SignTaskSubject: subject,
|
||||
Initiator: &openID{IDType: "corp", OpenID: c.config.OpenCorpID},
|
||||
SignTemplateID: c.config.TemplateID,
|
||||
AutoStart: autoStart,
|
||||
AutoFillFinalize: true,
|
||||
AutoFinish: true,
|
||||
SignInOrder: true,
|
||||
ExpiresTime: expiresTime,
|
||||
FreeSignType: freeSignTypeTemplate, // 按模板免验证签,无需 businessId
|
||||
TransReferenceID: strings.TrimSpace(req.TransReferenceID),
|
||||
Actors: actors,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return nil, fmt.Errorf("基于模板创建法大大签署任务失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
var data createSignTaskData
|
||||
if err := decodeData(res.Data, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析创建签署任务响应失败: %w", err)
|
||||
}
|
||||
signTaskID := data.SignTaskID
|
||||
if signTaskID == "" {
|
||||
return nil, fmt.Errorf("创建法大大签署任务失败: signTaskId 为空")
|
||||
}
|
||||
|
||||
// 填单后自动提交:乙方免验证签自动盖章,甲方再取签署链接
|
||||
if needFill {
|
||||
fillReq := *req.Fill
|
||||
if fillReq.CompanyName == "" {
|
||||
fillReq.CompanyName = req.PartyAName
|
||||
}
|
||||
if fillReq.UnifiedSocialCode == "" {
|
||||
fillReq.UnifiedSocialCode = req.PartyAUSCC
|
||||
}
|
||||
if fillReq.AuthorizedRepName == "" {
|
||||
fillReq.AuthorizedRepName = req.TransactorName
|
||||
}
|
||||
if err := c.FillSignTaskFields(signTaskID, &fillReq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.StartSignTask(signTaskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &CreateSignFlowResult{SignTaskID: signTaskID}, nil
|
||||
}
|
||||
|
||||
// StartSignTask 提交签署任务(POST /sign-task/start)
|
||||
func (c *Client) StartSignTask(signTaskID string) error {
|
||||
signTaskID = strings.TrimSpace(signTaskID)
|
||||
if signTaskID == "" {
|
||||
return fmt.Errorf("signTaskId 不能为空")
|
||||
}
|
||||
accessToken, err := c.ensureAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := c.http.PostBiz(pathStartSignTask, accessToken, &startSignTaskReq{SignTaskID: signTaskID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return fmt.Errorf("提交法大大签署任务失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSignURL 获取参与方签署链接(POST /sign-task/actor/get-url)
|
||||
func (c *Client) GetSignURL(signTaskID, actorID string) (*SignURLResult, error) {
|
||||
signTaskID = strings.TrimSpace(signTaskID)
|
||||
if signTaskID == "" {
|
||||
return nil, fmt.Errorf("signTaskId 不能为空")
|
||||
}
|
||||
if actorID == "" {
|
||||
actorID = c.config.ResolvePartyAActorID()
|
||||
}
|
||||
|
||||
accessToken, err := c.ensureAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
redirectURL := ""
|
||||
if c.config.Sign != nil {
|
||||
redirectURL = encodeRedirectURL(c.config.Sign.RedirectURL)
|
||||
}
|
||||
|
||||
res, err := c.http.PostBiz(pathGetSignTaskActorURL, accessToken, &getActorURLReq{
|
||||
SignTaskID: signTaskID,
|
||||
ActorID: actorID,
|
||||
RedirectURL: redirectURL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return nil, fmt.Errorf("获取法大大签署链接失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
|
||||
var data getActorURLData
|
||||
if err := decodeData(res.Data, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析签署链接响应失败: %w", err)
|
||||
}
|
||||
if data.ActorSignTaskURL == "" && data.ActorSignTaskEmbedURL == "" {
|
||||
return nil, fmt.Errorf("获取法大大签署链接失败: url 为空 requestId=%s", res.RequestID)
|
||||
}
|
||||
|
||||
return &SignURLResult{
|
||||
SignURL: data.ActorSignTaskURL,
|
||||
EmbedURL: data.ActorSignTaskEmbedURL,
|
||||
ActorID: actorID,
|
||||
SignTaskID: signTaskID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSignTaskPreviewURL 获取签署任务预览链接(POST /sign-task/get-preview-url,约 2 小时/单次)
|
||||
func (c *Client) GetSignTaskPreviewURL(signTaskID string) (*SignTaskPreviewURLResult, error) {
|
||||
signTaskID = strings.TrimSpace(signTaskID)
|
||||
if signTaskID == "" {
|
||||
return nil, fmt.Errorf("signTaskId 不能为空")
|
||||
}
|
||||
accessToken, err := c.ensureAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
redirectURL := ""
|
||||
if c.config.Sign != nil {
|
||||
redirectURL = encodeRedirectURL(c.config.Sign.RedirectURL)
|
||||
}
|
||||
res, err := c.http.PostBiz(pathGetSignTaskPreviewURL, accessToken, &getSignTaskPreviewURLReq{
|
||||
SignTaskID: signTaskID,
|
||||
RedirectURL: redirectURL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return nil, fmt.Errorf("获取法大大预览链接失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
var data getSignTaskPreviewURLData
|
||||
if err := decodeData(res.Data, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析预览链接响应失败: %w", err)
|
||||
}
|
||||
previewURL := strings.TrimSpace(data.SignTaskPreviewURL)
|
||||
if previewURL == "" {
|
||||
return nil, fmt.Errorf("获取法大大预览链接失败: signTaskPreviewUrl 为空 requestId=%s", res.RequestID)
|
||||
}
|
||||
return &SignTaskPreviewURLResult{
|
||||
SignTaskID: signTaskID,
|
||||
PreviewURL: previewURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QuerySignStatus 查询签署任务状态(POST /sign-task/app/get-detail)
|
||||
func (c *Client) QuerySignStatus(signTaskID string) (*SignStatusResult, error) {
|
||||
signTaskID = strings.TrimSpace(signTaskID)
|
||||
if signTaskID == "" {
|
||||
return nil, fmt.Errorf("signTaskId 不能为空")
|
||||
}
|
||||
|
||||
accessToken, err := c.ensureAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := c.http.PostBiz(pathGetSignTaskDetail, accessToken, &getSignTaskDetailReq{
|
||||
SignTaskID: signTaskID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return nil, fmt.Errorf("查询法大大签署状态失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
|
||||
var data getSignTaskDetailData
|
||||
if err := decodeData(res.Data, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析签署状态响应失败: %w", err)
|
||||
}
|
||||
|
||||
status := data.SignTaskStatus
|
||||
return &SignStatusResult{
|
||||
SignTaskID: signTaskID,
|
||||
SignTaskStatus: status,
|
||||
Completed: IsSignTaskCompletedStatus(status),
|
||||
Terminated: status == SignTaskStatusTerminated,
|
||||
FinishTime: data.FinishTime,
|
||||
TerminationNote: data.TerminationNote,
|
||||
RevokeReason: data.RevokeReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsSignTaskCompleted 签署任务是否已完成(可下载)
|
||||
func (c *Client) IsSignTaskCompleted(signTaskID string) (bool, error) {
|
||||
status, err := c.QuerySignStatus(signTaskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return status.Completed, nil
|
||||
}
|
||||
|
||||
// IsSignTaskCompletedStatus 状态是否视为签署完成
|
||||
func IsSignTaskCompletedStatus(status string) bool {
|
||||
switch status {
|
||||
case SignTaskStatusFinished, SignTaskStatusSignCompleted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadSignedFiles 获取签署完成文件下载地址(POST /sign-task/owner/get-download-url)
|
||||
func (c *Client) DownloadSignedFiles(signTaskID string) (*DownloadSignedFilesResult, error) {
|
||||
signTaskID = strings.TrimSpace(signTaskID)
|
||||
if signTaskID == "" {
|
||||
return nil, fmt.Errorf("signTaskId 不能为空")
|
||||
}
|
||||
if c.config.OpenCorpID == "" {
|
||||
return nil, fmt.Errorf("法大大 open_corp_id 不能为空")
|
||||
}
|
||||
|
||||
accessToken, err := c.ensureAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := c.http.PostBiz(pathGetOwnerSignTaskDownload, accessToken, &getOwnerDownloadURLReq{
|
||||
OwnerID: &openID{IDType: "corp", OpenID: c.config.OpenCorpID},
|
||||
SignTaskID: signTaskID,
|
||||
FileType: "doc",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return nil, fmt.Errorf("获取法大大已签文件下载地址失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
|
||||
var data getOwnerDownloadURLData
|
||||
if err := decodeData(res.Data, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析下载地址响应失败: %w", err)
|
||||
}
|
||||
if data.DownloadURL == "" {
|
||||
return nil, fmt.Errorf("获取法大大已签文件下载地址失败: downloadUrl 为空 requestId=%s", res.RequestID)
|
||||
}
|
||||
|
||||
return &DownloadSignedFilesResult{
|
||||
Files: []*SignedFile{{
|
||||
DownloadURL: data.DownloadURL,
|
||||
DownloadID: data.DownloadID,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateContractSigning 一站式:创建签署任务(可带填单)→ 获取甲方签署链接
|
||||
func (c *Client) GenerateContractSigning(req *ContractSigningRequest) (*ContractSigningResult, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("签署请求不能为空")
|
||||
}
|
||||
createReq := req.CreateSignFlowRequest
|
||||
createReq.Fill = &req.Fill
|
||||
if createReq.PartyAName == "" {
|
||||
createReq.PartyAName = req.Fill.CompanyName
|
||||
}
|
||||
if createReq.PartyAUSCC == "" {
|
||||
createReq.PartyAUSCC = req.Fill.UnifiedSocialCode
|
||||
}
|
||||
if createReq.TransactorName == "" {
|
||||
createReq.TransactorName = req.Fill.AuthorizedRepName
|
||||
}
|
||||
|
||||
createRes, err := c.CreateSignFlow(&createReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
urlRes, err := c.GetSignURL(createRes.SignTaskID, c.config.ResolvePartyAActorID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ContractSigningResult{
|
||||
SignTaskID: createRes.SignTaskID,
|
||||
SignURL: firstNonEmpty(urlRes.EmbedURL, urlRes.SignURL),
|
||||
EmbedURL: urlRes.EmbedURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const freeSignTypeTemplate = "template" // 根据模板免验证签(无需 businessId)
|
||||
|
||||
func (c *Client) buildSignActors(req *CreateSignFlowRequest) []signTaskActor {
|
||||
partyAActorID := c.config.ResolvePartyAActorID()
|
||||
partyBActorID := c.config.ResolvePartyBActorID()
|
||||
|
||||
// 甲方先签 OrderNo=1:用户预览后「立即签署」进 iframe,完成短信验证 / 实名确认
|
||||
partyA := signTaskActor{
|
||||
Actor: &actor{
|
||||
ActorType: "corp",
|
||||
ActorID: partyAActorID,
|
||||
ActorName: firstNonEmpty(strings.TrimSpace(req.PartyAName), partyAActorID),
|
||||
Permissions: []string{"sign"},
|
||||
ActorOpenID: strings.TrimSpace(req.PartyAOpenCorpID),
|
||||
IdentNameForMatch: strings.TrimSpace(req.PartyAName),
|
||||
CertNoForMatch: strings.TrimSpace(req.PartyAUSCC),
|
||||
AccountName: strings.TrimSpace(req.TransactorMobile),
|
||||
ClientUserID: firstNonEmpty(strings.TrimSpace(req.TransactorID), strings.TrimSpace(req.TransactorMobile)),
|
||||
},
|
||||
SignConfigInfo: &signConfigInfo{
|
||||
OrderNo: 1,
|
||||
JoinByLink: true,
|
||||
RequestVerifyFree: false,
|
||||
FreeLogin: false,
|
||||
BlockHere: false,
|
||||
},
|
||||
}
|
||||
|
||||
// 乙方(平台)后签 OrderNo=2:甲方签完后由系统按模板免验证签自动盖章
|
||||
partyB := signTaskActor{
|
||||
Actor: &actor{
|
||||
ActorType: "corp",
|
||||
ActorID: partyBActorID,
|
||||
ActorName: partyBActorID,
|
||||
Permissions: []string{"sign"},
|
||||
ActorOpenID: c.config.OpenCorpID,
|
||||
},
|
||||
SignConfigInfo: &signConfigInfo{
|
||||
OrderNo: 2,
|
||||
JoinByLink: true, // 链接加入;false 时要求 actorCorpMembers 非空
|
||||
RequestVerifyFree: true,
|
||||
FreeSignType: freeSignTypeTemplate,
|
||||
FreeLogin: true,
|
||||
BlockHere: false,
|
||||
},
|
||||
}
|
||||
|
||||
// 数组顺序与 orderNo 无强绑定(法大大按 orderNo 决定先后),保持 [甲方, 乙方] 便于阅读
|
||||
return []signTaskActor{partyA, partyB}
|
||||
}
|
||||
|
||||
func (c *Client) buildExpiresTime() string {
|
||||
days := 7
|
||||
if c.config.Contract != nil && c.config.Contract.ExpireDays > 0 {
|
||||
days = c.config.Contract.ExpireDays
|
||||
}
|
||||
return strconv.FormatInt(time.Now().AddDate(0, 0, days).UnixMilli(), 10)
|
||||
}
|
||||
|
||||
func validateCreateSignFlowRequest(req *CreateSignFlowRequest) error {
|
||||
if req == nil {
|
||||
return fmt.Errorf("创建签署请求不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.PartyAName) == "" && (req.Fill == nil || strings.TrimSpace(req.Fill.CompanyName) == "") {
|
||||
return fmt.Errorf("甲方企业名不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
69
internal/shared/fadada/signtask_service_test.go
Normal file
69
internal/shared/fadada/signtask_service_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package fadada
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsSignTaskCompletedStatus(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
SignTaskStatusFinished: true,
|
||||
SignTaskStatusSignCompleted: true,
|
||||
SignTaskStatusSignProgress: false,
|
||||
SignTaskStatusTerminated: false,
|
||||
"": false,
|
||||
}
|
||||
for status, want := range cases {
|
||||
if got := IsSignTaskCompletedStatus(status); got != want {
|
||||
t.Fatalf("status %q: got %v want %v", status, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSignActors(t *testing.T) {
|
||||
c := NewClient(&Config{
|
||||
AppID: "app",
|
||||
AppSecret: "secret",
|
||||
ServerURL: "https://uat-api.fadada.com/api/v5/",
|
||||
OpenCorpID: "haiyu-open-corp",
|
||||
TemplateID: "tpl",
|
||||
})
|
||||
actors := c.buildSignActors(&CreateSignFlowRequest{
|
||||
PartyAOpenCorpID: "client-open-corp",
|
||||
PartyAName: "测试企业",
|
||||
PartyAUSCC: "91110000MA01234567",
|
||||
TransactorMobile: "13800138000",
|
||||
TransactorID: "110101199001011234",
|
||||
})
|
||||
if len(actors) != 2 {
|
||||
t.Fatalf("actors len=%d", len(actors))
|
||||
}
|
||||
// 甲方先签 orderNo=1(需手动验证),乙方后签 orderNo=2(模板免验证签自动盖章)
|
||||
if actors[0].Actor.ActorID != ActorIDPartyA || actors[0].SignConfigInfo.RequestVerifyFree || actors[0].SignConfigInfo.OrderNo != 1 {
|
||||
t.Fatalf("party A (first/manual-sign)6 unexpected: %+v", actors[0])
|
||||
}
|
||||
if actors[1].Actor.ActorID != ActorIDPartyB || !actors[1].SignConfigInfo.RequestVerifyFree || actors[1].SignConfigInfo.OrderNo != 2 {
|
||||
t.Fatalf("party B (second/free-sign) unexpected: %+v", actors[1])
|
||||
}
|
||||
if actors[1].SignConfigInfo.FreeSignType != freeSignTypeTemplate {
|
||||
t.Fatalf("party B freeSignType=%q, want %s", actors[1].SignConfigInfo.FreeSignType, freeSignTypeTemplate)
|
||||
}
|
||||
if actors[0].Actor.ActorID == "party_a" || actors[1].Actor.ActorID == "party_b" {
|
||||
t.Fatal("actorId must match sign-template role names, not party_a/party_b")
|
||||
}
|
||||
if actors[1].Actor.ActorOpenID != "haiyu-open-corp" {
|
||||
t.Fatalf("party B openCorpId=%s", actors[1].Actor.ActorOpenID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallback(t *testing.T) {
|
||||
c := NewClient(&Config{AppID: "app", AppSecret: "secret", ServerURL: "https://x/"})
|
||||
biz := `{"signTaskId":"st-1","signTaskStatus":"task_finished","eventTime":"2026-07-14T10:00:00+08:00"}`
|
||||
ev, err := c.ParseCallback(map[string]string{"X-FASC-Event": "sign-task-finished"}, biz)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ev.SignTaskID != "st-1" || ev.SignTaskStatus != SignTaskStatusFinished {
|
||||
t.Fatalf("unexpected event: %+v", ev)
|
||||
}
|
||||
if !ev.IsSignCompletedCallback() {
|
||||
t.Fatal("expected completed callback")
|
||||
}
|
||||
}
|
||||
262
internal/shared/fadada/template_service.go
Normal file
262
internal/shared/fadada/template_service.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package fadada
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FillTemplate 填写文档模板并生成合同文件(POST /doc-template/fill-values)
|
||||
func (c *Client) FillTemplate(req *ContractFillRequest) (*ContractFileResult, error) {
|
||||
if err := c.config.ValidateTemplateFields(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContractFillRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessToken, err := c.ensureAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signDate := strings.TrimSpace(req.SignDate)
|
||||
if signDate == "" {
|
||||
signDate = time.Now().Format("2006年01月02日")
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(req.FileName)
|
||||
if fileName == "" {
|
||||
if c.config.Contract != nil && c.config.Contract.Name != "" {
|
||||
fileName = c.config.Contract.Name
|
||||
} else {
|
||||
fileName = "海宇数据-合作协议"
|
||||
}
|
||||
fileName = fmt.Sprintf("%s-%s", fileName, strings.TrimSpace(req.AgreementNo))
|
||||
}
|
||||
|
||||
fieldValues := c.BuildTemplateFieldValues(&ContractFillRequest{
|
||||
AgreementNo: req.AgreementNo,
|
||||
CompanyName: req.CompanyName,
|
||||
UnifiedSocialCode: req.UnifiedSocialCode,
|
||||
EnterpriseAddress: req.EnterpriseAddress,
|
||||
AuthorizedRepName: req.AuthorizedRepName,
|
||||
SignDate: signDate,
|
||||
})
|
||||
|
||||
docFieldValues := make([]docFieldValue, 0, len(fieldValues))
|
||||
for _, fv := range fieldValues {
|
||||
docFieldValues = append(docFieldValues, docFieldValue{
|
||||
FieldID: fv.FieldID,
|
||||
FieldValue: fv.FieldValue,
|
||||
})
|
||||
}
|
||||
|
||||
res, err := c.http.PostBiz(pathFillDocTemplateValues, accessToken, &fillDocTemplateReq{
|
||||
OwnerID: &openID{IDType: "corp", OpenID: c.config.OpenCorpID},
|
||||
DocTemplateID: c.config.TemplateID,
|
||||
FileName: fileName,
|
||||
DocFieldValues: docFieldValues,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return nil, fmt.Errorf("法大大填写模板失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
|
||||
var data fillDocTemplateData
|
||||
if err := decodeData(res.Data, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析模板填单响应失败: %w", err)
|
||||
}
|
||||
if data.FileID == "" {
|
||||
return nil, fmt.Errorf("法大大填写模板失败: fileId 为空 requestId=%s", res.RequestID)
|
||||
}
|
||||
|
||||
return &ContractFileResult{
|
||||
FileID: data.FileID,
|
||||
FileDownloadURL: data.FileDownloadURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FillSignTaskFields 向已创建的签署任务填写控件(POST /sign-task/field/fill-values)
|
||||
func (c *Client) FillSignTaskFields(signTaskID string, req *ContractFillRequest) error {
|
||||
if strings.TrimSpace(signTaskID) == "" {
|
||||
return fmt.Errorf("signTaskId 不能为空")
|
||||
}
|
||||
if err := c.config.ValidateTemplateFields(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateContractFillRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessToken, err := c.ensureAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
docID, err := c.resolveSignTaskDocID(signTaskID, accessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signDate := strings.TrimSpace(req.SignDate)
|
||||
if signDate == "" {
|
||||
signDate = time.Now().Format("2006年01月02日")
|
||||
}
|
||||
|
||||
fieldValues := c.BuildTemplateFieldValues(&ContractFillRequest{
|
||||
AgreementNo: req.AgreementNo,
|
||||
CompanyName: req.CompanyName,
|
||||
UnifiedSocialCode: req.UnifiedSocialCode,
|
||||
EnterpriseAddress: req.EnterpriseAddress,
|
||||
AuthorizedRepName: req.AuthorizedRepName,
|
||||
SignDate: signDate,
|
||||
})
|
||||
|
||||
docFieldValues := make([]docFieldValue, 0, len(fieldValues))
|
||||
for _, fv := range fieldValues {
|
||||
docFieldValues = append(docFieldValues, docFieldValue{
|
||||
DocID: docID,
|
||||
FieldDocID: docID,
|
||||
FieldID: fv.FieldID,
|
||||
FieldValue: fv.FieldValue,
|
||||
})
|
||||
}
|
||||
|
||||
res, err := c.http.PostBiz(pathFillSignTaskFieldValues, accessToken, &fillSignTaskFieldsReq{
|
||||
SignTaskID: signTaskID,
|
||||
DocFieldValues: docFieldValues,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return fmt.Errorf("法大大签署任务填单失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSignTaskDocID 解析签署任务文档 docId(填单必填)
|
||||
func (c *Client) resolveSignTaskDocID(signTaskID, accessToken string) (string, error) {
|
||||
if c.config != nil {
|
||||
if id := strings.TrimSpace(c.config.TemplateDocID); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
res, err := c.http.PostBiz(pathGetSignTaskDetail, accessToken, &getSignTaskDetailReq{
|
||||
SignTaskID: signTaskID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询签署任务文档失败: %w", err)
|
||||
}
|
||||
if res.Code != successCode {
|
||||
return "", fmt.Errorf("查询签署任务文档失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
|
||||
var data getSignTaskDetailData
|
||||
if err := decodeData(res.Data, &data); err != nil {
|
||||
return "", fmt.Errorf("解析签署任务文档失败: %w", err)
|
||||
}
|
||||
for _, d := range data.Docs {
|
||||
if id := strings.TrimSpace(d.DocID); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("签署任务未返回 docId,请在配置 fadada.template_doc_id 中填写模板文档ID")
|
||||
}
|
||||
|
||||
// BuildTemplateFieldValues 将业务字段映射为法大大控件编码/值
|
||||
func (c *Client) BuildTemplateFieldValues(req *ContractFillRequest) []TemplateFieldValue {
|
||||
fields := c.config.TemplateFields
|
||||
if fields == nil || req == nil {
|
||||
return nil
|
||||
}
|
||||
signDate := strings.TrimSpace(req.SignDate)
|
||||
agreementNo := strings.TrimSpace(req.AgreementNo)
|
||||
companyName := strings.TrimSpace(req.CompanyName)
|
||||
|
||||
pairs := make([]struct {
|
||||
fieldID string
|
||||
value string
|
||||
}, 0, len(fields.AgreementNo)+len(fields.PartyAName)+6)
|
||||
for _, id := range fields.AgreementNo {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, struct {
|
||||
fieldID string
|
||||
value string
|
||||
}{id, agreementNo})
|
||||
}
|
||||
for _, id := range fields.PartyAName {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, struct {
|
||||
fieldID string
|
||||
value string
|
||||
}{id, companyName})
|
||||
}
|
||||
pairs = append(pairs,
|
||||
struct {
|
||||
fieldID string
|
||||
value string
|
||||
}{fields.ContractDate, signDate},
|
||||
struct {
|
||||
fieldID string
|
||||
value string
|
||||
}{fields.PartyAUSCC, strings.TrimSpace(req.UnifiedSocialCode)},
|
||||
struct {
|
||||
fieldID string
|
||||
value string
|
||||
}{fields.PartyAAddress, strings.TrimSpace(req.EnterpriseAddress)},
|
||||
struct {
|
||||
fieldID string
|
||||
value string
|
||||
}{fields.PartyARep, strings.TrimSpace(req.AuthorizedRepName)},
|
||||
struct {
|
||||
fieldID string
|
||||
value string
|
||||
}{fields.PartyASignDate, signDate},
|
||||
struct {
|
||||
fieldID string
|
||||
value string
|
||||
}{fields.PartyBSignDate, signDate},
|
||||
)
|
||||
|
||||
out := make([]TemplateFieldValue, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
if p.fieldID == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, TemplateFieldValue{
|
||||
FieldID: p.fieldID,
|
||||
FieldValue: p.value,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateContractFillRequest(req *ContractFillRequest) error {
|
||||
if req == nil {
|
||||
return fmt.Errorf("合同填单请求不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.AgreementNo) == "" {
|
||||
return fmt.Errorf("协议编号不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.CompanyName) == "" {
|
||||
return fmt.Errorf("甲方企业名不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.UnifiedSocialCode) == "" {
|
||||
return fmt.Errorf("甲方统一信用代码不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.AuthorizedRepName) == "" {
|
||||
return fmt.Errorf("甲方授权代表不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
104
internal/shared/fadada/template_service_test.go
Normal file
104
internal/shared/fadada/template_service_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package fadada
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildTemplateFieldValues(t *testing.T) {
|
||||
client := NewClient(&Config{
|
||||
AppID: "app",
|
||||
AppSecret: "secret",
|
||||
ServerURL: "https://uat-api.fadada.com/api/v5/",
|
||||
OpenCorpID: "corp",
|
||||
TemplateID: "1784013372537192273",
|
||||
TemplateFields: &TemplateFields{
|
||||
AgreementNo: []string{
|
||||
"5199991781",
|
||||
"0923405093",
|
||||
},
|
||||
ContractDate: "2039326319",
|
||||
PartyAName: []string{
|
||||
"6920634255",
|
||||
"1847502631",
|
||||
},
|
||||
PartyAUSCC: "8702060291",
|
||||
PartyAAddress: "4557539440",
|
||||
PartyARep: "8627432823",
|
||||
},
|
||||
})
|
||||
|
||||
values := client.BuildTemplateFieldValues(&ContractFillRequest{
|
||||
AgreementNo: "HYDATA-20260714-R000001",
|
||||
CompanyName: "测试企业有限公司",
|
||||
UnifiedSocialCode: "91110000MA01234567",
|
||||
EnterpriseAddress: "海南省海口市",
|
||||
AuthorizedRepName: "张三",
|
||||
SignDate: "2026年07月14日",
|
||||
})
|
||||
|
||||
want := map[string]string{
|
||||
"5199991781": "HYDATA-20260714-R000001",
|
||||
"0923405093": "HYDATA-20260714-R000001",
|
||||
"6920634255": "测试企业有限公司",
|
||||
"1847502631": "测试企业有限公司",
|
||||
"2039326319": "2026年07月14日",
|
||||
"8702060291": "91110000MA01234567",
|
||||
"4557539440": "海南省海口市",
|
||||
"8627432823": "张三",
|
||||
}
|
||||
if len(values) != len(want) {
|
||||
t.Fatalf("field count = %d, want %d", len(values), len(want))
|
||||
}
|
||||
got := make(map[string]string, len(values))
|
||||
for _, v := range values {
|
||||
got[v.FieldID] = v.FieldValue
|
||||
}
|
||||
for id, val := range want {
|
||||
if got[id] != val {
|
||||
t.Fatalf("field %s = %q, want %q", id, got[id], val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSignTaskDocIDFromConfig(t *testing.T) {
|
||||
c := NewClient(&Config{
|
||||
AppID: "app",
|
||||
AppSecret: "secret",
|
||||
ServerURL: "https://uat-api.fadada.com/api/v5/",
|
||||
TemplateDocID: "19161471",
|
||||
})
|
||||
id, err := c.resolveSignTaskDocID("task-1", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "19161471" {
|
||||
t.Fatalf("docId=%s", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTemplateFields(t *testing.T) {
|
||||
cfg := &Config{
|
||||
TemplateID: "1784013372537192273",
|
||||
OpenCorpID: "corp",
|
||||
TemplateFields: &TemplateFields{
|
||||
AgreementNo: []string{"0923405093", "5199991781"},
|
||||
ContractDate: "2039326319",
|
||||
PartyAName: []string{"6920634255", "1847502631"},
|
||||
PartyAUSCC: "8702060291",
|
||||
PartyAAddress: "4557539440",
|
||||
PartyARep: "8627432823",
|
||||
},
|
||||
}
|
||||
if err := cfg.ValidateTemplateFields(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cfg.TemplateFields.PartyAName = nil
|
||||
if err := cfg.ValidateTemplateFields(); err == nil {
|
||||
t.Fatal("expected error when party_a_name empty")
|
||||
}
|
||||
cfg.TemplateFields.PartyAName = []string{"6920634255"}
|
||||
cfg.TemplateFields.AgreementNo = nil
|
||||
if err := cfg.ValidateTemplateFields(); err == nil {
|
||||
t.Fatal("expected error when agreement_no empty")
|
||||
}
|
||||
}
|
||||
71
internal/shared/fadada/token.go
Normal file
71
internal/shared/fadada/token.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package fadada
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tokenCache accessToken 内存缓存(官方有效期约 2 小时)
|
||||
type tokenCache struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func (c *tokenCache) get() (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.token == "" || time.Now().After(c.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
return c.token, true
|
||||
}
|
||||
|
||||
func (c *tokenCache) set(token string, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.token = token
|
||||
c.expiresAt = time.Now().Add(ttl)
|
||||
}
|
||||
|
||||
// ensureAccessToken 获取可用 accessToken,过期前自动刷新
|
||||
func (c *Client) ensureAccessToken() (string, error) {
|
||||
if token, ok := c.tokenCache.get(); ok {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
c.tokenMu.Lock()
|
||||
defer c.tokenMu.Unlock()
|
||||
|
||||
if token, ok := c.tokenCache.get(); ok {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
res, err := c.http.PostToken(pathGetAccessToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res.Code != "" && res.Code != successCode {
|
||||
return "", fmt.Errorf("获取法大大 accessToken 失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||
}
|
||||
|
||||
var data accessTokenData
|
||||
if err := decodeData(res.Data, &data); err != nil {
|
||||
return "", fmt.Errorf("解析 accessToken 失败: %w", err)
|
||||
}
|
||||
if data.AccessToken == "" {
|
||||
return "", fmt.Errorf("获取法大大 accessToken 失败: 响应为空 code=%s msg=%s", res.Code, res.Msg)
|
||||
}
|
||||
|
||||
ttl := 2*time.Hour - 5*time.Minute
|
||||
if expiresIn, err := strconv.Atoi(data.ExpiresIn); err == nil && expiresIn > 0 {
|
||||
ttl = time.Duration(expiresIn)*time.Second - 5*time.Minute
|
||||
if ttl < time.Minute {
|
||||
ttl = time.Minute
|
||||
}
|
||||
}
|
||||
c.tokenCache.set(data.AccessToken, ttl)
|
||||
return data.AccessToken, nil
|
||||
}
|
||||
167
internal/shared/fadada/types.go
Normal file
167
internal/shared/fadada/types.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package fadada
|
||||
|
||||
// EnterpriseAuthRequest 企业认证请求(与 e签宝 字段对齐,便于后续统一调用)
|
||||
type EnterpriseAuthRequest struct {
|
||||
// ClientCorpID 企业在应用内的唯一标识;为空时默认使用统一社会信用代码
|
||||
ClientCorpID string `json:"clientCorpId,omitempty"`
|
||||
// ClientUserID 经办人在应用内的唯一标识;为空时默认使用经办人身份证号
|
||||
ClientUserID string `json:"clientUserId,omitempty"`
|
||||
|
||||
CompanyName string `json:"companyName"`
|
||||
UnifiedSocialCode string `json:"unifiedSocialCode"`
|
||||
LegalPersonName string `json:"legalPersonName"`
|
||||
LegalPersonID string `json:"legalPersonId"`
|
||||
|
||||
TransactorName string `json:"transactorName"`
|
||||
TransactorMobile string `json:"transactorMobile"`
|
||||
TransactorID string `json:"transactorId"`
|
||||
}
|
||||
|
||||
// EnterpriseAuthResult 企业认证结果
|
||||
type EnterpriseAuthResult struct {
|
||||
// AuthFlowID 映射为法大大 clientCorpId,便于后续按企业标识查询
|
||||
AuthFlowID string `json:"authFlowId"`
|
||||
AuthURL string `json:"authUrl"`
|
||||
AuthShortURL string `json:"authShortUrl"`
|
||||
}
|
||||
|
||||
// QueryOrgIdentityRequest 查询企业是否已实名
|
||||
type QueryOrgIdentityRequest struct {
|
||||
CorpName string `json:"corpName"`
|
||||
CorpIdentNo string `json:"corpIdentNo"` // 统一社会信用代码
|
||||
}
|
||||
|
||||
// QueryOrgIdentityResult 企业实名查询结果
|
||||
type QueryOrgIdentityResult struct {
|
||||
Identified bool `json:"identified"`
|
||||
CorpName string `json:"corpName"`
|
||||
CorpIdentNo string `json:"corpIdentNo"`
|
||||
}
|
||||
|
||||
// ContractFillRequest 合作协议模板填单请求(对齐 e签宝 generateAndAddContractFile 入参)
|
||||
type ContractFillRequest struct {
|
||||
AgreementNo string `json:"agreementNo"` // 协议编号
|
||||
CompanyName string `json:"companyName"` // 甲方企业名
|
||||
UnifiedSocialCode string `json:"unifiedSocialCode"` // 甲方统一信用代码
|
||||
EnterpriseAddress string `json:"enterpriseAddress"` // 甲方联系地址
|
||||
AuthorizedRepName string `json:"authorizedRepName"` // 甲方授权代表/法人
|
||||
// SignDate 签署/签订日期,建议格式:2006年01月02日;为空则由调用方保证或使用当天
|
||||
SignDate string `json:"signDate"`
|
||||
FileName string `json:"fileName,omitempty"` // 生成文件名,可选
|
||||
}
|
||||
|
||||
// ContractFileResult 模板填单结果(对齐 e签宝 FillTemplate 返回)
|
||||
type ContractFileResult struct {
|
||||
FileID string `json:"fileId"`
|
||||
FileDownloadURL string `json:"fileDownloadUrl"`
|
||||
}
|
||||
|
||||
// TemplateFieldValue 单个控件填写值
|
||||
type TemplateFieldValue struct {
|
||||
FieldID string `json:"fieldId"`
|
||||
FieldValue string `json:"fieldValue"`
|
||||
}
|
||||
|
||||
// 签署任务参与方 ActorId
|
||||
// 基于签署模板创建时,必须与模板中「签署角色」名称完全一致。
|
||||
const (
|
||||
ActorIDPartyA = "甲方" // 甲方(企业入驻方)
|
||||
ActorIDPartyB = "乙方" // 乙方(平台海宇)
|
||||
)
|
||||
|
||||
// 签署任务状态(法大大 signTaskStatus)
|
||||
const (
|
||||
SignTaskStatusCreated = "task_created"
|
||||
SignTaskStatusFillProgress = "fill_progress"
|
||||
SignTaskStatusFillCompleted = "fill_completed"
|
||||
SignTaskStatusSignProgress = "sign_progress"
|
||||
SignTaskStatusSignCompleted = "sign_completed"
|
||||
SignTaskStatusFinished = "task_finished"
|
||||
SignTaskStatusTerminated = "task_terminated"
|
||||
)
|
||||
|
||||
// CreateSignFlowRequest 基于签署任务模板创建签署任务请求
|
||||
type CreateSignFlowRequest struct {
|
||||
// Subject 签署任务主题;为空则用合同名称
|
||||
Subject string `json:"subject,omitempty"`
|
||||
|
||||
// PartyAOpenCorpID 甲方在法大大侧 openCorpId(企业认证授权完成后获得)
|
||||
PartyAOpenCorpID string `json:"partyAOpenCorpId,omitempty"`
|
||||
PartyAName string `json:"partyAName"`
|
||||
PartyAUSCC string `json:"partyAUscc,omitempty"`
|
||||
|
||||
TransactorName string `json:"transactorName,omitempty"`
|
||||
TransactorMobile string `json:"transactorMobile,omitempty"`
|
||||
TransactorID string `json:"transactorId,omitempty"`
|
||||
|
||||
// TransReferenceID 业务侧关联号(如认证记录 ID)
|
||||
TransReferenceID string `json:"transReferenceId,omitempty"`
|
||||
// Fill 创建模板任务后立即填写控件;非空时创建不 autoStart,填完后自动 Start
|
||||
Fill *ContractFillRequest `json:"fill,omitempty"`
|
||||
}
|
||||
|
||||
// CreateSignFlowResult 创建签署任务结果
|
||||
type CreateSignFlowResult struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
}
|
||||
|
||||
// SignURLResult 参与方签署链接
|
||||
type SignURLResult struct {
|
||||
SignURL string `json:"signUrl"`
|
||||
EmbedURL string `json:"embedUrl,omitempty"`
|
||||
ActorID string `json:"actorId"`
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
}
|
||||
|
||||
// SignTaskPreviewURLResult 签署任务预览链接(EUI,非 PDF 直链)
|
||||
type SignTaskPreviewURLResult struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
}
|
||||
|
||||
// SignStatusResult 签署状态查询结果
|
||||
type SignStatusResult struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
SignTaskStatus string `json:"signTaskStatus"`
|
||||
Completed bool `json:"completed"`
|
||||
Terminated bool `json:"terminated"`
|
||||
FinishTime string `json:"finishTime,omitempty"`
|
||||
TerminationNote string `json:"terminationNote,omitempty"`
|
||||
RevokeReason string `json:"revokeReason,omitempty"`
|
||||
}
|
||||
|
||||
// SignedFile 已签署文件下载信息
|
||||
type SignedFile struct {
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
DownloadID string `json:"downloadId,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadSignedFilesResult 下载结果
|
||||
type DownloadSignedFilesResult struct {
|
||||
Files []*SignedFile `json:"files"`
|
||||
}
|
||||
|
||||
// CallbackEvent 法大大回调事件(认证/签署)
|
||||
type CallbackEvent struct {
|
||||
Event string `json:"event"`
|
||||
SignTaskID string `json:"signTaskId,omitempty"`
|
||||
SignTaskStatus string `json:"signTaskStatus,omitempty"`
|
||||
ClientCorpID string `json:"clientCorpId,omitempty"`
|
||||
OpenCorpID string `json:"openCorpId,omitempty"`
|
||||
AuthResult string `json:"authResult,omitempty"`
|
||||
EventTime string `json:"eventTime,omitempty"`
|
||||
Raw map[string]interface{} `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
// ContractSigningRequest 一站式:填单 → 创建签署 → 取甲方链接
|
||||
type ContractSigningRequest struct {
|
||||
CreateSignFlowRequest
|
||||
Fill ContractFillRequest `json:"fill"`
|
||||
}
|
||||
|
||||
// ContractSigningResult 一站式签署结果
|
||||
type ContractSigningResult struct {
|
||||
SignTaskID string `json:"signTaskId"`
|
||||
SignURL string `json:"signUrl"`
|
||||
EmbedURL string `json:"embedUrl,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user