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.
This commit is contained in:
CaIon
2026-04-17 23:51:30 +08:00
parent e2807c5f95
commit 209d90e861
15 changed files with 3570 additions and 1617 deletions
+2 -2
View File
@@ -362,7 +362,7 @@ func EpayNotify(c *gin.Context) {
return return
} }
log.Printf("易支付回调更新用户成功 %v", topUp) 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 { } else {
log.Printf("易支付异常回调: %v", verifyInfo) log.Printf("易支付异常回调: %v", verifyInfo)
@@ -461,7 +461,7 @@ func AdminCompleteTopUp(c *gin.Context) {
LockOrder(req.TradeNo) LockOrder(req.TradeNo)
defer UnlockOrder(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) common.ApiError(c, err)
return return
} }
+1 -1
View File
@@ -353,7 +353,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId) log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId)
} }
err := model.RechargeCreem(referenceId, customerEmail, customerName) err := model.RechargeCreem(referenceId, customerEmail, customerName, c.ClientIP())
if err != nil { if err != nil {
log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId) log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
c.AbortWithStatus(http.StatusInternalServerError) c.AbortWithStatus(http.StatusInternalServerError)
+11 -10
View File
@@ -170,15 +170,16 @@ func StripeWebhook(c *gin.Context) {
return return
} }
callerIp := c.ClientIP()
switch event.Type { switch event.Type {
case stripe.EventTypeCheckoutSessionCompleted: case stripe.EventTypeCheckoutSessionCompleted:
sessionCompleted(event) sessionCompleted(event, callerIp)
case stripe.EventTypeCheckoutSessionExpired: case stripe.EventTypeCheckoutSessionExpired:
sessionExpired(event) sessionExpired(event)
case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded: case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded:
sessionAsyncPaymentSucceeded(event) sessionAsyncPaymentSucceeded(event, callerIp)
case stripe.EventTypeCheckoutSessionAsyncPaymentFailed: case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
sessionAsyncPaymentFailed(event) sessionAsyncPaymentFailed(event, callerIp)
default: default:
log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type) log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
} }
@@ -186,7 +187,7 @@ func StripeWebhook(c *gin.Context) {
c.Status(http.StatusOK) c.Status(http.StatusOK)
} }
func sessionCompleted(event stripe.Event) { func sessionCompleted(event stripe.Event, callerIp string) {
customerId := event.GetObjectValue("customer") customerId := event.GetObjectValue("customer")
referenceId := event.GetObjectValue("client_reference_id") referenceId := event.GetObjectValue("client_reference_id")
status := event.GetObjectValue("status") status := event.GetObjectValue("status")
@@ -201,22 +202,22 @@ func sessionCompleted(event stripe.Event) {
return return
} }
fulfillOrder(event, referenceId, customerId) fulfillOrder(event, referenceId, customerId, callerIp)
} }
// sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.) // sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.)
// that confirm payment after the checkout session completes. // that confirm payment after the checkout session completes.
func sessionAsyncPaymentSucceeded(event stripe.Event) { func sessionAsyncPaymentSucceeded(event stripe.Event, callerIp string) {
customerId := event.GetObjectValue("customer") customerId := event.GetObjectValue("customer")
referenceId := event.GetObjectValue("client_reference_id") referenceId := event.GetObjectValue("client_reference_id")
log.Printf("Stripe 异步支付成功: %s", referenceId) log.Printf("Stripe 异步支付成功: %s", referenceId)
fulfillOrder(event, referenceId, customerId) fulfillOrder(event, referenceId, customerId, callerIp)
} }
// sessionAsyncPaymentFailed marks orders as failed when delayed payment methods // sessionAsyncPaymentFailed marks orders as failed when delayed payment methods
// ultimately fail (e.g. bank transfer not received, SEPA rejected). // 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") referenceId := event.GetObjectValue("client_reference_id")
log.Printf("Stripe 异步支付失败: %s", referenceId) 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. // 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 { if len(referenceId) == 0 {
log.Println("未提供支付单号") log.Println("未提供支付单号")
return return
@@ -274,7 +275,7 @@ func fulfillOrder(event stripe.Event, referenceId string, customerId string) {
return return
} }
err := model.Recharge(referenceId, customerId) err := model.Recharge(referenceId, customerId, callerIp)
if err != nil { if err != nil {
log.Println(err.Error(), referenceId) log.Println(err.Error(), referenceId)
return return
+1 -1
View File
@@ -357,7 +357,7 @@ func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.Pa
LockOrder(merchantOrderId) LockOrder(merchantOrderId)
defer UnlockOrder(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) log.Printf("Waffo 充值处理失败: %v, 订单: %s", err, merchantOrderId)
sendWaffoWebhookResponse(c, wh, false, err.Error()) sendWaffoWebhookResponse(c, wh, false, err.Error())
return return
+27
View File
@@ -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, 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{}) { 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)) 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
View File
@@ -57,7 +57,7 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp {
return topUp return topUp
} }
func Recharge(referenceId string, customerId string) (err error) { func Recharge(referenceId string, customerId string, callerIp string) (err error) {
if referenceId == "" { if referenceId == "" {
return errors.New("未提供支付单号") return errors.New("未提供支付单号")
} }
@@ -105,7 +105,7 @@ func Recharge(referenceId string, customerId string) (err error) {
return errors.New("充值失败,请稍后重试") 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 return nil
} }
@@ -242,7 +242,7 @@ func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp
} }
// ManualCompleteTopUp 管理员手动完成订单并给用户充值 // ManualCompleteTopUp 管理员手动完成订单并给用户充值
func ManualCompleteTopUp(tradeNo string) error { func ManualCompleteTopUp(tradeNo string, callerIp string) error {
if tradeNo == "" { if tradeNo == "" {
return errors.New("未提供订单号") return errors.New("未提供订单号")
} }
@@ -255,6 +255,7 @@ func ManualCompleteTopUp(tradeNo string) error {
var userId int var userId int
var quotaToAdd int var quotaToAdd int
var payMoney float64 var payMoney float64
var paymentMethod string
err := DB.Transaction(func(tx *gorm.DB) error { err := DB.Transaction(func(tx *gorm.DB) error {
topUp := &TopUp{} topUp := &TopUp{}
@@ -301,6 +302,7 @@ func ManualCompleteTopUp(tradeNo string) error {
userId = topUp.UserId userId = topUp.UserId
payMoney = topUp.Money payMoney = topUp.Money
paymentMethod = topUp.PaymentMethod
return nil 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 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 == "" { if referenceId == "" {
return errors.New("未提供支付单号") return errors.New("未提供支付单号")
} }
@@ -382,12 +384,12 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return errors.New("充值失败,请稍后重试") 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 return nil
} }
func RechargeWaffo(tradeNo string) (err error) { func RechargeWaffo(tradeNo string, callerIp string) (err error) {
if tradeNo == "" { if tradeNo == "" {
return errors.New("未提供支付单号") return errors.New("未提供支付单号")
} }
@@ -444,7 +446,7 @@ func RechargeWaffo(tradeNo string) (err error) {
} }
if quotaToAdd > 0 { 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 return nil
@@ -876,7 +876,12 @@ export const getLogsColumns = ({
), ),
dataIndex: 'ip', dataIndex: 'ip',
render: (text, record, index) => { 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}> <Tooltip content={text}>
<span> <span>
<Tag <Tag
+35 -2
View File
@@ -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({ expandDataLocal.push({
key: t('请求转换'), key: t('请求转换'),
value: requestConversionDisplayValue(other?.request_conversion), value: requestConversionDisplayValue(other?.request_conversion),
}); });
} }
if (isAdminUser && logs[i].type !== 6) { if (isAdminUser && logs[i].type !== 6 && logs[i].type !== 1) {
let localCountMode = ''; let localCountMode = '';
if (other?.admin_info?.local_count_tokens) { if (other?.admin_info?.local_count_tokens) {
localCountMode = t('本地计费'); localCountMode = t('本地计费');
@@ -713,6 +713,39 @@ export const useLogsData = () => {
value: localCountMode, 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; expandDatesLocal[logs[i].key] = expandDataLocal;
} }
+343 -193
View File
File diff suppressed because it is too large Load Diff
+368 -205
View File
File diff suppressed because it is too large Load Diff
+342 -191
View File
File diff suppressed because it is too large Load Diff
+374 -205
View File
File diff suppressed because it is too large Load Diff
+343 -195
View File
File diff suppressed because it is too large Load Diff
+1052 -438
View File
File diff suppressed because it is too large Load Diff
+655 -165
View File
File diff suppressed because it is too large Load Diff