📬 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
- Player gửi quà cho bạn bè (items, gold)
- System phân phối phần thưởng (event, daily, compensation)
- Thay thế inject trực tiếp vào inventory — player chủ động claim
- 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 |
⬜ |