feat(admin-frontend): 补齐活跃筛选与支付快照能力
新增用户管理“活跃状态”高级筛选,并在后端支持 activity_status 复合规则,支持按活跃与非活跃筛选用户。 补齐订单支付成功快照落库与后台展示,保存支付渠道、 支付方法、实付金额和支付 IP,并在订单详情中优先展示。 同时增强节点页在线/离线筛选与批量删除、仪表盘快捷入口, 并修复已关闭工单再次回复后自动重开的统一语义。 附带同步测试、迁移、CI 工作流命名及知识库记录
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user