Files
wordpress-theme-aksrt/src/Ticket/TicketRepository.php
T
kate522andClaude Opus 5 ffd8d7d1b7 fix(ticket): flatten TicketRepository traits to PSR-4-visible paths
TicketRepository fatalled on /user/tickets/ with "Trait AKSRT\Ticket\TicketQuery
not found". src/ classes load only via Composer PSR-4 (AKSRT\ => src/) — there is
no bootstrap require list for them — so the autoloader resolved the traits by
namespace to src/Ticket/TicketQuery.php and src/Ticket/TicketStatus.php. Both
files actually sat in src/Ticket/TicketRepository-traits/, one level deeper, and
were therefore invisible: PHP hit the `use` statement with no trait declared and
died the moment the repository compiled.

The nested directory name imitated the old inc/classes/**/*-traits/ layout, which
only worked because inc/bootstrap.php carried an explicit require_once for every
one of those files. No such mechanism exists under src/. Move both traits up to
sit flat as siblings of the class that uses them — the same shape as
GlobalControllerPart1, ModerationPart1 and the rest of src/. No code changes
inside the traits; their namespace was already correct.

Add tools/audit-psr4-paths.php, which checks every symbol under src/ against the
path Composer maps it to and exits 1 on mismatch. It flags this exact layout, so
the nesting cannot come back unnoticed. Document it in CLAUDE.md next to the
existing admin-contract audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-26 13:04:46 +08:00

394 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 工单仓储:全部 SQL 集中在此,上层(REST / 后台页)不直接碰 $wpdb。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketRepository {
use TicketQuery;
use TicketStatus;
private static ?self $instance = null;
public static function get_instance(): self {
return self::$instance ??= new self();
}
private function __construct() {}
private function table(): string {
return \AKSRT\Core\DBMigrator::table('ticket');
}
private function reply_table(): string {
return \AKSRT\Core\DBMigrator::table('ticket_reply');
}
public function create(array $data): int {
global $wpdb;
$table = $this->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<string,mixed>|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<string,mixed>|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<string,mixed>|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<string,mixed> $args
* @return array{items:list<array<string,mixed>>,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<string,mixed> $args
* @return array<string,int>
*/
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<string,mixed> $ticket
*/
public function receive(int $id, array $ticket): bool {
return $this->receive_ticket($id, $ticket);
}
/**
* @param array<string,mixed> $ticket
*/
public function resolve(int $id, array $ticket): bool {
return $this->resolve_ticket($id, $ticket);
}
/**
* @param array<string,mixed> $ticket
*/
public function close(int $id, array $ticket, string $actor): bool {
return $this->close_ticket($id, $ticket, $actor);
}
/**
* @param array<string,mixed> $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<array<string,mixed>>
*/
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<string,mixed>|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<string,mixed> $row
* @return array<string,mixed>
*/
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<string,mixed> $row
* @return array<string,mixed>
*/
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<string,mixed>|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 : '';
}
}