order('id', 'asc')->find(); if (!$row) { return ''; } return (string)$row['value']; } private function upsertSiteSetting(string $label, string $value): void { $row = SystemSiteSettings::where('label', $label)->order('id', 'asc')->find(); if ($row) { SystemSiteSettings::update(['value' => $value], ['id' => $row['id']]); return; } $model = new SystemSiteSettings(); $model->label = $label; $model->value = $value; $model->sort = 0; $model->save(); } /** * 获取短信网关配置 */ public function getSmsInfo() { try { $backendUrl = $this->getSiteSettingValue('backendUrl'); $apiKey = $this->getSiteSettingValue('apiKey'); // 前端约定返回 data[0],字段名兼容 backend_url/api_key 与 backendUrl/apiKey $data = [[ 'backend_url' => $backendUrl, 'api_key' => $apiKey, 'backendUrl' => $backendUrl, 'apiKey' => $apiKey, ]]; $this->logSuccess('短信管理', '获取短信网关配置', ['data' => $data]); return json([ 'code' => 200, 'msg' => '获取成功', 'data' => $data, ]); } catch (DbException $e) { $this->logFail('短信管理', '获取短信网关配置', $e->getMessage()); return json([ 'code' => 500, 'msg' => '获取失败:' . $e->getMessage(), 'data' => [], ]); } } /** * 编辑短信网关配置(本表设计为单条配置) */ public function editSmsInfo() { try { $raw = $this->request->post(); $data = [ 'backend_url' => $raw['backendUrl'] ?? $raw['backend_url'] ?? '', 'api_key' => $raw['apiKey'] ?? $raw['api_key'] ?? '', ]; $this->validate($data, [ 'backend_url|短信网关地址' => 'require|max:255', 'api_key|短信网关 API KEY' => 'require|max:255', ]); $this->upsertSiteSetting('backendUrl', (string)$data['backend_url']); $this->upsertSiteSetting('apiKey', (string)$data['api_key']); $updated = [[ 'backend_url' => $this->getSiteSettingValue('backendUrl'), 'api_key' => $this->getSiteSettingValue('apiKey'), ]]; $this->logSuccess('短信管理', '更新短信网关配置', ['data' => $updated]); return json([ 'code' => 200, 'msg' => '更新成功', 'data' => $updated, ]); } catch (ValidateException $e) { $this->logFail('短信管理', '更新短信网关配置', $e->getMessage()); return json([ 'code' => 400, 'msg' => $e->getError(), ]); } catch (DbException $e) { $this->logFail('短信管理', '更新短信网关配置', $e->getMessage()); return json([ 'code' => 500, 'msg' => '更新失败:' . $e->getMessage(), ]); } } /** * 后台发起“短信测试”:入队一条任务,等待 Android 网关轮询取走并发送 */ public function sendTestSms() { try { $raw = $this->request->post(); $phone = trim((string)($raw['phone'] ?? $raw['testPhone'] ?? '')); $content = trim((string)($raw['content'] ?? '')); if (empty($phone)) { return json(['code' => 400, 'msg' => '缺少测试手机号']); } // Android 网关文档:国际格式 +8613712345678 if (!preg_match('/^\+\d{6,15}$/', $phone)) { return json(['code' => 400, 'msg' => '请使用国际格式手机号(以 + 开头,后为数字)']); } // 获取当前网关配置(来自 mete_system_site_settings label/value) $apiKey = $this->getSiteSettingValue('apiKey'); $backendUrl = $this->getSiteSettingValue('backendUrl'); if (empty($apiKey)) { return json(['code' => 400, 'msg' => '请先配置短信网关 API KEY']); } if (empty($backendUrl)) { return json(['code' => 400, 'msg' => '请先配置短信网关地址 backendUrl']); } $code = (string)random_int(100000, 999999); if ($content === '') { $content = '短信测试验证码:' . $code; } // 兼容:调用 Uniapp/sms 后端入队发送任务 $enqueueUrl = rtrim($backendUrl, '/') . '/api/v1/business/outbound-tasks'; $enqueuePayload = [ 'phone' => $phone, 'content' => $content, ]; $enqueueHeaders = [ 'X-Api-Key: ' . (string)$apiKey, 'Content-Type: application/json; charset=utf-8', 'Accept: application/json', ]; Log::write('[sms_enqueue] url=' . $enqueueUrl . ' phone=' . $phone); $enqueueResp = $this->postJson($enqueueUrl, $enqueuePayload, $enqueueHeaders); if (empty($enqueueResp['ok'])) { Log::write('[sms_enqueue] failed http_status=' . ($enqueueResp['status'] ?? '') . ' body=' . ($enqueueResp['body'] ?? '')); return json([ 'code' => 500, 'msg' => '短信网关入队失败:' . ($enqueueResp['error'] ?? 'unknown'), ]); } $smsGatewayTaskId = (string)($enqueueResp['json']['taskId'] ?? ''); Log::write('[sms_enqueue] success taskId=' . $smsGatewayTaskId); $enqueueDebug = [ 'enqueueOk' => (bool)($enqueueResp['ok'] ?? false), 'enqueueStatus' => $enqueueResp['status'] ?? null, 'enqueueJsonKeys' => array_keys((array)($enqueueResp['json'] ?? [])), 'gatewayTaskId' => $smsGatewayTaskId, ]; // 同步一条本地记录(用于前端列表展示/对账) $now = date('Y-m-d H:i:s'); $taskId = Db::name('mete_system_sms_tasks')->insertGetId([ 'api_key' => (string)$apiKey, 'tid' => $this->getTenantId(), 'phone' => $phone, 'content' => $content, 'status' => 0, // pending(本地不自动同步网关状态) 'code' => $code, // 预期验证码(用于对账/展示) 'create_time' => $now, 'update_time' => $now, ]); $this->logSuccess('短信管理', '发送测试短信(调用网关入队)', [ 'to' => $phone, 'local_task_id' => $taskId ?? null, 'gateway_task_id' => $smsGatewayTaskId, ]); return json([ 'code' => 200, 'msg' => '测试短信任务已入队,等待网关发送', 'data' => [ 'taskId' => $smsGatewayTaskId ?: ($taskId ?? null), 'code' => $code, ], 'debug' => empty($smsGatewayTaskId) ? $enqueueDebug : null, ]); } catch (ValidateException $e) { $this->logFail('短信管理', '发送测试短信', $e->getMessage()); return json(['code' => 400, 'msg' => $e->getError()]); } catch (\Throwable $e) { $this->logFail('短信管理', '发送测试短信', $e->getMessage()); return json(['code' => 500, 'msg' => '发送失败:' . $e->getMessage()]); } } /** * 简单 JSON POST(无 curl 扩展时使用) * @return array{ok:bool,status?:int,body?:string,error?:string,json?:array} */ private function postJson(string $url, array $payload, array $headers): array { $body = json_encode($payload, JSON_UNESCAPED_UNICODE); if ($body === false) { return ['ok' => false, 'error' => 'json_encode failed']; } $headerStr = implode("\r\n", $headers); $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => $headerStr, 'content' => $body, 'timeout' => 10, ], ]); $respBody = @file_get_contents($url, false, $context); $status = 0; if (!empty($http_response_header) && is_array($http_response_header)) { foreach ($http_response_header as $line) { if (preg_match('#^HTTP/\\S+\\s+(\\d+)#', $line, $m)) { $status = (int)$m[1]; break; } } } if ($respBody === false) { $last = error_get_last(); $errMsg = $last['message'] ?? 'http request failed'; return ['ok' => false, 'status' => $status, 'error' => (string)$errMsg]; } $decoded = null; $decoded = json_decode((string)$respBody, true); if (!is_array($decoded)) { $decoded = []; } $ok = $status >= 200 && $status < 300; return [ 'ok' => $ok, 'status' => $status, 'body' => (string)$respBody, 'json' => $decoded, ]; } /** * 简单 JSON GET(无 curl 扩展时使用) * @return array{ok:bool,status?:int,body?:string,error?:string,json?:array} */ private function httpGetJson(string $url, array $headers): array { $headerStr = implode("\r\n", $headers); $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => $headerStr, 'timeout' => 10, // 对 4xx/5xx 也返回响应体,便于排障 'ignore_errors' => true, ], ]); $respBody = @file_get_contents($url, false, $context); $status = 0; if (!empty($http_response_header) && is_array($http_response_header)) { foreach ($http_response_header as $line) { if (preg_match('#^HTTP/\\S+\\s+(\\d+)#', $line, $m)) { $status = (int)$m[1]; break; } } } if ($respBody === false) { $last = error_get_last(); $errMsg = $last['message'] ?? 'http request failed'; return ['ok' => false, 'status' => $status, 'error' => (string)$errMsg]; } $decoded = json_decode((string)$respBody, true); if (!is_array($decoded)) { $decoded = []; } $ok = $status >= 200 && $status < 300; return [ 'ok' => $ok, 'status' => $status, 'body' => (string)$respBody, 'json' => $decoded, ]; } /** * Android 网关轮询:获取待发送任务 * * 约定: * - apiKey 通过 header `X-API-KEY` / `X-SMS-GATEWAY-API-KEY` 或 body/query `apiKey` */ public function gatewayPoll() { $apiKey = $this->getApiKeyFromRequest(); if (empty($apiKey)) { Log::write('[sms_gateway_poll] missing apiKey'); return json(['code' => 401, 'msg' => '缺少 apiKey']); } // 不强制要求 mete_system_site_settings(apiKey) 与网关入参完全一致; // 直接按任务表的 api_key 匹配,提高兼容性与排障速度。 $apiKeySetting = $this->getSiteSettingValue('apiKey'); if (empty($apiKeySetting) || (string)$apiKeySetting !== (string)$apiKey) { Log::write('[sms_gateway_poll] apiKey differs from setting; setting=' . $this->maskKey($apiKeySetting) . ' req=' . $this->maskKey($apiKey)); } $task = Db::name('mete_system_sms_tasks') ->where('api_key', (string)$apiKey) ->where('status', 0) ->where('delete_time', null) ->order('id', 'asc') ->find(); // 没有任务 if (!$task) { Log::write('[sms_gateway_poll] no task for apiKey=' . $this->maskKey($apiKey)); return json([ 'code' => 200, 'msg' => 'no task', 'data' => null, ]); } // 标记为处理中,避免被重复领取 Db::name('mete_system_sms_tasks')->where('id', (int)$task['id'])->update([ 'status' => 1, 'update_time' => date('Y-m-d H:i:s'), ]); Log::write('[sms_gateway_poll] send task id=' . (int)$task['id'] . ' phone=' . (string)$task['phone']); return json([ 'code' => 200, 'msg' => 'success', 'data' => [ 'taskId' => $task['id'], 'task_id' => $task['id'], 'phone' => $task['phone'], 'content' => $task['content'], 'smsContent' => $task['content'], ], ]); } /** * Android 网关上报:上传实际收到的短信内容并解析验证码 */ public function gatewayReport() { try { $raw = $this->request->post(); $apiKey = $this->getApiKeyFromRequest(); $taskId = $raw['taskId'] ?? $raw['task_id'] ?? $raw['id'] ?? null; $smsContent = trim((string)($raw['smsContent'] ?? $raw['sms_content'] ?? $raw['content'] ?? $raw['message'] ?? $raw['sms'] ?? '')); $status = $raw['status'] ?? $raw['result'] ?? $raw['state'] ?? null; // success/failed/int if (empty($apiKey)) { return json(['code' => 401, 'msg' => '缺少 apiKey']); } if (empty($taskId)) { return json(['code' => 400, 'msg' => '缺少 taskId']); } if ($smsContent === '') { return json(['code' => 400, 'msg' => '缺少 smsContent']); } $task = Db::name('mete_system_sms_tasks') ->where('id', (int)$taskId) ->where('api_key', (string)$apiKey) ->where('delete_time', null) ->find(); if (!$task) { Log::write('[sms_gateway_report] task not found; taskId=' . (int)$taskId . ' apiKey=' . $this->maskKey($apiKey)); return json(['code' => 404, 'msg' => '任务不存在或 apiKey 不匹配']); } Log::write('[sms_gateway_report] received taskId=' . (int)$taskId . ' status=' . (string)($status ?? '')); // 从短信内容里抓取验证码(通用策略:4-6 位数字) $parsedCode = ''; if (preg_match('/(\d{4,6})/', $smsContent, $m)) { $parsedCode = (string)$m[1]; } $update = [ 'report_raw' => $smsContent, 'code' => $parsedCode !== '' ? $parsedCode : (string)$task['code'], 'update_time' => date('Y-m-d H:i:s'), ]; if ($status === 'failed' || $status === 'error' || $status === 2) { $update['status'] = 2; } elseif ($status === 'success' || $status === 'reported' || $status === 3 || $status === 1) { // success => reported/done $update['status'] = 3; } else { // 默认当做已上报 $update['status'] = 3; } Db::name('mete_system_sms_tasks')->where('id', (int)$task['id'])->update($update); $this->logSuccess('短信管理', '网关上报短信(解析验证码)', [ 'task_id' => $task['id'], 'parsed_code' => $update['code'], ]); return json(['code' => 200, 'msg' => '上报成功']); } catch (DbException $e) { $this->logFail('短信管理', '网关上报短信', $e->getMessage()); return json(['code' => 500, 'msg' => '服务器错误:' . $e->getMessage()]); } catch (\Throwable $e) { $this->logFail('短信管理', '网关上报短信', $e->getMessage()); return json(['code' => 500, 'msg' => '服务器错误:' . $e->getMessage()]); } } /** * 管理员:获取短信任务列表(租户隔离) * * 支持参数(可选): * - status: 任务状态 * - phone: 手机号关键词 */ public function getSmsTaskList() { $backendUrl = $this->getSiteSettingValue('backendUrl'); $apiKey = $this->getSiteSettingValue('apiKey'); if (empty($backendUrl) || empty($apiKey)) { return json([ 'code' => 400, 'msg' => '短信网关配置未完成(backendUrl/apiKey 缺失)', 'list' => [], ]); } $status = (string)Request::param('status', ''); $phoneKeyword = trim((string)Request::param('phone', '')); $limit = (int)Request::param('limit', 50, 'int'); $limit = $limit > 0 ? min($limit, 200) : 50; // 前端 status: 0/1/2/3 -> uniapp backend status: pending/sending/failed/success $statusMap = [ '0' => 'pending', '1' => 'sending', '2' => 'failed', '3' => 'success', ]; $uniStatus = $status !== '' && isset($statusMap[$status]) ? $statusMap[$status] : ''; $url = rtrim($backendUrl, '/') . '/api/v1/business/outbound-tasks?limit=' . $limit; if ($uniStatus !== '') { $url .= '&status=' . urlencode($uniStatus); } if ($phoneKeyword !== '') { $url .= '&phone=' . urlencode($phoneKeyword); } $headers = [ 'X-Api-Key: ' . (string)$apiKey, 'Accept: application/json', ]; $resp = $this->httpGetJson($url, $headers); if (empty($resp['ok'])) { // 兼容:如果 uniapp 后端还未部署新增查询接口,则 fallback 到本地队列表(只能看到“已入队”,无法看到发送成功/失败) $fallback = []; try { $fWhere = [ ['api_key', '=', (string)$apiKey], ['delete_time', '=', null], ]; if ($phoneKeyword !== '') { $fWhere[] = ['phone', 'like', '%' . $phoneKeyword . '%']; } // 前端 status=0/1/2/3,局部队列表字段同样使用数字状态 if ($status !== '') { $fWhere[] = ['status', '=', (int)$status]; } $fallback = Db::name('mete_system_sms_tasks') ->where($fWhere) ->order('id', 'desc') ->limit($limit) ->select() ->toArray(); } catch (\Throwable $e) { $fallback = []; } return json([ 'code' => 200, 'msg' => 'uniapp 查询任务接口不可用,已使用本地队列表(仅展示入队状态)', 'detail' => [ 'http_status' => $resp['status'] ?? null, 'body_preview' => isset($resp['body']) ? substr((string)$resp['body'], 0, 300) : '', ], 'list' => $fallback, ]); } $tasks = (array)($resp['json']['tasks'] ?? []); $statusNumMap = [ 'pending' => 0, 'sending' => 1, 'failed' => 2, 'success' => 3, ]; $list = array_map(function ($t) use ($statusNumMap) { $content = (string)($t['content'] ?? ''); $parsedCode = ''; if (preg_match('/(\d{4,6})/', $content, $m)) { $parsedCode = (string)$m[1]; } $statusStr = (string)($t['status'] ?? ''); return [ 'id' => $t['taskId'] ?? '', 'phone' => $t['phone'] ?? '', 'content' => $content, 'code' => $parsedCode, 'status' => $statusNumMap[$statusStr] ?? 0, 'create_time' => isset($t['createdAt']) ? date('Y-m-d H:i:s', (int)($t['createdAt'] / 1000)) : '', 'update_time' => isset($t['updatedAt']) ? date('Y-m-d H:i:s', (int)($t['updatedAt'] / 1000)) : '', ]; }, $tasks); return json([ 'code' => 200, 'msg' => 'success', 'list' => $list, ]); } /** * 管理员:编辑短信任务(租户隔离) * * body: * - status * - code * - report_raw */ public function editSmsTask($id = null) { try { $tid = $this->getTenantId(); $id = $this->request->param('id', $id); $data = Request::only(['status', 'code', 'report_raw']); if (empty($id)) { return json(['code' => 400, 'msg' => '任务ID不能为空']); } if (!isset($data['status'])) { return json(['code' => 400, 'msg' => '状态不能为空']); } $this->validate($data, [ 'status|状态' => 'integer', 'code|验证码' => 'max:20', 'report_raw|上报内容' => 'max:100000', ]); $task = Db::name('mete_system_sms_tasks') ->where('id', (int)$id) ->where('tid', (int)$tid) ->find(); if (!$task) { return json(['code' => 404, 'msg' => '任务不存在或无权限']); } $update = [ 'status' => (int)$data['status'], 'code' => (string)($data['code'] ?? ''), 'report_raw' => (string)($data['report_raw'] ?? ''), 'update_time' => date('Y-m-d H:i:s'), ]; Db::name('mete_system_sms_tasks') ->where('id', (int)$id) ->where('tid', (int)$tid) ->update($update); return json(['code' => 200, 'msg' => '编辑成功']); } catch (ValidateException $e) { return json(['code' => 400, 'msg' => $e->getError()]); } catch (\Throwable $e) { return json(['code' => 500, 'msg' => '编辑失败:' . $e->getMessage()]); } } private function getApiKeyFromRequest(): string { $req = Request::instance(); $apiKey = (string)$req->header('X-SMS-GATEWAY-API-KEY', ''); if ($apiKey !== '') { return $apiKey; } $apiKey = (string)$req->header('X-API-KEY', ''); if ($apiKey !== '') { return $apiKey; } $apiKey = (string)$req->header('X-SMS-API-KEY', ''); if ($apiKey !== '') { return $apiKey; } $apiKey = (string)$req->header('Authorization', ''); if ($apiKey !== '' && preg_match('/Bearer\\s+(.+)/i', $apiKey, $m)) { return (string)$m[1]; } // body/query $apiKey = (string)($req->post('apiKey') ?? $req->post('api_key') ?? $req->param('apiKey', '') ?? $req->param('api_key', '')); return $apiKey; } private function maskKey(string $key): string { $key = (string)$key; if ($key === '') return ''; if (strlen($key) <= 6) return '***'; return substr($key, 0, 3) . '***' . substr($key, -3); } }