Merge branch 'main' of http://1.117.67.95:3000/team/hyapi-server
This commit is contained in:
112
config.yaml
112
config.yaml
@@ -173,15 +173,21 @@ email:
|
|||||||
|
|
||||||
# 存储服务配置 - 七牛云
|
# 存储服务配置 - 七牛云
|
||||||
storage:
|
storage:
|
||||||
access_key: "your-qiniu-access-key"
|
access_key: "AO6u6sDWi6L9TsPfr4awC7FYP85JTjt3bodZACCM"
|
||||||
secret_key: "your-qiniu-secret-key"
|
secret_key: "2fjxweGtSAEaUdVgDkWEmN7JbBxHBQDv1cLORb9_"
|
||||||
bucket: "your-bucket-name"
|
bucket: "tianyuanapi"
|
||||||
domain: "https://your-domain.com"
|
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:
|
ocr:
|
||||||
api_key: "your-baidu-api-key"
|
api_key: "aMsrBNGUJxgcgqdm3SEdcumm"
|
||||||
secret_key: "your-baidu-secret-key"
|
secret_key: "sWlv2h2AWA3aAt5bjXCkE6WeA5AzpAAD"
|
||||||
|
|
||||||
ratelimit:
|
ratelimit:
|
||||||
requests: 7500
|
requests: 7500
|
||||||
@@ -287,6 +293,64 @@ esign:
|
|||||||
client_type: "ALL"
|
client_type: "ALL"
|
||||||
redirect_url: "https://console.haiyudata.com/certification/callback/sign"
|
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_backups: 5
|
||||||
max_age: 30
|
max_age: 30
|
||||||
compress: true
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,66 @@ esign:
|
|||||||
sign_field_style: 1
|
sign_field_style: 1
|
||||||
client_type: "ALL"
|
client_type: "ALL"
|
||||||
redirect_url: "http://localhost:5173/certification/callback/sign"
|
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_origins: "http://localhost:5173,http://localhost:8080"
|
||||||
cors_allowed_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
cors_allowed_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
||||||
cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With,Access-Id"
|
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
|
||||||
@@ -93,6 +93,65 @@ esign:
|
|||||||
sign_field_style: 1
|
sign_field_style: 1
|
||||||
client_type: "ALL"
|
client_type: "ALL"
|
||||||
redirect_url: "https://console.haiyudata.com/certification/callback/sign"
|
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_enabled: false # 是否启用滑块验证码
|
||||||
captcha_secret: "" # 阿里云验证码密钥(可选)
|
captcha_secret: "" # 阿里云验证码密钥(可选)
|
||||||
scene_id: "wynt39to" # 阿里云验证码场景ID
|
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
|
||||||
443
docs/企业入驻-e签宝功能分析.md
Normal file
443
docs/企业入驻-e签宝功能分析.md
Normal file
@@ -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<br/>写入 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`)
|
||||||
619
docs/多平台签署整合实现方案.md
Normal file
619
docs/多平台签署整合实现方案.md
Normal file
@@ -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[前端展示平台选择页<br/>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 节中的决策项后,可据此进入具体开发。*
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,14 @@ type CertificationApplicationService interface {
|
|||||||
ConfirmSign(ctx context.Context, cmd *queries.ConfirmSignCommand) (*responses.ConfirmSignResponse, error)
|
ConfirmSign(ctx context.Context, cmd *queries.ConfirmSignCommand) (*responses.ConfirmSignResponse, error)
|
||||||
// 申请合同签署
|
// 申请合同签署
|
||||||
ApplyContract(ctx context.Context, cmd *commands.ApplyContractCommand) (*responses.ContractSignUrlResponse, 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营业执照识别
|
// OCR营业执照识别
|
||||||
RecognizeBusinessLicense(ctx context.Context, imageBytes []byte) (*responses.BusinessLicenseResult, error)
|
RecognizeBusinessLicense(ctx context.Context, imageBytes []byte) (*responses.BusinessLicenseResult, error)
|
||||||
@@ -48,8 +56,10 @@ type CertificationApplicationService interface {
|
|||||||
// AdminTransitionCertificationStatus 管理端按用户变更认证状态(以状态机为准:info_submitted=通过 / info_rejected=拒绝)
|
// AdminTransitionCertificationStatus 管理端按用户变更认证状态(以状态机为准:info_submitted=通过 / info_rejected=拒绝)
|
||||||
AdminTransitionCertificationStatus(ctx context.Context, cmd *commands.AdminTransitionCertificationStatusCommand) error
|
AdminTransitionCertificationStatus(ctx context.Context, cmd *commands.AdminTransitionCertificationStatusCommand) error
|
||||||
|
|
||||||
// ================ e签宝回调处理 ================
|
// ================ 第三方回调处理 ================
|
||||||
|
|
||||||
// 处理e签宝回调
|
// 处理e签宝回调
|
||||||
HandleEsignCallback(ctx context.Context, cmd *commands.EsignCallbackCommand) error
|
HandleEsignCallback(ctx context.Context, cmd *commands.EsignCallbackCommand) error
|
||||||
|
// 处理法大大回调
|
||||||
|
HandleFadadaCallback(ctx context.Context, headers map[string]string, body []byte) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"hyapi-server/internal/domains/certification/entities"
|
"hyapi-server/internal/domains/certification/entities"
|
||||||
certification_value_objects "hyapi-server/internal/domains/certification/entities/value_objects"
|
certification_value_objects "hyapi-server/internal/domains/certification/entities/value_objects"
|
||||||
"hyapi-server/internal/domains/certification/enums"
|
"hyapi-server/internal/domains/certification/enums"
|
||||||
|
"hyapi-server/internal/domains/certification/ports"
|
||||||
"hyapi-server/internal/domains/certification/repositories"
|
"hyapi-server/internal/domains/certification/repositories"
|
||||||
"hyapi-server/internal/domains/certification/services"
|
"hyapi-server/internal/domains/certification/services"
|
||||||
finance_service "hyapi-server/internal/domains/finance/services"
|
finance_service "hyapi-server/internal/domains/finance/services"
|
||||||
@@ -40,6 +41,7 @@ type CertificationApplicationServiceImpl struct {
|
|||||||
smsCodeService *user_service.SMSCodeService
|
smsCodeService *user_service.SMSCodeService
|
||||||
esignClient *esign.Client
|
esignClient *esign.Client
|
||||||
esignConfig *esign.Config
|
esignConfig *esign.Config
|
||||||
|
platformRegistry ports.SignPlatformRegistry
|
||||||
qiniuStorageService *storage.QiNiuStorageService
|
qiniuStorageService *storage.QiNiuStorageService
|
||||||
contractAggregateService user_service.ContractAggregateService
|
contractAggregateService user_service.ContractAggregateService
|
||||||
walletAggregateService finance_service.WalletAggregateService
|
walletAggregateService finance_service.WalletAggregateService
|
||||||
@@ -65,6 +67,7 @@ func NewCertificationApplicationService(
|
|||||||
smsCodeService *user_service.SMSCodeService,
|
smsCodeService *user_service.SMSCodeService,
|
||||||
esignClient *esign.Client,
|
esignClient *esign.Client,
|
||||||
esignConfig *esign.Config,
|
esignConfig *esign.Config,
|
||||||
|
platformRegistry ports.SignPlatformRegistry,
|
||||||
qiniuStorageService *storage.QiNiuStorageService,
|
qiniuStorageService *storage.QiNiuStorageService,
|
||||||
contractAggregateService user_service.ContractAggregateService,
|
contractAggregateService user_service.ContractAggregateService,
|
||||||
walletAggregateService finance_service.WalletAggregateService,
|
walletAggregateService finance_service.WalletAggregateService,
|
||||||
@@ -87,6 +90,7 @@ func NewCertificationApplicationService(
|
|||||||
smsCodeService: smsCodeService,
|
smsCodeService: smsCodeService,
|
||||||
esignClient: esignClient,
|
esignClient: esignClient,
|
||||||
esignConfig: esignConfig,
|
esignConfig: esignConfig,
|
||||||
|
platformRegistry: platformRegistry,
|
||||||
qiniuStorageService: qiniuStorageService,
|
qiniuStorageService: qiniuStorageService,
|
||||||
contractAggregateService: contractAggregateService,
|
contractAggregateService: contractAggregateService,
|
||||||
walletAggregateService: walletAggregateService,
|
walletAggregateService: walletAggregateService,
|
||||||
@@ -443,16 +447,22 @@ func (s *CertificationApplicationServiceImpl) ConfirmAuth(
|
|||||||
zap.String("record_id", record.ID))
|
zap.String("record_id", record.ID))
|
||||||
s.logger.Info("确认状态-步骤3-开始查询三方实名状态",
|
s.logger.Info("确认状态-步骤3-开始查询三方实名状态",
|
||||||
zap.String("user_id", cmd.UserID),
|
zap.String("user_id", cmd.UserID),
|
||||||
zap.String("company_name", record.CompanyName))
|
zap.String("company_name", record.CompanyName),
|
||||||
identity, err := s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{
|
zap.String("sign_platform", string(cert.ResolvedSignPlatform())))
|
||||||
OrgName: record.CompanyName,
|
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 {
|
if err != nil {
|
||||||
s.logger.Error("查询企业认证信息失败", zap.Error(err))
|
s.logger.Error("查询企业认证信息失败", zap.Error(err))
|
||||||
return nil, fmt.Errorf("查询企业认证信息失败: %w", err)
|
return nil, fmt.Errorf("查询企业认证信息失败: %w", err)
|
||||||
}
|
}
|
||||||
reason := ""
|
reason := ""
|
||||||
if identity != nil && identity.Data.RealnameStatus == 1 {
|
if verified {
|
||||||
s.logger.Info("确认状态-步骤3-三方实名状态已完成,准备事务内推进认证",
|
s.logger.Info("确认状态-步骤3-三方实名状态已完成,准备事务内推进认证",
|
||||||
zap.String("user_id", cmd.UserID))
|
zap.String("user_id", cmd.UserID))
|
||||||
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
|
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
|
||||||
@@ -501,7 +511,7 @@ func (s *CertificationApplicationServiceImpl) ConfirmSign(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyContract 申请合同签署
|
// ApplyContract 立即签署:若认证完成时已创建任务则只取 iframe 长链并推进状态;否则创建任务
|
||||||
func (s *CertificationApplicationServiceImpl) ApplyContract(
|
func (s *CertificationApplicationServiceImpl) ApplyContract(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cmd *commands.ApplyContractCommand,
|
cmd *commands.ApplyContractCommand,
|
||||||
@@ -509,59 +519,179 @@ func (s *CertificationApplicationServiceImpl) ApplyContract(
|
|||||||
s.logger.Info("开始申请合同签署",
|
s.logger.Info("开始申请合同签署",
|
||||||
zap.String("user_id", cmd.UserID))
|
zap.String("user_id", cmd.UserID))
|
||||||
|
|
||||||
// 1. 验证命令完整性
|
|
||||||
if err := s.validateApplyContractCommand(cmd); err != nil {
|
if err := s.validateApplyContractCommand(cmd); err != nil {
|
||||||
return nil, fmt.Errorf("命令验证失败: %s", err.Error())
|
return nil, fmt.Errorf("命令验证失败: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 加载认证聚合根
|
|
||||||
cert, err := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID)
|
cert, err := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("加载认证信息失败: %s", err.Error())
|
return nil, fmt.Errorf("加载认证信息失败: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 验证业务前置条件
|
|
||||||
if err := s.validateContractApplicationPreconditions(cert, cmd.UserID); err != nil {
|
if err := s.validateContractApplicationPreconditions(cert, cmd.UserID); err != nil {
|
||||||
return nil, fmt.Errorf("业务前置条件验证失败: %s", err.Error())
|
return nil, fmt.Errorf("业务前置条件验证失败: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 生成合同和签署链接
|
|
||||||
enterpriseInfo, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, cmd.UserID)
|
enterpriseInfo, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, cmd.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("获取企业信息失败", zap.Error(err))
|
s.logger.Error("获取企业信息失败", zap.Error(err))
|
||||||
return nil, fmt.Errorf("获取企业信息失败: %w", err)
|
return nil, fmt.Errorf("获取企业信息失败: %w", err)
|
||||||
}
|
}
|
||||||
contractInfo, err := s.generateContractAndSignURL(ctx, cert, enterpriseInfo.EnterpriseInfo)
|
|
||||||
if err != nil {
|
var embedURL, shortURL string
|
||||||
s.logger.Error("生成合同失败", zap.Error(err))
|
flowID := strings.TrimSpace(cert.EsignFlowID)
|
||||||
return nil, fmt.Errorf("生成合同失败: %s", err.Error())
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
s.logger.Error("合同申请状态转换失败", zap.Error(err))
|
s.logger.Error("合同申请状态转换失败", zap.Error(err))
|
||||||
return nil, fmt.Errorf("合同申请失败: %s", err.Error())
|
return nil, fmt.Errorf("合同申请失败: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 保存认证信息
|
|
||||||
err = s.aggregateService.SaveCertification(ctx, cert)
|
err = s.aggregateService.SaveCertification(ctx, cert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("保存认证信息失败", zap.Error(err))
|
s.logger.Error("保存认证信息失败", zap.Error(err))
|
||||||
return nil, fmt.Errorf("保存认证信息失败: %s", err.Error())
|
return nil, fmt.Errorf("保存认证信息失败: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 构建响应
|
|
||||||
response := responses.NewContractSignUrlResponse(
|
response := responses.NewContractSignUrlResponse(
|
||||||
cert.ID,
|
cert.ID,
|
||||||
contractInfo.ContractSignURL,
|
embedURL,
|
||||||
contractInfo.ContractURL,
|
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
|
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 获取认证详情
|
// 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 {
|
if cert.Status == enums.StatusInfoSubmitted {
|
||||||
err = s.checkAndCompleteEnterpriseVerification(ctx, cert)
|
if autoErr := s.checkAndCompleteEnterpriseVerification(ctx, cert); autoErr != nil {
|
||||||
if err != nil {
|
// 自动推进失败时仍返回当前认证详情,避免前端无法渲染步骤而回退到「填写企业信息」
|
||||||
return nil, err
|
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 {
|
if cert.Status == enums.StatusContractApplied {
|
||||||
@@ -873,8 +1029,10 @@ func (s *CertificationApplicationServiceImpl) AdminListSubmitRecords(
|
|||||||
items := make([]*responses.AdminSubmitRecordItem, 0, len(result.Records))
|
items := make([]*responses.AdminSubmitRecordItem, 0, len(result.Records))
|
||||||
for _, r := range result.Records {
|
for _, r := range result.Records {
|
||||||
certStatus := ""
|
certStatus := ""
|
||||||
|
signPlatform := ""
|
||||||
if cert, err := s.aggregateService.LoadCertificationByUserID(ctx, r.UserID); err == nil && cert != nil {
|
if cert, err := s.aggregateService.LoadCertificationByUserID(ctx, r.UserID); err == nil && cert != nil {
|
||||||
certStatus = string(cert.Status)
|
certStatus = string(cert.Status)
|
||||||
|
signPlatform = string(cert.SignPlatform)
|
||||||
}
|
}
|
||||||
items = append(items, &responses.AdminSubmitRecordItem{
|
items = append(items, &responses.AdminSubmitRecordItem{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
@@ -884,6 +1042,8 @@ func (s *CertificationApplicationServiceImpl) AdminListSubmitRecords(
|
|||||||
LegalPersonName: r.LegalPersonName,
|
LegalPersonName: r.LegalPersonName,
|
||||||
SubmitAt: r.SubmitAt,
|
SubmitAt: r.SubmitAt,
|
||||||
Status: r.Status,
|
Status: r.Status,
|
||||||
|
ManualReviewStatus: r.ManualReviewStatus,
|
||||||
|
SignPlatform: signPlatform,
|
||||||
CertificationStatus: certStatus,
|
CertificationStatus: certStatus,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -907,8 +1067,10 @@ func (s *CertificationApplicationServiceImpl) AdminGetSubmitRecordByID(ctx conte
|
|||||||
return nil, fmt.Errorf("获取提交记录失败: %w", err)
|
return nil, fmt.Errorf("获取提交记录失败: %w", err)
|
||||||
}
|
}
|
||||||
certStatus := ""
|
certStatus := ""
|
||||||
|
signPlatform := ""
|
||||||
if cert, loadErr := s.aggregateService.LoadCertificationByUserID(ctx, record.UserID); loadErr == nil && cert != nil {
|
if cert, loadErr := s.aggregateService.LoadCertificationByUserID(ctx, record.UserID); loadErr == nil && cert != nil {
|
||||||
certStatus = string(cert.Status)
|
certStatus = string(cert.Status)
|
||||||
|
signPlatform = string(cert.SignPlatform)
|
||||||
}
|
}
|
||||||
return &responses.AdminSubmitRecordDetail{
|
return &responses.AdminSubmitRecordDetail{
|
||||||
ID: record.ID,
|
ID: record.ID,
|
||||||
@@ -932,6 +1094,9 @@ func (s *CertificationApplicationServiceImpl) AdminGetSubmitRecordByID(ctx conte
|
|||||||
VerifiedAt: record.VerifiedAt,
|
VerifiedAt: record.VerifiedAt,
|
||||||
FailedAt: record.FailedAt,
|
FailedAt: record.FailedAt,
|
||||||
FailureReason: record.FailureReason,
|
FailureReason: record.FailureReason,
|
||||||
|
ManualReviewStatus: record.ManualReviewStatus,
|
||||||
|
ManualReviewRemark: record.ManualReviewRemark,
|
||||||
|
SignPlatform: signPlatform,
|
||||||
CertificationStatus: certStatus,
|
CertificationStatus: certStatus,
|
||||||
CreatedAt: record.CreatedAt,
|
CreatedAt: record.CreatedAt,
|
||||||
UpdatedAt: record.UpdatedAt,
|
UpdatedAt: record.UpdatedAt,
|
||||||
@@ -967,52 +1132,19 @@ func (s *CertificationApplicationServiceImpl) AdminApproveSubmitRecord(ctx conte
|
|||||||
if cert.Status != enums.StatusInfoPendingReview {
|
if cert.Status != enums.StatusInfoPendingReview {
|
||||||
return fmt.Errorf("认证状态不是待审核,当前: %s", enums.GetStatusName(cert.Status))
|
return fmt.Errorf("认证状态不是待审核,当前: %s", enums.GetStatusName(cert.Status))
|
||||||
}
|
}
|
||||||
enterpriseInfo := &certification_value_objects.EnterpriseInfo{
|
// 幂等:已审核通过、等待用户选平台
|
||||||
CompanyName: record.CompanyName,
|
if record.ManualReviewStatus == "approved" {
|
||||||
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))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := cert.ApproveEnterpriseInfoReview(authURL.AuthShortURL, authURL.AuthFlowID, adminID); err != nil {
|
// 审核通过后不立即生成认证链接,由用户选择签署平台(e签宝/法大大)后再生成
|
||||||
return fmt.Errorf("更新认证状态失败: %w", err)
|
|
||||||
}
|
|
||||||
if err := s.aggregateService.SaveCertification(ctx, cert); err != nil {
|
|
||||||
return fmt.Errorf("保存认证信息失败: %w", err)
|
|
||||||
}
|
|
||||||
record.MarkManualApproved(adminID, remark)
|
record.MarkManualApproved(adminID, remark)
|
||||||
if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
|
if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
|
||||||
return fmt.Errorf("保存企业信息提交记录失败: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1076,7 +1208,7 @@ func (s *CertificationApplicationServiceImpl) AdminTransitionCertificationStatus
|
|||||||
}
|
}
|
||||||
switch cmd.TargetStatus {
|
switch cmd.TargetStatus {
|
||||||
case string(enums.StatusInfoSubmitted):
|
case string(enums.StatusInfoSubmitted):
|
||||||
// 审核通过:与 AdminApproveSubmitRecord 一致,推状态并生成企业认证链接
|
// 审核通过:与 AdminApproveSubmitRecord 一致,仅标记审核通过,等待用户选择签署平台
|
||||||
switch cert.Status {
|
switch cert.Status {
|
||||||
case enums.StatusInfoSubmitted, enums.StatusEnterpriseVerified, enums.StatusContractApplied,
|
case enums.StatusInfoSubmitted, enums.StatusEnterpriseVerified, enums.StatusContractApplied,
|
||||||
enums.StatusContractSigned, enums.StatusCompleted, enums.StatusContractRejected, enums.StatusContractExpired:
|
enums.StatusContractSigned, enums.StatusCompleted, enums.StatusContractRejected, enums.StatusContractExpired:
|
||||||
@@ -1085,45 +1217,13 @@ func (s *CertificationApplicationServiceImpl) AdminTransitionCertificationStatus
|
|||||||
if cert.Status != enums.StatusInfoPendingReview {
|
if cert.Status != enums.StatusInfoPendingReview {
|
||||||
return fmt.Errorf("认证状态不是待审核,当前: %s", enums.GetStatusName(cert.Status))
|
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)
|
record.MarkManualApproved(cmd.AdminID, cmd.Remark)
|
||||||
if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
|
if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
|
||||||
return fmt.Errorf("保存企业信息提交记录失败: %w", err)
|
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
|
return nil
|
||||||
case string(enums.StatusInfoRejected):
|
case string(enums.StatusInfoRejected):
|
||||||
// 审核拒绝
|
// 审核拒绝
|
||||||
@@ -1160,6 +1260,7 @@ func (s *CertificationApplicationServiceImpl) convertToResponse(cert *entities.C
|
|||||||
UserID: cert.UserID,
|
UserID: cert.UserID,
|
||||||
Status: cert.Status,
|
Status: cert.Status,
|
||||||
StatusName: enums.GetStatusName(cert.Status),
|
StatusName: enums.GetStatusName(cert.Status),
|
||||||
|
SignPlatform: string(cert.SignPlatform),
|
||||||
Progress: cert.GetProgress(),
|
Progress: cert.GetProgress(),
|
||||||
CreatedAt: cert.CreatedAt,
|
CreatedAt: cert.CreatedAt,
|
||||||
UpdatedAt: cert.UpdatedAt,
|
UpdatedAt: cert.UpdatedAt,
|
||||||
@@ -1198,58 +1299,6 @@ func (s *CertificationApplicationServiceImpl) convertToResponse(cert *entities.C
|
|||||||
return response
|
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 {
|
func isEnterpriseAlreadyRealnamedErr(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
@@ -1258,6 +1307,18 @@ func isEnterpriseAlreadyRealnamedErr(err error) bool {
|
|||||||
return strings.Contains(msg, "企业用户已实名") || strings.Contains(msg, "已实名")
|
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 验证申请合同命令
|
// validateApplyContractCommand 验证申请合同命令
|
||||||
func (s *CertificationApplicationServiceImpl) validateApplyContractCommand(cmd *commands.ApplyContractCommand) error {
|
func (s *CertificationApplicationServiceImpl) validateApplyContractCommand(cmd *commands.ApplyContractCommand) error {
|
||||||
if cmd.UserID == "" {
|
if cmd.UserID == "" {
|
||||||
@@ -1279,27 +1340,41 @@ func (s *CertificationApplicationServiceImpl) validateContractApplicationPrecond
|
|||||||
|
|
||||||
// generateContractAndSignURL 生成合同和签署链接
|
// generateContractAndSignURL 生成合同和签署链接
|
||||||
func (s *CertificationApplicationServiceImpl) generateContractAndSignURL(ctx context.Context, cert *entities.Certification, enterpriseInfo *user_entities.EnterpriseInfo) (*certification_value_objects.ContractInfo, error) {
|
func (s *CertificationApplicationServiceImpl) generateContractAndSignURL(ctx context.Context, cert *entities.Certification, enterpriseInfo *user_entities.EnterpriseInfo) (*certification_value_objects.ContractInfo, error) {
|
||||||
// 发起签署流程
|
provider, err := s.resolveProvider(cert)
|
||||||
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,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("生成合同失败: %s", err.Error())
|
return nil, err
|
||||||
}
|
}
|
||||||
|
record, _ := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID)
|
||||||
_, shortUrl, err := s.esignClient.GetSignURL(signFlowID, enterpriseInfo.LegalPersonPhone, enterpriseInfo.CompanyName)
|
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 {
|
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{
|
return &certification_value_objects.ContractInfo{
|
||||||
ContractFileID: cert.ContractFileID,
|
ContractFileID: cert.ContractFileID,
|
||||||
EsignFlowID: signFlowID,
|
EsignFlowID: signRes.SignFlowID,
|
||||||
ContractSignURL: shortUrl,
|
ContractSignURL: embedURL,
|
||||||
|
ContractSignShortURL: strings.TrimSpace(signRes.ShortURL),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1333,7 +1408,7 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification(
|
|||||||
zap.String("user_id", userID),
|
zap.String("user_id", userID),
|
||||||
zap.String("record_id", record.ID))
|
zap.String("record_id", record.ID))
|
||||||
|
|
||||||
err = s.userAggregateService.CreateEnterpriseInfo(
|
err = s.userAggregateService.CreateOrUpdateEnterpriseInfo(
|
||||||
ctx,
|
ctx,
|
||||||
userID,
|
userID,
|
||||||
record.CompanyName,
|
record.CompanyName,
|
||||||
@@ -1346,17 +1421,26 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("保存企业信息到用户域失败", zap.Error(err))
|
s.logger.Error("保存企业信息到用户域失败", zap.Error(err))
|
||||||
return fmt.Errorf("保存企业信息失败: %s", err.Error())
|
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))
|
err = s.generateAndAddContractFile(ctx, cert, record.CompanyName, record.UnifiedSocialCode, record.EnterpriseAddress, pickAuthorizedRepName(record, record.LegalPersonName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.logger.Info("完成企业认证-步骤3-合同文件生成并写入认证成功", zap.String("user_id", userID))
|
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)
|
err = s.aggregateService.SaveCertification(ctx, cert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1368,6 +1452,46 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification(
|
|||||||
return nil
|
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 合同模板「客户授权代表」: 优先企业提交记录中的授权代表, 否则为法定代表人
|
// pickAuthorizedRepName 合同模板「客户授权代表」: 优先企业提交记录中的授权代表, 否则为法定代表人
|
||||||
func pickAuthorizedRepName(record *entities.EnterpriseInfoSubmitRecord, legalPersonName string) string {
|
func pickAuthorizedRepName(record *entities.EnterpriseInfoSubmitRecord, legalPersonName string) string {
|
||||||
if record != nil && strings.TrimSpace(record.AuthorizedRepName) != "" {
|
if record != nil && strings.TrimSpace(record.AuthorizedRepName) != "" {
|
||||||
@@ -1396,22 +1520,18 @@ func (s *CertificationApplicationServiceImpl) generateAndAddContractFile(
|
|||||||
agreementNo := cert.ContractCode
|
agreementNo := cert.ContractCode
|
||||||
|
|
||||||
signDate := time.Now().Format("2006年01月02日")
|
signDate := time.Now().Format("2006年01月02日")
|
||||||
|
provider, err := s.resolveProvider(cert)
|
||||||
// 控件 key 与 e 签宝合同模板中控件名一致(新合同)
|
if err != nil {
|
||||||
fileComponent := map[string]string{
|
return err
|
||||||
"jfqym": companyName,
|
|
||||||
"jfqym2": companyName,
|
|
||||||
"jfsqdb": authorizedRepName,
|
|
||||||
"jftyshxydm": unifiedSocialCode,
|
|
||||||
"jflxdz": enterpriseAddress,
|
|
||||||
// 甲方
|
|
||||||
"xybh": agreementNo,
|
|
||||||
"qsrq1": signDate,
|
|
||||||
"qsrq3": signDate,
|
|
||||||
// 乙方
|
|
||||||
"qsrq2": signDate,
|
|
||||||
}
|
}
|
||||||
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 {
|
if err != nil {
|
||||||
s.logger.Error("生成合同失败", zap.Error(err))
|
s.logger.Error("生成合同失败", zap.Error(err))
|
||||||
return fmt.Errorf("生成合同失败: %s", err.Error())
|
return fmt.Errorf("生成合同失败: %s", err.Error())
|
||||||
@@ -1419,8 +1539,9 @@ func (s *CertificationApplicationServiceImpl) generateAndAddContractFile(
|
|||||||
s.logger.Info("合同生成-步骤1-模板填充成功",
|
s.logger.Info("合同生成-步骤1-模板填充成功",
|
||||||
zap.String("user_id", cert.UserID),
|
zap.String("user_id", cert.UserID),
|
||||||
zap.String("file_id", fillTemplateResp.FileID),
|
zap.String("file_id", fillTemplateResp.FileID),
|
||||||
zap.String("contract_code", agreementNo))
|
zap.String("contract_code", agreementNo),
|
||||||
err = cert.AddContractFileID(fillTemplateResp.FileID, fillTemplateResp.FileDownloadUrl)
|
zap.String("sign_platform", string(cert.ResolvedSignPlatform())))
|
||||||
|
err = cert.AddContractFileID(fillTemplateResp.FileID, fillTemplateResp.FileDownloadURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("加入合同文件ID链接失败", zap.Error(err))
|
s.logger.Error("加入合同文件ID链接失败", zap.Error(err))
|
||||||
return fmt.Errorf("加入合同文件ID链接失败: %s", err.Error())
|
return fmt.Errorf("加入合同文件ID链接失败: %s", err.Error())
|
||||||
@@ -1466,13 +1587,19 @@ func (s *CertificationApplicationServiceImpl) checkAndCompleteEnterpriseVerifica
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("查找企业信息失败: %w", err)
|
return fmt.Errorf("查找企业信息失败: %w", err)
|
||||||
}
|
}
|
||||||
identity, err := s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{
|
provider, err := s.resolveProvider(cert)
|
||||||
OrgName: record.CompanyName,
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
verified, err := provider.QueryOrgVerified(ctx, &ports.OrgIdentityQuery{
|
||||||
|
OrgName: record.CompanyName,
|
||||||
|
OrgIdentNo: record.UnifiedSocialCode,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("查询企业认证信息失败", zap.Error(err))
|
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 {
|
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
|
||||||
return s.completeEnterpriseVerification(txCtx, cert, cert.UserID, record.CompanyName, record.LegalPersonName)
|
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 {
|
if cert.Status != enums.StatusContractApplied {
|
||||||
return fmt.Errorf("认证状态不正确")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("查询签署流程详情失败: %s", err.Error())
|
return fmt.Errorf("查询签署流程详情失败: %s", err.Error())
|
||||||
}
|
}
|
||||||
if detail.Data.SignFlowStatus == 2 {
|
if detail.Completed {
|
||||||
err = cert.SignSuccess()
|
err = cert.SignSuccess()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("合同签署成功失败: %s", err.Error())
|
return fmt.Errorf("合同签署成功失败: %s", err.Error())
|
||||||
@@ -1503,21 +1634,19 @@ func (s *CertificationApplicationServiceImpl) checkAndUpdateSignStatus(ctx conte
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("完成认证失败: %s", err.Error())
|
return fmt.Errorf("完成认证失败: %s", err.Error())
|
||||||
}
|
}
|
||||||
// 同步合同信息到用户域
|
|
||||||
err = s.handleContractAfterSignComplete(txCtx, cert)
|
err = s.handleContractAfterSignComplete(txCtx, cert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("同步合同信息到用户域失败", zap.Error(err))
|
s.logger.Error("同步合同信息到用户域失败", zap.Error(err))
|
||||||
return fmt.Errorf("同步合同信息失败: %s", err.Error())
|
return fmt.Errorf("同步合同信息失败: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
reason = "合同签署成功"
|
reason = "合同签署成功"
|
||||||
} else if detail.Data.SignFlowStatus == 7 {
|
} else if detail.Rejected {
|
||||||
err = cert.ContractRejection(detail.Data.SignFlowDescription)
|
err = cert.ContractRejection(detail.Message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("合同签署失败: %s", err.Error())
|
return fmt.Errorf("合同签署失败: %s", err.Error())
|
||||||
}
|
}
|
||||||
reason = "合同签署拒签"
|
reason = "合同签署拒签"
|
||||||
} else if detail.Data.SignFlowStatus == 5 {
|
} else if detail.Expired {
|
||||||
err = cert.ContractExpiration()
|
err = cert.ContractExpiration()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("合同签署过期失败: %s", err.Error())
|
return fmt.Errorf("合同签署过期失败: %s", err.Error())
|
||||||
@@ -1549,40 +1678,42 @@ func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ct
|
|||||||
return fmt.Errorf("用户企业信息不存在")
|
return fmt.Errorf("用户企业信息不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 获取所有已签署合同文件信息
|
provider, err := s.resolveProvider(cert)
|
||||||
downloadSignedFileResponse, err := s.esignClient.DownloadSignedFile(cert.EsignFlowID)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files, err := provider.DownloadSignedFiles(ctx, cert.EsignFlowID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("下载已签署文件失败: %s", err.Error())
|
return fmt.Errorf("下载已签署文件失败: %s", err.Error())
|
||||||
}
|
}
|
||||||
files := downloadSignedFileResponse.Data.Files
|
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
return fmt.Errorf("未获取到已签署合同文件")
|
return fmt.Errorf("未获取到已签署合同文件")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range files {
|
for i, file := range files {
|
||||||
fileUrl := file.DownloadUrl
|
fileUrl := file.DownloadURL
|
||||||
fileName := file.FileName
|
fileName := fmt.Sprintf("合作协议-%s-%d.pdf", cert.ContractCode, i+1)
|
||||||
fileId := file.FileId
|
fileId := firstNonEmptyStr(file.DownloadID, cert.EsignFlowID)
|
||||||
s.logger.Info("下载已签署文件准备", zap.String("file_url", fileUrl), zap.String("file_name", fileName))
|
s.logger.Info("下载已签署文件准备", zap.String("file_url", fileUrl), zap.String("file_name", fileName))
|
||||||
|
|
||||||
// 2. 下载文件内容
|
|
||||||
fileBytes, err := s.downloadFileContent(ctx, fileUrl)
|
fileBytes, err := s.downloadFileContent(ctx, fileUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("下载合同文件内容失败", zap.String("file_name", fileName), zap.Error(err))
|
s.logger.Error("下载合同文件内容失败", zap.String("file_name", fileName), zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 上传到七牛云
|
|
||||||
uploadResult, err := s.qiniuStorageService.UploadFile(ctx, fileBytes, fileName)
|
uploadResult, err := s.qiniuStorageService.UploadFile(ctx, fileBytes, fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("上传合同文件到七牛云失败", zap.String("file_name", fileName), zap.Error(err))
|
s.logger.Error("上传合同文件到七牛云失败", zap.String("file_name", fileName), zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
qiniuURL := uploadResult.URL
|
qiniuURL := uploadResult.URL
|
||||||
|
|
||||||
s.logger.Info("合同文件已上传七牛云", zap.String("file_name", fileName), zap.String("qiniu_url", qiniuURL))
|
s.logger.Info("合同文件已上传七牛云", zap.String("file_name", fileName), zap.String("qiniu_url", qiniuURL))
|
||||||
|
|
||||||
// 4. 保存到合同聚合根(复用认证阶段的合同编号)
|
if i == 0 {
|
||||||
|
cert.ContractURL = qiniuURL
|
||||||
|
}
|
||||||
|
|
||||||
_, err = s.contractAggregateService.CreateContractWithCode(
|
_, err = s.contractAggregateService.CreateContractWithCode(
|
||||||
ctx,
|
ctx,
|
||||||
user.EnterpriseInfo.ID,
|
user.EnterpriseInfo.ID,
|
||||||
@@ -1597,7 +1728,6 @@ func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ct
|
|||||||
s.logger.Error("保存合同信息到聚合根失败", zap.String("file_name", fileName), zap.Error(err))
|
s.logger.Error("保存合同信息到聚合根失败", zap.String("file_name", fileName), zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info("合同信息已保存到聚合根", zap.String("file_name", fileName), zap.String("qiniu_url", qiniuURL))
|
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["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
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyStr(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// completeUserActivationWithoutContract 创建钱包、API用户并在用户域标记完成认证(不依赖合同信息)
|
// completeUserActivationWithoutContract 创建钱包、API用户并在用户域标记完成认证(不依赖合同信息)
|
||||||
func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContract(ctx context.Context, cert *entities.Certification) error {
|
func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContract(ctx context.Context, cert *entities.Certification) error {
|
||||||
// 创建钱包
|
// 创建钱包
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ type AdminTransitionCertificationStatusCommand struct {
|
|||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SelectSignPlatformCommand 用户选择签署平台
|
||||||
|
type SelectSignPlatformCommand struct {
|
||||||
|
UserID string `json:"-"`
|
||||||
|
SignPlatform string `json:"sign_platform" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
// SubmitEnterpriseInfoCommand 提交企业信息命令
|
// SubmitEnterpriseInfoCommand 提交企业信息命令
|
||||||
type SubmitEnterpriseInfoCommand struct {
|
type SubmitEnterpriseInfoCommand struct {
|
||||||
UserID string `json:"-" comment:"用户唯一标识,从JWT token获取,不在JSON中暴露"`
|
UserID string `json:"-" comment:"用户唯一标识,从JWT token获取,不在JSON中暴露"`
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import (
|
|||||||
|
|
||||||
// CertificationResponse 认证响应
|
// CertificationResponse 认证响应
|
||||||
type CertificationResponse struct {
|
type CertificationResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Status enums.CertificationStatus `json:"status"`
|
Status enums.CertificationStatus `json:"status"`
|
||||||
StatusName string `json:"status_name"`
|
StatusName string `json:"status_name"`
|
||||||
Progress int `json:"progress"`
|
SignPlatform string `json:"sign_platform,omitempty"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
|
||||||
// 企业信息
|
// 企业信息
|
||||||
EnterpriseInfo *value_objects.EnterpriseInfo `json:"enterprise_info,omitempty"`
|
EnterpriseInfo *value_objects.EnterpriseInfo `json:"enterprise_info,omitempty"`
|
||||||
@@ -73,12 +74,26 @@ type CertificationListResponse struct {
|
|||||||
|
|
||||||
// ContractSignUrlResponse 合同签署URL响应
|
// ContractSignUrlResponse 合同签署URL响应
|
||||||
type ContractSignUrlResponse struct {
|
type ContractSignUrlResponse struct {
|
||||||
CertificationID string `json:"certification_id"`
|
CertificationID string `json:"certification_id"`
|
||||||
ContractSignURL string `json:"contract_sign_url"`
|
// ContractSignURL iframe 可用的签署长链(法大大 EmbedURL,约 10 分钟/单次)
|
||||||
ContractURL string `json:"contract_url,omitempty"`
|
ContractSignURL string `json:"contract_sign_url"`
|
||||||
ExpireAt *time.Time `json:"expire_at,omitempty"`
|
// ContractSignShortURL 短链(不可 iframe,一年有效)
|
||||||
NextAction string `json:"next_action"`
|
ContractSignShortURL string `json:"contract_sign_short_url,omitempty"`
|
||||||
Message string `json:"message"`
|
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 系统监控响应
|
// SystemMonitoringResponse 系统监控响应
|
||||||
@@ -119,7 +134,9 @@ type AdminSubmitRecordItem struct {
|
|||||||
LegalPersonName string `json:"legal_person_name"`
|
LegalPersonName string `json:"legal_person_name"`
|
||||||
SubmitAt time.Time `json:"submit_at"`
|
SubmitAt time.Time `json:"submit_at"`
|
||||||
Status string `json:"status"`
|
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)
|
// AdminSubmitRecordDetail 管理端提交记录详情(含完整信息与图片 URL)
|
||||||
@@ -145,6 +162,9 @@ type AdminSubmitRecordDetail struct {
|
|||||||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||||
FailedAt *time.Time `json:"failed_at,omitempty"`
|
FailedAt *time.Time `json:"failed_at,omitempty"`
|
||||||
FailureReason string `json:"failure_reason,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"` // 以状态机为准
|
CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -178,17 +198,19 @@ func NewCertificationListResponse(items []*CertificationResponse, total int64, p
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewContractSignUrlResponse 创建合同签署URL响应
|
// 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{
|
response := &ContractSignUrlResponse{
|
||||||
CertificationID: certificationID,
|
CertificationID: certificationID,
|
||||||
ContractSignURL: signURL,
|
ContractSignURL: signURL,
|
||||||
ContractURL: contractURL,
|
ContractSignShortURL: shortURL,
|
||||||
NextAction: nextAction,
|
ContractURL: contractURL,
|
||||||
Message: message,
|
NextAction: nextAction,
|
||||||
|
Message: message,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置过期时间(默认24小时)
|
// 法大大嵌入链接约 10 分钟;统一按 10 分钟提示前端刷新
|
||||||
expireAt := time.Now().Add(24 * time.Hour)
|
expireAt := time.Now().Add(10 * time.Minute)
|
||||||
response.ExpireAt = &expireAt
|
response.ExpireAt = &expireAt
|
||||||
|
|
||||||
return response
|
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 检查系统是否健康
|
// IsHealthy 检查系统是否健康
|
||||||
func (r *SystemMonitoringResponse) IsHealthy() bool {
|
func (r *SystemMonitoringResponse) IsHealthy() bool {
|
||||||
return r.SystemHealth.Overall == "healthy"
|
return r.SystemHealth.Overall == "healthy"
|
||||||
|
|||||||
273
internal/application/certification/platform_flow.go
Normal file
273
internal/application/certification/platform_flow.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type Config struct {
|
|||||||
App AppConfig `mapstructure:"app"`
|
App AppConfig `mapstructure:"app"`
|
||||||
WechatWork WechatWorkConfig `mapstructure:"wechat_work"`
|
WechatWork WechatWorkConfig `mapstructure:"wechat_work"`
|
||||||
Esign EsignConfig `mapstructure:"esign"`
|
Esign EsignConfig `mapstructure:"esign"`
|
||||||
|
Fadada FadadaConfig `mapstructure:"fadada"`
|
||||||
Wallet WalletConfig `mapstructure:"wallet"`
|
Wallet WalletConfig `mapstructure:"wallet"`
|
||||||
WestDex WestDexConfig `mapstructure:"westdex"`
|
WestDex WestDexConfig `mapstructure:"westdex"`
|
||||||
Zhicha ZhichaConfig `mapstructure:"zhicha"`
|
Zhicha ZhichaConfig `mapstructure:"zhicha"`
|
||||||
@@ -45,6 +46,7 @@ type Config struct {
|
|||||||
Nuoer NuoerConfig `mapstructure:"nuoer"`
|
Nuoer NuoerConfig `mapstructure:"nuoer"`
|
||||||
Jiyi JiyiConfig `mapstructure:"jiyi"`
|
Jiyi JiyiConfig `mapstructure:"jiyi"`
|
||||||
Huibo HuiboConfig `mapstructure:"huibo"`
|
Huibo HuiboConfig `mapstructure:"huibo"`
|
||||||
|
Rongxing RongxingConfig `mapstructure:"rongxing"`
|
||||||
PDFGen PDFGenConfig `mapstructure:"pdfgen"`
|
PDFGen PDFGenConfig `mapstructure:"pdfgen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,6 +391,41 @@ type SignConfig struct {
|
|||||||
RedirectURL string `mapstructure:"redirect_url"` // 重定向URL
|
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 钱包配置
|
// WalletConfig 钱包配置
|
||||||
type WalletConfig struct {
|
type WalletConfig struct {
|
||||||
DefaultCreditLimit float64 `mapstructure:"default_credit_limit"`
|
DefaultCreditLimit float64 `mapstructure:"default_credit_limit"`
|
||||||
@@ -670,10 +707,10 @@ type TianyuanapiConfig struct {
|
|||||||
|
|
||||||
// TianyuanapiLoggingConfig 天远 API 日志配置
|
// TianyuanapiLoggingConfig 天远 API 日志配置
|
||||||
type TianyuanapiLoggingConfig struct {
|
type TianyuanapiLoggingConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
LogDir string `mapstructure:"log_dir"`
|
LogDir string `mapstructure:"log_dir"`
|
||||||
UseDaily bool `mapstructure:"use_daily"`
|
UseDaily bool `mapstructure:"use_daily"`
|
||||||
EnableLevelSeparation bool `mapstructure:"enable_level_separation"`
|
EnableLevelSeparation bool `mapstructure:"enable_level_separation"`
|
||||||
LevelConfigs map[string]TianyuanapiLevelFileConfig `mapstructure:"level_configs"`
|
LevelConfigs map[string]TianyuanapiLevelFileConfig `mapstructure:"level_configs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,28 +805,28 @@ type JiyiLevelFileConfig struct {
|
|||||||
|
|
||||||
// HuiboConfig 汇博配置
|
// HuiboConfig 汇博配置
|
||||||
type HuiboConfig struct {
|
type HuiboConfig struct {
|
||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url"`
|
||||||
AppID string `mapstructure:"app_id"`
|
AppID string `mapstructure:"app_id"`
|
||||||
AppKey string `mapstructure:"app_key"`
|
AppKey string `mapstructure:"app_key"`
|
||||||
XOrderCode string `mapstructure:"x_order_code"`
|
XOrderCode string `mapstructure:"x_order_code"`
|
||||||
SecretID string `mapstructure:"secret_id"`
|
SecretID string `mapstructure:"secret_id"`
|
||||||
AESKey string `mapstructure:"aes_key"`
|
AESKey string `mapstructure:"aes_key"`
|
||||||
WorkOrderCode string `mapstructure:"work_order_code"`
|
WorkOrderCode string `mapstructure:"work_order_code"`
|
||||||
ProductCode string `mapstructure:"product_code"`
|
ProductCode string `mapstructure:"product_code"`
|
||||||
BaseURL2 string `mapstructure:"baseUrl2"`
|
BaseURL2 string `mapstructure:"baseUrl2"`
|
||||||
AppCode2 string `mapstructure:"app_code2"`
|
AppCode2 string `mapstructure:"app_code2"`
|
||||||
AuthPDFStorageDir string `mapstructure:"auth_pdf_storage_dir"`
|
AuthPDFStorageDir string `mapstructure:"auth_pdf_storage_dir"`
|
||||||
|
|
||||||
Logging HuiboLoggingConfig `mapstructure:"logging"`
|
Logging HuiboLoggingConfig `mapstructure:"logging"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HuiboLoggingConfig 汇博日志配置
|
// HuiboLoggingConfig 汇博日志配置
|
||||||
type HuiboLoggingConfig struct {
|
type HuiboLoggingConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
LogDir string `mapstructure:"log_dir"`
|
LogDir string `mapstructure:"log_dir"`
|
||||||
ServiceName string `mapstructure:"service_name"`
|
ServiceName string `mapstructure:"service_name"`
|
||||||
UseDaily bool `mapstructure:"use_daily"`
|
UseDaily bool `mapstructure:"use_daily"`
|
||||||
EnableLevelSeparation bool `mapstructure:"enable_level_separation"`
|
EnableLevelSeparation bool `mapstructure:"enable_level_separation"`
|
||||||
LevelConfigs map[string]HuiboLevelFileConfig `mapstructure:"level_configs"`
|
LevelConfigs map[string]HuiboLevelFileConfig `mapstructure:"level_configs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +838,36 @@ type HuiboLevelFileConfig struct {
|
|||||||
Compress bool `mapstructure:"compress"`
|
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生成服务配置
|
// PDFGenConfig PDF生成服务配置
|
||||||
type PDFGenConfig struct {
|
type PDFGenConfig struct {
|
||||||
DevelopmentURL string `mapstructure:"development_url"` // 开发环境服务地址
|
DevelopmentURL string `mapstructure:"development_url"` // 开发环境服务地址
|
||||||
|
|||||||
@@ -40,12 +40,16 @@ import (
|
|||||||
"hyapi-server/internal/infrastructure/external/alicloud"
|
"hyapi-server/internal/infrastructure/external/alicloud"
|
||||||
"hyapi-server/internal/infrastructure/external/captcha"
|
"hyapi-server/internal/infrastructure/external/captcha"
|
||||||
"hyapi-server/internal/infrastructure/external/email"
|
"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/huibo"
|
||||||
|
"hyapi-server/internal/infrastructure/external/rongxing"
|
||||||
"hyapi-server/internal/infrastructure/external/jiguang"
|
"hyapi-server/internal/infrastructure/external/jiguang"
|
||||||
"hyapi-server/internal/infrastructure/external/jiyi"
|
"hyapi-server/internal/infrastructure/external/jiyi"
|
||||||
"hyapi-server/internal/infrastructure/external/muzi"
|
"hyapi-server/internal/infrastructure/external/muzi"
|
||||||
"hyapi-server/internal/infrastructure/external/nuoer"
|
"hyapi-server/internal/infrastructure/external/nuoer"
|
||||||
"hyapi-server/internal/infrastructure/external/ocr"
|
"hyapi-server/internal/infrastructure/external/ocr"
|
||||||
|
"hyapi-server/internal/infrastructure/external/signplatform"
|
||||||
"hyapi-server/internal/infrastructure/external/shujubao"
|
"hyapi-server/internal/infrastructure/external/shujubao"
|
||||||
"hyapi-server/internal/infrastructure/external/shumai"
|
"hyapi-server/internal/infrastructure/external/shumai"
|
||||||
"hyapi-server/internal/infrastructure/external/sms"
|
"hyapi-server/internal/infrastructure/external/sms"
|
||||||
@@ -66,6 +70,7 @@ import (
|
|||||||
component_report "hyapi-server/internal/shared/component_report"
|
component_report "hyapi-server/internal/shared/component_report"
|
||||||
shared_database "hyapi-server/internal/shared/database"
|
shared_database "hyapi-server/internal/shared/database"
|
||||||
"hyapi-server/internal/shared/esign"
|
"hyapi-server/internal/shared/esign"
|
||||||
|
"hyapi-server/internal/shared/fadada"
|
||||||
shared_events "hyapi-server/internal/shared/events"
|
shared_events "hyapi-server/internal/shared/events"
|
||||||
"hyapi-server/internal/shared/export"
|
"hyapi-server/internal/shared/export"
|
||||||
"hyapi-server/internal/shared/health"
|
"hyapi-server/internal/shared/health"
|
||||||
@@ -321,6 +326,55 @@ func NewContainer() *Container {
|
|||||||
func(esignConfig *esign.Config) *esign.Client {
|
func(esignConfig *esign.Config) *esign.Client {
|
||||||
return esign.NewClient(esignConfig)
|
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 {
|
func(cfg *config.Config) *payment.AliPayService {
|
||||||
config := payment.AlipayConfig{
|
config := payment.AlipayConfig{
|
||||||
@@ -424,6 +478,10 @@ func NewContainer() *Container {
|
|||||||
func(cfg *config.Config) (*huibo.HuiboService, error) {
|
func(cfg *config.Config) (*huibo.HuiboService, error) {
|
||||||
return huibo.NewHuiboServiceWithConfig(cfg)
|
return huibo.NewHuiboServiceWithConfig(cfg)
|
||||||
},
|
},
|
||||||
|
// RongxingService - 戎行 Info360 服务
|
||||||
|
func(cfg *config.Config) (*rongxing.RongxingService, error) {
|
||||||
|
return rongxing.NewRongxingServiceWithConfig(cfg)
|
||||||
|
},
|
||||||
func(cfg *config.Config) *yushan.YushanService {
|
func(cfg *config.Config) *yushan.YushanService {
|
||||||
return yushan.NewYushanService(
|
return yushan.NewYushanService(
|
||||||
cfg.Yushan.URL,
|
cfg.Yushan.URL,
|
||||||
@@ -931,6 +989,7 @@ func NewContainer() *Container {
|
|||||||
smsCodeService *user_service.SMSCodeService,
|
smsCodeService *user_service.SMSCodeService,
|
||||||
esignClient *esign.Client,
|
esignClient *esign.Client,
|
||||||
esignConfig *esign.Config,
|
esignConfig *esign.Config,
|
||||||
|
platformRegistry *signplatform.Registry,
|
||||||
qiniuStorageService *storage.QiNiuStorageService,
|
qiniuStorageService *storage.QiNiuStorageService,
|
||||||
contractAggregateService user_service.ContractAggregateService,
|
contractAggregateService user_service.ContractAggregateService,
|
||||||
walletAggregateService finance_services.WalletAggregateService,
|
walletAggregateService finance_services.WalletAggregateService,
|
||||||
@@ -949,6 +1008,7 @@ func NewContainer() *Container {
|
|||||||
smsCodeService,
|
smsCodeService,
|
||||||
esignClient,
|
esignClient,
|
||||||
esignConfig,
|
esignConfig,
|
||||||
|
platformRegistry,
|
||||||
qiniuStorageService,
|
qiniuStorageService,
|
||||||
contractAggregateService,
|
contractAggregateService,
|
||||||
walletAggregateService,
|
walletAggregateService,
|
||||||
|
|||||||
@@ -1347,3 +1347,9 @@ type QYGLLUCMReq struct {
|
|||||||
EntName string `json:"ent_name" validate:"required,min=1,validEnterpriseName"`
|
EntName string `json:"ent_name" validate:"required,min=1,validEnterpriseName"`
|
||||||
AuthPDFBase64 string `json:"auth_pdf_base64" validate:"required,validAuthPDFBase64"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
jiyiext "hyapi-server/internal/infrastructure/external/jiyi"
|
jiyiext "hyapi-server/internal/infrastructure/external/jiyi"
|
||||||
"hyapi-server/internal/infrastructure/external/muzi"
|
"hyapi-server/internal/infrastructure/external/muzi"
|
||||||
nuoerext "hyapi-server/internal/infrastructure/external/nuoer"
|
nuoerext "hyapi-server/internal/infrastructure/external/nuoer"
|
||||||
|
"hyapi-server/internal/infrastructure/external/rongxing"
|
||||||
"hyapi-server/internal/infrastructure/external/shujubao"
|
"hyapi-server/internal/infrastructure/external/shujubao"
|
||||||
"hyapi-server/internal/infrastructure/external/tianyancha"
|
"hyapi-server/internal/infrastructure/external/tianyancha"
|
||||||
"hyapi-server/internal/infrastructure/external/tianyuanapi"
|
"hyapi-server/internal/infrastructure/external/tianyuanapi"
|
||||||
@@ -69,6 +70,7 @@ func NewApiRequestService(
|
|||||||
nuoerService *nuoerext.NuoerService,
|
nuoerService *nuoerext.NuoerService,
|
||||||
jiyiService *jiyiext.JiyiService,
|
jiyiService *jiyiext.JiyiService,
|
||||||
huiboService *huibo.HuiboService,
|
huiboService *huibo.HuiboService,
|
||||||
|
rongxingService *rongxing.RongxingService,
|
||||||
validator interfaces.RequestValidator,
|
validator interfaces.RequestValidator,
|
||||||
productManagementService *services.ProductManagementService,
|
productManagementService *services.ProductManagementService,
|
||||||
cfg *appconfig.Config,
|
cfg *appconfig.Config,
|
||||||
@@ -87,6 +89,7 @@ func NewApiRequestService(
|
|||||||
nuoerService,
|
nuoerService,
|
||||||
jiyiService,
|
jiyiService,
|
||||||
huiboService,
|
huiboService,
|
||||||
|
rongxingService,
|
||||||
validator,
|
validator,
|
||||||
productManagementService,
|
productManagementService,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -110,6 +113,7 @@ func NewApiRequestServiceWithRepos(
|
|||||||
nuoerService *nuoerext.NuoerService,
|
nuoerService *nuoerext.NuoerService,
|
||||||
jiyiService *jiyiext.JiyiService,
|
jiyiService *jiyiext.JiyiService,
|
||||||
huiboService *huibo.HuiboService,
|
huiboService *huibo.HuiboService,
|
||||||
|
rongxingService *rongxing.RongxingService,
|
||||||
validator interfaces.RequestValidator,
|
validator interfaces.RequestValidator,
|
||||||
productManagementService *services.ProductManagementService,
|
productManagementService *services.ProductManagementService,
|
||||||
cfg *appconfig.Config,
|
cfg *appconfig.Config,
|
||||||
@@ -139,6 +143,7 @@ func NewApiRequestServiceWithRepos(
|
|||||||
nuoerService,
|
nuoerService,
|
||||||
jiyiService,
|
jiyiService,
|
||||||
huiboService,
|
huiboService,
|
||||||
|
rongxingService,
|
||||||
validator,
|
validator,
|
||||||
combService,
|
combService,
|
||||||
reportRepo,
|
reportRepo,
|
||||||
@@ -202,6 +207,7 @@ func registerAllProcessors(combService *comb.CombService) {
|
|||||||
"JRZQR4N7": jrzq.ProcessJRZQR4N7Request, //借贷意向验证3.0
|
"JRZQR4N7": jrzq.ProcessJRZQR4N7Request, //借贷意向验证3.0
|
||||||
"JRZQH6M3": jrzq.ProcessJRZQH6M3Request, //无间司南-纯黑A版
|
"JRZQH6M3": jrzq.ProcessJRZQH6M3Request, //无间司南-纯黑A版
|
||||||
"JRZQW3L8": jrzq.ProcessJRZQW3L8Request, //信用司南
|
"JRZQW3L8": jrzq.ProcessJRZQW3L8Request, //信用司南
|
||||||
|
"JRZQ0OO1": jrzq.ProcessJRZQ0OO1Request, //戎行贷后信息
|
||||||
|
|
||||||
// QYGL系列处理器
|
// QYGL系列处理器
|
||||||
"QYGL7HBN": qygl.ProcessQYGL7HBNRequest, //企业全景报告(聚合 QYGLUY3S/QYGLJ0Q1/QYGL5S1I)
|
"QYGL7HBN": qygl.ProcessQYGL7HBNRequest, //企业全景报告(聚合 QYGLUY3S/QYGLJ0Q1/QYGL5S1I)
|
||||||
|
|||||||
@@ -318,7 +318,8 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string
|
|||||||
"JRZQW3L8": &dto.JRZQW3L8Req{}, //信用司南
|
"JRZQW3L8": &dto.JRZQW3L8Req{}, //信用司南
|
||||||
"IVYZX7J9": &dto.IVYZX7J9Req{}, //学籍核验
|
"IVYZX7J9": &dto.IVYZX7J9Req{}, //学籍核验
|
||||||
"QCXG6U5G": &dto.QCXG6U5GReq{}, //车辆核验
|
"QCXG6U5G": &dto.QCXG6U5GReq{}, //车辆核验
|
||||||
"QCXG7K2N": &dto.QCXG7K2NReq{}, //人车核验加强版
|
"JRZQ0OO1": &dto.JRZQ0OO1Req{}, //戎行贷后信息 Info360
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先返回已配置的DTO
|
// 优先返回已配置的DTO
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import (
|
|||||||
"hyapi-server/internal/infrastructure/external/jiyi"
|
"hyapi-server/internal/infrastructure/external/jiyi"
|
||||||
"hyapi-server/internal/infrastructure/external/muzi"
|
"hyapi-server/internal/infrastructure/external/muzi"
|
||||||
"hyapi-server/internal/infrastructure/external/nuoer"
|
"hyapi-server/internal/infrastructure/external/nuoer"
|
||||||
|
"hyapi-server/internal/infrastructure/external/rongxing"
|
||||||
"hyapi-server/internal/infrastructure/external/shujubao"
|
"hyapi-server/internal/infrastructure/external/shujubao"
|
||||||
"hyapi-server/internal/infrastructure/external/tianyuanapi"
|
|
||||||
"hyapi-server/internal/infrastructure/external/tianyancha"
|
"hyapi-server/internal/infrastructure/external/tianyancha"
|
||||||
|
"hyapi-server/internal/infrastructure/external/tianyuanapi"
|
||||||
"hyapi-server/internal/infrastructure/external/westdex"
|
"hyapi-server/internal/infrastructure/external/westdex"
|
||||||
"hyapi-server/internal/infrastructure/external/xingwei"
|
"hyapi-server/internal/infrastructure/external/xingwei"
|
||||||
"hyapi-server/internal/infrastructure/external/yushan"
|
"hyapi-server/internal/infrastructure/external/yushan"
|
||||||
@@ -33,23 +34,24 @@ type CallContext struct {
|
|||||||
|
|
||||||
// ProcessorDependencies 处理器依赖容器
|
// ProcessorDependencies 处理器依赖容器
|
||||||
type ProcessorDependencies struct {
|
type ProcessorDependencies struct {
|
||||||
WestDexService *westdex.WestDexService
|
WestDexService *westdex.WestDexService
|
||||||
ShujubaoService *shujubao.ShujubaoService
|
ShujubaoService *shujubao.ShujubaoService
|
||||||
MuziService *muzi.MuziService
|
MuziService *muzi.MuziService
|
||||||
YushanService *yushan.YushanService
|
YushanService *yushan.YushanService
|
||||||
TianYanChaService *tianyancha.TianYanChaService
|
TianYanChaService *tianyancha.TianYanChaService
|
||||||
AlicloudService *alicloud.AlicloudService
|
AlicloudService *alicloud.AlicloudService
|
||||||
ZhichaService *zhicha.ZhichaService
|
ZhichaService *zhicha.ZhichaService
|
||||||
XingweiService *xingwei.XingweiService
|
XingweiService *xingwei.XingweiService
|
||||||
JiguangService *jiguang.JiguangService
|
JiguangService *jiguang.JiguangService
|
||||||
TianyuanapiService *tianyuanapi.TianyuanapiService
|
TianyuanapiService *tianyuanapi.TianyuanapiService
|
||||||
NuoerService *nuoer.NuoerService
|
NuoerService *nuoer.NuoerService
|
||||||
JiyiService *jiyi.JiyiService
|
JiyiService *jiyi.JiyiService
|
||||||
HuiboService *huibo.HuiboService
|
HuiboService *huibo.HuiboService
|
||||||
Validator interfaces.RequestValidator
|
RongxingService *rongxing.RongxingService
|
||||||
CombService CombServiceInterface // Changed to interface to break import cycle
|
Validator interfaces.RequestValidator
|
||||||
Options *commands.ApiCallOptions // 添加Options支持
|
CombService CombServiceInterface // Changed to interface to break import cycle
|
||||||
CallContext *CallContext // 添加CallApi调用上下文
|
Options *commands.ApiCallOptions // 添加Options支持
|
||||||
|
CallContext *CallContext // 添加CallApi调用上下文
|
||||||
|
|
||||||
// 企业报告记录仓储,用于持久化 QYGLJ1U9 生成的企业报告
|
// 企业报告记录仓储,用于持久化 QYGLJ1U9 生成的企业报告
|
||||||
ReportRepo repositories.ReportRepository
|
ReportRepo repositories.ReportRepository
|
||||||
@@ -76,6 +78,7 @@ func NewProcessorDependencies(
|
|||||||
nuoerService *nuoer.NuoerService,
|
nuoerService *nuoer.NuoerService,
|
||||||
jiyiService *jiyi.JiyiService,
|
jiyiService *jiyi.JiyiService,
|
||||||
huiboService *huibo.HuiboService,
|
huiboService *huibo.HuiboService,
|
||||||
|
rongxingService *rongxing.RongxingService,
|
||||||
validator interfaces.RequestValidator,
|
validator interfaces.RequestValidator,
|
||||||
combService CombServiceInterface, // Changed to interface
|
combService CombServiceInterface, // Changed to interface
|
||||||
reportRepo repositories.ReportRepository,
|
reportRepo repositories.ReportRepository,
|
||||||
@@ -96,6 +99,7 @@ func NewProcessorDependencies(
|
|||||||
NuoerService: nuoerService,
|
NuoerService: nuoerService,
|
||||||
JiyiService: jiyiService,
|
JiyiService: jiyiService,
|
||||||
HuiboService: huiboService,
|
HuiboService: huiboService,
|
||||||
|
RongxingService: rongxingService,
|
||||||
Validator: validator,
|
Validator: validator,
|
||||||
CombService: combService,
|
CombService: combService,
|
||||||
Options: nil, // 初始化为nil,在调用时设置
|
Options: nil, // 初始化为nil,在调用时设置
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package entities
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapi-server/internal/domains/certification/entities/value_objects"
|
"hyapi-server/internal/domains/certification/entities/value_objects"
|
||||||
@@ -28,14 +29,17 @@ type Certification struct {
|
|||||||
CompletedAt *time.Time `json:"completed_at,omitempty" comment:"认证完成时间"`
|
CompletedAt *time.Time `json:"completed_at,omitempty" comment:"认证完成时间"`
|
||||||
ContractFileCreatedAt *time.Time `json:"contract_file_created_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"`
|
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:"合同/协议编号"`
|
ContractCode string `gorm:"type:varchar(100)" json:"contract_code,omitempty" comment:"合同/协议编号"`
|
||||||
ContractFileID string `gorm:"type:varchar(500)" json:"contract_file_id,omitempty" comment:"合同文件ID"`
|
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"`
|
EsignFlowID string `gorm:"type:varchar(500)" json:"esign_flow_id,omitempty" comment:"签署流程ID(兼容字段名)"`
|
||||||
ContractURL string `gorm:"type:varchar(500)" json:"contract_url,omitempty" comment:"合同文件访问链接"`
|
ContractURL string `gorm:"type:text" json:"contract_url,omitempty" comment:"合同文件访问链接(法大大下载链可能很长)"`
|
||||||
ContractSignURL string `gorm:"type:varchar(500)" json:"contract_sign_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:"失败原因"`
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 完成企业认证
|
// 完成企业认证(幂等:已是 enterprise_verified 则跳过状态流转,便于中途失败后重试)
|
||||||
func (c *Certification) CompleteEnterpriseVerification() error {
|
func (c *Certification) CompleteEnterpriseVerification() error {
|
||||||
|
if c.Status == enums.StatusEnterpriseVerified {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if c.Status != enums.StatusInfoSubmitted {
|
if c.Status != enums.StatusInfoSubmitted {
|
||||||
return fmt.Errorf("当前状态 %s 不允许完成企业认证", enums.GetStatusName(c.Status))
|
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 {
|
if err := c.TransitionTo(enums.StatusContractApplied, enums.ActorTypeUser, c.UserID, "用户申请合同签署"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.EsignFlowID = EsignFlowID
|
if EsignFlowID != "" {
|
||||||
c.ContractSignURL = ContractSignURL
|
c.EsignFlowID = EsignFlowID
|
||||||
|
}
|
||||||
|
if ContractSignURL != "" {
|
||||||
|
c.ContractSignURL = ContractSignURL
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
c.ContractFileCreatedAt = &now
|
c.ContractFileCreatedAt = &now
|
||||||
// 添加业务事件
|
// 添加业务事件
|
||||||
@@ -324,6 +335,18 @@ func (c *Certification) ApplyContract(EsignFlowID string, ContractSignURL string
|
|||||||
return nil
|
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 生成合同文件
|
// AddContractFileID 生成合同文件
|
||||||
func (c *Certification) AddContractFileID(contractFileID string, contractURL string) error {
|
func (c *Certification) AddContractFileID(contractFileID string, contractURL string) error {
|
||||||
c.ContractFileID = contractFileID
|
c.ContractFileID = contractFileID
|
||||||
@@ -500,13 +523,58 @@ func (c *Certification) CompleteCertification() error {
|
|||||||
return nil
|
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 根据当前状态获取对应的数据
|
// GetDataByStatus 根据当前状态获取对应的数据
|
||||||
func (c *Certification) GetDataByStatus() map[string]interface{} {
|
func (c *Certification) GetDataByStatus() map[string]interface{} {
|
||||||
data := 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 {
|
switch c.Status {
|
||||||
case enums.StatusInfoPendingReview:
|
case enums.StatusInfoPendingReview:
|
||||||
// 待审核,无额外数据
|
// 待审核/待选平台,额外元数据由应用层补充
|
||||||
case enums.StatusInfoSubmitted:
|
case enums.StatusInfoSubmitted:
|
||||||
data["auth_url"] = c.AuthURL
|
data["auth_url"] = c.AuthURL
|
||||||
case enums.StatusInfoRejected:
|
case enums.StatusInfoRejected:
|
||||||
@@ -514,11 +582,24 @@ func (c *Certification) GetDataByStatus() map[string]interface{} {
|
|||||||
data["failure_message"] = c.FailureMessage
|
data["failure_message"] = c.FailureMessage
|
||||||
case enums.StatusEnterpriseVerified:
|
case enums.StatusEnterpriseVerified:
|
||||||
data["ContractURL"] = c.ContractURL
|
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:
|
case enums.StatusContractApplied:
|
||||||
data["contract_sign_url"] = c.ContractSignURL
|
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.StatusContractSigned:
|
||||||
case enums.StatusCompleted:
|
case enums.StatusCompleted:
|
||||||
data["completed_at"] = c.CompletedAt
|
data["completed_at"] = c.CompletedAt
|
||||||
|
if c.ContractURL != "" {
|
||||||
|
data["contract_url"] = c.ContractURL
|
||||||
|
}
|
||||||
case enums.StatusContractRejected:
|
case enums.StatusContractRejected:
|
||||||
data["failure_reason"] = c.FailureReason
|
data["failure_reason"] = c.FailureReason
|
||||||
data["failure_message"] = c.FailureMessage
|
data["failure_message"] = c.FailureMessage
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ type ContractInfo struct {
|
|||||||
ContractFileID string `json:"contract_file_id"` // 合同文件ID
|
ContractFileID string `json:"contract_file_id"` // 合同文件ID
|
||||||
EsignFlowID string `json:"esign_flow_id"` // e签宝签署流程ID
|
EsignFlowID string `json:"esign_flow_id"` // e签宝签署流程ID
|
||||||
ContractURL string `json:"contract_url"` // 合同文件访问链接
|
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"` // 合同标题
|
ContractTitle string `json:"contract_title"` // 合同标题
|
||||||
|
|||||||
36
internal/domains/certification/enums/sign_platform.go
Normal file
36
internal/domains/certification/enums/sign_platform.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
152
internal/domains/certification/ports/sign_platform_provider.go
Normal file
152
internal/domains/certification/ports/sign_platform_provider.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
204
internal/infrastructure/external/esign/esign_provider.go
vendored
Normal file
204
internal/infrastructure/external/esign/esign_provider.go
vendored
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package esign
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapi-server/internal/domains/certification/enums"
|
||||||
|
"hyapi-server/internal/domains/certification/ports"
|
||||||
|
sharedesign "hyapi-server/internal/shared/esign"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
client *sharedesign.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProvider(client *sharedesign.Client) *Provider {
|
||||||
|
return &Provider{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Platform() enums.SignPlatform {
|
||||||
|
return enums.SignPlatformEsign
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GenerateEnterpriseAuth(ctx context.Context, req *ports.EnterpriseAuthRequest) (*ports.AuthLinkResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
res, err := p.client.GenerateEnterpriseAuth(&sharedesign.EnterpriseAuthRequest{
|
||||||
|
CompanyName: req.CompanyName,
|
||||||
|
UnifiedSocialCode: req.UnifiedSocialCode,
|
||||||
|
LegalPersonName: req.LegalPersonName,
|
||||||
|
LegalPersonID: req.LegalPersonID,
|
||||||
|
TransactorName: req.TransactorName,
|
||||||
|
TransactorMobile: req.TransactorMobile,
|
||||||
|
TransactorID: req.TransactorID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ports.AuthLinkResult{
|
||||||
|
AuthFlowID: res.AuthFlowID,
|
||||||
|
AuthURL: res.AuthURL,
|
||||||
|
AuthShortURL: firstNonEmpty(res.AuthShortURL, res.AuthURL),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) QueryOrgVerified(ctx context.Context, req *ports.OrgIdentityQuery) (bool, error) {
|
||||||
|
_ = ctx
|
||||||
|
identity, err := p.client.QueryOrgIdentityInfo(&sharedesign.QueryOrgIdentityRequest{
|
||||||
|
OrgName: req.OrgName,
|
||||||
|
OrgIDCardNum: req.OrgIdentNo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return identity != nil && identity.Data.RealnameStatus == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GenerateContractFile(ctx context.Context, req *ports.ContractGenerateRequest) (*ports.ContractFileResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
components := map[string]string{
|
||||||
|
"jfqym": req.CompanyName,
|
||||||
|
"jfqym2": req.CompanyName,
|
||||||
|
"jfsqdb": req.AuthorizedRepName,
|
||||||
|
"jftyshxydm": req.UnifiedSocialCode,
|
||||||
|
"jflxdz": req.EnterpriseAddress,
|
||||||
|
"xybh": req.AgreementNo,
|
||||||
|
"qsrq1": req.SignDate,
|
||||||
|
"qsrq3": req.SignDate,
|
||||||
|
"qsrq2": req.SignDate,
|
||||||
|
}
|
||||||
|
res, err := p.client.FillTemplate(components)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ports.ContractFileResult{
|
||||||
|
FileID: res.FileID,
|
||||||
|
FileDownloadURL: res.FileDownloadUrl,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) CreateSignFlow(ctx context.Context, req *ports.SignFlowCreateRequest) (*ports.SignFlowResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
flowID, err := p.client.CreateSignFlow(&sharedesign.CreateSignFlowRequest{
|
||||||
|
FileID: req.FileID,
|
||||||
|
SignerAccount: req.PartyAUSCC,
|
||||||
|
SignerName: req.PartyAName,
|
||||||
|
TransactorPhone: req.TransactorMobile,
|
||||||
|
TransactorName: req.TransactorName,
|
||||||
|
TransactorIDCardNum: req.TransactorID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.SkipActorURL {
|
||||||
|
return &ports.SignFlowResult{SignFlowID: flowID}, nil
|
||||||
|
}
|
||||||
|
longURL, shortURL, err := p.client.GetSignURL(flowID, req.TransactorMobile, req.PartyAName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ports.SignFlowResult{
|
||||||
|
SignFlowID: flowID,
|
||||||
|
SignURL: firstNonEmpty(longURL, shortURL),
|
||||||
|
ShortURL: shortURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GetActorSignURL(ctx context.Context, req *ports.GetActorSignURLRequest) (*ports.ActorSignURLResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
if req == nil || strings.TrimSpace(req.SignFlowID) == "" {
|
||||||
|
return nil, fmt.Errorf("signFlowId 不能为空")
|
||||||
|
}
|
||||||
|
longURL, shortURL, err := p.client.GetSignURL(req.SignFlowID, req.TransactorMobile, req.PartyAName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ports.ActorSignURLResult{
|
||||||
|
SignFlowID: req.SignFlowID,
|
||||||
|
EmbedURL: firstNonEmpty(longURL, shortURL),
|
||||||
|
ShortURL: shortURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GetSignTaskPreviewURL(ctx context.Context, signFlowID string) (*ports.SignTaskPreviewURLResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
// e签宝无独立预览接口:用已签/合同文件下载链作为预览源(前端用 PDF 打开)
|
||||||
|
files, err := p.DownloadSignedFiles(ctx, signFlowID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(files) == 0 || strings.TrimSpace(files[0].DownloadURL) == "" {
|
||||||
|
return nil, fmt.Errorf("未获取到可预览的合同文件")
|
||||||
|
}
|
||||||
|
return &ports.SignTaskPreviewURLResult{
|
||||||
|
SignFlowID: signFlowID,
|
||||||
|
PreviewURL: files[0].DownloadURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) QuerySignStatus(ctx context.Context, flowID string) (*ports.SignStatusResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
detail, err := p.client.QuerySignFlowDetail(flowID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status := detail.Data.SignFlowStatus
|
||||||
|
return &ports.SignStatusResult{
|
||||||
|
SignFlowID: flowID,
|
||||||
|
Status: fmt.Sprintf("%d", status),
|
||||||
|
Completed: status == 2,
|
||||||
|
Rejected: status == 7,
|
||||||
|
Expired: status == 5,
|
||||||
|
Message: detail.Data.SignFlowDescription,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) DownloadSignedFiles(ctx context.Context, flowID string) ([]*ports.SignedFile, error) {
|
||||||
|
_ = ctx
|
||||||
|
res, err := p.client.DownloadSignedFile(flowID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
files := make([]*ports.SignedFile, 0, len(res.Data.Files))
|
||||||
|
for _, f := range res.Data.Files {
|
||||||
|
files = append(files, &ports.SignedFile{DownloadURL: f.DownloadUrl})
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) VerifyCallback(ctx context.Context, headers map[string]string, body []byte) error {
|
||||||
|
_ = ctx
|
||||||
|
_ = headers
|
||||||
|
_ = body
|
||||||
|
// e签宝现有回调走独立验签逻辑,此处保持兼容(由原 HandleEsignCallback 处理)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) ParseCallback(ctx context.Context, headers map[string]string, body []byte) (*ports.CallbackEvent, error) {
|
||||||
|
_ = ctx
|
||||||
|
_ = headers
|
||||||
|
var raw map[string]interface{}
|
||||||
|
_ = json.Unmarshal(body, &raw)
|
||||||
|
action, _ := raw["action"].(string)
|
||||||
|
ev := &ports.CallbackEvent{Event: action, Raw: raw}
|
||||||
|
if action == "AUTH_PASS" {
|
||||||
|
ev.AuthPassed = true
|
||||||
|
}
|
||||||
|
if action == "SIGN_FLOW_COMPLETE" || action == "SIGN_MISSON_COMPLETE" {
|
||||||
|
ev.SignCompleted = true
|
||||||
|
}
|
||||||
|
return ev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ports.SignPlatformProvider = (*Provider)(nil)
|
||||||
247
internal/infrastructure/external/fadada/fadada_provider.go
vendored
Normal file
247
internal/infrastructure/external/fadada/fadada_provider.go
vendored
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapi-server/internal/domains/certification/enums"
|
||||||
|
"hyapi-server/internal/domains/certification/ports"
|
||||||
|
sharedfadada "hyapi-server/internal/shared/fadada"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
client *sharedfadada.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProvider(client *sharedfadada.Client) *Provider {
|
||||||
|
return &Provider{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Platform() enums.SignPlatform {
|
||||||
|
return enums.SignPlatformFadada
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GenerateEnterpriseAuth(ctx context.Context, req *ports.EnterpriseAuthRequest) (*ports.AuthLinkResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
res, err := p.client.GenerateEnterpriseAuth(&sharedfadada.EnterpriseAuthRequest{
|
||||||
|
ClientCorpID: req.ClientCorpID,
|
||||||
|
ClientUserID: req.ClientUserID,
|
||||||
|
CompanyName: req.CompanyName,
|
||||||
|
UnifiedSocialCode: req.UnifiedSocialCode,
|
||||||
|
LegalPersonName: req.LegalPersonName,
|
||||||
|
LegalPersonID: req.LegalPersonID,
|
||||||
|
TransactorName: req.TransactorName,
|
||||||
|
TransactorMobile: req.TransactorMobile,
|
||||||
|
TransactorID: req.TransactorID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ports.AuthLinkResult{
|
||||||
|
AuthFlowID: res.AuthFlowID,
|
||||||
|
AuthURL: res.AuthURL,
|
||||||
|
AuthShortURL: firstNonEmpty(res.AuthShortURL, res.AuthURL),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) QueryOrgVerified(ctx context.Context, req *ports.OrgIdentityQuery) (bool, error) {
|
||||||
|
_ = ctx
|
||||||
|
return p.client.QueryOrgVerified(&sharedfadada.QueryOrgIdentityRequest{
|
||||||
|
CorpName: req.OrgName,
|
||||||
|
CorpIdentNo: req.OrgIdentNo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateContractFile 法大大走签署任务模板(/sign-task/create-with-template),
|
||||||
|
// 此处返回空结果,实际填单由 CreateSignFlow 通过 /sign-task/field/fill-values 完成。
|
||||||
|
func (p *Provider) GenerateContractFile(ctx context.Context, req *ports.ContractGenerateRequest) (*ports.ContractFileResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
_ = req
|
||||||
|
return &ports.ContractFileResult{FileID: "", FileDownloadURL: ""}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) CreateSignFlow(ctx context.Context, req *ports.SignFlowCreateRequest) (*ports.SignFlowResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
createReq := &sharedfadada.CreateSignFlowRequest{
|
||||||
|
Subject: req.Subject,
|
||||||
|
PartyAOpenCorpID: req.PartyAOpenCorpID,
|
||||||
|
PartyAName: req.PartyAName,
|
||||||
|
PartyAUSCC: req.PartyAUSCC,
|
||||||
|
TransactorName: req.TransactorName,
|
||||||
|
TransactorMobile: req.TransactorMobile,
|
||||||
|
TransactorID: req.TransactorID,
|
||||||
|
TransReferenceID: req.TransReferenceID,
|
||||||
|
}
|
||||||
|
if req.Fill != nil {
|
||||||
|
createReq.Fill = &sharedfadada.ContractFillRequest{
|
||||||
|
AgreementNo: req.Fill.AgreementNo,
|
||||||
|
CompanyName: req.Fill.CompanyName,
|
||||||
|
UnifiedSocialCode: req.Fill.UnifiedSocialCode,
|
||||||
|
EnterpriseAddress: req.Fill.EnterpriseAddress,
|
||||||
|
AuthorizedRepName: req.Fill.AuthorizedRepName,
|
||||||
|
SignDate: req.Fill.SignDate,
|
||||||
|
FileName: req.Fill.FileName,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 签署模板创建必须填控件
|
||||||
|
createReq.Fill = &sharedfadada.ContractFillRequest{
|
||||||
|
CompanyName: req.PartyAName,
|
||||||
|
UnifiedSocialCode: req.PartyAUSCC,
|
||||||
|
AuthorizedRepName: req.TransactorName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createRes, err := p.client.CreateSignFlow(createReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.SkipActorURL {
|
||||||
|
return &ports.SignFlowResult{
|
||||||
|
SignFlowID: createRes.SignTaskID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
urlRes, err := p.client.GetSignURL(createRes.SignTaskID, sharedfadada.ActorIDPartyA)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
embedURL := strings.TrimSpace(urlRes.EmbedURL)
|
||||||
|
if embedURL == "" {
|
||||||
|
return nil, fmt.Errorf("法大大未返回可嵌入签署链接 actorSignTaskEmbedUrl")
|
||||||
|
}
|
||||||
|
return &ports.SignFlowResult{
|
||||||
|
SignFlowID: createRes.SignTaskID,
|
||||||
|
SignURL: embedURL,
|
||||||
|
ShortURL: strings.TrimSpace(urlRes.SignURL),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GetActorSignURL(ctx context.Context, req *ports.GetActorSignURLRequest) (*ports.ActorSignURLResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
if req == nil || strings.TrimSpace(req.SignFlowID) == "" {
|
||||||
|
return nil, fmt.Errorf("signFlowId 不能为空")
|
||||||
|
}
|
||||||
|
urlRes, err := p.client.GetSignURL(strings.TrimSpace(req.SignFlowID), sharedfadada.ActorIDPartyA)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
embedURL := strings.TrimSpace(urlRes.EmbedURL)
|
||||||
|
if embedURL == "" {
|
||||||
|
return nil, fmt.Errorf("法大大未返回可嵌入签署链接 actorSignTaskEmbedUrl")
|
||||||
|
}
|
||||||
|
return &ports.ActorSignURLResult{
|
||||||
|
SignFlowID: req.SignFlowID,
|
||||||
|
EmbedURL: embedURL,
|
||||||
|
ShortURL: strings.TrimSpace(urlRes.SignURL),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GetSignTaskPreviewURL(ctx context.Context, signFlowID string) (*ports.SignTaskPreviewURLResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
signFlowID = strings.TrimSpace(signFlowID)
|
||||||
|
if signFlowID == "" {
|
||||||
|
return nil, fmt.Errorf("signFlowId 不能为空")
|
||||||
|
}
|
||||||
|
res, err := p.client.GetSignTaskPreviewURL(signFlowID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ports.SignTaskPreviewURLResult{
|
||||||
|
SignFlowID: signFlowID,
|
||||||
|
PreviewURL: res.PreviewURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) QuerySignStatus(ctx context.Context, flowID string) (*ports.SignStatusResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
st, err := p.client.QuerySignStatus(flowID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ports.SignStatusResult{
|
||||||
|
SignFlowID: flowID,
|
||||||
|
Status: st.SignTaskStatus,
|
||||||
|
Completed: st.Completed,
|
||||||
|
Rejected: st.Terminated,
|
||||||
|
Expired: false,
|
||||||
|
Message: firstNonEmpty(st.TerminationNote, st.RevokeReason),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) DownloadSignedFiles(ctx context.Context, flowID string) ([]*ports.SignedFile, error) {
|
||||||
|
_ = ctx
|
||||||
|
res, err := p.client.DownloadSignedFiles(flowID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
files := make([]*ports.SignedFile, 0, len(res.Files))
|
||||||
|
for _, f := range res.Files {
|
||||||
|
files = append(files, &ports.SignedFile{
|
||||||
|
DownloadURL: f.DownloadURL,
|
||||||
|
DownloadID: f.DownloadID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) VerifyCallback(ctx context.Context, headers map[string]string, body []byte) error {
|
||||||
|
_ = ctx
|
||||||
|
bizContent := extractBizContent(body)
|
||||||
|
return p.client.VerifyCallback(headers, bizContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) ParseCallback(ctx context.Context, headers map[string]string, body []byte) (*ports.CallbackEvent, error) {
|
||||||
|
_ = ctx
|
||||||
|
bizContent := extractBizContent(body)
|
||||||
|
ev, err := p.client.ParseCallback(headers, bizContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &ports.CallbackEvent{
|
||||||
|
Event: ev.Event,
|
||||||
|
SignFlowID: ev.SignTaskID,
|
||||||
|
AuthFlowID: firstNonEmpty(ev.ClientCorpID, ev.OpenCorpID),
|
||||||
|
Raw: ev.Raw,
|
||||||
|
}
|
||||||
|
if ev.IsSignCompletedCallback() {
|
||||||
|
out.SignCompleted = true
|
||||||
|
}
|
||||||
|
// 企业授权/认证成功事件
|
||||||
|
switch strings.ToLower(ev.Event) {
|
||||||
|
case "corp-authorize", "corp-authorize-success", "corporation_authorize", "auth-pass":
|
||||||
|
out.AuthPassed = true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(ev.AuthResult, "success") || strings.EqualFold(ev.AuthResult, "pass") {
|
||||||
|
out.AuthPassed = true
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBizContent(body []byte) string {
|
||||||
|
raw := strings.TrimSpace(string(body))
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "{") {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
values, err := url.ParseQuery(raw)
|
||||||
|
if err == nil {
|
||||||
|
if v := values.Get("bizContent"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ports.SignPlatformProvider = (*Provider)(nil)
|
||||||
94
internal/infrastructure/external/rongxing/crypto.go
vendored
Normal file
94
internal/infrastructure/external/rongxing/crypto.go
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package rongxing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MD5Upper 对明文做 MD5,返回 32 位大写十六进制(入参说明要求大写)。
|
||||||
|
func MD5Upper(plaintext string) string {
|
||||||
|
sum := md5.Sum([]byte(strings.TrimSpace(plaintext)))
|
||||||
|
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodePasswordBase64 将明文密码做 Base64 编码(登录接口要求)。
|
||||||
|
func EncodePasswordBase64(password string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(password))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSignContent 生成登录签名原文:过滤 sign/空值 → 按 key 升序 → key=value&...
|
||||||
|
func BuildSignContent(params map[string]interface{}) string {
|
||||||
|
keys := make([]string, 0, len(params))
|
||||||
|
for k, v := range params {
|
||||||
|
if strings.EqualFold(k, "sign") || v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%v", k, params[k]))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignSHA256WithRSA 使用私钥对原文做 SHA256withRSA 签名,返回 Base64。
|
||||||
|
func SignSHA256WithRSA(content string, privateKey *rsa.PrivateKey) (string, error) {
|
||||||
|
if privateKey == nil {
|
||||||
|
return "", fmt.Errorf("私钥为空")
|
||||||
|
}
|
||||||
|
hashed := sha256.Sum256([]byte(content))
|
||||||
|
signed, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("RSA 签名失败: %w", err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(signed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePrivateKey 解析 RSA 私钥(支持 PEM / PKCS#8 / PKCS#1 原始 Base64)。
|
||||||
|
func ParsePrivateKey(raw string) (*rsa.PrivateKey, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("私钥为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
var der []byte
|
||||||
|
if block, _ := pem.Decode([]byte(raw)); block != nil {
|
||||||
|
der = block.Bytes
|
||||||
|
} else {
|
||||||
|
normalized := strings.ReplaceAll(raw, "\n", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\r", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("私钥 Base64 解码失败: %w", err)
|
||||||
|
}
|
||||||
|
der = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||||
|
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("私钥不是 RSA 类型")
|
||||||
|
}
|
||||||
|
return rsaKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("无法解析 RSA 私钥(需 PKCS#8 或 PKCS#1)")
|
||||||
|
}
|
||||||
66
internal/infrastructure/external/rongxing/crypto_test.go
vendored
Normal file
66
internal/infrastructure/external/rongxing/crypto_test.go
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package rongxing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMD5Upper(t *testing.T) {
|
||||||
|
if got := MD5Upper(""); got != "D41D8CD98F00B204E9800998ECF8427E" {
|
||||||
|
t.Fatalf("MD5Upper empty = %s", got)
|
||||||
|
}
|
||||||
|
if got := MD5Upper(" hello "); got != MD5Upper("hello") {
|
||||||
|
t.Fatalf("MD5Upper should trim spaces")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSignContent(t *testing.T) {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"account": "haiyukeji",
|
||||||
|
"password": "cGFzcw==",
|
||||||
|
"appId": "hykj",
|
||||||
|
"timestamp": int64(1710000000000),
|
||||||
|
"sign": "ignored",
|
||||||
|
}
|
||||||
|
got := BuildSignContent(params)
|
||||||
|
want := "account=haiyukeji&appId=hykj&password=cGFzcw==×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)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
internal/infrastructure/external/rongxing/curl_helper.go
vendored
Normal file
35
internal/infrastructure/external/rongxing/curl_helper.go
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package rongxing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateCurlCommand 生成可直接复现的 curl 命令,便于联调排查。
|
||||||
|
func generateCurlCommand(method, url string, headers map[string]string, body string) string {
|
||||||
|
var cmd strings.Builder
|
||||||
|
cmd.WriteString("curl -X ")
|
||||||
|
cmd.WriteString(method)
|
||||||
|
cmd.WriteString(" '")
|
||||||
|
cmd.WriteString(url)
|
||||||
|
cmd.WriteString("'")
|
||||||
|
|
||||||
|
for key, value := range headers {
|
||||||
|
cmd.WriteString(" \\\n -H '")
|
||||||
|
cmd.WriteString(key)
|
||||||
|
cmd.WriteString(": ")
|
||||||
|
cmd.WriteString(escapeSingleQuotes(value))
|
||||||
|
cmd.WriteString("'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if body != "" {
|
||||||
|
cmd.WriteString(" \\\n -d '")
|
||||||
|
cmd.WriteString(escapeSingleQuotes(body))
|
||||||
|
cmd.WriteString("'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeSingleQuotes(s string) string {
|
||||||
|
return strings.ReplaceAll(s, "'", `'\''`)
|
||||||
|
}
|
||||||
50
internal/infrastructure/external/rongxing/rongxing_factory.go
vendored
Normal file
50
internal/infrastructure/external/rongxing/rongxing_factory.go
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package rongxing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hyapi-server/internal/config"
|
||||||
|
"hyapi-server/internal/shared/external_logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRongxingServiceWithConfig 使用配置创建戎行服务
|
||||||
|
func NewRongxingServiceWithConfig(cfg *config.Config) (*RongxingService, error) {
|
||||||
|
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||||
|
Enabled: cfg.Rongxing.Logging.Enabled,
|
||||||
|
LogDir: cfg.Rongxing.Logging.LogDir,
|
||||||
|
ServiceName: "rongxing",
|
||||||
|
UseDaily: cfg.Rongxing.Logging.UseDaily,
|
||||||
|
EnableLevelSeparation: cfg.Rongxing.Logging.EnableLevelSeparation,
|
||||||
|
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Rongxing.Logging.ServiceName != "" {
|
||||||
|
loggingConfig.ServiceName = cfg.Rongxing.Logging.ServiceName
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range cfg.Rongxing.Logging.LevelConfigs {
|
||||||
|
loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{
|
||||||
|
MaxSize: value.MaxSize,
|
||||||
|
MaxBackups: value.MaxBackups,
|
||||||
|
MaxAge: value.MaxAge,
|
||||||
|
Compress: value.Compress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := cfg.Rongxing.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultRequestTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewRongxingService(serviceConfig{
|
||||||
|
BaseURL: cfg.Rongxing.URL,
|
||||||
|
Account: cfg.Rongxing.Account,
|
||||||
|
Password: cfg.Rongxing.Password,
|
||||||
|
AppID: cfg.Rongxing.AppID,
|
||||||
|
PrivateKey: cfg.Rongxing.PrivateKey,
|
||||||
|
Timeout: timeout,
|
||||||
|
}, logger), nil
|
||||||
|
}
|
||||||
396
internal/infrastructure/external/rongxing/rongxing_service.go
vendored
Normal file
396
internal/infrastructure/external/rongxing/rongxing_service.go
vendored
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
package rongxing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapi-server/internal/shared/external_logger"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultRequestTimeout = 10 * time.Second
|
||||||
|
apiKeyLogin = "auth_login"
|
||||||
|
pathAuthLogin = "/auth/login"
|
||||||
|
headerDmsToken = "dms-token"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceConfig 戎行服务运行时配置
|
||||||
|
type serviceConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
Account string
|
||||||
|
Password string
|
||||||
|
AppID string
|
||||||
|
PrivateKey string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// RongxingService 戎行数据源服务
|
||||||
|
type RongxingService struct {
|
||||||
|
config serviceConfig
|
||||||
|
logger *external_logger.ExternalServiceLogger
|
||||||
|
|
||||||
|
tokenMu sync.RWMutex
|
||||||
|
cachedToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiResponse 戎行统一响应。code 可能是数字或字符串;扣费以 consumeFlag 为准。
|
||||||
|
type apiResponse struct {
|
||||||
|
Flag bool `json:"flag"`
|
||||||
|
Code json.RawMessage `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
ConsumeFlag int `json:"consumeFlag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r apiResponse) text() string {
|
||||||
|
if r.Msg != "" {
|
||||||
|
return r.Msg
|
||||||
|
}
|
||||||
|
return r.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r apiResponse) code() string {
|
||||||
|
return parseCode(r.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRongxingService 创建戎行服务实例
|
||||||
|
func NewRongxingService(cfg serviceConfig, logger *external_logger.ExternalServiceLogger) *RongxingService {
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
cfg.Timeout = defaultRequestTimeout
|
||||||
|
}
|
||||||
|
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||||
|
return &RongxingService{config: cfg, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig 获取运行时配置
|
||||||
|
func (s *RongxingService) GetConfig() serviceConfig {
|
||||||
|
return s.config
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallAPI 通用业务接口调用。
|
||||||
|
// apiPath 为相对路径(如 /third/loan/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),
|
||||||
|
)
|
||||||
|
}
|
||||||
75
internal/infrastructure/external/rongxing/status_codes.go
vendored
Normal file
75
internal/infrastructure/external/rongxing/status_codes.go
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package rongxing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 业务返回码(仅用于错误描述/分类,不作为扣费依据)
|
||||||
|
const (
|
||||||
|
CodeSuccess = "200" // 成功
|
||||||
|
CodeNoData = "204" // 未查到数据
|
||||||
|
CodeInternalErr = "503" // 内部服务错误
|
||||||
|
)
|
||||||
|
|
||||||
|
// consumeFlag 扣费标记(对方各业务码场景下均可能返回)
|
||||||
|
const (
|
||||||
|
ConsumeFlagNoCharge = 0 // 不扣费
|
||||||
|
ConsumeFlagCharge = 1 // 扣费
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDatasource = errors.New("数据源异常")
|
||||||
|
ErrSystem = errors.New("系统异常")
|
||||||
|
ErrNotFound = errors.New("查询为空")
|
||||||
|
)
|
||||||
|
|
||||||
|
var codeMessage = map[string]string{
|
||||||
|
CodeSuccess: "成功",
|
||||||
|
CodeNoData: "未查到数据",
|
||||||
|
CodeInternalErr: "内部服务错误",
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCodeMessage 返回码描述
|
||||||
|
func GetCodeMessage(code string) string {
|
||||||
|
if msg, ok := codeMessage[code]; ok {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBillable 按 consumeFlag 判断是否扣费(不以 code 为依据)
|
||||||
|
func IsBillable(consumeFlag int) bool {
|
||||||
|
return consumeFlag == ConsumeFlagCharge
|
||||||
|
}
|
||||||
|
|
||||||
|
type rongxingError struct {
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *rongxingError) Error() string {
|
||||||
|
return fmt.Sprintf("戎行返回错误,code: %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
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/infrastructure/external/signplatform/registry.go
vendored
Normal file
52
internal/infrastructure/external/signplatform/registry.go
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package signplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"hyapi-server/internal/domains/certification/enums"
|
||||||
|
"hyapi-server/internal/domains/certification/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
providers map[enums.SignPlatform]ports.SignPlatformProvider
|
||||||
|
order []enums.SignPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry(providers ...ports.SignPlatformProvider) *Registry {
|
||||||
|
r := &Registry{
|
||||||
|
providers: make(map[enums.SignPlatform]ports.SignPlatformProvider, len(providers)),
|
||||||
|
order: make([]enums.SignPlatform, 0, len(providers)),
|
||||||
|
}
|
||||||
|
for _, p := range providers {
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
platform := p.Platform()
|
||||||
|
r.providers[platform] = p
|
||||||
|
r.order = append(r.order, platform)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Get(platform enums.SignPlatform) (ports.SignPlatformProvider, error) {
|
||||||
|
if platform == "" {
|
||||||
|
platform = enums.DefaultSignPlatform()
|
||||||
|
}
|
||||||
|
p, ok := r.providers[platform]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("不支持的签署平台: %s", platform)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) List() []ports.SignPlatformProvider {
|
||||||
|
out := make([]ports.SignPlatformProvider, 0, len(r.order))
|
||||||
|
for _, platform := range r.order {
|
||||||
|
if p, ok := r.providers[platform]; ok {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ports.SignPlatformRegistry = (*Registry)(nil)
|
||||||
@@ -305,11 +305,10 @@ func (s *QiNiuStorageService) DownloadFile(ctx context.Context, fileURL string)
|
|||||||
isTimeout = true
|
isTimeout = true
|
||||||
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||||
isTimeout = true
|
isTimeout = true
|
||||||
} else if errStr := err.Error();
|
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
|
||||||
errStr == "context deadline exceeded" ||
|
errStr == "timeout" ||
|
||||||
errStr == "timeout" ||
|
errStr == "Client.Timeout exceeded" ||
|
||||||
errStr == "Client.Timeout exceeded" ||
|
errStr == "net/http: request canceled" {
|
||||||
errStr == "net/http: request canceled" {
|
|
||||||
isTimeout = true
|
isTimeout = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,59 @@ func (h *CertificationHandler) ConfirmAuth(c *gin.Context) {
|
|||||||
h.response.Success(c, result, "状态确认成功")
|
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 前端确认是否完成签署
|
// ConfirmSign 前端确认是否完成签署
|
||||||
// @Summary 前端确认签署状态
|
// @Summary 前端确认签署状态
|
||||||
// @Description 前端轮询确认合同签署是否完成
|
// @Description 前端轮询确认合同签署是否完成
|
||||||
@@ -219,6 +272,61 @@ func (h *CertificationHandler) ApplyContract(c *gin.Context) {
|
|||||||
h.response.Success(c, result, "合同申请成功")
|
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识别营业执照
|
// RecognizeBusinessLicense OCR识别营业执照
|
||||||
// @Summary OCR识别营业执照
|
// @Summary OCR识别营业执照
|
||||||
// @Description 上传营业执照图片进行OCR识别,自动填充企业信息
|
// @Description 上传营业执照图片进行OCR识别,自动填充企业信息
|
||||||
|
|||||||
@@ -65,10 +65,17 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) {
|
|||||||
|
|
||||||
// 3. 申请合同签署
|
// 3. 申请合同签署
|
||||||
authGroup.POST("/apply-contract", r.handler.ApplyContract)
|
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.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)
|
authGroup.POST("/confirm-sign", r.handler.ConfirmSign)
|
||||||
|
|
||||||
@@ -95,7 +102,8 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) {
|
|||||||
// 回调路由(不需要认证,但需要验证签名)
|
// 回调路由(不需要认证,但需要验证签名)
|
||||||
callbackGroup := certificationGroup.Group("/callbacks")
|
callbackGroup := certificationGroup.Group("/callbacks")
|
||||||
{
|
{
|
||||||
callbackGroup.POST("/esign", r.handler.HandleEsignCallback) // e签宝回调(统一处理企业认证和合同签署回调)
|
callbackGroup.POST("/esign", r.handler.HandleEsignCallback) // e签宝回调
|
||||||
|
callbackGroup.POST("/fadada", r.handler.HandleFadadaCallback) // 法大大回调
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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的响应日志
|
// LogResponseWithID 记录包含响应ID的响应日志
|
||||||
func (e *ExternalServiceLogger) LogResponseWithID(requestID, transactionID, apiCode string, statusCode int, duration time.Duration, responseID string) {
|
func (e *ExternalServiceLogger) LogResponseWithID(requestID, transactionID, apiCode string, statusCode int, duration time.Duration, responseID string) {
|
||||||
logger := e.responseLogger
|
logger := e.responseLogger
|
||||||
|
|||||||
188
internal/shared/fadada/api_types.go
Normal file
188
internal/shared/fadada/api_types.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
// ---- 通用 ----
|
||||||
|
|
||||||
|
type openID struct {
|
||||||
|
IDType string `json:"idType"`
|
||||||
|
OpenID string `json:"openId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- accessToken ----
|
||||||
|
|
||||||
|
type accessTokenData struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
ExpiresIn string `json:"expiresIn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- /corp/get-auth-url ----
|
||||||
|
|
||||||
|
type getCorpAuthURLReq struct {
|
||||||
|
ClientCorpID string `json:"clientCorpId"`
|
||||||
|
ClientUserID string `json:"clientUserId,omitempty"`
|
||||||
|
AccountName string `json:"accountName,omitempty"`
|
||||||
|
CorpIdentInfo *corpIdentInfo `json:"corpIdentInfo,omitempty"`
|
||||||
|
CorpNonEditableInfo []string `json:"corpNonEditableInfo,omitempty"`
|
||||||
|
OprIdentInfo *oprIdentInfo `json:"oprIdentInfo,omitempty"`
|
||||||
|
OprNonEditableInfo []string `json:"oprNonEditableInfo,omitempty"`
|
||||||
|
CorpIdentInfoMatch bool `json:"corpIdentInfoMatch"`
|
||||||
|
AuthScopes []string `json:"authScopes,omitempty"`
|
||||||
|
RedirectURL string `json:"redirectUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type corpIdentInfo struct {
|
||||||
|
CorpName string `json:"corpName,omitempty"`
|
||||||
|
CorpIdentType string `json:"corpIdentType,omitempty"`
|
||||||
|
CorpIdentNo string `json:"corpIdentNo,omitempty"`
|
||||||
|
LegalRepName string `json:"legalRepName,omitempty"`
|
||||||
|
CorpIdentMethod []string `json:"corpIdentMethod,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type oprIdentInfo struct {
|
||||||
|
UserName string `json:"userName,omitempty"`
|
||||||
|
UserIdentType string `json:"userIdentType,omitempty"`
|
||||||
|
UserIdentNo string `json:"userIdentNo,omitempty"`
|
||||||
|
Mobile string `json:"mobile,omitempty"`
|
||||||
|
OprIdentMethod []string `json:"oprIdentMethod,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type getCorpAuthURLData struct {
|
||||||
|
AuthURL string `json:"authUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- /corp/get-identified-status ----
|
||||||
|
|
||||||
|
type getIdentifiedStatusReq struct {
|
||||||
|
CorpName string `json:"corpName,omitempty"`
|
||||||
|
CorpIdentNo string `json:"corpIdentNo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type getIdentifiedStatusData struct {
|
||||||
|
IdentStatus bool `json:"identStatus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 模板 / 签署 ----
|
||||||
|
|
||||||
|
type docFieldValue struct {
|
||||||
|
// DocID 签署任务中的文档标识;fill-values 接口必填(缺则报 100012 docId 不能为空)
|
||||||
|
DocID string `json:"docId,omitempty"`
|
||||||
|
// FieldDocID 部分文档/示例使用 fieldDocId,与 DocID 同值一并传以保证兼容
|
||||||
|
FieldDocID string `json:"fieldDocId,omitempty"`
|
||||||
|
FieldID string `json:"fieldId"`
|
||||||
|
FieldName string `json:"fieldName,omitempty"`
|
||||||
|
FieldValue string `json:"fieldValue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fillDocTemplateReq struct {
|
||||||
|
OwnerID *openID `json:"ownerId"`
|
||||||
|
DocTemplateID string `json:"docTemplateId"`
|
||||||
|
FileName string `json:"fileName,omitempty"`
|
||||||
|
DocFieldValues []docFieldValue `json:"docFieldValues"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fillDocTemplateData struct {
|
||||||
|
FileID string `json:"fileId"`
|
||||||
|
FileDownloadURL string `json:"fileDownloadUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fillSignTaskFieldsReq struct {
|
||||||
|
SignTaskID string `json:"signTaskId"`
|
||||||
|
DocFieldValues []docFieldValue `json:"docFieldValues"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type signTaskActor struct {
|
||||||
|
Actor *actor `json:"actor"`
|
||||||
|
SignConfigInfo *signConfigInfo `json:"signConfigInfo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type actor struct {
|
||||||
|
ActorType string `json:"actorType"`
|
||||||
|
ActorID string `json:"actorId"`
|
||||||
|
ActorName string `json:"actorName,omitempty"`
|
||||||
|
Permissions []string `json:"permissions,omitempty"`
|
||||||
|
ActorOpenID string `json:"actorOpenId,omitempty"`
|
||||||
|
IdentNameForMatch string `json:"identNameForMatch,omitempty"`
|
||||||
|
CertNoForMatch string `json:"certNoForMatch,omitempty"`
|
||||||
|
AccountName string `json:"accountName,omitempty"`
|
||||||
|
ClientUserID string `json:"clientUserId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type signConfigInfo struct {
|
||||||
|
OrderNo int `json:"orderNo"`
|
||||||
|
JoinByLink bool `json:"joinByLink"`
|
||||||
|
RequestVerifyFree bool `json:"requestVerifyFree"`
|
||||||
|
FreeSignType string `json:"freeSignType,omitempty"` // template=按模板;business=按场景码
|
||||||
|
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"`
|
||||||
|
}
|
||||||
184
internal/shared/fadada/callback.go
Normal file
184
internal/shared/fadada/callback.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 常见回调事件名(以官方实际推送为准,解析时兼容多种字段)
|
||||||
|
const (
|
||||||
|
CallbackEventSignTaskSigned = "sign-task-signed"
|
||||||
|
CallbackEventSignTaskFinished = "sign-task-finished"
|
||||||
|
CallbackEventSignTaskCanceled = "sign-task-canceled"
|
||||||
|
CallbackEventCorpAuthorize = "corp-authorize"
|
||||||
|
CallbackEventCorpAuthSuccess = "corp-authorize-success"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SuccessCallbackBody 回调应答,约定返回成功
|
||||||
|
var SuccessCallbackBody = []byte(`{"msg":"success"}`)
|
||||||
|
|
||||||
|
// VerifyCallback 校验法大大回调签名(HMAC-SHA256,与出站请求同一套算法)
|
||||||
|
func (c *Client) VerifyCallback(headers map[string]string, bizContent string) error {
|
||||||
|
if c.config == nil || c.config.AppSecret == "" {
|
||||||
|
return fmt.Errorf("法大大 app_secret 未配置,无法验签")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := normalizeHeaderMap(headers)
|
||||||
|
sign := firstHeader(normalized, "X-FASC-Sign", "X-Fasc-Sign")
|
||||||
|
if sign == "" {
|
||||||
|
return fmt.Errorf("缺少回调签名头 X-FASC-Sign")
|
||||||
|
}
|
||||||
|
timestamp := firstHeader(normalized, "X-FASC-Timestamp", "X-Fasc-Timestamp")
|
||||||
|
if timestamp == "" {
|
||||||
|
return fmt.Errorf("缺少回调时间戳头 X-FASC-Timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
headMap := map[string]string{
|
||||||
|
"X-FASC-App-Id": firstHeader(normalized, "X-FASC-App-Id", "X-Fasc-App-Id"),
|
||||||
|
"X-FASC-Sign-Type": firstHeader(normalized, "X-FASC-Sign-Type", "X-Fasc-Sign-Type"),
|
||||||
|
"X-FASC-Timestamp": timestamp,
|
||||||
|
"X-FASC-Nonce": firstHeader(normalized, "X-FASC-Nonce", "X-Fasc-Nonce"),
|
||||||
|
"X-FASC-Api-SubVersion": firstHeader(normalized, "X-FASC-Api-SubVersion", "X-Fasc-Api-SubVersion"),
|
||||||
|
"bizContent": bizContent,
|
||||||
|
}
|
||||||
|
if event := firstHeader(normalized, "X-FASC-Event", "X-Fasc-Event"); event != "" {
|
||||||
|
headMap["X-FASC-Event"] = event
|
||||||
|
}
|
||||||
|
if token := firstHeader(normalized, "X-FASC-AccessToken", "X-Fasc-AccessToken"); token != "" {
|
||||||
|
headMap["X-FASC-AccessToken"] = token
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := SignByMap(headMap, timestamp, c.config.AppSecret)
|
||||||
|
if !strings.EqualFold(expected, sign) {
|
||||||
|
return fmt.Errorf("法大大回调签名校验失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
appID := firstHeader(normalized, "X-FASC-App-Id", "X-Fasc-App-Id")
|
||||||
|
if appID != "" && c.config.AppID != "" && appID != c.config.AppID {
|
||||||
|
return fmt.Errorf("法大大回调 AppId 不匹配")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCallbackFromHTTP 从 http.Header 验签
|
||||||
|
func (c *Client) VerifyCallbackFromHTTP(h http.Header, bizContent string) error {
|
||||||
|
headers := make(map[string]string, len(h))
|
||||||
|
for k, vals := range h {
|
||||||
|
if len(vals) > 0 {
|
||||||
|
headers[k] = vals[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.VerifyCallback(headers, bizContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCallback 解析 bizContent 为统一 CallbackEvent
|
||||||
|
func (c *Client) ParseCallback(headers map[string]string, bizContent string) (*CallbackEvent, error) {
|
||||||
|
bizContent = strings.TrimSpace(bizContent)
|
||||||
|
if bizContent == "" {
|
||||||
|
return nil, fmt.Errorf("回调 bizContent 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(bizContent), &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析回调 bizContent 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := normalizeHeaderMap(headers)
|
||||||
|
event := firstHeader(normalized, "X-FASC-Event", "X-Fasc-Event")
|
||||||
|
if event == "" {
|
||||||
|
event = stringValue(raw, "event", "eventType", "type")
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := &CallbackEvent{
|
||||||
|
Event: event,
|
||||||
|
SignTaskID: stringValue(raw, "signTaskId", "sign_task_id"),
|
||||||
|
SignTaskStatus: stringValue(raw, "signTaskStatus", "sign_task_status"),
|
||||||
|
ClientCorpID: stringValue(raw, "clientCorpId", "client_corp_id"),
|
||||||
|
OpenCorpID: stringValue(raw, "openCorpId", "open_corp_id"),
|
||||||
|
AuthResult: stringValue(raw, "authResult", "auth_result", "result"),
|
||||||
|
EventTime: stringValue(raw, "eventTime", "event_time", "timestamp"),
|
||||||
|
Raw: raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, ok := raw["data"].(map[string]interface{}); ok {
|
||||||
|
if ev.SignTaskID == "" {
|
||||||
|
ev.SignTaskID = stringValue(data, "signTaskId", "sign_task_id")
|
||||||
|
}
|
||||||
|
if ev.SignTaskStatus == "" {
|
||||||
|
ev.SignTaskStatus = stringValue(data, "signTaskStatus", "sign_task_status")
|
||||||
|
}
|
||||||
|
if ev.ClientCorpID == "" {
|
||||||
|
ev.ClientCorpID = stringValue(data, "clientCorpId", "client_corp_id")
|
||||||
|
}
|
||||||
|
if ev.OpenCorpID == "" {
|
||||||
|
ev.OpenCorpID = stringValue(data, "openCorpId", "open_corp_id")
|
||||||
|
}
|
||||||
|
if ev.AuthResult == "" {
|
||||||
|
ev.AuthResult = stringValue(data, "authResult", "auth_result", "result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSignCompletedCallback 是否签署完成类回调
|
||||||
|
func (e *CallbackEvent) IsSignCompletedCallback() bool {
|
||||||
|
if e == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if IsSignTaskCompletedStatus(e.SignTaskStatus) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch e.Event {
|
||||||
|
case CallbackEventSignTaskFinished, CallbackEventSignTaskSigned:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHeaderMap(headers map[string]string) map[string]string {
|
||||||
|
out := make(map[string]string, len(headers))
|
||||||
|
for k, v := range headers {
|
||||||
|
out[http.CanonicalHeaderKey(k)] = v
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstHeader(headers map[string]string, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if v := strings.TrimSpace(headers[key]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(headers[http.CanonicalHeaderKey(key)]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(m map[string]interface{}, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if v, ok := m[key]; ok && v != nil {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(t) != "" {
|
||||||
|
return strings.TrimSpace(t)
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
return fmt.Sprintf("%.0f", t)
|
||||||
|
case json.Number:
|
||||||
|
return t.String()
|
||||||
|
default:
|
||||||
|
s := strings.TrimSpace(fmt.Sprint(t))
|
||||||
|
if s != "" && s != "<nil>" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
57
internal/shared/fadada/callback_test.go
Normal file
57
internal/shared/fadada/callback_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestVerifyCallback(t *testing.T) {
|
||||||
|
secret := "test-secret"
|
||||||
|
c := NewClient(&Config{
|
||||||
|
AppID: "80005307",
|
||||||
|
AppSecret: secret,
|
||||||
|
ServerURL: "https://uat-api.fadada.com/api/v5/",
|
||||||
|
})
|
||||||
|
|
||||||
|
biz := `{"signTaskId":"st-1","signTaskStatus":"task_finished"}`
|
||||||
|
timestamp := "1720000000000"
|
||||||
|
nonce := "nonce-1"
|
||||||
|
headMap := map[string]string{
|
||||||
|
"X-FASC-App-Id": "80005307",
|
||||||
|
"X-FASC-Sign-Type": "HMAC-SHA256",
|
||||||
|
"X-FASC-Timestamp": timestamp,
|
||||||
|
"X-FASC-Nonce": nonce,
|
||||||
|
"X-FASC-Event": "sign-task-finished",
|
||||||
|
"bizContent": biz,
|
||||||
|
}
|
||||||
|
sign := SignByMap(headMap, timestamp, secret)
|
||||||
|
|
||||||
|
headers := map[string]string{
|
||||||
|
"X-FASC-App-Id": "80005307",
|
||||||
|
"X-FASC-Sign-Type": "HMAC-SHA256",
|
||||||
|
"X-FASC-Timestamp": timestamp,
|
||||||
|
"X-FASC-Nonce": nonce,
|
||||||
|
"X-FASC-Event": "sign-task-finished",
|
||||||
|
"X-FASC-Sign": sign,
|
||||||
|
}
|
||||||
|
if err := c.VerifyCallback(headers, biz); err != nil {
|
||||||
|
t.Fatalf("verify failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers["X-FASC-Sign"] = "deadbeef"
|
||||||
|
if err := c.VerifyCallback(headers, biz); err == nil {
|
||||||
|
t.Fatal("expected signature failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignByMapStable(t *testing.T) {
|
||||||
|
m := map[string]string{
|
||||||
|
"X-FASC-App-Id": "app",
|
||||||
|
"X-FASC-Nonce": "n1",
|
||||||
|
"X-FASC-Sign-Type": "HMAC-SHA256",
|
||||||
|
"X-FASC-Timestamp": "1720000000000",
|
||||||
|
"bizContent": `{"a":1}`,
|
||||||
|
}
|
||||||
|
s1 := SignByMap(m, "1720000000000", "secret")
|
||||||
|
s2 := SignByMap(m, "1720000000000", "secret")
|
||||||
|
if s1 == "" || s1 != s2 {
|
||||||
|
t.Fatalf("signature unstable: %s vs %s", s1, s2)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
internal/shared/fadada/client.go
Normal file
40
internal/shared/fadada/client.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// API 路径常量(FASC OpenAPI v5.1)
|
||||||
|
const (
|
||||||
|
pathGetAccessToken = "/service/get-access-token"
|
||||||
|
pathGetCorpAuthURL = "/corp/get-auth-url"
|
||||||
|
pathGetCorpIdentifiedStatus = "/corp/get-identified-status"
|
||||||
|
pathFillDocTemplateValues = "/doc-template/fill-values"
|
||||||
|
pathCreateSignTaskTemplate = "/sign-task/create-with-template"
|
||||||
|
pathFillSignTaskFieldValues = "/sign-task/field/fill-values"
|
||||||
|
pathStartSignTask = "/sign-task/start"
|
||||||
|
pathGetSignTaskActorURL = "/sign-task/actor/get-url"
|
||||||
|
pathGetSignTaskDetail = "/sign-task/app/get-detail"
|
||||||
|
pathGetSignTaskPreviewURL = "/sign-task/get-preview-url"
|
||||||
|
pathGetOwnerSignTaskDownload = "/sign-task/owner/get-download-url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client 法大大客户端(纯 HTTP,不依赖官方 SDK)
|
||||||
|
type Client struct {
|
||||||
|
config *Config
|
||||||
|
http *HTTPClient
|
||||||
|
tokenCache *tokenCache
|
||||||
|
tokenMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient 创建法大大客户端
|
||||||
|
func NewClient(config *Config) *Client {
|
||||||
|
return &Client{
|
||||||
|
config: config,
|
||||||
|
http: NewHTTPClient(config),
|
||||||
|
tokenCache: &tokenCache{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig 获取当前配置
|
||||||
|
func (c *Client) GetConfig() *Config {
|
||||||
|
return c.config
|
||||||
|
}
|
||||||
187
internal/shared/fadada/config.go
Normal file
187
internal/shared/fadada/config.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthConfig 认证相关配置
|
||||||
|
type AuthConfig struct {
|
||||||
|
RedirectURL string `json:"redirectUrl" yaml:"redirect_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignConfig 签署相关配置
|
||||||
|
type SignConfig struct {
|
||||||
|
RedirectURL string `json:"redirectUrl" yaml:"redirect_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContractConfig 合同相关配置
|
||||||
|
type ContractConfig struct {
|
||||||
|
Name string `json:"name" yaml:"name"`
|
||||||
|
ExpireDays int `json:"expireDays" yaml:"expire_days"`
|
||||||
|
RetryCount int `json:"retryCount" yaml:"retry_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallbackConfig 回调配置
|
||||||
|
type CallbackConfig struct {
|
||||||
|
Enabled bool `json:"enabled" yaml:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateFields 合作协议模板控件 fieldId
|
||||||
|
type TemplateFields struct {
|
||||||
|
// AgreementNo 协议编号控件编码列表(法大大控件不可复用,多处填同一值)
|
||||||
|
AgreementNo []string `json:"agreementNo" yaml:"agreement_no"`
|
||||||
|
ContractDate string `json:"contractDate" yaml:"contract_date"`
|
||||||
|
// PartyAName 甲方企业名控件编码列表(多处填同一企业名)
|
||||||
|
PartyAName []string `json:"partyAName" yaml:"party_a_name"`
|
||||||
|
PartyAUSCC string `json:"partyAUscc" yaml:"party_a_uscc"`
|
||||||
|
PartyAAddress string `json:"partyAAddress" yaml:"party_a_address"`
|
||||||
|
PartyARep string `json:"partyARep" yaml:"party_a_rep"`
|
||||||
|
PartyASignDate string `json:"partyASignDate" yaml:"party_a_sign_date"`
|
||||||
|
PartyBSignDate string `json:"partyBSignDate" yaml:"party_b_sign_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config 法大大服务配置
|
||||||
|
type Config struct {
|
||||||
|
AppID string `json:"appId" yaml:"app_id"`
|
||||||
|
AppSecret string `json:"appSecret" yaml:"app_secret"`
|
||||||
|
ServerURL string `json:"serverUrl" yaml:"server_url"`
|
||||||
|
OpenCorpID string `json:"openCorpId" yaml:"open_corp_id"`
|
||||||
|
// NoAuthSceneCode 免验证签场景码(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"
|
||||||
|
)
|
||||||
180
internal/shared/fadada/corp_service.go
Normal file
180
internal/shared/fadada/corp_service.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 默认授权范围:认证信息 + 后续签署所需能力
|
||||||
|
var defaultAuthScopes = []string{
|
||||||
|
"ident_info",
|
||||||
|
"seal_info",
|
||||||
|
"signtask_info",
|
||||||
|
"signtask_init",
|
||||||
|
"signtask_file",
|
||||||
|
"organization",
|
||||||
|
"template",
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateEnterpriseAuth 获取企业认证/授权链接(POST /corp/get-auth-url)
|
||||||
|
// 文档见 1.md:corpIdentType 置空由用户在页面选择组织类型。
|
||||||
|
func (c *Client) GenerateEnterpriseAuth(req *EnterpriseAuthRequest) (*EnterpriseAuthResult, error) {
|
||||||
|
if err := validateEnterpriseAuthRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := c.ensureAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
clientCorpID := strings.TrimSpace(req.ClientCorpID)
|
||||||
|
if clientCorpID == "" {
|
||||||
|
clientCorpID = strings.TrimSpace(req.UnifiedSocialCode)
|
||||||
|
}
|
||||||
|
clientUserID := strings.TrimSpace(req.ClientUserID)
|
||||||
|
if clientUserID == "" {
|
||||||
|
clientUserID = strings.TrimSpace(req.TransactorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURL := ""
|
||||||
|
if c.config.Auth != nil {
|
||||||
|
redirectURL = c.config.Auth.RedirectURL
|
||||||
|
}
|
||||||
|
|
||||||
|
apiReq := &getCorpAuthURLReq{
|
||||||
|
ClientCorpID: clientCorpID,
|
||||||
|
ClientUserID: clientUserID,
|
||||||
|
AccountName: strings.TrimSpace(req.TransactorMobile),
|
||||||
|
CorpIdentInfo: &corpIdentInfo{
|
||||||
|
CorpName: strings.TrimSpace(req.CompanyName),
|
||||||
|
// 按官方文档:不传默认为企业;置空让用户在页面选择组织类型
|
||||||
|
// CorpIdentType: "",
|
||||||
|
CorpIdentNo: strings.TrimSpace(req.UnifiedSocialCode),
|
||||||
|
LegalRepName: strings.TrimSpace(req.LegalPersonName),
|
||||||
|
CorpIdentMethod: []string{CorpIdentMethodLegalRep},
|
||||||
|
},
|
||||||
|
// 文档合法值仅 corpName/corpIdentType/corpIdentNo;不传表示都可修改
|
||||||
|
// CorpNonEditableInfo: ,
|
||||||
|
OprIdentInfo: &oprIdentInfo{
|
||||||
|
UserName: strings.TrimSpace(req.TransactorName),
|
||||||
|
UserIdentType: UserIdentTypeIDCard,
|
||||||
|
UserIdentNo: strings.TrimSpace(req.TransactorID),
|
||||||
|
Mobile: strings.TrimSpace(req.TransactorMobile),
|
||||||
|
OprIdentMethod: []string{OprIdentMethodMobile},
|
||||||
|
},
|
||||||
|
OprNonEditableInfo: nil,
|
||||||
|
AuthScopes: append([]string{}, defaultAuthScopes...),
|
||||||
|
RedirectURL: encodeRedirectURL(redirectURL),
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := c.http.PostBiz(pathGetCorpAuthURL, accessToken, apiReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.Code != successCode {
|
||||||
|
return nil, fmt.Errorf("获取法大大企业认证链接失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data getCorpAuthURLData
|
||||||
|
if err := decodeData(res.Data, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析企业认证链接响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if data.AuthURL == "" {
|
||||||
|
return nil, fmt.Errorf("获取法大大企业认证链接失败: authUrl 为空 requestId=%s", res.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EnterpriseAuthResult{
|
||||||
|
AuthFlowID: clientCorpID,
|
||||||
|
AuthURL: data.AuthURL,
|
||||||
|
AuthShortURL: data.AuthURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryOrgVerified 查询企业是否已在法大大完成实名(POST /corp/get-identified-status)
|
||||||
|
func (c *Client) QueryOrgVerified(req *QueryOrgIdentityRequest) (bool, error) {
|
||||||
|
result, err := c.QueryOrgIdentity(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return result.Identified, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryOrgIdentity 查询企业实名认证状态详情
|
||||||
|
func (c *Client) QueryOrgIdentity(req *QueryOrgIdentityRequest) (*QueryOrgIdentityResult, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("查询请求不能为空")
|
||||||
|
}
|
||||||
|
corpName := strings.TrimSpace(req.CorpName)
|
||||||
|
corpIdentNo := strings.TrimSpace(req.CorpIdentNo)
|
||||||
|
if corpName == "" && corpIdentNo == "" {
|
||||||
|
return nil, fmt.Errorf("企业名称与统一社会信用代码不能同时为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := c.ensureAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := c.http.PostBiz(pathGetCorpIdentifiedStatus, accessToken, &getIdentifiedStatusReq{
|
||||||
|
CorpName: corpName,
|
||||||
|
CorpIdentNo: corpIdentNo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.Code != successCode {
|
||||||
|
return nil, fmt.Errorf("查询法大大企业实名状态失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data getIdentifiedStatusData
|
||||||
|
if err := decodeData(res.Data, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析企业实名状态失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &QueryOrgIdentityResult{
|
||||||
|
Identified: data.IdentStatus,
|
||||||
|
CorpName: corpName,
|
||||||
|
CorpIdentNo: corpIdentNo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateEnterpriseAuthRequest(req *EnterpriseAuthRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return fmt.Errorf("认证请求不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.CompanyName) == "" {
|
||||||
|
return fmt.Errorf("企业名称不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.UnifiedSocialCode) == "" {
|
||||||
|
return fmt.Errorf("统一社会信用代码不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.LegalPersonName) == "" {
|
||||||
|
return fmt.Errorf("法人姓名不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.TransactorName) == "" {
|
||||||
|
return fmt.Errorf("经办人姓名不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.TransactorMobile) == "" {
|
||||||
|
return fmt.Errorf("经办人手机号不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.TransactorID) == "" {
|
||||||
|
return fmt.Errorf("经办人身份证号不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeRedirectURL 按官方文档对 redirectUrl 做 URL encode;已编码则原样返回
|
||||||
|
func encodeRedirectURL(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, "%") {
|
||||||
|
if decoded, err := url.QueryUnescape(raw); err == nil && decoded != raw {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url.QueryEscape(raw)
|
||||||
|
}
|
||||||
35
internal/shared/fadada/corp_service_test.go
Normal file
35
internal/shared/fadada/corp_service_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateEnterpriseAuthRequest(t *testing.T) {
|
||||||
|
err := validateEnterpriseAuthRequest(&EnterpriseAuthRequest{
|
||||||
|
CompanyName: "测试企业有限公司",
|
||||||
|
UnifiedSocialCode: "91110000MA01234567",
|
||||||
|
LegalPersonName: "张三",
|
||||||
|
TransactorName: "张三",
|
||||||
|
TransactorMobile: "13800138000",
|
||||||
|
TransactorID: "110101199001011234",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateEnterpriseAuthRequest(nil); err == nil {
|
||||||
|
t.Fatal("expected error for nil request")
|
||||||
|
}
|
||||||
|
if err := validateEnterpriseAuthRequest(&EnterpriseAuthRequest{}); err == nil {
|
||||||
|
t.Fatal("expected error for empty request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeRedirectURL(t *testing.T) {
|
||||||
|
raw := "http://localhost:5173/profile/certification"
|
||||||
|
encoded := encodeRedirectURL(raw)
|
||||||
|
if encoded == "" || encoded == raw {
|
||||||
|
t.Fatalf("expected url-encoded redirect, got %q", encoded)
|
||||||
|
}
|
||||||
|
if encodeRedirectURL(encoded) != encoded {
|
||||||
|
t.Fatalf("already-encoded url should stay unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/shared/fadada/debug_log.go
Normal file
140
internal/shared/fadada/debug_log.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// debug 日志:将每次法大大 OpenAPI 调用的 cURL 命令 + 响应落盘到 logs/fadada/<date>/fadada.log
|
||||||
|
// 用于排查 /sign-task/create-with-template 等接口的实际请求/响应(如免验证签不生效)。
|
||||||
|
// 敏感字段(AppSecret)不会出现在日志里;AccessToken / Sign 会以明文写入以便复现。
|
||||||
|
|
||||||
|
const (
|
||||||
|
debugLogDir = "logs/fadada"
|
||||||
|
debugLogFile = "fadada.log"
|
||||||
|
debugMaxBytes = 10 * 1024 * 1024 // 单文件 10MB 后截断覆盖(轮转保持简单)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
debugLogMu sync.Mutex
|
||||||
|
debugLogFilePtr *os.File
|
||||||
|
debugLogDate string
|
||||||
|
debugLogSize int64
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeDebugLog 把一次 API 调用的请求与响应追加到当日日志文件。
|
||||||
|
// 调用方需保证 headers / body / respBody 不包含 AppSecret(法大大签名里只有时间戳参与,secret 本身不出现在 header/body 中)。
|
||||||
|
func writeDebugLog(method, fullURL string, headers map[string]string, body string, httpStatus int, respBody string, requestID string) {
|
||||||
|
if strings.TrimSpace(fullURL) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := buildDebugEntry(method, fullURL, headers, body, httpStatus, respBody, requestID)
|
||||||
|
|
||||||
|
debugLogMu.Lock()
|
||||||
|
defer debugLogMu.Unlock()
|
||||||
|
|
||||||
|
if err := openDebugLogFileLocked(); err != nil {
|
||||||
|
// 日志失败不影响业务
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n, _ := debugLogFilePtr.WriteString(entry)
|
||||||
|
debugLogSize += int64(n)
|
||||||
|
if debugLogSize >= debugMaxBytes {
|
||||||
|
_ = debugLogFilePtr.Close()
|
||||||
|
debugLogFilePtr = nil
|
||||||
|
debugLogSize = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDebugLogFileLocked() error {
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
if debugLogFilePtr != nil && debugLogDate == today {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if debugLogFilePtr != nil {
|
||||||
|
_ = debugLogFilePtr.Close()
|
||||||
|
}
|
||||||
|
dateDir := filepath.Join(debugLogDir, today)
|
||||||
|
if err := os.MkdirAll(dateDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
full := filepath.Join(dateDir, debugLogFile)
|
||||||
|
f, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info, err := f.Stat(); err == nil {
|
||||||
|
debugLogSize = info.Size()
|
||||||
|
} else {
|
||||||
|
debugLogSize = 0
|
||||||
|
}
|
||||||
|
debugLogFilePtr = f
|
||||||
|
debugLogDate = today
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDebugEntry 生成一段可读的日志条目(cURL 命令 + 响应)。
|
||||||
|
func buildDebugEntry(method, fullURL string, headers map[string]string, body string, httpStatus int, respBody string, requestID string) string {
|
||||||
|
now := time.Now().Format("2006-01-02 15:04:05.000")
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("\n==================== ")
|
||||||
|
sb.WriteString(now)
|
||||||
|
sb.WriteString(" ====================\n")
|
||||||
|
|
||||||
|
// ---- cURL ----
|
||||||
|
sb.WriteString("curl -sS -X ")
|
||||||
|
sb.WriteString(strings.ToUpper(strings.TrimSpace(method)))
|
||||||
|
sb.WriteString(" '")
|
||||||
|
sb.WriteString(fullURL)
|
||||||
|
sb.WriteString("'")
|
||||||
|
for _, k := range sortedHeaderKeys(headers) {
|
||||||
|
sb.WriteString(" \\\n -H '")
|
||||||
|
sb.WriteString(k)
|
||||||
|
sb.WriteString(": ")
|
||||||
|
sb.WriteString(headers[k])
|
||||||
|
sb.WriteString("'")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(body) != "" {
|
||||||
|
sb.WriteString(" \\\n --data-urlencode 'bizContent=")
|
||||||
|
sb.WriteString(body)
|
||||||
|
sb.WriteString("'")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// ---- RESPONSE ----
|
||||||
|
sb.WriteString("--- RESPONSE ---\n")
|
||||||
|
sb.WriteString("HTTP ")
|
||||||
|
sb.WriteString(fmt.Sprintf("%d", httpStatus))
|
||||||
|
if requestID != "" {
|
||||||
|
sb.WriteString(" requestId=")
|
||||||
|
sb.WriteString(requestID)
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString("body: ")
|
||||||
|
sb.WriteString(respBody)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedHeaderKeys(headers map[string]string) []string {
|
||||||
|
keys := make([]string, 0, len(headers))
|
||||||
|
for k := range headers {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
// 简单冒泡,依赖少
|
||||||
|
for i := 0; i < len(keys); i++ {
|
||||||
|
for j := i + 1; j < len(keys); j++ {
|
||||||
|
if keys[i] > keys[j] {
|
||||||
|
keys[i], keys[j] = keys[j], keys[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
187
internal/shared/fadada/http.go
Normal file
187
internal/shared/fadada/http.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPClient 法大大 OpenAPI HTTP 客户端(不依赖官方 SDK)
|
||||||
|
type HTTPClient struct {
|
||||||
|
config *Config
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPClient(config *Config) *HTTPClient {
|
||||||
|
return &HTTPClient{
|
||||||
|
config: config,
|
||||||
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiResponse 通用响应外壳
|
||||||
|
type apiResponse struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
RequestID string `json:"requestId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostBiz 发送业务 API:POST form bizContent=<JSON>,带 AccessToken 签名
|
||||||
|
func (h *HTTPClient) PostBiz(path, accessToken string, biz any) (*apiResponse, error) {
|
||||||
|
bizJSON, err := marshalBizContent(biz)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
headers := h.buildBizHeaders(accessToken, bizJSON)
|
||||||
|
return h.doPost(path, headers, bizJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostToken 获取 accessToken:无 AccessToken,带 Grant-Type
|
||||||
|
func (h *HTTPClient) PostToken(path string) (*apiResponse, error) {
|
||||||
|
headers := h.buildTokenHeaders()
|
||||||
|
return h.doPost(path, headers, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPClient) doPost(path string, headers map[string]string, bizJSON string) (*apiResponse, error) {
|
||||||
|
fullURL := joinURL(h.config.ServerURL, path)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("bizContent", bizJSON)
|
||||||
|
body := form.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, fullURL, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建法大大请求失败: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// 网络错误也记录一次,便于排查连接问题
|
||||||
|
writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, -1, fmt.Sprintf("HTTP ERROR: %v", err), "")
|
||||||
|
return nil, fmt.Errorf("调用法大大接口失败 %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, resp.StatusCode, fmt.Sprintf("READ BODY ERROR: %v", err), "")
|
||||||
|
return nil, fmt.Errorf("读取法大大响应失败 %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := ""
|
||||||
|
if vals := resp.Header.Values("X-FASC-Request-Id"); len(vals) > 0 {
|
||||||
|
requestID = vals[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 落盘 cURL + 响应(含 X-FASC-* 头与 bizContent),便于排查免验证签等问题
|
||||||
|
writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, resp.StatusCode, string(respBody), requestID)
|
||||||
|
|
||||||
|
var out apiResponse
|
||||||
|
if err := json.Unmarshal(respBody, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析法大大响应失败 %s: %w body=%s", path, err, truncate(string(respBody), 512))
|
||||||
|
}
|
||||||
|
if out.RequestID == "" {
|
||||||
|
out.RequestID = requestID
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPClient) buildTokenHeaders() map[string]string {
|
||||||
|
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||||
|
nonce := randomNonce()
|
||||||
|
headMap := map[string]string{
|
||||||
|
"X-FASC-App-Id": h.config.AppID,
|
||||||
|
"X-FASC-Sign-Type": signTypeHMACSHA256,
|
||||||
|
"X-FASC-Timestamp": timestamp,
|
||||||
|
"X-FASC-Nonce": nonce,
|
||||||
|
"X-FASC-Grant-Type": "client_credential",
|
||||||
|
"X-FASC-Api-SubVersion": apiSubVersion,
|
||||||
|
}
|
||||||
|
headMap["X-FASC-Sign"] = SignByMap(headMap, timestamp, h.config.AppSecret)
|
||||||
|
return headMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPClient) buildBizHeaders(accessToken, bizJSON string) map[string]string {
|
||||||
|
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||||
|
nonce := randomNonce()
|
||||||
|
headMap := map[string]string{
|
||||||
|
"X-FASC-App-Id": h.config.AppID,
|
||||||
|
"X-FASC-Sign-Type": signTypeHMACSHA256,
|
||||||
|
"X-FASC-Timestamp": timestamp,
|
||||||
|
"X-FASC-Nonce": nonce,
|
||||||
|
"X-FASC-AccessToken": accessToken,
|
||||||
|
"X-FASC-Api-SubVersion": apiSubVersion,
|
||||||
|
"bizContent": bizJSON,
|
||||||
|
}
|
||||||
|
sign := SignByMap(headMap, timestamp, h.config.AppSecret)
|
||||||
|
delete(headMap, "bizContent")
|
||||||
|
headMap["X-FASC-Sign"] = sign
|
||||||
|
return headMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalBizContent(biz any) (string, error) {
|
||||||
|
if biz == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
switch v := biz.(type) {
|
||||||
|
case string:
|
||||||
|
return v, nil
|
||||||
|
case []byte:
|
||||||
|
return string(v), nil
|
||||||
|
default:
|
||||||
|
b, err := json.Marshal(biz)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("序列化 bizContent 失败: %w", err)
|
||||||
|
}
|
||||||
|
// 紧凑 JSON,避免多余空格影响签名
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := json.Compact(&buf, b); err == nil {
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinURL(base, path string) string {
|
||||||
|
base = strings.TrimRight(base, "/")
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
return base + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomNonce() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeData 将 apiResponse.Data 反序列化到目标结构
|
||||||
|
func decodeData(raw json.RawMessage, dest any) error {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(raw, dest)
|
||||||
|
}
|
||||||
57
internal/shared/fadada/sign.go
Normal file
57
internal/shared/fadada/sign.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
signTypeHMACSHA256 = "HMAC-SHA256"
|
||||||
|
apiSubVersion = "5.1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SignByMap 法大大 HMAC-SHA256 签名(与官方 SDK 算法一致)
|
||||||
|
// 1) 对 map key 字典序排序,拼接 key=value&...(跳过空值)
|
||||||
|
// 2) signText = hex(SHA256(signContent))
|
||||||
|
// 3) signKey = HMAC-SHA256(timestamp, appSecret) 原始字节
|
||||||
|
// 4) signature = lowercase(hex(HMAC-SHA256(signText, signKey)))
|
||||||
|
func SignByMap(headMap map[string]string, timestamp, appSecret string) string {
|
||||||
|
signContent := sortMapForSign(headMap)
|
||||||
|
signText := sha256Hex(signContent)
|
||||||
|
secretSigning := hmacSHA256Bytes(timestamp, []byte(appSecret))
|
||||||
|
return strings.ToLower(hmacSHA256Hex(signText, secretSigning))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortMapForSign(headMap map[string]string) string {
|
||||||
|
keys := make([]string, 0, len(headMap))
|
||||||
|
for k := range headMap {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
if v := headMap[k]; v != "" {
|
||||||
|
parts = append(parts, k+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Hex(src string) string {
|
||||||
|
sum := sha256.Sum256([]byte(src))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func hmacSHA256Bytes(data string, secret []byte) []byte {
|
||||||
|
h := hmac.New(sha256.New, secret)
|
||||||
|
h.Write([]byte(data))
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hmacSHA256Hex(data string, secret []byte) string {
|
||||||
|
return hex.EncodeToString(hmacSHA256Bytes(data, secret))
|
||||||
|
}
|
||||||
435
internal/shared/fadada/signtask_service.go
Normal file
435
internal/shared/fadada/signtask_service.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
74
internal/shared/fadada/signtask_service_test.go
Normal file
74
internal/shared/fadada/signtask_service_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
262
internal/shared/fadada/template_service.go
Normal file
262
internal/shared/fadada/template_service.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FillTemplate 填写文档模板并生成合同文件(POST /doc-template/fill-values)
|
||||||
|
func (c *Client) FillTemplate(req *ContractFillRequest) (*ContractFileResult, error) {
|
||||||
|
if err := c.config.ValidateTemplateFields(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateContractFillRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := c.ensureAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
signDate := strings.TrimSpace(req.SignDate)
|
||||||
|
if signDate == "" {
|
||||||
|
signDate = time.Now().Format("2006年01月02日")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := strings.TrimSpace(req.FileName)
|
||||||
|
if fileName == "" {
|
||||||
|
if c.config.Contract != nil && c.config.Contract.Name != "" {
|
||||||
|
fileName = c.config.Contract.Name
|
||||||
|
} else {
|
||||||
|
fileName = "海宇数据-合作协议"
|
||||||
|
}
|
||||||
|
fileName = fmt.Sprintf("%s-%s", fileName, strings.TrimSpace(req.AgreementNo))
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldValues := c.BuildTemplateFieldValues(&ContractFillRequest{
|
||||||
|
AgreementNo: req.AgreementNo,
|
||||||
|
CompanyName: req.CompanyName,
|
||||||
|
UnifiedSocialCode: req.UnifiedSocialCode,
|
||||||
|
EnterpriseAddress: req.EnterpriseAddress,
|
||||||
|
AuthorizedRepName: req.AuthorizedRepName,
|
||||||
|
SignDate: signDate,
|
||||||
|
})
|
||||||
|
|
||||||
|
docFieldValues := make([]docFieldValue, 0, len(fieldValues))
|
||||||
|
for _, fv := range fieldValues {
|
||||||
|
docFieldValues = append(docFieldValues, docFieldValue{
|
||||||
|
FieldID: fv.FieldID,
|
||||||
|
FieldValue: fv.FieldValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := c.http.PostBiz(pathFillDocTemplateValues, accessToken, &fillDocTemplateReq{
|
||||||
|
OwnerID: &openID{IDType: "corp", OpenID: c.config.OpenCorpID},
|
||||||
|
DocTemplateID: c.config.TemplateID,
|
||||||
|
FileName: fileName,
|
||||||
|
DocFieldValues: docFieldValues,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.Code != successCode {
|
||||||
|
return nil, fmt.Errorf("法大大填写模板失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data fillDocTemplateData
|
||||||
|
if err := decodeData(res.Data, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析模板填单响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if data.FileID == "" {
|
||||||
|
return nil, fmt.Errorf("法大大填写模板失败: fileId 为空 requestId=%s", res.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ContractFileResult{
|
||||||
|
FileID: data.FileID,
|
||||||
|
FileDownloadURL: data.FileDownloadURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FillSignTaskFields 向已创建的签署任务填写控件(POST /sign-task/field/fill-values)
|
||||||
|
func (c *Client) FillSignTaskFields(signTaskID string, req *ContractFillRequest) error {
|
||||||
|
if strings.TrimSpace(signTaskID) == "" {
|
||||||
|
return fmt.Errorf("signTaskId 不能为空")
|
||||||
|
}
|
||||||
|
if err := c.config.ValidateTemplateFields(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateContractFillRequest(req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := c.ensureAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
docID, err := c.resolveSignTaskDocID(signTaskID, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
signDate := strings.TrimSpace(req.SignDate)
|
||||||
|
if signDate == "" {
|
||||||
|
signDate = time.Now().Format("2006年01月02日")
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldValues := c.BuildTemplateFieldValues(&ContractFillRequest{
|
||||||
|
AgreementNo: req.AgreementNo,
|
||||||
|
CompanyName: req.CompanyName,
|
||||||
|
UnifiedSocialCode: req.UnifiedSocialCode,
|
||||||
|
EnterpriseAddress: req.EnterpriseAddress,
|
||||||
|
AuthorizedRepName: req.AuthorizedRepName,
|
||||||
|
SignDate: signDate,
|
||||||
|
})
|
||||||
|
|
||||||
|
docFieldValues := make([]docFieldValue, 0, len(fieldValues))
|
||||||
|
for _, fv := range fieldValues {
|
||||||
|
docFieldValues = append(docFieldValues, docFieldValue{
|
||||||
|
DocID: docID,
|
||||||
|
FieldDocID: docID,
|
||||||
|
FieldID: fv.FieldID,
|
||||||
|
FieldValue: fv.FieldValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := c.http.PostBiz(pathFillSignTaskFieldValues, accessToken, &fillSignTaskFieldsReq{
|
||||||
|
SignTaskID: signTaskID,
|
||||||
|
DocFieldValues: docFieldValues,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.Code != successCode {
|
||||||
|
return fmt.Errorf("法大大签署任务填单失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSignTaskDocID 解析签署任务文档 docId(填单必填)
|
||||||
|
func (c *Client) resolveSignTaskDocID(signTaskID, accessToken string) (string, error) {
|
||||||
|
if c.config != nil {
|
||||||
|
if id := strings.TrimSpace(c.config.TemplateDocID); id != "" {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := c.http.PostBiz(pathGetSignTaskDetail, accessToken, &getSignTaskDetailReq{
|
||||||
|
SignTaskID: signTaskID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("查询签署任务文档失败: %w", err)
|
||||||
|
}
|
||||||
|
if res.Code != successCode {
|
||||||
|
return "", fmt.Errorf("查询签署任务文档失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data getSignTaskDetailData
|
||||||
|
if err := decodeData(res.Data, &data); err != nil {
|
||||||
|
return "", fmt.Errorf("解析签署任务文档失败: %w", err)
|
||||||
|
}
|
||||||
|
for _, d := range data.Docs {
|
||||||
|
if id := strings.TrimSpace(d.DocID); id != "" {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("签署任务未返回 docId,请在配置 fadada.template_doc_id 中填写模板文档ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTemplateFieldValues 将业务字段映射为法大大控件编码/值
|
||||||
|
func (c *Client) BuildTemplateFieldValues(req *ContractFillRequest) []TemplateFieldValue {
|
||||||
|
fields := c.config.TemplateFields
|
||||||
|
if fields == nil || req == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
signDate := strings.TrimSpace(req.SignDate)
|
||||||
|
agreementNo := strings.TrimSpace(req.AgreementNo)
|
||||||
|
companyName := strings.TrimSpace(req.CompanyName)
|
||||||
|
|
||||||
|
pairs := make([]struct {
|
||||||
|
fieldID string
|
||||||
|
value string
|
||||||
|
}, 0, len(fields.AgreementNo)+len(fields.PartyAName)+6)
|
||||||
|
for _, id := range fields.AgreementNo {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pairs = append(pairs, struct {
|
||||||
|
fieldID string
|
||||||
|
value string
|
||||||
|
}{id, agreementNo})
|
||||||
|
}
|
||||||
|
for _, id := range fields.PartyAName {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pairs = append(pairs, struct {
|
||||||
|
fieldID string
|
||||||
|
value string
|
||||||
|
}{id, companyName})
|
||||||
|
}
|
||||||
|
pairs = append(pairs,
|
||||||
|
struct {
|
||||||
|
fieldID string
|
||||||
|
value string
|
||||||
|
}{fields.ContractDate, signDate},
|
||||||
|
struct {
|
||||||
|
fieldID string
|
||||||
|
value string
|
||||||
|
}{fields.PartyAUSCC, strings.TrimSpace(req.UnifiedSocialCode)},
|
||||||
|
struct {
|
||||||
|
fieldID string
|
||||||
|
value string
|
||||||
|
}{fields.PartyAAddress, strings.TrimSpace(req.EnterpriseAddress)},
|
||||||
|
struct {
|
||||||
|
fieldID string
|
||||||
|
value string
|
||||||
|
}{fields.PartyARep, strings.TrimSpace(req.AuthorizedRepName)},
|
||||||
|
struct {
|
||||||
|
fieldID string
|
||||||
|
value string
|
||||||
|
}{fields.PartyASignDate, signDate},
|
||||||
|
struct {
|
||||||
|
fieldID string
|
||||||
|
value string
|
||||||
|
}{fields.PartyBSignDate, signDate},
|
||||||
|
)
|
||||||
|
|
||||||
|
out := make([]TemplateFieldValue, 0, len(pairs))
|
||||||
|
for _, p := range pairs {
|
||||||
|
if p.fieldID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, TemplateFieldValue{
|
||||||
|
FieldID: p.fieldID,
|
||||||
|
FieldValue: p.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateContractFillRequest(req *ContractFillRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return fmt.Errorf("合同填单请求不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.AgreementNo) == "" {
|
||||||
|
return fmt.Errorf("协议编号不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.CompanyName) == "" {
|
||||||
|
return fmt.Errorf("甲方企业名不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.UnifiedSocialCode) == "" {
|
||||||
|
return fmt.Errorf("甲方统一信用代码不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.AuthorizedRepName) == "" {
|
||||||
|
return fmt.Errorf("甲方授权代表不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
104
internal/shared/fadada/template_service_test.go
Normal file
104
internal/shared/fadada/template_service_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildTemplateFieldValues(t *testing.T) {
|
||||||
|
client := NewClient(&Config{
|
||||||
|
AppID: "app",
|
||||||
|
AppSecret: "secret",
|
||||||
|
ServerURL: "https://uat-api.fadada.com/api/v5/",
|
||||||
|
OpenCorpID: "corp",
|
||||||
|
TemplateID: "1784013372537192273",
|
||||||
|
TemplateFields: &TemplateFields{
|
||||||
|
AgreementNo: []string{
|
||||||
|
"5199991781",
|
||||||
|
"0923405093",
|
||||||
|
},
|
||||||
|
ContractDate: "2039326319",
|
||||||
|
PartyAName: []string{
|
||||||
|
"6920634255",
|
||||||
|
"1847502631",
|
||||||
|
},
|
||||||
|
PartyAUSCC: "8702060291",
|
||||||
|
PartyAAddress: "4557539440",
|
||||||
|
PartyARep: "8627432823",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
values := client.BuildTemplateFieldValues(&ContractFillRequest{
|
||||||
|
AgreementNo: "HYDATA-20260714-R000001",
|
||||||
|
CompanyName: "测试企业有限公司",
|
||||||
|
UnifiedSocialCode: "91110000MA01234567",
|
||||||
|
EnterpriseAddress: "海南省海口市",
|
||||||
|
AuthorizedRepName: "张三",
|
||||||
|
SignDate: "2026年07月14日",
|
||||||
|
})
|
||||||
|
|
||||||
|
want := map[string]string{
|
||||||
|
"5199991781": "HYDATA-20260714-R000001",
|
||||||
|
"0923405093": "HYDATA-20260714-R000001",
|
||||||
|
"6920634255": "测试企业有限公司",
|
||||||
|
"1847502631": "测试企业有限公司",
|
||||||
|
"2039326319": "2026年07月14日",
|
||||||
|
"8702060291": "91110000MA01234567",
|
||||||
|
"4557539440": "海南省海口市",
|
||||||
|
"8627432823": "张三",
|
||||||
|
}
|
||||||
|
if len(values) != len(want) {
|
||||||
|
t.Fatalf("field count = %d, want %d", len(values), len(want))
|
||||||
|
}
|
||||||
|
got := make(map[string]string, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
got[v.FieldID] = v.FieldValue
|
||||||
|
}
|
||||||
|
for id, val := range want {
|
||||||
|
if got[id] != val {
|
||||||
|
t.Fatalf("field %s = %q, want %q", id, got[id], val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSignTaskDocIDFromConfig(t *testing.T) {
|
||||||
|
c := NewClient(&Config{
|
||||||
|
AppID: "app",
|
||||||
|
AppSecret: "secret",
|
||||||
|
ServerURL: "https://uat-api.fadada.com/api/v5/",
|
||||||
|
TemplateDocID: "19161471",
|
||||||
|
})
|
||||||
|
id, err := c.resolveSignTaskDocID("task-1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id != "19161471" {
|
||||||
|
t.Fatalf("docId=%s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTemplateFields(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
TemplateID: "1784013372537192273",
|
||||||
|
OpenCorpID: "corp",
|
||||||
|
TemplateFields: &TemplateFields{
|
||||||
|
AgreementNo: []string{"0923405093", "5199991781"},
|
||||||
|
ContractDate: "2039326319",
|
||||||
|
PartyAName: []string{"6920634255", "1847502631"},
|
||||||
|
PartyAUSCC: "8702060291",
|
||||||
|
PartyAAddress: "4557539440",
|
||||||
|
PartyARep: "8627432823",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := cfg.ValidateTemplateFields(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
cfg.TemplateFields.PartyAName = nil
|
||||||
|
if err := cfg.ValidateTemplateFields(); err == nil {
|
||||||
|
t.Fatal("expected error when party_a_name empty")
|
||||||
|
}
|
||||||
|
cfg.TemplateFields.PartyAName = []string{"6920634255"}
|
||||||
|
cfg.TemplateFields.AgreementNo = nil
|
||||||
|
if err := cfg.ValidateTemplateFields(); err == nil {
|
||||||
|
t.Fatal("expected error when agreement_no empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
71
internal/shared/fadada/token.go
Normal file
71
internal/shared/fadada/token.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tokenCache accessToken 内存缓存(官方有效期约 2 小时)
|
||||||
|
type tokenCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
token string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenCache) get() (string, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.token == "" || time.Now().After(c.expiresAt) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return c.token, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tokenCache) set(token string, ttl time.Duration) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.token = token
|
||||||
|
c.expiresAt = time.Now().Add(ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureAccessToken 获取可用 accessToken,过期前自动刷新
|
||||||
|
func (c *Client) ensureAccessToken() (string, error) {
|
||||||
|
if token, ok := c.tokenCache.get(); ok {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.tokenMu.Lock()
|
||||||
|
defer c.tokenMu.Unlock()
|
||||||
|
|
||||||
|
if token, ok := c.tokenCache.get(); ok {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := c.http.PostToken(pathGetAccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if res.Code != "" && res.Code != successCode {
|
||||||
|
return "", fmt.Errorf("获取法大大 accessToken 失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data accessTokenData
|
||||||
|
if err := decodeData(res.Data, &data); err != nil {
|
||||||
|
return "", fmt.Errorf("解析 accessToken 失败: %w", err)
|
||||||
|
}
|
||||||
|
if data.AccessToken == "" {
|
||||||
|
return "", fmt.Errorf("获取法大大 accessToken 失败: 响应为空 code=%s msg=%s", res.Code, res.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := 2*time.Hour - 5*time.Minute
|
||||||
|
if expiresIn, err := strconv.Atoi(data.ExpiresIn); err == nil && expiresIn > 0 {
|
||||||
|
ttl = time.Duration(expiresIn)*time.Second - 5*time.Minute
|
||||||
|
if ttl < time.Minute {
|
||||||
|
ttl = time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.tokenCache.set(data.AccessToken, ttl)
|
||||||
|
return data.AccessToken, nil
|
||||||
|
}
|
||||||
167
internal/shared/fadada/types.go
Normal file
167
internal/shared/fadada/types.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package fadada
|
||||||
|
|
||||||
|
// EnterpriseAuthRequest 企业认证请求(与 e签宝 字段对齐,便于后续统一调用)
|
||||||
|
type EnterpriseAuthRequest struct {
|
||||||
|
// ClientCorpID 企业在应用内的唯一标识;为空时默认使用统一社会信用代码
|
||||||
|
ClientCorpID string `json:"clientCorpId,omitempty"`
|
||||||
|
// ClientUserID 经办人在应用内的唯一标识;为空时默认使用经办人身份证号
|
||||||
|
ClientUserID string `json:"clientUserId,omitempty"`
|
||||||
|
|
||||||
|
CompanyName string `json:"companyName"`
|
||||||
|
UnifiedSocialCode string `json:"unifiedSocialCode"`
|
||||||
|
LegalPersonName string `json:"legalPersonName"`
|
||||||
|
LegalPersonID string `json:"legalPersonId"`
|
||||||
|
|
||||||
|
TransactorName string `json:"transactorName"`
|
||||||
|
TransactorMobile string `json:"transactorMobile"`
|
||||||
|
TransactorID string `json:"transactorId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnterpriseAuthResult 企业认证结果
|
||||||
|
type EnterpriseAuthResult struct {
|
||||||
|
// AuthFlowID 映射为法大大 clientCorpId,便于后续按企业标识查询
|
||||||
|
AuthFlowID string `json:"authFlowId"`
|
||||||
|
AuthURL string `json:"authUrl"`
|
||||||
|
AuthShortURL string `json:"authShortUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryOrgIdentityRequest 查询企业是否已实名
|
||||||
|
type QueryOrgIdentityRequest struct {
|
||||||
|
CorpName string `json:"corpName"`
|
||||||
|
CorpIdentNo string `json:"corpIdentNo"` // 统一社会信用代码
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryOrgIdentityResult 企业实名查询结果
|
||||||
|
type QueryOrgIdentityResult struct {
|
||||||
|
Identified bool `json:"identified"`
|
||||||
|
CorpName string `json:"corpName"`
|
||||||
|
CorpIdentNo string `json:"corpIdentNo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContractFillRequest 合作协议模板填单请求(对齐 e签宝 generateAndAddContractFile 入参)
|
||||||
|
type ContractFillRequest struct {
|
||||||
|
AgreementNo string `json:"agreementNo"` // 协议编号
|
||||||
|
CompanyName string `json:"companyName"` // 甲方企业名
|
||||||
|
UnifiedSocialCode string `json:"unifiedSocialCode"` // 甲方统一信用代码
|
||||||
|
EnterpriseAddress string `json:"enterpriseAddress"` // 甲方联系地址
|
||||||
|
AuthorizedRepName string `json:"authorizedRepName"` // 甲方授权代表/法人
|
||||||
|
// SignDate 签署/签订日期,建议格式:2006年01月02日;为空则由调用方保证或使用当天
|
||||||
|
SignDate string `json:"signDate"`
|
||||||
|
FileName string `json:"fileName,omitempty"` // 生成文件名,可选
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContractFileResult 模板填单结果(对齐 e签宝 FillTemplate 返回)
|
||||||
|
type ContractFileResult struct {
|
||||||
|
FileID string `json:"fileId"`
|
||||||
|
FileDownloadURL string `json:"fileDownloadUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateFieldValue 单个控件填写值
|
||||||
|
type TemplateFieldValue struct {
|
||||||
|
FieldID string `json:"fieldId"`
|
||||||
|
FieldValue string `json:"fieldValue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签署任务参与方 ActorId
|
||||||
|
// 基于签署模板创建时,必须与模板中「签署角色」名称完全一致。
|
||||||
|
const (
|
||||||
|
ActorIDPartyA = "甲方" // 甲方(企业入驻方)
|
||||||
|
ActorIDPartyB = "乙方" // 乙方(平台海宇)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 签署任务状态(法大大 signTaskStatus)
|
||||||
|
const (
|
||||||
|
SignTaskStatusCreated = "task_created"
|
||||||
|
SignTaskStatusFillProgress = "fill_progress"
|
||||||
|
SignTaskStatusFillCompleted = "fill_completed"
|
||||||
|
SignTaskStatusSignProgress = "sign_progress"
|
||||||
|
SignTaskStatusSignCompleted = "sign_completed"
|
||||||
|
SignTaskStatusFinished = "task_finished"
|
||||||
|
SignTaskStatusTerminated = "task_terminated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateSignFlowRequest 基于签署任务模板创建签署任务请求
|
||||||
|
type CreateSignFlowRequest struct {
|
||||||
|
// Subject 签署任务主题;为空则用合同名称
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
|
||||||
|
// PartyAOpenCorpID 甲方在法大大侧 openCorpId(企业认证授权完成后获得)
|
||||||
|
PartyAOpenCorpID string `json:"partyAOpenCorpId,omitempty"`
|
||||||
|
PartyAName string `json:"partyAName"`
|
||||||
|
PartyAUSCC string `json:"partyAUscc,omitempty"`
|
||||||
|
|
||||||
|
TransactorName string `json:"transactorName,omitempty"`
|
||||||
|
TransactorMobile string `json:"transactorMobile,omitempty"`
|
||||||
|
TransactorID string `json:"transactorId,omitempty"`
|
||||||
|
|
||||||
|
// TransReferenceID 业务侧关联号(如认证记录 ID)
|
||||||
|
TransReferenceID string `json:"transReferenceId,omitempty"`
|
||||||
|
// Fill 创建模板任务后立即填写控件;非空时创建不 autoStart,填完后自动 Start
|
||||||
|
Fill *ContractFillRequest `json:"fill,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSignFlowResult 创建签署任务结果
|
||||||
|
type CreateSignFlowResult struct {
|
||||||
|
SignTaskID string `json:"signTaskId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignURLResult 参与方签署链接
|
||||||
|
type SignURLResult struct {
|
||||||
|
SignURL string `json:"signUrl"`
|
||||||
|
EmbedURL string `json:"embedUrl,omitempty"`
|
||||||
|
ActorID string `json:"actorId"`
|
||||||
|
SignTaskID string `json:"signTaskId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignTaskPreviewURLResult 签署任务预览链接(EUI,非 PDF 直链)
|
||||||
|
type SignTaskPreviewURLResult struct {
|
||||||
|
SignTaskID string `json:"signTaskId"`
|
||||||
|
PreviewURL string `json:"previewUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignStatusResult 签署状态查询结果
|
||||||
|
type SignStatusResult struct {
|
||||||
|
SignTaskID string `json:"signTaskId"`
|
||||||
|
SignTaskStatus string `json:"signTaskStatus"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
Terminated bool `json:"terminated"`
|
||||||
|
FinishTime string `json:"finishTime,omitempty"`
|
||||||
|
TerminationNote string `json:"terminationNote,omitempty"`
|
||||||
|
RevokeReason string `json:"revokeReason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedFile 已签署文件下载信息
|
||||||
|
type SignedFile struct {
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
DownloadID string `json:"downloadId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadSignedFilesResult 下载结果
|
||||||
|
type DownloadSignedFilesResult struct {
|
||||||
|
Files []*SignedFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallbackEvent 法大大回调事件(认证/签署)
|
||||||
|
type CallbackEvent struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
SignTaskID string `json:"signTaskId,omitempty"`
|
||||||
|
SignTaskStatus string `json:"signTaskStatus,omitempty"`
|
||||||
|
ClientCorpID string `json:"clientCorpId,omitempty"`
|
||||||
|
OpenCorpID string `json:"openCorpId,omitempty"`
|
||||||
|
AuthResult string `json:"authResult,omitempty"`
|
||||||
|
EventTime string `json:"eventTime,omitempty"`
|
||||||
|
Raw map[string]interface{} `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContractSigningRequest 一站式:填单 → 创建签署 → 取甲方链接
|
||||||
|
type ContractSigningRequest struct {
|
||||||
|
CreateSignFlowRequest
|
||||||
|
Fill ContractFillRequest `json:"fill"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContractSigningResult 一站式签署结果
|
||||||
|
type ContractSigningResult struct {
|
||||||
|
SignTaskID string `json:"signTaskId"`
|
||||||
|
SignURL string `json:"signUrl"`
|
||||||
|
EmbedURL string `json:"embedUrl,omitempty"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user