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"` +}