Compare commits

...

10 Commits

Author SHA1 Message Date
396556a30f wip 2026-08-14 11:48:53 +08:00
831113e22f wip 2026-08-12 15:31:07 +08:00
b8cd7e7658 wip 2026-08-08 16:28:22 +08:00
d4c08a8cdf wip 2026-08-08 15:06:12 +08:00
98b60c333a wip 2026-08-05 17:38:29 +08:00
e831ce810b wip 2026-08-05 14:05:08 +08:00
9ffc9687d0 wip 2026-08-05 11:25:25 +08:00
8380b8797b wip 2026-08-05 11:06:20 +08:00
b4e5e98353 wip 2026-08-05 10:31:38 +08:00
83c0865272 wip 2026-08-05 09:50:25 +08:00
21 changed files with 502 additions and 131 deletions

View File

@@ -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

View File

@@ -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 系统。

View File

@@ -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. 结论可信度
- 已确认的事实(从代码中直接读到)
- 根据代码作出的推断
- 尚未验证的假设(需要运行时验证)

61
AGENTS.md Normal file
View File

@@ -0,0 +1,61 @@
# AGENTS.md — NewTicket 项目约束
## 技术栈(后端)
- **语言/运行时**PHP 8.0.2 NTSphpStudy 自带,位于 `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 服务器**小皮面板phpStudyApache 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/<route>`(如 `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. **验证**:优先在本地真实环境(小皮面板托管服务)做实际验证,并展示真实命令输出;临时验证产生的数据、进程、脚本必须在结束后清理;无法运行时明确说明哪些结论仅为静态分析。
## 修改报告模板
每次改动完成后,按以下结构输出报告:
```
## 修改报告
### 修改内容
- 涉及文件与函数:
- 具体改动点:
### 修改原因
### 影响范围
- 调用方/关联模块:
### 验证方式与结果
- 执行命令与实际输出(粘贴关键结果):
### 剩余风险 / 注意事项
```

View File

@@ -56,17 +56,17 @@ class ExceptionHandle extends Handle
{ {
// 验证器异常 // 验证器异常
if ($e instanceof ValidateException) { if ($e instanceof ValidateException) {
return $this->error($e->getError(), 422); // 验证器没通过 return $this->error($e->getError(), [], 422); // 验证器没通过
}; };
// 请求异常 // 请求异常
if ($e instanceof HttpException) { if ($e instanceof HttpException) {
return $this->error($e->getMessage(), $e->getStatusCode()); return $this->error($e->getMessage(), [], $e->getStatusCode());
}; };
// 处理【自定义业务异常】(在 Service 层主动 throw 的错误) // 处理【自定义业务异常】(在 Service 层主动 throw 的错误)
if ($e instanceof \think\Exception) { if ($e instanceof \think\Exception) {
return $this->error($e->getMessage(), $e->getCode() ?: 400); return $this->error($e->getMessage(), [], $e->getCode() ?: 400);
} }
// 其他错误交给系统处理 // 其他错误交给系统处理

View File

@@ -11,7 +11,7 @@ class AccountManagement extends BaseController
public function getAllUsers(AccountService $accountService) public function getAllUsers(AccountService $accountService)
{ {
$result = $accountService->getAllUsers(); $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'])) { if (empty($params['username'])) {
return json(['code' => 400, 'message' => '缺少必要参数']); return $this->error('缺少必要参数');
} }
# 业务逻辑 # 业务逻辑
try { try {
$result = $accountService->createAccount($params); $result = $accountService->createAccount($params);
return json(['code' => 200, 'message' => '账户创建成功', 'data' => $result]); return $this->success('账户创建成功', $result);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error($e->getMessage());
return json([
'code' => 400,
'message' => $e->getMessage(),
'data' => null
]);
} }
} }
@@ -45,9 +40,9 @@ class AccountManagement extends BaseController
{ {
try { try {
$result = $accountService->updateAccountStatus($id, $status); $result = $accountService->updateAccountStatus($id, $status);
return json(['code' => 200, 'message' => '账户状态更新成功', 'data' => $result]); return $this->success('账户状态更新成功', $result);
} catch (\Exception $e) { } 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 { try {
$result = $accountService->deleteAccount($id); $result = $accountService->deleteAccount($id);
return json(['code' => 200, 'message' => '账户删除成功', 'data' => $result]); return $this->success('账户删除成功', $result);
} catch (\Exception $e) { } catch (\Exception $e) {
return json(['code' => 400, 'message' => $e->getMessage(), 'data' => null]); return $this->error($e->getMessage());
} }
} }
} }

View File

@@ -14,7 +14,7 @@ class CompanyAndOperators extends BaseController
public function getAllOperators(TicketService $ticketService) public function getAllOperators(TicketService $ticketService)
{ {
$result = $ticketService->getAllOperators(); $result = $ticketService->getAllOperators();
return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]); return $this->success('查询成功', $result);
} }
@@ -24,7 +24,7 @@ class CompanyAndOperators extends BaseController
$result = $ticketService->TestgetAllOperators(); $result = $ticketService->TestgetAllOperators();
return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]); return $this->success('查询成功', $result);
} }
@@ -36,7 +36,7 @@ class CompanyAndOperators extends BaseController
public function getStaffList(TicketService $ticketService) public function getStaffList(TicketService $ticketService)
{ {
$result = $ticketService->getStaffList(); $result = $ticketService->getStaffList();
return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]); return $this->success('查询成功', $result);
} }
@@ -46,7 +46,7 @@ class CompanyAndOperators extends BaseController
$result = $CompanyOperatorService->deleteOperator($id); $result = $CompanyOperatorService->deleteOperator($id);
return json(['code' => 200, 'msg' => '删除成功', 'data' => $result]); return $this->success('删除成功', $result);
} }
/* 删除客户单位 */ /* 删除客户单位 */
@@ -54,9 +54,9 @@ class CompanyAndOperators extends BaseController
{ {
$result = $CompanyOperatorService->deleteCompany($id); $result = $CompanyOperatorService->deleteCompany($id);
if (!$result) { if (!$result) {
return json(['code' => 400, 'msg' => '删除失败', 'data' => null]); return $this->error('删除失败');
} }
return json(['code' => 200, 'msg' => '删除成功']); return $this->success('删除成功');
} }
@@ -66,9 +66,9 @@ class CompanyAndOperators extends BaseController
$data = $this->request->post(); $data = $this->request->post();
try { try {
$result = $CompanyOperatorService->addCompany($data); $result = $CompanyOperatorService->addCompany($data);
return json(['code' => 200, 'msg' => '创建成功', 'data' => $result]); return $this->success('创建成功', $result);
} catch (\Exception $e) { } catch (\Exception $e) {
return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => null]); return $this->error($e->getMessage());
} }
} }
@@ -78,9 +78,9 @@ class CompanyAndOperators extends BaseController
$data = $this->request->post(); $data = $this->request->post();
try { try {
$result = $CompanyOperatorService->addOperator($data); $result = $CompanyOperatorService->addOperator($data);
return json(['code' => 200, 'msg' => '创建成功', 'data' => $result]); return $this->success('创建成功', $result);
} catch (\Exception $e) { } catch (\Exception $e) {
return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => null]); return $this->error($e->getMessage());
} }
} }

View File

@@ -5,6 +5,7 @@ namespace app\admin\controller;
use app\BaseController; use app\BaseController;
use app\common\model\Tickets; use app\common\model\Tickets;
use app\common\service\TicketNotifier;
use app\common\service\TicketService; use app\common\service\TicketService;
use think\facade\Session; use think\facade\Session;
@@ -27,7 +28,11 @@ class Ticket extends BaseController
"sortOrder" => $this->request->param('sort_order', 'desc'), "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 $this->success('查询成功', $result); return $this->success('查询成功', $result);
} }
@@ -44,6 +49,9 @@ class Ticket extends BaseController
$result = $ticketService->createTicket($data); $result = $ticketService->createTicket($data);
return $this->success('创建成功', $result); return $this->success('创建成功', $result);
} catch (\Exception $e) { } catch (\Exception $e) {
if ($e->getCode() === 409) {
return $this->error($e->getMessage(), ['confirm' => true], 409);
}
return $this->error($e->getMessage()); return $this->error($e->getMessage());
} }
} }
@@ -109,8 +117,14 @@ class Ticket extends BaseController
$ticket = Tickets::find($id); $ticket = Tickets::find($id);
if (!$ticket) return $this->error('工单不存在'); if (!$ticket) return $this->error('工单不存在');
return $ticket->delete() $deleted = $ticket->delete();
? $this->success('删除成功', []) if (!$deleted) {
: $this->error('删除失败', []); return $this->error('删除失败', []);
}
// 通知前端实时刷新(静默,广播服务不可用不影响删除流程)
TicketNotifier::notify($id, 'delete');
return $this->success('删除成功', []);
} }
} }

View File

@@ -19,12 +19,12 @@ class Utill extends BaseController
# session中获取当前登录是否是管理员 # session中获取当前登录是否是管理员
$isAdmin = Session::get('user_info.role') === 'admin' ? true : false; // 后续用来判断 $isAdmin = Session::get('user_info.role') === 'admin' ? true : false; // 后续用来判断
if (!$isAdmin) { if (!$isAdmin) {
return json(['code' => 403, 'msg' => '权限不足', 'data' => []]); return $this->error('权限不足', [], 403);
} }
$data = $UtillService->getExportData($range); $data = $UtillService->getExportData($range);
return json(['code' => 200, 'msg' => '查询成功', 'data' => $data]); return $this->success('查询成功', $data);
} catch (\Exception $e) { } catch (\Exception $e) {
return json(['code' => 500, 'msg' => '查询失败', 'data' => []]); return $this->error('查询失败', [], 500);
} }
} }
} }

View File

@@ -2,10 +2,13 @@
namespace app\admin\middleware; namespace app\admin\middleware;
use app\common\Trait\ApiResponse;
use think\facade\Session; use think\facade\Session;
class Auth class Auth
{ {
use ApiResponse;
/** /**
* 无需 admin 角色即可访问的路由名称(方法名小写) * 无需 admin 角色即可访问的路由名称(方法名小写)
*/ */
@@ -31,7 +34,7 @@ class Auth
public function handle($request, \Closure $next) public function handle($request, \Closure $next)
{ {
if (!Session::has('user_info')) { if (!Session::has('user_info')) {
return json(['code' => 401, 'msg' => '未登录,请先登录']); return $this->error('未登录,请先登录', [], 401);
} }
$user = Session::get('user_info'); $user = Session::get('user_info');
@@ -45,7 +48,7 @@ class Auth
// admin 专用接口 // admin 专用接口
if (in_array($action, $this->adminActions) && $role !== 'admin') { if (in_array($action, $this->adminActions) && $role !== 'admin') {
return json(['code' => 403, 'msg' => '权限不足,仅管理员可执行此操作']); return $this->error('权限不足,仅管理员可执行此操作', [], 403);
} }
return $next($request); return $next($request);

View File

@@ -2,7 +2,6 @@
namespace app\common\Trait; namespace app\common\Trait;
use think\Response;
use think\response\Json; use think\response\Json;
trait ApiResponse trait ApiResponse
@@ -26,7 +25,7 @@ trait ApiResponse
* @param mixed $data 额外的错误数据(如验证未通过的具体字段) * @param mixed $data 额外的错误数据(如验证未通过的具体字段)
* @return Json * @return Json
*/ */
protected function error(string $message = '操作失败', mixed $data = [], int $code = 400): Json protected function error(string $message = '操作失败', mixed $data = [], int $code = 400): Json
{ {
return $this->jsonResponse($message, $data, $code); return $this->jsonResponse($message, $data, $code);
} }

View File

@@ -24,18 +24,19 @@ class AuthService
} }
if ($user['status'] != 1) { if ($user['status'] != 1) {
throw new \Exception('账户已禁用'); throw new \Exception('账户已禁用,请联系管理员');
} }
$userInfo = [ $userInfo = [
'id' => $user['id'], // 用户ID 'id' => $user['id'], // 用户ID
'company_id' => $user['company_id'], // 所属公司ID
'company_name' => $user['company']['company_name'], // 所属公司名称 'company_name' => $user['company']['company_name'], // 所属公司名称
'username' => $user['username'], 'username' => $user['username'],
'role' => $user['role'], 'role' => $user['role'],
]; ];
Session::set('user_info', $user); Session::set('user_info', $userInfo);
return $userInfo; return $userInfo;
} }

View File

@@ -0,0 +1,50 @@
<?php
namespace app\common\service;
/**
* 工单变更通知(通过 Workerman 广播服务推送到前端)
*
* 重要约定:通知失败绝不能影响主业务流程,因此所有异常静默处理。
* 广播服务未启动时,新增/更新工单照常执行,只是前端不实时刷新。
*/
class TicketNotifier
{
/** 广播服务内部触发地址(仅监听本机) */
private const PUSH_URL = 'http://127.0.0.1:2347/push';
/** 请求超时(秒),不能太长以免拖慢主流程 */
private const TIMEOUT = 2;
/**
* 广播工单变更
*
* @param int|string $ticketId 工单ID
* @param string $action create|update|delete
* @return bool 通知是否成功发出false 不表示业务失败)
*/
public static function notify($ticketId, $action = 'create')
{
$payload = json_encode([
'action' => (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;
}
}
}

View File

@@ -6,94 +6,101 @@ use app\common\model\Tickets;
use app\common\model\CompanyOperators; use app\common\model\CompanyOperators;
use app\common\model\Companys; use app\common\model\Companys;
use app\common\model\User; use app\common\model\User;
use think\facade\Db;
use think\facade\Cache;
class TicketService 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)
{ {
$page = max(intval($params['page'] ?? 1), 1); try {
$limit = min(max(intval($params['limit'] ?? 20), 1), 100); // 查询构造器
$order = strtolower($params['sortOrder']) === 'asc' ? 'asc' : 'desc'; $query = Tickets::with(['ticketStaff', 'ticketCompany', 'ticketOperator']);
// 查询构造器 $equalFilters = ['operator_id', 'user_id', 'status'];
$query = Tickets::with(['ticketStaff', 'ticketCompany', 'ticketOperator']); foreach ($equalFilters as $field) {
if (isset($params[$field]) && $params[$field] !== '') {
$equalFilters = ['operator_id', 'user_id', 'status']; $query->where($field, $params[$field]);
foreach ($equalFilters as $field) { }
if (!empty($params[$field])) {
$query->where($field, $params[$field]);
} }
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['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;
} }
/** /**
* 新增工单 * 创建工单业务逻辑
* * @param array $data 工单数据
* @return Tickets
* @throws \Exception
*/ */
public function createTicket($data) public function createTicket(array $data)
{ {
if (empty($data['website'])) { if (!isset($data['website']) || trim((string)$data['website']) === '') { // isset检查键存在且不为null
throw new \Exception('网站不能为空'); throw new \Exception('网站输入不能为空');
} }
// 统一查重与落库值
$website = trim($data['website']); $website = trim($data['website']);
$data['website'] = $website;
// 并发锁 $ticketResult = Db::transaction(function () use ($data, $website) {
$lockKey = 'create_ticket_lock_' . md5($website); $unfinishedTicket = Tickets::where('website', $website) // 查找是否有未完成的同网站工单
if (Cache::has($lockKey)) {
throw new \Exception('当前该网址正在被创建工单中,请稍后重试');
}
Cache::set($lockKey, 1, 5); // 锁定5秒
try {
// 查找是否有未完成的同网站工单
$unfinishedTicket = Tickets::where('website', $website)
->whereIn('status', [0, 1]) ->whereIn('status', [0, 1])
->lock(true)
->find(); ->find();
if ($unfinishedTicket) { if ($unfinishedTicket) {
Cache::delete($lockKey); // 同运营判断(为null或者相同运营)
throw new \Exception('已存在未完成的同网站工单,无法创建'); $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);
}
} }
// 当前无相同网站 unset($data['confirm']); // 移除确认标记,避免落库
$ticketResult = Tickets::create($data);
return $ticketResult; return Tickets::create($data); // 创建新工单
} catch (\Exception $e) {
Cache::delete($lockKey); });
throw $e;
} // 通知前端实时刷新(静默,广播服务不可用不影响主流程)
TicketNotifier::notify($ticketResult->id, 'create');
return $ticketResult;
} }
@@ -105,7 +112,7 @@ class TicketService
* @param $role 当前登录用户角色 * @param $role 当前登录用户角色
* @return array * @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(); $ticket = Tickets::where('id', $id)->find();

View File

@@ -16,9 +16,9 @@ class ClientTicket extends BaseController
$clientCompanyId = Session::get('user_info.company_id'); // 获取当前登录客户ID $clientCompanyId = Session::get('user_info.company_id'); // 获取当前登录客户ID
try { try {
$tickets = $ClientTicketService->getClientTickets($clientCompanyId); $tickets = $ClientTicketService->getClientTickets($clientCompanyId);
return json(['code' => 200, 'msg' => '获取成功', 'data' => $tickets]); return $this->success('获取成功', $tickets);
} catch (\Exception $e) { } catch (\Exception $e) {
return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => null]); return $this->error($e->getMessage());
} }
} }
} }

View File

@@ -17,16 +17,25 @@ class Login extends BaseController
{ {
$params = $this->request->only(['username', 'password'], 'post'); $params = $this->request->only(['username', 'password'], 'post');
// 验证数据 if (empty(trim($params['username'])) || empty(trim($params['password']))) { // 验证数据
if (empty($params['username']) || empty($params['password'])) { return $this->error('用户名和密码不能为空');
return $this->error(400, '用户名和密码不能为空'); }
$username = trim($params['username']);
$failKey = 'login_fail_' . md5($username);
$fails = (int) cache($failKey); // 读取失败次数,并强制转为整数
if ($fails >= 5) {
return $this->error('失败次数过多,请 15 分钟后再试', [], 429);
} }
try { try {
$result = $authService->login($params['username'], $params['password']); $result = $authService->login(trim($params['username']), trim($params['password']));
return $this->success(200, '登录成功', $result); cache($failKey, null);
return $this->success('登录成功', $result);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->error(400, $e->getMessage()); cache($failKey, $fails + 1, 900); // 失败次数存活时间15分钟之后自动清零
return $this->error($e->getMessage());
} }
} }
@@ -36,6 +45,6 @@ class Login extends BaseController
public function logout() public function logout()
{ {
Session::clear(); Session::clear();
return $this->success(200, '退出成功'); return $this->success('退出成功');
} }
} }

View File

@@ -21,9 +21,9 @@ class Ticket extends BaseController
try { try {
$result = $ticketService->updateTicket($id, $data, $allowFields, $isUser, false); $result = $ticketService->updateTicket($id, $data, $allowFields, $isUser, false);
return json(['code' => 200, 'msg' => '更新成功', 'data' => $result]); return $this->success('更新成功', $result);
} catch (\Exception $e) { } catch (\Exception $e) {
return json(['code' => 400, 'msg' => $e->getMessage()]); return $this->error($e->getMessage());
} }
} }
} }

View File

@@ -2,9 +2,12 @@
declare (strict_types = 1); declare (strict_types = 1);
namespace app\middleware; namespace app\middleware;
use app\common\Trait\ApiResponse;
use think\facade\Session; use think\facade\Session;
class CheckLogin class CheckLogin
{ {
use ApiResponse;
/** /**
* 处理请求 * 处理请求
* @param \think\Request $request * @param \think\Request $request
@@ -14,7 +17,7 @@ class CheckLogin
public function handle($request, \Closure $next) public function handle($request, \Closure $next)
{ {
if (!Session::has('user_info')) { if (!Session::has('user_info')) {
return json(['code' => 401, 'message' => '未登录']); return $this->error('未登录', [], 401);
} }
return $next($request); return $next($request);
} }

View File

@@ -24,7 +24,8 @@
"topthink/framework": "^8.0", "topthink/framework": "^8.0",
"topthink/think-orm": "^3.0|^4.0", "topthink/think-orm": "^3.0|^4.0",
"topthink/think-filesystem": "^2.0|^3.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": { "require-dev": {
"topthink/think-dumper": "^1.0", "topthink/think-dumper": "^1.0",

View File

@@ -1 +0,0 @@
如果不使用模板,可以删除该目录

79
worker/websocket.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
/**
* Workerman 广播服务
* ------------------------------------------------------------
* 启动: php worker/websocket.php start
* 停止: 前台运行按 Ctrl+C 即可Windows 不支持 stop 命令)
*
* 端口:
* 2346 websocket 端口,员工前端长连接(只推送,不做业务)
* 2347 http 触发端口,仅监听 127.0.0.1,供 ThinkPHP 内部调用
*
* 触发方式POST http://127.0.0.1:2347/push
* body: {"action":"create","ticket_id":123}
*
* 注意:
* - Workerman 在 Windows 上不支持一个文件声明多个 worker
* 因此这里采用官方推荐的「单 worker + onWorkerStart 动态 listen()」方案,
* 两个端口跑在同一个进程内,共享在线连接表。
* - 部署到 Linux 后此写法同样适用,可额外增加 Worker::count 多进程。
*/
use Workerman\Worker;
use Workerman\Protocols\Http\Request;
require_once __DIR__ . '/../vendor/autoload.php';
// 当前在线前端连接connection id => connection
$clients = [];
// ---------- 主 workerWebSocket 服务(员工前端连接) ----------
$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();