mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2939 words
8 minutes
商城 Agent 实战(三):让 Agent 安全查询当前用户的订单
2026-08-07

商城 Agent 实战(三):让 Agent 安全查询当前用户的订单#

上一篇中,我把 AgentShop 的商品搜索、商品详情和库存查询包装成了 LangChain4j Tool。

当用户说“查一下华为手机”时,模型不再凭空编造商品,而是会先调用 Java Tool,根据商城中的真实数据回答。

这一篇继续接入订单能力,但订单和商品有一个根本区别:

商品数据:大多数登录用户看到的内容相同
订单数据:每个用户只能查看自己的订单

所以订单 Agent 的难点并不是再写两个 @Tool,而是:

如何让 Agent 使用当前登录用户的身份,并且无论用户和模型怎样组织参数,都不能查询其他人的订单。

本系列基于开源项目 macrozheng/mall 进行二次开发。订单模型和原有业务 Service 来自原项目,本文记录的是 Agent 接入、资源级权限校验和安全输出设计。

1. 这次实现的订单能力#

当前新增两个只读 Tool:

listMyOrders
查询当前登录会员的订单列表
getMyOrderDetail
查询当前登录会员的某一笔订单详情

调用链如下:

Authorization Header
Spring Security + JWT
PortalAiAssistantController
PortalAssistant
PortalOrderTools
OmsPortalOrderService
UmsMemberService.getCurrentMember()
按当前 memberId 查询或校验订单归属

原本准备单独设计 getMyOrderLogistics,但当前商城还没有接入真正的物流轨迹接口,只有物流公司和物流单号。因此第一版把脱敏后的物流信息放在订单详情中,没有为了凑 Tool 数量额外拆分方法。

认证和授权不是一回事#

在继续实现 Tool 之前,需要先区分两个概念:

认证(Authentication)
→ 通过 Spring Security 和 JWT 确认访问者是谁
授权(Authorization)
→ 确认当前用户能否访问指定订单

前台聊天接口 /ai/portal/chat 没有加入匿名白名单,因此请求必须携带有效 JWT。JWT 过滤器解析 Token,把认证结果写入 SecurityContext

但通过登录认证,只能证明“当前用户是会员 1”,不能证明“订单 1001 属于会员 1”。订单归属仍然必须在 Service 的数据库查询条件中校验。

完整的安全链路是:

JWT 认证:确认你是谁
Tool 参数限制:模型能提交什么
Service 授权:你能读取哪条订单
DTO 脱敏:能向模型暴露多少数据

提示词可以引导模型,但不能替代其中任何一层。

2. memberId 不能来自模型#

如果把订单 Tool 设计成:

@Tool("查询会员订单")
public String getOrder(Long memberId, Long orderId) {
// ...
}

就等于把用户身份交给了模型。

用户可以说:

帮我查询 memberId=2 的订单。

模型也可能因为上下文错误生成其他 memberId。如果 Java 端相信这个参数,就可能形成水平越权。

当前 Tool 的方法签名是:

public String listMyOrders(
Integer status,
Integer pageNum,
Integer pageSize
)
public String getMyOrderDetail(Long orderId)

参数中根本没有 memberId

这不是只靠 System Prompt 要求模型“不要传 memberId”,而是 Tool Schema 就没有这个字段。模型无法通过正常 Tool Calling 流程为它指定其他会员。

这里的原则是:

模型可以提供业务参数,但认证身份必须来自服务器的可信上下文。

3. 身份在 Service 中获取#

当前并没有为 Agent 额外创建 CurrentMemberProvider,而是复用原项目已有的:

UmsMemberService.getCurrentMember()

对于 AgentShop 来说,将身份获取和订单归属校验留在订单 Service 中更合适:

PortalOrderTools
只负责 Agent 参数校验、DTO 转换和输出脱敏
OmsPortalOrderService
负责当前会员身份和订单归属规则

这样即使未来订单 Service 被其他入口调用,也不会因为绕过 Agent Tool 而失去资源级权限校验。

4. 实现当前会员订单列表#

Tool 定义如下:

@Tool("List orders that belong to the current authenticated " +
"AgentShop member. Never accepts memberId.")
public String listMyOrders(
@P(name = "status", value = "Order status") Integer status,
@P(name = "pageNum", value = "Page number") Integer pageNum,
@P(name = "pageSize", value = "Page size") Integer pageSize
) {
int safeStatus = sanitizeStatus(status);
int safePageNum = sanitizePageNum(pageNum);
int safePageSize = sanitizePageSize(pageSize);
CommonPage<OmsOrderDetail> orderPage =
portalOrderService.list(
safeStatus,
safePageNum,
safePageSize
);
// 转换为精简 DTO 后返回 JSON
}

模型生成的页码和状态仍然是不可信输入。当前对它们进行了强制限制:

private static final int DEFAULT_STATUS = -1;
private static final int MAX_PAGE_NUM = 20;
private static final int MAX_PAGE_SIZE = 5;
private int sanitizeStatus(Integer status) {
if (status != null && status >= 0 && status <= 4) {
return status;
}
return DEFAULT_STATUS;
}

其中订单状态为:

-1 全部
0 待付款
1 待发货
2 已发货
3 已完成
4 已关闭

即使模型生成 pageNum=999999pageSize=50,实际传给 Service 的也最多是第 20 页、每页 5 条。

5. 订单列表必须带当前 memberId#

OmsPortalOrderServiceImpl.list() 先从安全上下文获取会员:

UmsMember member = memberService.getCurrentMember();

然后把会员 ID 写入 MyBatis 查询条件:

OmsOrderExample.Criteria criteria = orderExample.createCriteria();
criteria.andDeleteStatusEqualTo(0)
.andMemberIdEqualTo(member.getId());
if (status != null) {
criteria.andStatusEqualTo(status);
}

因此查询的含义不是:

查询状态为 1 的所有订单

而是:

查询当前会员中,未删除且状态为 1 的订单

无论用户如何描述“其他人的订单”,数据库查询中的 memberId 始终来自当前登录态。

6. 订单详情必须再做归属校验#

聊天入口已经要求登录,但“已登录”不等于“可以查询任意订单”。

如果详情 Service 只执行:

orderMapper.selectByPrimaryKey(orderId);

那么会员 A 就可能通过猜测订单 ID,查询会员 B 的订单。这属于典型的对象级越权。

当前代码在查到订单后立即校验:

UmsMember member = memberService.getCurrentMember();
OmsOrder omsOrder = orderMapper.selectByPrimaryKey(orderId);
if (omsOrder == null
|| !member.getId().equals(omsOrder.getMemberId())
|| Objects.equals(omsOrder.getDeleteStatus(), 1)) {
Asserts.fail("订单不存在或无权访问!");
}

三种情况对外使用同一个错误:

订单不存在
订单属于其他会员
订单已经被删除
→ 订单不存在或无权访问

这可以避免向请求者暴露“这个订单真实存在,只是属于别人”。

Tool 捕获异常后同样返回统一结果:

{
"found": false,
"orderId": 2002,
"reason": "order was not found for current member"
}

模型只能告诉用户没有找到该订单,不应猜测订单的真实归属。

7. 不要把完整订单对象交给模型#

订单对象中包含大量字段,包括:

memberId
memberUsername
收货人姓名
收货人手机号
省市区和详细地址
物流单号
支付和优惠信息
后台备注
删除状态

直接序列化 OmsOrderDetail 会造成过度暴露。因此新增 OrderToolDtos,将 Tool Result 分成:

OrderListResult
OrderSummary
OrderDetailView
OrderItemView

订单列表只返回摘要:

public record OrderSummary(
Long orderId,
String orderSn,
Integer status,
String statusText,
String createTime,
BigDecimal totalAmount,
BigDecimal payAmount,
Integer productCount,
List<String> productNames
) {}

其中没有 memberId、手机号和收货地址。

8. 订单详情中的敏感数据脱敏#

对于当前会员自己的订单,模型也不一定需要看到完整个人信息。

当前代码对收货人和物流信息进行了脱敏:

姓名:张三 → 张*
手机:13812345678 → 138****5678
地址:上海市浦东新区世纪大道100号
→ 上海上海市浦东新区 ***
物流单号:SF1234567890 → ****7890

手机号脱敏代码:

private String maskPhone(String phone) {
String digits = sanitizeText(phone);
if (!StringUtils.hasText(digits)) {
return "";
}
if (digits.length() < 8) {
return "****";
}
return digits.substring(0, 3)
+ "****"
+ digits.substring(digits.length() - 4);
}

收货地址只保留省市区,不把 receiverDetailAddress 交给模型:

private String maskAddress(OmsOrderDetail order) {
String province = sanitizeText(order.getReceiverProvince());
String city = sanitizeText(order.getReceiverCity());
String region = sanitizeText(order.getReceiverRegion());
String area = (province + city + region).trim();
return StringUtils.hasText(area) ? area + " ***" : "";
}

这种设计不是因为模型一定会泄露数据,而是遵守最小数据原则:

模型完成回答不需要的字段,不应该进入模型上下文。

9. 限制订单明细数量#

一笔订单可能包含很多商品明细。当前最多向模型返回 10 条:

List<OrderItemView> returnedItems = allItems.stream()
.limit(MAX_DETAIL_ITEM_SIZE)
.map(this::toItemView)
.toList();

同时返回截断信息:

{
"itemCount": 12,
"returnedItemCount": 10,
"itemDetailsTruncated": true
}

模型可以正确告诉用户“当前只展示部分商品明细”,而不会误以为订单总共只有 10 件商品。

10. 订单字段同样是不可信文本#

订单中的商品名称、SKU 属性和物流公司都可能包含外部输入。它们和第二篇的商品描述一样,只能被当作数据,不能被当作新指令。

PortalAssistant 现在的规则包含:

If the user asks about their orders, logistics,
payment status, or order items,
call the current-member order tools first.
Never ask for or invent memberId.
Order tools only use the currently authenticated member.
Do not reveal unmasked receiver names,
phone numbers, addresses, or tracking numbers.

同时,Tool 会对输出的普通文本做 HTML 去除、控制字符清理和长度截断。

需要注意,System Prompt 只是模型行为约束,并不是可代替 Java 权限校验的安全边界。即使模型完全忽略这些提示词,Service 中的 memberId 过滤和订单归属校验仍然必须生效。

11. 把订单 Tool 注册给 PortalAssistant#

前台 Assistant 现在同时拥有商品和订单 Tool:

@Bean
public PortalAssistant portalAssistant(
ChatModel chatModel,
PortalProductTools portalProductTools,
PortalOrderTools portalOrderTools
) {
return AiServices.builder(PortalAssistant.class)
.chatModel(chatModel)
.tools(portalProductTools, portalOrderTools)
.maxToolCallingRoundTrips(3)
.build();
}

模型会根据问题选择工具:

“有哪些华为手机?”
→ searchProducts
“我最近有哪些订单?”
→ listMyOrders
“订单 1001 发货了吗?”
→ getMyOrderDetail

前台仍然使用同一个接口:

POST /ai/portal/chat

用户不需要知道 Tool 名称,也不需要自己组装订单查询接口。

12. 测试 Tool 只返回安全字段#

PortalOrderToolsTest 首先验证分页和状态参数:

String json = tools.listMyOrders(99, 999999, 50);
assertThat(root.get("status").asInt()).isEqualTo(-1);
assertThat(root.get("pageNum").asInt()).isEqualTo(20);
assertThat(root.get("pageSize").asInt()).isEqualTo(5);

同时确认列表结果不包含身份和收货信息:

assertThat(root.at("/orders/0/memberId").isMissingNode())
.isTrue();
assertThat(root.at("/orders/0/receiverPhone").isMissingNode())
.isTrue();

详情测试则验证脱敏结果:

assertThat(root.get("receiverNameMasked").asText())
.isEqualTo("张*");
assertThat(root.get("receiverPhoneMasked").asText())
.isEqualTo("138****5678");
assertThat(root.get("deliverySnMasked").asText())
.isEqualTo("****7890");
assertThat(root.at("/receiverDetailAddress").isMissingNode())
.isTrue();

13. 测试其他会员的订单无法访问#

越权防护必须在 Service 层单独测试。

OmsPortalOrderServiceImplTest 构造:

当前会员 ID = 1
订单 1001 的 memberId = 2

然后断言:

assertThatThrownBy(() -> orderService.detail(1001L))
.isInstanceOf(ApiException.class)
.hasMessage("订单不存在或无权访问!");
verify(orderItemMapper, never())
.selectByExample(any());

第二个断言同样重要:在发现订单不属于当前会员后,Service 不应该继续读取订单商品明细。

Tool 层还测试了统一错误输出:

when(portalOrderService.detail(2002L))
.thenThrow(new ApiException("订单不存在或无权访问!"));
String json = tools.getMyOrderDetail(2002L);
assertThat(root.get("found").asBoolean()).isFalse();
assertThat(root.get("reason").asText())
.isEqualTo("order was not found for current member");

因此模型不会收到两种可以用来枚举订单的差异响应。

14. 验证完整的订单 Tool Calling 循环#

只直接调用 PortalOrderTools.listMyOrders(),不能证明 Agent 会选择这个 Tool。

因此新增 PortalAssistantOrderToolCallingTest,使用可控的假 ChatModel模拟两轮对话。

第一轮返回:

ToolExecutionRequest.builder()
.id("call-1")
.name("listMyOrders")
.arguments("""
{
"status": -1,
"pageNum": 1,
"pageSize": 5
}
""")
.build();

LangChain4j 执行 Tool 后,第二轮请求中应该出现 ToolExecutionResultMessage。测试检查:

assertThat(chatModel.requests).hasSize(2);
assertThat(chatModel.requests.get(0).toolSpecifications())
.extracting("name")
.contains("listMyOrders");
assertThat(chatModel.requests.get(1).messages())
.anySatisfy(message -> {
ToolExecutionResultMessage result =
(ToolExecutionResultMessage) message;
assertThat(result.toolName())
.isEqualTo("listMyOrders");
assertThat(result.text())
.contains("202608070001");
assertThat(result.text())
.doesNotContain("memberId");
});

这个测试证明:

订单 Tool Schema 已注册
模型可以发起 listMyOrders
Java 会执行当前会员订单查询
Tool Result 会返回模型
Tool Result 中没有 memberId
模型可以根据真实订单继续回答

15. 前台 Agent 和后台 Agent 必须隔离#

项目最初在公共 mall-ai 模块中放过一个通用的 MallAssistantController

POST /ai/chat

mall-adminmall-portal 都依赖 mall-ai,公共 Controller 会被两个应用扫描。这会让前后台共用一个含义不明确的入口,也让以后的 Tool 权限越来越难控制。

现在已经删除公共 Controller,让 mall-ai 只保存共享基础能力:

mall-ai
├── LangChain4j 和 DeepSeek 配置
├── 通用请求 DTO
└── 公共模型调用基础能力
mall-portal
├── PortalAiAssistantController
├── PortalAssistant
├── PortalProductTools
└── PortalOrderTools
mall-admin(后续实现)
├── AdminAiAssistantController
├── AdminAssistant
└── AdminTools

前台只能注册公开商品和当前用户订单 Tool;后台以后可以查询运营数据和全部订单,但必须使用独立入口、独立 Assistant 和独立 Tool 集合。

不能只在 Prompt 中告诉前台模型“不要调用后台 Tool”。真正可靠的隔离方式,是根本不把后台 Tool 注册给它。

16. 执行订单 Agent 与权限测试#

本次执行:

Terminal window
mvn -pl mall-portal -am `
"-Dtest=MallAiPublicControllerRemovalTest,PortalAiAssistantSecurityTest,PortalAiAssistantControllerTest,PortalOrderToolsTest,PortalAssistantOrderToolCallingTest,OmsPortalOrderServiceImplTest" `
"-Dsurefire.failIfNoSpecifiedTests=false" `
test

当前结果:

Tests run: 12
Failures: 0
Errors: 0
Skipped: 0
BUILD SUCCESS

这些测试不需要真实 DeepSeek,也不依赖数据库,可以稳定验证:

匿名请求返回 401,且 PortalAssistant 不会被调用
有效 JWT 可以访问 Portal Agent
订单 Tool Schema 不包含 memberId 和 username
模型额外生成 memberId 也不能切换业务身份
其他会员的订单不能被读取
订单敏感字段不会进入 Tool Result
公共 mall-ai Controller 不会被重新暴露

其中 PortalAiAssistantSecurityTest 使用真实 Spring Security 过滤器链。它不仅断言匿名请求返回 401,还通过 verifyNoInteractions(portalAssistant) 确认未认证请求不会调用模型、消耗费用。

MallAiPublicControllerRemovalTest 则固定了模块边界,防止以后又把通用 Controller 放回 mall-ai

17. 当前方案还缺什么#

核心身份与订单权限边界已经完成,但距离生产环境仍有三个改进项。

1. 订单列表归属条件的独立测试#

订单列表实际查询已经包含当前会员 ID,但还可以捕获列表查询使用的 OmsOrderExample,专门断言:

member_id = 当前登录会员ID
delete_status = 0

详情查询已经有同类测试,列表测试补齐后可以进一步防止代码回退。

2. Tool 参数日志脱敏#

当前 beforeToolExecution 会记录完整 Tool 参数。现有订单 Tool 只有状态、页码和订单 ID,风险暂时有限;以后加入售后原因、地址等能力后,日志可能包含个人信息。

生产环境更适合只记录:

requestId
userHash
toolName
duration
success
sanitizedArgumentSummary

3. 限流、审计和调用预算#

虽然一次 Agent 请求最多只允许 3 轮 Tool Calling,但还需要在 HTTP 入口增加:

按会员限流
按时间窗口限制调用数
记录 Tool 名称和执行结果
避免记录完整订单和收货信息
模型超时和失败降级

模型 API Key、数据库密码和 JWT Secret 也不能写入 Git,应从环境变量或密钥管理服务读取。真实凭据一旦进入仓库,需要删除明文并轮换,而不只是修改配置文件。

18. 这一阶段完成了什么#

现在的 AgentShop 前台 Agent 已经拥有两类业务能力:

公开业务数据
→ 商品搜索、详情、库存
当前用户私有数据
→ 订单列表、订单详情、脱敏物流信息

更重要的是,订单能力不是只靠 Prompt 声明“不要查别人的订单”,而是把权限约束落到了 Java 方法签名、安全上下文和数据库查询中:

Tool 没有 memberId 参数
→ 身份只来自当前登录态
→ 列表查询带 memberId
→ 详情查询校验订单归属
→ Tool DTO 移除或脱敏敏感字段

再加上 JWT 入口认证和前后台能力隔离,最终形成四道边界:

HTTP 入口确认登录状态
→ Tool Schema 不允许模型指定身份
→ Service 使用当前会员限制数据归属
→ DTO 最小化并脱敏模型可见数据

这才是一个业务 Agent 应该有的安全边界。最重要的原则是:

提示词不是权限系统,模型是否听话也不是安全保证。

19. 下一步#

下一篇将进入知识库 RAG:

《商城 Agent 实战(四):接入商城知识库 RAG》

预计包括:

  • 整理退换货、配送和会员规则;
  • 文档切分与向量化;
  • EmbeddingStore 和检索器配置;
  • 为回答附上知识来源;
  • 处理召回不到和召回错误;
  • 测试 RAG 是否真的优于只问模型。

从商品 Tool 到订单 Agent,我们解决了“模型如何查询结构化业务数据”。下一步要解决的是:

商城规则保存在文档里时,怎样让模型找到正确依据再回答,而不是凭自己的知识猜测。

参考资料#

Share

If this article helped you, please share it with others!

商城 Agent 实战(三):让 Agent 安全查询当前用户的订单
https://mizuki.mysqil.com/posts/langchain4j-agent-shop-03-order-agent/
Author
梦幻晨风
Published at
2026-08-07
License
CC BY-NC-SA 4.0

Some information may be outdated

Table of Contents