feat(admin-frontend): 补齐活跃筛选与支付快照能力

新增用户管理“活跃状态”高级筛选,并在后端支持
activity_status 复合规则,支持按活跃与非活跃筛选用户。

补齐订单支付成功快照落库与后台展示,保存支付渠道、
支付方法、实付金额和支付 IP,并在订单详情中优先展示。

同时增强节点页在线/离线筛选与批量删除、仪表盘快捷入口,
并修复已关闭工单再次回复后自动重开的统一语义。

附带同步测试、迁移、CI 工作流命名及知识库记录
This commit is contained in:
yinjianm
2026-04-25 00:59:08 +08:00
parent 2218457237
commit c64badfc23
55 changed files with 2023 additions and 71 deletions
+79 -1
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Exceptions\ApiException;
use App\Jobs\OrderHandleJob;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Plan;
use App\Models\TrafficResetLog;
use App\Models\User;
@@ -283,7 +284,7 @@ class OrderService
}
}
public function paid(string $callbackNo)
public function paid(string $callbackNo, array $paymentSnapshot = [])
{
$order = $this->order;
if ($order->status !== Order::STATUS_PENDING)
@@ -291,6 +292,7 @@ class OrderService
$order->status = Order::STATUS_PROCESSING;
$order->paid_at = time();
$order->callback_no = $callbackNo;
$order->fill($this->buildPaymentSnapshot($paymentSnapshot));
if (!$order->save())
return false;
try {
@@ -302,6 +304,82 @@ class OrderService
return true;
}
/**
* @param array<string, mixed> $paymentSnapshot
* @return array<string, mixed>
*/
private function buildPaymentSnapshot(array $paymentSnapshot): array
{
/** @var Payment|null $payment */
$payment = $this->order->relationLoaded('payment')
? $this->order->payment
: $this->order->payment()->first();
$channel = $this->normalizeSnapshotText(
$paymentSnapshot['payment_channel'] ?? $payment?->name ?? $payment?->payment
);
$method = $this->normalizeSnapshotText(
$paymentSnapshot['payment_method'] ?? $this->resolvePaymentMethod($payment)
);
$amount = $this->normalizeSnapshotAmount($paymentSnapshot['payment_amount'] ?? null);
$ip = $this->normalizeSnapshotText($paymentSnapshot['payment_ip'] ?? null);
return array_filter([
'payment_channel' => $channel,
'payment_method' => $method,
'payment_amount' => $amount,
'payment_ip' => $ip,
], static fn($value) => $value !== null && $value !== '');
}
private function resolvePaymentMethod(?Payment $payment): ?string
{
if (!$payment) {
return null;
}
$config = $payment->config;
if (is_string($config)) {
$decoded = json_decode($config, true);
$config = is_array($decoded) ? $decoded : [];
}
if (!is_array($config)) {
$config = [];
}
return $this->normalizeSnapshotText(
data_get($config, 'token_pay_currency') ?? $payment->payment ?? $payment->name
);
}
private function normalizeSnapshotText(mixed $value): ?string
{
if (!is_scalar($value)) {
return null;
}
$text = trim((string) $value);
return $text !== '' ? $text : null;
}
private function normalizeSnapshotAmount(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
$normalized = is_string($value)
? str_replace(',', '', trim($value))
: $value;
if (!is_numeric($normalized)) {
return null;
}
return (int) round(((float) $normalized) * 100);
}
public function cancel(): bool
{
$order = $this->order;
+19 -5
View File
@@ -22,11 +22,7 @@ class TicketService
'ticket_id' => $ticket->id,
'message' => $message
]);
$isAdmin = $userId !== $ticket->user_id;
$ticket->reply_status = $isAdmin
? Ticket::REPLY_STATUS_REPLIED
: Ticket::REPLY_STATUS_WAITING;
$ticket->last_reply_user_id = $userId;
$this->applyReplyState($ticket, (int) $userId);
if (!$ticketMessage || !$ticket->save()) {
throw new \Exception();
}
@@ -52,6 +48,24 @@ class TicketService
$this->sendEmailNotify($ticket, $ticketMessage);
}
/**
* Applies the unified reply state to a ticket.
*
* @param Ticket $ticket The target ticket.
* @param int $userId The replying user ID.
* @return void
*/
private function applyReplyState(Ticket $ticket, int $userId): void
{
$isAdmin = $userId !== $ticket->user_id;
$ticket->status = Ticket::STATUS_OPENING;
$ticket->reply_status = $isAdmin
? Ticket::REPLY_STATUS_REPLIED
: Ticket::REPLY_STATUS_WAITING;
$ticket->last_reply_user_id = $userId;
}
public function createTicket($userId, $subject, $level, $message)
{
try {