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

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

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

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

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

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

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

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

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

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

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

No files matched your search

-427
View File
@@ -1,427 +0,0 @@
<?php
/** User applications and administrator status management for order after-sales. */
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
final class AfterSaleService {
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
/** 处理记录来源:后台提交 / 用户提交(申请与撤销)。 */
public const SOURCE_ADMIN = 'admin';
public const SOURCE_USER = 'user';
/** 处理记录上限:与 MetaAssetService::MAX_RECORDS 同口径,超出后只保留最近的一段。 */
private const MAX_LOG_ENTRIES = 50;
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']); }
public function register_routes(): void {
$number = '(?P<order_number>[0-9]{20}|[0-9]{22})';
register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale', [
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_submit'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'reason' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
'reason_label' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'materials' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool {
if (!is_array($value) || count($value) > 10) return false;
foreach ($value as $item) {
if (!is_array($item)) return false;
foreach (['label', 'value'] as $key) {
if (isset($item[$key]) && !is_string($item[$key])) return false;
}
}
return true;
}],
'description' => ['required' => false, 'sanitize_callback' => 'sanitize_textarea_field'],
'images' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool {
return is_array($value) && count($value) <= 3;
}],
]],
['methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
]],
['methods' => 'DELETE', 'callback' => [$this, 'rest_cancel'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
]],
]);
// 售后举证图片上传:复用评论图片上传链路(大小 / 类型 / 内容审核同一套校验)。
register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale/upload-image', [
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_upload_image'], 'permission_callback' => [$this, 'require_after_sale_image']],
]);
}
/** 售后举证图片上传权限:登录 + nonce + 售后开启 + 图片开关开启 + 订单归属本人。 */
public function require_after_sale_image(\WP_REST_Request $request) {
$user = $this->require_user($request);
if (is_wp_error($user)) {
return $user;
}
if (!class_exists('\AKSRT\Settings\PaymentRuntime')) {
return new \WP_Error('aksrt_after_sale_forbidden', __('售后功能暂未开启', 'aksrt'), ['status' => 403]);
}
$settings = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
if (empty($settings['enabled']) || empty($settings['img_enabled'])) {
return new \WP_Error('aksrt_after_sale_forbidden', __('售后图片上传未开启', 'aksrt'), ['status' => 403]);
}
$number = sanitize_text_field((string) $request->get_param('order_number'));
if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) {
return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]);
}
return true;
}
public function rest_upload_image(\WP_REST_Request $request) {
if (!class_exists('\AKSRT\Admin\UserController')) {
return new \WP_Error('aksrt_after_sale_upload', __('上传服务不可用', 'aksrt'), ['status' => 500]);
}
return \AKSRT\Admin\UserController::get_instance()->rest_upload_comment_image($request);
}
public function require_user(\WP_REST_Request $request) {
if (!is_user_logged_in()) return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
$nonce = (string) $request->get_header('X-WP-Nonce');
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest') ? true : new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
public function rest_submit(\WP_REST_Request $request) {
$captcha = $this->require_captcha($request, 'after_sale_apply');
if (is_wp_error($captcha)) {
return $captcha;
}
$images = $request->get_param('images');
$materials = $request->get_param('materials');
$result = $this->create(
get_current_user_id(),
(string) $request->get_param('order_number'),
(string) $request->get_param('reason'),
is_array($images) ? $images : [],
[
'reason_label' => (string) $request->get_param('reason_label'),
'materials' => is_array($materials) ? $materials : [],
'description' => (string) $request->get_param('description'),
]
);
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
}
public function rest_list(\WP_REST_Request $request) {
$number = sanitize_text_field((string) $request->get_param('order_number'));
if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]);
return rest_ensure_response(['success' => true, 'data' => $this->list_for_order($number)]);
}
public function rest_cancel(\WP_REST_Request $request) {
$captcha = $this->require_captcha($request, 'after_sale_cancel');
if (is_wp_error($captcha)) {
return $captcha;
}
$result = $this->cancel_for_user(get_current_user_id(), (string) $request->get_param('order_number'));
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
}
/** 人机验证(申请售后 / 撤销售后):场景未启用时 aksrt_require_captcha 直接放行。 */
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 : []);
}
/** @param array<string,mixed> $apply_form */
public function create(int $user_id, string $order_number, string $reason, array $images = [], array $apply_form = []) {
$reason = sanitize_textarea_field($reason);
$images = $this->sanitize_images($images);
$apply_form = $this->sanitize_apply_form($apply_form);
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number) || $reason === '' || mb_strlen($reason) > 2000) {
return new \WP_Error('aksrt_after_sale_invalid', __('请填写有效的售后原因', 'aksrt'), ['status' => 400]);
}
global $wpdb;
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]);
// 只挡「仍在流转」的工单。已完结(completed)的工单不挡——能否再售后由
// 「订单是否已退款」决定,而订单已退款时上面的 STATUS_PAID 校验已先挡掉。
$active = $this->find_active($order_number);
if ($active) {
if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) {
return $active;
}
return new \WP_Error('aksrt_after_sale_exists', __('该订单已有未结束售后申请', 'aksrt'), ['status' => 409]);
}
$amount = $this->refund_amount($order);
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
$now = current_time('mysql');
$inserted = $wpdb->insert($this->table(), [
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
'reason' => $reason,
'apply_form' => $apply_form === [] ? null : wp_json_encode($apply_form, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'images' => wp_json_encode($images), 'refund_amount' => $amount,
'status' => self::STATUS_PENDING, 'created_at' => $now,
'handling_log' => $this->encode_log([$this->log_entry(self::STATUS_PENDING, '', self::SOURCE_USER, $now)]),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/** 售后举证图片:最多 3 条,统一走 esc_url_raw;非字符串与空值直接丢弃。 */
private function sanitize_images(array $images): array {
$clean = [];
foreach ($images as $url) {
if (!is_string($url) || count($clean) >= 3) {
continue;
}
$url = esc_url_raw(trim($url));
if ($url !== '') {
$clean[] = $url;
}
}
return $clean;
}
/**
* 结构化申请表单(预置原因 / 申请资料 / 申请说明),供售后详细弹窗分行展示;
* 融合字符串 reason 保持原样入库,保证后台列表与历史数据兼容。
*
* @param array<string,mixed> $form
* @return array<string,mixed>
*/
private function sanitize_apply_form(array $form): array {
$reason_label = sanitize_text_field((string) ($form['reason_label'] ?? ''));
if (mb_strlen($reason_label) > 120) $reason_label = mb_substr($reason_label, 0, 120);
$materials = [];
if (is_array($form['materials'] ?? null)) {
foreach ($form['materials'] as $item) {
if (!is_array($item) || count($materials) >= 10) continue;
$label = sanitize_text_field((string) ($item['label'] ?? ''));
$value = sanitize_text_field((string) ($item['value'] ?? ''));
if ($label === '' || $value === '') continue;
if (mb_strlen($label) > 120) $label = mb_substr($label, 0, 120);
if (mb_strlen($value) > 500) $value = mb_substr($value, 0, 500);
$materials[] = ['label' => $label, 'value' => $value];
}
}
$description = sanitize_textarea_field((string) ($form['description'] ?? ''));
if (mb_strlen($description) > 2000) $description = mb_substr($description, 0, 2000);
$clean = [];
if ($reason_label !== '') $clean['reason_label'] = $reason_label;
if ($materials !== []) $clean['materials'] = $materials;
if ($description !== '') $clean['description'] = $description;
return $clean;
}
/**
* 退款金额:积分单取整,现金单经 AssetAmount 归一为两位小数;
* 金额无法解析时返回 null,由 create() 拒掉这次申请。
*/
private function refund_amount(array $order): ?string {
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
try { return \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
}
/** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */
public function latest_for_order(string $order_number): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC LIMIT 1", $order_number), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
public function cancel_for_user(int $user_id, string $order_number) {
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number)) return new \WP_Error('aksrt_after_sale_invalid', __('售后申请无效', 'aksrt'), ['status' => 400]);
global $wpdb;
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$row = $this->find_active($order_number);
if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]);
if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]);
if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]);
$this->append_log((int) $row['id'], $row, self::STATUS_CANCELLED, '', self::SOURCE_USER);
return $this->get((int) $row['id']);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/** @return list<array<string,mixed>> */
public function list_for_order(string $order_number): array {
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC", $order_number), ARRAY_A);
return array_map([$this, 'normalize'], (array) $rows);
}
/** @return list<array<string,mixed>> */
public function admin_list(array $args = []): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
$valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_REJECTED, self::STATUS_CANCELLED, self::STATUS_COMPLETED];
if ($status !== '' && in_array($status, $valid, true)) { $where[] = 'status = %s'; $params[] = $status; }
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)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$sql = "SELECT * FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' ORDER BY id DESC LIMIT %d OFFSET %d';
$params[] = $limit; $params[] = $offset;
return array_map([$this, 'normalize'], (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A));
}
public function count(array $args = []): int {
global $wpdb;
$status = sanitize_key((string) ($args['status'] ?? ''));
$where = [];
$params = [];
if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; }
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)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
$sql = "SELECT COUNT(*) FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '');
return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
}
/** A refund status transition is valid only for its own active application. */
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
if ($after_sale_id <= 0 || $order_number === '') return false;
$row = $this->get($after_sale_id);
return is_array($row)
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true);
}
/**
* 管理员直接设置售后工单状态:纯状态更新,不触发订单、资产与支付渠道联动。
*
* 每次提交都往 handling_log 落一条「状态 + 备注 + 来源 + 时间」,备注为空也留痕,
* 后台与前台才能回放整条处理链路。读取-追加-写回需要与 create()/cancel_for_user()
* 同一把订单锁,否则两条并发提交会互相覆盖日志。
*/
public function set_status(int $id, string $status, string $note = '') {
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
$status = sanitize_key($status);
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) return new \WP_Error('aksrt_after_sale_status', __('售后状态无效', 'aksrt'), ['status' => 400]);
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
global $wpdb;
$lock = 'aksrt_after_sale_' . md5((string) ($row['order_number'] ?? ''));
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
// 锁内重读:追加必须基于最新快照,否则两条并发提交仍会互相覆盖日志。
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
if (!$this->update($id, ['status' => $status])) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
$this->append_log($id, $row, $status, $note, self::SOURCE_ADMIN);
return $this->get($id);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/**
* 追加一条处理记录。日志按时间正序存放,超出 MAX_LOG_ENTRIES 条时只保留最近的一段。
*
* @param array<string,mixed> $current 追加前的工单行(含已规范化的 handling_log)。
*/
private function append_log(int $id, array $current, string $status, string $note, string $source): void {
$log = is_array($current['handling_log'] ?? null) ? $current['handling_log'] : [];
$log[] = $this->log_entry($status, $note, $source, current_time('mysql'));
if (count($log) > self::MAX_LOG_ENTRIES) {
$log = array_slice($log, -self::MAX_LOG_ENTRIES);
}
$this->update($id, ['handling_log' => $this->encode_log($log)]);
}
/** @return array<string,string> */
private function log_entry(string $status, string $note, string $source, string $created_at): array {
return [
'status' => $status,
'note' => $note,
'source' => $source,
'created_at' => $created_at,
];
}
private function encode_log(array $log): string {
return wp_json_encode($log, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/** 该订单当前仍在处理中的售后工单(待审核/处理中),退款闸门只认它。 */
public function find_active(string $number): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
/** 按 ID 读取售后申请(含关联订单号),供后台操作面板解析关联订单。 */
public function get(int $id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $id), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
private function update(int $id, array $data): bool {
global $wpdb;
$formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($data));
return $wpdb->update($this->table(), $data, ['id' => $id], $formats, ['%d']) !== false;
}
private function table(): string { return \AKSRT\Core\DBMigrator::table('order_aftersale'); }
/** @param array<string,mixed> $row */
private function normalize(array $row): array {
$row['id'] = (int) ($row['id'] ?? 0);
$row['user_id'] = (int) ($row['user_id'] ?? 0);
$images = json_decode((string) ($row['images'] ?? ''), true);
$row['images'] = is_array($images)
? array_values(array_filter($images, static fn($url): bool => is_string($url) && $url !== ''))
: [];
$apply_form = json_decode((string) ($row['apply_form'] ?? ''), true);
$row['apply_form'] = is_array($apply_form) ? $apply_form : null;
$row['handling_log'] = $this->normalize_log(json_decode((string) ($row['handling_log'] ?? ''), true));
return $row;
}
/**
* 处理记录规范化:只保留 status 非空的条目,source 只认 user,其余归 admin;
* 非数组(含损坏 JSON)整体视为空,界面对老工单按「暂无处理记录」处理。
*
* @return list<array<string,string>>
*/
private function normalize_log($log): array {
if (!is_array($log)) return [];
$clean = [];
foreach ($log as $entry) {
if (!is_array($entry)) continue;
$status = sanitize_key((string) ($entry['status'] ?? ''));
if ($status === '') continue;
$clean[] = [
'status' => $status,
'note' => (string) ($entry['note'] ?? ''),
'source' => (string) ($entry['source'] ?? '') === self::SOURCE_USER ? self::SOURCE_USER : self::SOURCE_ADMIN,
'created_at' => (string) ($entry['created_at'] ?? ''),
];
}
return $clean;
}
}
+1 -1
View File
@@ -136,7 +136,7 @@ class BalanceGateway implements GatewayInterface {
}
public function refund(string $order_number, string $amount, string $request_id = ''): array {
return ['success' => false, 'status' => 'failed', 'message' => __('余额订单售后请通过用户中心申请并由管理员审核', 'aksrt')];
return ['success' => false, 'status' => 'failed', 'message' => __('余额退款请联系管理员处理', 'aksrt')];
}
private function balance_enabled(): bool {
+1 -8
View File
@@ -16,7 +16,6 @@ final class OrderCenterQuery {
public const STATUS_ALL = 'all';
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_AFTER_SALE = 'after_sale';
public const STATUS_REFUNDED = 'refunded';
public const STATUS_CLOSED = 'closed';
@@ -24,7 +23,6 @@ final class OrderCenterQuery {
self::STATUS_ALL,
self::STATUS_PENDING,
self::STATUS_PAID,
self::STATUS_AFTER_SALE,
self::STATUS_REFUNDED,
self::STATUS_CLOSED,
];
@@ -94,7 +92,7 @@ final class OrderCenterQuery {
/** @return string[] */
public function database_statuses(): array {
if ($this->status() === self::STATUS_ALL || $this->status() === self::STATUS_AFTER_SALE) {
if ($this->status() === self::STATUS_ALL) {
return array_merge(array_keys(DomainModel::order_status_labels()), ['refunding']);
}
if ($this->status() === self::STATUS_PAID) {
@@ -103,10 +101,6 @@ final class OrderCenterQuery {
return [$this->status()];
}
/** 售后中:订单带有「待审核 / 处理中」工单,与订单自身状态无关。 */
public function filters_open_after_sale(): bool {
return $this->status() === self::STATUS_AFTER_SALE;
}
/** @return array<string, scalar> */
public function url_values(): array {
@@ -122,7 +116,6 @@ final class OrderCenterQuery {
public function repository_args(): array {
return [
'status_in' => $this->database_statuses(),
'after_sale_open' => $this->filters_open_after_sale(),
'product_type' => $this->product_type(),
'order_number_prefix' => $this->order_number(),
'limit' => $this->per_page(),
-8
View File
@@ -511,10 +511,6 @@ trait OrderRepositoryAdmin {
'count' => 0,
'latest' => [],
],
'after_sale' => [
'count' => 0,
'latest' => [],
],
'coupon' => [],
'pricing' => [
'subtotal' => '0.00',
@@ -591,10 +587,6 @@ trait OrderRepositoryAdmin {
'count' => 0,
'latest' => [],
],
'after_sale' => [
'count' => 0,
'latest' => [],
],
'coupon' => $coupon,
'pricing' => [
'subtotal' => $subtotal,
-18
View File
@@ -361,11 +361,6 @@ trait OrderRepositoryCore {
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED && empty($extra['allow_refund'])) {
return false;
}
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
&& (!class_exists(AfterSaleService::class) || !AfterSaleService::get_instance()->can_finalize_order_refund((int) ($extra['after_sale_id'] ?? 0), $order_number))
) {
return false;
}
$preflight = $this->get($order_number);
if (!$preflight) {
@@ -762,19 +757,6 @@ trait OrderRepositoryCore {
}
}
// 售后中:只认「待审核 / 处理中」两张在流转状态,与订单自身状态正交,
// 因此作为独立谓词叠加上去(工单已完结或已撤销不算售后中)。
if (!empty($args['after_sale_open']) && class_exists(AfterSaleService::class)) {
$open = [
AfterSaleService::STATUS_PENDING,
AfterSaleService::STATUS_PROCESSING,
];
$where[] = 'order_number IN (SELECT order_number FROM '
. \AKSRT\Core\DBMigrator::table('order_aftersale')
. ' WHERE status IN (' . implode(', ', array_fill(0, count($open), '%s')) . '))';
array_push($params, ...$open);
}
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
if ($product_type !== '') {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
-78
View File
@@ -183,9 +183,6 @@ class UserOrders {
$can_pay = $group === OrderCenterQuery::STATUS_PENDING;
$can_cancel = $group === OrderCenterQuery::STATUS_PENDING;
$order_number = (string) ($order['order_number'] ?? '');
$after_sale_service = class_exists(AfterSaleService::class) ? AfterSaleService::get_instance() : null;
$after_sales = $detail && $after_sale_service ? $after_sale_service->list_for_order($order_number) : [];
$after_sale_state = $this->after_sale_state($group, $order_number, (string) ($order['paid_at'] ?? ''), $after_sale_service, $after_sales);
$updated_at = $this->resolve_updated_at($order);
$view = [
@@ -214,7 +211,6 @@ class UserOrders {
'can_pay' => $can_pay,
'can_cancel' => $can_cancel,
],
'after_sale' => $after_sale_state,
'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status),
'updated_at' => $updated_at,
'status_version' => $this->resolve_status_version($order, $updated_at),
@@ -229,84 +225,10 @@ class UserOrders {
$view['paid_at'] = (string) ($order['paid_at'] ?? '');
$view['expires_at'] = (string) ($order['expires_at'] ?? '');
$view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false];
$view['after_sales'] = $after_sales;
}
return $view;
}
/**
* 售后入口状态——前台「申请售后 / 售后详细」按钮与三个弹窗的唯一数据源。
*
* 列表模式只拉最新一条工单(latest_for_order);详情模式直接复用已取到的
* 全量工单(list_for_order 按 id DESC,首条即最新)。
*
* @param array<int,array<string,mixed>> $records
* @return array<string,mixed>
*/
private function after_sale_state(string $group, string $order_number, string $paid_at, ?AfterSaleService $service, array $records = []): array {
$settings = class_exists('\AKSRT\Settings\PaymentRuntime')
? \AKSRT\Settings\PaymentRuntime::get_after_sale_settings()
: [];
$settings = is_array($settings) ? $settings : [];
$open_statuses = ['pending', 'processing'];
$latest = null;
$open = false;
if ($service !== null) {
if ($records) {
foreach ($records as $record) {
if ($latest === null) {
$latest = $record;
}
if (in_array((string) ($record['status'] ?? ''), $open_statuses, true)) {
$open = true;
}
}
} elseif ($order_number !== '') {
$latest = $service->latest_for_order($order_number);
$open = $latest !== null && in_array((string) ($latest['status'] ?? ''), $open_statuses, true);
}
}
// 售后只对已支付订单开放;开关关闭时整体不处理售后。
$enabled = $group === OrderCenterQuery::STATUS_PAID && !empty($settings['enabled']);
$expired = $enabled && class_exists('\AKSRT\Settings\PaymentRuntime')
&& \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at);
$has_application = $latest !== null;
// 可再次申请售后的状态判定(按工单状态推理,不再以「不能再申请」为常态):
// - pending / processing:工单仍在流转(待审核 / 处理中),不允许重复申请;
// - rejected:审核未通过,允许补充资料后再次申请;
// - cancelled:用户自行取消,允许重新发起;
// - completed:工单已完结,但只要订单没被另标「已退款」(enabled 仍为 true),
// 依旧允许再次申请。能否再售后由「订单是否已退款」决定,不由工单状态决定:
// 订单已退款时 $group 变成 refunded、enabled 直接为 false,上面那条自然挡死。
$latest_status = (string) ($latest['status'] ?? '');
$reapplyable_statuses = [
AfterSaleService::STATUS_REJECTED,
AfterSaleService::STATUS_CANCELLED,
AfterSaleService::STATUS_COMPLETED,
];
return [
'enabled' => $enabled,
'expired' => (bool) $expired,
'has_application' => $has_application,
'open' => $open,
'can_apply' => $enabled && !$expired && !$open,
'can_reapply' => $enabled && !$expired && $has_application && in_array($latest_status, $reapplyable_statuses, true),
'latest' => $has_application ? [
'status' => (string) ($latest['status'] ?? ''),
'reason' => (string) ($latest['reason'] ?? ''),
'apply_form' => is_array($latest['apply_form'] ?? null) ? $latest['apply_form'] : null,
'images' => array_values((array) ($latest['images'] ?? [])),
'refund_amount' => (string) ($latest['refund_amount'] ?? '0'),
'created_at' => (string) ($latest['created_at'] ?? ''),
// 处理记录:每次状态提交一条(状态 + 备注 + 来源 + 时间),前台时间线的唯一数据源。
'handling_log' => array_values((array) ($latest['handling_log'] ?? [])),
] : null,
];
}
/** @return array<string, mixed> */
private function empty_view(OrderCenterQuery $query): array {