This commit is contained in:
2026-04-01 10:12:37 +08:00
parent 2a6b14f698
commit e4eab9ad89
9 changed files with 1444 additions and 39 deletions
@@ -0,0 +1,676 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\System;
use app\admin\BaseController;
use app\model\System\SystemSiteSettings;
use think\facade\Db;
use think\facade\Log;
use think\db\exception\DbException;
use think\exception\ValidateException;
use think\facade\Request;
class SmsController extends BaseController
{
private function getSiteSettingValue(string $label): string
{
$row = SystemSiteSettings::where('label', $label)->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);
}
}