应付云仓车费:附件支持点击下载 (#329)

This commit is contained in:
zhoushengchen 2026-10-13 13:58:31 +08:00
parent 26b83bd8e7
commit 8905192b78
6 changed files with 240 additions and 1 deletions

View File

@ -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());

View File

@ -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<string,mixed> $workflow @param array<string,mixed> $filters */
private function matchesWorkflowFilters(array $workflow, array $filters): bool
{

View File

@ -0,0 +1,71 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* 通过钉钉「下载审批附件」接口把审批表单里的附件(spaceId/fileId)换成
* 钉盘临时下载地址。应用凭证复用登录用的企业内部应用
* (config('dingtalk.client_id' / 'client_secret'))。
*/
class DingTalkApprovalAttachmentDownloader
{
private const GET_TOKEN_URL = 'https://oapi.dingtalk.com/gettoken';
private const GRANT_DOWNLOAD_URL = 'https://api.dingtalk.com/v1.0/workflow/processInstances/spaces/files/urls/download';
private const TOKEN_CACHE_KEY = 'dingtalk:app_access_token';
public function downloadUri(string $processInstanceId, string $fileId): ?string
{
$token = $this->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;
}
}

View File

@ -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 @@
<td>{{ $value($detail, '银行账号') }}</td>
<td class="ding-payable-detail"><details><summary>展开字段({{ count($fields) }} 项)</summary><pre>{{ $json($fields) }}</pre></details></td>
<td class="ding-payable-detail"><details><summary>展开付款明细</summary><pre>{{ $json($fields['付款明细'] ?? []) }}</pre></details></td>
<td class="ding-payable-detail"><details><summary>展开附件</summary><pre>{{ $json(($fields['附件'] ?? '') ?: ($fields['未签字版合同附件或其他明细附件'] ?? [])) }}</pre></details></td>
@php($attachmentFiles = array_merge(is_array($fields['附件'] ?? null) ? $fields['附件'] : [], is_array($fields['未签字版合同附件或其他明细附件'] ?? null) ? $fields['未签字版合同附件或其他明细附件'] : []))
<td class="ding-payable-detail">
@forelse ($attachmentFiles as $attachmentFile)
@if (is_array($attachmentFile) && ! empty($attachmentFile['fileId']))
<div>
<a class="ding-payable-attachment" href="{{ route('business.courier-payables.ding-payable-attachment.download', ['process_instance_id' => $row['process_instance_id'] ?? '', 'file_id' => $attachmentFile['fileId']]) }}" title="下载附件">{{ $attachmentFile['fileName'] ?? ('附件 ' . $attachmentFile['fileId']) }}</a>
@if (! empty($attachmentFile['fileSize']))<span class="ding-payable-attachment-size">{{ number_format(((int) $attachmentFile['fileSize']) / 1048576, 2) }} MB</span>@endif
</div>
@endif
@empty
<span>-</span>
@endforelse
</td>
<td class="ding-payable-detail"><details><summary>展开审批任务</summary><pre>{{ $json($row['tasks'] ?? []) }}</pre></details></td>
</tr>
@empty

View File

@ -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')

View File

@ -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();
}
}