diff --git a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php index 868a066db..ee8dc8c32 100644 --- a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php +++ b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php @@ -434,21 +434,25 @@ class WecomPromotionLogic try { $api->deleteLink($remoteLinkId); } catch (\Throwable $e) { - $message = '企业微信官方获客链接删除失败,本地方案已保留:' . $e->getMessage(); - Db::name('qywx_promotion_link')->where('id', $linkId)->update([ - 'sync_error' => mb_substr($message, 0, 500), - 'update_time' => time(), - ]); - Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->where('lock_token', $deleteToken)->update([ - 'status' => 4, - 'next_retry' => 0, - 'lock_token' => '', - 'lock_until' => 0, - 'last_error' => mb_substr($message, 0, 500), - 'update_time' => time(), - ]); + // 删除是幂等操作:企业微信明确返回 invalid link_id 时,远端目标已不存在 + // 或已不再属于当前应用,继续完成本地清理;其他错误仍保留方案以便重试。 + if (!self::isRemoteLinkAlreadyMissing($e)) { + $message = '企业微信官方获客链接删除失败,本地方案已保留:' . $e->getMessage(); + Db::name('qywx_promotion_link')->where('id', $linkId)->update([ + 'sync_error' => mb_substr($message, 0, 500), + 'update_time' => time(), + ]); + Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->where('lock_token', $deleteToken)->update([ + 'status' => 4, + 'next_retry' => 0, + 'lock_token' => '', + 'lock_until' => 0, + 'last_error' => mb_substr($message, 0, 500), + 'update_time' => time(), + ]); - throw new RuntimeException($message, 0, $e); + throw new RuntimeException($message, 0, $e); + } } // 多个历史官方链接部分成功时也保存进度,用户重试删除不会再次请求已删除链接。 Db::name('qywx_promotion_link')->where('id', $linkId)->update([ @@ -484,6 +488,16 @@ class WecomPromotionLogic }); } + private static function isRemoteLinkAlreadyMissing(\Throwable $error): bool + { + $message = strtolower($error->getMessage()); + $isInvalidLinkId = str_contains($message, 'invalid link_id'); + $isInvalidParameter = (int) $error->getCode() === 40058 + || str_contains($message, '[40058]'); + + return $isInvalidParameter && $isInvalidLinkId; + } + public static function saveMember(array $params, int $adminId, array $adminInfo): array { self::assertMemberDispatchSchema(); diff --git a/server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php b/server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php index 469466b7f..c0f413b7f 100644 --- a/server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php +++ b/server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php @@ -187,7 +187,8 @@ class QywxCustomerAcquisitionApiService if ($errcode === 60111) { throw new RuntimeException( '所选医助的企业微信 userid 不存在,或不在获客助手可调用应用的可见范围;' - . '请检查后台账号绑定和企业微信应用可见范围。企业微信返回:' . $errorMessage + . '请检查后台账号绑定和企业微信应用可见范围。企业微信返回:' . $errorMessage, + $errcode ); } @@ -195,7 +196,7 @@ class QywxCustomerAcquisitionApiService '企业微信获客助手接口失败[%d]:%s', $errcode, $errorMessage - )); + ), $errcode); } private function accessToken(): string diff --git a/server/tests/QywxCustomerAcquisitionApiServiceTest.php b/server/tests/QywxCustomerAcquisitionApiServiceTest.php index 6a7c65271..40d07f87d 100644 --- a/server/tests/QywxCustomerAcquisitionApiServiceTest.php +++ b/server/tests/QywxCustomerAcquisitionApiServiceTest.php @@ -121,4 +121,19 @@ try { $assert(str_contains($invalidUserMessage, 'userid 不存在'), '60111 应返回明确的成员绑定诊断提示'); $assert(str_contains($invalidUserMessage, '应用可见范围'), '60111 应提示检查应用可见范围'); +$invalidLinkService = new QywxCustomerAcquisitionApiService(new Client([ + 'base_uri' => 'https://qyapi.weixin.qq.com/', + 'handler' => HandlerStack::create(new MockHandler([ + $json(['errcode' => 40058, 'errmsg' => 'invalid link_id']), + ])), + 'http_errors' => false, +]), static fn (): string => 'mock_token'); +$invalidLinkCode = 0; +try { + $invalidLinkService->deleteLink('stale_link_id'); +} catch (RuntimeException $e) { + $invalidLinkCode = $e->getCode(); +} +$assert($invalidLinkCode === 40058, '企业微信错误码应保留在异常 code 中供业务层分类'); + echo "QYWX_CUSTOMER_ACQUISITION_API_TEST_OK\n"; diff --git a/server/tests/WecomPromotionDeletePoolBehaviorTest.php b/server/tests/WecomPromotionDeletePoolBehaviorTest.php index 864af7080..cd0bf442c 100644 --- a/server/tests/WecomPromotionDeletePoolBehaviorTest.php +++ b/server/tests/WecomPromotionDeletePoolBehaviorTest.php @@ -16,15 +16,18 @@ final class PromotionDeleteApiFake extends QywxCustomerAcquisitionApiService /** @var list */ public array $deleted = []; - public function __construct(private string $failLinkId = '') - { + public function __construct( + private string $failLinkId = '', + private string $failMessage = 'mock remote deletion failed', + private int $failCode = 0 + ) { } public function deleteLink(string $linkId): void { $this->deleted[] = $linkId; if ($linkId === $this->failLinkId) { - throw new RuntimeException('mock remote deletion failed'); + throw new RuntimeException($this->failMessage, $this->failCode); } } } @@ -94,7 +97,11 @@ try { 'update_time' => $now, ]); - $firstAttempt = new PromotionDeleteApiFake($secondRemoteId); + $firstAttempt = new PromotionDeleteApiFake( + $secondRemoteId, + '企业微信获客助手接口失败[40058]:invalid range', + 40058 + ); $message = ''; try { WecomPromotionLogic::deletePool($poolId, (int) $admin['id'], $admin, $firstAttempt); @@ -116,6 +123,44 @@ try { $assert((int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 0, '方案删除后成员仍处于启用状态'); $sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find(); $assert((int) ($sync['status'] ?? 0) === 4 && (string) ($sync['lock_token'] ?? '') === '', '方案删除后同步租约没有终止'); + + // 企业微信明确返回 invalid link_id,说明远端目标已不存在或当前应用已无法操作; + // 删除应保持幂等并继续清理本地方案,不能让陈旧 link_id 永久阻塞用户。 + $staleRemoteId = 'contract_stale_' . $suffix; + $stalePoolId = (int) Db::name('qywx_promotion_pool')->insertGetId([ + 'name' => '失效链接删除测试', + 'public_key' => bin2hex(random_bytes(16)), + 'status' => 1, + 'owner_admin_id' => (int) $admin['id'], + 'create_time' => $now, + 'update_time' => $now, + ]); + $staleLinkId = (int) Db::name('qywx_promotion_link')->insertGetId([ + 'pool_id' => $stalePoolId, + 'name' => '远端已失效链接', + 'wecom_url' => 'https://work.weixin.qq.com/ca/' . $staleRemoteId, + 'remote_link_id' => $staleRemoteId, + 'remote_status' => 1, + 'status' => 1, + 'owner_admin_id' => (int) $admin['id'], + 'create_time' => $now, + 'update_time' => $now, + ]); + $staleApi = new PromotionDeleteApiFake( + $staleRemoteId, + '企业微信获客助手接口失败[40058]:invalid link_id', + 40058 + ); + WecomPromotionLogic::deletePool($stalePoolId, (int) $admin['id'], $admin, $staleApi); + $assert($staleApi->deleted === [$staleRemoteId], '失效链接没有请求企业微信删除接口'); + $assert( + Db::name('qywx_promotion_pool')->where('id', $stalePoolId)->whereNotNull('delete_time')->count() === 1, + 'invalid link_id 阻塞了本地方案删除' + ); + $assert( + (int) Db::name('qywx_promotion_link')->where('id', $staleLinkId)->value('remote_status') === 2, + 'invalid link_id 没有按远端已删除状态收敛' + ); } finally { Db::rollback(); }