如何比较 Billing Software 中的 Product 状态并清理服务
结论
不能只看客户是否购买了 Database 或 DNS Record,而要比较该客户的全部 active services。
设 Database 和 DNS Record 的 Product ID 白名单为 TARGET_PRODUCT_IDS。对每位客户,需要:
- 查询全部 active services;
- 确认至少有一个服务属于
TARGET_PRODUCT_IDS; - 确认没有任何 active service 的 Product ID 位于白名单之外;
- 同时满足以上条件时,终止该客户名下的 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_idservice_idproduct_idstatus
可以使用条件聚合:
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,还可能使用以下状态:
PendingSuspendedPending CancellationFraudTerminated
需要先确认 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 执行终止,否则可能影响所有购买该产品的客户。
终止操作还应具有幂等性。服务已经终止或不存在时,应安全跳过,不能让整个批处理因此失败。
备注:内容仅供参考。
