系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第7篇 · 下一篇:第9篇

系列: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 时刻,采用左闭右开区间:

from\le t<to

相邻区间可以拼接而不重复边界上的记录:

第一段:[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 外卖系统实践 · 从第一篇开始

上一篇:第7篇 · 下一篇:第9篇