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:
1 parent
761c84d70f
commit
dbb7de41e9
81 files changed
+5812
-4179
No files matched your search
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单附件:清洗、渲染与下载。
|
||||
*
|
||||
* 上传链路不重写——复用 UserController::rest_upload_comment_image(),
|
||||
* 由 TicketService 注册 /ticket/upload-attachment 并挂工单专属权限回调
|
||||
* (与 UserController 的 /report/upload-image 同构)。因此附件形态限定为图片。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketAttachment {
|
||||
|
||||
/**
|
||||
* 入参清洗:只保留非空字符串 URL,按配置上限截断。
|
||||
*
|
||||
* @param mixed $raw
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function sanitize($raw, ?int $max = null): array {
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
$max = $max ?? TicketSettings::attachment_max();
|
||||
$clean = [];
|
||||
foreach ($raw as $url) {
|
||||
if (!is_string($url) || count($clean) >= $max) {
|
||||
continue;
|
||||
}
|
||||
$url = esc_url_raw(trim($url));
|
||||
if ($url !== '') {
|
||||
$clean[] = $url;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储态 JSON → 展示态数组。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function decode($stored): array {
|
||||
if (is_array($stored)) {
|
||||
return self::sanitize($stored, 9);
|
||||
}
|
||||
$decoded = json_decode((string) $stored, true);
|
||||
return is_array($decoded) ? self::sanitize($decoded, 9) : [];
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
public static function encode(array $urls): string {
|
||||
return wp_json_encode(array_values($urls), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单后台备注:管理员内部时间线,对用户不可见。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketNoteService {
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function for_ticket(int $ticket_id): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
|
||||
if ($table === '' || $ticket_id <= 0) {
|
||||
return [];
|
||||
}
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE ticket_id = %d ORDER BY id ASC", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$ticket_id
|
||||
), ARRAY_A);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = self::normalize($row);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function find(int $id): ?array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
|
||||
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) ? self::normalize($row) : null;
|
||||
}
|
||||
|
||||
public static function create(int $ticket_id, string $content, int $admin_id): int {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
|
||||
if ($table === '' || $ticket_id <= 0 || $content === '') {
|
||||
return 0;
|
||||
}
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'ticket_id' => $ticket_id,
|
||||
'admin_id' => $admin_id,
|
||||
'content' => $content,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%d', '%d', '%s', '%s']);
|
||||
return $inserted === 1 ? (int) $wpdb->insert_id : 0;
|
||||
}
|
||||
|
||||
public static function update(int $id, string $content): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
|
||||
if ($table === '' || $id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->update($table, ['content' => $content], ['id' => $id], ['%s'], ['%d']) !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
|
||||
if ($table === '' || $id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->delete($table, ['id' => $id], ['%d']) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalize(array $row): array {
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'ticket_id' => (int) ($row['ticket_id'] ?? 0),
|
||||
'admin_id' => (int) ($row['admin_id'] ?? 0),
|
||||
'content' => (string) ($row['content'] ?? ''),
|
||||
'admin_name' => self::admin_name((int) ($row['admin_id'] ?? 0)),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private static function admin_name(int $admin_id): string {
|
||||
if ($admin_id <= 0) {
|
||||
return '';
|
||||
}
|
||||
$user = get_userdata($admin_id);
|
||||
return $user instanceof \WP_User ? $user->display_name : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单站内信通知。
|
||||
*
|
||||
* 复用 AKSRT\Messages\Message::send()(持久化到 aksrt_notification,支持 target_url 深链)。
|
||||
* 邮件 / SMS 不在此范围:主题侧的邮件与短信是另一套实现,接入属于独立范围。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketNotifier {
|
||||
|
||||
/** 用户新建工单 → 通知管理员。 */
|
||||
public static function admins_new_ticket(array $ticket): void {
|
||||
$number = (string) ($ticket['ticket_num'] ?? '');
|
||||
$title = (string) ($ticket['title'] ?? '');
|
||||
$content = sprintf(
|
||||
/* translators: 1: ticket number 2: ticket title */
|
||||
__('新工单 %1$s:%2$s', 'aksrt'),
|
||||
$number,
|
||||
$title
|
||||
);
|
||||
self::to_admins('ticket_new', $content, __('新工单待处理', 'aksrt'));
|
||||
}
|
||||
|
||||
/** 管理员回复 → 通知工单归属用户。 */
|
||||
public static function user_replied(array $ticket): void {
|
||||
$user_id = (int) ($ticket['user_id'] ?? 0);
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
$content = sprintf(
|
||||
/* translators: 1: ticket number 2: ticket title */
|
||||
__('您的工单 %1$s(%2$s)收到新回复', 'aksrt'),
|
||||
(string) ($ticket['ticket_num'] ?? ''),
|
||||
(string) ($ticket['title'] ?? '')
|
||||
);
|
||||
self::send($user_id, 'ticket_reply', $content, __('工单有新回复', 'aksrt'), self::user_url());
|
||||
}
|
||||
|
||||
/** 用户回复 / 催单 → 通知跟进人。 */
|
||||
public static function admin_activity(array $ticket, string $kind): void {
|
||||
$admin_id = (int) ($ticket['admin_id'] ?? 0);
|
||||
if ($admin_id <= 0) {
|
||||
return;
|
||||
}
|
||||
if ($kind === 'urge') {
|
||||
$content = sprintf(
|
||||
/* translators: 1: ticket number 2: ticket title */
|
||||
__('用户催单 %1$s(%2$s)', 'aksrt'),
|
||||
(string) ($ticket['ticket_num'] ?? ''),
|
||||
(string) ($ticket['title'] ?? '')
|
||||
);
|
||||
$title = __('工单被催单', 'aksrt');
|
||||
} else {
|
||||
$content = sprintf(
|
||||
/* translators: 1: ticket number 2: ticket title */
|
||||
__('用户回复了工单 %1$s(%2$s)', 'aksrt'),
|
||||
(string) ($ticket['ticket_num'] ?? ''),
|
||||
(string) ($ticket['title'] ?? '')
|
||||
);
|
||||
$title = __('工单有新回复', 'aksrt');
|
||||
}
|
||||
self::send($admin_id, 'ticket_' . $kind, $content, $title, self::admin_url());
|
||||
}
|
||||
|
||||
/** 工单关闭 → 通知对方。 */
|
||||
public static function closed(array $ticket, string $closer): void {
|
||||
$content = sprintf(
|
||||
/* translators: 1: ticket number 2: ticket title */
|
||||
__('工单 %1$s(%2$s)已关闭', 'aksrt'),
|
||||
(string) ($ticket['ticket_num'] ?? ''),
|
||||
(string) ($ticket['title'] ?? '')
|
||||
);
|
||||
if ($closer === 'admin') {
|
||||
$user_id = (int) ($ticket['user_id'] ?? 0);
|
||||
if ($user_id > 0) {
|
||||
self::send($user_id, 'ticket_closed', $content, __('工单已关闭', 'aksrt'), self::user_url());
|
||||
}
|
||||
return;
|
||||
}
|
||||
$admin_id = (int) ($ticket['admin_id'] ?? 0);
|
||||
if ($admin_id > 0) {
|
||||
self::send($admin_id, 'ticket_closed', $content, __('工单已关闭', 'aksrt'), self::admin_url());
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<int> $user_ids */
|
||||
private static function send(int $user_id, string $type, string $content, string $title, string $url): void {
|
||||
if (!class_exists('\AKSRT\Messages\Message')) {
|
||||
return;
|
||||
}
|
||||
\AKSRT\Messages\Message::get_instance()->send($user_id, $type, $content, [
|
||||
'title' => $title,
|
||||
'target_url' => $url,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function to_admins(string $type, string $content, string $title): void {
|
||||
if (!class_exists('WP_User_Query')) {
|
||||
return;
|
||||
}
|
||||
$admins = get_users(['role' => 'administrator', 'fields' => 'ID', 'number' => 20]);
|
||||
foreach ($admins as $admin_id) {
|
||||
self::send((int) $admin_id, $type, $content, $title, self::admin_url());
|
||||
}
|
||||
}
|
||||
|
||||
private static function admin_url(): string {
|
||||
return admin_url('users.php?page=aksrt_tickets');
|
||||
}
|
||||
|
||||
private static function user_url(): string {
|
||||
return function_exists('aksrt_get_user_center_url')
|
||||
? aksrt_get_user_center_url('tickets')
|
||||
: home_url('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单号生成:前缀 + 年月日 + 6 位随机数字,冲突时重试。
|
||||
*
|
||||
* 与订单号(20/22 位纯数字)区分:工单号对外可读、可口头传达,
|
||||
* 形如 T20261025XXXXXX。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketNumber {
|
||||
|
||||
private const MAX_ATTEMPTS = 12;
|
||||
|
||||
public static function generate(): string {
|
||||
$prefix = TicketSettings::number_prefix();
|
||||
$date = current_time('Ymd');
|
||||
|
||||
for ($attempt = 0; $attempt < self::MAX_ATTEMPTS; $attempt++) {
|
||||
$candidate = $prefix . $date . wp_rand(100000, 999999);
|
||||
if (!self::exists($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// 极端冲突下追加随机后缀,保证仍可落库(uniq_ticket_num 会兜底)。
|
||||
return $prefix . $date . wp_rand(100000, 999999) . wp_rand(100, 999);
|
||||
}
|
||||
|
||||
private static function exists(string $ticket_num): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||
if ($table === '') {
|
||||
return false;
|
||||
}
|
||||
$found = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE ticket_num = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$ticket_num
|
||||
));
|
||||
return $found !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单预设回复:管理员维护的快捷话术,用户/管理员回复框可一键插入。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketPrereplyService {
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function all(): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'content' => (string) ($row['content'] ?? ''),
|
||||
'sort_order' => (int) ($row['sort_order'] ?? 0),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台只暴露 id + content。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function public_list(): array {
|
||||
$out = [];
|
||||
foreach (self::all() as $row) {
|
||||
$out[] = ['id' => $row['id'], 'content' => $row['content']];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function find(int $id): ?array {
|
||||
foreach (self::all() as $row) {
|
||||
if ((int) $row['id'] === $id) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 新建时取当前最大 sort_order + 1,追加到末尾。 */
|
||||
private static function next_sort_order(): int {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '') {
|
||||
return 0;
|
||||
}
|
||||
$max = $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
return (int) $max + 1;
|
||||
}
|
||||
|
||||
public static function create(string $content): int {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '') {
|
||||
return 0;
|
||||
}
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'content' => $content,
|
||||
'sort_order' => self::next_sort_order(),
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s']);
|
||||
return $inserted === 1 ? (int) $wpdb->insert_id : 0;
|
||||
}
|
||||
|
||||
public static function update(int $id, string $content): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '' || $id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->update($table, ['content' => $content], ['id' => $id], ['%s'], ['%d']) !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '' || $id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->delete($table, ['id' => $id], ['%d']) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序:入参必须是全量 ID(数量不符即拒绝,避免漏排导致顺序错乱)。
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
public static function reorder(array $ids): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '') {
|
||||
return false;
|
||||
}
|
||||
$ids = array_values(array_filter(array_map('intval', $ids), static fn(int $id): bool => $id > 0));
|
||||
$existing = array_map(static fn(array $row): int => (int) $row['id'], self::all());
|
||||
if (count($ids) !== count($existing) || array_diff($ids, $existing) !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($ids as $position => $id) {
|
||||
$wpdb->update($table, ['sort_order' => (int) $position], ['id' => $id], ['%d'], ['%d']);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单查询 trait:列表 WHERE 拼装、分页与统计。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait TicketQuery {
|
||||
|
||||
/**
|
||||
* 列表查询的 WHERE 片段与参数。
|
||||
*
|
||||
* 用户视角只可能看到自己的工单;管理视角可按用户 / 部门 / 状态 / 关键字过滤。
|
||||
* 管理侧默认隐藏已完结状态(对齐参考实现的 whereNotIn([3,4]))。
|
||||
*
|
||||
* @param array<string,mixed> $args
|
||||
* @return array{0:string,1:list<mixed>}
|
||||
*/
|
||||
private function ticket_where(array $args, bool $is_admin): array {
|
||||
global $wpdb;
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!$is_admin) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = (int) ($args['user_id'] ?? 0);
|
||||
} else {
|
||||
$user_id = (int) ($args['user_id'] ?? 0);
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
}
|
||||
|
||||
$statuses = [];
|
||||
foreach ((array) ($args['status'] ?? []) as $status) {
|
||||
$status = sanitize_key((string) $status);
|
||||
if ($status !== '') {
|
||||
$statuses[] = $status;
|
||||
}
|
||||
}
|
||||
if ($statuses !== []) {
|
||||
$where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')';
|
||||
$params = array_merge($params, $statuses);
|
||||
} elseif ($is_admin) {
|
||||
$closed = TicketStatusService::is_closed(TicketStatusService::KEY_CLOSED);
|
||||
if ($closed) {
|
||||
$where[] = 'status <> %s';
|
||||
$params[] = TicketStatusService::KEY_CLOSED;
|
||||
}
|
||||
}
|
||||
|
||||
$type_id = (int) ($args['ticket_type_id'] ?? 0);
|
||||
if ($type_id > 0) {
|
||||
$where[] = 'ticket_type_id = %d';
|
||||
$params[] = $type_id;
|
||||
}
|
||||
|
||||
$keywords = sanitize_text_field((string) ($args['keywords'] ?? ''));
|
||||
if ($keywords !== '') {
|
||||
$like = '%' . $wpdb->esc_like($keywords) . '%';
|
||||
$where[] = '(ticket_num LIKE %s OR title LIKE %s)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
|
||||
$date = sanitize_text_field((string) ($args[$key] ?? ''));
|
||||
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1) {
|
||||
$where[] = 'created_at ' . $operator . ' %s';
|
||||
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
|
||||
}
|
||||
}
|
||||
|
||||
unset($wpdb);
|
||||
return [
|
||||
$where === [] ? '1 = 1' : implode(' AND ', $where),
|
||||
$params,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function ticket_rows(array $args, bool $is_admin): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||
if ($table === '') {
|
||||
return [];
|
||||
}
|
||||
[$where, $params] = $this->ticket_where($args, $is_admin);
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY last_reply_time DESC, id DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
*/
|
||||
private function ticket_count(array $args, bool $is_admin): int {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||
if ($table === '') {
|
||||
return 0;
|
||||
}
|
||||
[$where, $params] = $this->ticket_where($args, $is_admin);
|
||||
$sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
|
||||
$count = $params === []
|
||||
? $wpdb->get_var($sql) // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
: $wpdb->get_var($wpdb->prepare($sql, $params)); // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
return (int) $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 各状态计数:侧栏统计卡与后台 badge 用。
|
||||
* 用户视角只统计自己的;管理视角统计全站(可按部门过滤)。
|
||||
*
|
||||
* @param array<string,mixed> $args
|
||||
* @return array<string,int> status_key => count
|
||||
*/
|
||||
private function ticket_statistic(array $args, bool $is_admin): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||
$out = [];
|
||||
foreach (array_keys(TicketStatusService::map()) as $key) {
|
||||
$out[(string) $key] = 0;
|
||||
}
|
||||
if ($table === '') {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$query_args = [
|
||||
'user_id' => (int) ($args['user_id'] ?? 0),
|
||||
'ticket_type_id' => $args['ticket_type_id'] ?? 0,
|
||||
];
|
||||
[$where, $params] = $this->ticket_where($query_args, $is_admin);
|
||||
$sql = "SELECT status, COUNT(*) AS total FROM {$table} WHERE {$where} GROUP BY status";
|
||||
$rows = $wpdb->get_results($params === [] ? $sql : $wpdb->prepare($sql, $params), ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
if (is_array($rows)) {
|
||||
foreach ($rows as $row) {
|
||||
$key = sanitize_key((string) ($row['status'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$out[$key] = (int) ($row['total'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单状态机 trait:迁移校验、落库与通知触发点。
|
||||
*
|
||||
* 方法统一加 _ticket 后缀,避免与 TicketRepository 的对外同名包装器冲突。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait TicketStatus {
|
||||
|
||||
/**
|
||||
* 执行状态迁移。合法性由 TicketStatusService::can_transition() 判定。
|
||||
*
|
||||
* @param array<string,mixed> $ticket 迁移前的工单行(normalize 后)
|
||||
*/
|
||||
private function transition_ticket(int $ticket_id, array $ticket, string $to, string $actor, int $actor_id): bool {
|
||||
$from = (string) ($ticket['status'] ?? '');
|
||||
if ($ticket_id <= 0 || !TicketStatusService::can_transition($from, $to)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||
if ($table === '') {
|
||||
return false;
|
||||
}
|
||||
$updated = $wpdb->update($table, [
|
||||
'status' => $to,
|
||||
'updated_at' => current_time('mysql'),
|
||||
], ['id' => $ticket_id], ['%s', '%s'], ['%d']);
|
||||
if ($updated === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ticket['status'] = $to;
|
||||
if ($to === TicketStatusService::KEY_CLOSED) {
|
||||
TicketNotifier::closed($ticket, $actor);
|
||||
}
|
||||
unset($actor_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「接收」:仅从未接单转处理中。
|
||||
*
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
private function receive_ticket(int $ticket_id, array $ticket): bool {
|
||||
if ((string) ($ticket['status'] ?? '') !== TicketStatusService::KEY_PENDING) {
|
||||
return false;
|
||||
}
|
||||
return $this->transition_ticket($ticket_id, $ticket, TicketStatusService::KEY_HANDLING, 'admin', get_current_user_id());
|
||||
}
|
||||
|
||||
/**
|
||||
* 「已解决」:直接落到已关闭。不允许从未接单一步跳(can_transition 已挡)。
|
||||
*
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
private function resolve_ticket(int $ticket_id, array $ticket): bool {
|
||||
return $this->transition_ticket($ticket_id, $ticket, TicketStatusService::KEY_CLOSED, 'admin', get_current_user_id());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
private function close_ticket(int $ticket_id, array $ticket, string $actor): bool {
|
||||
if (TicketStatusService::is_closed((string) ($ticket['status'] ?? ''))) {
|
||||
return false;
|
||||
}
|
||||
return $this->transition_ticket($ticket_id, $ticket, TicketStatusService::KEY_CLOSED, $actor, get_current_user_id());
|
||||
}
|
||||
|
||||
/**
|
||||
* 回复后的状态改写:管理员回复 → 已回复;用户回复 → 待回复。
|
||||
*
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
private function reply_status(int $ticket_id, array $ticket, string $author_type): bool {
|
||||
$target = $author_type === 'admin'
|
||||
? TicketStatusService::KEY_ADMIN_REPLIED
|
||||
: TicketStatusService::KEY_USER_REPLIED;
|
||||
$from = (string) ($ticket['status'] ?? '');
|
||||
if ($from === $target) {
|
||||
return true;
|
||||
}
|
||||
// 已完结工单上的回复不改变状态(回复本身由调用方拒绝)。
|
||||
if (TicketStatusService::is_closed($from)) {
|
||||
return false;
|
||||
}
|
||||
return $this->transition_ticket($ticket_id, $ticket, $target, $author_type, get_current_user_id());
|
||||
}
|
||||
}
|
||||
@@ -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 : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单 REST — 用户侧。
|
||||
*
|
||||
* 命名空间 aksrt/v1;每条路由都带 permission_callback,写操作过人机验证。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketRest {
|
||||
|
||||
private const CAPTCHA_SCENE_CREATE = 'ticket_create';
|
||||
private const CAPTCHA_SCENE_REPLY = 'ticket_reply';
|
||||
|
||||
public function register_routes(): void {
|
||||
$namespace = 'aksrt/v1';
|
||||
$id = '(?P<id>[0-9]+)';
|
||||
|
||||
register_rest_route($namespace, '/tickets', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_tickets'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
'args' => $this->list_args(),
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_ticket'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
'args' => [
|
||||
'title' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'ticket_type_id' => ['required' => true, 'sanitize_callback' => 'absint'],
|
||||
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
'order_number' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'attachment' => ['required' => false, 'type' => 'array'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/statistic', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'statistic'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
// 静态子路径必须早于 /tickets/{id} 注册,否则 WordPress 会先用动态规则匹配。
|
||||
register_rest_route($namespace, '/tickets/meta', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'meta'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/prereply', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'prereplies'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id, [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_ticket'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/reply', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'reply'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
'args' => [
|
||||
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
'attachment' => ['required' => false, 'type' => 'array'],
|
||||
'quote_reply_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/urge', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'urge'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/close', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'close'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/ticket/upload-attachment', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [\AKSRT\Admin\UserController::class, 'rest_upload_comment_image'],
|
||||
'permission_callback' => [$this, 'require_attachment_upload'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function list_args(): array {
|
||||
return [
|
||||
'keywords' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'status' => ['required' => false, 'sanitize_callback' => [$this, 'sanitize_status_list']],
|
||||
'ticket_type_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'page' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'per_page' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* status 允许传字符串或数组(后台多选筛选复用同一读取器)。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function sanitize_status_list($value): array {
|
||||
$values = is_array($value) ? $value : explode(',', (string) $value);
|
||||
$out = [];
|
||||
foreach ((array) $values as $item) {
|
||||
$key = sanitize_key((string) $item);
|
||||
if ($key !== '' && TicketStatusService::exists($key)) {
|
||||
$out[] = $key;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
public function require_user() {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
$nonce = isset($_SERVER['HTTP_X_WP_NONCE']) ? sanitize_text_field((string) wp_unslash($_SERVER['HTTP_X_WP_NONCE'])) : '';
|
||||
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest')
|
||||
? true
|
||||
: new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
|
||||
public function require_attachment_upload() {
|
||||
$user = $this->require_user();
|
||||
if (is_wp_error($user)) {
|
||||
return $user;
|
||||
}
|
||||
if (!TicketSettings::enabled() || !TicketSettings::attachment_enabled()) {
|
||||
return new \WP_Error('aksrt_ticket_attachment_forbidden', __('工单附件上传未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function list_tickets(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = max(1, min(50, (int) $request->get_param('per_page') ?: 20));
|
||||
$result = TicketRepository::get_instance()->paginate([
|
||||
'user_id' => get_current_user_id(),
|
||||
'keywords' => $request->get_param('keywords'),
|
||||
'status' => $request->get_param('status'),
|
||||
'ticket_type_id' => $request->get_param('ticket_type_id'),
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
], false);
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'items' => $result['items'],
|
||||
'total' => $result['total'],
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
'total_pages' => max(1, (int) ceil($result['total'] / $per_page)),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function statistic(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => TicketRepository::get_instance()->statistic(['user_id' => get_current_user_id()], false),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 下拉数据一次性下发:部门 + 状态 + 可关联订单 + 配置。 */
|
||||
public function meta(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'types' => TicketTypeService::public_list(),
|
||||
'statuses' => TicketStatusService::all(),
|
||||
'prereplies' => TicketPrereplyService::public_list(),
|
||||
'orders' => $this->linkable_orders(),
|
||||
'notice' => [
|
||||
'open' => TicketSettings::notice_open(),
|
||||
'text' => TicketSettings::notice_text(),
|
||||
],
|
||||
'attachment' => [
|
||||
'enabled' => TicketSettings::attachment_enabled(),
|
||||
'max' => TicketSettings::attachment_max(),
|
||||
],
|
||||
'urgeInterval' => TicketSettings::urge_interval(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function prereplies(\WP_REST_Request $request) {
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => TicketPrereplyService::public_list(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_ticket(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$id = absint($request->get_param('id'));
|
||||
$repo = TicketRepository::get_instance();
|
||||
$ticket = $repo->find_for_user($id, get_current_user_id());
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$ticket['replies'] = $repo->replies($id);
|
||||
$ticket['notes'] = [];
|
||||
return rest_ensure_response(['success' => true, 'data' => $ticket]);
|
||||
}
|
||||
|
||||
public function create_ticket(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$captcha = $this->require_captcha($request, self::CAPTCHA_SCENE_CREATE);
|
||||
if (is_wp_error($captcha)) {
|
||||
return $captcha;
|
||||
}
|
||||
|
||||
$title = trim((string) $request->get_param('title'));
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
$type_id = absint($request->get_param('ticket_type_id'));
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
|
||||
if ($title === '' || mb_strlen($title) > 200) {
|
||||
return new \WP_Error('aksrt_ticket_title', __('请填写有效的工单标题', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($content === '' || mb_strlen($content) > 5000) {
|
||||
return new \WP_Error('aksrt_ticket_content', __('请填写有效的问题描述', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!TicketTypeService::exists($type_id)) {
|
||||
return new \WP_Error('aksrt_ticket_type', __('请选择工单部门', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($order_number !== '' && !$this->owns_paid_order($order_number)) {
|
||||
return new \WP_Error('aksrt_ticket_order', __('关联订单无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
|
||||
$id = TicketRepository::get_instance()->create([
|
||||
'ticket_num' => TicketNumber::generate(),
|
||||
'user_id' => get_current_user_id(),
|
||||
'title' => $title,
|
||||
'ticket_type_id' => $type_id,
|
||||
'content' => $content,
|
||||
'order_number' => $order_number,
|
||||
'attachment' => TicketAttachment::encode($attachment),
|
||||
'status' => TicketStatusService::KEY_PENDING,
|
||||
]);
|
||||
if ($id <= 0) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('工单提交失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$ticket = TicketRepository::get_instance()->find($id);
|
||||
if (is_array($ticket)) {
|
||||
TicketNotifier::admins_new_ticket($ticket);
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => ['id' => $id]]);
|
||||
}
|
||||
|
||||
public function reply(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$captcha = $this->require_captcha($request, self::CAPTCHA_SCENE_REPLY);
|
||||
if (is_wp_error($captcha)) {
|
||||
return $captcha;
|
||||
}
|
||||
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find_for_user($id, get_current_user_id());
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!empty($ticket['is_closed'])) {
|
||||
return new \WP_Error('aksrt_ticket_closed', __('工单已关闭,无法回复', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
if ($content === '' || mb_strlen($content) > 3000) {
|
||||
return new \WP_Error('aksrt_ticket_reply_content', __('请填写有效的回复内容', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$quote_id = absint($request->get_param('quote_reply_id'));
|
||||
if ($quote_id > 0) {
|
||||
$quoted = $repo->find_reply($quote_id);
|
||||
if ($quoted === null || (int) $quoted['ticket_id'] !== $id) {
|
||||
$quote_id = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
|
||||
$reply_id = $repo->create_reply($id, 'user', get_current_user_id(), $content, $attachment, $quote_id);
|
||||
if ($reply_id <= 0) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('回复提交失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$repo->touch_reply($id, 'user', get_current_user_id());
|
||||
$repo->apply_reply_status($id, $ticket, 'user');
|
||||
TicketNotifier::admin_activity($repo->find($id) ?? $ticket, 'reply');
|
||||
return rest_ensure_response(['success' => true, 'data' => ['reply_id' => $reply_id]]);
|
||||
}
|
||||
|
||||
public function urge(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find_for_user($id, get_current_user_id());
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!empty($ticket['is_closed'])) {
|
||||
return new \WP_Error('aksrt_ticket_closed', __('工单已关闭,无法催单', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$last = get_transient('aksrt_ticket_urge_' . $id);
|
||||
if ($last !== false && (int) $last + TicketSettings::urge_interval() * MINUTE_IN_SECONDS > time()) {
|
||||
return new \WP_Error('aksrt_ticket_urge_limit', __('刚刚已催单,请稍后再试', 'aksrt'), ['status' => 429]);
|
||||
}
|
||||
set_transient('aksrt_ticket_urge_' . $id, time(), TicketSettings::urge_interval() * MINUTE_IN_SECONDS + 60);
|
||||
TicketNotifier::admin_activity($ticket, 'urge');
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function close(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find_for_user($id, get_current_user_id());
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!$repo->close($id, $ticket, 'user')) {
|
||||
return new \WP_Error('aksrt_ticket_state', __('工单状态不允许关闭', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 内部工具
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
private function ensure_enabled(): bool {
|
||||
return TicketSettings::enabled();
|
||||
}
|
||||
|
||||
private function disabled() {
|
||||
return new \WP_Error('aksrt_ticket_disabled', __('工单功能暂未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可关联订单:本人已支付订单(未退款、未关闭)。
|
||||
*
|
||||
* OrderRepository::get_user_orders() 返回的是归一化订单行(无 product_name),
|
||||
* 标题从 meta_payload 取,取不到回落订单号。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function linkable_orders(): array {
|
||||
if (!TicketSettings::order_link_enabled() || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return [];
|
||||
}
|
||||
$orders = \AKSRT\Payment\OrderRepository::get_instance()->get_user_orders(get_current_user_id(), [
|
||||
'status' => \AKSRT\Core\DomainModel::ORDER_STATUS_PAID,
|
||||
'limit' => 50,
|
||||
]);
|
||||
if (!is_array($orders)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($orders as $order) {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$out[] = [
|
||||
'order_number' => (string) ($order['order_number'] ?? ''),
|
||||
'product_name' => (string) ($meta['product_name'] ?? $meta['title'] ?? ''),
|
||||
'amount' => (string) ($order['amount'] ?? '0'),
|
||||
'currency' => (string) ($order['currency'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function owns_paid_order(string $order_number): bool {
|
||||
if ($order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return false;
|
||||
}
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get_user_order(get_current_user_id(), $order_number);
|
||||
return is_array($order)
|
||||
&& (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
|
||||
}
|
||||
|
||||
/**
|
||||
* 人机验证:场景未启用时直接放行;用户取消验证时返回可辨识的错误。
|
||||
*
|
||||
* @param array<string,mixed> $params
|
||||
*/
|
||||
private function require_captcha(\WP_REST_Request $request, string $scene) {
|
||||
if (!function_exists('aksrt_require_captcha')) {
|
||||
return true;
|
||||
}
|
||||
$params = $request->get_json_params();
|
||||
return aksrt_require_captcha($scene, is_array($params) ? $params : []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单 REST — 管理侧(后台工单页 AJAX)。
|
||||
*
|
||||
* 命名空间 aksrt/v1/admin;全部路由要求 manage_options。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketRestAdmin {
|
||||
|
||||
use TicketConfigRest;
|
||||
|
||||
public function register_routes(): void {
|
||||
$namespace = 'aksrt/v1/admin';
|
||||
$id = '(?P<id>[0-9]+)';
|
||||
|
||||
register_rest_route($namespace, '/tickets', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_tickets'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => $this->list_args(),
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_ticket'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'user_id' => ['required' => true, 'sanitize_callback' => 'absint'],
|
||||
'title' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'ticket_type_id' => ['required' => true, 'sanitize_callback' => 'absint'],
|
||||
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
'order_number' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'attachment' => ['required' => false, 'type' => 'array'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/statistic', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'statistic'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/statuses', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_statuses'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_status'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'name' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/statuses/' . $id, [
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [$this, 'update_status'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'name' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/statuses/' . $id . '/delete', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'delete_status'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/types', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_types'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_type'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'name' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'description' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'admin_ids' => ['required' => false, 'type' => 'array'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/types/' . $id, [
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [$this, 'update_type'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'name' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'description' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'admin_ids' => ['required' => false, 'type' => 'array'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/types/' . $id . '/delete', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'delete_type'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/prereplies', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_prereplies'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_prereply'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => ['content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field']],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/prereplies/' . $id, [
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [$this, 'update_prereply'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => ['content' => ['required' => false, 'sanitize_callback' => 'sanitize_textarea_field']],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/prereplies/' . $id . '/delete', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'delete_prereply'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/prereplies/order', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'reorder_prereplies'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => ['ids' => ['required' => true, 'type' => 'array']],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id, [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_ticket'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/reply', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'reply'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
'attachment' => ['required' => false, 'type' => 'array'],
|
||||
'quote_reply_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/status', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'set_status'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'status' => ['required' => true, 'sanitize_callback' => 'sanitize_key'],
|
||||
'ticket_type_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'admin_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/receive', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'receive'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/resolve', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'resolve'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/close', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'close'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/notes', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_notes'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_note'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => ['content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field']],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/notes/' . $id, [
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [$this, 'update_note'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => ['content' => ['required' => false, 'sanitize_callback' => 'sanitize_textarea_field']],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/notes/' . $id . '/delete', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'delete_note'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function list_args(): array {
|
||||
return [
|
||||
'keywords' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'status' => ['required' => false, 'sanitize_callback' => [$this, 'sanitize_status_list']],
|
||||
'ticket_type_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'user_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'admin_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'page' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'per_page' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function sanitize_status_list($value): array {
|
||||
$values = is_array($value) ? $value : explode(',', (string) $value);
|
||||
$out = [];
|
||||
foreach ((array) $values as $item) {
|
||||
$key = sanitize_key((string) $item);
|
||||
if ($key !== '' && TicketStatusService::exists($key)) {
|
||||
$out[] = $key;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
public function require_admin() {
|
||||
if (!current_user_can('manage_options')) {
|
||||
return new \WP_Error('aksrt_ticket_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 工单
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
public function list_tickets(\WP_REST_Request $request) {
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = max(1, min(100, (int) $request->get_param('per_page') ?: 20));
|
||||
$result = TicketRepository::get_instance()->paginate($this->read_filters($request, $per_page, $page), true);
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'items' => $result['items'],
|
||||
'total' => $result['total'],
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
'total_pages' => max(1, (int) ceil($result['total'] / $per_page)),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function statistic(\WP_REST_Request $request) {
|
||||
$filters = [
|
||||
'ticket_type_id' => absint($request->get_param('ticket_type_id')),
|
||||
];
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => TicketRepository::get_instance()->statistic($filters, true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_ticket(\WP_REST_Request $request) {
|
||||
$repo = TicketRepository::get_instance();
|
||||
$ticket = $repo->find(absint($request->get_param('id')));
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$ticket['replies'] = $repo->replies((int) $ticket['id']);
|
||||
$ticket['notes'] = TicketNoteService::for_ticket((int) $ticket['id']);
|
||||
$ticket['prereplies'] = TicketPrereplyService::public_list();
|
||||
return rest_ensure_response(['success' => true, 'data' => $ticket]);
|
||||
}
|
||||
|
||||
public function create_ticket(\WP_REST_Request $request) {
|
||||
$user_id = absint($request->get_param('user_id'));
|
||||
$title = trim((string) $request->get_param('title'));
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
$type_id = absint($request->get_param('ticket_type_id'));
|
||||
|
||||
if ($user_id <= 0 || !get_userdata($user_id)) {
|
||||
return new \WP_Error('aksrt_ticket_user', __('请选择工单所属用户', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($title === '' || mb_strlen($title) > 200) {
|
||||
return new \WP_Error('aksrt_ticket_title', __('请填写有效的工单标题', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($content === '' || mb_strlen($content) > 5000) {
|
||||
return new \WP_Error('aksrt_ticket_content', __('请填写有效的问题描述', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!TicketTypeService::exists($type_id)) {
|
||||
return new \WP_Error('aksrt_ticket_type', __('请选择工单部门', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
|
||||
$id = TicketRepository::get_instance()->create([
|
||||
'ticket_num' => TicketNumber::generate(),
|
||||
'user_id' => $user_id,
|
||||
'title' => $title,
|
||||
'ticket_type_id' => $type_id,
|
||||
'content' => $content,
|
||||
'order_number' => sanitize_text_field((string) $request->get_param('order_number')),
|
||||
'attachment' => TicketAttachment::encode($attachment),
|
||||
'status' => TicketStatusService::KEY_ADMIN_REPLIED,
|
||||
'post_admin_id' => get_current_user_id(),
|
||||
'admin_id' => get_current_user_id(),
|
||||
]);
|
||||
if ($id <= 0) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('工单创建失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$ticket = TicketRepository::get_instance()->find($id);
|
||||
if (is_array($ticket)) {
|
||||
TicketNotifier::user_replied($ticket);
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => ['id' => $id]]);
|
||||
}
|
||||
|
||||
public function reply(\WP_REST_Request $request) {
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find($id);
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!empty($ticket['is_closed'])) {
|
||||
return new \WP_Error('aksrt_ticket_closed', __('工单已关闭,无法回复', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
if ($content === '' || mb_strlen($content) > 3000) {
|
||||
return new \WP_Error('aksrt_ticket_reply_content', __('请填写有效的回复内容', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$quote_id = absint($request->get_param('quote_reply_id'));
|
||||
if ($quote_id > 0) {
|
||||
$quoted = $repo->find_reply($quote_id);
|
||||
if ($quoted === null || (int) $quoted['ticket_id'] !== $id) {
|
||||
$quote_id = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
|
||||
$reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, $attachment, $quote_id);
|
||||
if ($reply_id <= 0) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('回复提交失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$repo->touch_reply($id, 'admin', get_current_user_id());
|
||||
$repo->apply_reply_status($id, $ticket, 'admin');
|
||||
TicketNotifier::user_replied($repo->find($id) ?? $ticket);
|
||||
return rest_ensure_response(['success' => true, 'data' => ['reply_id' => $reply_id]]);
|
||||
}
|
||||
|
||||
public function set_status(\WP_REST_Request $request) {
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find($id);
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$to = sanitize_key((string) $request->get_param('status'));
|
||||
if (!TicketStatusService::can_transition((string) $ticket['status'], $to)) {
|
||||
return new \WP_Error('aksrt_ticket_state', __('工单状态不允许该变更', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
$fields = ['status' => $to];
|
||||
$type_id = absint($request->get_param('ticket_type_id'));
|
||||
if ($type_id > 0 && TicketTypeService::exists($type_id)) {
|
||||
$fields['ticket_type_id'] = $type_id;
|
||||
}
|
||||
$admin_id = absint($request->get_param('admin_id'));
|
||||
if ($admin_id > 0 && get_userdata($admin_id)) {
|
||||
$fields['admin_id'] = $admin_id;
|
||||
}
|
||||
if (!$repo->update_fields($id, $fields)) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('工单状态保存失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$ticket = array_merge($ticket, $fields);
|
||||
if ($to === TicketStatusService::KEY_CLOSED) {
|
||||
TicketNotifier::closed($ticket, 'admin');
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => $repo->find($id)]);
|
||||
}
|
||||
|
||||
public function receive(\WP_REST_Request $request) {
|
||||
return $this->simple_action($request, 'receive');
|
||||
}
|
||||
|
||||
public function resolve(\WP_REST_Request $request) {
|
||||
return $this->simple_action($request, 'resolve');
|
||||
}
|
||||
|
||||
public function close(\WP_REST_Request $request) {
|
||||
return $this->simple_action($request, 'close');
|
||||
}
|
||||
|
||||
/** 接收 / 已解决 / 关闭:三个动作都只是状态迁移,共用同一外壳。 */
|
||||
private function simple_action(\WP_REST_Request $request, string $action) {
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find($id);
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$ok = match ($action) {
|
||||
'receive' => $repo->receive($id, $ticket),
|
||||
'resolve' => $repo->resolve($id, $ticket),
|
||||
default => $repo->close($id, $ticket, 'admin'),
|
||||
};
|
||||
if (!$ok) {
|
||||
return new \WP_Error('aksrt_ticket_state', __('工单状态不允许该操作', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => $repo->find($id)]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 内部
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function read_filters(\WP_REST_Request $request, int $per_page, int $page): array {
|
||||
return [
|
||||
'keywords' => $request->get_param('keywords'),
|
||||
'status' => $request->get_param('status'),
|
||||
'ticket_type_id' => $request->get_param('ticket_type_id'),
|
||||
'user_id' => $request->get_param('user_id'),
|
||||
'admin_id' => $request->get_param('admin_id'),
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单系统入口(薄壳)。
|
||||
*
|
||||
* 只负责单例、REST 注册与配置缓存失效;业务在 TicketRepository /
|
||||
* TicketRest / TicketRestAdmin / TicketConfigRest 与各 Service 中。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketService {
|
||||
|
||||
private static ?self $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
return self::$instance ??= new self();
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_settings_domain_saved', static function (string $domain, string $section): void {
|
||||
if ($domain === 'users' && $section === TicketSettings::SECTION) {
|
||||
TicketTypeService::flush();
|
||||
TicketStatusService::flush();
|
||||
}
|
||||
}, 10, 2);
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
(new TicketRest())->register_routes();
|
||||
(new TicketRestAdmin())->register_routes();
|
||||
}
|
||||
|
||||
/** 供后台页与前台模板复用的下拉数据(不经过 REST)。 */
|
||||
public static function meta(): array {
|
||||
return [
|
||||
'types' => TicketTypeService::all(),
|
||||
'statuses' => TicketStatusService::all(),
|
||||
'prereplies' => TicketPrereplyService::all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* Ticket settings reader — 工单配置(域 users,分区 ticket)。
|
||||
*
|
||||
* 字段键、顺序、类型与默认值需与 assets/admin/src/schema/sections/users/ticket.js
|
||||
* 及 src/Admin/Schema/Users/SchemaUsersTicket.php 保持 1:1。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketSettings {
|
||||
|
||||
/** 默认分区名,SchemaProvider 据此定位 schema。 */
|
||||
public const SECTION = 'ticket';
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function all(): array {
|
||||
$s = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::read_domain('users', self::SECTION)
|
||||
: [];
|
||||
return is_array($s) ? $s : [];
|
||||
}
|
||||
|
||||
public static function enabled(): bool {
|
||||
$s = self::all();
|
||||
return !isset($s['ticket_s']) || !empty($s['ticket_s']);
|
||||
}
|
||||
|
||||
/** 工单号前缀(仅保留安全字符,空值回落 T)。 */
|
||||
public static function number_prefix(): string {
|
||||
$raw = sanitize_key((string) (self::all()['ticket_num_prefix'] ?? 'T'));
|
||||
return $raw !== '' ? strtoupper($raw) : 'T';
|
||||
}
|
||||
|
||||
public static function notice_open(): bool {
|
||||
return !empty(self::all()['ticket_notice_open']);
|
||||
}
|
||||
|
||||
public static function notice_text(): string {
|
||||
return wp_kses_post((string) (self::all()['ticket_notice_text'] ?? ''));
|
||||
}
|
||||
|
||||
public static function attachment_enabled(): bool {
|
||||
return !empty(self::all()['ticket_attachment_s']);
|
||||
}
|
||||
|
||||
/** 单个工单(主内容 + 每条回复)的附件数量上限。 */
|
||||
public static function attachment_max(): int {
|
||||
return max(1, min(9, (int) (self::all()['ticket_attachment_max'] ?? 5)));
|
||||
}
|
||||
|
||||
/** 是否允许在新建工单时关联订单。 */
|
||||
public static function order_link_enabled(): bool {
|
||||
return !empty(self::all()['ticket_order_link_s']);
|
||||
}
|
||||
|
||||
/** 催单冷却分钟数。 */
|
||||
public static function urge_interval(): int {
|
||||
return max(1, min(1440, (int) (self::all()['ticket_urge_interval'] ?? 15)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单状态(自定义):CRUD + seeds 兜底 + 状态机迁移规则。
|
||||
*
|
||||
* 状态是数据驱动的:管理员可增删改,因此状态机不写死合法目标集合,
|
||||
* 只校验「目标状态必须存在」以及几条业务不变量(已完结不可流转、
|
||||
* 「已解决」不允许从未接单直接跳、默认状态不可改删)。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketStatusService {
|
||||
|
||||
/** 内置状态 key:seeds 写入后不可改删的默认态。 */
|
||||
public const KEY_PENDING = 'pending';
|
||||
public const KEY_HANDLING = 'handling';
|
||||
public const KEY_USER_REPLIED = 'user_replied';
|
||||
public const KEY_ADMIN_REPLIED = 'admin_replied';
|
||||
public const KEY_CLOSED = 'closed';
|
||||
|
||||
/** @var array<string,array<string,mixed>>|null status_key => row */
|
||||
private static ?array $cache = null;
|
||||
|
||||
public static function flush(): void {
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部状态,按 sort_order / id 正序。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function all(): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
|
||||
if ($table === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
self::$cache = [];
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$normalized = self::normalize($row);
|
||||
self::$cache[(string) $normalized['status_key']] = $normalized;
|
||||
$out[] = $normalized;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,mixed>> status_key => row */
|
||||
public static function map(): array {
|
||||
if (self::$cache === null) {
|
||||
self::all();
|
||||
}
|
||||
return self::$cache ?? [];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function find(string $status_key): ?array {
|
||||
$map = self::map();
|
||||
return $map[$status_key] ?? null;
|
||||
}
|
||||
|
||||
public static function exists(string $status_key): bool {
|
||||
return self::find($status_key) !== null;
|
||||
}
|
||||
|
||||
public static function is_closed(string $status_key): bool {
|
||||
$row = self::find($status_key);
|
||||
return $row !== null && !empty($row['is_closed']);
|
||||
}
|
||||
|
||||
/** 状态展示名;未知 key 回落 key 本身(工单可能引用已被删的状态)。 */
|
||||
public static function label(string $status_key): string {
|
||||
$row = self::find($status_key);
|
||||
return $row !== null ? (string) $row['name'] : $status_key;
|
||||
}
|
||||
|
||||
public static function color(string $status_key): string {
|
||||
$row = self::find($status_key);
|
||||
return $row !== null ? (string) $row['color'] : '#6b7280';
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态机:从 $from 迁移到 $to 是否合法。
|
||||
*
|
||||
* 只挡确定非法的组合,其余交给「目标状态必须存在」:
|
||||
* - 已完结状态(is_closed)不可再流转;
|
||||
* - 「已解决/关闭」类终态不允许从未接单一步跳过去(必须先接收或回复),
|
||||
* 避免管理员误把刚提交的工单直接关掉;
|
||||
* - 同状态原地提交视为合法(幂等,便于后台重复点击)。
|
||||
*/
|
||||
public static function can_transition(string $from, string $to): bool {
|
||||
if ($to === '' || !self::exists($to)) {
|
||||
return false;
|
||||
}
|
||||
if ($from === $to) {
|
||||
return true;
|
||||
}
|
||||
if (self::is_closed($from)) {
|
||||
return false;
|
||||
}
|
||||
if ($from === self::KEY_PENDING && $to === self::KEY_CLOSED) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalize(array $row): array {
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'status_key' => sanitize_key((string) ($row['status_key'] ?? '')),
|
||||
'name' => sanitize_text_field((string) ($row['name'] ?? '')),
|
||||
'color' => self::sanitize_color((string) ($row['color'] ?? '')),
|
||||
'is_closed' => !empty($row['is_closed']),
|
||||
'is_default' => !empty($row['is_default']),
|
||||
'sort_order' => (int) ($row['sort_order'] ?? 0),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** 颜色只接受 #rgb / #rrggbb,其余回落默认灰。 */
|
||||
public static function sanitize_color(string $color): string {
|
||||
$color = strtolower(trim($color));
|
||||
if (preg_match('/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/', $color) !== 1) {
|
||||
return '#6b7280';
|
||||
}
|
||||
return $color;
|
||||
}
|
||||
|
||||
/** 新建状态时生成唯一 status_key(ASCII 标签取 key,其余取短哈希)。 */
|
||||
public static function make_key(string $name): string {
|
||||
$key = sanitize_key($name);
|
||||
if ($key === '') {
|
||||
$key = 'st' . substr(md5($name . (string) wp_rand(1, 999999)), 0, 8);
|
||||
}
|
||||
$base = $key;
|
||||
$suffix = 1;
|
||||
while (self::exists($key)) {
|
||||
$key = $base . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单部门:CRUD + 受理管理员(admin_ids JSON)。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketTypeService {
|
||||
|
||||
/** @var list<array<string,mixed>>|null */
|
||||
private static ?array $cache = null;
|
||||
|
||||
public static function flush(): void {
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function all(): array {
|
||||
if (self::$cache !== null) {
|
||||
return self::$cache;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
|
||||
if ($table === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = self::normalize($row);
|
||||
}
|
||||
self::$cache = $out;
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台可选部门(不含 description / admin_ids 内部字段)。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function public_list(): array {
|
||||
$out = [];
|
||||
foreach (self::all() as $row) {
|
||||
$out[] = [
|
||||
'id' => $row['id'],
|
||||
'name' => $row['name'],
|
||||
'description' => $row['description'],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function find(int $id): ?array {
|
||||
foreach (self::all() as $row) {
|
||||
if ((int) $row['id'] === $id) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function exists(int $id): bool {
|
||||
return self::find($id) !== null;
|
||||
}
|
||||
|
||||
public static function name(int $id): string {
|
||||
$row = self::find($id);
|
||||
return $row !== null ? (string) $row['name'] : '';
|
||||
}
|
||||
|
||||
/** 部门名是否已被占用(新建/改名时查重)。 */
|
||||
public static function name_taken(string $name, int $ignore_id = 0): bool {
|
||||
$name = trim($name);
|
||||
foreach (self::all() as $row) {
|
||||
if ((int) $row['id'] !== $ignore_id && (string) $row['name'] === $name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $admin_ids
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function sanitize_admin_ids(array $admin_ids): array {
|
||||
$clean = [];
|
||||
foreach ($admin_ids as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0 && !in_array($id, $clean, true)) {
|
||||
$clean[] = $id;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalize(array $row): array {
|
||||
$admins = json_decode((string) ($row['admin_ids'] ?? ''), true);
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'name' => sanitize_text_field((string) ($row['name'] ?? '')),
|
||||
'description' => sanitize_text_field((string) ($row['description'] ?? '')),
|
||||
'admin_ids' => is_array($admins) ? self::sanitize_admin_ids($admins) : [],
|
||||
'sort_order' => (int) ($row['sort_order'] ?? 0),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user