feat: 真实支付配置 + 修复 yansongda 配置结构 bug + 后台 AI 实时进度
- 支付设置:支付宝/微信各自支持正式/沙箱网关环境,凭据字段补全(支付宝公钥、微信商户证书私钥/平台证书 PEM),密钥可直接粘贴 PEM 文本(yansongda 支持字符串) - 修复关键 bug:yansongda 配置结构错误(app_id 等在 default 租户外,真实网关拿不到配置),现按 alipay.default / wechat.default 正确传入,并用 _force 保证每次调用生效 - 后台 AI 设置页新增 WebSocket 实时审核进度(ai.moderation.* 事件),未连接时降级提示 - 部署文档:明确 queue:work 与 workerman:serve 等价、dev:watch 用法 - 测试:支付宝/微信配置结构回归 + AI 设置页加载
This commit is contained in:
+21
-2
@@ -117,17 +117,36 @@ systemctl enable --now laralog-workerman
|
|||||||
|
|
||||||
调度器内注册的内容:`spatie/laravel-backup` 备份、缓存清理等(按需在 `routes/console.php` 增删)。
|
调度器内注册的内容:`spatie/laravel-backup` 备份、缓存清理等(按需在 `routes/console.php` 增删)。
|
||||||
|
|
||||||
## 队列降级(不使用 Workerman 时)
|
## 队列消费:workerman 与 queue:work 等价
|
||||||
|
|
||||||
|
队列任务(AI 审核/润色等)由**消费者**执行,两种方式消费同一个队列、功能完全等价:
|
||||||
|
|
||||||
|
| | workerman:serve | queue:work |
|
||||||
|
|---|---|---|
|
||||||
|
| 执行任务 | ✅ | ✅ |
|
||||||
|
| 性能 | 常驻进程,快 | 每任务启动框架,慢 |
|
||||||
|
| WebSocket 进度推送 | ✅ :8787 | ❌(任务照常执行,仅后台无实时提示) |
|
||||||
|
| 适用 | 生产推荐 | 降级 / 标准 Laravel 部署 |
|
||||||
|
|
||||||
|
**两者不要同时跑同一个队列**(虽不会重复消费,但浪费资源)。改业务代码后两者都需要重启才生效(常驻内存);`queue:work --once` 每任务新进程,适合调试。
|
||||||
|
|
||||||
|
### 降级方案(不用 Workerman)
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
# /etc/systemd/system/laralog-queue.service
|
# /etc/systemd/system/laralog-queue.service
|
||||||
[Service]
|
[Service]
|
||||||
User=www-data
|
User=www-data
|
||||||
WorkingDirectory=/var/www/laralog
|
WorkingDirectory=/var/www/laralog
|
||||||
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3
|
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3 --queue=default,ai
|
||||||
Restart=always
|
Restart=always
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 开发期热重载
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan dev:watch # 监听 app/plugins/routes/config/.env 变化,自动重启 workerman(fswatch 或 PHP 轮询兜底)
|
||||||
|
```
|
||||||
|
|
||||||
## 备份
|
## 备份
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -28,4 +28,60 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section heading="AI 审核实时进度"
|
||||||
|
:description="'WebSocket :'.config('workerman.websocket_port', 8787).'(需 workerman:serve 运行;未连接时结果仍会落库,可在评论列表查看)'">
|
||||||
|
<div id="ai-moderation-feed" class="space-y-1 text-sm" style="max-height:260px;overflow-y:auto;">
|
||||||
|
<p class="text-gray-500">等待事件…</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var port = {{ (int) config('workerman.websocket_port', 8787) }};
|
||||||
|
var host = location.hostname || 'localhost';
|
||||||
|
var feed = document.getElementById('ai-moderation-feed');
|
||||||
|
if (! feed) return;
|
||||||
|
|
||||||
|
var ws;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket('ws://' + host + ':' + port);
|
||||||
|
} catch (e) { return; }
|
||||||
|
|
||||||
|
ws.onopen = function () {
|
||||||
|
feed.innerHTML = '<p class="text-gray-400">已连接,等待 AI 审核事件…</p>';
|
||||||
|
};
|
||||||
|
ws.onclose = function () {
|
||||||
|
feed.innerHTML = '<p class="text-gray-500">WebSocket 未连接(workerman 未运行或端口不通)</p>';
|
||||||
|
};
|
||||||
|
ws.onmessage = function (e) {
|
||||||
|
var msg;
|
||||||
|
try { msg = JSON.parse(e.data); } catch (err) { return; }
|
||||||
|
if (! msg.event || msg.event.indexOf('ai.moderation') !== 0) return;
|
||||||
|
|
||||||
|
var d = msg.data || {};
|
||||||
|
var item = document.createElement('div');
|
||||||
|
var text = '[' + new Date().toLocaleTimeString() + '] 评论 #' + (d.comment_id || '?') + ' ';
|
||||||
|
var cls = 'border-gray-300 bg-gray-50 dark:border-white/10';
|
||||||
|
|
||||||
|
if (msg.event === 'ai.moderation.started') {
|
||||||
|
text += '开始审核…';
|
||||||
|
cls = 'border-blue-300 bg-blue-50 dark:border-blue-500/30';
|
||||||
|
} else if (msg.event === 'ai.moderation.finished') {
|
||||||
|
var labels = { approved: '已通过', rejected: '已拒绝', spam: '垃圾评论' };
|
||||||
|
text += '审核完成:' + (labels[d.verdict] || d.verdict);
|
||||||
|
cls = d.verdict === 'approved' ? 'border-green-300 bg-green-50 dark:border-green-500/30'
|
||||||
|
: (d.verdict === 'spam' ? 'border-red-300 bg-red-50 dark:border-red-500/30'
|
||||||
|
: 'border-amber-300 bg-amber-50 dark:border-amber-500/30');
|
||||||
|
} else if (msg.event === 'ai.moderation.failed') {
|
||||||
|
text += '审核失败:' + (d.error || '未知错误');
|
||||||
|
cls = 'border-red-300 bg-red-50 dark:border-red-500/30';
|
||||||
|
}
|
||||||
|
|
||||||
|
item.className = 'rounded-md border px-2 py-1 ' + cls;
|
||||||
|
item.textContent = text;
|
||||||
|
feed.prepend(item);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ namespace Plugins\Neatstudio\Payment\Filament\Pages;
|
|||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
@@ -35,9 +37,14 @@ class PaymentSettings extends Page
|
|||||||
'pay_sandbox' => (int) Setting::get('pay_sandbox', 1) === 1,
|
'pay_sandbox' => (int) Setting::get('pay_sandbox', 1) === 1,
|
||||||
'pay_alipay_app_id' => Setting::get('pay_alipay_app_id', ''),
|
'pay_alipay_app_id' => Setting::get('pay_alipay_app_id', ''),
|
||||||
'pay_alipay_app_secret' => Setting::get('pay_alipay_app_secret', ''),
|
'pay_alipay_app_secret' => Setting::get('pay_alipay_app_secret', ''),
|
||||||
|
'pay_alipay_public_key' => Setting::get('pay_alipay_public_key', ''),
|
||||||
|
'pay_alipay_mode' => Setting::get('pay_alipay_mode', 'normal'),
|
||||||
'pay_wechat_app_id' => Setting::get('pay_wechat_app_id', ''),
|
'pay_wechat_app_id' => Setting::get('pay_wechat_app_id', ''),
|
||||||
'pay_wechat_mch_id' => Setting::get('pay_wechat_mch_id', ''),
|
'pay_wechat_mch_id' => Setting::get('pay_wechat_mch_id', ''),
|
||||||
'pay_wechat_mch_secret' => Setting::get('pay_wechat_mch_secret', ''),
|
'pay_wechat_mch_secret' => Setting::get('pay_wechat_mch_secret', ''),
|
||||||
|
'pay_wechat_mch_cert' => Setting::get('pay_wechat_mch_cert', ''),
|
||||||
|
'pay_wechat_platform_cert' => Setting::get('pay_wechat_platform_cert', ''),
|
||||||
|
'pay_wechat_mode' => Setting::get('pay_wechat_mode', 'normal'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,21 +53,52 @@ class PaymentSettings extends Page
|
|||||||
return $schema
|
return $schema
|
||||||
->components([
|
->components([
|
||||||
Section::make('通用')
|
Section::make('通用')
|
||||||
|
->description('关闭沙箱后才会调用真实支付网关;支付宝/微信各自可选正式环境或官方沙箱环境(需对应环境的凭据)')
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('pay_sandbox')->label('沙箱模式(无需真实密钥即可测试支付流程)')->default(true),
|
Toggle::make('pay_sandbox')->label('沙箱模式(模拟支付,无需真实密钥即可测试完整流程)')->default(true),
|
||||||
]),
|
]),
|
||||||
Section::make('支付宝')
|
Section::make('支付宝')
|
||||||
|
->description('正式/沙箱环境凭据:open.alipay.com → 沙箱环境或控制台,RSA2 密钥生成器获取应用私钥与支付宝公钥')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('pay_alipay_app_id')->label('App ID')->columnSpanFull(),
|
Select::make('pay_alipay_mode')
|
||||||
TextInput::make('pay_alipay_app_secret')->label('应用私钥(app_secret_cert)')->password()->columnSpanFull(),
|
->label('网关环境')
|
||||||
|
->options(['normal' => '正式环境', 'sandbox' => '沙箱环境'])
|
||||||
|
->default('normal'),
|
||||||
|
TextInput::make('pay_alipay_app_id')
|
||||||
|
->label('App ID')
|
||||||
|
->placeholder('正式 / 沙箱应用 ID')
|
||||||
|
->columnSpan(2),
|
||||||
|
Textarea::make('pay_alipay_app_secret')
|
||||||
|
->label('应用私钥(RSA2 PEM)')
|
||||||
|
->rows(5)
|
||||||
|
->helperText('以 -----BEGIN PRIVATE KEY----- 开头')
|
||||||
|
->columnSpan(2),
|
||||||
|
Textarea::make('pay_alipay_public_key')
|
||||||
|
->label('支付宝公钥(RSA2 PEM)')
|
||||||
|
->rows(5)
|
||||||
|
->helperText('以 -----BEGIN PUBLIC KEY----- 开头;用于回调验签')
|
||||||
|
->columnSpan(2),
|
||||||
]),
|
]),
|
||||||
Section::make('微信支付')
|
Section::make('微信支付')
|
||||||
|
->description('微信商户平台(pay.weixin.qq.com)→ 账户中心 → API 安全:APIv3 密钥、商户 API 证书(apiclient_key.pem)、平台证书')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('pay_wechat_app_id')->label('App ID'),
|
Select::make('pay_wechat_mode')
|
||||||
|
->label('网关环境')
|
||||||
|
->options(['normal' => '正式环境', 'sandbox' => '沙箱环境'])
|
||||||
|
->default('normal'),
|
||||||
|
TextInput::make('pay_wechat_app_id')->label('App ID(小程序/公众号)'),
|
||||||
TextInput::make('pay_wechat_mch_id')->label('商户号 MCH ID'),
|
TextInput::make('pay_wechat_mch_id')->label('商户号 MCH ID'),
|
||||||
TextInput::make('pay_wechat_mch_secret')->label('API 密钥')->password()->columnSpanFull(),
|
TextInput::make('pay_wechat_mch_secret')->label('APIv3 密钥')->password()->columnSpan(2),
|
||||||
|
Textarea::make('pay_wechat_mch_cert')
|
||||||
|
->label('商户 API 证书私钥(apiclient_key.pem)')
|
||||||
|
->rows(5)
|
||||||
|
->columnSpan(2),
|
||||||
|
Textarea::make('pay_wechat_platform_cert')
|
||||||
|
->label('微信支付平台证书(公钥 PEM)')
|
||||||
|
->rows(5)
|
||||||
|
->columnSpan(2),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->statePath('data');
|
->statePath('data');
|
||||||
|
|||||||
@@ -113,31 +113,43 @@ class PaymentManager
|
|||||||
|
|
||||||
public function alipay(): Alipay
|
public function alipay(): Alipay
|
||||||
{
|
{
|
||||||
|
$mode = Setting::get('pay_alipay_mode', 'normal') === 'sandbox' ? Pay::MODE_SANDBOX : Pay::MODE_NORMAL;
|
||||||
|
|
||||||
return Pay::alipay([
|
return Pay::alipay([
|
||||||
'default' => [
|
'_force' => true,
|
||||||
'notify_url' => route('pay.notify', ['channel' => 'alipay']),
|
'alipay' => [
|
||||||
'return_url' => route('pay.return', ['channel' => 'alipay']),
|
'default' => [
|
||||||
|
'mode' => $mode,
|
||||||
|
'notify_url' => route('pay.notify', ['channel' => 'alipay']),
|
||||||
|
'return_url' => route('pay.return', ['channel' => 'alipay']),
|
||||||
|
'app_id' => Setting::get('pay_alipay_app_id', ''),
|
||||||
|
'app_secret_cert' => Setting::get('pay_alipay_app_secret', ''),
|
||||||
|
'app_public_cert_path' => null,
|
||||||
|
'alipay_public_cert_path' => Setting::get('pay_alipay_public_key', ''),
|
||||||
|
'alipay_root_cert_path' => null,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'app_id' => Setting::get('pay_alipay_app_id', ''),
|
|
||||||
'app_secret_cert' => Setting::get('pay_alipay_app_secret', ''),
|
|
||||||
'app_public_cert_path' => null,
|
|
||||||
'alipay_public_cert_path' => null,
|
|
||||||
'alipay_root_cert_path' => null,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function wechat(): Wechat
|
public function wechat(): Wechat
|
||||||
{
|
{
|
||||||
|
$mode = Setting::get('pay_wechat_mode', 'normal') === 'sandbox' ? Pay::MODE_SANDBOX : Pay::MODE_NORMAL;
|
||||||
|
|
||||||
return Pay::wechat([
|
return Pay::wechat([
|
||||||
'default' => [
|
'_force' => true,
|
||||||
'notify_url' => route('pay.notify', ['channel' => 'wechat']),
|
'wechat' => [
|
||||||
'return_url' => route('pay.return', ['channel' => 'wechat']),
|
'default' => [
|
||||||
|
'mode' => $mode,
|
||||||
|
'notify_url' => route('pay.notify', ['channel' => 'wechat']),
|
||||||
|
'return_url' => route('pay.return', ['channel' => 'wechat']),
|
||||||
|
'mch_id' => Setting::get('pay_wechat_mch_id', ''),
|
||||||
|
'mch_secret_key' => Setting::get('pay_wechat_mch_secret', ''),
|
||||||
|
'mch_secret_cert' => Setting::get('pay_wechat_mch_cert', ''),
|
||||||
|
'mch_public_cert_path' => Setting::get('pay_wechat_platform_cert', ''),
|
||||||
|
'mp_app_id' => Setting::get('pay_wechat_app_id', ''),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'mch_id' => Setting::get('pay_wechat_mch_id', ''),
|
|
||||||
'mch_secret_key' => Setting::get('pay_wechat_mch_secret', ''),
|
|
||||||
'mch_secret_cert' => null,
|
|
||||||
'mch_public_cert_path' => null,
|
|
||||||
'mp_app_id' => Setting::get('pay_wechat_app_id', ''),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class AdminPagesTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->actingAs($this->admin)->get('/admin/plugins')->assertOk();
|
$this->actingAs($this->admin)->get('/admin/plugins')->assertOk();
|
||||||
$this->actingAs($this->admin)->get('/admin/payments')->assertOk();
|
$this->actingAs($this->admin)->get('/admin/payments')->assertOk();
|
||||||
|
$this->actingAs($this->admin)->get('/admin/ai-settings')->assertOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_guest_is_redirected_to_login(): void
|
public function test_guest_is_redirected_to_login(): void
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Plugins\Neatstudio\Payment\Services\PaymentManager;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PaymentGatewayConfigTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_alipay_config_lands_in_default_tenant(): void
|
||||||
|
{
|
||||||
|
Setting::set('pay_alipay_mode', 'sandbox');
|
||||||
|
Setting::set('pay_alipay_app_id', '2021000000000000');
|
||||||
|
Setting::set('pay_alipay_app_secret', "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----");
|
||||||
|
Setting::set('pay_alipay_public_key', "-----BEGIN PUBLIC KEY-----\nBBBB\n-----END PUBLIC KEY-----");
|
||||||
|
|
||||||
|
app(PaymentManager::class)->alipay();
|
||||||
|
|
||||||
|
$config = \Yansongda\Pay\get_provider_config('alipay');
|
||||||
|
|
||||||
|
$this->assertSame(\Yansongda\Pay\Pay::MODE_SANDBOX, $config['mode']);
|
||||||
|
$this->assertSame('2021000000000000', $config['app_id']);
|
||||||
|
$this->assertStringContainsString('PRIVATE KEY', $config['app_secret_cert']);
|
||||||
|
$this->assertStringContainsString('PUBLIC KEY', $config['alipay_public_cert_path']);
|
||||||
|
$this->assertStringContainsString('pay/notify', $config['notify_url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_wechat_config_lands_in_default_tenant(): void
|
||||||
|
{
|
||||||
|
Setting::set('pay_wechat_mode', 'normal');
|
||||||
|
Setting::set('pay_wechat_mch_id', '1900000001');
|
||||||
|
Setting::set('pay_wechat_mch_secret', 'api-v3-key');
|
||||||
|
Setting::set('pay_wechat_mch_cert', "-----BEGIN PRIVATE KEY-----\nCCCC\n-----END PRIVATE KEY-----");
|
||||||
|
Setting::set('pay_wechat_platform_cert', "-----BEGIN PUBLIC KEY-----\nDDDD\n-----END PUBLIC KEY-----");
|
||||||
|
Setting::set('pay_wechat_app_id', 'wx123456');
|
||||||
|
|
||||||
|
app(PaymentManager::class)->wechat();
|
||||||
|
|
||||||
|
$config = \Yansongda\Pay\get_provider_config('wechat');
|
||||||
|
|
||||||
|
$this->assertSame(\Yansongda\Pay\Pay::MODE_NORMAL, $config['mode']);
|
||||||
|
$this->assertSame('1900000001', $config['mch_id']);
|
||||||
|
$this->assertSame('api-v3-key', $config['mch_secret_key']);
|
||||||
|
$this->assertStringContainsString('PRIVATE KEY', $config['mch_secret_cert']);
|
||||||
|
$this->assertStringContainsString('PUBLIC KEY', $config['mch_public_cert_path']);
|
||||||
|
$this->assertSame('wx123456', $config['mp_app_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user