mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 23:26:38 +08:00
裁剪
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:
@@ -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 "配置键"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 团队"
|
||||
)
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
)
|
||||
|
||||
// Health godoc
|
||||
// @Summary 健康检查
|
||||
// @Description 检查服务是否正常运行
|
||||
// @Tags health
|
||||
// @Produce json
|
||||
// @Success 200 {object} util.ResponseAny
|
||||
|
||||
@@ -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:"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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字节"
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = "无效的装饰图片"
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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, "没有需要退款的过期红包")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
@@ -17,5 +17,4 @@ limitations under the License.
|
||||
package user
|
||||
|
||||
const (
|
||||
EncryptPayKeyFailed = "加密支付密码失败"
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 根据任务类型获取元数据
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user