first commit

This commit is contained in:
2026-09-11 01:41:52 +08:00
commit 9f22e6ccae
263 changed files with 28935 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
// Package tokenhub 封装 Tokenhub 大模型 API 调用,用于生成每日星座运势与穿搭推荐。
package tokenhub
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
)
const baseURL = "https://tokenhub.tencentmaas.com/v1/chat/completions"
// Client Tokenhub API 客户端。
type Client struct {
apiKey string
model string
httpClient *http.Client
}
// NewClient 从全局配置创建客户端。
func NewClient() *Client {
return &Client{
apiKey: global.TokenhubSetting.APIKey,
model: global.TokenhubSetting.Model,
httpClient: &http.Client{
Timeout: 120 * time.Second,
},
}
}
// Message 聊天消息。
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// chatResponse Tokenhub chat/completions 外层响应结构(只取需要的字段)。
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
// ResponseFormat JSON Schema 响应格式。
type ResponseFormat struct {
Type string `json:"type"`
JSONSchema *JSONSchema `json:"json_schema"`
}
// JSONSchema 结构化输出定义。
type JSONSchema struct {
Name string `json:"name"`
Schema interface{} `json:"schema"`
}
// ChatCompletion 调用混元 chat/completions,返回原始响应体。
func (c *Client) ChatCompletion(ctx context.Context, messages []Message, responseFormat *ResponseFormat) ([]byte, error) {
body := map[string]interface{}{
"model": c.model,
"messages": messages,
}
if responseFormat != nil {
body["response_format"] = responseFormat
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
request, err := http.NewRequestWithContext(ctx, "POST", baseURL, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
request.Header.Set("Authorization", "Bearer "+c.apiKey)
request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("tokenhub api error: status=%d, body=%s", response.StatusCode, string(data))
}
return data, nil
}
+206
View File
@@ -0,0 +1,206 @@
package tokenhub
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
)
// FortuneItem 单个星座运势。
type FortuneItem struct {
Zodiac string `json:"zodiac"`
Overall int8 `json:"overall"`
Love int8 `json:"love"`
Work int8 `json:"work"`
Money int8 `json:"money"`
LuckyColor string `json:"lucky_color"`
LuckyNumber string `json:"lucky_number"`
Description string `json:"description"`
}
// ToHoroscope 将 LLM 返回的运势项转换为 Horoscope 模型。
func (f FortuneItem) ToHoroscope(statDate int64) *mysqlmodel.Horoscope {
return &mysqlmodel.Horoscope{
StatDate: statDate,
Zodiac: f.Zodiac,
OverallScore: f.Overall,
LoveScore: f.Love,
CareerScore: f.Work,
WealthScore: f.Money,
LuckyColor: f.LuckyColor,
LuckyNumber: f.LuckyNumber,
DailyText: f.Description,
}
}
// FortuneResponse Tokenhub 返回的运势 JSON。
type FortuneResponse struct {
Date string `json:"date"`
Fortunes []FortuneItem `json:"fortunes"`
}
// GenerateDailyFortunes 调用 Tokenhub 生成今日十二星座运势。
func (c *Client) GenerateDailyFortunes(ctx context.Context) (*FortuneResponse, error) {
today := time.Now().Format("2006-01-02")
messages := []Message{
{Role: "user", Content: buildFortunePrompt(today)},
}
responseFormat := &ResponseFormat{
Type: "json_schema",
JSONSchema: &JSONSchema{
Name: "daily_fortunes",
Schema: buildFortuneSchema(),
},
}
data, err := c.ChatCompletion(ctx, messages, responseFormat)
if err != nil {
return nil, err
}
// 解析混元外层响应
var cr chatResponse
if err := json.Unmarshal(data, &cr); err != nil {
return nil, fmt.Errorf("unmarshal chat response: %w", err)
}
if len(cr.Choices) == 0 {
return nil, fmt.Errorf("no choices in response")
}
// 解析内层内容。注意:部分模型(如 hy3-preview)不严格遵守 json_schema,
// 可能返回中文键名的 map 结构,这里做容错解析。
content := sanitizeJSONContent(cr.Choices[0].Message.Content)
result, err := parseFortuneContent(content)
if err != nil {
return nil, err
}
// 兜底:模型未返回日期时,使用当天日期
if result.Date == "" {
result.Date = time.Now().Format("2006-01-02")
}
return result, nil
}
// cnFortuneEntry 兼容模型返回的中文键名结构:{"白羊座":{"综合运势":4,...}}。
type cnFortuneEntry struct {
Overall int8 `json:"综合运势"`
Love int8 `json:"爱情运势"`
Work int8 `json:"工作运势"`
Money int8 `json:"财富运势"`
LuckyColor string `json:"幸运色"`
LuckyNumber string `json:"幸运数字"`
Description string `json:"今日运势描述"`
}
// sanitizeJSONContent 去除可能的 markdown 代码围栏与首尾空白。
func sanitizeJSONContent(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
if idx := strings.IndexByte(s, '\n'); idx != -1 {
s = s[idx+1:]
}
s = strings.TrimSuffix(strings.TrimSpace(s), "```")
}
return strings.TrimSpace(s)
}
// parseFortuneContent 容错解析运势内容,兼容两种结构:
// 1. 标准结构 {"date":..,"fortunes":[{"zodiac":..,"overall":..}]}
// 2. 中文键名结构 {"白羊座":{"综合运势":..}}
func parseFortuneContent(content string) (*FortuneResponse, error) {
// 1. 标准结构
var std FortuneResponse
if err := json.Unmarshal([]byte(content), &std); err == nil && len(std.Fortunes) > 0 {
return &std, nil
}
// 2. 中文键名 map 结构
var cn map[string]cnFortuneEntry
if err := json.Unmarshal([]byte(content), &cn); err == nil && len(cn) > 0 {
items := make([]FortuneItem, 0, len(cn))
for zodiac, v := range cn {
// 过滤非星座键(无有效运势内容)
if v.Description == "" && v.Overall == 0 {
continue
}
items = append(items, FortuneItem{
Zodiac: zodiac,
Overall: v.Overall,
Love: v.Love,
Work: v.Work,
Money: v.Money,
LuckyColor: v.LuckyColor,
LuckyNumber: v.LuckyNumber,
Description: v.Description,
})
}
if len(items) > 0 {
return &FortuneResponse{Fortunes: items}, nil
}
}
return nil, fmt.Errorf("no fortunes parsed from content, raw=%s", content)
}
// buildFortunePrompt 构造运势生成 prompt。
func buildFortunePrompt(today string) string {
return fmt.Sprintf(`请为十二星座生成%s的每日运势。
要求:
1. 十二星座运势要有差异,星数分布要合理,不能全部一样
2. 描述要个性化,不要套模板,50 字以内,生动有趣,可结合当前季节和月份特点
3. 整体运势要乐观积极
4. 描述中不要出现星号(*)或 markdown 标记
5. 每个星座给出幸运色(lucky_color)和幸运数字(lucky_number)
必须严格按以下 JSON 结构输出(英文键名,overall/love/work/money 为 1-5 的整数),不要输出任何多余文字或代码围栏:
{
"date": "%s",
"fortunes": [
{"zodiac": "白羊座", "overall": 4, "love": 3, "work": 5, "money": 4, "lucky_color": "红色", "lucky_number": "6", "description": "……"}
]
}
其中 fortunes 数组必须恰好包含 12 个星座(白羊座、金牛座、双子座、巨蟹座、狮子座、处女座、天秤座、天蝎座、射手座、摩羯座、水瓶座、双鱼座)。`,
today, today)
}
// buildFortuneSchema 构造 JSON Schema 让混元按结构返回。
func buildFortuneSchema() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"date": map[string]interface{}{
"type": "string",
"description": "日期,格式 YYYY-MM-DD",
},
"fortunes": map[string]interface{}{
"type": "array",
"description": "十二星座运势列表,必须恰好 12 条",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"zodiac": map[string]interface{}{"type": "string", "description": "星座名称,如:白羊座"},
"overall": map[string]interface{}{"type": "integer", "description": "综合运势,1-5 星"},
"love": map[string]interface{}{"type": "integer", "description": "爱情运势,1-5 星"},
"work": map[string]interface{}{"type": "integer", "description": "工作运势,1-5 星"},
"money": map[string]interface{}{"type": "integer", "description": "财富运势,1-5 星"},
"lucky_color": map[string]interface{}{"type": "string", "description": "幸运色"},
"lucky_number": map[string]interface{}{"type": "string", "description": "幸运数字"},
"description": map[string]interface{}{"type": "string", "description": "今日运势描述,50 字以内"},
},
"required": []string{"zodiac", "overall", "love", "work", "money", "lucky_color", "lucky_number", "description"},
},
},
},
"required": []string{"date", "fortunes"},
}
}
+262
View File
@@ -0,0 +1,262 @@
package tokenhub
import (
"context"
"encoding/json"
"fmt"
"strings"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
)
// outfitCategories 穿搭推荐支持的衣物大类,与 mysqlmodel.Clothes.CategoryMain 取值对齐。
var outfitCategories = []string{"上装", "下装", "连衣裙", "外套", "鞋", "配饰"}
// RecommendLLMItem 大模型返回的单品推荐。
type RecommendLLMItem struct {
Type string `json:"type"` // 品类:上装/下装/连衣裙/外套/鞋/配饰
ClothesID int64 `json:"clothes_id"` // 衣橱衣物 ID
ClothesUUID string `json:"clothes_uuid"` // 衣橱衣物 UUID
Reason string `json:"reason"` // 选择理由
}
// RecommendLLMResponse 大模型返回的穿搭推荐。
type RecommendLLMResponse struct {
OutfitName string `json:"outfit_name"`
Description string `json:"description"`
Style string `json:"style"` // 穿搭风格
Scene string `json:"scene"` // 适合场景
Items []RecommendLLMItem `json:"items"`
}
// GenerateOutfitRecommendation 调用混元生成今日穿搭推荐。
func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes) (*RecommendLLMResponse, error) {
messages := []Message{
{Role: "user", Content: buildRecommendPrompt(zodiac, fortune, clothes)},
}
responseFormat := &ResponseFormat{
Type: "json_schema",
JSONSchema: &JSONSchema{
Name: "outfit_recommendation",
Schema: buildRecommendSchema(),
},
}
data, err := c.ChatCompletion(ctx, messages, responseFormat)
if err != nil {
return nil, fmt.Errorf("call hunyuan for outfit recommend: %w", err)
}
var cr chatResponse
if err := json.Unmarshal(data, &cr); err != nil {
return nil, fmt.Errorf("unmarshal chat response: %w", err)
}
if len(cr.Choices) == 0 {
return nil, fmt.Errorf("no choices in response")
}
var result RecommendLLMResponse
content := sanitizeJSONContent(cr.Choices[0].Message.Content)
if err := json.Unmarshal([]byte(content), &result); err != nil {
return nil, fmt.Errorf("unmarshal outfit recommend json: %w, raw=%s", err, content)
}
return &result, nil
}
// buildRecommendPrompt 构造穿搭推荐 prompt。
func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes) string {
clothesText := FormatClothesInfo(clothes)
return fmt.Sprintf(`你是一位专业的时尚搭配师。请根据用户今天的星座运势和衣橱中的衣物,为用户推荐一套今日穿搭。
## 用户信息
- 星座:%s
- 今日综合运势:%d 星(满分 5 星)
- 今日爱情运势:%d 星
- 今日工作运势:%d 星
- 今日财富运势:%d 星
- 今日运势描述:%s
## 用户衣橱
%s
## 搭配规则
1. 所有推荐单品必须严格选自用户衣橱,clothes_id 与 clothes_uuid 必须与衣橱中存在的衣物一一对应,不得虚构
2. 先识别衣橱中包含的有效品类,仅统计以下 6 类:上装、下装、连衣裙、外套、鞋、配饰:
- 每个存在的品类最多推荐 1 件最合适的单品
- 衣橱中没有的品类,不在结果中输出,无需强制凑满 6 件
- 连衣裙为「一件式」单品,若选择连衣裙则通常不再搭配下装,可搭配外套、鞋、配饰
- 鞋为必备品类(若衣橱中存在),配饰为可选点缀
3. 结合今日运势调整风格:运势评分高可选择出彩、有设计感的搭配;运势一般则选择稳重、舒适的基础款搭配
4. 参考衣服的版型(version)、面料材质(material)、图案纹理(pattern)、颜色(主色/副色)、袖型、衣长等进行搭配,确保搭配和谐
5. 保证所选单品之间颜色、风格协调统一
6. 为每件推荐单品撰写一句简洁的推荐理由
7. 为整套穿搭拟定一个贴合风格与运势的好听标题
## 输出要求
必须严格按以下 JSON 结构输出,不要输出任何多余文字、解释或代码围栏:
{
"outfit_name": "穿搭标题",
"description": "结合今日运势的整体搭配说明",
"style": "穿搭风格,如:简约/复古/街头/甜美等",
"scene": "适合场景,如:通勤/约会/休闲/运动等",
"items": [
{"type": "上装", "clothes_id": 1, "clothes_uuid": "衣橱中该衣物对应的UUID", "reason": "单品推荐理由"}
]
}
补充约束:
- type 字段取值仅限「上装」「下装」「连衣裙」「外套」「鞋」「配饰」,与衣橱实际品类一一对应
- style 表示穿搭风格(如简约/复古/街头/甜美),scene 表示适合场景(如通勤/约会/休闲/运动),两者含义不同,必须分别输出
- items 数组长度由衣橱中存在的品类数量决定,不存在的品类不写入数组
- clothes_id 必须为整数,且必须是衣橱中真实存在的 ID
- clothes_uuid 必须从「用户衣橱」信息中精确复制,且与 clothes_id 一一对应,严禁编造或改动 UUID`,
zodiac, fortune.Overall, fortune.Love, fortune.Work, fortune.Money, fortune.Description, clothesText)
}
// buildRecommendSchema 构造 JSON Schema 要求大模型结构化输出。
func buildRecommendSchema() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"outfit_name": map[string]interface{}{
"type": "string",
"description": "穿搭标题",
},
"description": map[string]interface{}{
"type": "string",
"description": "穿搭整体描述,结合运势说明搭配理由",
},
"style": map[string]interface{}{
"type": "string",
"description": "穿搭风格,如:简约、复古、街头、甜美等",
},
"scene": map[string]interface{}{
"type": "string",
"description": "适合场景,如:通勤、约会、休闲、运动等",
},
"items": map[string]interface{}{
"type": "array",
"description": "推荐的单品列表,每个存在的品类最多一件,不存在的品类不输出",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"type": map[string]interface{}{
"type": "string",
"description": "品类,必须是以下之一:上装、下装、连衣裙、外套、鞋、配饰",
"enum": outfitCategories,
},
"clothes_id": map[string]interface{}{
"type": "integer",
"description": "衣橱中衣物的 ID",
},
"clothes_uuid": map[string]interface{}{
"type": "string",
"description": "衣橱中衣物的 UUID,必须与 clothes_id 对应",
},
"reason": map[string]interface{}{
"type": "string",
"description": "推荐理由,一句话说明为什么选这件",
},
},
"required": []string{"type", "clothes_id", "clothes_uuid", "reason"},
},
},
},
"required": []string{"outfit_name", "description", "style", "scene", "items"},
}
}
// ValidateClothesIDs 校验并纠偏大模型返回的推荐结果:
// 1. 过滤 clothes_id 不在用户衣橱中的项;
// 2. 将 type 规范化为衣物实际的大类(CategoryMain);
// 3. 以衣橱实际数据回填正确的 ClothesUUID(防止模型抄错 UUID);
// 4. 对重复的 clothes_id 去重(保留首次出现)。
func (r *RecommendLLMResponse) ValidateClothesIDs(clothes []mysqlmodel.Clothes) {
clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothes))
for _, c := range clothes {
clothesByID[c.ID] = c
}
filtered := make([]RecommendLLMItem, 0, len(r.Items))
seen := make(map[int64]struct{}, len(r.Items))
for _, item := range r.Items {
c, ok := clothesByID[item.ClothesID]
if !ok {
continue
}
if _, dup := seen[item.ClothesID]; dup {
continue
}
// 以衣橱中实际数据为准,纠偏模型可能返回的 type 与 UUID。
item.Type = c.CategoryMain
item.ClothesUUID = c.ClothesUUID
seen[item.ClothesID] = struct{}{}
filtered = append(filtered, item)
}
r.Items = filtered
}
// GetClothesIDs 获取推荐中所有不重复的 clothes ID。
func (r *RecommendLLMResponse) GetClothesIDs() []int64 {
ids := make([]int64, 0, len(r.Items))
seen := make(map[int64]struct{}, len(r.Items))
for _, item := range r.Items {
if _, ok := seen[item.ClothesID]; ok {
continue
}
seen[item.ClothesID] = struct{}{}
ids = append(ids, item.ClothesID)
}
return ids
}
// FormatClothesInfo 将衣橱衣物列表转换为传给 LLM 的摘要格式。
// 使用「字段名:值」标注,补全穿搭相关字段,便于模型准确理解每个属性。
// 所有字符串字段经 sanitizePromptField 清洗,降低 prompt 注入风险。
func FormatClothesInfo(clothesList []mysqlmodel.Clothes) string {
var sb strings.Builder
for _, c := range clothesList {
sb.WriteString(fmt.Sprintf(
"- ID:%d | UUID:%s | 大类:%s | 小类:%s | 名称:%s | 主色:%s | 副色:%s | 风格:%s | 版型:%s | 材质:%s | 花纹:%s | 厚度:%s | 袖型:%s | 衣长:%s | 领型:%s | 正式度:%s | 场合:%s | 视觉冲击力:%s\n",
c.ID,
sanitizePromptField(c.ClothesUUID), sanitizePromptField(c.CategoryMain), sanitizePromptField(c.CategorySub),
sanitizePromptField(c.Name), sanitizePromptField(c.ColorMain), sanitizePromptField(c.ColorSub),
sanitizePromptField(c.Style), sanitizePromptField(c.Version), sanitizePromptField(c.Material),
sanitizePromptField(c.Pattern), sanitizePromptField(c.Thickness), sanitizePromptField(c.SleeveType),
sanitizePromptField(c.ClothesType), sanitizePromptField(c.NecklineType), sanitizePromptField(c.Formality),
sanitizePromptField(c.Occasions), sanitizePromptField(c.VisualImpact)))
}
return sb.String()
}
// sanitizePromptField 清洗拼入 prompt 的用户可控字段:
// 截断长度、移除换行与控制字符、压缩连续空白,降低 prompt 注入风险。
func sanitizePromptField(s string) string {
const maxLen = 200
if s == "" {
return ""
}
var b strings.Builder
for _, r := range s {
switch {
case r == '\n' || r == '\r' || r == '\t':
b.WriteRune(' ') // 换行/制表符压缩为空格,防止注入换行指令。
case r < 0x20 || r == 0x7F:
continue // 丢弃其它控制字符。
default:
b.WriteRune(r)
}
}
// 压缩连续空白。
out := strings.Join(strings.Fields(b.String()), " ")
runes := []rune(out)
if len(runes) > maxLen {
out = string(runes[:maxLen])
}
return out
}
+170
View File
@@ -0,0 +1,170 @@
package tokenhub
import (
"context"
"log/slog"
"strconv"
"sync"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
"github.com/google/uuid"
)
// fortuneEnsureMu 用于同一实例内串行化当日运势兜底生成,避免并发重复触发 LLM。
var fortuneEnsureMu sync.Mutex
// StartFortuneScheduler 启动后台定时任务:每天 0:00 生成当日运势并入库。
func StartFortuneScheduler() {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("fortune scheduler panic", "recover", r)
}
}()
slog.Info("fortune scheduler started")
client := NewClient()
horoscopeDao := mysqldao.NewHoroscopeDao()
for {
// 每次循环重新计算到下一个午夜的等待时间,避免漂移累积。
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
wait := next.Sub(now)
slog.Info("fortune scheduler: next run",
"at", next.Format("2006-01-02 15:04:05"),
"in", wait.Round(time.Second).String(),
)
time.Sleep(wait)
// 生成 & 入库(带分布式锁,多实例仅一个执行)。
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("fortune generation panic", "recover", r)
}
}()
runFortuneGenerationWithLock(client, horoscopeDao)
}()
}
}()
}
// runFortuneGeneration 调用混元生成运势,写入数据库。
func runFortuneGeneration(client *Client, horoscopeDao *mysqldao.HoroscopeDao) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
ctx, span := global.Tracer.Start(ctx, "scheduler.FortuneGeneration")
defer span.End()
slog.InfoContext(ctx, "generating daily fortunes...")
result, err := client.GenerateDailyFortunes(ctx)
if err != nil {
span.RecordError(err)
slog.ErrorContext(ctx, "generate fortunes failed", "error", err)
return
}
// result.Date 形如 "2006-01-02",转换为 Horoscope.StatDate 的 YYYYMMDD 整数。
date, err := time.Parse("2006-01-02", result.Date)
if err != nil {
slog.ErrorContext(ctx, "parse fortune date failed", "date", result.Date, "error", err)
return
}
statDate, err := strconv.ParseInt(date.Format("20060102"), 10, 64)
if err != nil {
slog.ErrorContext(ctx, "parse stat date failed", "date", result.Date, "error", err)
return
}
successCount := 0
for _, f := range result.Fortunes {
horoscope := f.ToHoroscope(statDate)
if err := horoscopeDao.HoroscopeCreate(ctx, horoscope); err != nil {
slog.ErrorContext(ctx, "save horoscope failed", "zodiac", f.Zodiac, "error", err)
} else {
successCount++
}
}
slog.InfoContext(ctx, "daily fortune generation completed",
"total", len(result.Fortunes),
"saved", successCount,
)
}
// runFortuneGenerationWithLock 带 Redis 分布式锁执行当日运势生成:
// 多实例部署时仅一个实例执行;锁释放校验持有者 value,防止超时误删其它实例的锁。
func runFortuneGenerationWithLock(client *Client, horoscopeDao *mysqldao.HoroscopeDao) {
today := time.Now().Format("2006-01-02")
lockKey := "ztcloset:lock:fortune:daily:" + today
lockVal := uuid.NewString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 5*time.Minute)
if lerr != nil || !ok {
slog.Info("fortune generation already in progress, skip", "date", today)
return
}
defer redisdao.Unlock(context.Background(), lockKey, lockVal)
runFortuneGeneration(client, horoscopeDao)
}
// EnsureTodayFortunes 检查今日运势数据是否完整,不完整则自动生成。
// 该函数异步执行,适用于服务器启动时兜底。
func EnsureTodayFortunes() {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("EnsureTodayFortunes panic", "recover", r)
}
}()
// 进程内防重:已有兜底生成在进行中则直接跳过。
if !fortuneEnsureMu.TryLock() {
return
}
defer fortuneEnsureMu.Unlock()
client := NewClient()
horoscopeDao := mysqldao.NewHoroscopeDao()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
now := time.Now()
today := now.Format("2006-01-02")
statDate, err := strconv.ParseInt(now.Format("20060102"), 10, 64)
if err != nil {
slog.Error("EnsureTodayFortunes: parse stat date failed", "error", err)
return
}
horoscopes, err := horoscopeDao.HoroscopeGetByDate(ctx, statDate)
if err != nil {
slog.Warn("EnsureTodayFortunes: query today fortunes failed, will try to generate", "error", err, "date", today)
}
if len(horoscopes) >= 12 {
slog.Info("EnsureTodayFortunes: today fortunes already complete, skip", "date", today, "count", len(horoscopes))
return
}
slog.Info("EnsureTodayFortunes: today fortunes incomplete, generating...",
"date", today,
"existing", len(horoscopes),
"needed", 12,
)
runFortuneGenerationWithLock(client, horoscopeDao)
}()
}