//go:build ignore // Confidential BFF sample for the workplace wallet channel. // // Copy this file and wallet-channel-sample.html onto a cross-origin host // (not Jianliao Web / API). Register that URL as the workplace app web_route. // // CLIENT_ID=app_xxx \ // CLIENT_SECRET=ocs_dev_replace_me \ // WEBHOOK_SECRET=wcs_dev_replace_me \ // TSDD_API=http://127.0.0.1:8090/v1 \ // PUBLIC_BASE=https://app.example \ // go run wallet-channel-sample-bff.go // // No production secrets. Do not commit CLIENT_SECRET / WEBHOOK_SECRET. package main import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "path/filepath" "runtime" "strconv" "strings" "sync" "time" ) const cookieName = "wcs_sample_sid" type session struct { Sub string Exp time.Time } type localOrder struct { MerchantOrderNo string `json:"merchant_order_no"` Direction string `json:"direction"` AppAmount int64 `json:"app_amount"` Status string `json:"status"` Sub string `json:"sub"` PrepayID string `json:"prepay_id,omitempty"` OrderNo string `json:"order_no,omitempty"` IMAmount int64 `json:"im_amount,omitempty"` IMCurrency string `json:"im_currency,omitempty"` AppCurrency string `json:"app_currency,omitempty"` Credited bool `json:"credited"` Jianliao any `json:"jianliao,omitempty"` } type bff struct { apiBase string clientID string clientSecret string webhookSecret string publicBase string html []byte mu sync.Mutex sessions map[string]session orders map[string]*localOrder balance int64 httpClient *http.Client } func main() { clientID := strings.TrimSpace(os.Getenv("CLIENT_ID")) clientSecret := os.Getenv("CLIENT_SECRET") if clientID == "" || clientSecret == "" { log.Fatal("set CLIENT_ID and CLIENT_SECRET (confidential OAuth secret; never ship in the HTML)") } apiBase := v1Root(os.Getenv("TSDD_API")) if apiBase == "" { log.Fatal("set TSDD_API to the Jianliao API /v1 root") } html, err := os.ReadFile(sampleHTMLPath()) if err != nil { log.Fatalf("sample HTML: %v", err) } listen := strings.TrimSpace(os.Getenv("LISTEN")) if listen == "" { listen = "127.0.0.1:8787" } s := &bff{ apiBase: apiBase, clientID: clientID, clientSecret: clientSecret, webhookSecret: os.Getenv("WEBHOOK_SECRET"), publicBase: strings.TrimRight(strings.TrimSpace(os.Getenv("PUBLIC_BASE")), "/"), html: html, sessions: map[string]session{}, orders: map[string]*localOrder{}, httpClient: &http.Client{Timeout: 15 * time.Second}, } mux := http.NewServeMux() mux.HandleFunc("/", s.handleIndex) mux.HandleFunc("/session", s.handleSession) mux.HandleFunc("/me", s.handleMe) mux.HandleFunc("/prepay", s.handlePrepay) mux.HandleFunc("/payout", s.handlePayout) mux.HandleFunc("/orders", s.handleOrders) mux.HandleFunc("/callbacks/jianliao-wallet", s.handleWebhook) log.Printf("sample BFF listen=%s api=%s client_id=%s (secret not logged)", listen, apiBase, clientID) log.Fatal(http.ListenAndServe(listen, mux)) } func sampleHTMLPath() string { if p := strings.TrimSpace(os.Getenv("SAMPLE_HTML")); p != "" { return p } _, file, _, ok := runtime.Caller(0) if ok { return filepath.Join(filepath.Dir(file), "wallet-channel-sample.html") } return "wallet-channel-sample.html" } func v1Root(s string) string { s = strings.TrimSpace(s) for strings.HasSuffix(s, "/") { s = strings.TrimSuffix(s, "/") } if s == "" { return "" } if !strings.HasSuffix(s, "/v1") { s += "/v1" } return s } func (s *bff) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(s.html) } func (s *bff) handleSession(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } var in struct { Token string `json:"token"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil || strings.TrimSpace(in.Token) == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"code": "invalid_request", "msg": "token required"}) return } body, _ := json.Marshal(map[string]string{"token": in.Token, "client_id": s.clientID, "client_secret": s.clientSecret}) resp, err := s.doJianliao(http.MethodPost, s.apiBase+"/oauth/jump/redeem", body) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"code": "upstream", "msg": err.Error()}) return } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { writeJSON(w, resp.StatusCode, jsonObject(raw)) return } var out struct { Sub string `json:"sub"` } if err := json.Unmarshal(raw, &out); err != nil || out.Sub == "" { writeJSON(w, http.StatusBadGateway, map[string]any{"code": "upstream", "msg": "redeem missing sub"}) return } sid := randomID() s.mu.Lock() s.sessions[sid] = session{Sub: out.Sub, Exp: time.Now().Add(12 * time.Hour)} s.mu.Unlock() http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: sid, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: strings.HasPrefix(s.publicBase, "https://"), MaxAge: 12 * 3600, }) writeJSON(w, http.StatusOK, map[string]any{"sub": out.Sub}) } func (s *bff) handleMe(w http.ResponseWriter, r *http.Request) { sub, ok := s.currentSub(r) if !ok { writeJSON(w, http.StatusUnauthorized, map[string]any{"code": "unauthorized"}) return } s.mu.Lock() bal := s.balance s.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"sub": sub, "app_balance": bal}) } func (s *bff) handlePrepay(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } sub, ok := s.currentSub(r) if !ok { writeJSON(w, http.StatusUnauthorized, map[string]any{"code": "unauthorized"}) return } var in struct { AppAmount int64 `json:"app_amount"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.AppAmount < 1 { writeJSON(w, http.StatusBadRequest, map[string]any{"code": "invalid_request", "msg": "app_amount"}) return } mo := "sample_pay_" + randomID() payload := map[string]any{ "merchant_order_no": mo, "sub": sub, "app_amount": in.AppAmount, } if u := s.notifyURL(); u != "" { payload["notify_url"] = u } raw, status, err := s.postMerchant("/wallet/channel/prepay", payload) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"code": "upstream", "msg": err.Error()}) return } if status >= 300 { writeJSON(w, status, jsonObject(raw)) return } var jl map[string]any _ = json.Unmarshal(raw, &jl) ord := &localOrder{ MerchantOrderNo: mo, Direction: "pay", AppAmount: in.AppAmount, Status: stringField(jl, "status"), Sub: sub, PrepayID: stringField(jl, "prepay_id"), OrderNo: stringField(jl, "order_no"), IMAmount: intField(jl, "im_amount"), IMCurrency: stringField(jl, "im_currency"), AppCurrency: stringField(jl, "app_currency"), Jianliao: jl, } s.mu.Lock() s.orders[mo] = ord s.mu.Unlock() writeJSON(w, http.StatusOK, ord) } func (s *bff) handlePayout(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } sub, ok := s.currentSub(r) if !ok { writeJSON(w, http.StatusUnauthorized, map[string]any{"code": "unauthorized"}) return } var in struct { AppAmount int64 `json:"app_amount"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.AppAmount < 1 { writeJSON(w, http.StatusBadRequest, map[string]any{"code": "invalid_request", "msg": "app_amount"}) return } s.mu.Lock() if s.balance < in.AppAmount { s.mu.Unlock() writeJSON(w, http.StatusBadRequest, map[string]any{"code": "app_insufficient", "msg": "debit own ledger first"}) return } s.balance -= in.AppAmount s.mu.Unlock() mo := "sample_payout_" + randomID() payload := map[string]any{ "merchant_order_no": mo, "sub": sub, "app_amount": in.AppAmount, } if u := s.notifyURL(); u != "" { payload["notify_url"] = u } raw, status, err := s.postMerchant("/wallet/channel/payout", payload) if err != nil || status >= 300 { s.mu.Lock() s.balance += in.AppAmount s.mu.Unlock() if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"code": "upstream", "msg": err.Error()}) return } writeJSON(w, status, jsonObject(raw)) return } var jl map[string]any _ = json.Unmarshal(raw, &jl) ord := &localOrder{ MerchantOrderNo: mo, Direction: "payout", AppAmount: in.AppAmount, Status: stringField(jl, "status"), Sub: sub, OrderNo: stringField(jl, "order_no"), IMAmount: intField(jl, "im_amount"), IMCurrency: stringField(jl, "im_currency"), AppCurrency: stringField(jl, "app_currency"), Jianliao: jl, } s.mu.Lock() s.orders[mo] = ord s.mu.Unlock() writeJSON(w, http.StatusOK, ord) } func (s *bff) handleOrders(w http.ResponseWriter, r *http.Request) { sub, ok := s.currentSub(r) if !ok { writeJSON(w, http.StatusUnauthorized, map[string]any{"code": "unauthorized"}) return } mo := strings.TrimSpace(r.URL.Query().Get("merchant_order_no")) if mo == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"code": "invalid_request"}) return } s.mu.Lock() ord, exists := s.orders[mo] bal := s.balance s.mu.Unlock() if !exists || ord.Sub != sub { writeJSON(w, http.StatusNotFound, map[string]any{"code": "not_found"}) return } raw, status, err := s.getMerchantOrder(mo) view := *ord if err == nil && status < 300 { var jl map[string]any _ = json.Unmarshal(raw, &jl) view.Jianliao = jl if st := stringField(jl, "status"); st != "" { view.Status = st } } writeJSON(w, http.StatusOK, map[string]any{"order": view, "app_balance": bal}) } func (s *bff) handleWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "body", http.StatusBadRequest) return } ts := r.Header.Get("X-Tsdd-Channel-Timestamp") sig := r.Header.Get("X-Tsdd-Channel-Signature") if s.webhookSecret == "" || !validNotifyHMAC(s.webhookSecret, ts, raw, sig) { http.Error(w, "signature", http.StatusUnauthorized) return } var n struct { MerchantOrderNo string `json:"merchant_order_no"` Status string `json:"status"` Direction string `json:"direction"` AppAmount int64 `json:"app_amount"` Sub string `json:"sub"` } if err := json.Unmarshal(raw, &n); err != nil { http.Error(w, "json", http.StatusBadRequest) return } if n.Status != "paid" || n.Direction != "pay" { w.WriteHeader(http.StatusOK) return } s.mu.Lock() defer s.mu.Unlock() ord := s.orders[n.MerchantOrderNo] if ord == nil { ord = &localOrder{MerchantOrderNo: n.MerchantOrderNo, Direction: n.Direction, AppAmount: n.AppAmount, Sub: n.Sub} s.orders[n.MerchantOrderNo] = ord } ord.Status = "paid" if !ord.Credited { s.balance += n.AppAmount ord.Credited = true } w.WriteHeader(http.StatusOK) } func (s *bff) currentSub(r *http.Request) (string, bool) { c, err := r.Cookie(cookieName) if err != nil || c.Value == "" { return "", false } s.mu.Lock() defer s.mu.Unlock() sess, ok := s.sessions[c.Value] if !ok || time.Now().After(sess.Exp) { return "", false } return sess.Sub, true } func (s *bff) notifyURL() string { if !strings.HasPrefix(s.publicBase, "https://") { return "" } return s.publicBase + "/callbacks/jianliao-wallet" } func (s *bff) postMerchant(path string, payload map[string]any) ([]byte, int, error) { body, err := json.Marshal(payload) if err != nil { return nil, 0, err } resp, err := s.doJianliao(http.MethodPost, s.apiBase+path, body) if err != nil { return nil, 0, err } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) return raw, resp.StatusCode, err } func (s *bff) getMerchantOrder(mo string) ([]byte, int, error) { u := s.apiBase + "/wallet/channel/merchant/orders?merchant_order_no=" + url.QueryEscape(mo) resp, err := s.doJianliao(http.MethodGet, u, nil) if err != nil { return nil, 0, err } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) return raw, resp.StatusCode, err } func (s *bff) doJianliao(method, rawURL string, body []byte) (*http.Response, error) { var rdr io.Reader if body != nil { rdr = bytes.NewReader(body) } req, err := http.NewRequest(method, rawURL, rdr) if err != nil { return nil, err } req.SetBasicAuth(s.clientID, s.clientSecret) if body != nil { req.Header.Set("Content-Type", "application/json") } return s.httpClient.Do(req) } func validNotifyHMAC(secret, timestamp string, raw []byte, got string) bool { if secret == "" || timestamp == "" || got == "" { return false } unix, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } skew := time.Since(time.Unix(unix, 0)) if skew > 5*time.Minute || skew < -5*time.Minute { return false } mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(timestamp)) _, _ = mac.Write([]byte(".")) _, _ = mac.Write(raw) want := "sha256=" + hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(want), []byte(got)) } func randomID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b[:]) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func jsonObject(raw []byte) any { var v any if err := json.Unmarshal(raw, &v); err != nil { return map[string]any{"raw": string(raw)} } return v } func stringField(m map[string]any, k string) string { if m == nil { return "" } if s, ok := m[k].(string); ok { return s } return "" } func intField(m map[string]any, k string) int64 { if m == nil { return 0 } switch v := m[k].(type) { case float64: return int64(v) case json.Number: n, _ := v.Int64() return n case int64: return v } return 0 }