tp/app/admin/controller/System/SmsController.php
2026-04-01 10:12:37 +08:00

677 lines
24 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}