浏览代码

这次改动主要实现了:

✅ 国际化支持 - 手机号支持国际区号
✅ 短信发送集成 - 接入短信宝API
✅ OAuth准备 - 为 Google/Zalo 登录准备配置接口
✅ 字段兼容 - 降级处理避免数据库字段缺失导致崩溃
✅ 公开接口 - OAuth配置和排行榜公开访问
这些修改为国际化和第三方登录做了准备,同时增强了系统的健壮性。
urbanu 1 月之前
父节点
当前提交
fd5c76de2f

+ 154 - 25
apis/daytask/auth.go

@@ -4,13 +4,20 @@ import (
 	"app/apis/middleware"
 	"app/commons/core/redisclient"
 	"app/commons/model/entity"
+	"crypto/md5"
 	"crypto/rand"
+	"encoding/hex"
 	"fmt"
-	"github.com/gin-gonic/gin"
-	"golang.org/x/crypto/bcrypt"
+	"io"
 	"math/big"
+	"net/http"
+	"net/url"
 	"regexp"
+	"strings"
 	"time"
+
+	"github.com/gin-gonic/gin"
+	"golang.org/x/crypto/bcrypt"
 )
 
 // generateInviteCode 生成邀请码
@@ -29,13 +36,65 @@ func generateUid() string {
 	return fmt.Sprintf("DT%d", time.Now().UnixNano()/1000000)
 }
 
+// sendSmsBao 调用短信宝发送短信
+func sendSmsBao(user, pass, sign, phone, code string) error {
+	// 短信宝API接口
+	// 国内: http://api.smsbao.com/sms
+	// 国际: http://api.smsbao.com/wsms
+	apiUrl := "http://api.smsbao.com/wsms" // 默认使用国际接口
+
+	// 国内手机号使用国内接口
+	if strings.HasPrefix(phone, "86") || (!strings.HasPrefix(phone, "+") && len(phone) == 11) {
+		apiUrl = "http://api.smsbao.com/sms"
+		// 国内手机号去掉区号前缀
+		if strings.HasPrefix(phone, "86") {
+			phone = phone[2:]
+		}
+	}
+
+	// MD5加密密码
+	h := md5.New()
+	h.Write([]byte(pass))
+	passHash := hex.EncodeToString(h.Sum(nil))
+
+	// 短信内容
+	content := fmt.Sprintf("【%s】您的验证码是%s,在5分钟内有效。", sign, code)
+
+	// 构建请求参数
+	params := url.Values{}
+	params.Set("u", user)
+	params.Set("p", passHash)
+	params.Set("m", phone)
+	params.Set("c", content)
+
+	// 发送请求
+	resp, err := http.Get(apiUrl + "?" + params.Encode())
+	if err != nil {
+		return fmt.Errorf("sms request failed: %v", err)
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(resp.Body)
+	result := strings.TrimSpace(string(body))
+
+	// 短信宝返回码: 0=成功, 其他=失败
+	if result != "0" {
+		return fmt.Errorf("smsbao error: %s", result)
+	}
+
+	return nil
+}
+
 // SendSmsCode 发送短信验证码
 func (s *Server) SendSmsCode(c *gin.Context) {
 	ctx := s.FromContext(c)
+	db := s.DB()
 
 	type Req struct {
-		Phone string `json:"phone" binding:"required"`
-		Type  string `json:"type" binding:"required"` // register/login/reset
+		Type     string `json:"type" binding:"required"`    // phone/email
+		Account  string `json:"account" binding:"required"` // 手机号或邮箱
+		AreaCode string `json:"areaCode"`                   // 国际区号 如 84, 86
+		Scene    string `json:"scene" binding:"required"`   // register/login/reset/bind
 	}
 	var req Req
 	if err := c.ShouldBindJSON(&req); err != nil {
@@ -43,15 +102,27 @@ func (s *Server) SendSmsCode(c *gin.Context) {
 		return
 	}
 
+	// 目前只支持手机号
+	if req.Type != "phone" {
+		ctx.Fail("unsupported_type")
+		return
+	}
+
 	// 验证手机号格式
 	phoneRegex := regexp.MustCompile(`^\d{9,15}$`)
-	if !phoneRegex.MatchString(req.Phone) {
+	if !phoneRegex.MatchString(req.Account) {
 		ctx.Fail("invalid_phone")
 		return
 	}
 
+	// 完整手机号(带区号)
+	fullPhone := req.Account
+	if req.AreaCode != "" {
+		fullPhone = req.AreaCode + req.Account
+	}
+
 	// 防止重复发送
-	cacheKey := fmt.Sprintf("sms:%s:%s", req.Type, req.Phone)
+	cacheKey := fmt.Sprintf("sms:%s:%s", req.Scene, fullPhone)
 	if !ctx.RepeatFilter(cacheKey, 60*time.Second) {
 		ctx.Fail("sms_send_too_fast")
 		return
@@ -61,11 +132,32 @@ func (s *Server) SendSmsCode(c *gin.Context) {
 	n, _ := rand.Int(rand.Reader, big.NewInt(900000))
 	code := fmt.Sprintf("%06d", n.Int64()+100000)
 
-	// TODO: 调用短信服务发送验证码
-	// smsService.Send(req.Phone, code)
+	// 获取短信宝配置
+	var smsUser, smsPass, smsSign string
+	var config entity.DtConfig
+	if err := db.Where("`key` = ?", entity.ConfigKeySmsUser).First(&config).Error; err == nil {
+		smsUser = config.Value
+	}
+	if err := db.Where("`key` = ?", entity.ConfigKeySmsPass).First(&config).Error; err == nil {
+		smsPass = config.Value
+	}
+	if err := db.Where("`key` = ?", entity.ConfigKeySmsSign).First(&config).Error; err == nil {
+		smsSign = config.Value
+	}
+
+	// 调用短信宝发送验证码
+	if smsUser != "" && smsPass != "" {
+		if err := sendSmsBao(smsUser, smsPass, smsSign, fullPhone, code); err != nil {
+			ctx.Fail("sms_send_failed")
+			return
+		}
+	} else {
+		// 开发模式:打印验证码到日志
+		fmt.Printf("[DEV] SMS Code for %s: %s\n", fullPhone, code)
+	}
 
 	// 存储验证码到Redis
-	codeKey := fmt.Sprintf("smscode:%s:%s", req.Type, req.Phone)
+	codeKey := fmt.Sprintf("smscode:%s:%s", req.Scene, fullPhone)
 	redisclient.DefaultClient().Set(c, codeKey, code, 5*time.Minute)
 
 	ctx.OK(gin.H{
@@ -79,10 +171,12 @@ func (s *Server) Register(c *gin.Context) {
 	db := s.DB()
 
 	type Req struct {
-		Phone      string `json:"phone" binding:"required"`
+		Type       string `json:"type" binding:"required"`    // phone/email
+		Account    string `json:"account" binding:"required"` // 手机号或邮箱
 		Code       string `json:"code" binding:"required"`
 		Password   string `json:"password" binding:"required,min=6"`
 		InviteCode string `json:"inviteCode"`
+		AreaCode   string `json:"areaCode"` // 国际区号
 	}
 	var req Req
 	if err := c.ShouldBindJSON(&req); err != nil {
@@ -90,8 +184,20 @@ func (s *Server) Register(c *gin.Context) {
 		return
 	}
 
+	// 目前只支持手机号注册
+	if req.Type != "phone" {
+		ctx.Fail("unsupported_type")
+		return
+	}
+
+	// 完整手机号(带区号)
+	fullPhone := req.Account
+	if req.AreaCode != "" {
+		fullPhone = req.AreaCode + req.Account
+	}
+
 	// 验证短信验证码
-	codeKey := fmt.Sprintf("smscode:register:%s", req.Phone)
+	codeKey := fmt.Sprintf("smscode:register:%s", fullPhone)
 	storedCode, err := redisclient.DefaultClient().Get(c, codeKey).Result()
 	if err != nil || storedCode != req.Code {
 		ctx.Fail("invalid_code")
@@ -100,7 +206,7 @@ func (s *Server) Register(c *gin.Context) {
 
 	// 检查手机号是否已注册
 	var existUser entity.DtUser
-	if err := db.Where("phone = ?", req.Phone).First(&existUser).Error; err == nil {
+	if err := db.Where("phone = ?", fullPhone).First(&existUser).Error; err == nil {
 		ctx.Fail("phone_registered")
 		return
 	}
@@ -128,9 +234,9 @@ func (s *Server) Register(c *gin.Context) {
 	// 创建用户
 	user := &entity.DtUser{
 		Uid:        generateUid(),
-		Phone:      req.Phone,
+		Phone:      fullPhone,
 		Password:   string(hashedPassword),
-		Nickname:   "用户" + req.Phone[len(req.Phone)-4:],
+		Nickname:   "用户" + req.Account[len(req.Account)-4:],
 		ParentId:   parentId,
 		LevelId:    defaultLevel.Id,
 		InviteCode: generateInviteCode(),
@@ -191,8 +297,10 @@ func (s *Server) LoginByPassword(c *gin.Context) {
 	db := s.DB()
 
 	type Req struct {
-		Phone    string `json:"phone" binding:"required"`
+		Type     string `json:"type" binding:"required"`    // phone/email
+		Account  string `json:"account" binding:"required"` // 手机号或邮箱
 		Password string `json:"password" binding:"required"`
+		AreaCode string `json:"areaCode"` // 国际区号
 	}
 	var req Req
 	if err := c.ShouldBindJSON(&req); err != nil {
@@ -200,9 +308,15 @@ func (s *Server) LoginByPassword(c *gin.Context) {
 		return
 	}
 
+	// 完整手机号(带区号)
+	fullPhone := req.Account
+	if req.AreaCode != "" {
+		fullPhone = req.AreaCode + req.Account
+	}
+
 	// 查找用户
 	var user entity.DtUser
-	if err := db.Where("phone = ?", req.Phone).First(&user).Error; err != nil {
+	if err := db.Where("phone = ?", fullPhone).First(&user).Error; err != nil {
 		ctx.Fail("user_not_found")
 		return
 	}
@@ -248,8 +362,9 @@ func (s *Server) LoginBySms(c *gin.Context) {
 	db := s.DB()
 
 	type Req struct {
-		Phone string `json:"phone" binding:"required"`
-		Code  string `json:"code" binding:"required"`
+		Phone    string `json:"phone" binding:"required"`
+		Code     string `json:"code" binding:"required"`
+		AreaCode string `json:"areaCode"` // 国际区号
 	}
 	var req Req
 	if err := c.ShouldBindJSON(&req); err != nil {
@@ -257,8 +372,14 @@ func (s *Server) LoginBySms(c *gin.Context) {
 		return
 	}
 
+	// 完整手机号(带区号)
+	fullPhone := req.Phone
+	if req.AreaCode != "" {
+		fullPhone = req.AreaCode + req.Phone
+	}
+
 	// 验证短信验证码
-	codeKey := fmt.Sprintf("smscode:login:%s", req.Phone)
+	codeKey := fmt.Sprintf("smscode:login:%s", fullPhone)
 	storedCode, err := redisclient.DefaultClient().Get(c, codeKey).Result()
 	if err != nil || storedCode != req.Code {
 		ctx.Fail("invalid_code")
@@ -267,7 +388,7 @@ func (s *Server) LoginBySms(c *gin.Context) {
 
 	// 查找用户
 	var user entity.DtUser
-	if err := db.Where("phone = ?", req.Phone).First(&user).Error; err != nil {
+	if err := db.Where("phone = ?", fullPhone).First(&user).Error; err != nil {
 		ctx.Fail("user_not_found")
 		return
 	}
@@ -286,8 +407,8 @@ func (s *Server) LoginBySms(c *gin.Context) {
 	redisclient.DefaultClient().Del(c, codeKey)
 
 	// 生成Token
-	token, err := middleware.GenerateJWT(middleware.Member{ID: user.Id, Uid: user.Uid})
-	if err != nil {
+	token, err2 := middleware.GenerateJWT(middleware.Member{ID: user.Id, Uid: user.Uid})
+	if err2 != nil {
 		ctx.Fail("login_failed")
 		return
 	}
@@ -310,9 +431,11 @@ func (s *Server) ResetPassword(c *gin.Context) {
 	db := s.DB()
 
 	type Req struct {
-		Phone       string `json:"phone" binding:"required"`
+		Type        string `json:"type" binding:"required"`    // phone/email
+		Account     string `json:"account" binding:"required"` // 手机号或邮箱
 		Code        string `json:"code" binding:"required"`
 		NewPassword string `json:"newPassword" binding:"required,min=6"`
+		AreaCode    string `json:"areaCode"` // 国际区号
 	}
 	var req Req
 	if err := c.ShouldBindJSON(&req); err != nil {
@@ -320,8 +443,14 @@ func (s *Server) ResetPassword(c *gin.Context) {
 		return
 	}
 
+	// 完整手机号(带区号)
+	fullPhone := req.Account
+	if req.AreaCode != "" {
+		fullPhone = req.AreaCode + req.Account
+	}
+
 	// 验证短信验证码
-	codeKey := fmt.Sprintf("smscode:reset:%s", req.Phone)
+	codeKey := fmt.Sprintf("smscode:reset:%s", fullPhone)
 	storedCode, err := redisclient.DefaultClient().Get(c, codeKey).Result()
 	if err != nil || storedCode != req.Code {
 		ctx.Fail("invalid_code")
@@ -330,7 +459,7 @@ func (s *Server) ResetPassword(c *gin.Context) {
 
 	// 查找用户
 	var user entity.DtUser
-	if err := db.Where("phone = ?", req.Phone).First(&user).Error; err != nil {
+	if err := db.Where("phone = ?", fullPhone).First(&user).Error; err != nil {
 		ctx.Fail("user_not_found")
 		return
 	}

+ 71 - 14
apis/daytask/home.go

@@ -3,6 +3,7 @@ package daytask
 import (
 	"app/commons/model/entity"
 	"app/commons/services"
+	"strings"
 	"github.com/gin-gonic/gin"
 )
 
@@ -28,19 +29,25 @@ func (s *Server) HomeIndex(c *gin.Context) {
 
 	// 获取推荐任务
 	recommendTasks := make([]*entity.DtTask, 0)
-	db.Model(&entity.DtTask{}).
-		Where("status = ? AND is_recommend = ?", 1, 1).
-		Order("sort DESC, id DESC").
-		Limit(10).
-		Find(&recommendTasks)
+	query := db.Model(&entity.DtTask{}).Where("status = ?", 1)
+	// 尝试使用 is_recommend 字段,如果字段不存在则降级处理
+	if err := query.Where("is_recommend = ?", 1).Order("sort DESC, id DESC").Limit(10).Find(&recommendTasks).Error; err != nil {
+		if isColumnNotExistError(err) {
+			// 字段不存在,使用降级查询(不筛选推荐)
+			query.Order("sort DESC, id DESC").Limit(10).Find(&recommendTasks)
+		}
+	}
 
 	// 获取普通任务
 	normalTasks := make([]*entity.DtTask, 0)
-	db.Model(&entity.DtTask{}).
-		Where("status = ? AND remain_count > 0", 1).
-		Order("is_top DESC, created_at DESC").
-		Limit(20).
-		Find(&normalTasks)
+	query = db.Model(&entity.DtTask{}).Where("status = ? AND remain_count > 0", 1)
+	// 尝试使用 is_top 字段,如果字段不存在则降级处理
+	if err := query.Order("is_top DESC, created_at DESC").Limit(20).Find(&normalTasks).Error; err != nil {
+		if isColumnNotExistError(err) {
+			// 字段不存在,使用降级查询(不按置顶排序)
+			query.Order("created_at DESC").Limit(20).Find(&normalTasks)
+		}
+	}
 
 	ctx.OK(gin.H{
 		"banners":        banners,
@@ -97,10 +104,19 @@ func (s *Server) HomeHall(c *gin.Context) {
 	query.Count(&paging.Total)
 	paging.Computer()
 
-	query.Order("is_top DESC, is_recommend DESC, created_at DESC").
+	// 尝试使用 is_top 和 is_recommend 字段,如果字段不存在则降级处理
+	if err := query.Order("is_top DESC, is_recommend DESC, created_at DESC").
 		Offset(int(paging.Start)).
 		Limit(int(paging.Size)).
-		Find(&tasks)
+		Find(&tasks).Error; err != nil {
+		if isColumnNotExistError(err) {
+			// 字段不存在,使用降级查询(不按置顶和推荐排序)
+			query.Order("created_at DESC").
+				Offset(int(paging.Start)).
+				Limit(int(paging.Size)).
+				Find(&tasks)
+		}
+	}
 
 	ctx.OK(gin.H{
 		"totalReward":    totalReward,
@@ -151,10 +167,19 @@ func (s *Server) TaskList(c *gin.Context) {
 	paging.Computer()
 
 	tasks := make([]*entity.DtTask, 0)
-	query.Order("is_top DESC, is_recommend DESC, created_at DESC").
+	// 尝试使用 is_top 和 is_recommend 字段,如果字段不存在则降级处理
+	if err := query.Order("is_top DESC, is_recommend DESC, created_at DESC").
 		Offset(int(paging.Start)).
 		Limit(int(paging.Size)).
-		Find(&tasks)
+		Find(&tasks).Error; err != nil {
+		if isColumnNotExistError(err) {
+			// 字段不存在,使用降级查询(不按置顶和推荐排序)
+			query.Order("created_at DESC").
+				Offset(int(paging.Start)).
+				Limit(int(paging.Size)).
+				Find(&tasks)
+		}
+	}
 
 	ctx.OK(gin.H{
 		"list":   tasks,
@@ -246,3 +271,35 @@ func (s *Server) CustomerService(c *gin.Context) {
 
 	ctx.OK(services)
 }
+
+// OAuthConfig 获取OAuth配置(公开接口,只返回客户端需要的信息)
+func (s *Server) OAuthConfig(c *gin.Context) {
+	ctx := s.FromContext(c)
+	db := s.DB()
+
+	result := make(map[string]string)
+
+	// 获取 Google Client ID
+	var googleConfig entity.DtConfig
+	if err := db.Where("`key` = ?", entity.ConfigKeyGoogleClientId).First(&googleConfig).Error; err == nil {
+		result["googleClientId"] = googleConfig.Value
+	}
+
+	// 获取 Zalo App ID
+	var zaloConfig entity.DtConfig
+	if err := db.Where("`key` = ?", entity.ConfigKeyZaloAppId).First(&zaloConfig).Error; err == nil {
+		result["zaloAppId"] = zaloConfig.Value
+	}
+
+	ctx.OK(result)
+}
+
+// isColumnNotExistError 检查是否是列不存在的错误
+func isColumnNotExistError(err error) bool {
+	if err == nil {
+		return false
+	}
+	errStr := err.Error()
+	// MySQL 错误 1054 表示列不存在
+	return strings.Contains(errStr, "1054") || strings.Contains(errStr, "Unknown column")
+}

+ 3 - 3
apis/daytask/routers.go

@@ -47,10 +47,12 @@ func (h Router) Register(group *gin.RouterGroup) {
 	group.GET("customer/service", server().CustomerService) // 客服配置
 
 	// 系统配置
-	group.GET("config/get", server().ConfigGet) // 获取配置
+	group.GET("config/get", server().ConfigGet)     // 获取配置
+	group.GET("config/oauth", server().OAuthConfig) // OAuth配置(公开)
 
 	// 排行榜(公开)
 	group.GET("rank/reward", server().RankReward) // 排行榜奖励配置
+	group.GET("rank/list", server().RankList)     // 排行榜列表
 }
 
 // UserRouter DayTask用户路由(需要登录)
@@ -107,6 +109,4 @@ func (h UserRouter) Register(group *gin.RouterGroup) {
 	group.GET("notice/unread", server().NoticeUnread)     // 未读数量
 	group.POST("notice/delete", server().NoticeDelete)    // 删除消息
 
-	// 排行榜
-	group.GET("rank/list", server().RankList) // 排行榜
 }

+ 13 - 11
commons/model/entity/dt_config.go

@@ -26,15 +26,17 @@ func NewDtConfig() *DtConfig {
 
 // 常用配置键
 const (
-	ConfigKeyCommissionRate1  = "bfb_1"          // 一级返佣比例
-	ConfigKeyInviteBonus      = "xshare_bonus"   // 直推奖励金额
-	ConfigKeyEnableInviteBonus = "is_share_bonus" // 是否启用直推奖励
-	ConfigKeyWithdrawFee      = "charge"         // 提现手续费
-	ConfigKeyMinWithdraw      = "min_withdraw"   // 最低提现金额
-	ConfigKeySmsUser          = "smsbao_user"    // 短信宝账号
-	ConfigKeySmsPass          = "smsbao_pass"    // 短信宝密码
-	ConfigKeySmsSign          = "smsbao_sign"    // 短信签名
-	ConfigKeyKefuEmail        = "kefu_email"     // 客服邮箱
-	ConfigKeyKefuTelegram     = "kefu_telegram"  // 客服Telegram
-	ConfigKeyKefuPhone        = "kefu_phone"     // 客服电话
+	ConfigKeyCommissionRate1   = "bfb_1"            // 一级返佣比例
+	ConfigKeyInviteBonus       = "xshare_bonus"     // 直推奖励金额
+	ConfigKeyEnableInviteBonus = "is_share_bonus"   // 是否启用直推奖励
+	ConfigKeyWithdrawFee       = "charge"           // 提现手续费
+	ConfigKeyMinWithdraw       = "min_withdraw"     // 最低提现金额
+	ConfigKeySmsUser           = "smsbao_user"      // 短信宝账号
+	ConfigKeySmsPass           = "smsbao_pass"      // 短信宝密码
+	ConfigKeySmsSign           = "smsbao_sign"      // 短信签名
+	ConfigKeyKefuEmail         = "kefu_email"       // 客服邮箱
+	ConfigKeyKefuTelegram      = "kefu_telegram"    // 客服Telegram
+	ConfigKeyKefuPhone         = "kefu_phone"       // 客服电话
+	ConfigKeyGoogleClientId    = "google_client_id" // Google OAuth Client ID
+	ConfigKeyZaloAppId         = "zalo_app_id"      // Zalo App ID
 )

+ 29 - 3
commons/services/server_i18n.go

@@ -3,6 +3,9 @@ package services
 import (
 	"app/commons/constant"
 	"app/commons/model/entity"
+	"errors"
+	"strings"
+	"gorm.io/gorm"
 )
 
 var (
@@ -32,6 +35,11 @@ func (s *I18nService) refreshCache() error {
 	// 查询所有国际化数据
 	var i18ns []*entity.SysI18n
 	if err := s.DB().Find(&i18ns).Error; err != nil {
+		// 如果表不存在,初始化空映射并返回nil(不报错)
+		if errors.Is(err, gorm.ErrRecordNotFound) || isTableNotExistError(err) {
+			i18nMap = make(map[string]map[string]string)
+			return nil
+		}
 		return err
 	}
 	// 删除旧数据
@@ -53,15 +61,33 @@ func (s *I18nService) Translate(lang string, i18nKey string) string {
 	if text, ok := i18nMap[lang][i18nKey]; ok {
 		return text
 	}
-	_ = s.DB().Create(&entity.SysI18n{
+	// 如果表不存在,直接返回key,不尝试插入
+	if err := s.DB().Create(&entity.SysI18n{
 		Lang: lang,
 		Name: i18nKey,
 		Text: i18nKey,
-	}).Error
-	_ = s.refreshCache()
+	}).Error; err != nil {
+		// 如果是表不存在错误,直接返回key,不刷新缓存
+		if isTableNotExistError(err) {
+			return i18nKey
+		}
+		_ = s.refreshCache()
+	} else {
+		_ = s.refreshCache()
+	}
 	return i18nKey
 }
 
 func (s *I18nService) langCompatibility(lang string) string {
 	return constant.LangCompatibility(lang)
 }
+
+// isTableNotExistError 检查是否是表不存在的错误
+func isTableNotExistError(err error) bool {
+	if err == nil {
+		return false
+	}
+	errStr := err.Error()
+	// MySQL 错误 1146 表示表不存在
+	return strings.Contains(errStr, "1146") || strings.Contains(errStr, "doesn't exist")
+}

+ 1 - 1
tasks/period_profit/02_cal_level_profit.go

@@ -30,7 +30,7 @@ func (s *Service) calLevelProfit(periodNo string) error {
 		return err
 	}
 	if nowPeriodJob.StaticSymbolProfit.LessThanOrEqual(decimal.Zero) {
-		core.JobLog.Errorf("期:%s 静态收益为:%s", periodNo, nowPeriodJob.StaticSymbolProfit)
+		core.JobLog.Warnf("期:%s 静态收益为:%s,跳过等级收益计算", periodNo, nowPeriodJob.StaticSymbolProfit)
 		return nil
 	}
 	periodProfit := nowPeriodJob.StaticSymbolProfit