391 lines
12 KiB
Go
Executable File
391 lines
12 KiB
Go
Executable File
package admin
|
|
|
|
import (
|
|
"bindbox-game/internal/code"
|
|
"bindbox-game/internal/pkg/core"
|
|
"bindbox-game/internal/pkg/validation"
|
|
"bindbox-game/internal/service/douyin"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ---------- 抖店配置 API ----------
|
|
|
|
type getDouyinConfigResponse struct {
|
|
Cookie string `json:"cookie"`
|
|
IntervalMinutes int `json:"interval_minutes"`
|
|
Proxy string `json:"proxy"`
|
|
}
|
|
|
|
func (h *handler) GetDouyinConfig() core.HandlerFunc {
|
|
return func(ctx core.Context) {
|
|
cfg, err := h.douyinSvc.GetConfig(ctx.RequestContext())
|
|
if err != nil {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
|
return
|
|
}
|
|
ctx.Payload(getDouyinConfigResponse{
|
|
Cookie: cfg.Cookie,
|
|
IntervalMinutes: cfg.IntervalMinutes,
|
|
Proxy: cfg.Proxy,
|
|
})
|
|
}
|
|
}
|
|
|
|
type saveDouyinConfigRequest struct {
|
|
Cookie string `json:"cookie"`
|
|
IntervalMinutes int `json:"interval_minutes"`
|
|
Proxy string `json:"proxy"`
|
|
}
|
|
|
|
func (h *handler) SaveDouyinConfig() core.HandlerFunc {
|
|
return func(ctx core.Context) {
|
|
req := new(saveDouyinConfigRequest)
|
|
if err := ctx.ShouldBindJSON(req); err != nil {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
|
return
|
|
}
|
|
|
|
if err := h.douyinSvc.SaveConfig(ctx.RequestContext(), req.Cookie, req.Proxy, req.IntervalMinutes); err != nil {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
|
return
|
|
}
|
|
|
|
ctx.Payload(pcSimpleMessage{Message: "保存成功"})
|
|
}
|
|
}
|
|
|
|
// ---------- 抖店订单列表 API ----------
|
|
|
|
type listDouyinOrdersRequest struct {
|
|
Page int `form:"page"`
|
|
PageSize int `form:"page_size"`
|
|
Status *int `form:"status"`
|
|
Match string `form:"match_status"`
|
|
ShopOrderID string `form:"shop_order_id"`
|
|
DouyinUserID string `form:"douyin_user_id"`
|
|
}
|
|
|
|
type douyinOrderItem struct {
|
|
ID int64 `json:"id"`
|
|
ShopOrderID string `json:"shop_order_id"`
|
|
OrderStatus int32 `json:"order_status"`
|
|
OrderStatusText string `json:"order_status_text"`
|
|
DouyinUserID string `json:"douyin_user_id"`
|
|
LocalUserID int64 `json:"local_user_id"`
|
|
LocalUserNickname string `json:"local_user_nickname"`
|
|
ActualReceiveAmount string `json:"actual_receive_amount"`
|
|
ActualPayAmount string `json:"actual_pay_amount"`
|
|
PayTypeDesc string `json:"pay_type_desc"`
|
|
Remark string `json:"remark"`
|
|
UserNickname string `json:"user_nickname"`
|
|
ProductCount int64 `json:"product_count"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type listDouyinOrdersResponse struct {
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
Total int64 `json:"total"`
|
|
List []douyinOrderItem `json:"list"`
|
|
}
|
|
|
|
func (h *handler) ListDouyinOrders() core.HandlerFunc {
|
|
return func(ctx core.Context) {
|
|
req := new(listDouyinOrdersRequest)
|
|
if err := ctx.ShouldBindForm(req); err != nil {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
|
return
|
|
}
|
|
|
|
filter := &douyin.ListOrdersFilter{
|
|
Status: req.Status,
|
|
}
|
|
if req.Match != "" {
|
|
filter.MatchStatus = &req.Match
|
|
}
|
|
filter.ShopOrderID = strings.TrimSpace(req.ShopOrderID)
|
|
filter.DouyinUserID = strings.TrimSpace(req.DouyinUserID)
|
|
|
|
orders, total, err := h.douyinSvc.ListOrders(ctx.RequestContext(), req.Page, req.PageSize, filter)
|
|
if err != nil {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
|
return
|
|
}
|
|
|
|
// 获取匹配用户的昵称
|
|
userNicknameMap := make(map[int64]string)
|
|
var userIDs []int64
|
|
for _, o := range orders {
|
|
if uid, err := strconv.ParseInt(o.LocalUserID, 10, 64); err == nil && uid > 0 {
|
|
userIDs = append(userIDs, uid)
|
|
}
|
|
}
|
|
if len(userIDs) > 0 {
|
|
var users []struct {
|
|
ID int64
|
|
Nickname string
|
|
}
|
|
h.repo.GetDbR().Table("users").Where("id IN ?", userIDs).Select("id, nickname").Find(&users)
|
|
for _, u := range users {
|
|
userNicknameMap[u.ID] = u.Nickname
|
|
}
|
|
}
|
|
|
|
// 构建响应
|
|
list := make([]douyinOrderItem, len(orders))
|
|
for i, o := range orders {
|
|
uid, _ := strconv.ParseInt(o.LocalUserID, 10, 64)
|
|
list[i] = douyinOrderItem{
|
|
ID: o.ID,
|
|
ShopOrderID: o.ShopOrderID,
|
|
OrderStatus: o.OrderStatus,
|
|
OrderStatusText: getOrderStatusText(o.OrderStatus),
|
|
DouyinUserID: o.DouyinUserID,
|
|
LocalUserID: uid,
|
|
LocalUserNickname: userNicknameMap[uid],
|
|
ActualReceiveAmount: formatAmount(o.ActualReceiveAmount),
|
|
ActualPayAmount: formatAmount(o.ActualPayAmount),
|
|
PayTypeDesc: o.PayTypeDesc,
|
|
Remark: o.Remark,
|
|
UserNickname: o.UserNickname,
|
|
ProductCount: int64(o.ProductCount),
|
|
CreatedAt: o.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
}
|
|
}
|
|
|
|
ctx.Payload(listDouyinOrdersResponse{
|
|
Page: req.Page,
|
|
PageSize: req.PageSize,
|
|
Total: total,
|
|
List: list,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------- 手动同步 API ----------
|
|
|
|
type syncDouyinOrdersResponse struct {
|
|
Message string `json:"message"`
|
|
TotalFetched int `json:"total_fetched"`
|
|
NewOrders int `json:"new_orders"`
|
|
MatchedUsers int `json:"matched_users"`
|
|
TotalUsers int `json:"total_users"`
|
|
ProcessedUsers int `json:"processed_users"`
|
|
SkippedUsers int `json:"skipped_users"`
|
|
ElapsedMS int64 `json:"elapsed_ms"`
|
|
}
|
|
|
|
type syncDouyinOrdersRequest struct {
|
|
OnlyUnmatched *bool `json:"only_unmatched"`
|
|
MaxUsers int `json:"max_users"`
|
|
BatchSize int `json:"batch_size"`
|
|
Concurrency int `json:"concurrency"`
|
|
InterBatchDelayMS *int `json:"inter_batch_delay_ms"`
|
|
}
|
|
|
|
func (h *handler) SyncDouyinOrders() core.HandlerFunc {
|
|
return func(ctx core.Context) {
|
|
req := new(syncDouyinOrdersRequest)
|
|
if err := ctx.ShouldBindJSON(req); err != nil && !errors.Is(err, io.EOF) {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
|
return
|
|
}
|
|
|
|
fetchOpts := &douyin.FetchOptions{
|
|
OnlyUnmatched: true,
|
|
MaxUsers: req.MaxUsers,
|
|
BatchSize: req.BatchSize,
|
|
Concurrency: req.Concurrency,
|
|
}
|
|
if req.OnlyUnmatched != nil {
|
|
fetchOpts.OnlyUnmatched = *req.OnlyUnmatched
|
|
}
|
|
if req.InterBatchDelayMS != nil {
|
|
delay := time.Duration(*req.InterBatchDelayMS) * time.Millisecond
|
|
fetchOpts.InterBatchDelay = delay
|
|
}
|
|
|
|
// 使用独立 Context 防止 HTTP 请求断开导致同步中断
|
|
// 设置 5 分钟超时,确保有足够时间完成全量同步
|
|
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
result, err := h.douyinSvc.FetchAndSyncOrders(bgCtx, fetchOpts)
|
|
if err != nil {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
|
return
|
|
}
|
|
|
|
ctx.Payload(syncDouyinOrdersResponse{
|
|
Message: fmt.Sprintf("同步成功,处理 %d/%d 个用户,用时 %.2f 秒", result.ProcessedUsers, result.TotalUsers, float64(result.ElapsedMS)/1000.0),
|
|
TotalFetched: result.TotalFetched,
|
|
NewOrders: result.NewOrders,
|
|
MatchedUsers: result.MatchedUsers,
|
|
TotalUsers: result.TotalUsers,
|
|
ProcessedUsers: result.ProcessedUsers,
|
|
SkippedUsers: result.SkippedUsers,
|
|
ElapsedMS: result.ElapsedMS,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------- 新增: 手动同步接口 (优化版) ----------
|
|
|
|
type manualSyncAllRequest struct {
|
|
DurationHours int `json:"duration_hours"` // 可选,默认1小时
|
|
}
|
|
|
|
type manualSyncAllResponse struct {
|
|
Message string `json:"message"`
|
|
DebugInfo string `json:"debug_info"`
|
|
}
|
|
|
|
// ManualSyncAll 手动全量订单同步
|
|
func (h *handler) ManualSyncAll() core.HandlerFunc {
|
|
return func(ctx core.Context) {
|
|
req := new(manualSyncAllRequest)
|
|
if err := ctx.ShouldBindJSON(req); err != nil {
|
|
// 如果没有传参数,使用默认值
|
|
req.DurationHours = 1
|
|
}
|
|
|
|
if req.DurationHours <= 0 {
|
|
req.DurationHours = 1
|
|
}
|
|
|
|
// 使用独立 Context,设置 5 分钟超时
|
|
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
duration := time.Duration(req.DurationHours) * time.Hour
|
|
result, err := h.douyinSvc.SyncAllOrders(bgCtx, duration, true) // 管理后台手动同步使用代理
|
|
if err != nil {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
|
return
|
|
}
|
|
|
|
ctx.Payload(manualSyncAllResponse{
|
|
Message: fmt.Sprintf("全量同步成功 (同步范围: %d小时)", req.DurationHours),
|
|
DebugInfo: result.DebugInfo,
|
|
})
|
|
}
|
|
}
|
|
|
|
type manualSyncRefundResponse struct {
|
|
Message string `json:"message"`
|
|
RefundedCount int `json:"refunded_count"`
|
|
}
|
|
|
|
// ManualSyncRefund 手动退款状态同步
|
|
func (h *handler) ManualSyncRefund() core.HandlerFunc {
|
|
return func(ctx core.Context) {
|
|
// 使用独立 Context,设置 3 分钟超时
|
|
bgCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
err := h.douyinSvc.SyncRefundStatus(bgCtx)
|
|
if err != nil {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
|
return
|
|
}
|
|
|
|
ctx.Payload(manualSyncRefundResponse{
|
|
Message: "退款状态同步成功",
|
|
RefundedCount: 0, // TODO: 可以从 SyncRefundStatus 返回实际退款数量
|
|
})
|
|
}
|
|
}
|
|
|
|
type manualGrantPrizesResponse struct {
|
|
Message string `json:"message"`
|
|
GrantedCount int `json:"granted_count"`
|
|
}
|
|
|
|
type grantOrderRewardResponse struct {
|
|
ShopOrderID string `json:"shop_order_id"`
|
|
Message string `json:"message"`
|
|
Granted bool `json:"granted"`
|
|
RewardGranted int32 `json:"reward_granted"`
|
|
ProductCount int32 `json:"product_count"`
|
|
OrderStatus int32 `json:"order_status"`
|
|
LocalUserID string `json:"local_user_id"`
|
|
}
|
|
|
|
// ManualGrantPrizes 手动发放直播间奖品
|
|
func (h *handler) ManualGrantPrizes() core.HandlerFunc {
|
|
return func(ctx core.Context) {
|
|
// 使用独立 Context,设置 3 分钟超时
|
|
bgCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
err := h.douyinSvc.GrantLivestreamPrizes(bgCtx)
|
|
if err != nil {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
|
return
|
|
}
|
|
|
|
ctx.Payload(manualGrantPrizesResponse{
|
|
Message: "直播奖品发放成功",
|
|
GrantedCount: 0, // TODO: 可以从 GrantLivestreamPrizes 返回实际发放数量
|
|
})
|
|
}
|
|
}
|
|
|
|
// GrantOrderReward 手动触发单个订单的发奖
|
|
func (h *handler) GrantOrderReward() core.HandlerFunc {
|
|
return func(ctx core.Context) {
|
|
shopOrderID := ctx.Param("shop_order_id")
|
|
if shopOrderID == "" {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "shop_order_id 不能为空"))
|
|
return
|
|
}
|
|
|
|
bgCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
res, err := h.douyinSvc.GrantOrderReward(bgCtx, shopOrderID)
|
|
if err != nil {
|
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
|
return
|
|
}
|
|
|
|
ctx.Payload(grantOrderRewardResponse(*res))
|
|
}
|
|
}
|
|
|
|
// ---------- 辅助函数 ----------
|
|
|
|
func getOrderStatusText(status int32) string {
|
|
switch status {
|
|
case 1:
|
|
return "待付款"
|
|
case 2:
|
|
return "待发货"
|
|
case 3:
|
|
return "已发货"
|
|
case 4:
|
|
return "已退款/已取消"
|
|
case 5:
|
|
return "已完成"
|
|
default:
|
|
return "未知"
|
|
}
|
|
}
|
|
|
|
func formatAmount(amountCents int64) string {
|
|
yuan := float64(amountCents) / 100.0
|
|
return fmt.Sprintf("%.2f", yuan)
|
|
}
|
|
|
|
// SetDouyinService 设置抖店服务 (用于 handler 初始化)
|
|
func (h *handler) SetDouyinService(svc douyin.Service) {
|
|
h.douyinSvc = svc
|
|
}
|