From 0315235c24780132d28b53e6f0c8ce81b1dd54de Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Fri, 6 Nov 2026 11:38:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BF=AB=E9=80=92=E5=85=A5=E9=A9=BB?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=90=8E=E8=87=AA=E5=8A=A8=E5=8F=91=E8=B5=B7?= =?UTF-8?q?=E9=92=89=E9=92=89=E5=AE=A1=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StartCourierOnboardingDingTalk.php | 39 ++++ .../CourierOnboardingDingTalkException.php | 13 ++ .../Business/CourierOnboardingEvent.php | 4 + .../DingTalk/CourierOnboardingClient.php | 118 +++++++++++ app/Support/CourierOnboardingDingTalk.php | 166 +++++++++++++++ app/Support/CourierOnboardingService.php | 12 ++ config/courier_onboarding.php | 10 + docs/快递入驻钉钉自动发起.md | 43 ++++ .../CourierOnboardingDingTalkTest.php | 195 ++++++++++++++++++ .../CourierOnboardingWorkflowTest.php | 68 ++++++ 10 files changed, 668 insertions(+) create mode 100644 app/Console/Commands/StartCourierOnboardingDingTalk.php create mode 100644 app/Exceptions/CourierOnboardingDingTalkException.php create mode 100644 app/Services/DingTalk/CourierOnboardingClient.php create mode 100644 app/Support/CourierOnboardingDingTalk.php create mode 100644 docs/快递入驻钉钉自动发起.md create mode 100644 tests/Feature/Business/CourierOnboardingDingTalkTest.php diff --git a/app/Console/Commands/StartCourierOnboardingDingTalk.php b/app/Console/Commands/StartCourierOnboardingDingTalk.php new file mode 100644 index 00000000..fd7fd149 --- /dev/null +++ b/app/Console/Commands/StartCourierOnboardingDingTalk.php @@ -0,0 +1,39 @@ +argument('id')) || ! ctype_digit((string) $this->option('company')) + || (int) $this->argument('id') < 1 || (int) $this->option('company') < 1) { + $this->error('请指定有效的入驻申请ID及 --company 公司ID。'); + + return self::FAILURE; + } + if ($this->option('instance')) { + $service->reconcile((int) $this->option('company'), (int) $this->argument('id'), (string) $this->option('instance')); + $this->info('已核对并关联现有审批,没有创建新审批。'); + + return self::SUCCESS; + } + + $status = $service->start((int) $this->option('company'), (int) $this->argument('id'), (bool) $this->option('retry')); + $this->line(match ($status) { + 'started' => '已发起钉钉审批。', + 'disabled' => '入驻钉钉自动发起未启用。', + 'skipped' => '未重复发起;已有审批、已有尝试或当前状态不允许,请查看入驻处理记录。', + default => '未确认发起成功,请查看入驻处理记录;结果待核查时请勿重发。', + }); + + return $status === 'started' || $status === 'skipped' ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Exceptions/CourierOnboardingDingTalkException.php b/app/Exceptions/CourierOnboardingDingTalkException.php new file mode 100644 index 00000000..0d6667f5 --- /dev/null +++ b/app/Exceptions/CourierOnboardingDingTalkException.php @@ -0,0 +1,13 @@ + '生成入驻二维码', 'saved' => '快递方保存资料', 'submitted' => '快递方提交审核', + 'dingtalk_starting' => '正在发起钉钉审批', + 'dingtalk_started' => '已发起钉钉审批', + 'dingtalk_failed' => '钉钉审批发起失败', + 'dingtalk_unknown' => '钉钉审批结果待核查', 'first_approved' => '初审通过', 'returned' => '退回补件', 'final_approved' => '财务复审通过', diff --git a/app/Services/DingTalk/CourierOnboardingClient.php b/app/Services/DingTalk/CourierOnboardingClient.php new file mode 100644 index 00000000..2ceae6af --- /dev/null +++ b/app/Services/DingTalk/CourierOnboardingClient.php @@ -0,0 +1,118 @@ +connectTimeout(3)->timeout(8) + ->post(self::BASE_URL . '/oauth2/accessToken', [ + 'appKey' => $settings['client_id'], + 'appSecret' => $settings['client_secret'], + ]); + } catch (ConnectionException) { + throw new CourierOnboardingDingTalkException('获取钉钉应用凭证超时,尚未发起审批。'); + } + + $token = $response->json('accessToken'); + if (! $response->successful() || ! is_string($token) || trim($token) === '') { + throw new CourierOnboardingDingTalkException('获取钉钉应用凭证失败,请检查应用凭据和权限。'); + } + + return $token; + } + + public function start(string $token, array $payload): string + { + try { + $response = Http::acceptJson()->connectTimeout(3)->timeout(12) + ->withHeaders(['x-acs-dingtalk-access-token' => $token]) + ->post(self::BASE_URL . '/workflow/processInstances', $payload); + } catch (ConnectionException) { + throw new CourierOnboardingDingTalkException('发起请求超时,请先在钉钉核查是否已生成审批,勿重复发起。', true); + } + + if (! $response->successful()) { + // 不保存第三方原始响应,防止请求内容或凭据被带入入驻记录。 + $code = preg_replace('/[^a-zA-Z0-9_.-]/', '', (string) $response->json('code', '')); + $uncertain = $response->serverError() || $response->status() === 408; + throw new CourierOnboardingDingTalkException( + '钉钉返回 HTTP ' . $response->status() . ($code !== '' ? '(' . substr($code, 0, 100) . ')' : '') + . '。' . ($uncertain ? '请先核查是否已生成审批。' : '请检查流程配置和发起权限。'), + $uncertain, + ); + } + + $id = $response->json('instanceId'); + if (! is_string($id) || trim($id) === '') { + throw new CourierOnboardingDingTalkException('钉钉响应缺少审批编号,请先核查是否已生成审批。', true); + } + + return trim($id); + } + + public function instance(string $token, string $instanceId): array + { + try { + $response = Http::acceptJson()->connectTimeout(3)->timeout(8) + ->withHeaders(['x-acs-dingtalk-access-token' => $token]) + ->get(self::BASE_URL . '/workflow/processInstances', ['processInstanceId' => $instanceId]); + } catch (ConnectionException) { + throw new CourierOnboardingDingTalkException('查询钉钉审批超时。'); + } + + if (! $response->successful() || ! is_array($response->json('result'))) { + throw new CourierOnboardingDingTalkException('无法读取指定的钉钉审批。'); + } + + return $response->json('result'); + } + + public function belongsToProcess(string $token, string $instanceId, string $createdAt): bool + { + $created = Carbon::parse($createdAt); + $nextToken = 0; + // 仅查询创建时刻前后一分钟的指定流程与发起人,限制核查范围。 + for ($page = 0; $page < 5; $page++) { + $response = Http::acceptJson()->connectTimeout(3)->timeout(8) + ->withHeaders(['x-acs-dingtalk-access-token' => $token]) + ->post(self::BASE_URL . '/workflow/processes/instanceIds/query', [ + 'processCode' => config('courier_onboarding.dingtalk.process_code'), + 'userIds' => [config('courier_onboarding.dingtalk.originator_user_id')], + 'startTime' => $created->copy()->subMinute()->getTimestampMs(), + 'endTime' => $created->copy()->addMinute()->getTimestampMs(), + 'maxResults' => 100, 'nextToken' => $nextToken, + ]); + if (! $response->successful() || ! is_array($response->json('result.list'))) { + throw new CourierOnboardingDingTalkException('无法核对审批所属流程,未关联。'); + } + if (in_array($instanceId, $response->json('result.list'), true)) { + return true; + } + $next = $response->json('result.nextToken'); + if ($next === null || $next === '' || (int) $next === $nextToken) { + return false; + } + $nextToken = (int) $next; + } + + return false; + } +} diff --git a/app/Support/CourierOnboardingDingTalk.php b/app/Support/CourierOnboardingDingTalk.php new file mode 100644 index 00000000..bc7e0c29 --- /dev/null +++ b/app/Support/CourierOnboardingDingTalk.php @@ -0,0 +1,166 @@ +query($companyId)->lockForUpdate()->findOrFail($id); + if (filled($record->dingtalk_instance_id) + || ! in_array($record->status, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED], true)) { + return null; + } + $latest = CourierOnboardingEvent::allCompanies()->withTrashed()->companyId($companyId) + ->where('onboarding_id', $id)->whereIn('event_type', self::EVENTS)->latest('id')->first(); + if ($latest !== null && (! $retry || $latest->event_type !== 'dingtalk_failed')) { + return null; + } + // 先持久化占位,再发 HTTP。进程中断也不能盲目重发。 + $this->event($record, 'dingtalk_starting', '财务系统自动发起入驻审批。'); + + return $record; + }); + if ($onboarding === null) { + return 'skipped'; + } + + $payload = $this->payload($onboarding); + $token = $this->client->accessToken(); + $sending = true; + $instanceId = $this->client->start($token, $payload); + $this->bind($onboarding, $instanceId); + + return 'started'; + } catch (Throwable $exception) { + $uncertain = $exception instanceof CourierOnboardingDingTalkException ? $exception->uncertain : $sending; + $message = $exception instanceof CourierOnboardingDingTalkException + ? $exception->getMessage() : '钉钉审批对接异常,请联系系统管理员核查。'; + if ($onboarding !== null) { + try { + $this->event($onboarding, $uncertain ? 'dingtalk_unknown' : 'dingtalk_failed', $message); + } catch (Throwable) { + // 保留之前的 starting 记录,阻止在数据库故障后重复发起。 + } + } + Log::warning('Courier onboarding DingTalk start did not complete.', [ + 'company_id' => $companyId, 'onboarding_id' => $id, + 'uncertain' => $uncertain, 'exception_class' => $exception::class, + ]); + + return $uncertain ? 'unknown' : 'failed'; + } + } + + /** 核查超时请求后只补关联,不再次创建审批。 */ + public function reconcile(int $companyId, int $id, string $instanceId): void + { + $record = $this->query($companyId)->findOrFail($id); + $token = $this->client->accessToken(); + $snapshot = $this->client->instance($token, $instanceId); + $reason = collect($snapshot['formComponentValues'] ?? [])->firstWhere('name', '原因')['value'] ?? ''; + if (($snapshot['originatorUserId'] ?? '') !== config('courier_onboarding.dingtalk.originator_user_id') + || ! str_contains((string) $reason, $this->marker($record)) + || empty($snapshot['createTime']) + || ! $this->client->belongsToProcess($token, $instanceId, $snapshot['createTime'])) { + throw new CourierOnboardingDingTalkException('该钉钉审批与本入驻申请不匹配,未关联。'); + } + $this->bind($record, $instanceId); + } + + public function payload(CourierOnboarding $record): array + { + $values = ['快递名称' => $record->displayName()]; + foreach (CourierOnboarding::PROFILE_FIELDS as $field => [$label]) { + if (filled($record->{$field})) { + $values[$label] = (string) $record->{$field}; + } + } + // 业务类型与「是否需要核对物流点」含义不同,不能相互代用。 + $logistics = match ($record->service_type) { + '快递' => '否', + '快运', '大件专线' => '是', + default => null, + }; + if ($logistics !== null) { + $values['快递是否为物流'] = $logistics; + } + if ($record->workplatform_account === '已有') { + $values['绑定快递名称'] = $record->displayName(); + } + // 与该流程现有实例的 PhoneField 值保持一致,使用手机号文本。 + $values['电话'] = (string) $record->contact_phone; + $detailUrl = rtrim((string) (config('courier_onboarding.public_url') ?: config('app.url')), '/') + . route('business.courier-onboardings.show', ['company_id' => $record->company_id, 'onboarding' => $record->id], false); + $values['原因'] = '快递方已提交入驻资料,申请开通快递合作。' . "\n" + . $this->marker($record) . "\n主体:" . $record->company_full_name . "\n业务类型:" . $record->service_type + . "\n对接人:" . $record->contact_name . "\n资料详情(登录财务系统查看):" . $detailUrl + . "\n报价签署及回传沿用财务系统的易签宝流程,无需重复上传报价单原件。"; + + // 系统对接方式、账号密码由后续节点补充;不传原件、占位密码或公开扫码链接。 + return [ + 'processCode' => config('courier_onboarding.dingtalk.process_code'), + 'originatorUserId' => config('courier_onboarding.dingtalk.originator_user_id'), + 'deptId' => (int) config('courier_onboarding.dingtalk.dept_id'), + 'formComponentValues' => collect($values)->map(fn (string $value, string $name): array => compact('name', 'value'))->values()->all(), + ]; + } + + private function marker(CourierOnboarding $record): string + { + return '[财务入驻:' . $record->company_id . ':' . $record->id . ':' . $record->number . ']'; + } + + private function query(int $companyId) + { + return CourierOnboarding::allCompanies()->companyId($companyId); + } + + private function bind(CourierOnboarding $record, string $instanceId): void + { + DB::transaction(function () use ($record, $instanceId): void { + $record = $this->query($record->company_id)->lockForUpdate()->findOrFail($record->id); + if (filled($record->dingtalk_instance_id)) { + if ($record->dingtalk_instance_id !== $instanceId) { + throw new CourierOnboardingDingTalkException('入驻申请已关联其他钉钉审批,未覆盖。', true); + } + + return; + } + $record->update(['dingtalk_instance_id' => $instanceId]); + $this->event($record, 'dingtalk_started', '已发起钉钉审批,审批编号:' . $instanceId); + }); + } + + private function event(CourierOnboarding $record, string $type, string $note): void + { + CourierOnboardingEvent::create([ + 'company_id' => $record->company_id, 'onboarding_id' => $record->id, + 'event_type' => $type, 'from_status' => $record->status, 'to_status' => $record->status, + 'note' => $note, 'actor_type' => 'system', + ]); + } +} diff --git a/app/Support/CourierOnboardingService.php b/app/Support/CourierOnboardingService.php index 67e2db9c..83258301 100644 --- a/app/Support/CourierOnboardingService.php +++ b/app/Support/CourierOnboardingService.php @@ -121,6 +121,12 @@ class CourierOnboardingService } return DB::transaction(function () use ($onboarding, $data, $files, $submit): CourierOnboarding { + // 再读一次并锁定,防止两个扫码请求都拿着旧的可编辑状态提交。 + $onboarding = CourierOnboarding::allCompanies()->companyId($onboarding->company_id) + ->lockForUpdate()->findOrFail($onboarding->id); + if (! $onboarding->courierCanEdit()) { + throw ValidationException::withMessages(['form' => '资料已提交审核或链接已过期,请刷新页面。']); + } $onboarding->fill($this->courierAttributes($data)); $onboarding->save(); @@ -155,6 +161,12 @@ class CourierOnboardingService ]); $this->log($onboarding, 'submitted', $from, CourierOnboarding::STATUS_SUBMITTED, null, 'courier'); + $companyId = (int) $onboarding->company_id; + $onboardingId = (int) $onboarding->id; + DB::afterCommit(static function () use ($companyId, $onboardingId): void { + app(CourierOnboardingDingTalk::class)->start($companyId, $onboardingId); + }); + return $onboarding; }); } diff --git a/config/courier_onboarding.php b/config/courier_onboarding.php index 8eb5fb45..e4fd41fd 100644 --- a/config/courier_onboarding.php +++ b/config/courier_onboarding.php @@ -7,4 +7,14 @@ return [ // 仅 local/testing 环境允许初审人自己复审;生产始终由不同人员完成。 'allow_same_reviewer' => (bool) env('COURIER_ONBOARDING_ALLOW_SAME_REVIEWER', false), + + // 入驻审批使用独立应用,不能覆盖用于登录和读取其他审批的 DINGTALK_CLIENT_*。 + 'dingtalk' => [ + 'enabled' => (bool) env('COURIER_ONBOARDING_DINGTALK_ENABLED', false), + 'client_id' => env('COURIER_ONBOARDING_DINGTALK_CLIENT_ID'), + 'client_secret' => env('COURIER_ONBOARDING_DINGTALK_CLIENT_SECRET'), + 'process_code' => env('COURIER_ONBOARDING_DINGTALK_PROCESS_CODE'), + 'originator_user_id' => env('COURIER_ONBOARDING_DINGTALK_ORIGINATOR_USER_ID'), + 'dept_id' => env('COURIER_ONBOARDING_DINGTALK_DEPT_ID'), + ], ]; diff --git a/docs/快递入驻钉钉自动发起.md b/docs/快递入驻钉钉自动发起.md new file mode 100644 index 00000000..fc03fe32 --- /dev/null +++ b/docs/快递入驻钉钉自动发起.md @@ -0,0 +1,43 @@ +# 快递入驻自动发起钉钉审批 + +快递方扫码填写资料后点击「提交审核」,财务系统先保存资料,再通过独立的钉钉「财务系统」应用发起审批。暂存、校验未通过和事务回滚均不发起。现有财务系统初审、复审及易签宝报价签署流程继续使用,钉钉审批结果不会自动替代这些审核。 + +生产使用「新建快递流程-通用」,发起人为扬灵。以下配置写入服务器私有环境文件,不进入 Git;使用独立的 `COURIER_ONBOARDING_DINGTALK_*` 凭据,不能覆盖钉钉登录应用的 `DINGTALK_CLIENT_*`。 + +```dotenv +COURIER_ONBOARDING_DINGTALK_ENABLED=true +COURIER_ONBOARDING_DINGTALK_CLIENT_ID=<独立应用 ClientId> +COURIER_ONBOARDING_DINGTALK_CLIENT_SECRET=<独立应用 ClientSecret> +COURIER_ONBOARDING_DINGTALK_PROCESS_CODE=<流程编码> +COURIER_ONBOARDING_DINGTALK_ORIGINATOR_USER_ID=<企业用户 userid> +COURIER_ONBOARDING_DINGTALK_DEPT_ID=<该用户所属部门ID> +COURIER_ONBOARDING_PUBLIC_URL=https://caiwu.jiansuntuzhi.cn:8442 +``` + +应用需要 `Workflow.Instance.Write`、`Workflow.Form.Read`、`Workflow.Instance.Read`;发起人须有模板可见及发起权限。凭据和模板可见性核验不代表已经创建审批,首次真实提交仍需核对钉钉审批单。 + +## 表单内容 + +已填写的快递资料按同名字段传入;电话沿用该流程已有实例的手机号文本格式;业务类型「快递」对应物流「否」,「快运 / 大件专线」对应「是」,「其他」不猜测。已有工作平台账号时填入绑定快递名称。原因包含入驻编号、主体、联系人和需要登录财务系统的详情地址,不包含免登录扫码凭证、银行账号或证件号码。 + +不上传报价单原件,报价沿用易签宝签署和回传。系统对接方式和三组账号密码由内部后续审批节点补充,不以占位内容代填。其他文件留在财务系统资料页查看。本次不增加钉盘上传权限。 + +钉钉模板应允许上述字段在发起时留空,并为负责人员配置后续填写权限;如钉钉返回必填或表单配置错误,在模板调整后对明确失败的申请单独重试,不填造假的账号或附件来绕过校验。 + +## 记录与故障恢复 + +入驻详情的处理记录显示发起中、已发起、明确失败或结果待核查。已有的 `dingtalk_instance_id` 保存成功的审批编号。本次复用现有入驻表和事件表,没有数据库迁移。 + +同一申请只自动尝试一次;退回后重新提交不重复创建已有的审批。创建之前先持久化发起记录,再调用接口。HTTP 超时、服务端错误、缺少审批编号、进程中断或成功后本地保存失败时,不自动重试;须先核查扬灵发起的审批单,按原因中的入驻标识核对。 + +仅管理员在服务器运行以下命令(Laravel 命令使用 `www-data`)。先确认申请ID和公司ID: + +```bash +# 只对明确失败的请求重试;发起中、待核查、已成功的请求均不会重复创建。 +php artisan courier-onboarding:dingtalk <申请ID> --company=<公司ID> --retry + +# 已在钉钉找到对应单据时补关联;校验流程编码、发起人和入驻标识,不创建新单。 +php artisan courier-onboarding:dingtalk <申请ID> --company=<公司ID> --instance=<审批编号> +``` + +不批量补发上线前的旧申请。配置开关关闭时不调用钉钉。接口失败不会撤销已提交的入驻资料,也不会自动变更本地审核结果。 diff --git a/tests/Feature/Business/CourierOnboardingDingTalkTest.php b/tests/Feature/Business/CourierOnboardingDingTalkTest.php new file mode 100644 index 00000000..a49b3363 --- /dev/null +++ b/tests/Feature/Business/CourierOnboardingDingTalkTest.php @@ -0,0 +1,195 @@ +company->makeCurrent(); + config(['courier_onboarding.dingtalk' => [ + 'enabled' => true, 'client_id' => 'onboarding-app', 'client_secret' => 'onboarding-secret', + 'process_code' => 'PROC-TEST', 'originator_user_id' => 'originator-test', 'dept_id' => 123, + ], 'courier_onboarding.public_url' => 'https://finance.example.com:8442']); + Http::preventStrayRequests(); + } + + public function test_uses_separate_app_and_omits_original_and_internal_credentials(): void + { + $record = $this->record(); + config(['dingtalk.client_id' => 'login-app', 'dingtalk.client_secret' => 'login-secret']); + $this->fakeSuccess(); + + $this->assertSame('started', $this->service()->start($record->company_id, $record->id)); + $this->assertSame('instance-test', $record->fresh()->dingtalk_instance_id); + $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $record->fresh()->status); + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/oauth2/accessToken') + && $request['appKey'] === 'onboarding-app' && $request['appSecret'] === 'onboarding-secret'); + Http::assertSent(function (Request $request) use ($record): bool { + if (! str_ends_with($request->url(), '/workflow/processInstances')) { + return false; + } + $values = array_column($request['formComponentValues'], 'value', 'name'); + $this->assertSame('originator-test', $request['originatorUserId']); + $this->assertSame(123, $request['deptId']); + $this->assertSame('否', $values['快递是否为物流']); + $this->assertSame('是', $values['是否需要核对物流点']); + $this->assertSame('13800001111', $values['电话']); + $this->assertSame('测试快递', $values['绑定快递名称']); + foreach (['报价单原件', '附件', '系统对接方式', '查件记账账号', '查件记账密码', '财务账单账号', '财务账单密码', '聚水潭聚工单账号', '聚水潭聚工单密码'] as $field) { + $this->assertArrayNotHasKey($field, $values); + } + $body = json_encode($request->data(), JSON_UNESCAPED_UNICODE); + $this->assertStringNotContainsString($record->token, $body); + $this->assertStringContainsString('/' . $record->company_id . '/workspace/courier-payables/courier-onboarding/applications/' . $record->id, $values['原因']); + + return true; + }); + $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true)); + Http::assertSentCount(2); + } + + public function test_pending_claim_blocks_reentry_and_resubmission_does_not_create_another_approval(): void + { + $record = $this->record(); + Http::fake(function (Request $request) use ($record) { + if (str_ends_with($request->url(), '/oauth2/accessToken')) { + $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true)); + + return Http::response(['accessToken' => 'test-token']); + } + + return Http::response(['instanceId' => 'instance-test']); + }); + $this->assertSame('started', $this->service()->start($record->company_id, $record->id)); + $record->update(['status' => CourierOnboarding::STATUS_RETURNED]); + $record->update(['status' => CourierOnboarding::STATUS_SUBMITTED]); + $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id)); + Http::assertSentCount(2); + } + + public function test_rejected_request_can_only_be_retried_explicitly_and_does_not_store_raw_errors(): void + { + $record = $this->record(); + Http::fake([ + '*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']), + '*/workflow/processInstances' => Http::sequence() + ->push(['code' => 'Forbidden.AccessDenied', 'message' => 'secret-raw-data'], 403) + ->push(['instanceId' => 'instance-test']), + ]); + $this->assertSame('failed', $this->service()->start($record->company_id, $record->id)); + $this->assertNull($record->fresh()->dingtalk_instance_id); + $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id)); + $event = $record->events()->reorder('id', 'desc')->firstOrFail(); + $this->assertSame('dingtalk_failed', $event->event_type); + $this->assertStringContainsString('Forbidden.AccessDenied', $event->note); + $this->assertStringNotContainsString('secret-raw-data', $event->note); + $this->assertSame('started', $this->service()->start($record->company_id, $record->id, true)); + } + + public function test_timeout_server_error_and_incomplete_success_never_retry_blindly(): void + { + foreach (['timeout', 'server-error', 'incomplete'] as $scenario) { + $record = $this->record(); + Http::fake(function (Request $request) use ($scenario) { + if (str_ends_with($request->url(), '/oauth2/accessToken')) { + return Http::response(['accessToken' => 'test-token']); + } + if ($scenario === 'timeout') { + throw new ConnectionException('sensitive-request-context'); + } + + return Http::response([], $scenario === 'server-error' ? 500 : 200); + }); + $this->assertSame('unknown', $this->service()->start($record->company_id, $record->id)); + $this->assertSame('dingtalk_unknown', $record->events()->reorder('id', 'desc')->firstOrFail()->event_type); + $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id, true)); + $this->assertNull($record->fresh()->dingtalk_instance_id); + } + } + + public function test_disabled_unsubmitted_and_other_company_records_do_not_call_api(): void + { + $record = $this->record(); + config(['courier_onboarding.dingtalk.enabled' => false]); + $this->assertSame('disabled', $this->service()->start($record->company_id, $record->id)); + config(['courier_onboarding.dingtalk.enabled' => true]); + $this->assertSame('failed', $this->service()->start($record->company_id + 1, $record->id)); + foreach ([CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED] as $status) { + $record->update(['status' => $status]); + $this->assertSame('skipped', $this->service()->start($record->company_id, $record->id)); + } + Http::assertNothingSent(); + $this->assertSame(0, $record->events()->count()); + } + + public function test_missing_credentials_fail_before_creation_and_unknown_business_type_is_not_invented(): void + { + $record = $this->record(); + $record->update(['service_type' => '其他', 'workplatform_account' => '新开']); + $values = array_column($this->service()->payload($record)['formComponentValues'], 'value', 'name'); + $this->assertArrayNotHasKey('快递是否为物流', $values); + $this->assertArrayNotHasKey('绑定快递名称', $values); + config(['courier_onboarding.dingtalk.client_secret' => '']); + $this->assertSame('failed', $this->service()->start($record->company_id, $record->id)); + $this->assertSame('dingtalk_failed', $record->events()->reorder('id', 'desc')->firstOrFail()->event_type); + Http::assertNothingSent(); + } + + public function test_recovery_checks_workflow_originator_and_onboarding_marker_before_binding(): void + { + $record = $this->record(); + $other = $this->record(); + $reason = collect($this->service()->payload($record)['formComponentValues'])->firstWhere('name', '原因'); + Http::fake([ + '*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']), + '*/workflow/processInstances*' => Http::response(['result' => [ + 'createTime' => '2026-11-06T03:00Z', 'originatorUserId' => 'originator-test', 'formComponentValues' => [$reason], + ]]), + '*/workflow/processes/instanceIds/query' => Http::response(['result' => ['list' => ['existing-instance']]]), + ]); + try { + $this->service()->reconcile($other->company_id, $other->id, 'existing-instance'); + $this->fail('不同入驻申请不可关联同一审批'); + } catch (CourierOnboardingDingTalkException) { + $this->assertNull($other->fresh()->dingtalk_instance_id); + } + $this->service()->reconcile($record->company_id, $record->id, 'existing-instance'); + $this->assertSame('existing-instance', $record->fresh()->dingtalk_instance_id); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST' + && str_ends_with($request->url(), '/workflow/processInstances')); + } + + private function record(): CourierOnboarding + { + return CourierOnboarding::create([ + 'company_id' => $this->company->id, 'number' => 'RZ-TEST-' . uniqid(), 'token' => bin2hex(random_bytes(20)), + 'status' => CourierOnboarding::STATUS_SUBMITTED, 'courier_name' => '测试快递', + 'company_full_name' => '测试快递公司', 'contact_name' => '测试联系人', 'contact_phone' => '13800001111', + 'service_type' => '快递', 'workplatform_account' => '已有', 'need_logistics_check' => '是', + ]); + } + + private function fakeSuccess(): void + { + Http::fake([ + '*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']), + '*/workflow/processInstances' => Http::response(['instanceId' => 'instance-test']), + ]); + } + + private function service(): CourierOnboardingDingTalk + { + return app(CourierOnboardingDingTalk::class); + } +} diff --git a/tests/Feature/Business/CourierOnboardingWorkflowTest.php b/tests/Feature/Business/CourierOnboardingWorkflowTest.php index 3968480b..023ae253 100644 --- a/tests/Feature/Business/CourierOnboardingWorkflowTest.php +++ b/tests/Feature/Business/CourierOnboardingWorkflowTest.php @@ -1184,6 +1184,74 @@ class CourierOnboardingWorkflowTest extends FeatureTestCase ->assertSeeText('江苏省常州市天宁区'); } + public function test_dingtalk_only_starts_after_successful_submission_and_does_not_repeat_on_stale_request(): void + { + config(['courier_onboarding.dingtalk' => [ + 'enabled' => true, 'client_id' => 'onboarding-app', 'client_secret' => 'test-secret', + 'process_code' => 'PROC-TEST', 'originator_user_id' => 'originator-test', 'dept_id' => 123, + ]]); + Http::preventStrayRequests(); + Http::fake([ + '*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']), + '*/workflow/processInstances' => Http::response(['instanceId' => 'onboarding-instance']), + ]); + $record = $this->invite(); + $stale = $record->fresh(); + $this->asGuest(); + $url = route('courier-onboarding.update', ['token' => $record->token]); + $this->withExceptionHandling()->post($url, ['action' => 'submit'])->assertSessionHasErrors(); + Http::assertNothingSent(); + $this->post($url, [...$this->completePayload(), 'action' => 'save'])->assertSessionHasNoErrors(); + Http::assertNothingSent(); + $this->post($url, $this->completePayload(false))->assertSessionHasNoErrors(); + $this->assertSame('onboarding-instance', $record->fresh()->dingtalk_instance_id); + Http::assertSentCount(2); + try { + app(CourierOnboardingService::class)->saveCourierForm($stale, [], [], true); + $this->fail('旧请求不应重复提交资料'); + } catch (\Illuminate\Validation\ValidationException) { + Http::assertSentCount(2); + } + } + + public function test_dingtalk_failure_does_not_roll_back_courier_submission(): void + { + config(['courier_onboarding.dingtalk' => [ + 'enabled' => true, 'client_id' => 'onboarding-app', 'client_secret' => 'test-secret', + 'process_code' => 'PROC-TEST', 'originator_user_id' => 'originator-test', 'dept_id' => 123, + ]]); + Http::fake([ + '*/oauth2/accessToken' => Http::response(['accessToken' => 'test-token']), + '*/workflow/processInstances' => Http::response(['code' => 'Forbidden.AccessDenied'], 403), + ]); + $record = $this->submittedOnboarding(); + $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $record->status); + $this->assertNotNull($record->submitted_at); + $this->assertNull($record->dingtalk_instance_id); + $this->assertSame('dingtalk_failed', $record->events()->reorder('id', 'desc')->firstOrFail()->event_type); + $this->assertGreaterThan(0, $record->files()->count()); + } + + public function test_rolled_back_submission_does_not_send_dingtalk_request(): void + { + config(['courier_onboarding.dingtalk.enabled' => true]); + Http::preventStrayRequests(); + Http::fake(); + $record = $this->invite(); + $payload = $this->completePayload(); + $files = $payload['files']; + unset($payload['files']); + DB::beginTransaction(); + try { + app(CourierOnboardingService::class)->saveCourierForm($record, $payload, $files, true); + Http::assertNothingSent(); + } finally { + DB::rollBack(); + } + $this->assertSame(CourierOnboarding::STATUS_INVITED, $record->fresh()->status); + Http::assertNothingSent(); + } + /** 生成二维码时必填的快递资料。 */ private static function profile(): array { -- 2.47.2