本文目录导读:

我来详细讲解PHP项目如何实现工单系统。
系统架构设计
目录结构
ticket-system/
├── app/
│ ├── Controllers/
│ │ ├── TicketController.php
│ │ ├── UserController.php
│ │ └── AdminController.php
│ ├── Models/
│ │ ├── Ticket.php
│ │ ├── User.php
│ │ └── Category.php
│ ├── Views/
│ │ ├── tickets/
│ │ └── admin/
│ └── Middleware/
│ └── Auth.php
├── public/
│ └── index.php
├── config/
│ └── database.php
└── vendor/
数据库设计
核心表结构
-- 用户表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role ENUM('user', 'agent', 'admin') DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 工单表
CREATE TABLE tickets (
id INT PRIMARY KEY AUTO_INCREMENT,
ticket_number VARCHAR(20) UNIQUE NOT NULL,
user_id INT NOT NULL,
category_id INT,VARCHAR(200) NOT NULL,
description TEXT,
priority ENUM('low', 'medium', 'high', 'urgent') DEFAULT 'medium',
status ENUM('open', 'in_progress', 'waiting', 'resolved', 'closed') DEFAULT 'open',
assigned_to INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (assigned_to) REFERENCES users(id),
FOREIGN KEY (category_id) REFERENCES categories(id)
);
-- 工单回复表
CREATE TABLE ticket_replies (
id INT PRIMARY KEY AUTO_INCREMENT,
ticket_id INT NOT NULL,
user_id INT NOT NULL,
message TEXT NOT NULL,
is_staff TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 分类表
CREATE TABLE categories (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 附件表
CREATE TABLE ticket_attachments (
id INT PRIMARY KEY AUTO_INCREMENT,
ticket_id INT,
reply_id INT,
filename VARCHAR(255) NOT NULL,
filepath VARCHAR(255) NOT NULL,
filesize INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(id),
FOREIGN KEY (reply_id) REFERENCES ticket_replies(id)
);
PHP核心实现
TicketController.php
<?php
namespace App\Controllers;
use App\Models\Ticket;
use App\Models\TicketReply;
use App\Helpers\Auth;
class TicketController {
// 创建工单
public function create($request) {
$auth = new Auth();
$user_id = $auth->getUserId();
$data = [
'ticket_number' => $this->generateTicketNumber(),
'user_id' => $user_id,
'category_id' => $request['category_id'],
'title' => $request['title'],
'description' => $request['description'],
'priority' => $request['priority'] ?? 'medium',
'status' => 'open'
];
$ticket = new Ticket();
if ($ticket_id = $ticket->create($data)) {
// 处理附件上传
if (!empty($_FILES['attachments'])) {
$this->handleAttachments($ticket_id, $_FILES['attachments']);
}
// 发送通知
$this->sendNotification('new_ticket', $ticket_id);
return ['success' => true, 'ticket_id' => $ticket_id];
}
return ['success' => false, 'message' => '创建工单失败'];
}
// 生成工单编号
private function generateTicketNumber() {
$prefix = 'TKT';
$timestamp = time();
$random = rand(1000, 9999);
return $prefix . date('Ymd', $timestamp) . $random;
}
// 获取用户工单列表
public function getUserTickets($page = 1, $perPage = 10) {
$auth = new Auth();
$user_id = $auth->getUserId();
$ticket = new Ticket();
$tickets = $ticket->getByUserId($user_id, $page, $perPage);
$total = $ticket->countByUserId($user_id);
return [
'tickets' => $tickets,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'pages' => ceil($total / $perPage)
]
];
}
// 回复工单
public function reply($request) {
$auth = new Auth();
$user_id = $auth->getUserId();
$ticket_id = $request['ticket_id'];
$message = $request['message'];
// 验证权限
if (!$this->canReply($ticket_id, $user_id)) {
return ['success' => false, 'message' => '无权回复此工单'];
}
$ticketReply = new TicketReply();
$reply_data = [
'ticket_id' => $ticket_id,
'user_id' => $user_id,
'message' => $message,
'is_staff' => $auth->isAdmin() || $auth->isAgent()
];
if ($reply_id = $ticketReply->create($reply_data)) {
// 更新工单状态
$ticket = new Ticket();
$ticket->update($ticket_id, [
'status' => $auth->isAdmin() ? 'in_progress' : 'waiting',
'updated_at' => date('Y-m-d H:i:s')
]);
// 处理附件
if (!empty($_FILES['attachments'])) {
$this->handleAttachments($ticket_id, $_FILES['attachments'], $reply_id);
}
// 发送通知
$this->sendNotification('new_reply', $ticket_id);
return ['success' => true, 'reply_id' => $reply_id];
}
return ['success' => false, 'message' => '回复失败'];
}
// 处理附件上传
private function handleAttachments($ticket_id, $files, $reply_id = null) {
$upload_dir = __DIR__ . '/../../public/uploads/tickets/';
if (!is_dir($upload_dir)) {
mkdir($upload_dir, 0755, true);
}
foreach ($files['tmp_name'] as $key => $tmp_name) {
if ($files['error'][$key] === UPLOAD_ERR_OK) {
$filename = $files['name'][$key];
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$new_filename = uniqid() . '.' . $ext;
if (move_uploaded_file($tmp_name, $upload_dir . $new_filename)) {
// 保存到数据库
$attachment = new \App\Models\TicketAttachment();
$attachment->create([
'ticket_id' => $ticket_id,
'reply_id' => $reply_id,
'filename' => $filename,
'filepath' => 'uploads/tickets/' . $new_filename,
'filesize' => $files['size'][$key]
]);
}
}
}
}
// 更新工单状态
public function updateStatus($request) {
$ticket_id = $request['ticket_id'];
$status = $request['status'];
$auth = new Auth();
if (!$auth->isAdmin() && !$auth->isAgent()) {
return ['success' => false, 'message' => '权限不足'];
}
$ticket = new Ticket();
// 获取工单当前信息
$current_ticket = $ticket->find($ticket_id);
if (!$current_ticket) {
return ['success' => false, 'message' => '工单不存在'];
}
// 分配工单
if ($status === 'in_progress' && empty($current_ticket['assigned_to'])) {
$ticket->update($ticket_id, [
'status' => $status,
'assigned_to' => $auth->getUserId()
]);
} else {
$ticket->update($ticket_id, ['status' => $status]);
}
// 记录状态变更历史
$this->logStatusChange($ticket_id, $current_ticket['status'], $status);
return ['success' => true, 'message' => '状态更新成功'];
}
}
Ticket模型
<?php
namespace App\Models;
class Ticket {
private $db;
public function __construct() {
$this->db = \App\Helpers\Database::getInstance();
}
public function create($data) {
$sql = "INSERT INTO tickets (ticket_number, user_id, category_id, title, description, priority, status)
VALUES (:ticket_number, :user_id, :category_id, :title, :description, :priority, :status)";
$stmt = $this->db->prepare($sql);
$stmt->execute($data);
return $this->db->lastInsertId();
}
public function find($id) {
$sql = "SELECT t.*, u.username, c.name as category_name
FROM tickets t
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.id = :id";
$stmt = $this->db->prepare($sql);
$stmt->execute([':id' => $id]);
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
public function getByUserId($user_id, $page, $perPage) {
$offset = ($page - 1) * $perPage;
$sql = "SELECT * FROM tickets WHERE user_id = :user_id
ORDER BY created_at DESC LIMIT :limit OFFSET :offset";
$stmt = $this->db->prepare($sql);
$stmt->bindParam(':user_id', $user_id, \PDO::PARAM_INT);
$stmt->bindParam(':limit', $perPage, \PDO::PARAM_INT);
$stmt->bindParam(':offset', $offset, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
public function search($filters, $page, $perPage) {
$conditions = [];
$params = [];
if (!empty($filters['status'])) {
$conditions[] = "status = :status";
$params[':status'] = $filters['status'];
}
if (!empty($filters['priority'])) {
$conditions[] = "priority = :priority";
$params[':priority'] = $filters['priority'];
}
if (!empty($filters['keyword'])) {
$conditions[] = "(title LIKE :keyword OR description LIKE :keyword)";
$params[':keyword'] = '%' . $filters['keyword'] . '%';
}
$where = !empty($conditions) ? 'WHERE ' . implode(' AND ', $conditions) : '';
$offset = ($page - 1) * $perPage;
$sql = "SELECT * FROM tickets {$where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset";
$stmt = $this->db->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindParam(':limit', $perPage, \PDO::PARAM_INT);
$stmt->bindParam(':offset', $offset, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}
前端界面
创建工单表单 (create_ticket.php)
<form action="/ticket/create" method="POST" enctype="multipart/form-data">
<div class="form-group">
<label for="title">标题</label>
<input type="text" id="title" name="title" required>
</div>
<div class="form-group">
<label for="category">分类</label>
<select id="category" name="category_id" required>
<?php foreach ($categories as $category): ?>
<option value="<?= $category['id'] ?>">
<?= htmlspecialchars($category['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="priority">优先级</label>
<select id="priority" name="priority">
<option value="low">低</option>
<option value="medium" selected>中</option>
<option value="high">高</option>
<option value="urgent">紧急</option>
</select>
</div>
<div class="form-group">
<label for="description">描述</label>
<textarea id="description" name="description" rows="5" required></textarea>
</div>
<div class="form-group">
<label for="attachments">附件</label>
<input type="file" id="attachments" name="attachments[]" multiple>
<small>支持的文件类型:jpg, png, pdf, zip (最大10MB)</small>
</div>
<button type="submit" class="btn btn-primary">提交工单</button>
</form>
工单列表页面样式
/* ticket-list.css */
.ticket-list {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.ticket-item {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
transition: box-shadow 0.3s;
}
.ticket-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.ticket-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.ticket-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.ticket-meta {
display: flex;
gap: 15px;
font-size: 14px;
color: #666;
}
.ticket-status {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-open { background: #e8f5e9; color: #2e7d32; }
.status-in_progress { background: #e3f2fd; color: #1565c0; }
.status-resolved { background: #fff3e0; color: #ef6c00; }
.status-closed { background: #f5f5f5; color: #616161; }
.ticket-description {
margin-top: 10px;
color: #555;
line-height: 1.6;
}
/* 响应式设计 */
@media (max-width: 768px) {
.ticket-header {
flex-direction: column;
}
.ticket-meta {
margin-top: 10px;
flex-wrap: wrap;
}
}
API接口设计
// routes/api.php
// 创建工单
POST /api/tickets
// 请求体:
{: "系统登录问题",
"category_id": 1,
"description": "无法登录系统...",
"priority": "high"
}
// 获取工单列表
GET /api/tickets?status=open&page=1&per_page=10
// 获取工单详情
GET /api/tickets/{id}
// 回复工单
POST /api/tickets/{id}/replies
// 请求体:
{
"message": "请尝试清除浏览器缓存"
}
// 更新工单状态
PUT /api/tickets/{id}/status
// 请求体:
{
"status": "resolved"
}
安全性和性能优化
// 安全中间件
class SecurityMiddleware {
public function handle($request) {
// 防止XSS
$request = $this->sanitizeInput($request);
// CSRF保护
if (!$this->validateCsrfToken($request)) {
throw new \Exception('CSRF token validation failed');
}
// 访问控制
$this->checkPermissions($request);
return $request;
}
private function sanitizeInput($data) {
if (is_array($data)) {
return array_map([$this, 'sanitizeInput'], $data);
}
return htmlspecialchars(trim($data), ENT_QUOTES, 'UTF-8');
}
}
// 缓存优化
class TicketCache {
public function getTicket($id) {
$cacheKey = "ticket:{$id}";
// 尝试从缓存获取
if ($cached = $this->redis->get($cacheKey)) {
return json_decode($cached, true);
}
// 从数据库获取并缓存
$ticket = $this->db->find($id);
$this->redis->setex($cacheKey, 3600, json_encode($ticket));
return $ticket;
}
}
测试用例
// tests/TicketTest.php
class TicketTest extends PHPUnit\Framework\TestCase {
public function testCreateTicket() {
$controller = new TicketController();
$request = [
'title' => '测试工单',
'category_id' => 1,
'description' => '这是一个测试',
'priority' => 'medium'
];
$result = $controller->create($request);
$this->assertTrue($result['success']);
$this->assertNotEmpty($result['ticket_id']);
}
public function testTicketWorkflow() {
// 测试工单状态流转
$ticketController = new TicketController();
// 创建工单
$createResult = $ticketController->create([...]);
// 回复工单
$replyResult = $ticketController->reply([
'ticket_id' => $createResult['ticket_id'],
'message' => '客服回复'
]);
// 关闭工单
$closeResult = $ticketController->updateStatus([
'ticket_id' => $createResult['ticket_id'],
'status' => 'resolved'
]);
$this->assertTrue($closeResult['success']);
}
}
这个工单系统包含了完整的CRUD操作、权限控制、文件上传、通知系统等核心功能,你可以根据具体需求进行调整和扩展。
版权声明:足球直播
足球直播
足球直播
足球直播
足球直播
中超直播
中超直播
中超直播
中超直播
中超直播
英超直播
英超直播
英超直播
英超直播
英超直播
德甲直播
德甲直播
德甲直播
德甲直播
德甲直播
西甲直播
西甲直播
西甲直播
西甲直播
西甲直播
意甲直播
意甲直播
意甲直播
意甲直播
意甲直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
篮球直播
篮球直播
篮球直播
篮球直播
篮球直播
足球直播
足球直播
足球直播
足球直播
足球直播
中超直播
中超直播
中超直播
中超直播
中超直播
英超直播
英超直播
英超直播
英超直播
英超直播
德甲直播
德甲直播
德甲直播
德甲直播
德甲直播
西甲直播
西甲直播
西甲直播
西甲直播
西甲直播
意甲直播
意甲直播
意甲直播
意甲直播
意甲直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
篮球直播
篮球直播
篮球直播
篮球直播
篮球直播
足球直播
足球直播
足球直播
足球直播
足球直播
中超直播
中超直播
中超直播
中超直播
中超直播
英超直播
英超直播
英超直播
英超直播
英超直播
德甲直播
德甲直播
德甲直播
德甲直播
德甲直播
西甲直播
西甲直播
西甲直播
西甲直播
西甲直播
意甲直播
意甲直播
意甲直播
意甲直播
意甲直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
篮球直播
篮球直播
篮球直播
篮球直播
篮球直播