From 7244df5ae127c7f8d735c7a6430cd26e9c47fee9 Mon Sep 17 00:00:00 2001
From: liangzai <2440983361@qq.com>
Date: Sun, 26 Jul 2026 23:14:52 +0800
Subject: [PATCH] f
---
cmd/fadada_get_auth_url/main.go | 124 ++++
config.yaml | 115 ++--
configs/env.development.yaml | 40 +-
configs/env.production.yaml | 38 +-
docs/法大大天远侧配置待办清单.md | 252 ++++++++
.../certification/already_authorized_test.go | 51 ++
.../certification_application_service.go | 15 +-
.../certification_application_service_impl.go | 555 +++++++++++++-----
.../dto/commands/certification_commands.go | 14 +
.../dto/responses/certification_responses.go | 70 ++-
.../certification/platform_flow.go | 280 +++++++++
internal/config/config.go | 36 ++
internal/container/container.go | 53 ++
.../services/api_user_aggregate_service.go | 13 +
.../certification/entities/certification.go | 155 ++++-
.../entities/value_objects/contract_info.go | 4 +-
.../certification/enums/sign_platform.go | 36 ++
.../ports/sign_platform_provider.go | 152 +++++
.../services/wallet_aggregate_service.go | 12 +-
.../external/esign/esign_provider.go | 204 +++++++
.../external/fadada/fadada_provider.go | 247 ++++++++
.../external/signplatform/registry.go | 52 ++
.../http/handlers/certification_handler.go | 125 ++++
.../http/routes/certification_routes.go | 11 +-
internal/shared/fadada/api_types.go | 194 ++++++
internal/shared/fadada/callback.go | 184 ++++++
internal/shared/fadada/callback_test.go | 57 ++
internal/shared/fadada/client.go | 40 ++
internal/shared/fadada/config.go | 187 ++++++
internal/shared/fadada/corp_service.go | 183 ++++++
internal/shared/fadada/corp_service_test.go | 35 ++
internal/shared/fadada/debug_log.go | 140 +++++
internal/shared/fadada/http.go | 187 ++++++
internal/shared/fadada/sign.go | 57 ++
internal/shared/fadada/signtask_service.go | 435 ++++++++++++++
.../shared/fadada/signtask_service_test.go | 74 +++
internal/shared/fadada/template_service.go | 262 +++++++++
.../shared/fadada/template_service_test.go | 104 ++++
internal/shared/fadada/token.go | 71 +++
internal/shared/fadada/types.go | 170 ++++++
40 files changed, 4774 insertions(+), 260 deletions(-)
create mode 100644 cmd/fadada_get_auth_url/main.go
create mode 100644 docs/法大大天远侧配置待办清单.md
create mode 100644 internal/application/certification/already_authorized_test.go
create mode 100644 internal/application/certification/platform_flow.go
create mode 100644 internal/domains/certification/enums/sign_platform.go
create mode 100644 internal/domains/certification/ports/sign_platform_provider.go
create mode 100644 internal/infrastructure/external/esign/esign_provider.go
create mode 100644 internal/infrastructure/external/fadada/fadada_provider.go
create mode 100644 internal/infrastructure/external/signplatform/registry.go
create mode 100644 internal/shared/fadada/api_types.go
create mode 100644 internal/shared/fadada/callback.go
create mode 100644 internal/shared/fadada/callback_test.go
create mode 100644 internal/shared/fadada/client.go
create mode 100644 internal/shared/fadada/config.go
create mode 100644 internal/shared/fadada/corp_service.go
create mode 100644 internal/shared/fadada/corp_service_test.go
create mode 100644 internal/shared/fadada/debug_log.go
create mode 100644 internal/shared/fadada/http.go
create mode 100644 internal/shared/fadada/sign.go
create mode 100644 internal/shared/fadada/signtask_service.go
create mode 100644 internal/shared/fadada/signtask_service_test.go
create mode 100644 internal/shared/fadada/template_service.go
create mode 100644 internal/shared/fadada/template_service_test.go
create mode 100644 internal/shared/fadada/token.go
create mode 100644 internal/shared/fadada/types.go
diff --git a/cmd/fadada_get_auth_url/main.go b/cmd/fadada_get_auth_url/main.go
new file mode 100644
index 0000000..5953f7f
--- /dev/null
+++ b/cmd/fadada_get_auth_url/main.go
@@ -0,0 +1,124 @@
+// 单独调用法大大 POST /corp/get-auth-url,打印 authUrl。
+//
+// 示例:
+//
+// go run ./cmd/fadada_get_auth_url -corp-id=HY-SEAL -name=海南海宇大数据有限公司 -phone=13876051080 -free-sign
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+
+ "tyapi-server/internal/config"
+ "tyapi-server/internal/shared/fadada"
+)
+
+func main() {
+ corpID := flag.String("corp-id", "", "clientCorpId(必填,业务侧企业唯一标识)")
+ name := flag.String("name", "", "企业名称 corpName")
+ uscc := flag.String("uscc", "", "统一社会信用代码")
+ legal := flag.String("legal", "", "法人姓名")
+ phone := flag.String("phone", "", "经办人手机号")
+ user := flag.String("user", "", "经办人姓名(默认用法人)")
+ idNo := flag.String("id", "", "经办人身份证号")
+ freeSign := flag.Bool("free-sign", false, "是否附带免验证签场景码(读配置 no_auth_scene_code)")
+ redirect := flag.String("redirect", "", "回跳地址(默认读配置 auth.redirect_url)")
+ flag.Parse()
+
+ if strings.TrimSpace(*corpID) == "" {
+ fmt.Fprintln(os.Stderr, "缺少 -corp-id(clientCorpId)")
+ flag.Usage()
+ os.Exit(2)
+ }
+
+ cfg, err := config.LoadConfig()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
+ os.Exit(1)
+ }
+ f := cfg.Fadada
+ if f.AppID == "" || f.AppSecret == "" || f.ServerURL == "" {
+ fmt.Fprintln(os.Stderr, "config 中 fadada.app_id / app_secret / server_url 不完整")
+ os.Exit(1)
+ }
+
+ authRedirect := strings.TrimSpace(*redirect)
+ if authRedirect == "" {
+ authRedirect = f.Auth.RedirectURL
+ }
+
+ fdCfg, err := fadada.NewConfig(
+ f.AppID, f.AppSecret, f.ServerURL, f.OpenCorpID, f.NoAuthSceneCode, f.TemplateID,
+ f.PartyAActorID, f.PartyBActorID, f.TemplateDocID,
+ &fadada.TemplateFields{
+ AgreementNo: f.TemplateFields.AgreementNo,
+ ContractDate: f.TemplateFields.ContractDate,
+ PartyAName: f.TemplateFields.PartyAName,
+ PartyAUSCC: f.TemplateFields.PartyAUSCC,
+ PartyAAddress: f.TemplateFields.PartyAAddress,
+ PartyARep: f.TemplateFields.PartyARep,
+ PartyASignDate: f.TemplateFields.PartyASignDate,
+ PartyBSignDate: f.TemplateFields.PartyBSignDate,
+ },
+ &fadada.ContractConfig{Name: f.Contract.Name, ExpireDays: f.Contract.ExpireDays, RetryCount: f.Contract.RetryCount},
+ &fadada.AuthConfig{RedirectURL: authRedirect},
+ &fadada.SignConfig{RedirectURL: f.Sign.RedirectURL},
+ &fadada.CallbackConfig{Enabled: f.Callback.Enabled},
+ )
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "构造法大大配置失败: %v\n", err)
+ os.Exit(1)
+ }
+
+ transactorName := strings.TrimSpace(*user)
+ if transactorName == "" {
+ transactorName = strings.TrimSpace(*legal)
+ }
+ if transactorName == "" {
+ transactorName = "经办人"
+ }
+
+ req := &fadada.EnterpriseAuthRequest{
+ ClientCorpID: strings.TrimSpace(*corpID),
+ CompanyName: strings.TrimSpace(*name),
+ UnifiedSocialCode: strings.TrimSpace(*uscc),
+ LegalPersonName: strings.TrimSpace(*legal),
+ TransactorName: transactorName,
+ TransactorMobile: strings.TrimSpace(*phone),
+ TransactorID: strings.TrimSpace(*idNo),
+ }
+ if *freeSign {
+ req.FreeSignBusinessID = strings.TrimSpace(f.NoAuthSceneCode)
+ }
+
+ // 官方多数字段可选;本地校验要求较严时,用占位补齐以便联调拿链接
+ if req.CompanyName == "" {
+ req.CompanyName = "待授权企业"
+ }
+ if req.UnifiedSocialCode == "" {
+ req.UnifiedSocialCode = req.ClientCorpID
+ }
+ if req.LegalPersonName == "" {
+ req.LegalPersonName = transactorName
+ }
+ if req.TransactorMobile == "" {
+ req.TransactorMobile = "13800000000"
+ }
+ if req.TransactorID == "" {
+ req.TransactorID = "110101199001011234"
+ }
+
+ client := fadada.NewClient(fdCfg)
+ fmt.Printf("调用 get-auth-url\n server=%s\n app_id=%s\n clientCorpId=%s\n corpName=%s\n freeSign=%v businessId=%s\n\n",
+ f.ServerURL, f.AppID, req.ClientCorpID, req.CompanyName, *freeSign, req.FreeSignBusinessID)
+
+ res, err := client.GenerateEnterpriseAuth(req)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "调用失败: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Println("authUrl:")
+ fmt.Println(res.AuthURL)
+}
diff --git a/config.yaml b/config.yaml
index 6370580..9c46f1f 100644
--- a/config.yaml
+++ b/config.yaml
@@ -276,9 +276,62 @@ esign:
sign:
auto_finish: true
sign_field_style: 1
- client_type: "ALL"
+ client_type: "A LL"
redirect_url: "https://console.tianyuanapi.com/certification/callback/sign"
+# ===========================================
+# 📝 法大大服务配置(FASC OpenAPI v5.1)
+# 天远应用 AppId=00004833;乙方自动盖章主体=海南海宇大数据有限公司
+# 应用页展示的 openCorpId=aca08b5fb5454a87920d083b820cb105(≠ 乙方盖章身份,当前代码未单独使用)
+# ===========================================
+fadada:
+ app_id: "00004833"
+ app_secret: "NOTJVESMXI7NFRVNPRX2BQJZVWCJHD1C"
+ server_url: "https://api.fadada.com/api/v5/"
+ # 乙方自动盖章身份(本应用下授权后的 openCorpId)
+ open_corp_id: "8c5f38b1661f4d569465df12a8a5c04a"
+ # 免验证签场景码(businessId);与「模板ID」不是同一个值
+ no_auth_scene_code: "972412b0ac2a63091cecd49f3d5a29fb"
+ # 签署模板 ID(来自 hyapi 海宇同模板;不是场景码 c66587b4...)
+ template_id: "1785057760485117592"
+ party_a_actor_id: "甲方"
+ party_b_actor_id: "乙方"
+ template_doc_id: ""
+ # 控件 fieldId 与海宇同模板保持一致
+ template_fields:
+ agreement_no:
+ - "0277518263"
+ - "5199991781"
+ - "0923405093"
+ - "6021178427"
+ - "5137728710"
+ - "3181735831"
+ - "2594442367"
+ - "5884938840"
+ - "6914410621"
+ - "6308592720"
+ - "3347376554"
+ - "8264194159"
+ contract_date: "2039326319"
+ party_a_name:
+ - "6920634255"
+ - "1847502631"
+ party_a_uscc: "8702060291"
+ party_a_address: "4557539440"
+ party_a_rep: "8627432823"
+ party_a_sign_date: ""
+ party_b_sign_date: ""
+ contract:
+ name: "天远数据API合作协议"
+ expire_days: 7
+ retry_count: 3
+ auth:
+ redirect_url: "https://console.tianyuanapi.com/profile/certification"
+ sign:
+ redirect_url: "https://console.tianyuanapi.com/certification/callback/fadada/sign"
+ callback:
+ enabled: true
+
# ===========================================
# 💰 钱包配置
# ===========================================
@@ -776,63 +829,3 @@ haiyuapi:
max_backups: 5
max_age: 30
compress: true
-
-
-# ===========================================
-# 📝 法大大服务配置(FASC OpenAPI v5.1)
-# 应用名称:天远数据 | AppId:80005307 | 状态:待上线
-# ===========================================
-fadada:
- app_id: "00004782"
- app_secret: "E9GMVOQ9NBSOUDG5VOFM0J1CZPOAP2Y8"
- # 待上线/联调用测试环境;正式上线后改为 https://api.fadada.com/api/v5/
- server_url: "https://api.fadada.com/api/v5/"
- open_corp_id: "7118e66dfd9349d998c0395c5d0742ad" # 天远数据在法大大侧企业 ID
- # 免验证签场景码(businessId):乙方 requestVerifyFree=true 时必传,平台校验是否已授权
- no_auth_scene_code: "c66587b4a048bba3c1cb032eb7750182"
- template_id: "1784619066713193840" # 合作协议模板 ID(填单/签署;freeSignType=template)
- # 签署模板中的参与方标识(actorId),必须与模板「签署角色」名称完全一致
- party_a_actor_id: "甲方"
- party_b_actor_id: "乙方"
- # 签署模板内文档 docId(填单必填);可从模板详情/任务详情 docs[].docId 获取,留空则创建后自动解析
- template_doc_id: ""
- # 模板控件编码(fieldId)——与法大大后台控件编码一致
-
- template_fields:
- # 协议编号:线上由 GenerateContractCode 生成,格式 CON01{YYYYMMDD}{6位随机数},已有 contract_code 则复用;下列控件全部填同一编号
- agreement_no:
- - "0277518263"
- - "5199991781"
- - "0923405093"
- - "6021178427"
- - "5137728710"
- - "3181735831"
- - "2594442367"
- - "5884938840"
- - "6914410621"
- - "6308592720"
- - "3347376554"
- - "8264194159"
- contract_date: "2039326319" # 签订日期(系统填写)
- # 甲方企业名:控件不可复用,多处填同一企业名
- party_a_name:
- - "6920634255"
- - "1847502631"
- party_a_uscc: "8702060291" # 甲方统一信用代码
- party_a_address: "4557539440" # 甲方联系地址
- party_a_rep: "8627432823" # 甲方授权代表/法人
- # 签署日期为签署控件(非填写控件),由甲/乙方签署时自动写入,勿走填单 API
- party_a_sign_date: ""
- party_b_sign_date: ""
- contract:
- name: "合作协议-天远API"
- expire_days: 7
- retry_count: 3
- auth:
- # 认证完成后顶层跳回回调页(勿用 callback 页留在 iframe 内)
- redirect_url: "https://console.tianyuanapi.com/certification/callback/fadada/auth"
- sign:
- # 甲方签署完成后回跳(iframe 内会顶层跳出)
- redirect_url: "https://console.tianyuanapi.com/certification/callback/fadada/sign"
- callback:
- enabled: true
diff --git a/configs/env.development.yaml b/configs/env.development.yaml
index 75a2386..79e093d 100644
--- a/configs/env.development.yaml
+++ b/configs/env.development.yaml
@@ -68,17 +68,49 @@ esign:
# ===========================================
# 📝 法大大服务配置(开发环境本地联调)
+# 当前使用正式 API + 天远应用凭证(若另有 UAT 应用请再换一套)
# ===========================================
fadada:
+ app_id: "00004833"
+ app_secret: "NOTJVESMXI7NFRVNPRX2BQJZVWCJHD1C"
+ server_url: "https://api.fadada.com/api/v5/"
+ # 乙方自动盖章:本应用下授权后的 openCorpId
+ open_corp_id: "8c5f38b1661f4d569465df12a8a5c04a"
+ no_auth_scene_code: "972412b0ac2a63091cecd49f3d5a29fb"
+ template_id: "1785057760485117592"
+ party_a_actor_id: "甲方"
+ party_b_actor_id: "乙方"
+ template_doc_id: ""
+ template_fields:
+ agreement_no:
+ - "0277518263"
+ - "5199991781"
+ - "0923405093"
+ - "6021178427"
+ - "5137728710"
+ - "3181735831"
+ - "2594442367"
+ - "5884938840"
+ - "6914410621"
+ - "6308592720"
+ - "3347376554"
+ - "8264194159"
+ contract_date: "2039326319"
+ party_a_name:
+ - "6920634255"
+ - "1847502631"
+ party_a_uscc: "8702060291"
+ party_a_address: "4557539440"
+ party_a_rep: "8627432823"
+ party_a_sign_date: ""
+ party_b_sign_date: ""
contract:
- name: "合作协议-天远API"
+ name: "天远数据API合作协议"
expire_days: 7
retry_count: 3
auth:
- # 本地控制台认证回调
- redirect_url: "http://localhost:5173/certification/callback/fadada/auth"
+ redirect_url: "http://localhost:5173/profile/certification"
sign:
- # 本地控制台签署回调
redirect_url: "http://localhost:5173/certification/callback/fadada/sign"
callback:
enabled: true
diff --git a/configs/env.production.yaml b/configs/env.production.yaml
index f733bb0..33d6018 100644
--- a/configs/env.production.yaml
+++ b/configs/env.production.yaml
@@ -98,14 +98,48 @@ esign:
# ===========================================
# 📝 法大大服务配置(生产环境)
+# 天远应用 AppId=00004833;乙方自动盖章=海南海宇大数据有限公司
# ===========================================
fadada:
+ app_id: "00004833"
+ app_secret: "NOTJVESMXI7NFRVNPRX2BQJZVWCJHD1C"
+ server_url: "https://api.fadada.com/api/v5/"
+ # 乙方自动盖章身份(本应用下授权后的 openCorpId);应用页 openCorpId=aca08b5f... 未写入此处
+ open_corp_id: "8c5f38b1661f4d569465df12a8a5c04a"
+ no_auth_scene_code: "972412b0ac2a63091cecd49f3d5a29fb"
+ template_id: "1785057760485117592"
+ party_a_actor_id: "甲方"
+ party_b_actor_id: "乙方"
+ template_doc_id: ""
+ template_fields:
+ agreement_no:
+ - "0277518263"
+ - "5199991781"
+ - "0923405093"
+ - "6021178427"
+ - "5137728710"
+ - "3181735831"
+ - "2594442367"
+ - "5884938840"
+ - "6914410621"
+ - "6308592720"
+ - "3347376554"
+ - "8264194159"
+ contract_date: "2039326319"
+ party_a_name:
+ - "6920634255"
+ - "1847502631"
+ party_a_uscc: "8702060291"
+ party_a_address: "4557539440"
+ party_a_rep: "8627432823"
+ party_a_sign_date: ""
+ party_b_sign_date: ""
contract:
- name: "合作协议-天远API"
+ name: "天远数据API合作协议"
expire_days: 7
retry_count: 3
auth:
- redirect_url: "https://console.tianyuanapi.com/certification/callback/fadada/auth"
+ redirect_url: "https://console.tianyuanapi.com/profile/certification"
sign:
redirect_url: "https://console.tianyuanapi.com/certification/callback/fadada/sign"
callback:
diff --git a/docs/法大大天远侧配置待办清单.md b/docs/法大大天远侧配置待办清单.md
new file mode 100644
index 0000000..47e253a
--- /dev/null
+++ b/docs/法大大天远侧配置待办清单.md
@@ -0,0 +1,252 @@
+# 法大大(天远)接入待办清单
+
+> 代码已从 hyapi(海宇)移植到 tyapi(天远)。
+> **两边是不同公司、不同法大大账号:海宇的密钥 / 模板 / 企业 ID 一律不可复用。**
+> 本文只列 **天远侧需要你准备、填写、在法大大后台配置** 的事项。
+
+---
+
+## 一、结论(先看这个)
+
+| 类别 | 是否需要你做 |
+|------|----------------|
+| 代码移植 | ✅ 已完成(后端 + 前端) |
+| 天远法大大开户 / 应用密钥 | ❌ **必须你提供** |
+| 天远合同模板 + 控件 fieldId | ❌ **必须你新建并回填配置** |
+| 天远企业 openCorpId / 印章 / 免验证签场景码 | ❌ **必须你开通** |
+| 回调与回跳域名 | ❌ **必须你在法大大后台登记** |
+| 海宇配置抄过来 | ⛔ **禁止** |
+
+配置已按 2026-07-26 提供的天远应用凭证写入(三份 yaml 的 `fadada:`)。
+
+**重要纠偏(已按 hyapi 对照处理):**
+
+| 你提供的值 | 实际写入 | 说明 |
+|------------|----------|------|
+| `c66587b4a048bba3c1cb032eb7750182` 当作模板 ID | **否** → 写入 `no_auth_scene_code` | hyapi 里这是免签场景码,不是模板 ID |
+| 模板 ID | `1784619066713193840` | hyapi 同模板真实 ID;请在法大大后台再确认一次 |
+| `openCorpId=aca08b5f...`(应用页) | **未**作为盖章身份 | 代码 `open_corp_id` 用于乙方自动盖章 ActorOpenID |
+| 乙方=海南海宇大数据有限公司 | `open_corp_id=7118e66dfd9349d998c0395c5d0742ad` | 取自 hyapi 海宇企业 ID,请确认是否仍正确 |
+
+---
+
+## 二、密钥与账号(必填)
+
+在法大大开放平台为 **天远数据** 单独创建应用后,提供:
+
+| 项 | 配置键 | 说明 | 从哪拿 |
+|----|--------|------|--------|
+| 应用 ID | `fadada.app_id` | 天远应用的 AppId | 开放平台 → 应用详情 |
+| 应用密钥 | `fadada.app_secret` | AppSecret(验签也用它) | 开放平台 → 应用详情 |
+| API 环境 | `fadada.server_url` | 测试:`https://uat-api.fadada.com/api/v5/`
正式:`https://api.fadada.com/api/v5/` | 按环境选择 |
+| 天远企业 ID | `fadada.open_corp_id` | 天远主体在法大大侧的 `openCorpId`(乙方自动盖章身份) | 企业授权完成后在控制台/接口可见 |
+| 免验证签场景码 | `fadada.no_auth_scene_code` | 即 `businessId`;乙方免验证签必传 | 法大大后台申请「模板免验证签」场景 |
+
+**不需要**单独提供 `.pem` / 证书文件;API 与回调验签均使用 `app_id` + `app_secret`(HMAC-SHA256)。
+
+建议:**测试应用 / 正式应用各一套**,分别写入 development / production。
+
+---
+
+## 三、合同模板与文件(必填)
+
+### 3.1 需要准备的文件
+
+| 文件 | 用途 | 备注 |
+|------|------|------|
+| 《天远数据API合作协议》Word/PDF 原件 | 上传到法大大做成「签署模板」 | **不能**用海宇模板;法务定稿后再上传 |
+| (可选)控件 fieldId 对照表 | Excel/截图,标清每个填写控件编码 | 方便回填 `template_fields` |
+
+### 3.2 模板在法大大后台要配好
+
+1. 创建 **签署模板**(含甲、乙双方角色,角色名建议:`甲方` / `乙方`)
+2. 甲方:客户企业手动签(验证码等)
+3. 乙方:天远,开启 **按模板免验证签**(自动盖章)
+4. 放置填写控件:协议编号、签订日期、甲方企业名、统一社会信用代码、联系地址、授权代表等
+5. 签署日期用 **签署控件**(不要当填写控件走填单 API)
+
+### 3.3 回填到配置的 ID
+
+| 项 | 配置键 | 说明 |
+|----|--------|------|
+| 模板 ID | `fadada.template_id` | 签署模板 ID |
+| 文档 docId | `fadada.template_doc_id` | 可先留空,运行时自动解析;有则更稳 |
+| 甲方角色 | `fadada.party_a_actor_id` | 须与模板角色名一致,默认 `"甲方"` |
+| 乙方角色 | `fadada.party_b_actor_id` | 默认 `"乙方"` |
+
+### 3.4 控件 fieldId(`fadada.template_fields`)
+
+以 **天远自己模板** 控制台导出的编码为准,示例结构:
+
+```yaml
+template_fields:
+ agreement_no: # 协议编号(多处同值可配多个 fieldId)
+ - "xxxxxxxx"
+ contract_date: "xxxxxxxx" # 签订日期
+ party_a_name: # 甲方企业名(多处同值可配多个)
+ - "xxxxxxxx"
+ party_a_uscc: "xxxxxxxx" # 统一社会信用代码
+ party_a_address: "xxxxxxxx" # 联系地址
+ party_a_rep: "xxxxxxxx" # 授权代表/法人
+ party_a_sign_date: "" # 签署控件,一般留空
+ party_b_sign_date: "" # 签署控件,一般留空
+```
+
+> 海宇配置里的一串数字 fieldId **全部作废**,不可拷贝。
+
+协议编号线上生成规则(已实现,一般不用改):`CON01` + `YYYYMMDD` + 6 位随机数。
+
+---
+
+## 四、企业主体与印章(必填)
+
+| 项 | 说明 |
+|----|------|
+| 天远企业在法大大完成实名 | 主体名称、统一社会信用代码与营业执照一致 |
+| 应用授权 | 天远企业授权给本开放平台应用 |
+| 企业公章 / 合同章 | 开通并可用于签署;免验证签场景需绑定可用印章 |
+| 免验证签开通 | 与 `no_auth_scene_code` 对应;未开通则乙方自动盖章失败 |
+
+---
+
+## 五、回调与前端回跳(必填)
+
+### 5.1 服务端事件回调(法大大 → 天远 API)
+
+在法大大应用里配置回调 URL(公网 HTTPS):
+
+```text
+https://console.tianyuanapi.com/api/v1/certifications/callbacks/fadada
+```
+
+(若 API 域名与控制台不同,改成实际 API 网关域名。)
+
+对应配置:
+
+```yaml
+fadada:
+ callback:
+ enabled: true
+```
+
+### 5.2 浏览器回跳(用户 iframe 完成后)
+
+| 场景 | 配置键 | 建议值 |
+|------|--------|--------|
+| 企业认证完成 | `fadada.auth.redirect_url` | 生产:`https://console.tianyuanapi.com/profile/certification`
本地:`http://localhost:5173/profile/certification` |
+| 签署完成 | `fadada.sign.redirect_url` | 生产:`https://console.tianyuanapi.com/certification/callback/fadada/sign`
本地:`http://localhost:5173/certification/callback/fadada/sign` |
+
+开发/生产 yaml 里已写好占位,确认域名证书与前端路由已上线即可。
+
+---
+
+## 六、文案与其它(建议核对)
+
+| 项 | 当前默认 | 是否要你改 |
+|----|----------|------------|
+| 合同名称 | `天远数据API合作协议` | 与法务文件名一致即可 |
+| `expire_days` / `retry_count` | 7 / 3 | 按业务调整 |
+| 协议编号前缀 | `CON01...`(代码已生成) | 一般不用改 |
+
+---
+
+## 七、联调检查清单(按顺序打勾)
+
+### Phase A — 账号与后台
+
+- [ ] 天远法大大账号开户完成
+- [ ] 创建开放平台应用,拿到 `app_id` / `app_secret`
+- [ ] 企业实名 + 应用授权,拿到 `open_corp_id`
+- [ ] 开通免验证签,拿到 `no_auth_scene_code`
+- [ ] 上传《天远数据API合作协议》,建签署模板
+- [ ] 记录 `template_id` 与全部填写控件 `fieldId`
+- [ ] 配置印章与免验证签绑定
+- [ ] 配置事件回调 URL(公网可达)
+
+### Phase B — 写入工程配置
+
+- [ ] 填写 `configs/env.development.yaml` 的 `fadada:`(UAT)
+- [ ] 填写 `configs/env.production.yaml` 的 `fadada:`(正式 URL + 正式密钥)
+- [ ] 同步/确认 `config.yaml` 本地联调段
+- [ ] 确认 `auth.redirect_url` / `sign.redirect_url` 指向天远域名
+
+### Phase C — 端到端验证
+
+- [ ] 管理员审核通过企业信息(此时**不会**立刻调第三方)
+- [ ] 用户侧自动/手动选择法大大 → 出现企业认证 iframe
+- [ ] 企业认证成功 → 合同预览(法大大预览页,非 PDF 直链)
+- [ ] 申请签署 → 甲方签署 iframe(链接约 10 分钟,进入页会刷新)
+- [ ] 甲方签完后乙方自动盖章
+- [ ] 状态变为完成,已签文件下载归档到系统
+- [ ] 回调日志有验签成功记录(或轮询兜底成功)
+
+### Phase D — 存量 e签宝
+
+- [ ] 历史进行中的 e签宝用户可继续走完(后端仍保留 e签宝 Provider)
+- [ ] 新用户前端不可见 e签宝(仅法大大)
+
+---
+
+## 八、交给法大大/商务时可复制的需求说明
+
+请为「天远数据」开通 FASC OpenAPI v5.1,并提供:
+
+1. 测试环境 & 正式环境各自的 AppId、AppSecret
+2. 企业 openCorpId
+3. 模板免验证签场景码(businessId)
+4. 签署模板能力:甲乙双方、甲方手签、乙方按模板免验证签自动盖章
+5. 事件回调地址登记权限(我方提供 HTTPS URL)
+6. 控制台导出模板 ID 与控件 fieldId 列表的权限
+
+合同文件由我方法务提供终稿后上传。
+
+---
+
+## 九、代码侧已完成(无需你再开发)
+
+- `internal/shared/fadada`:FASC HTTP 客户端
+- `SignPlatformProvider` + Registry(e签宝 + 法大大)
+- 审核通过后选平台 → 认证 → 签署 → 下载全流程
+- 回调:`POST /api/v1/certifications/callbacks/fadada`
+- 前端:平台选择 / 静默选法大大、签署与预览链接刷新、iframe 顶层回跳
+- 配置键与 yaml 占位已就位
+
+**你当前唯一阻塞点:填好天远自己的法大大密钥、模板与回调。**
+
+---
+
+## 十、配置填写模板(可直接贴回 yaml)
+
+```yaml
+fadada:
+ app_id: "这里填天远AppId"
+ app_secret: "这里填天远AppSecret"
+ server_url: "https://uat-api.fadada.com/api/v5/" # 正式改为 https://api.fadada.com/api/v5/
+ open_corp_id: "这里填天远openCorpId"
+ no_auth_scene_code: "这里填免验证签场景码"
+ template_id: "这里填签署模板ID"
+ party_a_actor_id: "甲方"
+ party_b_actor_id: "乙方"
+ template_doc_id: "" # 可选
+ template_fields:
+ agreement_no:
+ - "控件ID"
+ contract_date: "控件ID"
+ party_a_name:
+ - "控件ID"
+ party_a_uscc: "控件ID"
+ party_a_address: "控件ID"
+ party_a_rep: "控件ID"
+ party_a_sign_date: ""
+ party_b_sign_date: ""
+ contract:
+ name: "天远数据API合作协议"
+ expire_days: 7
+ retry_count: 3
+ auth:
+ redirect_url: "https://console.tianyuanapi.com/profile/certification"
+ sign:
+ redirect_url: "https://console.tianyuanapi.com/certification/callback/fadada/sign"
+ callback:
+ enabled: true
+```
diff --git a/internal/application/certification/already_authorized_test.go b/internal/application/certification/already_authorized_test.go
new file mode 100644
index 0000000..27abc87
--- /dev/null
+++ b/internal/application/certification/already_authorized_test.go
@@ -0,0 +1,51 @@
+package certification
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestIsEnterpriseAlreadyAuthorizedErr(t *testing.T) {
+ cases := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {
+ name: "fadada 210002",
+ err: errors.New("获取法大大企业认证链接失败: code=210002 msg=该企业已授权,无需重复操作 requestId=abc"),
+ want: true,
+ },
+ {
+ name: "exist authorize wording",
+ err: errors.New("当前应用已存在授权"),
+ want: true,
+ },
+ {
+ name: "other error",
+ err: errors.New("获取法大大企业认证链接失败: code=100011 msg=参数错误"),
+ want: false,
+ },
+ {
+ name: "nil",
+ err: nil,
+ want: false,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := isEnterpriseAlreadyAuthorizedErr(tc.err); got != tc.want {
+ t.Fatalf("got %v want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestIsEnterpriseAlreadyRealnamedErr(t *testing.T) {
+ if !isEnterpriseAlreadyRealnamedErr(errors.New("企业用户已实名")) {
+ t.Fatal("expected true for 已实名")
+ }
+ if isEnterpriseAlreadyRealnamedErr(errors.New("该企业已授权,无需重复操作")) {
+ t.Fatal("已授权 should not match 已实名 helper")
+ }
+}
diff --git a/internal/application/certification/certification_application_service.go b/internal/application/certification/certification_application_service.go
index bb045b5..52d532f 100644
--- a/internal/application/certification/certification_application_service.go
+++ b/internal/application/certification/certification_application_service.go
@@ -20,6 +20,14 @@ type CertificationApplicationService interface {
ConfirmSign(ctx context.Context, cmd *queries.ConfirmSignCommand) (*responses.ConfirmSignResponse, error)
// 申请合同签署
ApplyContract(ctx context.Context, cmd *commands.ApplyContractCommand) (*responses.ContractSignUrlResponse, error)
+ // RefreshContractSignURL 重新获取签署 iframe 长链(法大大 EmbedURL 约 10 分钟/单次有效)
+ RefreshContractSignURL(ctx context.Context, userID string) (*responses.ContractSignUrlResponse, error)
+ // RefreshContractPreviewURL 获取签署任务预览链接(法大大 get-preview-url)
+ RefreshContractPreviewURL(ctx context.Context, userID string) (*responses.ContractPreviewUrlResponse, error)
+ // ListSignPlatforms 可选签署平台列表
+ ListSignPlatforms(ctx context.Context) (*responses.SignPlatformsResponse, error)
+ // SelectSignPlatform 选择签署平台并生成企业认证链接
+ SelectSignPlatform(ctx context.Context, cmd *commands.SelectSignPlatformCommand) (*responses.CertificationResponse, error)
// OCR营业执照识别
RecognizeBusinessLicense(ctx context.Context, imageBytes []byte) (*responses.BusinessLicenseResult, error)
@@ -37,6 +45,9 @@ type CertificationApplicationService interface {
// AdminCompleteCertificationWithoutContract 管理员代用户完成认证(暂不关联合同)
AdminCompleteCertificationWithoutContract(ctx context.Context, cmd *commands.AdminCompleteCertificationCommand) (*responses.CertificationResponse, error)
+ // AdminResetForResignContract 管理员触发重新认证(回退到提交企业信息,保留钱包/API Key/历史合同,不改 is_certified)
+ AdminResetForResignContract(ctx context.Context, cmd *commands.AdminResetForResignContractCommand) (*responses.CertificationResponse, error)
+
// AdminListSubmitRecords 管理端分页查询企业信息提交记录
AdminListSubmitRecords(ctx context.Context, query *queries.AdminListSubmitRecordsQuery) (*responses.AdminSubmitRecordsListResponse, error)
// AdminGetSubmitRecordByID 管理端获取单条提交记录详情
@@ -48,8 +59,10 @@ type CertificationApplicationService interface {
// AdminTransitionCertificationStatus 管理端按用户变更认证状态(以状态机为准:info_submitted=通过 / info_rejected=拒绝)
AdminTransitionCertificationStatus(ctx context.Context, cmd *commands.AdminTransitionCertificationStatusCommand) error
- // ================ e签宝回调处理 ================
+ // ================ 第三方回调处理 ================
// 处理e签宝回调
HandleEsignCallback(ctx context.Context, cmd *commands.EsignCallbackCommand) error
+ // 处理法大大回调
+ HandleFadadaCallback(ctx context.Context, headers map[string]string, body []byte) error
}
diff --git a/internal/application/certification/certification_application_service_impl.go b/internal/application/certification/certification_application_service_impl.go
index 9c748d4..33486a7 100644
--- a/internal/application/certification/certification_application_service_impl.go
+++ b/internal/application/certification/certification_application_service_impl.go
@@ -17,6 +17,7 @@ import (
"tyapi-server/internal/domains/certification/entities"
certification_value_objects "tyapi-server/internal/domains/certification/entities/value_objects"
"tyapi-server/internal/domains/certification/enums"
+ "tyapi-server/internal/domains/certification/ports"
"tyapi-server/internal/domains/certification/repositories"
"tyapi-server/internal/domains/certification/services"
finance_entities "tyapi-server/internal/domains/finance/entities"
@@ -45,6 +46,7 @@ type CertificationApplicationServiceImpl struct {
smsCodeService *user_service.SMSCodeService
esignClient *esign.Client
esignConfig *esign.Config
+ platformRegistry ports.SignPlatformRegistry
qiniuStorageService *storage.QiNiuStorageService
contractAggregateService user_service.ContractAggregateService
walletAggregateService finance_service.WalletAggregateService
@@ -72,6 +74,7 @@ func NewCertificationApplicationService(
smsCodeService *user_service.SMSCodeService,
esignClient *esign.Client,
esignConfig *esign.Config,
+ platformRegistry ports.SignPlatformRegistry,
qiniuStorageService *storage.QiNiuStorageService,
contractAggregateService user_service.ContractAggregateService,
walletAggregateService finance_service.WalletAggregateService,
@@ -96,6 +99,7 @@ func NewCertificationApplicationService(
smsCodeService: smsCodeService,
esignClient: esignClient,
esignConfig: esignConfig,
+ platformRegistry: platformRegistry,
qiniuStorageService: qiniuStorageService,
contractAggregateService: contractAggregateService,
walletAggregateService: walletAggregateService,
@@ -454,16 +458,22 @@ func (s *CertificationApplicationServiceImpl) ConfirmAuth(
zap.String("record_id", record.ID))
s.logger.Info("确认状态-步骤3-开始查询三方实名状态",
zap.String("user_id", cmd.UserID),
- zap.String("company_name", record.CompanyName))
- identity, err := s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{
- OrgName: record.CompanyName,
+ zap.String("company_name", record.CompanyName),
+ zap.String("sign_platform", string(cert.ResolvedSignPlatform())))
+ provider, err := s.resolveProvider(cert)
+ if err != nil {
+ return nil, err
+ }
+ verified, err := provider.QueryOrgVerified(ctx, &ports.OrgIdentityQuery{
+ OrgName: record.CompanyName,
+ OrgIdentNo: record.UnifiedSocialCode,
})
if err != nil {
s.logger.Error("查询企业认证信息失败", zap.Error(err))
return nil, fmt.Errorf("查询企业认证信息失败: %w", err)
}
reason := ""
- if identity != nil && identity.Data.RealnameStatus == 1 {
+ if verified {
s.logger.Info("确认状态-步骤3-三方实名状态已完成,准备事务内推进认证",
zap.String("user_id", cmd.UserID))
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
@@ -512,7 +522,7 @@ func (s *CertificationApplicationServiceImpl) ConfirmSign(
}, nil
}
-// ApplyContract 申请合同签署
+// ApplyContract 立即签署:若认证完成时已创建任务则只取 iframe 长链并推进状态;否则创建任务
func (s *CertificationApplicationServiceImpl) ApplyContract(
ctx context.Context,
cmd *commands.ApplyContractCommand,
@@ -520,59 +530,177 @@ func (s *CertificationApplicationServiceImpl) ApplyContract(
s.logger.Info("开始申请合同签署",
zap.String("user_id", cmd.UserID))
- // 1. 验证命令完整性
if err := s.validateApplyContractCommand(cmd); err != nil {
return nil, fmt.Errorf("命令验证失败: %s", err.Error())
}
- // 2. 加载认证聚合根
cert, err := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID)
if err != nil {
return nil, fmt.Errorf("加载认证信息失败: %s", err.Error())
}
-
- // 3. 验证业务前置条件
if err := s.validateContractApplicationPreconditions(cert, cmd.UserID); err != nil {
return nil, fmt.Errorf("业务前置条件验证失败: %s", err.Error())
}
- // 5. 生成合同和签署链接
enterpriseInfo, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, cmd.UserID)
if err != nil {
s.logger.Error("获取企业信息失败", zap.Error(err))
return nil, fmt.Errorf("获取企业信息失败: %w", err)
}
- contractInfo, err := s.generateContractAndSignURL(ctx, cert, enterpriseInfo.EnterpriseInfo)
- if err != nil {
- s.logger.Error("生成合同失败", zap.Error(err))
- return nil, fmt.Errorf("生成合同失败: %s", err.Error())
+
+ var embedURL, shortURL string
+ flowID := strings.TrimSpace(cert.EsignFlowID)
+
+ if flowID != "" {
+ provider, err := s.resolveProvider(cert)
+ if err != nil {
+ return nil, err
+ }
+ ei := enterpriseInfo.EnterpriseInfo
+ urlRes, err := provider.GetActorSignURL(ctx, &ports.GetActorSignURLRequest{
+ SignFlowID: flowID,
+ TransactorMobile: ei.LegalPersonPhone,
+ PartyAName: ei.CompanyName,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("获取签署链接失败: %s", err.Error())
+ }
+ embedURL = firstNonEmptyStr(urlRes.EmbedURL, urlRes.ShortURL)
+ shortURL = strings.TrimSpace(urlRes.ShortURL)
+ } else {
+ contractInfo, err := s.generateContractAndSignURL(ctx, cert, enterpriseInfo.EnterpriseInfo)
+ if err != nil {
+ s.logger.Error("生成合同失败", zap.Error(err))
+ return nil, fmt.Errorf("生成合同失败: %s", err.Error())
+ }
+ flowID = contractInfo.EsignFlowID
+ embedURL = contractInfo.ContractSignURL
+ shortURL = contractInfo.ContractSignShortURL
}
- err = cert.ApplyContract(contractInfo.EsignFlowID, contractInfo.ContractSignURL)
+ if embedURL == "" {
+ return nil, fmt.Errorf("签署链接为空")
+ }
+
+ storeURL := firstNonEmptyStr(shortURL, cert.ContractSignURL, embedURL)
+ err = cert.ApplyContract(flowID, storeURL)
if err != nil {
s.logger.Error("合同申请状态转换失败", zap.Error(err))
return nil, fmt.Errorf("合同申请失败: %s", err.Error())
}
- // 7. 保存认证信息
err = s.aggregateService.SaveCertification(ctx, cert)
if err != nil {
s.logger.Error("保存认证信息失败", zap.Error(err))
return nil, fmt.Errorf("保存认证信息失败: %s", err.Error())
}
- // 8. 构建响应
response := responses.NewContractSignUrlResponse(
cert.ID,
- contractInfo.ContractSignURL,
- contractInfo.ContractURL,
+ embedURL,
+ shortURL,
+ cert.ContractURL,
"请在规定时间内完成合同签署",
"合同申请成功",
)
- s.logger.Info("合同申请成功", zap.String("user_id", cmd.UserID))
+ s.logger.Info("合同申请成功", zap.String("user_id", cmd.UserID), zap.String("esign_flow_id", flowID))
return response, nil
}
+// RefreshContractSignURL 进入签署页时刷新 iframe 长链
+func (s *CertificationApplicationServiceImpl) RefreshContractSignURL(
+ ctx context.Context,
+ userID string,
+) (*responses.ContractSignUrlResponse, error) {
+ if strings.TrimSpace(userID) == "" {
+ return nil, fmt.Errorf("用户ID不能为空")
+ }
+ cert, err := s.aggregateService.LoadCertificationByUserID(ctx, userID)
+ if err != nil {
+ return nil, fmt.Errorf("加载认证信息失败: %s", err.Error())
+ }
+ if cert.Status != enums.StatusContractApplied && cert.Status != enums.StatusEnterpriseVerified {
+ return nil, fmt.Errorf("当前状态不允许获取签署链接")
+ }
+ if strings.TrimSpace(cert.EsignFlowID) == "" {
+ return nil, fmt.Errorf("签署任务尚未创建")
+ }
+
+ provider, err := s.resolveProvider(cert)
+ if err != nil {
+ return nil, err
+ }
+ enterpriseInfo, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, userID)
+ if err != nil {
+ return nil, fmt.Errorf("获取企业信息失败: %w", err)
+ }
+ ei := enterpriseInfo.EnterpriseInfo
+ urlRes, err := provider.GetActorSignURL(ctx, &ports.GetActorSignURLRequest{
+ SignFlowID: cert.EsignFlowID,
+ TransactorMobile: ei.LegalPersonPhone,
+ PartyAName: ei.CompanyName,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("刷新签署链接失败: %s", err.Error())
+ }
+ embedURL := firstNonEmptyStr(urlRes.EmbedURL, urlRes.ShortURL)
+ if embedURL == "" {
+ return nil, fmt.Errorf("签署链接为空")
+ }
+ if short := strings.TrimSpace(urlRes.ShortURL); short != "" && short != cert.ContractSignURL {
+ cert.ContractSignURL = short
+ _ = s.aggregateService.SaveCertification(ctx, cert)
+ }
+
+ return responses.NewContractSignUrlResponse(
+ cert.ID,
+ embedURL,
+ urlRes.ShortURL,
+ cert.ContractURL,
+ "请在规定时间内完成合同签署",
+ "签署链接已刷新",
+ ), nil
+}
+
+// RefreshContractPreviewURL 合同预览页:调用法大大 get-preview-url(非 PDF 直链)
+func (s *CertificationApplicationServiceImpl) RefreshContractPreviewURL(
+ ctx context.Context,
+ userID string,
+) (*responses.ContractPreviewUrlResponse, error) {
+ if strings.TrimSpace(userID) == "" {
+ return nil, fmt.Errorf("用户ID不能为空")
+ }
+ cert, err := s.aggregateService.LoadCertificationByUserID(ctx, userID)
+ if err != nil {
+ return nil, fmt.Errorf("加载认证信息失败: %s", err.Error())
+ }
+ if cert.Status != enums.StatusEnterpriseVerified && cert.Status != enums.StatusContractApplied {
+ return nil, fmt.Errorf("当前状态不允许预览合同")
+ }
+ if strings.TrimSpace(cert.EsignFlowID) == "" {
+ return nil, fmt.Errorf("签署任务尚未创建,无法预览")
+ }
+ provider, err := s.resolveProvider(cert)
+ if err != nil {
+ return nil, err
+ }
+ preview, err := provider.GetSignTaskPreviewURL(ctx, cert.EsignFlowID)
+ if err != nil {
+ return nil, fmt.Errorf("获取预览链接失败: %s", err.Error())
+ }
+ mode := "iframe"
+ if cert.ResolvedSignPlatform() == enums.SignPlatformEsign {
+ mode = "pdf"
+ }
+ return &responses.ContractPreviewUrlResponse{
+ CertificationID: cert.ID,
+ PreviewURL: preview.PreviewURL,
+ PreviewMode: mode,
+ SignFlowID: cert.EsignFlowID,
+ Message: "预览链接已刷新",
+ }, nil
+}
+
// ================ 查询用例 ================
// GetCertification 获取认证详情
@@ -614,6 +742,16 @@ func (s *CertificationApplicationServiceImpl) GetCertification(
}
}
+ if cert.Status == enums.StatusEnterpriseVerified && strings.TrimSpace(cert.EsignFlowID) == "" {
+ if err := cert.ResetAfterSignTaskCreateFailed(); err != nil {
+ s.logger.Warn("回退未创建签署任务的脏状态失败", zap.String("user_id", query.UserID), zap.Error(err))
+ } else if saveErr := s.aggregateService.SaveCertification(ctx, cert); saveErr != nil {
+ s.logger.Warn("保存回退状态失败", zap.String("user_id", query.UserID), zap.Error(saveErr))
+ } else {
+ s.logger.Info("已回退未创建签署任务的脏状态到待选平台", zap.String("user_id", query.UserID))
+ }
+ }
+
if cert.Status == enums.StatusInfoSubmitted {
err = s.checkAndCompleteEnterpriseVerification(ctx, cert)
if err != nil {
@@ -858,6 +996,48 @@ func (s *CertificationApplicationServiceImpl) AdminCompleteCertificationWithoutC
return response, nil
}
+// AdminResetForResignContract 管理员触发重新认证(回退到提交企业信息)
+// 回退到 pending,用户可重新填写企业信息并走完整流程;不改 is_certified,不动钱包/API Key/历史合同
+func (s *CertificationApplicationServiceImpl) AdminResetForResignContract(
+ ctx context.Context,
+ cmd *commands.AdminResetForResignContractCommand,
+) (*responses.CertificationResponse, error) {
+ s.logger.Info("管理员触发重新认证",
+ zap.String("admin_id", cmd.AdminID),
+ zap.String("user_id", cmd.UserID),
+ zap.String("reason", cmd.Reason),
+ )
+
+ if strings.TrimSpace(cmd.UserID) == "" {
+ return nil, fmt.Errorf("用户ID不能为空")
+ }
+ if strings.TrimSpace(cmd.Reason) == "" {
+ return nil, fmt.Errorf("请填写操作原因")
+ }
+
+ cert, err := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID)
+ if err != nil {
+ return nil, fmt.Errorf("加载认证信息失败: %s", err.Error())
+ }
+
+ if err := cert.ResetForResignContract(cmd.AdminID, fmt.Sprintf("管理员触发重新认证:%s", cmd.Reason)); err != nil {
+ return nil, err
+ }
+
+ if err := s.aggregateService.SaveCertification(ctx, cert); err != nil {
+ return nil, fmt.Errorf("保存认证信息失败: %s", err.Error())
+ }
+
+ response := s.convertToResponse(cert)
+ s.logger.Info("管理员触发重新认证成功",
+ zap.String("admin_id", cmd.AdminID),
+ zap.String("user_id", cmd.UserID),
+ zap.String("certification_id", cert.ID),
+ zap.String("status", string(cert.Status)),
+ )
+ return response, nil
+}
+
// AdminListSubmitRecords 管理端分页查询企业信息提交记录
func (s *CertificationApplicationServiceImpl) AdminListSubmitRecords(
ctx context.Context,
@@ -884,8 +1064,10 @@ func (s *CertificationApplicationServiceImpl) AdminListSubmitRecords(
items := make([]*responses.AdminSubmitRecordItem, 0, len(result.Records))
for _, r := range result.Records {
certStatus := ""
+ signPlatform := ""
if cert, err := s.aggregateService.LoadCertificationByUserID(ctx, r.UserID); err == nil && cert != nil {
certStatus = string(cert.Status)
+ signPlatform = string(cert.SignPlatform)
}
items = append(items, &responses.AdminSubmitRecordItem{
ID: r.ID,
@@ -895,6 +1077,8 @@ func (s *CertificationApplicationServiceImpl) AdminListSubmitRecords(
LegalPersonName: r.LegalPersonName,
SubmitAt: r.SubmitAt,
Status: r.Status,
+ ManualReviewStatus: r.ManualReviewStatus,
+ SignPlatform: signPlatform,
CertificationStatus: certStatus,
})
}
@@ -918,8 +1102,10 @@ func (s *CertificationApplicationServiceImpl) AdminGetSubmitRecordByID(ctx conte
return nil, fmt.Errorf("获取提交记录失败: %w", err)
}
certStatus := ""
+ signPlatform := ""
if cert, loadErr := s.aggregateService.LoadCertificationByUserID(ctx, record.UserID); loadErr == nil && cert != nil {
certStatus = string(cert.Status)
+ signPlatform = string(cert.SignPlatform)
}
return &responses.AdminSubmitRecordDetail{
ID: record.ID,
@@ -943,6 +1129,9 @@ func (s *CertificationApplicationServiceImpl) AdminGetSubmitRecordByID(ctx conte
VerifiedAt: record.VerifiedAt,
FailedAt: record.FailedAt,
FailureReason: record.FailureReason,
+ ManualReviewStatus: record.ManualReviewStatus,
+ ManualReviewRemark: record.ManualReviewRemark,
+ SignPlatform: signPlatform,
CertificationStatus: certStatus,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
@@ -978,52 +1167,17 @@ func (s *CertificationApplicationServiceImpl) AdminApproveSubmitRecord(ctx conte
if cert.Status != enums.StatusInfoPendingReview {
return fmt.Errorf("认证状态不是待审核,当前: %s", enums.GetStatusName(cert.Status))
}
- enterpriseInfo := &certification_value_objects.EnterpriseInfo{
- CompanyName: record.CompanyName,
- UnifiedSocialCode: record.UnifiedSocialCode,
- LegalPersonName: record.LegalPersonName,
- LegalPersonID: record.LegalPersonID,
- LegalPersonPhone: record.LegalPersonPhone,
- EnterpriseAddress: record.EnterpriseAddress,
- }
- authReq := &esign.EnterpriseAuthRequest{
- CompanyName: enterpriseInfo.CompanyName,
- UnifiedSocialCode: enterpriseInfo.UnifiedSocialCode,
- LegalPersonName: enterpriseInfo.LegalPersonName,
- LegalPersonID: enterpriseInfo.LegalPersonID,
- TransactorName: enterpriseInfo.LegalPersonName,
- TransactorMobile: enterpriseInfo.LegalPersonPhone,
- TransactorID: enterpriseInfo.LegalPersonID,
- }
- authURL, alreadyVerified, err := s.generateEnterpriseAuthOrDetectVerified(ctx, authReq)
- if err != nil {
- return fmt.Errorf("生成企业认证链接失败: %w", err)
- }
- if alreadyVerified {
- if err := cert.ApproveEnterpriseInfoReview("", "", adminID); err != nil {
- return fmt.Errorf("更新认证状态失败: %w", err)
- }
- if err := s.completeEnterpriseVerification(ctx, cert, cert.UserID, record.CompanyName, record.LegalPersonName); err != nil {
- return err
- }
- record.MarkManualApproved(adminID, remark)
- if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
- return fmt.Errorf("保存企业信息提交记录失败: %w", err)
- }
- s.logger.Info("管理员审核通过企业信息", zap.String("record_id", recordID), zap.String("admin_id", adminID))
+ if record.ManualReviewStatus == "approved" {
return nil
}
- if err := cert.ApproveEnterpriseInfoReview(authURL.AuthShortURL, authURL.AuthFlowID, adminID); err != nil {
- return fmt.Errorf("更新认证状态失败: %w", err)
- }
- if err := s.aggregateService.SaveCertification(ctx, cert); err != nil {
- return fmt.Errorf("保存认证信息失败: %w", err)
- }
record.MarkManualApproved(adminID, remark)
if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
return fmt.Errorf("保存企业信息提交记录失败: %w", err)
}
- s.logger.Info("管理员审核通过企业信息", zap.String("record_id", recordID), zap.String("admin_id", adminID))
+ s.logger.Info("管理员审核通过企业信息,等待用户选择签署平台",
+ zap.String("record_id", recordID),
+ zap.String("admin_id", adminID),
+ zap.String("user_id", record.UserID))
return nil
}
@@ -1062,7 +1216,6 @@ func (s *CertificationApplicationServiceImpl) AdminTransitionCertificationStatus
}
switch cmd.TargetStatus {
case string(enums.StatusInfoSubmitted):
- // 审核通过:与 AdminApproveSubmitRecord 一致,推状态并生成企业认证链接
switch cert.Status {
case enums.StatusInfoSubmitted, enums.StatusEnterpriseVerified, enums.StatusContractApplied,
enums.StatusContractSigned, enums.StatusCompleted, enums.StatusContractRejected, enums.StatusContractExpired:
@@ -1071,45 +1224,13 @@ func (s *CertificationApplicationServiceImpl) AdminTransitionCertificationStatus
if cert.Status != enums.StatusInfoPendingReview {
return fmt.Errorf("认证状态不是待审核,当前: %s", enums.GetStatusName(cert.Status))
}
- enterpriseInfo := &certification_value_objects.EnterpriseInfo{
- CompanyName: record.CompanyName, UnifiedSocialCode: record.UnifiedSocialCode,
- LegalPersonName: record.LegalPersonName, LegalPersonID: record.LegalPersonID,
- LegalPersonPhone: record.LegalPersonPhone, EnterpriseAddress: record.EnterpriseAddress,
- }
- authReq := &esign.EnterpriseAuthRequest{
- CompanyName: enterpriseInfo.CompanyName, UnifiedSocialCode: enterpriseInfo.UnifiedSocialCode,
- LegalPersonName: enterpriseInfo.LegalPersonName, LegalPersonID: enterpriseInfo.LegalPersonID,
- TransactorName: enterpriseInfo.LegalPersonName, TransactorMobile: enterpriseInfo.LegalPersonPhone, TransactorID: enterpriseInfo.LegalPersonID,
- }
- authURL, alreadyVerified, err := s.generateEnterpriseAuthOrDetectVerified(ctx, authReq)
- if err != nil {
- return fmt.Errorf("生成企业认证链接失败: %w", err)
- }
- if alreadyVerified {
- if err := cert.ApproveEnterpriseInfoReview("", "", cmd.AdminID); err != nil {
- return fmt.Errorf("更新认证状态失败: %w", err)
- }
- if err := s.completeEnterpriseVerification(ctx, cert, cert.UserID, record.CompanyName, record.LegalPersonName); err != nil {
- return err
- }
- record.MarkManualApproved(cmd.AdminID, cmd.Remark)
- if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
- return fmt.Errorf("保存企业信息提交记录失败: %w", err)
- }
- s.logger.Info("管理端变更认证状态为通过", zap.String("user_id", cmd.UserID), zap.String("admin_id", cmd.AdminID))
- return nil
- }
- if err := cert.ApproveEnterpriseInfoReview(authURL.AuthShortURL, authURL.AuthFlowID, cmd.AdminID); err != nil {
- return fmt.Errorf("更新认证状态失败: %w", err)
- }
- if err := s.aggregateService.SaveCertification(ctx, cert); err != nil {
- return fmt.Errorf("保存认证信息失败: %w", err)
- }
record.MarkManualApproved(cmd.AdminID, cmd.Remark)
if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
return fmt.Errorf("保存企业信息提交记录失败: %w", err)
}
- s.logger.Info("管理端变更认证状态为通过", zap.String("user_id", cmd.UserID), zap.String("admin_id", cmd.AdminID))
+ s.logger.Info("管理端审核通过,等待用户选择签署平台",
+ zap.String("user_id", cmd.UserID),
+ zap.String("admin_id", cmd.AdminID))
return nil
case string(enums.StatusInfoRejected):
if cmd.Remark == "" {
@@ -1187,6 +1308,7 @@ func (s *CertificationApplicationServiceImpl) convertToResponse(cert *entities.C
UserID: cert.UserID,
Status: cert.Status,
StatusName: enums.GetStatusName(cert.Status),
+ SignPlatform: string(cert.SignPlatform),
Progress: cert.GetProgress(),
CreatedAt: cert.CreatedAt,
UpdatedAt: cert.UpdatedAt,
@@ -1285,6 +1407,37 @@ func isEnterpriseAlreadyRealnamedErr(err error) bool {
return strings.Contains(msg, "企业用户已实名") || strings.Contains(msg, "已实名")
}
+// isEnterpriseAlreadyAuthorizedErr 法大大等平台:企业已对本应用授权,无需再获取授权链接(如 code=210002)
+func isEnterpriseAlreadyAuthorizedErr(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ return strings.Contains(msg, "210002") ||
+ strings.Contains(msg, "该企业已授权") ||
+ strings.Contains(msg, "无需重复操作") ||
+ strings.Contains(msg, "已存在授权")
+}
+
+func firstNonEmptyStr(values ...string) string {
+ for _, v := range values {
+ if strings.TrimSpace(v) != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func (s *CertificationApplicationServiceImpl) contractSubjectPrefix(cert *entities.Certification) string {
+ if cert.ResolvedSignPlatform() == enums.SignPlatformFadada && s.config != nil && s.config.Fadada.Contract.Name != "" {
+ return s.config.Fadada.Contract.Name
+ }
+ if s.config != nil && s.config.Esign.Contract.Name != "" {
+ return s.config.Esign.Contract.Name
+ }
+ return "天远数据API合作协议"
+}
+
// validateApplyContractCommand 验证申请合同命令
func (s *CertificationApplicationServiceImpl) validateApplyContractCommand(cmd *commands.ApplyContractCommand) error {
if cmd.UserID == "" {
@@ -1325,27 +1478,40 @@ func pickEnterpriseString(primary string, record *entities.EnterpriseInfoSubmitR
// generateContractAndSignURL 生成合同和签署链接
func (s *CertificationApplicationServiceImpl) generateContractAndSignURL(ctx context.Context, cert *entities.Certification, enterpriseInfo *user_entities.EnterpriseInfo) (*certification_value_objects.ContractInfo, error) {
- // 发起签署流程
- signFlowID, err := s.esignClient.CreateSignFlow(&esign.CreateSignFlowRequest{
- FileID: cert.ContractFileID,
- SignerAccount: enterpriseInfo.UnifiedSocialCode,
- SignerName: enterpriseInfo.CompanyName,
- TransactorPhone: enterpriseInfo.LegalPersonPhone,
- TransactorName: enterpriseInfo.LegalPersonName,
- TransactorIDCardNum: enterpriseInfo.LegalPersonID,
- })
+ provider, err := s.resolveProvider(cert)
if err != nil {
- return nil, fmt.Errorf("生成合同失败: %s", err.Error())
+ return nil, err
}
-
- _, shortUrl, err := s.esignClient.GetSignURL(signFlowID, enterpriseInfo.LegalPersonPhone, enterpriseInfo.CompanyName)
+ record, _ := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID)
+ repName := enterpriseInfo.LegalPersonName
+ address := enterpriseInfo.EnterpriseAddress
+ if record != nil {
+ repName = pickAuthorizedRepName(record, enterpriseInfo.LegalPersonName)
+ if address == "" {
+ address = record.EnterpriseAddress
+ }
+ }
+ req := &ports.SignFlowCreateRequest{
+ Subject: s.contractSubjectPrefix(cert) + "-" + enterpriseInfo.CompanyName,
+ FileID: cert.ContractFileID,
+ PartyAName: enterpriseInfo.CompanyName,
+ PartyAUSCC: enterpriseInfo.UnifiedSocialCode,
+ TransactorName: enterpriseInfo.LegalPersonName,
+ TransactorMobile: enterpriseInfo.LegalPersonPhone,
+ TransactorID: enterpriseInfo.LegalPersonID,
+ TransReferenceID: cert.ID,
+ Fill: ensureContractFill(cert, enterpriseInfo.CompanyName, enterpriseInfo.UnifiedSocialCode, address, repName),
+ }
+ signRes, err := provider.CreateSignFlow(ctx, req)
if err != nil {
- return nil, fmt.Errorf("获取签署链接失败: %s", err.Error())
+ return nil, fmt.Errorf("生成签署流程失败: %s", err.Error())
}
+ embedURL := firstNonEmptyStr(signRes.SignURL, signRes.ShortURL)
return &certification_value_objects.ContractInfo{
- ContractFileID: cert.ContractFileID,
- EsignFlowID: signFlowID,
- ContractSignURL: shortUrl,
+ ContractFileID: cert.ContractFileID,
+ EsignFlowID: signRes.SignFlowID,
+ ContractSignURL: embedURL,
+ ContractSignShortURL: strings.TrimSpace(signRes.ShortURL),
}, nil
}
@@ -1396,13 +1562,20 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification(
s.logger.Info("企业信息已保存到用户域", zap.String("user_id", userID))
}
- // 生成合同
err = s.generateAndAddContractFile(ctx, cert, record.CompanyName, record.UnifiedSocialCode, record.EnterpriseAddress, pickAuthorizedRepName(record, record.LegalPersonName))
if err != nil {
return err
}
s.logger.Info("完成企业认证-步骤3-合同文件生成并写入认证成功", zap.String("user_id", userID))
+ if err := s.createSignTaskAfterEnterpriseVerified(ctx, cert, record); err != nil {
+ s.logger.Error("企业认证后预创建签署任务失败", zap.Error(err), zap.String("user_id", userID))
+ return fmt.Errorf("创建签署任务失败: %w", err)
+ }
+ s.logger.Info("完成企业认证-步骤3b-签署任务已创建(乙方免签)",
+ zap.String("user_id", userID),
+ zap.String("esign_flow_id", cert.EsignFlowID))
+
// 保存认证信息
err = s.aggregateService.SaveCertification(ctx, cert)
if err != nil {
@@ -1414,6 +1587,45 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification(
return nil
}
+// createSignTaskAfterEnterpriseVerified 认证完成后创建签署任务并启动(乙方免签),不推进到 contract_applied
+func (s *CertificationApplicationServiceImpl) createSignTaskAfterEnterpriseVerified(
+ ctx context.Context,
+ cert *entities.Certification,
+ record *entities.EnterpriseInfoSubmitRecord,
+) error {
+ if cert == nil || record == nil {
+ return fmt.Errorf("认证或企业信息为空")
+ }
+ if strings.TrimSpace(cert.EsignFlowID) != "" {
+ return nil
+ }
+ provider, err := s.resolveProvider(cert)
+ if err != nil {
+ return err
+ }
+ repName := pickAuthorizedRepName(record, record.LegalPersonName)
+ address := record.EnterpriseAddress
+ req := &ports.SignFlowCreateRequest{
+ Subject: s.contractSubjectPrefix(cert) + "-" + record.CompanyName,
+ FileID: cert.ContractFileID,
+ PartyAName: record.CompanyName,
+ PartyAUSCC: record.UnifiedSocialCode,
+ TransactorName: record.LegalPersonName,
+ TransactorMobile: record.LegalPersonPhone,
+ TransactorID: record.LegalPersonID,
+ TransReferenceID: cert.ID,
+ Fill: ensureContractFill(cert, record.CompanyName, record.UnifiedSocialCode, address, repName),
+ SkipActorURL: true,
+ }
+ signRes, err := provider.CreateSignFlow(ctx, req)
+ if err != nil {
+ return err
+ }
+ storeURL := firstNonEmptyStr(signRes.ShortURL, signRes.SignURL)
+ cert.BindSignTask(signRes.SignFlowID, storeURL)
+ return nil
+}
+
// generateAndAddContractFile 生成并添加合同文件的公共方法
func (s *CertificationApplicationServiceImpl) generateAndAddContractFile(
ctx context.Context,
@@ -1437,25 +1649,22 @@ func (s *CertificationApplicationServiceImpl) generateAndAddContractFile(
signDate := time.Now().Format("2006年01月02日")
if strings.TrimSpace(unifiedSocialCode) == "" {
- s.logger.Warn("合同模板控件 jftyshxydm:统一社会信用代码为空;若 PDF 上该处空白,请核对 enterprise_infos.unified_social_code、提交记录或 e签宝模板控件 componentKey 是否与代码键名一致",
+ s.logger.Warn("合同模板统一社会信用代码为空",
zap.String("user_id", cert.UserID))
}
- // 控件 key 须与 e签宝控制台该控件「控件编码/componentKey」完全一致(区分大小写)
- fileComponent := map[string]string{
- "jfqym": companyName,
- "jfqym2": companyName,
- "jfsqdb": authorizedRepName,
- "jftyshxydm": unifiedSocialCode,
- "jflxdz": enterpriseAddress,
- // 甲方
- "xybh": agreementNo,
- "qsrq1": signDate,
- "qsrq3": signDate,
- // 乙方
- "qsrq2": signDate,
+ provider, err := s.resolveProvider(cert)
+ if err != nil {
+ return err
}
- fillTemplateResp, err := s.esignClient.FillTemplate(fileComponent)
+ fillTemplateResp, err := provider.GenerateContractFile(ctx, &ports.ContractGenerateRequest{
+ AgreementNo: agreementNo,
+ CompanyName: companyName,
+ UnifiedSocialCode: unifiedSocialCode,
+ EnterpriseAddress: enterpriseAddress,
+ AuthorizedRepName: authorizedRepName,
+ SignDate: signDate,
+ })
if err != nil {
s.logger.Error("生成合同失败", zap.Error(err))
return fmt.Errorf("生成合同失败: %s", err.Error())
@@ -1463,8 +1672,9 @@ func (s *CertificationApplicationServiceImpl) generateAndAddContractFile(
s.logger.Info("合同生成-步骤1-模板填充成功",
zap.String("user_id", cert.UserID),
zap.String("file_id", fillTemplateResp.FileID),
- zap.String("contract_code", agreementNo))
- err = cert.AddContractFileID(fillTemplateResp.FileID, fillTemplateResp.FileDownloadUrl)
+ zap.String("contract_code", agreementNo),
+ zap.String("sign_platform", string(cert.ResolvedSignPlatform())))
+ err = cert.AddContractFileID(fillTemplateResp.FileID, fillTemplateResp.FileDownloadURL)
if err != nil {
s.logger.Error("加入合同文件ID链接失败", zap.Error(err))
return fmt.Errorf("加入合同文件ID链接失败: %s", err.Error())
@@ -1523,13 +1733,19 @@ func (s *CertificationApplicationServiceImpl) checkAndCompleteEnterpriseVerifica
if err != nil {
return fmt.Errorf("查找企业信息失败: %w", err)
}
- identity, err := s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{
- OrgName: record.CompanyName,
+ provider, err := s.resolveProvider(cert)
+ if err != nil {
+ return err
+ }
+ verified, err := provider.QueryOrgVerified(ctx, &ports.OrgIdentityQuery{
+ OrgName: record.CompanyName,
+ OrgIdentNo: record.UnifiedSocialCode,
})
if err != nil {
s.logger.Error("查询企业认证信息失败", zap.Error(err))
+ return nil
}
- if identity != nil && identity.Data.RealnameStatus == 1 {
+ if verified {
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
return s.completeEnterpriseVerification(txCtx, cert, cert.UserID, record.CompanyName, record.LegalPersonName)
})
@@ -1547,11 +1763,15 @@ func (s *CertificationApplicationServiceImpl) checkAndUpdateSignStatus(ctx conte
if cert.Status != enums.StatusContractApplied {
return fmt.Errorf("认证状态不正确")
}
- detail, err := s.esignClient.QuerySignFlowDetail(cert.EsignFlowID)
+ provider, err := s.resolveProvider(cert)
+ if err != nil {
+ return err
+ }
+ detail, err := provider.QuerySignStatus(txCtx, cert.EsignFlowID)
if err != nil {
return fmt.Errorf("查询签署流程详情失败: %s", err.Error())
}
- if detail.Data.SignFlowStatus == 2 {
+ if detail.Completed {
err = cert.SignSuccess()
if err != nil {
return fmt.Errorf("合同签署成功失败: %s", err.Error())
@@ -1560,21 +1780,19 @@ func (s *CertificationApplicationServiceImpl) checkAndUpdateSignStatus(ctx conte
if err != nil {
return fmt.Errorf("完成认证失败: %s", err.Error())
}
- // 同步合同信息到用户域
err = s.handleContractAfterSignComplete(txCtx, cert)
if err != nil {
s.logger.Error("同步合同信息到用户域失败", zap.Error(err))
return fmt.Errorf("同步合同信息失败: %s", err.Error())
}
-
reason = "合同签署成功"
- } else if detail.Data.SignFlowStatus == 7 {
- err = cert.ContractRejection(detail.Data.SignFlowDescription)
+ } else if detail.Rejected {
+ err = cert.ContractRejection(detail.Message)
if err != nil {
return fmt.Errorf("合同签署失败: %s", err.Error())
}
reason = "合同签署拒签"
- } else if detail.Data.SignFlowStatus == 5 {
+ } else if detail.Expired {
err = cert.ContractExpiration()
if err != nil {
return fmt.Errorf("合同签署过期失败: %s", err.Error())
@@ -1583,7 +1801,7 @@ func (s *CertificationApplicationServiceImpl) checkAndUpdateSignStatus(ctx conte
} else {
reason = "合同签署中"
}
- err = s.aggregateService.SaveCertification(ctx, cert)
+ err = s.aggregateService.SaveCertification(txCtx, cert)
if err != nil {
return fmt.Errorf("保存认证信息失败: %s", err.Error())
}
@@ -1606,40 +1824,43 @@ func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ct
return fmt.Errorf("用户企业信息不存在")
}
- // 1. 获取所有已签署合同文件信息
- downloadSignedFileResponse, err := s.esignClient.DownloadSignedFile(cert.EsignFlowID)
+ // 获取已签署合同文件
+ provider, err := s.resolveProvider(cert)
+ if err != nil {
+ return err
+ }
+ files, err := provider.DownloadSignedFiles(ctx, cert.EsignFlowID)
if err != nil {
return fmt.Errorf("下载已签署文件失败: %s", err.Error())
}
- files := downloadSignedFileResponse.Data.Files
if len(files) == 0 {
return fmt.Errorf("未获取到已签署合同文件")
}
- for _, file := range files {
- fileUrl := file.DownloadUrl
- fileName := file.FileName
- fileId := file.FileId
+ for i, file := range files {
+ fileUrl := file.DownloadURL
+ fileName := fmt.Sprintf("合作协议-%s-%d.pdf", cert.ContractCode, i+1)
+ fileId := firstNonEmptyStr(file.DownloadID, cert.EsignFlowID)
s.logger.Info("下载已签署文件准备", zap.String("file_url", fileUrl), zap.String("file_name", fileName))
- // 2. 下载文件内容
fileBytes, err := s.downloadFileContent(ctx, fileUrl)
if err != nil {
s.logger.Error("下载合同文件内容失败", zap.String("file_name", fileName), zap.Error(err))
continue
}
- // 3. 上传到七牛云
uploadResult, err := s.qiniuStorageService.UploadFile(ctx, fileBytes, fileName)
if err != nil {
s.logger.Error("上传合同文件到七牛云失败", zap.String("file_name", fileName), zap.Error(err))
continue
}
qiniuURL := uploadResult.URL
-
s.logger.Info("合同文件已上传七牛云", zap.String("file_name", fileName), zap.String("qiniu_url", qiniuURL))
- // 4. 保存到合同聚合根(复用认证阶段生成的合同编号;旧数据无编号时退回自动生成)
+ if i == 0 {
+ cert.ContractURL = qiniuURL
+ }
+
if strings.TrimSpace(cert.ContractCode) != "" {
_, err = s.contractAggregateService.CreateContractWithCode(
ctx,
@@ -1663,6 +1884,17 @@ func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ct
)
}
if err != nil {
+ // 重试场景:合同可能已落库,视为成功继续后续激活
+ if strings.Contains(err.Error(), "合同文件ID已存在") {
+ s.logger.Info("合同文件已存在,跳过重复保存",
+ zap.String("file_name", fileName),
+ zap.String("file_id", fileId),
+ )
+ if i == 0 && cert.ContractURL == "" {
+ cert.ContractURL = qiniuURL
+ }
+ continue
+ }
s.logger.Error("保存合同信息到聚合根失败", zap.String("file_name", fileName), zap.Error(err))
continue
}
@@ -1670,7 +1902,6 @@ func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ct
s.logger.Info("合同信息已保存到聚合根", zap.String("file_name", fileName), zap.String("qiniu_url", qiniuURL))
}
- // 合同签署完成后的基础激活流程
return s.completeUserActivationWithoutContract(ctx, cert)
}
@@ -1717,11 +1948,15 @@ func (s *CertificationApplicationServiceImpl) AddStatusMetadata(ctx context.Cont
metadata["contract_url"] = contracts[0].ContractFileURL
}
}
-
+ metadata = s.appendPlatformSelectMetadata(ctx, cert, metadata)
+ if need, _ := metadata["need_select_platform"].(bool); need {
+ metadata["next_action"] = "请选择签署平台以继续企业认证"
+ }
return metadata, nil
}
// completeUserActivationWithoutContract 创建钱包、API用户并在用户域标记完成认证(不依赖合同信息)
+// 钱包与 API 用户已存在时幂等跳过,避免重复创建导致事务中止
func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContract(ctx context.Context, cert *entities.Certification) error {
// 创建钱包:子账号认证通过后不赠送初始余额(初始额度为0)
isSubordinate := false
@@ -1737,16 +1972,24 @@ func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContr
if _, err := s.walletRepo.GetByUserID(ctx, cert.UserID); err != nil {
zeroWallet := finance_entities.NewWallet(cert.UserID, decimal.Zero)
if _, createErr := s.walletRepo.Create(ctx, *zeroWallet); createErr != nil {
- s.logger.Error("创建子账号钱包失败", zap.String("user_id", cert.UserID), zap.Error(createErr))
+ // 并发下可能已创建成功,再查一次;仍失败则返回,避免事务中止后误报
+ if existing, getErr := s.walletRepo.GetByUserID(ctx, cert.UserID); getErr == nil && existing != nil {
+ s.logger.Info("子账号钱包已存在,跳过创建", zap.String("user_id", cert.UserID))
+ } else {
+ s.logger.Error("创建子账号钱包失败", zap.String("user_id", cert.UserID), zap.Error(createErr))
+ return fmt.Errorf("创建子账号钱包失败: %w", createErr)
+ }
}
}
} else if _, err := s.walletAggregateService.CreateWallet(ctx, cert.UserID); err != nil {
s.logger.Error("创建钱包失败", zap.String("user_id", cert.UserID), zap.Error(err))
+ return fmt.Errorf("创建钱包失败: %w", err)
}
- // 创建API用户
+ // 创建API用户(已存在则幂等成功,不重复插入)
if err := s.apiUserAggregateService.CreateApiUser(ctx, cert.UserID); err != nil {
s.logger.Error("创建API用户失败", zap.String("user_id", cert.UserID), zap.Error(err))
+ return fmt.Errorf("创建API用户失败: %w", err)
}
// 标记用户域完成认证
diff --git a/internal/application/certification/dto/commands/certification_commands.go b/internal/application/certification/dto/commands/certification_commands.go
index 99fd29e..a008aaf 100644
--- a/internal/application/certification/dto/commands/certification_commands.go
+++ b/internal/application/certification/dto/commands/certification_commands.go
@@ -85,6 +85,14 @@ type AdminCompleteCertificationCommand struct {
// 备注信息,用于记录后台操作原因
Reason string `json:"reason" validate:"required"`
}
+
+// AdminResetForResignContractCommand 管理员触发用户重新认证(回退到提交企业信息,保留钱包/API Key/历史记录)
+type AdminResetForResignContractCommand struct {
+ AdminID string `json:"-"`
+ UserID string `json:"user_id" validate:"required"`
+ Reason string `json:"reason" validate:"required,min=2,max=500"`
+}
+
// ForceTransitionStatusCommand 强制状态转换命令(管理员)
type ForceTransitionStatusCommand struct {
CertificationID string `json:"certification_id" validate:"required"`
@@ -102,6 +110,12 @@ type AdminTransitionCertificationStatusCommand struct {
Remark string `json:"remark"`
}
+// SelectSignPlatformCommand 用户选择签署平台
+type SelectSignPlatformCommand struct {
+ UserID string `json:"-"`
+ SignPlatform string `json:"sign_platform" binding:"required"`
+}
+
// SubmitEnterpriseInfoCommand 提交企业信息命令
type SubmitEnterpriseInfoCommand struct {
UserID string `json:"-" comment:"用户唯一标识,从JWT token获取,不在JSON中暴露"`
diff --git a/internal/application/certification/dto/responses/certification_responses.go b/internal/application/certification/dto/responses/certification_responses.go
index 2d2c9c9..5ad072e 100644
--- a/internal/application/certification/dto/responses/certification_responses.go
+++ b/internal/application/certification/dto/responses/certification_responses.go
@@ -9,11 +9,12 @@ import (
// CertificationResponse 认证响应
type CertificationResponse struct {
- ID string `json:"id"`
- UserID string `json:"user_id"`
- Status enums.CertificationStatus `json:"status"`
- StatusName string `json:"status_name"`
- Progress int `json:"progress"`
+ ID string `json:"id"`
+ UserID string `json:"user_id"`
+ Status enums.CertificationStatus `json:"status"`
+ StatusName string `json:"status_name"`
+ SignPlatform string `json:"sign_platform,omitempty"`
+ Progress int `json:"progress"`
// 企业信息
EnterpriseInfo *value_objects.EnterpriseInfo `json:"enterprise_info,omitempty"`
@@ -73,12 +74,24 @@ type CertificationListResponse struct {
// ContractSignUrlResponse 合同签署URL响应
type ContractSignUrlResponse struct {
- CertificationID string `json:"certification_id"`
- ContractSignURL string `json:"contract_sign_url"`
- ContractURL string `json:"contract_url,omitempty"`
- ExpireAt *time.Time `json:"expire_at,omitempty"`
- NextAction string `json:"next_action"`
- Message string `json:"message"`
+ CertificationID string `json:"certification_id"`
+ // ContractSignURL iframe 可用的签署长链(法大大 EmbedURL,约 10 分钟/单次)
+ ContractSignURL string `json:"contract_sign_url"`
+ // ContractSignShortURL 短链(不可 iframe,一年有效)
+ ContractSignShortURL string `json:"contract_sign_short_url,omitempty"`
+ ContractURL string `json:"contract_url,omitempty"`
+ ExpireAt *time.Time `json:"expire_at,omitempty"`
+ NextAction string `json:"next_action"`
+ Message string `json:"message"`
+}
+
+// ContractPreviewUrlResponse 合同预览链接响应(法大大 get-preview-url)
+type ContractPreviewUrlResponse struct {
+ CertificationID string `json:"certification_id"`
+ PreviewURL string `json:"preview_url"`
+ PreviewMode string `json:"preview_mode"`
+ SignFlowID string `json:"sign_flow_id,omitempty"`
+ Message string `json:"message,omitempty"`
}
// SystemMonitoringResponse 系统监控响应
@@ -119,6 +132,8 @@ type AdminSubmitRecordItem struct {
LegalPersonName string `json:"legal_person_name"`
SubmitAt time.Time `json:"submit_at"`
Status string `json:"status"`
+ ManualReviewStatus string `json:"manual_review_status,omitempty"`
+ SignPlatform string `json:"sign_platform,omitempty"`
CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准:info_pending_review/info_submitted/info_rejected 等
}
@@ -145,6 +160,9 @@ type AdminSubmitRecordDetail struct {
VerifiedAt *time.Time `json:"verified_at,omitempty"`
FailedAt *time.Time `json:"failed_at,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
+ ManualReviewStatus string `json:"manual_review_status,omitempty"`
+ ManualReviewRemark string `json:"manual_review_remark,omitempty"`
+ SignPlatform string `json:"sign_platform,omitempty"`
CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -178,22 +196,36 @@ func NewCertificationListResponse(items []*CertificationResponse, total int64, p
}
// NewContractSignUrlResponse 创建合同签署URL响应
-func NewContractSignUrlResponse(certificationID, signURL, contractURL, nextAction, message string) *ContractSignUrlResponse {
+// signURL 为 iframe 长链;shortURL 为短链(可选)
+func NewContractSignUrlResponse(certificationID, signURL, shortURL, contractURL, nextAction, message string) *ContractSignUrlResponse {
response := &ContractSignUrlResponse{
- CertificationID: certificationID,
- ContractSignURL: signURL,
- ContractURL: contractURL,
- NextAction: nextAction,
- Message: message,
+ CertificationID: certificationID,
+ ContractSignURL: signURL,
+ ContractSignShortURL: shortURL,
+ ContractURL: contractURL,
+ NextAction: nextAction,
+ Message: message,
}
- // 设置过期时间(默认24小时)
- expireAt := time.Now().Add(24 * time.Hour)
+ expireAt := time.Now().Add(10 * time.Minute)
response.ExpireAt = &expireAt
return response
}
+// SignPlatformItem 可选签署平台
+type SignPlatformItem struct {
+ Platform string `json:"platform"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Available bool `json:"available"`
+}
+
+// SignPlatformsResponse 签署平台列表响应
+type SignPlatformsResponse struct {
+ Platforms []*SignPlatformItem `json:"platforms"`
+}
+
// NewSystemAlert 创建系统警告
func NewSystemAlert(level, alertType, message, metric string, value, threshold interface{}) *SystemAlert {
return &SystemAlert{
diff --git a/internal/application/certification/platform_flow.go b/internal/application/certification/platform_flow.go
new file mode 100644
index 0000000..7d69868
--- /dev/null
+++ b/internal/application/certification/platform_flow.go
@@ -0,0 +1,280 @@
+package certification
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "tyapi-server/internal/application/certification/dto/commands"
+ "tyapi-server/internal/application/certification/dto/responses"
+ "tyapi-server/internal/domains/certification/entities"
+ "tyapi-server/internal/domains/certification/enums"
+ "tyapi-server/internal/domains/certification/ports"
+ user_entities "tyapi-server/internal/domains/user/entities"
+
+ "go.uber.org/zap"
+)
+
+// ListSignPlatforms 可选签署平台列表
+func (s *CertificationApplicationServiceImpl) ListSignPlatforms(ctx context.Context) (*responses.SignPlatformsResponse, error) {
+ _ = ctx
+ items := make([]*responses.SignPlatformItem, 0, 2)
+ for _, p := range s.platformRegistry.List() {
+ platform := p.Platform()
+ item := &responses.SignPlatformItem{
+ Platform: string(platform),
+ Name: enums.GetSignPlatformName(platform),
+ Available: true,
+ Description: "",
+ }
+ switch platform {
+ case enums.SignPlatformEsign:
+ item.Description = "通过 e签宝 完成企业实名认证与合同签署"
+ case enums.SignPlatformFadada:
+ item.Description = "通过法大大完成企业实名认证与合同签署"
+ }
+ items = append(items, item)
+ }
+ return &responses.SignPlatformsResponse{Platforms: items}, nil
+}
+
+// SelectSignPlatform 用户选择签署平台并生成企业认证链接
+func (s *CertificationApplicationServiceImpl) SelectSignPlatform(
+ ctx context.Context,
+ cmd *commands.SelectSignPlatformCommand,
+) (*responses.CertificationResponse, error) {
+ if cmd == nil || cmd.UserID == "" {
+ return nil, fmt.Errorf("用户ID不能为空")
+ }
+ platform := enums.SignPlatform(strings.TrimSpace(cmd.SignPlatform))
+ if !enums.IsValidSignPlatform(platform) {
+ return nil, fmt.Errorf("无效的签署平台: %s", cmd.SignPlatform)
+ }
+
+ cert, err := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID)
+ if err != nil {
+ return nil, fmt.Errorf("加载认证信息失败: %w", err)
+ }
+
+ record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cmd.UserID)
+ if err != nil || record == nil {
+ return nil, fmt.Errorf("未找到企业信息提交记录")
+ }
+ if record.ManualReviewStatus != "approved" {
+ return nil, fmt.Errorf("请先等待管理员审核通过后再选择签署平台")
+ }
+
+ // 已选择且已有认证链接:幂等返回
+ if cert.SignPlatform == platform && cert.Status == enums.StatusInfoSubmitted && cert.AuthURL != "" {
+ resp := s.convertToResponse(cert)
+ meta, _ := s.AddStatusMetadata(ctx, cert)
+ resp.Metadata = meta
+ return resp, nil
+ }
+ if cert.SignPlatform != "" && cert.SignPlatform != platform {
+ return nil, fmt.Errorf("签署平台已选择为 %s,不可更改", enums.GetSignPlatformName(cert.SignPlatform))
+ }
+ if cert.Status != enums.StatusInfoPendingReview && !(cert.Status == enums.StatusInfoSubmitted && cert.AuthURL == "") {
+ return nil, fmt.Errorf("当前状态不允许选择签署平台: %s", enums.GetStatusName(cert.Status))
+ }
+
+ if err := cert.SetSignPlatform(platform); err != nil {
+ return nil, err
+ }
+
+ provider, err := s.platformRegistry.Get(platform)
+ if err != nil {
+ return nil, err
+ }
+
+ authReq := &ports.EnterpriseAuthRequest{
+ ClientCorpID: record.UnifiedSocialCode,
+ ClientUserID: record.LegalPersonID,
+ CompanyName: record.CompanyName,
+ UnifiedSocialCode: record.UnifiedSocialCode,
+ LegalPersonName: record.LegalPersonName,
+ LegalPersonID: record.LegalPersonID,
+ TransactorName: record.LegalPersonName,
+ TransactorMobile: record.LegalPersonPhone,
+ TransactorID: record.LegalPersonID,
+ }
+
+ authRes, alreadyVerified, err := s.generateEnterpriseAuthOrDetectVerifiedWithProvider(ctx, provider, authReq)
+ if err != nil {
+ return nil, fmt.Errorf("生成企业认证链接失败: %w", err)
+ }
+
+ if alreadyVerified {
+ // 已授权/已实名:跳过授权页,直接推进到 enterprise_verified
+ err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
+ clientCorpID := strings.TrimSpace(record.UnifiedSocialCode)
+ if cert.Status == enums.StatusInfoPendingReview {
+ if err := cert.ApproveEnterpriseInfoReview("", clientCorpID, cmd.UserID); err != nil {
+ return err
+ }
+ } else if cert.Status == enums.StatusInfoSubmitted {
+ if clientCorpID != "" && cert.AuthFlowID == "" {
+ cert.AuthFlowID = clientCorpID
+ }
+ } else {
+ return fmt.Errorf("当前状态不允许完成已授权企业认证: %s", enums.GetStatusName(cert.Status))
+ }
+ return s.completeEnterpriseVerification(txCtx, cert, cert.UserID, record.CompanyName, record.LegalPersonName)
+ })
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ authURL := authRes.AuthShortURL
+ if authURL == "" {
+ authURL = authRes.AuthURL
+ }
+ if err := cert.ApproveEnterpriseInfoReview(authURL, authRes.AuthFlowID, cmd.UserID); err != nil {
+ return nil, err
+ }
+ if err := s.aggregateService.SaveCertification(ctx, cert); err != nil {
+ return nil, fmt.Errorf("保存认证信息失败: %w", err)
+ }
+ }
+
+ s.logger.Info("用户已选择签署平台",
+ zap.String("user_id", cmd.UserID),
+ zap.String("sign_platform", string(platform)),
+ zap.Bool("already_verified", alreadyVerified))
+
+ resp := s.convertToResponse(cert)
+ meta, _ := s.AddStatusMetadata(ctx, cert)
+ resp.Metadata = meta
+ return resp, nil
+}
+
+// HandleFadadaCallback 法大大回调
+func (s *CertificationApplicationServiceImpl) HandleFadadaCallback(ctx context.Context, headers map[string]string, body []byte) error {
+ provider, err := s.platformRegistry.Get(enums.SignPlatformFadada)
+ if err != nil {
+ return err
+ }
+ if err := provider.VerifyCallback(ctx, headers, body); err != nil {
+ return fmt.Errorf("法大大回调验签失败: %w", err)
+ }
+ ev, err := provider.ParseCallback(ctx, headers, body)
+ if err != nil {
+ return fmt.Errorf("法大大回调解析失败: %w", err)
+ }
+ s.logger.Info("收到法大大回调",
+ zap.String("event", ev.Event),
+ zap.Bool("auth_passed", ev.AuthPassed),
+ zap.Bool("sign_completed", ev.SignCompleted),
+ zap.String("sign_flow_id", ev.SignFlowID))
+
+ // 认证完成主要依赖 ConfirmAuth / details 轮询;签署完成按任务 ID 兜底推进
+ if ev.SignCompleted && ev.SignFlowID != "" {
+ cert, err := s.queryRepository.FindByEsignFlowID(ctx, ev.SignFlowID)
+ if err == nil && cert != nil && cert.Status == enums.StatusContractApplied {
+ _, _ = s.checkAndUpdateSignStatus(ctx, cert)
+ }
+ }
+ return nil
+}
+
+func (s *CertificationApplicationServiceImpl) resolveProvider(cert *entities.Certification) (ports.SignPlatformProvider, error) {
+ return s.platformRegistry.Get(cert.ResolvedSignPlatform())
+}
+
+func (s *CertificationApplicationServiceImpl) generateEnterpriseAuthOrDetectVerifiedWithProvider(
+ ctx context.Context,
+ provider ports.SignPlatformProvider,
+ authReq *ports.EnterpriseAuthRequest,
+) (*ports.AuthLinkResult, bool, error) {
+ authRes, err := provider.GenerateEnterpriseAuth(ctx, authReq)
+ if err == nil {
+ return authRes, false, nil
+ }
+
+ // 法大大:企业已对本应用授权(210002)= 授权已完成,直接跳过授权页
+ if isEnterpriseAlreadyAuthorizedErr(err) {
+ s.logger.Info("第三方返回企业已授权,跳过认证链接并视为认证完成",
+ zap.String("company_name", authReq.CompanyName),
+ zap.String("uscc", authReq.UnifiedSocialCode),
+ zap.String("platform", string(provider.Platform())),
+ zap.Error(err))
+ return nil, true, nil
+ }
+
+ // e签宝等:提示已实名时再查一次实名状态确认
+ if !isEnterpriseAlreadyRealnamedErr(err) && !strings.Contains(strings.ToLower(err.Error()), "identified") {
+ return nil, false, err
+ }
+ ok, qErr := provider.QueryOrgVerified(ctx, &ports.OrgIdentityQuery{
+ OrgName: authReq.CompanyName,
+ OrgIdentNo: authReq.UnifiedSocialCode,
+ })
+ if qErr != nil {
+ return nil, false, fmt.Errorf("%v; 且查询实名状态失败: %w", err, qErr)
+ }
+ if ok {
+ s.logger.Info("第三方返回企业已实名,跳过认证链接并视为认证完成",
+ zap.String("company_name", authReq.CompanyName),
+ zap.String("platform", string(provider.Platform())))
+ return nil, true, nil
+ }
+ return nil, false, err
+}
+
+func (s *CertificationApplicationServiceImpl) appendPlatformSelectMetadata(
+ ctx context.Context,
+ cert *entities.Certification,
+ metadata map[string]interface{},
+) map[string]interface{} {
+ if metadata == nil {
+ metadata = map[string]interface{}{}
+ }
+ record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID)
+ if err != nil || record == nil {
+ return metadata
+ }
+ metadata["manual_review_status"] = record.ManualReviewStatus
+ if record.ManualReviewStatus == "approved" &&
+ cert.Status == enums.StatusInfoPendingReview &&
+ (cert.SignPlatform == "" || cert.AuthURL == "") {
+ metadata["need_select_platform"] = true
+ // 本期前端仅开放法大大;e签宝后端保留供存量链路
+ platforms := make([]map[string]string, 0, 1)
+ for _, p := range s.platformRegistry.List() {
+ if p.Platform() != enums.SignPlatformFadada {
+ continue
+ }
+ platforms = append(platforms, map[string]string{
+ "platform": string(p.Platform()),
+ "name": enums.GetSignPlatformName(p.Platform()),
+ })
+ }
+ metadata["available_platforms"] = platforms
+ if len(platforms) == 1 {
+ metadata["auto_select_platform"] = platforms[0]["platform"]
+ }
+ metadata["next_action"] = "请选择签署平台以继续企业认证"
+ }
+ if cert.SignPlatform != "" {
+ metadata["sign_platform"] = string(cert.SignPlatform)
+ metadata["sign_platform_name"] = enums.GetSignPlatformName(cert.SignPlatform)
+ }
+ return metadata
+}
+
+// ensureContractFill 签署模板路径补齐协议编号与日期(协议编号生成规则与 e签宝一致)
+func ensureContractFill(cert *entities.Certification, companyName, uscc, address, repName string) *ports.ContractGenerateRequest {
+ if cert.ContractCode == "" {
+ cert.SetContractCode(user_entities.GenerateContractCode(user_entities.ContractTypeCooperation))
+ }
+ signDate := time.Now().Format("2006年01月02日")
+ return &ports.ContractGenerateRequest{
+ AgreementNo: cert.ContractCode,
+ CompanyName: companyName,
+ UnifiedSocialCode: uscc,
+ EnterpriseAddress: address,
+ AuthorizedRepName: repName,
+ SignDate: signDate,
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 2c47d52..f64b0c5 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -28,6 +28,7 @@ type Config struct {
App AppConfig `mapstructure:"app"`
WechatWork WechatWorkConfig `mapstructure:"wechat_work"`
Esign EsignConfig `mapstructure:"esign"`
+ Fadada FadadaConfig `mapstructure:"fadada"`
Wallet WalletConfig `mapstructure:"wallet"`
WestDex WestDexConfig `mapstructure:"westdex"`
Zhicha ZhichaConfig `mapstructure:"zhicha"`
@@ -380,6 +381,41 @@ type SignConfig struct {
RedirectURL string `mapstructure:"redirect_url"` // 重定向URL
}
+// FadadaConfig 法大大(FASC OpenAPI)配置
+type FadadaConfig struct {
+ AppID string `mapstructure:"app_id"`
+ AppSecret string `mapstructure:"app_secret"`
+ ServerURL string `mapstructure:"server_url"`
+ OpenCorpID string `mapstructure:"open_corp_id"`
+ NoAuthSceneCode string `mapstructure:"no_auth_scene_code"`
+ TemplateID string `mapstructure:"template_id"`
+ PartyAActorID string `mapstructure:"party_a_actor_id"`
+ PartyBActorID string `mapstructure:"party_b_actor_id"`
+ TemplateDocID string `mapstructure:"template_doc_id"`
+ TemplateFields FadadaTemplateFields `mapstructure:"template_fields"`
+ Contract ContractConfig `mapstructure:"contract"`
+ Auth AuthConfig `mapstructure:"auth"`
+ Sign SignConfig `mapstructure:"sign"`
+ Callback CallbackConfig `mapstructure:"callback"`
+}
+
+// FadadaTemplateFields 法大大合作协议模板控件 fieldId
+type FadadaTemplateFields struct {
+ AgreementNo []string `mapstructure:"agreement_no"`
+ ContractDate string `mapstructure:"contract_date"`
+ PartyAName []string `mapstructure:"party_a_name"`
+ PartyAUSCC string `mapstructure:"party_a_uscc"`
+ PartyAAddress string `mapstructure:"party_a_address"`
+ PartyARep string `mapstructure:"party_a_rep"`
+ PartyASignDate string `mapstructure:"party_a_sign_date"`
+ PartyBSignDate string `mapstructure:"party_b_sign_date"`
+}
+
+// CallbackConfig 第三方回调配置
+type CallbackConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+}
+
// WalletConfig 钱包配置
type WalletConfig struct {
DefaultCreditLimit float64 `mapstructure:"default_credit_limit"`
diff --git a/internal/container/container.go b/internal/container/container.go
index a184b43..c83b439 100644
--- a/internal/container/container.go
+++ b/internal/container/container.go
@@ -42,6 +42,8 @@ import (
"tyapi-server/internal/infrastructure/external/alicloud"
"tyapi-server/internal/infrastructure/external/captcha"
"tyapi-server/internal/infrastructure/external/email"
+ infraesign "tyapi-server/internal/infrastructure/external/esign"
+ infrafadada "tyapi-server/internal/infrastructure/external/fadada"
"tyapi-server/internal/infrastructure/external/haiyuapi"
"tyapi-server/internal/infrastructure/external/huibo"
"tyapi-server/internal/infrastructure/external/jiguang"
@@ -50,6 +52,7 @@ import (
"tyapi-server/internal/infrastructure/external/ocr"
"tyapi-server/internal/infrastructure/external/shujubao"
"tyapi-server/internal/infrastructure/external/shumai"
+ "tyapi-server/internal/infrastructure/external/signplatform"
"tyapi-server/internal/infrastructure/external/sms"
"tyapi-server/internal/infrastructure/external/storage"
"tyapi-server/internal/infrastructure/external/tianyancha"
@@ -69,6 +72,7 @@ import (
component_report "tyapi-server/internal/shared/component_report"
shared_database "tyapi-server/internal/shared/database"
"tyapi-server/internal/shared/esign"
+ "tyapi-server/internal/shared/fadada"
shared_events "tyapi-server/internal/shared/events"
"tyapi-server/internal/shared/export"
"tyapi-server/internal/shared/health"
@@ -322,6 +326,53 @@ func NewContainer() *Container {
func(esignConfig *esign.Config) *esign.Client {
return esign.NewClient(esignConfig)
},
+ // 法大大配置
+ func(cfg *config.Config) (*fadada.Config, error) {
+ return fadada.NewConfig(
+ cfg.Fadada.AppID,
+ cfg.Fadada.AppSecret,
+ cfg.Fadada.ServerURL,
+ cfg.Fadada.OpenCorpID,
+ cfg.Fadada.NoAuthSceneCode,
+ cfg.Fadada.TemplateID,
+ cfg.Fadada.PartyAActorID,
+ cfg.Fadada.PartyBActorID,
+ cfg.Fadada.TemplateDocID,
+ &fadada.TemplateFields{
+ AgreementNo: cfg.Fadada.TemplateFields.AgreementNo,
+ ContractDate: cfg.Fadada.TemplateFields.ContractDate,
+ PartyAName: cfg.Fadada.TemplateFields.PartyAName,
+ PartyAUSCC: cfg.Fadada.TemplateFields.PartyAUSCC,
+ PartyAAddress: cfg.Fadada.TemplateFields.PartyAAddress,
+ PartyARep: cfg.Fadada.TemplateFields.PartyARep,
+ PartyASignDate: cfg.Fadada.TemplateFields.PartyASignDate,
+ PartyBSignDate: cfg.Fadada.TemplateFields.PartyBSignDate,
+ },
+ &fadada.ContractConfig{
+ Name: cfg.Fadada.Contract.Name,
+ ExpireDays: cfg.Fadada.Contract.ExpireDays,
+ RetryCount: cfg.Fadada.Contract.RetryCount,
+ },
+ &fadada.AuthConfig{
+ RedirectURL: cfg.Fadada.Auth.RedirectURL,
+ },
+ &fadada.SignConfig{
+ RedirectURL: cfg.Fadada.Sign.RedirectURL,
+ },
+ &fadada.CallbackConfig{
+ Enabled: cfg.Fadada.Callback.Enabled,
+ },
+ )
+ },
+ func(fadadaConfig *fadada.Config) *fadada.Client {
+ return fadada.NewClient(fadadaConfig)
+ },
+ func(esignClient *esign.Client, fadadaClient *fadada.Client) *signplatform.Registry {
+ return signplatform.NewRegistry(
+ infraesign.NewProvider(esignClient),
+ infrafadada.NewProvider(fadadaClient),
+ )
+ },
// 支付宝支付服务
func(cfg *config.Config) *payment.AliPayService {
config := payment.AlipayConfig{
@@ -967,6 +1018,7 @@ func NewContainer() *Container {
smsCodeService *user_service.SMSCodeService,
esignClient *esign.Client,
esignConfig *esign.Config,
+ platformRegistry *signplatform.Registry,
qiniuStorageService *storage.QiNiuStorageService,
contractAggregateService user_service.ContractAggregateService,
walletAggregateService finance_services.WalletAggregateService,
@@ -987,6 +1039,7 @@ func NewContainer() *Container {
smsCodeService,
esignClient,
esignConfig,
+ platformRegistry,
qiniuStorageService,
contractAggregateService,
walletAggregateService,
diff --git a/internal/domains/api/services/api_user_aggregate_service.go b/internal/domains/api/services/api_user_aggregate_service.go
index 1ceccd4..646ddaf 100644
--- a/internal/domains/api/services/api_user_aggregate_service.go
+++ b/internal/domains/api/services/api_user_aggregate_service.go
@@ -2,10 +2,14 @@ package services
import (
"context"
+ "errors"
"time"
+
"tyapi-server/internal/config"
"tyapi-server/internal/domains/api/entities"
repo "tyapi-server/internal/domains/api/repositories"
+
+ "gorm.io/gorm"
)
type ApiUserAggregateService interface {
@@ -30,6 +34,15 @@ func NewApiUserAggregateService(repo repo.ApiUserRepository, cfg *config.Config)
}
func (s *ApiUserAggregateServiceImpl) CreateApiUser(ctx context.Context, apiUserId string) error {
+ // 已存在则幂等成功,避免重复 INSERT 导致 PostgreSQL 事务中止(SQLSTATE 25P02)
+ existing, err := s.repo.FindByUserId(ctx, apiUserId)
+ if err == nil && existing != nil {
+ return nil
+ }
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+
apiUser, err := entities.NewApiUser(apiUserId, s.cfg.Wallet.BalanceAlert.DefaultEnabled, s.cfg.Wallet.BalanceAlert.DefaultThreshold)
if err != nil {
return err
diff --git a/internal/domains/certification/entities/certification.go b/internal/domains/certification/entities/certification.go
index 570c681..bcd7a06 100644
--- a/internal/domains/certification/entities/certification.go
+++ b/internal/domains/certification/entities/certification.go
@@ -3,6 +3,7 @@ package entities
import (
"errors"
"fmt"
+ "strings"
"time"
"tyapi-server/internal/domains/certification/entities/value_objects"
@@ -28,15 +29,17 @@ type Certification struct {
CompletedAt *time.Time `json:"completed_at,omitempty" comment:"认证完成时间"`
ContractFileCreatedAt *time.Time `json:"contract_file_created_at,omitempty" comment:"合同文件生成时间"`
- // === e签宝相关信息 ===
+ // === 签署平台(esign | fadada)===
+ SignPlatform enums.SignPlatform `gorm:"type:varchar(20);index" json:"sign_platform,omitempty" comment:"签署平台: esign|fadada"`
+
+ // === 第三方认证/签署信息(字段名历史兼容,语义为平台无关)===
AuthFlowID string `gorm:"type:varchar(500)" json:"auth_flow_id,omitempty" comment:"企业认证流程ID"`
- AuthURL string `gorm:"type:varchar(500)" json:"auth_url,omitempty" comment:"企业认证链接"`
+ AuthURL string `gorm:"type:text" json:"auth_url,omitempty" comment:"企业认证链接"`
+ ContractCode string `gorm:"type:varchar(100)" json:"contract_code,omitempty" comment:"合同/协议编号"`
ContractFileID string `gorm:"type:varchar(500)" json:"contract_file_id,omitempty" comment:"合同文件ID"`
- EsignFlowID string `gorm:"type:varchar(500)" json:"esign_flow_id,omitempty" comment:"签署流程ID"`
- ContractURL string `gorm:"type:varchar(500)" json:"contract_url,omitempty" comment:"合同文件访问链接"`
- ContractSignURL string `gorm:"type:varchar(500)" json:"contract_sign_url,omitempty" comment:"合同签署链接"`
- // ContractCode 合作协议编号(与电子合同模板控件 xybh 一致,签署完成后写入用户域合同)
- ContractCode string `gorm:"type:varchar(255)" json:"contract_code,omitempty" comment:"合作协议编号"`
+ EsignFlowID string `gorm:"type:varchar(500)" json:"esign_flow_id,omitempty" comment:"签署流程ID(兼容字段名)"`
+ ContractURL string `gorm:"type:text" json:"contract_url,omitempty" comment:"合同文件访问链接(法大大下载链可能很长)"`
+ ContractSignURL string `gorm:"type:text" json:"contract_sign_url,omitempty" comment:"合同签署链接(短链/长链)"`
// === 失败信息 ===
FailureReason enums.FailureReason `gorm:"type:varchar(100)" json:"failure_reason,omitempty" comment:"失败原因"`
@@ -319,8 +322,12 @@ func (c *Certification) ApplyContract(EsignFlowID string, ContractSignURL string
if err := c.TransitionTo(enums.StatusContractApplied, enums.ActorTypeUser, c.UserID, "用户申请合同签署"); err != nil {
return err
}
- c.EsignFlowID = EsignFlowID
- c.ContractSignURL = ContractSignURL
+ if EsignFlowID != "" {
+ c.EsignFlowID = EsignFlowID
+ }
+ if ContractSignURL != "" {
+ c.ContractSignURL = ContractSignURL
+ }
now := time.Now()
c.ContractFileCreatedAt = &now
// 添加业务事件
@@ -333,7 +340,19 @@ func (c *Certification) ApplyContract(EsignFlowID string, ContractSignURL string
return nil
}
-// SetContractCode 设置合作协议编号(首次生成合同时写入,后续换文件复用)
+// BindSignTask 企业认证完成后预创建签署任务(不改状态;乙方可先免签)
+func (c *Certification) BindSignTask(esignFlowID, contractSignURL string) {
+ if esignFlowID != "" {
+ c.EsignFlowID = esignFlowID
+ }
+ if contractSignURL != "" {
+ c.ContractSignURL = contractSignURL
+ }
+ now := time.Now()
+ c.ContractFileCreatedAt = &now
+}
+
+// SetContractCode 设置合同/协议编号
func (c *Certification) SetContractCode(code string) {
c.ContractCode = code
}
@@ -509,13 +528,114 @@ func (c *Certification) CompleteCertification() error {
return nil
}
+// ResolvedSignPlatform 解析签署平台(空则默认 e签宝,兼容历史数据)
+func (c *Certification) ResolvedSignPlatform() enums.SignPlatform {
+ if enums.IsValidSignPlatform(c.SignPlatform) {
+ return c.SignPlatform
+ }
+ return enums.DefaultSignPlatform()
+}
+
+// ResetAfterSignTaskCreateFailed 签署任务未创建却已到 enterprise_verified 时,回退到待选平台
+func (c *Certification) ResetAfterSignTaskCreateFailed() error {
+ if c.Status != enums.StatusEnterpriseVerified {
+ return fmt.Errorf("当前状态 %s 无需回退", enums.GetStatusName(c.Status))
+ }
+ if strings.TrimSpace(c.EsignFlowID) != "" {
+ return fmt.Errorf("签署任务已存在,不能回退到选平台")
+ }
+ c.Status = enums.StatusInfoPendingReview
+ c.SignPlatform = ""
+ c.AuthURL = ""
+ c.AuthFlowID = ""
+ c.ContractFileID = ""
+ c.ContractURL = ""
+ c.ContractSignURL = ""
+ c.EsignFlowID = ""
+ c.EnterpriseVerifiedAt = nil
+ c.UpdatedAt = time.Now()
+ return nil
+}
+
+// ResetForResignContract 管理员触发重新认证:回退到 pending,用户可重新填写企业信息并走完整入驻流程
+// 不改动用户域认证标记、钱包、API Key、历史合同记录
+func (c *Certification) ResetForResignContract(actorID, reason string) error {
+ switch c.Status {
+ case enums.StatusCompleted, enums.StatusContractSigned, enums.StatusContractApplied,
+ enums.StatusContractRejected, enums.StatusContractExpired,
+ enums.StatusEnterpriseVerified, enums.StatusInfoSubmitted, enums.StatusInfoPendingReview:
+ // 允许从已进入流程的各状态重置,供用户重新填写企业信息
+ default:
+ if c.Status == enums.StatusPending {
+ return fmt.Errorf("当前已是待提交企业信息状态,无需重置")
+ }
+ return fmt.Errorf("当前状态 %s 不允许重新认证", enums.GetStatusName(c.Status))
+ }
+
+ oldStatus := c.Status
+ now := time.Now()
+
+ c.Status = enums.StatusPending
+ c.SignPlatform = ""
+ c.AuthFlowID = ""
+ c.AuthURL = ""
+ c.ContractCode = ""
+ c.ContractFileID = ""
+ c.EsignFlowID = ""
+ c.ContractURL = ""
+ c.ContractSignURL = ""
+ c.InfoSubmittedAt = nil
+ c.EnterpriseVerifiedAt = nil
+ c.ContractAppliedAt = nil
+ c.ContractSignedAt = nil
+ c.CompletedAt = nil
+ c.ContractFileCreatedAt = nil
+ c.RetryCount = 0
+ c.clearFailureInfo()
+ c.updateTransitionAudit(enums.ActorTypeAdmin, actorID)
+ c.UpdatedAt = now
+
+ c.addDomainEvent(&CertificationStatusChangedEvent{
+ CertificationID: c.ID,
+ UserID: c.UserID,
+ FromStatus: oldStatus,
+ ToStatus: enums.StatusPending,
+ Actor: enums.ActorTypeAdmin,
+ ActorID: actorID,
+ Reason: reason,
+ TransitionedAt: now,
+ })
+ return nil
+}
+
+// SetSignPlatform 设置签署平台(选定后锁定)
+func (c *Certification) SetSignPlatform(platform enums.SignPlatform) error {
+ if !enums.IsValidSignPlatform(platform) {
+ return fmt.Errorf("无效的签署平台: %s", platform)
+ }
+ if c.SignPlatform != "" && c.SignPlatform != platform {
+ return fmt.Errorf("签署平台已选择为 %s,不可更改", enums.GetSignPlatformName(c.SignPlatform))
+ }
+ c.SignPlatform = platform
+ return nil
+}
+
+// ClearSignPlatform 清除已选签署平台(内部回退用)
+func (c *Certification) ClearSignPlatform() {
+ c.SignPlatform = ""
+}
+
// ================ 查询方法 ================
// GetDataByStatus 根据当前状态获取对应的数据
func (c *Certification) GetDataByStatus() map[string]interface{} {
data := map[string]interface{}{}
+ if c.SignPlatform != "" {
+ data["sign_platform"] = string(c.SignPlatform)
+ data["sign_platform_name"] = enums.GetSignPlatformName(c.SignPlatform)
+ }
switch c.Status {
case enums.StatusInfoPendingReview:
- // 待审核,无额外数据
+ // 待审核/待选平台,额外元数据由应用层补充
case enums.StatusInfoSubmitted:
data["auth_url"] = c.AuthURL
case enums.StatusInfoRejected:
@@ -523,11 +643,24 @@ func (c *Certification) GetDataByStatus() map[string]interface{} {
data["failure_message"] = c.FailureMessage
case enums.StatusEnterpriseVerified:
data["ContractURL"] = c.ContractURL
+ data["contract_url"] = c.ContractURL
+ if c.EsignFlowID != "" {
+ data["esign_flow_id"] = c.EsignFlowID
+ data["sign_task_ready"] = true
+ }
case enums.StatusContractApplied:
data["contract_sign_url"] = c.ContractSignURL
+ data["ContractURL"] = c.ContractURL
+ data["contract_url"] = c.ContractURL
+ if c.EsignFlowID != "" {
+ data["esign_flow_id"] = c.EsignFlowID
+ }
case enums.StatusContractSigned:
case enums.StatusCompleted:
data["completed_at"] = c.CompletedAt
+ if c.ContractURL != "" {
+ data["contract_url"] = c.ContractURL
+ }
case enums.StatusContractRejected:
data["failure_reason"] = c.FailureReason
data["failure_message"] = c.FailureMessage
diff --git a/internal/domains/certification/entities/value_objects/contract_info.go b/internal/domains/certification/entities/value_objects/contract_info.go
index f1ef4e5..b29b008 100644
--- a/internal/domains/certification/entities/value_objects/contract_info.go
+++ b/internal/domains/certification/entities/value_objects/contract_info.go
@@ -15,7 +15,9 @@ type ContractInfo struct {
ContractFileID string `json:"contract_file_id"` // 合同文件ID
EsignFlowID string `json:"esign_flow_id"` // e签宝签署流程ID
ContractURL string `json:"contract_url"` // 合同文件访问链接
- ContractSignURL string `json:"contract_sign_url"` // 合同签署链接
+ ContractSignURL string `json:"contract_sign_url"` // 合同签署链接(iframe 长链,可能短期有效)
+ // ContractSignShortURL 短链(法大大不可 iframe;可选)
+ ContractSignShortURL string `json:"contract_sign_short_url,omitempty"`
// 合同元数据
ContractTitle string `json:"contract_title"` // 合同标题
diff --git a/internal/domains/certification/enums/sign_platform.go b/internal/domains/certification/enums/sign_platform.go
new file mode 100644
index 0000000..be7f594
--- /dev/null
+++ b/internal/domains/certification/enums/sign_platform.go
@@ -0,0 +1,36 @@
+package enums
+
+// SignPlatform 签署/认证平台
+type SignPlatform string
+
+const (
+ SignPlatformEsign SignPlatform = "esign"
+ SignPlatformFadada SignPlatform = "fadada"
+)
+
+// IsValidSignPlatform 是否为合法平台
+func IsValidSignPlatform(p SignPlatform) bool {
+ switch p {
+ case SignPlatformEsign, SignPlatformFadada:
+ return true
+ default:
+ return false
+ }
+}
+
+// GetSignPlatformName 平台中文名
+func GetSignPlatformName(p SignPlatform) string {
+ switch p {
+ case SignPlatformEsign:
+ return "e签宝"
+ case SignPlatformFadada:
+ return "法大大"
+ default:
+ return string(p)
+ }
+}
+
+// DefaultSignPlatform 历史数据默认平台
+func DefaultSignPlatform() SignPlatform {
+ return SignPlatformEsign
+}
diff --git a/internal/domains/certification/ports/sign_platform_provider.go b/internal/domains/certification/ports/sign_platform_provider.go
new file mode 100644
index 0000000..50fed34
--- /dev/null
+++ b/internal/domains/certification/ports/sign_platform_provider.go
@@ -0,0 +1,152 @@
+package ports
+
+import (
+ "context"
+
+ "tyapi-server/internal/domains/certification/enums"
+)
+
+// EnterpriseAuthRequest 企业认证链接请求
+type EnterpriseAuthRequest struct {
+ ClientCorpID string
+ ClientUserID string
+ CompanyName string
+ UnifiedSocialCode string
+ LegalPersonName string
+ LegalPersonID string
+ TransactorName string
+ TransactorMobile string
+ TransactorID string
+}
+
+// AuthLinkResult 企业认证链接结果
+type AuthLinkResult struct {
+ AuthFlowID string
+ AuthURL string
+ AuthShortURL string
+}
+
+// OrgIdentityQuery 企业实名查询
+type OrgIdentityQuery struct {
+ OrgName string
+ OrgIdentNo string
+ OpenCorpID string
+ ClientCorpID string
+}
+
+// ContractGenerateRequest 合同模板填单
+type ContractGenerateRequest struct {
+ AgreementNo string
+ CompanyName string
+ UnifiedSocialCode string
+ EnterpriseAddress string
+ AuthorizedRepName string
+ SignDate string
+ FileName string
+}
+
+// ContractFileResult 合同文件结果
+type ContractFileResult struct {
+ FileID string
+ FileDownloadURL string
+}
+
+// SignFlowCreateRequest 创建签署流程
+type SignFlowCreateRequest struct {
+ Subject string
+ FileID string
+ DocName string
+ PartyAOpenCorpID string
+ PartyAName string
+ PartyAUSCC string
+ TransactorName string
+ TransactorMobile string
+ TransactorID string
+ TransReferenceID string
+ Fill *ContractGenerateRequest
+ // SkipActorURL 为企业认证后预创建任务时跳过取签署链接(避免消耗 10 分钟单次 EmbedURL)
+ SkipActorURL bool
+}
+
+// SignFlowResult 签署流程创建结果
+type SignFlowResult struct {
+ SignFlowID string
+ // SignURL 可供 iframe 嵌入的签署长链(法大大 actorSignTaskEmbedUrl / e签宝 url)
+ SignURL string
+ // ShortURL 短链(法大大 actorSignTaskUrl,不可 iframe)
+ ShortURL string
+}
+
+// GetActorSignURLRequest 重新获取参与方签署链接
+type GetActorSignURLRequest struct {
+ SignFlowID string
+ TransactorMobile string
+ PartyAName string
+}
+
+// ActorSignURLResult 参与方签署链接(长链可 iframe,短链不可)
+type ActorSignURLResult struct {
+ SignFlowID string
+ EmbedURL string // iframe 用
+ ShortURL string // 短链,一年有效,需登录
+}
+
+// SignTaskPreviewURLResult 签署任务预览链接
+type SignTaskPreviewURLResult struct {
+ SignFlowID string
+ PreviewURL string
+}
+
+// SignStatusResult 签署状态
+type SignStatusResult struct {
+ SignFlowID string
+ Status string
+ Completed bool
+ Rejected bool
+ Expired bool
+ Message string
+}
+
+// SignedFile 已签文件
+type SignedFile struct {
+ DownloadURL string
+ DownloadID string
+}
+
+// CallbackEvent 回调事件
+type CallbackEvent struct {
+ Event string
+ AuthPassed bool
+ SignCompleted bool
+ SignRejected bool
+ SignFlowID string
+ AuthFlowID string
+ OrgName string
+ Raw map[string]interface{}
+}
+
+// SignPlatformProvider 签署平台统一能力(e签宝 / 法大大)
+type SignPlatformProvider interface {
+ Platform() enums.SignPlatform
+
+ GenerateEnterpriseAuth(ctx context.Context, req *EnterpriseAuthRequest) (*AuthLinkResult, error)
+ QueryOrgVerified(ctx context.Context, req *OrgIdentityQuery) (bool, error)
+
+ GenerateContractFile(ctx context.Context, req *ContractGenerateRequest) (*ContractFileResult, error)
+ CreateSignFlow(ctx context.Context, req *SignFlowCreateRequest) (*SignFlowResult, error)
+ // GetActorSignURL 按已有签署任务重新获取参与方链接(法大大长链 10 分钟/单次,进入签署页时应刷新)
+ GetActorSignURL(ctx context.Context, req *GetActorSignURLRequest) (*ActorSignURLResult, error)
+ // GetSignTaskPreviewURL 获取签署任务预览链接(法大大 EUI,约 2 小时/单次;勿用 PDF 直链做预览)
+ GetSignTaskPreviewURL(ctx context.Context, signFlowID string) (*SignTaskPreviewURLResult, error)
+ QuerySignStatus(ctx context.Context, flowID string) (*SignStatusResult, error)
+ DownloadSignedFiles(ctx context.Context, flowID string) ([]*SignedFile, error)
+
+ VerifyCallback(ctx context.Context, headers map[string]string, body []byte) error
+ ParseCallback(ctx context.Context, headers map[string]string, body []byte) (*CallbackEvent, error)
+}
+
+// SignPlatformRegistry 按平台路由 Provider
+type SignPlatformRegistry interface {
+ Get(platform enums.SignPlatform) (SignPlatformProvider, error)
+ List() []SignPlatformProvider
+}
diff --git a/internal/domains/finance/services/wallet_aggregate_service.go b/internal/domains/finance/services/wallet_aggregate_service.go
index ccb6b00..a62c654 100644
--- a/internal/domains/finance/services/wallet_aggregate_service.go
+++ b/internal/domains/finance/services/wallet_aggregate_service.go
@@ -52,16 +52,20 @@ func NewWalletAggregateService(
}
}
-// CreateWallet 创建钱包
+// CreateWallet 创建钱包(已存在则幂等返回已有钱包,避免重复 INSERT 中止事务)
func (s *WalletAggregateServiceImpl) CreateWallet(ctx context.Context, userID string) (*entities.Wallet, error) {
// 检查是否已存在
- w, _ := s.walletRepo.GetByUserID(ctx, userID)
- if w != nil {
- return nil, fmt.Errorf("用户已存在钱包")
+ w, err := s.walletRepo.GetByUserID(ctx, userID)
+ if err == nil && w != nil {
+ return w, nil
}
wallet := entities.NewWallet(userID, decimal.NewFromFloat(s.cfg.Wallet.DefaultCreditLimit))
created, err := s.walletRepo.Create(ctx, *wallet)
if err != nil {
+ // 并发下可能已创建成功,再查一次
+ if existing, getErr := s.walletRepo.GetByUserID(ctx, userID); getErr == nil && existing != nil {
+ return existing, nil
+ }
s.logger.Error("创建钱包失败", zap.Error(err))
return nil, err
}
diff --git a/internal/infrastructure/external/esign/esign_provider.go b/internal/infrastructure/external/esign/esign_provider.go
new file mode 100644
index 0000000..c58193d
--- /dev/null
+++ b/internal/infrastructure/external/esign/esign_provider.go
@@ -0,0 +1,204 @@
+package esign
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "tyapi-server/internal/domains/certification/enums"
+ "tyapi-server/internal/domains/certification/ports"
+ sharedesign "tyapi-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)
diff --git a/internal/infrastructure/external/fadada/fadada_provider.go b/internal/infrastructure/external/fadada/fadada_provider.go
new file mode 100644
index 0000000..f0f1c7c
--- /dev/null
+++ b/internal/infrastructure/external/fadada/fadada_provider.go
@@ -0,0 +1,247 @@
+package fadada
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "strings"
+
+ "tyapi-server/internal/domains/certification/enums"
+ "tyapi-server/internal/domains/certification/ports"
+ sharedfadada "tyapi-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)
diff --git a/internal/infrastructure/external/signplatform/registry.go b/internal/infrastructure/external/signplatform/registry.go
new file mode 100644
index 0000000..e897614
--- /dev/null
+++ b/internal/infrastructure/external/signplatform/registry.go
@@ -0,0 +1,52 @@
+package signplatform
+
+import (
+ "fmt"
+
+ "tyapi-server/internal/domains/certification/enums"
+ "tyapi-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)
diff --git a/internal/infrastructure/http/handlers/certification_handler.go b/internal/infrastructure/http/handlers/certification_handler.go
index be8c25d..c834370 100644
--- a/internal/infrastructure/http/handlers/certification_handler.go
+++ b/internal/infrastructure/http/handlers/certification_handler.go
@@ -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,38 @@ func (h *CertificationHandler) ApplyContract(c *gin.Context) {
h.response.Success(c, result, "合同申请成功")
}
+// RefreshContractSignURL 刷新合同签署 iframe 长链
+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 刷新合同预览链接
+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识别,自动填充企业信息
@@ -451,6 +536,46 @@ func (h *CertificationHandler) AdminCompleteCertificationWithoutContract(c *gin.
h.response.Success(c, result, "代用户完成认证成功")
}
+// AdminResetForResignContract 管理员触发重新认证
+// @Summary 管理员触发重新认证
+// @Description 将认证回退到 pending,用户可重新填写企业信息并走完整入驻流程;保留钱包、API Key、历史合同与 is_certified
+// @Tags 认证管理
+// @Accept json
+// @Produce json
+// @Security Bearer
+// @Param request body commands.AdminResetForResignContractCommand true "管理员重新认证请求"
+// @Success 200 {object} responses.CertificationResponse "操作成功"
+// @Failure 400 {object} map[string]interface{} "请求参数错误"
+// @Failure 401 {object} map[string]interface{} "未认证"
+// @Failure 403 {object} map[string]interface{} "权限不足"
+// @Router /api/v1/certifications/admin/reset-for-resign-contract [post]
+func (h *CertificationHandler) AdminResetForResignContract(c *gin.Context) {
+ adminID := h.getCurrentUserID(c)
+ if adminID == "" {
+ h.response.Unauthorized(c, "用户未登录")
+ return
+ }
+
+ var cmd commands.AdminResetForResignContractCommand
+ if err := h.validator.BindAndValidate(c, &cmd); err != nil {
+ return
+ }
+ cmd.AdminID = adminID
+
+ result, err := h.appService.AdminResetForResignContract(c.Request.Context(), &cmd)
+ if err != nil {
+ h.logger.Error("管理员触发重新认证失败",
+ zap.Error(err),
+ zap.String("admin_id", adminID),
+ zap.String("user_id", cmd.UserID),
+ )
+ h.response.BadRequest(c, err.Error())
+ return
+ }
+
+ h.response.Success(c, result, "已重置为重新认证,请通知用户前往企业入驻重新填写企业信息")
+}
+
// AdminListSubmitRecords 管理端分页查询企业信息提交记录
// @Summary 管理端企业审核列表
// @Tags 认证管理
diff --git a/internal/infrastructure/http/routes/certification_routes.go b/internal/infrastructure/http/routes/certification_routes.go
index 262644d..7b51664 100644
--- a/internal/infrastructure/http/routes/certification_routes.go
+++ b/internal/infrastructure/http/routes/certification_routes.go
@@ -64,9 +64,16 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) {
// 3. 申请合同签署
authGroup.POST("/apply-contract", r.handler.ApplyContract)
+ 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)
}
@@ -78,6 +85,7 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) {
{
adminGroup.GET("", r.handler.ListCertifications) // 查询认证列表(管理员)
adminGroup.POST("/complete-without-contract", r.handler.AdminCompleteCertificationWithoutContract)
+ adminGroup.POST("/reset-for-resign-contract", r.handler.AdminResetForResignContract)
adminGroup.POST("/transition-status", r.handler.AdminTransitionCertificationStatus)
}
adminCertGroup := adminGroup.Group("/submit-records")
@@ -91,7 +99,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)
+ callbackGroup.POST("/fadada", r.handler.HandleFadadaCallback)
}
}
diff --git a/internal/shared/fadada/api_types.go b/internal/shared/fadada/api_types.go
new file mode 100644
index 0000000..d6e4938
--- /dev/null
+++ b/internal/shared/fadada/api_types.go
@@ -0,0 +1,194 @@
+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"`
+ FreeSignInfo *freeSignInfo `json:"freeSignInfo,omitempty"`
+ RedirectURL string `json:"redirectUrl,omitempty"`
+}
+
+// freeSignInfo 授权同时开通免验证签
+type freeSignInfo struct {
+ BusinessID string `json:"businessId,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=按场景码
+ 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"` // 免验证签场景码;requestVerifyFree=true 时必传
+ 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"`
+}
diff --git a/internal/shared/fadada/callback.go b/internal/shared/fadada/callback.go
new file mode 100644
index 0000000..b43a1c3
--- /dev/null
+++ b/internal/shared/fadada/callback.go
@@ -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 != "" {
+ return s
+ }
+ }
+ }
+ }
+ return ""
+}
diff --git a/internal/shared/fadada/callback_test.go b/internal/shared/fadada/callback_test.go
new file mode 100644
index 0000000..d7cf850
--- /dev/null
+++ b/internal/shared/fadada/callback_test.go
@@ -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)
+ }
+}
diff --git a/internal/shared/fadada/client.go b/internal/shared/fadada/client.go
new file mode 100644
index 0000000..6b008f2
--- /dev/null
+++ b/internal/shared/fadada/client.go
@@ -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
+}
diff --git a/internal/shared/fadada/config.go b/internal/shared/fadada/config.go
new file mode 100644
index 0000000..5ee7316
--- /dev/null
+++ b/internal/shared/fadada/config.go
@@ -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 免验证签场景码(businessId);requestVerifyFree=true 时必传。
+ 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"
+)
diff --git a/internal/shared/fadada/corp_service.go b/internal/shared/fadada/corp_service.go
new file mode 100644
index 0000000..3875717
--- /dev/null
+++ b/internal/shared/fadada/corp_service.go
@@ -0,0 +1,183 @@
+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),
+ }
+ if bid := strings.TrimSpace(req.FreeSignBusinessID); bid != "" {
+ apiReq.FreeSignInfo = &freeSignInfo{BusinessID: bid}
+ }
+
+ 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)
+}
diff --git a/internal/shared/fadada/corp_service_test.go b/internal/shared/fadada/corp_service_test.go
new file mode 100644
index 0000000..245cfcc
--- /dev/null
+++ b/internal/shared/fadada/corp_service_test.go
@@ -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")
+ }
+}
diff --git a/internal/shared/fadada/debug_log.go b/internal/shared/fadada/debug_log.go
new file mode 100644
index 0000000..1315f75
--- /dev/null
+++ b/internal/shared/fadada/debug_log.go
@@ -0,0 +1,140 @@
+package fadada
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+// debug 日志:将每次法大大 OpenAPI 调用的 cURL 命令 + 响应落盘到 logs/fadada//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
+}
diff --git a/internal/shared/fadada/http.go b/internal/shared/fadada/http.go
new file mode 100644
index 0000000..17b10fe
--- /dev/null
+++ b/internal/shared/fadada/http.go
@@ -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=,带 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)
+}
diff --git a/internal/shared/fadada/sign.go b/internal/shared/fadada/sign.go
new file mode 100644
index 0000000..802b10c
--- /dev/null
+++ b/internal/shared/fadada/sign.go
@@ -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))
+}
diff --git a/internal/shared/fadada/signtask_service.go b/internal/shared/fadada/signtask_service.go
new file mode 100644
index 0000000..f5e7d64
--- /dev/null
+++ b/internal/shared/fadada/signtask_service.go
@@ -0,0 +1,435 @@
+package fadada
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// CreateSignFlow 基于签署任务模板创建签署任务(POST /sign-task/create-with-template)
+// - autoStart:无填单时创建即自动提交;有填单时创建后填控件再 Start(完成自动提交)
+// - 签署顺序:甲方 orderNo=1 先签(短信/验证);乙方 orderNo=2 后签,由系统按模板免验证签自动盖章
+// - 乙方参与方开启免验证签(requestVerifyFree=true + freeSignType=template);requestVerifyFree 时 businessId 必传
+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
+
+ businessID, err := c.resolveBusinessID()
+ if err != nil {
+ return nil, err
+ }
+
+ 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: businessID, // requestVerifyFree=true 时必传场景码
+ 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" // 根据模板免验证签;requestVerifyFree 时仍须传 businessId
+
+// resolveBusinessID 免验证签场景码(requestVerifyFree=true 时 businessId 必传)
+func (c *Client) resolveBusinessID() (string, error) {
+ if c.config == nil {
+ return "", fmt.Errorf("法大大配置为空")
+ }
+ businessID := strings.TrimSpace(c.config.NoAuthSceneCode)
+ if businessID == "" {
+ return "", fmt.Errorf("法大大 no_auth_scene_code(免验证签场景码)不能为空:requestVerifyFree 时 businessId 必传")
+ }
+ return businessID, nil
+}
+
+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:甲方签完后由系统按模板免验证签自动盖章(任务级传 businessId)
+ 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 ""
+}
diff --git a/internal/shared/fadada/signtask_service_test.go b/internal/shared/fadada/signtask_service_test.go
new file mode 100644
index 0000000..28896a1
--- /dev/null
+++ b/internal/shared/fadada/signtask_service_test.go
@@ -0,0 +1,74 @@
+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",
+ NoAuthSceneCode: "scene-code",
+ 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)
+ }
+ businessID, err := c.resolveBusinessID()
+ if err != nil || businessID != "scene-code" {
+ t.Fatalf("businessId=%q err=%v, want scene-code", businessID, err)
+ }
+ 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")
+ }
+}
diff --git a/internal/shared/fadada/template_service.go b/internal/shared/fadada/template_service.go
new file mode 100644
index 0000000..40d192e
--- /dev/null
+++ b/internal/shared/fadada/template_service.go
@@ -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
+}
diff --git a/internal/shared/fadada/template_service_test.go b/internal/shared/fadada/template_service_test.go
new file mode 100644
index 0000000..d320e0f
--- /dev/null
+++ b/internal/shared/fadada/template_service_test.go
@@ -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")
+ }
+}
diff --git a/internal/shared/fadada/token.go b/internal/shared/fadada/token.go
new file mode 100644
index 0000000..9306428
--- /dev/null
+++ b/internal/shared/fadada/token.go
@@ -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
+}
diff --git a/internal/shared/fadada/types.go b/internal/shared/fadada/types.go
new file mode 100644
index 0000000..8c9605d
--- /dev/null
+++ b/internal/shared/fadada/types.go
@@ -0,0 +1,170 @@
+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"`
+
+ // FreeSignBusinessID 可选:授权时一并开通免验证签场景(对应 freeSignInfo.businessId)
+ FreeSignBusinessID string `json:"freeSignBusinessId,omitempty"`
+}
+
+// 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"`
+}