PHP项目怎么实现工单系统?

wen java案例 3

本文目录导读:

PHP项目怎么实现工单系统?

  1. 系统架构设计
  2. 数据库设计
  3. PHP核心实现
  4. 前端界面
  5. API接口设计
  6. 安全性和性能优化
  7. 测试用例

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

抱歉,评论功能暂时关闭!