mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package dashboard
|
||||
|
||||
const (
|
||||
dashboardCacheKeyPrefix = "dashboard:stats:user_balance"
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// getDateRange 计算查询的时间范围
|
||||
func getDateRange(days int) (startDate, endDate time.Time) {
|
||||
now := time.Now()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return todayStart.AddDate(0, 0, -(days - 1)), todayStart.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
// dailyAmountResult 每日金额查询结果
|
||||
type dailyAmountResult struct {
|
||||
Date time.Time
|
||||
Amount decimal.Decimal
|
||||
}
|
||||
|
||||
// queryDailyAmounts 查询每日金额
|
||||
// isIncome: true=收入(payee), false=支出(payer)
|
||||
func queryDailyAmounts(ctx context.Context, userID uint64, isIncome bool, startDate, endDate time.Time) (map[string]decimal.Decimal, error) {
|
||||
var results []dailyAmountResult
|
||||
var err error
|
||||
|
||||
if isIncome {
|
||||
// 收入查询:payee_user_id = user
|
||||
// 包括:普通收款、红包领取(red_envelope_receive)、红包退款(red_envelope_refund)
|
||||
err = db.DB(ctx).Model(&model.Order{}).
|
||||
Select("DATE_TRUNC('day', created_at) as date, SUM(amount) as amount").
|
||||
Where("payee_user_id = ?", userID).
|
||||
Where("status = ?", model.OrderStatusSuccess).
|
||||
Where("created_at >= ? AND created_at < ?", startDate, endDate).
|
||||
Group("DATE_TRUNC('day', created_at)").
|
||||
Scan(&results).Error
|
||||
} else {
|
||||
// 支出查询:payer_user_id = user,但排除 red_envelope_receive
|
||||
// red_envelope_receive 的 payer_user_id 是红包创建者,但创建者的支出已在 red_envelope_send 时计算
|
||||
err = db.DB(ctx).Model(&model.Order{}).
|
||||
Select("DATE_TRUNC('day', created_at) as date, SUM(amount) as amount").
|
||||
Where("payer_user_id = ?", userID).
|
||||
Where("status = ?", model.OrderStatusSuccess).
|
||||
Where("type != ?", model.OrderTypeRedEnvelopeReceive).
|
||||
Where("created_at >= ? AND created_at < ?", startDate, endDate).
|
||||
Group("DATE_TRUNC('day', created_at)").
|
||||
Scan(&results).Error
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 转换为 map
|
||||
statsMap := make(map[string]decimal.Decimal)
|
||||
for _, r := range results {
|
||||
dateStr := r.Date.Format("2006-01-02")
|
||||
statsMap[dateStr] = r.Amount
|
||||
}
|
||||
|
||||
return statsMap, nil
|
||||
}
|
||||
|
||||
// mergeDailyStats 合并收入和支出统计,填充无数据的日期
|
||||
func mergeDailyStats(startDate time.Time, days int, incomeMap, expenseMap map[string]decimal.Decimal) []DailyStatsItem {
|
||||
result := make([]DailyStatsItem, days)
|
||||
|
||||
for i := range result {
|
||||
dateStr := startDate.AddDate(0, 0, i).Format("2006-01-02")
|
||||
result[i] = DailyStatsItem{
|
||||
Date: dateStr,
|
||||
Income: incomeMap[dateStr],
|
||||
Expense: expenseMap[dateStr],
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// userBalanceStatsResult 用户余额统计查询结果
|
||||
type userBalanceStatsResult struct {
|
||||
TotalCount int64
|
||||
TotalAmount decimal.Decimal
|
||||
AvgAmount decimal.Decimal
|
||||
MinAmount decimal.Decimal
|
||||
MaxAmount decimal.Decimal
|
||||
StdDev decimal.Decimal
|
||||
}
|
||||
|
||||
// calculateUserBalanceStats 计算用户余额统计数据
|
||||
func calculateUserBalanceStats(ctx context.Context) (*userBalanceStatsResult, error) {
|
||||
var result userBalanceStatsResult
|
||||
|
||||
err := db.DB(ctx).Model(&model.User{}).
|
||||
Select(`
|
||||
COUNT(*) as total_count,
|
||||
COALESCE(SUM(available_balance), 0) as total_amount,
|
||||
COALESCE(AVG(available_balance), 0) as avg_amount,
|
||||
COALESCE(MIN(available_balance), 0) as min_amount,
|
||||
COALESCE(MAX(available_balance), 0) as max_amount,
|
||||
COALESCE(STDDEV_SAMP(available_balance), 0) as std_dev
|
||||
`).
|
||||
Scan(&result).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// calculateMedian 计算用户余额中位数
|
||||
func calculateMedian(ctx context.Context) (decimal.Decimal, error) {
|
||||
var median decimal.Decimal
|
||||
|
||||
err := db.DB(ctx).Raw(`
|
||||
SELECT COALESCE(
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY available_balance),
|
||||
0
|
||||
) as median
|
||||
FROM users
|
||||
`).Scan(&median).Error
|
||||
|
||||
if err != nil {
|
||||
return decimal.Zero, err
|
||||
}
|
||||
|
||||
return median, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/linux-do/credit/internal/apps/oauth"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// DailyStatsRequest 每日统计请求参数
|
||||
type DailyStatsRequest struct {
|
||||
Days int `form:"days" binding:"required,min=1,max=7"`
|
||||
}
|
||||
|
||||
// DailyStatsItem 每日统计项
|
||||
type DailyStatsItem struct {
|
||||
Date string `json:"date"`
|
||||
Income decimal.Decimal `json:"income"`
|
||||
Expense decimal.Decimal `json:"expense"`
|
||||
}
|
||||
|
||||
// TopCustomersRequest Top客户请求参数
|
||||
type TopCustomersRequest struct {
|
||||
Days int `form:"days" binding:"required,min=1,max=7"`
|
||||
Limit int `form:"limit" binding:"required,min=1,max=10"`
|
||||
}
|
||||
|
||||
// TopCustomer Top客户项
|
||||
type TopCustomer struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
TotalAmount decimal.Decimal `json:"total_amount"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
}
|
||||
|
||||
// GetDailyStats 获取每日收支统计
|
||||
// @Summary 获取每日收支统计
|
||||
// @Tags dashboard
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param days query int true "查询天数,最大7天"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/dashboard/stats/daily [get]
|
||||
func GetDailyStats(c *gin.Context) {
|
||||
var req DailyStatsRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
ctx := c.Request.Context()
|
||||
startDate, endDate := getDateRange(req.Days)
|
||||
|
||||
// 查询每日收入(用户作为收款方)
|
||||
incomeStats, err := queryDailyAmounts(ctx, user.ID, true, startDate, endDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 查询每日支出(用户作为付款方)
|
||||
expenseStats, err := queryDailyAmounts(ctx, user.ID, false, startDate, endDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
dailyStats := mergeDailyStats(startDate, req.Days, incomeStats, expenseStats)
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(dailyStats))
|
||||
}
|
||||
|
||||
// GetTopCustomers 获取Top客户(向当前用户付款最多的客户)
|
||||
// @Summary 获取Top客户
|
||||
// @Tags dashboard
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param days query int true "查询天数,最大7天"
|
||||
// @Param limit query int true "返回数量,最大10"
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
// @Router /api/v1/dashboard/stats/top-customers [get]
|
||||
func GetTopCustomers(c *gin.Context) {
|
||||
var req TopCustomersRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
ctx := c.Request.Context()
|
||||
startDate, endDate := getDateRange(req.Days)
|
||||
|
||||
// 查询Top客户
|
||||
var customers []TopCustomer
|
||||
err := db.DB(ctx).Model(&model.Order{}).
|
||||
Select(`
|
||||
orders.payer_user_id as user_id,
|
||||
users.username,
|
||||
SUM(orders.amount) as total_amount,
|
||||
COUNT(*) as order_count
|
||||
`).
|
||||
Joins("LEFT JOIN users ON orders.payer_user_id = users.id").
|
||||
Where("orders.payee_user_id = ?", user.ID).
|
||||
Where("orders.status = ?", model.OrderStatusSuccess).
|
||||
Where("orders.type in ?", []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
|
||||
Where("orders.created_at >= ? AND orders.created_at < ?", startDate, endDate).
|
||||
Group("orders.payer_user_id, users.username").
|
||||
Order("total_amount DESC").
|
||||
Limit(req.Limit).
|
||||
Scan(&customers).Error
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(customers))
|
||||
}
|
||||
|
||||
// UserBalanceStatsResponse 用户余额统计响应
|
||||
type UserBalanceStatsResponse struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
TotalAmount decimal.Decimal `json:"total_amount"`
|
||||
AvgAmount decimal.Decimal `json:"avg_amount"`
|
||||
MedianAmount decimal.Decimal `json:"median_amount"`
|
||||
MinAmount decimal.Decimal `json:"min_amount"`
|
||||
MaxAmount decimal.Decimal `json:"max_amount"`
|
||||
StdDev decimal.Decimal `json:"std_dev"`
|
||||
}
|
||||
|
||||
// GetUserBalanceStats 获取用户余额统计
|
||||
// @Summary 获取用户余额统计
|
||||
// @Description 统计所有用户的AvailableBalance字段
|
||||
// @Tags dashboard
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} util.ResponseAny{data=UserBalanceStatsResponse}
|
||||
// @Router /api/v1/dashboard/stats/user-balance [get]
|
||||
func GetUserBalanceStats(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var cachedStats UserBalanceStatsResponse
|
||||
if err := db.GetJSON(ctx, dashboardCacheKeyPrefix, &cachedStats); err == nil {
|
||||
c.JSON(http.StatusOK, util.OK(cachedStats))
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := calculateUserBalanceStats(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err("查询用户余额统计失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
median, err := calculateMedian(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err("计算中位数失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response := UserBalanceStatsResponse{
|
||||
TotalCount: stats.TotalCount,
|
||||
TotalAmount: stats.TotalAmount,
|
||||
AvgAmount: stats.AvgAmount,
|
||||
MedianAmount: median,
|
||||
MinAmount: stats.MinAmount,
|
||||
MaxAmount: stats.MaxAmount,
|
||||
StdDev: stats.StdDev,
|
||||
}
|
||||
|
||||
if cacheTTL, errGet := model.GetIntByKey(ctx, model.ConfigKeyUserBalanceStatsCacheTTL); errGet == nil && cacheTTL > 0 {
|
||||
_ = db.SetJSON(ctx, dashboardCacheKeyPrefix, response, time.Duration(cacheTTL)*time.Second)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(response))
|
||||
}
|
||||
Reference in New Issue
Block a user