PAYATHON 2026

如何比较 Billing Software 中的 Product 状态并清理服务

数据小许

结论

不能只看客户是否购买了 Database 或 DNS Record,而要比较该客户的全部 active services。

设 Database 和 DNS Record 的 Product ID 白名单为 TARGET_PRODUCT_IDS。对每位客户,需要:

  1. 查询全部 active services;
  2. 确认至少有一个服务属于 TARGET_PRODUCT_IDS;
  3. 确认没有任何 active service 的 Product ID 位于白名单之外;
  4. 同时满足以上条件时,终止该客户名下的 Database、DNS Record,或两者。

判断条件可以写成:

存在目标服务
AND
不存在非目标 active service

也就是:

activeProductIds 非空
AND
activeProductIds ⊆ TARGET_PRODUCT_IDS

这个条件可以处理三种情况:

  • 只有 Database;
  • 只有 DNS Record;
  • 同时有 Database 和 DNS Record。

只要客户还有其他 active service,就不能进入清理流程。

判断逻辑

假设两个产品的唯一 ID 分别是:

$targetProductIds = [
    101, // Database
    102, // DNS Record
];

不要直接判断数组是否等于 [101]、[102] 或 [101, 102],因为同一种 Product 可能有多个服务实例。更稳妥的做法是把服务分成目标服务和其他 active services:

$targetServices = [];
$otherActiveServices = [];

foreach ($activeServices as $service) {
    $productId = (int) $service['product_id'];

    if (in_array($productId, $targetProductIds, true)) {
        $targetServices[] = $service;
    } else {
        $otherActiveServices[] = $service;
    }
}

$shouldTerminate =
    count($targetServices) > 0 &&
    count($otherActiveServices) === 0;

这里检查的是每个服务实例,不只是去重后的 Product ID。即使客户有三个 Database 和两个 DNS Record,也可以终止全部目标服务。

完整示例

问题没有说明具体的 billing software 及其 API,所以下面使用 PHP 风格的通用示例。getClients()、getClientServices() 和 terminateService() 需要替换为实际系统提供的 API、SDK 或内部函数。

<?php

$targetProductIds = [101, 102];
$dryRun = true;

foreach (getClients() as $client) {
    $clientId = (int) $client['id'];

    // 必须获取完整列表,注意处理 API 分页。
    $services = getClientServices($clientId);

    $activeServices = array_filter(
        $services,
        static fn(array $service): bool =>
            $service['status'] === 'Active'
    );

    $targetServices = [];
    $otherActiveServices = [];

    foreach ($activeServices as $service) {
        $productId = (int) $service['product_id'];

        if (in_array($productId, $targetProductIds, true)) {
            $targetServices[] = $service;
        } else {
            $otherActiveServices[] = $service;
        }
    }

    $shouldTerminate =
        count($targetServices) > 0 &&
        count($otherActiveServices) === 0;

    if (!$shouldTerminate) {
        continue;
    }

    foreach ($targetServices as $service) {
        $serviceId = (int) $service['id'];

        if ($dryRun) {
            printf(
                "DRY RUN: client=%d service=%d product=%d would be terminated\n",
                $clientId,
                $serviceId,
                (int) $service['product_id']
            );
            continue;
        }

        try {
            terminateService($serviceId);

            printf(
                "TERMINATED: client=%d service=%d product=%d\n",
                $clientId,
                $serviceId,
                (int) $service['product_id']
            );
        } catch (Throwable $exception) {
            printf(
                "FAILED: client=%d service=%d error=%s\n",
                $clientId,
                $serviceId,
                $exception->getMessage()
            );
        }
    }
}

如果只需要判断 Product ID,也可以使用集合差集:

$activeProductIds = array_values(array_unique(array_map(
    static fn(array $service): int => (int) $service['product_id'],
    $activeServices
)));

$hasTargetProduct = count(
    array_intersect($activeProductIds, $targetProductIds)
) > 0;

$hasOtherProduct = count(
    array_diff($activeProductIds, $targetProductIds)
) > 0;

$shouldTerminate = $hasTargetProduct && !$hasOtherProduct;

实际执行终止操作时,仍要保留原始服务记录,因为终止操作通常需要 service_id,而不是 product_id。

SQL 层面的筛选方式

如果 billing software 允许直接查询数据库,可以先筛选符合条件的客户,再由应用层调用正式的终止流程。

假设服务表包含:

  • client_id
  • service_id
  • product_id
  • status

可以使用条件聚合:

SELECT client_id
FROM services
WHERE status = 'Active'
GROUP BY client_id
HAVING
    SUM(CASE WHEN product_id IN (101, 102) THEN 1 ELSE 0 END) > 0
    AND
    SUM(CASE WHEN product_id NOT IN (101, 102) THEN 1 ELSE 0 END) = 0;

第一条 HAVING 确保客户至少有一个 Database 或 DNS Record,第二条确保该客户没有其他 active service。

筛选出客户后,再查询需要终止的具体服务:

SELECT service_id, client_id, product_id
FROM services
WHERE status = 'Active'
  AND product_id IN (101, 102)
  AND client_id IN (
      SELECT client_id
      FROM services
      WHERE status = 'Active'
      GROUP BY client_id
      HAVING
          SUM(CASE WHEN product_id IN (101, 102) THEN 1 ELSE 0 END) > 0
          AND
          SUM(CASE WHEN product_id NOT IN (101, 102) THEN 1 ELSE 0 END) = 0
  );

表名、字段名和状态值需要根据实际系统调整。通常不建议直接用 SQL 修改服务状态,因为正式的终止流程可能还要删除远端资源、执行模块回调、写入审计记录并发送通知。

执行前需要确认的事项

明确 “active” 的范围

有些系统除了 Active,还可能使用以下状态:

  • Pending
  • Suspended
  • Pending Cancellation
  • Fraud
  • Terminated

需要先确认 Suspended 是否仍表示客户保留着付费服务。如果是,就不能只查询 Active,否则可能误删附属资源。可以明确列出所有应阻止清理的状态:

$blockingStatuses = [
    'Active',
    'Suspended',
    'Pending',
];

不要遗漏其他服务类型

客户的付费项目不一定都在同一张 services 表中,也可能包括 domains、addons、subscriptions 或其他 billing item。题目要求确认客户 “没有其他 active services”,因此查询必须覆盖所有代表有效付费关系的条目。

如果这些类型来自不同的 API 或数据表,最终条件应为:

没有其他 active products
AND 没有有效 domains
AND 没有有效 addons
AND 没有其他应阻止清理的 billing items

先执行 dry run

第一次运行时只输出候选记录,不要实际终止服务:

client_id
service_id
product_id
当前状态
判定原因

人工抽查无误后,再关闭 $dryRun。数千条服务不适合一次性处理,最好分批执行,并记录成功、失败和跳过的原因。

终止前再次检查

从查询到实际终止之间,客户可能购买新服务。为避免竞态条件,每次真正终止前都应重新读取该客户的状态,并再次执行相同的判断。

使用服务 ID 执行终止

product_id 用于判断产品类别,service_id 用于定位具体实例。不要直接使用 Product ID 执行终止,否则可能影响所有购买该产品的客户。

终止操作还应具有幂等性。服务已经终止或不存在时,应安全跳过,不能让整个批处理因此失败。

备注:内容仅供参考。