2026-05-09 00:16:01 +08:00

331 lines
9.9 KiB
Go

package taskcenter
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
// ListTasks 返回 App 任务列表,字段兼容旧 Flutter 模型。
func (s *Service) ListTasks(ctx context.Context, user AuthUser) ([]AppTaskItem, error) {
sysOrigin := s.normalizeSysOrigin(user.SysOrigin)
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)
if err != nil {
return nil, err
}
if bundle == nil {
return []AppTaskItem{}, nil
}
result := make([]AppTaskItem, 0, len(bundle.Tasks))
for _, task := range bundle.Tasks {
cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, time.Now())
progress, exists, err := s.loadProgress(ctx, sysOrigin, user.UserID, task.ID, cycleKey)
if err != nil {
return nil, err
}
if task.TaskCategory == TaskCategoryNewbie && exists && progress.RewardCollected {
continue
}
completedValue := int64(0)
rewardCollected := 0
if exists {
completedValue = progress.CompletedValue
if progress.RewardCollected {
rewardCollected = 1
}
}
taskStatus := 0
if completedValue >= task.TargetValue {
taskStatus = 1
}
result = append(result, AppTaskItem{
TaskType: appTaskType(task.TaskCategory),
TaskName: task.TaskName,
TaskDesc: task.TaskDesc,
JumpPage: resolveJumpPage(task),
ConditionType: task.ConditionType,
SortOrder: task.SortOrder,
TaskID: task.ID,
TaskStatus: taskStatus,
Cover: task.Cover,
TaskIcon: task.TaskIcon,
ConditionValue: task.ConditionType,
CompletedValue: completedValue,
IsRewardCollected: rewardCollected,
TargetValue: task.TargetValue,
Quantity: task.RewardGold,
})
}
return result, nil
}
// ClaimableCount 返回可领取任务数量。
func (s *Service) ClaimableCount(ctx context.Context, user AuthUser) (*ClaimableCountResponse, error) {
tasks, err := s.ListTasks(ctx, user)
if err != nil {
return nil, err
}
count := 0
for _, task := range tasks {
if task.TaskStatus == 1 && task.IsRewardCollected == 0 {
count++
}
}
return &ClaimableCountResponse{Count: count}, nil
}
// Claim 领取任务奖励。
func (s *Service) Claim(ctx context.Context, user AuthUser, taskID int64) (bool, error) {
if taskID <= 0 {
return false, NewAppError(http.StatusBadRequest, "invalid_task_id", "taskId is required")
}
sysOrigin := s.normalizeSysOrigin(user.SysOrigin)
bundle, err := s.loadEnabledBundle(ctx, sysOrigin)
if err != nil {
return false, err
}
if bundle == nil {
return false, NewAppError(http.StatusNotFound, "task_center_not_configured", "task center is not configured")
}
var task model.TaskCenterTaskConfig
found := false
for _, candidate := range bundle.Tasks {
if candidate.ID == taskID {
task = candidate
found = true
break
}
}
if !found {
return false, NewAppError(http.StatusNotFound, "task_not_found", "task not found")
}
cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, time.Now())
progress, exists, err := s.loadProgress(ctx, sysOrigin, user.UserID, task.ID, cycleKey)
if err != nil {
return false, err
}
if !exists || progress.CompletedValue < task.TargetValue {
return false, NewAppError(http.StatusBadRequest, "task_not_completed", "task is not completed")
}
if progress.RewardCollected {
return true, nil
}
walletEventID := buildWalletEventID(sysOrigin, user.UserID, task.TaskCode, cycleKey)
if err := s.dispatchClaimGold(ctx, sysOrigin, user.UserID, task, walletEventID); err != nil {
_ = s.saveClaimRecord(ctx, task, progress, walletEventID, ClaimStatusFailed, err.Error(), nil)
return false, err
}
now := time.Now()
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.saveClaimRecordTx(tx, task, progress, walletEventID, ClaimStatusSuccess, "", &now); err != nil {
return err
}
return tx.Model(&model.TaskCenterUserProgress{}).
Where("id = ?", progress.ID).
Updates(map[string]any{
"status": TaskStatusClaimed,
"reward_collected": true,
"claimed_at": now,
"update_time": now,
}).Error
}); err != nil {
return false, err
}
return true, nil
}
func (s *Service) dispatchClaimGold(ctx context.Context, sysOrigin string, userID int64, task model.TaskCenterTaskConfig, walletEventID string) error {
if task.RewardGold <= 0 {
return nil
}
if s.java == nil {
return NewAppError(http.StatusBadGateway, "wallet_gateway_missing", "wallet gateway is not configured")
}
exists, err := s.java.ExistsGoldEvent(ctx, walletEventID)
if err != nil {
return NewAppError(http.StatusBadGateway, "wallet_event_check_failed", err.Error())
}
if exists {
return nil
}
if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
ReceiptType: "INCOME",
UserID: userID,
SysOrigin: sysOrigin,
EventID: walletEventID,
Remark: fmt.Sprintf("taskCode=%s cycleKey=%s", task.TaskCode, currentCycleKey(task.TaskCategory, s.location, time.Now())),
Amount: integration.NewPennyAmountPayloadFromDollar(task.RewardGold),
CloseDelayAsset: false,
OpUserType: "APP",
Origin: "DAILY_TASK",
}); err != nil {
return NewAppError(http.StatusBadGateway, "wallet_change_failed", err.Error())
}
return nil
}
func (s *Service) saveClaimRecord(ctx context.Context, task model.TaskCenterTaskConfig, progress model.TaskCenterUserProgress, walletEventID string, status string, failureReason string, claimedAt *time.Time) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.saveClaimRecordTx(tx, task, progress, walletEventID, status, failureReason, claimedAt)
})
}
func (s *Service) saveClaimRecordTx(tx *gorm.DB, task model.TaskCenterTaskConfig, progress model.TaskCenterUserProgress, walletEventID string, status string, failureReason string, claimedAt *time.Time) error {
now := time.Now()
var row model.TaskCenterClaimRecord
err := tx.Where("wallet_event_id = ?", walletEventID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
id, idErr := utils.NextID()
if idErr != nil {
return idErr
}
row = model.TaskCenterClaimRecord{
ID: id,
SysOrigin: progress.SysOrigin,
UserID: progress.UserID,
TaskID: task.ID,
TaskCode: task.TaskCode,
TaskName: task.TaskName,
TaskCategory: task.TaskCategory,
CycleKey: progress.CycleKey,
RewardGold: task.RewardGold,
WalletEventID: walletEventID,
CreateTime: now,
}
} else if err != nil {
return err
}
row.Status = status
row.FailureReason = strings.TrimSpace(failureReason)
row.ClaimedAt = claimedAt
row.UpdateTime = now
return tx.Save(&row).Error
}
type taskCenterBundle struct {
Config model.TaskCenterConfig
Tasks []model.TaskCenterTaskConfig
Location *time.Location
}
func (s *Service) loadEnabledBundle(ctx context.Context, sysOrigin string) (*taskCenterBundle, error) {
var configRow model.TaskCenterConfig
err := s.db.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&configRow).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if !configRow.Enabled {
return nil, nil
}
location := s.location
if parsed, parseErr := time.LoadLocation(normalizeTimezone(configRow.Timezone, s.defaultTimezone())); parseErr == nil {
location = parsed
}
var tasks []model.TaskCenterTaskConfig
if err := s.db.WithContext(ctx).
Where("config_id = ? AND enabled = ?", configRow.ID, true).
Order("sort_order asc, id asc").
Find(&tasks).Error; err != nil {
return nil, err
}
return &taskCenterBundle{Config: configRow, Tasks: tasks, Location: location}, nil
}
func (s *Service) loadProgress(ctx context.Context, sysOrigin string, userID int64, taskID int64, cycleKey string) (model.TaskCenterUserProgress, bool, error) {
var row model.TaskCenterUserProgress
err := s.db.WithContext(ctx).
Where("sys_origin = ? AND user_id = ? AND task_id = ? AND cycle_key = ?", sysOrigin, userID, taskID, cycleKey).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.TaskCenterUserProgress{}, false, nil
}
if err != nil {
return model.TaskCenterUserProgress{}, false, err
}
return row, true, nil
}
func appTaskType(category string) int {
if normalizeCategory(category) == TaskCategoryDaily {
return 0
}
return 1
}
func resolveJumpPage(task model.TaskCenterTaskConfig) string {
jumpPage := strings.TrimSpace(task.JumpPage)
if jumpPage != "" {
return jumpPage
}
if isRechargeTask(task) {
return defaultRechargeJumpPage
}
switch normalizeJumpType(task.JumpType) {
case JumpTypeRechargeInApp:
return defaultRechargeJumpPage
case "ROOM_RANDOM", "ROOM_RANDOM_WITH_MIC":
return "/room"
case "NONE":
return ""
default:
return jumpPage
}
}
func isRechargeTask(task model.TaskCenterTaskConfig) bool {
return strings.EqualFold(strings.TrimSpace(task.ConditionType), EventTypeRechargeGold)
}
func buildWalletEventID(sysOrigin string, userID int64, taskCode string, cycleKey string) string {
normalizedSysOrigin := compactWalletEventPart(sysOrigin, 8)
if normalizedSysOrigin == "" {
normalizedSysOrigin = "SYS"
}
raw := strings.Join([]string{
strings.ToUpper(strings.TrimSpace(sysOrigin)),
strconv.FormatInt(userID, 10),
strings.ToUpper(strings.TrimSpace(taskCode)),
strings.TrimSpace(cycleKey),
}, "|")
sum := sha1.Sum([]byte(raw))
return fmt.Sprintf("TC:%s:%s:%s", normalizedSysOrigin, strconv.FormatInt(userID, 36), hex.EncodeToString(sum[:])[:16])
}
func compactWalletEventPart(value string, maxLen int) string {
value = strings.ToUpper(strings.TrimSpace(value))
if maxLen <= 0 {
return ""
}
var builder strings.Builder
for _, char := range value {
if (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
builder.WriteRune(char)
if builder.Len() >= maxLen {
break
}
}
}
return builder.String()
}