diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 3f7e7a9a..84ba0401 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -31,6 +31,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; use Illuminate\Support\Carbon; @@ -69,6 +70,7 @@ use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository; use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; use Modules\EcommerceFinance\Services\AccountMappingStore; use Modules\EcommerceFinance\Services\AccountMappingValidator; +use Modules\EcommerceFinance\Services\DingTalkApprovalAttachmentDownloader; use Modules\EcommerceFinance\Services\StoreReportInitialPeriods; use Modules\EcommerceFinance\Services\TaxForecastLogic; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; @@ -4688,6 +4690,37 @@ class Pages extends Controller } } + /** + * 下载「应付云仓车费」某条审批实例的钉钉附件: + * 先校验附件确实属于该实例(镜像表单数据),再换取钉盘临时地址并透传下载。 + */ + public function downloadDingPayableAttachment(Request $request): StreamedResponse + { + $processInstanceId = mb_substr(trim((string) $request->query('process_instance_id', '')), 0, 100); + $fileId = mb_substr(trim((string) $request->query('file_id', '')), 0, 64); + abort_if($processInstanceId === '' || $fileId === '', 404); + + $attachment = $this->dingPayableWorkflows->attachment($processInstanceId, $fileId); + abort_if($attachment === null, 404, '附件不存在或不属于该审批实例。'); + + $uri = app(DingTalkApprovalAttachmentDownloader::class)->downloadUri($processInstanceId, $fileId); + abort_if($uri === null || $uri === '', 502, '暂时无法获取钉钉附件下载地址,请稍后重试。'); + + // 钉盘地址是带签名的 OSS 链接:请求时不要附带额外请求头,避免破坏签名。 + $upstream = Http::withOptions(['stream' => true])->timeout(60)->get($uri); + abort_if(! $upstream->successful(), 502, '附件下载失败,请稍后重试。'); + + $fileName = $attachment['fileName'] !== '' ? $attachment['fileName'] : $fileId; + $contentType = (string) ($upstream->header('Content-Type') ?: 'application/octet-stream'); + + return response()->streamDownload(function () use ($upstream): void { + $body = $upstream->toPsrResponse()->getBody(); + while (! $body->eof()) { + echo $body->read(8192); + } + }, $fileName, ['Content-Type' => $contentType]); + } + protected function contentForOnlineDaifaPrice(): array { $filters = $this->onlineDaifaPriceFilters(request()); diff --git a/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php index 3d0bbb17..0617e04f 100644 --- a/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/DingPayableWorkflowMirrorRepository.php @@ -98,6 +98,57 @@ class DingPayableWorkflowMirrorRepository ]; } + /** + * 校验某个审批实例里是否存在该附件,并返回附件元数据(下载接口使用, + * 防止拿任意 fileId 越权取钉盘文件)。 + * + * @return array{fileName:string,fileType:string,fileSize:int}|null + */ + public function attachment(string $processInstanceId, string $fileId): ?array + { + if ($processInstanceId === '' || $fileId === '') { + return null; + } + + $record = $this->connection->table('ding_payable_workflow_mirror') + ->where('process_instance_id', $processInstanceId) + ->first(); + + if ($record === null) { + return null; + } + + $scheme = $this->decode((string) $record->scheme_payload, []); + $forms = is_array($scheme) ? $this->formFields($scheme) : []; + $files = []; + + foreach (['附件', '未签字版合同附件或其他明细附件'] as $key) { + $value = $forms[$key] ?? null; + if (! is_array($value)) { + continue; + } + foreach ($value as $file) { + if (is_array($file)) { + $files[] = $file; + } + } + } + + foreach ($files as $file) { + if ((string) ($file['fileId'] ?? '') !== $fileId) { + continue; + } + + return [ + 'fileName' => trim((string) ($file['fileName'] ?? '')) ?: $fileId, + 'fileType' => trim((string) ($file['fileType'] ?? '')), + 'fileSize' => (int) ($file['fileSize'] ?? 0), + ]; + } + + return null; + } + /** @param array $workflow @param array $filters */ private function matchesWorkflowFilters(array $workflow, array $filters): bool { diff --git a/modules/EcommerceFinance/Services/DingTalkApprovalAttachmentDownloader.php b/modules/EcommerceFinance/Services/DingTalkApprovalAttachmentDownloader.php new file mode 100644 index 00000000..073e64cc --- /dev/null +++ b/modules/EcommerceFinance/Services/DingTalkApprovalAttachmentDownloader.php @@ -0,0 +1,71 @@ +accessToken(); + + if ($token === null) { + return null; + } + + $response = Http::withHeaders(['x-acs-dingtalk-access-token' => $token]) + ->timeout(20) + ->post(self::GRANT_DOWNLOAD_URL, [ + 'processInstanceId' => $processInstanceId, + 'fileId' => $fileId, + ]); + + if (! $response->successful()) { + report(new \RuntimeException( + 'dingtalk approval attachment grant failed: HTTP ' . $response->status() + )); + + return null; + } + + $uri = (string) $response->json('result.downloadUri'); + + return $uri !== '' ? $uri : null; + } + + private function accessToken(): ?string + { + $clientId = (string) config('dingtalk.client_id'); + $secret = (string) config('dingtalk.client_secret'); + + if ($clientId === '' || $secret === '') { + return null; + } + + $token = Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(100), function () use ($clientId, $secret): ?string { + $response = Http::timeout(15)->get(self::GET_TOKEN_URL, [ + 'appkey' => $clientId, + 'appsecret' => $secret, + ]); + + $token = (string) $response->json('access_token'); + + return $token !== '' ? $token : null; + }); + + return is_string($token) && $token !== '' ? $token : null; + } +} diff --git a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php index 893ade32..7903fc5b 100644 --- a/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php +++ b/resources/views/business/pages/courier-payables/payable-cloud-warehouse-vehicle-fee.blade.php @@ -45,6 +45,8 @@ .ding-payable-table tr:hover td { background:#fcfaf4; } .ding-payable-table .num { text-align:right; font-variant-numeric:tabular-nums; } .ding-payable-detail { max-width:26rem; white-space:normal; } + .ding-payable-attachment { color:var(--vip-content-accent-dark,#856632); } + .ding-payable-attachment-size { margin-left:.35rem; color:var(--vip-content-muted,#595959); font-size:.68rem; } .ding-payable-detail summary { cursor:pointer; color:var(--vip-content-accent-dark,#856632); } .ding-payable-detail pre { max-width:36rem; max-height:18rem; overflow:auto; margin:.5rem 0 0; padding:.65rem; border:1px solid var(--vip-border,#D8D2C7); background:#faf9f6; white-space:pre-wrap; word-break:break-word; font:inherit; } .ding-payable-empty { padding:4rem 1rem; text-align:center; color:var(--vip-content-muted,#595959); } @@ -124,7 +126,19 @@ {{ $value($detail, '银行账号') }}
展开字段({{ count($fields) }} 项)
{{ $json($fields) }}
展开付款明细
{{ $json($fields['付款明细'] ?? []) }}
-
展开附件
{{ $json(($fields['附件'] ?? '') ?: ($fields['未签字版合同附件或其他明细附件'] ?? [])) }}
+ @php($attachmentFiles = array_merge(is_array($fields['附件'] ?? null) ? $fields['附件'] : [], is_array($fields['未签字版合同附件或其他明细附件'] ?? null) ? $fields['未签字版合同附件或其他明细附件'] : [])) + + @forelse ($attachmentFiles as $attachmentFile) + @if (is_array($attachmentFile) && ! empty($attachmentFile['fileId'])) +
+ {{ $attachmentFile['fileName'] ?? ('附件 ' . $attachmentFile['fileId']) }} + @if (! empty($attachmentFile['fileSize'])){{ number_format(((int) $attachmentFile['fileSize']) / 1048576, 2) }} MB@endif +
+ @endif + @empty + - + @endforelse +
展开审批任务
{{ $json($row['tasks'] ?? []) }}
@empty diff --git a/routes/admin.php b/routes/admin.php index 094bdb16..3c47b50d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -356,6 +356,10 @@ Route::get('workspace/courier-payables/return-pickup-management/detail', 'Busine ->middleware('business.page:courier-payables,return-pickup-management') ->name('business.courier-payables.return-pickup-management.detail'); +Route::get('workspace/courier-payables/payable-cloud-warehouse-vehicle-fee/attachment', 'Business\Pages@downloadDingPayableAttachment') + ->middleware('business.page:courier-payables,payable-cloud-warehouse-vehicle-fee') + ->name('business.courier-payables.ding-payable-attachment.download'); + // 必须注册在通用 {section}/{page}/{tab}/export 之前,否则会被通用导出路由截获。 Route::get('workspace/courier-payables/courier-surcharge/settlement/export', 'Business\Pages@exportCourierSurchargeSettlement') ->middleware('business.page:courier-payables,courier-surcharge') diff --git a/tests/Feature/Business/DingPayableWorkflowPageTest.php b/tests/Feature/Business/DingPayableWorkflowPageTest.php index 29a3e174..355afdd6 100644 --- a/tests/Feature/Business/DingPayableWorkflowPageTest.php +++ b/tests/Feature/Business/DingPayableWorkflowPageTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; +use Illuminate\Support\Facades\Http; use Mockery; use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository; use Tests\Feature\FeatureTestCase; @@ -61,6 +62,9 @@ class DingPayableWorkflowPageTest extends FeatureTestCase ->assertSee('全部表单字段') ->assertSee('审批任务') ->assertSee('测试对账附件.xlsx') + ->assertSee('process_instance_id=PROC-TEST-001', false) + ->assertSee('file_id=123456', false) + ->assertSee('ding-payable-attachment"', false) ->assertSee('镜像状态:succeeded'); } @@ -147,4 +151,66 @@ class DingPayableWorkflowPageTest extends FeatureTestCase ->assertSee('状态') ->assertSee('全部状态'); } + + public function test_attachment_download_streams_dingtalk_file(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + config([ + 'dingtalk.client_id' => 'test-client-id', + 'dingtalk.client_secret' => 'test-client-secret', + ]); + + $repository = Mockery::mock(DingPayableWorkflowMirrorRepository::class); + $repository->shouldReceive('attachment') + ->once() + ->with('PROC-TEST-001', '123456') + ->andReturn(['fileName' => '测试对账附件.xlsx', 'fileType' => 'xlsx', 'fileSize' => 9]); + $this->app->instance(DingPayableWorkflowMirrorRepository::class, $repository); + + Http::fake([ + 'oapi.dingtalk.com/*' => Http::response(['access_token' => 'test-token']), + 'api.dingtalk.com/*' => Http::response([ + 'success' => true, + 'result' => ['downloadUri' => 'https://fake-oss.example.com/file'], + ]), + 'fake-oss.example.com/*' => Http::response('file-bytes', 200, ['Content-Type' => 'application/octet-stream']), + ]); + + $response = $this->loginAs()->get(route('business.courier-payables.ding-payable-attachment.download', [ + 'process_instance_id' => 'PROC-TEST-001', + 'file_id' => '123456', + ])); + + $response->assertOk() + ->assertHeader('content-type', 'application/octet-stream'); + + $this->assertSame('file-bytes', $response->streamedContent()); + $this->assertStringContainsString( + rawurlencode('测试对账附件.xlsx'), + (string) $response->headers->get('content-disposition'), + ); + } + + public function test_attachment_download_rejects_unknown_file(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $repository = Mockery::mock(DingPayableWorkflowMirrorRepository::class); + $repository->shouldReceive('attachment')->once()->andReturn(null); + $this->app->instance(DingPayableWorkflowMirrorRepository::class, $repository); + + Http::fake(); + + $this->loginAs() + ->withExceptionHandling() + ->withHeaders(['X-Requested-With' => 'XMLHttpRequest']) + ->get(route('business.courier-payables.ding-payable-attachment.download', [ + 'process_instance_id' => 'PROC-UNKNOWN', + 'file_id' => '999', + ])) + ->assertNotFound(); + + Http::assertNothingSent(); + } }