快递提交入驻资料后自动发起钉钉审批 #930

Merged
limeng merged 1 commits from feature/limeng-courier-onboarding-dingtalk into main 2026-11-06 11:39:25 +08:00
10 changed files with 668 additions and 0 deletions

View File

@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Support\CourierOnboardingDingTalk;
use Illuminate\Console\Command;
class StartCourierOnboardingDingTalk extends Command
{
protected $signature = 'courier-onboarding:dingtalk {id : 入驻申请ID} {--company= : 所属公司ID} {--retry : 仅重试明确失败的请求} {--instance= : 核查并关联已有审批,不发起新单}';
protected $description = '处理指定入驻申请的钉钉审批;超时或中断请求禁止盲目重发';
public function handle(CourierOnboardingDingTalk $service): int
{
if (! ctype_digit((string) $this->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;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class CourierOnboardingDingTalkException extends RuntimeException
{
public function __construct(string $message, public readonly bool $uncertain = false)
{
parent::__construct($message);
}
}

View File

@ -29,6 +29,10 @@ class CourierOnboardingEvent extends Model
'invited' => '生成入驻二维码',
'saved' => '快递方保存资料',
'submitted' => '快递方提交审核',
'dingtalk_starting' => '正在发起钉钉审批',
'dingtalk_started' => '已发起钉钉审批',
'dingtalk_failed' => '钉钉审批发起失败',
'dingtalk_unknown' => '钉钉审批结果待核查',
'first_approved' => '初审通过',
'returned' => '退回补件',
'final_approved' => '财务复审通过',

View File

@ -0,0 +1,118 @@
<?php
namespace App\Services\DingTalk;
use App\Exceptions\CourierOnboardingDingTalkException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Carbon;
/** 独立的「财务系统」应用,仅负责快递入驻审批。创建请求绝不自动重试。 */
class CourierOnboardingClient
{
private const BASE_URL = 'https://api.dingtalk.com/v1.0';
public function accessToken(): string
{
$settings = config('courier_onboarding.dingtalk');
foreach (['client_id', 'client_secret', 'process_code', 'originator_user_id', 'dept_id'] as $key) {
if (trim((string) ($settings[$key] ?? '')) === '') {
throw new CourierOnboardingDingTalkException('入驻钉钉应用配置不完整,请联系系统管理员。');
}
}
try {
$response = Http::acceptJson()->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;
}
}

View File

@ -0,0 +1,166 @@
<?php
namespace App\Support;
use App\Exceptions\CourierOnboardingDingTalkException;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingEvent;
use App\Services\DingTalk\CourierOnboardingClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class CourierOnboardingDingTalk
{
private const EVENTS = ['dingtalk_starting', 'dingtalk_started', 'dingtalk_failed', 'dingtalk_unknown'];
public function __construct(private readonly CourierOnboardingClient $client)
{
}
/** 资料提交后同步尝试一次。外部服务失败不影响入驻资料提交。 */
public function start(int $companyId, int $id, bool $retry = false): string
{
if (! config('courier_onboarding.dingtalk.enabled')) {
return 'disabled';
}
$onboarding = null;
$sending = false;
try {
$onboarding = DB::transaction(function () use ($companyId, $id, $retry): ?CourierOnboarding {
$record = $this->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',
]);
}
}

View File

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

View File

@ -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'),
],
];

View File

@ -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=<审批编号>
```
不批量补发上线前的旧申请。配置开关关闭时不调用钉钉。接口失败不会撤销已提交的入驻资料,也不会自动变更本地审核结果。

View File

@ -0,0 +1,195 @@
<?php
namespace Tests\Feature\Business;
use App\Exceptions\CourierOnboardingDingTalkException;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingEvent;
use App\Support\CourierOnboardingDingTalk;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\Feature\FeatureTestCase;
class CourierOnboardingDingTalkTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@ -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
{