diff --git a/config.yaml b/config.yaml index edd80d2..a8855de 100644 --- a/config.yaml +++ b/config.yaml @@ -173,15 +173,21 @@ email: # 存储服务配置 - 七牛云 storage: - access_key: "your-qiniu-access-key" - secret_key: "your-qiniu-secret-key" - bucket: "your-bucket-name" - domain: "https://your-domain.com" + access_key: "AO6u6sDWi6L9TsPfr4awC7FYP85JTjt3bodZACCM" + secret_key: "2fjxweGtSAEaUdVgDkWEmN7JbBxHBQDv1cLORb9_" + bucket: "tianyuanapi" + domain: "https://file.tianyuanapi.com" + # access_key: "your-qiniu-access-key" + # secret_key: "your-qiniu-secret-key" + # bucket: "your-bucket-name" + # domain: "https://your-domain.com" -# OCR服务配置 - 百度智能云 +# =========================================== +# 🔍 OCR服务配置 - 百度智能云 +# =========================================== ocr: - api_key: "your-baidu-api-key" - secret_key: "your-baidu-secret-key" + api_key: "aMsrBNGUJxgcgqdm3SEdcumm" + secret_key: "sWlv2h2AWA3aAt5bjXCkE6WeA5AzpAAD" ratelimit: requests: 7500 @@ -287,6 +293,64 @@ esign: client_type: "ALL" redirect_url: "https://console.haiyudata.com/certification/callback/sign" +# =========================================== +# 📝 法大大服务配置(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 生成,格式 HYDATA-{当天YYYYMMDD}-R{6位随机数}(R=入驻),已有 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: "海宇数据-合作协议" + expire_days: 7 + retry_count: 3 + auth: + # 认证完成后顶层跳回企业入驻页(勿用 callback 页留在 iframe 内) + redirect_url: "https://console.haiyudata.com/profile/certification" + sign: + # 甲方签署完成后回跳(iframe 内会顶层跳出到入驻页) + redirect_url: "https://console.haiyudata.com/certification/callback/fadada/sign" + callback: + enabled: true + # =========================================== # 💰 钱包配置 # =========================================== @@ -785,3 +849,37 @@ huibo: max_backups: 5 max_age: 30 compress: true + +# =========================================== +# ✨ 戎行配置需开启vpn进行其企业内网访问。 +# =========================================== +rongxing: + url: "http://192.168.3.43:7007" # 基础地址,登录=/auth/login,查询=/third/loan/info360 + account: "haiyukeji" + password: "haiyukeji@123" # 明文,请求时自动 Base64 + app_id: "hykj" + private_key: "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw==" + timeout: 10s + logging: + enabled: true + log_dir: "logs/external_services" + service_name: "rongxing" + use_daily: true + enable_level_separation: true + level_configs: + info: + max_size: 100 + max_backups: 5 + max_age: 30 + compress: true + error: + max_size: 200 + max_backups: 10 + max_age: 90 + compress: true + warn: + max_size: 100 + max_backups: 5 + max_age: 30 + compress: true + diff --git a/configs/env.development.yaml b/configs/env.development.yaml index 7812a61..e3e9812 100644 --- a/configs/env.development.yaml +++ b/configs/env.development.yaml @@ -63,6 +63,66 @@ esign: sign_field_style: 1 client_type: "ALL" redirect_url: "http://localhost:5173/certification/callback/sign" + + +# =========================================== +# 📝 法大大服务配置(FASC OpenAPI v5.1) +# 应用名称:海宇数据 | AppId:80005307 | 状态:待上线 +# =========================================== +fadada: + app_id: "80005307" + app_secret: "SM0R81LFZBOAFQY6MV6SRXUJZKWHPLW7" + # 待上线/联调用测试环境;正式上线后改为 https://api.fadada.com/api/v5/ + server_url: "https://uat-api.fadada.com/api/v5/" + open_corp_id: "cc1a281c715e4f289b681eb15d512e3f" # 海宇数据在法大大侧企业 ID + # 免验证签场景码(businessId):乙方 requestVerifyFree=true 时必传 + no_auth_scene_code: "d2a3af30bf4fa5740bc65ffff446ab16" + template_id: "1784281786354133853" # 合作协议模板 ID(填单/签署) + # 签署模板中的参与方标识(actorId),必须与模板「签署角色」名称完全一致 + party_a_actor_id: "甲方" + party_b_actor_id: "乙方" + # 签署模板内文档 docId(填单必填);可从模板详情/任务详情 docs[].docId 获取,留空则创建后自动解析 + template_doc_id: "" + # 模板控件编码(fieldId)——与法大大后台控件编码一致 + template_fields: + # 协议编号:线上由 GenerateContractCode 生成,格式 HYDATA-{当天YYYYMMDD}-R{6位随机数}(R=入驻),已有 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: "海宇数据-合作协议" + expire_days: 7 + retry_count: 3 + auth: + # 认证完成后顶层跳回企业入驻页(勿用 callback 页留在 iframe 内) + redirect_url: "http://localhost:5173/profile/certification" + sign: + # 甲方签署完成后回跳(iframe 内会顶层跳出到入驻页) + redirect_url: "http://localhost:5173/certification/callback/fadada/sign" + callback: + enabled: true + # =========================================== # 🌍 西部数据配置 # =========================================== @@ -208,3 +268,36 @@ development: cors_allowed_origins: "http://localhost:5173,http://localhost:8080" cors_allowed_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS" cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With,Access-Id" + +# =========================================== +# ✨ 戎行配置需开启vpn进行其企业内网访问。 +# =========================================== +rongxing: + url: "http://192.168.3.43:7007" # 基础地址,登录=/auth/login,查询=/third/loan/info360 + account: "haiyukeji" + password: "haiyukeji@123" # 明文,请求时自动 Base64 + app_id: "hykj" + private_key: "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw==" + timeout: 10s + logging: + enabled: true + log_dir: "logs/external_services" + service_name: "rongxing" + use_daily: true + enable_level_separation: true + level_configs: + info: + max_size: 100 + max_backups: 5 + max_age: 30 + compress: true + error: + max_size: 200 + max_backups: 10 + max_age: 90 + compress: true + warn: + max_size: 100 + max_backups: 5 + max_age: 30 + compress: true \ No newline at end of file diff --git a/configs/env.production.yaml b/configs/env.production.yaml index d33ddfd..d3c7815 100644 --- a/configs/env.production.yaml +++ b/configs/env.production.yaml @@ -93,6 +93,65 @@ esign: sign_field_style: 1 client_type: "ALL" redirect_url: "https://console.haiyudata.com/certification/callback/sign" + +# =========================================== +# 📝 法大大服务配置(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 生成,格式 HYDATA-{当天YYYYMMDD}-R{6位随机数}(R=入驻),已有 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: "海宇数据-合作协议" + expire_days: 7 + retry_count: 3 + auth: + # 认证完成后顶层跳回企业入驻页(勿用 callback 页留在 iframe 内) + redirect_url: "https://console.haiyudata.com/profile/certification" + sign: + # 甲方签署完成后回跳(iframe 内会顶层跳出到入驻页) + redirect_url: "https://console.haiyudata.com/certification/callback/fadada/sign" + callback: + enabled: true + # =========================================== # 💰 支付宝支付配置 # =========================================== @@ -175,3 +234,36 @@ sms: captcha_enabled: false # 是否启用滑块验证码 captcha_secret: "" # 阿里云验证码密钥(可选) scene_id: "wynt39to" # 阿里云验证码场景ID + +# =========================================== +# ✨ 戎行配置需开启vpn进行其企业内网访问。 +# =========================================== +rongxing: + url: "http://192.168.3.43:7007" # 基础地址,登录=/auth/login,查询=/third/loan/info360 + account: "haiyukeji" + password: "haiyukeji@123" # 明文,请求时自动 Base64 + app_id: "hykj" + private_key: "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw==" + timeout: 10s + logging: + enabled: true + log_dir: "logs/external_services" + service_name: "rongxing" + use_daily: true + enable_level_separation: true + level_configs: + info: + max_size: 100 + max_backups: 5 + max_age: 30 + compress: true + error: + max_size: 200 + max_backups: 10 + max_age: 90 + compress: true + warn: + max_size: 100 + max_backups: 5 + max_age: 30 + compress: true \ No newline at end of file diff --git a/docs/企业入驻-e签宝功能分析.md b/docs/企业入驻-e签宝功能分析.md new file mode 100644 index 0000000..bf240c2 --- /dev/null +++ b/docs/企业入驻-e签宝功能分析.md @@ -0,0 +1,443 @@ +# 企业入驻 × e签宝 功能分析 + +> 文档基于 `hyapi-server` 与 `hyapi-consoleweb` 代码梳理,最后更新:2026-07-13 + +## 1. 功能概述 + +企业入驻是独立的**认证域(certification)**,通过自研 Go SDK(`internal/shared/esign`)对接 e签宝 OpenAPI,引导企业用户完成: + +1. 填写并提交企业信息(含人工审核) +2. e签宝机构实名认证 +3. 基于模板生成合作协议并电子签署 +4. 入驻完成(激活钱包、API 用户等) + +**核心实现文件:** + +| 层级 | 路径 | +|------|------| +| 业务编排 | `internal/application/certification/certification_application_service_impl.go` | +| 状态机实体 | `internal/domains/certification/entities/certification.go` | +| e签宝 SDK | `internal/shared/esign/` | +| HTTP 路由 | `internal/infrastructure/http/routes/certification_routes.go` | +| 前端主流程 | `hyapi-consoleweb/src/pages/certification/` | + +--- + +## 2. 涉及数据表 + +表结构通过 GORM `AutoMigrate` 自动创建(`internal/app/app.go`),无独立 SQL migration。 + +### 2.1 认证域(核心) + +#### `certifications` — 认证主表(聚合根) + +每个用户最多一条认证记录(`user_id` 唯一),贯穿全流程并存储 e签宝 关键 ID/链接。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | varchar(64) | 认证申请 ID | +| `user_id` | varchar(36) | 用户 ID(唯一) | +| `status` | varchar(50) | 认证状态(见第 3 节) | +| `info_submitted_at` | datetime | 企业信息审核通过时间 | +| `enterprise_verified_at` | datetime | e签宝 企业认证完成时间 | +| `contract_applied_at` | datetime | 申请签署时间 | +| `contract_signed_at` | datetime | 签署完成时间 | +| `completed_at` | datetime | 入驻完成时间 | +| `contract_file_created_at` | datetime | 合同文件生成时间(用于 50 分钟过期判断) | +| `auth_flow_id` | varchar(500) | e签宝 企业认证流程 ID | +| `auth_url` | varchar(500) | e签宝 企业认证页面链接 | +| `contract_code` | varchar(100) | 合作协议编号(如 `HYDATA-20260713-R123456`) | +| `contract_file_id` | varchar(500) | e签宝 合同文件 ID | +| `esign_flow_id` | varchar(500) | e签宝 签署流程 ID | +| `contract_url` | varchar(500) | 合同预览/下载 URL | +| `contract_sign_url` | varchar(500) | 合同签署链接 | +| `failure_reason` / `failure_message` | varchar/text | 失败原因与详情 | +| `retry_count` | int | 重试次数(上限 3) | +| `last_transition_*` | — | 状态转换审计字段 | + +#### `enterprise_info_submit_records` — 企业信息提交与人工审核 + +用户每次提交企业信息时写入,承载审核材料与审核结果。 + +| 字段分组 | 主要字段 | 说明 | +|----------|----------|------| +| 企业四要素 | `company_name`、`unified_social_code`、`legal_person_*`、`enterprise_address` | 企业基本信息 | +| 授权代表 | `authorized_rep_name/id/phone`、`authorized_rep_id_image_urls` | 授权代表信息(身份证图片为 JSON 数组) | +| 资质材料 | `business_license_image_url`、`office_place_image_urls`、`api_usage`、`scenario_attachment_urls` | 营业执照、办公场地、业务场景说明及附件 | +| 前置校验 | `status` | `submitted` / `verified` / `failed`(西部数据三真等) | +| 人工审核 | `manual_review_status` | `pending` / `approved` / `rejected` | +| 审核人 | `manual_reviewer_id`、`manual_review_remark`、`manual_reviewed_at` | 管理员审核信息 | + +#### `esign_contract_generate_records` — 合同生成记录 + +| 字段 | 说明 | +|------|------| +| `certification_id`、`user_id` | 关联认证与用户 | +| `template_id` | e签宝 模板 ID | +| `contract_file_id`、`contract_url`、`contract_name` | 生成结果 | +| `status` | `success` / `failed` | +| `fill_time` | 填单时间 | + +#### `esign_contract_sign_records` — 合同签署记录 + +| 字段 | 说明 | +|------|------| +| `esign_flow_id`、`contract_file_id` | e签宝 流程与文件 | +| `sign_url`、`sign_short_url` | 签署链接 | +| `signed_file_url` | 已签署文件 URL | +| `status` | `pending` / `signing` / `success` / `failed` / `expired` | +| `signer_name/phone/id_card` | 签署人信息 | +| `request_at`、`signed_at`、`expired_at` | 时间戳(默认 7 天过期) | + +### 2.2 用户域(认证完成后写入) + +#### `enterprise_infos` — 已认证企业信息 + +认证通过后从提交记录同步,供充值、开票、API 调用等使用;与用户一对一。 + +| 字段 | 说明 | +|------|------| +| `user_id` | 用户 ID(唯一索引) | +| `company_name` | 企业名称 | +| `unified_social_code` | 统一社会信用代码 | +| `legal_person_name/id/phone` | 法人信息 | +| `enterprise_address` | 企业地址 | + +#### `contract_infos` — 已签署合同 + +签署完成后写入,一个企业可有多份合同。 + +| 字段 | 说明 | +|------|------| +| `enterprise_info_id`、`user_id` | 关联企业与用户 | +| `contract_code` | 合同编号(唯一) | +| `contract_name` | 合同名称 | +| `contract_type` | `cooperation`(入驻)/ `resign`(补签) | +| `contract_file_id` | e签宝 文件 ID | +| `contract_file_url` | 七牛云永久存储链接 | + +### 2.3 入驻完成后关联表(非 e签宝 直接写入,但属完成动作) + +| 表名 | 说明 | +|------|------| +| `wallets` | 创建用户钱包 | +| `api_users` | 创建 API 调用身份 | +| `users` | 标记用户已完成认证(`CompleteCertification`) | + +--- + +## 3. 认证状态机 + +``` +pending + └─► info_pending_review(提交企业信息,等待人工审核) + ├─► info_rejected(审核拒绝 / e签宝 认证失败) + └─► info_submitted(审核通过,持有 auth_url) + └─► enterprise_verified(e签宝 机构实名成功,已生成合同) + └─► contract_applied(已申请签署,持有 contract_sign_url) + ├─► contract_signed ──► completed(入驻完成) + ├─► contract_rejected(拒签,可重试) + └─► contract_expired(超时,可重试) +``` + +| 状态值 | 中文 | 进度 | 用户操作 | +|--------|------|------|----------| +| `pending` | 待认证 | 0% | 填写企业信息 | +| `info_pending_review` | 企业信息待审核 | 15% | 等待管理员 | +| `info_submitted` | 已提交企业信息 | 25% | 完成 e签宝 企业认证 | +| `enterprise_verified` | 已企业认证 | 50% | 申请签署合同 | +| `contract_applied` | 已申请签署 | 75% | 在 iframe 中签署 | +| `contract_signed` | 已签署合同 | 100% | 等待系统处理 | +| `completed` | 认证完成 | 100% | 可使用全部功能 | +| `info_rejected` | 企业信息被拒绝 | — | 修正后重新提交 | +| `contract_rejected` | 合同被拒签 | — | 重新申请 | +| `contract_expired` | 合同签署超时 | — | 重新申请 | + +--- + +## 4. 主要业务流程 + +### 4.1 流程总览 + +```mermaid +flowchart TD + A[用户注册/登录] --> B[POST /enterprise-info 提交企业信息] + B --> C[短信验证 + 西部数据三真校验] + C --> D[状态: info_pending_review] + D --> E{管理员审核} + E -->|拒绝| F[info_rejected] + E -->|通过| G[调用 e签宝 /v3/org-auth-url] + G --> H[状态: info_submitted
写入 auth_url] + H --> I[iframe 完成机构实名] + I --> J{确认方式} + J -->|回调 AUTH_PASS| K[enterprise_verified] + J -->|轮询 GET /details 或 POST /confirm-auth| K + K --> L[模板填单生成合同 PDF] + L --> M[POST /apply-contract 申请签署] + M --> N[状态: contract_applied] + N --> O[iframe 签署合同] + O --> P{确认方式} + P -->|POST /confirm-sign 轮询| Q[contract_signed → completed] + Q --> R[下载已签文件 → 七牛 → contract_infos] + R --> S[创建钱包 + API 用户] +``` + +### 4.2 步骤一:提交企业信息 + +**接口:** `POST /api/v1/certifications/enterprise-info`(JWT + 日限流) + +**处理逻辑(`SubmitEnterpriseInfo`):** + +1. 防重复:若已有 `info_pending_review` 记录则拒绝重复提交 +2. 短信验证码校验(场景 `SMSSceneCertification`) +3. 统一社会信用代码唯一性检查(`enterprise_infos` + `enterprise_info_submit_records`) +4. 字段格式校验 + **西部数据三真**(`ValidateWithWestdex`) +5. 写入 `enterprise_info_submit_records`(状态 `verified`) +6. 认证状态转为 `info_pending_review` +7. 企业微信通知管理员待审核 + +> **此阶段不调用 e签宝。** + +**辅助接口:** + +- `POST /ocr/business-license` — 营业执照 OCR +- `POST /upload` — 认证材料上传七牛云 + +### 4.3 步骤二:管理员人工审核 + +**接口:** + +- `GET /api/v1/certifications/admin/submit-records` — 列表 +- `GET /api/v1/certifications/admin/submit-records/:id` — 详情 +- `POST /api/v1/certifications/admin/submit-records/:id/approve` — 通过 +- `POST /api/v1/certifications/admin/submit-records/:id/reject` — 拒绝 + +**审核通过(`AdminApproveSubmitRecord`):** + +1. 调用 `esignClient.GenerateEnterpriseAuth()` → e签宝 `POST /v3/org-auth-url` +2. 若企业已在 e签宝 实名(错误含「已实名」),则 `QueryOrgIdentityInfo` 后直接 `completeEnterpriseVerification` +3. 否则状态 → `info_submitted`,写入 `auth_flow_id`、`auth_url` +4. 提交记录 `manual_review_status` → `approved` + +**经办人信息:** 当前使用**法人**作为 e签宝 经办人(`TransactorName = LegalPersonName`),未使用授权代表字段。 + +### 4.4 步骤三:e签宝 企业实名认证 + +**前端:** `EnterpriseVerify.vue` 通过 iframe 加载 `metadata.auth_url` + +**完成确认(双路径):** + +| 路径 | 触发 | 处理 | +|------|------|------| +| 服务端回调 | `POST /api/v1/certifications/callbacks/esign`,`action=AUTH_PASS` 且 `authType=ORG` | `HandleEsignCallback` → `completeEnterpriseVerification` | +| 前端轮询 | `GET /details` 或 `POST /confirm-auth` | 查询 `GET /v3/organizations/identity-info`,`realnameStatus==1` 时推进 | + +**Redirect:** 用户完成后跳转 `auth.redirect_url`(如 `/certification/callback/auth`),`IframeCallback.vue` 通过 `postMessage` 通知父页面刷新状态。 + +**`completeEnterpriseVerification` 动作:** + +1. 状态 → `enterprise_verified` +2. 企业信息写入 `enterprise_infos` +3. 调用 `FillTemplate` 填控件生成合同(见 4.5) +4. 保存 `contract_file_id`、`contract_url`、`contract_code` + +### 4.5 步骤四:生成合作协议 + +**e签宝 API:** `POST /v3/files/create-by-doc-template` + +**模板控件映射:** + +| 控件 key | 填入内容 | +|----------|----------| +| `jfqym` / `jfqym2` | 企业名称 | +| `jfsqdb` | 授权代表姓名(无则用法人的) | +| `jftyshxydm` | 统一社会信用代码 | +| `jflxdz` | 企业地址 | +| `xybh` | 协议编号 | +| `qsrq1` / `qsrq2` / `qsrq3` | 签署日期 | + +**合同编号规则:** `HYDATA-YYYYMMDD-R{6位随机数}`(R = 入驻) + +**过期策略:** 合同文件生成后 **50 分钟**过期,`GET /details` 时会自动重新生成(`IsContractFileNeedUpdate`)。 + +### 4.6 步骤五:申请并签署合同 + +**接口:** `POST /api/v1/certifications/apply-contract` + +**前置条件:** 状态为 `enterprise_verified` + +**处理逻辑:** + +1. `CreateSignFlow` → `POST /v3/sign-flow/create-by-file`(甲方手动签 + 乙方自动盖章) +2. `GetSignURL` → `POST /v3/sign-flow/{id}/sign-url` +3. 状态 → `contract_applied`,保存 `esign_flow_id`、`contract_sign_url` + +**签署确认:** + +- 前端 iframe 加载 `contract_sign_url`,完成后 redirect 到 `sign.redirect_url` +- `POST /confirm-sign` 或 `GET /details` 轮询 `QuerySignFlowDetail` + +**签署状态码(业务使用):** + +| SignFlowStatus | 含义 | 系统动作 | +|----------------|------|----------| +| `2` | 签署完成 | → `contract_signed` → `completed` | +| `7` | 拒签 | → `contract_rejected` | +| `5` | 过期 | → `contract_expired` | +| 其他 | 签署中 | 继续轮询 | + +### 4.7 步骤六:入驻完成 + +**`handleContractAfterSignComplete`:** + +1. `DownloadSignedFile` 从 e签宝 下载已签 PDF +2. 上传七牛云获取永久 URL +3. 写入 `contract_infos` +4. `completeUserActivationWithoutContract`: + - 创建 `wallets` + - 创建 `api_users` + - 用户域标记认证完成 + - 企业微信通知「企业认证成功」 + +--- + +## 5. API 端点汇总 + +### 5.1 本系统 REST API + +前缀:`/api/v1/certifications` + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| GET | `/details` | JWT | 获取/创建认证详情,自动同步 e签宝 状态 | +| POST | `/enterprise-info` | JWT + 日限流 | 提交企业信息 | +| POST | `/ocr/business-license` | JWT | 营业执照 OCR | +| POST | `/upload` | JWT | 认证材料上传 | +| POST | `/confirm-auth` | JWT | 轮询确认企业实名 | +| POST | `/apply-contract` | JWT | 申请合同签署 | +| POST | `/confirm-sign` | JWT | 轮询确认签署状态 | +| POST | `/callbacks/esign` | 无 | e签宝 服务端回调 | +| GET | `` | JWT 管理员 | 认证列表 | +| POST | `/admin/complete-without-contract` | 管理员 | 跳过合同直接完成认证 | +| POST | `/admin/transition-status` | 管理员 | 手动变更状态 | +| GET/POST | `/admin/submit-records[...]` | 管理员 | 提交记录 CRUD + 审核 | + +### 5.2 e签宝 OpenAPI(SDK 封装) + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/v3/org-auth-url` | 获取机构认证页面链接 | +| GET | `/v3/organizations/identity-info` | 查询机构是否已实名 | +| POST | `/v3/files/create-by-doc-template` | 模板填单生成 PDF | +| POST | `/v3/sign-flow/create-by-file` | 创建签署流程 | +| POST | `/v3/sign-flow/{id}/sign-url` | 获取签署链接 | +| GET | `/v3/sign-flow/{id}/detail` | 查询签署状态 | +| GET | `/v3/sign-flow/{id}/file-download-url` | 下载已签署文件 | + +### 5.3 前端路由 + +| 路由 | 说明 | +|------|------| +| `/profile/certification` | 企业入驻主流程(5 步) | +| `/certification/callback/auth` | e签宝 认证完成 redirect | +| `/certification/callback/sign` | e签宝 签署完成 redirect | +| `/admin/certification-reviews` | 管理端审核列表 | + +--- + +## 6. e签宝 配置项 + +配置文件:`config.yaml` / `configs/env.{development,production}.yaml` + +```yaml +esign: + app_id: "..." # 应用 ID + app_secret: "..." # 应用密钥(API 签名) + server_url: "..." # 沙箱: https://smlopenapi.esign.cn + # 生产: https://openapi.esign.cn + template_id: "..." # 合作协议模板 ID + contract: + name: "海宇数据-合作协议" + expire_days: 7 # 签署链接有效期(天) + retry_count: 3 + auth: + org_auth_modes: ["PSN_MOBILE3"] + default_auth_mode: "PSN_MOBILE3" + psn_auth_modes: ["PSN_MOBILE3", "PSN_IDCARD"] + willingness_auth_modes: ["CODE_SMS"] + redirect_url: "https://console.haiyudata.com/certification/callback/auth" + sign: + auto_finish: true + sign_field_style: 1 + client_type: "ALL" + redirect_url: "https://console.haiyudata.com/certification/callback/sign" +``` + +Go 结构:`config.EsignConfig` → 注入为 `esign.Config`(`container.go`)。 + +--- + +## 7. 架构与代码组织 + +``` +hyapi-server/ +├── internal/ +│ ├── application/certification/ # 应用服务(主业务编排) +│ ├── domains/certification/ # 领域模型、状态机、枚举 +│ ├── domains/user/entities/ # enterprise_infos, contract_infos +│ ├── infrastructure/ +│ │ ├── http/handlers/certification_handler.go +│ │ ├── http/routes/certification_routes.go +│ │ └── database/repositories/certification/ +│ └── shared/esign/ # e签宝 SDK +│ ├── client.go # 统一入口 +│ ├── orgauth_service.go # 机构认证 +│ ├── org_identity.go # 实名查询 +│ ├── template_service.go # 模板填单 +│ ├── signflow_service.go # 签署流程 +│ └── fileops_service.go # 文件下载/状态查询 + +hyapi-consoleweb/ +└── src/pages/certification/ + ├── index.vue # 主流程容器 + ├── IframeCallback.vue # e签宝 redirect 回调 + └── components/ + ├── EnterpriseInfo.vue # 填写企业信息 + ├── ManualReviewPending.vue # 人工审核等待 + ├── EnterpriseVerify.vue # e签宝 认证 iframe + ├── ContractPreview.vue / ContractSign.vue + └── CertificationComplete.vue +``` + +--- + +## 8. 注意事项与已知问题 + +1. **回调验签未启用** + `HandleEsignCallback` 中 `esign.VerifySignature` 已被注释,生产环境存在安全风险。 + +2. **遗留代码未接入** + `internal/infrastructure/external/esign/certification_esign_service.go` 为模拟实现,未注册到 DI;`CertificationWorkflowOrchestrator` 部分方法为 stub。 + +3. **双路径状态同步** + 企业认证支持「e签宝 回调」与「前端轮询」两条路径;合同签署主要依赖 `confirm-sign` / `GET /details` 主动查询,回调仅处理 `AUTH_PASS`。 + +4. **经办人默认法人** + 生成认证链接时经办人使用法人信息,授权代表字段仅用于合同模板 `jfsqdb` 控件。 + +5. **签署状态码注释不一致** + SDK 注释与业务代码对 `SignFlowStatus` 的含义存在差异,排查问题时以 `checkAndUpdateSignStatus` 中的判断为准(`2`=成功,`7`=拒签,`5`=过期)。 + +6. **与 API 实名产品无关** + `IVYZSQ0E` / `IVYZFO5K` 等数据 API 的身份证实名校验属于 API 调用产品,与本入驻流程无关。 + +--- + +## 9. 入驻完成后的系统能力 + +认证状态为 `completed` 后,用户可使用: + +- 钱包充值(`Wallet.vue`) +- 开票申请(`Invoice.vue`) +- API 调用(需 `api_users`) +- IP 白名单等需认证页面(`useCertification` / `CertificationBanner` 检查 `isCertified`) diff --git a/docs/多平台签署整合实现方案.md b/docs/多平台签署整合实现方案.md new file mode 100644 index 0000000..c7d09d1 --- /dev/null +++ b/docs/多平台签署整合实现方案.md @@ -0,0 +1,619 @@ +# 多平台签署整合实现方案(e签宝 + 法大大) + +> 基于现有企业入驻流程的改造设计,供评审后决定实施范围与优先级。 +> 关联文档:[企业入驻-e签宝功能分析.md](./企业入驻-e签宝功能分析.md) + +--- + +## 1. 背景与目标 + +### 1.1 现状 + +当前企业入驻的**企业实名认证 + 合同签署**全流程绑定在 **e签宝** 单一平台: + +- 管理员审核通过后,直接调用 e签宝 `/v3/org-auth-url` 生成 `auth_url` +- 企业认证、模板填单、签署流程、回调/轮询均硬编码在 `CertificationApplicationServiceImpl` + `internal/shared/esign` + +### 1.2 目标 + +1. `certifications` 主表新增 **`sign_platform`(签署平台)** 字段 +2. 管理员审核通过后,**由用户选择签署平台**(e签宝 / 法大大) +3. 根据所选平台发放对应的**企业认证链接**与**合同签署链接** +4. 尽量复用现有状态机,降低对已完成用户的影响 + +### 1.3 非目标(本期可不做) + +- 同一用户在不同平台各签一份合同 +- 签署中途切换平台(选定后锁定,除非管理员重置) +- 替换掉 e签宝 的全部历史数据 + +--- + +## 2. 改造后流程设计 + +### 2.1 新增步骤:选择签署平台 + +在「人工审核通过」与「企业认证 iframe」之间插入一步: + +``` +填写企业信息 + → 人工审核(info_pending_review) + → 【新增】选择签署平台(info_submitted 前/后,见 2.2) + → 企业实名认证(info_submitted) + → 合同预览与签署(enterprise_verified → contract_applied → completed) +``` + +### 2.2 平台选择时机(推荐方案) + +| 方案 | 时机 | 优点 | 缺点 | +|------|------|------|------| +| **A(推荐)** | 审核通过后、生成认证链接前,用户主动选择 | 符合需求;认证与签署同一平台;不浪费第三方调用 | 多一个前端步骤 | +| B | 提交企业信息时一并选择 | 步骤少 | 审核拒绝后需重选;用户尚不清楚平台差异 | +| C | 仅合同阶段选择,认证仍固定 e签宝 | 改动小 | 法大大合同要求法大大侧企业实名,链路不完整 | + +**推荐采用方案 A**,状态流转如下: + +```mermaid +flowchart TD + A[info_pending_review] --> B[管理员审核通过] + B --> C{sign_platform 已选择?} + C -->|否| D[前端展示平台选择页
POST /select-sign-platform] + D --> E[写入 sign_platform] + E --> F[按平台生成 auth_url] + C -->|是| F + F --> G[info_submitted + auth_url] + G --> H[iframe 企业认证] + H --> I[enterprise_verified + 生成合同] + I --> J[apply-contract 按平台发起签署] + J --> K[completed] +``` + +### 2.3 平台锁定规则 + +| 规则 | 说明 | +|------|------| +| 可选时机 | `info_pending_review` 审核通过后 ~ `info_submitted` 之前(`sign_platform` 为空) | +| 锁定时机 | 用户确认平台后写入 `sign_platform`,并生成该平台认证链接 | +| 不可变更 | 进入 `info_submitted` 后不允许用户自行切换 | +| 管理员重置 | 仅管理员可将 `sign_platform` 置空并重置到待选平台状态(见 4.3) | + +--- + +## 3. 数据库变更 + +### 3.1 `certifications` 主表 + +新增字段: + +```sql +ALTER TABLE certifications + ADD COLUMN sign_platform VARCHAR(20) DEFAULT NULL + COMMENT '签署平台: esign | fadada,NULL 表示尚未选择'; +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `sign_platform` | varchar(20), nullable | `esign` / `fadada` / `NULL` | + +**现有 e签宝 字段复用策略(推荐不重命名):** + +| 现有字段 | 多平台语义 | +|----------|------------| +| `auth_flow_id` | 第三方企业认证流程 ID(平台无关) | +| `auth_url` | 第三方企业认证页面 URL | +| `contract_file_id` | 第三方合同文件 ID | +| `esign_flow_id` | 第三方签署流程 ID(建议后续迭代改名为 `sign_flow_id`) | +| `contract_sign_url` | 第三方签署页面 URL | + +> 首期可保留 `esign_flow_id` 列名不动,避免大面积迁移;在代码层用 `SignFlowID` 别名理解即可。 + +### 3.2 子记录表(建议同步加平台字段) + +| 表 | 变更 | +|----|------| +| `esign_contract_generate_records` | 新增 `sign_platform varchar(20)`;后期可重命名为 `contract_generate_records` | +| `esign_contract_sign_records` | 新增 `sign_platform varchar(20)`;后期可重命名为 `contract_sign_records` | +| `contract_infos` | 新增 `sign_platform varchar(20)`,便于历史合同溯源 | + +### 3.3 枚举定义(Go) + +```go +// internal/domains/certification/enums/sign_platform.go +type SignPlatform string + +const ( + SignPlatformEsign SignPlatform = "esign" + SignPlatformFadada SignPlatform = "fadada" +) + +func IsValidSignPlatform(p SignPlatform) bool { ... } +``` + +### 3.4 历史数据迁移 + +```sql +-- 已有认证记录默认归属 e签宝 +UPDATE certifications SET sign_platform = 'esign' WHERE sign_platform IS NULL; + +UPDATE esign_contract_generate_records SET sign_platform = 'esign' WHERE sign_platform IS NULL; +UPDATE esign_contract_sign_records SET sign_platform = 'esign' WHERE sign_platform IS NULL; +UPDATE contract_infos SET sign_platform = 'esign' WHERE sign_platform IS NULL; +``` + +对于**进行中**且 `sign_platform IS NULL` 的记录:迁移后视为已选 e签宝,与当前行为一致。 + +--- + +## 4. 后端架构改造 + +### 4.1 核心思路:签署平台抽象层(Strategy + Registry) + +将 e签宝 特有逻辑从 `CertificationApplicationServiceImpl` 中抽离,定义统一接口: + +``` +internal/domains/certification/ports/sign_platform_provider.go # 接口(端口) +internal/infrastructure/external/esign/esign_provider.go # e签宝 实现 +internal/infrastructure/external/fadada/fadada_provider.go # 法大大 实现 +internal/infrastructure/external/signplatform/registry.go # 按 sign_platform 路由 +``` + +**接口草案:** + +```go +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) + QuerySignStatus(ctx context.Context, flowID string) (*SignStatusResult, error) + DownloadSignedFiles(ctx context.Context, flowID string) ([]*SignedFile, error) + + // 回调 + ParseCallback(ctx context.Context, raw []byte, headers map[string]string) (*CallbackEvent, error) + VerifyCallback(ctx context.Context, raw []byte, headers map[string]string) error +} +``` + +`CertificationApplicationServiceImpl` 中所有 `s.esignClient.XXX()` 改为: + +```go +provider := s.platformRegistry.Get(cert.SignPlatform) +provider.GenerateEnterpriseAuth(...) +``` + +### 4.2 需改造的后端触点(按优先级) + +| 方法 | 现状 | 改造 | +|------|------|------| +| `AdminApproveSubmitRecord` | 直接 `esignClient.GenerateEnterpriseAuth` | 审核通过后**不立即**生成链接;仅将状态保持在「待选平台」或新增中间状态 | +| **新增** `SelectSignPlatform` | 无 | 用户选择平台 → 写 `sign_platform` → 调 provider 生成 `auth_url` → `info_submitted` | +| `ConfirmAuth` / `checkAndCompleteEnterpriseVerification` | 查 e签宝 identity | 调当前 provider 的 `QueryOrgVerified` | +| `generateAndAddContractFile` | e签宝 FillTemplate | 调 provider 的 `GenerateContractFile` | +| `ApplyContract` / `generateContractAndSignURL` | e签宝 SignFlow | 调 provider 的 `CreateSignFlow` | +| `checkAndUpdateSignStatus` | e签宝 QuerySignFlowDetail | 调 provider 的 `QuerySignStatus` | +| `handleContractAfterSignComplete` | e签宝 DownloadSignedFile | 调 provider 的 `DownloadSignedFiles` | +| `HandleEsignCallback` | 仅处理 e签宝 | 拆为 `/callbacks/esign` + `/callbacks/fadada`,或统一入口按 header/字段分发 | + +### 4.3 状态机微调(可选两种做法) + +**做法 1(最小改动,推荐):不新增状态** + +- 管理员审核通过后,认证状态仍为 `info_pending_review` 或新增语义:`info_submitted` 但 `sign_platform=NULL` 且 `auth_url` 为空 +- 前端根据 `metadata.need_select_platform=true` 展示平台选择页 +- 用户选完平台后才进入 `info_submitted`(有 `auth_url`) + +**做法 2:新增状态 `platform_pending`** + +- 语义更清晰,但需改 `certification_status.go`、前端步骤映射、管理端筛选 +- 适合后续平台数量继续增加时 + +**推荐做法 1**,通过 metadata 驱动前端,避免状态机膨胀: + +```json +{ + "status": "info_pending_review", + "metadata": { + "manual_review_status": "approved", + "need_select_platform": true, + "available_platforms": ["esign", "fadada"] + } +} +``` + +或审核通过后直接将状态推到 `info_submitted`,但 `auth_url` 为空、`need_select_platform=true`。 + +### 4.4 新增 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/certifications/sign-platforms` | 返回可选平台列表(名称、图标、说明、是否可用) | +| POST | `/api/v1/certifications/select-sign-platform` | 用户选择平台,生成认证链接 | +| POST | `/api/v1/certifications/callbacks/fadada` | 法大大回调(与 esign 并列) | +| POST | `/api/v1/certifications/admin/reset-sign-platform` | 管理员重置平台选择(可选) | + +**`SelectSignPlatform` 请求体:** + +```json +{ + "sign_platform": "fadada" +} +``` + +**响应(与现有 details 结构对齐):** + +```json +{ + "status": "info_submitted", + "sign_platform": "fadada", + "metadata": { + "auth_url": "https://...", + "next_action": "请完成企业认证" + } +} +``` + +**`apply-contract` / `confirm-sign` / `confirm-auth`**:无需改路径,内部按 `cert.SignPlatform` 路由。 + +### 4.5 管理员审核逻辑调整 + +当前 `AdminApproveSubmitRecord` 审核通过后会**立即**调用 e签宝。改造后: + +``` +审核通过 → 仅更新 submit_record.manual_review_status = approved + → 认证状态:保持 info_pending_review(或推到 info_submitted 但无 auth_url) + → 不调用任何第三方 +用户选择平台 → SelectSignPlatform → 调对应 provider → 写入 auth_url → info_submitted +``` + +管理员后台可展示 `sign_platform` 列,便于客服排查。 + +--- + +## 5. 法大大接入方案 + +### 5.1 平台与文档 + +- 产品:法大大 **FASC OpenAPI v5.1**(公有云) +- 开放平台:`https://cloud.fadada.com` / 开发文档 `https://dev.fadada.com` +- 参考 SDK:`fasc-openapi-go-sdk`(官方 Go SDK,建议优先使用) + +### 5.2 需准备的法大大侧资源 + +| 资源 | 用途 | +|------|------| +| AppID / AppSecret | API 鉴权 | +| openCorpId | 接入企业(海宇数据自身)在法大大侧的企业 ID | +| 应用模板 / 签署模板 | 对应合作协议 PDF 模板 | +| 测试环境域名 | 沙箱联调 | +| 回调地址 | `https://{domain}/api/v1/certifications/callbacks/fadada` | + +### 5.3 法大大 API 与 e签宝 映射 + +| 业务步骤 | e签宝(现有) | 法大大 FASC(待接) | +|----------|---------------|---------------------| +| 获取企业认证链接 | `POST /v3/org-auth-url` | `Corp` 模块 - 获取企业认证/授权 URL | +| 查询企业是否已实名 | `GET /v3/organizations/identity-info` | `Corp` 模块 - 查询企业认证状态 | +| 模板生成合同文件 | `POST /v3/files/create-by-doc-template` | `AppTemplate` + `Doc` 模块 - 填写模板生成文档 | +| 创建签署流程 | `POST /v3/sign-flow/create-by-file` | `fadada.CreateSignFlow` → `/sign-task/create` 或 `/sign-task/create-with-template` | +| 获取签署链接 | `POST /v3/sign-flow/{id}/sign-url` | `fadada.GetSignURL` → `/sign-task/actor/get-url` | +| 查询签署状态 | `GET /v3/sign-flow/{id}/detail` | `fadada.QuerySignStatus` → `/sign-task/app/get-detail`(`task_finished`/`sign_completed`=完成) | +| 下载已签文件 | `GET /v3/sign-flow/{id}/file-download-url` | `fadada.DownloadSignedFiles` → `/sign-task/owner/get-download-url` | +| 回调 | `AUTH_PASS` 等 | `fadada.VerifyCallback` + `ParseCallback`(`X-FASC-*` HMAC-SHA256) | + +> 具体接口路径以法大大 v5.1 文档为准;上表为模块级映射,实施时按官方 SDK 方法名落地。 + +### 5.4 法大大合同模板控件 + +模板 ID:`1784013372537192273`(`config.yaml` → `fadada.template_id`) + +| 业务字段 | e签宝 控件 key | 法大大控件编码 (fieldId) | 配置键 | +|----------|----------------|--------------------------|--------| +| 协议编号 | `xybh` | 多控件同值(见 `config.yaml` `agreement_no` 列表) | `agreement_no` | +| 签订日期 | (系统) | `2039326319` | `contract_date` | +| 甲方企业名 | `jfqym` / `jfqym2` | `6920634255` | `party_a_name` | +| 甲方统一信用代码 | `jftyshxydm` | `8702060291` | `party_a_uscc` | +| 甲方联系地址 | `jflxdz` | `4557539440` | `party_a_address` | +| 甲方授权代表/法人 | `jfsqdb` | `8627432823` | `party_a_rep` | +| 甲方签署日期 | `qsrq1` / `qsrq3` | `6047306418` | `party_a_sign_date` | +| 乙方签署日期 | `qsrq2` | `3849218908` | `party_b_sign_date` | + +日期格式与现网一致:`2006年01月02日`。实现:`fadada.Client.FillTemplate` / `FillSignTaskFields`。 + +### 5.5 法大大 SDK 目录规划 + +``` +hyapi-server/internal/shared/fadada/ # 或 infrastructure/external/fadada/ +├── client.go # 封装官方 SDK Client +├── config.go +├── corp_service.go # 企业认证 +├── signtask_service.go # 签署任务 +├── template_service.go # 模板/文档 +├── callback.go # 回调解析与验签 +└── provider.go # 实现 SignPlatformProvider +``` + +--- + +## 6. 配置变更 + +### 6.1 config.yaml 新增法大大段 + +```yaml +# 签署平台全局开关 +sign_platform: + enabled: ["esign", "fadada"] # 前端可选列表 + default: "" # 空=强制用户选择 + +esign: + # 保持现有配置不变 + ... + +fadada: + app_id: "" + app_secret: "" + server_url: "https://api.fadada.com" # 以官方文档为准 + open_corp_id: "" # 海宇数据在法大大的企业 ID + template_id: "" # 应用签署模板 ID + contract: + name: "海宇数据-合作协议" + expire_days: 7 + auth: + redirect_url: "https://console.haiyudata.com/certification/callback/fadada/auth" + sign: + redirect_url: "https://console.haiyudata.com/certification/callback/fadada/sign" + callback: + enabled: true +``` + +### 6.2 Go Config 结构 + +```go +type SignPlatformConfig struct { + Enabled []string `mapstructure:"enabled"` + Default string `mapstructure:"default"` +} + +type FadadaConfig struct { + AppID string `mapstructure:"app_id"` + AppSecret string `mapstructure:"app_secret"` + ServerURL string `mapstructure:"server_url"` + OpenCorpID string `mapstructure:"open_corp_id"` + TemplateID string `mapstructure:"template_id"` + Contract ContractConfig `mapstructure:"contract"` + Auth AuthConfig `mapstructure:"auth"` + Sign SignConfig `mapstructure:"sign"` + Callback CallbackConfig `mapstructure:"callback"` +} +``` + +--- + +## 7. 前端改造 + +### 7.1 新增步骤与组件 + +``` +certification/components/ +├── PlatformSelect.vue # 【新增】签署平台选择 +├── EnterpriseVerify.vue # 改造:支持不同平台 iframe / 跳转方式 +├── ContractSign.vue # 改造:按平台展示签署页 +└── ... +``` + +**步骤条调整:** + +```javascript +const certificationSteps = [ + { key: 'enterprise_info', title: '填写企业信息', ... }, + { key: 'manual_review', title: '人工审核', ... }, + { key: 'platform_select', title: '选择签署平台', ... }, // 新增 + { key: 'enterprise_verify', title: '企业认证', ... }, + { key: 'contract_sign', title: '签署合同', ... }, + { key: 'completed', title: '完成', ... }, +] +``` + +### 7.2 步骤路由逻辑(`setCurrentStepByStatus`) + +```javascript +// 审核已通过,但未选平台 +if (status === 'info_pending_review' && metadata.manual_review_status === 'approved' && !data.sign_platform) { + currentStep.value = 'platform_select' + return +} +// 或:status === 'info_submitted' && !metadata.auth_url && metadata.need_select_platform +``` + +### 7.3 PlatformSelect.vue 交互 + +- 调用 `GET /sign-platforms` 获取可选平台卡片(e签宝 / 法大大) +- 每张卡片展示:平台名称、说明、预计耗时、合规提示 +- 用户点击「确认选择」→ `POST /select-sign-platform` +- 成功后跳转 `enterprise_verify`,iframe 加载返回的 `auth_url` + +### 7.4 API 封装(`src/api/index.js`) + +```javascript +export const certificationApi = { + // ...existing + getSignPlatforms: () => request.get('/certifications/sign-platforms'), + selectSignPlatform: (data) => request.post('/certifications/select-sign-platform', data), +} +``` + +### 7.5 回调路由扩展 + +现有: + +- `/certification/callback/auth` +- `/certification/callback/sign` + +建议扩展(便于各平台 redirect_url 分离统计): + +- `/certification/callback/esign/auth` +- `/certification/callback/esign/sign` +- `/certification/callback/fadada/auth` +- `/certification/callback/fadada/sign` + +`IframeCallback.vue` 可增加 `platform` 路由参数,回调后 `postMessage` 携带 `{ type: 'CHECK_STATUS', scene, platform }`。 + +### 7.6 管理端 + +`certification-reviews/index.vue` 列表增加「签署平台」列;详情展示平台与第三方 flow ID,方便客服排查。 + +--- + +## 8. 回调与轮询策略 + +| 环节 | e签宝(现有) | 法大大(新增) | 统一策略 | +|------|---------------|----------------|----------| +| 企业认证完成 | 回调 `AUTH_PASS` + 轮询 identity | 法大大认证回调 + 轮询认证状态 | 两平台均保留「回调 + 轮询」双保险 | +| 合同签署完成 | 主要依赖 `confirm-sign` 轮询 | 同上 | `GET /details` 时按平台自动同步 | +| 验签 | 当前已注释 | **必须实现** | 生产环境两个平台都开启验签 | + +--- + +## 9. 实施分期建议 + +### Phase 0 — 准备(1~2 天) + +- [ ] 法大大开放平台开户、创建应用、获取 AppID/AppSecret/openCorpId +- [ ] 在法大大创建合作协议模板(控件与海宇业务字段对齐) +- [ ] 确认沙箱环境与回调公网可达 + +### Phase 1 — 抽象层 + 数据字段(3~5 天) + +- [x] `certifications.sign_platform` 及子表字段 +- [x] `SignPlatform` 枚举 + `SignPlatformProvider` 接口 +- [x] 将现有 e签宝 逻辑迁入 `EsignProvider`(行为不变) +- [x] `PlatformRegistry` 注入容器 +- [ ] 历史数据迁移脚本(AutoMigrate 已加列;存量可按需刷 `esign`) +- [ ] 回归测试:现有 e签宝 全流程无回归 + +### Phase 2 — 平台选择流程(2~3 天) + +- [x] 后端 `SelectSignPlatform` + `GetSignPlatforms` +- [x] 调整 `AdminApproveSubmitRecord`(审核后不立即调第三方) +- [x] 前端 `PlatformSelect.vue` + 步骤条改造 +- [ ] 仅 e签宝 模式下验证「先选平台 → 再认证 → 再签署」 + +### Phase 3 — 法大大 Provider(5~8 天) + +- [x] 接入 HTTP/SDK 封装,实现 `FadadaProvider`(骨架 + OpenAPI 封装) +- [x] 企业认证链接、实名查询(代码层) +- [x] 模板填单、签署任务、状态查询、文件下载(代码层) +- [x] `/callbacks/fadada` + 验签钩子 +- [ ] 法大大沙箱端到端联调(需有效凭证与模板) + +### Phase 4 — 收尾(2~3 天) + +- [x] 管理端展示 `sign_platform` / 待选平台 +- [ ] 启用 e签宝 回调验签(安全债补齐) +- [ ] 文档与 Swagger 更新 +- [ ] 生产配置与灰度发布 + +**预估总工期:12~18 个工作日**(1 人全职,含联调;法大大审核/模板配置时间另计) + +--- + +## 10. 风险与注意事项 + +| 风险 | 说明 | 缓解 | +|------|------|------| +| 两平台企业实名不互通 | 选法大大则必须在法大大完成企业认证 | 平台选择页明确提示;选定后锁定 | +| 模板不一致 | 两份模板需法务分别审核 | 上线前做法务对齐检查清单 | +| 签署状态码差异 | e签宝 `2`=完成;法大大状态码不同 | 封装在各自 Provider 内,对外统一 `SignStatusResult` | +| 经办人信息 | 当前 e签宝 用法人作经办人 | 法大大侧同步规则;授权代表字段映射需确认 | +| 合同文件过期 | e签宝 50 分钟过期重生成 | 法大大侧确认是否有过期策略,在 Provider 实现 | +| 进行中的用户 | 迁移时默认 `esign` | 仅新用户或管理员重置后才出现平台选择 | + +--- + +## 11. 待你决策的问题 + +在动手写代码前,建议确认以下事项: + +### Q1. 平台选择时机 + +- [ ] **A. 审核通过后由用户选择**(推荐) +- [ ] B. 提交企业信息时选择 +- [ ] C. 其他:___________ + +### Q2. 是否新增状态 `platform_pending` + +- [ ] **A. 不新增,用 metadata 驱动**(推荐,改动小) +- [ ] B. 新增独立状态 + +### Q3. e签宝 是否继续保留 + +- [ ] **A. 双平台并存,用户可选**(推荐) +- [ ] B. 新用户仅法大大,e签宝 仅维护存量 +- [ ] C. 全量迁移到法大大 + +### Q4. 管理员审核后行为 + +- [ ] **A. 审核通过不生成链接,等用户选平台**(推荐) +- [ ] B. 审核通过默认 e签宝 并生成链接,用户可在认证前改选法大大 + +### Q5. 法大大接入方式 + +- [ ] **A. 官方 Go SDK**(推荐) +- [ ] B. 自研 HTTP 封装(与 esign 包一致) + +### Q6. 合同模板 + +- [ ] A. 法大大新建一份等价模板 +- [ ] B. 非法大大模板,改用上传 PDF 方式发起签署 + +--- + +## 12. 文件改动清单(实施时参考) + +### 后端 + +| 文件 | 改动类型 | +|------|----------| +| `domains/certification/entities/certification.go` | 新增 `SignPlatform` 字段 | +| `domains/certification/enums/sign_platform.go` | 新增 | +| `domains/certification/ports/sign_platform_provider.go` | 新增接口 | +| `infrastructure/external/esign/esign_provider.go` | 从 service_impl 抽取 | +| `infrastructure/external/fadada/*` | 新增 | +| `application/certification/certification_application_service_impl.go` | 改为调 registry | +| `application/certification/dto/commands/` | 新增 `SelectSignPlatformCommand` | +| `infrastructure/http/routes/certification_routes.go` | 新增路由 | +| `infrastructure/http/handlers/certification_handler.go` | 新增 handler | +| `config/config.go` + `config.yaml` | 新增 fadada / sign_platform 配置 | +| `container/container.go` | 注册 FadadaProvider + Registry | + +### 前端 + +| 文件 | 改动类型 | +|------|----------| +| `pages/certification/index.vue` | 步骤与状态映射 | +| `pages/certification/components/PlatformSelect.vue` | 新增 | +| `pages/certification/IframeCallback.vue` | 支持多平台 | +| `src/api/index.js` | 新增 API | +| `src/router/index.js` | 扩展 callback 路由 | +| `pages/admin/certification-reviews/index.vue` | 展示 sign_platform | + +--- + +## 13. 总结 + +本次改造的核心不是「替换 e签宝」,而是: + +1. **主表增加 `sign_platform`**,记录用户选择的签署平台 +2. **抽象 `SignPlatformProvider`**,将 e签宝 现有实现原样迁入 +3. **审核通过后增加平台选择步骤**,再按平台发放认证/签署链接 +4. **新增法大大 Provider**,与 e签宝 并行 + +这样对存量 e签宝 用户零影响,新用户可自由选择;后续若再接入第三方,只需新增 Provider 实现并加入 `enabled` 列表。 + +--- + +*请确认第 11 节中的决策项后,可据此进入具体开发。* 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 74fd20f..6f05b8f 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) @@ -48,8 +56,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 0fc6ca8..960effe 100644 --- a/internal/application/certification/certification_application_service_impl.go +++ b/internal/application/certification/certification_application_service_impl.go @@ -17,6 +17,7 @@ import ( "hyapi-server/internal/domains/certification/entities" certification_value_objects "hyapi-server/internal/domains/certification/entities/value_objects" "hyapi-server/internal/domains/certification/enums" + "hyapi-server/internal/domains/certification/ports" "hyapi-server/internal/domains/certification/repositories" "hyapi-server/internal/domains/certification/services" finance_service "hyapi-server/internal/domains/finance/services" @@ -40,6 +41,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 @@ -65,6 +67,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, @@ -87,6 +90,7 @@ func NewCertificationApplicationService( smsCodeService: smsCodeService, esignClient: esignClient, esignConfig: esignConfig, + platformRegistry: platformRegistry, qiniuStorageService: qiniuStorageService, contractAggregateService: contractAggregateService, walletAggregateService: walletAggregateService, @@ -443,16 +447,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 { @@ -501,7 +511,7 @@ func (s *CertificationApplicationServiceImpl) ConfirmSign( }, nil } -// ApplyContract 申请合同签署 +// ApplyContract 立即签署:若认证完成时已创建任务则只取 iframe 长链并推进状态;否则创建任务 func (s *CertificationApplicationServiceImpl) ApplyContract( ctx context.Context, cmd *commands.ApplyContractCommand, @@ -509,59 +519,179 @@ 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 != "" { + // 任务已在企业认证完成后创建(乙方通常已免签),此处只刷新甲方 iframe 长链 + 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 获取认证详情 @@ -603,10 +733,36 @@ func (s *CertificationApplicationServiceImpl) GetCertification( } } + // 脏数据修复:曾因预创建签署软失败推进到 enterprise_verified 但无任务,回退到待选平台 + 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 { - return nil, err + if autoErr := s.checkAndCompleteEnterpriseVerification(ctx, cert); autoErr != nil { + // 自动推进失败时仍返回当前认证详情,避免前端无法渲染步骤而回退到「填写企业信息」 + s.logger.Error("获取详情时自动完成企业认证失败,返回当前状态", + zap.String("user_id", query.UserID), + zap.String("cert_status", string(cert.Status)), + zap.Error(autoErr)) + fresh, loadErr := s.aggregateService.LoadCertificationByUserID(ctx, query.UserID) + if loadErr != nil { + return nil, fmt.Errorf("加载认证信息失败: %w", loadErr) + } + cert = fresh + response := s.convertToResponse(cert) + meta, metaErr := s.AddStatusMetadata(ctx, cert) + if metaErr == nil && meta != nil { + response.Metadata = meta + response.Metadata["auto_complete_error"] = autoErr.Error() + } + return response, nil } } if cert.Status == enums.StatusContractApplied { @@ -873,8 +1029,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, @@ -884,6 +1042,8 @@ func (s *CertificationApplicationServiceImpl) AdminListSubmitRecords( LegalPersonName: r.LegalPersonName, SubmitAt: r.SubmitAt, Status: r.Status, + ManualReviewStatus: r.ManualReviewStatus, + SignPlatform: signPlatform, CertificationStatus: certStatus, }) } @@ -907,8 +1067,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, @@ -932,6 +1094,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, @@ -967,52 +1132,19 @@ 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) - } + // 审核通过后不立即生成认证链接,由用户选择签署平台(e签宝/法大大)后再生成 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 } @@ -1076,7 +1208,7 @@ func (s *CertificationApplicationServiceImpl) AdminTransitionCertificationStatus } switch cmd.TargetStatus { case string(enums.StatusInfoSubmitted): - // 审核通过:与 AdminApproveSubmitRecord 一致,推状态并生成企业认证链接 + // 审核通过:与 AdminApproveSubmitRecord 一致,仅标记审核通过,等待用户选择签署平台 switch cert.Status { case enums.StatusInfoSubmitted, enums.StatusEnterpriseVerified, enums.StatusContractApplied, enums.StatusContractSigned, enums.StatusCompleted, enums.StatusContractRejected, enums.StatusContractExpired: @@ -1085,45 +1217,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): // 审核拒绝 @@ -1160,6 +1260,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, @@ -1198,58 +1299,6 @@ func (s *CertificationApplicationServiceImpl) convertToResponse(cert *entities.C return response } -func (s *CertificationApplicationServiceImpl) generateEnterpriseAuthOrDetectVerified( - ctx context.Context, - req *esign.EnterpriseAuthRequest, -) (*esign.EnterpriseAuthResult, bool, error) { - s.logger.Info("企业认证链接生成-步骤1-开始调用三方创建认证链接", - zap.String("company_name", req.CompanyName), - zap.String("unified_social_code", req.UnifiedSocialCode)) - authURL, err := s.esignClient.GenerateEnterpriseAuth(req) - if err == nil { - s.logger.Info("企业认证链接生成-步骤1-创建成功", - zap.String("company_name", req.CompanyName), - zap.String("auth_flow_id", authURL.AuthFlowID)) - return authURL, false, nil - } - if !isEnterpriseAlreadyRealnamedErr(err) { - s.logger.Error("企业认证链接生成-步骤1-创建失败且非已实名场景", - zap.String("company_name", req.CompanyName), - zap.Error(err)) - return nil, false, err - } - - s.logger.Warn("企业已实名,跳过生成认证链接并转为自动确认", - zap.String("company_name", req.CompanyName), - zap.String("unified_social_code", req.UnifiedSocialCode), - zap.Error(err)) - - identity, identityErr := s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{ - OrgIDCardNum: req.UnifiedSocialCode, - OrgIDCardType: esign.OrgIDCardTypeUSCC, - }) - if identityErr != nil { - s.logger.Warn("企业认证链接生成-步骤2-按信用代码查询实名状态失败,回退按企业名查询", - zap.String("company_name", req.CompanyName), - zap.Error(identityErr)) - identity, identityErr = s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{ - OrgName: req.CompanyName, - }) - } - if identityErr != nil { - return nil, false, fmt.Errorf("企业用户已实名,但查询实名状态失败: %w", identityErr) - } - s.logger.Info("企业认证链接生成-步骤2-实名状态查询成功", - zap.String("company_name", req.CompanyName), - zap.Int32("realname_status", identity.Data.RealnameStatus)) - if identity == nil || identity.Data.RealnameStatus != 1 { - return nil, false, err - } - s.logger.Info("企业认证链接生成-步骤3-确认企业已实名,返回自动确认标记", - zap.String("company_name", req.CompanyName)) - return nil, true, nil -} - func isEnterpriseAlreadyRealnamedErr(err error) bool { if err == nil { return false @@ -1258,6 +1307,18 @@ 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, "已存在授权") +} + // validateApplyContractCommand 验证申请合同命令 func (s *CertificationApplicationServiceImpl) validateApplyContractCommand(cmd *commands.ApplyContractCommand) error { if cmd.UserID == "" { @@ -1279,27 +1340,41 @@ func (s *CertificationApplicationServiceImpl) validateContractApplicationPrecond // 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: "海宇数据-合作协议-" + 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()) } + // iframe 必须用长链 SignURL(法大大 EmbedURL);短链不可嵌入 + 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 } @@ -1333,7 +1408,7 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification( zap.String("user_id", userID), zap.String("record_id", record.ID)) - err = s.userAggregateService.CreateEnterpriseInfo( + err = s.userAggregateService.CreateOrUpdateEnterpriseInfo( ctx, userID, record.CompanyName, @@ -1346,17 +1421,26 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification( if err != nil { s.logger.Error("保存企业信息到用户域失败", zap.Error(err)) return fmt.Errorf("保存企业信息失败: %s", err.Error()) - } else { - s.logger.Info("企业信息已保存到用户域", zap.String("user_id", userID)) } + 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)) + // 立即创建并提交签署任务:乙方免验证签先盖章;甲方稍后在「立即签署」取 iframe 长链 + // 创建失败必须整体失败,不得推进到 enterprise_verified / 签署合同步骤 + 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 { @@ -1368,6 +1452,46 @@ 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: "海宇数据-合作协议-" + 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, // 预创建不取甲方链接,避免消耗单次 EmbedURL + } + signRes, err := provider.CreateSignFlow(ctx, req) + if err != nil { + return err + } + storeURL := firstNonEmptyStr(signRes.ShortURL, signRes.SignURL) + cert.BindSignTask(signRes.SignFlowID, storeURL) + // 预览链接约 2 小时/单次,进入预览页时再调 get-preview-url,此处不落库 PDF 直链 + return nil +} + // pickAuthorizedRepName 合同模板「客户授权代表」: 优先企业提交记录中的授权代表, 否则为法定代表人 func pickAuthorizedRepName(record *entities.EnterpriseInfoSubmitRecord, legalPersonName string) string { if record != nil && strings.TrimSpace(record.AuthorizedRepName) != "" { @@ -1396,22 +1520,18 @@ func (s *CertificationApplicationServiceImpl) generateAndAddContractFile( agreementNo := cert.ContractCode signDate := time.Now().Format("2006年01月02日") - - // 控件 key 与 e 签宝合同模板中控件名一致(新合同) - 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()) @@ -1419,8 +1539,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()) @@ -1466,13 +1587,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) }) @@ -1490,11 +1617,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()) @@ -1503,21 +1634,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()) @@ -1549,40 +1678,42 @@ 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 + } + _, err = s.contractAggregateService.CreateContractWithCode( ctx, user.EnterpriseInfo.ID, @@ -1597,7 +1728,6 @@ func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ct s.logger.Error("保存合同信息到聚合根失败", zap.String("file_name", fileName), zap.Error(err)) continue } - s.logger.Info("合同信息已保存到聚合根", zap.String("file_name", fileName), zap.String("qiniu_url", qiniuURL)) } @@ -1648,10 +1778,22 @@ 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 } +func firstNonEmptyStr(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + // completeUserActivationWithoutContract 创建钱包、API用户并在用户域标记完成认证(不依赖合同信息) func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContract(ctx context.Context, cert *entities.Certification) error { // 创建钱包 diff --git a/internal/application/certification/dto/commands/certification_commands.go b/internal/application/certification/dto/commands/certification_commands.go index d0b98a2..d5a40bc 100644 --- a/internal/application/certification/dto/commands/certification_commands.go +++ b/internal/application/certification/dto/commands/certification_commands.go @@ -102,6 +102,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 e820663..d9b8a5c 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,26 @@ 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 签署任务预览 EUI 链接(约 2 小时/单次),用 iframe 打开,勿当 PDF 直链 + PreviewURL string `json:"preview_url"` + // PreviewMode iframe | pdf + PreviewMode string `json:"preview_mode"` + SignFlowID string `json:"sign_flow_id,omitempty"` + Message string `json:"message,omitempty"` } // SystemMonitoringResponse 系统监控响应 @@ -119,7 +134,9 @@ type AdminSubmitRecordItem struct { LegalPersonName string `json:"legal_person_name"` SubmitAt time.Time `json:"submit_at"` Status string `json:"status"` - CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准:info_pending_review/info_submitted/info_rejected 等 + ManualReviewStatus string `json:"manual_review_status,omitempty"` // pending/approved/rejected + SignPlatform string `json:"sign_platform,omitempty"` // esign/fadada + CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准:info_pending_review/info_submitted/info_rejected 等 } // AdminSubmitRecordDetail 管理端提交记录详情(含完整信息与图片 URL) @@ -145,6 +162,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,17 +198,19 @@ 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) + // 法大大嵌入链接约 10 分钟;统一按 10 分钟提示前端刷新 + expireAt := time.Now().Add(10 * time.Minute) response.ExpireAt = &expireAt return response @@ -208,6 +230,19 @@ func NewSystemAlert(level, alertType, message, metric string, value, threshold i } } +// 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"` +} + // IsHealthy 检查系统是否健康 func (r *SystemMonitoringResponse) IsHealthy() bool { return r.SystemHealth.Overall == "healthy" diff --git a/internal/application/certification/platform_flow.go b/internal/application/certification/platform_flow.go new file mode 100644 index 0000000..1ff1e43 --- /dev/null +++ b/internal/application/certification/platform_flow.go @@ -0,0 +1,273 @@ +package certification + +import ( + "context" + "fmt" + "strings" + "time" + + "hyapi-server/internal/application/certification/dto/commands" + "hyapi-server/internal/application/certification/dto/responses" + "hyapi-server/internal/domains/certification/entities" + "hyapi-server/internal/domains/certification/enums" + "hyapi-server/internal/domains/certification/ports" + user_entities "hyapi-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 + platforms := make([]map[string]string, 0, 2) + for _, p := range s.platformRegistry.List() { + platforms = append(platforms, map[string]string{ + "platform": string(p.Platform()), + "name": enums.GetSignPlatformName(p.Platform()), + }) + } + metadata["available_platforms"] = platforms + 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 c6c404e..b263956 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"` @@ -45,6 +46,7 @@ type Config struct { Nuoer NuoerConfig `mapstructure:"nuoer"` Jiyi JiyiConfig `mapstructure:"jiyi"` Huibo HuiboConfig `mapstructure:"huibo"` + Rongxing RongxingConfig `mapstructure:"rongxing"` PDFGen PDFGenConfig `mapstructure:"pdfgen"` } @@ -389,6 +391,41 @@ type SignConfig struct { RedirectURL string `mapstructure:"redirect_url"` // 重定向URL } +// FadadaConfig 法大大(FASC OpenAPI)配置 +type FadadaConfig struct { + AppID string `mapstructure:"app_id"` // 应用ID + AppSecret string `mapstructure:"app_secret"` // 应用密钥 + ServerURL string `mapstructure:"server_url"` // 服务器URL + OpenCorpID string `mapstructure:"open_corp_id"` // 接入方(海宇)在法大大侧企业ID + NoAuthSceneCode string `mapstructure:"no_auth_scene_code"` // 免验证签场景码(businessId;requestVerifyFree 时必传) + TemplateID string `mapstructure:"template_id"` // 模板ID + PartyAActorID string `mapstructure:"party_a_actor_id"` // 签署模板甲方参与方标识(actorId) + PartyBActorID string `mapstructure:"party_b_actor_id"` // 签署模板乙方参与方标识(actorId) + TemplateDocID string `mapstructure:"template_doc_id"` // 签署模板文档 docId(填单用) + 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"` @@ -670,10 +707,10 @@ type TianyuanapiConfig struct { // TianyuanapiLoggingConfig 天远 API 日志配置 type TianyuanapiLoggingConfig struct { - Enabled bool `mapstructure:"enabled"` - LogDir string `mapstructure:"log_dir"` - UseDaily bool `mapstructure:"use_daily"` - EnableLevelSeparation bool `mapstructure:"enable_level_separation"` + Enabled bool `mapstructure:"enabled"` + LogDir string `mapstructure:"log_dir"` + UseDaily bool `mapstructure:"use_daily"` + EnableLevelSeparation bool `mapstructure:"enable_level_separation"` LevelConfigs map[string]TianyuanapiLevelFileConfig `mapstructure:"level_configs"` } @@ -768,28 +805,28 @@ type JiyiLevelFileConfig struct { // HuiboConfig 汇博配置 type HuiboConfig struct { - URL string `mapstructure:"url"` - AppID string `mapstructure:"app_id"` - AppKey string `mapstructure:"app_key"` - XOrderCode string `mapstructure:"x_order_code"` - SecretID string `mapstructure:"secret_id"` - AESKey string `mapstructure:"aes_key"` - WorkOrderCode string `mapstructure:"work_order_code"` - ProductCode string `mapstructure:"product_code"` - BaseURL2 string `mapstructure:"baseUrl2"` - AppCode2 string `mapstructure:"app_code2"` - AuthPDFStorageDir string `mapstructure:"auth_pdf_storage_dir"` + URL string `mapstructure:"url"` + AppID string `mapstructure:"app_id"` + AppKey string `mapstructure:"app_key"` + XOrderCode string `mapstructure:"x_order_code"` + SecretID string `mapstructure:"secret_id"` + AESKey string `mapstructure:"aes_key"` + WorkOrderCode string `mapstructure:"work_order_code"` + ProductCode string `mapstructure:"product_code"` + BaseURL2 string `mapstructure:"baseUrl2"` + AppCode2 string `mapstructure:"app_code2"` + AuthPDFStorageDir string `mapstructure:"auth_pdf_storage_dir"` Logging HuiboLoggingConfig `mapstructure:"logging"` } // HuiboLoggingConfig 汇博日志配置 type HuiboLoggingConfig struct { - Enabled bool `mapstructure:"enabled"` - LogDir string `mapstructure:"log_dir"` - ServiceName string `mapstructure:"service_name"` - UseDaily bool `mapstructure:"use_daily"` - EnableLevelSeparation bool `mapstructure:"enable_level_separation"` + Enabled bool `mapstructure:"enabled"` + LogDir string `mapstructure:"log_dir"` + ServiceName string `mapstructure:"service_name"` + UseDaily bool `mapstructure:"use_daily"` + EnableLevelSeparation bool `mapstructure:"enable_level_separation"` LevelConfigs map[string]HuiboLevelFileConfig `mapstructure:"level_configs"` } @@ -801,6 +838,36 @@ type HuiboLevelFileConfig struct { Compress bool `mapstructure:"compress"` } +// RongxingConfig 戎行(Info360)配置 +type RongxingConfig struct { + URL string `mapstructure:"url"` // 基础地址,如 http://host:7007 + Account string `mapstructure:"account"` // 登录账号 + Password string `mapstructure:"password"` // 明文密码(请求时 Base64) + AppID string `mapstructure:"app_id"` // 应用 ID + PrivateKey string `mapstructure:"private_key"` // RSA 私钥(PEM 或 PKCS#8 Base64) + Timeout time.Duration `mapstructure:"timeout"` + + Logging RongxingLoggingConfig `mapstructure:"logging"` +} + +// RongxingLoggingConfig 戎行日志配置 +type RongxingLoggingConfig struct { + Enabled bool `mapstructure:"enabled"` + LogDir string `mapstructure:"log_dir"` + ServiceName string `mapstructure:"service_name"` + UseDaily bool `mapstructure:"use_daily"` + EnableLevelSeparation bool `mapstructure:"enable_level_separation"` + LevelConfigs map[string]RongxingLevelFileConfig `mapstructure:"level_configs"` +} + +// RongxingLevelFileConfig 戎行日志级别配置 +type RongxingLevelFileConfig struct { + MaxSize int `mapstructure:"max_size"` + MaxBackups int `mapstructure:"max_backups"` + MaxAge int `mapstructure:"max_age"` + Compress bool `mapstructure:"compress"` +} + // PDFGenConfig PDF生成服务配置 type PDFGenConfig struct { DevelopmentURL string `mapstructure:"development_url"` // 开发环境服务地址 diff --git a/internal/container/container.go b/internal/container/container.go index ae5ea83..79c7b97 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -40,12 +40,16 @@ import ( "hyapi-server/internal/infrastructure/external/alicloud" "hyapi-server/internal/infrastructure/external/captcha" "hyapi-server/internal/infrastructure/external/email" + infraesign "hyapi-server/internal/infrastructure/external/esign" + infrafadada "hyapi-server/internal/infrastructure/external/fadada" "hyapi-server/internal/infrastructure/external/huibo" + "hyapi-server/internal/infrastructure/external/rongxing" "hyapi-server/internal/infrastructure/external/jiguang" "hyapi-server/internal/infrastructure/external/jiyi" "hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/nuoer" "hyapi-server/internal/infrastructure/external/ocr" + "hyapi-server/internal/infrastructure/external/signplatform" "hyapi-server/internal/infrastructure/external/shujubao" "hyapi-server/internal/infrastructure/external/shumai" "hyapi-server/internal/infrastructure/external/sms" @@ -66,6 +70,7 @@ import ( component_report "hyapi-server/internal/shared/component_report" shared_database "hyapi-server/internal/shared/database" "hyapi-server/internal/shared/esign" + "hyapi-server/internal/shared/fadada" shared_events "hyapi-server/internal/shared/events" "hyapi-server/internal/shared/export" "hyapi-server/internal/shared/health" @@ -321,6 +326,55 @@ 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) + }, + // 签署平台 Registry(e签宝 + 法大大) + 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{ @@ -424,6 +478,10 @@ func NewContainer() *Container { func(cfg *config.Config) (*huibo.HuiboService, error) { return huibo.NewHuiboServiceWithConfig(cfg) }, + // RongxingService - 戎行 Info360 服务 + func(cfg *config.Config) (*rongxing.RongxingService, error) { + return rongxing.NewRongxingServiceWithConfig(cfg) + }, func(cfg *config.Config) *yushan.YushanService { return yushan.NewYushanService( cfg.Yushan.URL, @@ -931,6 +989,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, @@ -949,6 +1008,7 @@ func NewContainer() *Container { smsCodeService, esignClient, esignConfig, + platformRegistry, qiniuStorageService, contractAggregateService, walletAggregateService, diff --git a/internal/domains/api/dto/api_request_dto.go b/internal/domains/api/dto/api_request_dto.go index 4ca50f9..8d94753 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -1347,3 +1347,9 @@ type QYGLLUCMReq struct { EntName string `json:"ent_name" validate:"required,min=1,validEnterpriseName"` AuthPDFBase64 string `json:"auth_pdf_base64" validate:"required,validAuthPDFBase64"` } + +type JRZQ0OO1Req struct { + MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"` + IDCard string `json:"id_card" validate:"required,validIDCard"` + Name string `json:"name" validate:"required,min=1,validName"` +} diff --git a/internal/domains/api/services/api_request_service.go b/internal/domains/api/services/api_request_service.go index 124e0ab..bb3aa06 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -23,6 +23,7 @@ import ( jiyiext "hyapi-server/internal/infrastructure/external/jiyi" "hyapi-server/internal/infrastructure/external/muzi" nuoerext "hyapi-server/internal/infrastructure/external/nuoer" + "hyapi-server/internal/infrastructure/external/rongxing" "hyapi-server/internal/infrastructure/external/shujubao" "hyapi-server/internal/infrastructure/external/tianyancha" "hyapi-server/internal/infrastructure/external/tianyuanapi" @@ -69,6 +70,7 @@ func NewApiRequestService( nuoerService *nuoerext.NuoerService, jiyiService *jiyiext.JiyiService, huiboService *huibo.HuiboService, + rongxingService *rongxing.RongxingService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -87,6 +89,7 @@ func NewApiRequestService( nuoerService, jiyiService, huiboService, + rongxingService, validator, productManagementService, cfg, @@ -110,6 +113,7 @@ func NewApiRequestServiceWithRepos( nuoerService *nuoerext.NuoerService, jiyiService *jiyiext.JiyiService, huiboService *huibo.HuiboService, + rongxingService *rongxing.RongxingService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -139,6 +143,7 @@ func NewApiRequestServiceWithRepos( nuoerService, jiyiService, huiboService, + rongxingService, validator, combService, reportRepo, @@ -202,6 +207,7 @@ func registerAllProcessors(combService *comb.CombService) { "JRZQR4N7": jrzq.ProcessJRZQR4N7Request, //借贷意向验证3.0 "JRZQH6M3": jrzq.ProcessJRZQH6M3Request, //无间司南-纯黑A版 "JRZQW3L8": jrzq.ProcessJRZQW3L8Request, //信用司南 + "JRZQ0OO1": jrzq.ProcessJRZQ0OO1Request, //戎行贷后信息 // QYGL系列处理器 "QYGL7HBN": qygl.ProcessQYGL7HBNRequest, //企业全景报告(聚合 QYGLUY3S/QYGLJ0Q1/QYGL5S1I) diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index ef2e88f..5223ec5 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -318,7 +318,8 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "JRZQW3L8": &dto.JRZQW3L8Req{}, //信用司南 "IVYZX7J9": &dto.IVYZX7J9Req{}, //学籍核验 "QCXG6U5G": &dto.QCXG6U5GReq{}, //车辆核验 - "QCXG7K2N": &dto.QCXG7K2NReq{}, //人车核验加强版 + "JRZQ0OO1": &dto.JRZQ0OO1Req{}, //戎行贷后信息 Info360 + } // 优先返回已配置的DTO diff --git a/internal/domains/api/services/processors/dependencies.go b/internal/domains/api/services/processors/dependencies.go index 6385757..d7c08f9 100644 --- a/internal/domains/api/services/processors/dependencies.go +++ b/internal/domains/api/services/processors/dependencies.go @@ -11,9 +11,10 @@ import ( "hyapi-server/internal/infrastructure/external/jiyi" "hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/nuoer" + "hyapi-server/internal/infrastructure/external/rongxing" "hyapi-server/internal/infrastructure/external/shujubao" - "hyapi-server/internal/infrastructure/external/tianyuanapi" "hyapi-server/internal/infrastructure/external/tianyancha" + "hyapi-server/internal/infrastructure/external/tianyuanapi" "hyapi-server/internal/infrastructure/external/westdex" "hyapi-server/internal/infrastructure/external/xingwei" "hyapi-server/internal/infrastructure/external/yushan" @@ -33,23 +34,24 @@ type CallContext struct { // ProcessorDependencies 处理器依赖容器 type ProcessorDependencies struct { - WestDexService *westdex.WestDexService - ShujubaoService *shujubao.ShujubaoService - MuziService *muzi.MuziService - YushanService *yushan.YushanService - TianYanChaService *tianyancha.TianYanChaService - AlicloudService *alicloud.AlicloudService - ZhichaService *zhicha.ZhichaService - XingweiService *xingwei.XingweiService - JiguangService *jiguang.JiguangService + WestDexService *westdex.WestDexService + ShujubaoService *shujubao.ShujubaoService + MuziService *muzi.MuziService + YushanService *yushan.YushanService + TianYanChaService *tianyancha.TianYanChaService + AlicloudService *alicloud.AlicloudService + ZhichaService *zhicha.ZhichaService + XingweiService *xingwei.XingweiService + JiguangService *jiguang.JiguangService TianyuanapiService *tianyuanapi.TianyuanapiService - NuoerService *nuoer.NuoerService - JiyiService *jiyi.JiyiService - HuiboService *huibo.HuiboService - Validator interfaces.RequestValidator - CombService CombServiceInterface // Changed to interface to break import cycle - Options *commands.ApiCallOptions // 添加Options支持 - CallContext *CallContext // 添加CallApi调用上下文 + NuoerService *nuoer.NuoerService + JiyiService *jiyi.JiyiService + HuiboService *huibo.HuiboService + RongxingService *rongxing.RongxingService + Validator interfaces.RequestValidator + CombService CombServiceInterface // Changed to interface to break import cycle + Options *commands.ApiCallOptions // 添加Options支持 + CallContext *CallContext // 添加CallApi调用上下文 // 企业报告记录仓储,用于持久化 QYGLJ1U9 生成的企业报告 ReportRepo repositories.ReportRepository @@ -76,6 +78,7 @@ func NewProcessorDependencies( nuoerService *nuoer.NuoerService, jiyiService *jiyi.JiyiService, huiboService *huibo.HuiboService, + rongxingService *rongxing.RongxingService, validator interfaces.RequestValidator, combService CombServiceInterface, // Changed to interface reportRepo repositories.ReportRepository, @@ -96,6 +99,7 @@ func NewProcessorDependencies( NuoerService: nuoerService, JiyiService: jiyiService, HuiboService: huiboService, + RongxingService: rongxingService, Validator: validator, CombService: combService, Options: nil, // 初始化为nil,在调用时设置 diff --git a/internal/domains/api/services/processors/jrzq/jrzq0oo1_processor.go b/internal/domains/api/services/processors/jrzq/jrzq0oo1_processor.go new file mode 100644 index 0000000..d37c02d --- /dev/null +++ b/internal/domains/api/services/processors/jrzq/jrzq0oo1_processor.go @@ -0,0 +1,46 @@ +package jrzq + +import ( + "context" + "encoding/json" + "errors" + + "hyapi-server/internal/domains/api/dto" + "hyapi-server/internal/domains/api/services/processors" + "hyapi-server/internal/infrastructure/external/rongxing" +) + +func ProcessJRZQ0OO1Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.JRZQ0OO1Req + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + if deps.RongxingService == nil { + return nil, errors.Join(processors.ErrSystem, errors.New("戎行服务未初始化")) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, errors.Join(processors.ErrInvalidParam, err) + } + + // 业务入参在处理器侧组装;三要素按接口要求 MD5 大写后入请求体 + reqdata := map[string]interface{}{ + "phone": rongxing.MD5Upper(paramsDto.MobileNo), + "idCard": rongxing.MD5Upper(paramsDto.IDCard), + "name": rongxing.MD5Upper(paramsDto.Name), + } + + respBytes, err := deps.RongxingService.CallAPI(ctx, "/third/loan/info360", reqdata) + if err != nil { + if errors.Is(err, rongxing.ErrNotFound) { + return nil, errors.Join(processors.ErrNotFound, err) + } + if errors.Is(err, rongxing.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + return respBytes, nil +} diff --git a/internal/domains/certification/entities/certification.go b/internal/domains/certification/entities/certification.go index f104cef..a24ba74 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" "hyapi-server/internal/domains/certification/entities/value_objects" @@ -28,14 +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:"合同签署链接"` + 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:"失败原因"` @@ -238,8 +242,11 @@ func (c *Certification) RejectEnterpriseInfoReview(actorID, message string) erro return nil } -// 完成企业认证 +// 完成企业认证(幂等:已是 enterprise_verified 则跳过状态流转,便于中途失败后重试) func (c *Certification) CompleteEnterpriseVerification() error { + if c.Status == enums.StatusEnterpriseVerified { + return nil + } if c.Status != enums.StatusInfoSubmitted { return fmt.Errorf("当前状态 %s 不允许完成企业认证", enums.GetStatusName(c.Status)) } @@ -310,8 +317,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 // 添加业务事件 @@ -324,6 +335,18 @@ func (c *Certification) ApplyContract(EsignFlowID string, ContractSignURL string return nil } +// BindSignTask 企业认证完成后预创建签署任务(不改状态;乙方可先免签) +func (c *Certification) BindSignTask(esignFlowID, contractSignURL string) { + if esignFlowID != "" { + c.EsignFlowID = esignFlowID + } + if contractSignURL != "" { + c.ContractSignURL = contractSignURL + } + now := time.Now() + c.ContractFileCreatedAt = &now +} + // AddContractFileID 生成合同文件 func (c *Certification) AddContractFileID(contractFileID string, contractURL string) error { c.ContractFileID = contractFileID @@ -500,13 +523,58 @@ 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 +} + +// 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 +} + // ================ 查询方法 ================ // 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: @@ -514,11 +582,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..4357bd2 --- /dev/null +++ b/internal/domains/certification/ports/sign_platform_provider.go @@ -0,0 +1,152 @@ +package ports + +import ( + "context" + + "hyapi-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/infrastructure/external/esign/esign_provider.go b/internal/infrastructure/external/esign/esign_provider.go new file mode 100644 index 0000000..4204cdf --- /dev/null +++ b/internal/infrastructure/external/esign/esign_provider.go @@ -0,0 +1,204 @@ +package esign + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "hyapi-server/internal/domains/certification/enums" + "hyapi-server/internal/domains/certification/ports" + sharedesign "hyapi-server/internal/shared/esign" +) + +type Provider struct { + client *sharedesign.Client +} + +func NewProvider(client *sharedesign.Client) *Provider { + return &Provider{client: client} +} + +func (p *Provider) Platform() enums.SignPlatform { + return enums.SignPlatformEsign +} + +func (p *Provider) GenerateEnterpriseAuth(ctx context.Context, req *ports.EnterpriseAuthRequest) (*ports.AuthLinkResult, error) { + _ = ctx + res, err := p.client.GenerateEnterpriseAuth(&sharedesign.EnterpriseAuthRequest{ + CompanyName: req.CompanyName, + UnifiedSocialCode: req.UnifiedSocialCode, + LegalPersonName: req.LegalPersonName, + LegalPersonID: req.LegalPersonID, + TransactorName: req.TransactorName, + TransactorMobile: req.TransactorMobile, + TransactorID: req.TransactorID, + }) + if err != nil { + return nil, err + } + return &ports.AuthLinkResult{ + AuthFlowID: res.AuthFlowID, + AuthURL: res.AuthURL, + AuthShortURL: firstNonEmpty(res.AuthShortURL, res.AuthURL), + }, nil +} + +func (p *Provider) QueryOrgVerified(ctx context.Context, req *ports.OrgIdentityQuery) (bool, error) { + _ = ctx + identity, err := p.client.QueryOrgIdentityInfo(&sharedesign.QueryOrgIdentityRequest{ + OrgName: req.OrgName, + OrgIDCardNum: req.OrgIdentNo, + }) + if err != nil { + return false, err + } + return identity != nil && identity.Data.RealnameStatus == 1, nil +} + +func (p *Provider) GenerateContractFile(ctx context.Context, req *ports.ContractGenerateRequest) (*ports.ContractFileResult, error) { + _ = ctx + components := map[string]string{ + "jfqym": req.CompanyName, + "jfqym2": req.CompanyName, + "jfsqdb": req.AuthorizedRepName, + "jftyshxydm": req.UnifiedSocialCode, + "jflxdz": req.EnterpriseAddress, + "xybh": req.AgreementNo, + "qsrq1": req.SignDate, + "qsrq3": req.SignDate, + "qsrq2": req.SignDate, + } + res, err := p.client.FillTemplate(components) + if err != nil { + return nil, err + } + return &ports.ContractFileResult{ + FileID: res.FileID, + FileDownloadURL: res.FileDownloadUrl, + }, nil +} + +func (p *Provider) CreateSignFlow(ctx context.Context, req *ports.SignFlowCreateRequest) (*ports.SignFlowResult, error) { + _ = ctx + flowID, err := p.client.CreateSignFlow(&sharedesign.CreateSignFlowRequest{ + FileID: req.FileID, + SignerAccount: req.PartyAUSCC, + SignerName: req.PartyAName, + TransactorPhone: req.TransactorMobile, + TransactorName: req.TransactorName, + TransactorIDCardNum: req.TransactorID, + }) + if err != nil { + return nil, err + } + if req.SkipActorURL { + return &ports.SignFlowResult{SignFlowID: flowID}, nil + } + longURL, shortURL, err := p.client.GetSignURL(flowID, req.TransactorMobile, req.PartyAName) + if err != nil { + return nil, err + } + return &ports.SignFlowResult{ + SignFlowID: flowID, + SignURL: firstNonEmpty(longURL, shortURL), + ShortURL: shortURL, + }, nil +} + +func (p *Provider) GetActorSignURL(ctx context.Context, req *ports.GetActorSignURLRequest) (*ports.ActorSignURLResult, error) { + _ = ctx + if req == nil || strings.TrimSpace(req.SignFlowID) == "" { + return nil, fmt.Errorf("signFlowId 不能为空") + } + longURL, shortURL, err := p.client.GetSignURL(req.SignFlowID, req.TransactorMobile, req.PartyAName) + if err != nil { + return nil, err + } + return &ports.ActorSignURLResult{ + SignFlowID: req.SignFlowID, + EmbedURL: firstNonEmpty(longURL, shortURL), + ShortURL: shortURL, + }, nil +} + +func (p *Provider) GetSignTaskPreviewURL(ctx context.Context, signFlowID string) (*ports.SignTaskPreviewURLResult, error) { + _ = ctx + // e签宝无独立预览接口:用已签/合同文件下载链作为预览源(前端用 PDF 打开) + files, err := p.DownloadSignedFiles(ctx, signFlowID) + if err != nil { + return nil, err + } + if len(files) == 0 || strings.TrimSpace(files[0].DownloadURL) == "" { + return nil, fmt.Errorf("未获取到可预览的合同文件") + } + return &ports.SignTaskPreviewURLResult{ + SignFlowID: signFlowID, + PreviewURL: files[0].DownloadURL, + }, nil +} + +func (p *Provider) QuerySignStatus(ctx context.Context, flowID string) (*ports.SignStatusResult, error) { + _ = ctx + detail, err := p.client.QuerySignFlowDetail(flowID) + if err != nil { + return nil, err + } + status := detail.Data.SignFlowStatus + return &ports.SignStatusResult{ + SignFlowID: flowID, + Status: fmt.Sprintf("%d", status), + Completed: status == 2, + Rejected: status == 7, + Expired: status == 5, + Message: detail.Data.SignFlowDescription, + }, nil +} + +func (p *Provider) DownloadSignedFiles(ctx context.Context, flowID string) ([]*ports.SignedFile, error) { + _ = ctx + res, err := p.client.DownloadSignedFile(flowID) + if err != nil { + return nil, err + } + files := make([]*ports.SignedFile, 0, len(res.Data.Files)) + for _, f := range res.Data.Files { + files = append(files, &ports.SignedFile{DownloadURL: f.DownloadUrl}) + } + return files, nil +} + +func (p *Provider) VerifyCallback(ctx context.Context, headers map[string]string, body []byte) error { + _ = ctx + _ = headers + _ = body + // e签宝现有回调走独立验签逻辑,此处保持兼容(由原 HandleEsignCallback 处理) + return nil +} + +func (p *Provider) ParseCallback(ctx context.Context, headers map[string]string, body []byte) (*ports.CallbackEvent, error) { + _ = ctx + _ = headers + var raw map[string]interface{} + _ = json.Unmarshal(body, &raw) + action, _ := raw["action"].(string) + ev := &ports.CallbackEvent{Event: action, Raw: raw} + if action == "AUTH_PASS" { + ev.AuthPassed = true + } + if action == "SIGN_FLOW_COMPLETE" || action == "SIGN_MISSON_COMPLETE" { + ev.SignCompleted = true + } + return ev, nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +var _ ports.SignPlatformProvider = (*Provider)(nil) diff --git a/internal/infrastructure/external/fadada/fadada_provider.go b/internal/infrastructure/external/fadada/fadada_provider.go new file mode 100644 index 0000000..31292cb --- /dev/null +++ b/internal/infrastructure/external/fadada/fadada_provider.go @@ -0,0 +1,247 @@ +package fadada + +import ( + "context" + "fmt" + "net/url" + "strings" + + "hyapi-server/internal/domains/certification/enums" + "hyapi-server/internal/domains/certification/ports" + sharedfadada "hyapi-server/internal/shared/fadada" +) + +type Provider struct { + client *sharedfadada.Client +} + +func NewProvider(client *sharedfadada.Client) *Provider { + return &Provider{client: client} +} + +func (p *Provider) Platform() enums.SignPlatform { + return enums.SignPlatformFadada +} + +func (p *Provider) GenerateEnterpriseAuth(ctx context.Context, req *ports.EnterpriseAuthRequest) (*ports.AuthLinkResult, error) { + _ = ctx + res, err := p.client.GenerateEnterpriseAuth(&sharedfadada.EnterpriseAuthRequest{ + ClientCorpID: req.ClientCorpID, + ClientUserID: req.ClientUserID, + CompanyName: req.CompanyName, + UnifiedSocialCode: req.UnifiedSocialCode, + LegalPersonName: req.LegalPersonName, + LegalPersonID: req.LegalPersonID, + TransactorName: req.TransactorName, + TransactorMobile: req.TransactorMobile, + TransactorID: req.TransactorID, + }) + if err != nil { + return nil, err + } + return &ports.AuthLinkResult{ + AuthFlowID: res.AuthFlowID, + AuthURL: res.AuthURL, + AuthShortURL: firstNonEmpty(res.AuthShortURL, res.AuthURL), + }, nil +} + +func (p *Provider) QueryOrgVerified(ctx context.Context, req *ports.OrgIdentityQuery) (bool, error) { + _ = ctx + return p.client.QueryOrgVerified(&sharedfadada.QueryOrgIdentityRequest{ + CorpName: req.OrgName, + CorpIdentNo: req.OrgIdentNo, + }) +} + +// GenerateContractFile 法大大走签署任务模板(/sign-task/create-with-template), +// 此处返回空结果,实际填单由 CreateSignFlow 通过 /sign-task/field/fill-values 完成。 +func (p *Provider) GenerateContractFile(ctx context.Context, req *ports.ContractGenerateRequest) (*ports.ContractFileResult, error) { + _ = ctx + _ = req + return &ports.ContractFileResult{FileID: "", FileDownloadURL: ""}, nil +} + +func (p *Provider) CreateSignFlow(ctx context.Context, req *ports.SignFlowCreateRequest) (*ports.SignFlowResult, error) { + _ = ctx + createReq := &sharedfadada.CreateSignFlowRequest{ + Subject: req.Subject, + PartyAOpenCorpID: req.PartyAOpenCorpID, + PartyAName: req.PartyAName, + PartyAUSCC: req.PartyAUSCC, + TransactorName: req.TransactorName, + TransactorMobile: req.TransactorMobile, + TransactorID: req.TransactorID, + TransReferenceID: req.TransReferenceID, + } + if req.Fill != nil { + createReq.Fill = &sharedfadada.ContractFillRequest{ + AgreementNo: req.Fill.AgreementNo, + CompanyName: req.Fill.CompanyName, + UnifiedSocialCode: req.Fill.UnifiedSocialCode, + EnterpriseAddress: req.Fill.EnterpriseAddress, + AuthorizedRepName: req.Fill.AuthorizedRepName, + SignDate: req.Fill.SignDate, + FileName: req.Fill.FileName, + } + } else { + // 签署模板创建必须填控件 + createReq.Fill = &sharedfadada.ContractFillRequest{ + CompanyName: req.PartyAName, + UnifiedSocialCode: req.PartyAUSCC, + AuthorizedRepName: req.TransactorName, + } + } + + createRes, err := p.client.CreateSignFlow(createReq) + if err != nil { + return nil, err + } + if req.SkipActorURL { + return &ports.SignFlowResult{ + SignFlowID: createRes.SignTaskID, + }, nil + } + urlRes, err := p.client.GetSignURL(createRes.SignTaskID, sharedfadada.ActorIDPartyA) + if err != nil { + return nil, err + } + embedURL := strings.TrimSpace(urlRes.EmbedURL) + if embedURL == "" { + return nil, fmt.Errorf("法大大未返回可嵌入签署链接 actorSignTaskEmbedUrl") + } + return &ports.SignFlowResult{ + SignFlowID: createRes.SignTaskID, + SignURL: embedURL, + ShortURL: strings.TrimSpace(urlRes.SignURL), + }, nil +} + +func (p *Provider) GetActorSignURL(ctx context.Context, req *ports.GetActorSignURLRequest) (*ports.ActorSignURLResult, error) { + _ = ctx + if req == nil || strings.TrimSpace(req.SignFlowID) == "" { + return nil, fmt.Errorf("signFlowId 不能为空") + } + urlRes, err := p.client.GetSignURL(strings.TrimSpace(req.SignFlowID), sharedfadada.ActorIDPartyA) + if err != nil { + return nil, err + } + embedURL := strings.TrimSpace(urlRes.EmbedURL) + if embedURL == "" { + return nil, fmt.Errorf("法大大未返回可嵌入签署链接 actorSignTaskEmbedUrl") + } + return &ports.ActorSignURLResult{ + SignFlowID: req.SignFlowID, + EmbedURL: embedURL, + ShortURL: strings.TrimSpace(urlRes.SignURL), + }, nil +} + +func (p *Provider) GetSignTaskPreviewURL(ctx context.Context, signFlowID string) (*ports.SignTaskPreviewURLResult, error) { + _ = ctx + signFlowID = strings.TrimSpace(signFlowID) + if signFlowID == "" { + return nil, fmt.Errorf("signFlowId 不能为空") + } + res, err := p.client.GetSignTaskPreviewURL(signFlowID) + if err != nil { + return nil, err + } + return &ports.SignTaskPreviewURLResult{ + SignFlowID: signFlowID, + PreviewURL: res.PreviewURL, + }, nil +} + +func (p *Provider) QuerySignStatus(ctx context.Context, flowID string) (*ports.SignStatusResult, error) { + _ = ctx + st, err := p.client.QuerySignStatus(flowID) + if err != nil { + return nil, err + } + return &ports.SignStatusResult{ + SignFlowID: flowID, + Status: st.SignTaskStatus, + Completed: st.Completed, + Rejected: st.Terminated, + Expired: false, + Message: firstNonEmpty(st.TerminationNote, st.RevokeReason), + }, nil +} + +func (p *Provider) DownloadSignedFiles(ctx context.Context, flowID string) ([]*ports.SignedFile, error) { + _ = ctx + res, err := p.client.DownloadSignedFiles(flowID) + if err != nil { + return nil, err + } + files := make([]*ports.SignedFile, 0, len(res.Files)) + for _, f := range res.Files { + files = append(files, &ports.SignedFile{ + DownloadURL: f.DownloadURL, + DownloadID: f.DownloadID, + }) + } + return files, nil +} + +func (p *Provider) VerifyCallback(ctx context.Context, headers map[string]string, body []byte) error { + _ = ctx + bizContent := extractBizContent(body) + return p.client.VerifyCallback(headers, bizContent) +} + +func (p *Provider) ParseCallback(ctx context.Context, headers map[string]string, body []byte) (*ports.CallbackEvent, error) { + _ = ctx + bizContent := extractBizContent(body) + ev, err := p.client.ParseCallback(headers, bizContent) + if err != nil { + return nil, err + } + out := &ports.CallbackEvent{ + Event: ev.Event, + SignFlowID: ev.SignTaskID, + AuthFlowID: firstNonEmpty(ev.ClientCorpID, ev.OpenCorpID), + Raw: ev.Raw, + } + if ev.IsSignCompletedCallback() { + out.SignCompleted = true + } + // 企业授权/认证成功事件 + switch strings.ToLower(ev.Event) { + case "corp-authorize", "corp-authorize-success", "corporation_authorize", "auth-pass": + out.AuthPassed = true + } + if strings.EqualFold(ev.AuthResult, "success") || strings.EqualFold(ev.AuthResult, "pass") { + out.AuthPassed = true + } + return out, nil +} + +func extractBizContent(body []byte) string { + raw := strings.TrimSpace(string(body)) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "{") { + return raw + } + values, err := url.ParseQuery(raw) + if err == nil { + if v := values.Get("bizContent"); v != "" { + return v + } + } + return raw +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +var _ ports.SignPlatformProvider = (*Provider)(nil) diff --git a/internal/infrastructure/external/rongxing/crypto.go b/internal/infrastructure/external/rongxing/crypto.go new file mode 100644 index 0000000..89cb2da --- /dev/null +++ b/internal/infrastructure/external/rongxing/crypto.go @@ -0,0 +1,94 @@ +package rongxing + +import ( + "crypto" + "crypto/md5" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "fmt" + "sort" + "strings" +) + +// MD5Upper 对明文做 MD5,返回 32 位大写十六进制(入参说明要求大写)。 +func MD5Upper(plaintext string) string { + sum := md5.Sum([]byte(strings.TrimSpace(plaintext))) + return strings.ToUpper(hex.EncodeToString(sum[:])) +} + +// EncodePasswordBase64 将明文密码做 Base64 编码(登录接口要求)。 +func EncodePasswordBase64(password string) string { + return base64.StdEncoding.EncodeToString([]byte(password)) +} + +// BuildSignContent 生成登录签名原文:过滤 sign/空值 → 按 key 升序 → key=value&... +func BuildSignContent(params map[string]interface{}) string { + keys := make([]string, 0, len(params)) + for k, v := range params { + if strings.EqualFold(k, "sign") || v == nil { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%v", k, params[k])) + } + return strings.Join(parts, "&") +} + +// SignSHA256WithRSA 使用私钥对原文做 SHA256withRSA 签名,返回 Base64。 +func SignSHA256WithRSA(content string, privateKey *rsa.PrivateKey) (string, error) { + if privateKey == nil { + return "", fmt.Errorf("私钥为空") + } + hashed := sha256.Sum256([]byte(content)) + signed, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:]) + if err != nil { + return "", fmt.Errorf("RSA 签名失败: %w", err) + } + return base64.StdEncoding.EncodeToString(signed), nil +} + +// ParsePrivateKey 解析 RSA 私钥(支持 PEM / PKCS#8 / PKCS#1 原始 Base64)。 +func ParsePrivateKey(raw string) (*rsa.PrivateKey, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("私钥为空") + } + + var der []byte + if block, _ := pem.Decode([]byte(raw)); block != nil { + der = block.Bytes + } else { + normalized := strings.ReplaceAll(raw, "\n", "") + normalized = strings.ReplaceAll(normalized, "\r", "") + normalized = strings.ReplaceAll(normalized, " ", "") + decoded, err := base64.StdEncoding.DecodeString(normalized) + if err != nil { + return nil, fmt.Errorf("私钥 Base64 解码失败: %w", err) + } + der = decoded + } + + if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("私钥不是 RSA 类型") + } + return rsaKey, nil + } + + if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return key, nil + } + + return nil, fmt.Errorf("无法解析 RSA 私钥(需 PKCS#8 或 PKCS#1)") +} diff --git a/internal/infrastructure/external/rongxing/crypto_test.go b/internal/infrastructure/external/rongxing/crypto_test.go new file mode 100644 index 0000000..6701630 --- /dev/null +++ b/internal/infrastructure/external/rongxing/crypto_test.go @@ -0,0 +1,66 @@ +package rongxing + +import ( + "crypto/rand" + "crypto/rsa" + "testing" +) + +func TestMD5Upper(t *testing.T) { + if got := MD5Upper(""); got != "D41D8CD98F00B204E9800998ECF8427E" { + t.Fatalf("MD5Upper empty = %s", got) + } + if got := MD5Upper(" hello "); got != MD5Upper("hello") { + t.Fatalf("MD5Upper should trim spaces") + } +} + +func TestBuildSignContent(t *testing.T) { + params := map[string]interface{}{ + "account": "haiyukeji", + "password": "cGFzcw==", + "appId": "hykj", + "timestamp": int64(1710000000000), + "sign": "ignored", + } + got := BuildSignContent(params) + want := "account=haiyukeji&appId=hykj&password=cGFzcw==×tamp=1710000000000" + if got != want { + t.Fatalf("BuildSignContent mismatch\ngot: %s\nwant: %s", got, want) + } +} + +func TestSignSHA256WithRSA(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatalf("generate key: %v", err) + } + sig, err := SignSHA256WithRSA("account=a&appId=b", key) + if err != nil { + t.Fatalf("sign: %v", err) + } + if sig == "" { + t.Fatal("empty signature") + } +} + +func TestParsePrivateKeyPKCS8Base64(t *testing.T) { + // 与对接文档一致的 PKCS#8 Base64(无 PEM 头) + raw := "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw==" + key, err := ParsePrivateKey(raw) + if err != nil { + t.Fatalf("ParsePrivateKey: %v", err) + } + if key == nil { + t.Fatal("nil key") + } + if _, err := SignSHA256WithRSA("account=haiyukeji&appId=hykj", key); err != nil { + t.Fatalf("sign with parsed key: %v", err) + } +} + +func TestEncodePasswordBase64(t *testing.T) { + if got := EncodePasswordBase64("haiyukeji@123"); got != "aGFpeXVrZWppQDEyMw==" { + t.Fatalf("EncodePasswordBase64 = %s", got) + } +} diff --git a/internal/infrastructure/external/rongxing/curl_helper.go b/internal/infrastructure/external/rongxing/curl_helper.go new file mode 100644 index 0000000..49c3832 --- /dev/null +++ b/internal/infrastructure/external/rongxing/curl_helper.go @@ -0,0 +1,35 @@ +package rongxing + +import ( + "strings" +) + +// generateCurlCommand 生成可直接复现的 curl 命令,便于联调排查。 +func generateCurlCommand(method, url string, headers map[string]string, body string) string { + var cmd strings.Builder + cmd.WriteString("curl -X ") + cmd.WriteString(method) + cmd.WriteString(" '") + cmd.WriteString(url) + cmd.WriteString("'") + + for key, value := range headers { + cmd.WriteString(" \\\n -H '") + cmd.WriteString(key) + cmd.WriteString(": ") + cmd.WriteString(escapeSingleQuotes(value)) + cmd.WriteString("'") + } + + if body != "" { + cmd.WriteString(" \\\n -d '") + cmd.WriteString(escapeSingleQuotes(body)) + cmd.WriteString("'") + } + + return cmd.String() +} + +func escapeSingleQuotes(s string) string { + return strings.ReplaceAll(s, "'", `'\''`) +} diff --git a/internal/infrastructure/external/rongxing/rongxing_factory.go b/internal/infrastructure/external/rongxing/rongxing_factory.go new file mode 100644 index 0000000..795777a --- /dev/null +++ b/internal/infrastructure/external/rongxing/rongxing_factory.go @@ -0,0 +1,50 @@ +package rongxing + +import ( + "hyapi-server/internal/config" + "hyapi-server/internal/shared/external_logger" +) + +// NewRongxingServiceWithConfig 使用配置创建戎行服务 +func NewRongxingServiceWithConfig(cfg *config.Config) (*RongxingService, error) { + loggingConfig := external_logger.ExternalServiceLoggingConfig{ + Enabled: cfg.Rongxing.Logging.Enabled, + LogDir: cfg.Rongxing.Logging.LogDir, + ServiceName: "rongxing", + UseDaily: cfg.Rongxing.Logging.UseDaily, + EnableLevelSeparation: cfg.Rongxing.Logging.EnableLevelSeparation, + LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig), + } + + if cfg.Rongxing.Logging.ServiceName != "" { + loggingConfig.ServiceName = cfg.Rongxing.Logging.ServiceName + } + + for key, value := range cfg.Rongxing.Logging.LevelConfigs { + loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{ + MaxSize: value.MaxSize, + MaxBackups: value.MaxBackups, + MaxAge: value.MaxAge, + Compress: value.Compress, + } + } + + logger, err := external_logger.NewExternalServiceLogger(loggingConfig) + if err != nil { + return nil, err + } + + timeout := cfg.Rongxing.Timeout + if timeout <= 0 { + timeout = defaultRequestTimeout + } + + return NewRongxingService(serviceConfig{ + BaseURL: cfg.Rongxing.URL, + Account: cfg.Rongxing.Account, + Password: cfg.Rongxing.Password, + AppID: cfg.Rongxing.AppID, + PrivateKey: cfg.Rongxing.PrivateKey, + Timeout: timeout, + }, logger), nil +} diff --git a/internal/infrastructure/external/rongxing/rongxing_service.go b/internal/infrastructure/external/rongxing/rongxing_service.go new file mode 100644 index 0000000..2713ebb --- /dev/null +++ b/internal/infrastructure/external/rongxing/rongxing_service.go @@ -0,0 +1,396 @@ +package rongxing + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "hyapi-server/internal/shared/external_logger" + + "go.uber.org/zap" +) + +const ( + defaultRequestTimeout = 10 * time.Second + apiKeyLogin = "auth_login" + pathAuthLogin = "/auth/login" + headerDmsToken = "dms-token" +) + +// serviceConfig 戎行服务运行时配置 +type serviceConfig struct { + BaseURL string + Account string + Password string + AppID string + PrivateKey string + Timeout time.Duration +} + +// RongxingService 戎行数据源服务 +type RongxingService struct { + config serviceConfig + logger *external_logger.ExternalServiceLogger + + tokenMu sync.RWMutex + cachedToken string +} + +// apiResponse 戎行统一响应。code 可能是数字或字符串;扣费以 consumeFlag 为准。 +type apiResponse struct { + Flag bool `json:"flag"` + Code json.RawMessage `json:"code"` + Msg string `json:"msg"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + ConsumeFlag int `json:"consumeFlag"` +} + +func (r apiResponse) text() string { + if r.Msg != "" { + return r.Msg + } + return r.Message +} + +func (r apiResponse) code() string { + return parseCode(r.Code) +} + +// NewRongxingService 创建戎行服务实例 +func NewRongxingService(cfg serviceConfig, logger *external_logger.ExternalServiceLogger) *RongxingService { + if cfg.Timeout <= 0 { + cfg.Timeout = defaultRequestTimeout + } + cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + return &RongxingService{config: cfg, logger: logger} +} + +// GetConfig 获取运行时配置 +func (s *RongxingService) GetConfig() serviceConfig { + return s.config +} + +// CallAPI 通用业务接口调用。 +// apiPath 为相对路径(如 /third/loan/info360),reqData 为已组装好的请求体。 +// Token 获取与 Header 注入由服务内部处理;401/403 时自动刷新 Token 并重试一次。 +func (s *RongxingService) CallAPI(ctx context.Context, apiPath string, reqData map[string]interface{}) ([]byte, error) { + apiKey := strings.Trim(apiPath, "/") + + var transactionID string + if id, ok := ctx.Value("transaction_id").(string); ok { + transactionID = id + } + + if err := s.validateConfig(); err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKey, "", err, nil) + return nil, err + } + + if !strings.HasPrefix(apiPath, "/") { + apiPath = "/" + apiPath + } + requestURL := s.config.BaseURL + apiPath + + bodyBytes, err := json.Marshal(reqData) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKey, "", err, reqData) + return nil, err + } + bodyStr := string(bodyBytes) + + for attempt := 0; attempt < 2; attempt++ { + token, err := s.getToken(ctx, transactionID) + if err != nil { + return nil, err + } + + headers := map[string]string{ + "Content-Type": "application/json", + headerDmsToken: token, + } + curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, "") + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(headerDmsToken, token) + + respBody, statusCode, err := s.doHTTP(req) + if err != nil { + err = errors.Join(ErrDatasource, err) + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, "") + return nil, err + } + + respStr := string(respBody) + + if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden { + s.clearToken() + if attempt == 0 { + continue + } + err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", statusCode)) + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr) + return nil, err + } + + if statusCode != http.StatusOK { + err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", statusCode)) + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr) + return nil, err + } + + var resp apiResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w, body=%s", err, respStr)) + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr) + return nil, err + } + + code := resp.code() + payload := extractBusinessPayload(resp.Data) + + // 扣费只看 consumeFlag:1 扣费(按成功返回),0 不扣费 + if IsBillable(resp.ConsumeFlag) { + return payload, nil + } + + sentinel := MapNonBillableToErr(code) + err = errors.Join(sentinel, NewRongxingError(code, resp.text())) + if !errors.Is(sentinel, ErrNotFound) { + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr) + } + return nil, err + } + + return nil, errors.Join(ErrDatasource, errors.New("请求失败")) +} + +func (s *RongxingService) getToken(ctx context.Context, transactionID string) (string, error) { + s.tokenMu.RLock() + token := s.cachedToken + s.tokenMu.RUnlock() + if token != "" { + return token, nil + } + + s.tokenMu.Lock() + defer s.tokenMu.Unlock() + if s.cachedToken != "" { + return s.cachedToken, nil + } + + token, err := s.login(ctx, transactionID) + if err != nil { + return "", err + } + s.cachedToken = token + return token, nil +} + +func (s *RongxingService) clearToken() { + s.tokenMu.Lock() + s.cachedToken = "" + s.tokenMu.Unlock() +} + +func (s *RongxingService) login(ctx context.Context, transactionID string) (string, error) { + privateKey, err := ParsePrivateKey(s.config.PrivateKey) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKeyLogin, "", err, nil) + return "", err + } + + passwordB64 := EncodePasswordBase64(s.config.Password) + timestamp := time.Now().UnixMilli() + signParams := map[string]interface{}{ + "account": s.config.Account, + "password": passwordB64, + "appId": s.config.AppID, + "timestamp": timestamp, + } + content := BuildSignContent(signParams) + sign, err := SignSHA256WithRSA(content, privateKey) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKeyLogin, "", err, nil) + return "", err + } + + payload := map[string]interface{}{ + "account": s.config.Account, + "password": passwordB64, + "appId": s.config.AppID, + "timestamp": timestamp, + "sign": sign, + } + requestURL := s.config.BaseURL + pathAuthLogin + + bodyBytes, err := json.Marshal(payload) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKeyLogin, "", err, map[string]interface{}{ + "account": s.config.Account, + "appId": s.config.AppID, + }) + return "", err + } + bodyStr := string(bodyBytes) + + headers := map[string]string{"Content-Type": "application/json"} + curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, "") + return "", err + } + req.Header.Set("Content-Type", "application/json") + + respBody, statusCode, err := s.doHTTP(req) + if err != nil { + err = errors.Join(ErrDatasource, err) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, "") + return "", err + } + respStr := string(respBody) + + if statusCode != http.StatusOK { + err = errors.Join(ErrDatasource, fmt.Errorf("登录 HTTP状态码 %d, body=%s", statusCode, respStr)) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr) + return "", err + } + + var loginResp apiResponse + if err := json.Unmarshal(respBody, &loginResp); err != nil { + err = errors.Join(ErrSystem, fmt.Errorf("登录响应解析失败: %w, body=%s", err, respStr)) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr) + return "", err + } + + code := loginResp.code() + if code != CodeSuccess { + rxErr := NewRongxingError(code, loginResp.text()) + err = errors.Join(ErrDatasource, rxErr) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr) + return "", err + } + + var token string + if err := json.Unmarshal(loginResp.Data, &token); err != nil { + err = errors.Join(ErrSystem, fmt.Errorf("登录响应 Token 解析失败: %w, body=%s", err, respStr)) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr) + return "", err + } + token = strings.TrimSpace(token) + if token == "" { + err = errors.Join(ErrSystem, fmt.Errorf("登录响应 Token 为空, body=%s", respStr)) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr) + return "", err + } + + return token, nil +} + +func (s *RongxingService) doHTTP(req *http.Request) ([]byte, int, error) { + client := &http.Client{Timeout: s.config.Timeout} + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return body, resp.StatusCode, nil +} + +func (s *RongxingService) validateConfig() error { + if s.config.BaseURL == "" { + return errors.New("戎行 url 未配置") + } + if strings.TrimSpace(s.config.Account) == "" { + return errors.New("戎行 account 未配置") + } + if strings.TrimSpace(s.config.Password) == "" { + return errors.New("戎行 password 未配置") + } + if strings.TrimSpace(s.config.AppID) == "" { + return errors.New("戎行 app_id 未配置") + } + if strings.TrimSpace(s.config.PrivateKey) == "" { + return errors.New("戎行 private_key 未配置") + } + return nil +} + +func parseCode(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return strings.TrimSpace(s) + } + var n json.Number + if err := json.Unmarshal(raw, &n); err == nil { + return n.String() + } + return strings.Trim(string(raw), `"`) +} + +// extractBusinessPayload 提取对外返回的业务 data。 +// 若外层 data 内还嵌套 data(如 Info360),则取内层标签对象。 +func extractBusinessPayload(data json.RawMessage) []byte { + if len(data) == 0 || string(data) == "null" { + return []byte("{}") + } + + var wrap struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(data, &wrap); err == nil && + len(wrap.Data) > 0 && string(wrap.Data) != "null" { + return wrap.Data + } + return data +} + +func (s *RongxingService) logError(transactionID, apiKey, requestID string, err error, payload interface{}) { + if s.logger == nil { + return + } + s.logger.LogError(requestID, transactionID, apiKey, err, payload) +} + +func (s *RongxingService) logErrorWithCurl(transactionID, apiKey string, err error, payload interface{}, curlCmd, respBody string) { + if s.logger == nil { + return + } + s.logger.LogErrorWithFields("rongxing API错误", + zap.String("transaction_id", transactionID), + zap.String("api_code", apiKey), + zap.Error(err), + zap.Any("params", payload), + zap.String("curl", curlCmd), + zap.String("response_body", respBody), + ) +} diff --git a/internal/infrastructure/external/rongxing/status_codes.go b/internal/infrastructure/external/rongxing/status_codes.go new file mode 100644 index 0000000..10e0f36 --- /dev/null +++ b/internal/infrastructure/external/rongxing/status_codes.go @@ -0,0 +1,75 @@ +package rongxing + +import ( + "errors" + "fmt" +) + +// 业务返回码(仅用于错误描述/分类,不作为扣费依据) +const ( + CodeSuccess = "200" // 成功 + CodeNoData = "204" // 未查到数据 + CodeInternalErr = "503" // 内部服务错误 +) + +// consumeFlag 扣费标记(对方各业务码场景下均可能返回) +const ( + ConsumeFlagNoCharge = 0 // 不扣费 + ConsumeFlagCharge = 1 // 扣费 +) + +var ( + ErrDatasource = errors.New("数据源异常") + ErrSystem = errors.New("系统异常") + ErrNotFound = errors.New("查询为空") +) + +var codeMessage = map[string]string{ + CodeSuccess: "成功", + CodeNoData: "未查到数据", + CodeInternalErr: "内部服务错误", +} + +// GetCodeMessage 返回码描述 +func GetCodeMessage(code string) string { + if msg, ok := codeMessage[code]; ok { + return msg + } + return "" +} + +// IsBillable 按 consumeFlag 判断是否扣费(不以 code 为依据) +func IsBillable(consumeFlag int) bool { + return consumeFlag == ConsumeFlagCharge +} + +type rongxingError struct { + Code string + Message string +} + +func (e *rongxingError) Error() string { + return fmt.Sprintf("戎行返回错误,code: %s,message: %s", e.Code, e.Message) +} + +// NewRongxingError 创建戎行业务错误 +func NewRongxingError(code, message string) *rongxingError { + if message == "" { + if desc := GetCodeMessage(code); desc != "" { + message = desc + } else { + message = "戎行返回未知错误" + } + } + return &rongxingError{Code: code, Message: message} +} + +// MapNonBillableToErr 不扣费场景下,将返回码映射为内部哨兵错误 +func MapNonBillableToErr(code string) error { + switch code { + case CodeSuccess, CodeNoData: + return ErrNotFound + default: + return ErrDatasource + } +} diff --git a/internal/infrastructure/external/signplatform/registry.go b/internal/infrastructure/external/signplatform/registry.go new file mode 100644 index 0000000..6f698fd --- /dev/null +++ b/internal/infrastructure/external/signplatform/registry.go @@ -0,0 +1,52 @@ +package signplatform + +import ( + "fmt" + + "hyapi-server/internal/domains/certification/enums" + "hyapi-server/internal/domains/certification/ports" +) + +type Registry struct { + providers map[enums.SignPlatform]ports.SignPlatformProvider + order []enums.SignPlatform +} + +func NewRegistry(providers ...ports.SignPlatformProvider) *Registry { + r := &Registry{ + providers: make(map[enums.SignPlatform]ports.SignPlatformProvider, len(providers)), + order: make([]enums.SignPlatform, 0, len(providers)), + } + for _, p := range providers { + if p == nil { + continue + } + platform := p.Platform() + r.providers[platform] = p + r.order = append(r.order, platform) + } + return r +} + +func (r *Registry) Get(platform enums.SignPlatform) (ports.SignPlatformProvider, error) { + if platform == "" { + platform = enums.DefaultSignPlatform() + } + p, ok := r.providers[platform] + if !ok { + return nil, fmt.Errorf("不支持的签署平台: %s", platform) + } + return p, nil +} + +func (r *Registry) List() []ports.SignPlatformProvider { + out := make([]ports.SignPlatformProvider, 0, len(r.order)) + for _, platform := range r.order { + if p, ok := r.providers[platform]; ok { + out = append(out, p) + } + } + return out +} + +var _ ports.SignPlatformRegistry = (*Registry)(nil) diff --git a/internal/infrastructure/external/storage/qiniu_storage_service.go b/internal/infrastructure/external/storage/qiniu_storage_service.go index 77cf56a..777c33a 100644 --- a/internal/infrastructure/external/storage/qiniu_storage_service.go +++ b/internal/infrastructure/external/storage/qiniu_storage_service.go @@ -305,11 +305,10 @@ func (s *QiNiuStorageService) DownloadFile(ctx context.Context, fileURL string) isTimeout = true } else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { isTimeout = true - } else if errStr := err.Error(); - errStr == "context deadline exceeded" || - errStr == "timeout" || - errStr == "Client.Timeout exceeded" || - errStr == "net/http: request canceled" { + } else if errStr := err.Error(); errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { isTimeout = true } diff --git a/internal/infrastructure/http/handlers/certification_handler.go b/internal/infrastructure/http/handlers/certification_handler.go index d17d9f5..6af4c6b 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,61 @@ func (h *CertificationHandler) ApplyContract(c *gin.Context) { h.response.Success(c, result, "合同申请成功") } +// RefreshContractSignURL 刷新合同签署 iframe 长链 +// @Summary 刷新合同签署链接 +// @Description 重新获取可供 iframe 嵌入的签署长链(法大大约 10 分钟/单次有效) +// @Tags 认证管理 +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} responses.ContractSignUrlResponse "刷新成功" +// @Failure 400 {object} map[string]interface{} "请求参数错误" +// @Failure 401 {object} map[string]interface{} "未认证" +// @Router /api/v1/certifications/refresh-contract-sign-url [post] +func (h *CertificationHandler) RefreshContractSignURL(c *gin.Context) { + userID := h.getCurrentUserID(c) + if userID == "" { + h.response.Unauthorized(c, "用户未登录") + return + } + + result, err := h.appService.RefreshContractSignURL(c.Request.Context(), userID) + if err != nil { + h.logger.Error("刷新签署链接失败", zap.Error(err), zap.String("user_id", userID)) + h.response.BadRequest(c, err.Error()) + return + } + + h.response.Success(c, result, "签署链接已刷新") +} + +// RefreshContractPreviewURL 刷新合同预览链接 +// @Summary 刷新合同预览链接 +// @Description 获取签署任务预览链接(法大大 /sign-task/get-preview-url) +// @Tags 认证管理 +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} responses.ContractPreviewUrlResponse "刷新成功" +// @Failure 400 {object} map[string]interface{} "请求参数错误" +// @Router /api/v1/certifications/refresh-contract-preview-url [post] +func (h *CertificationHandler) RefreshContractPreviewURL(c *gin.Context) { + userID := h.getCurrentUserID(c) + if userID == "" { + h.response.Unauthorized(c, "用户未登录") + return + } + + result, err := h.appService.RefreshContractPreviewURL(c.Request.Context(), userID) + if err != nil { + h.logger.Error("刷新合同预览链接失败", zap.Error(err), zap.String("user_id", userID)) + h.response.BadRequest(c, err.Error()) + return + } + + h.response.Success(c, result, "预览链接已刷新") +} + // RecognizeBusinessLicense OCR识别营业执照 // @Summary OCR识别营业执照 // @Description 上传营业执照图片进行OCR识别,自动填充企业信息 diff --git a/internal/infrastructure/http/routes/certification_routes.go b/internal/infrastructure/http/routes/certification_routes.go index f6736e8..28785b1 100644 --- a/internal/infrastructure/http/routes/certification_routes.go +++ b/internal/infrastructure/http/routes/certification_routes.go @@ -65,10 +65,17 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) { // 3. 申请合同签署 authGroup.POST("/apply-contract", r.handler.ApplyContract) + // 刷新签署 iframe 长链(法大大 EmbedURL 短期有效) + authGroup.POST("/refresh-contract-sign-url", r.handler.RefreshContractSignURL) + authGroup.POST("/refresh-contract-preview-url", r.handler.RefreshContractPreviewURL) // 前端确认是否完成认证 authGroup.POST("/confirm-auth", r.handler.ConfirmAuth) + // 签署平台选择(审核通过后、企业认证前) + authGroup.GET("/sign-platforms", r.handler.ListSignPlatforms) + authGroup.POST("/select-sign-platform", r.handler.SelectSignPlatform) + // 前端确认是否完成签署 authGroup.POST("/confirm-sign", r.handler.ConfirmSign) @@ -95,7 +102,8 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) { // 回调路由(不需要认证,但需要验证签名) callbackGroup := certificationGroup.Group("/callbacks") { - callbackGroup.POST("/esign", r.handler.HandleEsignCallback) // e签宝回调(统一处理企业认证和合同签署回调) + callbackGroup.POST("/esign", r.handler.HandleEsignCallback) // e签宝回调 + callbackGroup.POST("/fadada", r.handler.HandleFadadaCallback) // 法大大回调 } } diff --git a/internal/shared/external_logger/external_logger.go b/internal/shared/external_logger/external_logger.go index 837466c..eb959c2 100644 --- a/internal/shared/external_logger/external_logger.go +++ b/internal/shared/external_logger/external_logger.go @@ -327,6 +327,24 @@ func (e *ExternalServiceLogger) LogResponse(requestID, transactionID, apiCode st ) } +// LogResponseWithBody 记录响应日志(含原始响应体) +func (e *ExternalServiceLogger) LogResponseWithBody(requestID, transactionID, apiCode string, statusCode int, duration time.Duration, responseBody string) { + logger := e.responseLogger + if logger == nil { + logger = e.logger + } + logger.Info(fmt.Sprintf("%s API响应", e.serviceName), + zap.String("service", e.serviceName), + zap.String("request_id", requestID), + zap.String("transaction_id", transactionID), + zap.String("api_code", apiCode), + zap.Int("status_code", statusCode), + zap.Duration("duration", duration), + zap.String("response_body", responseBody), + zap.String("timestamp", time.Now().Format(time.RFC3339)), + ) +} + // LogResponseWithID 记录包含响应ID的响应日志 func (e *ExternalServiceLogger) LogResponseWithID(requestID, transactionID, apiCode string, statusCode int, duration time.Duration, responseID string) { logger := e.responseLogger diff --git a/internal/shared/fadada/api_types.go b/internal/shared/fadada/api_types.go new file mode 100644 index 0000000..e631b58 --- /dev/null +++ b/internal/shared/fadada/api_types.go @@ -0,0 +1,188 @@ +package fadada + +// ---- 通用 ---- + +type openID struct { + IDType string `json:"idType"` + OpenID string `json:"openId"` +} + +// ---- accessToken ---- + +type accessTokenData struct { + AccessToken string `json:"accessToken"` + ExpiresIn string `json:"expiresIn"` +} + +// ---- /corp/get-auth-url ---- + +type getCorpAuthURLReq struct { + ClientCorpID string `json:"clientCorpId"` + ClientUserID string `json:"clientUserId,omitempty"` + AccountName string `json:"accountName,omitempty"` + CorpIdentInfo *corpIdentInfo `json:"corpIdentInfo,omitempty"` + CorpNonEditableInfo []string `json:"corpNonEditableInfo,omitempty"` + OprIdentInfo *oprIdentInfo `json:"oprIdentInfo,omitempty"` + OprNonEditableInfo []string `json:"oprNonEditableInfo,omitempty"` + CorpIdentInfoMatch bool `json:"corpIdentInfoMatch"` + AuthScopes []string `json:"authScopes,omitempty"` + RedirectURL string `json:"redirectUrl,omitempty"` +} + +type corpIdentInfo struct { + CorpName string `json:"corpName,omitempty"` + CorpIdentType string `json:"corpIdentType,omitempty"` + CorpIdentNo string `json:"corpIdentNo,omitempty"` + LegalRepName string `json:"legalRepName,omitempty"` + CorpIdentMethod []string `json:"corpIdentMethod,omitempty"` +} + +type oprIdentInfo struct { + UserName string `json:"userName,omitempty"` + UserIdentType string `json:"userIdentType,omitempty"` + UserIdentNo string `json:"userIdentNo,omitempty"` + Mobile string `json:"mobile,omitempty"` + OprIdentMethod []string `json:"oprIdentMethod,omitempty"` +} + +type getCorpAuthURLData struct { + AuthURL string `json:"authUrl"` +} + +// ---- /corp/get-identified-status ---- + +type getIdentifiedStatusReq struct { + CorpName string `json:"corpName,omitempty"` + CorpIdentNo string `json:"corpIdentNo,omitempty"` +} + +type getIdentifiedStatusData struct { + IdentStatus bool `json:"identStatus"` +} + +// ---- 模板 / 签署 ---- + +type docFieldValue struct { + // DocID 签署任务中的文档标识;fill-values 接口必填(缺则报 100012 docId 不能为空) + DocID string `json:"docId,omitempty"` + // FieldDocID 部分文档/示例使用 fieldDocId,与 DocID 同值一并传以保证兼容 + FieldDocID string `json:"fieldDocId,omitempty"` + FieldID string `json:"fieldId"` + FieldName string `json:"fieldName,omitempty"` + FieldValue string `json:"fieldValue"` +} + +type fillDocTemplateReq struct { + OwnerID *openID `json:"ownerId"` + DocTemplateID string `json:"docTemplateId"` + FileName string `json:"fileName,omitempty"` + DocFieldValues []docFieldValue `json:"docFieldValues"` +} + +type fillDocTemplateData struct { + FileID string `json:"fileId"` + FileDownloadURL string `json:"fileDownloadUrl"` +} + +type fillSignTaskFieldsReq struct { + SignTaskID string `json:"signTaskId"` + DocFieldValues []docFieldValue `json:"docFieldValues"` +} + +type signTaskActor struct { + Actor *actor `json:"actor"` + SignConfigInfo *signConfigInfo `json:"signConfigInfo,omitempty"` +} + +type actor struct { + ActorType string `json:"actorType"` + ActorID string `json:"actorId"` + ActorName string `json:"actorName,omitempty"` + Permissions []string `json:"permissions,omitempty"` + ActorOpenID string `json:"actorOpenId,omitempty"` + IdentNameForMatch string `json:"identNameForMatch,omitempty"` + CertNoForMatch string `json:"certNoForMatch,omitempty"` + AccountName string `json:"accountName,omitempty"` + ClientUserID string `json:"clientUserId,omitempty"` +} + +type signConfigInfo struct { + OrderNo int `json:"orderNo"` + JoinByLink bool `json:"joinByLink"` + RequestVerifyFree bool `json:"requestVerifyFree"` + FreeSignType string `json:"freeSignType,omitempty"` // template=按模板;business=按场景码 + 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..78c8304 --- /dev/null +++ b/internal/shared/fadada/corp_service.go @@ -0,0 +1,180 @@ +package fadada + +import ( + "fmt" + "net/url" + "strings" +) + +// 默认授权范围:认证信息 + 后续签署所需能力 +var defaultAuthScopes = []string{ + "ident_info", + "seal_info", + "signtask_info", + "signtask_init", + "signtask_file", + "organization", + "template", +} + +// GenerateEnterpriseAuth 获取企业认证/授权链接(POST /corp/get-auth-url) +// 文档见 1.md:corpIdentType 置空由用户在页面选择组织类型。 +func (c *Client) GenerateEnterpriseAuth(req *EnterpriseAuthRequest) (*EnterpriseAuthResult, error) { + if err := validateEnterpriseAuthRequest(req); err != nil { + return nil, err + } + + accessToken, err := c.ensureAccessToken() + if err != nil { + return nil, err + } + + clientCorpID := strings.TrimSpace(req.ClientCorpID) + if clientCorpID == "" { + clientCorpID = strings.TrimSpace(req.UnifiedSocialCode) + } + clientUserID := strings.TrimSpace(req.ClientUserID) + if clientUserID == "" { + clientUserID = strings.TrimSpace(req.TransactorID) + } + + redirectURL := "" + if c.config.Auth != nil { + redirectURL = c.config.Auth.RedirectURL + } + + apiReq := &getCorpAuthURLReq{ + ClientCorpID: clientCorpID, + ClientUserID: clientUserID, + AccountName: strings.TrimSpace(req.TransactorMobile), + CorpIdentInfo: &corpIdentInfo{ + CorpName: strings.TrimSpace(req.CompanyName), + // 按官方文档:不传默认为企业;置空让用户在页面选择组织类型 + // CorpIdentType: "", + CorpIdentNo: strings.TrimSpace(req.UnifiedSocialCode), + LegalRepName: strings.TrimSpace(req.LegalPersonName), + CorpIdentMethod: []string{CorpIdentMethodLegalRep}, + }, + // 文档合法值仅 corpName/corpIdentType/corpIdentNo;不传表示都可修改 + // CorpNonEditableInfo: , + OprIdentInfo: &oprIdentInfo{ + UserName: strings.TrimSpace(req.TransactorName), + UserIdentType: UserIdentTypeIDCard, + UserIdentNo: strings.TrimSpace(req.TransactorID), + Mobile: strings.TrimSpace(req.TransactorMobile), + OprIdentMethod: []string{OprIdentMethodMobile}, + }, + OprNonEditableInfo: nil, + AuthScopes: append([]string{}, defaultAuthScopes...), + RedirectURL: encodeRedirectURL(redirectURL), + } + + res, err := c.http.PostBiz(pathGetCorpAuthURL, accessToken, apiReq) + if err != nil { + return nil, err + } + if res.Code != successCode { + return nil, fmt.Errorf("获取法大大企业认证链接失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID) + } + + var data getCorpAuthURLData + if err := decodeData(res.Data, &data); err != nil { + return nil, fmt.Errorf("解析企业认证链接响应失败: %w", err) + } + if data.AuthURL == "" { + return nil, fmt.Errorf("获取法大大企业认证链接失败: authUrl 为空 requestId=%s", res.RequestID) + } + + return &EnterpriseAuthResult{ + AuthFlowID: clientCorpID, + AuthURL: data.AuthURL, + AuthShortURL: data.AuthURL, + }, nil +} + +// QueryOrgVerified 查询企业是否已在法大大完成实名(POST /corp/get-identified-status) +func (c *Client) QueryOrgVerified(req *QueryOrgIdentityRequest) (bool, error) { + result, err := c.QueryOrgIdentity(req) + if err != nil { + return false, err + } + return result.Identified, nil +} + +// QueryOrgIdentity 查询企业实名认证状态详情 +func (c *Client) QueryOrgIdentity(req *QueryOrgIdentityRequest) (*QueryOrgIdentityResult, error) { + if req == nil { + return nil, fmt.Errorf("查询请求不能为空") + } + corpName := strings.TrimSpace(req.CorpName) + corpIdentNo := strings.TrimSpace(req.CorpIdentNo) + if corpName == "" && corpIdentNo == "" { + return nil, fmt.Errorf("企业名称与统一社会信用代码不能同时为空") + } + + accessToken, err := c.ensureAccessToken() + if err != nil { + return nil, err + } + + res, err := c.http.PostBiz(pathGetCorpIdentifiedStatus, accessToken, &getIdentifiedStatusReq{ + CorpName: corpName, + CorpIdentNo: corpIdentNo, + }) + if err != nil { + return nil, err + } + if res.Code != successCode { + return nil, fmt.Errorf("查询法大大企业实名状态失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID) + } + + var data getIdentifiedStatusData + if err := decodeData(res.Data, &data); err != nil { + return nil, fmt.Errorf("解析企业实名状态失败: %w", err) + } + + return &QueryOrgIdentityResult{ + Identified: data.IdentStatus, + CorpName: corpName, + CorpIdentNo: corpIdentNo, + }, nil +} + +func validateEnterpriseAuthRequest(req *EnterpriseAuthRequest) error { + if req == nil { + return fmt.Errorf("认证请求不能为空") + } + if strings.TrimSpace(req.CompanyName) == "" { + return fmt.Errorf("企业名称不能为空") + } + if strings.TrimSpace(req.UnifiedSocialCode) == "" { + return fmt.Errorf("统一社会信用代码不能为空") + } + if strings.TrimSpace(req.LegalPersonName) == "" { + return fmt.Errorf("法人姓名不能为空") + } + if strings.TrimSpace(req.TransactorName) == "" { + return fmt.Errorf("经办人姓名不能为空") + } + if strings.TrimSpace(req.TransactorMobile) == "" { + return fmt.Errorf("经办人手机号不能为空") + } + if strings.TrimSpace(req.TransactorID) == "" { + return fmt.Errorf("经办人身份证号不能为空") + } + return nil +} + +// encodeRedirectURL 按官方文档对 redirectUrl 做 URL encode;已编码则原样返回 +func encodeRedirectURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.Contains(raw, "%") { + if decoded, err := url.QueryUnescape(raw); err == nil && decoded != raw { + return raw + } + } + return url.QueryEscape(raw) +} 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..0e93be8 --- /dev/null +++ b/internal/shared/fadada/types.go @@ -0,0 +1,167 @@ +package fadada + +// EnterpriseAuthRequest 企业认证请求(与 e签宝 字段对齐,便于后续统一调用) +type EnterpriseAuthRequest struct { + // ClientCorpID 企业在应用内的唯一标识;为空时默认使用统一社会信用代码 + ClientCorpID string `json:"clientCorpId,omitempty"` + // ClientUserID 经办人在应用内的唯一标识;为空时默认使用经办人身份证号 + ClientUserID string `json:"clientUserId,omitempty"` + + CompanyName string `json:"companyName"` + UnifiedSocialCode string `json:"unifiedSocialCode"` + LegalPersonName string `json:"legalPersonName"` + LegalPersonID string `json:"legalPersonId"` + + TransactorName string `json:"transactorName"` + TransactorMobile string `json:"transactorMobile"` + TransactorID string `json:"transactorId"` +} + +// EnterpriseAuthResult 企业认证结果 +type EnterpriseAuthResult struct { + // AuthFlowID 映射为法大大 clientCorpId,便于后续按企业标识查询 + AuthFlowID string `json:"authFlowId"` + AuthURL string `json:"authUrl"` + AuthShortURL string `json:"authShortUrl"` +} + +// QueryOrgIdentityRequest 查询企业是否已实名 +type QueryOrgIdentityRequest struct { + CorpName string `json:"corpName"` + CorpIdentNo string `json:"corpIdentNo"` // 统一社会信用代码 +} + +// QueryOrgIdentityResult 企业实名查询结果 +type QueryOrgIdentityResult struct { + Identified bool `json:"identified"` + CorpName string `json:"corpName"` + CorpIdentNo string `json:"corpIdentNo"` +} + +// ContractFillRequest 合作协议模板填单请求(对齐 e签宝 generateAndAddContractFile 入参) +type ContractFillRequest struct { + AgreementNo string `json:"agreementNo"` // 协议编号 + CompanyName string `json:"companyName"` // 甲方企业名 + UnifiedSocialCode string `json:"unifiedSocialCode"` // 甲方统一信用代码 + EnterpriseAddress string `json:"enterpriseAddress"` // 甲方联系地址 + AuthorizedRepName string `json:"authorizedRepName"` // 甲方授权代表/法人 + // SignDate 签署/签订日期,建议格式:2006年01月02日;为空则由调用方保证或使用当天 + SignDate string `json:"signDate"` + FileName string `json:"fileName,omitempty"` // 生成文件名,可选 +} + +// ContractFileResult 模板填单结果(对齐 e签宝 FillTemplate 返回) +type ContractFileResult struct { + FileID string `json:"fileId"` + FileDownloadURL string `json:"fileDownloadUrl"` +} + +// TemplateFieldValue 单个控件填写值 +type TemplateFieldValue struct { + FieldID string `json:"fieldId"` + FieldValue string `json:"fieldValue"` +} + +// 签署任务参与方 ActorId +// 基于签署模板创建时,必须与模板中「签署角色」名称完全一致。 +const ( + ActorIDPartyA = "甲方" // 甲方(企业入驻方) + ActorIDPartyB = "乙方" // 乙方(平台海宇) +) + +// 签署任务状态(法大大 signTaskStatus) +const ( + SignTaskStatusCreated = "task_created" + SignTaskStatusFillProgress = "fill_progress" + SignTaskStatusFillCompleted = "fill_completed" + SignTaskStatusSignProgress = "sign_progress" + SignTaskStatusSignCompleted = "sign_completed" + SignTaskStatusFinished = "task_finished" + SignTaskStatusTerminated = "task_terminated" +) + +// CreateSignFlowRequest 基于签署任务模板创建签署任务请求 +type CreateSignFlowRequest struct { + // Subject 签署任务主题;为空则用合同名称 + Subject string `json:"subject,omitempty"` + + // PartyAOpenCorpID 甲方在法大大侧 openCorpId(企业认证授权完成后获得) + PartyAOpenCorpID string `json:"partyAOpenCorpId,omitempty"` + PartyAName string `json:"partyAName"` + PartyAUSCC string `json:"partyAUscc,omitempty"` + + TransactorName string `json:"transactorName,omitempty"` + TransactorMobile string `json:"transactorMobile,omitempty"` + TransactorID string `json:"transactorId,omitempty"` + + // TransReferenceID 业务侧关联号(如认证记录 ID) + TransReferenceID string `json:"transReferenceId,omitempty"` + // Fill 创建模板任务后立即填写控件;非空时创建不 autoStart,填完后自动 Start + Fill *ContractFillRequest `json:"fill,omitempty"` +} + +// CreateSignFlowResult 创建签署任务结果 +type CreateSignFlowResult struct { + SignTaskID string `json:"signTaskId"` +} + +// SignURLResult 参与方签署链接 +type SignURLResult struct { + SignURL string `json:"signUrl"` + EmbedURL string `json:"embedUrl,omitempty"` + ActorID string `json:"actorId"` + SignTaskID string `json:"signTaskId"` +} + +// SignTaskPreviewURLResult 签署任务预览链接(EUI,非 PDF 直链) +type SignTaskPreviewURLResult struct { + SignTaskID string `json:"signTaskId"` + PreviewURL string `json:"previewUrl"` +} + +// SignStatusResult 签署状态查询结果 +type SignStatusResult struct { + SignTaskID string `json:"signTaskId"` + SignTaskStatus string `json:"signTaskStatus"` + Completed bool `json:"completed"` + Terminated bool `json:"terminated"` + FinishTime string `json:"finishTime,omitempty"` + TerminationNote string `json:"terminationNote,omitempty"` + RevokeReason string `json:"revokeReason,omitempty"` +} + +// SignedFile 已签署文件下载信息 +type SignedFile struct { + DownloadURL string `json:"downloadUrl"` + DownloadID string `json:"downloadId,omitempty"` +} + +// DownloadSignedFilesResult 下载结果 +type DownloadSignedFilesResult struct { + Files []*SignedFile `json:"files"` +} + +// CallbackEvent 法大大回调事件(认证/签署) +type CallbackEvent struct { + Event string `json:"event"` + SignTaskID string `json:"signTaskId,omitempty"` + SignTaskStatus string `json:"signTaskStatus,omitempty"` + ClientCorpID string `json:"clientCorpId,omitempty"` + OpenCorpID string `json:"openCorpId,omitempty"` + AuthResult string `json:"authResult,omitempty"` + EventTime string `json:"eventTime,omitempty"` + Raw map[string]interface{} `json:"raw,omitempty"` +} + +// ContractSigningRequest 一站式:填单 → 创建签署 → 取甲方链接 +type ContractSigningRequest struct { + CreateSignFlowRequest + Fill ContractFillRequest `json:"fill"` +} + +// ContractSigningResult 一站式签署结果 +type ContractSigningResult struct { + SignTaskID string `json:"signTaskId"` + SignURL string `json:"signUrl"` + EmbedURL string `json:"embedUrl,omitempty"` +} diff --git a/worker b/worker index 36e684c..18bd068 100644 Binary files a/worker and b/worker differ