diff --git a/app/admin/controller/LoginController.php b/app/admin/controller/LoginController.php index fafa7d2..35924e0 100644 --- a/app/admin/controller/LoginController.php +++ b/app/admin/controller/LoginController.php @@ -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 diff --git a/app/admin/controller/System/SmsController.php b/app/admin/controller/System/SmsController.php new file mode 100644 index 0000000..7074cc6 --- /dev/null +++ b/app/admin/controller/System/SmsController.php @@ -0,0 +1,676 @@ +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); + } +} + diff --git a/app/admin/controller/UserController.php b/app/admin/controller/UserController.php index a2132a6..750d97e 100644 --- a/app/admin/controller/UserController.php +++ b/app/admin/controller/UserController.php @@ -14,21 +14,46 @@ 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(); - return json([ - 'code' => 200, - 'msg' => '获取成功', - 'data' => [ - 'list' => $users, - 'total' => count($users) - ] - ]); + 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' => '获取成功', + 'data' => [ + 'list' => $users, + 'total' => count($users) + ] + ]); + } catch (\Throwable $e) { + return json([ + 'code' => 401, + 'msg' => '获取用户失败:' . $e->getMessage(), + 'data' => [ + 'list' => [], + 'total' => 0 + ] + ]); + } } /** @@ -37,15 +62,33 @@ 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(); - return json([ - 'code' => 200, - 'msg' => '获取成功', - 'data' => [ - 'list' => $users, - 'total' => count($users) - ] - ]); + 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' => '获取成功', + 'data' => [ + 'list' => $users, + '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,18 +164,20 @@ 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]); return json([ @@ -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') ]); diff --git a/app/admin/route/routes/base.php b/app/admin/route/routes/base.php index 8679f35..59984a9 100644 --- a/app/admin/route/routes/base.php +++ b/app/admin/route/routes/base.php @@ -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'); diff --git a/app/admin/route/routes/sms.php b/app/admin/route/routes/sms.php new file mode 100644 index 0000000..187ffdc --- /dev/null +++ b/app/admin/route/routes/sms.php @@ -0,0 +1,33 @@ + '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', + ]; +} + diff --git a/public/themes/template3/assets/css/style.css b/public/themes/template3/assets/css/style.css index 2ba6b48..cd091af 100644 --- a/public/themes/template3/assets/css/style.css +++ b/public/themes/template3/assets/css/style.css @@ -9,24 +9,36 @@ } .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 { background-color: #f9f9f9 !important; } .feature-box { - display: flex; - flex-direction: column; - align-items: center; + display: flex; + flex-direction: column; + align-items: center; } .feature-box img { width: 150px; - object-fit: cover; + object-fit: cover; } .feature-box img:hover { transform: scale(1.1); transition: all 0.3s ease-in-out; -} \ No newline at end of file +} diff --git a/read_sms_site_settings.php b/read_sms_site_settings.php new file mode 100644 index 0000000..f0651f3 --- /dev/null +++ b/read_sms_site_settings.php @@ -0,0 +1,40 @@ + 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; + } +} + diff --git a/test_sms_gateway_tasklist.php b/test_sms_gateway_tasklist.php new file mode 100644 index 0000000..8f9f13e --- /dev/null +++ b/test_sms_gateway_tasklist.php @@ -0,0 +1,75 @@ + 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"; +} +