Explorar o código

feat(topup): add admin-only audit info to top-up logs

Thread caller IP from webhook/admin controllers through model recharge
functions and record a new RecordTopupLog entry with admin_info (server
IP, caller IP, order payment method, callback payment method, system
version). Frontend shows these fields in the expanded log row and the
IP column for admins on top-up logs, while non-admins continue to see
admin_info stripped by formatUserLogs.
CaIon hai 2 meses
pai
achega
209d90e861

+ 2 - 2
controller/topup.go

@@ -362,7 +362,7 @@ func EpayNotify(c *gin.Context) {
 				return
 			}
 			log.Printf("易支付回调更新用户成功 %v", topUp)
-			model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money))
+			model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay")
 		}
 	} else {
 		log.Printf("易支付异常回调: %v", verifyInfo)
@@ -461,7 +461,7 @@ func AdminCompleteTopUp(c *gin.Context) {
 	LockOrder(req.TradeNo)
 	defer UnlockOrder(req.TradeNo)
 
-	if err := model.ManualCompleteTopUp(req.TradeNo); err != nil {
+	if err := model.ManualCompleteTopUp(req.TradeNo, c.ClientIP()); err != nil {
 		common.ApiError(c, err)
 		return
 	}

+ 1 - 1
controller/topup_creem.go

@@ -353,7 +353,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
 		log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId)
 	}
 
-	err := model.RechargeCreem(referenceId, customerEmail, customerName)
+	err := model.RechargeCreem(referenceId, customerEmail, customerName, c.ClientIP())
 	if err != nil {
 		log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
 		c.AbortWithStatus(http.StatusInternalServerError)

+ 11 - 10
controller/topup_stripe.go

@@ -170,15 +170,16 @@ func StripeWebhook(c *gin.Context) {
 		return
 	}
 
+	callerIp := c.ClientIP()
 	switch event.Type {
 	case stripe.EventTypeCheckoutSessionCompleted:
-		sessionCompleted(event)
+		sessionCompleted(event, callerIp)
 	case stripe.EventTypeCheckoutSessionExpired:
 		sessionExpired(event)
 	case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded:
-		sessionAsyncPaymentSucceeded(event)
+		sessionAsyncPaymentSucceeded(event, callerIp)
 	case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
-		sessionAsyncPaymentFailed(event)
+		sessionAsyncPaymentFailed(event, callerIp)
 	default:
 		log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
 	}
@@ -186,7 +187,7 @@ func StripeWebhook(c *gin.Context) {
 	c.Status(http.StatusOK)
 }
 
-func sessionCompleted(event stripe.Event) {
+func sessionCompleted(event stripe.Event, callerIp string) {
 	customerId := event.GetObjectValue("customer")
 	referenceId := event.GetObjectValue("client_reference_id")
 	status := event.GetObjectValue("status")
@@ -201,22 +202,22 @@ func sessionCompleted(event stripe.Event) {
 		return
 	}
 
-	fulfillOrder(event, referenceId, customerId)
+	fulfillOrder(event, referenceId, customerId, callerIp)
 }
 
 // sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.)
 // that confirm payment after the checkout session completes.
-func sessionAsyncPaymentSucceeded(event stripe.Event) {
+func sessionAsyncPaymentSucceeded(event stripe.Event, callerIp string) {
 	customerId := event.GetObjectValue("customer")
 	referenceId := event.GetObjectValue("client_reference_id")
 	log.Printf("Stripe 异步支付成功: %s", referenceId)
 
-	fulfillOrder(event, referenceId, customerId)
+	fulfillOrder(event, referenceId, customerId, callerIp)
 }
 
 // sessionAsyncPaymentFailed marks orders as failed when delayed payment methods
 // ultimately fail (e.g. bank transfer not received, SEPA rejected).
-func sessionAsyncPaymentFailed(event stripe.Event) {
+func sessionAsyncPaymentFailed(event stripe.Event, callerIp string) {
 	referenceId := event.GetObjectValue("client_reference_id")
 	log.Printf("Stripe 异步支付失败: %s", referenceId)
 
@@ -253,7 +254,7 @@ func sessionAsyncPaymentFailed(event stripe.Event) {
 }
 
 // fulfillOrder is the shared logic for crediting quota after payment is confirmed.
-func fulfillOrder(event stripe.Event, referenceId string, customerId string) {
+func fulfillOrder(event stripe.Event, referenceId string, customerId string, callerIp string) {
 	if len(referenceId) == 0 {
 		log.Println("未提供支付单号")
 		return
@@ -274,7 +275,7 @@ func fulfillOrder(event stripe.Event, referenceId string, customerId string) {
 		return
 	}
 
-	err := model.Recharge(referenceId, customerId)
+	err := model.Recharge(referenceId, customerId, callerIp)
 	if err != nil {
 		log.Println(err.Error(), referenceId)
 		return

+ 1 - 1
controller/topup_waffo.go

@@ -357,7 +357,7 @@ func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.Pa
 	LockOrder(merchantOrderId)
 	defer UnlockOrder(merchantOrderId)
 
-	if err := model.RechargeWaffo(merchantOrderId); err != nil {
+	if err := model.RechargeWaffo(merchantOrderId, c.ClientIP()); err != nil {
 		log.Printf("Waffo 充值处理失败: %v, 订单: %s", err, merchantOrderId)
 		sendWaffoWebhookResponse(c, wh, false, err.Error())
 		return

+ 27 - 0
model/log.go

@@ -90,6 +90,33 @@ func RecordLog(userId int, logType int, content string) {
 	}
 }
 
+func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) {
+	username, _ := GetUsernameById(userId, false)
+	adminInfo := map[string]interface{}{
+		"server_ip":               common.GetIp(),
+		"caller_ip":               callerIp,
+		"payment_method":          paymentMethod,
+		"callback_payment_method": callbackPaymentMethod,
+		"version":                 common.Version,
+	}
+	other := map[string]interface{}{
+		"admin_info": adminInfo,
+	}
+	log := &Log{
+		UserId:    userId,
+		Username:  username,
+		CreatedAt: common.GetTimestamp(),
+		Type:      LogTypeTopup,
+		Content:   content,
+		Ip:        callerIp,
+		Other:     common.MapToJsonStr(other),
+	}
+	err := LOG_DB.Create(log).Error
+	if err != nil {
+		common.SysLog("failed to record topup log: " + err.Error())
+	}
+}
+
 func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
 	isStream bool, group string, other map[string]interface{}) {
 	logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))

+ 10 - 8
model/topup.go

@@ -57,7 +57,7 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp {
 	return topUp
 }
 
-func Recharge(referenceId string, customerId string) (err error) {
+func Recharge(referenceId string, customerId string, callerIp string) (err error) {
 	if referenceId == "" {
 		return errors.New("未提供支付单号")
 	}
@@ -105,7 +105,7 @@ func Recharge(referenceId string, customerId string) (err error) {
 		return errors.New("充值失败,请稍后重试")
 	}
 
-	RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount))
+	RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, "stripe")
 
 	return nil
 }
@@ -242,7 +242,7 @@ func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp
 }
 
 // ManualCompleteTopUp 管理员手动完成订单并给用户充值
-func ManualCompleteTopUp(tradeNo string) error {
+func ManualCompleteTopUp(tradeNo string, callerIp string) error {
 	if tradeNo == "" {
 		return errors.New("未提供订单号")
 	}
@@ -255,6 +255,7 @@ func ManualCompleteTopUp(tradeNo string) error {
 	var userId int
 	var quotaToAdd int
 	var payMoney float64
+	var paymentMethod string
 
 	err := DB.Transaction(func(tx *gorm.DB) error {
 		topUp := &TopUp{}
@@ -301,6 +302,7 @@ func ManualCompleteTopUp(tradeNo string) error {
 
 		userId = topUp.UserId
 		payMoney = topUp.Money
+		paymentMethod = topUp.PaymentMethod
 		return nil
 	})
 
@@ -309,10 +311,10 @@ func ManualCompleteTopUp(tradeNo string) error {
 	}
 
 	// 事务外记录日志,避免阻塞
-	RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney))
+	RecordTopupLog(userId, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney), callerIp, paymentMethod, "admin")
 	return nil
 }
-func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) {
+func RechargeCreem(referenceId string, customerEmail string, customerName string, callerIp string) (err error) {
 	if referenceId == "" {
 		return errors.New("未提供支付单号")
 	}
@@ -382,12 +384,12 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
 		return errors.New("充值失败,请稍后重试")
 	}
 
-	RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money))
+	RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, "creem")
 
 	return nil
 }
 
-func RechargeWaffo(tradeNo string) (err error) {
+func RechargeWaffo(tradeNo string, callerIp string) (err error) {
 	if tradeNo == "" {
 		return errors.New("未提供支付单号")
 	}
@@ -444,7 +446,7 @@ func RechargeWaffo(tradeNo string) (err error) {
 	}
 
 	if quotaToAdd > 0 {
-		RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
+		RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, "waffo")
 	}
 
 	return nil

+ 6 - 1
web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx

@@ -876,7 +876,12 @@ export const getLogsColumns = ({
       ),
       dataIndex: 'ip',
       render: (text, record, index) => {
-        return (record.type === 2 || record.type === 5) && text ? (
+        const showIp =
+          (record.type === 2 ||
+            record.type === 5 ||
+            (isAdminUser && record.type === 1)) &&
+          text;
+        return showIp ? (
           <Tooltip content={text}>
             <span>
               <Tag

+ 35 - 2
web/src/hooks/usage-logs/useUsageLogsData.jsx

@@ -695,13 +695,13 @@ export const useLogsData = () => {
           ),
         });
       }
-      if (isAdminUser && logs[i].type !== 6) {
+      if (isAdminUser && logs[i].type !== 6 && logs[i].type !== 1) {
         expandDataLocal.push({
           key: t('请求转换'),
           value: requestConversionDisplayValue(other?.request_conversion),
         });
       }
-      if (isAdminUser && logs[i].type !== 6) {
+      if (isAdminUser && logs[i].type !== 6 && logs[i].type !== 1) {
         let localCountMode = '';
         if (other?.admin_info?.local_count_tokens) {
           localCountMode = t('本地计费');
@@ -713,6 +713,39 @@ export const useLogsData = () => {
           value: localCountMode,
         });
       }
+      if (isAdminUser && logs[i].type === 1 && other?.admin_info) {
+        const adminInfo = other.admin_info;
+        if (adminInfo.payment_method) {
+          expandDataLocal.push({
+            key: t('订单支付方式'),
+            value: adminInfo.payment_method,
+          });
+        }
+        if (adminInfo.callback_payment_method) {
+          expandDataLocal.push({
+            key: t('回调支付方式'),
+            value: adminInfo.callback_payment_method,
+          });
+        }
+        if (adminInfo.caller_ip) {
+          expandDataLocal.push({
+            key: t('回调调用者IP'),
+            value: adminInfo.caller_ip,
+          });
+        }
+        if (adminInfo.server_ip) {
+          expandDataLocal.push({
+            key: t('服务器IP'),
+            value: adminInfo.server_ip,
+          });
+        }
+        if (adminInfo.version) {
+          expandDataLocal.push({
+            key: t('系统版本'),
+            value: adminInfo.version,
+          });
+        }
+      }
       expandDatesLocal[logs[i].key] = expandDataLocal;
     }
 

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 201 - 106
web/src/i18n/locales/en.json


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 212 - 111
web/src/i18n/locales/fr.json


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 203 - 106
web/src/i18n/locales/ja.json


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 217 - 111
web/src/i18n/locales/ru.json


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 203 - 106
web/src/i18n/locales/vi.json


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 374 - 13
web/src/i18n/locales/zh-CN.json


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 295 - 77
web/src/i18n/locales/zh-TW.json


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio