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
@@ -0,0 +1,77 @@
<?php
namespace Tests\Unit\Admin;
use App\Http\Controllers\V2\Admin\UserController;
use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Database\Query\Builder as QueryBuilder;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
class UserControllerActivityStatusFilterTest extends TestCase
{
private static ?Capsule $capsule = null;
public function test_resolve_activity_status_value_supports_eq_payloads(): void
{
$controller = new UserController();
$method = new ReflectionMethod(UserController::class, 'resolveActivityStatusValue');
$method->setAccessible(true);
$this->assertTrue($method->invoke($controller, 'eq:1'));
$this->assertFalse($method->invoke($controller, 'eq:0'));
$this->assertNull($method->invoke($controller, 'gte:1'));
}
public function test_active_activity_status_filter_requires_plan_remaining_traffic_and_recent_online(): void
{
$builder = $this->newQueryBuilder();
$this->applyFilter($builder, 'activity_status', 'eq:1');
$sql = $builder->toSql();
$this->assertStringContainsString('"plan_id" is not null', $sql);
$this->assertStringContainsString('COALESCE(transfer_enable, 0) > COALESCE(u, 0) + COALESCE(d, 0)', $sql);
$this->assertStringContainsString('"last_online_at" is not null', $sql);
$this->assertStringContainsString('"last_online_at" >= ?', $sql);
$this->assertCount(1, $builder->getBindings());
}
public function test_inactive_activity_status_filter_uses_reverse_condition_set(): void
{
$builder = $this->newQueryBuilder();
$this->applyFilter($builder, 'activity_status', 'eq:0');
$sql = $builder->toSql();
$this->assertStringContainsString('("plan_id" is null or COALESCE(transfer_enable, 0) <= COALESCE(u, 0) + COALESCE(d, 0) or "last_online_at" is null or "last_online_at" < ?)', $sql);
$this->assertCount(1, $builder->getBindings());
}
private function applyFilter(QueryBuilder $builder, string $field, mixed $value): void
{
$controller = new UserController();
$method = new ReflectionMethod(UserController::class, 'buildFilterQuery');
$method->setAccessible(true);
$method->invoke($controller, $builder, $field, $value);
}
private function newQueryBuilder(): QueryBuilder
{
if (!self::$capsule) {
$capsule = new Capsule();
$capsule->addConnection([
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
$capsule->setAsGlobal();
$capsule->bootEloquent();
self::$capsule = $capsule;
}
return self::$capsule->getConnection()->table('v2_user');
}
}
@@ -0,0 +1,92 @@
<?php
namespace Tests\Unit\Orders;
use App\Models\Order;
use App\Models\Payment;
use App\Services\OrderService;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
class OrderServicePaymentSnapshotTest extends TestCase
{
public function test_build_payment_snapshot_prefers_callback_metadata_over_payment_defaults(): void
{
$payment = new Payment([
'name' => 'TokenPay 收银台',
'payment' => 'TokenPay',
'config' => ['token_pay_currency' => 'USDT_TRC20'],
]);
$order = new Order();
$order->setRelation('payment', $payment);
$payload = $this->invokeBuildPaymentSnapshot($order, [
'payment_channel' => '链上收银台',
'payment_method' => 'TRC20',
'payment_amount' => '19.29',
'payment_ip' => '117.132.7.238',
]);
$this->assertSame('链上收银台', $payload['payment_channel']);
$this->assertSame('TRC20', $payload['payment_method']);
$this->assertSame(1929, $payload['payment_amount']);
$this->assertSame('117.132.7.238', $payload['payment_ip']);
}
public function test_build_payment_snapshot_falls_back_to_payment_config_when_callback_method_missing(): void
{
$payment = new Payment([
'name' => 'TokenPay 收银台',
'payment' => 'TokenPay',
'config' => ['token_pay_currency' => 'USDT_TRC20'],
]);
$order = new Order();
$order->setRelation('payment', $payment);
$payload = $this->invokeBuildPaymentSnapshot($order, []);
$this->assertSame('TokenPay 收银台', $payload['payment_channel']);
$this->assertSame('USDT_TRC20', $payload['payment_method']);
$this->assertArrayNotHasKey('payment_amount', $payload);
$this->assertArrayNotHasKey('payment_ip', $payload);
}
public function test_build_payment_snapshot_ignores_invalid_amount_and_blank_ip(): void
{
$payment = new Payment([
'payment' => 'TokenPay',
'config' => [],
]);
$order = new Order();
$order->setRelation('payment', $payment);
$payload = $this->invokeBuildPaymentSnapshot($order, [
'payment_amount' => 'invalid',
'payment_ip' => ' ',
]);
$this->assertSame('TokenPay', $payload['payment_channel']);
$this->assertSame('TokenPay', $payload['payment_method']);
$this->assertArrayNotHasKey('payment_amount', $payload);
$this->assertArrayNotHasKey('payment_ip', $payload);
}
/**
* @param array<string, mixed> $paymentSnapshot
* @return array<string, mixed>
*/
private function invokeBuildPaymentSnapshot(Order $order, array $paymentSnapshot): array
{
$service = new OrderService($order);
$method = new ReflectionMethod(OrderService::class, 'buildPaymentSnapshot');
$method->setAccessible(true);
/** @var array<string, mixed> $result */
$result = $method->invoke($service, $paymentSnapshot);
return $result;
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Unit;
use App\Models\Ticket;
use App\Services\TicketService;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
class TicketServiceReplyStateTest extends TestCase
{
public function test_user_reply_reopens_closed_ticket_and_marks_waiting(): void
{
$ticket = new Ticket([
'user_id' => 1001,
'status' => Ticket::STATUS_CLOSED,
'reply_status' => Ticket::REPLY_STATUS_REPLIED,
'last_reply_user_id' => 2002,
]);
$this->applyReplyState($ticket, 1001);
$this->assertSame(Ticket::STATUS_OPENING, $ticket->status);
$this->assertSame(Ticket::REPLY_STATUS_WAITING, $ticket->reply_status);
$this->assertSame(1001, $ticket->last_reply_user_id);
}
public function test_admin_reply_reopens_closed_ticket_and_marks_replied(): void
{
$ticket = new Ticket([
'user_id' => 1001,
'status' => Ticket::STATUS_CLOSED,
'reply_status' => Ticket::REPLY_STATUS_WAITING,
'last_reply_user_id' => 1001,
]);
$this->applyReplyState($ticket, 3003);
$this->assertSame(Ticket::STATUS_OPENING, $ticket->status);
$this->assertSame(Ticket::REPLY_STATUS_REPLIED, $ticket->reply_status);
$this->assertSame(3003, $ticket->last_reply_user_id);
}
private function applyReplyState(Ticket $ticket, int $userId): void
{
$service = new TicketService();
$method = new ReflectionMethod(TicketService::class, 'applyReplyState');
$method->setAccessible(true);
$method->invoke($service, $ticket, $userId);
}
}