fix(存量报价): 修复批量保存被安全检查误拦截 #347

Merged
limeng merged 1 commits from fix/limeng-stock-quote-structured-review into main 2026-10-16 16:28:19 +08:00
3 changed files with 92 additions and 9 deletions

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Business;
use App\Models\Business\CourierStockQuoteEntry;
use App\Support\CourierStockQuoteBatchService;
use App\Support\CourierStockQuoteEntryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@ -54,11 +55,16 @@ class CourierStockQuoteEntries extends Controller
]);
}
public function saveBatch(Request $request, string $batch): RedirectResponse
public function saveBatch(Request $request, string $batch): RedirectResponse|JsonResponse
{
// 单个 JSON 字段避免百家工作表触及 PHP 表单字段数量限制。
$request->validate(['selection_json' => ['required', 'json', 'max:200000']]);
$selection = json_decode($request->input('selection_json'), true);
// JSON 请求体不受 max_input_vars 限制,防火墙也可逐字段检查,避免跨字段误匹配。
// 保留旧表单入口,已打开的页面仍由原有防护检查,不豁免任何用户输入。
if ($request->isJson()) {
$selection = $request->json()->all();
} else {
$request->validate(['selection_json' => ['required', 'json', 'max:200000']]);
$selection = json_decode($request->input('selection_json'), true);
}
if (! is_array($selection)) {
throw ValidationException::withMessages(['selection_json' => '请选择本次要录入的工作表。']);
}
@ -86,6 +92,10 @@ class CourierStockQuoteEntries extends Controller
])->validate();
$this->batches->save($batch, (int) user_id(), $data);
if ($request->expectsJson()) {
return response()->json(['redirect' => route('business.courier-stock-quotes.batch', $batch)]);
}
return redirect()->route('business.courier-stock-quotes.batch', $batch);
}

View File

@ -72,6 +72,7 @@
@if($batch)<p class="sb-hint">已保留本次上传的 Excel 和选择,无需重新上传。</p>@endif
</div>
@endif
<div class="sb-errors sb-error" role="alert" data-save-errors hidden tabindex="-1"></div>
@if(!$batch)
<p class="sb-notice">一个 Excel,每家一张工作表,电子报价旁附签署原件图片,整本上传一次。各家的价格、计费条件和执行日期分别核对。</p>
<form action="{{ route('business.courier-stock-quotes.batch-preview') }}" method="POST" enctype="multipart/form-data" data-batch-upload>
@ -116,7 +117,6 @@
<p class="sb-notice">{{ $manifest['source_file']['name'] }} · 识别到 <strong>{{ count($rows) }}</strong> 张工作表,{{ count(array_filter($rows, fn ($row) => $row['courier_expcode'] !== '' && !$row['error'] && !$row['duplicate'])) }} 张已匹配。未匹配、重名或重复报价会提示处理,不覆盖已有报价。</p>
<form action="{{ route('business.courier-stock-quotes.batch-save', $batch) }}" method="POST" data-batch-review>
@csrf
<input type="hidden" name="selection_json" data-selection-json>
<section class="sb-panel">
<h2 class="sb-header"><span class="material-icons-outlined" aria-hidden="true">rule</span>共用设置与计费规则</h2>
<div class="sb-body">
@ -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();
})();

View File

@ -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'] = '<script>alert(1)</script>';
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());