我如何在 10 天内构建全栈书店 App(以及我学到的)
Source: Dev.to
The Challenge
十天。这就是我在亚马逊 ATLAS 培训项目中,需要从零构建一个可投入生产的在线书店的时间。
最初的需求看起来很直接:用户应该能够浏览图书、将其加入购物车并完成结算。但随后出现的“可有可无”功能,很快在我脑中变成了必须实现的需求:
- 个性化推荐
- 浏览历史
- 带有批量操作的管理后台
- 受亚马逊 ASIN 启发的库存系统
下面是我在构建过程中学到的经验——以及差点把我逼疯的 bug。
The Tech Stack
后端
- Java 17 + Spring Boot 3
- DynamoDB(开发时使用 Local)
- Maven
前端
- React 18 + Vite
- Material‑UI v5
- React Router v6
DevOps
- GitHub Actions 用于 CI/CD
- Docker 用于 DynamoDB Local
为什么选这套技术栈?Spring Boot 让我的开发速度飞快,并且自带依赖注入。DynamoDB 强迫我采用 NoSQL 思维(不允许 JOIN!)。React + MUI 则让我把精力放在功能实现上,而不是 CSS 战争。
Challenge #1: The Duplicate Books Nightmare
The Problem
第 3 天。管理后台已经可以使用,批量上传功能也能正常工作,我可以从 CSV 文件导入 100 本书。
再次运行导入时出现了重复条目:
The Hobbit (ID: a1b2c3d4)
The Hobbit (ID: e5f6g7h8) // 同一本书,不同 ID!
The Hobbit (ID: i9j0k1l2) // 已经跑了三遍...
罪魁祸首?随机 UUID。
// The problematic code
public void saveBook(Book book) {
if (book.getId() == null) {
book.setId(UUID.randomUUID().toString()); // Different every time!
}
bookTable.putItem(book);
}
The Solution: Deterministic IDs
我需要相同的图书始终获得相同的 ID。解决方案是对图书的自然键(标题 + 作者)进行哈希。
public class DeterministicId {
public static String forBook(String title, String author) {
String input = (title + "|" + author).toLowerCase().trim();
byte[] hash = sha1(input);
return "b-" + toHex(hash).substring(0, 12);
}
private static byte[] sha1(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
return md.digest(input.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
现在 “The Hobbit” by “J.R.R. Tolkien” 总是生成 b-7a3f2e1d9c8b。同一个 CSV 导入 100 次?仍然只有一份。
关键收获: 当你需要幂等操作时,使用基于业务键的确定性标识符,而不是随机 UUID。
Challenge #2: Building an Amazon‑Style ASIN System
The Implementation
public class AsinGenerator {
private static final String CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
public static String generateFromBook(String title, String author) {
String input = (title + "|" + author).toLowerCase().trim();
byte[] hash = sha256(input);
StringBuilder sb = new StringBuilder("B0"); // Amazon format
for (int i = 0; i < 10; i++) {
int idx = (hash[i] & 0xFF) % CHARS.length();
sb.append(CHARS.charAt(idx));
}
return sb.toString();
}
private static byte[] sha256(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return md.digest(input.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
Browsing History (Related Feature)
public class HistoryService {
private static final int MAX_CAPACITY = 20;
public void addToHistory(String username, String bookId) {
Deque<String> history = getHistory(username);
// Remove if already exists (user viewed it before)
history.remove(bookId);
// Add to front (most recent)
history.addFirst(bookId);
// Maintain capacity
while (history.size() > MAX_CAPACITY) {
history.removeLast();
}
saveHistory(username, history);
}
}
在 addFirst(bookId) 之前调用 remove(bookId) 处理了用户连续查看同一本书的边缘情况——它会被移动到最前,而不是出现两次。
Challenge #4: DynamoDB Said “No” to Empty Strings
The Problem
部署时抛出了异常:
DynamoDbException: The AttributeValue for a key attribute
cannot contain an empty string value.
我的 React 前端为 id 发送了空字符串:
const book = {
id: '', // Empty string, NOT null
title: 'New Book',
author: 'Some Author'
};
DynamoDB 的分区键不能是空字符串。null 或 undefined 是可以接受的。
The Solution: Defense in Depth
前端
const bookToUpload = {
id: book.id?.trim() || undefined, // Convert empty to undefined
title: book.title,
author: book.author,
};
后端
@PostMapping
public ResponseEntity createBook(@RequestBody Book book) {
if (book.getId() == null || book.getId().isBlank()) {
book.setId(DeterministicId.forBook(
book.getTitle(),
book.getAuthor()
));
}
adapter.save(book);
return ResponseEntity.created(uri).build();
}
关键收获: 始终在 API 边界进行校验。某种语言中看似 “null” 的值,在另一种语言里可能是空字符串。
Challenge #5: Book Covers Were Getting Cropped
The Problem
使用 OpenLibrary 的封面 API:
const coverUrl = `https://covers.openlibrary.org/b/isbn/${isbn}-L.jpg`;
我的 CSS 强制裁剪:
img {
width: 100%;
height: 300px;
object-fit: cover; /* Fills space, crops excess */
}
The Solution
亚马逊展示的是带有内边距的完整商品图。我改为在样式容器内部使用 object-fit: contain:
<img
src={coverUrl}
alt={title}
style={{ width: '100%', height: '300px', objectFit: 'contain' }}
onError={() => setShowFallback(true)}
/>
对于没有 ISBN 的图书,我创建了渐变占位图(实现细节此处略)。