Compare commits

...

25 Commits

Author SHA1 Message Date
f9adabf5d8 wip 2026-08-26 17:50:41 +08:00
d3866efcd8 wip 2026-08-19 19:56:47 +08:00
07a24c5297 wip 2026-08-19 14:14:29 +08:00
30ae8f813d Merge branch 'dev' of https://gitea.fzcaiji.com/Ggy_shinidie/NewTicket into dev 2026-08-18 14:55:41 +08:00
94efeb524d Merge branch 'main' into dev 2026-08-18 14:55:24 +08:00
497a47382d Merge branch 'main' into dev 2026-08-18 11:35:39 +08:00
7eda834a9f 删除部分不需要的文件 2026-08-18 11:22:46 +08:00
dc118e1368 wip 2026-08-14 13:50:43 +08:00
9aa0006990 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: Ggy_shinidie/NewTicket#1
2026-08-14 13:44:58 +08:00
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
9789a64ebb wip 2026-07-21 16:40:38 +08:00
9f8d6d629a wip 2026-07-20 16:54:59 +08:00
247e113014 修改远程数据库配置 2026-07-20 15:15:48 +08:00
77930b3232 wip 2026-07-20 14:59:16 +08:00
cd1175b304 修改配置文件 2026-07-06 16:25:45 +08:00
131cbccb35 新增可修改人员 2026-05-28 10:00:31 +08:00
38 changed files with 842 additions and 340 deletions

View File

@@ -2,9 +2,9 @@ APP_DEBUG = true
DB_TYPE = mysql DB_TYPE = mysql
DB_HOST = 127.0.0.1 DB_HOST = 127.0.0.1
DB_NAME = test DB_NAME =
DB_USER = username DB_USER =
DB_PASS = password DB_PASS =
DB_PORT = 3306 DB_PORT = 3306
DB_CHARSET = utf8 DB_CHARSET = utf8

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

3
.gitignore vendored
View File

@@ -10,4 +10,5 @@ Thumbs.db
/.settings /.settings
/.buildpath /.buildpath
/.project /.project
/runtime /runtime
/.github

61
AGENTS.md Normal file
View File

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

@@ -1,32 +0,0 @@
ThinkPHP遵循Apache2开源协议发布,并提供免费使用。
版权所有Copyright © 2006-2025 by ThinkPHP (http://thinkphp.cn)
All rights reserved。
ThinkPHP® 商标和著作权所有者为上海顶想信息科技有限公司。
Apache Licence是著名的非盈利开源组织Apache采用的协议。
该协议和BSD类似,鼓励代码共享和尊重原作者的著作权,
允许代码修改,再作为开源或商业软件发布。需要满足
的条件:
1. 需要给代码的用户一份Apache Licence ;
2. 如果你修改了代码,需要在被修改的文件中说明;
3. 在延伸的代码中(修改和有源代码衍生的代码中)需要
带有原来代码中的协议,商标,专利声明和其他原来作者规
定需要包含的说明;
4. 如果再发布的产品中包含一个Notice文件,则在Notice文
件中需要带有本协议内容。你可以在Notice中增加自己的
许可,但不可以表现为对Apache Licence构成更改。
具体的协议参考:http://www.apache.org/licenses/LICENSE-2.0
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,77 +0,0 @@
![](https://www.thinkphp.cn/uploads/images/20230630/300c856765af4d8ae758c503185f8739.png)
ThinkPHP 8
===============
## 特性
* 基于PHP`8.0+`重构
* 升级`PSR`依赖
* 依赖`think-orm`3.0+版本
* 全新的`think-dumper`服务,支持远程调试
* 支持`6.0`/`6.1`无缝升级
> ThinkPHP8的运行环境要求PHP8.0+
现在开始,你可以使用官方提供的[ThinkChat](https://chat.topthink.com/),让你在学习ThinkPHP的旅途中享受私人AI助理服务!
![](https://www.topthink.com/uploads/assistant/20230630/4d1a3f0ad2958b49bb8189b7ef824cb0.png)
ThinkPHP生态服务由[顶想云](https://www.topthink.com)(TOPThink Cloud)提供,为生态提供专业的开发者服务和价值之选。
## 文档
[完全开发手册](https://doc.thinkphp.cn)
## 赞助
全新的[赞助计划](https://www.thinkphp.cn/sponsor)可以让你通过我们的网站、手册、欢迎页及GIT仓库获得巨大曝光,同时提升企业的品牌声誉,也更好保障ThinkPHP的可持续发展。
[![](https://www.thinkphp.cn/sponsor/special.svg)](https://www.thinkphp.cn/sponsor/special)
[![](https://www.thinkphp.cn/sponsor.svg)](https://www.thinkphp.cn/sponsor)
## 安装
~~~
composer create-project topthink/think tp
~~~
启动服务
~~~
cd tp
php think run
~~~
然后就可以在浏览器中访问
~~~
http://localhost:8000
~~~
如果需要更新框架使用
~~~
composer update topthink/framework
~~~
## 命名规范
`ThinkPHP`遵循PSR-2命名规范和PSR-4自动加载规范。
## 参与开发
直接提交PR或者Issue即可
## 版权信息
ThinkPHP遵循Apache2开源协议发布,并提供免费使用。
本项目包含的第三方源码和二进制文件之版权信息另行标注。
版权所有Copyright © 2006-2024 by ThinkPHP (http://thinkphp.cn) All rights reserved。
ThinkPHP® 商标和著作权所有者为上海顶想信息科技有限公司。
更多细节参阅 [LICENSE.txt](LICENSE.txt)

View File

@@ -1,17 +1,21 @@
<?php <?php
declare (strict_types = 1);
declare(strict_types=1);
namespace app; namespace app;
use think\App; use think\App;
use think\exception\ValidateException; use think\exception\ValidateException;
use think\Validate; use think\Validate;
use app\common\Trait\ApiResponse;
/** /**
* 控制器基础类 * 控制器基础类
*/ */
abstract class BaseController abstract class BaseController
{ {
use ApiResponse;
/** /**
* Request实例 * Request实例
* @var \think\Request * @var \think\Request
@@ -51,8 +55,7 @@ abstract class BaseController
} }
// 初始化 // 初始化
protected function initialize() protected function initialize() {}
{}
/** /**
* 验证数据 * 验证数据
@@ -90,5 +93,4 @@ abstract class BaseController
return $v->failException(true)->check($data); return $v->failException(true)->check($data);
} }
} }

View File

@@ -1,4 +1,5 @@
<?php <?php
namespace app; namespace app;
use think\db\exception\DataNotFoundException; use think\db\exception\DataNotFoundException;
@@ -9,12 +10,15 @@ use think\exception\HttpResponseException;
use think\exception\ValidateException; use think\exception\ValidateException;
use think\Response; use think\Response;
use Throwable; use Throwable;
use app\common\Trait\ApiResponse;
/** /**
* 应用异常处理类 * 应用异常处理类
*/ */
class ExceptionHandle extends Handle class ExceptionHandle extends Handle
{ {
use ApiResponse;
/** /**
* 不需要记录信息(日志)的异常类列表 * 不需要记录信息(日志)的异常类列表
* @var array * @var array
@@ -50,7 +54,20 @@ class ExceptionHandle extends Handle
*/ */
public function render($request, Throwable $e): Response public function render($request, Throwable $e): Response
{ {
// 添加自定义异常处理机制 // 验证器异常
if ($e instanceof ValidateException) {
return $this->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); return parent::render($request, $e);

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

@@ -13,10 +13,8 @@ class CompanyAndOperators extends BaseController
*/ */
public function getAllOperators(TicketService $ticketService) public function getAllOperators(TicketService $ticketService)
{ {
$result = $ticketService->getAllOperators(); $result = $ticketService->getAllOperators();
return $this->success('查询成功', $result);
return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]);
} }
@@ -26,7 +24,7 @@ class CompanyAndOperators extends BaseController
$result = $ticketService->TestgetAllOperators(); $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) public function getStaffList(TicketService $ticketService)
{ {
$result = $ticketService->getStaffList(); $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); $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); $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('删除成功');
} }
@@ -68,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());
} }
} }
@@ -80,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,8 +5,11 @@ 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;
use app\common\service\TicketScope;
class Ticket extends BaseController class Ticket extends BaseController
{ {
@@ -14,24 +17,30 @@ class Ticket extends BaseController
/** /**
* 查询工单 * 查询工单
*/ */
public function ticketsFind(TicketService $ticketService) public function fetchTickets(TicketService $ticketService)
{ {
// 在控制器明确接收参数 // 在控制器明确接收参数
$params = [ $params = [
"page" => $this->request->param('page', 1, 'intval'), "page" => $this->request->param('page', 1, 'intval'),
"limit" => $this->request->param('limit', 20, 'intval'), "limit" => $this->request->param('limit', 20, 'intval'),
"status" => $this->request->param('status', null),
"operator_id" => $this->request->param('operator_id', null), "operator_id" => $this->request->param('operator_id', null),
"user_id" => $this->request->param('user_id', null), "user_id" => $this->request->param('user_id', null),
"status" => $this->request->param('status', null),
"searchKeyword" => $this->request->param('search', null), "searchKeyword" => $this->request->param('search', null),
"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';
try {
$result = $ticketService->queryTickets($params, $page, $limit, $order, TicketScope::admin());
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]); return $this->success('查询成功', $result);
} }
@@ -44,10 +53,13 @@ class Ticket extends BaseController
try { try {
$result = $ticketService->createTicket($data); $result = $ticketService->createTicket($data);
return json(['code' => 200, 'msg' => '创建成功', 'data' => $result]);
} catch (\Exception $e) { } 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());
} }
return $this->success('创建成功', $result);
} }
@@ -64,10 +76,11 @@ class Ticket extends BaseController
try { try {
$result = $ticketService->updateTicket($id, $data, $allowFields, $adminId, true); $result = $ticketService->updateTicket($id, $data, $allowFields, $adminId, true);
return json(['code' => 200, 'msg' => '更新成功', 'data' => $result]);
} catch (\Exception $e) { } catch (\Exception $e) {
return json(['code' => 400, 'msg' => $e->getMessage(), 'data' => []]); return $this->error($e->getMessage());
} }
return $this->success('更新成功', $result);
} }
@@ -77,7 +90,7 @@ class Ticket extends BaseController
public function getTicketDashboardStats(TicketService $ticketService) public function getTicketDashboardStats(TicketService $ticketService)
{ {
$result = $ticketService->getTicketDashboardStats(); $result = $ticketService->getTicketDashboardStats();
return json(['code' => 200, 'msg' => '查询成功', 'data' => $result]); return $this->success('查询成功', $result);
} }
/** /**
@@ -90,16 +103,16 @@ class Ticket extends BaseController
$ids = $data['ids'] ?? []; $ids = $data['ids'] ?? [];
if (!is_array($ids) || empty($ids)) { if (!is_array($ids) || empty($ids)) {
return json(['code' => 400, 'msg' => 'ids 必须是非空数组', 'data' => []]); return $this->error('ids 必须是非空数组');
} }
$allowFields = ['status', 'lock_time']; $allowFields = ['status', 'lock_time'];
try { try {
$result = $ticketService->batchUpdateTickets($ids, $data, $allowFields, $adminId, true); $result = $ticketService->batchUpdateTickets($ids, $data, $allowFields, $adminId, true);
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' => []]); return $this->error($e->getMessage());
} }
} }
@@ -109,10 +122,16 @@ class Ticket extends BaseController
public function deleteTicket($id) public function deleteTicket($id)
{ {
$ticket = Tickets::find($id); $ticket = Tickets::find($id);
if (!$ticket) return json(['code' => false, 'msg' => '工单不存在', 'data' => []]); if (!$ticket) return $this->error('工单不存在');
return $ticket->delete() $deleted = $ticket->delete();
? json(['code' => true, 'msg' => '删除成功', 'data' => []]) if (!$deleted) {
: json(['code' => false, 'msg' => '删除失败', 'data' => []]); 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,20 +2,54 @@
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 角色即可访问的路由名称(方法名小写)
*/
protected $publicActions = [];
/**
* 需要 admin 角色才能访问的路由名称(方法名小写)
*/
protected $adminActions = [
'deleteoperator',
'deletecompany',
'addcompany',
'addoperator',
'getallusers',
'addaccount',
'updateaccountstatus',
'deleteaccount',
'getexportdata',
'deleteticket',
'batchupdate',
];
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');
$role = $user['role'] ?? 'user';
$request->login_user_id = $user['id'] ?? null; $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); return $next($request);
} }

View File

@@ -2,16 +2,16 @@
use think\facade\Route; use think\facade\Route;
use app\admin\middleware\Auth; use app\admin\middleware\Auth;
use app\middleware\CheckLogin;
Route::group(function () { Route::group(function () {
Route::get('ticketsfind', 'Ticket/ticketsFind'); Route::get('ticketsfind', 'Ticket/fetchTickets');
Route::post('addticket', 'Ticket/createTicket'); Route::post('addticket', 'Ticket/createTicket');
Route::put('ticket/:id', 'Ticket/updateTicket'); Route::put('ticket/:id', 'Ticket/updateTicket');
Route::delete('ticket/:id', 'Ticket/deleteTicket'); Route::delete('ticket/:id', 'Ticket/deleteTicket');
Route::put('tickets/batch-update', 'Ticket/batchUpdate'); Route::put('tickets/batch-update', 'Ticket/batchUpdate');
Route::get('getticketdashboardstats', 'Ticket/getTicketDashboardStats'); Route::get('getticketdashboardstats', 'Ticket/getTicketDashboardStats');
Route::get('getUserStaffList', 'CompanyAndOperators/getStaffList'); Route::get('getUserStaffList', 'CompanyAndOperators/getStaffList');
Route::get('operators', 'CompanyAndOperators/getAllOperators'); Route::get('operators', 'CompanyAndOperators/getAllOperators');
Route::get('getOperatorList', 'CompanyAndOperators/TestgetAllOperators'); Route::get('getOperatorList', 'CompanyAndOperators/TestgetAllOperators');
@@ -26,4 +26,5 @@ Route::group(function () {
Route::delete('deleteAccount/:id', 'AccountManagement/deleteAccount'); Route::delete('deleteAccount/:id', 'AccountManagement/deleteAccount');
Route::post('getExportData', 'Utill/getExportData'); Route::post('getExportData', 'Utill/getExportData');
})->middleware(Auth::class); })->middleware([]);
// [CheckLogin::class, Auth::class]

View File

@@ -12,15 +12,20 @@ class AccountService
{ {
$users = UserModel::with('company')->select(); $users = UserModel::with('company')->select();
return $users->map(function ($user) { return $users->map(function ($user) {
$companyName = $user->role === 'client' && (bool) $user->can_view_all_tickets
? '全部客户'
: ($user->company->company_name ?? '');
return [ return [
'id' => $user->id, 'id' => $user->id,
'company' => [ 'company' => [
'id' => $user->company_id, 'id' => $user->company_id,
'company_name' => $user->company->company_name 'company_name' => $companyName
], ],
'username' => $user->username, 'username' => $user->username,
'real_name' => $user->real_name, 'real_name' => $user->real_name,
'role' => $user->role, 'role' => $user->role,
'can_view_all_tickets' => (bool) $user->can_view_all_tickets,
'status' => $user->status, 'status' => $user->status,
]; ];
})->toArray(); })->toArray();
@@ -40,19 +45,34 @@ class AccountService
throw new \Exception('用户名已存在'); throw new \Exception('用户名已存在');
} }
$role = $data['role'] ?? 'user';
$companyId = (int) ($data['company_id'] ?? 0);
$canViewAllTickets = $role === 'client' && !empty($data['can_view_all_tickets']);
// 系统中只允许存在一个可查看全部工单的客户汇总账号。
if ($canViewAllTickets) {
$allAccessClient = UserModel::where('role', 'client')
->where('can_view_all_tickets', 1)
->find();
if ($allAccessClient) {
throw new \Exception('已存在可查看全部工单的客户账号');
}
}
$insertData = [ $insertData = [
'company_id' => $data['company_id'] ?? 0, 'company_id' => $companyId,
'username' => $data['username'], 'username' => $data['username'],
'real_name' => $data['real_name'] ?? $data['username'], 'real_name' => $data['real_name'] ?? $data['username'],
'password' => password_hash('123456', PASSWORD_DEFAULT), 'password' => password_hash('123456', PASSWORD_DEFAULT),
'role' => $data['role'] ?? 'user', 'role' => $role,
'can_view_all_tickets' => $canViewAllTickets ? 1 : 0,
'status' => 1, 'status' => 1,
]; ];
$user = UserModel::create($insertData); $user = UserModel::create($insertData);
/** @var \think\Model $user */ /** @var \think\Model $user */
return $user->visible(['id', 'username', 'real_name', 'role', 'status'])->toArray(); return $user->visible(['id', 'username', 'real_name', 'role', 'can_view_all_tickets', 'status'])->toArray();
} }
/* 更新用户状态 */ /* 更新用户状态 */

View File

@@ -0,0 +1,12 @@
<?php
declare (strict_types = 1);
namespace app\api\controller;
class Index
{
public function index()
{
return '您好!这是一个[api]示例应用';
}
}

View File

@@ -2,65 +2,45 @@
namespace app\common\Trait; namespace app\common\Trait;
use think\Response; use think\response\Json;
/**
* API 响应 Trait
* 提供统一的 JSON 响应格式
*/
trait ApiResponse trait ApiResponse
{ {
/** /**
* 成功响应 * 成功响应
* * @param string $message 提示信息
* @param mixed $data 响应数据 * @param mixed $data 返回数据
* @param string $msg 响应消息 * @param int $code 自定义业务状态码
* @param int $code 响应码 * @return Json
* @param int $httpCode HTTP状态码
* @return Response
*/ */
protected function successResponse($data = [], $msg = '成功', $code = 200, $httpCode = 200) protected function success(string $message = '操作成功', mixed $data = [], int $code = 200): Json
{ {
return json([ return $this->jsonResponse($message, $data, $code);
'code' => $code,
'msg' => $msg,
'data' => $data
], $httpCode);
} }
/** /**
* 错误响应 * 失败响应
* * @param string $message 错误提示信息
* @param string $msg 错误消息 * @param int $code 自定义业务状态码
* @param mixed $data 错误数据 * @param mixed $data 额外的错误数据(如验证未通过的具体字段)
* @param int $code 错误码 * @return Json
* @param int $httpCode HTTP状态码
* @return Response
*/ */
protected function errorResponse($msg = '失败', $data = null, $code = 400, $httpCode = 400) protected function error(string $message = '操作失败', mixed $data = [], int $code = 400): Json
{ {
return json([ return $this->jsonResponse($message, $data, $code);
'code' => $code,
'msg' => $msg,
'data' => $data
], $httpCode);
} }
/** /**
* 自定义响应 * 统一 JSON 返回格式
*
* @param int $code 响应码
* @param string $msg 响应消息
* @param mixed $data 响应数据
* @param int $httpCode HTTP状态码
* @return Response
*/ */
protected function customResponse($code, $msg, $data = [], $httpCode = 200) private function jsonResponse(string $message, mixed $data, int $code): Json
{ {
return json([ $result = [
'code' => $code, 'code' => $code,
'msg' => $msg, 'msg' => $message,
'data' => $data 'data' => $data,
], $httpCode); ];
return json($result);
} }
} }

View File

@@ -2,12 +2,14 @@
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
* @param \Closure $next * @param \Closure $next
* @return Response * @return Response
@@ -15,9 +17,8 @@ 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

@@ -16,7 +16,6 @@ class Tickets extends Model
protected $createTime = 'create_time'; protected $createTime = 'create_time';
protected $updateTime = 'update_time'; protected $updateTime = 'update_time';
# 定义字段类型 # 定义字段类型
protected $type = [ protected $type = [
'id' => 'integer', 'id' => 'integer',
@@ -30,10 +29,17 @@ class Tickets extends Model
'remark' => 'string', 'remark' => 'string',
'lock_time' => 'integer', 'lock_time' => 'integer',
'create_time' => 'integer', 'create_time' => 'integer',
'update_time' => 'integer', 'update_time' => 'integer',
]; ];
# 定义状态常量
const STATUS_UNASSIGNED = 0;
const STATUS_PROCESSING = 1;
const STATUS_COMPLETED = 2;
const STATUS_CLOSED = 3;
/* /*
时间戳获取器 时间戳获取器
*/ */

View File

@@ -12,6 +12,10 @@ class User extends Model
{ {
protected $table = 'users'; protected $table = 'users';
protected $type = [
'can_view_all_tickets' => 'boolean',
];
public function company(){ public function company(){
return $this->belongsTo(Companys::class,'company_id','id'); return $this->belongsTo(Companys::class,'company_id','id');
} }

View File

@@ -18,25 +18,28 @@ class AuthService
->find(); ->find();
if (!$user || !password_verify($password, $user['password'])) { if (!$user || !password_verify($password, $user['password'])) {
throw new \Exception('用户名或密码错误'); throw new \Exception('用户名或密码错误');
} }
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_name' => $user['company']['company_name'], // 所属公司名称 'company_id' => $user['company_id'], // 所属公司ID
'username' => $user['username'], 'company_name' => $user['role'] === 'client' && (bool) $user['can_view_all_tickets']
'role' => $user['role'], ? '全部客户'
: ($user['company']['company_name'] ?? ''), // 所属公司名称
'username' => $user['username'],
'role' => $user['role'],
'can_view_all_tickets' => (bool) $user['can_view_all_tickets'],
]; ];
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

@@ -0,0 +1,35 @@
<?php
namespace app\common\service;
/**
* 把当前用户能看哪些工单封装成一个明确的对象TicketScope,避免用零散变量或含义不清的数组传递数据范围
* final 表示不允许被其他类继承
*/
final class TicketScope
{
# readonly 仅在PHP8.1中存在,当前版本8.0.2
private function __construct(
public string $type,
public ?int $staffId = null,
public ?int $clientId = null
) {}
/** 管理员:不限制状态、不限制归属 */
public static function admin(): self
{
return new self('admin');
}
/** 员工:未分配全部可见,其余状态仅自己 */
public static function staff(int $staffId): self
{
return new self('staff', staffId: $staffId);
}
/** 客户:$clientId 只能看自己的,传null可以看当前公司所有的 */
public static function client(?int $clientId): self
{
return new self('client', clientId: $clientId);
}
}

View File

@@ -2,111 +2,126 @@
namespace app\common\service; namespace app\common\service;
use app\admin\controller\Ticket;
use app\common\model\Tickets; 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 app\common\service\TicketScope;
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 queryTickets(array $params, int $page, int $limit, string $order, TicketScope $scope)
public function getTicketList(array $params)
{ {
try {
// 查询构造器
$query = Tickets::with(['ticketStaff', 'ticketCompany', 'ticketOperator']);
$query = Tickets::with(['ticketStaff', 'ticketCompany', 'ticketOperator']); // 查询构造器 $this->applyScope($query, $scope);
// 状态筛选 $equalFilters = ['operator_id', 'user_id', 'status'];
if ($params['status'] !== null) { foreach ($equalFilters as $field) {
$query->where('status', $params['status']); if (isset($params[$field]) && $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['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;
} }
/** private function applyScope($query, TicketScope $scope): void
* 新增工单
*
*/
public function createTicket($data)
{ {
if (empty($data['website'])) { match ($scope->type) {
throw new \Exception('网站不能为空'); 'client' => null,
'staff' => $query->where(fn($q) => $q
->where('status', Tickets::STATUS_UNASSIGNED)
->where('user_id', $scope->staffId)),
};
}
/**
* 创建工单业务逻辑
* @param array $data 工单数据
* @return Tickets
* @throws \Exception
*/
public function createTicket(array $data)
{
if (!isset($data['website']) || trim((string)$data['website']) === '') { // isset检查键存在且不为null
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;
} }
@@ -118,7 +133,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();
@@ -126,6 +141,8 @@ class TicketService
throw new \Exception('工单不存在'); throw new \Exception('工单不存在');
} }
// 提取 $allowFields 允许修改的字段 // 提取 $allowFields 允许修改的字段
$updateData = []; $updateData = [];
foreach ($allowFields as $field) { foreach ($allowFields as $field) {

View File

@@ -13,12 +13,14 @@ class ClientTicket extends BaseController
*/ */
public function getClientTickets(ClientTicketService $ClientTicketService) public function getClientTickets(ClientTicketService $ClientTicketService)
{ {
$clientCompanyId = Session::get('user_info.company_id'); // 获取当前登录客户ID $clientCompanyId = (int) Session::get('user_info.company_id'); // 获取当前登录客户ID
$canViewAll = Session::get('user_info.role') === 'client'
&& (bool) Session::get('user_info.can_view_all_tickets');
try { try {
$tickets = $ClientTicketService->getClientTickets($clientCompanyId); $tickets = $ClientTicketService->getClientTickets($clientCompanyId, $canViewAll);
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

@@ -10,7 +10,6 @@ use think\facade\Session;
// 已完成 // 已完成
class Login extends BaseController class Login extends BaseController
{ {
/* /*
处理登录请求(已完成) 处理登录请求(已完成)
*/ */
@@ -18,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 json(['code' => 400, 'message' => '用户名和密码不能为空']); }
$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 json(['code' => 200, 'message' => '登录成功', 'data' => $result]); cache($failKey, null);
return $this->success('登录成功', $result);
} catch (\Exception $e) { } 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() public function logout()
{ {
Session::clear(); Session::clear();
return json(['code' => 200, 'message' => '退出成功']); return $this->success('退出登录');
} }
} }

View File

@@ -15,15 +15,18 @@ class Ticket extends BaseController
{ {
$isUser = Session::get('user_info.role') === 'user'; // 获取当前登录ID $isUser = Session::get('user_info.role') === 'user'; // 获取当前登录ID
$data = $this->request->post(); $post = $this->request->post();
$allowFields = ['datanum', 'progress', 'status', 'remark', 'lock_time']; $data = [
'datanum' => $post['datanum']
]
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

@@ -0,0 +1,26 @@
<?php
namespace app\client\controller;
use app\BaseController;
use app\index\service\ClientTicketService;
use think\facade\Session;
class Index extends BaseController
{
/**
* 获取客户关联工单
*/
public function fetchTickets(ClientTicketService $ClientTicketService)
{
$clientCompanyId = (int) Session::get('user_info.company_id'); // 获取当前登录客户ID
$canViewAll = Session::get('user_info.role') === 'client'
&& (bool) Session::get('user_info.can_view_all_tickets');
try {
$tickets = $ClientTicketService->getClientTickets($clientCompanyId, $canViewAll);
return $this->success('获取成功', $tickets);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace app\staff\controller;
use app\BaseController;
use app\common\service\TicketService;
use app\common\service\TicketScope;
use think\facade\Session;
class Ticket extends BaseController
{
public function fetchTickets(TicketService $TicketService)
{
$params = [
"page" => $this->request->param('page', 1, 'intval'),
"limit" => $this->request->param('limit', 20, 'intval'),
"operator_id" => $this->request->param('operator_id', null),
"status" => $this->request->param('status', null),
"searchKeyword" => $this->request->param('search', null),
"sortOrder" => $this->request->param('sort_order', 'desc'),
];
$staffId = (int) Session::get('user_info.id'); // 若没有,则为0
if ($staffId <= 0) {
return $this->error('无法获取当前员工信息');
}
$page = max(($params['page'] ?? 1), 1);
$limit = min(max(($params['limit'] ?? 20), 1), 100);
$order = strtolower($params['sortOrder'] ?? 'desc') === 'asc' ? 'asc' : 'desc';
try {
$result = $TicketService->queryTickets($params, $page, $limit, $order, TicketScope::staff($staffId));
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
return $this->success('查询成功', $result);
}
/**
* 更新工单(员工调用)
*/
public function updateTicket(TicketService $ticketService, $id)
{
$isUser = Session::get('user_info.role') === 'user';
$data = $this->request->post();
$allowFields = ['datanum', 'progress', 'status', 'remark', 'lock_time', 'user_id'];
try {
$result = $ticketService->updateTicket($id, $data, $allowFields, $isUser, false);
return $this->success('更新成功', $result);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
}

View File

@@ -8,17 +8,20 @@ use app\common\model\Tickets;
class ClientTicketService class ClientTicketService
{ {
/* 获取客户关联工单 */ /* 获取客户关联工单 */
public function getClientTickets($clientCompanyId) public function getClientTickets(int $clientCompanyId, bool $canViewAll = false)
{ {
# 查询当前登录用户所对应的公司的工单 // 普通客户仅查询所属公司的工单;唯一的汇总客户账号可查看全部。
$tickets = Tickets::where('company_id', $clientCompanyId)->select(); $query = Tickets::whereIn('status', [0, 1, 2]);
if (!$canViewAll) {
$query->where('company_id', $clientCompanyId);
}
$tickets = $query->select();
$newList = []; $newList = [];
foreach ($tickets as $ticket) { foreach ($tickets as $ticket) {
if (in_array($ticket->status, [0, 1, 2])) { $ticket->visible(['id', 'website', 'datanum', 'progress', 'status']);
$ticket->visible(['id', 'website', 'datanum', 'progress', 'status']); $newList[] = $ticket->toArray();
$newList[] = $ticket->toArray();
}
} }
return $newList; return $newList;

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

@@ -15,7 +15,7 @@ return [
// 时间字段取出后的默认时间格式 // 时间字段取出后的默认时间格式
'datetime_format' => 'Y-m-d H:i:s', 'datetime_format' => 'Y-m-d H:i:s',
// 时间字段配置 配置格式:create_time,update_time // 时间字段配置 配置格式:create_time,update_time
'datetime_field' => '', 'datetime_field' => '',
// 数据库连接配置信息 // 数据库连接配置信息
@@ -58,17 +58,21 @@ return [
'fields_cache' => false, 'fields_cache' => false,
], ],
'remote' => [ 'remote' => [
// 数据库类型
'type' => 'mysql', 'type' => 'mysql',
'hostname' => '112.5.15.136', // 服务器地址(必须从 .env 读取)
'database' => '112_5_15_136_255', 'hostname' => env('REMOTE_DB_HOST'),
'username' => '112_5_15_136_255', // 数据库名(必须从 .env 读取)
'password' => 'xz1YX1jswSWzkE4K', 'database' => env('REMOTE_DB_NAME'),
'hostport' => '3306', // 用户名(必须从 .env 读取)
'username' => env('REMOTE_DB_USER'),
// 密码(必须从 .env 读取)
'password' => env('REMOTE_DB_PASS'),
// 端口
'hostport' => env('REMOTE_DB_PORT', '3306'),
'charset' => 'utf8mb4', 'charset' => 'utf8mb4',
'prefix' => '', // 如果有表前缀请填写 'prefix' => '',
'break_reconnect' => true, // 远程连接建议开启断线重连 'break_reconnect' => true,
], ],
// 更多的数据库配置信息
], ],
]; ];

View File

@@ -4,7 +4,6 @@
// +---------------------------------------------------------------------- // +----------------------------------------------------------------------
return [ return [
// session name
'name' => 'PHPSESSID', 'name' => 'PHPSESSID',
// SESSION_ID的提交变量,解决flash上传跨域 // SESSION_ID的提交变量,解决flash上传跨域
'var_session_id' => '', 'var_session_id' => '',

View File

@@ -0,0 +1,6 @@
-- Up: 为客户汇总账号增加显式的全量工单查看标记。
ALTER TABLE `users`
ADD COLUMN `can_view_all_tickets` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `role`;
-- Rollback(需要回滚时单独执行):
-- ALTER TABLE `users` DROP COLUMN `can_view_all_tickets`;

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 = [];
// ---------- 主 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();