table(); if ($table === '') { return 0; } $now = current_time('mysql'); $inserted = $wpdb->insert($table, [ 'ticket_num' => (string) ($data['ticket_num'] ?? ''), 'user_id' => (int) ($data['user_id'] ?? 0), 'title' => (string) ($data['title'] ?? ''), 'ticket_type_id' => (int) ($data['ticket_type_id'] ?? 0), 'content' => (string) ($data['content'] ?? ''), 'status' => (string) ($data['status'] ?? TicketStatusService::KEY_PENDING), 'order_number' => (string) ($data['order_number'] ?? ''), 'attachment' => (string) ($data['attachment'] ?? ''), 'post_time' => $now, 'notes' => (string) ($data['notes'] ?? ''), 'admin_id' => (int) ($data['admin_id'] ?? 0), 'post_admin_id' => (int) ($data['post_admin_id'] ?? 0), 'created_at' => $now, ], [ '%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', ]); return $inserted === 1 ? (int) $wpdb->insert_id : 0; } /** @return array|null */ public function find(int $id): ?array { global $wpdb; $table = $this->table(); if ($table === '' || $id <= 0) { return null; } $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL $id ), ARRAY_A); return is_array($row) ? $this->normalize($row) : null; } /** * 归属校验读取:用户视角必须命中本人。 * * @return array|null */ public function find_for_user(int $id, int $user_id): ?array { $row = $this->find($id); if ($row === null || (int) $row['user_id'] !== $user_id) { return null; } return $row; } /** * 工单号反查(后台深链 / 通知跳转)。 * * @return array|null */ public function find_by_number(string $ticket_num): ?array { global $wpdb; $table = $this->table(); if ($table === '' || $ticket_num === '') { return null; } $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE ticket_num = %s", // phpcs:ignore WordPress.DB.PreparedSQL $ticket_num ), ARRAY_A); return is_array($row) ? $this->normalize($row) : null; } /** * @param array $args * @return array{items:list>,total:int} */ public function paginate(array $args, bool $is_admin): array { return [ 'items' => array_map([$this, 'normalize'], $this->ticket_rows($args, $is_admin)), 'total' => $this->ticket_count($args, $is_admin), ]; } /** * @param array $args * @return array */ public function statistic(array $args, bool $is_admin): array { return $this->ticket_statistic($args, $is_admin); } public function update_fields(int $id, array $fields): bool { global $wpdb; $table = $this->table(); if ($table === '' || $id <= 0 || $fields === []) { return false; } $formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($fields)); return $wpdb->update($table, $fields, ['id' => $id], $formats, ['%d']) !== false; } /* ------------------------------------------------------------------ * * 状态机(对外入口,内部规则在 TicketStatus trait) * * Trait 必须与宿主类同级平铺:PSR-4 按命名空间寻址,不解析子目录。 * ------------------------------------------------------------------ */ /** * @param array $ticket */ public function receive(int $id, array $ticket): bool { return $this->receive_ticket($id, $ticket); } /** * @param array $ticket */ public function resolve(int $id, array $ticket): bool { return $this->resolve_ticket($id, $ticket); } /** * @param array $ticket */ public function close(int $id, array $ticket, string $actor): bool { return $this->close_ticket($id, $ticket, $actor); } /** * @param array $ticket */ public function apply_reply_status(int $id, array $ticket, string $author_type): bool { return $this->reply_status($id, $ticket, $author_type); } public function count_by_status(string $status_key): int { global $wpdb; $table = $this->table(); if ($table === '' || $status_key === '') { return 0; } return (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", // phpcs:ignore WordPress.DB.PreparedSQL $status_key )); } public function count_by_type(int $type_id): int { global $wpdb; $table = $this->table(); if ($table === '' || $type_id <= 0) { return 0; } return (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE ticket_type_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL $type_id )); } public function touch_reply(int $id, string $author_type, int $author_id): void { global $wpdb; $table = $this->table(); if ($table === '') { return; } $fields = ['last_reply_time' => current_time('mysql'), 'updated_at' => current_time('mysql')]; if ($author_type === 'admin') { $fields['last_reply_admin_id'] = $author_id; if ($author_id > 0) { $fields['admin_id'] = $author_id; } } $formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($fields)); $wpdb->update($table, $fields, ['id' => $id], $formats, ['%d']); } /* ------------------------------------------------------------------ * * 沟通记录 * ------------------------------------------------------------------ */ public function create_reply(int $ticket_id, string $author_type, int $author_id, string $content, array $attachment, int $quote_reply_id = 0): int { global $wpdb; $table = $this->reply_table(); if ($table === '' || $ticket_id <= 0 || $content === '') { return 0; } $inserted = $wpdb->insert($table, [ 'ticket_id' => $ticket_id, 'author_type' => $author_type, 'author_id' => $author_id, 'content' => $content, 'attachment' => TicketAttachment::encode($attachment), 'quote_reply_id' => max(0, $quote_reply_id), 'created_at' => current_time('mysql'), ], ['%d', '%s', '%d', '%s', '%s', '%d', '%s']); return $inserted === 1 ? (int) $wpdb->insert_id : 0; } /** * 沟通记录(按时间正序)+ 每条的作者名与引用摘要。 * * @return list> */ public function replies(int $ticket_id): array { global $wpdb; $table = $this->reply_table(); if ($table === '' || $ticket_id <= 0) { return []; } $rows = $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$table} WHERE ticket_id = %d ORDER BY created_at ASC, id ASC", // phpcs:ignore WordPress.DB.PreparedSQL $ticket_id ), ARRAY_A); if (!is_array($rows)) { return []; } $out = []; foreach ($rows as $row) { $out[] = $this->normalize_reply($row); } return $out; } /** @return array|null */ public function find_reply(int $reply_id): ?array { global $wpdb; $table = $this->reply_table(); if ($table === '' || $reply_id <= 0) { return null; } $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL $reply_id ), ARRAY_A); return is_array($row) ? $this->normalize_reply($row) : null; } public function delete_reply(int $reply_id): bool { global $wpdb; $table = $this->reply_table(); if ($table === '' || $reply_id <= 0) { return false; } return $wpdb->delete($table, ['id' => $reply_id], ['%d']) !== false; } /** * 某工单下管理员最后一次回复时间(邮件/通知文案用,暂留供后续接入)。 */ public function last_admin_reply_time(int $ticket_id): string { global $wpdb; $table = $this->reply_table(); if ($table === '' || $ticket_id <= 0) { return ''; } $value = $wpdb->get_var($wpdb->prepare( "SELECT created_at FROM {$table} WHERE ticket_id = %d AND author_type = 'admin' ORDER BY id DESC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL $ticket_id )); return $value === null ? '' : (string) $value; } /* ------------------------------------------------------------------ * * 规范化 * ------------------------------------------------------------------ */ /** * @param array $row * @return array */ private function normalize(array $row): array { return [ 'id' => (int) ($row['id'] ?? 0), 'ticket_num' => (string) ($row['ticket_num'] ?? ''), 'user_id' => (int) ($row['user_id'] ?? 0), 'title' => (string) ($row['title'] ?? ''), 'ticket_type_id' => (int) ($row['ticket_type_id'] ?? 0), 'ticket_type_name' => TicketTypeService::name((int) ($row['ticket_type_id'] ?? 0)), 'content' => (string) ($row['content'] ?? ''), 'status' => sanitize_key((string) ($row['status'] ?? '')), 'status_label' => TicketStatusService::label(sanitize_key((string) ($row['status'] ?? ''))), 'status_color' => TicketStatusService::color(sanitize_key((string) ($row['status'] ?? ''))), 'is_closed' => TicketStatusService::is_closed(sanitize_key((string) ($row['status'] ?? ''))), 'order_number' => (string) ($row['order_number'] ?? ''), 'attachment' => TicketAttachment::decode($row['attachment'] ?? ''), 'last_reply_time' => (string) ($row['last_reply_time'] ?? ''), 'post_time' => (string) ($row['post_time'] ?? ''), 'notes' => (string) ($row['notes'] ?? ''), 'admin_id' => (int) ($row['admin_id'] ?? 0), 'admin_name' => $this->user_name((int) ($row['admin_id'] ?? 0)), 'last_reply_admin_id' => (int) ($row['last_reply_admin_id'] ?? 0), 'post_admin_id' => (int) ($row['post_admin_id'] ?? 0), 'user_name' => $this->user_name((int) ($row['user_id'] ?? 0)), 'created_at' => (string) ($row['created_at'] ?? ''), 'updated_at' => (string) ($row['updated_at'] ?? ''), ]; } /** * @param array $row * @return array */ private function normalize_reply(array $row): array { $author_type = (string) ($row['author_type'] ?? '') === 'admin' ? 'admin' : 'user'; $author_id = (int) ($row['author_id'] ?? 0); $reply_id = (int) ($row['id'] ?? 0); $quote_id = (int) ($row['quote_reply_id'] ?? 0); return [ 'id' => $reply_id, 'ticket_id' => (int) ($row['ticket_id'] ?? 0), 'author_type' => $author_type, 'author_id' => $author_id, 'author_name' => $this->user_name($author_id), 'content' => (string) ($row['content'] ?? ''), 'attachment' => TicketAttachment::decode($row['attachment'] ?? ''), 'quote_reply_id' => $quote_id, 'quote' => $quote_id > 0 ? $this->quote_summary($quote_id, $reply_id) : null, 'created_at' => (string) ($row['created_at'] ?? ''), ]; } /** * 引用摘要:被引用的回复可能已被删除,此时返回 is_deleted 标记, * 由界面显示「原回复已删除」而不是空白块。 * * @return array|null */ private function quote_summary(int $quote_id, int $self_id): ?array { if ($quote_id === $self_id) { return null; } $quoted = $this->find_reply($quote_id); if ($quoted === null) { return [ 'id' => $quote_id, 'content' => '', 'author_type' => '', 'author_name' => '', 'created_at' => '', 'is_deleted' => true, ]; } return [ 'id' => $quoted['id'], 'content' => mb_substr((string) $quoted['content'], 0, 100), 'author_type' => $quoted['author_type'], 'author_name' => $quoted['author_name'], 'created_at' => $quoted['created_at'], 'is_deleted' => false, ]; } private function user_name(int $user_id): string { if ($user_id <= 0) { return ''; } $user = get_userdata($user_id); return $user instanceof \WP_User ? $user->display_name : ''; } }