Files
wordpress-theme-aksrt/src/Ticket/TicketConfigRest.php
T
kate522andClaude Opus 5 dbb7de41e9 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>
2026-10-25 23:29:51 +08:00

285 lines
13 KiB
PHP

<?php
/**
* 工单配置 CRUD trait:自定义状态 / 部门 / 预设回复 / 后台备注。
*
* 由 TicketRestAdmin 组合,工单本身的读写留在 TicketRestAdmin。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
trait TicketConfigRest {
/* ------------------------------------------------------------------ *
* 状态
* ------------------------------------------------------------------ */
public function list_statuses(\WP_REST_Request $request) {
return rest_ensure_response(['success' => true, 'data' => TicketStatusService::all()]);
}
public function create_status(\WP_REST_Request $request) {
$name = trim((string) $request->get_param('name'));
if ($name === '' || mb_strlen($name) > 60) {
return new \WP_Error('aksrt_ticket_status_name', __('请填写有效的状态名称', 'aksrt'), ['status' => 400]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '') {
return new \WP_Error('aksrt_ticket_db', __('状态表不可用', 'aksrt'), ['status' => 500]);
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'status_key' => TicketStatusService::make_key($name),
'name' => $name,
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
'sort_order' => $max + 1,
'created_at' => current_time('mysql'),
], ['%s', '%s', '%s', '%d', '%d', '%s']);
if ($inserted !== 1) {
return new \WP_Error('aksrt_ticket_db', __('状态创建失败', 'aksrt'), ['status' => 500]);
}
TicketStatusService::flush();
return rest_ensure_response(['success' => true]);
}
public function update_status(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
$row = $this->status_row($id);
if ($row === null) {
return new \WP_Error('aksrt_ticket_status_missing', __('工单状态不存在', 'aksrt'), ['status' => 404]);
}
if (!empty($row['is_default'])) {
return new \WP_Error('aksrt_ticket_status_default', __('默认状态不可修改', 'aksrt'), ['status' => 409]);
}
$name = trim((string) $request->get_param('name'));
if ($name === '' || mb_strlen($name) > 60) {
return new \WP_Error('aksrt_ticket_status_name', __('请填写有效的状态名称', 'aksrt'), ['status' => 400]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
$updated = $wpdb->update($table, [
'name' => $name,
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%d', '%s'], ['%d']);
if ($updated === false) {
return new \WP_Error('aksrt_ticket_db', __('状态保存失败', 'aksrt'), ['status' => 500]);
}
TicketStatusService::flush();
return rest_ensure_response(['success' => true]);
}
public function delete_status(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
$row = $this->status_row($id);
if ($row === null) {
return new \WP_Error('aksrt_ticket_status_missing', __('工单状态不存在', 'aksrt'), ['status' => 404]);
}
if (!empty($row['is_default'])) {
return new \WP_Error('aksrt_ticket_status_default', __('默认状态不可删除', 'aksrt'), ['status' => 409]);
}
if (TicketRepository::get_instance()->count_by_status((string) $row['status_key']) > 0) {
return new \WP_Error('aksrt_ticket_status_in_use', __('该状态下还有工单,无法删除', 'aksrt'), ['status' => 409]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
$wpdb->delete($table, ['id' => $id], ['%d']);
TicketStatusService::flush();
return rest_ensure_response(['success' => true]);
}
/** @return array<string,mixed>|null */
private function status_row(int $id): ?array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
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) ? $row : null;
}
/* ------------------------------------------------------------------ *
* 部门
* ------------------------------------------------------------------ */
public function list_types(\WP_REST_Request $request) {
return rest_ensure_response(['success' => true, 'data' => TicketTypeService::all()]);
}
public function create_type(\WP_REST_Request $request) {
$name = trim((string) $request->get_param('name'));
if ($name === '' || mb_strlen($name) > 120) {
return new \WP_Error('aksrt_ticket_type_name', __('请填写有效的部门名称', 'aksrt'), ['status' => 400]);
}
if (TicketTypeService::name_taken($name)) {
return new \WP_Error('aksrt_ticket_type_duplicate', __('该部门已存在', 'aksrt'), ['status' => 409]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
if ($table === '') {
return new \WP_Error('aksrt_ticket_db', __('部门表不可用', 'aksrt'), ['status' => 500]);
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'name' => $name,
'description' => sanitize_text_field((string) $request->get_param('description')),
'admin_ids' => wp_json_encode(TicketTypeService::sanitize_admin_ids((array) $request->get_param('admin_ids'))),
'sort_order' => $max + 1,
'created_at' => current_time('mysql'),
], ['%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
return new \WP_Error('aksrt_ticket_db', __('部门创建失败', 'aksrt'), ['status' => 500]);
}
TicketTypeService::flush();
return rest_ensure_response(['success' => true]);
}
public function update_type(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (!TicketTypeService::exists($id)) {
return new \WP_Error('aksrt_ticket_type_missing', __('工单部门不存在', 'aksrt'), ['status' => 404]);
}
$name = trim((string) $request->get_param('name'));
if ($name === '' || mb_strlen($name) > 120) {
return new \WP_Error('aksrt_ticket_type_name', __('请填写有效的部门名称', 'aksrt'), ['status' => 400]);
}
if (TicketTypeService::name_taken($name, $id)) {
return new \WP_Error('aksrt_ticket_type_duplicate', __('该部门已存在', 'aksrt'), ['status' => 409]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
$updated = $wpdb->update($table, [
'name' => $name,
'description' => sanitize_text_field((string) $request->get_param('description')),
'admin_ids' => wp_json_encode(TicketTypeService::sanitize_admin_ids((array) $request->get_param('admin_ids'))),
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%s', '%s'], ['%d']);
if ($updated === false) {
return new \WP_Error('aksrt_ticket_db', __('部门保存失败', 'aksrt'), ['status' => 500]);
}
TicketTypeService::flush();
return rest_ensure_response(['success' => true]);
}
public function delete_type(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (!TicketTypeService::exists($id)) {
return new \WP_Error('aksrt_ticket_type_missing', __('工单部门不存在', 'aksrt'), ['status' => 404]);
}
if (TicketRepository::get_instance()->count_by_type($id) > 0) {
return new \WP_Error('aksrt_ticket_type_in_use', __('该部门下还有工单,无法删除', 'aksrt'), ['status' => 409]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
$wpdb->delete($table, ['id' => $id], ['%d']);
TicketTypeService::flush();
return rest_ensure_response(['success' => true]);
}
/* ------------------------------------------------------------------ *
* 预设回复
* ------------------------------------------------------------------ */
public function list_prereplies(\WP_REST_Request $request) {
return rest_ensure_response(['success' => true, 'data' => TicketPrereplyService::all()]);
}
public function create_prereply(\WP_REST_Request $request) {
$content = trim((string) $request->get_param('content'));
if ($content === '' || mb_strlen($content) > 2000) {
return new \WP_Error('aksrt_ticket_prereply_content', __('请填写有效的预设回复', 'aksrt'), ['status' => 400]);
}
if (TicketPrereplyService::create($content) <= 0) {
return new \WP_Error('aksrt_ticket_db', __('预设回复创建失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
public function update_prereply(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (TicketPrereplyService::find($id) === null) {
return new \WP_Error('aksrt_ticket_prereply_missing', __('预设回复不存在', 'aksrt'), ['status' => 404]);
}
$content = trim((string) $request->get_param('content'));
if ($content === '' || mb_strlen($content) > 2000) {
return new \WP_Error('aksrt_ticket_prereply_content', __('请填写有效的预设回复', 'aksrt'), ['status' => 400]);
}
if (!TicketPrereplyService::update($id, $content)) {
return new \WP_Error('aksrt_ticket_db', __('预设回复保存失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
public function delete_prereply(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (!TicketPrereplyService::delete($id)) {
return new \WP_Error('aksrt_ticket_db', __('预设回复删除失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
public function reorder_prereplies(\WP_REST_Request $request) {
if (!TicketPrereplyService::reorder((array) $request->get_param('ids'))) {
return new \WP_Error('aksrt_ticket_prereply_order', __('预设回复排序失败', 'aksrt'), ['status' => 400]);
}
return rest_ensure_response(['success' => true]);
}
/* ------------------------------------------------------------------ *
* 后台备注
* ------------------------------------------------------------------ */
public function list_notes(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
return rest_ensure_response(['success' => true, 'data' => TicketNoteService::for_ticket($id)]);
}
public function create_note(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (TicketRepository::get_instance()->find($id) === null) {
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
}
$content = trim((string) $request->get_param('content'));
if ($content === '' || mb_strlen($content) > 2000) {
return new \WP_Error('aksrt_ticket_note_content', __('请填写有效的备注内容', 'aksrt'), ['status' => 400]);
}
if (TicketNoteService::create($id, $content, get_current_user_id()) <= 0) {
return new \WP_Error('aksrt_ticket_db', __('备注创建失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
public function update_note(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (TicketNoteService::find($id) === null) {
return new \WP_Error('aksrt_ticket_note_missing', __('备注不存在', 'aksrt'), ['status' => 404]);
}
$content = trim((string) $request->get_param('content'));
if ($content === '' || mb_strlen($content) > 2000) {
return new \WP_Error('aksrt_ticket_note_content', __('请填写有效的备注内容', 'aksrt'), ['status' => 400]);
}
if (!TicketNoteService::update($id, $content)) {
return new \WP_Error('aksrt_ticket_db', __('备注保存失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
public function delete_note(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (!TicketNoteService::delete($id)) {
return new \WP_Error('aksrt_ticket_db', __('备注删除失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
}