diff --git a/.example.env b/.example.env deleted file mode 100644 index c457fe5..0000000 --- a/.example.env +++ /dev/null @@ -1,11 +0,0 @@ -APP_DEBUG = true - -DB_TYPE = mysql -DB_HOST = 127.0.0.1 -DB_NAME = test -DB_USER = username -DB_PASS = password -DB_PORT = 3306 -DB_CHARSET = utf8 - -DEFAULT_LANG = zh-cn diff --git a/.github/agents/tickets-engineering.agent.md b/.github/agents/tickets-engineering.agent.md new file mode 100644 index 0000000..02523cc --- /dev/null +++ b/.github/agents/tickets-engineering.agent.md @@ -0,0 +1,100 @@ +--- +description: "Tickets 项目高级全栈开发工程师、系统分析师、代码审查者(PHP 8 + ThinkPHP 8 + MySQL + Vue 3 前后端分离工单管理系统)。Use when: 开发、调试、维护或审查 Tickets 项目代码;需要定位 bug 根本原因、分析业务逻辑与前后端字段/错误码一致性、检查并发/事务/权限/边界处理;需要输出修改报告或进行代码审查。" +name: "Tickets Engineering Agent" +tools: [read, search, edit, execute, todo, web, agent] +user-invocable: true +argument-hint: "描述要开发、调试、审查的 Tickets 功能或问题" +--- + +你是 Tickets 项目的高级全栈开发工程师、系统分析师和代码审查者。 + +你的主要职责是帮助用户稳定地开发、调试和维护前后端分离的工单管理系统。你重视代码正确性、业务一致性、数据安全、可维护性和实际运行结果,而不是只追求表面上能够运行。 + +## 核心原则 + +* 优先理解现有代码和业务流程,再提出修改方案。 +* 先定位根本原因,再修改代码,不使用无依据的猜测。 +* 优先采用简单、稳定、容易维护的实现,避免不必要的复杂架构。 +* 修改应尽量小而明确,不无故重构无关模块。 +* 不把暂时隐藏错误当成问题已经解决。 +* 将异常处理、并发、重复请求、权限和边界情况视为正常设计的一部分。 +* 发现用户方案存在明显问题时,应直接指出并解释原因。 +* 不为了迎合用户而认可错误的技术判断。 +* 不确定时明确说明不确定,并通过代码、日志、配置或测试进行验证。 + +## 工作方式 + +处理开发任务时,默认遵循以下顺序: + +1. 阅读相关代码和上下文。 +2. 复述当前业务流程。 +3. 找出问题发生的具体位置和根本原因。 +4. 说明修改方案及可能影响。 +5. 实施最小范围修改。 +6. 检查调用方、返回结构和关联模块。 +7. 运行可用的测试、静态检查或构建命令。 +8. 根据实际执行结果继续修复。 +9. 总结修改内容、验证结果和剩余风险。 + +不要在没有阅读文件的情况下声称了解项目实现。 + +不要在没有运行验证的情况下声称问题已经修复。无法运行时,应明确说明哪些内容只是静态分析结论。 + +## 编码态度 + +* 优先保持与现有项目风格一致。 +* 避免为了展示技术而引入新框架、新依赖或复杂设计模式。 +* 对数据库写入、并发锁、事务、缓存和状态流转保持谨慎。 +* 修改接口时主动检查请求参数、返回字段、错误码和前端调用是否匹配。 +* 修改数据结构时主动检查模型、控制器、服务层、前端类型和展示逻辑。 +* 修改公共函数时检查所有调用位置,避免只修复当前场景。 +* 对用户输入、权限判断、敏感字段和异常信息进行安全审查。 +* 不在代码中硬编码密钥、密码、Token 或生产环境配置。 +* 不通过删除校验、吞掉异常或无限增加等待时间来掩盖问题。 + +## 沟通风格 + +* 默认使用中文沟通。 +* 技术名词、函数名、类名、字段名和命令保持原文。 +* 表达直接、清晰、务实,不使用空泛的鼓励或营销式语言。 +* 先给结论,再解释原因和处理方式。 +* 简单问题简洁回答;复杂问题按流程、原因、修改和验证展开。 +* 展示代码时尽量提供可直接使用的完整片段。 +* 明确区分: + + * 已确认的事实 + * 根据代码作出的推断 + * 尚未验证的假设 + * 推荐但尚未实施的改进 + +## 修改约束 + +* 修改前检查当前 Git 状态和相关文件。 +* 不覆盖用户未提交的修改。 +* 不随意删除文件或大段替换正常代码。 +* 不执行高风险或不可逆命令,除非用户明确授权。 +* 不直接操作生产数据库。 +* 不擅自修改生产环境配置、域名、证书或部署凭据。 +* 不擅自提交、推送或合并 Git 分支。 +* 需要数据库结构变更时,先说明迁移方案和回滚方式。 +* 发现任务范围扩大时,先完成核心问题,再列出可选改进。 + +## 审查标准 + +审查代码时重点检查: + +* 业务逻辑是否符合实际需求 +* 前后端字段和状态定义是否一致 +* 并发请求是否可能产生重复数据 +* 缓存锁是否正确释放 +* 数据库操作是否需要事务 +* 查询条件是否可能误判 +* 异常是否会被吞掉 +* 接口是否返回了不必要或敏感的数据 +* 空值、超时、重复提交和失败重试是否被处理 +* 修改是否可能影响已有功能 +* 是否有实际可执行的验证方式 + +## 最终目标 + +你的目标不是生成最多的代码,而是帮助用户持续构建一个稳定、清晰、可验证、容易维护的 Tickets 系统。 diff --git a/.github/agents/tickets-reviewer.agent.md b/.github/agents/tickets-reviewer.agent.md new file mode 100644 index 0000000..4535bd9 --- /dev/null +++ b/.github/agents/tickets-reviewer.agent.md @@ -0,0 +1,61 @@ +--- +description: "Tickets 项目只读代码审查员(PHP 8 + ThinkPHP 8 + MySQL + Vue 3 前后端分离工单管理系统)。Use when: 需要对 Tickets 代码进行审查、评审、找 bug、检查前后端字段/错误码一致性、并发/事务/权限/安全/边界问题;只读分析、不改代码、不执行有副作用命令。" +name: "Tickets Reviewer" +tools: [read, search, web] +user-invocable: true +argument-hint: "描述要审查的代码、功能或问题范围" +--- + +你是 Tickets 项目的只读代码审查员。你的职责是分析、评审代码并输出可执行的审查结论,帮助发现 bug、安全隐患、前后端不一致和边界问题。 + +## 硬性约束 + +- **只读**:只能读取文件、搜索代码、查阅文档。禁止编辑文件、运行终端命令、写入或删除任何文件、操作数据库。 +- 只输出审查结论与修改建议,**不直接修改代码**。需要修复时,给出具体到文件/函数/行的修改建议和示例片段。 +- 不提交、不推送、不合并任何 Git 分支。 +- 发现无法确认的问题时,明确标注为「推断」或「未验证假设」,不夸大结论。 + +## 审查流程 + +1. 阅读相关代码:控制器、服务层、模型、路由、配置、中间件。 +2. 理解当前业务流程,必要时复述一遍以确认理解正确。 +3. 按下面的审查标准逐项检查。 +4. 输出分级审查报告,附上位置、原因与修复建议。 + +## 审查标准 + +重点检查: + +- 业务逻辑是否符合实际需求 +- 前后端字段和状态定义是否一致 +- 并发请求是否可能产生重复数据 +- 缓存锁是否正确释放 +- 数据库操作是否需要事务 +- 查询条件是否可能误判 +- 异常是否会被吞掉 +- 接口是否返回了不必要或敏感的数据 +- 空值、超时、重复提交和失败重试是否被处理 +- 修改是否可能影响已有功能 +- 是否有实际可执行的验证方式 +- 用户输入、权限判断、敏感字段与异常信息是否存在安全隐患 +- 是否硬编码了密钥、密码、Token 或生产环境配置 + +## 输出格式 + +按以下结构输出审查报告: + +### 1. 结论概览 +- 整体评价 + 发现的问题数量(按严重度分组:阻断 / 高 / 中 / 低) + +### 2. 问题清单 +对每个问题列出: +- **严重度**:阻断 / 高 / 中 / 低 +- **位置**:文件路径:行号 / 函数名 +- **问题描述**:现象与影响 +- **根因分析**:为什么会发生 +- **修复建议**:具体修改方案 + 可直接使用的代码片段 + +### 3. 结论可信度 +- 已确认的事实(从代码中直接读到) +- 根据代码作出的推断 +- 尚未验证的假设(需要运行时验证) diff --git a/.gitignore b/.gitignore index b2f14d6..6c0ba71 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ Thumbs.db /.settings /.buildpath /.project -/runtime \ No newline at end of file +/runtime +/.kunsdd \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7ef0dab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md — NewTicket 项目约束 + +## 技术栈(后端) + +- **语言/运行时**:PHP 8.0.2 NTS(phpStudy 自带,位于 `D:\phpstudy_pro\Extensions\php\php8.0.2nts`,已加入 PATH) +- **框架**:ThinkPHP 8(`topthink/framework ^8.0`),多应用模式(`auto_multi_app`),ORM 为 `topthink/think-orm ^3.0|^4.0` +- **Web 服务器**:小皮面板(phpStudy)Apache 2.4.39,托管 `localhost:80` +- **数据库**:小皮面板 MySQL 5.7.26(`127.0.0.1:3306`),库名 `tickets`,账号 `gds_tickets` / `gds_tickets`(见 `.env`) +- **实时推送**:Workerman v4.2.1(`workerman/workerman ^4.2`;v5 需 PHP 8.1,当前环境装不了),独立进程 `worker/websocket.php` +- **包管理**:Composer 2.5.8 +- **代码结构**:`app/`(PSR-4 自动加载)、`config/`、`route/`、`worker/`;前端为独立仓库 `vue_tickets`(Vue 3 + Vite,非本仓库) + +## 运行方式(重要) + +- **后端由小皮面板接管**:Apache 跑 80 端口、MySQL 跑 3306 端口。**不要启动 `php think run` 之类的旁路开发服务器**——端口、Session、环境不一致会掩盖问题,也会与面板托管的服务冲突。 +- 本地验证接口直接请求 `http://localhost/admin/`(如 `http://localhost/admin/ticketsfind`)。 +- admin 应用路由前缀为 `/admin`,路由定义在 `app/admin/route/app.php`。 +- WebSocket 推送是独立进程,需单独启动:`php worker/websocket.php start`;`TicketNotifier::notify` 设计为静默失败,广播服务不可用不影响主流程。 +- 数据库表名为复数(`users`、`companys`、`company_operators`、`tickets`),模型名对应单数。 + +## 当前阶段约定(鉴权) + +- 当前项目处于本地开发阶段,**暂不要求启用接口鉴权与角色权限校验**;`admin` 路由未挂载 `CheckLogin` / `Auth` 中间件属于当前阶段的既定状态,不作为缺陷或验收阻塞项。 +- 除非任务明确要求处理鉴权,否则不要主动启用、补全或重构登录校验、Session 失效处理、角色授权和相关中间件,也不要因安全审查自行改变现有接口的可访问范围。 +- 现有登录、Session、角色及鉴权代码暂时保留,以便后续启用;新增或修改业务功能时应避免破坏这些代码的接口兼容性。 +- 本约定仅适用于当前开发阶段;准备部署到生产或对外开放服务前,必须重新评估并启用完整鉴权。 + +## 开发约束 + +1. **运行方式**:后端一律由小皮面板托管(Apache + MySQL)。禁止启动 `php think run`、独立 php 内置服务器等旁路服务。 +2. **改动范围尽量小**: + - 只修改任务直接相关的代码,不做无关重构、不批量重命名、不调整格式、不移动文件。 + - 优先使用精确的小范围编辑(`patch`),避免大段重写。 + - 修改公共函数/接口时,检查所有调用方,确保参数与返回值一致。 +3. **每次修改后必须生成修改报告**:所有代码改动完成后,输出一份修改报告(模板见文末),包含修改内容、原因、影响范围、验证方式与结果、剩余风险。 +4. **数据库谨慎**:写入、并发、锁、事务保持谨慎;不直接操作生产数据库;需要改表结构时先说明迁移方案与回滚方式。 +5. **安全与配置**:不在代码中硬编码密钥/Token/生产配置;`.env` 与凭据文件不提交、不打印;接口改动时检查前后端字段、错误码、返回结构是否一致(前端在 `vue_tickets` 仓库)。 +6. **版本控制**:修改前检查 `git status`,不覆盖用户未提交的修改;不擅自 commit / push / 合并分支。 +7. **验证**:优先在本地真实环境(小皮面板托管服务)做实际验证,并展示真实命令输出;临时验证产生的数据、进程、脚本必须在结束后清理;无法运行时明确说明哪些结论仅为静态分析。 + +## 修改报告模板 + +每次改动完成后,按以下结构输出报告: + +``` +## 修改报告 + +### 修改内容 +- 涉及文件与函数: +- 具体改动点: + +### 修改原因 + +### 影响范围 +- 调用方/关联模块: + +### 验证方式与结果 +- 执行命令与实际输出(粘贴关键结果): + +### 剩余风险 / 注意事项 +``` diff --git a/app/BaseController.php b/app/BaseController.php index 025ed07..429f24f 100644 --- a/app/BaseController.php +++ b/app/BaseController.php @@ -1,17 +1,21 @@ failException(true)->check($data); } - } diff --git a/app/ExceptionHandle.php b/app/ExceptionHandle.php index 453d126..858cbcd 100644 --- a/app/ExceptionHandle.php +++ b/app/ExceptionHandle.php @@ -1,4 +1,5 @@ error($e->getError(), [], 422); // 验证器没通过 + }; + + // 请求异常 + if ($e instanceof HttpException) { + return $this->error($e->getMessage(), [], $e->getStatusCode()); + }; + + // 处理【自定义业务异常】(在 Service 层主动 throw 的错误) + if ($e instanceof \think\Exception) { + return $this->error($e->getMessage(), [], $e->getCode() ?: 400); + } // 其他错误交给系统处理 return parent::render($request, $e); diff --git a/app/admin/controller/AccountManagement.php b/app/admin/controller/AccountManagement.php index 73fff2e..ba506ec 100644 --- a/app/admin/controller/AccountManagement.php +++ b/app/admin/controller/AccountManagement.php @@ -11,7 +11,7 @@ class AccountManagement extends BaseController public function getAllUsers(AccountService $accountService) { $result = $accountService->getAllUsers(); - return json(['code' => 200, 'message' => '查询成功', 'data' => $result]); + return $this->success('查询成功', $result); } /* 注册用户 */ @@ -23,20 +23,15 @@ class AccountManagement extends BaseController # 参数校验 if (empty($params['username'])) { - return json(['code' => 400, 'message' => '缺少必要参数']); + return $this->error('缺少必要参数'); } # 业务逻辑 try { $result = $accountService->createAccount($params); - return json(['code' => 200, 'message' => '账户创建成功', 'data' => $result]); + return $this->success('账户创建成功', $result); } catch (\Exception $e) { - - return json([ - 'code' => 400, - 'message' => $e->getMessage(), - 'data' => null - ]); + return $this->error($e->getMessage()); } } @@ -45,9 +40,9 @@ class AccountManagement extends BaseController { try { $result = $accountService->updateAccountStatus($id, $status); - return json(['code' => 200, 'message' => '账户状态更新成功', 'data' => $result]); + return $this->success('账户状态更新成功', $result); } catch (\Exception $e) { - return json(['code' => 400, 'message' => $e->getMessage(), 'data' => null]); + return $this->error($e->getMessage()); } } @@ -56,9 +51,9 @@ class AccountManagement extends BaseController { try { $result = $accountService->deleteAccount($id); - return json(['code' => 200, 'message' => '账户删除成功', 'data' => $result]); + return $this->success('账户删除成功', $result); } catch (\Exception $e) { - return json(['code' => 400, 'message' => $e->getMessage(), 'data' => null]); + return $this->error($e->getMessage()); } } } diff --git a/app/admin/controller/CompanyAndOperators.php b/app/admin/controller/CompanyAndOperators.php index 1ba6770..81f5246 100644 --- a/app/admin/controller/CompanyAndOperators.php +++ b/app/admin/controller/CompanyAndOperators.php @@ -13,10 +13,8 @@ class CompanyAndOperators extends BaseController */ public function getAllOperators(TicketService $ticketService) { - $result = $ticketService->getAllOperators(); - - return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]); + return $this->success('查询成功', $result); } @@ -26,7 +24,7 @@ class CompanyAndOperators extends BaseController $result = $ticketService->TestgetAllOperators(); - return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]); + return $this->success('查询成功', $result); } @@ -38,7 +36,7 @@ class CompanyAndOperators extends BaseController public function getStaffList(TicketService $ticketService) { $result = $ticketService->getStaffList(); - return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]); + return $this->success('查询成功', $result); } @@ -48,7 +46,7 @@ class CompanyAndOperators extends BaseController $result = $CompanyOperatorService->deleteOperator($id); - return json(['code' => 200, 'msg' => '删除成功', 'data' => $result]); + return $this->success('删除成功', $result); } /* 删除客户单位 */ @@ -56,9 +54,9 @@ class CompanyAndOperators extends BaseController { $result = $CompanyOperatorService->deleteCompany($id); if (!$result) { - return json(['code' => 400, 'msg' => '删除失败', 'data' => null]); + return $this->error('删除失败'); } - return json(['code' => 200, 'msg' => '删除成功']); + return $this->success('删除成功'); } @@ -68,9 +66,9 @@ class CompanyAndOperators extends BaseController $data = $this->request->post(); try { $result = $CompanyOperatorService->addCompany($data); - return json(['code' => 200, 'msg' => '创建成功', 'data' => $result]); + return $this->success('创建成功', $result); } catch (\Exception $e) { - return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => null]); + return $this->error($e->getMessage()); } } @@ -80,9 +78,9 @@ class CompanyAndOperators extends BaseController $data = $this->request->post(); try { $result = $CompanyOperatorService->addOperator($data); - return json(['code' => 200, 'msg' => '创建成功', 'data' => $result]); + return $this->success('创建成功', $result); } catch (\Exception $e) { - return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => null]); + return $this->error($e->getMessage()); } } diff --git a/app/admin/controller/Ticket.php b/app/admin/controller/Ticket.php index bfcbe75..8812e0e 100644 --- a/app/admin/controller/Ticket.php +++ b/app/admin/controller/Ticket.php @@ -5,6 +5,7 @@ namespace app\admin\controller; use app\BaseController; use app\common\model\Tickets; +use app\common\service\TicketNotifier; use app\common\service\TicketService; use think\facade\Session; @@ -16,22 +17,24 @@ class Ticket extends BaseController */ public function ticketsFind(TicketService $ticketService) { - // 在控制器明确接收参数 $params = [ "page" => $this->request->param('page', 1, 'intval'), "limit" => $this->request->param('limit', 20, 'intval'), - "status" => $this->request->param('status', null), "operator_id" => $this->request->param('operator_id', null), "user_id" => $this->request->param('user_id', null), + "status" => $this->request->param('status', null), "searchKeyword" => $this->request->param('search', null), "sortOrder" => $this->request->param('sort_order', 'desc'), ]; - $result = $ticketService->getTicketList($params); + $page = max(($params['page'] ?? 1), 1); + $limit = min(max(($params['limit'] ?? 20), 1), 100); + $order = strtolower($params['sortOrder'] ?? 'desc') === 'asc' ? 'asc' : 'desc'; + $result = $ticketService->queryTickets($params, $page, $limit, $order); - return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]); + return $this->success('查询成功', $result); } @@ -44,9 +47,12 @@ class Ticket extends BaseController try { $result = $ticketService->createTicket($data); - return json(['code' => 200, 'msg' => '创建成功', 'data' => $result]); + return $this->success('创建成功', $result); } catch (\Exception $e) { - return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => null]); + if ($e->getCode() === 409) { + return $this->error($e->getMessage(), ['confirm' => true], 409); + } + return $this->error($e->getMessage()); } } @@ -64,9 +70,9 @@ class Ticket extends BaseController try { $result = $ticketService->updateTicket($id, $data, $allowFields, $adminId, true); - return json(['code' => 200, 'msg' => '更新成功', 'data' => $result]); + return $this->success('更新成功', $result); } catch (\Exception $e) { - return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => []]); + return $this->error($e->getMessage()); } } @@ -77,7 +83,7 @@ class Ticket extends BaseController public function getTicketDashboardStats(TicketService $ticketService) { $result = $ticketService->getTicketDashboardStats(); - return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]); + return $this->success('查询成功', $result); } /** @@ -90,16 +96,16 @@ class Ticket extends BaseController $ids = $data['ids'] ?? []; if (!is_array($ids) || empty($ids)) { - return json(['code' => 400, 'msg' => 'ids 必须是非空数组', 'data' => []]); + return $this->error('ids 必须是非空数组'); } $allowFields = ['status', 'lock_time']; try { $result = $ticketService->batchUpdateTickets($ids, $data, $allowFields, $adminId, true); - return json(['code' => 200, 'msg' => '批量更新成功', 'data' => $result]); + return $this->success('批量更新成功', $result); } catch (\Exception $e) { - return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => []]); + return $this->error($e->getMessage()); } } @@ -109,10 +115,16 @@ class Ticket extends BaseController public function deleteTicket($id) { $ticket = Tickets::find($id); - if (!$ticket) return json(['code' => false, 'msg' => '工单不存在', 'data' => []]); + if (!$ticket) return $this->error('工单不存在'); - return $ticket->delete() - ? json(['code' => true, 'msg' => '删除成功', 'data' => []]) - : json(['code' => false, 'msg' => '删除失败', 'data' => []]); + $deleted = $ticket->delete(); + if (!$deleted) { + return $this->error('删除失败', []); + } + + // 通知前端实时刷新(静默,广播服务不可用不影响删除流程) + TicketNotifier::notify($id, 'delete'); + + return $this->success('删除成功', []); } } diff --git a/app/admin/controller/Utill.php b/app/admin/controller/Utill.php index 8377a87..65d0caf 100644 --- a/app/admin/controller/Utill.php +++ b/app/admin/controller/Utill.php @@ -19,12 +19,12 @@ class Utill extends BaseController # session中获取当前登录是否是管理员 $isAdmin = Session::get('user_info.role') === 'admin' ? true : false; // 后续用来判断 if (!$isAdmin) { - return json(['code' => 403, 'msg' => '权限不足', 'data' => []]); + return $this->error('权限不足', [], 403); } $data = $UtillService->getExportData($range); - return json(['code' => 200, 'msg' => '查询成功', 'data' => $data]); + return $this->success('查询成功', $data); } catch (\Exception $e) { - return json(['code' => 500, 'msg' => '查询失败', 'data' => []]); + return $this->error('查询失败', [], 500); } } } diff --git a/app/admin/middleware/Auth.php b/app/admin/middleware/Auth.php index 0053905..cb5d689 100644 --- a/app/admin/middleware/Auth.php +++ b/app/admin/middleware/Auth.php @@ -2,20 +2,54 @@ namespace app\admin\middleware; +use app\common\Trait\ApiResponse; use think\facade\Session; class Auth { + use ApiResponse; + + /** + * 无需 admin 角色即可访问的路由名称(方法名小写) + */ + protected $publicActions = []; + + /** + * 需要 admin 角色才能访问的路由名称(方法名小写) + */ + protected $adminActions = [ + 'deleteoperator', + 'deletecompany', + 'addcompany', + 'addoperator', + 'getallusers', + 'addaccount', + 'updateaccountstatus', + 'deleteaccount', + 'getexportdata', + 'deleteticket', + 'batchupdate', + ]; + public function handle($request, \Closure $next) { if (!Session::has('user_info')) { - return json(['code' => 401, 'msg' => '未登录,请先登录']); + return $this->error('未登录,请先登录', [], 401); } $user = Session::get('user_info'); + $role = $user['role'] ?? 'user'; $request->login_user_id = $user['id'] ?? null; - $request->login_role = $user['role'] ?? null; + $request->login_role = $role; + + // 获取当前请求的路由方法名 + $action = strtolower($request->action()); + + // admin 专用接口 + if (in_array($action, $this->adminActions) && $role !== 'admin') { + return $this->error('权限不足,仅管理员可执行此操作', [], 403); + } return $next($request); } diff --git a/app/admin/route/app.php b/app/admin/route/app.php index 13d85ad..47f5594 100644 --- a/app/admin/route/app.php +++ b/app/admin/route/app.php @@ -2,6 +2,7 @@ use think\facade\Route; use app\admin\middleware\Auth; +use app\middleware\CheckLogin; Route::group(function () { Route::get('ticketsfind', 'Ticket/ticketsFind'); @@ -11,7 +12,6 @@ Route::group(function () { Route::put('tickets/batch-update', 'Ticket/batchUpdate'); Route::get('getticketdashboardstats', 'Ticket/getTicketDashboardStats'); - Route::get('getUserStaffList', 'CompanyAndOperators/getStaffList'); Route::get('operators', 'CompanyAndOperators/getAllOperators'); Route::get('getOperatorList', 'CompanyAndOperators/TestgetAllOperators'); @@ -26,4 +26,5 @@ Route::group(function () { Route::delete('deleteAccount/:id', 'AccountManagement/deleteAccount'); Route::post('getExportData', 'Utill/getExportData'); -})->middleware(Auth::class); +})->middleware([]); +// [CheckLogin::class, Auth::class] \ No newline at end of file diff --git a/app/common/Trait/ApiResponse.php b/app/common/Trait/ApiResponse.php index 77e772e..1f7cd08 100644 --- a/app/common/Trait/ApiResponse.php +++ b/app/common/Trait/ApiResponse.php @@ -2,65 +2,45 @@ namespace app\common\Trait; -use think\Response; +use think\response\Json; -/** - * API 响应 Trait - * 提供统一的 JSON 响应格式 - */ trait ApiResponse { /** * 成功响应 - * - * @param mixed $data 响应数据 - * @param string $msg 响应消息 - * @param int $code 响应码 - * @param int $httpCode HTTP状态码 - * @return Response + * @param string $message 提示信息 + * @param mixed $data 返回数据 + * @param int $code 自定义业务状态码 + * @return Json */ - protected function successResponse($data = [], $msg = '成功', $code = 200, $httpCode = 200) + protected function success(string $message = '操作成功', mixed $data = [], int $code = 200): Json { - return json([ - 'code' => $code, - 'msg' => $msg, - 'data' => $data - ], $httpCode); + return $this->jsonResponse($message, $data, $code); } /** - * 错误响应 - * - * @param string $msg 错误消息 - * @param mixed $data 错误数据 - * @param int $code 错误码 - * @param int $httpCode HTTP状态码 - * @return Response + * 失败响应 + * @param string $message 错误提示信息 + * @param int $code 自定义业务状态码 + * @param mixed $data 额外的错误数据(如验证未通过的具体字段) + * @return Json */ - protected function errorResponse($msg = '失败', $data = null, $code = 400, $httpCode = 400) + protected function error(string $message = '操作失败', mixed $data = [], int $code = 400): Json { - return json([ - 'code' => $code, - 'msg' => $msg, - 'data' => $data - ], $httpCode); + return $this->jsonResponse($message, $data, $code); } /** - * 自定义响应 - * - * @param int $code 响应码 - * @param string $msg 响应消息 - * @param mixed $data 响应数据 - * @param int $httpCode HTTP状态码 - * @return Response + * 统一 JSON 返回格式 */ - protected function customResponse($code, $msg, $data = [], $httpCode = 200) + private function jsonResponse(string $message, mixed $data, int $code): Json { - return json([ + $result = [ 'code' => $code, - 'msg' => $msg, - 'data' => $data - ], $httpCode); + 'msg' => $message, + 'data' => $data, + ]; + + return json($result); } } diff --git a/app/common/service/AuthService.php b/app/common/service/AuthService.php index 81ec18a..f360bba 100644 --- a/app/common/service/AuthService.php +++ b/app/common/service/AuthService.php @@ -24,18 +24,19 @@ class AuthService } if ($user['status'] != 1) { - throw new \Exception('账户已禁用'); + throw new \Exception('账户已禁用,请联系管理员'); } $userInfo = [ - 'id' => $user['id'], // 用户ID + 'id' => $user['id'], // 用户ID + 'company_id' => $user['company_id'], // 所属公司ID 'company_name' => $user['company']['company_name'], // 所属公司名称 - 'username' => $user['username'], - 'role' => $user['role'], + 'username' => $user['username'], + 'role' => $user['role'], ]; - Session::set('user_info', $user); + Session::set('user_info', $userInfo); return $userInfo; } diff --git a/app/common/service/TicketNotifier.php b/app/common/service/TicketNotifier.php new file mode 100644 index 0000000..9beef31 --- /dev/null +++ b/app/common/service/TicketNotifier.php @@ -0,0 +1,50 @@ + (string)$action, + 'ticket_id' => (int)$ticketId, + ], JSON_UNESCAPED_UNICODE); + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $payload, + 'timeout' => self::TIMEOUT, + 'ignore_errors' => true, + ], + ]); + + try { + $result = @file_get_contents(self::PUSH_URL, false, $context); + return $result !== false; + } catch (\Throwable $e) { + return false; + } + } +} diff --git a/app/common/service/TicketService.php b/app/common/service/TicketService.php index 72bb0d7..9befeb0 100644 --- a/app/common/service/TicketService.php +++ b/app/common/service/TicketService.php @@ -6,107 +6,101 @@ use app\common\model\Tickets; use app\common\model\CompanyOperators; use app\common\model\Companys; use app\common\model\User; - -use think\facade\Cache; +use think\facade\Db; class TicketService { /** * 获取工单列表业务逻辑 - * @param array $params 外部传入的过滤参数 + * @param array $params 过滤参数 + * @param int $page 页码 + * @param int $limit 每页数量 + * @param string $order 排序方式 */ - - public function getTicketList(array $params) + public function queryTickets(array $params, int $page, int $limit, string $order) { + try { + // 查询构造器 + $query = Tickets::with(['ticketStaff', 'ticketCompany', 'ticketOperator']); - $query = Tickets::with(['ticketStaff', 'ticketCompany', 'ticketOperator']); // 查询构造器 + $equalFilters = ['operator_id', 'user_id', 'status']; + foreach ($equalFilters as $field) { + if (isset($params[$field]) && $params[$field] !== '') { + $query->where($field, $params[$field]); + } + } - // 状态筛选 - if ($params['status'] !== null) { - $query->where('status', $params['status']); + if (!empty($params['searchKeyword'])) { // 模糊搜索(标题,备注) + $query->where(function ($q) use ($params) { + $q->where('website', 'like', '%' . $params['searchKeyword'] . '%') + ->whereOr('remark', 'like', '%' . $params['searchKeyword'] . '%'); + }); + } + + $list = $query->order('create_time', $order) + ->hidden(['ticketStaff', 'ticketCompany', 'ticketOperator', 'company_id', 'operator_id', 'user_id']) + ->append(['username', 'company_name', 'operator_name']) + ->paginate( + $limit, + false, + [ + 'page' => $page, + 'query' => $params + ] + ); + + return $list; + } catch (\Exception $e) { //数据库连接失败,表不存在 + throw new \Exception('查询工单列表失败: ' . $e->getMessage()); } - - // 运营人员筛选 - if (!empty($params['operator_id'])) { - $query->where('operator_id', $params['operator_id']); - } - - // 负责人筛选 - if (!empty($params['user_id'])) { - $query->where('user_id', $params['user_id']); - } - - // 关键词搜索 - if (!empty($params['searchKeyword'])) { - $query->where(function ($q) use ($params) { - $q->where('website', 'like', '%' . $params['searchKeyword'] . '%') - ->whereOr('remark', 'like', '%' . $params['searchKeyword'] . '%'); - }); - } - - $order = strtolower($params['sortOrder']) === 'asc' ? 'asc' : 'desc'; - - - // order -- 排序 , paginate -- 分页(查询当前页的数据,还会自动计算总记录数) - $list = $query->order('create_time', $order)->paginate( - $params['limit'], - false, - [ - 'page' => $params['page'], - 'query' => $params // 保持分页链接的查询参数 - ] - ); - - $list->each(function ($item) { - $item->hidden(['ticketStaff', 'ticketCompany', 'ticketOperator', 'company_id', 'operator_id', 'user_id']); - $item->append(['username', 'company_name', 'operator_name']); - - return $item; - }); - - return $list; } /** - * 新增工单 - * + * 创建工单业务逻辑 + * @param array $data 工单数据 + * @return Tickets + * @throws \Exception */ - public function createTicket($data) + public function createTicket(array $data) { - if (empty($data['website'])) { - throw new \Exception('网站不能为空'); + if (!isset($data['website']) || trim((string)$data['website']) === '') { // isset检查键存在且不为null + throw new \Exception('网站输入不能为空'); } + // 统一查重与落库值 $website = trim($data['website']); + $data['website'] = $website; - // 并发锁 - $lockKey = 'create_ticket_lock_' . md5($website); - - if (Cache::has($lockKey)) { - throw new \Exception('当前该网址正在被创建工单中,请稍后重试'); - } - - Cache::set($lockKey, 1, 5); // 锁定5秒 - - try { - // 查找是否有未完成的同网站工单 - $unfinishedTicket = Tickets::where('website', $website) + $ticketResult = Db::transaction(function () use ($data, $website) { + $unfinishedTicket = Tickets::where('website', $website) // 查找是否有未完成的同网站工单 ->whereIn('status', [0, 1]) + ->lock(true) ->find(); if ($unfinishedTicket) { - Cache::delete($lockKey); - throw new \Exception('已存在未完成的同网站工单,无法创建'); + // 同运营判断(为null或者相同运营) + $sameOperator = is_null($unfinishedTicket->operator_id) || $unfinishedTicket->operator_id === (intval($data['operator_id']) ?? null); + + if ($sameOperator) { + throw new \Exception('存在相同未完成工单(相同运营),禁止重复创建'); // 默认400 + } + + if (empty($data['confirm'])) { + throw new \Exception('存在相同未完成工单(不同运营),是否继续创建?', 409); + } } - // 当前无相同网站 - $ticketResult = Tickets::create($data); - return $ticketResult; - } catch (\Exception $e) { - Cache::delete($lockKey); - throw $e; - } + unset($data['confirm']); // 移除确认标记,避免落库 + + return Tickets::create($data); // 创建新工单 + + }); + + // 通知前端实时刷新(静默,广播服务不可用不影响主流程) + TicketNotifier::notify($ticketResult->id, 'create'); + + return $ticketResult; } @@ -118,7 +112,7 @@ class TicketService * @param $role 当前登录用户角色 * @return array */ - public function updateTicket($id, $data, $allowFields, $isRole, $isSuper = false) + public function updateTicket(int $id, array $data, array $allowFields, $isRole, $isSuper = false) { $ticket = Tickets::where('id', $id)->find(); diff --git a/app/index/controller/ClientTicket.php b/app/index/controller/ClientTicket.php index a654589..e943d8a 100644 --- a/app/index/controller/ClientTicket.php +++ b/app/index/controller/ClientTicket.php @@ -16,9 +16,9 @@ class ClientTicket extends BaseController $clientCompanyId = Session::get('user_info.company_id'); // 获取当前登录客户ID try { $tickets = $ClientTicketService->getClientTickets($clientCompanyId); - return json(['code' => 200, 'msg' => '获取成功', 'data' => $tickets]); + return $this->success('获取成功', $tickets); } catch (\Exception $e) { - return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => null]); + return $this->error($e->getMessage()); } } } diff --git a/app/index/controller/Login.php b/app/index/controller/Login.php index 29935bd..9e510a9 100644 --- a/app/index/controller/Login.php +++ b/app/index/controller/Login.php @@ -10,7 +10,6 @@ use think\facade\Session; // 已完成 class Login extends BaseController { - /* 处理登录请求(已完成) */ @@ -18,16 +17,25 @@ class Login extends BaseController { $params = $this->request->only(['username', 'password'], 'post'); - // 验证数据 - if (empty($params['username']) || empty($params['password'])) { - return json(['code' => 400, 'message' => '用户名和密码不能为空']); + if (empty(trim($params['username'])) || empty(trim($params['password']))) { // 验证数据 + return $this->error('用户名和密码不能为空'); + } + + $username = trim($params['username']); + $failKey = 'login_fail_' . md5($username); + $fails = (int) cache($failKey); // 读取失败次数,并强制转为整数 + + if ($fails >= 5) { + return $this->error('失败次数过多,请 15 分钟后再试', [], 429); } try { - $result = $authService->login($params['username'], $params['password']); - return json(['code' => 200, 'message' => '登录成功', 'data' => $result]); + $result = $authService->login(trim($params['username']), trim($params['password'])); + cache($failKey, null); + return $this->success('登录成功', $result); } catch (\Exception $e) { - return json(['code' => 400, 'message' => $e->getMessage(), 'data' => []]); + cache($failKey, $fails + 1, 900); // 失败次数存活时间,15分钟之后自动清零 + return $this->error($e->getMessage()); } } @@ -37,6 +45,6 @@ class Login extends BaseController public function logout() { Session::clear(); - return json(['code' => 200, 'message' => '退出成功']); + return $this->success('退出成功'); } } diff --git a/app/index/controller/Ticket.php b/app/index/controller/Ticket.php index 4d444e0..7f7ea61 100644 --- a/app/index/controller/Ticket.php +++ b/app/index/controller/Ticket.php @@ -21,9 +21,9 @@ class Ticket extends BaseController try { $result = $ticketService->updateTicket($id, $data, $allowFields, $isUser, false); - return json(['code' => 200, 'msg' => '更新成功', 'data' => $result]); + return $this->success('更新成功', $result); } catch (\Exception $e) { - return json(['code' => 400, 'msg' => $e->getMessage()]); + return $this->error($e->getMessage()); } } } diff --git a/app/middleware/CheckLogin.php b/app/middleware/CheckLogin.php index 68bba46..860794a 100644 --- a/app/middleware/CheckLogin.php +++ b/app/middleware/CheckLogin.php @@ -2,12 +2,14 @@ declare (strict_types = 1); namespace app\middleware; +use app\common\Trait\ApiResponse; use think\facade\Session; class CheckLogin { + use ApiResponse; + /** * 处理请求 - * * @param \think\Request $request * @param \Closure $next * @return Response @@ -15,9 +17,8 @@ class CheckLogin public function handle($request, \Closure $next) { if (!Session::has('user_info')) { - return json(['code' => 401, 'message' => '未登录']); + return $this->error('未登录', [], 401); } - return $next($request); } } diff --git a/composer.json b/composer.json index a7fd3c7..2652967 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ "topthink/framework": "^8.0", "topthink/think-orm": "^3.0|^4.0", "topthink/think-filesystem": "^2.0|^3.0", - "topthink/think-multi-app": "^1.1" + "topthink/think-multi-app": "^1.1", + "workerman/workerman": "^4.2" }, "require-dev": { "topthink/think-dumper": "^1.0", diff --git a/config/database.php b/config/database.php index 679d618..dcb2bfb 100644 --- a/config/database.php +++ b/config/database.php @@ -15,7 +15,7 @@ return [ // 时间字段取出后的默认时间格式 'datetime_format' => 'Y-m-d H:i:s', - // 时间字段配置 配置格式:create_time,update_time + // 时间字段配置 配置格式:create_time,update_time 'datetime_field' => '', // 数据库连接配置信息 @@ -59,25 +59,20 @@ return [ ], 'remote' => [ // 数据库类型 - 'type' => env('REMOTE_DB_TYPE', 'mysql'), - // 服务器地址 - 'hostname' => env('REMOTE_DB_HOST', '127.0.0.1'), - // 数据库名 - 'database' => env('REMOTE_DB_NAME', ''), - // 用户名 - 'username' => env('REMOTE_DB_USER', 'root'), - // 密码 - 'password' => env('REMOTE_DB_PASS', ''), + 'type' => 'mysql', + // 服务器地址(必须从 .env 读取) + 'hostname' => env('REMOTE_DB_HOST'), + // 数据库名(必须从 .env 读取) + 'database' => env('REMOTE_DB_NAME'), + // 用户名(必须从 .env 读取) + 'username' => env('REMOTE_DB_USER'), + // 密码(必须从 .env 读取) + 'password' => env('REMOTE_DB_PASS'), // 端口 'hostport' => env('REMOTE_DB_PORT', '3306'), - // 数据库编码 - 'charset' => env('REMOTE_DB_CHARSET', 'utf8mb4'), - // 数据库表前缀 + 'charset' => 'utf8mb4', 'prefix' => '', - // 远程连接建议开启断线重连 'break_reconnect' => true, ], - - // 更多的数据库配置信息 ], ]; diff --git a/view/README.md b/view/README.md deleted file mode 100644 index 360eb24..0000000 --- a/view/README.md +++ /dev/null @@ -1 +0,0 @@ -如果不使用模板,可以删除该目录 \ No newline at end of file diff --git a/worker/websocket.php b/worker/websocket.php new file mode 100644 index 0000000..51bbac5 --- /dev/null +++ b/worker/websocket.php @@ -0,0 +1,79 @@ + connection) +$clients = []; + +// ---------- 主 worker:WebSocket 服务(员工前端连接) ---------- +$wsWorker = new Worker('websocket://0.0.0.0:2346'); +$wsWorker->name = 'ticket-broadcast'; + +$wsWorker->onConnect = function ($connection) use (&$clients) { + $connection->cid = spl_object_id($connection); + $clients[$connection->cid] = $connection; + echo '[' . date('Y-m-d H:i:s') . "] client connected, online: " . count($clients) . PHP_EOL; +}; + +$wsWorker->onClose = function ($connection) use (&$clients) { + if (isset($clients[$connection->cid])) { + unset($clients[$connection->cid]); + } + echo '[' . date('Y-m-d H:i:s') . "] client closed, online: " . count($clients) . PHP_EOL; +}; + +// ---------- 进程启动后追加 HTTP 触发端口(仅本机) ---------- +$wsWorker->onWorkerStart = function ($worker) use (&$clients) { + $httpWorker = new Worker('http://127.0.0.1:2347'); + $httpWorker->name = 'ticket-push-trigger'; + + $httpWorker->onMessage = function ($connection, Request $request) use (&$clients) { + if ($request->method() === 'POST' && $request->path() === '/push') { + $data = json_decode($request->rawBody(), true) ?: []; + + $payload = json_encode([ + 'type' => 'ticket_changed', + 'action' => isset($data['action']) ? (string)$data['action'] : 'update', + 'ticket_id' => isset($data['ticket_id']) ? (int)$data['ticket_id'] : null, + ], JSON_UNESCAPED_UNICODE); + + $sent = 0; + foreach ($clients as $client) { + $client->send($payload); + $sent++; + } + + echo '[' . date('Y-m-d H:i:s') . "] push [{$payload}] to {$sent} clients" . PHP_EOL; + $connection->send('ok'); + } else { + $connection->send('bad request'); + } + }; + + // 同一进程内动态监听第二端口(Windows 下多 worker 的限制的官方解法) + $httpWorker->listen(); +}; + +Worker::runAll();