mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend
Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.
Text is now translated where its reader is:
- Web console APIs return the English source text as message_key with
message_params, and the web console translates it in all seven languages.
message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
the backend (en, zh-CN, zh-TW) in the language saved on the account, then
the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
messages that were Chinese before, word for word. Gateways that use another
new-api as their upstream match that text, for example to disable a channel
whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
params) and rendered in the viewer's language. Task fail reasons and refund
reasons written by the gateway are English keys. Rows written earlier are
shown as before.
- Server logs are English.
Also:
- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
non-English string literal, on a web console message without translations
and on a locale key without a constant.
* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it
Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.
common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.
* docs: list LOG_LANGUAGE in the docker compose example
* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE
Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.
DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.
Review fixes:
- Channel test logs store the token name "Model test"; the console shows
it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
translates. The HTTP status and the upstream detail go to the server log
when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.
Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
translated message key.
- Remove guards and helpers that no caller needs.
* fix(i18n): address review on language lookup, notices and error responses
- Relay requests no longer load the user a second time to find the
language: the settings TokenAuth put into the request are used as they
are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
messages again, with the keys and status codes used before this PR.
Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
senders. Running the rendered text through Sprintf appended
%!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
535 lines
19 KiB
Go
535 lines
19 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/service"
|
|
"github.com/QuantumNous/new-api/setting"
|
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/shopspring/decimal"
|
|
"github.com/thanhpk/randstr"
|
|
)
|
|
|
|
type WaffoPancakePayRequest struct {
|
|
Amount int64 `json:"amount"`
|
|
}
|
|
|
|
func RequestWaffoPancakeAmount(c *gin.Context) {
|
|
var req WaffoPancakePayRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
paymentError(c, common.NewMessage("Invalid parameters"))
|
|
return
|
|
}
|
|
|
|
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
|
|
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": setting.WaffoPancakeMinTopUp}))
|
|
return
|
|
}
|
|
id := c.GetInt("id")
|
|
if rejectInvalidTopUpQuota(c, id, req.Amount) {
|
|
return
|
|
}
|
|
|
|
group, err := model.GetUserGroup(id, true)
|
|
if err != nil {
|
|
paymentError(c, common.NewMessage("Failed to get the user group"))
|
|
return
|
|
}
|
|
|
|
payMoney := getWaffoPancakePayMoney(req.Amount, group)
|
|
if payMoney <= 0.01 {
|
|
paymentError(c, common.NewMessage("Payment amount is too low"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "success", "data": fmt.Sprintf("%.2f", payMoney)})
|
|
}
|
|
|
|
func getWaffoPancakePayMoney(amount int64, group string) float64 {
|
|
dAmount := decimal.NewFromInt(amount)
|
|
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
|
dAmount = dAmount.Div(decimal.NewFromFloat(common.QuotaPerUnit))
|
|
}
|
|
|
|
topupGroupRatio := common.GetTopupGroupRatio(group)
|
|
if topupGroupRatio == 0 {
|
|
topupGroupRatio = 1
|
|
}
|
|
|
|
discount := 1.0
|
|
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok && ds > 0 {
|
|
discount = ds
|
|
}
|
|
|
|
payMoney := dAmount.
|
|
Mul(decimal.NewFromFloat(setting.WaffoPancakeUnitPrice)).
|
|
Mul(decimal.NewFromFloat(topupGroupRatio)).
|
|
Mul(decimal.NewFromFloat(discount))
|
|
|
|
return payMoney.InexactFloat64()
|
|
}
|
|
|
|
func normalizeWaffoPancakeTopUpAmount(amount int64) int64 {
|
|
if operation_setting.GetQuotaDisplayType() != operation_setting.QuotaDisplayTypeTokens {
|
|
return amount
|
|
}
|
|
|
|
normalized := decimal.NewFromInt(amount).
|
|
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
|
|
IntPart()
|
|
if normalized < 1 {
|
|
return 1
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func formatWaffoPancakeAmount(payMoney float64) string {
|
|
return decimal.NewFromFloat(payMoney).StringFixed(2)
|
|
}
|
|
|
|
func getWaffoPancakeBuyerEmail(user *model.User) string {
|
|
if user != nil && strings.TrimSpace(user.Email) != "" {
|
|
return user.Email
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// The admin config endpoints below accept typed-but-not-yet-saved creds in
|
|
// the body and fall back to persisted creds when the body is blank (see
|
|
// resolveWaffoPancakeAdminCreds). Only SaveWaffoPancake writes to OptionMap.
|
|
|
|
type saveWaffoPancakeRequest struct {
|
|
MerchantID string `json:"merchant_id"`
|
|
PrivateKey string `json:"private_key"`
|
|
ReturnURL string `json:"return_url"`
|
|
StoreID string `json:"store_id"`
|
|
ProductID string `json:"product_id"`
|
|
}
|
|
|
|
type createWaffoPancakePairRequest struct {
|
|
MerchantID string `json:"merchant_id"`
|
|
PrivateKey string `json:"private_key"`
|
|
ReturnURL string `json:"return_url"`
|
|
}
|
|
|
|
// SaveWaffoPancake atomically persists all five operator-controlled fields.
|
|
// Catalog / pair endpoints are transient — only this one writes the OptionMap.
|
|
func SaveWaffoPancake(c *gin.Context) {
|
|
var req saveWaffoPancakeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
paymentError(c, common.NewMessage("Invalid parameters"))
|
|
return
|
|
}
|
|
if err := service.SaveWaffoPancakeConfig(
|
|
c.Request.Context(),
|
|
req.MerchantID,
|
|
req.PrivateKey,
|
|
req.ReturnURL,
|
|
req.StoreID,
|
|
req.ProductID,
|
|
); err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText(
|
|
"Waffo Pancake failed to save config store_id=%q product_id=%q error=%q",
|
|
req.StoreID, req.ProductID, err.Error(),
|
|
))
|
|
paymentError(c, common.NewMessage("Failed to save configuration"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "success",
|
|
"data": gin.H{
|
|
"product_id": setting.WaffoPancakeProductID,
|
|
"store_id": setting.WaffoPancakeStoreID,
|
|
},
|
|
})
|
|
}
|
|
|
|
// resolveWaffoPancakeAdminCreds prefers body creds (typed-but-not-yet-saved
|
|
// values, for verification) and falls back to persisted creds when the body
|
|
// is blank (so returning admins don't have to re-paste the private key,
|
|
// which is stripped from GET /api/option/).
|
|
func resolveWaffoPancakeAdminCreds(bodyMerchantID, bodyPrivateKey string) (string, string) {
|
|
m := strings.TrimSpace(bodyMerchantID)
|
|
k := strings.TrimSpace(bodyPrivateKey)
|
|
if m == "" && k == "" {
|
|
return setting.WaffoPancakeMerchantID, setting.WaffoPancakePrivateKey
|
|
}
|
|
return m, k
|
|
}
|
|
|
|
// CreateWaffoPancakePair mints a Store + OnetimeProduct pair in one round-
|
|
// trip. Surfaces an orphan-store flag when the product half fails so the
|
|
// frontend can preselect / retry without losing context.
|
|
func CreateWaffoPancakePair(c *gin.Context) {
|
|
var req createWaffoPancakePairRequest
|
|
if c.Request.ContentLength > 0 {
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
paymentError(c, common.NewMessage("Invalid parameters"))
|
|
return
|
|
}
|
|
}
|
|
merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey)
|
|
if merchantID == "" || privateKey == "" {
|
|
paymentError(c, common.NewMessage("Waffo Pancake credentials are not configured"))
|
|
return
|
|
}
|
|
result, err := service.CreateWaffoPancakePrimaryPair(
|
|
c.Request.Context(), merchantID, privateKey, req.ReturnURL,
|
|
)
|
|
if err != nil {
|
|
orphan := result != nil && result.OrphanStore
|
|
logger.LogError(c.Request.Context(), common.LogText(
|
|
"Waffo Pancake failed to create store and product orphan_store=%t store_id=%q error=%q",
|
|
orphan, func() string {
|
|
if result == nil {
|
|
return ""
|
|
}
|
|
return result.StoreID
|
|
}(), err.Error(),
|
|
))
|
|
data := gin.H{"error": err.Error()}
|
|
if orphan {
|
|
data["store_id"] = result.StoreID
|
|
data["store_name"] = result.StoreName
|
|
data["orphan_store"] = true
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": data})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "success",
|
|
"data": gin.H{
|
|
"store_id": result.StoreID,
|
|
"store_name": result.StoreName,
|
|
"product_id": result.ProductID,
|
|
"product_name": result.ProductName,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ListWaffoPancakeCatalog returns the merchant's Stores + OnetimeProducts.
|
|
// Doubles as a credential probe (a successful 200 proves the resolved creds
|
|
// authenticate). See resolveWaffoPancakeAdminCreds for credential resolution.
|
|
func ListWaffoPancakeCatalog(c *gin.Context) {
|
|
// Missing query creds mean "use persisted creds".
|
|
merchantID, privateKey := resolveWaffoPancakeAdminCreds(
|
|
strings.TrimSpace(c.Query("merchant_id")),
|
|
strings.TrimSpace(c.Query("private_key")),
|
|
)
|
|
if merchantID == "" || privateKey == "" {
|
|
paymentError(c, common.NewMessage("Waffo Pancake credentials are not configured"))
|
|
return
|
|
}
|
|
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
|
|
if err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText(
|
|
"Waffo Pancake failed to fetch store and product catalog error=%q", err.Error(),
|
|
))
|
|
paymentError(c, common.NewMessage("Failed to fetch the catalog"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "success", "data": catalog})
|
|
}
|
|
|
|
type createWaffoPancakeSubscriptionProductRequest struct {
|
|
Name string `json:"name"`
|
|
Amount string `json:"amount"`
|
|
}
|
|
|
|
// CreateWaffoPancakeSubscriptionProduct mints an OnetimeProduct (not
|
|
// SubscriptionProduct — see service.CreateWaffoPancakeProductForPlan)
|
|
// sized to a plan's `name` + `amount`, using persisted Pancake credentials
|
|
// + StoreID. Reads from the form, not the plan row, so newly-typed unsaved
|
|
// plans can mint a product too.
|
|
func CreateWaffoPancakeSubscriptionProduct(c *gin.Context) {
|
|
var req createWaffoPancakeSubscriptionProductRequest
|
|
if c.Request.ContentLength > 0 {
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
paymentError(c, common.NewMessage("Invalid parameters"))
|
|
return
|
|
}
|
|
}
|
|
if strings.TrimSpace(req.Name) == "" {
|
|
paymentError(c, common.NewMessage("Plan name cannot be empty"))
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Amount) == "" {
|
|
paymentError(c, common.NewMessage("Plan price cannot be empty"))
|
|
return
|
|
}
|
|
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
|
|
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
|
|
if merchantID == "" || privateKey == "" || storeID == "" {
|
|
paymentError(c, common.NewMessage("Waffo Pancake is not fully configured. Complete the gateway binding in payment settings first."))
|
|
return
|
|
}
|
|
productID, err := service.CreateWaffoPancakeProductForPlan(
|
|
c.Request.Context(),
|
|
merchantID,
|
|
privateKey,
|
|
storeID,
|
|
req.Name,
|
|
req.Amount,
|
|
setting.WaffoPancakeReturnURL,
|
|
)
|
|
if err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText(
|
|
"Waffo Pancake failed to create plan product store_id=%q name=%q amount=%q error=%q",
|
|
storeID, req.Name, req.Amount, err.Error(),
|
|
))
|
|
paymentError(c, common.NewMessage("Failed to create the plan product"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "success",
|
|
"data": gin.H{
|
|
"product_id": productID,
|
|
"product_name": req.Name,
|
|
"store_id": storeID,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ListWaffoPancakeSubscriptionProductOptions returns the OnetimeProducts
|
|
// in the saved Pancake store, for the subscription-plan dropdown. The name
|
|
// reflects new-api's plan concept; under the hood it's still OnetimeProducts.
|
|
func ListWaffoPancakeSubscriptionProductOptions(c *gin.Context) {
|
|
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
|
|
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
|
|
if merchantID == "" || privateKey == "" || storeID == "" {
|
|
paymentError(c, common.NewMessage("Waffo Pancake is not fully configured. Complete the gateway binding in payment settings first."))
|
|
return
|
|
}
|
|
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
|
|
if err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText(
|
|
"Waffo Pancake failed to fetch subscription product list store_id=%q error=%q", storeID, err.Error(),
|
|
))
|
|
paymentError(c, common.NewMessage("Failed to fetch the product list"))
|
|
return
|
|
}
|
|
products := []service.WaffoPancakeCatalogProduct{}
|
|
for _, store := range catalog.Stores {
|
|
if store.ID == storeID {
|
|
products = store.OnetimeProducts
|
|
break
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "success",
|
|
"data": gin.H{
|
|
"store_id": storeID,
|
|
"products": products,
|
|
},
|
|
})
|
|
}
|
|
|
|
func getWaffoPancakeBuyerIdentity(user *model.User) string {
|
|
if user == nil {
|
|
return ""
|
|
}
|
|
return service.WaffoPancakeBuyerIdentityFromUserID(user.Id)
|
|
}
|
|
|
|
func RequestWaffoPancakePay(c *gin.Context) {
|
|
if !isWaffoPancakeTopUpEnabled() {
|
|
paymentError(c, common.NewMessage("Waffo Pancake configuration is incomplete"))
|
|
return
|
|
}
|
|
|
|
var req WaffoPancakePayRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
paymentError(c, common.NewMessage("Invalid parameters"))
|
|
return
|
|
}
|
|
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
|
|
paymentError(c, common.NewMessage("Top-up amount cannot be less than {{min}}", map[string]any{"min": setting.WaffoPancakeMinTopUp}))
|
|
return
|
|
}
|
|
id := c.GetInt("id")
|
|
if rejectInvalidTopUpQuota(c, id, req.Amount) {
|
|
return
|
|
}
|
|
|
|
user, err := model.GetUserById(id, false)
|
|
if err != nil || user == nil {
|
|
paymentError(c, common.NewMessage("User does not exist"))
|
|
return
|
|
}
|
|
|
|
group, err := model.GetUserGroup(id, true)
|
|
if err != nil {
|
|
paymentError(c, common.NewMessage("Failed to get the user group"))
|
|
return
|
|
}
|
|
|
|
payMoney := getWaffoPancakePayMoney(req.Amount, group)
|
|
if payMoney < 0.01 {
|
|
paymentError(c, common.NewMessage("Payment amount is too low"))
|
|
return
|
|
}
|
|
|
|
tradeNo := fmt.Sprintf("WAFFO_PANCAKE-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6))
|
|
topUp := &model.TopUp{
|
|
UserId: id,
|
|
Amount: normalizeWaffoPancakeTopUpAmount(req.Amount),
|
|
Money: payMoney,
|
|
TradeNo: tradeNo,
|
|
PaymentMethod: model.PaymentMethodWaffoPancake,
|
|
PaymentProvider: model.PaymentProviderWaffoPancake,
|
|
CreateTime: time.Now().Unix(),
|
|
Status: common.TopUpStatusPending,
|
|
}
|
|
if err := topUp.Insert(); err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error()))
|
|
paymentError(c, common.NewMessage("Failed to create order"))
|
|
return
|
|
}
|
|
|
|
expiresInSeconds := 45 * 60
|
|
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
|
|
ProductID: setting.WaffoPancakeProductID,
|
|
BuyerIdentity: getWaffoPancakeBuyerIdentity(user),
|
|
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
|
|
Amount: formatWaffoPancakeAmount(payMoney),
|
|
TaxCategory: "saas",
|
|
},
|
|
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
|
ExpiresInSeconds: &expiresInSeconds,
|
|
OrderMerchantExternalID: tradeNo,
|
|
})
|
|
if err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake failed to create checkout session user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
|
|
topUp.Status = common.TopUpStatusFailed
|
|
_ = topUp.Update()
|
|
paymentError(c, common.NewMessage("Failed to start payment"))
|
|
return
|
|
}
|
|
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake top-up order created user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f", id, tradeNo, session.SessionID, req.Amount, payMoney))
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "success",
|
|
"data": gin.H{
|
|
"checkout_url": session.CheckoutURL,
|
|
"session_id": session.SessionID,
|
|
"expires_at": session.ExpiresAt,
|
|
"order_id": tradeNo,
|
|
"token": session.Token,
|
|
"token_expires_at": session.TokenExpiresAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
func WaffoPancakeWebhook(c *gin.Context) {
|
|
if !isWaffoPancakeWebhookEnabled() {
|
|
logger.LogWarn(c.Request.Context(), common.LogText("Waffo Pancake webhook rejected reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
|
c.String(http.StatusForbidden, "webhook disabled")
|
|
return
|
|
}
|
|
|
|
// :env splits test vs prod traffic at the routing layer — operator
|
|
// registers each URL in the matching webhook slot in Pancake's dashboard.
|
|
// We then enforce event.mode == expectedEnv to catch mis-registrations.
|
|
expectedEnv := strings.TrimSpace(c.Param("env"))
|
|
if expectedEnv != "test" && expectedEnv != "prod" {
|
|
logger.LogWarn(c.Request.Context(), common.LogText(
|
|
"Waffo Pancake webhook invalid env path segment env=%q path=%q client_ip=%s",
|
|
expectedEnv, c.Request.RequestURI, c.ClientIP(),
|
|
))
|
|
c.String(http.StatusNotFound, "unknown env")
|
|
return
|
|
}
|
|
|
|
bodyBytes, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake webhook failed to read request body path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
|
c.String(http.StatusBadRequest, "bad request")
|
|
return
|
|
}
|
|
|
|
signature := c.GetHeader("X-Waffo-Signature")
|
|
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake webhook received request path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
|
|
|
|
event, err := service.VerifyConfiguredWaffoPancakeWebhook(string(bodyBytes), signature)
|
|
if err != nil {
|
|
logger.LogWarn(c.Request.Context(), common.LogText("Waffo Pancake webhook signature verification failed path=%q client_ip=%s signature=%q body=%q error=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes), err.Error()))
|
|
c.String(http.StatusUnauthorized, "invalid signature")
|
|
return
|
|
}
|
|
|
|
if !strings.EqualFold(strings.TrimSpace(event.Mode), expectedEnv) {
|
|
logger.LogError(c.Request.Context(), common.LogText(
|
|
"Waffo Pancake webhook env mismatch expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
|
|
expectedEnv, event.Mode, event.ID, event.Data.OrderID, c.ClientIP(),
|
|
))
|
|
c.String(http.StatusOK, "OK")
|
|
return
|
|
}
|
|
|
|
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake webhook signature verified event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
|
|
if event.NormalizedEventType() != "order.completed" {
|
|
c.String(http.StatusOK, "OK")
|
|
return
|
|
}
|
|
|
|
// Dispatch by trade_no prefix. OrderMerchantExternalID = our trade_no;
|
|
// OrderID is Pancake's internal ORD_* (logs only).
|
|
rawTradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID)
|
|
isSubscription := strings.HasPrefix(rawTradeNo, "WAFFO_PANCAKE_SUB-")
|
|
|
|
if isSubscription {
|
|
tradeNo, err := service.ResolveWaffoPancakeSubscriptionTradeNo(event)
|
|
if err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText(
|
|
"Waffo Pancake webhook failed to resolve subscription order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
|
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
|
|
))
|
|
c.String(http.StatusOK, "OK")
|
|
return
|
|
}
|
|
LockOrder(tradeNo)
|
|
defer UnlockOrder(tradeNo)
|
|
if err := model.CompleteSubscriptionOrder(tradeNo, string(bodyBytes), model.PaymentProviderWaffoPancake, ""); err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake subscription completion failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
|
|
c.String(http.StatusInternalServerError, "retry")
|
|
return
|
|
}
|
|
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake subscription completed trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
|
|
c.String(http.StatusOK, "OK")
|
|
return
|
|
}
|
|
|
|
tradeNo, err := service.ResolveWaffoPancakeTradeNo(event)
|
|
if err != nil {
|
|
// LogError (not LogWarn): covers order-not-found and buyer-identity
|
|
// mismatch — both warrant human attention. 200 OK so Waffo doesn't
|
|
// retry a permanently-unresolvable webhook.
|
|
logger.LogError(c.Request.Context(), common.LogText(
|
|
"Waffo Pancake webhook failed to resolve order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
|
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
|
|
))
|
|
c.String(http.StatusOK, "OK")
|
|
return
|
|
}
|
|
|
|
LockOrder(tradeNo)
|
|
defer UnlockOrder(tradeNo)
|
|
|
|
if err := model.RechargeWaffoPancake(tradeNo); err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("Waffo Pancake top-up processing failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
|
|
c.String(http.StatusInternalServerError, "retry")
|
|
return
|
|
}
|
|
|
|
logger.LogInfo(c.Request.Context(), common.LogText("Waffo Pancake top-up succeeded trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
|
|
c.String(http.StatusOK, "OK")
|
|
}
|