Trong bài viết

Toàn bộ luồng trong một phút

Kim Tài không để ứng dụng Flutter gọi thẳng từng website vàng. Việc đọc HTML, giữ secret, xử lý fallback và sửa parser khi nguồn đổi đều nằm ở backend. Mobile chỉ nhận một contract ổn định, nên thay đổi ở website nguồn không buộc người dùng cập nhật ứng dụng.

Pipeline rút gọntext
Website hoặc API công khai
  -> Edge Function + adapter theo nguồn
  -> quote chuẩn theo VND/lượng
  -> PostgreSQL + public view
  -> Supabase REST
  -> Flutter repository + cache
  1. Backend lấy và parse dữ liệu theo contract của từng nguồn.
  2. Database lưu lịch sử, còn public view chỉ phát hành record mới nhất đã vượt quality gate.
  3. Flutter gọi API đọc, validate lại response rồi chọn remote, cache, giá thủ công hoặc trạng thái rỗng.

Bước 1: Lấy dữ liệu từ nguồn

Một lịch pg_cron gọi Supabase Edge Function theo từng nhóm provider. Mỗi provider có adapter riêng, khai báo URL chính, URL dự phòng, kiểu payload, đơn vị, market và các product code hoặc label được phép nhận. Nhờ vậy parser không phải đoán một trang HTML bất kỳ đang nói về sản phẩm nào.

Contract đầu ra của adapterts
type ProviderQuote = {
  providerId: string;
  priceMarketCode: string;
  productCode: string;
  buy: number | null;
  sell: number | null;
  unit:
    | "vnd_per_chi"
    | "vnd_per_luong"
    | "thousand_vnd_per_chi"
    | "thousand_vnd_per_luong";
  quotedAt: string;
  sourceUrl: string;
};

Với JSON, adapter map một tập field đã biết như buy và sell. Với HTML, adapter match product code trước; nếu không có code thì match label có token boundary, sau đó chỉ tìm giá ở phần text nằm sau label. Cách này ngăn số trong tên sản phẩm bị hiểu nhầm thành giá.

  1. Fetch nguồn chính với deadline và giới hạn response.
  2. Từ chối HTTP lỗi, challenge page, payload sai shape hoặc unit mơ hồ.
  3. Thử nguồn dự phòng đã khai báo nếu lỗi thuộc nhóm được phép fallback.
  4. Parse từng provider và market độc lập để một nguồn hỏng không làm mất dữ liệu sạch của nguồn khác.

Bước 2: Chuẩn hoá rồi lưu database

Mọi nguồn phải hội tụ về cùng một đơn vị là số nguyên VND trên một lượng. Không dùng double và không format thành chuỗi ở bước này. Một lượng bằng mười chỉ, nên normalizer chỉ cần các hệ số nguyên dưới đây.

Quy tắc đổi về VND trên một lượng
Đơn vị nguồnPhép đổiVí dụ
VND/chỉgiá × 1014.500.000 → 145.000.000
VND/lượnggiữ nguyên145.000.000 → 145.000.000
nghìn VND/chỉgiá × 10.00014.500 → 145.000.000
nghìn VND/lượnggiá × 1.000145.000 → 145.000.000
Quality gate rút gọnts
const accepted =
  Number.isSafeInteger(buy) &&
  Number.isSafeInteger(sell) &&
  buy >= 10_000_000 &&
  sell <= 300_000_000 &&
  buy <= sell &&
  (BigInt(sell) - BigInt(buy)) * 10n <= BigInt(buy);

Gate yêu cầu đủ cả giá mua và bán, hai giá là safe integer, mua không lớn hơn bán và spread không vượt 10% giá mua. Khoảng 10 đến 300 triệu VND/lượng là guardrail kỹ thuật tại thời điểm bài viết, không phải dự báo thị trường. Khi giá thật đạt biên, backend và mobile phải cùng cập nhật contract.

Record lưu trong PostgreSQLts
type PriceTick = {
  provider_id: string;
  market_code: string;
  gold_type: string;
  buy_price_per_luong_vnd: number;
  sell_price_per_luong_vnd: number;
  quoted_at: string;
  source_url: string;
};

Identity của một tick là provider, market, product và quoted_at; giá được lưu bằng bigint. Bảng gold_price_ticks giữ lịch sử để audit. Ứng dụng không đọc bảng này trực tiếp mà đọc market_prices_latest_by_market, một public view chỉ lấy provider, market và product đang bật, đủ hai phía giá và vẫn đạt quality predicate. Raw payload cùng crawl log không được project vào view.

Ý tưởng của latest viewsql
select distinct on (provider_id, market_code, gold_type)
  provider_id, market_code, gold_type,
  buy_price_per_luong_vnd, sell_price_per_luong_vnd,
  quoted_at, source_url
from gold_price_ticks
where buy_price_per_luong_vnd <= sell_price_per_luong_vnd
  and (sell_price_per_luong_vnd - buy_price_per_luong_vnd) * 10
      <= buy_price_per_luong_vnd
order by provider_id, market_code, gold_type, quoted_at desc;

Bước 3: Mobile gọi API lấy về

Supabase tự cung cấp REST API cho public view. Flutter dùng client-safe SUPABASE_URLSUPABASE_PUBLISHABLE_KEY, chỉ select những cột màn hình cần. App không biết technical fetch URL, cron secret hay service-role key của pipeline ghi dữ liệu.

Flutter đọc latest viewdart
final rows = await supabase
    .from('market_prices_latest_by_market')
    .select(
      'provider_id, market_code, gold_type, '
      'buy_price_per_luong_vnd, sell_price_per_luong_vnd, '
      'quoted_at, source_url',
    )
    .order('quoted_at', ascending: false);

final prices = rows
    .map(GoldPrice.fromJson)
    .where((price) => price.isValid)
    .toList();

Repository mobile validate lại identity, integer range, thứ tự mua bán, timestamp và source URL HTTPS canonical nếu có. Một row lỗi bị loại riêng thay vì làm crash toàn snapshot, nhưng response có row lỗi không được ghi đè remote cache sạch.

  1. Đọc remote view và trả snapshot remote khi có row hợp lệ.
  2. Nếu remote lỗi hoặc rỗng, đọc last-known snapshot từ cache.
  3. Nếu cache không dùng được, lấy giá người dùng đã nhập ở storage manual riêng.
  4. Nếu cả ba tầng đều không có dữ liệu, trả trạng thái empty thay vì tạo giá giả.

Cache merge theo providerId::priceMarketCode::productCode; record có timestamp mới hơn thắng. Vì cache theo identity, một response partial không xoá last-known quote của provider đang tạm vắng mặt. Đây là lý do market code phải xuất hiện ở cả database identity lẫn mobile cache key.

Tóm lại, lấy giá vàng online không phải là cho mobile gọi fetch() tới một website. Quy trình đúng là gom dữ liệu ở server, chuẩn hoá về một contract có thể kiểm tra, lưu lịch sử trong database, chỉ phát hành read model an toàn và để mobile tập trung vào đọc API cùng fallback.

Xem sản phẩm

Dữ liệu kỹ thuật, trải nghiệm đơn giản

Kim Tài dùng pipeline này để đưa giá tham khảo vào ứng dụng mà không biến mobile thành một web crawler.