// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\wxchat\service; use think\facade\Log; /** * 真人认证 AI 智能审核服务 * * 内置两类审核器(provider),通过插件配置 wxchat.ai_audit_provider 切换: * 1) local —— 本地启发式:按资料完整度打分(脱敏存储,仅作初筛,不作为最终结论) * 2) http —— 真实服务:调用可配置 HTTPS 接口(OCR / 活体人脸 / 反欺诈), * 失败或超时时自动回退 local,保证审核链路不中断。 * * 审核结果约定: * ['score'=>int(0-100), 'passed'=>bool, 'result'=>int(1通过/2不通过), 'detail'=>string, 'provider'=>string] */ class AiAuditService { /** * 读取 wxchat 插件配置(存于 wxapp_addon_config,非框架 Config 命名空间) */ private static function cfg(string $key, $default = null) { try { $conf = \ywxapp\service\AddonService::config('wxchat'); if (! is_array($conf)) { return $default; } return $conf[$key] ?? $default; } catch (\Throwable $e) { return $default; } } /** * 统一入口 */ public static function audit(array $data): array { $provider = (string) self::cfg('ai_audit_provider', 'local'); if ($provider === 'http' && self::cfg('ai_audit_url')) { try { return self::httpProvider($data); } catch (\Throwable $e) { Log::warning('AI审核真实服务调用失败,已回退本地启发式:' . $e->getMessage()); } } return self::localProvider($data); } /** * 本地启发式:资料完整度打分 */ private static function localProvider(array $data): array { $score = 0; if (! empty($data['front_img'])) { $score += 30; } if (! empty($data['back_img'])) { $score += 20; } if (! empty($data['hold_img'])) { $score += 20; } if (! empty($data['face_img'])) { $score += 30; } // 真实姓名 + 证件号齐全(非脱敏校验,仅判断完整性) if (empty($data['real_name']) || empty($data['id_card'])) { $score = max(0, $score - 10); } $passScore = (int) self::cfg('ai_audit_pass_score', 80); $passed = $score >= $passScore; return [ 'score' => $score, 'passed' => $passed, 'result' => $passed ? 1 : 2, 'detail' => '本地启发式审核(资料完整度=' . $score . ',阈值=' . $passScore . ')', 'provider' => 'local', ]; } /** * 真实服务:调用可配置 HTTPS 接口 * 期望响应(常见结构,兼容多种返回): * { "code":1, "message":"...", "data": { "score": 92, "passed": true } } * 或 * { "score": 92, "passed": true, "msg":"..." } */ private static function httpProvider(array $data): array { $url = self::cfg('ai_audit_url', ''); $appcode = self::cfg('ai_audit_appcode', ''); $timeout = (int) self::cfg('ai_audit_timeout', 8); $client = new \GuzzleHttp\Client([ 'base_uri' => $url, 'timeout' => $timeout, 'verify' => (bool) self::cfg('ai_audit_ssl_verify', false), ]); $headers = ['Accept' => 'application/json']; if ($appcode) { // 阿里云市场等常见鉴权头;如服务商不同,可在配置中改用自定义头 $headers['Authorization'] = 'APPCODE ' . $appcode; } $token = self::cfg('ai_audit_token', ''); if ($token) { $headers['X-Api-Token'] = $token; } $resp = $client->post('', [ 'headers' => $headers, 'json' => [ 'uid' => $data['uid'] ?? 0, 'real_name' => $data['real_name'] ?? '', 'id_card' => $data['id_card_raw'] ?? ($data['id_card'] ?? ''), 'front_img' => $data['front_img'] ?? '', 'back_img' => $data['back_img'] ?? '', 'hold_img' => $data['hold_img'] ?? '', 'face_img' => $data['face_img'] ?? '', ], ]); $body = json_decode($resp->getBody()->getContents(), true); if (! is_array($body)) { throw new \RuntimeException('AI审核服务返回非JSON'); } $inner = $body['data'] ?? $body; $score = (int) ($inner['score'] ?? ($body['score'] ?? 0)); $passed = ! empty($inner['passed']) || ! empty($body['passed']) || $score >= (int) self::cfg('ai_audit_pass_score', 80); $detail = $inner['message'] ?? ($inner['msg'] ?? ($body['message'] ?? ($body['msg'] ?? '真实服务返回'))); return [ 'score' => $score, 'passed' => (bool) $passed, 'result' => $passed ? 1 : 2, 'detail' => is_string($detail) ? $detail : json_encode($detail, JSON_UNESCAPED_UNICODE), 'provider' => 'http', ]; } /** * 18 位居民身份证号校验(GB 11643-1999 校验位算法) * 仅在校验「原始」证件号时调用(落库为脱敏存储,无法直接校验)。 */ public static function isValidIdCard(string $id): bool { if (! preg_match('/^\d{17}[\dXx]$/', $id)) { return false; } $weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; $codes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; $sum = 0; for ($i = 0; $i < 17; $i++) { $sum += (int) $id[$i] * $weights[$i]; } return $codes[$sum % 11] === strtoupper($id[17]); } }