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>
394 lines
14 KiB
PHP
394 lines
14 KiB
PHP
<?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 : '';
|
||
}
|
||
}
|