从零重构外卖系统(八):从管理页面反推后端契约,补齐检索、顾客状态与安全审计
系列:Han Menu 外卖系统实践 · 从第一篇开始
系列:Han Menu 外卖系统实践 · P7 前置后端
面向读者:已经理解前几阶段的 DDD 模块边界、JPA、身份与支付流程,希望学习怎样把已有业务能力整理成真正可供管理界面使用的接口。
本篇依据提交
09bf306编写。代码直接展示实际关键实现,省略 package、import 或外围方法的片段不是独立完整文件。这一提交补齐管理端后端能力并确定前端路线,没有创建客户端工程。后来的 PC-1 提交
be73fb3会在下一篇单独讲解;后端接口存在,不代表对应管理页面已经交付。
1. 业务闭环跑通以后,为什么还会缺管理端接口
到 P6,顾客已经能下单、付款、取消和催单,员工能履约,系统也有通知和报表。
但准备画管理页面时,会发现“业务已经可以运行”和“页面需要的契约已经完整”并不相同:
- 订单列表怎样同时按状态、订单编号、顾客和收货电话检索?
- 管理员能不能查看顾客档案并停用账号?
- 支付和退款需要按什么时间筛选,页面刷新会不会意外发起查单?
- 安全审计里究竟已经记录了哪些事件?
- 顾客详情和顾客自己的资料接口,是不是应该返回同一个 DTO?
本次补充从这些真实页面问题出发,没有把数据库表机械地映射成一批“万能管理接口”。
先建立一份页面—权限—契约对照,再决定缺的是查询能力、业务行为,还是尚未实现的产品范围。
2. 先列能力矩阵,避免把想象中的按钮当作已有业务
| 页面能力 | 使用者 | 本次处理 |
|---|---|---|
| 登录、本人身份、改密、退出 | 员工 | 复用已有 sessions / me |
| 订单作业 | ADMIN / STAFF | 扩展真实组合检索,履约动作沿用 P5 |
| 顾客档案与启停用 | ADMIN | 新增管理查询和版本化状态变更 |
| 支付/退款流水 | ADMIN | 新增持久化事实列表和详情 |
| 身份安全审计 | ADMIN | 查询实际已经登记的安全事件 |
| 工作台、通知、报表 | 按 P6 权限 | 已有后端能力,前端按阶段接入 |
没有接口的能力,也需要明确说明:当前不提供动态角色授权、管理端代改顾客密码或地址、任意金额退款、配送员分配、营销和多店管理。
不能因为某个成熟后台模板里有这些按钮,就替后端编造字段或给页面返回假成功。
flowchart LR
P[页面与使用场景] --> R[明确操作者和业务规则]
R --> C[核对已有HTTP契约]
C --> Q[补充查询条件与最小响应]
C --> B[补充真实业务行为]
Q --> T[权限、边界与数据库测试]
B --> T
这张图表达开发判断顺序,不表示前端可以决定服务端权限。
3. 管理端身份不能复用顾客身份,隐藏菜单也不能代替授权
新增管理端资源使用员工 hme_ Bearer。顾客 hmc_ 令牌不能访问它们,普通员工也不能因为知道管理员页面地址就读取资金或顾客档案。
同时保留两个层次的检查:
| 位置 | 负责什么 |
|---|---|
| Spring Security 安全链 | 按资源路径与当前认证主体限制入口 |
| 应用服务公开授权契约 | 重验真实账号状态、角色及安全版本 |
顾客管理的查询入口不是直接执行仓储:
public CustomerPageView search(StaffIdentity actor, CustomerSearch search) {
staff.requireAdministrator(actor);
var result = customers.search(search);
return new CustomerPageView(
result.items().stream().map(ManagedCustomerView::from).toList(),
search.page(),
search.size(),
result.totalElements(),
Math.ceilDiv(result.totalElements(), search.size()));
}
它先调用 staff.requireAdministrator(actor)。即使另一个 Java 调用方构造了一个 role="ADMIN" 的身份快照,也不能凭这个字符串得到管理员能力。
管理端接单和配送仍允许 STAFF;查询资金、顾客管理和安全审计则要求 ADMIN。不能为了方便布局,把所有页面统一开放给“任意已登录员工”。
4. 组合检索首先是一份有明确语义的输入契约
4.1 把筛选条件组织成不可变输入
订单检索使用 OrderSearch:
/** 后台订单组合条件,时间区间为创建时刻的左闭右开区间,电话匹配历史收货快照. */
public record OrderSearch(
Order.Status status,
UUID orderId,
UUID customerId,
String phone,
Instant from,
Instant to,
int page,
int size) {
/** 验证分页、时间先后和完整电话号码;不把空字符串视作全量检索. */
public OrderSearch {
if (page < 0
|| page > 10000
|| size < 1
|| size > 50
|| (from != null && to != null && !from.isBefore(to))) {
throw new OrderException(OrderException.Reason.INVALID_INPUT, "分页或时间范围不合法");
}
if (phone != null) {
phone = phone.strip();
if (!phone.matches("\\+?[1-9][0-9]{6,14}")) {
throw new OrderException(OrderException.Reason.INVALID_INPUT, "请输入完整收货手机号");
}
if (!phone.startsWith("+")) {
phone = "+" + phone;
}
}
}
/** 查询诊断不输出收货电话号码. */
@Override
public String toString() {
return "OrderSearch[page=" + page + ", size=" + size + "]";
}
}
它不依赖 Spring、HTTP 或 JPA,只表达查询条件及其有效范围。
这里值得注意的规则包括:
- page 从 0 开始,最多 10000。
- size 为 1—50,防止无界读取。
- from/to 可以单独存在,同时存在时必须 from < to。
- phone 是完整电话的精确匹配,不把空字符串解释为查询全部。
- 去除电话首尾空白,省略的前导
+会被规范化。 toString()不输出收货电话,避免调试日志暴露查询中的个人资料。
值对象使这些规则不只依赖某一个 Controller 的参数注解,也能用于应用层或测试中的直接调用。
4.2 相似字段不代表相同业务含义
订单列表中的 phone 查询的是成交时的收货电话快照。
它不是:
- 当前顾客的登录手机号;
- 当前默认地址的电话;
- 管理员从顾客昵称推测出来的联系人。
例如顾客为家人下单,账号手机号和收货电话可以不同。后来顾客修改地址簿,也不应该改变旧订单按收货电话检索的结果。
P4 的快照设计,在这里继续发挥作用。
5. 时间区间要和 P6 报表日期明确区分
本轮管理查询使用带时区的 ISO-8601 时刻,采用左闭右开区间:
相邻区间可以拼接而不重复边界上的记录:
第一段:[09:00, 10:00)
第二段:[10:00, 11:00)
10:00 的记录只属于第二段。
如果页面选择上海经营时间的 2026 年 9 月 19 日整天,应转换为:
from = 2026-09-18T16:00:00Z
to = 2026-09-19T16:00:00Z
不要使用“23:59:59.999”猜测一天最后一瞬间。数据库时间精度变化或更细的时刻都可能让这种边界写法出错。
| 接口类型 | from/to 类型与含义 |
|---|---|
| 本轮管理列表 | Instant,from ≤ 时间 < to |
| P6 经营报表 | LocalDate,包含首尾经营日期 |
订单、顾客、支付、退款列表分别按自己的 createdAt 筛选;安全审计按 occurredAt。
特别是退款列表,筛选退款意图创建时间,不是原支付时间,也不是退款最终确认时间。资金按确认日统计的任务仍由 P6 报表承担。
6. 筛选必须在分页之前进入数据库查询
6.1 为什么不能先拿一页,再在 Java 或浏览器里过滤
假设第一页有 20 条订单,只有 2 条符合电话条件。不能据此返回“匹配总量 2”,因为其他页面可能还有匹配订单。
真正的筛选必须影响数据查询和匹配总量计算。
订单仓储的实际实现:
public OrderPage management(OrderSearch search) {
Specification<OrderEntity> filters =
(root, query, builder) -> {
var predicates = new ArrayList<Predicate>();
if (search.status() != null) {
predicates.add(builder.equal(root.get("status"), search.status()));
}
if (search.orderId() != null) {
predicates.add(builder.equal(root.get("id"), search.orderId()));
}
if (search.customerId() != null) {
predicates.add(builder.equal(root.get("customerId"), search.customerId()));
}
if (search.phone() != null) {
predicates.add(builder.equal(root.get("address").get("phone"), search.phone()));
}
if (search.from() != null) {
predicates.add(builder.greaterThanOrEqualTo(root.get("createdAt"), search.from()));
}
if (search.to() != null) {
predicates.add(builder.lessThan(root.get("createdAt"), search.to()));
}
return builder.and(predicates.toArray(Predicate[]::new));
};
var result =
records.findBy(
filters,
query ->
query
.as(OrderSummaryValue.class)
.page(
PageRequest.of(
search.page(),
search.size(),
Sort.by("createdAt").descending().and(Sort.by("id").descending()))));
return new OrderPage(
result.getContent().stream().map(OrderSummaryValue::domain).toList(),
result.getTotalElements());
}
可选条件通过 AND 组合,电话字段指向 address.phone,即订单持久化快照。
列表使用 OrderSummaryValue 投影,不为了显示摘要而加载收货资料和全部明细,也不把集合抓取与分页混在一起。
6.2 稳定排序不等于跨请求冻结快照
当前使用 createdAt DESC, id DESC。UUID 是相同创建时间下的次级排序键,不是订单创建时间编码。
这样可以避免相同时间记录没有稳定顺序,却不意味着偏移分页在并发插入时永远不发生跨页位置变化。
这个阶段没有把所有管理列表改成游标分页。P6 的通知提交游标解决的是可靠补查问题,不能不加分析地移植成所有列表的统一协议。
7. “名称包含”与 SQL 通配符搜索不是一回事
顾客管理允许按名称进行字面包含查询,输入 %、_ 或转义符时,应把它们当作名字的一部分。
实际关键代码:
String literal = search.name()
.replace("!", "!!")
.replace("%", "!%")
.replace("_", "!_");
predicates.add(
builder.like(root.get("displayName"), "%" + literal + "%", '!'));
% 和 _ 在 LIKE 中有特殊意义,因此这里先转义,再用外围 % 表示“包含”。
这属于匹配语义处理,不能和 SQL 参数绑定防注入混为一谈。即使用参数化查询,不转义通配符,也可能让用户输入 % 时变成匹配所有名称。
手机号仍然使用精确匹配,没有提供任意前缀、后缀或模糊手机号搜索。
构建 URL 时应使用标准查询参数编码,例如:
const query = new URLSearchParams({ phone: '+8613800138000' });
// phone=%2B8613800138000
这是接口调用示例,不是该提交中已经实现的 PC 页面。不要直接把 + 拼到查询字符串里,再指望所有解析路径都把它当作加号。
8. 顾客管理的响应,不应该等于顾客聚合的序列化
管理员需要查看的档案字段是:
public record ManagedCustomerView(
UUID id,
String phone,
String displayName,
boolean enabled,
long version,
Instant createdAt,
Instant updatedAt) {
// 实际代码还包含集中映射与脱敏 toString()。
}
没有 passwordHash、securityVersion、会话摘要、地址簿和购物车。
管理员确实被授权读取手机号,但这不等于所有领域内部字段都应该暴露给界面。DTO 应当按用例设计,而不是通过反射复制整个聚合。
名称使用 ManagedCustomerView 也有文档层面的价值:管理员视图与顾客自己的资料视图不是同一个协议。
在 Java 中,不同包可以各有名为 CustomerView 的类型;生成 OpenAPI 时则要核对 schema 命名和引用,避免简名冲突或错误复用。只看 Java 能不能编译,不足以保证生成的前端契约正确。
本轮集成测试检查管理端与顾客端的实际 schema 引用及字段隔离,为下一篇的类型生成提供基础。
9. 启停用要同时处理版本、会话撤销和无变化请求
新增操作:
PATCH /api/v1/management/customers/<顾客UUID>/status
Authorization: Bearer <管理员令牌>
Content-Type: application/json
{"enabled": false, "version": 0}
实际应用方法如下。它覆盖类级只读事务配置,使用写事务:
@Transactional
public ManagedCustomerView changeStatus(
StaffIdentity actor, UUID id, boolean enabled, long version) {
staff.requireAdministrator(actor);
var customer = customers.lock(id);
customer.requireVersion(version);
if (customer.enabled() != enabled) {
customer.changeEnabled(enabled, clock.instant());
customers.update(customer);
audit.customerStatusChanged(actor, id);
}
return ManagedCustomerView.from(customers.findById(id).orElseThrow());
}
9.1 先锁账号,再检查版本
顾客启停用锁定账号行,与既有地址簿和下单中的顾客协调锁共用一个稳定对象。
它不因为目标是“管理端状态”就绕开先前业务的并发约束。
9.2 同状态请求也不能跳过版本检查
代码先执行 requireVersion(version),再判断状态是否需要变化。
因此:
| 请求 | 结果 |
|---|---|
| 当前版本,目标状态不同 | 变更状态、保存、登记审计 |
| 当前版本,目标状态相同 | 返回当前档案,不重复改变和审计 |
| 陈旧版本,即使目标状态相同 | 409,要求客户端重新读取 |
否则,客户端可能用一个完全过时的状态判断发出“无变化请求”,却误以为操作已经依据最新数据确认。
9.3 重新启用不能恢复旧会话
领域行为沿用顾客账号自己的规则:
public void changeEnabled(boolean replacement, Instant now) {
if (enabled != replacement) {
enabled = replacement;
securityVersion++;
updatedAt = Objects.requireNonNull(now);
}
}
securityVersion 与业务 version 不同。前者撤销已有会话,后者保护资源并发写入。
停用、再启用会继续推进安全版本,不能让停用之前的 token 重新有效。
停用账号也不是删除顾客或中断所有已付款交易。历史订单仍存在,商家履约和服务端支付处理不依赖被停用顾客重新登录才能继续。
10. 跨模块审计应该是明确能力,而不是随意写另一张表
customer 模块只调用 identity 的公开契约:
/** 对外提供固定安全事件登记,不接受请求正文、凭证或任意审计文本. */
public interface StaffAudit {
/** 在调用方业务事务内登记管理员变更顾客状态的事实. */
void customerStatusChanged(StaffIdentity actor, UUID customerId);
}
实现使用 MANDATORY,参与调用方已经开启的业务事务:
/** 审计通过公开契约参与业务事务,不形成跨模块表访问. */
@Service
@Transactional(propagation = Propagation.MANDATORY)
class StaffAuditService implements StaffAudit {
private final StaffAuthorization authorization;
private final AuditTrail audit;
StaffAuditService(StaffAuthorization authorization, AuditTrail audit) {
this.authorization = authorization;
this.audit = audit;
}
@Override
public void customerStatusChanged(StaffIdentity actor, UUID customerId) {
authorization.requireAdministrator(actor);
audit.record(AuditTrail.Action.CHANGE_CUSTOMER_STATUS, actor.employeeId(), customerId, true);
}
}
只允许固定动作 CHANGE_CUSTOMER_STATUS,记录管理员 UUID、顾客 UUID 和结果,不接受请求体、认证头或任意拼接文本。
sequenceDiagram
participant A as 管理员请求
participant C as CustomerAdministration
participant I as identity.api.StaffAudit
participant D as PostgreSQL
A->>C: enabled与当前version
C->>D: 锁顾客账号并校验版本
C->>D: 保存真实状态变化
C->>I: 登记固定安全动作
I->>D: 保存安全审计行
Note over C,D: 同一本地事务提交或回滚
C-->>A: 最新档案和版本
这里的原子性指数据库里的状态和审计记录。普通控制台日志不是数据库事务资源,不能宣称已经写出的日志会随回滚消失。
测试直接验证了回滚和应用层重新授权:
void statusAndAuditRollbackTogetherAndAuthorizationIsRechecked() {
transactions.executeWithoutResult(
tx -> {
administration.changeStatus(admin, customerId, false, 0);
tx.setRollbackOnly();
});
assertThat(customers.findById(customerId).orElseThrow().enabled()).isTrue();
assertThat(statusAudits()).isZero();
var forged = new StaffIdentity(staff.employeeId(), "staff", "员工", "ADMIN", 0);
assertThatThrownBy(() -> administration.changeStatus(forged, customerId, false, 0))
.isInstanceOf(IdentityException.class);
var stale = new StaffIdentity(admin.employeeId(), "admin", "管理员", "ADMIN", 1);
assertThatThrownBy(() -> administration.get(stale, customerId))
.isInstanceOf(IdentityException.class);
}
这比只断言“调用过一次 audit 方法”更接近真正的业务保证。
11. 管理员查看支付流水,不等于发起支付动作
支付和退款管理使用只读服务:
@Service
@Transactional(readOnly = true)
public class PaymentManagement {
// 仅依赖员工授权与本模块仓储。
}
支付列表的实际入口:
public TransactionPageView<ManagedPaymentView> payments(
StaffIdentity actor, Payment.Status status, TransactionSearch search) {
staff.requireAdministrator(actor);
var result = repository.searchPayments(status, search);
return new TransactionPageView<>(
result.items().stream().map(ManagedPaymentView::from).toList(),
search.page(),
search.size(),
result.totalElements(),
Math.ceilDiv(result.totalElements(), search.size()));
}
服务中没有 PaymentGateway,也没有在 GET 时隐式执行查单、重建支付意图或发起退款。readOnly=true 本身不是阻止外部副作用的防火墙,仍要通过职责、依赖和测试保证查询路径不执行业务写动作。
页面刷新应该观察已持久化事实,后台工作器继续按 P5 规则确认与恢复。如果以后需要管理员主动操作,应单独定义受控命令,不把副作用藏进一个查询请求。
11.1 业务引用不产生反向模块依赖
接口参数叫 orderId,但 payment 模块用自己的 businessRef 字段筛选。
它不需要导入 Order,也不直接读取订单表来补齐顾客手机号。相同 UUID 的业务含义通过契约约定,不通过跨模块 JPA 关联强行绑定。
11.2 支付查询与退款查询不能混淆时间和标识
| 条件 | 支付列表 | 退款列表 |
|---|---|---|
| paymentId | 当前支付 ID | 原支付 ID |
| status | PENDING / SUCCEEDED / CLOSED | PENDING / SUCCEEDED |
| from/to | 支付意图创建时间 | 退款意图创建时间 |
| orderId | 本模块 businessRef | 本模块 businessRef |
退款 PENDING 就展示处理中,不能因为能查到这条退款意图,便显示“已退款”。
11.3 诊断信息也要限制范围
管理响应提供渠道交易号、期限、付款/确认时间、nextAttemptAt、固定 lastFailure 和版本。
它不返回幂等键、请求摘要、App 签名参数、私钥或渠道异常正文。
nextAttemptAt 表示服务器安排的下一次处理机会,不是“到这个时刻必然退款完成”的倒计时。界面应该将它用于必要的诊断,而不是自己推演交易终态。
12. 安全审计查询有自己的范围,不是全业务操作录像
新增接口支持 action、actorId、subjectId、successful、from、to 和分页:
GET /api/v1/management/audit-events?action=LOGIN&successful=false&page=0&size=20
Authorization: Bearer <管理员令牌>
每条事实只返回:
record Entry(
UUID id,
AuditTrail.Action action,
UUID actorId,
UUID subjectId,
boolean successful,
Instant occurredAt) {}
action 使用已有安全事件枚举,包括登录、退出、员工管理、改密、授权拒绝、限流,以及本轮的顾客状态变更。
actorId 或 subjectId 可以为空。接口不凭空补造姓名、IP、设备和自由文本,也不提供修改/删除历史审计的能力。
这份查询展示的是 identity 实际登记的安全事实。它没有宣称已经完整记录所有商品编辑、门店变更或订单履约操作。
13. 索引应当服务真实查询,而不是见到筛选框就加一个
本次新增 V8,只增加管理检索索引,不重建表或清空数据。部分实际迁移如下:
CREATE INDEX ordering_management_history
ON ordering_order (created_at DESC, id DESC);
CREATE INDEX ordering_phone_history
ON ordering_order (phone, created_at DESC, id DESC);
CREATE INDEX customer_enabled_history
ON customer_account (enabled, created_at DESC, id DESC);
CREATE INDEX payment_status_history
ON payment_intent (status, created_at DESC, id DESC);
CREATE INDEX refund_business_history
ON payment_refund (business_ref, created_at DESC, id DESC);
CREATE INDEX identity_audit_actor_history
ON identity_audit (actor_id, occurred_at DESC, id DESC);
这些索引针对常见等值筛选与稳定时间排序。UUID 精确检索可以复用主键,唯一手机号也可以复用既有唯一索引。
一个复合索引不会自动优化所有 AND 组合,索引也会增加写入与维护成本。
名称包含查询当前没有引入额外扩展索引。以后应根据数据量和执行计划评估,而不是宣称普通 B-tree 对任意 %关键词% 都有同样效果。
14. 把页面可以依赖的接口列清楚
本轮新增的资源都位于 /api/v1 下:
| 方法与路径 | 主要条件或动作 |
|---|---|
GET /management/customers |
phone/name/enabled/from/to/page/size |
GET /management/customers/{id} |
最小顾客档案 |
PATCH /management/customers/{id}/status |
enabled/version |
GET /management/payments |
status/orderId/customerId/paymentId/from/to/page/size |
GET /management/payments/{id} |
持久化支付详情 |
GET /management/refunds |
status/orderId/customerId/paymentId/from/to/page/size |
GET /management/refunds/{id} |
持久化退款详情 |
GET /management/audit-events |
固定动作、主体、结果、发生时间与分页 |
原有订单列表增加组合条件,没有增加一个旧协议别名或万能状态修改入口。
分页统一返回 items/page/size/totalElements/totalPages。没有匹配和越过末页都可以返回空 items,但 totalElements 仍应表达真实匹配总量。
非法条件为 400,资源不存在为 404,旧版本为 409,沿用 RFC 9457、code 与 traceId。
这份契约能支撑未来页面,但对应 PC 管理页面并没有在这个后端提交中实现。
15. 测试必须检查协议之外的实际副作用
ManagementCapabilitiesIt 的重点包括:
| 场景 | 需要证明什么 |
|---|---|
| ADMIN、STAFF、顾客和匿名请求 | 安全链与资源角色正确分离 |
| 伪造角色和陈旧安全版本 | 应用层不能只相信输入快照 |
| 历史收货电话检索 | 不误查当前账号手机号 |
| 多条件与分页 | 数据库 AND 筛选,总量不是当前页数量 |
| from/to 边界 | 起点包含,终点排除 |
名称包含 %、_ |
使用字面匹配 |
| 同版本并发启停用 | 最多一个成功变化 |
| 状态事务回滚 | 数据库审计一同回滚 |
| 支付/退款 GET | 不调用渠道,不泄露凭证 |
| OpenAPI 输出 | 管理视图与顾客视图没有混用 |
支付列表测试在验证字段和分页之后,还会执行:
verifyNoInteractions(gateway);
它证明该测试查询路径没有偷偷发送渠道请求,不只是响应“看起来像只读”。
这一提交的阶段验收记录为:52 项单元/架构测试、103 项集成测试,共 155 项后端测试通过,没有失败、错误或跳过。本轮新增 11 项集成测试。
文章引用的是提交中的验收记录,没有因为撰写博客而重新运行真实数据库测试。
16. 这次补齐如何为 PC-1 铺路
这一步交付的不只是几个 Controller:
- 前端可以明确区分页面权限,不需要猜“已登录是不是都能看”。
- 列表筛选有数据库语义,不需要用当前页数据伪装全量搜索。
- 状态变化有版本与审计约束,页面不能自行判断“应该已经成功”。
- 资金查询只展示后端事实,刷新不会改变渠道交易。
- OpenAPI 可以提供明确的管理端类型基线。
读者练习
练习一:三个电话。 顾客账号手机号、地址簿电话和历史订单收货电话分别应该用于哪些查询?若顾客为家人下单,错误地混用会产生什么结果?
练习二:跨日筛选。 页面选择上海时间的某一天,如何构造左闭右开的 UTC 区间?这与 P6 的报表日期参数有什么不同?
练习三:无变化请求。 账号当前已启用,旧页面也提交 enabled=true。为什么仍应先检查 version?
练习四:只读支付列表。 如果产品希望提供“立即核对渠道状态”,应该新增什么授权和请求语义,而不是把查单塞进 GET?
练习五:文档模型。 两个模块各定义同名 Java record 时,怎样验证生成的 OpenAPI 没有错误复用 schema?前端静态类型是否能自行发现服务端文档已经错了?
下一篇将进入 PC-1:建立真实管理端工程,并把身份恢复、请求取消、权限和错误处理落到浏览器里。
延伸阅读
系列:Han Menu 外卖系统实践 · 从第一篇开始