qinghuan
← 黑马点评 / 全部笔记

单元 78 / 黑马点评

P0 · 必会主线L2 · 进阶

黑马点评:先画业务与数据导图

按用户的实际操作串起项目,再画清用户、商户、笔记和订单的关系,理解数据为什么这样保存。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能画出一个请求经过接口、数据库和 Redis 的数据流,并标出事实来源。

学习理由、准备要求与知识点

为什么学它

先确定业务事实和派生数据的边界,才能理解点评项目各模块为什么选择不同存储。

先读这些内容

知识点清单

  • 用户与商户
  • 评价与关注
  • 券与订单
  • 主外键关系
  • MySQL 业务事实
  • Redis 派生数据
  • 登录态
  • 接口数据流

运行前先确认以点评应用的通用教学设计说明业务与数据关系,不对应已检查的黑马点评源码。概念使用 MySQL 8.4 与 Redis 7.4,Java 源码固定 Spring Framework 6.2.11;完整练习为 Java 17。

01先弄懂它是什么

学习点评项目时,先沿着用户操作走一遍:登录、查商户、看探店、关注作者、领券、查订单。每一步写清输入是什么、谁在操作、要检查什么、哪些数据会变化,以及返回什么。例如查商户主要读数据,发布笔记会新增内容,买券则同时涉及用户、库存和订单唯一性。这样才能知道每个技术组件在解决什么问题。

再把用户、商户、笔记、关注关系、优惠券和订单分别列出来。笔记有作者并关联商户,券属于商户,订单关联买券用户和券;关注有方向,A 关注 B 不等于 B 关注 A。编号用来识别对象,字段记录状态,关系约束它们怎样联系。昵称重复可能允许,订单编号重复通常必须拒绝,要按含义定规则。

还要分清哪份数据记录业务发生了什么,哪份只是为了方便查询而算出来。数据库里的有效订单记录一次购买;商户订单数可以从订单汇总得到,Redis 详情、排名或地理索引则是整理出的查询数据,常称为投影。登录态不同,它还有自己的有效期,不能读一次用户表就恢复成原来的会话。只有原始事实、转换规则和需要恢复的范围都明确,才能说某份查询数据可以重建。

文中这几个词,先认一认

业务事实
业务已经发生、需要可靠保留的信息,例如订单金额、创建时间和有效状态。
查询投影
把原始事实整理成方便某种查询的数据,例如由订单汇总商户订单数。
不变量
每次合法操作后都必须成立的规则,例如一笔订单必须有对应的购买用户。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

先为一条下单链写关系图:会话恢复用户身份,业务层校验券状态,原子条件限制库存,订单记录保存购买者与成交信息,查询层返回最小 DTO。请求中的 userId 不应覆盖服务端确认的当前用户。订单列表除了登录检查,还需按当前用户限制结果;认证证明是谁,授权决定可以操作哪条记录,这两个边界都应出现在图中。

给每类数据记录唯一键、写入者、生命周期和恢复入口。用户手机号的唯一规则取决于业务约定,用户加优惠券的组合唯一约束适用于规定一人一单的教学场景。关系存在可以由外键或等价业务校验保护,但库存下界、状态流转和多表提交还需要各自机制。画一个数据库方框并不会自动让所有操作拥有同一事务。

下面先用纯内存事实重建商户订单统计。程序验证用户、商户、券与订单关系,再聚合成交金额。成交价保存在订单中,不能回头用优惠券当前价格重新计算历史成交。这个练习把表之间的连线变成失败时可观察的检查:缺少关联对象、重复订单或违反一人一券规则都必须有明确结果,而不是静默跳过。

正在加载流程图…

事实之间的关系决定约束;派生查询视图需要可复现的转换规则。
查看流程图源码(Mermaid)
flowchart TD
U["用户事实"] --> N["探店笔记"]
S["商户事实"] --> N
S --> V["优惠券"]
U --> O["订单及成交金额"]
V --> O
O --> P["商户订单统计投影"]
U --> L["登录后生成有有效期的会话"]

按顺序看,每一步做了什么

  1. 选择一个用户动作,写清输入、身份与返回字段。
  2. 列出事实实体及其主键、关联和业务唯一性。
  3. 将数据库事实、会话和查询投影标为不同生命周期。
  4. 给每个跨系统写入画出失败位置、补偿与重建入口。

03对照实现代码

BeanUtils 属性复制不等于业务投影与授权 (新标签页)↗

Spring Framework v6.2.11 · BeanUtils#copyProperties(Object,Object,Class,String...);BeanUtils#isAssignable

以下是原创显式 DTO 映射,用于对照 BeanUtils 的通用属性复制边界;不是黑马点评实际源码,也不是 BeanUtils 源码节选。

核心机制代码 · java
import java.util.Objects;

class SourceModel {
  record User(long id, String nickName, String icon, String phone, String passwordHash) {}
  record UserDTO(long id, String nickName, String icon) {}
  static UserDTO toDTO(User entity) {
    Objects.requireNonNull(entity); // 输入实体存在并不代表请求已获得授权
    if (entity.id() <= 0) throw new IllegalArgumentException("id");
    return new UserDTO(entity.id(), entity.nickName(), entity.icon());
    // 显式白名单只输出所需字段,不把手机号与密码摘要传给页面
  }
  static UserDTO ownProfile(long authenticatedUserId, User entity) {
    if (authenticatedUserId != entity.id()) throw new SecurityException("forbidden");
    return toDTO(entity); // 对象级授权在映射前完成,映射工具不能替代它
  }
}

真实 copyProperties 遍历目标属性描述符,寻找同名可读源属性与可写目标属性,并检查类型可赋值性后调用方法。

复制属性工具不会自动检查订单属于谁、当前用户是谁,或某字段是否敏感。这些允许复制与返回的规则,要由业务接口明确规定。

源对象多出的属性可能被忽略,类型不兼容也不会自动变成所需业务转换。String 编号到 Long 应显式解析并验证。

教学使用不可变 record 和构造器表达白名单,不依赖反射 setter;这减少字段边界的隐式行为,但仍要维护映射与测试。

代码先调用谁,再调用谁

  1. 服务端确认当前身份
  2. 查询业务事实
  3. 校验对象归属与状态
  4. 显式映射最小 DTO
  5. 序列化响应

04动手写一个例子

验证事实关系并重建商户订单投影

给定用户、商户、券和已成交订单,验证主键、关联与一人一券规则,再返回每家商户的订单数和成交金额。金额为分,历史成交额取订单字段;本题不覆盖退款、取消和库存预占。

输入 1
商户10的券100;两位用户分别成交90分、80分
输出 1
商户10:订单2,金额170分
输入 2
订单用户不存在
输出 2
拒绝关系缺失,不生成统计

为各实体构建按编号查找的映射,重复编号或非法编号立即拒绝,使后续关系查询拥有唯一目标。

订单逐条检查用户、券、商户一致性以及用户券组合唯一性,用结构化 Pair 防止字符串拼接碰撞。

通过校验的订单按商户累加,金额取成交快照并使用精确加法检测溢出;无订单商户仍输出零统计,避免报表漏项。

为什么这样写是对的每个被统计订单都有唯一合法身份与完整关联,恰好归入一家商户;每个用户券组合最多出现一次。

时间 平均 O(u+s+v+o)额外空间 O(u+s+v+o)

各输入集合扫描一次,关联和去重平均哈希查询为常数;索引、组合去重与结果空间随数据量增长。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 63 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
import java.util.function.ToLongFunction;

public class Main {
  record User(long id) {}
  record Shop(long id) {}
  record Voucher(long id, long shopId, long currentPrice) {}
  record Order(long id, long userId, long voucherId, long shopId, long paidCents) {}
  record Pair(long userId, long voucherId) {}
  record Stats(long count, long paidCents) {}
  static <T> Map<Long,T> index(List<T> rows, ToLongFunction<T> id) {
    Map<Long,T> map = new HashMap<>();
    for (T row : rows) {
      long key = id.applyAsLong(row);
      if (key <= 0 || map.putIfAbsent(key,row) != null) throw new IllegalArgumentException("id");
    }
    return map;
  }
  static Map<Long,Stats> rebuild(List<User> users, List<Shop> shops,
      List<Voucher> vouchers, List<Order> orders) {
    Map<Long,User> us = index(users,User::id);
    Map<Long,Shop> ss = index(shops,Shop::id);
    Map<Long,Voucher> vs = index(vouchers,Voucher::id);
    index(orders,Order::id); // 先校验订单主键,不能由聚合掩盖重复事实
    for (Voucher v : vouchers)
      if (!ss.containsKey(v.shopId()) || v.currentPrice()<0) throw new IllegalArgumentException("voucher");
    Set<Pair> unique = new HashSet<>();
    Map<Long,Stats> result = new HashMap<>();
    ss.keySet().forEach(id -> result.put(id,new Stats(0,0))); // 零订单商户也有查询投影
    for (Order o : orders) {
      Voucher v = vs.get(o.voucherId());
      if (!us.containsKey(o.userId()) || v==null || v.shopId()!=o.shopId() || o.paidCents()<0)
        throw new IllegalArgumentException("relation");
      if (!unique.add(new Pair(o.userId(),o.voucherId())))
        throw new IllegalArgumentException("one order per user and voucher");
      Stats old = result.get(o.shopId());
      result.put(o.shopId(),new Stats(Math.addExact(old.count(),1),
          Math.addExact(old.paidCents(),o.paidCents()))); // 成交快照不随当前券价变化
    }
    return Map.copyOf(result); // 只有全部输入通过后才发布结果
  }
  static void check(boolean ok) { if(!ok) throw new AssertionError(); }
  static void bad(Runnable action) {
    try { action.run(); } catch (IllegalArgumentException expected) { return; }
    throw new AssertionError("expected rejection");
  }
  public static void main(String[] args) {
    List<User> u=List.of(new User(1),new User(2));
    List<Shop> s=List.of(new Shop(10),new Shop(20));
    List<Voucher> v=List.of(new Voucher(100,10,999));
    List<Order> o=List.of(new Order(1,1,100,10,90),new Order(2,2,100,10,80));
    Map<Long,Stats> r=rebuild(u,s,v,o);
    check(r.get(10L).equals(new Stats(2,170)));
    check(r.get(20L).equals(new Stats(0,0)));
    bad(() -> rebuild(u,s,v,List.of(new Order(1,3,100,10,90))));
    bad(() -> rebuild(u,s,v,List.of(new Order(1,1,100,20,90))));
    bad(() -> rebuild(u,s,v,List.of(o.get(0),new Order(2,1,100,10,80))));
    bad(() -> rebuild(u,s,v,List.of(o.get(0),o.get(0))));
    bad(() -> rebuild(u,s,List.of(new Voucher(100,30,10)),List.of()));
    check(rebuild(u,s,v,List.of()).get(10L).count()==0);
    System.out.println("8 business projection checks passed");
  }
}

这些特殊情况也要试一试

  • 一人一券是本题业务规则,不是所有优惠券产品的普遍限制。
  • 商户和券归属假定稳定;真实迁移和历史归属需要保存快照或版本规则。
  • 退款与取消改变统计口径,不能直接把全部订单都当成已成交订单。
进阶选读 · 生产拓展:可重放的增量投影

场景订单量增长后不能每次从全部事实重建商户统计。

做法用带稳定事件编号的成交与退款事件增量更新投影,保存已处理位置并支持幂等;周期性按一致事实快照全量对账,发现差异可重建替换。

复杂度单事件平均 O(1) 更新;完整对账 O(o),存储成本取决于事件去重与保留窗口。

好处与代价读取更快,但事件顺序、重复、漏投与版本升级都需要恢复协议,不能只维护一个自增计数。

05项目里怎么用

业务导图要能回答失败之后怎么办。数据库提交成功而缓存删除失败,需要失效补偿;笔记已经发布而粉丝投影失败,需要重建或重投;验证码已消费但用户创建失败,需要重新验证或设计受控的后续步骤。这些流程的事实写入点不同,不能把任何异常都归为一次简单回滚。

先把数据模型说清,再决定是否拆成多个系统。同一个本地数据库事务能保护相关修改,但 Redis、消息和数据库不会自动共用它。能重建的查询副本,可以允许短暂延迟并安排恢复;不可丢的业务事实,则要先可靠保存,再异步生成查询数据,并让调用方能查到结果。

验收要覆盖无数据、关联缺失、重复请求、敏感字段和用户权限。字段名正确,不表示查的就是当前用户的数据;DTO 精简,也不表示订单存在或金额正确。把业务关系写成可运行检查,以后改缓存或并发实现时,才能确认原本规则仍成立。

容易踩的坑

  • 把登录态、缓存、待处理事件都称为可随意删除的缓存。
  • 用券的当前价格重算历史成交金额。
  • 将数据库实体整对象直接作为对外接口或会话内容。
  • 只画实体连线,不写唯一性、写入者和失败恢复责任。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
直接查询事实表链路简单且便于核对复杂聚合可能较慢规模较小或一致性要求明确的入口
维护查询投影可适配排序、计数和检索需要同步、对账与重建高频读取和可接受延迟的视图
显式 DTO 映射字段与类型边界清晰需要维护映射代码会话和对外接口

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么订单计数可以重建而登录令牌不能从用户表直接恢复?

展开参考答案与推导

计数有订单事实与聚合规则;有效令牌还包含独立的发行与有效期状态,不能从用户存在推出凭证有效。

02认证与授权在订单链路中分别做什么?

展开参考答案与推导

认证确认当前用户,授权限制其能读取或修改哪些订单。

03BeanUtils 能自动处理敏感字段吗?

展开参考答案与推导

不能。它按属性与类型复制,敏感性和业务权限必须由 DTO 契约及授权逻辑确定。

换个情况,还会处理吗

01优惠券改价后历史报表总金额变化,哪里可能错了?

展开参考答案与推导

报表可能使用当前券价而非订单成交快照,应按历史金额和状态口径统计。

02Redis 清空后要恢复什么?

展开参考答案与推导

先区分可由事实重建的投影、需要重新登录的会话,以及是否另有可靠记录的未处理事件。

03数据库已提交而投影失败,能直接宣称订单失败吗?

展开参考答案与推导

不能。事实可能已经成功,需要查询结果并补偿投影,避免用户重试造成重复业务。

写代码试一试

01两个用户成交90分与80分,当前券价999分,汇总多少?

输入 / 条件
90、80、当前999
输出 / 预期
170分

提示 取成交快照。

展开参考答案与推导

当前价格不参与历史合计,计数为2。

02同一用户同一券两条不同订单编号是否通过?

输入 / 条件
一人一券规则
输出 / 预期
拒绝组合重复

提示 主键唯一与业务唯一分开。

展开参考答案与推导

订单编号不同只满足主键约束,仍违反用户券组合唯一性。

03无订单商户应从统计中消失吗?

输入 / 条件
商户存在,订单为空
输出 / 预期
订单0,金额0

提示 先初始化投影。

展开参考答案与推导

本题保留所有商户,以零值区别不存在商户与没有成交。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能画出一个请求经过接口、数据库和 Redis 的数据流,并标出事实来源。

本篇重点加练

主外键只能表达部分关系,可靠业务事实还需明确发生时间、修改责任和追溯口径。

  • 主外键关系
  • MySQL 业务事实

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    点评站允许商户停业、用户注销以及撤回评价。分别定义这三种动作对历史评价和关注关系的影响,指出尚需产品确认的规则。

    提交内容
    实体关系图与规则清单
    怎样算完成
    • 区分实体身份和展示字段
    • 覆盖不存在及已注销主体

    练到的知识点用户与商户 · 评价与关注

  2. 作业 02设计评审

    同一商户可发行多种券,一张券允许分批发售。评审“订单只存商户名称和购买金额”的表设计,并提出能追溯购买对象的字段约定。

    提交内容
    字段字典与关联约束表
    怎样算完成
    • 说明各字段事实含义
    • 包含改名和停售场景

    练到的知识点券与订单 · 主外键关系

  3. 作业 03代码推演

    画出匿名查看商户、登录发布评价、登录查看订单三条请求链。请求体都允许携带 userId 时,逐一标明该字段在哪一步被解释。

    提交内容
    三张数据流图
    怎样算完成
    • 标出身份来源和资源归属检查
    • 区分缓存读取与持久写入

    练到的知识点登录态 · 接口数据流

  4. 作业 04原理问答

    同一个用户可以先发布评价、后补录订单。分别定义评价关联订单是强制关系还是可选证据,说明数据库约束能检验和不能检验的业务真实性。

    提交内容
    关系约束与证据表
    怎样算完成
    • 区分关联存在和事件确实发生
    • 标明规则来自技术还是产品要求

    练到的知识点主外键关系 · MySQL 业务事实

综合应用

  1. 作业 05面试场景

    数据库有退款记录,Redis 商户成交榜只按下单次数累加。运营要求昨日净成交额,给出核对口径与数据来源判定。

    提交内容
    口径说明与对账样本
    怎样算完成
    • 区分事实与派生结果
    • 包含退款跨日和重复事件

    练到的知识点MySQL 业务事实 · Redis 派生数据

  2. 作业 06编程实践

    编写本地 Java 校验器读取小型商户、券、订单、退款数据,生成异常清单与商户日统计;统计口径自行声明,禁止静默吞掉异常记录。

    提交内容
    源码、输入数据和运行记录
    怎样算完成
    • 覆盖孤立关联、重复编号和金额边界
    • 说明异常记录是否影响整个批次

    练到的知识点用户与商户 · 券与订单 · 主外键关系 · MySQL 业务事实 · Redis 派生数据

  3. 作业 07代码推演

    两次导入都使用从 1 开始的商户局部编号,随后订单文件混合到一起。构造最小样本,判断仅验证编号存在会遗漏什么信息。

    提交内容
    数据样本与关联判定记录
    怎样算完成
    • 区分编号域和全局身份
    • 记录每条关联的来源

    练到的知识点主外键关系

  4. 作业 08面试场景

    客服误改已成交订单金额,现有日志只保存最终行内容。制定事实追溯需求,并评审能否从当前表恢复修改前状态。

    提交内容
    信息缺口及审计需求清单
    怎样算完成
    • 已知与无法确定分开
    • 明确谁能修改何种事实

    练到的知识点MySQL 业务事实

  5. 作业 09编程实践

    写本地 Java 数据血缘检查器:读取接口字段到表字段的声明,列出无来源字段、环形依赖和引用未知事实的字段;输入只用少量虚构数据。

    提交内容
    程序、依赖文件与检查记录
    怎样算完成
    • 检查结果能定位字段路径
    • 声明静态声明无法证明真实写入行为

    练到的知识点主外键关系 · MySQL 业务事实

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    新增“商户合并”功能:两家商户合并展示,但历史评价与订单需要可追溯。给出迁移前后读写规则以及中断后的核验方案。

    提交内容
    变更方案与验收矩阵
    怎样算完成
    • 覆盖迁移期间请求
    • 区分历史事实和新展示投影

    练到的知识点评价与关注 · MySQL 业务事实 · Redis 派生数据 · 接口数据流

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    把“数据库中有订单就证明交易发生”的说法作为待审查命题,构造至少两种来源不同的反例,并说明还需要何种外部事实才能排除它们。

    提交内容
    反例与证据需求
    怎样算完成
    • 反例改变证据条件而非仅改数值
    • 不预设外部证据永远可信

    练到的知识点主外键关系 · MySQL 业务事实

  2. 作业 12思维拓展 · 跨域迁移

    将点评系统的事实与投影划分迁移到实验室样本管理:样本会拆分、合并并撤回检测结果。哪些关系和追溯规则需要重新定义?

    提交内容
    跨域模型与迁移说明
    怎样算完成
    • 包含不可逆实验动作
    • 逐项说明沿用和新增的约束

    练到的知识点主外键关系 · MySQL 业务事实

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗