swagger

移除 merchant

swagger

改造首页内容为通用后台管理系统定位

- 修改首页标题从 'LINUX DO Credit' 改为 'Modern Platform'
- 更新副标题为 '为二次开发而生'
- 更新首页描述为通用平台的特点
- 更新首页特性标签为 '开箱即用、高度可扩展、工业级基建'
- 修改展示卡片为技术栈和二次开发相关
- 更新开发者示例代码为通用的注册和 API Key 获取示例
- 更新页脚品牌名为 'Modern Platform'
- 调整页脚导航链接为通用平台相关内容

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

去除遗留

裁剪
移除 /api/v1/user/pay-key 相关代码

- 删除后端 UpdatePayKey 处理器函数和 UpdatePayKeyRequest 结构体
- 删除 User 模型中的 PayKey 字段
- 删除 User.VerifyPayKey 方法
- 删除 EncryptPayKeyFailed 错误常量
- 删除 /api/v1/user/pay-key PUT 路由
- 删除 OAuth 返回中的 IsPayKey 字段
- 删除前端 UserService.updatePayKey 方法
- 删除前端所有支付密钥 UI 和逻辑
- 更新相关的导出和注释

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

去除遗留

api 修正

系统配置

前端裁剪

后端裁剪

init
This commit is contained in:
ryan
2026-06-07 15:32:14 +08:00
parent 8a782525de
commit 48d414e197
180 changed files with 1538 additions and 27463 deletions
+22 -5
View File
@@ -31,6 +31,7 @@ import (
type CreateSystemConfigRequest struct {
Key string `json:"key" binding:"required,max=64"`
Value string `json:"value" binding:"required,max=255"`
Type string `json:"type" binding:"required,oneof=system business"`
Description string `json:"description" binding:"max=255"`
}
@@ -41,10 +42,12 @@ type UpdateSystemConfigRequest struct {
}
// CreateSystemConfig 创建系统配置
// @Summary 创建系统配置
// @Description 创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreateSystemConfigRequest true "request body"
// @Param request body CreateSystemConfigRequest true "创建请求参数"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/system-configs [post]
func CreateSystemConfig(c *gin.Context) {
@@ -67,6 +70,7 @@ func CreateSystemConfig(c *gin.Context) {
config := model.SystemConfig{
Key: req.Key,
Value: req.Value,
Type: req.Type,
Description: req.Description,
}
@@ -90,15 +94,22 @@ func CreateSystemConfig(c *gin.Context) {
}
// ListSystemConfigs 获取系统配置列表
// @Summary 获取系统配置列表
// @Description 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限
// @Tags admin
// @Produce json
// @Param type query string false "配置类型(system/business)"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/system-configs [get]
func ListSystemConfigs(c *gin.Context) {
configType := c.Query("type")
query := db.DB(c.Request.Context()).Order("created_at DESC")
if configType != "" {
query = query.Where("type = ?", configType)
}
var configs []model.SystemConfig
if err := db.DB(c.Request.Context()).
Order("created_at DESC").
Find(&configs).Error; err != nil {
if err := query.Find(&configs).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
@@ -107,6 +118,8 @@ func ListSystemConfigs(c *gin.Context) {
}
// GetSystemConfig 获取单个系统配置
// @Summary 获取单个系统配置
// @Description 根据配置键获取对应的系统配置详情,需要管理员权限
// @Tags admin
// @Produce json
// @Param key path string true "配置键"
@@ -127,11 +140,13 @@ func GetSystemConfig(c *gin.Context) {
}
// UpdateSystemConfig 更新系统配置
// @Summary 更新系统配置
// @Description 根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Param key path string true "配置键"
// @Param request body UpdateSystemConfigRequest true "request body"
// @Param request body UpdateSystemConfigRequest true "更新请求参数"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/system-configs/{key} [put]
func UpdateSystemConfig(c *gin.Context) {
@@ -178,6 +193,8 @@ func UpdateSystemConfig(c *gin.Context) {
}
// DeleteSystemConfig 删除系统配置
// @Summary 删除系统配置
// @Description 根据配置键删除对应配置,同时从 Redis 中移除对应缓存,需要管理员权限
// @Tags admin
// @Produce json
// @Param key path string true "配置键"
+7 -37
View File
@@ -17,7 +17,6 @@ limitations under the License.
package task
import (
"encoding/json"
"fmt"
"net/http"
"time"
@@ -30,6 +29,8 @@ import (
)
// ListTaskTypes 获取支持的任务类型列表
// @Summary 获取支持的任务类型
// @Description 返回系统支持的所有可调度任务类型列表,需要管理员权限
// @Tags admin
// @Produce json
// @Success 200 {object} util.ResponseAny
@@ -47,10 +48,12 @@ type DispatchTaskRequest struct {
}
// DispatchTask 下发任务
// @Summary 下发异步任务
// @Description 手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Param request body DispatchTaskRequest true "request body"
// @Param request body DispatchTaskRequest true "任务请求参数"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/tasks/dispatch [post]
func DispatchTask(c *gin.Context) {
@@ -69,41 +72,8 @@ func DispatchTask(c *gin.Context) {
var taskInfo *asynq.Task
var taskID string
switch req.TaskType {
case task.TaskTypeOrderSync:
if req.StartTime != nil && req.EndTime != nil {
if req.EndTime.Before(*req.StartTime) {
c.JSON(http.StatusBadRequest, util.Err(InvalidTimeRange))
return
}
payload, _ := json.Marshal(map[string]interface{}{
"start_time": req.StartTime,
"end_time": req.EndTime,
})
taskInfo = asynq.NewTask(meta.AsynqTask, payload)
taskID = fmt.Sprintf("manual_%s_%s_%s", req.TaskType,
req.StartTime.Format("20060102150405"),
req.EndTime.Format("20060102150405"))
} else {
taskInfo = asynq.NewTask(meta.AsynqTask, nil)
taskID = fmt.Sprintf("manual_%s", req.TaskType)
}
case task.TaskTypeUserGamification:
if req.UserID == nil || *req.UserID == 0 {
taskInfo = asynq.NewTask(task.UpdateUserGamificationScoresTask, nil)
taskID = fmt.Sprintf("manual_%s", req.TaskType)
} else {
payload, _ := json.Marshal(map[string]interface{}{
"user_id": *req.UserID,
})
taskInfo = asynq.NewTask(meta.AsynqTask, payload)
taskID = fmt.Sprintf("manual_%s_user_%d", req.TaskType, *req.UserID)
}
default:
taskInfo = asynq.NewTask(meta.AsynqTask, nil)
taskID = fmt.Sprintf("manual_%s", req.TaskType)
}
taskInfo = asynq.NewTask(meta.AsynqTask, nil)
taskID = fmt.Sprintf("manual_%s", req.TaskType)
_, err := scheduler.AsynqClient.Enqueue(
taskInfo,
+6 -1
View File
@@ -64,6 +64,8 @@ type listUsersResponse struct {
}
// ListUsers 获取用户列表
// @Summary 获取用户列表
// @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限
// @Tags admin
// @Produce json
// @Param request query listUsersRequest true "查询参数"
@@ -122,10 +124,13 @@ type updateUserStatusRequest struct {
}
// UpdateUserStatus 更新用户状态(启用/禁用)
// @Summary 更新用户状态
// @Description 启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Param id path int true "用户ID"
// @Param request body updateUserStatusRequest true "状态"
// @Param request body updateUserStatusRequest true "状态参数"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/users/{id}/status [put]
func UpdateUserStatus(c *gin.Context) {
@@ -1,25 +0,0 @@
/*
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 user_pay_config
const (
UserPayConfigNotFound = "支付配置不存在"
LevelRequired = "等级不能为空"
MinScoreRequired = "最小分数不能为空"
ScoreRangeInvalid = "分数范围无效:最大分数必须大于最小分数"
LevelExists = "等级已存在"
)
@@ -1,211 +0,0 @@
/*
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 user_pay_config
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"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"
"gorm.io/gorm"
)
// CreateUserPayConfigRequest 创建支付配置请求
type CreateUserPayConfigRequest struct {
Level model.PayLevel `json:"level"`
MinScore int64 `json:"min_score"`
MaxScore *int64 `json:"max_score" binding:"omitempty,gtfield=MinScore"`
DailyLimit *int64 `json:"daily_limit"`
FeeRate decimal.Decimal `json:"fee_rate" binding:"required"`
ScoreRate decimal.Decimal `json:"score_rate" binding:"required"`
DistributeRate decimal.Decimal `json:"distribute_rate" binding:"required"`
}
// UpdateUserPayConfigRequest 更新支付配置请求
type UpdateUserPayConfigRequest struct {
MinScore int64 `json:"min_score"`
MaxScore *int64 `json:"max_score" binding:"omitempty,gtfield=MinScore"`
DailyLimit *int64 `json:"daily_limit"`
FeeRate decimal.Decimal `json:"fee_rate" binding:"required"`
ScoreRate decimal.Decimal `json:"score_rate" binding:"required"`
DistributeRate decimal.Decimal `json:"distribute_rate" binding:"required"`
}
// CreateUserPayConfig 创建支付配置
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreateUserPayConfigRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/user-pay-configs [post]
func CreateUserPayConfig(c *gin.Context) {
var req CreateUserPayConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 验证费率和积分倍率
if err := util.ValidateRates(req.FeeRate, req.ScoreRate, req.DistributeRate); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 检查等级是否已存在
var existing model.UserPayConfig
if err := db.DB(c.Request.Context()).Where("level = ?", req.Level).First(&existing).Error; err == nil {
c.JSON(http.StatusBadRequest, util.Err(LevelExists))
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
config := model.UserPayConfig{
Level: req.Level,
MinScore: req.MinScore,
MaxScore: req.MaxScore,
DailyLimit: req.DailyLimit,
FeeRate: req.FeeRate,
ScoreRate: req.ScoreRate,
DistributeRate: req.DistributeRate,
}
if err := db.DB(c.Request.Context()).Create(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(config))
}
// ListUserPayConfigs 获取支付配置列表
// @Tags admin
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/user-pay-configs [get]
func ListUserPayConfigs(c *gin.Context) {
var configs []model.UserPayConfig
if err := db.DB(c.Request.Context()).
Order("min_score ASC").
Find(&configs).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(configs))
}
// GetUserPayConfig 获取单个支付配置
// @Tags admin
// @Produce json
// @Param id path string true "配置ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/user-pay-configs/{id} [get]
func GetUserPayConfig(c *gin.Context) {
var config model.UserPayConfig
if err := db.DB(c.Request.Context()).Where("id = ?", c.Param("id")).First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(UserPayConfigNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
c.JSON(http.StatusOK, util.OK(config))
}
// UpdateUserPayConfig 更新支付配置
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "配置ID"
// @Param request body UpdateUserPayConfigRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/user-pay-configs/{id} [put]
func UpdateUserPayConfig(c *gin.Context) {
var req UpdateUserPayConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 验证费率和积分倍率
if err := util.ValidateRates(req.FeeRate, req.ScoreRate); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 检查配置是否存在
var config model.UserPayConfig
if err := db.DB(c.Request.Context()).Where("id = ?", c.Param("id")).First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(UserPayConfigNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
// 更新配置
if err := db.DB(c.Request.Context()).
Model(&config).
Updates(map[string]interface{}{
"min_score": req.MinScore,
"max_score": req.MaxScore,
"fee_rate": req.FeeRate,
"score_rate": req.ScoreRate,
"daily_limit": req.DailyLimit,
"distribute_rate": req.DistributeRate,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// DeleteUserPayConfig 删除支付配置
// @Tags admin
// @Produce json
// @Param id path string true "配置ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/user-pay-configs/{id} [delete]
func DeleteUserPayConfig(c *gin.Context) {
// 检查配置是否存在
var config model.UserPayConfig
if err := db.DB(c.Request.Context()).Where("id = ?", c.Param("id")).First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(UserPayConfigNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
if err := db.DB(c.Request.Context()).Delete(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+29 -60
View File
@@ -22,88 +22,57 @@ import (
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
// PublicConfigResponse 公共配置响应
type PublicConfigResponse struct {
DisputeTimeWindowHours int `json:"dispute_time_window_hours"` // 争议时间窗口(小时)
RedEnvelopeEnabled bool `json:"red_envelope_enabled"` // 红包功能是否启用
RedEnvelopeMaxAmount decimal.Decimal `json:"red_envelope_max_amount"` // 单个红包的最大积分上限
RedEnvelopeDailyLimit int `json:"red_envelope_daily_limit"` // 每日发红包的个数限制
RedEnvelopeFeeRate decimal.Decimal `json:"red_envelope_fee_rate"` // 红包手续费率
RedEnvelopeMaxRecipients int `json:"red_envelope_max_recipients"` // 每个红包的最大可领取人数上限
SettlementDelayDaysMin int `json:"settlement_delay_days_min"` // 商户收款延迟到账最小天数
SettlementDelayDaysMax int `json:"settlement_delay_days_max"` // 商户收款延迟到账最大天数
UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名
SiteName string `json:"site_name"` // 站点名称
RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册
MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量
}
// GetPublicConfig 获取公共配置
// @Summary 获取公共配置
// @Description 返回对前端公开的系统配置信息,如允许上传的文件类型、站点名称、是否开放注册等
// @Tags config
// @Accept json
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/config/public [get]
func GetPublicConfig(c *gin.Context) {
// 获取争议时间窗口配置
disputeTimeHours, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyDisputeTimeWindowHours)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
ctx := c.Request.Context()
var sc model.SystemConfig
// 1. upload_allowed_extensions
var uploadExtensions string
if err := sc.GetByKey(ctx, model.ConfigKeyUploadAllowedExtensions); err == nil {
uploadExtensions = sc.Value
}
// 获取红包功能启用状态
redEnvelopeEnabled, err := model.GetBoolByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeEnabled)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
// 2. site_name
var siteName string
if err := sc.GetByKey(ctx, model.ConfigKeySiteName); err == nil {
siteName = sc.Value
}
// 获取红包配置
redEnvelopeMaxAmount, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxAmount, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
// 3. registration_enabled
var registrationEnabled bool
if val, err := model.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled); err == nil {
registrationEnabled = val
}
redEnvelopeDailyLimit, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeDailyLimit)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
redEnvelopeFeeRate, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeFeeRate, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
redEnvelopeMaxRecipients, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxRecipients)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
settlementDelayDaysMin, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeySettlementDelayDaysMin)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
settlementDelayDaysMax, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeySettlementDelayDaysMax)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
// 4. max_api_keys_per_user
var maxAPIKeys int
if val, err := model.GetIntByKey(ctx, model.ConfigKeyMaxAPIKeysPerUser); err == nil {
maxAPIKeys = val
}
response := PublicConfigResponse{
DisputeTimeWindowHours: disputeTimeHours,
RedEnvelopeEnabled: redEnvelopeEnabled,
RedEnvelopeMaxAmount: redEnvelopeMaxAmount,
RedEnvelopeDailyLimit: redEnvelopeDailyLimit,
RedEnvelopeFeeRate: redEnvelopeFeeRate,
RedEnvelopeMaxRecipients: redEnvelopeMaxRecipients,
SettlementDelayDaysMin: settlementDelayDaysMin,
SettlementDelayDaysMax: settlementDelayDaysMax,
UploadAllowedExtensions: uploadExtensions,
SiteName: siteName,
RegistrationEnabled: registrationEnabled,
MaxAPIKeysPerUser: maxAPIKeys,
}
c.JSON(http.StatusOK, util.OK(response))
-21
View File
@@ -1,21 +0,0 @@
/*
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"
)
-149
View File
@@ -1,149 +0,0 @@
/*
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
}
-197
View File
@@ -1,197 +0,0 @@
/*
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))
}
-26
View File
@@ -1,26 +0,0 @@
/*
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 dispute
const (
OrderNotFoundForDispute = "订单不存在"
DisputeNotFound = "争议不存在"
NotOrderMerchant = "您不是该订单的商家"
ReasonRequiredForRefusal = "拒绝退款时必须提供理由"
DisputeTimeWindowExpired = "订单已交易完成,超过争议时间窗口,无法发起争议"
DuplicateDispute = "无法重复发起争议,如仍有疑问请联系商家或LINUX DO Credit 团队"
)
-449
View File
@@ -1,449 +0,0 @@
/*
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 dispute
import (
"errors"
"net/http"
"strings"
"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"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ListDisputesRequest 查询争议列表请求
type ListDisputesRequest struct {
Page int `json:"page" form:"page" binding:"min=1"`
PageSize int `json:"page_size" form:"page_size" binding:"min=1,max=100"`
Status string `json:"status" form:"status" binding:"omitempty,oneof=disputing refund closed"`
DisputeID *uint64 `json:"dispute_id,string" form:"dispute_id" binding:"omitempty"`
}
// ListDisputesResponse 查询争议列表响应
type ListDisputesResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Disputes []struct {
model.Dispute
OrderName string `json:"order_name"`
PayeeUsername string `json:"payee_username"`
Amount decimal.Decimal `json:"amount"`
} `json:"disputes"`
}
// ListDisputes 查询当前用户作为发起者的争议订单
// @Tags order
// @Accept json
// @Produce json
// @Param request body ListDisputesRequest false "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/disputes [post]
func ListDisputes(c *gin.Context) {
var req ListDisputesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
baseQuery := db.DB(c.Request.Context()).Model(&model.Dispute{}).
Select("disputes.*, orders.order_name, payee_user.username as payee_username, orders.amount, initiator_user.username as initiator_username, handler_user.username as handler_username").
Joins("JOIN orders ON disputes.order_id = orders.id").
Joins("JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
Joins("JOIN users as initiator_user ON disputes.initiator_user_id = initiator_user.id").
Joins("LEFT JOIN users as handler_user ON disputes.handler_user_id = handler_user.id").
Where("disputes.initiator_user_id = ?", user.ID)
if req.Status != "" {
baseQuery = baseQuery.Where("disputes.status = ?", model.DisputeStatus(req.Status))
}
if req.DisputeID != nil {
baseQuery = baseQuery.Where("disputes.id = ?", req.DisputeID)
}
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
response := &ListDisputesResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}
offset := (req.Page - 1) * req.PageSize
if err := baseQuery.Order("disputes.created_at DESC").Offset(offset).Limit(req.PageSize).Find(&response.Disputes).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(response))
}
// ListMerchantDisputes 查询当前用户作为商家的争议订单
// @Tags order
// @Accept json
// @Produce json
// @Param request body ListDisputesRequest false "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/disputes/merchant [post]
func ListMerchantDisputes(c *gin.Context) {
var req ListDisputesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
baseQuery := db.DB(c.Request.Context()).Model(&model.Dispute{}).
Select("disputes.*, orders.order_name, payee_user.username as payee_username, orders.amount, initiator_user.username as initiator_username, handler_user.username as handler_username").
Joins("JOIN orders ON disputes.order_id = orders.id").
Joins("JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
Joins("JOIN users as initiator_user ON disputes.initiator_user_id = initiator_user.id").
Joins("LEFT JOIN users as handler_user ON disputes.handler_user_id = handler_user.id").
Where("orders.payee_user_id = ?", user.ID)
if req.Status != "" {
baseQuery = baseQuery.Where("disputes.status = ?", model.DisputeStatus(req.Status))
}
if req.DisputeID != nil {
baseQuery = baseQuery.Where("disputes.id = ?", req.DisputeID)
}
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
response := &ListDisputesResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}
offset := (req.Page - 1) * req.PageSize
if err := baseQuery.Order("disputes.created_at DESC").Offset(offset).Limit(req.PageSize).Find(&response.Disputes).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(response))
}
// CreateDisputeRequest 发起争议请求
type CreateDisputeRequest struct {
OrderID uint64 `json:"order_id,string" binding:"required"`
Reason string `json:"reason" binding:"required,max=100"`
}
// CreateDispute 发起争议
// @Tags order
// @Accept json
// @Produce json
// @Param request body CreateDisputeRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/dispute [post]
func CreateDispute(c *gin.Context) {
var req CreateDisputeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
// 获取争议时间窗口配置(小时)
disputeTimeHours, errKey := model.GetIntByKey(c.Request.Context(), model.ConfigKeyDisputeTimeWindowHours)
if errKey != nil {
c.JSON(http.StatusInternalServerError, util.Err(errKey.Error()))
return
}
dispute := model.Dispute{
OrderID: req.OrderID,
InitiatorUserID: user.ID,
Reason: req.Reason,
Status: model.DisputeStatusDisputing,
}
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND payer_user_id = ? AND status = ? AND type IN ?", req.OrderID, user.ID, model.OrderStatusSuccess, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(OrderNotFoundForDispute)
}
return err
}
// 检查是否在争议时间窗口内
// 订单支付时间 + 争议时间窗口 <= 当前时间,则无法发起争议
disputeDeadline := order.TradeTime.Add(time.Duration(disputeTimeHours) * time.Hour)
if time.Now().After(disputeDeadline) {
return errors.New(DisputeTimeWindowExpired)
}
if err := tx.Create(&dispute).Error; err != nil {
return err
}
// 更新订单状态为争议中
if err := tx.Model(&order).Update("status", model.OrderStatusDisputing).Error; err != nil {
return err
}
return nil
},
); err != nil {
errMsg := err.Error()
if errMsg == OrderNotFoundForDispute {
c.JSON(http.StatusNotFound, util.Err(OrderNotFoundForDispute))
} else if errMsg == DisputeTimeWindowExpired {
c.JSON(http.StatusBadRequest, util.Err(DisputeTimeWindowExpired))
} else if strings.Contains(errMsg, "SQLSTATE 23505") {
c.JSON(http.StatusBadRequest, util.Err(DuplicateDispute))
} else {
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OK(dispute))
}
// RefundReviewRequest 退款审核请求
type RefundReviewRequest struct {
DisputeID uint64 `json:"dispute_id,string" binding:"required"`
Status string `json:"status" binding:"required,oneof=refund closed"`
Reason string `json:"reason" binding:"omitempty,max=100"`
}
// RefundReview 退款审核(同意/拒绝)
// @Tags order
// @Accept json
// @Produce json
// @Param request body RefundReviewRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/refund-review [post]
func RefundReview(c *gin.Context) {
var req RefundReviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
status := model.DisputeStatus(req.Status)
if status == model.DisputeStatusClosed && req.Reason == "" {
c.JSON(http.StatusBadRequest, util.Err(ReasonRequiredForRefusal))
return
}
merchantUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
var dispute model.Dispute
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND status = ?", req.DisputeID, model.DisputeStatusDisputing).
First(&dispute).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(DisputeNotFound)
}
return err
}
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND payee_user_id = ? AND status = ? AND type IN ?", dispute.OrderID, merchantUser.ID, model.OrderStatusDisputing, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(NotOrderMerchant)
}
return err
}
if status == model.DisputeStatusRefund {
var payerUser model.User
if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
return err
}
// 获取商家的支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
return err
}
merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
if err := tx.Model(&model.User{}).
Where("id = ?", merchantUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance - ?", order.Amount),
"total_receive": gorm.Expr("total_receive - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", payerUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance + ?", order.Amount),
"total_payment": gorm.Expr("total_payment - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
}).Error; err != nil {
return err
}
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
Updates(map[string]interface{}{
"status": model.DisputeStatusRefund,
"handler_user_id": merchantUser.ID,
}).Error; err != nil {
return err
}
if err := tx.Model(&model.Order{}).
Where("id = ?", order.ID).
Update("status", model.OrderStatusRefund).Error; err != nil {
return err
}
} else if status == model.DisputeStatusClosed {
updateData := map[string]interface{}{
"status": model.DisputeStatusClosed,
"handler_user_id": merchantUser.ID,
"reason": dispute.Reason + " [服务方拒绝理由: " + req.Reason + "]",
}
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
Updates(updateData).Error; err != nil {
return err
}
if err := tx.Model(&model.Order{}).
Where("id = ?", order.ID).
Update("status", model.OrderStatusRefused).Error; err != nil {
return err
}
}
return nil
},
); err != nil {
errMsg := err.Error()
if errMsg == DisputeNotFound {
c.JSON(http.StatusNotFound, util.Err(DisputeNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// CloseDisputeRequest 关闭争议请求
type CloseDisputeRequest struct {
DisputeID uint64 `json:"dispute_id,string" binding:"required"`
}
// CloseDispute 用户主动关闭争议(只能由发起者关闭)
// @Tags order
// @Accept json
// @Produce json
// @Param request body CloseDisputeRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/dispute/close [post]
func CloseDispute(c *gin.Context) {
var req CloseDisputeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
var dispute model.Dispute
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND initiator_user_id = ? AND status = ?", req.DisputeID, user.ID, model.DisputeStatusDisputing).
First(&dispute).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(DisputeNotFound)
}
return err
}
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND status = ? AND type IN ?", dispute.OrderID, model.OrderStatusDisputing, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(OrderNotFoundForDispute)
}
return err
}
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
Updates(map[string]interface{}{
"status": model.DisputeStatusClosed,
"handler_user_id": user.ID,
}).Error; err != nil {
return err
}
if err := tx.Model(&model.Order{}).
Where("id = ?", order.ID).
Update("status", model.OrderStatusSuccess).Error; err != nil {
return err
}
return nil
},
); err != nil {
errMsg := err.Error()
if errMsg == DisputeNotFound {
c.JSON(http.StatusNotFound, util.Err(DisputeNotFound))
} else if errMsg == OrderNotFoundForDispute {
c.JSON(http.StatusNotFound, util.Err(OrderNotFoundForDispute))
} else {
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OKNil())
}
-199
View File
@@ -1,199 +0,0 @@
/*
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 dispute
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// HandleAutoRefundExpiredDisputes 处理所有过期争议的批量任务
func HandleAutoRefundExpiredDisputes(ctx context.Context, t *asynq.Task) error {
// 获取争议时间窗口配置(小时)
disputeTimeHours, errGet := model.GetIntByKey(ctx, model.ConfigKeyDisputeTimeWindowHours)
if errGet != nil {
logger.ErrorF(ctx, "获取争议时间窗口配置失败: %v", errGet)
return errGet
}
pageSize := 1000
lastID := uint64(0)
currentDelay := 0 * time.Second
// 计算过期时间阈值:created_at < deadline 的争议需要自动退款
deadline := time.Now().Add(-time.Duration(disputeTimeHours) * time.Hour)
for {
var disputes []model.Dispute
if err := db.DB(ctx).
Where("id > ? AND status = ? AND created_at < ?",
lastID, model.DisputeStatusDisputing, deadline).
Order("id ASC").
Limit(pageSize).
Find(&disputes).Error; err != nil {
logger.ErrorF(ctx, "查询过期争议失败: %v", err)
return err
}
// 没有更多争议,退出循环
if len(disputes) == 0 {
break
}
for _, dispute := range disputes {
currentDelay += time.Duration(config.Config.Scheduler.DisputeAutoRefundDispatchIntervalSeconds) * time.Second
payload, _ := json.Marshal(map[string]interface{}{
"dispute_id": dispute.ID,
})
if _, errTask := scheduler.AsynqClient.Enqueue(
asynq.NewTask(task.AutoRefundSingleDisputeTask, payload),
asynq.ProcessIn(currentDelay),
asynq.MaxRetry(5),
); errTask != nil {
logger.ErrorF(ctx, "下发争议[ID:%d]自动退款任务失败: %v", dispute.ID, errTask)
return errTask
} else {
logger.InfoF(ctx, "下发争议[ID:%d]自动退款任务成功", dispute.ID)
}
}
lastID = disputes[len(disputes)-1].ID
}
return nil
}
// HandleAutoRefundSingleDispute 处理单个争议的自动退款任务
func HandleAutoRefundSingleDispute(ctx context.Context, t *asynq.Task) error {
// 解析任务参数
var payload struct {
DisputeID uint64 `json:"dispute_id"`
}
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("解析任务参数失败: %w", err)
}
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var dispute model.Dispute
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND status = ?", payload.DisputeID, model.DisputeStatusDisputing).
First(&dispute).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.InfoF(ctx, "争议[ID:%d]已被处理或不存在,跳过", payload.DisputeID)
return nil // 已处理,不算错误
}
return err
}
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where(
"id = ? AND status = ? AND type IN ?",
dispute.OrderID,
model.OrderStatusDisputing,
[]model.OrderType{model.OrderTypePayment, model.OrderTypeOnline},
).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.ErrorF(ctx, "争议[ID:%d]关联订单[ID:%d]不存在或状态异常", payload.DisputeID, dispute.OrderID)
return nil // 订单状态异常,跳过
}
return err
}
// 获取付款方和收款方用户
var payerUser, payeeUser model.User
if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
return fmt.Errorf("查询付款方用户失败: %w", err)
}
if err := payeeUser.GetByID(tx, order.PayeeUserID); err != nil {
return fmt.Errorf("查询收款方用户失败: %w", err)
}
// 获取商家的支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(tx, payeeUser.PayScore); err != nil {
return fmt.Errorf("查询商家支付配置失败: %w", err)
}
// 计算商家积分减少:订单金额 × 商家的 score_rate
merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
// 商家(收款方)退款:扣除可用余额、总收款和积分
if err := tx.Model(&model.User{}).
Where("id = ?", payeeUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance - ?", order.Amount),
"total_receive": gorm.Expr("total_receive - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
}).Error; err != nil {
return fmt.Errorf("商家退款失败: %w", err)
}
// 付款方收到退款:增加可用余额,减少总支付和支付积分
if err := tx.Model(&model.User{}).
Where("id = ?", payerUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance + ?", order.Amount),
"total_payment": gorm.Expr("total_payment - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
}).Error; err != nil {
return fmt.Errorf("付款方退款失败: %w", err)
}
// 更新争议状态为已退款,handler_user_id 设为 0(系统自动处理)
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
Updates(map[string]interface{}{
"status": model.DisputeStatusRefund,
"handler_user_id": 0,
}).Error; err != nil {
return fmt.Errorf("更新争议状态失败: %w", err)
}
// 更新订单状态为已退款
if err := tx.Model(&model.Order{}).
Where("id = ?", order.ID).
Update("status", model.OrderStatusRefund).Error; err != nil {
return fmt.Errorf("更新订单状态失败: %w", err)
}
logger.InfoF(ctx, "自动退款成功: 争议[ID:%d] 订单[ID:%d] 金额[%s] 付款方[%s] 商家[%s]",
dispute.ID, order.ID, order.Amount.String(), payerUser.Username, payeeUser.Username)
return nil
}); err != nil {
logger.ErrorF(ctx, "处理争议[ID:%d]自动退款失败: %v", payload.DisputeID, err)
return err
}
return nil
}
+2
View File
@@ -24,6 +24,8 @@ import (
)
// Health godoc
// @Summary 健康检查
// @Description 检查服务是否正常运行
// @Tags health
// @Produce json
// @Success 200 {object} util.ResponseAny
-21
View File
@@ -1,21 +0,0 @@
/*
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 leaderboard
const (
cacheKeyPrefix = "user:leaderboard:"
)
-98
View File
@@ -1,98 +0,0 @@
/*
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 leaderboard
import (
"context"
"encoding/json"
"fmt"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
)
func getList(ctx context.Context, req *ListRequest) (*ListResponse, error) {
// 检查 Redis 缓存
cacheKey := fmt.Sprintf("%slist:p:%d:s:%d", cacheKeyPrefix, req.Page, req.PageSize)
if data, err := db.Redis.Get(ctx, db.PrefixedKey(cacheKey)).Bytes(); err == nil {
var cached ListResponse
if err := json.Unmarshal(data, &cached); err == nil {
return &cached, nil
}
}
// 2. 查询数据库
items, total, err := queryLeaderboard(ctx, req)
if err != nil {
return nil, err
}
response := &ListResponse{
SortBy: "available_balance",
Order: "desc",
Page: req.Page,
PageSize: req.PageSize,
Total: total,
Items: items,
}
if data, err := json.Marshal(response); err == nil {
_ = db.Redis.Set(ctx, db.PrefixedKey(cacheKey), data, getCacheTTL(ctx)).Err()
}
return response, nil
}
func getUserRank(ctx context.Context, userID uint64) (*UserRankResponse, error) {
// 检查缓存
cacheKey := fmt.Sprintf("%suser:%d", cacheKeyPrefix, userID)
if data, err := db.Redis.Get(ctx, db.PrefixedKey(cacheKey)).Bytes(); err == nil {
var cached UserRankResponse
if err := json.Unmarshal(data, &cached); err == nil {
return &cached, nil
}
}
// 查询用户余额
var user model.User
if err := db.DB(ctx).Select("id, available_balance").Where("id = ? AND is_active = ?", userID, true).First(&user).Error; err != nil {
return nil, err
}
// 计算排名:统计比当前用户排位更高的人数
// 排序规则:available_balance DESC, id ASC
// 即:余额更高,或余额相同但 id 更小
var rank int64
if err := db.DB(ctx).Model(&model.User{}).
Where("is_active = ? AND ((available_balance > ?) OR (available_balance = ? AND id < ?))",
true, user.AvailableBalance, user.AvailableBalance, userID).
Count(&rank).Error; err != nil {
return nil, err
}
response := &UserRankResponse{
User: UserRankInfo{
UserID: userID,
Rank: int(rank + 1),
AvailableBalance: user.AvailableBalance,
},
}
if data, err := json.Marshal(response); err == nil {
_ = db.Redis.Set(ctx, db.PrefixedKey(cacheKey), data, getCacheTTL(ctx)).Err()
}
return response, nil
}
-135
View File
@@ -1,135 +0,0 @@
/*
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 leaderboard
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
// ListRequest 排行榜列表请求
type ListRequest struct {
Page int `form:"page" binding:"required,min=1"`
PageSize int `form:"page_size" binding:"required,min=1,max=50"`
}
// LeaderboardEntry 排行榜条目(rank 由前端根据 offset + index + 1 计算)
type LeaderboardEntry struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
AvailableBalance decimal.Decimal `json:"available_balance"`
}
// ListResponse 排行榜列表响应
type ListResponse struct {
SortBy string `json:"sort_by"`
Order string `json:"order"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
Items []LeaderboardEntry `json:"items"`
}
// UserRankResponse 用户排名响应
type UserRankResponse struct {
User UserRankInfo `json:"user"`
}
// UserRankInfo 用户排名信息
type UserRankInfo struct {
UserID uint64 `json:"user_id"`
Rank int `json:"rank"`
AvailableBalance decimal.Decimal `json:"available_balance"`
}
// List 获取排行榜列表
// @Summary 获取排行榜列表
// @Tags leaderboard
// @Accept json
// @Produce json
// @Param page query int true "页码"
// @Param page_size query int true "每页数量"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/leaderboard [get]
func List(c *gin.Context) {
var req ListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
ctx := c.Request.Context()
response, err := getList(ctx, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(response))
}
// GetMyRank 获取当前用户排名
// @Summary 获取当前用户排名
// @Tags leaderboard
// @Accept json
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/leaderboard/me [get]
func GetMyRank(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
response, err := getUserRank(ctx, user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(response))
}
// GetUserRankByID 获取指定用户排名
// @Summary 获取指定用户排名
// @Tags leaderboard
// @Accept json
// @Produce json
// @Param id path int true "用户ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/leaderboard/users/{id} [get]
func GetUserRankByID(c *gin.Context) {
userIDStr := c.Param("id")
userID, err := strconv.ParseUint(userIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err("invalid user id"))
return
}
ctx := c.Request.Context()
response, err := getUserRank(ctx, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(response))
}
-56
View File
@@ -1,56 +0,0 @@
/*
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 leaderboard
import (
"context"
"time"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
)
func getCacheTTL(ctx context.Context) time.Duration {
ttl, err := model.GetIntByKey(ctx, model.ConfigKeyLeaderboardCacheTTLSeconds)
if err != nil || ttl <= 0 {
ttl = 30
}
return time.Duration(ttl) * time.Second
}
func queryLeaderboard(ctx context.Context, req *ListRequest) ([]LeaderboardEntry, int64, error) {
offset := (req.Page - 1) * req.PageSize
baseQuery := db.DB(ctx).Model(&model.User{}).Where("is_active = ?", true)
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
var items []LeaderboardEntry
if err := baseQuery.
Select("id as user_id, username, avatar_url, available_balance").
Order("available_balance DESC, id ASC").
Offset(offset).
Limit(req.PageSize).
Scan(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
-22
View File
@@ -1,22 +0,0 @@
/*
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 api_key
const (
APIKeyNotFound = "API Key 不存在"
NoFieldsToUpdate = "没有需要更新的字段"
)
@@ -1,46 +0,0 @@
/*
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 api_key
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/merchant"
"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"
)
func RequireAPIKey() gin.HandlerFunc {
return func(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
var apiKey model.MerchantAPIKey
if err := db.DB(c.Request.Context()).
Where("id = ? AND user_id = ?", c.Param("id"), user.ID).
First(&apiKey).Error; err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, util.Err(APIKeyNotFound))
return
}
util.SetToContext(c, merchant.APIKeyObjKey, &apiKey)
c.Next()
}
}
-200
View File
@@ -1,200 +0,0 @@
/*
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 api_key
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/merchant"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/apps/payment"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
type CreateAPIKeyRequest struct {
AppName string `json:"app_name" binding:"required,max=20"`
AppHomepageURL string `json:"app_homepage_url" binding:"required,max=100,url"`
AppDescription string `json:"app_description" binding:"max=100"`
RedirectURI string `json:"redirect_uri" binding:"omitempty,max=100,url"`
NotifyURL string `json:"notify_url" binding:"required,max=100,url"`
PublicKey string `json:"public_key" binding:"omitempty,max=100"`
TestMode bool `json:"test_mode"`
}
type UpdateAPIKeyRequest struct {
AppName string `json:"app_name" binding:"omitempty,max=20"`
AppHomepageURL string `json:"app_homepage_url" binding:"omitempty,max=100,url"`
AppDescription string `json:"app_description" binding:"omitempty,max=100"`
RedirectURI string `json:"redirect_uri" binding:"omitempty,max=100,url"`
NotifyURL string `json:"notify_url" binding:"omitempty,max=100,url"`
PublicKey string `json:"public_key" binding:"omitempty,max=100"`
TestMode bool `json:"test_mode"`
}
type APIKeyListResponse struct {
Total int64 `json:"total"`
Data []model.MerchantAPIKey `json:"data"`
}
// CreateAPIKey 创建商户 API Key
// @Tags merchant
// @Accept json
// @Produce json
// @Param request body CreateAPIKeyRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys [post]
func CreateAPIKey(c *gin.Context) {
var req CreateAPIKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
apiKey := model.MerchantAPIKey{
UserID: user.ID,
ClientID: util.GenerateUniqueIDSimple(),
ClientSecret: util.GenerateUniqueIDSimple(),
AppName: req.AppName,
AppHomepageURL: req.AppHomepageURL,
AppDescription: req.AppDescription,
RedirectURI: req.RedirectURI,
NotifyURL: req.NotifyURL,
TestMode: req.TestMode,
}
if len(req.PublicKey) > 0 {
publicKeyBytes, err := util.Base64Decode(req.PublicKey)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyFormat))
return
}
if len(publicKeyBytes) != 32 {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyLength))
return
}
apiKey.PublicKey = publicKeyBytes
}
if err := db.DB(c.Request.Context()).Create(&apiKey).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(apiKey))
}
// ListAPIKeys 获取商户 API Key 列表
// @Tags merchant
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys [get]
func ListAPIKeys(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
var apiKeys []model.MerchantAPIKey
if err := db.DB(c.Request.Context()).
Where("user_id = ?", user.ID).
Order("created_at DESC").
Find(&apiKeys).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(apiKeys))
}
// GetAPIKey 获取单个商户 API Key
// @Tags merchant
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id} [get]
func GetAPIKey(c *gin.Context) {
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
c.JSON(http.StatusOK, util.OK(apiKey))
}
// UpdateAPIKey 更新商户 API Key
// @Tags merchant
// @Accept json
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param request body UpdateAPIKeyRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id} [put]
func UpdateAPIKey(c *gin.Context) {
var req UpdateAPIKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
updates := map[string]interface{}{
"app_name": req.AppName,
"app_homepage_url": req.AppHomepageURL,
"app_description": req.AppDescription,
"redirect_uri": req.RedirectURI,
"notify_url": req.NotifyURL,
"test_mode": req.TestMode,
}
if len(req.PublicKey) > 0 {
publicKeyBytes, err := util.Base64Decode(req.PublicKey)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyFormat))
return
}
if len(publicKeyBytes) != 32 {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyLength))
return
}
updates["public_key"] = publicKeyBytes
}
if err := db.DB(c.Request.Context()).
Model(&apiKey).
Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// DeleteAPIKey 删除商户 API Key
// @Tags merchant
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id} [delete]
func DeleteAPIKey(c *gin.Context) {
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
if err := db.DB(c.Request.Context()).Delete(&apiKey).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
-22
View File
@@ -1,22 +0,0 @@
/*
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 merchant
const (
APIKeyObjKey = "merchant_api_key_obj"
PaymentLinkObjKey = "payment_link_obj"
)
-23
View File
@@ -1,23 +0,0 @@
/*
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 link
const (
PaymentLinkNotFound = "支付链接不存在"
PaymentLinkTotalLimitExceeded = "该支付链接已达到付款次数上限"
PaymentLinkUserLimitExceeded = "您已达到该链接的付款次数限制"
)
@@ -1,42 +0,0 @@
/*
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 link
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/merchant"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
// RequirePaymentLink 根据 Token 查询支付链接并保存到上下文
func RequirePaymentLink() gin.HandlerFunc {
return func(c *gin.Context) {
var paymentLink model.MerchantPaymentLink
if err := paymentLink.GetByToken(db.DB(c.Request.Context()), c.Param("token")); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
return
}
util.SetToContext(c, merchant.PaymentLinkObjKey, &paymentLink)
c.Next()
}
}
-441
View File
@@ -1,441 +0,0 @@
/*
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 link
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/merchant"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// PayByLinkRequest 通过支付链接支付请求
type PayByLinkRequest struct {
Token string `json:"token" binding:"required"`
PayKey string `json:"pay_key" binding:"required,max=6"`
Remark string `json:"remark" binding:"max=100"`
}
// PaymentLinkRequest 创建支付链接请求
type PaymentLinkRequest struct {
Amount decimal.Decimal `json:"amount" binding:"required"`
ProductName string `json:"product_name" binding:"required,max=30"`
Remark string `json:"remark" binding:"max=100"`
TotalLimit *uint `json:"total_limit" binding:"omitempty,min=1"`
UserLimit *uint `json:"user_limit" binding:"omitempty,min=1"`
}
// CreatePaymentLink 创建支付链接
// @Tags merchant
// @Accept json
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param request body PaymentLinkRequest true "创建支付链接请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links [post]
func CreatePaymentLink(c *gin.Context) {
var req PaymentLinkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 验证金额
if err := util.ValidateAmount(req.Amount); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
paymentLink := model.MerchantPaymentLink{
MerchantAPIKeyID: apiKey.ID,
Token: util.GenerateUniqueIDSimple(),
Amount: req.Amount,
ProductName: req.ProductName,
Remark: req.Remark,
TotalLimit: req.TotalLimit,
UserLimit: req.UserLimit,
}
if err := db.DB(c.Request.Context()).Create(&paymentLink).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(paymentLink))
}
// PaymentLinkDetail 支付链接详情
type PaymentLinkDetail struct {
ID uint64 `json:"id,string"`
Token string `json:"token"`
Amount decimal.Decimal `json:"amount"`
ProductName string `json:"product_name"`
Remark string `json:"remark"`
TotalLimit *uint `json:"total_limit"`
UserLimit *uint `json:"user_limit"`
CreatedAt time.Time `json:"created_at"`
AppName string `json:"app_name"`
RedirectURI string `json:"redirect_uri"`
}
// ListPaymentLinks 获取支付链接列表
// @Tags merchant
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links [get]
func ListPaymentLinks(c *gin.Context) {
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
var paymentLinks []PaymentLinkDetail
if err := db.DB(c.Request.Context()).
Table("merchant_payment_links").
Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.total_limit, merchant_payment_links.user_limit, merchant_payment_links.created_at, merchant_api_keys.app_name, merchant_api_keys.redirect_uri").
Joins("JOIN merchant_api_keys ON merchant_api_keys.id = merchant_payment_links.merchant_api_key_id").
Where("merchant_payment_links.merchant_api_key_id = ? AND merchant_payment_links.deleted_at IS NULL", apiKey.ID).
Order("merchant_payment_links.created_at DESC").
Find(&paymentLinks).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(paymentLinks))
}
// GetPaymentLinkByToken 通过 Token 查询支付链接信息
// @Tags merchant
// @Produce json
// @Param token path string true "支付链接 Token"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/payment-links/{token} [get]
func GetPaymentLinkByToken(c *gin.Context) {
var paymentLink PaymentLinkDetail
if err := db.DB(c.Request.Context()).
Table("merchant_payment_links").
Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.created_at, merchant_api_keys.app_name, merchant_api_keys.redirect_uri").
Joins("JOIN merchant_api_keys ON merchant_api_keys.id = merchant_payment_links.merchant_api_key_id").
Where("merchant_payment_links.token = ? AND merchant_payment_links.deleted_at IS NULL", c.Param("token")).
First(&paymentLink).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
c.JSON(http.StatusOK, util.OK(paymentLink))
}
// DeletePaymentLink 删除支付链接
// @Tags merchant
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param linkId path uint64 true "Payment Link ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links/{linkId} [delete]
func DeletePaymentLink(c *gin.Context) {
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
linkID := c.Param("linkId")
result := db.DB(c.Request.Context()).
Where("id = ? AND merchant_api_key_id = ?", linkID, apiKey.ID).
Delete(&model.MerchantPaymentLink{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, util.Err(result.Error.Error()))
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// UpdatePaymentLink 更新支付链接
// @Tags merchant
// @Accept json
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param linkId path uint64 true "Payment Link ID"
// @Param request body PaymentLinkRequest true "更新支付链接请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links/{linkId} [put]
func UpdatePaymentLink(c *gin.Context) {
var req PaymentLinkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 验证金额
if err := util.ValidateAmount(req.Amount); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
linkID := c.Param("linkId")
result := db.DB(c.Request.Context()).
Model(&model.MerchantPaymentLink{}).
Where("id = ? AND merchant_api_key_id = ?", linkID, apiKey.ID).
Updates(map[string]interface{}{
"amount": req.Amount,
"product_name": req.ProductName,
"remark": req.Remark,
"total_limit": req.TotalLimit,
"user_limit": req.UserLimit,
})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, util.Err(result.Error.Error()))
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// PayByLink 通过支付链接支付
// @Tags merchant
// @Accept json
// @Produce json
// @Param request body PayByLinkRequest true "支付请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/payment-links/pay [post]
func PayByLink(c *gin.Context) {
var req PayByLinkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
var paymentLink model.MerchantPaymentLink
if err := paymentLink.GetByToken(db.DB(c.Request.Context()), req.Token); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if !currentUser.VerifyPayKey(req.PayKey) {
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
return
}
// 检查余额是否足够
if currentUser.AvailableBalance.LessThan(paymentLink.Amount) {
c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance))
return
}
// 查询商户 API Key
var merchantAPIKey model.MerchantAPIKey
if err := merchantAPIKey.GetByID(db.DB(c.Request.Context()), paymentLink.MerchantAPIKeyID); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 查询商户用户
var merchantUser model.User
if err := merchantUser.GetByID(db.DB(c.Request.Context()), merchantAPIKey.UserID); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 验证测试模式下的支付权限
if err := service.ValidateTestModePayment(currentUser.ID, merchantUser.ID, merchantAPIKey.TestMode); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 获取商户的支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(db.DB(c.Request.Context()), merchantUser.PayScore); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 获取付款方的支付配置
var payerPayConfig model.UserPayConfig
if err := payerPayConfig.GetByPayScore(db.DB(c.Request.Context()), currentUser.PayScore); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
isTestMode := merchantAPIKey.TestMode
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
// 非测试模式
if !isTestMode {
if paymentLink.TotalLimit != nil || paymentLink.UserLimit != nil {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", paymentLink.ID).Error; err != nil {
return err
}
}
// 检查总付款次数限制
if paymentLink.TotalLimit != nil {
var totalCount int64
if err := tx.Table("orders").
Where("payment_link_id = ? AND status = ?", paymentLink.ID, model.OrderStatusSuccess).
Count(&totalCount).Error; err != nil {
return err
}
if totalCount >= int64(*paymentLink.TotalLimit) {
return errors.New(PaymentLinkTotalLimitExceeded)
}
}
// 检查单用户付款次数限制
if paymentLink.UserLimit != nil {
var userCount int64
if err := tx.Table("orders").
Where("payment_link_id = ? AND status = ? AND payer_user_id = ?",
paymentLink.ID, model.OrderStatusSuccess, currentUser.ID).
Count(&userCount).Error; err != nil {
return err
}
if userCount >= int64(*paymentLink.UserLimit) {
return errors.New(PaymentLinkUserLimitExceeded)
}
}
// 检查每日限额
if err := service.CheckDailyLimit(tx, currentUser.ID, paymentLink.Amount, payerPayConfig.DailyLimit); err != nil {
return err
}
}
// 计算手续费
_, merchantAmount, feePercent := service.CalculateFee(paymentLink.Amount, merchantPayConfig.FeeRate)
var remark string
var orderType model.OrderType
var paymentLinkID *uint64
if isTestMode {
remark = common.TestModeOrderRemark
orderType = model.OrderTypeTest
} else {
feeRemark := fmt.Sprintf("[系统]: 收取商家%d%%手续费", feePercent)
if req.Remark != "" {
remark = req.Remark + " " + feeRemark
} else {
remark = feeRemark
}
orderType = model.OrderTypeOnline
paymentLinkID = &paymentLink.ID
}
// 创建订单
order := model.Order{
OrderName: paymentLink.ProductName,
PayerUserID: currentUser.ID,
PayeeUserID: merchantUser.ID,
ClientID: merchantAPIKey.ClientID,
Amount: paymentLink.Amount,
Status: model.OrderStatusSuccess,
Type: orderType,
Remark: remark,
PaymentLinkID: paymentLinkID,
TradeTime: time.Now(),
ExpiresAt: time.Now(),
}
if err := tx.Create(&order).Error; err != nil {
return err
}
// 非测试模式:扣减用户余额和增加商户余额
if !isTestMode {
// 扣用户
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: currentUser.ID,
Amount: paymentLink.Amount,
Operation: service.BalanceDeduct,
ScoreChange: paymentLink.Amount.Round(0).IntPart(),
TotalField: "total_payment",
CheckBalance: true,
}); err != nil {
return err
}
// 加商家
merchantScoreIncrease := paymentLink.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: merchantUser.ID,
Amount: merchantAmount,
Operation: service.BalanceAdd,
ScoreChange: merchantScoreIncrease,
TotalField: "total_receive",
CheckBalance: false,
AsyncTransfer: true,
}); err != nil {
return err
}
// 创建异步流转记录
orderTransfer := model.OrderTransfer{
OrderID: order.ID,
PayeeUserID: merchantUser.ID,
Amount: merchantAmount,
Status: model.OrderTransferStatusPending,
TransferAt: model.GetRandomSettleAt(c.Request.Context()),
}
if err := tx.Create(&orderTransfer).Error; err != nil {
return err
}
}
return service.EnqueueMerchantNotify(order.ID, merchantAPIKey.ClientID)
},
); err != nil {
errMsg := err.Error()
switch errMsg {
case common.InsufficientBalance, common.DailyLimitExceeded,
PaymentLinkTotalLimitExceeded, PaymentLinkUserLimitExceeded:
c.JSON(http.StatusBadRequest, util.Err(errMsg))
default:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+54 -43
View File
@@ -48,67 +48,78 @@ func GetUserIDFromContext(c *gin.Context) uint64 {
return GetUserIDFromSession(session)
}
// doOAuth 执行 OAuth2/OIDC 认证流程
// doOAuth 执行 OAuth2/OIDC 流程
func doOAuth(ctx context.Context, code string, nonce string) (*model.User, error) {
ctx, span := otel_trace.Start(ctx, "OAuth")
defer span.End()
// 使用授权码换取 Token
token, err := oauthConf.Exchange(ctx, code)
if err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
}
var userInfo model.OAuthUserInfo
if oidcVerifier != nil {
if rawIDToken, ok := token.Extra("id_token").(string); ok {
idToken, verifyErr := oidcVerifier.Verify(ctx, rawIDToken)
if verifyErr != nil {
err := fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if nonce != "" && idToken.Nonce != nonce {
span.SetStatus(codes.Error, NonceMismatch)
return nil, errors.New(NonceMismatch)
}
if claimsErr := idToken.Claims(&userInfo); claimsErr != nil {
span.SetStatus(codes.Error, claimsErr.Error())
return nil, claimsErr
}
if config.Config.App.Env == "development" && code == "dev_mock_code" {
userInfo = model.OAuthUserInfo{
Id: 999999,
Username: "dev_user",
Name: "Developer User",
Active: true,
AvatarUrl: "https://linux.do/user_avatar/linux.do/system/45/1_2.png",
TrustLevel: 3,
}
} else {
// 使用授权码换取 Token
token, err := oauthConf.Exchange(ctx, code)
if err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
}
}
if userInfo.GetID() == 0 {
client := oauthConf.Client(ctx, token)
resp, httpErr := client.Get(config.Config.OAuth2.UserEndpoint)
if httpErr != nil {
span.SetStatus(codes.Error, httpErr.Error())
return nil, httpErr
if oidcVerifier != nil {
if rawIDToken, ok := token.Extra("id_token").(string); ok {
idToken, verifyErr := oidcVerifier.Verify(ctx, rawIDToken)
if verifyErr != nil {
err := fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if nonce != "" && idToken.Nonce != nonce {
span.SetStatus(codes.Error, NonceMismatch)
return nil, errors.New(NonceMismatch)
}
if claimsErr := idToken.Claims(&userInfo); claimsErr != nil {
span.SetStatus(codes.Error, claimsErr.Error())
return nil, claimsErr
}
}
}
defer resp.Body.Close()
responseData, readErr := io.ReadAll(resp.Body)
if readErr != nil {
span.SetStatus(codes.Error, readErr.Error())
return nil, readErr
}
if unmarshalErr := json.Unmarshal(responseData, &userInfo); unmarshalErr != nil {
span.SetStatus(codes.Error, unmarshalErr.Error())
return nil, unmarshalErr
if userInfo.GetID() == 0 {
client := oauthConf.Client(ctx, token)
resp, httpErr := client.Get(config.Config.OAuth2.UserEndpoint)
if httpErr != nil {
span.SetStatus(codes.Error, httpErr.Error())
return nil, httpErr
}
defer resp.Body.Close()
responseData, readErr := io.ReadAll(resp.Body)
if readErr != nil {
span.SetStatus(codes.Error, readErr.Error())
return nil, readErr
}
if unmarshalErr := json.Unmarshal(responseData, &userInfo); unmarshalErr != nil {
span.SetStatus(codes.Error, unmarshalErr.Error())
return nil, unmarshalErr
}
}
}
if !userInfo.Active {
err = errors.New(common.BannedAccount)
err := errors.New(common.BannedAccount)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
var user model.User
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var holder model.User
if conflictErr := tx.Where("username = ? AND id != ?", userInfo.Username, userInfo.GetID()).First(&holder).Error; conflictErr == nil {
// 存在冲突 -> 将占用者改名并注销
@@ -134,7 +145,7 @@ func doOAuth(ctx context.Context, code string, nonce string) (*model.User, error
} else if errors.Is(queryErr, gorm.ErrRecordNotFound) {
// 用户不存在 -> 创建新用户
user = model.User{}
if createErr := user.CreateWithInitialCredit(tx, &userInfo); createErr != nil {
if createErr := user.CreateUser(tx, &userInfo); createErr != nil {
return createErr
}
} else {
+18 -26
View File
@@ -24,14 +24,16 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
// GetLoginURL godoc
// @Summary 获取登录地址
// @Description 生成 OAuth 登录 URL,前端跳转至该地址完成授权
// @Tags oauth
// @Produce json
// @Success 200 {object} util.ResponseAny
@@ -49,7 +51,9 @@ func GetLoginURL(c *gin.Context) {
// 构造登录 URL
var authURL string
if oidcVerifier != nil {
if config.Config.App.Env == "development" {
authURL = fmt.Sprintf("%s/login?code=dev_mock_code&state=%s", config.Config.App.FrontendURL, state)
} else if oidcVerifier != nil {
// OIDC 模式:state 同时用作 nonce
authURL = oauthConf.AuthCodeURL(state, oidc.Nonce(state))
} else {
@@ -65,8 +69,11 @@ type CallbackRequest struct {
}
// Callback godoc
// @Summary OAuth 回调
// @Description 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立用户会话
// @Tags oauth
// @Param request body CallbackRequest true "request body"
// @Accept json
// @Param request body CallbackRequest true "回调请求参数"
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/oauth/callback [post]
@@ -122,14 +129,15 @@ type BasicUserInfo struct {
AvailableBalance decimal.Decimal `json:"available_balance"`
PendingBalance decimal.Decimal `json:"pending_balance"`
PayScore int64 `json:"pay_score"`
IsPayKey bool `json:"is_pay_key"`
IsAdmin bool `json:"is_admin"`
RemainQuota decimal.Decimal `json:"remain_quota"`
PayLevel model.PayLevel `json:"pay_level"`
PayLevel string `json:"pay_level"`
DailyLimit *int64 `json:"daily_limit"`
}
// UserInfo godoc
// @Summary 获取当前登录用户信息
// @Description 返回当前登录用户的基本信息及余额数据,需要登录
// @Tags oauth
// @Produce json
// @Success 200 {object} util.ResponseAny
@@ -137,23 +145,6 @@ type BasicUserInfo struct {
func UserInfo(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, UserObjKey)
var payConfig model.UserPayConfig
if err := payConfig.GetByPayScore(db.DB(c.Request.Context()), user.PayScore); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 计算剩余额度(-1 表示无限额)
remainQuota := decimal.NewFromInt(-1)
if payConfig.DailyLimit != nil && *payConfig.DailyLimit > 0 {
todayUsed, err := service.GetTodayUsedAmount(db.DB(c.Request.Context()), user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
remainQuota = decimal.NewFromInt(*payConfig.DailyLimit).Sub(todayUsed)
}
c.JSON(
http.StatusOK,
util.OK(BasicUserInfo{
@@ -170,16 +161,17 @@ func UserInfo(c *gin.Context) {
AvailableBalance: user.AvailableBalance,
PendingBalance: user.PendingBalance,
PayScore: user.PayScore,
IsPayKey: user.PayKey != "",
IsAdmin: user.IsAdmin,
RemainQuota: remainQuota,
PayLevel: payConfig.Level,
DailyLimit: payConfig.DailyLimit,
RemainQuota: decimal.NewFromInt(-1),
PayLevel: "Free",
DailyLimit: nil,
}),
)
}
// Logout godoc
// @Summary 退出登录
// @Description 清除当前用户的登录会话,完成退出
// @Tags oauth
// @Produce json
// @Success 200 {object} util.ResponseAny
-219
View File
@@ -1,219 +0,0 @@
/*
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 order
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"
)
type TransactionListRequest struct {
Page int `json:"page" form:"page" binding:"min=1"`
PageSize int `json:"page_size" form:"page_size" binding:"min=1,max=100"`
Types []string `json:"types" form:"types" binding:"omitempty,dive,oneof=receive payment transfer community online test distribute red_envelope_send red_envelope_receive red_envelope_refund"`
Statuses []string `json:"statuses" form:"statuses" binding:"omitempty,dive,oneof=success pending failed expired disputing refund refused"`
ClientID string `json:"client_id" form:"client_id" binding:"omitempty"`
StartTime *time.Time `json:"startTime" form:"startTime" binding:"omitempty"`
EndTime *time.Time `json:"endTime" form:"endTime" binding:"omitempty,gtfield=StartTime"`
ID *uint64 `json:"id,string" form:"id" binding:"omitempty"`
OrderName string `json:"order_name" form:"order_name" binding:"omitempty"`
PayerUsername string `json:"payer_username" form:"payer_username" binding:"omitempty"`
PayeeUsername string `json:"payee_username" form:"payee_username" binding:"omitempty"`
PayeeTransferStatus model.OrderTransferStatus `json:"payee_transfer_status" form:"payee_transfer_status" binding:"omitempty,oneof=pending completed"`
}
type TransactionListResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Orders []struct {
model.Order
AppName string `json:"app_name"`
AppHomepageURL string `json:"app_homepage_url"`
AppDescription string `json:"app_description"`
RedirectURI string `json:"redirect_uri"`
DisputeID *uint64 `json:"dispute_id,string"`
PayerUsername string `json:"payer_username"`
PayeeUsername string `json:"payee_username"`
PayerAvatarURL string `json:"payer_avatar_url"`
PayeeAvatarURL string `json:"payee_avatar_url"`
PayeeTransferStatus string `json:"payee_transfer_status"`
PayeeTransferAt string `json:"payee_transfer_at"`
} `json:"orders"`
}
// ListTransactions 获取交易列表
// @Tags order
// @Accept json
// @Produce json
// @Param request body TransactionListRequest false "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/transactions [post]
func ListTransactions(c *gin.Context) {
var req TransactionListRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
baseQuery := db.DB(c.Request.Context()).Model(&model.Order{}).
Select("orders.*, merchant_api_keys.app_name, merchant_api_keys.app_homepage_url, merchant_api_keys.app_description, merchant_api_keys.redirect_uri, disputes.id as dispute_id, payer_user.username as payer_username, payee_user.username as payee_username, payer_user.avatar_url as payer_avatar_url, payee_user.avatar_url as payee_avatar_url, order_transfers.status as payee_transfer_status, order_transfers.transfer_at as payee_transfer_at").
Joins("LEFT JOIN merchant_api_keys ON orders.client_id = merchant_api_keys.client_id").
Joins("LEFT JOIN disputes ON orders.id = disputes.order_id").
Joins("LEFT JOIN users as payer_user ON orders.payer_user_id = payer_user.id").
Joins("LEFT JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
Joins("LEFT JOIN order_transfers ON orders.id = order_transfers.order_id")
clientIDHandled := false
if len(req.Types) > 0 {
// 使用 IN 查询支持多个类型
var conditions []string
var args []interface{}
for _, t := range req.Types {
orderType := model.OrderType(t)
switch orderType {
case model.OrderTypeReceive:
// receive 类型:查询当前用户作为收款方的 payment 订单
conditions = append(conditions, "(orders.type = ? AND orders.payee_user_id = ?)")
args = append(args, model.OrderTypePayment, user.ID)
case model.OrderTypeCommunity, model.OrderTypeRedEnvelopeRefund, model.OrderTypeRedEnvelopeReceive:
// community、red_envelope_refund、red_envelope_receive 类型:查询当前用户作为收款方的订单
conditions = append(conditions, "(orders.type = ? AND orders.payee_user_id = ?)")
args = append(args, orderType, user.ID)
case model.OrderTypeOnline:
// online 类型:商家可查看自己 client_id 的所有订单,普通用户只能查看与自己相关的订单
if req.ClientID != "" {
clientIDHandled = true
var count int64
if err := db.DB(c.Request.Context()).Model(&model.MerchantAPIKey{}).
Where("client_id = ? AND user_id = ?", req.ClientID, user.ID).
Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if count > 0 {
conditions = append(conditions, "(orders.type = ? AND orders.client_id = ?)")
args = append(args, orderType, req.ClientID)
} else {
conditions = append(conditions, "(orders.type = ? AND orders.client_id = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?))")
args = append(args, orderType, req.ClientID, user.ID, user.ID)
}
} else {
conditions = append(conditions, "(orders.type = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?))")
args = append(args, orderType, user.ID, user.ID)
}
case model.OrderTypePayment, model.OrderTypeTransfer, model.OrderTypeTest, model.OrderTypeRedEnvelopeSend:
// payment、transfer、test、red_envelope_send 类型:查询当前用户作为付款方的订单
conditions = append(conditions, "(orders.type = ? AND orders.payer_user_id = ?)")
args = append(args, orderType, user.ID)
case model.OrderTypeDistribute:
// distribute 类型:查询当前用户作为付款方或者收款方的订单
conditions = append(conditions, "((orders.type = ? AND orders.payer_user_id = ?) OR (orders.type = ? AND orders.payee_user_id = ?))")
args = append(args, orderType, user.ID, orderType, user.ID)
}
}
if len(conditions) > 0 {
combinedCondition := conditions[0]
for i := 1; i < len(conditions); i++ {
combinedCondition += " OR " + conditions[i]
}
baseQuery = baseQuery.Where(combinedCondition, args...)
}
} else {
// 查询所有与当前用户相关的订单,但排除用户作为payer的red_envelope_receive订单
baseQuery = baseQuery.Where(
"orders.payee_user_id = ? OR (orders.payer_user_id = ? AND orders.type != ?)",
user.ID, user.ID, model.OrderTypeRedEnvelopeReceive,
)
}
if len(req.Statuses) > 0 {
baseQuery = baseQuery.Where("orders.status IN ?", req.Statuses)
}
if req.ClientID != "" && !clientIDHandled {
baseQuery = baseQuery.Where("orders.client_id = ?", req.ClientID)
}
if req.ID != nil {
baseQuery = baseQuery.Where("orders.id = ?", req.ID)
}
if req.OrderName != "" {
baseQuery = baseQuery.Where("orders.order_name LIKE ?", req.OrderName+"%")
}
if req.PayerUsername != "" {
baseQuery = baseQuery.Where("payer_user.username LIKE ?", req.PayerUsername+"%")
}
if req.PayeeUsername != "" {
baseQuery = baseQuery.Where("payee_user.username LIKE ?", req.PayeeUsername+"%")
}
if req.PayeeTransferStatus != "" {
switch req.PayeeTransferStatus {
case model.OrderTransferStatusPending:
baseQuery = baseQuery.Where("order_transfers.status = ?", model.OrderTransferStatusPending)
case model.OrderTransferStatusCompleted:
baseQuery = baseQuery.Where("order_transfers.status = ? OR order_transfers.status IS NULL", model.OrderTransferStatusCompleted)
}
}
if req.StartTime != nil {
baseQuery = baseQuery.Where("orders.created_at >= ?", req.StartTime)
}
if req.EndTime != nil {
baseQuery = baseQuery.Where("orders.created_at <= ?", req.EndTime)
}
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
response := &TransactionListResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}
offset := (req.Page - 1) * req.PageSize
if err := baseQuery.Order("orders.created_at DESC").Offset(offset).Limit(req.PageSize).Find(&response.Orders).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 转换订单类型:从收款方视角看,payment 订单应该显示为 receive
// 并更新 Payee_transfer_status,兼容为空的场景
for i := range response.Orders {
if response.Orders[i].PayeeTransferStatus == "" {
response.Orders[i].PayeeTransferStatus = string(model.OrderTransferStatusCompleted)
}
if response.Orders[i].Type == model.OrderTypePayment && response.Orders[i].PayeeUserID == user.ID {
response.Orders[i].Type = model.OrderTypeReceive
}
}
c.JSON(http.StatusOK, util.OK(response))
}
-190
View File
@@ -1,190 +0,0 @@
/*
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 order
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// HandleSyncOrdersToClickHouse 同步订单数据
func HandleSyncOrdersToClickHouse(ctx context.Context, t *asynq.Task) error {
if !config.Config.ClickHouse.Enabled {
logger.InfoF(ctx, "ClickHouse 未启用,跳过订单同步")
return nil
}
var payload struct {
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
}
_ = json.Unmarshal(t.Payload(), &payload)
var startOfDay, endOfDay time.Time
if payload.StartTime != nil && payload.EndTime != nil {
startOfDay = *payload.StartTime
endOfDay = *payload.EndTime
} else {
now := time.Now()
yesterday := now.AddDate(0, 0, -1)
startOfDay = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location())
endOfDay = startOfDay.AddDate(0, 0, 1)
}
logger.InfoF(ctx, "开始同步订单到 ClickHouse: %s ~ %s", startOfDay.Format("2006-01-02 15:04:05"), endOfDay.Format("2006-01-02 15:04:05"))
pageSize := 10000
lastID := uint64(0)
totalSynced := 0
for {
var orders []model.Order
if err := db.DB(ctx).
Where("updated_at >= ? AND updated_at < ? AND id > ?", startOfDay, endOfDay, lastID).
Order("id ASC").
Limit(pageSize).
Find(&orders).Error; err != nil {
logger.ErrorF(ctx, "查询订单失败: %v", err)
return err
}
if len(orders) == 0 {
break
}
if err := batchInsertToClickHouse(ctx, orders); err != nil {
logger.ErrorF(ctx, "写入 ClickHouse 失败: %v", err)
return err
}
totalSynced += len(orders)
lastID = orders[len(orders)-1].ID
logger.InfoF(ctx, "已同步 %d 条订单,当前进度 ID: %d", len(orders), lastID)
}
logger.InfoF(ctx, "订单同步完成,共同步 %d 条", totalSynced)
return nil
}
// batchInsertToClickHouse 批量写入订单
func batchInsertToClickHouse(ctx context.Context, orders []model.Order) error {
batch, err := db.ChConn.PrepareBatch(ctx, `
INSERT INTO orders (
id, order_name, merchant_order_no, client_id,
payer_user_id, payee_user_id, amount,
status, type, remark, payment_type,
trade_time, expires_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return err
}
for _, order := range orders {
if err := batch.Append(
order.ID,
order.OrderName,
util.DerefString(order.MerchantOrderNo),
order.ClientID,
order.PayerUserID,
order.PayeeUserID,
order.Amount,
string(order.Status),
string(order.Type),
order.Remark,
order.PaymentType,
order.TradeTime,
order.ExpiresAt,
order.CreatedAt,
order.UpdatedAt,
); err != nil {
return err
}
}
return batch.Send()
}
// HandleSettlePendingPayments 处理到期的延迟到账订单
func HandleSettlePendingPayments(ctx context.Context, _ *asynq.Task) error {
batchSize := 500
totalSettled := 0
for {
orders, err := model.GetDueTransferOrders(ctx, batchSize)
if err != nil {
logger.ErrorF(ctx, "查询到期待结算订单失败: %v", err)
return err
}
if len(orders) == 0 {
break
}
for _, order := range orders {
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
// 锁定行
var lockedOrderTransfer model.OrderTransfer
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND status = ?", order.ID, model.OrderTransferStatusPending).
First(&lockedOrderTransfer).Error; err != nil {
return fmt.Errorf("lock order failed: %w", err)
}
// 移动到可用余额
if err := service.SettlePendingToAvailable(tx, lockedOrderTransfer.PayeeUserID, lockedOrderTransfer.Amount); err != nil {
return fmt.Errorf("settle balance failed: %w", err)
}
// 更新订单状态
if err := tx.Model(&lockedOrderTransfer).Updates(
map[string]interface{}{
"transfer_at": time.Now(),
"status": model.OrderTransferStatusCompleted,
},
).Error; err != nil {
return fmt.Errorf("update order status failed: %w", err)
}
logger.InfoF(ctx, "订单[ID:%d]延迟到账结算成功: 商户[ID:%d] 金额[%s]", lockedOrderTransfer.ID, lockedOrderTransfer.PayeeUserID, lockedOrderTransfer.Amount.String())
return nil
}); err != nil {
logger.ErrorF(ctx, "结算订单[ID:%d]失败: %v", order.ID, err)
continue
}
totalSettled++
}
}
if totalSettled > 0 {
logger.InfoF(ctx, "延迟到账结算完成,共结算 %d 笔", totalSettled)
}
return nil
}
-29
View File
@@ -1,29 +0,0 @@
/*
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 payment
const (
APIKeyObjKey = "payment_api_key_obj"
CreateOrderRequestKey = "payment_create_order_request"
)
const (
// OrderMerchantIDCacheKeyFormat Redis key 格式,用于存储订单号对应的商户ID
OrderMerchantIDCacheKeyFormat = "payment:order:%s"
// OrderExpireKeyFormat Redis key 格式,用于订单过期监听,key中包含订单ID
OrderExpireKeyFormat = "payment:order:expire:%d"
)
-30
View File
@@ -1,30 +0,0 @@
/*
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 payment
const (
OrderNotFound = "订单不存在或已完成"
OrderStatusInvalid = "订单状态不允许支付"
OrderExpired = "订单已过期"
MerchantInfoNotFound = "商户信息不存在"
RecipientNotFound = "收款人不存在"
OrderNoFormatError = "订单号格式错误"
CannotTransferToSelf = "不能转账给自己"
PayConfigNotFound = "支付配置不存在"
InvalidPublicKeyFormat = "公钥格式错误"
InvalidPublicKeyLength = "公钥长度必须为32字节"
)
-161
View File
@@ -1,161 +0,0 @@
/*
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 payment
import (
"encoding/base64"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/common"
"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"
)
// CreateOrderRequest 商户创建订单统一请求
type CreateOrderRequest struct {
OrderName string `json:"order_name" binding:"required,max=64"`
MerchantOrderNo *string `json:"merchant_order_no" binding:"omitempty,min=1,max=64"`
Amount decimal.Decimal `json:"amount" binding:"required"`
Remark string `json:"remark" binding:"max=100"`
PaymentType string `json:"payment_type"`
NotifyURL string `json:"notify_url" binding:"omitempty,max=100,url"`
ReturnURL string `json:"return_url" binding:"omitempty,max=100,url"`
}
// EPayRequest 易支付请求
type EPayRequest struct {
ClientID string `form:"pid" binding:"required"`
OrderName string `form:"name" binding:"required,max=64"`
MerchantOrderNo *string `form:"out_trade_no" binding:"required,min=1,max=64"`
Amount decimal.Decimal `form:"money" binding:"required"`
NotifyURL string `form:"notify_url" binding:"omitempty,max=100,url"`
ReturnURL string `form:"return_url" binding:"omitempty,max=100,url"`
Device string `form:"device"`
Sign string `form:"sign" binding:"required"`
PayType string `form:"type" binding:"required"`
SignType string `form:"sign_type"`
}
// LDCPayRequest LDC支付请求
type LDCPayRequest struct {
ClientID string `form:"client_id" binding:"required"`
OrderName string `form:"order_name" binding:"required,max=64"`
MerchantOrderNo *string `form:"out_trade_no" binding:"required,min=1,max=64"`
Amount decimal.Decimal `form:"money" binding:"required"`
NotifyURL string `form:"notify_url" binding:"omitempty,max=100,url"`
ReturnURL string `form:"return_url" binding:"omitempty,max=100,url"`
PayType string `form:"type" binding:"required"`
Sign string `form:"sign" binding:"required"`
}
// NewCreateOrderRequest 从支付请求创建通用订单请求
func NewCreateOrderRequest(orderName string, merchantOrderNo *string, amount decimal.Decimal, payType string, notifyURL string, returnURL string) *CreateOrderRequest {
return &CreateOrderRequest{
OrderName: orderName,
MerchantOrderNo: merchantOrderNo,
Amount: amount,
PaymentType: payType,
NotifyURL: notifyURL,
ReturnURL: returnURL,
}
}
// RequireMerchantAuth 验证商户 ClientID/ClientSecret(Basic Auth)
func RequireMerchantAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// Authorization: Basic base64(ClientID:ClientSecret)
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("缺少认证信息"))
return
}
// 解析 Basic Auth
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Basic" {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证格式错误"))
return
}
// 解码 base64
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证信息解码失败"))
return
}
// 解析 ClientID:ClientSecret
credentials := strings.SplitN(string(decoded), ":", 2)
if len(credentials) != 2 {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证信息格式错误"))
return
}
clientID := credentials[0]
clientSecret := credentials[1]
var apiKey model.MerchantAPIKey
if err := db.DB(c.Request.Context()).
Where("client_secret = ? AND client_id = ?", clientSecret, clientID).
First(&apiKey).Error; err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证失败"))
return
}
util.SetToContext(c, APIKeyObjKey, &apiKey)
c.Next()
}
}
// RequireSignatureAuth 验证签名
func RequireSignatureAuth() gin.HandlerFunc {
return func(c *gin.Context) {
PayType := c.Request.FormValue("type")
var apiKey model.MerchantAPIKey
var createOrderReq *CreateOrderRequest
var err error
switch PayType {
case common.PayTypeLDCPay:
createOrderReq, err = VerifySignatureEd25519(c, &apiKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(err.Error()))
return
}
case common.PayTypeEPay:
createOrderReq, err = VerifySignatureMD5(c, &apiKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(err.Error()))
return
}
default:
c.AbortWithStatusJSON(http.StatusBadRequest, util.Err("不支持的请求类型"))
return
}
util.SetToContext(c, CreateOrderRequestKey, createOrderReq)
util.SetToContext(c, APIKeyObjKey, &apiKey)
c.Next()
}
}
-761
View File
@@ -1,761 +0,0 @@
/*
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 payment
import (
"cmp"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"time"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/service"
"github.com/gin-gonic/gin"
"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"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PayOrderRequest 用户支付订单请求
type PayOrderRequest struct {
OrderNo string `json:"order_no" binding:"required"`
PayKey string `json:"pay_key" binding:"required,max=6"`
}
// GetOrderRequest 查询订单请求
type GetOrderRequest struct {
OrderNo string `form:"order_no" json:"order_no" binding:"required"`
}
// MerchantInfo 商户信息
type MerchantInfo struct {
AppName string `json:"app_name"`
RedirectURI string `json:"redirect_uri"`
}
// GetOrderResponse 查询订单响应
type GetOrderResponse struct {
Order *model.Order `json:"order"`
FeeRate decimal.Decimal `json:"fee_rate"`
Merchant MerchantInfo `json:"merchant"`
}
// TransferRequest 转账请求
type TransferRequest struct {
RecipientID uint64 `json:"recipient_id,string" binding:"required"`
RecipientUsername string `json:"recipient_username" binding:"required"`
Amount decimal.Decimal `json:"amount" binding:"required"`
PayKey string `json:"pay_key" binding:"required,max=6"`
Remark string `json:"remark" binding:"max=100"`
}
// QueryOrderRequest 商户查询订单请求
type QueryOrderRequest struct {
Act string `form:"act" json:"act"`
ClientID string `form:"pid" json:"pid" binding:"required"`
ClientSecret string `form:"key" json:"key" binding:"required"`
MerchantOrderNo *string `form:"out_trade_no" json:"out_trade_no" binding:"required,min=1,max=64"`
}
// RefundOrderRequest 商户退款请求
type RefundOrderRequest struct {
ClientID string `form:"pid" json:"pid" binding:"required"`
ClientSecret string `form:"key" json:"key" binding:"required"`
MerchantOrderNo string `form:"out_trade_no" json:"out_trade_no"`
TradeNo uint64 `form:"trade_no" json:"trade_no" binding:"required"`
Amount decimal.Decimal `form:"money" json:"money" binding:"required"`
}
// CreateMerchantOrder 商户创建订单接口
// @Tags payment
// @Accept x-www-form-urlencoded
// @Produce json
// @Param request body CreateOrderRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /pay/submit.php [post]
// @Router /pay/submit.php [get]
func CreateMerchantOrder(c *gin.Context) {
req, _ := util.GetFromContext[*CreateOrderRequest](c, CreateOrderRequestKey)
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, APIKeyObjKey)
// 获取商户用户信息
var merchantUser model.User
if err := db.DB(c.Request.Context()).Where("id = ? AND is_active = ?", apiKey.UserID, true).First(&merchantUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(MerchantInfoNotFound))
return
}
// 获取商家订单过期时间(分钟)
expireMinutes, errGet := model.GetIntByKey(c.Request.Context(), model.ConfigKeyMerchantOrderExpireMinutes)
if errGet != nil {
c.JSON(http.StatusInternalServerError, util.Err(errGet.Error()))
return
}
var payURL string
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
// 创建订单
order := model.Order{
OrderName: req.OrderName,
ClientID: apiKey.ClientID,
MerchantOrderNo: req.MerchantOrderNo,
PayeeUserID: merchantUser.ID,
Amount: req.Amount,
Status: model.OrderStatusPending,
Type: model.OrderTypePayment,
Remark: req.Remark,
PaymentType: req.PaymentType,
RedirectURI: req.ReturnURL,
NotifyURL: req.NotifyURL,
ExpiresAt: time.Now().Add(time.Duration(expireMinutes) * time.Minute),
}
if err := tx.Create(&order).Error; err != nil {
return err
}
encryptString, err := util.Encrypt(merchantUser.SignKey, strconv.FormatUint(order.ID, 10))
if err != nil {
return err
}
merchantIDStr := strconv.FormatUint(merchantUser.ID, 10)
if errSet := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OrderMerchantIDCacheKeyFormat, encryptString)), merchantIDStr, time.Duration(expireMinutes)*time.Minute).Err(); errSet != nil {
return fmt.Errorf("failed to set redis key: %w", errSet)
}
expireKey := db.PrefixedKey(fmt.Sprintf(OrderExpireKeyFormat, order.ID))
if errSet := db.Redis.Set(c.Request.Context(), expireKey, order.ID, time.Duration(expireMinutes)*time.Minute).Err(); errSet != nil {
return fmt.Errorf("failed to set order expire key: %w", errSet)
}
payURL = fmt.Sprintf("%s?order_no=%s", config.Config.App.FrontendPayURL, url.QueryEscape(encryptString))
return nil
},
); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.Redirect(http.StatusFound, payURL)
}
// QueryMerchantOrderResponse 查询订单响应
type QueryMerchantOrderResponse struct {
Code int `json:"code" example:"1"`
Msg string `json:"msg" example:"查询订单号成功!"`
TradeNo string `json:"trade_no" example:"123456"`
OutTradeNo string `json:"out_trade_no" example:"M202312080001"`
Type string `json:"type" example:"epay"`
Pid string `json:"pid" example:"1001"`
AddTime string `json:"addtime" example:"2023-12-08 12:00:00"`
EndTime string `json:"endtime" example:"2023-12-08 12:05:00"`
Name string `json:"name" example:"商品名称"`
Money string `json:"money" example:"10.00"`
Status int `json:"status" example:"1"`
}
// QueryMerchantOrder 商户主动查询订单状态接口
// @Tags payment
// @Accept json
// @Produce json
// @Param request query QueryOrderRequest true "查询参数"
// @Success 200 {object} QueryMerchantOrderResponse
// @Router /api.php [get]
func QueryMerchantOrder(c *gin.Context) {
var req QueryOrderRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": err.Error()})
return
}
var apiKey model.MerchantAPIKey
if err := db.DB(c.Request.Context()).Where("client_id = ? AND client_secret = ?", req.ClientID, req.ClientSecret).First(&apiKey).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": MerchantInfoNotFound})
return
}
var order model.Order
if err := db.DB(c.Request.Context()).Where("client_id = ? AND merchant_order_no = ?", req.ClientID, req.MerchantOrderNo).First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": -1, "msg": OrderNotFound})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": -1, "msg": err.Error()})
return
}
statusInt := 0
if order.Status == model.OrderStatusSuccess {
statusInt = 1
}
c.JSON(http.StatusOK, gin.H{
"code": 1,
"msg": "查询订单号成功!",
"trade_no": strconv.FormatUint(order.ID, 10),
"out_trade_no": order.MerchantOrderNo,
"type": order.PaymentType,
"pid": order.ClientID,
"addtime": order.CreatedAt.Format("2006-01-02 15:04:05"),
"endtime": order.TradeTime.Format("2006-01-02 15:04:05"),
"name": order.OrderName,
"money": order.Amount.Truncate(2).StringFixed(2),
"status": statusInt,
})
}
// RefundMerchantOrderResponse 退款响应
type RefundMerchantOrderResponse struct {
Code int `json:"code" example:"1"`
Msg string `json:"msg" example:"退款成功"`
}
// RefundMerchantOrder 商户退款接口
// @Tags payment
// @Accept json
// @Produce json
// @Param request body RefundOrderRequest true "退款请求"
// @Success 200 {object} RefundMerchantOrderResponse
// @Router /api.php [post]
func RefundMerchantOrder(c *gin.Context) {
var req RefundOrderRequest
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": err.Error()})
return
}
if err := util.ValidateAmount(req.Amount); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": err.Error()})
return
}
var apiKey model.MerchantAPIKey
if err := db.DB(c.Request.Context()).Where("client_id = ? AND client_secret = ?", req.ClientID, req.ClientSecret).First(&apiKey).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": MerchantInfoNotFound})
return
}
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND client_id = ? AND status = ? AND amount = ? AND type IN ?", req.TradeNo, req.ClientID, model.OrderStatusSuccess, req.Amount, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(OrderNotFound)
}
return err
}
var payerUser model.User
if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
return err
}
var merchantUser model.User
if err := tx.Where("id = ? AND is_active = ?", apiKey.UserID, true).First(&merchantUser).Error; err != nil {
return err
}
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
return err
}
merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
if err := tx.Model(&model.User{}).
Where("id = ?", merchantUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance - ?", order.Amount),
"total_receive": gorm.Expr("total_receive - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", payerUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance + ?", order.Amount),
"total_payment": gorm.Expr("total_payment - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
}).Error; err != nil {
return err
}
if err := tx.Model(&model.Order{}).
Where("id = ?", order.ID).
Update("status", model.OrderStatusRefund).Error; err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusOK, gin.H{"code": -1, "msg": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 1,
"msg": "退款成功",
})
}
// MerchantDistributeRequest 商户分发请求
type MerchantDistributeRequest struct {
RecipientID uint64 `json:"user_id" binding:"required"`
RecipientUsername string `json:"username" binding:"required"`
Amount decimal.Decimal `json:"amount" binding:"required"`
MerchantOrderNo *string `json:"out_trade_no" binding:"omitempty,min=1,max=64"`
Remark string `json:"remark" binding:"max=100"`
}
// MerchantDistribute 商户分发接口(商户向用户分发)
// @Tags payment
// @Accept json
// @Produce json
// @Param Authorization header string true "Basic Auth (base64(client_id:client_secret))"
// @Param request body MerchantDistributeRequest true "分发请求"
// @Success 200 {object} util.ResponseAny
// @Router /pay/distribute [post]
func MerchantDistribute(c *gin.Context) {
var req MerchantDistributeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := util.ValidateAmount(req.Amount); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, APIKeyObjKey)
var orderID uint64
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
// 验证收款人是否存在且用户名匹配
var recipient model.User
if err := tx.Where("id = ? AND username = ?", req.RecipientID, req.RecipientUsername).First(&recipient).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(RecipientNotFound)
}
return err
}
// 获取商户用户信息
var merchantUser model.User
if err := tx.Where("id = ? AND is_active = ?", apiKey.UserID, true).
First(&merchantUser).Error; err != nil {
return errors.New(MerchantInfoNotFound)
}
// 不能分发给自己
if recipient.ID == merchantUser.ID {
return errors.New(CannotTransferToSelf)
}
// 获取商户支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
return errors.New(PayConfigNotFound)
}
if err := service.CheckDailyLimit(tx, merchantUser.ID, req.Amount, merchantPayConfig.DailyLimit); err != nil {
return err
}
_, recipientAmount, distributePercent := service.CalculateFee(req.Amount, merchantPayConfig.DistributeRate)
merchantScore := req.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
order := model.Order{
OrderName: "商户分发",
ClientID: apiKey.ClientID,
MerchantOrderNo: req.MerchantOrderNo,
PayerUserID: merchantUser.ID,
PayeeUserID: recipient.ID,
Amount: req.Amount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeDistribute,
Remark: req.Remark,
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
distributeRemark := fmt.Sprintf("[系统]: 分发费率%d%%", distributePercent)
if order.Remark != "" {
order.Remark = order.Remark + " " + distributeRemark
} else {
order.Remark = distributeRemark
}
if err := tx.Create(&order).Error; err != nil {
return err
}
orderID = order.ID
// 扣减商户余额,同时增加平台分数
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: merchantUser.ID,
Amount: req.Amount,
Operation: service.BalanceDeduct,
ScoreChange: merchantScore,
TotalField: "total_payment",
CheckBalance: true,
}); err != nil {
return err
}
// 增加收款人余额(按分发费率计算后的金额)
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: recipient.ID,
Amount: recipientAmount,
Operation: service.BalanceAdd,
TotalField: "total_receive",
CheckBalance: false,
AsyncTransfer: true,
}); err != nil {
return err
}
// 创建异步到账任务
orderTransfer := model.OrderTransfer{
OrderID: orderID,
PayeeUserID: recipient.ID,
Amount: recipientAmount,
Status: model.OrderTransferStatusPending,
TransferAt: model.GetRandomSettleAt(c.Request.Context()),
}
if err := tx.Create(&orderTransfer).Error; err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(gin.H{
"trade_no": strconv.FormatUint(orderID, 10),
"out_trade_no": req.MerchantOrderNo,
}))
}
// GetPaymentPageDetails 查询支付订单信息接口(用于收银台页面)
// @Tags payment
// @Accept json
// @Produce json
// @Param order_no query string true "订单号"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/payment/order [get]
func GetPaymentPageDetails(c *gin.Context) {
var req GetOrderRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
orderCtx, errCtx := ParseOrderNo(c, req.OrderNo)
if HandleParseOrderNoError(c, errCtx) {
return
}
var order model.Order
if err := db.DB(c.Request.Context()).
Select("orders.*, payee_user.username as payee_username").
Joins("LEFT JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
Where("orders.id = ? AND orders.status = ?", orderCtx.OrderID, model.OrderStatusPending).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(OrderNotFound))
return
}
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
order.PayerUsername = orderCtx.CurrentUser.Username
var merchant model.MerchantAPIKey
if err := db.DB(c.Request.Context()).
Where("client_id = ?", order.ClientID).
First(&merchant).Error; err != nil {
c.JSON(http.StatusNotFound, util.Err(MerchantInfoNotFound))
return
}
redirectURI := cmp.Or(order.RedirectURI, merchant.RedirectURI)
c.JSON(http.StatusOK, util.OK(GetOrderResponse{
Order: &order,
FeeRate: orderCtx.MerchantPayConfig.FeeRate,
Merchant: MerchantInfo{
AppName: merchant.AppName,
RedirectURI: redirectURI,
},
}))
}
// PayMerchantOrder 用户支付订单接口
// @Tags payment
// @Accept json
// @Produce json
// @Param request body PayOrderRequest true "支付订单请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/payment [post]
func PayMerchantOrder(c *gin.Context) {
var req PayOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
orderCtx, errCtx := ParseOrderNo(c, req.OrderNo)
if HandleParseOrderNoError(c, errCtx) {
return
}
if !orderCtx.CurrentUser.VerifyPayKey(req.PayKey) {
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
return
}
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND status = ?", orderCtx.OrderID, model.OrderStatusPending).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(OrderNotFound)
}
return err
}
// 检查订单是否过期
if order.ExpiresAt.Before(time.Now()) {
return errors.New(OrderExpired)
}
isTestMode := orderCtx.MerchantAPIKey.TestMode
// 非测试模式:检查每日限额
if !isTestMode {
if err := service.CheckDailyLimit(tx, orderCtx.CurrentUser.ID, order.Amount, orderCtx.PayerPayConfig.DailyLimit); err != nil {
return err
}
}
// 计算手续费
_, merchantAmount, feePercent := service.CalculateFee(order.Amount, orderCtx.MerchantPayConfig.FeeRate)
// 更新订单状态
order.Status = model.OrderStatusSuccess
order.PayerUserID = orderCtx.CurrentUser.ID
order.TradeTime = time.Now()
if isTestMode {
order.Type = model.OrderTypeTest
order.Remark = common.TestModeOrderRemark
} else {
feeRemark := fmt.Sprintf("[系统]: 收取商家%d%%手续费", feePercent)
if order.Remark != "" {
order.Remark = order.Remark + " " + feeRemark
} else {
order.Remark = feeRemark
}
}
if err := tx.Save(&order).Error; err != nil {
return err
}
// 非测试模式:扣减用户余额和增加商户余额
if !isTestMode {
// 扣用户
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: orderCtx.CurrentUser.ID,
Amount: order.Amount,
Operation: service.BalanceDeduct,
ScoreChange: order.Amount.Round(0).IntPart(),
TotalField: "total_payment",
CheckBalance: true,
}); err != nil {
return err
}
// 加给商家
merchantScoreIncrease := order.Amount.Mul(orderCtx.MerchantPayConfig.ScoreRate).Round(0).IntPart()
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: orderCtx.MerchantUser.ID,
Amount: merchantAmount,
Operation: service.BalanceAdd,
ScoreChange: merchantScoreIncrease,
TotalField: "total_receive",
CheckBalance: false,
AsyncTransfer: true,
}); err != nil {
return err
}
// 异步到账任务
orderTransfer := model.OrderTransfer{
OrderID: order.ID,
PayeeUserID: order.PayeeUserID,
Amount: merchantAmount,
Status: model.OrderTransferStatusPending,
TransferAt: model.GetRandomSettleAt(c.Request.Context()),
}
if err := tx.Create(&orderTransfer).Error; err != nil {
return err
}
}
expireKey := db.PrefixedKey(fmt.Sprintf(OrderExpireKeyFormat, order.ID))
if err := db.Redis.Del(c.Request.Context(), expireKey).Err(); err != nil {
log.Printf("[Payment] 删除订单过期key失败: order_id=%d, error=%v", order.ID, err)
}
return service.EnqueueMerchantNotify(order.ID, order.ClientID)
},
); err != nil {
errMsg := err.Error()
switch errMsg {
case common.InsufficientBalance, OrderExpired, common.DailyLimitExceeded:
c.JSON(http.StatusBadRequest, util.Err(errMsg))
case OrderNotFound:
c.JSON(http.StatusNotFound, util.Err(errMsg))
default:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// Transfer 用户转账接口
// @Tags payment
// @Accept json
// @Produce json
// @Param request body TransferRequest true "转账请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/payment/transfer [post]
func Transfer(c *gin.Context) {
var req TransferRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := util.ValidateAmount(req.Amount); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if !currentUser.VerifyPayKey(req.PayKey) {
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
return
}
if currentUser.ID == req.RecipientID && currentUser.Username == req.RecipientUsername {
c.JSON(http.StatusBadRequest, util.Err(CannotTransferToSelf))
return
}
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
// 验证收款人是否存在且用户名匹配
var recipient model.User
if err := tx.Where("id = ? AND username = ?", req.RecipientID, req.RecipientUsername).First(&recipient).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(RecipientNotFound)
}
return err
}
// 获取转账人支付配置
var payerPayConfig model.UserPayConfig
if err := payerPayConfig.GetByPayScore(tx, currentUser.PayScore); err != nil {
return err
}
if err := service.CheckDailyLimit(tx, currentUser.ID, req.Amount, payerPayConfig.DailyLimit); err != nil {
return err
}
// 创建转账订单
order := model.Order{
OrderName: "转账",
PayerUserID: currentUser.ID,
PayeeUserID: recipient.ID,
Amount: req.Amount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeTransfer,
Remark: req.Remark,
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
if err := tx.Create(&order).Error; err != nil {
return err
}
// 扣减付款人余额
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: currentUser.ID,
Amount: req.Amount,
Operation: service.BalanceDeduct,
TotalField: "total_transfer",
CheckBalance: true,
}); err != nil {
return err
}
// 增加收款人余额
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: recipient.ID,
Amount: req.Amount,
Operation: service.BalanceAdd,
TotalField: "total_receive",
CheckBalance: false,
}); err != nil {
return err
}
return nil
},
); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
-142
View File
@@ -1,142 +0,0 @@
/*
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 payment
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"gorm.io/gorm"
)
// HandleMerchantPaymentNotify 处理商户支付回调任务
func HandleMerchantPaymentNotify(ctx context.Context, t *asynq.Task) error {
// 解析任务参数
var payload struct {
OrderID uint64 `json:"order_id"`
ClientID string `json:"client_id"`
}
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
logger.ErrorF(ctx, "解析商户回调任务参数失败: %v", err)
return fmt.Errorf("解析任务参数失败: %w", err)
}
// 查询订单信息
var order model.Order
if err := db.DB(ctx).Where("id = ? AND status = ?", payload.OrderID, model.OrderStatusSuccess).First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.ErrorF(ctx, "订单[ID:%d]不存在,跳过回调", payload.OrderID)
return nil
}
return fmt.Errorf("查询订单失败: %w", err)
}
// 查询商户API Key信息
var apiKey model.MerchantAPIKey
if err := apiKey.GetByClientID(db.DB(ctx), payload.ClientID); err != nil {
logger.ErrorF(ctx, "查询商户[ClientID:%s]失败: %v", payload.ClientID, err)
return fmt.Errorf("查询商户信息失败: %w", err)
}
// 回调 URL
callbackURL := cmp.Or(order.NotifyURL, apiKey.NotifyURL)
// 判断是否需要回调
if config.Config.App.IsProduction() && util.IsLocalhost(callbackURL) {
return nil
}
// 构建回调参数
callbackParams := map[string]string{
"pid": payload.ClientID,
"trade_no": strconv.FormatUint(order.ID, 10),
"out_trade_no": util.DerefString(order.MerchantOrderNo),
"type": common.PayTypeEPay,
"name": order.OrderName,
"money": order.Amount.Truncate(2).StringFixed(2),
"trade_status": "TRADE_SUCCESS",
}
callbackParams["sign"] = GenerateSignature(callbackParams, apiKey.ClientSecret, true)
// 回调
if err := sendCallbackRequest(ctx, callbackURL, callbackParams); err != nil {
retried, _ := asynq.GetRetryCount(ctx)
logger.ErrorF(ctx, "商户回调失败: 订单[ID:%d] 重试次数[%d] 错误: %v",
payload.OrderID, retried+1, err)
return err
}
logger.InfoF(ctx, "商户回调成功: 订单[ID:%d] ClientID[%s]", payload.OrderID, payload.ClientID)
return nil
}
// sendCallbackRequest 发送HTTP回调请求
func sendCallbackRequest(ctx context.Context, callbackURL string, params map[string]string) error {
vals := url.Values{}
for k, v := range params {
vals.Add(k, v)
}
// 拼接URL
separator := "?"
if strings.Contains(callbackURL, "?") {
separator = "&"
}
targetURL := callbackURL + separator + vals.Encode()
headers := map[string]string{
"User-Agent": "LinuxDo-Credit/1.0",
}
resp, err := util.Request(ctx, http.MethodGet, targetURL, nil, headers, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("回调返回异常状态码: %d", resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取响应失败: %w", err)
}
responseText := strings.TrimSpace(strings.ToLower(string(respBody)))
if responseText != "success" {
return fmt.Errorf("回调返回非成功响应: %s", string(respBody))
}
logger.InfoF(ctx, "商户回调请求成功: URL[%s] 响应[%s]", callbackURL, string(respBody))
return nil
}
-278
View File
@@ -1,278 +0,0 @@
/*
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 payment
import (
"crypto/md5"
"crypto/subtle"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// HandleParseOrderNoError 处理 ParseOrderNo 返回的错误,返回对应的 HTTP 响应
func HandleParseOrderNoError(c *gin.Context, err error) bool {
if err == nil {
return false
}
errMsg := err.Error()
switch errMsg {
case OrderNotFound:
c.JSON(http.StatusNotFound, util.Err(errMsg))
case MerchantInfoNotFound, PayConfigNotFound:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
case common.CannotPaySelf, common.TestModeCannotProcessOrder, OrderNoFormatError:
c.JSON(http.StatusBadRequest, util.Err(errMsg))
case common.UnAuthorized:
c.JSON(http.StatusUnauthorized, util.Err(errMsg))
default:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return true
}
// OrderContext 订单上下文信息
type OrderContext struct {
OrderID uint64
MerchantUser *model.User
CurrentUser *model.User
PayerPayConfig *model.UserPayConfig
MerchantPayConfig *model.UserPayConfig
MerchantAPIKey *model.MerchantAPIKey
}
// ParseOrderNo 解析订单号,获取订单上下文信息
func ParseOrderNo(c *gin.Context, orderNo string) (*OrderContext, error) {
merchantIDStr, errGet := db.Redis.Get(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OrderMerchantIDCacheKeyFormat, orderNo))).Result()
if errGet != nil {
if errors.Is(errGet, redis.Nil) {
return nil, errors.New(OrderNotFound)
}
return nil, errGet
}
merchantID, errParse := strconv.ParseUint(merchantIDStr, 10, 64)
if errParse != nil {
return nil, errors.New(OrderNoFormatError)
}
// 获取商户用户信息
var merchantUser model.User
if err := db.DB(c.Request.Context()).Where("id = ? AND is_active = ?", merchantID, true).First(&merchantUser).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(MerchantInfoNotFound)
}
return nil, err
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
orderNoStr, errDecrypt := util.Decrypt(merchantUser.SignKey, orderNo)
if errDecrypt != nil {
return nil, errors.New(OrderNoFormatError)
}
orderID, errParse := strconv.ParseUint(orderNoStr, 10, 64)
if errParse != nil {
return nil, errors.New(OrderNoFormatError)
}
var apiKey model.MerchantAPIKey
if err := db.DB(c.Request.Context()).
Where("client_id = (SELECT client_id FROM orders WHERE id = ?)", orderID).
First(&apiKey).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(OrderNotFound)
}
return nil, err
}
// 验证测试模式下的支付权限
if err := service.ValidateTestModePayment(currentUser.ID, merchantUser.ID, apiKey.TestMode); err != nil {
return nil, err
}
ctx := &OrderContext{
OrderID: orderID,
MerchantUser: &merchantUser,
CurrentUser: currentUser,
MerchantAPIKey: &apiKey,
}
// 获取付款用户的支付配置(用于限额检查)
var payerPayConfig model.UserPayConfig
if err := payerPayConfig.GetByPayScore(db.DB(c.Request.Context()), currentUser.PayScore); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(PayConfigNotFound)
}
return nil, err
}
ctx.PayerPayConfig = &payerPayConfig
// 获取商家的支付配置(用于手续费倍率)
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(db.DB(c.Request.Context()), merchantUser.PayScore); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(PayConfigNotFound)
}
return nil, err
}
ctx.MerchantPayConfig = &merchantPayConfig
return ctx, nil
}
// GenerateSignature 生成签名
func GenerateSignature(params map[string]string, secret string, isMD5 bool) string {
// 按key排序
keys := make([]string, 0, len(params))
for k := range params {
if k == "sign" || k == "sign_type" {
continue
}
// 空值不参与签名
if params[k] == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
// 拼接签名字符串
var builder strings.Builder
builder.Grow(256)
for i, k := range keys {
if i > 0 {
builder.WriteByte('&')
}
builder.WriteString(k)
builder.WriteByte('=')
builder.WriteString(params[k])
}
builder.WriteString(secret)
if isMD5 {
// MD5加密
hash := md5.Sum([]byte(builder.String()))
return fmt.Sprintf("%x", hash)
}
return builder.String()
}
// VerifySignatureMD5 验证MD5签名
func VerifySignatureMD5(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrderRequest, error) {
var req EPayRequest
if err := c.ShouldBind(&req); err != nil {
return nil, err
}
// 验证金额
if err := util.ValidateAmount(req.Amount); err != nil {
return nil, err
}
if err := apiKey.GetByClientID(db.DB(c.Request.Context()), req.ClientID); err != nil {
return nil, err
}
// 构建签名参数
params := map[string]string{
"pid": req.ClientID,
"type": req.PayType,
"out_trade_no": util.DerefString(req.MerchantOrderNo),
"notify_url": req.NotifyURL,
"return_url": req.ReturnURL,
"name": req.OrderName,
"device": req.Device,
}
params["money"] = req.Amount.Truncate(2).StringFixed(2)
expectedSignFixed := GenerateSignature(params, apiKey.ClientSecret, true)
params["money"] = req.Amount.Truncate(2).String()
expectedSignTrimmed := GenerateSignature(params, apiKey.ClientSecret, true)
matchFixed := subtle.ConstantTimeCompare([]byte(strings.ToLower(expectedSignFixed)), []byte(strings.ToLower(req.Sign))) == 1
matchTrimmed := subtle.ConstantTimeCompare([]byte(strings.ToLower(expectedSignTrimmed)), []byte(strings.ToLower(req.Sign))) == 1
if !matchFixed && !matchTrimmed {
return nil, errors.New("签名验证失败")
}
return NewCreateOrderRequest(req.OrderName, req.MerchantOrderNo, req.Amount, req.PayType, req.NotifyURL, req.ReturnURL), nil
}
// VerifySignatureEd25519 验证 Ed25519 签名
func VerifySignatureEd25519(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrderRequest, error) {
var req LDCPayRequest
if err := c.ShouldBind(&req); err != nil {
return nil, err
}
// 验证金额
if err := util.ValidateAmount(req.Amount); err != nil {
return nil, err
}
if err := apiKey.GetByClientID(db.DB(c.Request.Context()), req.ClientID); err != nil {
return nil, err
}
if len(apiKey.PublicKey) == 0 {
return nil, errors.New("商户未配置公钥")
}
signatureBytes, err := util.Base64Decode(req.Sign)
if err != nil {
return nil, errors.New("签名格式错误")
}
// 构建签名参数
params := map[string]string{
"client_id": req.ClientID,
"type": req.PayType,
"out_trade_no": util.DerefString(req.MerchantOrderNo),
"order_name": req.OrderName,
"notify_url": req.NotifyURL,
"return_url": req.ReturnURL,
"money": req.Amount.Truncate(2).StringFixed(2),
}
signatureParam := GenerateSignature(params, apiKey.ClientSecret, false)
validTrimmed := util.Ed25519Verify(apiKey.PublicKey, []byte(signatureParam), signatureBytes)
if !validTrimmed {
return nil, errors.New("签名验证失败")
}
return NewCreateOrderRequest(req.OrderName, req.MerchantOrderNo, req.Amount, req.PayType, req.NotifyURL, req.ReturnURL), nil
}
-33
View File
@@ -1,33 +0,0 @@
/*
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 redenvelope
const (
RedEnvelopeNotFound = "红包不存在"
RedEnvelopeExpired = "红包已过期"
RedEnvelopeFinished = "红包已领完"
RedEnvelopeAlreadyClaimed = "您已领取过该红包"
CannotClaimOwnRedEnvelope = "不能领取自己的红包"
InvalidRedEnvelopeType = "无效的红包类型"
InvalidRedEnvelopeCount = "红包个数必须大于0"
InvalidRedEnvelopeAmount = "红包金额必须大于0"
AmountTooSmall = "每个红包金额不能小于0.01"
RedEnvelopeTooPopular = "太火爆啦,稍后再试试吧~"
InvalidRedEnvelopeID = "红包ID格式错误"
InvalidCoverImage = "无效的封面图片"
InvalidHeterotypicImage = "无效的装饰图片"
)
-42
View File
@@ -1,42 +0,0 @@
/*
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 redenvelope
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
// CheckRedEnvelopeEnabled 检查红包功能是否启用的中间件
func CheckRedEnvelopeEnabled() gin.HandlerFunc {
return func(c *gin.Context) {
enabled, err := model.GetBoolByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeEnabled)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if !enabled {
c.AbortWithStatusJSON(http.StatusForbidden, util.Err(common.RedEnvelopeDisabled))
return
}
c.Next()
}
}
-543
View File
@@ -1,543 +0,0 @@
/*
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 redenvelope
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// CreateRequest 创建红包请求
type CreateRequest struct {
Type model.RedEnvelopeType `json:"type" binding:"required,oneof=fixed random"`
TotalAmount decimal.Decimal `json:"total_amount" binding:"required"`
TotalCount int `json:"total_count" binding:"required,min=1"`
Greeting string `json:"greeting" binding:"max=100"`
PayKey string `json:"pay_key" binding:"required,max=10"`
CoverUploadID *uint64 `json:"cover_upload_id,string" binding:"omitempty"`
HeterotypicUploadID *uint64 `json:"heterotypic_upload_id,string" binding:"omitempty"`
}
// CreateResponse 创建红包响应
type CreateResponse struct {
ID uint64 `json:"id,string"`
}
// ClaimRequest 领取红包请求
type ClaimRequest struct {
ID uint64 `json:"id,string" binding:"required"`
}
// ClaimResponse 领取红包响应
type ClaimResponse struct {
Amount decimal.Decimal `json:"amount"`
RedEnvelope model.RedEnvelope `json:"red_envelope"`
}
// DetailResponse 红包详情响应
type DetailResponse struct {
RedEnvelope model.RedEnvelope `json:"red_envelope"`
Claims []model.RedEnvelopeClaim `json:"claims"`
UserClaimed *model.RedEnvelopeClaim `json:"user_claimed,omitempty"`
}
// ListRequest 红包列表请求
type ListRequest struct {
Page int `json:"page" binding:"required,min=1"`
PageSize int `json:"page_size" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"omitempty,oneof=sent received"`
}
// ListResponse 红包列表响应
type ListResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
RedEnvelopes []model.RedEnvelope `json:"red_envelopes"`
}
// Create 创建红包
// @Tags redenvelope
// @Accept json
// @Produce json
// @Param request body CreateRequest true "创建红包请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/create [post]
func Create(c *gin.Context) {
var req CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := util.ValidateAmount(req.TotalAmount); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 检查红包最低金额限制(1 LDC)
if req.TotalAmount.LessThan(decimal.NewFromInt(1)) {
c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeMinAmountRequired))
return
}
// 检查单个红包最大金额限制
maxAmount, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxAmount, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if req.TotalAmount.GreaterThan(maxAmount) {
c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeAmountExceeded))
return
}
// 检查红包最大领取人数限制
maxRecipients, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxRecipients)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if req.TotalCount > maxRecipients {
c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeRecipientsExceeded))
return
}
// 检查每个红包平均金额不能小于0.01(避免前面领取者获得0 LDC)
perAmount := req.TotalAmount.Div(decimal.NewFromInt(int64(req.TotalCount)))
if perAmount.LessThan(decimal.NewFromFloat(0.01)) {
c.JSON(http.StatusBadRequest, util.Err(AmountTooSmall))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
// 检查每日红包发送数量限制
dailyLimit, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeDailyLimit)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 查询今日已发送的红包数量
var todayCount int64
today := time.Now().Truncate(24 * time.Hour)
if err := db.DB(c.Request.Context()).Model(&model.RedEnvelope{}).
Where("creator_id = ? AND created_at >= ?", currentUser.ID, today).
Count(&todayCount).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if todayCount >= int64(dailyLimit) {
c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeDailyLimitExceeded))
return
}
if !currentUser.VerifyPayKey(req.PayKey) {
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
return
}
// 获取红包手续费率并计算手续费
feeRate, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeFeeRate, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 计算手续费(红包金额 * 费率)
feeAmount := req.TotalAmount.Mul(feeRate).Round(2)
// 总扣款金额 = 红包金额 + 手续费
totalDeduction := req.TotalAmount.Add(feeAmount)
// 提前检查余额,避免不必要的事务
if currentUser.AvailableBalance.LessThan(totalDeduction) {
c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance))
return
}
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var coverUploadID *uint64
var heterotypicUploadID *uint64
if req.CoverUploadID != nil {
var coverUpload model.Upload
if err := tx.Where("id = ? AND status IN (?, ?) AND user_id = ? AND type = ?", *req.CoverUploadID, model.UploadStatusPending, model.UploadStatusUsed, currentUser.ID, model.UploadTypeCover).
First(&coverUpload).Error; err != nil {
return errors.New(InvalidCoverImage)
}
if coverUpload.Status == model.UploadStatusPending {
if err := tx.Model(&model.Upload{}).
Where("id = ? AND status = ?", coverUpload.ID, model.UploadStatusPending).
Update("status", model.UploadStatusUsed).Error; err != nil {
return err
}
}
coverUploadID = &coverUpload.ID
}
if req.HeterotypicUploadID != nil {
var heterotypicUpload model.Upload
if err := tx.Where("id = ? AND status IN (?, ?) AND user_id = ? AND type = ?", *req.HeterotypicUploadID, model.UploadStatusPending, model.UploadStatusUsed, currentUser.ID, model.UploadTypeHeterotypic).
First(&heterotypicUpload).Error; err != nil {
return errors.New(InvalidHeterotypicImage)
}
if heterotypicUpload.Status == model.UploadStatusPending {
if err := tx.Model(&model.Upload{}).
Where("id = ? AND status = ?", heterotypicUpload.ID, model.UploadStatusPending).
Update("status", model.UploadStatusUsed).Error; err != nil {
return err
}
}
heterotypicUploadID = &heterotypicUpload.ID
}
// 扣减发送者余额并更新total_payment
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: currentUser.ID,
Amount: totalDeduction,
Operation: service.BalanceDeduct,
TotalField: "total_payment",
CheckBalance: true,
}); err != nil {
return err
}
// 创建红包
redEnvelope = model.RedEnvelope{
ID: idgen.NextUint64ID(),
CreatorID: currentUser.ID,
Type: req.Type,
TotalAmount: req.TotalAmount,
RemainingAmount: req.TotalAmount,
TotalCount: req.TotalCount,
RemainingCount: req.TotalCount,
Greeting: req.Greeting,
Status: model.RedEnvelopeStatusActive,
CoverUploadID: coverUploadID,
HeterotypicUploadID: heterotypicUploadID,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
if err := tx.Create(&redEnvelope).Error; err != nil {
return err
}
// 创建订单记录(红包支出)
remarkMsg := fmt.Sprintf("创建红包,共%d个", req.TotalCount)
if feeAmount.GreaterThan(decimal.Zero) {
remarkMsg = fmt.Sprintf("%s,手续费: %s", remarkMsg, feeAmount.String())
}
if req.Greeting != "" {
remarkMsg = fmt.Sprintf("%s,祝福语: %s", remarkMsg, req.Greeting)
}
order := model.Order{
OrderName: "红包支出",
PayerUserID: currentUser.ID,
PayeeUserID: 0,
Amount: totalDeduction,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeRedEnvelopeSend,
Remark: remarkMsg,
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
return tx.Create(&order).Error
}); err != nil {
if err.Error() == common.InsufficientBalance {
c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance))
} else if err.Error() == InvalidCoverImage || err.Error() == InvalidHeterotypicImage {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
c.JSON(http.StatusOK, util.OK(CreateResponse{
ID: redEnvelope.ID,
}))
}
// Claim 领取红包
// @Tags redenvelope
// @Accept json
// @Produce json
// @Param request body ClaimRequest true "领取红包请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/claim [post]
func Claim(c *gin.Context) {
var req ClaimRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
var claimedAmount decimal.Decimal
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
// 使用 FOR UPDATE 锁定红包记录,防止并发领取
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ?", req.ID).First(&redEnvelope).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(RedEnvelopeNotFound)
}
// 捕获锁等待超时错误,返回友好提示
return errors.New(RedEnvelopeTooPopular)
}
// 检查红包状态
if redEnvelope.Status == model.RedEnvelopeStatusExpired || redEnvelope.ExpiresAt.Before(time.Now()) {
return errors.New(RedEnvelopeExpired)
}
if redEnvelope.Status == model.RedEnvelopeStatusFinished || redEnvelope.RemainingCount <= 0 {
return errors.New(RedEnvelopeFinished)
}
// 检查是否已领取
var existingClaim model.RedEnvelopeClaim
if err := tx.Where("red_envelope_id = ? AND user_id = ?", redEnvelope.ID, currentUser.ID).
First(&existingClaim).Error; err == nil {
return errors.New(RedEnvelopeAlreadyClaimed)
}
// 计算领取金额
if redEnvelope.Type == model.RedEnvelopeTypeFixed {
// 固定金额:如果是最后一个,给全部剩余金额(避免舍入误差)
if redEnvelope.RemainingCount == 1 {
claimedAmount = redEnvelope.RemainingAmount
} else {
claimedAmount = redEnvelope.TotalAmount.Div(decimal.NewFromInt(int64(redEnvelope.TotalCount))).Round(2)
}
} else {
// 拼手气红包:使用二倍均值算法
claimedAmount = calculateRandomAmount(redEnvelope.RemainingAmount, redEnvelope.RemainingCount)
}
// 创建领取记录
claim := model.RedEnvelopeClaim{
ID: idgen.NextUint64ID(),
RedEnvelopeID: redEnvelope.ID,
UserID: currentUser.ID,
Amount: claimedAmount,
}
if err := tx.Create(&claim).Error; err != nil {
return err
}
// 更新红包状态
newRemainingCount := redEnvelope.RemainingCount - 1
newRemainingAmount := redEnvelope.RemainingAmount.Sub(claimedAmount)
newStatus := redEnvelope.Status
if newRemainingCount <= 0 {
newStatus = model.RedEnvelopeStatusFinished
}
if err := tx.Model(&model.RedEnvelope{}).Where("id = ?", redEnvelope.ID).
Updates(map[string]interface{}{
"remaining_count": newRemainingCount,
"remaining_amount": newRemainingAmount,
"status": newStatus,
}).Error; err != nil {
return err
}
// 更新红包对象用于返回
redEnvelope.RemainingCount = newRemainingCount
redEnvelope.RemainingAmount = newRemainingAmount
redEnvelope.Status = newStatus
// 增加领取者余额并更新total_receive
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: currentUser.ID,
Amount: claimedAmount,
Operation: service.BalanceAdd,
TotalField: "total_receive",
}); err != nil {
return err
}
// 创建订单记录(红包收入)
order := model.Order{
OrderName: "红包收入",
PayerUserID: redEnvelope.CreatorID,
PayeeUserID: currentUser.ID,
Amount: claimedAmount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeRedEnvelopeReceive,
Remark: fmt.Sprintf("祝福语: %s", redEnvelope.Greeting),
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
return tx.Create(&order).Error
}); err != nil {
errMsg := err.Error()
switch errMsg {
case RedEnvelopeNotFound:
c.JSON(http.StatusNotFound, util.Err(errMsg))
case RedEnvelopeExpired, RedEnvelopeFinished, RedEnvelopeAlreadyClaimed, CannotClaimOwnRedEnvelope:
c.JSON(http.StatusBadRequest, util.Err(errMsg))
default:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
var redEnvelopeView model.RedEnvelope
if err := db.DB(c.Request.Context()).
Model(&model.RedEnvelope{}).
Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url").
Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id").
Where("red_envelopes.id = ?", redEnvelope.ID).First(&redEnvelopeView).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(ClaimResponse{
Amount: claimedAmount,
RedEnvelope: redEnvelopeView,
}))
}
// GetDetail 获取红包详情
// @Tags redenvelope
// @Produce json
// @Param id path string true "红包ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/{id} [get]
func GetDetail(c *gin.Context) {
idStr := c.Param("id")
redEnvelopeID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(InvalidRedEnvelopeID))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).
Model(&model.RedEnvelope{}).
Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url").
Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id").
Where("red_envelopes.id = ?", redEnvelopeID).First(&redEnvelope).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(RedEnvelopeNotFound))
return
}
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
var claims []model.RedEnvelopeClaim
db.DB(c.Request.Context()).
Select("red_envelope_claims.*, users.username, users.avatar_url").
Joins("LEFT JOIN users ON red_envelope_claims.user_id = users.id").
Where("red_envelope_claims.red_envelope_id = ?", redEnvelope.ID).
Order("red_envelope_claims.claimed_at DESC").
Find(&claims)
var userClaimed *model.RedEnvelopeClaim
if currentUser != nil {
for i := range claims {
if claims[i].UserID == currentUser.ID {
userClaimed = &claims[i]
break
}
}
}
c.JSON(http.StatusOK, util.OK(DetailResponse{
RedEnvelope: redEnvelope,
Claims: claims,
UserClaimed: userClaimed,
}))
}
// List 获取红包列表
// @Tags redenvelope
// @Accept json
// @Produce json
// @Param request body ListRequest true "列表请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/list [post]
func List(c *gin.Context) {
var req ListRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
query := db.DB(c.Request.Context()).Model(&model.RedEnvelope{}).
Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url").
Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id")
switch req.Type {
case "sent":
query = query.Where("red_envelopes.creator_id = ?", currentUser.ID)
case "received":
query = query.Joins("INNER JOIN red_envelope_claims ON red_envelopes.id = red_envelope_claims.red_envelope_id").
Where("red_envelope_claims.user_id = ?", currentUser.ID)
default:
query = query.Where("red_envelopes.creator_id = ?", currentUser.ID)
}
var total int64
query.Count(&total)
var redEnvelopes []model.RedEnvelope
query.Order("red_envelopes.created_at DESC").
Offset((req.Page - 1) * req.PageSize).
Limit(req.PageSize).
Find(&redEnvelopes)
c.JSON(http.StatusOK, util.OK(ListResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
RedEnvelopes: redEnvelopes,
}))
}
-133
View File
@@ -1,133 +0,0 @@
/*
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 redenvelope
import (
"context"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"gorm.io/gorm"
)
// HandleRefundExpiredRedEnvelopes 处理过期红包退款的定时任务
func HandleRefundExpiredRedEnvelopes(ctx context.Context, t *asynq.Task) error {
logger.InfoF(ctx, "开始处理过期红包退款任务")
refundExpiredRedEnvelopes(ctx)
logger.InfoF(ctx, "过期红包退款任务完成")
return nil
}
// refundExpiredRedEnvelopes 退款过期红包
func refundExpiredRedEnvelopes(ctx context.Context) {
const batchSize = 100 // 每批处理100个红包
var lastID uint64 = 0
var totalProcessed int = 0
for {
// 使用游标分页查询过期红包
var expiredEnvelopes []model.RedEnvelope
if err := db.DB(ctx).
Where("id > ? AND status = ? AND expires_at < ? AND remaining_amount > 0", lastID, model.RedEnvelopeStatusActive, time.Now()).
Order("id ASC").
Limit(batchSize).
Find(&expiredEnvelopes).Error; err != nil {
logger.ErrorF(ctx, "查询过期红包失败: %v", err)
return
}
// 没有更多数据,退出循环
if len(expiredEnvelopes) == 0 {
break
}
logger.InfoF(ctx, "本批次找到 %d 个需要退款的过期红包", len(expiredEnvelopes))
// 处理每个过期红包
for _, envelope := range expiredEnvelopes {
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
// 更新红包状态为已过期
if err := tx.Model(&model.RedEnvelope{}).
Where("id = ? AND status = ?", envelope.ID, model.RedEnvelopeStatusActive).
Updates(map[string]interface{}{
"status": model.RedEnvelopeStatusExpired,
"remaining_amount": 0,
"remaining_count": 0,
}).Error; err != nil {
return err
}
// 退还剩余金额给创建者
if envelope.RemainingAmount.IsPositive() {
// 增加余额并减少total_payment
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: envelope.CreatorID,
Amount: envelope.RemainingAmount.Neg(),
Operation: service.BalanceDeduct,
TotalField: "total_payment",
}); err != nil {
return err
}
// 创建退款订单记录
remarkMsg := fmt.Sprintf("红包过期退款,红包ID:%d", envelope.ID)
if envelope.Greeting != "" {
remarkMsg = fmt.Sprintf("%s,祝福语: %s", remarkMsg, envelope.Greeting)
}
order := model.Order{
OrderName: "红包退款",
PayerUserID: 0,
PayeeUserID: envelope.CreatorID,
Amount: envelope.RemainingAmount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeRedEnvelopeRefund,
Remark: remarkMsg,
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
if err := tx.Create(&order).Error; err != nil {
return err
}
logger.InfoF(ctx, "红包ID:%d 退款成功,金额:%s", envelope.ID, envelope.RemainingAmount.String())
}
return nil
}); err != nil {
logger.ErrorF(ctx, "红包ID:%d 退款失败: %v", envelope.ID, err)
} else {
totalProcessed++
}
// 更新游标
lastID = envelope.ID
}
}
if totalProcessed > 0 {
logger.InfoF(ctx, "退款任务完成,共处理 %d 个过期红包", totalProcessed)
} else {
logger.InfoF(ctx, "没有需要退款的过期红包")
}
}
-73
View File
@@ -1,73 +0,0 @@
/*
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 redenvelope
import (
"math/rand"
"github.com/shopspring/decimal"
)
// calculateRandomAmount 二倍均值算法计算随机红包金额
func calculateRandomAmount(remaining decimal.Decimal, count int) decimal.Decimal {
// 如果是最后一个红包,返回所有剩余金额(避免舍入误差)
if count == 1 {
return remaining
}
minAmount := decimal.NewFromFloat(0.01)
// 确保剩余金额足够分配给所有人至少0.01
minRequired := minAmount.Mul(decimal.NewFromInt(int64(count)))
if remaining.LessThanOrEqual(minRequired) {
// 如果剩余金额刚好或不足,每人分配0.01(确保不会出现0 LDC的情况)
return minAmount
}
// 二倍均值算法:金额范围 [0.01, min(剩余金额/剩余人数*2, 剩余金额-其他人最小金额)]
avg := remaining.Div(decimal.NewFromInt(int64(count)))
maxAmount := avg.Mul(decimal.NewFromInt(2))
// 确保给其他人留下足够的金额(每人至少0.01)
maxPossible := remaining.Sub(minAmount.Mul(decimal.NewFromInt(int64(count - 1))))
if maxAmount.GreaterThan(maxPossible) {
maxAmount = maxPossible
}
// 确保maxAmount不小于minAmount
if maxAmount.LessThan(minAmount) {
maxAmount = minAmount
}
// 生成随机金额 [minAmount, maxAmount]
diff := maxAmount.Sub(minAmount)
if diff.LessThanOrEqual(decimal.Zero) {
return minAmount
}
// 生成随机数:转换为分(cents)来处理,避免精度问题
diffCents := diff.Mul(decimal.NewFromInt(100)).IntPart()
if diffCents <= 0 {
return minAmount
}
randCents := rand.Int63n(diffCents + 1) // [0, diffCents]
randAmount := decimal.NewFromInt(randCents).Div(decimal.NewFromInt(100))
amount := minAmount.Add(randAmount)
return amount.Round(2)
}
-28
View File
@@ -1,28 +0,0 @@
/*
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 upload
// 封面类型
const (
CoverTypeCover = "cover"
CoverTypeHeterotypic = "heterotypic"
)
// 最大文件大小
const (
MaxFileSize = 2 * 1024 * 1024 // 2MB
)
-233
View File
@@ -1,233 +0,0 @@
/*
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 upload
import (
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"strings"
"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/db/idgen"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/storage"
"github.com/linux-do/credit/internal/util"
_ "golang.org/x/image/webp"
"gorm.io/gorm"
)
// UploadResponse 上传响应
type UploadResponse struct {
ID uint64 `json:"id,string"`
}
// UploadRedEnvelopeCover 上传红包封面
// @Tags upload
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "图片文件"
// @Param type formData string true "封面类型 (cover/heterotypic)"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/upload/redenvelope/cover [post]
func UploadRedEnvelopeCover(c *gin.Context) {
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
// 获取上传的文件
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(ErrNoFileSelected))
return
}
// 获取封面类型
coverType := c.PostForm("type")
if coverType != CoverTypeCover && coverType != CoverTypeHeterotypic {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidCoverType))
return
}
// 验证文件大小
if file.Size > int64(MaxFileSize) {
c.JSON(http.StatusBadRequest, util.Err(ErrFileTooLarge))
return
}
// 打开上传的文件
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(ErrOpenFileFailed))
return
}
defer src.Close()
// 验证文件确实是图片并获取尺寸
_, format, err := image.DecodeConfig(src)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidImage))
return
}
// 验证图片类型
norm := strings.ToLower(format)
if norm == "jpeg" {
norm = "jpg"
}
var sc model.SystemConfig
if err := sc.GetByKey(c.Request.Context(), model.ConfigKeyUploadAllowedExtensions); err != nil || strings.TrimSpace(sc.Value) == "" {
c.JSON(http.StatusInternalServerError, util.Err(ErrUploadExtensionsNotConfigured))
return
}
v := strings.ToLower(strings.ReplaceAll(sc.Value, " ", ""))
v = strings.ReplaceAll(v, "jpeg", "jpg")
if !strings.Contains(","+v+",", ","+norm+",") {
c.JSON(http.StatusBadRequest, util.Err(ErrUnsupportedFormat))
return
}
// 重置文件指针
src.Close()
src, _ = file.Open()
defer src.Close()
// 计算文件 MD5 以避免重复上传
hash := md5.New()
if _, err := io.Copy(hash, src); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(ErrProcessFileFailed))
return
}
md5Sum := hex.EncodeToString(hash.Sum(nil))
// 重置文件指针
src.Close()
src, _ = file.Open()
defer src.Close()
// 生成安全的文件名: 用户ID_类型_MD5.扩展名
// 使用完整 MD5 实现去重,同一用户上传相同图片会命中已有文件
safeExt := "." + format
if format == "jpeg" {
safeExt = ".jpg"
}
filename := fmt.Sprintf("%s%s", md5Sum, safeExt)
// 构建 S3 object key: {prefix}{type}/{date}/{userID}/{filename}
now := time.Now()
objectPath := fmt.Sprintf("%s/%s/%d/%s", coverType, now.Format("2006/01/02"), currentUser.ID, filename)
s3Key := storage.BuildKey(objectPath)
// validate S3 key
if err := ValidateS3Key(s3Key); err != nil {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidFilePath))
return
}
// Content type for S3
contentType := "image/" + format
var recordID uint64
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var existing model.Upload
if err := tx.Where("file_path = ?", s3Key).First(&existing).Error; err == nil {
recordID = existing.ID
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// Upload to S3
if err := storage.PutObject(c.Request.Context(), s3Key, src, file.Size, contentType); err != nil {
return errors.New(ErrSaveFileFailed)
}
upload := model.Upload{
ID: idgen.NextUint64ID(),
UserID: currentUser.ID,
FilePath: s3Key,
FileSize: file.Size,
Type: coverType,
Status: model.UploadStatusPending,
}
if err := tx.Create(&upload).Error; err != nil {
// 如果数据库保存失败,尝试删除已上传的文件以避免垃圾数据
_ = storage.DeleteObject(c.Request.Context(), s3Key)
return errors.New(ErrSaveUploadRecordFailed)
}
recordID = upload.ID
return nil
}); err != nil {
if err.Error() == ErrSaveFileFailed {
c.JSON(http.StatusInternalServerError, util.Err(ErrSaveFileFailed))
return
}
c.JSON(http.StatusInternalServerError, util.Err(ErrSaveUploadRecordFailed))
return
}
c.JSON(http.StatusOK, util.OK(UploadResponse{
ID: recordID,
}))
}
// ListRedEnvelopeCovers 获取用户历史红包封面
// @Tags redenvelope
// @Produce json
// @Param type query string true "封面类型 (cover/heterotypic)"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/covers [get]
func ListRedEnvelopeCovers(c *gin.Context) {
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
coverType := c.Query("type")
if coverType != CoverTypeCover && coverType != CoverTypeHeterotypic {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidCoverType))
return
}
var uploads []model.Upload
if err := db.DB(c.Request.Context()).
Where("user_id = ? AND status = ? AND type = ?",
currentUser.ID, model.UploadStatusUsed, coverType).
Order("created_at DESC").
Limit(20).
Find(&uploads).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(ErrQueryHistoryCoverFailed))
return
}
var results []UploadResponse
for _, u := range uploads {
results = append(results, UploadResponse{
ID: u.ID,
})
}
c.JSON(http.StatusOK, util.OK(results))
}
-1
View File
@@ -17,5 +17,4 @@ limitations under the License.
package user
const (
EncryptPayKeyFailed = "加密支付密码失败"
)
-48
View File
@@ -15,51 +15,3 @@ limitations under the License.
*/
package user
import (
"net/http"
"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"
)
// UpdatePayKeyRequest 更新支付密钥请求
type UpdatePayKeyRequest struct {
PayKey string `json:"pay_key" binding:"required,max=6"`
}
// UpdatePayKey 更新用户支付密钥
// @Tags user
// @Accept json
// @Produce json
// @Param request body UpdatePayKeyRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/user/pay-key [put]
func UpdatePayKey(c *gin.Context) {
var req UpdatePayKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
encryptedPayKey, err := util.Encrypt(user.SignKey, req.PayKey)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(EncryptPayKeyFailed))
return
}
if err := db.DB(c.Request.Context()).
Model(&model.User{}).
Where("id = ?", user.ID).
Update("pay_key", encryptedPayKey).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
-279
View File
@@ -1,279 +0,0 @@
/*
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 user
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-redis/redis_rate/v10"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
var (
linuxDoRateLimiter *redis_rate.Limiter
rateLimitKey string
)
func init() {
linuxDoRateLimiter = redis_rate.NewLimiter(db.Redis)
rateLimitKey = db.PrefixedKey(linuxDoAPIRateLimitKey)
}
// waitForRateLimit 等待获取限流令牌(阻塞直到获取到令牌)
func waitForRateLimit(ctx context.Context, key string, limit redis_rate.Limit) error {
for {
res, err := linuxDoRateLimiter.Allow(ctx, key, limit)
if err != nil {
return fmt.Errorf("redis 限流器错误: %w", err)
}
if res.Allowed > 0 {
return nil // 获取到令牌
}
// 未获取到令牌,等待后重试
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(res.RetryAfter):
// 继续重试
}
}
}
// HandleUpdateUserGamificationScores 处理所有用户积分更新任务
func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) error {
rateLimit := config.Config.Worker.GamificationScoreRateLimit
limit := redis_rate.Limit{
Rate: rateLimit.Rate,
Burst: rateLimit.Rate,
Period: time.Duration(rateLimit.Period) * time.Second,
}
page := 0
totalProcessed := 0
for {
if err := waitForRateLimit(ctx, rateLimitKey, limit); err != nil {
logger.ErrorF(ctx, "速率限制等待失败: %v", err)
return err
}
leaderboard, err := model.GetLeaderboard(ctx, page)
if err != nil {
logger.ErrorF(ctx, "获取排行榜第 %d 页失败: %v", page, err)
return err
}
if len(leaderboard.Users) == 0 {
logger.InfoF(ctx, "[调度] 排行榜数据处理完成,共处理 %d 个用户", totalProcessed)
break
}
if err = enqueueBatchScoreTask(ctx, leaderboard.Users); err != nil {
logger.ErrorF(ctx, "下发第 %d 页批量任务失败: %v", page, err)
return err
}
totalProcessed += len(leaderboard.Users)
logger.InfoF(ctx, "[调度] 已处理排行榜第 %d 页,本页 %d 个用户,累计 %d 个用户",
page, len(leaderboard.Users), totalProcessed)
page++
}
return nil
}
// enqueueBatchScoreTask 下发批量积分更新任务
func enqueueBatchScoreTask(ctx context.Context, userScores []model.LeaderboardUser) error {
payload, _ := json.Marshal(map[string]interface{}{
"user_scores": userScores,
})
opts := []asynq.Option{
asynq.Queue(task.QueueWhitelistOnly),
asynq.MaxRetry(5),
}
if _, err := scheduler.AsynqClient.Enqueue(asynq.NewTask(task.UpdateSingleUserGamificationScoreTask, payload), opts...); err != nil {
logger.ErrorF(ctx, "下发批量积分任务失败: %v", err)
return err
}
return nil
}
// batchUpdateUserScores 批量更新用户积分
func batchUpdateUserScores(ctx context.Context, userScores []model.LeaderboardUser) error {
if len(userScores) == 0 {
return nil
}
userIDs := make([]uint64, len(userScores))
scoreMap := make(map[uint64]int64, len(userScores))
for i, u := range userScores {
userIDs[i] = u.ID
scoreMap[u.ID] = u.TotalScore
}
users, err := model.GetByIDs(db.DB(ctx), userIDs)
if err != nil {
return fmt.Errorf("批量查询用户失败: %w", err)
}
protectionDays, err := model.GetIntByKey(ctx, model.ConfigKeyNewUserProtectionDays)
if err != nil {
return fmt.Errorf("%s: %w", common.GetProtectionDaysFailed, err)
}
now := time.Now()
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
for _, user := range users {
newScore, exists := scoreMap[user.ID]
if !exists {
continue
}
newCommunityBalance := decimal.NewFromInt(newScore)
oldCommunityBalance := user.CommunityBalance
diff := newCommunityBalance.Sub(oldCommunityBalance)
createOrder := func(amount decimal.Decimal, remark string) error {
order := model.Order{
OrderName: "社区积分更新",
PayerUserID: 0,
PayeeUserID: user.ID,
Amount: amount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeCommunity,
Remark: remark,
TradeTime: now,
ExpiresAt: now,
}
if err = tx.Create(&order).Error; err != nil {
return fmt.Errorf("创建用户[%s]订单失败: %w", user.Username, err)
}
return nil
}
if user.CommunityBalance.IsZero() && user.TotalCommunity.IsZero() {
if err = tx.Model(&user).UpdateColumns(map[string]interface{}{
"community_balance": newCommunityBalance,
}).Error; err != nil {
return fmt.Errorf("初始化用户[%s]社区积分失败: %w", user.Username, err)
}
logger.InfoF(ctx, "用户[%s]首次同步社区积分: %s", user.Username, newCommunityBalance.String())
continue
}
// 积分未变化
if diff.IsZero() {
remark := fmt.Sprintf("社区积分从 %s 更新到 %s,变化 %s", oldCommunityBalance.String(), newCommunityBalance.String(), diff.String())
if err = createOrder(decimal.Zero, remark); err != nil {
return err
}
continue
}
// 新用户保护期检查
if diff.IsNegative() && protectionDays > 0 {
registeredDays := int(time.Since(user.CreatedAt).Hours() / 24)
if registeredDays < protectionDays {
if err = tx.Model(&user).UpdateColumns(map[string]interface{}{
"community_balance": newCommunityBalance,
}).Error; err != nil {
return fmt.Errorf("更新用户[%s]积分失败: %w", user.Username, err)
}
remark := fmt.Sprintf("社区积分从 %s 更新到 %s,变化 %s(保护期内,跳过扣分)",
oldCommunityBalance.String(), newCommunityBalance.String(), diff.String())
if err = createOrder(decimal.Zero, remark); err != nil {
return err
}
logger.InfoF(ctx, "用户[%s]在保护期内,积分下降%s,跳过扣分", user.Username, diff.Abs().String())
continue
}
}
// 更新用户积分
if err = tx.Model(&user).UpdateColumns(map[string]interface{}{
"community_balance": newCommunityBalance,
"total_community": gorm.Expr("total_community + ?", diff),
"total_receive": gorm.Expr("total_receive + ?", diff),
"available_balance": gorm.Expr("available_balance + ?", diff),
}).Error; err != nil {
return fmt.Errorf("更新用户[%s]积分失败: %w", user.Username, err)
}
remark := fmt.Sprintf("社区积分从 %s 更新到 %s,变化 %s",
oldCommunityBalance.String(), newCommunityBalance.String(), diff.String())
if err = createOrder(diff, remark); err != nil {
return err
}
}
return nil
})
}
// HandleUpdateSingleUserGamificationScore 处理用户积分更新任务
func HandleUpdateSingleUserGamificationScore(ctx context.Context, t *asynq.Task) error {
var payload struct {
UserID uint64 `json:"user_id"`
UserScores []model.LeaderboardUser `json:"user_scores"`
}
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("解析任务参数失败: %w", err)
}
if len(payload.UserScores) > 0 {
return batchUpdateUserScores(ctx, payload.UserScores)
}
if payload.UserID > 0 {
var user model.User
if err := user.GetByID(db.DB(ctx), payload.UserID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.InfoF(ctx, "用户[%d]不存在,跳过积分更新", payload.UserID)
return nil
}
return fmt.Errorf("查询用户[%d]失败: %w", payload.UserID, err)
}
response, errGet := user.GetUserGamificationScore(ctx)
if errGet != nil {
logger.ErrorF(ctx, "处理用户[%s]失败: %v", user.Username, errGet)
return errGet
}
return batchUpdateUserScores(ctx, []model.LeaderboardUser{
{ID: payload.UserID, TotalScore: response.User.GamificationScore},
})
}
return nil
}
+12 -17
View File
@@ -17,23 +17,18 @@ limitations under the License.
package common
const (
BannedAccount = "账号已被封禁"
AmountMustBeGreaterThanZero = "金额必须大于0"
AmountDecimalPlacesExceeded = "金额小数位数不能超过2位"
RateMustBeBetweenZeroAndOne = "比率必须在 0 到 1 之间"
RateDecimalPlacesExceeded = "比率小数位数不能超过2位"
InsufficientBalance = "余额不足"
DailyLimitExceeded = "已超过每日限额"
PayKeyIncorrect = "支付密钥错误"
CannotPaySelf = "不能给自己付款"
TestModeCannotProcessOrder = "测试模式下无法处理订单"
TestModeOrderRemark = "[测试模式] 此订单为测试订单,未实际扣款"
UnAuthorized = "未登录"
RedEnvelopeDisabled = "红包功能未启用"
RedEnvelopeAmountExceeded = "红包金额超过单个红包最大限额"
RedEnvelopeDailyLimitExceeded = "今日发红包数量已达上限"
RedEnvelopeRecipientsExceeded = "红包个数超过最大可领取人数上限"
RedEnvelopeMinAmountRequired = "红包总金额不能低于1LDC"
BannedAccount = "账号已被封禁"
AmountMustBeGreaterThanZero = "金额必须大于0"
AmountDecimalPlacesExceeded = "金额小数位数不能超过2位"
RateMustBeBetweenZeroAndOne = "比率必须在 0 到 1 之间"
RateDecimalPlacesExceeded = "比率小数位数不能超过2位"
InsufficientBalance = "余额不足"
DailyLimitExceeded = "已超过每日限额"
PayKeyIncorrect = "支付密钥错误"
CannotPaySelf = "不能给自己付款"
TestModeCannotProcessOrder = "测试模式下无法处理订单"
TestModeOrderRemark = "[测试模式] 此订单为测试订单,未实际扣款"
UnAuthorized = "未登录"
)
const (
+4 -17
View File
@@ -147,21 +147,14 @@ type logConfig struct {
// schedulerConfig 定时任务配置
type schedulerConfig struct {
UpdateUserGamificationScoresTaskCron string `mapstructure:"update_user_gamification_scores_task_cron"`
DisputeAutoRefundDispatchIntervalSeconds int `mapstructure:"dispute_auto_refund_dispatch_interval_seconds"`
AutoRefundExpiredDisputesTaskCron string `mapstructure:"auto_refund_expired_disputes_task_cron"`
SyncOrdersToClickHouseTaskCron string `mapstructure:"sync_orders_to_clickhouse_task_cron"`
RefundExpiredRedEnvelopesTaskCron string `mapstructure:"refund_expired_red_envelopes_task_cron"`
CleanupUnusedUploadsTaskCron string `mapstructure:"cleanup_unused_uploads_task_cron"`
SettlePendingPaymentsTaskCron string `mapstructure:"settle_pending_payments_task_cron"`
CleanupUnusedUploadsTaskCron string `mapstructure:"cleanup_unused_uploads_task_cron"`
}
// workerConfig 工作配置
type workerConfig struct {
Concurrency int `mapstructure:"concurrency"`
StrictPriority bool `mapstructure:"strict_priority"`
Queues []QueueConfig `mapstructure:"queues"`
GamificationScoreRateLimit RateLimitConfig `mapstructure:"gamification_score_rate_limit"`
Concurrency int `mapstructure:"concurrency"`
StrictPriority bool `mapstructure:"strict_priority"`
Queues []QueueConfig `mapstructure:"queues"`
}
// QueueConfig 队列配置
@@ -170,12 +163,6 @@ type QueueConfig struct {
Priority int `mapstructure:"priority"`
}
// RateLimitConfig 速率限制配置(N次/M秒)
type RateLimitConfig struct {
Rate int `mapstructure:"rate"` // 允许的请求次数
Period int `mapstructure:"period"` // 时间周期(秒)
}
// linuxDoConfig
type linuxDoConfig struct {
ApiKey string `mapstructure:"api_key"`
+15 -143
View File
@@ -24,7 +24,6 @@ import (
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/shopspring/decimal"
)
func Migrate() {
@@ -34,15 +33,7 @@ func Migrate() {
if err := db.DB(context.Background()).AutoMigrate(
&model.User{},
&model.UserPayConfig{},
&model.MerchantAPIKey{},
&model.MerchantPaymentLink{},
&model.Order{},
&model.OrderTransfer{},
&model.SystemConfig{},
&model.Dispute{},
&model.RedEnvelope{},
&model.RedEnvelopeClaim{},
&model.Upload{},
); err != nil {
log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err)
@@ -51,9 +42,6 @@ func Migrate() {
// 初始化系统配置数据
initSystemConfigs()
// 初始化用户支付配置数据
initUserPayConfigs()
}
// initSystemConfigs 初始化系统配置数据
@@ -71,80 +59,29 @@ func initSystemConfigs() {
}
defaultConfigs := []model.SystemConfig{
{
Key: model.ConfigKeyMerchantOrderExpireMinutes,
Value: "5",
Description: "商家订单过期时间(分钟)",
},
{
Key: model.ConfigKeyWebsiteOrderExpireMinutes,
Value: "10",
Description: "网站订单过期时间(分钟)",
},
{
Key: model.ConfigKeyDisputeTimeWindowHours,
Value: "168",
Description: "商家争议时间窗口(小时)",
},
{
Key: model.ConfigKeyNewUserInitialCredit,
Value: "0",
Description: "新用户注册初始积分",
},
{
Key: model.ConfigKeyNewUserProtectionDays,
Value: "30",
Description: "新用户保护期天数,期内积分下降不扣分",
},
{
Key: model.ConfigKeyLeaderboardCacheTTLSeconds,
Value: "600",
Description: "排行榜缓存过期时间(秒)",
},
{
Key: model.ConfigKeyRedEnvelopeEnabled,
Value: "0",
Description: "红包功能是否启用(1启用,0禁用)",
},
{
Key: model.ConfigKeyRedEnvelopeMaxAmount,
Value: "1000",
Description: "单个红包的最大积分上限",
},
{
Key: model.ConfigKeyRedEnvelopeDailyLimit,
Value: "10",
Description: "每日发红包的个数限制",
},
{
Key: model.ConfigKeyRedEnvelopeFeeRate,
Value: "0",
Description: "红包手续费率(0-1之间的小数,0表示不收费)",
},
{
Key: model.ConfigKeyRedEnvelopeMaxRecipients,
Value: "10000",
Description: "每个红包的最大可领取人数上限",
},
{
Key: model.ConfigKeyUserBalanceStatsCacheTTL,
Value: "600",
Description: "用户余额统计缓存过期时间(秒)",
},
{
Key: model.ConfigKeyUploadAllowedExtensions,
Value: "jpg,png,webp",
Type: "system",
Description: "允许上传的图片扩展名(逗号分隔)",
},
{
Key: model.ConfigKeySettlementDelayDaysMin,
Value: "7",
Description: "商户收款延迟到账最小天数(大于等于1)",
Key: model.ConfigKeySiteName,
Value: "Antigravity Project",
Type: "system",
Description: "系统平台的展示名称",
},
{
Key: model.ConfigKeySettlementDelayDaysMax,
Value: "14",
Description: "商户收款延迟到账最大天数(实际天数在min~max随机)",
Key: model.ConfigKeyRegistrationEnabled,
Value: "true",
Type: "system",
Description: "控制普通用户是否可以自主注册(true/false)",
},
{
Key: model.ConfigKeyMaxAPIKeysPerUser,
Value: "5",
Type: "business",
Description: "限制每个普通用户可以创建的 API Key 最大数量",
},
}
@@ -154,68 +91,3 @@ func initSystemConfigs() {
log.Printf("[PostgreSQL] initialized %d default system configs\n", len(defaultConfigs))
}
}
// int64Ptr 返回 int64 指针
func int64Ptr(v int64) *int64 {
return &v
}
// initUserPayConfigs 初始化用户支付配置数据
func initUserPayConfigs() {
tx := db.DB(context.Background())
var count int64
if err := tx.Model(&model.UserPayConfig{}).Count(&count).Error; err != nil {
log.Printf("[PostgreSQL] failed to check user_pay_configs table: %v\n", err)
return
}
if count > 0 {
return
}
defaultConfigs := []model.UserPayConfig{
{
Level: model.PayLevelFree,
MinScore: 0,
MaxScore: int64Ptr(2000),
DailyLimit: int64Ptr(1000),
FeeRate: decimal.Zero,
ScoreRate: decimal.Zero,
DistributeRate: decimal.Zero,
},
{
Level: model.PayLevelBasic,
MinScore: 2000,
MaxScore: int64Ptr(10000),
DailyLimit: int64Ptr(6000),
FeeRate: decimal.Zero,
ScoreRate: decimal.Zero,
DistributeRate: decimal.Zero,
},
{
Level: model.PayLevelStandard,
MinScore: 10000,
MaxScore: int64Ptr(50000),
DailyLimit: int64Ptr(25000),
FeeRate: decimal.Zero,
ScoreRate: decimal.Zero,
DistributeRate: decimal.Zero,
},
{
Level: model.PayLevelPremium,
MinScore: 50000,
MaxScore: nil,
DailyLimit: nil,
FeeRate: decimal.Zero,
ScoreRate: decimal.Zero,
DistributeRate: decimal.Zero,
},
}
if err := tx.Create(&defaultConfigs).Error; err != nil {
log.Printf("[PostgreSQL] failed to create default user pay configs: %v\n", err)
} else {
log.Printf("[PostgreSQL] initialized %d default user pay configs\n", len(defaultConfigs))
}
}
View File
-157
View File
@@ -1,157 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2025 linux.do
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package listener
import (
"context"
"errors"
"fmt"
"log"
"strconv"
"strings"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/redis/go-redis/v9"
)
// orderExpireKeyPrefix 订单过期 Key 前缀
const orderExpireKeyPrefix = "payment:order:expire:"
// StartExpireListener 启动过期监听器
func StartExpireListener(ctx context.Context) error {
if db.Redis == nil {
return fmt.Errorf("redis client is not initialized")
}
// 初始化时先处理已过期的订单
model.ExpirePendingOrders(ctx)
cfg := config.Config.Redis
// Cluster 模式:订阅所有分片节点
if cfg.ClusterMode {
clusterClient, ok := db.Redis.(*redis.ClusterClient)
if !ok {
return fmt.Errorf("redis client is not a ClusterClient")
}
return startClusterExpireListener(ctx, clusterClient)
}
// Standalone/Sentinel 模式
return startStandaloneExpireListener(ctx, cfg.DB)
}
// startStandaloneExpireListener Standalone/Sentinel 模式的过期监听
func startStandaloneExpireListener(ctx context.Context, dbIndex int) error {
// 确保 Redis 开启 keyspace notifications
configResult := db.Redis.ConfigSet(ctx, "notify-keyspace-events", "Ex")
if configResult.Err() != nil {
log.Printf("[Expire Listener] 警告: 设置 keyspace notifications 失败: %v", configResult.Err())
log.Printf("[Expire Listener] 请手动执行: CONFIG SET notify-keyspace-events Ex")
return configResult.Err()
}
// 订阅过期事件频道
expiredChannel := fmt.Sprintf("__keyevent@%d__:expired", dbIndex)
pubSub := db.Redis.Subscribe(ctx, expiredChannel)
go subscribeExpireEvents(ctx, pubSub, expiredChannel)
return nil
}
// startClusterExpireListener Cluster 模式的过期监听
// 需要订阅所有分片节点的过期事件
func startClusterExpireListener(ctx context.Context, clusterClient *redis.ClusterClient) error {
expiredChannel := "__keyevent@0__:expired"
err := clusterClient.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error {
// 为每个分片设置 keyspace notifications
if err := shard.ConfigSet(ctx, "notify-keyspace-events", "Ex").Err(); err != nil {
log.Printf("[Expire Listener] 警告: 分片 %s 设置 keyspace notifications 失败: %v",
shard.Options().Addr, err)
// 继续处理其他分片
}
// 订阅该分片的过期事件
pubSub := shard.Subscribe(ctx, expiredChannel)
go subscribeExpireEvents(ctx, pubSub, fmt.Sprintf("%s (shard: %s)", expiredChannel, shard.Options().Addr))
return nil
})
return err
}
// subscribeExpireEvents 订阅并处理过期事件
func subscribeExpireEvents(ctx context.Context, pubSub *redis.PubSub, channelDesc string) {
defer pubSub.Close()
log.Printf("[Expire Listener] 过期监听器已启动,监听频道: %s", channelDesc)
for {
msg, err := pubSub.ReceiveMessage(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
log.Printf("[Expire Listener] 监听器已停止: %s", channelDesc)
return
}
logger.ErrorF(ctx, "接收 Redis 过期事件失败: %v", err)
continue
}
// 处理过期事件
handleExpiredKey(ctx, msg.Payload)
}
}
// handleExpiredKey 处理过期的 Redis key
func handleExpiredKey(ctx context.Context, expiredKey string) {
fullPrefix := db.PrefixedKey(orderExpireKeyPrefix)
// 只处理订单过期相关的 key
if !strings.HasPrefix(expiredKey, fullPrefix) {
return
}
orderIDStr := strings.TrimPrefix(expiredKey, fullPrefix)
orderID, err := strconv.ParseUint(orderIDStr, 10, 64)
if err != nil {
logger.ErrorF(ctx, "解析订单ID失败: key=%s, error=%v", expiredKey, err)
return
}
// 更新订单状态为过期
result := db.DB(ctx).Model(&model.Order{}).
Where("id = ? AND status = ?", orderID, model.OrderStatusPending).
Update("status", model.OrderStatusExpired)
if result.Error != nil {
logger.ErrorF(ctx, "更新订单状态为过期失败: order_id=%d, error=%v", orderID, result.Error)
} else if result.RowsAffected > 0 {
logger.InfoF(ctx, "订单已过期: order_id=%d", orderID)
}
}
-52
View File
@@ -1,52 +0,0 @@
/*
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 model
import (
"time"
"github.com/linux-do/credit/internal/db/idgen"
"gorm.io/gorm"
)
type DisputeStatus string
const (
DisputeStatusDisputing DisputeStatus = "disputing"
DisputeStatusRefund DisputeStatus = "refund"
DisputeStatusClosed DisputeStatus = "closed"
)
type Dispute struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
OrderID uint64 `json:"order_id,string" gorm:"uniqueIndex:idx_dispute_order;index:idx_dispute_order_status,priority:1;not null"`
InitiatorUserID uint64 `json:"initiator_user_id" gorm:"not null;index:idx_initiator_status_created,priority:1"`
Reason string `json:"reason" gorm:"size:500;not null"`
Status DisputeStatus `json:"status" gorm:"type:varchar(20);index;index:idx_dispute_order_status,priority:2;index:idx_initiator_status_created,priority:2;not null;default:'disputing'"`
HandlerUserID *uint64 `json:"handler_user_id" gorm:"index"`
InitiatorUsername string `json:"initiator_username" gorm:"-:migration;->"`
HandlerUsername string `json:"handler_username" gorm:"-:migration;->"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_initiator_status_created,priority:3"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func (d *Dispute) BeforeCreate(*gorm.DB) error {
if d.ID == 0 {
d.ID = idgen.NextUint64ID()
}
return nil
}
-58
View File
@@ -1,58 +0,0 @@
/*
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 model
import (
"time"
"github.com/linux-do/credit/internal/db/idgen"
"gorm.io/gorm"
)
type MerchantAPIKey struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
UserID uint64 `json:"user_id" gorm:"not null;index:idx_merchant_api_keys_user_created,priority:1"`
ClientID string `json:"client_id" gorm:"size:64;uniqueIndex;index:idx_client_credentials,priority:2;not null"`
ClientSecret string `json:"client_secret" gorm:"size:64;index:idx_client_credentials,priority:1;not null"`
AppName string `json:"app_name" gorm:"size:20;not null"`
AppHomepageURL string `json:"app_homepage_url" gorm:"size:100;not null"`
AppDescription string `json:"app_description" gorm:"size:100"`
RedirectURI string `json:"redirect_uri" gorm:"size:100"`
NotifyURL string `json:"notify_url" gorm:"size:100;not null"`
PublicKey []byte `json:"public_key" gorm:"type:bytea"`
TestMode bool `json:"test_mode" gorm:"default:false"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_merchant_api_keys_user_created,priority:2"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
// GetByID 通过 ID 查询商户 API Key
func (m *MerchantAPIKey) GetByID(tx *gorm.DB, id uint64) error {
return tx.Where("id = ?", id).First(m).Error
}
// GetByClientID 通过 ClientID 查询商户 API Key
func (m *MerchantAPIKey) GetByClientID(tx *gorm.DB, clientID string) error {
return tx.Where("client_id = ?", clientID).First(m).Error
}
func (m *MerchantAPIKey) BeforeCreate(*gorm.DB) error {
if m.ID == 0 {
m.ID = idgen.NextUint64ID()
}
return nil
}
-51
View File
@@ -1,51 +0,0 @@
/*
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 model
import (
"time"
"github.com/linux-do/credit/internal/db/idgen"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type MerchantPaymentLink struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
MerchantAPIKeyID uint64 `json:"merchant_api_key_id,string" gorm:"not null;index"`
Token string `json:"token" gorm:"size:64;uniqueIndex;not null"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"`
ProductName string `json:"product_name" gorm:"size:30;not null"`
Remark string `json:"remark" gorm:"size:100"`
TotalLimit *uint `json:"total_limit" gorm:"default:null"`
UserLimit *uint `json:"user_limit" gorm:"default:null"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
// GetByToken 通过 Token 查询支付链接
func (m *MerchantPaymentLink) GetByToken(tx *gorm.DB, token string) error {
return tx.Where("token = ?", token).First(m).Error
}
func (m *MerchantPaymentLink) BeforeCreate(*gorm.DB) error {
if m.ID == 0 {
m.ID = idgen.NextUint64ID()
}
return nil
}
-55
View File
@@ -1,55 +0,0 @@
/*
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 model
import (
"context"
"time"
"github.com/linux-do/credit/internal/db"
"github.com/shopspring/decimal"
)
type OrderTransferStatus string
const (
OrderTransferStatusPending OrderTransferStatus = "pending"
OrderTransferStatusCompleted OrderTransferStatus = "completed"
)
type OrderTransfer struct {
ID uint64 `json:"id,string" gorm:"primaryKey;autoIncrement"`
OrderID uint64 `json:"order_id,string" gorm:"index;uniqueIndex:uk_order_id;index:idx_order_status,priority:1"`
PayeeUserID uint64 `json:"payee_user_id" gorm:"index"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"`
Status OrderTransferStatus `json:"status" gorm:"type:varchar(20);not null;index:idx_status_transfer_at,priority:1;index:idx_order_status,priority:2"`
TransferAt time.Time `json:"transfer_at" gorm:"not null;index:idx_status_transfer_at,priority:2"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
func GetDueTransferOrders(ctx context.Context, limit int) ([]OrderTransfer, error) {
var transfers []OrderTransfer
if err := db.DB(ctx).
Where("status = ? AND transfer_at <= ?", OrderTransferStatusPending, time.Now()).
Order("status, transfer_at ASC").
Limit(limit).
Find(&transfers).Error; err != nil {
return nil, err
}
return transfers, nil
}
-107
View File
@@ -1,107 +0,0 @@
/*
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 model
import (
"context"
"fmt"
"time"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
"github.com/linux-do/credit/internal/logger"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type OrderType string
const (
OrderTypeReceive OrderType = "receive"
OrderTypePayment OrderType = "payment"
OrderTypeTransfer OrderType = "transfer"
OrderTypeCommunity OrderType = "community"
OrderTypeOnline OrderType = "online"
OrderTypeTest OrderType = "test"
OrderTypeDistribute OrderType = "distribute"
OrderTypeRedEnvelopeSend OrderType = "red_envelope_send"
OrderTypeRedEnvelopeReceive OrderType = "red_envelope_receive"
OrderTypeRedEnvelopeRefund OrderType = "red_envelope_refund"
)
type OrderStatus string
const (
OrderStatusSuccess OrderStatus = "success"
OrderStatusFailed OrderStatus = "failed"
OrderStatusPending OrderStatus = "pending"
OrderStatusExpired OrderStatus = "expired"
OrderStatusDisputing OrderStatus = "disputing"
OrderStatusRefund OrderStatus = "refund"
OrderStatusRefused OrderStatus = "refused"
)
type Order struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
OrderNo string `json:"order_no" gorm:"-"`
OrderName string `json:"order_name" gorm:"size:64;not null;index"`
MerchantOrderNo *string `json:"merchant_order_no" gorm:"size:64;uniqueIndex:idx_orders_client_merchant_order,priority:2"`
ClientID string `json:"client_id" gorm:"size:64;index:idx_orders_client_status_created,priority:1;index:idx_orders_client_payee,priority:1;index:idx_orders_client_payer,priority:1;uniqueIndex:idx_orders_client_merchant_order,priority:1"`
PayerUserID uint64 `json:"payer_user_id" gorm:"index:idx_orders_payer_status_type_created,priority:1;index:idx_orders_payer_status_type_trade,priority:1;index:idx_orders_client_payer,priority:2"`
PayeeUserID uint64 `json:"payee_user_id" gorm:"index:idx_orders_payee_status_type_created,priority:1;index:idx_orders_client_payee,priority:2"`
PayerUsername string `json:"payer_username" gorm:"-:migration;->"`
PayeeUsername string `json:"payee_username" gorm:"-:migration;->"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null;index"`
Status OrderStatus `json:"status" gorm:"type:varchar(20);not null;index:idx_orders_payee_status_type_created,priority:2;index:idx_orders_payer_status_type_created,priority:2;index:idx_orders_client_status_created,priority:2;index:idx_orders_payer_status_type_trade,priority:2;index:idx_orders_payment_link_status,priority:2;index:idx_orders_status_expires,priority:1"`
Type OrderType `json:"type" gorm:"type:varchar(20);not null;index:idx_orders_payee_status_type_created,priority:3;index:idx_orders_payer_status_type_created,priority:3;index:idx_orders_payer_status_type_trade,priority:3"`
Remark string `json:"remark" gorm:"size:255"`
PaymentType string `json:"payment_type" gorm:"size:20"`
RedirectURI string `json:"redirect_uri" gorm:"size:100"`
NotifyURL string `json:"notify_url" gorm:"size:100"`
PaymentLinkID *uint64 `json:"payment_link_id,string" gorm:"index:idx_orders_payment_link_status,priority:1"`
TradeTime time.Time `json:"trade_time" gorm:"index:idx_orders_payer_status_type_trade,priority:4"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index:idx_orders_status_expires,priority:2"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_orders_payee_status_type_created,priority:4;index:idx_orders_payer_status_type_created,priority:4;index:idx_orders_client_status_created,priority:3"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
func (o *Order) BeforeCreate(*gorm.DB) error {
if o.ID == 0 {
o.ID = idgen.NextUint64ID()
}
return nil
}
// AfterFind 格式化 OrderNo
func (o *Order) AfterFind(*gorm.DB) error {
o.OrderNo = fmt.Sprintf("%018d", o.ID)
return nil
}
// ExpirePendingOrders 将已过期且 pending 状态的订单设置为 expired
func ExpirePendingOrders(ctx context.Context) {
result := db.DB(ctx).Model(&Order{}).
Where("status = ? AND expires_at <= ?", OrderStatusPending, time.Now()).
Update("status", OrderStatusExpired)
if result.Error != nil {
logger.ErrorF(ctx, "过期 pending 订单失败: %v", result.Error)
} else {
logger.InfoF(ctx, "已将 %d 个已过期的 pending 订单设置为 expired", result.RowsAffected)
}
}
-69
View File
@@ -1,69 +0,0 @@
/*
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 model
import (
"time"
"github.com/shopspring/decimal"
)
type RedEnvelopeType string
const (
RedEnvelopeTypeFixed RedEnvelopeType = "fixed"
RedEnvelopeTypeRandom RedEnvelopeType = "random"
)
type RedEnvelopeStatus string
const (
RedEnvelopeStatusActive RedEnvelopeStatus = "active"
RedEnvelopeStatusFinished RedEnvelopeStatus = "finished"
RedEnvelopeStatusExpired RedEnvelopeStatus = "expired"
)
// RedEnvelope 红包
type RedEnvelope struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
CreatorID uint64 `json:"creator_id,string" gorm:"index;not null"`
CreatorUsername string `json:"creator_username" gorm:"-:migration;->"`
CreatorAvatarURL string `json:"creator_avatar_url" gorm:"-:migration;->"`
Type RedEnvelopeType `json:"type" gorm:"type:varchar(20);not null"`
TotalAmount decimal.Decimal `json:"total_amount" gorm:"type:numeric(20,2);not null"`
RemainingAmount decimal.Decimal `json:"remaining_amount" gorm:"type:numeric(20,2);not null"`
TotalCount int `json:"total_count" gorm:"not null"`
RemainingCount int `json:"remaining_count" gorm:"not null"`
Greeting string `json:"greeting" gorm:"size:100"`
Status RedEnvelopeStatus `json:"status" gorm:"type:varchar(20);not null"`
CoverUploadID *uint64 `json:"cover_upload_id,string,omitempty" gorm:"index"`
HeterotypicUploadID *uint64 `json:"heterotypic_upload_id,string,omitempty" gorm:"index"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// RedEnvelopeClaim 红包领取记录
type RedEnvelopeClaim struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
RedEnvelopeID uint64 `json:"red_envelope_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:2;not null"`
UserID uint64 `json:"user_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:1;not null"`
Username string `json:"username" gorm:"-:migration;->"`
AvatarURL string `json:"avatar_url" gorm:"-:migration;->"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"`
ClaimedAt time.Time `json:"claimed_at" gorm:"autoCreateTime"`
}
+5 -43
View File
@@ -20,7 +20,6 @@ import (
"context"
"errors"
"fmt"
"math/rand"
"strconv"
"time"
@@ -32,21 +31,10 @@ import (
// 配置键常量 - 所有系统配置的 key 定义
const (
ConfigKeyMerchantOrderExpireMinutes = "merchant_order_expire_minutes" // 商家订单过期时间(分钟)
ConfigKeyWebsiteOrderExpireMinutes = "website_order_expire_minutes" // 网站订单过期时间(分钟)
ConfigKeyDisputeTimeWindowHours = "dispute_time_window_hours" // 商家争议时间窗口(小时)
ConfigKeyNewUserInitialCredit = "new_user_initial_credit" // 新用户注册初始积分
ConfigKeyNewUserProtectionDays = "new_user_protection_days" // 新用户保护期天数(期内不扣分)
ConfigKeyLeaderboardCacheTTLSeconds = "leaderboard_cache_ttl_seconds" // 排行榜缓存过期时间(秒)
ConfigKeyRedEnvelopeEnabled = "red_envelope_enabled" // 红包功能是否启用(1启用,0禁用)
ConfigKeyRedEnvelopeMaxAmount = "red_envelope_max_amount" // 单个红包的最大积分上限
ConfigKeyRedEnvelopeDailyLimit = "red_envelope_daily_limit" // 每日发红包的个数限制
ConfigKeyRedEnvelopeFeeRate = "red_envelope_fee_rate" // 红包手续费率(0-1之间的小数,0表示不收费)
ConfigKeyRedEnvelopeMaxRecipients = "red_envelope_max_recipients" // 每个红包的最大可领取人数上限
ConfigKeyUserBalanceStatsCacheTTL = "user_balance_stats_cache_ttl" // 用户余额统计缓存过期时间(秒)
ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔
ConfigKeySettlementDelayDaysMin = "settlement_delay_days_min" // 商户收款延迟到账最小天数(0表示即时到账)
ConfigKeySettlementDelayDaysMax = "settlement_delay_days_max" // 商户收款延迟到账最大天数(实际天数在min~max随机)
ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔
ConfigKeySiteName = "site_name" // 站点名称
ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册
ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" // 每个用户最大 API Key 数量
)
const (
@@ -57,6 +45,7 @@ const (
type SystemConfig struct {
Key string `json:"key" gorm:"primaryKey;size:64;not null"`
Value string `json:"value" gorm:"size:255;not null"`
Type string `json:"type" gorm:"size:32;not null;default:'system'"`
Description string `json:"description" gorm:"size:255"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
@@ -128,30 +117,3 @@ func GetBoolByKey(ctx context.Context, key string) (bool, error) {
return value, nil
}
const defaultHoldDays = 7
func GetRandomHoldDays(ctx context.Context) int {
// get config
holdDays := defaultHoldDays
holdDaysMin, errMin := GetIntByKey(ctx, ConfigKeySettlementDelayDaysMin)
if errMin != nil || holdDaysMin <= 0 {
holdDaysMin = defaultHoldDays
}
holdDaysMax, errMax := GetIntByKey(ctx, ConfigKeySettlementDelayDaysMax)
if errMax != nil || holdDaysMax <= 0 {
holdDaysMax = defaultHoldDays
}
// check config
if holdDaysMin == holdDaysMax {
return holdDaysMin
}
if holdDaysMin > holdDaysMax {
return holdDays
}
return holdDaysMin + rand.Intn(holdDaysMax-holdDaysMin+1)
}
func GetRandomSettleAt(ctx context.Context) time.Time {
return time.Now().AddDate(0, 0, GetRandomHoldDays(ctx))
}
-58
View File
@@ -1,58 +0,0 @@
/*
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 model
import (
"time"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type PayLevel uint8
const (
PayLevelFree PayLevel = iota
PayLevelBasic
PayLevelStandard
PayLevelPremium
)
type UserPayConfig struct {
ID uint64 `json:"id,string" gorm:"primaryKey;autoIncrement"`
Level PayLevel `json:"level" gorm:"uniqueIndex;not null"`
MinScore int64 `json:"min_score" gorm:"not null;index:idx_score_range,priority:1"`
MaxScore *int64 `json:"max_score" gorm:"index:idx_score_range,priority:2"`
DailyLimit *int64 `json:"daily_limit"`
FeeRate decimal.Decimal `json:"fee_rate" gorm:"type:numeric(3,2);default:0;check:fee_rate >= 0 AND fee_rate <= 1"`
ScoreRate decimal.Decimal `json:"score_rate" gorm:"type:numeric(3,2);default:0;check:score_rate >= 0 AND score_rate <= 1"`
DistributeRate decimal.Decimal `json:"distribute_rate" gorm:"type:numeric(3,2);default:0;check:distribute_rate >= 0 AND distribute_rate <= 1"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// GetByPayScore 通过 pay_score 查询对应的支付配置
func (upc *UserPayConfig) GetByPayScore(tx *gorm.DB, payScore int64) error {
return tx.Where("min_score <= ?", payScore).
Where("max_score IS NULL OR max_score > ?", payScore).
First(upc).Error
}
// GetByID 通过 ID 查询支付配置
func (upc *UserPayConfig) GetByID(tx *gorm.DB, id uint64) error {
return tx.Where("id = ?", id).First(upc).Error
}
+21 -76
View File
@@ -18,7 +18,6 @@ package model
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@@ -26,11 +25,7 @@ import (
"strconv"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
@@ -97,7 +92,6 @@ type User struct {
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
TrustLevel TrustLevel `json:"trust_level" gorm:"index"`
PayScore int64 `json:"pay_score" gorm:"default:0;index"`
PayKey string `json:"pay_key" gorm:"size:128"`
SignKey string `json:"sign_key" gorm:"size:64;uniqueIndex;not null"`
TotalReceive decimal.Decimal `json:"total_receive" gorm:"type:numeric(20,2);default:0"`
TotalPayment decimal.Decimal `json:"total_payment" gorm:"type:numeric(20,2);default:0"`
@@ -129,21 +123,14 @@ func GetByIDs(tx *gorm.DB, ids []uint64) ([]User, error) {
return users, nil
}
// VerifyPayKey 验证用户支付密码
// 使用用户的 SignKey 解密存储的加密密码,然后与输入的明文密码比较
func (u *User) VerifyPayKey(inputPayKey string) bool {
if u.PayKey == "" {
return false
}
decrypted, err := util.Decrypt(u.SignKey, u.PayKey)
if err != nil {
return false
}
return subtle.ConstantTimeCompare([]byte(decrypted), []byte(inputPayKey)) == 1
}
func (u *User) GetUserGamificationScore(ctx context.Context) (*UserGamificationScoreResponse, error) {
if u.Username == "dev_user" {
var response UserGamificationScoreResponse
response.User.GamificationScore = 12345
return &response, nil
}
url := fmt.Sprintf("https://linux.do/u/%s.json", u.Username)
resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil)
if err != nil {
@@ -190,6 +177,9 @@ func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) {
u.IsActive = oauthInfo.Active
u.TrustLevel = oauthInfo.TrustLevel
u.LastLoginAt = time.Now()
if oauthInfo.Username == "dev_user" {
u.IsAdmin = true
}
}
// CheckActive 检查用户账户是否激活,未激活则返回错误
@@ -200,69 +190,24 @@ func (u *User) CheckActive() error {
return nil
}
// EnqueueBadgeScoreTask 为用户下发积分计算任务
func (u *User) EnqueueBadgeScoreTask(ctx context.Context, delay time.Duration) error {
payload, _ := json.Marshal(map[string]interface{}{
"user_id": u.ID,
})
opts := []asynq.Option{
asynq.Queue(task.QueueWhitelistOnly),
asynq.MaxRetry(5),
asynq.TaskID(fmt.Sprintf("user_gamification_score_%d", u.ID)),
}
if delay > 0 {
opts = append(opts, asynq.ProcessIn(delay))
}
if _, err := scheduler.AsynqClient.Enqueue(asynq.NewTask(task.UpdateSingleUserGamificationScoreTask, payload), opts...); err != nil {
logger.ErrorF(ctx, "下发用户[%s]积分计算任务失败: %v", u.Username, err)
return err
}
return nil
}
// CreateWithInitialCredit 创建新用户并初始化积分、订单
func (u *User) CreateWithInitialCredit(tx *gorm.DB, oauthInfo *OAuthUserInfo) error {
ctx := tx.Statement.Context
newUserInitialCredit, err := GetDecimalByKey(ctx, ConfigKeyNewUserInitialCredit, 2)
if err != nil {
return err
}
// CreateUser 创建新用户
func (u *User) CreateUser(tx *gorm.DB, oauthInfo *OAuthUserInfo) error {
now := time.Now()
newUser := User{
ID: oauthInfo.GetID(),
Username: oauthInfo.Username,
Nickname: oauthInfo.Name,
AvatarUrl: oauthInfo.AvatarUrl,
IsActive: oauthInfo.Active,
TrustLevel: oauthInfo.TrustLevel,
SignKey: util.GenerateUniqueIDSimple(),
TotalReceive: newUserInitialCredit,
AvailableBalance: newUserInitialCredit,
LastLoginAt: now,
ID: oauthInfo.GetID(),
Username: oauthInfo.Username,
Nickname: oauthInfo.Name,
AvatarUrl: oauthInfo.AvatarUrl,
IsActive: oauthInfo.Active,
TrustLevel: oauthInfo.TrustLevel,
SignKey: util.GenerateUniqueIDSimple(),
LastLoginAt: now,
IsAdmin: oauthInfo.Username == "dev_user",
}
if err = tx.Create(&newUser).Error; err != nil {
return err
}
order := Order{
OrderName: "新用户注册奖励",
PayerUserID: 0,
PayeeUserID: newUser.ID,
Amount: newUserInitialCredit,
Status: OrderStatusSuccess,
Type: OrderTypeCommunity,
Remark: fmt.Sprintf("新用户 %s 注册赠送初始积分 %s", newUser.Username, newUserInitialCredit.String()),
TradeTime: now,
ExpiresAt: now,
}
if err = tx.Create(&order).Error; err != nil {
if err := tx.Create(&newUser).Error; err != nil {
return err
}
*u = newUser
return u.EnqueueBadgeScoreTask(ctx, 0)
return nil
}
+1 -124
View File
@@ -31,28 +31,16 @@ import (
admin_task "github.com/linux-do/credit/internal/apps/admin/task"
admin_user "github.com/linux-do/credit/internal/apps/admin/user"
publicconfig "github.com/linux-do/credit/internal/apps/config"
"github.com/linux-do/credit/internal/apps/dispute"
"github.com/linux-do/credit/internal/apps/health"
"github.com/linux-do/credit/internal/apps/merchant/api_key"
"github.com/linux-do/credit/internal/apps/merchant/link"
"github.com/linux-do/credit/internal/apps/redenvelope"
"github.com/linux-do/credit/internal/apps/upload"
"github.com/linux-do/credit/internal/listener"
"github.com/linux-do/credit/internal/util"
"github.com/linux-do/credit/internal/apps/payment"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin"
_ "github.com/linux-do/credit/docs"
"github.com/linux-do/credit/internal/apps/admin/system_config"
"github.com/linux-do/credit/internal/apps/admin/user_pay_config"
"github.com/linux-do/credit/internal/apps/dashboard"
"github.com/linux-do/credit/internal/apps/leaderboard"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/apps/order"
"github.com/linux-do/credit/internal/apps/user"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/otel_trace"
swaggerFiles "github.com/swaggo/files"
@@ -104,15 +92,6 @@ func Serve() {
// 补充中间件
r.Use(otelgin.Middleware(config.Config.App.AppName), loggerMiddleware())
// 支付接口
r.Match([]string{"GET", "POST"}, "/pay/submit.php", payment.RequireSignatureAuth(), payment.CreateMerchantOrder)
// 查询订单
r.GET("/api.php", payment.QueryMerchantOrder)
// 退款接口
r.POST("/api.php", payment.RefundMerchantOrder)
// 商户分发接口
r.POST("/pay/distribute", payment.RequireMerchantAuth(), payment.MerchantDistribute)
// Serve files by ID
r.GET("/f/:id", upload.ServeFileByID)
@@ -139,103 +118,19 @@ func Serve() {
userRouter := apiV1Router.Group("/user")
userRouter.Use(oauth.LoginRequired())
{
userRouter.PUT("/pay-key", user.UpdatePayKey)
}
// Dashboard
dashboardRouter := apiV1Router.Group("/dashboard")
dashboardRouter.Use(oauth.LoginRequired())
{
dashboardRouter.GET("/stats/daily", dashboard.GetDailyStats)
dashboardRouter.GET("/stats/top-customers", dashboard.GetTopCustomers)
}
apiV1Router.GET("/dashboard/stats/user-balance", dashboard.GetUserBalanceStats)
// Leaderboard
leaderboardRouter := apiV1Router.Group("/leaderboard")
leaderboardRouter.Use(oauth.LoginRequired())
{
leaderboardRouter.GET("", leaderboard.List)
leaderboardRouter.GET("/me", leaderboard.GetMyRank)
leaderboardRouter.GET("/users/:id", leaderboard.GetUserRankByID)
}
// Order
orderRouter := apiV1Router.Group("/order")
orderRouter.Use(oauth.LoginRequired())
{
orderRouter.POST("/transactions", order.ListTransactions)
orderRouter.POST("/dispute", dispute.CreateDispute)
orderRouter.POST("/disputes/merchant", dispute.ListMerchantDisputes)
orderRouter.POST("/disputes", dispute.ListDisputes)
orderRouter.POST("/refund-review", dispute.RefundReview)
orderRouter.POST("/dispute/close", dispute.CloseDispute)
}
// Payment
paymentRouter := apiV1Router.Group("/payment")
paymentRouter.Use(oauth.LoginRequired())
{
paymentRouter.POST("/transfer", payment.Transfer)
}
// Red Envelope
redEnvelopeRouter := apiV1Router.Group("/redenvelope")
{
redEnvelopeRouter.GET("/covers", oauth.LoginRequired(), upload.ListRedEnvelopeCovers)
redEnvelopeRouter.GET("/:id", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.GetDetail)
redEnvelopeRouter.POST("/create", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.Create)
redEnvelopeRouter.POST("/claim", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.Claim)
redEnvelopeRouter.POST("/list", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.List)
}
// Upload
uploadRouter := apiV1Router.Group("/upload")
uploadRouter.Use(oauth.LoginRequired())
{
uploadRouter.POST("/redenvelope/cover", upload.UploadRedEnvelopeCover)
// Keep generic uploads if needed
}
// Config (public)
configRouter := apiV1Router.Group("/config")
{
configRouter.GET("/public", publicconfig.GetPublicConfig)
configRouter.GET("/user-pay", user_pay_config.ListUserPayConfigs)
}
// MerchantAPIKey
merchantRouter := apiV1Router.Group("/merchant")
{
merchantRouter.POST("/api-keys", oauth.LoginRequired(), api_key.CreateAPIKey)
merchantRouter.GET("/api-keys", oauth.LoginRequired(), api_key.ListAPIKeys)
apiKeyRouter := merchantRouter.Group("/api-keys/:id")
apiKeyRouter.Use(oauth.LoginRequired(), api_key.RequireAPIKey())
{
apiKeyRouter.GET("", api_key.GetAPIKey)
apiKeyRouter.PUT("", api_key.UpdateAPIKey)
apiKeyRouter.DELETE("", api_key.DeleteAPIKey)
// Payment Links
linkRouter := apiKeyRouter.Group("/payment-links")
{
linkRouter.GET("", link.ListPaymentLinks)
linkRouter.POST("", link.CreatePaymentLink)
linkRouter.PUT("/:linkId", link.UpdatePaymentLink)
linkRouter.DELETE("/:linkId", link.DeletePaymentLink)
}
}
merchantRouter.GET("/payment-links/:token", oauth.LoginRequired(), link.GetPaymentLinkByToken)
merchantRouter.POST("/payment-links/pay", oauth.LoginRequired(), link.PayByLink)
// MerchantAPIKey Payment
MerchantPaymentRouter := merchantRouter.Group("/payment")
{
MerchantPaymentRouter.GET("/order", oauth.LoginRequired(), payment.GetPaymentPageDetails)
MerchantPaymentRouter.POST("", oauth.LoginRequired(), payment.PayMerchantOrder)
}
}
// Admin
@@ -260,27 +155,10 @@ func Serve() {
systemConfigRouter.PUT("", system_config.UpdateSystemConfig)
systemConfigRouter.DELETE("", system_config.DeleteSystemConfig)
}
// User Credit Config
adminRouter.POST("/user-pay-configs", user_pay_config.CreateUserPayConfig)
adminRouter.GET("/user-pay-configs", user_pay_config.ListUserPayConfigs)
userPayConfigRouter := adminRouter.Group("/user-pay-configs/:id")
{
userPayConfigRouter.GET("", user_pay_config.GetUserPayConfig)
userPayConfigRouter.PUT("", user_pay_config.UpdateUserPayConfig)
userPayConfigRouter.DELETE("", user_pay_config.DeleteUserPayConfig)
}
}
}
}
expireListenerCtx, expireListenerCancel := context.WithCancel(context.Background())
if err := listener.StartExpireListener(expireListenerCtx); err != nil {
log.Fatalf("[API] 警告: 启动过期监听器失败: %v\n", err)
}
srv := &http.Server{
Addr: config.Config.App.Addr,
Handler: r,
@@ -299,7 +177,6 @@ func Serve() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
defer cancel()
defer expireListenerCancel()
otel_trace.Shutdown(shutdownCtx)
View File
-192
View File
@@ -1,192 +0,0 @@
/*
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 service
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// BalanceOperation 余额操作类型
type BalanceOperation int
const (
BalanceAdd BalanceOperation = iota
BalanceDeduct
)
// BalanceUpdateOptions 余额更新选项
type BalanceUpdateOptions struct {
UserID uint64
Amount decimal.Decimal
Operation BalanceOperation
ScoreChange int64
TotalField string // 累计字段:total_payment / total_receive / total_transfer
CheckBalance bool
AsyncTransfer bool // 异步结算时使用 pending_balance 字段
}
// UpdateBalance 通用余额更新函数
func UpdateBalance(tx *gorm.DB, opts BalanceUpdateOptions) error {
updates := make(map[string]interface{})
balanceField := "available_balance"
if opts.AsyncTransfer {
balanceField = "pending_balance"
}
if opts.Operation == BalanceAdd {
updates[balanceField] = gorm.Expr(balanceField+" + ?", opts.Amount)
} else {
updates[balanceField] = gorm.Expr(balanceField+" - ?", opts.Amount)
}
if opts.TotalField != "" {
updates[opts.TotalField] = gorm.Expr(opts.TotalField+" + ?", opts.Amount)
}
if opts.ScoreChange != 0 {
updates["pay_score"] = gorm.Expr("pay_score + ?", opts.ScoreChange)
}
query := tx.Model(&model.User{}).Where("id = ?", opts.UserID)
if opts.CheckBalance {
query = query.Where(balanceField+" >= ?", opts.Amount)
}
result := query.UpdateColumns(updates)
if result.Error != nil {
return result.Error
}
if opts.CheckBalance && result.RowsAffected == 0 {
return errors.New(common.InsufficientBalance)
}
return nil
}
// SettlePendingToAvailable 将资金从 PendingBalance 转入 AvailableBalance
func SettlePendingToAvailable(tx *gorm.DB, userID uint64, amount decimal.Decimal) error {
result := tx.Model(&model.User{}).
Where("id = ? AND pending_balance >= ?", userID, amount).
UpdateColumns(map[string]interface{}{
"pending_balance": gorm.Expr("pending_balance - ?", amount),
"available_balance": gorm.Expr("available_balance + ?", amount),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New(common.InsufficientBalance)
}
return nil
}
// CheckDailyLimit 检查用户每日支付限额
// 返回 nil 表示未超限额,返回 error 表示超限或查询失败
func CheckDailyLimit(tx *gorm.DB, userID uint64, amount decimal.Decimal, dailyLimit *int64) error {
if dailyLimit == nil || *dailyLimit <= 0 {
return nil
}
now := time.Now()
datePart := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
lockID := int64(userID)*100000000 + datePart
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", lockID).Error; err != nil {
return err
}
todayUsed, err := GetTodayUsedAmount(tx, userID)
if err != nil {
return err
}
if todayUsed.Add(amount).GreaterThan(decimal.NewFromInt(*dailyLimit)) {
return errors.New(common.DailyLimitExceeded)
}
return nil
}
// GetTodayUsedAmount 获取用户当日已使用的支付额度
func GetTodayUsedAmount(db *gorm.DB, userID uint64) (decimal.Decimal, error) {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayEnd := todayStart.Add(24 * time.Hour)
var total decimal.Decimal
err := db.Model(&model.Order{}).
Where("payer_user_id = ? AND status = ? AND type IN ? AND trade_time >= ? AND trade_time < ?",
userID,
model.OrderStatusSuccess,
[]model.OrderType{model.OrderTypePayment, model.OrderTypeOnline, model.OrderTypeDistribute, model.OrderTypeTransfer},
todayStart,
todayEnd).
Select("COALESCE(SUM(amount), 0)").
Scan(&total).Error
return total, err
}
// CalculateFee 计算手续费和商户实收金额
// 返回:手续费、商户实收金额、手续费百分比
func CalculateFee(amount decimal.Decimal, feeRate decimal.Decimal) (fee decimal.Decimal, merchantAmount decimal.Decimal, feePercent int64) {
fee = amount.Mul(feeRate).Round(2)
merchantAmount = amount.Sub(fee)
feePercent = feeRate.Mul(decimal.NewFromInt(100)).IntPart()
return
}
// ValidateTestModePayment 验证测试模式下的支付权限
// 返回 error:nil 表示允许支付,非 nil 表示拒绝支付
func ValidateTestModePayment(currentUserID, merchantUserID uint64, isTestMode bool) error {
if currentUserID == merchantUserID {
if !isTestMode {
return errors.New(common.CannotPaySelf)
}
} else if isTestMode {
return errors.New(common.TestModeCannotProcessOrder)
}
return nil
}
// EnqueueMerchantNotify 下发商户回调任务
func EnqueueMerchantNotify(orderID uint64, clientID string) error {
notifyPayload, _ := json.Marshal(map[string]interface{}{
"order_id": orderID,
"client_id": clientID,
})
if _, err := scheduler.AsynqClient.Enqueue(
asynq.NewTask(task.MerchantPaymentNotifyTask, notifyPayload),
asynq.Queue(task.QueueWebhook),
asynq.MaxRetry(10),
asynq.Timeout(30*time.Second),
); err != nil {
return fmt.Errorf("下发商户回调任务失败: %w", err)
}
return nil
}
+3 -63
View File
@@ -17,31 +17,16 @@ limitations under the License.
package task
const (
UpdateUserGamificationScoresTask = "user:gamification:update_scores_task"
UpdateSingleUserGamificationScoreTask = "user:gamification:update_single_score_task"
AutoRefundExpiredDisputesTask = "dispute:auto_refund_expired"
AutoRefundSingleDisputeTask = "dispute:auto_refund_single"
MerchantPaymentNotifyTask = "payment:merchant_notify"
SyncOrdersToClickHouseTask = "order:sync_to_clickhouse"
RefundExpiredRedEnvelopesTask = "redenvelope:refund_expired"
CleanupUnusedUploadsTask = "upload:cleanup_unused"
SettlePendingPaymentsTask = "order:settle_pending_payments"
CleanupUnusedUploadsTask = "upload:cleanup_unused"
)
const (
QueueWhitelistOnly = "whitelist_only"
QueueWebhook = "webhook"
QueueDefault = "default"
QueueDefault = "default"
)
// 管理员可下发的任务类型标识
const (
TaskTypeOrderSync = "order_sync"
TaskTypeUserGamification = "user_gamification"
TaskTypeDisputeRefund = "dispute_auto_refund"
TaskTypeRedEnvelopeRefund = "redenvelope_auto_refund"
TaskTypeCleanupUploads = "cleanup_unused_uploads"
TaskTypeSettlePending = "settle_pending_payments"
TaskTypeCleanupUploads = "cleanup_unused_uploads"
)
// TaskMeta 任务元数据
@@ -57,42 +42,6 @@ type TaskMeta struct {
// DispatchableTasks 可下发的任务列表
var DispatchableTasks = []TaskMeta{
{
Type: TaskTypeOrderSync,
AsynqTask: SyncOrdersToClickHouseTask,
Name: "订单同步",
Description: "同步订单数据到 ClickHouse",
SupportsTime: true,
MaxRetry: 5,
Queue: QueueDefault,
},
{
Type: TaskTypeUserGamification,
AsynqTask: UpdateSingleUserGamificationScoreTask,
Name: "用户积分更新",
Description: "更新用户的点数积分",
SupportsTime: false,
MaxRetry: 5,
Queue: QueueWhitelistOnly,
},
{
Type: TaskTypeDisputeRefund,
AsynqTask: AutoRefundExpiredDisputesTask,
Name: "争议自动退款",
Description: "处理过期争议的自动退款",
SupportsTime: false,
MaxRetry: 5,
Queue: QueueDefault,
},
{
Type: TaskTypeRedEnvelopeRefund,
AsynqTask: RefundExpiredRedEnvelopesTask,
Name: "红包自动退款",
Description: "处理过期红包的自动退款",
SupportsTime: false,
MaxRetry: 5,
Queue: QueueDefault,
},
{
Type: TaskTypeCleanupUploads,
AsynqTask: CleanupUnusedUploadsTask,
@@ -102,15 +51,6 @@ var DispatchableTasks = []TaskMeta{
MaxRetry: 3,
Queue: QueueDefault,
},
{
Type: TaskTypeSettlePending,
AsynqTask: SettlePendingPaymentsTask,
Name: "延迟到账结算",
Description: "结算到期的延迟到账订单,将在途资金转入可用余额",
SupportsTime: false,
MaxRetry: 5,
Queue: QueueDefault,
},
}
// GetTaskMeta 根据任务类型获取元数据
-50
View File
@@ -53,46 +53,6 @@ func StartScheduler() error {
},
)
// 用户积分更新任务
if _, err = scheduler.Register(
config.Config.Scheduler.UpdateUserGamificationScoresTaskCron,
asynq.NewTask(task.UpdateUserGamificationScoresTask, nil),
asynq.Queue(task.QueueWhitelistOnly),
asynq.MaxRetry(5),
asynq.Unique(23*time.Hour),
); err != nil {
return
}
// 争议自动退款任务
if _, err = scheduler.Register(
config.Config.Scheduler.AutoRefundExpiredDisputesTaskCron,
asynq.NewTask(task.AutoRefundExpiredDisputesTask, nil),
asynq.MaxRetry(5),
asynq.Unique(23*time.Hour),
); err != nil {
return
}
// 订单同步任务
if _, err = scheduler.Register(
config.Config.Scheduler.SyncOrdersToClickHouseTaskCron,
asynq.NewTask(task.SyncOrdersToClickHouseTask, nil),
asynq.MaxRetry(10),
asynq.Unique(23*time.Hour),
); err != nil {
return
}
// 红包过期退款任务
if _, err = scheduler.Register(
config.Config.Scheduler.RefundExpiredRedEnvelopesTaskCron,
asynq.NewTask(task.RefundExpiredRedEnvelopesTask, nil),
asynq.Unique(23*time.Hour),
); err != nil {
return
}
// 清理未使用的上传文件任务
if _, err = scheduler.Register(
config.Config.Scheduler.CleanupUnusedUploadsTaskCron,
@@ -103,16 +63,6 @@ func StartScheduler() error {
return
}
// 延迟到账结算任务
if _, err = scheduler.Register(
config.Config.Scheduler.SettlePendingPaymentsTaskCron,
asynq.NewTask(task.SettlePendingPaymentsTask, nil),
asynq.Unique(55*time.Minute),
asynq.MaxRetry(3),
); err != nil {
return
}
// 启动调度器
err = scheduler.Run()
})
+1 -44
View File
@@ -17,18 +17,10 @@ limitations under the License.
package worker
import (
"log"
"math/rand"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/apps/dispute"
"github.com/linux-do/credit/internal/apps/order"
"github.com/linux-do/credit/internal/apps/payment"
"github.com/linux-do/credit/internal/apps/redenvelope"
"github.com/linux-do/credit/internal/apps/upload"
"github.com/linux-do/credit/internal/apps/user"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/task"
)
@@ -42,46 +34,13 @@ func StartWorker() error {
ShutdownTimeout: 3 * time.Minute,
Queues: buildQueuesFromConfig(),
StrictPriority: config.Config.Worker.StrictPriority,
RetryDelayFunc: func(n int, err error, t *asynq.Task) time.Duration {
log.Printf("[RetryDelayFunc] 任务类型: %s, 重试次数: %d, 错误: %v", t.Type(), n, err)
// 针对积分更新任务使用更长的重试间隔 + 随机抖动
if t.Type() == task.UpdateSingleUserGamificationScoreTask {
var baseDelay time.Duration
if err != nil && strings.Contains(err.Error(), "429") {
baseDelay = 120 * time.Second
} else {
baseDelay = 60 * time.Second
}
// 指数退避:n=0 时 delay=baseDelay,之后翻倍
delay := baseDelay * time.Duration(1<<uint(n))
if delay > 30*time.Minute {
delay = 30 * time.Minute
}
// delay ~ 2*delay
if delay > 0 {
jitter := time.Duration(rand.Int63n(int64(delay)))
delay += jitter
}
return delay
}
return asynq.DefaultRetryDelayFunc(n, err, t)
},
},
)
// 注册任务处理器
mux := asynq.NewServeMux()
mux.Use(taskLoggingMiddleware)
mux.HandleFunc(task.UpdateUserGamificationScoresTask, user.HandleUpdateUserGamificationScores)
mux.HandleFunc(task.UpdateSingleUserGamificationScoreTask, user.HandleUpdateSingleUserGamificationScore)
mux.HandleFunc(task.AutoRefundExpiredDisputesTask, dispute.HandleAutoRefundExpiredDisputes)
mux.HandleFunc(task.AutoRefundSingleDisputeTask, dispute.HandleAutoRefundSingleDispute)
mux.HandleFunc(task.MerchantPaymentNotifyTask, payment.HandleMerchantPaymentNotify)
mux.HandleFunc(task.SyncOrdersToClickHouseTask, order.HandleSyncOrdersToClickHouse)
mux.HandleFunc(task.RefundExpiredRedEnvelopesTask, redenvelope.HandleRefundExpiredRedEnvelopes)
mux.HandleFunc(task.CleanupUnusedUploadsTask, upload.HandleCleanupUnusedUploads)
mux.HandleFunc(task.SettlePendingPaymentsTask, order.HandleSettlePendingPayments)
// 启动服务器
return asynqServer.Run(mux)
@@ -103,9 +62,7 @@ func buildQueuesFromConfig() map[string]int {
// 如果配置为空,使用默认队列
if len(queues) == 0 {
queues = map[string]int{
task.QueueWebhook: 10,
task.QueueWhitelistOnly: 5,
task.QueueDefault: 1,
task.QueueDefault: 1,
}
}