@@ -263,7 +263,10 @@
const select = !visible.every(row => row.querySelector('[data-row-selected]').checked);
visible.forEach(row => row.querySelector('[data-row-selected]').checked = select); update();
});
- form.addEventListener('submit', event => {
+ let saving = false;
+ form.addEventListener('submit', async event => {
+ event.preventDefault();
+ if (saving) return;
const payload = {
quote_type: form.querySelector('[data-common-type]').value,
effective_date: commonDate.value,
@@ -273,9 +276,41 @@
rule_groups: [...groups.querySelectorAll('[data-rule-group]')].map(group => ({ name: group.querySelector('[data-rule-name]').value, rules: group.querySelector('[data-rule-text]').value })),
entries: rows.map(row => ({ selected: row.querySelector('[data-row-selected]').checked, courier_expcode: row.querySelector('[data-row-courier]').value.trim(), rule_group: Number(row.querySelector('[data-row-rule]').value), effective_date: row.querySelector('[data-row-date]').value }))
};
- form.querySelector('[data-selection-json]').value = JSON.stringify(payload);
- form.querySelectorAll('button[type=submit]').forEach(button => button.disabled = true);
+ const errors = root.querySelector('[data-save-errors]');
+ errors.hidden = true;
+ const buttons = [...form.querySelectorAll('button[type=submit]')];
+ const label = event.submitter?.textContent;
+ saving = true;
+ buttons.forEach(button => button.disabled = true);
if (event.submitter) event.submitter.textContent = '正在批量保存…';
+ try {
+ const response = await fetch(form.action, {
+ method: 'POST', credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': form.querySelector('[name=_token]').value },
+ body: JSON.stringify(payload)
+ });
+ const result = response.headers.get('content-type')?.includes('application/json') ? await response.json() : {};
+ if (response.ok && result.redirect) {
+ window.location.assign(result.redirect);
+ return;
+ }
+ const messages = result.errors ? Object.values(result.errors).flat() : [
+ response.status === 419 ? '登录状态已过期,请刷新页面后重试;已上传的原文件会保留。' :
+ response.status === 403 ? '保存请求被安全检查或权限检查拦截,本页内容已保留,请联系管理员检查。' :
+ '本次保存未能确认完成,请在本页重试;同一批次重试不会重复录入。'
+ ];
+ errors.replaceChildren(...messages.map(message => { const item = document.createElement('div'); item.textContent = message; return item; }));
+ errors.hidden = false;
+ errors.focus();
+ } catch (error) {
+ errors.textContent = '网络连接中断,暂时无法确认保存结果。请在本页重试,同一批次不会重复录入,已填写内容和原件会保留。';
+ errors.hidden = false;
+ errors.focus();
+ } finally {
+ saving = false;
+ buttons.forEach(button => button.disabled = false);
+ if (event.submitter) event.submitter.textContent = label;
+ }
});
update();
})();
diff --git a/tests/Feature/Business/CourierStockQuoteBatchTest.php b/tests/Feature/Business/CourierStockQuoteBatchTest.php
index 1dc819cf..0926f0d6 100644
--- a/tests/Feature/Business/CourierStockQuoteBatchTest.php
+++ b/tests/Feature/Business/CourierStockQuoteBatchTest.php
@@ -2,6 +2,7 @@
namespace Tests\Feature\Business;
+use Akaunting\Firewall\Middleware\Xss;
use App\Models\Auth\Permission;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierStockQuoteEntry;
@@ -101,6 +102,42 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$this->assertDatabaseCount('business_courier_quote_signatures', 0);
}
+ public function test_structured_json_save_passes_real_xss_checks_and_returns_correctable_errors(): void
+ {
+ $batch = $this->preview(['EXP-001', 'EXP-002']);
+ $selection = $this->selection(['EXP-001', 'EXP-002']);
+ // 合法名称中出现 JavaScript,但没有协议或标签;序列化后会与后续字段的冒号串联误匹配。
+ $selection['summary'] = 'JavaScript物流报价补录';
+ $middleware = new Xss();
+ $middleware->prepare(\Illuminate\Http\Request::create('/', 'POST'));
+ $this->assertSame(1, $middleware->match($middleware->getPatterns()[3], ['selection_json' => json_encode($selection)]));
+ config(['firewall.middleware.xss.enabled' => true]);
+ $url = route('business.courier-stock-quotes.batch-save', $batch);
+ $selection['entries'][1]['courier_expcode'] = 'EXP-001';
+ $this->withExceptionHandling()->loginAs()->postJson($url, $selection)
+ ->assertUnprocessable()->assertJsonValidationErrors('entries.1');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ $selection['entries'][1]['courier_expcode'] = 'EXP-002';
+ $selection['submit_for_review'] = $selection['confirmed'] = true;
+ $this->postJson($url, $selection)->assertOk()
+ ->assertJsonPath('redirect', route('business.courier-stock-quotes.batch', $batch));
+ $this->assertSame([CourierQuoteChange::STATUS_PENDING_REVIEW], CourierQuoteChange::pluck('status')->unique()->all());
+ $this->postJson($url, $selection)->assertOk();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
+ $this->assertDatabaseCount('business_courier_quote_versions', 0);
+ }
+
+ public function test_structured_json_keeps_malicious_fields_blocked_by_real_xss_middleware(): void
+ {
+ $batch = $this->preview(['EXP-001']);
+ $selection = $this->selection(['EXP-001']);
+ $selection['rule_groups'][0]['rules'] = '';
+ config(['firewall.middleware.xss.enabled' => true, 'firewall.logs.enabled' => false]);
+ $this->withExceptionHandling()->loginAs()->postJson(route('business.courier-stock-quotes.batch-save', $batch), $selection)
+ ->assertForbidden();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ }
+
public function test_row_errors_are_atomic_keep_preview_and_do_not_override_existing_quotes(): void
{
$batch = $this->preview(['EXP-001', 'EXP-002']);
@@ -160,7 +197,8 @@ class CourierStockQuoteBatchTest extends FeatureTestCase
$this->mirror($couriers);
$batch = $this->preview($names, array_fill(0, 120, 1));
$this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('120');
- $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($this->selection($names))])->assertRedirect();
+ config(['firewall.middleware.xss.enabled' => true]);
+ $this->postJson(route('business.courier-stock-quotes.batch-save', $batch), $this->selection($names))->assertOk();
$this->assertDatabaseCount('business_courier_stock_quote_entries', 120);
$this->assertDatabaseCount('business_courier_quote_changes', 120);
$this->assertCount(1, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[0]['path'])->unique());
--
2.47.2