我如何在 10 天内构建全栈书店 App(以及我学到的)

发布: (2025年12月10日 GMT+8 17:56)
5 min read
原文: Dev.to

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);
        }
    }
}
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 的分区键不能是空字符串。nullundefined 是可以接受的。

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 的图书,我创建了渐变占位图(实现细节此处略)。

Back to Blog

相关文章

阅读更多 »