Việc còn phải xem lại sau đợt review schema (2026-07-28). Đã review cả 8 module.
Mục đã đánh [x] thì giữ lại làm dấu vết chứ không cần đọc nữa.
Ký hiệu: [V] = đã verify bằng chạy migrate thật + insert thử trên DB trắng. [R] = phát hiện bằng đọc code, chưa chạy tới.
Thứ tự đang theo: schema trước, app sau — đổi schema khi đã có tiền/đơn thật là việc
đau. Phần schema về cơ bản đã xong; việc lớn nhất còn lại là 10.2: module order phía
Go chưa được viết. Refactor id.ID[K] đã xong nên không còn gì chặn nó.
Trống — [V] cả 8 module đã apply sạch (2026-07-28). 0.1 (seller_tax_info rename
dở) và 0.2 ('verified' không có trong enum) đã sửa cùng đợt review finance.
go run ./cmd/migrate đã chạy được (refactor id.ID[K] xong): 8/8 module ok trên DB
trắng. Không cần script vòng nữa — xem "Cách verify lại".
- 1.1 "Ít nhất một identifier" — đã ép. [V]
account_has_identifierCHECK (COALESCE("phone","email","username") IS NOT NULL), và comment đầu bảng (vốn nói "typically required") đã sửa cho khớp. Hệ quả bắt buộc cho tầng app: đăng ký OAuth-only phải điền một trong ba — sinhusernamekhi provider không trả email (Apple "hide my email"). Không ép được "có identifier hoặc có oauth_identity" ở DB vì đó là ràng buộc liên bảng. - 1.2
profilethực chất là 0..1, không phải 1-1. PK = FK nên account tồn tại mà không có profile là hợp lệ, trong khiprofile.name NOT NULLnghĩa là tên hiển thị bắt buộc → "account không tên" là trạng thái schema cho phép. Chốt: ép tạo profile cùng transaction, hay gộp thẳng vàoaccount. - 1.3
notificationchưa có partition / retention — đã có. [V] Cảnotificationvànotification_deliverylà hypertable theocreated_at, chunk 7 ngày, retention 180 ngày. Khácchat.message(không đặt retention): thông báo không phải bằng chứng tranh chấp, "đơn đã giao" của năm ngoái không ai đọc. - 1.4 Một notification gửi 4 kênh = 4 row trùng — đã tách. [V]
notificationgiữ nội dung (category, title, payload,read_atthayis_read),notification_deliverygiữ mỗi kênh một dòng vớistatus/attempts/last_error/sent_at. Đo thật: 1 notification + 2 kênh = title/payload chỉ tồn tại một bản. Không có FK giữa hai bảng: retention drop nguyên chunk bênnotification, FK sẽ hoặc chặn drop hoặc để lại dòng mồ côi. Hai retention window đặt bằng nhau để chúng đi cùng nhịp. FK vềaccountthì vẫn còn (hypertable trỏ ra bảng thường được). - 1.5 Tên enum quá generic — đã đổi. [V]
status→account_status,role→account_role,gender→profile_gender,address_type→contact_address_type. Chỉ đổi tên type, tên cột giữ nguyên ("status" "account_status"), nên không đụng gì tới tầng app. Quét lại toàn bộ 27 enum của 8 module: không còn tên nào không gắn với chủ thể của nó. - 1.6
ON UPDATE CASCADEở mọi FK là vô nghĩa — đã bỏ. [V] 34 FK, bỏ 31, giữ lại 2:product_spu_tagvàtag_embeddingtrỏ tớicatalog.tag, màtag.idlàVARCHARslug — khoá tự nhiên thì đổi được, nên ở đó cascade mới có nghĩa. Đúng ngoại lệ mà luật id đã ghi.common.option.idcũng là slug nhưng không FK nào trỏ tới nó.ON DELETEkhông bị đụng: vẫn 19 CASCADE / 11 NO ACTION / 3 SET NULL / 1 RESTRICT như trước. - 1.7
profile.countrybị dùng để suy ra currency của ví.Comment ởĐã sửa cùng 5.1: currency là ISO 4217 tường minh và nằm trong khoá chính củafinanceghiwallet.currency"matches the account's country".wallet. - 1.8 Không ép cấm opt-out
category='system'ở DB. Chốt: thông báo nào là bắt buộc là quy tắc sản phẩm, nên domain quyết và bảng chỉ ghi lại. Đã ghi vào comment củanotification_preferenceđể lần sau không ai tưởng là bỏ sót. - 1.9 KYC giấy tờ tuỳ thân — đã có
account.identity_document. [V] Đặt ởaccountchứ khôngfinance: nó xác lập người này là ai, và nối vớiaccount.status(treo tài khoản sau khi bị từ chối) thì cùng schema. Không lưu số giấy tờ, không lưu ảnh scan — vendor eKYC làm việc kiểm, mình chỉ giữprovider+provider_ref+ phán quyết, nên rò bảng này không mạo danh được ai. Cóexpires_atvì hộ chiếu/CCCD hết hạn, và partial uniqueone_verified_per_accountbiến "tài khoản này đã xác minh chưa" thành index lookup cho cổng payout.
- 2.1 Cap
max_lengthcủa job embedding ở 512–1024 token — việc của tầng app, DB đã chặn sẵn nhưng chặn muộn. [V] Đo thật: 1000 non-zero vào được, 1001 bịERROR: sparsevec cannot have more than 1000 non-zero elements for hnsw index, tức index ép ngay ở INSERT chứ không phải lúc build. BGE-M3 sinh một non-zero cho mỗi token unique, nên cap 512–1024 là không bao giờ với tới. Không cap mà embed cả description dài (BGE-M3 chịu 8192 token) thì cả batch embedding chết ở lệnh ghi. Comment trong schema đã nói đúng chỗ nó ép; phần còn lại chờ job được viết. - 2.2
cached_ratinglà denormalization xuyên module. Nguồn làtrust.review, nên catalog phải consume một event từ trust (bus.Client) để cập nhật. Chưa có event nào; nếu không làm thìcached_ratingđứng mãi ở 0. Đây là cột denormalized duy nhất còn lại, và lý do nó tồn tại là cross-module (không join được), chứ không phải vì không index được — xem mục "Đã chốt" về giá. - 2.3
CREATE EXTENSION vectorthiếuWITH SCHEMA public— đã sửa. [V] Xem "Đã chốt" để biết lý do. Lưu ý vận hành: DB nào đã apply001rồi thì extension vẫn nằm ởcatalog— sửa file không tự dời nó (xem 7.4).
- 3.1
trustkhông có bảngaudit_log— đã thêm. [V] Cùng khuôn với 5 module kia (record_id BIGINT, unique(table_name, record_id, version)). Giờ cả 7 module nghiệp vụ đều có;observabilitycố ý không có vì bản thân nó đã là log. - 3.2
reputationvàreview— chốt: KHÔNG gộp. [V] Thêmreview_rating_sum/review_rating_countriêng bên cạnhrating_sum/rating_count. Lý do không cộng chung: một đơn có thể sinh cả hai (buyer vừa đánh giá giao dịch vừa review sản phẩm), cộng lại là đếm hai lần; và "giao đúng hẹn" không phải cùng một khẳng định với "hàng đúng mô tả" — gộp thì mất luôn khả năng phân biệt.reputation_reviews_are_seller_onlyép cột review = 0 vớirole='buyer', và thêm CHECK không âm cho cả 6 counter. - 3.3 Năm cột
updated_atcòn lại là "lần recompute cuối" — đã ghi chú tại cột. [V]trust.reputation,catalog.product_embedding,catalog.category_embedding,catalog.tag_embedding,catalog.account_interest. Tất cả do cron ghi và mốc đó là dữ liệu vận hành (biết khi nào cần embed lại), khácupdated_atkiểu audit mà account/catalog/finance đã bỏ. Giờ mỗi cột có một dòng comment nên đợt dọn sau không xoá nhầm. - 3.4 Đã review kỹ
feedback/report— kết quả là 3.5–3.11 dưới đây. - 3.5 Báo cáo một review thì không xử lý được — y hệt lỗ hổng 8.3 của message.
report_ref_typeđã có'review'và'review-reply', nhưngreport_actionkhông có'review-removed', vàreview/review_replykhông códeleted_at. Tức là admin nhận được báo cáo, xử lý xong thì không gỡ được nội dung và cũng không ghi lại được là đã gỡ. Đây đúng là lỗ mà 8.3 đã vá cho message ('message-removed'+message.deleted_at), chỉ khác entity. Ưu tiên cao nhất trong nhóm này. - 3.6
reportkhông ép bốn cột kết luận đi cùngstatus.action_taken,resolved_by_id,resolved_at,resolution_noteset được khistatus='open', và ngược lạistatus='actioned'không cần cột nào. Đây đúng loại "cột phải đi cùng nhau" mà dự án đã ép ở bốn chỗ khác (account_suspension_requires_suspended,identity_document_verified_at_matches_status,notification_delivery_sent_at_matches_status, và cặp trạng thái củarefund) — chỉ riêngreportbỏ sót. - 3.7
feedbackcho phép tự đánh giá mình.rater_id = ratee_idvào được, dù domain cóErrSelfFeedback. Chat đã ép đúng loại này ở DB (CHECK ("account_a_id" < "account_b_id")chặn luôn tự chat với mình), nên đây là lệch chuẩn nội bộ chứ không phải quyết định. Một dòngCHECK ("rater_id" <> "ratee_id"). - 3.8 Feedback "blind until revealed" nhưng job reveal không có index nào để chạy.
[V]
feedback_ratee_id_idxlà partialWHERE "published_at" IS NOT NULL— tức nó loại trừ đúng tập hàng mà job cần. Job "hết X ngày thì công bố, hoặc công bố khi cả hai chiều đã nộp" sẽ seq scan cả bảng. Cần partial index ngược lại:("created_at") WHERE "published_at" IS NULL, cùng khuôn vớirefund_review_deadline_idxvànotification_delivery_pending_idx. - 3.9
report_status_idxnên là partial trên hàng đợi. [V] Đo trên 100k report với phân bố thật (1.1% open/reviewing): index hiện tại có được planner dùng — nên lập luận "4 giá trị thì planner bỏ qua" của mục 2 không áp dụng ở đây, vì phân bố lệch. Nhưng nó lấy cả 1099 hàng rồi mới sort: 837 buffer, 0.94ms. Đổi sang("created_at") WHERE "status" IN ('open','reviewing'): 36 buffer, 0.073ms, không còn node Sort — nhanh 13×, nhỏ hơn 17× (40 kB so với 696 kB). - 3.10 Index recompute reputation thiếu
direction.feedback_ratee_id_idxchỉ có("ratee_id"), trong khi recompute chorole='seller'lọcratee_id = ? AND direction = 'buyer-to-seller'. Thêmdirectionvào index để mỗi role đọc đúng phần của nó. Nhỏ, làm cùng 3.8 cho gọn. - 3.11 Mấy thứ nhỏ, chưa chốt. (a)
review_vote.vote = 0là một dòng không nói gì — cùng loại vớiwallet_transaction_moves_somethingmà dự án đã chặn; xoá dòng cũng diễn đạt được "đã bỏ vote". (b) Không có gì chặn tự vote / tự reply review của mình, nhưng đó là liên bảng nên thuộc service. (c)feedback.comment,review.body,report.detailđều làTEXTkhông giới hạn, trong khi domain đặtmax=2000.
Mục 4.1–4.4 cũ (về resource_reference) đã biến mất cùng bảng đó — xem "Đã chốt".
- 4.1 Listing không gắn được ảnh — đã sửa. [V] Thêm
attachments BIGINT[] NOT NULL DEFAULT '{}'vàocatalog.product_spu,catalog.product_sku,order.refund,trust.review. Cùng khuôn vớichat.message.attachmentsvốn đã có (và đã làBIGINT[], không phảiUUID[]như ghi chú cũ). Tổng cộng 5 cộtattachmentstrong schema. Mảng có thứ tự và thứ tự đó là dữ liệu:attachments[1]là ảnh cover của listing. SKU rỗng nghĩa là dùng gallery của SPU, nênNOT NULL DEFAULT '{}'chứ không nullable — "chưa có ảnh" và "không có ảnh riêng" là cùng một trạng thái.account.profile.avatar_resource_idvàcommon.option.logo_resource_idgiữ cột đơn vì quan hệ 1-1. Không đặt index GIN: truy vấn thường là đọc mảng của một row đã biết, còn chiều ngược chỉ có reaper dùng và nó quét một lượt (xem 4.2). Chưa có chỗ choorder-receiptcủa enum cũ — chưa có bảng receipt, và bằng chứng giao hàng thì nên lấy từ API carrier. Bỏ qua tới khi có nhu cầu thật. - 4.2 Không còn gì ràng buộc resource được gắn có tồn tại thật. Bỏ join table
là bỏ FK: một id trong
attachmentstrỏ tới resource đã xoá thì DB không biết. Module sở hữu phải tự kiểm khi ghi. Chiều còn lại là job dọn resource, và giờ nó phải gộp cả 5 mảng — thiếu một cái là xoá nhầm file đang được dùng:sql WITH referenced AS ( SELECT unnest(attachments) AS rid FROM catalog.product_spu UNION SELECT unnest(attachments) FROM catalog.product_sku UNION SELECT unnest(attachments) FROM chat.message UNION SELECT unnest(attachments) FROM "order".refund UNION SELECT unnest(attachments) FROM trust.review ) SELECT id FROM common.resource WHERE id NOT IN (SELECT rid FROM referenced);[V] Đã chạy: nhận đúng resource mồ côi, bỏ qua 4 cái đang được tham chiếu. Hai cảnh báo: query này schema-qualify nên chỉ chạy khi còn chung một database — tách service thì phải đổi thành từng module tự khai resource nó đang giữ. Và nó quét toàn bảng, nên khi các mảng lớn lên thì cần GIN index hoặc chạy theo lô.
- 5.1 Đa tiền tệ — đã làm. [V]
wallet_pkey PRIMARY KEY ("account_id","currency"). Xem "Đã chốt" để biết lý do và các hệ quả kéo theo. - 5.2
wallet_transactionkhông có cộtcurrency. [V] Đã thêm — row tự mô tả được đơn vị của nó, không còn phải suy ra từ ví hay từpayment_session.fx_snapshot. - 5.3 Cân bằng tiền chưa được ép, chỉ mới đo được.
group_id(vừa thêm) cho phép viết query đối soát "tổng delta của một movement + tiền qua rail ngoài = 0", nhưng không có gì ép. Muốn ép thật thì cần trigger deferred kiểm theo group. - 5.4 Hai bất biến của reversal nằm ở tầng service, DB không ép được (liên row):
abs(reversal.amount) <= original.amount, và currency hai bên phải khớp. Đã ghi trong comment cạnhtransaction_reverses_id_unique; cần test ở service. - 5.5
bank_account.account_number+account_holderlưu plaintext. Đủ để lừa đảo nếu DB rò. Tối thiểu: chắc chắn không bao giờ log. Cân nhắc mã hoá như đã làm với secret Vault. - 5.6
tax_info.updated_at— đã bỏ. [V] Dấu vết thay đổi đã cófinance.audit_loglo, và trạng thái xác minh thìverified_atmới là mốc có nghĩa. - 5.7
'topup'là giá trị enum không có đường vào. [R]wallet_txn_kindcó'topup'nhưngsession_kindkhông có — mà tiền từ rail ngoài vào thì bắt buộc đi qua một session (transactionchỉ ghi leg dưới session). Nên hiện không có cách nào để user nạp tiền vào ví. Hoặc đây là giá trị chết (cùng loại'shipping'ởrefund_status, xem 5.11), hoặc thiếusession_kind = 'topup'. Fragmentfinancekhông có endpoint nạp tiền vì lý do này — mô hình escrow hiện tại không cần: buyer trả trực tiếp qua sessionbuyer-checkout. - 5.8 Index của "session của tôi" thiếu cột thời gian. [R]
payment_session_from_id_idxvà_to_id_idxđều là một cột (from_id/to_id), nênGET /payment-sessions?role=payerxếp mới-nhất-trước phải qua node Sort. Đổi thành("from_id", "created_at" DESC)và tương tự choto_id— đúng khuôn vớiorder_buyer_id_idx/item_buyer_id_idxvốn đã làm vậy. - 5.9 Kết luận của admin cho withdrawal nằm trong JSONB. [R] Theo comment đầu file,
withdrawalkhông có bảng riêng: đích đến + phán quyết của admin nằm trongpayment_session.data. Hệ quả: "withdrawal admin này đã xử lý" và "còn tồn quá X ngày" là truy vấn JSONB chứ không phải index lookup. Cân nhắc đưaresolved_by_id/resolved_atlên thành cột nếu hàng đợi này thành nóng — cùng lý lẽ đã dùng choorder.completed_at(outcome fact thì nên là cột). - 5.10
account_numberkhông bao giờ ra khỏi API. Chốt ở tầng contract: DTOBankAccountchỉ cóaccount_number_masked, vàPATCH /bank-accounts/{id}chỉ đổi đượcis_default— muốn sửa số thì đăng ký cái mới rồi xoá cái cũ. Lý do sau quan trọng hơn lý do bảo mật: một withdrawal đã hoàn tất trỏ tới hàng này như bằng chứng tiền đã đi đâu, nên số tài khoản của nó không được phép đổi dưới chân nó. Vá được phần "chắc chắn không bao giờ log" của 5.5; phần mã hoá at-rest vẫn mở.
Trống — đã review hết 8 module. order ở mục 10, observability ở mục 9.
- 7.1 CLAUDE.md nói module
payment, code làfinance— đã sửa. Kèminternal/provider/payment→finance(thư mục thật tênfinance). Cái này không chỉ là docs:docker-compose.ymlcũng đang cấpPAYMENT_DB_DSN+INVENTORY_DB_DSNmàconfig.gokhông đọc, và thiếu hẳnFINANCE_DB_DSN→docker compose --profile app upchết ngay lúc khởi động (mọi env đều required, fail-fast). [V] Đã sửa và so khớp lại: 15 biến compose cấp = đúng 15 biến config đọc, không thiếu không thừa. - 7.2 CLAUDE.md khai
inventorylà module riêng — đã sửa.stocknằm trong schemacatalog; trên đĩa chỉ có 8 module và không cóinventory. - 7.3 CLAUDE.md mô tả
trustthiếu reviews — đã sửa. Thêmreview,review_reply,review_vote. Cũng bỏresource_referencekhỏi danh sách bảng củacommon(bảng đó đã xoá), và ghi thêm hai thứ mới của observability:instance(envINSTANCE_ID) và cách đọc p95 bằngapprox_percentile. - 7.4 Mọi sửa đổi đều là sửa tại chỗ
001_init.sql, không phải002_*.postgres.Migratetrack version trongschema_migrations, nên DB nào đã apply001rồi thì phảiDROP SCHEMA <module> CASCADEvà migrate lại; sửa file không tự động áp dụng.
Đánh số 8 để không phải đổi số các mục cũ; thứ tự section là lịch sử review.
- 8.1 Read state đang là per-message, chưa per-participant.
message.statuschạy được với thread 1-1, nhưng đếm chưa đọc phảicount(*) WHERE conversation_id = ? AND sender_id <> me AND status <> 'read'. Đã đo trên hypertable: query này fan-out qua mọi chunk (Bitmap Index Scan trên từng_hyper_1_N_chunk), và không thể giới hạn theo thời gian vì một tin chưa đọc có thể rất cũ. Indexmessage_unread_idxcó được dùng nhưng không cứu được fan-out. Cách gọn:conversation_participant (conversation_id, account_id, last_read_message_id)→ đếm chưa đọc thành O(1), đọc một dòng, không chạm chunk nào. Cũng là chỗ để cho admin tham gia thread khi có refund dispute. Chưa chốt. - 8.2
message.id BIGINTbắt hai module khác trả giá — hết hiệu lực. Tiền đề (report.ref_idvàchat.audit_log.record_idphải làTEXT) không còn đúng: cả hai đã làBIGINT, và comment cũ "targets mix UUID and bigint keys" cũng đã biến mất khỏi file trust. Đợt refactor id giải quyết rồi —ref_typecho biết kind nênParseOpaquedựng lại được id mờ từ mộtBIGINT. Không phải đổimessage.idsang UUID nữa. Chỗ duy nhất cònTEXTlàcommon.audit_log.record_id, và đó là cố ý (schemacommontrộn khoá BIGINT với khoá slugoption.id). - 8.3 Report một message xong chưa xử lý được ở tầng action — đã xong.
trust.report_actiongiờ là('none','listing-removed','message-removed', 'account-suspended','warning'), nên việc admin xoá một tin nhắn có chỗ ghi lại.message.deleted_atlo phần redact. - 8.4 Phân trang message phải dùng cursor theo
created_at, không dùng OFFSET. Hệ quả của việcmessagethành hypertable: chỉ khi query có mốc thời gian thì chunk exclusion mới bỏ được chunk cũ. Ràng buộc ở tầng repo, DB không ép được. - 8.5 Query hộp thư phải viết dạng
UNION ALLhai nhánh. Hệ quả của cặp đối xứng:WHERE account_a_id = me OR account_b_id = me ORDER BY last_message_at DESCsẽ thành sort toàn bộ. Viết thành hai nhánh, mỗi nhánh một ordered index scan, rồi merge — mỗi nhánh dùng đúng một trong hai index đã tạo. Ràng buộc ở tầng repo.
- 9.1
Sinkphải điềninstancecho cả 4 bảng — đã làm.config.InstanceID(envINSTANCE_ID, required như mọi biến khác) →NewSink(bus, log, instance)→ cả 4 struct trongdomain/sample.go→ cộtinstancetrong 4 lệnh COPY của repo. Đóng dấu ởSinkchứ không ở từng call site, vì nó là thuộc tính của process. TestTestSink_RecordHTTPPublishesSamplegiữ chỗ này khỏi hồi quy. - 9.2
provider_calls.pathphải được template hoá ởhttpx.ObserveOutbound. Nếu id bị nhúng vào path (/v1/orders/12345) thìGROUP BY pathnổ cardinality. DB không ép được, phải làm ở chỗ ghi. - 9.3 Chốt danh sách field được mirror vào
business_events.payload. Hiện mirror cả payload event. Grafana đọc schema này bằng một Postgres datasource, nên ai xem được dashboard là xem được mọi thứ ở đây — phạm vi rộng hơn bảng gốc. Chỉ nên mirror id + số tiền + trạng thái, không mirror dữ liệu cá nhân. - 9.4 p95 thật — đã làm. [V]
timescaledb_toolkit(1.23.0, có sẵn trong imagetimescaledb-ha) + cộtlatency percentile_agg("duration_ms")trong cagg. Cách đọc:approx_percentile(0.95, "latency"), vàrollup("latency")trước khi gộp nhiều bucket. Đã đo trên 1000 request (90% ~10ms, 10% ~400ms): avg 51.5 / p50 13.0 / p95 410.3 — đúng cái mà avg giấu đi. - 9.5 Chưa có cagg cho
provider_calls. Dashboard latency/error-rate của provider vẫn quét raw. Thêm khi nào panel đó thành nóng. - 9.6 Telemetry bị mất không tự quan sát được.
reportDroppedchỉ đếm trong process; nếu pipeline chết thì dashboard trông giống "không có traffic". Cân nhắc mirror con số đó vàoruntime_metrics.
- 10.1
draft_ordersẽ phình vì mỗi row chứa một bản copy của listing.spu_snapshotgiữ SPU + các SKU, và draft không bao giờ bị xoá (chỉ cócancelled_at). Draft đã thành order thì không xoá được (order.draft_id+item.draft_idlà NO ACTION), nhưng draft bỏ dở / hết hạn mà không có item thì nên dọn — không thì bảng này dần thành một bản sao của catalog.draft_order_expiring_idxđã có để job quét. - 10.2 Module
orderphía Go không phải "cần sửa cho khớp" — nó chưa được viết. Đã kiểm: 389 dòng, và không hề códraft_order,item,transport,refund,offerở bất kỳ đâu (greptoàn repo chỉ raid/kinds.govà provider payment).domain.Orderlà{ID, BuyerID, Total, Status}còn repo ghiINSERT INTO orders (buyer_id, total, status)— sai tên bảng (schema làorder, số ít) và cả ba cột đều không tồn tại trong schema. Nói cách khác 5 "thay đổi hình dạng" không có gì để sửa: không có fieldaddressnào để đổi sang JSONB, không có entityitemhaydraft_order. → Đây là việc viết mới cả module theo 9 bảng của schema, không phải một patch. Refactorid.ID[K]đã xong nên không còn gì chặn:go build ./...,go vet ./...vàgo test ./...(30 gói) đều sạch. Đây giờ là mục lớn nhất còn lại. - 10.3
item.serial_idsđã bị bỏ cùng với bảngcatalog.serialmà bạn xoá. Nếu sau này cần theo dõi IMEI/serial thì phải dựng lại cả hai. - 10.4 Không lưu số tiền hoàn trên
refund. Cố ý: refund là toàn đơn, nên số tiền đã có đúng một chỗ ởfinance.transaction(chân đảo, trỏ tới quarefund_tx_id). Thêmamountvào đây là tạo nguồn sự thật thứ hai cho một con số tiền. Nếu đối soát cần nó ở phía order thì đó là lúc thêm — và phải chấp nhận rủi ro lệch. - 10.5 Không có ràng buộc nào cho các item trong cùng order phải cùng
transport_option. Một order = mộttransport(order.transport_idUNIQUE NOT NULL), màtransport_optionlại chọn ở từng item. Service phải đảm bảo, DB không ép được vì nó là quy tắc gom nhóm, không phải fact về một row. - 10.6 Bất biến cần test ở service (không ép ở DB, theo nguyên tắc "DB chỉ ràng
buộc fact"): máy trạng thái
refund(refund_tx_idchỉ có khiaccepted,rejection_reasonchỉ khirejected,return_to_buyer_transport_idchỉ ở nhánh backflow), vàitem.cancelled_by_idchỉ có nghĩa khicancelled_atđã set.
Đợt viết contract bắt đầu 2026-07-28, đi từng module một. Nguồn: schema
(migrations/) cho hình dạng, ../docs/typst/spec/source-of-truth.typ (UC-001→013,
BR-001→020) cho nghiệp vụ. Code Go ở api/, handler/, service.go là scaffold cũ,
không phải nguồn tham chiếu.
Convention dùng chung ghi ở docs/superpowers/specs/2026-07-28-order-api-design.md §1 —
đọc nó trước khi viết fragment cho module tiếp theo.
-
11.1
order— xong. 34 endpoint, 8 bảng, kèm surface moderator. Delta schema mà nó kéo theo nằm ở §2 của spec doc (5 mục: 3 cột UC-006 trênorder,payout_session_id,refundsang đồng hồ 48h,product_spu.shipping_paid_by, 3 kind mới trongid/kinds.go). Chưa làm migration nào. -
11.2
finance— xong. 25 endpoint. Quyết định định hình cả surface: client không tự mở payment session — order tạo (checkout / accept offer / phí ship) hoặc job tạo (payout); user chỉ thêm leg vào session đã có (POST /payment-sessions/{id}/payments), mỗi rail một leg một gateway URL, và đó cũng là cách split-tender hoạt động. Ngoại lệ duy nhất làwithdrawal. Ledger ví phân trang theoseqkhông theocreated_at(timestamp trùng thì không replay được), nênwallet_transactionkhông cần kind id mới —seqvừa là danh tính vừa là thứ tự. Findings: 5.7–5.10. -
11.3 Cả 7 module xong (2026-07-28). 166 endpoint, 200 schema:
| module | endpoint | ghi chú định hình | |---|---|---| | `account` | 44 | 11 bảng: auth + profile + contact + device + notification + favorite/follow + KYC + admin | | `order` | 34 | pay-first, transition là POST lên sub-resource danh từ | | `catalog` | 25 | browse/search/feed + SKU + stock + category/tag + moderation | | `finance` | 25 | client không tự mở session; withdrawal cần admin duyệt | | `trust` | 19 | feedback (blind) tách khỏi review; report queue | | `common` | 10 | upload presigned, server đặt `object_key` | | `chat` | 9 | 1 thread / 1 cặp; đọc đều theo cursor thời gian | Bốn quyết định lặp lại ở nhiều module, đáng ghi vì đều xuất phát từ schema: – **Bảng hypertable thì "mark read" nhận mốc thời gian, không nhận danh sách id.** `notification` và `chat.message` đều chunk theo `created_at`; update theo id lẻ fan-out qua mọi chunk. Kèm hệ quả tốt: `notification` **không cần** id mờ nào cả. – **Ngược lại `chat` GET unread-count vẫn không tránh được fan-out** vì read state là per-message (mục 8.1 chưa chốt). Đã ghi thẳng vào description của endpoint đó thay vì giấu đi. – **Ledger `wallet_transaction` phơi `seq`, không phơi id** — `seq` vừa là danh tính vừa là thứ tự, nên tiết được một prefix vĩnh viễn. – **Số tài khoản / push token / `vault_secret_path` không bao giờ ra khỏi API.** `BankAccount.account_number_masked`, `Device.push_token_suffix`, và `Option` cố ý không có `vault_secret_path`. -
11.5 Id schema dồn hết về
openapi.base.yaml. Ban đầu mỗi fragment tự khai id của module khác (OrderAccountID,FinanceAccountID, sắp cóCommonAccountID…) vì namespace schema là phẳng nên phải prefix. Sai chỗ: id là primitive củashared/id, không của module, và hơn nửa số kind bị 3–4 fragment tham chiếu. Giờ base khai đúng một schema cho mỗi kind trongkinds.go(25 cái) +CurrencyCode, fragment chỉ$ref. Lý tưởng nhất làcmd/specgensinh chúng từkinds.gođể chỉ còn một nguồn — chưa làm,kinds.gokhông có registry để liệt kê. -
11.6 5 kind mới — đã thêm.
CartItem→crt,DraftOrder→drf,Transport→trp,Device→**dvc**,IdentityDocument→idd. Chốt 2026-07-28, lúc chưa phát id nào. 25 kind, khớp 1-1 với 25 id schema ởopenapi.base.yaml.RefsResolvekhông bắt được lệch giữa hai chỗ này — nó chỉ kiểm YAML, nên thêm entity mà quênkinds.gothì lỗi chỉ hiện lúc marshal đầu tiên. -
11.8 Gateway đã scaffold — 166/166 route đăng ký, mọi handler trả 501.
errx.ErrNotImplemented(501, codenot_implemented). 501 chứ không 404: hai cái nói hai chuyện khác nhau, 404 là client sai URL còn 501 là URL đúng mà chưa có tính năng — nối route documented vào 404 là bắt client debug sai đầu. Handler struct vẫn giữsvc/v/logdù chưa dùng, để graph fx còn thật (pool của module vẫn mở, config vẫn validate lúc khởi động) và viết một method là sửa tại chỗ chứ không phải đi nối dây lại.handler.Payment→handler.Financecho khớp tên module. Route viết tay trongrouter.go, không sinh từ spec lúc chạy — router dựng từ document sẽ passAllPathsRoutedmột cách tự động và mất luôn khả năng bắt endpoint documented mà chưa ai nối. Bảng tên handler thì sinh một lần bằng script có assert hai chiều với spec (thiếu tên → fail, tên không có route → fail). -
11.9
AllPathsRoutedđã thành hard fail (đảo lại 11.4). Đã verify guard sống: bỏ một dòng route ra khỏirouter.gothì test fail và chỉ đúngPOST /orders/{id}/receipt. Chiều nguy hiểm hơn (route sống không có contract) vẫn không kiểm được vìhttp.ServeMuxkhông expose pattern đã đăng ký. -
11.10 Hai test đã bị xoá cùng handler cũ, cần viết lại khi implement.
handler/catalog_test.go(đã xoá) assert hai hành vi vẫn còn giá trị: handler lấyuserIDtừgwctxchứ không từ body, và request sai validate tag thì trả 400 trước khi gọi service.router_test.gocũng mấtPublicGetListing_RejectsRawID— id thô trong path phải là 400, không được xuống service dưới dạng zero value. Cả ba phải quay lại cùng handler thật. -
11.7 Delta schema mà spec 7 module kéo theo. Ngoài 4 mục ở §2 của spec doc order, thêm:
catalog.product_spucầnshipping_paid_by(đã ghi), vàfinance.payment_sessioncần index("from_id","created_at" DESC)(mục 5.8). Chưa apply migration nào. -
11.4
openapi_contract_test.gođã đổi ý nghĩa. Trước: mọi path trong spec phải resolve trên router (hard fail) — sai hướng cho contract-first, vì viết fragment trước handler thành ra test đỏ. Giờ là hai test:RefsResolve(hard fail — mọi$refphải trỏ tới component có thật, đây là guard thật vì merge làm phẳng schema của cả 7 module vào một namespace nên order ref đượcPaymentSessioncủa finance), vàAllPathsRoutedchỉ báo cáo (56/72 chưa có route). 405 cũng tính là chưa có route: ServeMux trả 405 khi path có mà method khác, nên chỉ nhìn 404 sẽ coiGET /orderslà đã có nhờ routePOST /orders. Đổi lại thành hard fail khi gateway được viết xong.
Cách chuẩn giờ đã dùng được — cmd/migrate compile và chạy. Trỏ mọi DSN vào một
database trắng (DB dev đã có schema_migrations đánh dấu 001 nên migrate sẽ skip
và không nói lên gì). Container không publish port, nên lấy IP trên bridge của compose:
DSN="postgres://app:app@172.18.0.2:5432/shopnexus_verify?sslmode=disable" # ip: xem docker inspect
GATEWAY_ADDR=localhost:8080 INSTANCE_ID=verify LOG_LEVEL=info \
ID_CIPHER_KEY=0123456789abcdef0123456789abcdef JWT_SECRET=0123456789012345678901234567890123 \
NATS_URL=nats://172.18.0.4:4222 REDIS_ADDR=172.18.0.3:6379 REDIS_PASSWORD=app \
ACCOUNT_DB_DSN=$DSN CATALOG_DB_DSN=$DSN ORDER_DB_DSN=$DSN CHAT_DB_DSN=$DSN \
TRUST_DB_DSN=$DSN FINANCE_DB_DSN=$DSN OBSERVABILITY_DB_DSN=$DSN \
go run ./cmd/migrateMuốn assert trên schema thì vẫn dùng psql bên trong container db (không cần port
trên host):
C=shopneuxsnew-db-1; DB=shopnexus_verify
docker exec -i $C psql -U app -d postgres -c "DROP DATABASE IF EXISTS $DB WITH (FORCE)" -c "CREATE DATABASE $DB"
for m in account catalog chat common order finance observability trust; do
docker exec -i $C psql -U app -d $DB -c "CREATE SCHEMA IF NOT EXISTS \"$m\""
{ printf 'SET search_path TO "%s", public;\n' "$m"; cat internal/module/$m/migrations/*.sql; } \
| docker exec -i $C psql -U app -d $DB -v ON_ERROR_STOP=1 --single-transaction -f -
done--single-transaction là để giống postgres.Migrate, vốn đưa cả file qua một
pool.Exec (Postgres bọc trong implicit transaction). Không có cross-schema FK nên thứ
tự module không quan trọng. Hai cái bẫy đã dính: (1) đừng coi mọi stdout là lỗi —
create_hypertable/add_retention_policy trả về result row, chỉ grep ERROR|FATAL;
(2) SAVEPOINT không dùng được vì psql chạy autocommit, muốn test một INSERT phải hỏng
thì chạy riêng một lệnh.
Nếu docker báo permission denied dù đã ở trong group docker: shell hiện tại lấy
credential từ trước lúc thêm group. sg docker -c '<lệnh>' chạy được ngay, hoặc đăng
nhập lại.
Trạng thái lần chạy cuối (2026-07-28): cả 8 module apply sạch, kèm assert cho
draft_order.spu_snapshot, ví đa tiền tệ (5.1) và p95 qua cagg (9.4).
PostGIS, TimescaleDB 2.28.3 và timescaledb_toolkit 1.23.0 đều có sẵn trên
timescale/timescaledb-ha:pg18.
Quyết định đã cân nhắc và kết luận, kèm lý do (lý do mới là phần dễ mất):
- Không cần bảng seller/shop. Marketplace C2C thuần:
catalog.product_spu.account_idlà seller,common.option.owner_idchứa cấu hình payment/transport riêng của người bán,trust.reputationgiữ rating,finance.bank_account/tax_infogiữ payout.account.profilechính là trang shop. "Vacation mode" cũng không cần cột mới:listing_status='hidden'là đủ. - Session/refresh token lưu ở Redis, không phải Postgres.
cache.ClientcóSetvới TTL +Deletenên revoke rẻ hơn DB. Hai điều cần nhớ ở tầng app: khi setstatus='suspended'phải chủ độngDeletekey session (DB không chặn được token đang lưu hành), và flush Redis = logout toàn hệ thống. - Review sản phẩm thuộc
trust, không thuộccatalog. Trust đã sở hữu feedback hai chiều theo order; một chỗ authoritative là điều giữ rating người bán khỏi lệch giữa hai module. Khi chuyển đã bỏ: polymorphicref_type/ref_id(thànhspu_idtường minh),ref_type='account'(trùngfeedback),updated_at, và thang điểm 0..100 (đổi sangrating1..5 cho khớpfeedback). review.order_idNOT NULL — không mua thì không review.- Reply không giới hạn số lần trên mỗi thread. Constraint cũ của catalog
(
UNIQUE (ref_type, ref_id, account_id)) chặn mỗi user một reply/thread. - KHÔNG cache giá ở
product_spu. Sort feed theo giá là join sangproduct_sku(cùng schema) và planner không cần sort node: ordered index scan trênproduct_sku_price_idxlàm bảng dẫn, nested loop traproduct_spuqua unique indexproduct_spu_featured_sku_id_key, nested loop giữ nguyên thứ tự outer nênLIMITdừng sớm. Đã đo trên 50k listing: quét 24 index row để ra 20 kết quả, 98 buffer hit, 0.37ms, không có Sort. Một index không span 2 bảng, nhưng plan thì tránh được sort — đừng suy từ cái thứ nhất ra cái thứ hai. Lưu ý duy nhất: bảng dẫn làproduct_sku, nên nếu tỉ lệ listingactivexuống thấp thì scan phải đi qua nhiều entry hơn trước khi gom đủLIMIT. - Index browse đều partial
WHERE status='active' AND deleted_at IS NULL(created_at / cached_rating), thay cho index trênstatus— 4 giá trị thì planner không dùng. Riêng'pending'có partial index cho hàng đợi kiểm duyệt. Giá không nằm trong nhóm này, nó đi quaproduct_sku_price_idx. - Soft delete thống nhất bằng
deleted_atở cảproduct_spuvàproduct_sku;statuschỉ còn lo lifecycle + moderation. Hai thứ không chồng nhau:'hidden'là người bán tạm hạ một listing đang sống,deleted_atlà mất hẳn. Soft chứ không hard vìorder.itemgiữspu_id/sku_idmà không có FK, nên lịch sử đơn phải còn tra được sau khi người bán xoá listing. - Xoá category không cascade sang dữ liệu.
category_parent_id_fkeylàON DELETE SET NULL(xoá cha thì con thành root, không mất nhánh),product_spu_category_id_fkeylàON DELETE RESTRICT(phải dọn hết listing mới xoá được category;category_idNOT NULL nên SET NULL không dùng được). featured_sku_idnullable, không dùngDEFERRABLE. Nullable đã đủ phá vòng lặp: INSERT SPU (NULL) → INSERT SKU → UPDATE SPU.stock.sku_idcó FK thật — sau khi bỏref_typepolymorphic thì FK khai được, và hai bảng cùng schema nên không tốn gì.audit_logthay choupdated_atởaccountvàcatalog.- Mọi extension khai
WITH SCHEMA public. Extension là object cấp database và chỉ nằm ở một schema, nênCREATE EXTENSIONdướisearch_path = <module>, publicsẽ cài vào schema của module. Đã đo hai hệ quả khivectornằm ởcatalog: schema khác báotype "vector" does not exist, vàDROP SCHEMA catalog CASCADE— đúng quy trình re-migrate ở 7.4 — kéo theodrop cascades to extension vector. Cái bẫy thật nằm ở chỗ khác: module thứ hai cần vector sẽ khaiCREATE EXTENSION IF NOT EXISTS vectorvà gặp no-op im lặng (đã tồn tại ở cấp DB), extension vẫn ởcatalog, rồi CREATE TABLE của nó fail với lỗi trỏ sai chỗ — migration của chính nó không tự chữa được. Nếu sau này mỗi module một database thì chuyện này gần như vô hại (mỗi DB một extension riêng); nó chỉ cắn ở mô hình chung DB, tức là mô hình hiện tại. - Vector index cho BGE-M3: HNSW cho cả dense và sparse.
dense vector(1024)+vector_cosine_ops,sparse sparsevec(250048)+sparsevec_ip_ops. – HNSW chứ không IVFFlat: IVFFlat tuy không có giới hạn 1000 nnz nhưng cần k-means training trên dữ liệu có sẵn, mà các bảng embedding khởi tạo rỗng rồi fill dần bằng cron → recall tệ cho tới khi rebuild. Giới hạn nnz thì xử lý được bằng capmax_length(mục 2.1), rẻ hơn nhiều so với đổi loại index. –sparsevec_ip_opsvìcompute_lexical_matching_scorecủa BGE-M3 là dot product các weight của token chung, không phải L2 hay cosine. – Dense giữ cosine: FlagEmbedding trả về vector đã normalize nên cosine và IP xếp hạng y nhau, nhưng cosine vẫn đúng nếu norm có lúc lệch khỏi 1. –sparsevec(250048)chỉ cần lớn hơn token id lớn nhất (vocab XLM-R ~250k); chiều khai dư không tốn storage vì sparsevec chỉ lưu phần non-zero. Nhưng insert vector khai chiều khác là pgvector báo lỗi → fix cứng con số này ở tầng job. – Khi viết query:<#>trả về inner product âm, nênORDER BY ... <#> :qtăng dần mới là giống nhất, và điểm phải đổi dấu khi trả ra ngoài. - Khoá chính là
BIGINTidentity, không phải UUID (trừ khoá dạng slug:catalog.tag.id,common.option.id— cả hai đã CHECK kebab-case). Ra ngoài API thìinternal/shared/idmã hoá thành chuỗiprefix_base32bằng Feistel 4 vòng khoá AES, tweak riêng theo từng kind. Hệ quả cho schema: cộtref_idpolymorphic không cần là TEXT nữa —BIGINT+ref_typelà đủ, vìFormatOpaque/ParseOpaquelấy kind từref_type. Hai điều phải nhớ ở tầng vận hành: khoá AES gần như vĩnh viễn (rotate là vô hiệu mọi id đã phát ra) nên phải backup như credential DB; và id mờ không phải authorization — mọi handler vẫn cần check ownership. - Tên kind và prefix bám theo tên bảng, không theo từ vựng sản phẩm.
Listing→ProductSPUvà prefixlst→spu(bảng làcatalog.product_spu);SKU→ProductSKU(prefixskuvốn đã khớp). Xoá kindComment: entity đó đã thànhtrust.review, không còn bảng nào, nêncmtlà prefix chết. Đổi prefix là cửa một chiều — nó là tweak của cipher, nên mọi id đã phát ra sẽ vô hiệu. Làm được vì lúc này chưa có dữ liệu và chưa phát id nào. Sau khi có user thật thì không đổi nữa. Đã kiểm bằng golden vector:acc/ord/msggiữ nguyên giá trị, chỉ nhánhspuđổi — đúng như kỳ vọng khi chỉ tweak của một kind thay đổi. 18 kind còn lại đã khớp tên bảng. Vẫn chưa có kind chodraft_order,transport,cart_item,review_vote— thiếu chứ không lệch, thêm khi moduleorderđược viết. - Đường đi của id, sau khi refactor
id.ID[K]xong.domain/port/adapterlàint64thuần; chỉ DTO trongapi/dùngID[K]; service đổi ở đúng biên (req.X.Int64()vào,id.Of[K](n)ra). Bốn quyết định kèm theo: – Subject của JWT là id mờ, không phải khoá thô: token nằm trong tay client và decode được, để khoá tuần tự ở đó là vô hiệu hoá chínhshared/id. –gwctxmangid.ID[id.Account], không mang string. Middleware parse một lần, nên handler thả thẳng vào DTO và không handler nào phải tự quyết định subject hỏng nghĩa là gì (nó thành 401 tại middleware). –id.SetCipherđược gọi trongcmd/gatewayquafx.InvokevớiID_CIPHER_KEY(env, required). Trước đó không ai gọi — lần marshal id đầu tiên sẽ panic. Độ dài khoá doSetCipherkiểm (16/24/32), không lặp lại ở validate tag để hai nơi khỏi lệch. – Event trên bus giữ khoá thô (OrderPlaced.OrderID int64): nó không phải payload công khai, và module nhận cần khoá để tra DB. common.resource_referencebị bỏ. Mỗi bảng tự quản lýattachments BIGINT[], vì ghi message/listing và reference của nó qua hai schema sẽ không atomic được khi tách service. Đánh đổi: mất FK, xem 4.2.- DB chỉ ràng buộc fact, không ràng buộc nghiệp vụ. Số dư không bao giờ âm là
fact →
CHECKtrong schema. Các bước chuyển trạng thái hợp lệ củarefundlà quy tắc nghiệp vụ → thuộc domain layer, dù hoàn toàn viết được thành CHECK. Lý do: quy tắc nghiệp vụ đổi theo sản phẩm, ép ở DB thì mỗi lần đổi là một migration, và cùng một quy tắc ở hai nơi sẽ lệch. Vẫn ép ở DB: dấu âm, cột phải đi cùng nhau, uniqueness, one-default-per-owner, idempotency key. - Refund là toàn đơn, không hoàn lẻ. Hoàn lẻ kéo theo cả một luồng thương lượng
(seller mở thương lượng → buyer không đồng ý → quá X ngày tự hoàn full → seller không
đồng ý thì mở dispute), nên không làm. Vì vậy không có bảng
refund_item, vàrefund.amountcũng không lưu (xem 10.4). draft_orderlà phiên mua của buyer, tồn tại để đóng băng điều khoản: listing hiện 100k thì lúc chốt đơn không được tính giá vừa update.item.draft_idvàorder.draft_id(UNIQUE) nối chuỗi lại: một phiên mua sinh tối đa một order, và item tồn tại được trước khi order có.- Đóng băng bằng
spu_snapshot JSONB, không phải con trỏ. Snapshot phải chứa cả các SKU, vì giá nằm ởproduct_sku— snapshot riêng SPU thì chẳng đóng băng được gì; kèmpackage_detailsvì báo giá vận chuyển tính từ đó. Không dùng id củaaudit_logđể trỏ, dù nó cũng lưu snapshot, vì: giá nằm ở bảng khác nên một id chỉ trỏ được một dòng trong khi cần SPU + N SKU;product_sputhuộccatalognên đó là con trỏ xuyên schema và sẽ là xuyên database; vàaudit_loglà log sẽ bị prune, prune là mất điều khoản đã chốt của đơn. item.order_idlàON DELETE NO ACTION, không phải CASCADE.itemgiữpayment_session_id+total_amount, tức nó là bản ghi tiền buyer đã trả; CASCADE cho phép một câuDELETE FROM "order"xoá sạch dấu vết tiền.- Địa chỉ trong order là snapshot JSONB của contact, không phải một dòng text. Mã hành
chính là thứ carrier API cần, nên thiếu nó thì không tạo lại được vận đơn từ dữ liệu đơn
(
account.contactcó thể đã đổi).order.pickup_addresscũng snapshot, vì carrier cần cả hai đầu. orderkhông có cột status, chỉ cócompleted_at/cancelled_at. Vòng đời là việc của service; "kết thúc khi nào" là fact, và nó biến "đơn đang mở của tôi" thành index lookup thay vì join qua items + transport + refunds.- Telemetry có vòng đời dữ liệu, khác
chat.message. Cả 4 hypertable củaobservabilityđều columnstore sau 7 ngày và bị drop theo retention (http_requests 30 ngày, provider_calls 30, runtime_metrics 90, business_events 180). Lý do khácchat: ở đây dữ liệu sinh ra để vẽ dashboard và bỏ được, còn tin nhắn là bằng chứng trong tranh chấp. Không đặt retention thì cái để giám sát hệ thống sẽ là cái làm sập hệ thống. - Mọi bảng telemetry có
instance. Không có nó thì từ pod thứ hai trở điruntime_metricsvô nghĩa (heap/goroutine của 3 pod đan vào một series) và không tách được "một pod chậm" khỏi "cả hệ thống chậm". Giá trị đến từ envINSTANCE_ID(required, không default về hostname: một giá trị sai mà trông hợp lý còn tệ hơn là chết lúc khởi động), và đượcSinkđóng dấu một lần thay vì truyền qua từng call site. - Dùng từ vựng columnstore mới của TimescaleDB 2.18+:
enable_columnstore/segmentby/orderby+CALL add_columnstore_policy, chứ không phảitimescaledb.compress+add_compression_policy(đã deprecated, sẽ bỏ ở major kế). Cả hai gọi cùng một hàm C, vàtimescaledb_information.jobsvẫn hiệnpolicy_compression. Đã verify trên 2.28.3. - Cagg
http_requests_1mbậtmaterialized_only = false. Để default (true) thì panel mất 1–2 phút gần nhất, vì job refresh luôn chạy sauend_offset. Vàstart_offsetlà 1 ngày chứ không phải 1 giờ: refresh chỉ tính lại bucket bị invalidate nên cửa sổ rộng gần như miễn phí, nhưng cứu được trường hợp job fail một lúc — cửa sổ 1 giờ sẽ để lại lỗ vĩnh viễn. - p95 lưu bằng sketch, không lưu bằng số. Cagg giữ
percentile_agg("duration_ms") AS latency(UddSketch củatimescaledb_toolkit), đọc ra bằngapprox_percentile(0.95, "latency")vàrollup("latency")khi gộp nhiều bucket. Không lưu sẵn một con số p95 mỗi phút vì percentile không cộng được: trung bình của các p95 theo phút không phải p95 của cả giờ. Sketch thì rollup được nên nó đúng ở mọi khoảng thời gian.percentile_contkhông dùng được ở đây — nó cần toàn bộ row, tức không partial-aggregate được, tức không nằm trong cagg được. Đã đo (1000 request, 90% ~10ms + 10% ~400ms): avg 51.5 / p50 13.0 / p95 410.3. - Cagg tạo được bên trong migration.
postgres.Migrateđưa cả file qua mộtpool.Exec, tức Postgres bọc trong implicit transaction, màCREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)vẫn chạy — đã verify trên 2.28.3, không cần tách file riêng. DùngWITH NO DATAđể migration không phải materialize lịch sử. - Idempotency là ràng buộc ở DB, không phải quy ước ở code.
transactioncóprovider_ref+UNIQUE ("payment_option","provider_ref"), nên webhook được gateway gửi lại (at-least-once, chúng retry tới khi nhận 200) sẽ vi phạm unique thay vì ghi có lần hai.wallet_transactioncóidempotency_keyunique cho cùng mục đích ở phía ví. - Ví đa tiền tệ:
wallet_pkey PRIMARY KEY ("account_id","currency"). Mở một số dư mới thành một INSERT, thay vì một migration đổi PK trên bảng đang giữ tiền thật — đó là thứ đắt, nên trả trước khi bảng còn rỗng. Bốn thứ đi kèm, tất cả là hệ quả bắt buộc chứ không phải tuỳ chọn: –wallet_transaction.currency(đóng luôn 5.2): một row ledger phải tự nói được đơn vị của nó. Trước đây phải suy từ ví, màtopup/adjustmentkhông có session nênpayment_session.fx_snapshotcũng không cứu được. –seqlà per ví, không per account:UNIQUE ("account_id","currency","seq"). Mỗi số dư là một ledger riêng và đánh số lại từ 1. – FK thật("account_id","currency") → wallet: cùng schema nên khai được, và nó chặn việc ghi tiền vào một ví chưa từng mở. – Không có index("account_id","currency","seq" DESC)riêng: unique constraint đã xếp đúng thứ tự cột đó và btree quét ngược rẻ y như quét xuôi. Index DESC riêng chỉ đáng khi thứ tự trộn chiều (a ASC, b DESC). Đổi lại:walletkhông còn tra được bằngaccount_idđơn lẻ, và chuyển tiền giữa hai ví của cùng một người là hai movement + một tỉ giá, không phải một UPDATE. - Ledger ví có thứ tự toàn phần bằng
seqper account, không dựa vàocreated_at(hai row trùng timestamp là chuỗi không replay được và thiếu row không phát hiện được).seqđược cấp dưới cùng row lock với việc cập nhật số dư (SELECT ... FOR UPDATEtrênwallet) — đó cũng là thứ chặn hai movement đồng thời tính*_aftertừ cùng một số dư cũ. - Ba vòng đời, ba enum riêng ở finance:
session_status(5 trạng thái),transaction_status(chỉpending/success/failedtheo đúng state machine của leg), vàverification_statuscho KYC. Trước đó cả ba dùng chung một enumstatus, nên mỗi cột đều giữ được trạng thái nó không bao giờ hợp lệ — và KYC thì thiếu hẳn'verified'. - Secret của provider không nằm trong DB.
common.option.datachỉ chứa config không bí mật; credential nằm ở Vault,option.vault_secret_pathgiữ đường dẫn. Provider client resolve lúc runtime, nên không có key material trong DB, backup, replica, hay snapshot củaaudit_log. common.resourcesoft delete.provider+object_keylà handle duy nhất tới object trên storage, nên row phải sống lâu hơn lệnh xoá cho tới khi reaper dọn xong object. Xoá row trước là leak file vĩnh viễn.- Chat: một thread cho mỗi cặp account, không scope theo listing. Cặp được lưu có
thứ tự (
account_a_id<account_b_id) nên không thể lách unique bằng cách đổi bên, và cùng CHECK đó chặn luôn tự chat với mình.buyer_id/seller_idcũ bị bỏ vì với một thread duy nhất thì vai trò không còn nghĩa. Muốn nhắc tới một sản phẩm cụ thể (gửi offer) thì đi quamessage.metadata. - Chat:
attachments UUID[]inline, cố ý không dùngcommon.resource_reference. Message và reference của nó ở hai schema, ghi cả hai atomic sẽ không còn khả thi khi tách module ra service riêng. - Chat:
message_type ('user','system'),sender_idNULL trên row 'system'. Có CHECK ép hai thứ đi cùng nhau. text và ảnh không phải type riêng — ảnh làattachments. - Chat:
messagelà hypertable theocreated_at, KHÔNG bật retention policy. Chunk hoá để index maintenance và vacuum không phình theo bảng, nhưng tin nhắn là bằng chứng trong refund dispute và user mong đợi lịch sử, nên việc xoá dữ liệu cũ là quyết định sản phẩm/pháp lý, không phải default. Cách bật ghi trong comment của file. account_interestkhông cần vector index. Nó được đọc bằng PK để dựng feed, còn ANN chạy trênproduct_embedding; mỗi account chỉ có vài slot nên quét là miễn phí. Index chỉ có nghĩa cho chiều ngược ("listing này thì ai muốn") và sẽ phải chịu chi phí các vector này bị ghi lại liên tục.date_of_birthchỉ CHECK lower bound> 1900-01-01. Không dùngCHECK (dob < now())vìnow()không IMMUTABLE, để trong CHECK là footgun khi dump/restore. Chặn ngày tương lai + tuổi tối thiểu thuộcdomain.contact.district_codenullable — VN bỏ cấp huyện từ 2025-07-01 (còn tỉnh–xã). Carrier vẫn giữ territory id riêng → đó là lý do cóprovider_codes JSONB.contact.locationnullable — geocode fail vẫn phải lưu được địa chỉ; mã hành chính mới là routing source of truth, pin chỉ advisory.profile.locale/timezoneNOT NULL không DEFAULT — tránh hardcode giả địnhvi-VNvào schema.notification_categorylà enum, không phải free text — preference key theo category, typo sẽ đọc thành "không có preference" → fallback sang gửi, tức opt-out bị bỏ qua âm thầm.- Default của notification preference nằm ở
domain, không trong DB — nó khác nhau theo category và đổi mà không nên phải migrate. Bảng chỉ lưu chỗ user lệch khỏi default. device.push_tokenUNIQUE toàn cục (không per-account) — FCM/APNs cấp lại đúng token đó cho máy cũ khi user khác đăng nhập; upsert theo token để dờiaccount_id, không thì chủ cũ vẫn nhận push của máy đó.