feat(gold): integrate VNAppMob SJC gold price with auto-refresh API key

This commit is contained in:
2026-06-15 10:49:19 +07:00
parent 2dc78733a1
commit b37a8dcd6f
10 changed files with 916 additions and 12 deletions
+9
View File
@@ -90,6 +90,8 @@ func main() {
exportOptionalEnv("STOCK_INCOME_EVENTS_API_TOKEN", cfg.StockIncomeEventsAPIToken)
exportOptionalEnv("GOLD_PRICE_API_URL", cfg.GoldPriceAPIURL)
exportOptionalEnv("GOLD_FX_API_URL", cfg.GoldFXAPIURL)
exportOptionalEnv("GOLD_VNAPP_API_URL", cfg.GoldVNAppAPIURL)
exportOptionalEnv("GOLD_VNAPP_API_KEY", cfg.GoldVNAppAPIKey)
exportOptionalEnv("COIN_BINANCE_API_URL", cfg.CoinBinanceAPIURL)
exportOptionalEnv("COIN_COINBASE_API_URL", cfg.CoinCoinbaseAPIURL)
exportOptionalEnv("COIN_COINGECKO_API_URL", cfg.CoinCoinGeckoAPIURL)
@@ -269,6 +271,8 @@ type config struct {
StockIncomeEventsAPIToken string
GoldPriceAPIURL string
GoldFXAPIURL string
GoldVNAppAPIURL string
GoldVNAppAPIKey string
CoinBinanceAPIURL string
CoinCoinbaseAPIURL string
CoinCoinGeckoAPIURL string
@@ -282,6 +286,7 @@ type config struct {
CronSecretParam string
GeminiAPIKeyParam string
StockIncomeEventsAPITokenParam string
GoldVNAppAPIKeyParam string
}
func loadConfig() config {
@@ -313,6 +318,8 @@ func loadConfig() config {
StockIncomeEventsAPIToken: envMap["STOCK_INCOME_EVENTS_API_TOKEN"],
GoldPriceAPIURL: envMap["GOLD_PRICE_API_URL"],
GoldFXAPIURL: envMap["GOLD_FX_API_URL"],
GoldVNAppAPIURL: envMap["GOLD_VNAPP_API_URL"],
GoldVNAppAPIKey: envMap["GOLD_VNAPP_API_KEY"],
CoinBinanceAPIURL: envMap["COIN_BINANCE_API_URL"],
CoinCoinbaseAPIURL: envMap["COIN_COINBASE_API_URL"],
CoinCoinGeckoAPIURL: envMap["COIN_COINGECKO_API_URL"],
@@ -326,6 +333,7 @@ func loadConfig() config {
CronSecretParam: strings.TrimSpace(envMap["CRON_SHARED_SECRET_PARAMETER_NAME"]),
GeminiAPIKeyParam: strings.TrimSpace(envMap["GEMINI_API_KEY_PARAMETER_NAME"]),
StockIncomeEventsAPITokenParam: strings.TrimSpace(envMap["STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME"]),
GoldVNAppAPIKeyParam: strings.TrimSpace(envMap["GOLD_VNAPP_API_KEY_PARAMETER_NAME"]),
}
}
@@ -339,6 +347,7 @@ func resolveSSMSecrets(ctx context.Context, cfg *config) error {
{name: cfg.CronSecretParam, target: &cfg.CronSecret},
{name: cfg.GeminiAPIKeyParam, target: &cfg.GeminiAPIKey},
{name: cfg.StockIncomeEventsAPITokenParam, target: &cfg.StockIncomeEventsAPIToken},
{name: cfg.GoldVNAppAPIKeyParam, target: &cfg.GoldVNAppAPIKey},
}
targetsByName := map[string][]*string{}
+74
View File
@@ -0,0 +1,74 @@
package gold
import (
"context"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/storage"
)
// sjcPriceFetcher is the subset of VNAppMobClient needed by the composite
// fetcher. It is implemented by *VNAppMobClient and by test stubs.
type sjcPriceFetcher interface {
FetchSJCPrice(ctx context.Context) (buy, sell float64, err error)
}
// compositePriceFetcher prefers VNAppMob SJC prices and falls back to the
// existing XAU/USD-derived chain when SJC is unavailable.
type compositePriceFetcher struct {
vnappmob sjcPriceFetcher
fallback priceFetcher
}
// NewCompositePriceFetcherFromEnv builds the production price fetcher using
// env-driven VNAppMob and fallback clients.
func NewCompositePriceFetcherFromEnv(kv storage.KVStore) priceFetcher {
return &compositePriceFetcher{
vnappmob: NewVNAppMobClientFromEnv(kv),
fallback: NewGoldPriceClientFromEnv(),
}
}
// FetchLuongPrice returns a representative VND/lượng price. It uses the SJC
// mid price when available, otherwise the XAU/USD-derived spot price.
func (f *compositePriceFetcher) FetchLuongPrice(ctx context.Context) (float64, error) {
buy, sell, err := f.FetchLuongPrices(ctx)
if err != nil {
return 0, err
}
return (buy + sell) / 2, nil
}
// FetchLuongPrices returns SJC buy/sell VND/lượng when VNAppMob is healthy,
// otherwise the XAU/USD-derived spot price for both sides.
func (f *compositePriceFetcher) FetchLuongPrices(ctx context.Context) (float64, float64, error) {
buy, sell, err := f.vnappmob.FetchSJCPrice(ctx)
if err == nil {
return buy, sell, nil
}
log.Warn("vnappmob_sjc_failed", "err", err)
p, err := f.fallback.FetchLuongPrice(ctx)
return p, p, err
}
// FetchPrice returns a GoldPrice. When VNAppMob succeeds the struct carries
// SJC buy/sell data and Source "vnappmob-sjc"; otherwise it falls back to the
// XAU/USD chain with Source "xau-fallback".
func (f *compositePriceFetcher) FetchPrice(ctx context.Context) (GoldPrice, error) {
buy, sell, err := f.vnappmob.FetchSJCPrice(ctx)
if err == nil {
mid := (buy + sell) / 2
return GoldPrice{
VNDPerLuong: mid,
Source: "vnappmob-sjc",
SJC: &SJCPrice{Buy: buy, Sell: sell},
}, nil
}
log.Warn("vnappmob_sjc_failed", "err", err)
p, err := f.fallback.FetchPrice(ctx)
if err != nil {
return GoldPrice{}, err
}
p.Source = "xau-fallback"
return p, nil
}
@@ -0,0 +1,152 @@
package gold
import (
"context"
"errors"
"strings"
"testing"
"github.com/tiennm99/miti99bot/internal/storage"
)
// stubVNAppMobClient implements just enough of the VNAppMob client contract
// for composite fetcher tests.
type stubVNAppMobClient struct {
buy float64
sell float64
err error
}
func (s *stubVNAppMobClient) FetchSJCPrice(context.Context) (float64, float64, error) {
return s.buy, s.sell, s.err
}
type stubFallbackFetcher struct {
price float64
err error
}
func (s *stubFallbackFetcher) FetchLuongPrice(context.Context) (float64, error) {
return s.price, s.err
}
func (s *stubFallbackFetcher) FetchLuongPrices(context.Context) (float64, float64, error) {
return s.price, s.price, s.err
}
func (s *stubFallbackFetcher) FetchPrice(context.Context) (GoldPrice, error) {
if s.err != nil {
return GoldPrice{}, s.err
}
return GoldPrice{VNDPerLuong: s.price, Source: "xau-fallback"}, nil
}
func TestCompositeFetcher_PrefersVNAppMob(t *testing.T) {
f := &compositePriceFetcher{
vnappmob: &stubVNAppMobClient{buy: 90_000_000, sell: 91_000_000},
fallback: &stubFallbackFetcher{err: errors.New("fallback unavailable")},
}
p, err := f.FetchPrice(context.Background())
if err != nil {
t.Fatalf("FetchPrice: %v", err)
}
if p.Source != "vnappmob-sjc" {
t.Fatalf("source: got %q, want vnappmob-sjc", p.Source)
}
if p.SJC == nil || p.SJC.Buy != 90_000_000 || p.SJC.Sell != 91_000_000 {
t.Fatalf("SJC: got %+v", p.SJC)
}
wantMid := 90_500_000.0
if p.VNDPerLuong != wantMid {
t.Fatalf("VNDPerLuong: got %v, want %v", p.VNDPerLuong, wantMid)
}
mid, err := f.FetchLuongPrice(context.Background())
if err != nil {
t.Fatalf("FetchLuongPrice: %v", err)
}
if mid != wantMid {
t.Fatalf("FetchLuongPrice: got %v, want %v", mid, wantMid)
}
buy, sell, err := f.FetchLuongPrices(context.Background())
if err != nil {
t.Fatalf("FetchLuongPrices: %v", err)
}
if buy != 90_000_000 || sell != 91_000_000 {
t.Fatalf("FetchLuongPrices: got buy=%v sell=%v", buy, sell)
}
}
func TestCompositeFetcher_FallsBack(t *testing.T) {
f := &compositePriceFetcher{
vnappmob: &stubVNAppMobClient{err: errors.New("vnappmob down")},
fallback: &stubFallbackFetcher{price: 88_000_000},
}
p, err := f.FetchPrice(context.Background())
if err != nil {
t.Fatalf("FetchPrice: %v", err)
}
if p.Source != "xau-fallback" {
t.Fatalf("source: got %q, want xau-fallback", p.Source)
}
if p.VNDPerLuong != 88_000_000 {
t.Fatalf("VNDPerLuong: got %v, want 88000000", p.VNDPerLuong)
}
mid, err := f.FetchLuongPrice(context.Background())
if err != nil {
t.Fatalf("FetchLuongPrice: %v", err)
}
if mid != 88_000_000 {
t.Fatalf("FetchLuongPrice: got %v, want 88000000", mid)
}
}
func TestGoldPriceLines(t *testing.T) {
t.Run("sjc", func(t *testing.T) {
lines := goldPriceLines(GoldPrice{
VNDPerLuong: 90_500_000,
Source: "vnappmob-sjc",
SJC: &SJCPrice{Buy: 90_000_000, Sell: 91_000_000},
})
want := []string{"Gold Spot Price (SJC)", "Buy:", "Sell:"}
if len(lines) != len(want) {
t.Fatalf("got %d lines, want %d: %v", len(lines), len(want), lines)
}
for i, w := range want {
if !strings.Contains(lines[i], w) {
t.Fatalf("line %d missing %q in %v", i, w, lines)
}
}
})
t.Run("fallback", func(t *testing.T) {
lines := goldPriceLines(GoldPrice{
XAUUSD: 3000,
USDVND: 25000,
VNDPerLuong: 90_000_000,
Source: "xau-fallback",
})
want := []string{"Gold Spot Price", "XAU:", "Rate:", "VND:"}
for i, w := range want {
if i >= len(lines) || !strings.Contains(lines[i], w) {
t.Fatalf("line %d missing %q in %v", i, w, lines)
}
}
})
}
func TestNewCompositePriceFetcherFromEnv(t *testing.T) {
f, ok := NewCompositePriceFetcherFromEnv(storage.NewMemoryKVStore()).(*compositePriceFetcher)
if !ok {
t.Fatalf("expected *compositePriceFetcher, got %T", f)
}
if f.vnappmob == nil {
t.Fatal("vnappmob client is nil")
}
if f.fallback == nil {
t.Fatal("fallback client is nil")
}
}
+22 -11
View File
@@ -27,13 +27,24 @@ func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Upda
if err != nil {
return s.replyPriceError(ctx, b, update, err)
}
lines := []string{
lines := goldPriceLines(p)
return chathelper.Reply(ctx, b, update.Message, strings.Join(lines, "\n"))
}
func goldPriceLines(p GoldPrice) []string {
if p.Source == "vnappmob-sjc" && p.SJC != nil {
return []string{
"Gold Spot Price (SJC)",
"Buy: " + FormatVND(p.SJC.Buy) + "/luong",
"Sell: " + FormatVND(p.SJC.Sell) + "/luong",
}
}
return []string{
"Gold Spot Price",
"XAU: " + FormatUSD(p.XAUUSD) + " USD/oz",
"Rate: " + FormatVND(p.USDVND) + "/USD",
"VND: " + FormatVND(p.VNDPerLuong) + "/luong",
}
return chathelper.Reply(ctx, b, update.Message, strings.Join(lines, "\n"))
}
func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Update) error {
@@ -79,11 +90,11 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
if !ok {
return chathelper.Reply(ctx, b, update.Message, "Luong must be a positive finite number.")
}
price, err := s.prices.FetchLuongPrice(ctx)
_, sellPrice, err := s.prices.FetchLuongPrices(ctx)
if err != nil {
return s.replyPriceError(ctx, b, update, err)
}
cost := qty * price
cost := qty * sellPrice
if !isSafeVND(cost) {
return chathelper.Reply(ctx, b, update.Message, "Trade value is too large.")
}
@@ -108,7 +119,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message,
"Bought "+FormatLuong(qty)+" luong gold @ "+FormatVND(price)+"/luong\nCost: "+FormatVND(cost)+
"Bought "+FormatLuong(qty)+" luong gold @ "+FormatVND(sellPrice)+"/luong\nCost: "+FormatVND(cost)+
"\nRemaining: "+FormatVND(p.VND))
}
@@ -126,13 +137,13 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
if !ok {
return chathelper.Reply(ctx, b, update.Message, "Luong must be a positive finite number.")
}
price, err := s.prices.FetchLuongPrice(ctx)
buyPrice, _, err := s.prices.FetchLuongPrices(ctx)
if err != nil {
return s.replyPriceError(ctx, b, update, err)
}
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
revenue := qty * price
revenue := qty * buyPrice
if !isSafeVND(revenue) {
return chathelper.Reply(ctx, b, update.Message, "Trade value is too large.")
}
@@ -155,7 +166,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message,
"Sold "+FormatLuong(qty)+" luong gold @ "+FormatVND(price)+"/luong\nRevenue: "+FormatVND(revenue)+
"Sold "+FormatLuong(qty)+" luong gold @ "+FormatVND(buyPrice)+"/luong\nRevenue: "+FormatVND(revenue)+
"\nRemaining: "+FormatVND(p.VND))
}
@@ -173,10 +184,10 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
lines := []string{"Gold Account Summary\n", "VND: " + FormatVND(p.VND), "Gold: " + FormatLuong(p.Luong) + " luong"}
totalValue := p.VND
if price, err := s.prices.FetchLuongPrice(ctx); err == nil {
goldValue := p.Luong * price
if buyPrice, _, err := s.prices.FetchLuongPrices(ctx); err == nil {
goldValue := p.Luong * buyPrice
totalValue += goldValue
lines = append(lines, "Price: "+FormatVND(price)+"/luong")
lines = append(lines, "Price: "+FormatVND(buyPrice)+"/luong")
lines = append(lines, "Gold value: "+FormatVND(goldValue))
lines = append(lines, "Total value: "+FormatVND(totalValue))
lines = append(lines, "Invested: "+FormatVND(p.Meta.Invested))
+74
View File
@@ -21,6 +21,10 @@ func (f fakePriceFetcher) FetchLuongPrice(context.Context) (float64, error) {
return f.price, f.err
}
func (f fakePriceFetcher) FetchLuongPrices(context.Context) (float64, float64, error) {
return f.price, f.price, f.err
}
func (f fakePriceFetcher) FetchPrice(context.Context) (GoldPrice, error) {
if f.err != nil {
return GoldPrice{}, f.err
@@ -164,6 +168,29 @@ func TestStatsWithAndWithoutPrice(t *testing.T) {
rb.AssertSentText(t, "Price: no price")
}
// spreadPriceFetcher returns different buy/sell prices so handler tests can
// verify that buys use the sell price and sells use the buy price.
type spreadPriceFetcher struct {
buy float64
sell float64
err error
}
func (f spreadPriceFetcher) FetchLuongPrice(context.Context) (float64, error) {
return (f.buy + f.sell) / 2, f.err
}
func (f spreadPriceFetcher) FetchLuongPrices(context.Context) (float64, float64, error) {
return f.buy, f.sell, f.err
}
func (f spreadPriceFetcher) FetchPrice(context.Context) (GoldPrice, error) {
if f.err != nil {
return GoldPrice{}, f.err
}
return GoldPrice{XAUUSD: 3000, USDVND: 25000, VNDPerLuong: (f.buy + f.sell) / 2}, nil
}
func modDepsForTest() modules.Deps {
return modules.Deps{KV: storage.NewMemoryKVStore()}
}
@@ -199,3 +226,50 @@ func TestHandlePriceFetchError(t *testing.T) {
}
rb.AssertSentText(t, "Could not fetch gold price")
}
func TestHandleBuyUsesSellPrice(t *testing.T) {
ctx := context.Background()
s := &state{
kv: storage.NewMemoryKVStore(),
prices: spreadPriceFetcher{buy: 90_000_000, sell: 91_000_000},
nowFn: func() time.Time { return time.UnixMilli(123) },
}
rb := testutil.NewRecordingBot(t)
_ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_topup 500000000"))
rb.Reset()
if err := s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 1")); err != nil {
t.Fatalf("buy: %v", err)
}
text := rb.LastSent().Text()
if !strings.Contains(text, "91.000.000 VND/luong") {
t.Fatalf("expected sell price 91.000.000 in %q", text)
}
p, _ := LoadPortfolio(ctx, s.kv, 7, 999)
if p.VND != 409_000_000 {
t.Fatalf("balance after buy at 91M: got %v, want 409000000", p.VND)
}
}
func TestHandleSellUsesBuyPrice(t *testing.T) {
ctx := context.Background()
s := &state{
kv: storage.NewMemoryKVStore(),
prices: spreadPriceFetcher{buy: 90_000_000, sell: 91_000_000},
nowFn: func() time.Time { return time.UnixMilli(123) },
}
rb := testutil.NewRecordingBot(t)
_ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_topup 500000000"))
_ = s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 1"))
rb.Reset()
if err := s.handleSell(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_sell 1")); err != nil {
t.Fatalf("sell: %v", err)
}
text := rb.LastSent().Text()
if !strings.Contains(text, "90.000.000 VND/luong") {
t.Fatalf("expected buy price 90.000.000 in %q", text)
}
p, _ := LoadPortfolio(ctx, s.kv, 7, 999)
if p.VND != 499_000_000 {
t.Fatalf("balance after sell at 90M: got %v, want 499000000", p.VND)
}
}
+2 -1
View File
@@ -19,6 +19,7 @@ import (
type priceFetcher interface {
FetchLuongPrice(ctx context.Context) (float64, error)
FetchLuongPrices(ctx context.Context) (buy, sell float64, err error)
FetchPrice(ctx context.Context) (GoldPrice, error)
}
@@ -30,7 +31,7 @@ type state struct {
}
func newState(kv storage.KVStore) *state {
return &state{kv: kv, prices: NewGoldPriceClientFromEnv()}
return &state{kv: kv, prices: NewCompositePriceFetcherFromEnv(kv)}
}
func (s *state) now() time.Time {
+15
View File
@@ -27,6 +27,14 @@ type GoldPrice struct {
XAUUSD float64
USDVND float64
VNDPerLuong float64
Source string // "vnappmob-sjc" or "xau-fallback"
SJC *SJCPrice // non-nil when Source == "vnappmob-sjc"
}
// SJCPrice holds VNAppMob SJC buy/sell quotes per lượng (VND).
type SJCPrice struct {
Buy float64
Sell float64
}
// GoldPriceClient fetches XAU/USD through a chain of free providers (see
@@ -80,6 +88,13 @@ func (c *GoldPriceClient) FetchLuongPrice(ctx context.Context) (float64, error)
return p.VNDPerLuong, nil
}
// FetchLuongPrices returns the same representative spot price for both buy and
// sell because the XAU/USD fallback has no bid/ask spread.
func (c *GoldPriceClient) FetchLuongPrices(ctx context.Context) (float64, float64, error) {
p, err := c.FetchLuongPrice(ctx)
return p, p, err
}
func (c *GoldPriceClient) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
+291
View File
@@ -0,0 +1,291 @@
package gold
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/storage"
)
const (
vnappmobDefaultURL = "https://api.vnappmob.com"
vnappmobKeyCacheKey = "vnappmob:api_key"
vnappmobRefreshBuffer = 24 * time.Hour
vnappmobHTTPTimeout = 10 * time.Second
)
// VNAppMobClient fetches Vietnam SJC gold prices from api.vnappmob.com.
// It self-manages a free JWT API key, caching it in KV and refreshing it
// before expiry or when the SJC endpoint returns 403.
type VNAppMobClient struct {
HTTP *http.Client
BaseURL string // optional override; default https://api.vnappmob.com
Token string // optional env override (GOLD_VNAPP_API_KEY)
KV storage.KVStore // module-scoped KV store
nowFn func() time.Time
mu sync.Mutex
}
// NewVNAppMobClientFromEnv creates a client reading GOLD_VNAPP_API_URL and
// GOLD_VNAPP_API_KEY from the environment. The API key env var is intended
// for local dev or SSM injection; when empty the client refreshes the key
// automatically via the VNAppMob refresh endpoint.
func NewVNAppMobClientFromEnv(kv storage.KVStore) *VNAppMobClient {
return &VNAppMobClient{
BaseURL: strings.TrimSpace(os.Getenv("GOLD_VNAPP_API_URL")),
Token: strings.TrimSpace(os.Getenv("GOLD_VNAPP_API_KEY")),
KV: kv,
}
}
func (c *VNAppMobClient) now() time.Time {
if c.nowFn != nil {
return c.nowFn()
}
return time.Now()
}
func (c *VNAppMobClient) baseURL() string {
if s := strings.TrimSpace(c.BaseURL); s != "" {
return strings.TrimRight(s, "/")
}
return vnappmobDefaultURL
}
func (c *VNAppMobClient) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: vnappmobHTTPTimeout}
}
// FetchSJCPrice returns the VNAppMob SJC buy/sell price per lượng in VND.
// On 403 it refreshes the API key once and retries.
func (c *VNAppMobClient) FetchSJCPrice(ctx context.Context) (buy, sell float64, err error) {
key, err := c.getKey(ctx)
if err != nil {
return 0, 0, fmt.Errorf("vnappmob: get key: %w", err)
}
buy, sell, err = c.fetchSJC(ctx, key)
if err == nil {
return buy, sell, nil
}
var statusErr *httpStatusError
if !errors.As(err, &statusErr) || (statusErr.StatusCode != http.StatusForbidden && statusErr.StatusCode != http.StatusUnauthorized) {
return 0, 0, err
}
log.Warn("vnappmob_sjc_auth_failed", "status", statusErr.StatusCode, "msg", "refreshing key after auth failure")
if refreshErr := c.refreshKey(ctx); refreshErr != nil {
return 0, 0, fmt.Errorf("vnappmob: 403 refresh failed: %w", refreshErr)
}
key, err = c.getKey(ctx)
if err != nil {
return 0, 0, fmt.Errorf("vnappmob: get key after refresh: %w", err)
}
return c.fetchSJC(ctx, key)
}
func (c *VNAppMobClient) fetchSJC(ctx context.Context, key string) (buy, sell float64, err error) {
endpoint := c.baseURL() + "/api/v2/gold/sjc"
if err := validateEndpoint(endpoint); err != nil {
return 0, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return 0, 0, fmt.Errorf("vnappmob: build request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)")
req.Header.Set("Authorization", "Bearer "+key)
resp, err := c.httpClient().Do(req)
if err != nil {
return 0, 0, fmt.Errorf("vnappmob: SJC request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return 0, 0, &httpStatusError{
StatusCode: resp.StatusCode,
msg: fmt.Sprintf("vnappmob: SJC status %d", resp.StatusCode),
}
}
var body struct {
Results []struct {
Buy1L json.Number `json:"buy_1l"`
Sell1L json.Number `json:"sell_1l"`
} `json:"results"`
}
dec := json.NewDecoder(resp.Body)
dec.UseNumber()
if err := dec.Decode(&body); err != nil {
return 0, 0, fmt.Errorf("vnappmob: SJC decode: %w", err)
}
if len(body.Results) == 0 {
return 0, 0, ErrNoGoldPrice
}
r := body.Results[0]
buy, err = r.Buy1L.Float64()
if err != nil {
return 0, 0, ErrNoGoldPrice
}
sell, err = r.Sell1L.Float64()
if err != nil {
return 0, 0, ErrNoGoldPrice
}
if buy <= 0 || sell <= 0 || math.IsNaN(buy) || math.IsNaN(sell) || math.IsInf(buy, 0) || math.IsInf(sell, 0) {
return 0, 0, ErrNoGoldPrice
}
return buy, sell, nil
}
type httpStatusError struct {
StatusCode int
msg string
}
func (e *httpStatusError) Error() string { return e.msg }
// getKey returns a valid API key, refreshing from KV or the remote endpoint
// when the current key is missing or close to expiry.
func (c *VNAppMobClient) getKey(ctx context.Context) (string, error) {
if c.Token != "" {
return c.Token, nil
}
c.mu.Lock()
defer c.mu.Unlock()
var cached struct {
Token string `json:"token"`
Exp int64 `json:"exp"`
}
if err := c.KV.GetJSON(ctx, vnappmobKeyCacheKey, &cached); err == nil {
if cached.Token != "" && !c.isExpired(cached.Exp) {
return cached.Token, nil
}
} else if !errors.Is(err, storage.ErrNotFound) {
log.Warn("vnappmob_kv_read_failed", "err", err)
}
if err := c.refreshKeyLocked(ctx); err != nil {
return "", err
}
// Re-read from KV to get the freshly stored key.
if err := c.KV.GetJSON(ctx, vnappmobKeyCacheKey, &cached); err != nil {
return "", fmt.Errorf("vnappmob: read refreshed key: %w", err)
}
if cached.Token == "" {
return "", fmt.Errorf("vnappmob: refreshed key is empty")
}
return cached.Token, nil
}
// refreshKey acquires a new JWT from VNAppMob and stores it in KV.
// It is safe to call concurrently; last-write-wins is acceptable because all
// callers receive a valid key.
func (c *VNAppMobClient) refreshKey(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.refreshKeyLocked(ctx)
}
func (c *VNAppMobClient) refreshKeyLocked(ctx context.Context) error {
endpoint := c.baseURL() + "/api/request_api_key?scope=gold"
if err := validateEndpoint(endpoint); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return fmt.Errorf("vnappmob: build refresh request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)")
resp, err := c.httpClient().Do(req)
if err != nil {
return fmt.Errorf("vnappmob: refresh request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("vnappmob: refresh status %d", resp.StatusCode)
}
var token string
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("vnappmob: refresh read body: %w", err)
}
// The endpoint may return either a JSON-quoted string or a raw JWT.
token = strings.TrimSpace(string(body))
var quoted string
if err := json.Unmarshal(body, &quoted); err == nil {
token = strings.TrimSpace(quoted)
}
if token == "" {
return fmt.Errorf("vnappmob: refresh returned empty token")
}
exp, err := jwtExp(token)
if err != nil {
log.Warn("vnappmob_jwt_parse_failed", "err", err)
// Store anyway; next call will refresh if expiry cannot be verified.
}
if err := c.KV.PutJSON(ctx, vnappmobKeyCacheKey, struct {
Token string `json:"token"`
Exp int64 `json:"exp"`
}{Token: token, Exp: exp}); err != nil {
return fmt.Errorf("vnappmob: cache key: %w", err)
}
log.Info("vnappmob_key_refreshed", "exp", exp)
return nil
}
// jwtExp extracts the exp claim from a JWT's payload segment using only the
// standard library. It does not verify the signature.
func jwtExp(token string) (int64, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return 0, fmt.Errorf("vnappmob: JWT has %d parts, want 3", len(parts))
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return 0, fmt.Errorf("vnappmob: decode JWT payload: %w", err)
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return 0, fmt.Errorf("vnappmob: unmarshal JWT claims: %w", err)
}
if claims.Exp == 0 {
return 0, fmt.Errorf("vnappmob: JWT missing exp claim")
}
return claims.Exp, nil
}
// isExpired reports whether a key expiring at exp (Unix seconds) should be
// refreshed now. A 24-hour buffer avoids midnight edge cases and clock skew.
func (c *VNAppMobClient) isExpired(exp int64) bool {
if exp == 0 {
return true
}
return c.now().After(time.Unix(exp, 0).Add(-vnappmobRefreshBuffer))
}
@@ -0,0 +1,265 @@
package gold
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/tiennm99/miti99bot/internal/storage"
)
func newTestVNAppMobClient(srv *httptest.Server, kv storage.KVStore) *VNAppMobClient {
return &VNAppMobClient{
HTTP: srv.Client(),
BaseURL: srv.URL,
KV: kv,
nowFn: func() time.Time { return time.Unix(1000, 0) },
}
}
func makeJWT(exp int64) string {
header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
payload, _ := json.Marshal(map[string]any{"exp": exp, "scope": "gold", "permission": "read"})
h := base64.RawURLEncoding.EncodeToString(header)
p := base64.RawURLEncoding.EncodeToString(payload)
return h + "." + p + ".dummy-signature"
}
func TestJWTExp(t *testing.T) {
now := time.Unix(1000, 0)
tests := []struct {
name string
token string
wantExp int64
wantErr bool
}{
{
name: "valid",
token: makeJWT(now.Add(48 * time.Hour).Unix()),
wantExp: now.Add(48 * time.Hour).Unix(),
},
{
name: "missing exp",
token: makeJWT(0),
wantErr: true,
},
{
name: "malformed",
token: "not-a-jwt",
wantErr: true,
},
{
name: "bad base64 payload",
token: "header.!!!.sig",
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := jwtExp(tc.token)
if tc.wantErr {
if err == nil {
t.Fatalf("want error, got exp=%d", got)
}
return
}
if err != nil {
t.Fatalf("jwtExp: %v", err)
}
if got != tc.wantExp {
t.Fatalf("exp: got %d, want %d", got, tc.wantExp)
}
})
}
}
func TestIsExpired(t *testing.T) {
now := time.Unix(1000, 0)
c := &VNAppMobClient{nowFn: func() time.Time { return now }}
if !c.isExpired(0) {
t.Fatal("exp=0 should be expired")
}
if !c.isExpired(now.Add(12 * time.Hour).Unix()) {
t.Fatal("key expiring in 12h should be expired (within 24h buffer)")
}
if c.isExpired(now.Add(48 * time.Hour).Unix()) {
t.Fatal("key expiring in 48h should not be expired")
}
}
func TestRefreshKey(t *testing.T) {
exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix()
var refreshHits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("refresh: want POST, got %s", r.Method)
}
if !strings.HasSuffix(r.URL.Path, "/api/request_api_key") {
t.Errorf("refresh path: got %s", r.URL.Path)
}
if scope := r.URL.Query().Get("scope"); scope != "gold" {
t.Errorf("refresh scope: got %q", scope)
}
atomic.AddInt32(&refreshHits, 1)
fmt.Fprint(w, makeJWT(exp))
}))
defer srv.Close()
kv := storage.NewMemoryKVStore()
c := newTestVNAppMobClient(srv, kv)
key, err := c.getKey(context.Background())
if err != nil {
t.Fatalf("getKey: %v", err)
}
if key == "" {
t.Fatal("expected non-empty key")
}
if atomic.LoadInt32(&refreshHits) != 1 {
t.Fatalf("refresh hits: got %d, want 1", refreshHits)
}
// A second call should reuse the cached key without hitting refresh again.
key2, err := c.getKey(context.Background())
if err != nil {
t.Fatalf("getKey cached: %v", err)
}
if key2 != key {
t.Fatal("cached key changed unexpectedly")
}
if atomic.LoadInt32(&refreshHits) != 1 {
t.Fatalf("refresh hits after cache: got %d, want 1", refreshHits)
}
}
func TestFetchSJCPrice(t *testing.T) {
exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/request_api_key":
fmt.Fprint(w, makeJWT(exp))
case "/api/v2/gold/sjc":
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
t.Errorf("missing bearer token, got %q", auth)
}
_, _ = w.Write([]byte(`{"results":[{"buy_1l":"90000000.0","sell_1l":"91000000.0"}]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
kv := storage.NewMemoryKVStore()
c := newTestVNAppMobClient(srv, kv)
buy, sell, err := c.FetchSJCPrice(context.Background())
if err != nil {
t.Fatalf("FetchSJCPrice: %v", err)
}
if buy != 90_000_000 || sell != 91_000_000 {
t.Fatalf("price: got buy=%v sell=%v", buy, sell)
}
}
func TestFetchSJCPrice_401Or403Refreshes(t *testing.T) {
exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix()
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
t.Run(fmt.Sprintf("status_%d", status), func(t *testing.T) {
var sjcHits, refreshHits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/request_api_key":
atomic.AddInt32(&refreshHits, 1)
fmt.Fprint(w, makeJWT(exp))
case "/api/v2/gold/sjc":
n := atomic.AddInt32(&sjcHits, 1)
if n == 1 {
w.WriteHeader(status)
return
}
_, _ = w.Write([]byte(`{"results":[{"buy_1l":"90000000.0","sell_1l":"91000000.0"}]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c := newTestVNAppMobClient(srv, storage.NewMemoryKVStore())
buy, sell, err := c.FetchSJCPrice(context.Background())
if err != nil {
t.Fatalf("FetchSJCPrice: %v", err)
}
if buy != 90_000_000 || sell != 91_000_000 {
t.Fatalf("price: got buy=%v sell=%v", buy, sell)
}
if atomic.LoadInt32(&sjcHits) != 2 {
t.Fatalf("SJC hits: got %d, want 2", sjcHits)
}
// First refresh gets the initial key; the auth failure triggers a second refresh.
if atomic.LoadInt32(&refreshHits) != 2 {
t.Fatalf("refresh hits: got %d, want 2", refreshHits)
}
})
}
}
func TestFetchSJCPrice_FallbackError(t *testing.T) {
exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/request_api_key":
fmt.Fprint(w, makeJWT(exp))
case "/api/v2/gold/sjc":
_, _ = w.Write([]byte(`{"results":[]}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
kv := storage.NewMemoryKVStore()
c := newTestVNAppMobClient(srv, kv)
_, _, err := c.FetchSJCPrice(context.Background())
if !errors.Is(err, ErrNoGoldPrice) {
t.Fatalf("got %v, want ErrNoGoldPrice", err)
}
}
func TestFetchSJCPrice_InvalidValues(t *testing.T) {
exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix()
cases := []struct {
name string
body string
}{
{name: "zero values", body: `{"results":[{"buy_1l":0,"sell_1l":0}]}`},
{name: "missing fields", body: `{"results":[{"buy_1l":90000000}]}`},
{name: "nan values", body: `{"results":[{"buy_1l":"NaN","sell_1l":"NaN"}]}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/request_api_key":
fmt.Fprint(w, makeJWT(exp))
case "/api/v2/gold/sjc":
_, _ = w.Write([]byte(tc.body))
}
}))
defer srv.Close()
c := newTestVNAppMobClient(srv, storage.NewMemoryKVStore())
_, _, err := c.FetchSJCPrice(context.Background())
if err == nil {
t.Fatal("expected error for invalid values")
}
})
}
}
+12
View File
@@ -47,6 +47,16 @@ Parameters:
Default: ""
Description: Optional USD/VND FX API URL override. Empty uses the built-in ExchangeRate-API open endpoint.
GoldVNAppAPIURL:
Type: String
Default: ""
Description: Optional VNAppMob API base URL override. Empty uses https://api.vnappmob.com.
GoldVNAppAPIKeyParameterName:
Type: String
Default: ""
Description: Optional SSM SecureString parameter name containing a pre-issued VNAppMob JWT API key.
CoinBinanceAPIURL:
Type: String
Default: ""
@@ -169,6 +179,8 @@ Resources:
STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME: !Ref StockIncomeEventsAPITokenParameterName
GOLD_PRICE_API_URL: !Ref GoldPriceAPIURL
GOLD_FX_API_URL: !Ref GoldFXAPIURL
GOLD_VNAPP_API_URL: !Ref GoldVNAppAPIURL
GOLD_VNAPP_API_KEY_PARAMETER_NAME: !Ref GoldVNAppAPIKeyParameterName
COIN_BINANCE_API_URL: !Ref CoinBinanceAPIURL
COIN_COINBASE_API_URL: !Ref CoinCoinbaseAPIURL
COIN_COINGECKO_API_URL: !Ref CoinCoinGeckoAPIURL