应付云仓车费:附件支持点击下载(钉钉审批附件接口+钉盘地址透传)
This commit is contained in:
parent
26b83bd8e7
commit
8004325b2f
@ -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());
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user