在一个 AI SaaS 项目中,AiProxyService 曾经是后端的绝对核心——文本对话、图片生成、视频生成、3D 生成、任务查询、取消、退款、供应商路由全部堆在这一类里,一度膨胀到两千多行。经过几轮拆分,主类降到一千行出头,图片/视频协议各自独立,查询状态机和取消链路也有了清晰边界。
一、拆分前的上帝类
拆分前的 AiProxyService 长这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| @Service @RequiredArgsConstructor public class AiProxyService { private final BillingService billingService; private final AccountMapper accountMapper; private final AiTaskMapper aiTaskMapper; private final RestTemplate restTemplate; private final SystemConfigService configService; private final ProviderRouterService providerRouter;
public JsonData<?> createGenericTask(GenericAiTaskDTO dto, String productCode) { }
private JsonData<?> createImageTask(...) { }
private JsonData<?> createVideoTask(...) { }
public JsonData<?> queryGenericTask(...) { }
public JsonData<?> cancelTask(...) { }
public JsonData<?> createTextTask(...) { }
private JsonData<?> create3DTask(...) { }
private String buildUrl(...) { ... } private HttpEntity<?> buildRequest(...) { ... } private String extractResultUrl(...) { ... } }
|
所有东西都堆在一个类里,改一处要小心翼翼地检查会不会影响其他地方。
二、拆分原则
不追求一步到位式的”完美重构”,拆分的三条铁律:
1. 先拆低耦合,再拆高耦合。 先把 URL 拼接、请求头组装、类型转换这类纯工具逻辑拆出去。这类代码几乎没有状态依赖,拆出去不会引入 bug。
2. 每个 stage 都能单独回滚。 不一次搬动图片、视频、3D、取消和查询全链路。拆完一个、验证一个、上线一个。
3. 不变的边界不动。 扣费时机不变、退款逻辑不变、供应商路由算法不变、对外接口响应格式不变。重构是代码组织方式的变化,不是行为的变化。
三、落位判断:合入还是新建?
不是每组方法都要新建一个 service。在决定”新建文件”前先问三个问题:
- 这个能力是否已有现成的 service 可以合入?
- 合入后现有 service 的职责是否清晰?
- 这个能力是否有独立的业务生命周期和多处复用需求?
只有三个都满足才新建文件。具体规则:
四、拆分内容详解
第一阶段:小瘦身
在拆 service 之前,先清理冗余代码——这些都是零风险的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
private Map<String, Object> toStringObjectMap(Object obj) { return CommonUtil.toStringObjectMap(obj); }
private Map<String, Object> buildDirectImageResult(String url) { ... } private Map<String, Object> buildDirectVideoResult(String url) { ... }
private Map<String, Object> buildDirectResult(String url, boolean video) { ... }
|
第二阶段:工具类拆出
把 URL 拼接、请求头组装等工具代码拆到专门的服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| @Service public class AiMediaUrlService { public String joinApiUrl(String baseUrl, String path) { ... } public String extractResultUrl(JsonNode response, String apiFormat) { ... } public boolean isDirectUrl(String url) { ... } }
@Service public class AiProviderHeaderService { public String getBaseUrl(JsonNode config) { ... } public HttpHeaders buildJsonHeaders(JsonNode config) { ... } public String getConfigString(JsonNode config, String key, String fallback) { ... } public JsonNode mergeProviderConfig(ProductProviderDO provider, JsonNode config) { ... } }
@Service public class AiGuardService { public void validateResolution(GenericAiTaskDTO dto, String apiType, String model) { ... } public void normalizeDuration(GenericAiTaskDTO dto, String apiType, String model) { ... } public void applyVideoReferenceConsistencyGuard(...) { ... } }
|
这些工具类本身就很薄,拆出去后主类少了很多私有工具方法,但职责还没变。
第三阶段:独立业务 service
真正需要新建文件的几个核心能力:
AiTaskMetaService —— 任务 meta 的读写
1 2 3 4 5 6 7 8 9 10 11
| @Service public class AiTaskMetaService {
public Map<String, Object> readParams(AiTaskDO task) { ... } public void writeParams(AiTaskDO task, Map<String, Object> params) { ... } public Map<String, Object> buildClientDedupScope(GenericAiTaskDTO dto) { ... } public void mergeResultMeta(AiTaskDO task, Map<String, Object> resultMeta) { ... } }
|
拆前,主类直接用 objectMapper.readValue(task.getParamsJson()) 到处读写 JSON 字段。拆后所有 JSON 操作收敛到这里,方便后续引入 JSONB 或换序列化方式。
AiTaskCancelService —— 取消任务的完整链路
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| @Service public class AiTaskCancelService {
public JsonData<?> cancelTask(String taskId, BiFunction<String, String, JsonData<?>> schedulerQuery) {
AiTaskDO task = findTask(taskId); if (currentUserNotOwner(task, taskId)) return JsonData.buildError("无权操作此任务");
if (isTaskCompleted(task)) return JsonData.buildError("任务已完成,无法取消"); if (isTaskCancelled(task) || Objects.equals(task.getRefunded(), 1)) return buildCancelSuccessResult(task, "任务已取消或已退款");
JsonData<?> preflight = queryBeforeCancel(task, schedulerQuery); if (preflight != null && taskCompletedInPreflight(preflight)) return JsonData.buildError("任务已完成,无法取消");
UpstreamCancelResult upstreamResult = cancelUpstreamTask(task); if (!upstreamResult.success()) return JsonData.buildCodeMsgData(-2, upstreamResult.message(), ...);
return refundCancelledTask(task, upstreamResult); }
private JsonData<?> refundCancelledTask(AiTaskDO task, UpstreamCancelResult upstreamResult) { UpdateWrapper<AiTaskDO> casUpdate = new UpdateWrapper<>(); casUpdate.eq("id", task.getId()) .eq("refunded", 0) .ne("status", "completed") .set("refunded", 1) .set("status", "cancelled") .set("error_msg", savedMsg) .set("gmt_modified", LocalDateTime.now());
int rows = aiTaskMapper.update(null, casUpdate); if (rows <= 0) { AiTaskDO refreshed = aiTaskMapper.selectById(task.getId()); if (isCancelledOrRefunded(refreshed)) return buildCancelSuccessResult(refreshed, "任务已取消或已退款"); return JsonData.buildError("任务状态已变化,取消结果未落库,请刷新后重试"); }
billingService.refundBalance(task.getAccountId(), task.getCost(), task.getTraceId()); taskAccountingService.markConsumptionStatus(task.getTraceId(), "refunded"); return buildCancelSuccessResult(task, upstreamResult.message()); } }
|
AiTaskQueryService —— 查询状态机
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| @Service public class AiTaskQueryService {
public JsonData<?> queryForUser(String productCode, String taskId) { AiTaskDO task = findTask(taskId); if (isCompletedOrTransferPending(task)) return buildCompletedResult(task);
submitBackgroundCheck(task); return buildProcessingResult(task); }
public void queryForScheduler(String productCode, String taskId) { AiTaskDO task = findTask(taskId); if (isTaskCompleted(task)) return;
try { Map<String, Object> result = queryUpstream(task); if (result != null && result.containsKey("url")) { markTransferPending(task, result); transferResult(task, result); } } catch (RetryableException e) { return; } catch (UpstreamFailure e) { markFailedAndRefund(task, e.getMessage()); } } }
|
AiImageProviderClient 和 AiVideoProviderClient —— 协议适配
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Service public class AiImageProviderClient { public Map<String, Object> createImageTask(AiTaskDO task, ProductProviderDO provider, GenericAiTaskDTO dto, String apiType, String modelName) { return switch (apiType) { case "openai" -> createOpenAIImage(task, provider, dto, modelName); case "gemini" -> createGeminiImage(task, provider, dto, modelName); case "volcengine" -> createVolcengineImage(task, provider, dto, modelName); default -> createDefaultImage(task, provider, dto, modelName); }; }
public Map<String, Object> queryImageTask(AiTaskDO task, ProductProviderDO provider) { } }
|
1 2 3 4 5 6 7 8 9 10 11 12
| @Service public class AiVideoProviderClient { public boolean isNativeVideoFormat(String apiFormat) { return "native_video".equals(apiFormat); }
public Map<String, Object> createVideoTask(AiTaskDO task, ProductProviderDO provider, GenericAiTaskDTO dto, String apiType, String modelName) { } }
|
五、拆分后主类的样子
拆分后的 AiProxyService 从两千多行降到一千行出头,只保留核心编排:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| @Service @RequiredArgsConstructor public class AiProxyService {
private final BillingService billingService; private final ProviderRouterService providerRouter; private final AiTaskCancelService taskCancelService; private final AiTaskQueryService taskQueryService; private final AiImageProviderClient imageProviderClient; private final AiVideoProviderClient videoProviderClient; private final AiTaskAccountingService taskAccountingService; private final AiTaskMetaService taskMetaService; private final AiProviderHeaderService providerHeaderService; private final AiMediaUrlService mediaUrlService; private final AiGuardService aiGuardService; private final AiRequestValueService requestValueService;
public JsonData<?> createGenericTask(GenericAiTaskDTO dto, String productCode) { }
public JsonData<?> cancelTask(String taskId) { return taskCancelService.cancelTask(taskId, this::queryGenericTaskForScheduler); }
public JsonData<?> queryGenericTask(String productCode, String taskId) { return taskQueryService.queryForUser(productCode, taskId); }
}
|
六、几个踩过的坑
1. 计费/退款的顺序不能动
创建任务必须先有本地 ai_task 锚点 → 再扣费 → 再调上游。这个顺序一旦打乱,会出现”扣了钱但没任务”或”上游失败了但钱已经扣了”。
1 2 3 4 5 6 7 8 9 10
| AiTaskDO task = insertTask(dto); consumption = billingService.charge(...); try { result = callUpstream(task, provider); } catch (Exception e) { billingService.refundBalance( task.getAccountId(), task.getCost(), task.getTraceId()); throw e; }
|
2. 取消任务的 CAS 是关键
只在上游明确返回取消成功后才允许本地退款。中间用 CAS 更新避免并发问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| int rows = aiTaskMapper.update(null, new UpdateWrapper<AiTaskDO>() .eq("id", task.getId()) .eq("refunded", 0) .ne("status", "completed") .set("refunded", 1) .set("status", "cancelled") .set("gmt_modified", now));
if (rows <= 0) { AiTaskDO refreshed = aiTaskMapper.selectById(task.getId()); if (isCancelledOrRefunded(refreshed)) return success; return error("状态已变化,请刷新后重试"); }
|
3. 不要为了拆而拆
每个新 service 都必须减少主类的职责面,不是把同一段逻辑换个文件名。新 service 内仍要优先复用已有 service,不能把主类的重复逻辑搬过去后继续重复。
七、拆分后的收益
- 新接入视频供应商:只需要在
AiVideoProviderClient 里加分支,完全不用碰主类
- 修改取消逻辑:只改
AiTaskCancelService,不影响创建和查询
- 调整任务 meta 存储格式:只改
AiTaskMetaService,对上下游透明
- 测试更聚焦:每个新 service 可以单独做单元测试
拆分不是终点,后续按真实维护痛点再决定是否继续拆。当前主类约一千行,已进入观察期——除非出现真实维护痛点,否则不再机械拆分。