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] + "..." }