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

View File

@ -19,6 +19,9 @@ use app\model\System\OperationLog;
class LoginController extends BaseController
{
private const SMS_CODE_TTL_SECONDS = 300; // 5分钟
private const SMS_CODE_RESEND_SECONDS = 60; // 60秒可重发
private function generateToken($userInfo): string
{
return JwtService::generateToken($userInfo);
@ -29,6 +32,186 @@ class LoginController extends BaseController
return JwtService::verifyToken($token);
}
private function getSiteSettingValue(string $label): string
{
$row = SystemSiteSettings::where('label', $label)
->where('delete_time', null)
->order('id', 'asc')
->find();
return $row ? (string)$row['value'] : '';
}
private function smsCodeCacheKey(string $scene, string $tenantName, string $account, string $phone): string
{
return 'sms_code:' . md5($scene . '|' . $tenantName . '|' . $account . '|' . $phone);
}
private function smsCodeThrottleKey(string $scene, string $tenantName, string $account, string $phone): string
{
return 'sms_code_throttle:' . md5($scene . '|' . $tenantName . '|' . $account . '|' . $phone);
}
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'];
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $body,
'timeout' => 10,
'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();
return ['ok' => false, 'status' => $status, 'error' => (string)($last['message'] ?? 'http request failed')];
}
$decoded = json_decode((string)$respBody, true);
if (!is_array($decoded)) {
$decoded = [];
}
return [
'ok' => $status >= 200 && $status < 300,
'status' => $status,
'body' => (string)$respBody,
'json' => $decoded,
];
}
private function sendSmsCodeInternal(string $scene, string $tenantName, string $account, string $phone): Json
{
$throttleKey = $this->smsCodeThrottleKey($scene, $tenantName, $account, $phone);
if (Cache::has($throttleKey)) {
return json(['code' => 429, 'msg' => '验证码发送过于频繁,请稍后再试']);
}
$backendUrl = $this->getSiteSettingValue('backendUrl');
$apiKey = $this->getSiteSettingValue('apiKey');
if ($backendUrl === '' || $apiKey === '') {
return json(['code' => 500, 'msg' => '短信网关配置缺失,请联系管理员']);
}
$code = (string)random_int(100000, 999999);
$sceneTextMap = [
'register' => '注册',
'reset' => '找回密码',
'login' => '登录',
];
$sceneText = $sceneTextMap[$scene] ?? '验证';
$content = "【云泽网】{$sceneText}验证码:{$code}5分钟内有效。";
$enqueueUrl = rtrim($backendUrl, '/') . '/api/v1/business/outbound-tasks';
$resp = $this->postJson($enqueueUrl, [
'phone' => $phone,
'content' => $content,
], [
'X-Api-Key: ' . $apiKey,
'Content-Type: application/json; charset=utf-8',
'Accept: application/json',
]);
if (empty($resp['ok'])) {
return json([
'code' => 500,
'msg' => '验证码短信发送失败',
'detail' => [
'http_status' => $resp['status'] ?? null,
'body_preview' => isset($resp['body']) ? substr((string)$resp['body'], 0, 200) : '',
]
]);
}
$codeKey = $this->smsCodeCacheKey($scene, $tenantName, $account, $phone);
Cache::set($codeKey, $code, self::SMS_CODE_TTL_SECONDS);
Cache::set($throttleKey, 1, self::SMS_CODE_RESEND_SECONDS);
return json(['code' => 200, 'msg' => '验证码已发送']);
}
private function buildLoginSuccessResponse($user, $tenant): Json
{
$tid = (int)$tenant->id;
try {
$loginCount = isset($user['login_count']) && $user['login_count'] !== null ? (int)$user['login_count'] : 0;
AdminUser::where('id', $user['id'])->update([
'login_count' => $loginCount + 1,
'last_login_ip' => $this->request->ip(),
'last_login_time' => date('Y-m-d H:i:s')
]);
} catch (\Exception $e) {
error_log('更新登录信息失败: ' . $e->getMessage());
}
$userInfo = [
'id' => $user['id'],
'account' => $user['account'],
'name' => $user['name'],
'group_id' => $user['group_id'],
'tid' => $tid,
'tenant' => $tenant
];
if ($user['group_id']) {
$userGroup = AdminUserGroup::where('id', $user['group_id'])->find();
if ($userGroup && $userGroup->rights) {
$userInfo['rights'] = json_decode($userGroup->rights, true);
}
}
try {
$token = $this->generateToken($userInfo);
} catch (\Exception $e) {
$this->logFail('登录管理', '登录', 'Token生成失败: ' . $e->getMessage());
return json(['code' => 500, 'msg' => '登录失败,请稍后重试']);
}
try {
$cacheKey = 'admin_user_' . $user['id'] . '_' . $tid;
\think\facade\Cache::set($cacheKey, $userInfo, 86400 * 7);
} catch (\Exception $e) {
error_log('用户缓存写入失败: ' . $e->getMessage());
}
try {
$this->logSuccess('登录管理', '登录', [
'id' => $user['id'],
'tid' => $tid,
'tenant' => $tenant
], $userInfo);
} catch (\Exception $e) {
error_log('登录日志记录失败: ' . $e->getMessage());
}
return json([
'code' => 200,
'msg' => '登录成功',
'data' => [
'token' => $token,
'user' => $userInfo
]
]);
}
/**
* 登录接口
* @return Json
@ -191,6 +374,87 @@ class LoginController extends BaseController
}
}
/**
* 发送手机号登录验证码
*/
public function sendLoginCode(): Json
{
try {
$data = $this->request->post();
$this->validate($data, [
'tenant_name|租户名称' => 'require|length:1,128',
'phone|手机号' => 'require|mobile',
]);
$tenant = Tenant::where('tenant_name', (string)$data['tenant_name'])
->where('status', 1)
->find();
if (!$tenant) {
return json(['code' => 400, 'msg' => '租户不存在或已禁用']);
}
$user = AdminUser::where('tid', (int)$tenant['id'])
->where('phone', (string)$data['phone'])
->where('status', 1)
->where('delete_time', null)
->find();
if (!$user) {
return json(['code' => 404, 'msg' => '手机号未绑定可用账号']);
}
return $this->sendSmsCodeInternal('login', (string)$data['tenant_name'], (string)$data['phone'], (string)$data['phone']);
} catch (ValidateException $e) {
return json(['code' => 400, 'msg' => $e->getError()]);
} catch (\Throwable $e) {
return json(['code' => 500, 'msg' => '发送失败:' . $e->getMessage()]);
}
}
/**
* 手机号验证码登录
*/
public function loginBySms(): Json
{
try {
$data = $this->request->post();
$this->validate($data, [
'tenant_name|租户名称' => 'require|length:1,128',
'phone|手机号' => 'require|mobile',
'sms_code|短信验证码' => 'require|length:4,8',
]);
$codeKey = $this->smsCodeCacheKey('login', (string)$data['tenant_name'], (string)$data['phone'], (string)$data['phone']);
$cachedCode = (string)Cache::get($codeKey, '');
if ($cachedCode === '' || $cachedCode !== (string)$data['sms_code']) {
return json(['code' => 400, 'msg' => '短信验证码错误或已过期']);
}
$tenant = Tenant::where('tenant_name', (string)$data['tenant_name'])
->where('status', 1)
->field(['id', 'tenant_name'])
->find();
if (!$tenant) {
return json(['code' => 401, 'msg' => '租户不存在或已禁用']);
}
$user = AdminUser::where('tid', (int)$tenant['id'])
->where('phone', (string)$data['phone'])
->where('status', 1)
->where('delete_time', null)
->find();
if (!$user) {
return json(['code' => 401, 'msg' => '手机号未绑定可用账号']);
}
Cache::delete($codeKey);
return $this->buildLoginSuccessResponse($user, $tenant);
} catch (ValidateException $e) {
return json(['code' => 400, 'msg' => $e->getError()]);
} catch (\Throwable $e) {
return json(['code' => 500, 'msg' => '登录失败:' . $e->getMessage()]);
}
}
/**
* 退出登录
* @return Json
@ -298,6 +562,182 @@ class LoginController extends BaseController
]);
}
/**
* 注册账号(按租户维度)
*/
public function sendRegisterCode(): Json
{
try {
$data = $this->request->post();
$this->validate($data, [
'tenant_name|租户名称' => 'require|length:1,128',
'account|账号' => 'require|length:3,32',
'phone|手机号' => 'require|mobile',
]);
return $this->sendSmsCodeInternal('register', (string)$data['tenant_name'], (string)$data['account'], (string)$data['phone']);
} catch (ValidateException $e) {
return json(['code' => 400, 'msg' => $e->getError()]);
} catch (\Throwable $e) {
return json(['code' => 500, 'msg' => '发送失败:' . $e->getMessage()]);
}
}
public function register(): Json
{
try {
$data = $this->request->post();
$this->validate($data, [
'tenant_name|租户名称' => 'require|length:1,128',
'account|账号' => 'require|length:3,32',
'name|姓名' => 'require|length:2,32',
'password|密码' => 'require|length:6,32',
'confirm_password|确认密码' => 'require',
'phone|手机号' => 'require|mobile',
'sms_code|短信验证码' => 'require|length:4,8',
]);
if ((string)$data['password'] !== (string)$data['confirm_password']) {
return json(['code' => 400, 'msg' => '两次输入的密码不一致']);
}
$codeKey = $this->smsCodeCacheKey('register', (string)$data['tenant_name'], (string)$data['account'], (string)$data['phone']);
$cachedCode = (string)Cache::get($codeKey, '');
if ($cachedCode === '' || $cachedCode !== (string)$data['sms_code']) {
return json(['code' => 400, 'msg' => '短信验证码错误或已过期']);
}
$tenant = Tenant::where('tenant_name', (string)$data['tenant_name'])
->where('status', 1)
->find();
if (!$tenant) {
return json(['code' => 400, 'msg' => '租户不存在或已禁用']);
}
$exists = AdminUser::where('tid', (int)$tenant['id'])
->where('account', (string)$data['account'])
->where('delete_time', null)
->find();
if ($exists) {
return json(['code' => 400, 'msg' => '账号已存在']);
}
$now = date('Y-m-d H:i:s');
$user = new AdminUser();
$user->save([
'tid' => (int)$tenant['id'],
'account' => (string)$data['account'],
'name' => (string)$data['name'],
'phone' => (string)$data['phone'],
'email' => (string)($data['email'] ?? ''),
'password' => md5((string)$data['password']),
'status' => 1,
'group_id' => 0,
'login_count' => 0,
'create_time' => $now,
'update_time' => $now,
]);
Cache::delete($codeKey);
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()]);
}
}
/**
* 忘记密码:通过租户 + 账号 + 手机号重置密码
*/
public function sendResetCode(): Json
{
try {
$data = $this->request->post();
$this->validate($data, [
'tenant_name|租户名称' => 'require|length:1,128',
'account|账号' => 'require|length:3,32',
'phone|手机号' => 'require|mobile',
]);
$tenant = Tenant::where('tenant_name', (string)$data['tenant_name'])
->where('status', 1)
->find();
if (!$tenant) {
return json(['code' => 400, 'msg' => '租户不存在或已禁用']);
}
$user = AdminUser::where('tid', (int)$tenant['id'])
->where('account', (string)$data['account'])
->where('phone', (string)$data['phone'])
->where('delete_time', null)
->find();
if (!$user) {
return json(['code' => 404, 'msg' => '账号不存在或手机号不匹配']);
}
return $this->sendSmsCodeInternal('reset', (string)$data['tenant_name'], (string)$data['account'], (string)$data['phone']);
} catch (ValidateException $e) {
return json(['code' => 400, 'msg' => $e->getError()]);
} catch (\Throwable $e) {
return json(['code' => 500, 'msg' => '发送失败:' . $e->getMessage()]);
}
}
public function resetPassword(): Json
{
try {
$data = $this->request->post();
$this->validate($data, [
'tenant_name|租户名称' => 'require|length:1,128',
'account|账号' => 'require|length:3,32',
'phone|手机号' => 'require|mobile',
'new_password|新密码' => 'require|length:6,32',
'confirm_password|确认密码' => 'require',
'sms_code|短信验证码' => 'require|length:4,8',
]);
if ((string)$data['new_password'] !== (string)$data['confirm_password']) {
return json(['code' => 400, 'msg' => '两次输入的密码不一致']);
}
$codeKey = $this->smsCodeCacheKey('reset', (string)$data['tenant_name'], (string)$data['account'], (string)$data['phone']);
$cachedCode = (string)Cache::get($codeKey, '');
if ($cachedCode === '' || $cachedCode !== (string)$data['sms_code']) {
return json(['code' => 400, 'msg' => '短信验证码错误或已过期']);
}
$tenant = Tenant::where('tenant_name', (string)$data['tenant_name'])
->where('status', 1)
->find();
if (!$tenant) {
return json(['code' => 400, 'msg' => '租户不存在或已禁用']);
}
$user = AdminUser::where('tid', (int)$tenant['id'])
->where('account', (string)$data['account'])
->where('phone', (string)$data['phone'])
->where('delete_time', null)
->find();
if (!$user) {
return json(['code' => 404, 'msg' => '账号不存在或手机号不匹配']);
}
AdminUser::where('id', (int)$user['id'])->update([
'password' => md5((string)$data['new_password']),
'update_time' => date('Y-m-d H:i:s'),
]);
Cache::delete($codeKey);
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()]);
}
}
/**
* 获取极验3.0的id和key
* @return Json

View File

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

View File

@ -14,13 +14,28 @@ use app\model\System\AdminUser;
class UserController extends BaseController
{
private function getCurrentTenantIdOrFail(): int
{
$tid = $this->getTenantId();
if ($tid <= 0) {
throw new \RuntimeException('未获取到有效租户信息');
}
return $tid;
}
/**
* 获取所有用户信息
* @return Json
*/
public function getAllUsers()
{
$users = AdminUser::where('delete_time', null)->field('id, account, name, phone, birth, email, qq, sex, group_id, status, last_login_ip, login_count, create_time, update_time')->select()->toArray();
try {
$tid = $this->getCurrentTenantIdOrFail();
$users = AdminUser::where('delete_time', null)
->where('tid', $tid)
->field('id, tid, account, name, phone, birth, email, qq, sex, group_id, status, last_login_ip, login_count, create_time, update_time')
->select()
->toArray();
return json([
'code' => 200,
'msg' => '获取成功',
@ -29,6 +44,16 @@ class UserController extends BaseController
'total' => count($users)
]
]);
} catch (\Throwable $e) {
return json([
'code' => 401,
'msg' => '获取用户失败:' . $e->getMessage(),
'data' => [
'list' => [],
'total' => 0
]
]);
}
}
/**
@ -37,7 +62,19 @@ class UserController extends BaseController
*/
public function getTenantUsers(int $tenantId)
{
$users = AdminUser::where('delete_time', null)->where('tid', $tenantId)->field('id, account, name, phone, birth, email, qq, sex, group_id, status, last_login_ip, login_count, create_time, update_time')->select()->toArray();
try {
$tid = $this->getCurrentTenantIdOrFail();
if ($tenantId !== $tid) {
return json([
'code' => 403,
'msg' => '禁止跨租户查看用户'
]);
}
$users = AdminUser::where('delete_time', null)
->where('tid', $tid)
->field('id, tid, account, name, phone, birth, email, qq, sex, group_id, status, last_login_ip, login_count, create_time, update_time')
->select()
->toArray();
return json([
'code' => 200,
'msg' => '获取成功',
@ -46,6 +83,12 @@ class UserController extends BaseController
'total' => count($users)
]
]);
} catch (\Throwable $e) {
return json([
'code' => 401,
'msg' => '获取用户失败:' . $e->getMessage()
]);
}
}
/**
@ -54,9 +97,18 @@ class UserController extends BaseController
*/
public function getUserInfo(int $id)
{
$tid = $this->getTenantId();
$user = AdminUser::where('id', $id)
->where('tid', $tid)
->where('delete_time', null)
->field('id, account, name, phone, email, birth, qq, sex, group_id, status, create_time, last_login_ip')
->find();
if (!$user) {
return json([
'code' => 404,
'msg' => '用户不存在或无权限访问'
]);
}
// 记录操作日志
$this->logSuccess('用户管理', '获取用户信息', ['id' => $id]);
@ -75,10 +127,20 @@ class UserController extends BaseController
public function changePassword(int $id, string $password)
{
try {
AdminUser::where('id', $id)->update([
$tid = $this->getCurrentTenantIdOrFail();
$affected = AdminUser::where('id', $id)
->where('tid', $tid)
->where('delete_time', null)
->update([
'password' => md5($password),
'update_time' => date('Y-m-d H:i:s')
]);
if (!$affected) {
return json([
'code' => 404,
'msg' => '用户不存在或无权限修改'
]);
}
// 记录操作日志
$this->logSuccess('用户管理', '修改密码', ['id' => $id]);
return json([
@ -102,17 +164,19 @@ class UserController extends BaseController
public function addUser()
{
$data = request()->param();
$tid = $this->getTenantId();
if ($tid <= 0) {
return json([
'code' => 401,
'msg' => '未获取到有效租户信息'
]);
}
$data['password'] = md5($data['password']);
$data['create_time'] = date('Y-m-d H:i:s');
$data['update_time'] = $data['create_time'];
$data['group_id'] = 2;
if (!isset($data['tid']) || empty($data['tid'])) {
return json([
'code' => 400,
'msg' => '租户ID不能为空'
]);
}
$data['tid'] = $tid;
$id = AdminUser::insertGetId($data);
$this->logSuccess('用户管理', '添加用户', ['data' => $data]);
@ -131,8 +195,25 @@ class UserController extends BaseController
{
$data = request()->param();
unset($data['_t'], $data['id']);
unset($data['tid']);
$tid = $this->getTenantId();
if ($tid <= 0) {
return json([
'code' => 401,
'msg' => '未获取到有效租户信息'
]);
}
$data['update_time'] = date('Y-m-d H:i:s');
AdminUser::where('id', $id)->update($data);
$affected = AdminUser::where('id', $id)
->where('tid', $tid)
->where('delete_time', null)
->update($data);
if (!$affected) {
return json([
'code' => 404,
'msg' => '用户不存在或无权限编辑'
]);
}
$this->logSuccess('用户管理', '编辑用户', ['id' => $id]);
return json([
'code' => 200,
@ -146,16 +227,29 @@ class UserController extends BaseController
*/
public function deleteUser(int $id)
{
$user = AdminUser::where('id', $id)->where('delete_time', null)->find();
$tid = $this->getTenantId();
if ($tid <= 0) {
return json([
'code' => 401,
'msg' => '未获取到有效租户信息'
]);
}
$user = AdminUser::where('id', $id)
->where('tid', $tid)
->where('delete_time', null)
->find();
if (!$user) {
return json([
'code' => 404,
'msg' => '用户不存在或已删除'
'msg' => '用户不存在、已删除或无权限操作'
]);
}
AdminUser::where('id', $id)->update([
AdminUser::where('id', $id)
->where('tid', $tid)
->update([
'delete_time' => date('Y-m-d H:i:s')
]);

View File

@ -11,6 +11,12 @@ Route::get('storage/:path', 'app\\admin\\controller\\StorageController@index')->
// 登录路由
Route::post('loginInfo', 'app\\admin\\controller\\LoginController@loginInfo');
Route::post('login', 'app\\admin\\controller\\LoginController@login');
Route::post('sendLoginCode', 'app\\admin\\controller\\LoginController@sendLoginCode');
Route::post('loginBySms', 'app\\admin\\controller\\LoginController@loginBySms');
Route::post('sendRegisterCode', 'app\\admin\\controller\\LoginController@sendRegisterCode');
Route::post('sendResetCode', 'app\\admin\\controller\\LoginController@sendResetCode');
Route::post('register', 'app\\admin\\controller\\LoginController@register');
Route::post('resetPassword', 'app\\admin\\controller\\LoginController@resetPassword');
Route::post('logout', 'app\\admin\\controller\\LoginController@logout');
Route::get('user/info', 'app\\admin\\controller\\LoginController@userInfo');

View File

@ -0,0 +1,33 @@
<?php
use think\facade\Route;
// 短信管理路由
Route::group('sms', function () {
// 获取短信网关配置
Route::get('info', 'app\\admin\\controller\\System\\SmsController@getSmsInfo');
// 编辑短信网关配置
Route::post('editinfo', 'app\\admin\\controller\\System\\SmsController@editSmsInfo');
// 发送测试短信(入队任务)
Route::post('sendtest', 'app\\admin\\controller\\System\\SmsController@sendTestSms');
// Android 网关轮询待发送任务
Route::post('gateway/poll', 'app\\admin\\controller\\System\\SmsController@gatewayPoll');
// 兼容:如果网关端只请求 poll不带 gateway
Route::post('poll', 'app\\admin\\controller\\System\\SmsController@gatewayPoll');
Route::get('poll', 'app\\admin\\controller\\System\\SmsController@gatewayPoll');
// Android 网关上报短信内容(解析验证码)
Route::post('gateway/report', 'app\\admin\\controller\\System\\SmsController@gatewayReport');
// 兼容:如果网关端只请求 report不带 gateway
Route::post('report', 'app\\admin\\controller\\System\\SmsController@gatewayReport');
// 管理员:短信任务列表(租户隔离)
Route::get('taskList', 'app\\admin\\controller\\System\\SmsController@getSmsTaskList');
// 管理员:编辑短信任务(租户隔离)
Route::post('taskEdit/:id', 'app\\admin\\controller\\System\\SmsController@editSmsTask');
});

View File

@ -0,0 +1,29 @@
<?php
namespace app\model\System;
use think\Model;
/**
* 短信发送任务表Android 网关轮询获取)
*/
class SmsTask extends Model
{
// 数据库表名
protected $name = 'mete_system_sms_tasks';
protected $type = [
'id' => 'integer',
'tid' => 'integer',
'api_key' => 'string',
'phone' => 'string',
'content' => 'string',
'status' => 'integer',
'code' => 'string',
'report_raw' => 'string',
'create_time' => 'datetime',
'update_time' => 'datetime',
'delete_time' => 'datetime',
];
}

View File

@ -9,9 +9,21 @@
}
.post-img {
width: 330px;
height: 160px;
object-fit: cover;
width: 100%; /* 强制宽度撑满父容器 */
height: 160px; /* 固定高度 */
object-fit: cover; /* 核心:保持比例裁剪 */
object-position: center; /* 确保裁剪时以中心为准(默认通常是中心,但显式声明更稳妥) */
display: block; /* 消除行内元素的底部间隙 */
}
.post-img img {
width: 100%; /* 宽度撑满父容器 */
height: 160px; /* 设定固定高度 */
display: block; /* 移除图片底部的幽灵间隙 */
/* 核心属性:居中缩放的关键 */
object-fit: cover; /* 保持原比例,裁切多余部分以填充容器 */
object-position: center; /* 确保缩放裁切时,视觉焦点在图片正中心 */
}
.recent-posts {

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
$envPath = __DIR__ . DIRECTORY_SEPARATOR . '.env';
$lines = @file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (!$lines) {
echo "failed to read env: {$envPath}\n";
exit(1);
}
$map = [];
foreach ($lines as $line) {
$line = trim((string)$line);
if ($line === '' || strpos($line, '=') === false) {
continue;
}
$parts = explode('=', $line, 2);
$map[trim((string)$parts[0])] = trim((string)$parts[1]);
}
$dsn = 'mysql:host=' . $map['DB_HOST'] . ';port=' . $map['DB_PORT'] . ';dbname=' . $map['DB_NAME'] . ';charset=' . $map['DB_CHARSET'];
$pdo = new PDO($dsn, $map['DB_USER'], $map['DB_PASS'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare('SELECT label,value FROM ' . $map['DB_PREFIX'] . 'system_site_settings WHERE label IN (?, ?) ORDER BY id ASC');
$stmt->execute(['backendUrl', 'apiKey']);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$label = (string)$r['label'];
$value = (string)$r['value'];
if ($label === 'apiKey') {
$mask = substr($value, 0, 3) . '***' . substr($value, -3);
echo $label . '=' . $mask . PHP_EOL;
} else {
echo $label . '=' . $value . PHP_EOL;
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
$envPath = __DIR__ . DIRECTORY_SEPARATOR . '.env';
$lines = @file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (!$lines) {
echo "failed to read env\n";
exit(1);
}
$map = [];
foreach ($lines as $line) {
$line = trim((string)$line);
if ($line === '' || strpos($line, '=') === false) continue;
[$k, $v] = explode('=', $line, 2);
$map[trim($k)] = trim($v);
}
$dsn = 'mysql:host=' . $map['DB_HOST'] . ';port=' . $map['DB_PORT'] . ';dbname=' . $map['DB_NAME'] . ';charset=' . $map['DB_CHARSET'];
$pdo = new PDO($dsn, $map['DB_USER'], $map['DB_PASS'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$stmt = $pdo->prepare('SELECT label,value FROM ' . $map['DB_PREFIX'] . 'system_site_settings WHERE label IN (?, ?) ORDER BY id ASC');
$stmt->execute(['backendUrl', 'apiKey']);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$backendUrl = '';
$apiKey = '';
foreach ($rows as $r) {
if ($r['label'] === 'backendUrl') $backendUrl = (string)$r['value'];
if ($r['label'] === 'apiKey') $apiKey = (string)$r['value'];
}
if ($backendUrl === '' || $apiKey === '') {
echo "missing backendUrl/apiKey\n";
exit(1);
}
$url = rtrim($backendUrl, '/') . '/api/v1/business/outbound-tasks?limit=3';
$headers = [
'X-Api-Key: ' . $apiKey,
'Accept: application/json',
];
$headerStr = implode("\r\n", $headers);
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => $headerStr,
'timeout' => 10,
// 关键:即便 HTTP code 非 2xx也让 file_get_contents 返回响应体
'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;
}
}
}
echo "GET $url\n";
echo "HTTP status=$status\n";
if ($respBody === false) {
echo "body=false\n";
} else {
$preview = substr((string)$respBody, 0, 500);
echo "body_preview=" . str_replace(["\r", "\n"], ['\\r', '\\n'], $preview) . "\n";
}