feat(ticket): replace the after-sale flow with a general ticket system

The old 售后 flow was an order-refund appendage: tickets were hard-bound to an
order number, users could only file them from 「我的订单」against a paid order,
and the admin action bottomed out at 「标记订单已退款」. It could not carry
account questions or feature requests that have nothing to do with an order.

Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket:
title + department + description are required, linking an order is optional
(user's own paid orders only). Add departments, custom statuses, preset replies
and internal admin notes as four configuration surfaces. Conversation history
moves off the ticket row's JSON log into dedicated ticket_reply rows, which
unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end
moves from 支付中心 → 售后服务 to users.php → 工单管理.

Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new
ones, delete the old code. No dual paths, no data migration. The upstream
plugin's upstream/downstream delivery and internal-ticket conversion are
deliberately left out — both are multi-provider platform concerns with no
meaning on a single WordPress site.

Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply,
ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION
2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status
「待接单」is flagged as non-editable.

Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse
UserController::rest_upload_comment_image with a ticket-specific permission
callback, mirroring the existing /report/upload-image route rather than
growing a second upload path.

Notifications go through AKSRT\Messages\Message::send() and deep-link to the
right surface; email/SMS stay out of scope since the theme's mail and SMS
stacks are separate implementations.

Refund gate opens up: marking an order refunded no longer requires an in-flight
ticket, only that the order is paid.

Also generalise a hardcoded special case in tools/audit-admin-contracts.php —
schema domain inference now reads the parent directory instead of matching
"PostList" by name, so new leaf schema files need no per-name exception.

Verification:
  php tools/audit-admin-contracts.php   exit 0, errors=0
  node --test (widget-system + admin)   664 pass / 0 fail
  php -l over all new/changed files     clean
  node --check over all JS              clean
  npm run build + build:public          clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-10-25 23:29:51 +08:00
1 parent 761c84d70f
commit dbb7de41e9
81 files changed
+5812 -4179

No files matched your search

+391
View File
@@ -0,0 +1,391 @@
<?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)
* ------------------------------------------------------------------ */
/**
* @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 : '';
}
}