📬 HỆ THỐNG HỘP THƯ IN-GAME — GAME DESIGN DOCUMENT

Project: NRO Server
Version: 1.0.0
Ngày tạo: 2026-03-11
Trạng thái: Draft → Review
Tech stack: Java + Spring + MongoDB (CacheManager pattern)


MỤC LỤC

# Phần Nội dung
1 Game Design Feature Overview, Entity, Behavior, Edge Cases
2 Database Design MongoDB Schema, Indexing, Broadcast Optimization
3 Architecture Service Design, Sequence Diagrams
4 Ops & QA Logging, Edge Cases chi tiết, QA Test Plan

PHẦN 1 — GAME DESIGN


1. Feature Overview

Hộp thư in-game hỗ trợ 2 chiều giao tiếp:

Player A  ──mail──►  Player B       (P2P mail)
System    ──mail──►  Player         (System mail: event, compensation)

Các flow hỗ trợ

Chiều Hỗ trợ Use case
Player → Player Gửi quà, gửi tin nhắn kèm item
System → Player Event reward, compensation, daily login
Player → System Không hỗ trợ

Mục đích

  1. Player gửi quà cho bạn bè (items, gold)
  2. System phân phối phần thưởng (event, daily, compensation)
  3. Thay thế inject trực tiếp vào inventory — player chủ động claim
  4. Audit trail — mọi transfer đều có log

2. Design Goals

# Mục tiêu Mô tả
G1 No item loss Mail KHÔNG ĐƯỢC mất. Pending mail + retry
G2 Idempotent claim Claim 2 lần → không duplicate item
G3 Simple Ít bảng, ít logic phức tạp, dễ maintain
G4 Consistent Dùng pattern giống PrivateChatManager (CacheManager + MongoDB)
G5 Scalable Broadcast không tạo N rows

3. Player Experience

3.1 Flow gửi thư P2P

Player A mở giao diện gửi thư
  └─► Chọn Player B (nhập tên hoặc UID)
  └─► Nhập tiêu đề (tùy chọn)
  └─► Nhập nội dung
  └─► Đính kèm items từ inventory (tùy chọn, max 5 slots)
  └─► Xác nhận gửi
  └─► Items bị trừ khỏi inventory Player A
  └─► Mail xuất hiện trong mailbox Player B
  └─► Nếu Player B online → push notification

3.2 Flow nhận thư

Player mở hộp thư
  └─► Danh sách thư, mới nhất trước (created_at DESC)
  └─► Tap thư → đánh dấu is_read = true
  └─► Nếu có attachment → nút "Nhận"
  └─► Tap "Nhận" → items vào inventory
  └─► Nút "Nhận tất cả" → claim toàn bộ thư có attachment

3.3 UI Indicators

Trạng thái Icon Red Dot
Thư chưa đọc + có attachment 📩 + 🎁
Thư chưa đọc + không attachment 📩
Đã đọc + attachment chưa claim 📧 + 🎁
Đã đọc + đã claim (hoặc ko có) 📧
// Red dot logic
boolean showRedDot = mailbox.stream().anyMatch(m -> 
    !m.isRead() || (m.hasAttachments() && !m.isClaimed())
);

3.4 Cảnh báo hết hạn

Khi expires_at - now() ≤ 72 giờ → label "Sắp hết hạn" màu đỏ.


4. Mail Entity Specification

4.1 Schema

Field Type Default Mô tả
id String UUID ID duy nhất (MongoDB _id)
senderUid long UID người gửi (-1 = System)
senderName String Tên hiển thị người gửi
recipientUid long UID người nhận (-1 = broadcast)
title String "" Tối đa 60 ký tự
content String "" Tối đa 500 ký tự, plain text
attachments String null Format: itemId_bindStatus_num;...
mailType int 0 Enum loại thư
isRead boolean false Đã đọc chưa
isClaimed boolean false Đã nhận hết attachment
createdAt long Server timestamp (ms)
expiresAt long Default: createdAt + 30 ngày

4.2 Attachment Format

Format:  itemId_bindStatus_num;itemId_bindStatus_num;...
Example: 1001_1_5;2002_0_1

  1001_1_5  → Item ID 1001, Bound (1), số lượng 5
  2002_0_1  → Item ID 2002, Unbound (0), số lượng 1
Field Giá trị
itemId ID item trong template
bindStatus 0 = Unbound, 1 = Bound
num Số lượng ≥ 1

Rule: Max 5 slots cho P2P mail, max 10 slots cho System mail.


5. Mail Type

Enum Value Sender Mô tả
PLAYER 0 Player Player gửi cho Player
SYSTEM 1 System Hệ thống tự động
EVENT 2 System Event reward
GM 3 System GM compensation

Quy tắc theo type

Type Ai gửi Có attachment Expire
PLAYER Player Tùy chọn (max 5) 7 ngày
SYSTEM Server code Tùy chọn (max 10) 30 ngày
EVENT Event system Thường có Custom
GM GM command Thường có 30 ngày

6. Mailbox Behavior

6.1 Giới hạn

Thuộc tính Giá trị
Dung lượng tối đa 50 thư
Thứ tự createdAt DESC
Cảnh báo hết hạn expiresAt - now ≤ 72h

6.2 State Machine

[Diagram]

6.3 P2P Send Rules

Rule Mô tả
Không gửi cho chính mình senderUid == recipientUid → reject
Recipient phải tồn tại Check player exists trong DB
Items trừ trước Trừ items khỏi inventory sender TRƯỚC khi tạo mail
Recipient mailbox full → Lưu vào pending_mail, retry khi login
Cooldown Tùy chọn: 1 phút giữa các lần gửi P2P

7. Expiry Handling

Scenario Hành động
Mail hết hạn, chưa claim Xóa mail + log
Mail hết hạn, đã claim Xóa mail record, items đã ở inventory
Pending mail hết hạn Discard + log
P2P mail expire (chưa claim) Items mất (sender đã mất items khi gửi)

Expiry Worker (Cron)

Schedule: Mỗi 5 phút
Logic:
  1. Tìm mail có expiresAt <= now()
  2. Log nếu có attachment chưa claim
  3. Delete mail
  4. Tìm + delete pending_mail đã expire

8. Pending Mail Logic

8.1 Khi nào vào pending?

int currentCount = countMailByRecipient(recipientUid);
if (currentCount >= 50) {
    saveToPendingMail(mail);  // chờ deliver sau
} else {
    saveToMail(mail);         // deliver ngay
}

8.2 Delivery Retry

Trigger: Player login
Flow:
  1. Query pending_mail WHERE recipientUid = ? ORDER BY createdAt ASC
  2. Loop:
     - Nếu mailbox < 50 → move pending → mail
     - Else → dừng, chờ login tiếp
  3. Log mỗi delivery

9. Broadcast Mail

Broadcast (recipientUid = -1) dùng lazy materialization:

Gửi broadcast:
  → INSERT 1 record vào mail_broadcast collection

Player mở mailbox:
  → Query mail_broadcast (chưa expire)
  → LEFT JOIN mail_broadcast_read (trạng thái per-player)
  → Merge với mail cá nhân → hiển thị unified

Player claim broadcast:
  → UPSERT mail_broadcast_read (isClaimed = true)
  → Add items vào inventory

1M player broadcast = 1 document, không phải 1M documents.


10. Edge Cases

# Case Xử lý Priority
E1 Mailbox full pending_mail, retry khi login P0
E2 Double claim Idempotent: check isClaimed P0
E3 Player gửi thư cho player offline Mail nằm trong DB, hiển thị khi login P0
E4 P2P gửi nhưng recipient không tồn tại Validate trước, reject + không trừ items P0
E5 Sender inventory thiếu items Validate trước, reject gửi P0
E6 Claim khi inventory full Reject claim, thông báo "Hành trang đầy" P0
E7 Mail expire khi player đang đọc Server validate expiresAt khi claim P1
E8 Broadcast 1M+ Lazy materialization, 1 document P0
E9 Invalid itemId trong attachment Skip item, log warning P1
E10 Server crash giữa lúc claim Transaction: rollback → mail vẫn unclaimed P0
E11 P2P spam Cooldown 1 phút, max 20 thư gửi/ngày P1
E12 Pending overflow Max 100 pending/player, discard oldest P1


PHẦN 2 — DATABASE DESIGN (MongoDB)


1. Tổng quan Collections

Collection Mục đích Volume
player_mail Thư cá nhân (P2P + System targeted) N mails/player, max 50 active
mail_broadcast Template broadcast, 1 doc cho tất cả Ít (vài chục/tháng)
mail_broadcast_read Tracking read/claim per player cho broadcast Sparse, lazy create
pending_mail Thư chờ deliver (mailbox full) Tạm thời, auto cleanup

2. Schema Chi Tiết

2.1 Collection player_mail

@Document(collection = "player_mail")
public class PlayerMail {

    @Id
    private String id;            // UUID string

    private long senderUid;       // UID người gửi (-1 = System)
    private String senderName;    // Tên hiển thị
    private long recipientUid;    // UID người nhận
    private String title;         // Max 60 chars
    private String content;       // Max 500 chars
    private String attachments;   // "itemId_bind_num;..." (nullable)
    private int mailType;         // 0=PLAYER, 1=SYSTEM, 2=EVENT, 3=GM
    private boolean isRead;       // default false
    private boolean isClaimed;    // default false
    private long createdAt;       // System.currentTimeMillis()
    private long expiresAt;       // createdAt + duration
}

Indexes:

Index Fields Mục đích
idx_recipient_created {recipientUid: 1, createdAt: -1} Load mailbox sorted
idx_expires {expiresAt: 1} Expiry worker batch
idx_recipient_unread {recipientUid: 1, isRead: 1, isClaimed: 1} Red dot check nhanh
// MongoDB index creation
db.player_mail.createIndex({recipientUid: 1, createdAt: -1})
db.player_mail.createIndex({expiresAt: 1})
db.player_mail.createIndex({recipientUid: 1, isRead: 1, isClaimed: 1})

2.2 Collection mail_broadcast

@Document(collection = "mail_broadcast")
public class MailBroadcast {

    @Id
    private String id;            // UUID
    private String title;
    private String content;
    private String attachments;   // nullable
    private int mailType;         // 2=EVENT, 3=GM
    private String senderName;    // "Hệ Thống"
    private long createdAt;
    private long expiresAt;
}

Indexes:

db.mail_broadcast.createIndex({expiresAt: 1})

2.3 Collection mail_broadcast_read

Chỉ tạo document khi player đọc/claim broadcast. Không tồn tại = chưa đọc.

@Document(collection = "mail_broadcast_read")
public class MailBroadcastRead {

    @Id
    private String id;            // "{broadcastId}_{recipientUid}"
    private String broadcastId;
    private long recipientUid;
    private boolean isRead;       // default false
    private boolean isClaimed;    // default false
    private long readAt;          // 0 nếu chưa đọc
    private long claimedAt;       // 0 nếu chưa claim
}

Indexes:

db.mail_broadcast_read.createIndex({recipientUid: 1})
db.mail_broadcast_read.createIndex({broadcastId: 1})

2.4 Collection pending_mail

Giống player_mail + thêm retryCount. Tạm chứa mail khi mailbox đầy.

@Document(collection = "pending_mail")
public class PendingMail {

    @Id
    private String id;            // UUID
    private long senderUid;
    private String senderName;
    private long recipientUid;
    private String title;
    private String content;
    private String attachments;
    private int mailType;
    private long createdAt;
    private long expiresAt;
    private int retryCount;       // default 0
}

Indexes:

db.pending_mail.createIndex({recipientUid: 1, createdAt: 1})
db.pending_mail.createIndex({expiresAt: 1})

3. Indexing Strategy

Hot Path Analysis

Operation Query pattern Index dùng
Load mailbox recipientUid = ? ORDER BY createdAt DESC LIMIT 50 idx_recipient_created
Red dot check recipientUid = ? AND (isRead = false OR isClaimed = false) idx_recipient_unread
Claim mail _id = ? PK
Mark read _id = ? PK
Expiry batch expiresAt <= now() idx_expires
Pending delivery recipientUid = ? ORDER BY createdAt ASC idx_recipient_created on pending_mail
Broadcast active expiresAt > now() idx_expires on mail_broadcast

4. Mailbox Query (Merged View)

// Pseudo code cho load mailbox
public List<MailView> getMailbox(long playerUid) {
    // 1. Targeted mail
    List<PlayerMail> targeted = cacheManager.findAll(
        PlayerMail.class, "recipientUid", playerUid
    ); // filtered: expiresAt > now, sorted createdAt DESC

    // 2. Active broadcasts
    List<MailBroadcast> broadcasts = findActiveBroadcasts();

    // 3. Broadcast read status cho player này
    List<MailBroadcastRead> reads = cacheManager.findAll(
        MailBroadcastRead.class, "recipientUid", playerUid
    );
    Map<String, MailBroadcastRead> readMap = toMap(reads);

    // 4. Merge + sort + limit 50
    List<MailView> merged = new ArrayList<>();
    for (PlayerMail m : targeted) {
        if (m.getExpiresAt() > now) merged.add(toView(m));
    }
    for (MailBroadcast b : broadcasts) {
        MailBroadcastRead r = readMap.get(b.getId());
        merged.add(toView(b, r)); // r = null means unread
    }
    merged.sort(Comparator.comparingLong(MailView::getCreatedAt).reversed());
    return merged.subList(0, Math.min(50, merged.size()));
}

5. Broadcast Optimization

Approach 1M player broadcast Storage
❌ Eager (1M docs) 1,000,000 documents ~200MB+
Lazy (1 doc + read tracking) 1 doc + N read docs (sparse) ~1KB + sparse

Chỉ tạo mail_broadcast_read khi player thực sự đọc/claim.
1M player nhưng chỉ 500K online claim → 500K read docs thay vì 1M mail docs.



PHẦN 3 — SERVICE ARCHITECTURE & SEQUENCE DIAGRAMS


1. Component Overview

┌────────────────────────────────────────┐
│           MailManager                  │
│  (Spring @Component, like ChatManager) │
│                                        │
│  ┌──────────┐  ┌──────────┐           │
│  │ sendMail │  │claimMail │           │
│  │  (P2P)   │  │          │           │
│  └────┬─────┘  └────┬─────┘           │
│       │              │                 │
│  ┌────┴──────────────┴────┐           │
│  │     CacheManager       │           │
│  │     (MongoDB)          │           │
│  └────────────────────────┘           │
└──────────┬─────────────────────────────┘
           │
    ┌──────┴──────┐
    │  MongoDB    │
    │  Collections│
    └─────────────┘
           ▲
           │ login event
    ┌──────┴──────┐     ┌──────────────┐
    │ LoginFlow   │     │ ExpiryTask   │
    │ (deliver    │     │ (Cron 5min)  │
    │  pending)   │     │              │
    └─────────────┘     └──────────────┘

Pattern giống PrivateChatManager: Spring @Component, inject CacheManager, dùng SendMsgUtils để push data cho client.


2. MailManager API

@Component
public class MailManager {

    private final CacheManager cacheManager;

    // === P2P Mail ===
    
    /**
     * Player A gửi thư cho Player B (có thể kèm items)
     */
    void sendPlayerMail(Player sender, long recipientUid, 
                        String title, String content, String attachments);

    // === System Mail ===
    
    /**
     * System gửi thư cho 1 player (event reward, compensation)
     */
    void sendSystemMail(long recipientUid, String title, String content,
                        String attachments, int mailType);

    /**
     * System gửi broadcast cho tất cả player
     */
    void sendBroadcastMail(String title, String content, 
                           String attachments, int mailType);

    // === Player Actions ===
    
    /**
     * Load mailbox: targeted + broadcast, merged, sorted, limit 50
     */
    void loadMailbox(Player player);

    /**
     * Player đọc 1 thư → mark isRead = true
     */
    void markRead(Player player, String mailId, boolean isBroadcast);

    /**
     * Player claim attachment từ 1 thư
     */
    void claimAttachment(Player player, String mailId, boolean isBroadcast);

    /**
     * Player claim tất cả attachment
     */
    void claimAll(Player player);

    /**
     * Player xóa thư đã claim (hoặc không attachment)
     */
    void deleteMail(Player player, String mailId);

    // === Internal ===
    
    /**
     * Gọi khi player login → deliver pending mail
     */
    void deliverPending(long playerUid);

    /**
     * Cron job: xóa mail hết hạn + pending expired
     */
    void cleanupExpired();
}

3. Key Logic

3.1 Send P2P Mail Flow

void sendPlayerMail(Player sender, long recipientUid, ...) {
    // 1. Validate
    if (senderUid == recipientUid) return; // không gửi cho mình
    Player recipient = playerManager.getPlayerById(recipientUid);
    if (recipient == null) { sendNotice("Người chơi không tồn tại"); return; }
    
    // 2. Parse + validate attachments
    List<AttachItem> items = parseAttachments(attachments);
    if (items.size() > 5) { sendNotice("Tối đa 5 vật phẩm"); return; }
    
    // 3. Trừ items khỏi inventory sender (TRƯỚC KHI tạo mail)
    if (!inventoryService.removeItems(sender, items)) {
        sendNotice("Không đủ vật phẩm"); return;
    }
    
    // 4. Tạo mail
    PlayerMail mail = new PlayerMail(senderUid, senderName, recipientUid, ...);
    mail.setExpiresAt(now + 7_DAYS); // P2P = 7 ngày
    
    // 5. Deliver hoặc pending
    int count = countMailByRecipient(recipientUid);
    if (count >= 50) {
        savePendingMail(mail);
    } else {
        cacheManager.saveNoCache(mail);
    }
    
    // 6. Notify recipient nếu online
    if (recipient.isOnline()) {
        SendMsgUtils.sendMailNotification(recipientUid, buildMailPB(mail));
    }
    
    // 7. Confirm cho sender
    SendMsgUtils.serverNotice(senderUid, "Đã gửi thư thành công");
}

3.2 Claim Flow

void claimAttachment(Player player, String mailId, boolean isBroadcast) {
    long uid = player.getPlayerId();
    
    if (isBroadcast) {
        claimBroadcast(player, mailId);
        return;
    }
    
    // 1. Tìm mail
    PlayerMail mail = cacheManager.findOne(PlayerMail.class, "_id", mailId);
    if (mail == null || mail.getRecipientUid() != uid) return;
    
    // 2. Check đã claim
    if (mail.isClaimed()) {
        sendNotice(uid, "Đã nhận rồi"); return;
    }
    
    // 3. Check expire
    if (mail.getExpiresAt() <= System.currentTimeMillis()) {
        sendNotice(uid, "Thư đã hết hạn"); return;
    }
    
    // 4. Parse attachments
    List<AttachItem> items = parseAttachments(mail.getAttachments());
    if (items.isEmpty()) {
        mail.setClaimed(true);
        cacheManager.saveNoCache(mail);
        return;
    }
    
    // 5. Check inventory slots
    if (!inventoryService.hasSlots(player, items.size())) {
        sendNotice(uid, "Hành trang đầy"); return;
    }
    
    // 6. Add items
    inventoryService.addItems(player, items);
    
    // 7. Mark claimed
    mail.setClaimed(true);
    cacheManager.saveNoCache(mail);
    
    // 8. Response
    SendMsgUtils.sendClaimResult(uid, items);
}

4. Sequence Diagrams

4.1 Player A gửi thư cho Player B (P2P)

[Diagram]

4.2 Player Claim Attachment

[Diagram]

4.3 System gửi mail (Event/Compensation)

[Diagram]

4.4 Player Login → Deliver Pending

[Diagram]

4.5 Broadcast Event Mail

[Diagram]


PHẦN 4 — LOGGING, EDGE CASES & QA TEST PLAN


1. Logging

Game server dùng log.info / log.warn (SLF4J). Không cần bảng log riêng — query từ log file hoặc log aggregator.

1.1 Log Types

Event Level Format Mục đích
Mail sent (P2P) INFO [Mail] SENT P2P sender={} recipient={} mailId={} attachments={} Audit ai gửi gì
Mail sent (System) INFO [Mail] SENT SYSTEM recipient={} mailId={} type={} attachments={} Track system delivery
Broadcast created INFO [Mail] BROADCAST broadcastId={} type={} attachments={} Track broadcast
Mail claimed INFO [Mail] CLAIMED uid={} mailId={} source={} attachments={} Audit claim
Claim failed WARN [Mail] CLAIM_FAIL uid={} mailId={} reason={} Debug
Mail expired INFO [Mail] EXPIRED mailId={} uid={} hadUnclaimed={} attachments={} CS tra cứu
Pending delivered INFO [Mail] DELIVERED pendingId={} mailId={} uid={} Track pending
Pending expired WARN [Mail] PENDING_EXPIRED pendingId={} uid={} attachments={} Items mất
Invalid item WARN [Mail] INVALID_ITEM mailId={} itemId={} uid={} Bug detection

1.2 Ví dụ Log

[Mail] SENT P2P sender=1001 recipient=1002 mailId=abc-123 attachments=1001_1_5;2002_0_1
[Mail] CLAIMED uid=1002 mailId=abc-123 source=MAIL attachments=1001_1_5;2002_0_1
[Mail] EXPIRED mailId=def-456 uid=1003 hadUnclaimed=true attachments=3001_0_10
[Mail] PENDING_EXPIRED pendingId=ghi-789 uid=1004 attachments=4001_1_3

1.3 Phục vụ CS / Anti-cheat

Tình huống CS Tìm trong log
"Tôi không nhận được quà event" Grep EXPIRED + uid=X hoặc CLAIMED uid=X
"Ai đã gửi item cho tôi?" Grep SENT P2P recipient=X
"Item biến mất" Grep CLAIMED uid=X + SENT P2P sender=X
"Nghi ngờ dupe item" Grep CLAIMED mailId=Y — phải chỉ có 1 dòng

2. Edge Cases — Chi Tiết

E1: Mailbox Full

Trigger: recipientUid có >= 50 mail
Action:  Lưu vào pending_mail
Retry:   Mỗi lần login → deliverPending()
Limit:   Max 100 pending/player
Overflow: Discard oldest pending nếu > 100 → log WARN

E2: Double Claim (Idempotent)

Trigger: Player tap "Nhận" 2 lần nhanh
Guard:   if (mail.isClaimed()) return "Đã nhận"
Result:  Lần 2 → no-op, không duplicate items

E3: Player Offline

Trigger: Player B offline khi A gửi thư
Action:  Mail nằm trong DB (player_mail hoặc pending_mail)
         Khi B login → load mailbox → thấy thư mới
         deliverPending() chạy tự động

E4: Recipient Không Tồn Tại

Trigger: sendPlayerMail với UID không hợp lệ
Guard:   playerManager.getPlayerById(recipientUid) == null
Action:  Reject. KHÔNG trừ items từ sender
Notice:  "Người chơi không tồn tại"

E5: Sender Thiếu Items

Trigger: Player gửi P2P nhưng inventory không đủ
Guard:   inventoryService.removeItems() return false
Action:  Reject gửi
Notice:  "Không đủ vật phẩm"

E6: Inventory Full Khi Claim

Trigger: Player claim nhưng inventory hết slot
Guard:   inventoryService.hasSlots(player, itemCount) == false
Action:  Reject claim, mail giữ nguyên unclaimed
Notice:  "Hành trang đầy, vui lòng dọn trước"

E7: Expire Khi Đang Đọc

Trigger: Player mở thư, nhưng thư expire trước khi claim
Guard:   Server check expiresAt khi claimAttachment()
Action:  Return "Thư đã hết hạn"

E8: Broadcast 1M+

Trigger: Event system gửi reward cho tất cả player
Action:  1 document trong mail_broadcast
         Lazy materialization: tạo broadcast_read khi player đọc/claim
Impact:  Tối thiểu DB writes

E9: Invalid ItemId

Trigger: Attachment chứa itemId không tồn tại trong template
Action:  Skip item đó, log WARN
         Claim phần items valid còn lại

E10: Server Crash Giữa Claim

Trigger: Process chết giữa lúc claim
Risk:    Items đã add nhưng mail chưa mark claimed
         → player claim lại → duplicate
Mitigation:
  Option A: Mark claimed TRƯỚC, add items SAU
            crash = items mất (ít hại hơn dupe)
  Option B: Dùng claim log để detect duplicate
  Recommend: Option A (mark first, then add)

E11: P2P Spam

Trigger: Player liên tục gửi thư spam
Guard:   
  - Cooldown 60s giữa 2 lần gửi P2P
  - Max 20 thư P2P gửi / ngày
  - Có thể thêm: block list

E12: Attachment Rỗng

Trigger: Gửi thư không có attachment
Action:  Cho phép (thư thông báo thuần)
         isClaimed auto = true (không cần claim)

3. QA Test Plan

3.1 Functional Tests

ID Test Case Steps Expected
F01 P2P gửi thư + items A gửi cho B, attach 1001_1_5 Items trừ từ A, mail xuất hiện ở B
F02 P2P gửi thư không items A gửi cho B, no attachment Mail ở B, isClaimed = true
F03 Claim attachment B tap "Nhận" Items vào inventory B, isClaimed = true
F04 Claim All B có 3 thư unclaimed 3 thư claimed, items vào inventory
F05 Mark read B mở thư isRead = true
F06 Red dot hiển thị B có thư unread Red dot on
F07 Red dot biến mất Tất cả read + claimed Red dot off
F08 System mail deliver Event reward cho uid=1001 Mail xuất hiện
F09 Mail expire Chờ hết hạn Mail biến mất, log EXPIRED
F10 Cảnh báo sắp hết hạn Mail expire trong 72h Label "Sắp hết hạn"
F11 Thứ tự hiển thị 5 mail khác thời gian Mới nhất hiện trước
F12 Xóa thư đã claim Player xóa thư Thư biến mất
F13 Giới hạn title 60 chars Gửi title 61 chars Reject hoặc truncate
F14 P2P max 5 attachment Gửi 6 items Reject
F15 System max 10 attachment System gửi 11 items Reject

3.2 Edge Case Tests

ID Test Case Steps Expected
E01 Mailbox full → pending B có 50 mail, A gửi thêm Vào pending_mail
E02 Login deliver pending B login, có 3 pending, box = 48 Deliver 2, 1 vẫn pending
E03 Double claim Tap "Nhận" 2 lần nhanh Lần 2 = "Đã nhận", no dupe
E04 Gửi cho bản thân A gửi cho A Reject
E05 Gửi cho UID không tồn tại A gửi cho uid=999999 Reject, items không trừ
E06 Claim thư hết hạn Mail vừa expire Return "Thư đã hết hạn"
E07 Inventory full claim Inv 0 slot "Hành trang đầy"
E08 Broadcast claim Event broadcast, B claim Items vào inv
E09 Broadcast double claim B claim broadcast 2 lần Lần 2 idempotent
E10 Pending mail expire Pending vượt expiresAt Discard + log
E11 Invalid itemId Attach "99999_1_1" Skip + log
E12 Spam protection Gửi 2 thư trong 30s Lần 2 reject (cooldown)
E13 Claim All + inv partial 5 thư, chỉ đủ slot 3 3 claimed, 2 giữ nguyên

3.3 Load Tests

ID Test Case Parameter Acceptance
L01 Broadcast 1M 1 broadcast 1 doc in mail_broadcast. Query < 50ms
L02 Login storm 5K concurrent, mỗi player 5 pending Tất cả delivered, no dupe, < 5s
L03 P2P spam 1K player gửi cùng lúc All delivered, no item dupe
L04 Expiry batch 50K expired mail Cleanup < 30s
L05 Mailbox query Player 50 mail + 10 broadcast Response < 20ms

4. Implementation Checklist

# Task Owner Status
1 Tạo entities: PlayerMail, PendingMail, MailBroadcast, MailBroadcastRead Dev
2 Tạo MailManager với CacheManager Dev
3 Implement sendPlayerMail (P2P) Dev
4 Implement sendSystemMail + sendBroadcastMail Dev
5 Implement loadMailbox (merge targeted + broadcast) Dev
6 Implement claimAttachment + claimAll Dev
7 Implement deliverPending (login hook) Dev
8 Implement cleanupExpired (cron) Dev
9 Tạo Protobuf messages cho client Dev
10 Tạo SendMsgUtils methods cho mail Dev
11 Hook vào login flow Dev
12 Client UI integration Client
13 QA functional test pass QA
14 QA edge case test pass QA