本文目录导读:

我来详细讲解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操作、权限控制、文件上传、通知系统等核心功能,你可以根据具体需求进行调整和扩展。