Files
epic-go/internal/logic/cron/third_party_sync.go
hxt f8001aef5b feat(cron): 添加角色配装信息刷新任务并优化神器数据同步功能
- 新增每5天执行一次的角色配装信息刷新任务
- 重构神器数据同步功能,优化数据处理和保存逻辑- 添加神器图片URL获取和上传逻辑
- 更新相关测试用例
2025-07-17 22:05:27 +08:00

546 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package cron
import (
"context"
"encoding/json"
"epic/internal/consts"
"epic/internal/dao"
"epic/internal/logic/i18n"
"epic/internal/model/dto"
"epic/internal/model/entity"
"epic/internal/util"
"fmt"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/gclient"
"github.com/gogf/gf/v2/os/gtime"
"strconv"
"strings"
"time"
)
// ThirdPartyDataSync 第三方数据同步器
type ThirdPartyDataSync struct {
client *gclient.Client
}
// NewThirdPartyDataSync 创建第三方数据同步器
func NewThirdPartyDataSync() *ThirdPartyDataSync {
return &ThirdPartyDataSync{
client: gclient.New().Timeout(30 * time.Second),
}
}
func (t *ThirdPartyDataSync) SyncHeroData(ctx context.Context) error {
g.Log().Info(ctx, "开始同步英雄数据...")
// 示例从第三方API获取英雄数据
heroData, err := t.fetchHeroDataFromAPI(ctx)
if err != nil || heroData == nil {
g.Log().Error(ctx, "获取英雄数据失败:", err)
return err
}
// 处理并保存数据
if err := t.processAndSaveHeroData(ctx, heroData); err != nil {
g.Log().Error(ctx, "处理英雄数据失败:", err)
return err
}
g.Log().Info(ctx, "英雄数据同步完成")
return nil
}
// SyncArtifactData 同步神器数据
func (t *ThirdPartyDataSync) SyncArtifactData(ctx context.Context) error {
g.Log().Info(ctx, "开始同步神器数据...")
//从第三方API获取神器数据
artifactData, err := t.fetchArtifactDataFromAPI(ctx)
if err != nil {
g.Log().Error(ctx, "获取神器数据失败:", err)
return err
}
// 处理并保存数据
if err := t.processAndSaveArtifactData(ctx, artifactData); err != nil {
g.Log().Error(ctx, "处理神器数据失败:", err)
return err
}
g.Log().Info(ctx, "神器数据同步完成")
return nil
}
// fetchHeroDataFromAPI 从API获取英雄数据
func (t *ThirdPartyDataSync) fetchHeroDataFromAPI(ctx context.Context) ([]byte, error) {
// 示例API地址实际使用时需要替换为真实的API
apiURL := consts.HeroListURL
// 添加请求头
headers := map[string]string{
"User-Agent": "EpicGameBot/1.0",
"Accept": "application/json",
}
// 发送GET请求
resp, err := t.client.Header(headers).Get(ctx, apiURL)
if err != nil {
return nil, fmt.Errorf("API请求失败: %v", err)
}
defer resp.Close()
// 检查响应状态
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API响应错误状态码: %d", resp.StatusCode)
}
// 读取响应内容
content := resp.ReadAll()
g.Log().Debug(ctx, "API响应内容长度:", len(content))
return content, nil
}
// fetchHeroBuildsFromAPI 通过角色英文名POST请求获取配装数据
func (t *ThirdPartyDataSync) FetchHeroBuildsFromAPI(ctx context.Context, heroName string) (string, error) {
apiURL := consts.HeroNameURL
headers := map[string]string{
"User-Agent": "EpicGameBot/1.0",
"Accept": "application/json",
"Content-Type": "application/json",
}
heroNameEN := i18n.Zh2En(heroName)
resp, err := t.client.Header(headers).Post(ctx, apiURL, heroNameEN)
if err != nil {
return "", fmt.Errorf("API请求失败: %v", err)
}
defer resp.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API响应错误状态码: %d", resp.StatusCode)
}
content := resp.ReadAll()
g.Log().Debug(ctx, "配装API响应内容长度:", len(content))
return string(content), nil
}
// 从API获取神器数据
func (t *ThirdPartyDataSync) fetchArtifactDataFromAPI(ctx context.Context) (string, error) {
// 示例API地址
apiURL := consts.ArtifactDataURL
headers := map[string]string{
"Accept": "application/json",
}
resp, err := t.client.Header(headers).Get(ctx, apiURL)
if err != nil {
return "", fmt.Errorf("API请求失败: %v", err)
}
defer resp.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API响应错误状态码: %d", resp.StatusCode)
}
content := resp.ReadAll()
g.Log().Debug(ctx, "神器API响应内容长度:", len(content))
return string(content), nil
}
func (t *ThirdPartyDataSync) processAndSaveHeroData(ctx context.Context, data []byte) error {
// 使用 gjson 解析
j := gjson.New(data)
if j == nil || j.IsNil() {
return fmt.Errorf("英雄数据格式错误期望是一个JSON对象")
}
// 先解析为 map[string]*ThirdPartyHeroDTO
var heroes map[string]*dto.ThirdPartyHeroDTO
if err := j.Scan(&heroes); err != nil {
return fmt.Errorf("解析英雄数据到DTO失败: %v", err)
}
g.Log().Info(ctx, "解析到", len(heroes), "个英雄数据")
// 一次性查出所有数据库英雄构建map
var dbHeroes []*entity.EpicHeroInfo
err := dao.EpicHeroInfo.Ctx(ctx).Scan(&dbHeroes)
if err != nil {
return fmt.Errorf("查询数据库英雄失败: %v", err)
}
heroMap := make(map[string]*entity.EpicHeroInfo, len(dbHeroes))
for _, h := range dbHeroes {
heroMap[h.HeroCode] = h
}
// 遍历 map设置 Name 字段,并保存
for name, hero := range heroes {
hero.Name = name // 将 map 的 key 作为 Name 字段
dbHero, exists := heroMap[hero.Code]
if exists && dbHero.RawJson != "" {
// 已有且rawJson有值跳过
continue
}
if exists && dbHero.RawJson == "" {
// 只更新 rawJson 字段
rawJsonBytes, err := json.Marshal(hero)
if err != nil {
g.Log().Error(ctx, "序列化英雄数据失败:", err)
continue
}
rawJson := string(rawJsonBytes)
_, err = dao.EpicHeroInfo.Ctx(ctx).
Where(dao.EpicHeroInfo.Columns().HeroCode, hero.Code).
Data(g.Map{
dao.EpicHeroInfo.Columns().RawJson: rawJson,
dao.EpicHeroInfo.Columns().UpdateTime: gtime.Now(),
}).
Update()
if err != nil {
g.Log().Error(ctx, "更新英雄rawJson失败:", err)
continue
}
g.Log().Debug(ctx, "更新英雄rawJson:", hero.Code)
continue
}
// 新增逻辑保持原样
status60 := hero.CalculatedStatus.Lv60SixStarFullyAwakened
status60json, _ := gjson.EncodeString(status60)
heroJson, _ := gjson.EncodeString(hero)
zhHeroName := i18n.GetZh(ctx, hero.Name)
//zhRole := i18n.GetZh(ctx, hero.Role)
//zhAttribute := i18n.GetZh(ctx, hero.Attribute)
fmt.Println(hero.Assets.Image)
// 上传图片到 OSS并获取自定义域名的访问路径
ossObjectKey := fmt.Sprintf("epic/hero/images/%s.png", hero.Code)
ossUrl, err := util.DownloadAndUploadToOSS(ctx, hero.Assets.Icon, ossObjectKey)
if err != nil {
g.Log().Error(ctx, "上传英雄图片到OSS失败:", err)
return err // 直接返回,后续数据库不会插入
}
fmt.Println(ossUrl)
// 替换为自定义域名
customImgUrl := ""
if ossUrl != "" {
prefix := consts.S3Endpoint + "/" + consts.S3Bucket
if len(ossUrl) > len(prefix) && ossUrl[:len(prefix)] == prefix {
customImgUrl = consts.S3CustomDomain + ossUrl[len(prefix):]
} else {
customImgUrl = ossUrl // fallback
}
}
newHero := &entity.EpicHeroInfo{
Id: 0,
HeroName: zhHeroName,
HeroCode: hero.Code,
HeroAttrLv60: status60json,
Creator: "",
CreateTime: gtime.Now(),
Updater: "",
UpdateTime: gtime.Now(),
Deleted: false,
//NickName: "",
Rarity: strconv.Itoa(hero.Rarity),
Role: hero.Role,
//Zodiac: "",
HeadImgUrl: customImgUrl,
Attribute: hero.Attribute,
Remark: "",
RawJson: heroJson,
}
_, err = dao.EpicHeroInfo.Ctx(ctx).Data(newHero).Insert()
if err != nil {
g.Log().Error(ctx, "插入新英雄失败:", err)
continue
}
g.Log().Debug(ctx, "插入新英雄:", hero.Code)
// 新增后立即刷新该图片的OSS预签名URL到缓存
if customImgUrl != "" {
key := ossObjectKey
err := util.RefreshOssPresignedUrlCache(ctx, []string{key}, 24*time.Hour)
if err != nil {
g.Log().Error(ctx, "刷新新英雄图片预签名URL失败:", key, err)
}
}
}
return nil
}
// processAndSaveArtifactData 处理并保存神器数据
func (t *ThirdPartyDataSync) processAndSaveArtifactData(ctx context.Context, data string) error {
// 1. 解析json为map
var artifactMap map[string]struct {
Name string `json:"name"`
Rarity int `json:"rarity"`
Role string `json:"role"`
Stats struct {
Attack int `json:"attack"`
Health int `json:"health"`
Defense int `json:"defense"`
} `json:"stats"`
Code string `json:"code"`
}
if err := json.Unmarshal([]byte(data), &artifactMap); err != nil {
return fmt.Errorf("解析神器数据失败: %v", err)
}
g.Log().Info(ctx, "解析到", len(artifactMap), "个神器数据")
// 2. 查询数据库所有神器构建map
var dbArtifacts []*entity.EpicArtifactInfo
err := dao.EpicArtifactInfo.Ctx(ctx).Scan(&dbArtifacts)
if err != nil {
return fmt.Errorf("查询数据库神器失败: %v", err)
}
artifactDbMap := make(map[string]*entity.EpicArtifactInfo, len(dbArtifacts))
for _, a := range dbArtifacts {
artifactDbMap[a.ArtifactCode] = a
}
for _, art := range artifactMap {
var dbArt *entity.EpicArtifactInfo
if v, ok := artifactDbMap[art.Code]; ok {
dbArt = v
}
customImgUrl := getArtifactImageUrl(ctx, art.Code, dbArt)
artifact := &entity.EpicArtifactInfo{
ArtifactName: i18n.GetZh(ctx, art.Name),
ArtifactNameEn: art.Name,
ArtifactCode: art.Code,
Rarity: strconv.Itoa(art.Rarity),
Role: art.Role,
StatsAttack: art.Stats.Attack,
StatsHealth: art.Stats.Health,
StatsDefense: art.Stats.Defense,
ImageUrl: customImgUrl,
Updater: "sync",
UpdateTime: gtime.Now(),
Deleted: false,
}
if dbArt != nil {
artifact.Id = dbArt.Id
_, err := dao.EpicArtifactInfo.Ctx(ctx).
Where(dao.EpicArtifactInfo.Columns().ArtifactCode, art.Code).
Data(artifact).
Update()
if err != nil {
g.Log().Error(ctx, "更新神器失败:", art.Name, err)
continue
}
g.Log().Info(ctx, "更新神器:", art.Name)
} else {
artifact.Creator = "sync"
artifact.CreateTime = gtime.Now()
_, err := dao.EpicArtifactInfo.Ctx(ctx).Data(artifact).Insert()
if err != nil {
g.Log().Error(ctx, "插入神器失败:", art.Name, err)
continue
}
g.Log().Info(ctx, "插入神器:", art.Name)
}
}
return nil
}
// saveHeroData 保存单个英雄数据
func (t *ThirdPartyDataSync) saveHeroData(ctx context.Context, hero *dto.ThirdPartyHeroDTO) error {
// 查询是否存在
var dbHero *entity.EpicHeroInfo
err := dao.EpicHeroInfo.Ctx(ctx).
Where(dao.EpicHeroInfo.Columns().HeroCode, hero.Code).
Scan(&dbHero)
if err != nil {
return err
}
// 获取原始 JSON 字符串
rawJsonBytes, err := json.Marshal(hero)
if err != nil {
return err
}
rawJson := string(rawJsonBytes)
if dbHero != nil && dbHero.HeroCode != "" {
// 查到记录
if dbHero.RawJson == "" {
// 只更新 rawJson 字段
_, err = dao.EpicHeroInfo.Ctx(ctx).
Where(dao.EpicHeroInfo.Columns().HeroCode, hero.Code).
Data(g.Map{
dao.EpicHeroInfo.Columns().RawJson: rawJson,
dao.EpicHeroInfo.Columns().UpdateTime: gtime.Now(),
}).
Update()
if err != nil {
return err
}
g.Log().Debug(ctx, "更新英雄rawJson:", hero.Code)
}
// 已有 rawJson不做处理
} else {
status60 := hero.CalculatedStatus.Lv60SixStarFullyAwakened
status60json, _ := gjson.EncodeString(status60)
heroJson, _ := gjson.EncodeString(hero)
// 使用i18n服务转换字段
zhHeroName := i18n.GetZh(ctx, hero.Name)
//zhRole := i18n.GetZh(ctx, hero.Role)
//zhAttribute := i18n.GetZh(ctx, hero.Attribute)
newHero := &entity.EpicHeroInfo{
Id: 0,
HeroName: zhHeroName,
HeroCode: hero.Code,
HeroAttrLv60: status60json,
Creator: "",
CreateTime: gtime.Now(),
Updater: "",
UpdateTime: gtime.Now(),
Deleted: false,
//NickName: nil,
Rarity: strconv.Itoa(hero.Rarity),
Role: hero.Role,
Zodiac: "",
HeadImgUrl: "",
Attribute: hero.Attribute,
Remark: "",
RawJson: heroJson,
}
// 没查到,插入新记录,字段按 DO 结构体补全
_, err = dao.EpicHeroInfo.Ctx(ctx).Data(newHero).Insert()
if err != nil {
return err
}
g.Log().Debug(ctx, "插入新英雄:", hero.Code)
}
return nil
}
// 保存单个神器数据
func (t *ThirdPartyDataSync) saveArtifactData(ctx context.Context, artifact *dto.ThirdPartyArtifactDTO) error {
// TODO: 实现具体的数据库保存逻辑
// 现在 artifact 是一个强类型对象, 可以直接使用 artifact.Code, artifact.Name 等
// 示例:记录同步日志
syncLog := map[string]interface{}{
"type": "artifact_sync",
"artifact_code": artifact.Code,
"sync_time": gtime.Now(),
"status": "success",
}
g.Log().Debug(ctx, "保存神器数据:", syncLog)
return nil
}
// SyncAllData 同步所有数据
func (t *ThirdPartyDataSync) SyncAllData(ctx context.Context) error {
g.Log().Info(ctx, "开始同步所有第三方数据...")
// 同步英雄数据
if err := t.SyncHeroData(ctx); err != nil {
g.Log().Error(ctx, "英雄数据同步失败:", err)
// 继续同步其他数据
}
// 同步神器数据
if err := t.SyncArtifactData(ctx); err != nil {
g.Log().Error(ctx, "神器数据同步失败:", err)
// 继续同步其他数据
}
// 可以继续添加其他数据类型的同步
g.Log().Info(ctx, "所有第三方数据同步完成")
return nil
}
// RefreshHeroSetContentByHeroInfo 刷新单个角色配装字段
func (t *ThirdPartyDataSync) RefreshHeroSetContentByHeroInfo(ctx context.Context, heroName, heroCode, jsonStr string) error {
g.Log().Infof(ctx, "刷新角色配装: %s", heroName)
if len(jsonStr) > 200 {
_, err := dao.EpicHeroInfo.Ctx(ctx).
Where(dao.EpicHeroInfo.Columns().HeroCode, heroCode).
Data(g.Map{
dao.EpicHeroInfo.Columns().SetContentJson: jsonStr,
dao.EpicHeroInfo.Columns().SetUpdateTime: gtime.Now(),
}).
Update()
if err != nil {
g.Log().Errorf(ctx, "更新数据库失败: %s, err: %v", heroName, err)
return err
}
g.Log().Infof(ctx, "已更新: %s", heroName)
return nil
} else {
g.Log().Error(ctx, "配装数据无效(长度<=200): %s", heroName)
return fmt.Errorf("配装数据无效(长度<=200)")
}
}
//刷新所有角色配装字段
func (t *ThirdPartyDataSync) RefreshAllHeroSetContent(ctx context.Context) error {
g.Log().Info(ctx, "开始批量刷新所有角色配装字段...")
// 1. 查询所有角色按set_update_time正序排列为空的在最前面
var heroList []*entity.EpicHeroInfo
err := dao.EpicHeroInfo.Ctx(ctx).
OrderAsc("set_update_time").
Scan(&heroList)
if err != nil {
g.Log().Error(ctx, "查询epic_hero_info失败:", err)
return err
}
for i, epicHeroInfo := range heroList {
g.Log().Infof(ctx, "[%d/%d] 处理角色: %s", i+1, len(heroList), epicHeroInfo.HeroName)
jsonStr, err := t.FetchHeroBuildsFromAPI(ctx, epicHeroInfo.HeroName)
if err != nil {
g.Log().Errorf(ctx, "获取配装数据失败: %s, err: %v", epicHeroInfo.HeroName, err)
} else {
err = t.RefreshHeroSetContentByHeroInfo(ctx, epicHeroInfo.HeroName, epicHeroInfo.HeroCode, jsonStr)
}
// 远程接口有频率限制停顿10分钟
if i < len(heroList)-1 {
g.Log().Info(ctx, "等待10分钟...")
time.Sleep(10 * time.Minute)
}
if err != nil {
continue
}
}
g.Log().Info(ctx, "所有角色配装字段刷新完成")
return nil
}
// 获取神器图片URL已上传则复用否则上传
func getArtifactImageUrl(ctx context.Context, artCode string, dbArt *entity.EpicArtifactInfo) string {
if dbArt != nil && dbArt.ImageUrl != "" && strings.HasPrefix(dbArt.ImageUrl, consts.S3CustomDomain) {
return dbArt.ImageUrl
}
ossObjectKey := fmt.Sprintf("epic/artifact/images/%s.png", artCode)
ossUrl, err := util.DownloadAndUploadToOSS(ctx, "", ossObjectKey)
if err != nil || ossUrl == "" {
return ""
}
prefix := consts.S3Endpoint + "/" + consts.S3Bucket
if strings.HasPrefix(ossUrl, prefix) {
return consts.S3CustomDomain + ossUrl[len(prefix):]
}
return ossUrl
}