摘要

代码仓库:https://gitee.com/liu-jinhao-C/lottery-system-application
在企业营销抽奖系统里,运营人员经常需要查询活动详情、奖品列表、中奖记录等信息。传统做法是点开后台页面,一个个筛选、查询、复制数据。
接入 AI 以后,一个自然的想法是:能不能让运营人员直接问:
帮我查一下活动 1001 的中奖记录
这个活动配置了哪些奖品?
最近一页奖品列表有哪些?
然后 AI 自动理解问题,调用后端已有接口查询数据,再用自然语言回答。
我的抽奖系统基于 Spring Boot 3.4 + Spring AI + DeepSeek + MyBatis + Redis + RabbitMQ 构建。在传统抽奖业务基础上,我增量接入了 Spring AI 运营助手能力,支持:
活动草稿生成;
运营智能问答;
中奖通知文案生成;
基于 Tool Calling 查询活动详情、奖品列表、中奖记录。
但这里有一个非常重要的边界:
AI 只能做运营侧和通知侧增强,不能参与中奖决策,不能修改核心业务状态。
因此,我在项目中只给 AI 暴露只读工具,不开放抽奖、改状态、删除数据等写操作。同时配合管理员权限校验、系统提示词约束、熔断降级和审计日志,保证 AI 能力可控、可回退、可排查。
本文以“运营智能问答”为主线,复盘 Spring AI Tool Calling 在抽奖系统中的工程化落地。

为什么抽奖系统需要 AI 运营助手

抽奖系统的核心业务流程包括:
用户注册登录;
活动创建;
奖品管理;
活动参与人员管理;
抽奖执行;
中奖记录查询;
邮件 / 短信通知。
对于运营人员来说,很多问题并不需要修改数据,只是查询和汇总:
活动 1 的详情是什么?
一等奖中奖人有哪些?
当前奖品列表第一页有什么?
某个活动有没有中奖记录?
如果每次都让运营人员在后台页面里点来点去,效率比较低。AI 运营助手适合做一层自然语言入口:
自然语言问题
-> AI 理解意图
-> 调用只读业务工具
-> 后端查询真实数据
-> AI 组织答案
这里的关键不是“让 AI 自己猜业务数据”,而是让 AI 调用后端真实工具。
这就是 Spring AI Tool Calling 的价值。
整体架构
运营助手的核心链路如下:

不可用

可用

运营人员提问

AiOpsController

AiAccessService 管理员权限校验

AiOpsAssistantServiceImpl

AiCircuitBreaker 熔断判断

返回降级答案

Spring AI ChatClient

DeepSeek / OpenAI-compatible Model

LotteryOperationTools 只读工具

ActivityService

PrizeService

DrawPrizeService

MySQL / Redis

自然语言回答

AiAuditService 审计日志

这条链路里有几个工程设计点:
Controller 层先校验管理员身份;
AI Service 层生成 requestId,用于审计;
熔断器判断 AI 服务是否可用;
ChatClient 挂载只读工具;
工具内部调用已有业务 Service;
AI 回答只来自工具结果,不允许编造业务数据;
成功、失败、降级都会记录审计日志。
Spring AI 配置:用 OpenAI 兼容接口接 DeepSeek
项目中使用的是 Spring AI 的 OpenAI Starter:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

配置文件中通过环境变量注入模型信息:

spring:
  ai:
    openai:
      api-key: ${AI_API_KEY:NO_API_KEY_CONFIGURED}
      base-url: ${AI_BASE_URL:https://api.deepseek.com}
      chat:
        options:
          model: ${AI_CHAT_MODEL:deepseek-chat}
          temperature: ${AI_CHAT_TEMPERATURE:0}

lottery:
  ai:
    enabled: ${LOTTERY_AI_ENABLED:true}
    circuit-breaker:
      failure-threshold: ${LOTTERY_AI_CB_FAILURE_THRESHOLD:3}
      open-duration: ${LOTTERY_AI_CB_OPEN_DURATION:60s}

这里有几个细节:
api-key 不写死在代码里,而是走环境变量。
base-url 默认配置为 DeepSeek 的 OpenAI 兼容地址。
temperature 默认是 0,因为运营查询类问题更需要稳定、少发散。
LOTTERY_AI_ENABLED 可以一键关闭 AI 能力。
熔断阈值和打开时间都可以配置。
这些配置能让 AI 能力更像一个可运维的后端模块,而不是写死在代码里的实验功能。
Controller:所有 AI 操作先过管理员权限
运营助手接口在 AiOpsController 中:

@RestController
public class AiOpsController {
    private final AiAccessService aiAccessService;
    private final AiActivityAssistantService activityAssistantService;
    private final AiOpsAssistantService opsAssistantService;

    @PostMapping("/ai/ops/chat")
    public CommonResult<AiOpsChatResultDTO> chat(
            @Valid @RequestBody AiOpsChatParam param,
            HttpServletRequest request) {
        String operatorId = aiAccessService.requireAdmin(request);
        return CommonResult.success(
                opsAssistantService.chat(param.getQuestion(), operatorId)
        );
    }
}

请求参数也做了基本校验:

@Data
public class AiOpsChatParam implements Serializable {
    @NotBlank(message = "question must not be blank")
    @Size(max = 1000, message = "question is too long")
    private String question;
}

也就是说:
问题不能为空;
问题长度不能超过 1000;
只有管理员可以调用 AI 运营助手。
管理员校验逻辑在 AiAccessService:

@Service
public class AiAccessService {
    public String requireAdmin(HttpServletRequest request) {
        String token = request.getHeader("user_token");
        if (!StringUtils.hasText(token)) {
            throw new ServiceException(ServiceErrorCodeConstants.IDENTITY_ERROR);
        }
        Claims claims = JWTUtil.parseJWT(token);
        if (claims == null) {
            throw new ServiceException(ServiceErrorCodeConstants.IDENTITY_ERROR);
        }
        String identity = String.valueOf(claims.get("identity"));
        if (UserIdentityEnum.forName(identity) != UserIdentityEnum.ADMIN) {
            throw new ServiceException(ServiceErrorCodeConstants.IDENTITY_ERROR);
        }
        Object id = claims.get("id");
        return id == null ? "unknown" : String.valueOf(id);
    }
}

这里没有因为“只是 AI 问答”就放松权限。因为 AI 一旦能查业务数据,本质上就是一个新的业务入口,必须和后台页面一样做权限控制。
核心 Service:ChatClient + Tool Calling
运营助手核心实现是 AiOpsAssistantServiceImpl

@Service
public class AiOpsAssistantServiceImpl implements AiOpsAssistantService {
    private static final String SYSTEM_PROMPT = """
            You are an operations assistant for an enterprise lottery system.
            Answer in Simplified Chinese.
            You can use the provided read-only tools to query activities, prizes, and winning records.
            Never invent business data. If a tool result is empty, say no matching data was found.
            Never create activities, draw prizes, modify state, or decide winners.
            """;

    private final ChatClient chatClient;
    private final AiCircuitBreaker circuitBreaker;
    private final LotteryOperationTools lotteryOperationTools;
    private final AiAuditService auditService;

    public AiOpsAssistantServiceImpl(ChatClient.Builder chatClientBuilder,
                                     AiCircuitBreaker circuitBreaker,
                                     LotteryOperationTools lotteryOperationTools,
                                     AiAuditService auditService) {
        this.chatClient = chatClientBuilder.build();
        this.circuitBreaker = circuitBreaker;
        this.lotteryOperationTools = lotteryOperationTools;
        this.auditService = auditService;
    }
}

这个系统提示词很关键,里面明确约束:
用中文回答;
可以使用只读工具查询活动、奖品、中奖记录;
不允许编造业务数据;
工具结果为空时要说明没查到;
不允许创建活动;
不允许执行抽奖;
不允许修改状态;
不允许决定中奖人。
真正调用模型和工具的代码:

String answer = chatClient.prompt()
        .system(SYSTEM_PROMPT)
        .user(question)
        .tools(lotteryOperationTools)
        .call()
        .content();

这里的 .tools(lotteryOperationTools) 就是 Spring AI Tool Calling 的关键。模型在回答问题时,可以根据用户问题选择调用 LotteryOperationTools 中被 @Tool 标注的方法。
只读工具设计:只给 AI 查数据,不给 AI 改数据
工具类 LotteryOperationTools 只暴露了三个只读方法:

@Component
public class LotteryOperationTools {
    private final ActivityService activityService;
    private final DrawPrizeService drawPrizeService;
    private final PrizeService prizeService;

    @Tool(description = "Query activity detail by activityId. This is a read-only operation.")
    public String getActivityDetail(Long activityId) {
        return JacksonUtil.writeValueAsString(
                activityService.getActivityDetail(activityId)
        );
    }

    @Tool(description = "Query winning records by activityId and optional prizeId. This is a read-only operation.")
    public String getWinningRecords(Long activityId, Long prizeId) {
        ShowWinningRecordsParam param = new ShowWinningRecordsParam();
        param.setActivityId(activityId);
        param.setPrizeId(prizeId);
        return JacksonUtil.writeValueAsString(drawPrizeService.getRecords(param));
    }

    @Tool(description = "Query prize list by page and pageSize. This is a read-only operation.")
    public String findPrizeList(Integer page, Integer pageSize) {
        PageParam param = new PageParam();
        param.setCurrentPage(page == null || page <= 0 ? 1 : page);
        param.setPageSize(pageSize == null || pageSize <= 0 ? 10 : pageSize);
        return JacksonUtil.writeValueAsString(prizeService.findPrizeList(param));
    }
}

注意,这里没有暴露这些方法:
drawPrize:执行抽奖;
createActivity:创建活动;
updateActivityStatus:修改活动状态;
deletePrize:删除奖品;
sendSms:发送短信;
sendMail:发送邮件。
这是我认为 Tool Calling 里最重要的设计原则:
工具能力越强,权限边界越要保守。能只读,就不要开放写操作。

为什么?
因为模型有可能误解用户意图,也可能被提示词注入影响。如果给它开放写工具,轻则误改数据,重则影响抽奖公平性。
在抽奖系统这种业务里,中奖决策必须由后端确定性流程完成,不能交给模型。

Tool Calling 实际能解决什么问题

假设运营人员问:
查一下活动 1001 的中奖记录
模型可以选择调用:
getWinningRecords(1001, null)
后端真实查询中奖记录,再把 JSON 返回给模型,模型再组织成中文回答。
再比如:
帮我看看活动 1001 的详情
模型可以调用:
getActivityDetail(1001)
如果问:
奖品列表第一页有什么?
模型可以调用:
findPrizeList(1, 10)
这和普通 RAG 不一样。RAG 查的是知识库,Tool Calling 查的是实时业务系统。对于运营场景来说,Tool Calling 更适合查询活动、奖品、中奖记录这类动态数据。
熔断与降级:AI 不可用时不能拖垮后台
AI 服务最大的问题之一是不稳定:
API Key 没配;
网络超时;
模型服务限流;
返回格式异常;
第三方服务不可用。
所以项目里加了一个简单熔断器:

@Component
public class AiCircuitBreaker {
    private final LotteryAiProperties properties;
    private final AtomicInteger failures = new AtomicInteger();
    private volatile Instant openUntil = Instant.MIN;

    public boolean allowRequest() {
        return properties.isEnabled() && Instant.now().isAfter(openUntil);
    }

    public void recordSuccess() {
        failures.set(0);
        openUntil = Instant.MIN;
    }

    public void recordFailure() {
        int currentFailures = failures.incrementAndGet();
        if (currentFailures >= properties.getCircuitBreaker().getFailureThreshold()) {
            openUntil = Instant.now().plus(properties.getCircuitBreaker().getOpenDuration());
        }
    }
}

运营问答里先判断熔断状态:

if (!circuitBreaker.allowRequest()) {
    result.setDegraded(true);
    result.setAnswer("AI 服务暂时不可用,请通过活动列表、奖品列表和中奖记录页面查询。");
    auditService.record(requestId, operatorId, "AI_OPS_CHAT", false, true);
    return result;
}

调用失败时记录失败并降级:

try {
    String answer = chatClient.prompt()
            .system(SYSTEM_PROMPT)
            .user(question)
            .tools(lotteryOperationTools)
            .call()
            .content();
    result.setAnswer(answer);
    circuitBreaker.recordSuccess();
    auditService.record(requestId, operatorId, "AI_OPS_CHAT", true, false);
    return result;
} catch (Exception e) {
    circuitBreaker.recordFailure();
    result.setDegraded(true);
    result.setAnswer("AI 服务调用失败,请稍后重试,或通过后台页面查询活动和中奖记录。");
    auditService.record(requestId, operatorId, "AI_OPS_CHAT", false, true);
    return result;
}

这里的设计目标是:
AI 可用时提升效率;
AI 不可用时不影响传统后台;
用户仍然知道可以通过普通页面完成查询;
后端有日志可排查。
返回 DTO 也带了 degraded 字段:

@Data
public class AiOpsChatResultDTO implements Serializable {
    private String requestId;
    private String answer;
    private boolean degraded;
}

前端可以根据 degraded 提示“当前为降级回答”。
审计日志:AI 操作必须可追踪
每次 AI 调用都会生成 requestId:
String requestId = UUID.randomUUID().toString();
并记录审计日志:

@Service
public class AiAuditService {
    private static final Logger logger = LoggerFactory.getLogger("AI_AUDIT");

    public void record(String requestId, String operatorId, String operation, boolean success, boolean degraded) {
        logger.info("ai_audit requestId={} operatorId={} operation={} success={} degraded={}",
                requestId, operatorId, operation, success, degraded);
    }
}

审计字段包括:
requestId:本次 AI 请求 ID;
operatorId:操作人 ID;
operation:操作类型,比如 AI_OPS_CHAT;
success:是否成功;
degraded:是否降级。
为什么要做审计?
因为 AI 变成业务入口以后,不能只看回答结果,还要能追踪:
谁问了问题;
问的是哪个业务模块;
模型调用是否成功;
是否发生降级;
是否需要后续排查。
这也是把 AI 功能从 Demo 做成工程能力的重要一步。
活动草稿生成:结构化输出校验的另一个 AI 场景
虽然本文主线是 Tool Calling 运营问答,但项目里还有一个活动草稿生成能力,能体现另一个 AI 工程化重点:结构化输出校验。
系统提示词要求模型只返回严格 JSON:

private static final String SYSTEM_PROMPT = """
        You are an enterprise lottery operations assistant.
        Convert the user's natural language into strict JSON only.
        Do not create lottery results. Do not call any write operation.
        The JSON schema is:
        {
          "activityName": "string",
          "description": "string",
          "activityPrizeList": [
            {"prizeId": 1, "prizeAmount": 1, "prizeTiers": "FIRST_PRIZE"}
          ],
          "activityUserList": [
            {"userId": 1, "userName": "name"}
          ]
        }
        prizeTiers must be FIRST_PRIZE, SECOND_PRIZE, or THIRD_PRIZE.
        If the user did not provide ids, return null for ids instead of guessing.
        """;

模型返回后,用 AiJsonParser 提取 JSON:

public <T> T parseObject(String content, Class<T> targetType) {
    try {
        return objectMapper.readValue(extractJson(content), targetType);
    } catch (Exception e) {
        throw new IllegalArgumentException("AI response is not valid JSON", e);
    }
}

再用 Validator 做字段校验:

if (!StringUtils.hasText(draft.getActivityName())) {
    errors.add("activityName is required.");
}
if (ActivityPrizeTiersEnum.forName(prize.getPrizeTiers()) == null) {
    errors.add("invalid prizeTiers: " + prize.getPrizeTiers());
}
if (prizeAmount > draft.getActivityUserList().size()) {
    errors.add("total prize amount cannot exceed participant amount.");
}

如果 AI 不可用,会生成本地草稿:

result.setDegraded(true);
result.setDraft(fallbackService.buildDraft(prompt));
result.setWarnings(List.of("AI 服务暂时不可用,已生成本地活动草稿,请继续圈选奖品和人员。"));

这说明项目里的 AI 能力不是单一玩法:
Tool Calling 适合查实时业务数据;
结构化输出适合生成草稿;
两者都必须做校验、降级和权限控制。
为什么 AI 不能参与抽奖决策
抽奖系统和普通内容生成系统不一样。
中奖结果涉及公平性、可解释性和用户权益。模型不能决定:
谁中奖;
奖品归属;
活动状态是否完成;
中奖记录是否落库;
是否发送通知。
这些操作必须由后端业务流程完成,比如:
活动状态校验;
奖品状态校验;
参与者状态校验;
抽奖算法;
RabbitMQ 异步消费;
中奖记录落库;
缓存刷新;
邮件 / 短信通知。
AI 最多辅助运营人员查询信息、生成文案、生成活动草稿。这个边界必须写进系统提示词,也必须落实到代码层面:不给 AI 暴露写工具。
一次运营问答的完整流程
以这个问题为例:
帮我查一下活动 1001 的中奖记录
完整流程是:

  1. 前端调用 POST /ai/ops/chat
  2. AiOpsController 接收问题
  3. AiAccessService 从 user_token 解析 JWT,校验 ADMIN 权限
  4. AiOpsAssistantServiceImpl 生成 requestId
  5. AiCircuitBreaker 判断 AI 能力是否可用
  6. ChatClient 发送 system prompt + user question
  7. 模型判断需要调用 getWinningRecords 工具
  8. LotteryOperationTools 调用 DrawPrizeService.getRecords
  9. DrawPrizeService 从 Redis / MySQL 查询真实中奖记录
  10. 工具结果返回给模型
  11. 模型组织中文回答
  12. AiAuditService 记录 AI_AUDIT 日志
  13. 返回 requestId / answer / degraded 给前端
    这个流程看起来不复杂,但每一步都在解决真实工程问题:
    权限;
    工具边界;
    模型稳定性;
    业务数据可信;
    降级;
    审计。

工程设计取舍

  1. 为什么只暴露只读工具?
    因为运营问答的核心价值是查询和汇总,不需要修改业务状态。只读工具能大幅降低模型误操作风险。
  2. 为什么 system prompt 里反复强调 Never?
    Prompt 不是安全边界,但它是第一层行为约束。真正的安全边界在代码层:不提供写工具、不开放抽奖方法、不开放状态修改方法。
  3. 为什么 temperature 设为 0?
    运营查询类场景更重视稳定性和事实一致性,不需要太多创造性。温度越低,回答越稳定。
  4. 为什么要有 degraded 字段?
    前端和用户需要知道当前回答是否来自正常 AI 调用。如果是降级结果,就应该提示用户去传统后台页面查询。
  5. 为什么要记录 AI_AUDIT?
    AI 是新的业务入口,必须能追踪谁调用、调用什么、是否成功、是否降级,方便排查和责任界定。
    面试怎么讲这个项目亮点
    如果面试官问:“你这个抽奖系统里的 Spring AI 是怎么落地的?”
    我会这样回答:
    我没有简单地调一个大模型 API,而是把 AI 当成抽奖系统的旁路增强能力来设计。核心业务里的抽奖决策、状态流转、中奖记录落库仍然由后端确定性流程完成,AI 不参与中奖决策。
    在运营问答场景里,我用 Spring AI ChatClient 接入 DeepSeek 的 OpenAI 兼容接口,通过 .tools(lotteryOperationTools) 给模型挂载了三个只读工具:查询活动详情、查询中奖记录、查询奖品列表。工具内部调用已有的 ActivityService、DrawPrizeService 和 PrizeService,所以 AI 回答来自真实业务数据,而不是模型编造。
    为了保证安全,我在 Controller 层通过 JWT 校验管理员身份,系统提示词明确禁止创建活动、执行抽奖、修改状态和决定中奖人,代码层也没有暴露任何写工具。同时我加了熔断降级和 AI_AUDIT 审计日志,AI 服务不可用时返回降级提示,不影响传统后台页面继续使用。

总结

这次 Spring AI Tool Calling 改造,本质上不是“给抽奖系统加个聊天框”,而是把 AI 做成一个受控的业务增强模块。
核心设计可以总结为:
用 Spring AI ChatClient 接入 DeepSeek;
用 Tool Calling 让模型调用后端真实业务查询;
只暴露活动详情、中奖记录、奖品列表三个只读工具;
Controller 层校验管理员权限;
System Prompt 明确禁止编造数据、抽奖、改状态、决定中奖人;
熔断器控制 AI 服务可用性;
降级结果不影响传统后台使用;
AI_AUDIT 记录 requestId、operatorId、operation、success、degraded;
活动草稿生成场景额外做 JSON 解析和字段校验。
我认为 AI 落地业务系统时,最重要的不是“模型能做什么”,而是“模型不能做什么”。
尤其在抽奖系统这种涉及状态流转和公平性的业务里,AI 必须被限制在可控边界内:能查、能解释、能生成草稿,但不能替代后端做核心决策。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐