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
No known key found for this signature in database
GPG Key ID: 0CFA613529A9921D
15 changed files with 3570 additions and 1617 deletions

View File

@ -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
}

View File

@ -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)

View File

@ -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

View File

@ -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

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,
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))

View File

@ -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

View File

@ -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

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({
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;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff