You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
240 lines
10 KiB
240 lines
10 KiB
package idverify
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// 创蓝云智(253)OM2.0 - 身份证二要素核验
|
|
//
|
|
// 接口 POST https://wsauth.253.com/api/v2/auth/idcard/id-card-auth
|
|
// 编码 application/json,UTF-8
|
|
// 鉴权 **走请求头**,不是 body:
|
|
// AppID 控制台 → 账号中心 → API Key
|
|
// Nonce 随机数(最大 128 字符)
|
|
// CurTime 当前 UTC 时间戳(秒)
|
|
// CheckSum SHA1(AppSecret + Nonce + CurTime),十六进制小写
|
|
// body 只有 name(姓名) / idNum(身份证号)
|
|
// 出参 code("000000" 成功,OM 标准码)/ msg / chargeStatus / chargeCount / requestId / data.result
|
|
// data.result:01=一致(收费) 02=不一致(收费) 03=认证不确定(不收费) 04=认证失败(不收费)
|
|
//
|
|
// ⚠️ OM2.0 的成功码是 **"000000"**,老版是 "200000";说明字段是 **msg**,老版是 message。
|
|
// 沿用老版那两个常量会让**每一次成功核验都被判成调用失败**,且日志里 message 恒为空。
|
|
//
|
|
// ⚠️ 别接成老版的 `/open/idcard/id-card-auth`(form-urlencoded、凭据放 body)——
|
|
// 那套已废弃,拿 OM2.0 的 AppID 去打会回 500902「用户不存在」,
|
|
// 看起来像凭据配错,实则是接口版本用错了。
|
|
//
|
|
// ⚠️ 与阿里/腾讯不同,创蓝把「不确定」「失败」也放在 data.result 里作为业务结果码返回,
|
|
// 且此时 code 仍是 200000。**03/04 绝不能当成"不一致"**——那是查无结论,
|
|
// 按不一致处理会让用户看到"您填的信息有误",而实际上只是上游库没查到或临时故障。
|
|
// 这两种情况一律返回 error,走「服务暂时不可用」的口径。
|
|
// 服务商自己也是这么划的:03/04 的 chargeStatus=0(不收费),只有 01/02 才计费。
|
|
const (
|
|
chuanglanDefaultURL = "https://wsauth.253.com/api/v2/auth/idcard/id-card-auth"
|
|
|
|
chuanglanCodeOK = "000000" // 调用成功(业务结论看 data.result)——OM2.0 标准码,不是老版的 200000
|
|
// 120001 CheckSum 校验失败:AppSecret 配错,或本机时钟与标准时间偏差超过 5 分钟。
|
|
chuanglanCodeBadCheckSum = "120001"
|
|
// 500902「用户不存在」= AppID 不被识别(填错、或用在了错误的接口版本上)。
|
|
chuanglanCodeUserNotFound = "500902"
|
|
// 190004 参数校验异常:姓名/身份证号不合规(姓名须为中文汉字 1~30 字符、不能以间隔符开头结尾)。
|
|
// 这是**我们传错了参数**,不是服务故障,得让排查的人一眼分清。
|
|
chuanglanCodeInvalidParam = "190004"
|
|
|
|
chuanglanResultMatched = "01" // 一致(收费)
|
|
chuanglanResultMismatch = "02" // 不一致(收费)
|
|
chuanglanResultUnknown = "03" // 认证不确定(不收费)
|
|
chuanglanResultFailed = "04" // 认证失败(不收费)
|
|
)
|
|
|
|
type chuanglanVerifier struct {
|
|
appID string
|
|
appSecret string
|
|
url string
|
|
cli *http.Client
|
|
}
|
|
|
|
// chuanglanResp 只取需要的字段;data 里的省市/生日/年龄等一律不接,
|
|
// 避免无谓地把个人信息带进进程——本服务只关心「一致与否」。
|
|
type chuanglanResp struct {
|
|
Code string `json:"code"`
|
|
Msg string `json:"msg"` // OM2.0 是 msg,不是老版的 message
|
|
ChargeStatus int `json:"chargeStatus"` // 1=收费 0=不收费
|
|
ChargeCount int `json:"chargeCount"` // 计费条数
|
|
RequestId string `json:"requestId"` // 订单号,对账用
|
|
Data struct {
|
|
OrderNo string `json:"orderNo"`
|
|
Result string `json:"result"`
|
|
Remark string `json:"remark"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
func newChuanglan(fields map[string]string) (Verifier, error) {
|
|
if err := requireFields(fields, "appid", "appkey"); err != nil {
|
|
return nil, err
|
|
}
|
|
endpoint := field(fields, "url")
|
|
if endpoint == "" {
|
|
endpoint = chuanglanDefaultURL
|
|
}
|
|
return &chuanglanVerifier{
|
|
appID: field(fields, "appid"),
|
|
// 后台字段沿用 appkey 这个键名(存量配置已经在用),值就是 OM2.0 的 AppSecret。
|
|
appSecret: field(fields, "appkey"),
|
|
url: endpoint,
|
|
cli: &http.Client{Timeout: 10 * time.Second},
|
|
}, nil
|
|
}
|
|
|
|
func (v *chuanglanVerifier) Provider() string { return ProviderChuanglan }
|
|
|
|
func (v *chuanglanVerifier) Verify(ctx context.Context, realname, idcardno string) (Result, error) {
|
|
payload, err := json.Marshal(map[string]string{
|
|
"name": strings.TrimSpace(realname),
|
|
"idNum": strings.TrimSpace(idcardno),
|
|
})
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("idverify/chuanglan: 构造请求失败: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, v.url, strings.NewReader(string(payload)))
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("idverify/chuanglan: 构造请求失败: %w", err)
|
|
}
|
|
nonce, err := chuanglanNonce()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("idverify/chuanglan: 生成 Nonce 失败: %w", err)
|
|
}
|
|
curTime := strconv.FormatInt(time.Now().Unix(), 10)
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
// ⚠️ 鉴权头**直接写 map,不用 Header.Set**:Set 会做 MIME 规范化,把
|
|
// AppID→Appid、CurTime→Curtime、CheckSum→Checksum。HTTP 头本该大小写无关,
|
|
// 但创蓝网关是按字面匹配的,被改写后它认不出来,回 120301
|
|
// 「request header is missing AppID or incorrect」——看着像 AppID 填错,实则头名没对上。
|
|
// 该接口走 HTTP/1.1,大小写能原样上线;若哪天改走 HTTP/2(强制小写头),这里要另想办法。
|
|
req.Header["AppID"] = []string{v.appID}
|
|
req.Header["Nonce"] = []string{nonce}
|
|
req.Header["CurTime"] = []string{curTime}
|
|
req.Header["CheckSum"] = []string{chuanglanCheckSum(v.appSecret, nonce, curTime)}
|
|
|
|
resp, err := v.cli.Do(req)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("idverify/chuanglan: 调用失败: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("idverify/chuanglan: 读取响应失败: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Result{}, fmt.Errorf("idverify/chuanglan: HTTP %d: %s", resp.StatusCode, truncate(string(raw), 200))
|
|
}
|
|
|
|
res, err := parseChuanglanBody(raw)
|
|
if err != nil {
|
|
// 鉴权类错误光看报错分不清是哪一项配错了,带上脱敏指纹让运维一眼能对上后台里那把。
|
|
// 只打首尾各 2 位和长度,不打明文。
|
|
if hint := chuanglanCredHint(raw); hint != "" {
|
|
return res, fmt.Errorf("%w(%s;本服务当前使用 AppID=%s AppSecret=%s)",
|
|
err, hint, maskCred(v.appID), maskCred(v.appSecret))
|
|
}
|
|
}
|
|
return res, err
|
|
}
|
|
|
|
// chuanglanCredHint 针对鉴权类错误码给出「该查哪里」的提示;非鉴权错误返回空串。
|
|
func chuanglanCredHint(raw []byte) string {
|
|
s := string(raw)
|
|
switch {
|
|
case strings.Contains(s, chuanglanCodeUserNotFound):
|
|
return "AppID 不被创蓝识别:确认后台填的是 OM2.0 控制台「账号中心 → API Key」里的 AppID"
|
|
case strings.Contains(s, chuanglanCodeBadCheckSum):
|
|
return "CheckSum 校验失败:AppSecret 填错,或本机时钟与标准时间偏差超过 5 分钟"
|
|
case strings.Contains(s, chuanglanCodeInvalidParam):
|
|
return "参数校验异常(姓名须为中文汉字 1~30 字符、不能以间隔符开头或结尾)——是入参问题,不是服务故障"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// chuanglanCheckSum 按 OM2.0 规则算签名:SHA1(AppSecret + Nonce + CurTime) 十六进制小写。
|
|
func chuanglanCheckSum(appSecret, nonce, curTime string) string {
|
|
sum := sha1.Sum([]byte(appSecret + nonce + curTime))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// chuanglanNonce 生成随机数(文档限制最长 128 字符,这里取 32 位十六进制)。
|
|
// 用 crypto/rand:Nonce 参与签名,可预测的随机源会让签名可被重放。
|
|
func chuanglanNonce() (string, error) {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
// parseChuanglanBody 把创蓝的响应体解析成 Result。抽出来单测,不用打真接口。
|
|
func parseChuanglanBody(raw []byte) (Result, error) {
|
|
var r chuanglanResp
|
|
if err := json.Unmarshal(raw, &r); err != nil {
|
|
return Result{}, fmt.Errorf("idverify/chuanglan: 响应解析失败: %w (body=%s)", err, truncate(string(raw), 200))
|
|
}
|
|
// code 非 200000 表示调用层面失败(鉴权/参数/余额不足),结论未知——不能当成"不一致"。
|
|
if r.Code != chuanglanCodeOK {
|
|
return Result{}, fmt.Errorf("idverify/chuanglan: 调用返回 code=%s msg=%s chargeStatus=%d requestId=%s",
|
|
r.Code, r.Msg, r.ChargeStatus, r.RequestId)
|
|
}
|
|
|
|
msg := strings.TrimSpace(r.Data.Remark)
|
|
if msg == "" {
|
|
msg = r.Msg
|
|
}
|
|
switch r.Data.Result {
|
|
case chuanglanResultMatched:
|
|
return Result{Matched: true, BizCode: r.Data.Result, Message: msg}, nil
|
|
case chuanglanResultMismatch:
|
|
return Result{Matched: false, BizCode: r.Data.Result, Message: msg}, nil
|
|
case chuanglanResultUnknown, chuanglanResultFailed:
|
|
// 查无结论:上游库没覆盖到,或服务商侧临时失败。既未计费也无结论,
|
|
// 必须当调用失败上报,绝不能退化成"不一致"。
|
|
return Result{}, fmt.Errorf("idverify/chuanglan: 未得出结论 result=%s msg=%s orderNo=%s requestId=%s chargeStatus=%d",
|
|
r.Data.Result, msg, r.Data.OrderNo, r.RequestId, r.ChargeStatus)
|
|
default:
|
|
// 文档只定义了 01..04;出现别的值说明接口有变更,同样按"结论未知"处理。
|
|
// 带上原始响应,省得再为「返回了什么」跑一趟线上。
|
|
return Result{}, fmt.Errorf("idverify/chuanglan: 未知 result=%q body=%s",
|
|
r.Data.Result, truncate(string(raw), 300))
|
|
}
|
|
}
|
|
|
|
// maskCred 只保留首尾各 2 位 + 长度,够对账、不泄露。
|
|
func maskCred(s string) string {
|
|
r := []rune(s)
|
|
switch n := len(r); {
|
|
case n == 0:
|
|
return "(空)"
|
|
case n <= 4:
|
|
return fmt.Sprintf("****(len=%d)", n)
|
|
default:
|
|
return fmt.Sprintf("%s****%s(len=%d)", string(r[:2]), string(r[n-2:]), n)
|
|
}
|
|
}
|
|
|
|
// truncate 截断超长文本,避免把整个响应体灌进日志。
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|
|
|