This commit is contained in:
sjk
2026-01-06 19:36:42 +08:00
parent 15b579d64a
commit 19942144fb
261 changed files with 24034 additions and 5477 deletions

View File

@@ -6,6 +6,7 @@ import (
"ai_xhs/models"
"ai_xhs/utils"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -188,9 +189,9 @@ func (s *AuthService) WechatLogin(code string, phone string, phoneCode string) (
return fmt.Errorf("保存微信绑定信息失败: %v", err)
}
// 2. 检查是否已存在作者记录(通过手机号和企业ID
// 2. 检查是否已存在作者记录(通过 created_user_id 和企业ID
var existingAuthor models.Author
result := tx.Where("phone = ? AND enterprise_id = ?", employee.Phone, employee.EnterpriseID).First(&existingAuthor)
result := tx.Where("created_user_id = ? AND enterprise_id = ?", employee.ID, employee.EnterpriseID).First(&existingAuthor)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
// 作者记录不存在,创建新记录
@@ -201,7 +202,7 @@ func (s *AuthService) WechatLogin(code string, phone string, phoneCode string) (
AuthorName: employee.RealName,
Department: employee.Department,
Status: "active",
Channel: 3, // 3=weixin (微信小程序
Channel: 1, // 1=小红书(默认渠道
}
// 如果真实姓名为空,使用用户名
@@ -213,7 +214,7 @@ func (s *AuthService) WechatLogin(code string, phone string, phoneCode string) (
return fmt.Errorf("创建作者记录失败: %v", err)
}
log.Printf("[微信登录] 创建作者记录成功: ID=%d, Name=%s", author.ID, author.AuthorName)
log.Printf("[微信登录] 创建作者记录成功: ID=%d, Name=%s, Channel=1(小红书)", author.ID, author.AuthorName)
} else if result.Error != nil {
// 其他数据库错误
return fmt.Errorf("检查作者记录失败: %v", result.Error)
@@ -237,6 +238,15 @@ func (s *AuthService) WechatLogin(code string, phone string, phoneCode string) (
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 5. 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[微信登录] 存储token到Redis失败: %v", err)
// 不阻断登录流程,但记录错误
} else {
log.Printf("[微信登录] 用户%d token已存入Redis", employee.ID)
}
return token, &employee, nil
}
@@ -256,6 +266,12 @@ func (s *AuthService) PhoneLogin(phone string) (string, *models.User, error) {
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[手机号登录] 存储token到Redis失败: %v", err)
}
return token, &employee, nil
}
@@ -274,5 +290,154 @@ func (s *AuthService) loginByEmployeeID(employeeID int) (string, *models.User, e
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[ID登录] 存储token到Redis失败: %v", err)
}
return token, &employee, nil
}
// PhonePasswordLogin 手机号密码登录
func (s *AuthService) PhonePasswordLogin(phone string, password string) (string, *models.User, error) {
if phone == "" || password == "" {
return "", nil, errors.New("手机号和密码不能为空")
}
var employee models.User
// 查找员工
result := database.DB.Where("phone = ? AND status = ?", phone, "active").First(&employee)
if result.Error != nil {
return "", nil, errors.New("手机号或密码错误")
}
// 验证密码
if !utils.VerifyPassword(password, employee.Password) {
return "", nil, errors.New("手机号或密码错误")
}
// 生成token
token, err := utils.GenerateToken(employee.ID)
if err != nil {
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[密码登录] 存储token到Redis失败: %v", err)
}
return token, &employee, nil
}
// CheckPhoneExists 检查手机号是否存在于user表中
func (s *AuthService) CheckPhoneExists(phone string) error {
var count int64
result := database.DB.Model(&models.User{}).Where("phone = ? AND status = ?", phone, "active").Count(&count)
if result.Error != nil {
return fmt.Errorf("查询用户信息失败: %v", result.Error)
}
if count == 0 {
return errors.New("手机号未注册,请联系管理员添加")
}
return nil
}
// XHSPhoneCodeLogin 小红书手机号验证码登录
func (s *AuthService) XHSPhoneCodeLogin(phone string, code string) (string, *models.User, error) {
if phone == "" || code == "" {
return "", nil, errors.New("手机号和验证码不能为空")
}
// 调用短信服务验证验证码
smsService := GetSmsService()
if err := smsService.VerifyCode(phone, code); err != nil {
return "", nil, err
}
var employee models.User
// 查找员工
result := database.DB.Where("phone = ? AND status = ?", phone, "active").First(&employee)
if result.Error != nil {
// 用户不存在,不允许登录
return "", nil, errors.New("手机号未注册,请联系管理员添加")
}
// 生成token
token, err := utils.GenerateToken(employee.ID)
if err != nil {
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[验证码登录] 存储token到Redis失败: %v", err)
}
return token, &employee, nil
}
// createNewUserFromPhone 从手机号创建新用户
func (s *AuthService) createNewUserFromPhone(phone string) (string, *models.User, error) {
// 使用事务创建用户和作者记录
var employee models.User
err := database.DB.Transaction(func(tx *gorm.DB) error {
// 1. 创建用户记录
employee = models.User{
Phone: phone,
Username: phone, // 默认用户名为手机号
Role: "user",
Status: "active",
EnterpriseID: 1, // 默认企业ID可根据实际调整
EnterpriseName: "默认企业", // 默认企业名称
}
if err := tx.Create(&employee).Error; err != nil {
return fmt.Errorf("创建用户失败: %v", err)
}
// 2. 创建作者记录
author := models.Author{
EnterpriseID: employee.EnterpriseID,
CreatedUserID: employee.ID,
Phone: employee.Phone,
AuthorName: employee.Username,
Department: "",
Status: "active",
Channel: 1, // 1=小红书
}
if err := tx.Create(&author).Error; err != nil {
return fmt.Errorf("创建作者记录失败: %v", err)
}
log.Printf("[手机号登录] 创建新用户成功: Phone=%s, UserID=%d, AuthorID=%d", phone, employee.ID, author.ID)
return nil
})
if err != nil {
return "", nil, err
}
// 生成token
token, err := utils.GenerateToken(employee.ID)
if err != nil {
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[新用户登录] 存储token到Redis失败: %v", err)
}
return token, &employee, nil
}

View File

@@ -0,0 +1,169 @@
package service
import (
"ai_xhs/database"
"ai_xhs/utils"
"context"
"fmt"
"log"
"time"
)
// CacheService 缓存管理服务 - 统一管理缓存键和清除策略
type CacheService struct{}
func NewCacheService() *CacheService {
return &CacheService{}
}
// 缓存键前缀常量
const (
CacheKeyPrefixUser = "user:profile:"
CacheKeyPrefixAuthor = "author:user:"
CacheKeyPrefixXHSStatus = "xhs:status:"
CacheKeyPrefixProducts = "products:enterprise:"
CacheKeyPrefixRateLimit = "rate:sms:"
CacheKeyPrefixLock = "lock:"
)
// GetUserCacheKey 获取用户缓存键
func (s *CacheService) GetUserCacheKey(userID int) string {
return fmt.Sprintf("%s%d", CacheKeyPrefixUser, userID)
}
// GetAuthorCacheKey 获取作者缓存键
func (s *CacheService) GetAuthorCacheKey(userID int) string {
return fmt.Sprintf("%s%d", CacheKeyPrefixAuthor, userID)
}
// GetXHSStatusCacheKey 获取小红书状态缓存键
func (s *CacheService) GetXHSStatusCacheKey(userID int) string {
return fmt.Sprintf("%s%d", CacheKeyPrefixXHSStatus, userID)
}
// GetProductsCacheKey 获取产品列表缓存键
func (s *CacheService) GetProductsCacheKey(enterpriseID, page, pageSize int) string {
return fmt.Sprintf("%spage:%d:size:%d", CacheKeyPrefixProducts+fmt.Sprintf("%d:", enterpriseID), page, pageSize)
}
// GetRateLimitKey 获取限流键
func (s *CacheService) GetRateLimitKey(phone string) string {
return fmt.Sprintf("%s%s", CacheKeyPrefixRateLimit, phone)
}
// GetLockKey 获取分布式锁键
func (s *CacheService) GetLockKey(resource string) string {
return fmt.Sprintf("%s%s", CacheKeyPrefixLock, resource)
}
// ClearUserRelatedCache 清除用户相关的所有缓存
func (s *CacheService) ClearUserRelatedCache(ctx context.Context, userID int) error {
keys := []string{
s.GetUserCacheKey(userID),
s.GetAuthorCacheKey(userID),
s.GetXHSStatusCacheKey(userID),
}
if err := utils.DelCache(ctx, keys...); err != nil {
log.Printf("清除用户缓存失败 (userID=%d): %v", userID, err)
return err
}
log.Printf("已清除用户相关缓存: userID=%d", userID)
return nil
}
// ClearProductsCache 清除企业的产品列表缓存
func (s *CacheService) ClearProductsCache(ctx context.Context, enterpriseID int) error {
// 使用模糊匹配删除所有分页缓存
pattern := fmt.Sprintf("%s%d:*", CacheKeyPrefixProducts, enterpriseID)
// 注意: 这需要扫描所有键,生产环境建议记录所有已创建的缓存键
// 这里简化处理,实际应该维护一个产品缓存键集合
log.Printf("需要清除产品缓存: enterpriseID=%d, pattern=%s", enterpriseID, pattern)
log.Printf("建议: 在产品更新时调用此方法")
// 简化版: 只清除前几页的缓存
for page := 1; page <= 10; page++ {
for _, pageSize := range []int{10, 20, 50} {
key := s.GetProductsCacheKey(enterpriseID, page, pageSize)
utils.DelCache(ctx, key)
}
}
return nil
}
// AcquireLock 获取分布式锁
func (s *CacheService) AcquireLock(ctx context.Context, resource string, ttl time.Duration) (bool, error) {
lockKey := s.GetLockKey(resource)
return utils.SetCacheNX(ctx, lockKey, "locked", ttl)
}
// ReleaseLock 释放分布式锁
func (s *CacheService) ReleaseLock(ctx context.Context, resource string) error {
lockKey := s.GetLockKey(resource)
return utils.DelCache(ctx, lockKey)
}
// WithLock 使用分布式锁执行函数
func (s *CacheService) WithLock(ctx context.Context, resource string, ttl time.Duration, fn func() error) error {
// 尝试获取锁
log.Printf("[分布式锁] 尝试获取锁: %s (TTL: %v)", resource, ttl)
acquired, err := s.AcquireLock(ctx, resource, ttl)
if err != nil {
log.Printf("[分布式锁] 获取锁失败: %s, 错误: %v", resource, err)
return fmt.Errorf("获取锁失败: %w", err)
}
if !acquired {
log.Printf("[分布式锁] 锁已被占用: %s", resource)
// 检查锁的剩余时间
lockKey := s.GetLockKey(resource)
ttl, _ := database.RDB.TTL(ctx, lockKey).Result()
return fmt.Errorf("资源被锁定,请稍后重试(剩余时间: %v", ttl)
}
log.Printf("[分布式锁] 成功获取锁: %s", resource)
// 确保释放锁
defer func() {
if err := s.ReleaseLock(ctx, resource); err != nil {
log.Printf("[分布式锁] 释放锁失败 (resource=%s): %v", resource, err)
} else {
log.Printf("[分布式锁] 成功释放锁: %s", resource)
}
}()
// 执行函数
log.Printf("[分布式锁] 开始执行受保护的函数: %s", resource)
return fn()
}
// SetCacheWithNullProtection 设置缓存(带空值保护,防止缓存穿透)
func (s *CacheService) SetCacheWithNullProtection(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
if value == nil {
// 缓存空值,但使用较短的过期时间(1分钟)
return utils.SetCache(ctx, key, "NULL", 1*time.Minute)
}
return utils.SetCache(ctx, key, value, ttl)
}
// GetCacheWithNullCheck 获取缓存(检查空值标记)
func (s *CacheService) GetCacheWithNullCheck(ctx context.Context, key string, dest interface{}) (bool, error) {
var tempValue interface{}
err := utils.GetCache(ctx, key, &tempValue)
if err != nil {
// 缓存不存在
return false, err
}
// 检查是否是空值标记
if strValue, ok := tempValue.(string); ok && strValue == "NULL" {
return true, fmt.Errorf("cached null value")
}
// 正常获取缓存
return true, utils.GetCache(ctx, key, dest)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
package service
import (
"ai_xhs/database"
"ai_xhs/models"
)
// FeedbackService 反馈服务
type FeedbackService struct{}
// NewFeedbackService 创建反馈服务
func NewFeedbackService() *FeedbackService {
return &FeedbackService{}
}
// CreateFeedback 创建反馈
func (fs *FeedbackService) CreateFeedback(feedback *models.Feedback) error {
return database.DB.Create(feedback).Error
}
// GetFeedbackList 获取反馈列表
func (fs *FeedbackService) GetFeedbackList(userID, page, pageSize int, feedbackType, status string) ([]models.Feedback, int64, error) {
var feedbacks []models.Feedback
var total int64
query := database.DB.Model(&models.Feedback{}).Where("created_user_id = ?", userID)
// 筛选条件
if feedbackType != "" {
query = query.Where("feedback_type = ?", feedbackType)
}
if status != "" {
query = query.Where("status = ?", status)
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&feedbacks).Error; err != nil {
return nil, 0, err
}
return feedbacks, total, nil
}
// GetFeedbackByID 根据ID获取反馈
func (fs *FeedbackService) GetFeedbackByID(id int) (*models.Feedback, error) {
var feedback models.Feedback
if err := database.DB.First(&feedback, id).Error; err != nil {
return nil, err
}
return &feedback, nil
}
// UpdateFeedbackStatus 更新反馈状态(管理员使用)
func (fs *FeedbackService) UpdateFeedbackStatus(id int, status string) error {
return database.DB.Model(&models.Feedback{}).Where("id = ?", id).Update("status", status).Error
}

View File

@@ -13,8 +13,6 @@ import (
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
@@ -332,7 +330,7 @@ func (s *SchedulerService) AutoPublishArticles() {
len(articles), successCount, failCount, duration)
}
// publishArticle 发布单篇文案
// publishArticle 发布单篇文案使用FastAPI服务
func (s *SchedulerService) publishArticle(article models.Article) error {
// 1. 获取用户信息(发布用户)
var user models.User
@@ -347,9 +345,22 @@ func (s *SchedulerService) publishArticle(article models.Article) error {
}
}
// 2. 检查用户是否绑定了小红书
if user.IsBoundXHS != 1 || user.XHSCookie == "" {
return errors.New("用户未绑定小红书账号或Cookie已失效")
// 2. 检查用户是否绑定了小红书并获取author记录
if user.IsBoundXHS != 1 {
return errors.New("用户未绑定小红书账号")
}
// 查询对应的 author 记录获取Cookie
var author models.Author
if err := database.DB.Where(
"phone = ? AND enterprise_id = ? AND channel = 1 AND status = 'active'",
user.Phone, user.EnterpriseID,
).First(&author).Error; err != nil {
return fmt.Errorf("未找到有效的小红书作者记录: %w", err)
}
if author.XHSCookie == "" {
return errors.New("小红书Cookie已失效")
}
// 3. 获取文章图片
@@ -378,142 +389,130 @@ func (s *SchedulerService) publishArticle(article models.Article) error {
}
}
// 6. 解析Cookie数据库中存储的是JSON字符串
var cookies interface{}
if err := json.Unmarshal([]byte(user.XHSCookie), &cookies); err != nil {
return fmt.Errorf("解析Cookie失败: %wCookie内容: %s", err, user.XHSCookie)
}
// 6. 准备发布数据优先使用storage_state文件其次使用login_state
var cookiesData interface{}
var loginStateData map[string]interface{}
var useStorageStateMode bool
// 7. 构造发布配置
publishConfig := map[string]interface{}{
"cookies": cookies, // 解析后的Cookie对象或数组
"title": article.Title,
"content": article.Content,
"images": imageURLs,
"tags": tags,
}
// 检查storage_state文件是否存在根据手机号查找
storageStateFile := fmt.Sprintf("../backend/storage_states/xhs_%s.json", author.XHSPhone)
if _, err := os.Stat(storageStateFile); err == nil {
log.Printf("[调度器] 检测到storage_state文件: %s", storageStateFile)
useStorageStateMode = true
} else {
log.Printf("[调度器] storage_state文件不存在使用login_state或cookies模式")
useStorageStateMode = false
// 决定本次发布使用的代理
proxyToUse := config.AppConfig.Scheduler.Proxy
if proxyToUse == "" && config.AppConfig.Scheduler.ProxyFetchURL != "" {
if dynamicProxy, err := fetchProxyFromPool(); err != nil {
log.Printf("[代理池] 获取代理失败: %v", err)
} else if dynamicProxy != "" {
proxyToUse = dynamicProxy
log.Printf("[代理池] 使用动态代理: %s", proxyToUse)
}
}
// 注入代理和User-Agent如果有配置
if proxyToUse != "" {
publishConfig["proxy"] = proxyToUse
}
if ua := config.AppConfig.Scheduler.UserAgent; ua != "" {
publishConfig["user_agent"] = ua
}
// 8. 保存临时配置文件
tempDir := filepath.Join("..", "backend", "temp")
os.MkdirAll(tempDir, 0755)
configFile := filepath.Join(tempDir, fmt.Sprintf("publish_%d_%d.json", article.ID, time.Now().Unix()))
configData, err := json.MarshalIndent(publishConfig, "", " ")
if err != nil {
return fmt.Errorf("生成配置文件失败: %w", err)
}
if err := os.WriteFile(configFile, configData, 0644); err != nil {
return fmt.Errorf("保存配置文件失败: %w", err)
}
defer os.Remove(configFile) // 发布完成后删除临时文件
// 9. 调用Python发布脚本
backendDir := filepath.Join("..", "backend")
pythonScript := filepath.Join(backendDir, "xhs_publish.py")
pythonCmd := getPythonPath(backendDir)
cmd := exec.Command(pythonCmd, pythonScript, "--config", configFile)
cmd.Dir = backendDir
// 设置超时
if s.publishTimeout > 0 {
timer := time.AfterFunc(time.Duration(s.publishTimeout)*time.Second, func() {
cmd.Process.Kill()
})
defer timer.Stop()
}
// 捕获输出
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// 执行命令
err = cmd.Run()
// 打印Python脚本日志
if stderr.Len() > 0 {
log.Printf("[Python日志-发布文案%d] %s", article.ID, stderr.String())
}
if err != nil {
// 更新文章状态为failed
s.updateArticleStatus(article.ID, "failed", fmt.Sprintf("发布失败: %v", err))
return fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
}
// 10. 解析发布结果
// 注意Python脚本可能输出日志到stdout需要提取最后一行JSON
outputStr := stdout.String()
// 查找最后一个完整的JSON对象
var result map[string]interface{}
found := false
// 尝试从最后一行开始解析JSON
lines := strings.Split(strings.TrimSpace(outputStr), "\n")
// 从后往前找第一个有效的JSON
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
// 尝试解析为JSON必须以{开头)
if strings.HasPrefix(line, "{") {
if err := json.Unmarshal([]byte(line), &result); err == nil {
found = true
log.Printf("成功解析JSON结果(第%d行): %s", i+1, line)
break
// 尝试解析为JSON对象
if err := json.Unmarshal([]byte(author.XHSCookie), &loginStateData); err == nil {
// 检查是否是login_state格式包含cookies字段
if _, ok := loginStateData["cookies"]; ok {
log.Printf("[调度器] 检测到login_state格式将使用完整登录状态")
cookiesData = loginStateData // 使用完整的login_state
} else {
// 可能是cookies数组
log.Printf("[调度器] 检测到纯cookies格式")
cookiesData = loginStateData
}
} else {
return fmt.Errorf("解析Cookie失败: %wCookie内容: %s", err, author.XHSCookie[:100])
}
}
if !found {
errMsg := "Python脚本未返回有效JSON结果"
s.updateArticleStatus(article.ID, "failed", errMsg)
log.Printf("完整输出内容:\n%s", outputStr)
if stderr.Len() > 0 {
log.Printf("错误输出:\n%s", stderr.String())
// 7. 调用FastAPI服务使用浏览器池+预热)
fastAPIURL := config.AppConfig.XHS.PythonServiceURL
if fastAPIURL == "" {
fastAPIURL = "http://localhost:8000" // 默认地址
}
publishEndpoint := fastAPIURL + "/api/xhs/publish-with-cookies"
// 构造请求体
// 优先级storage_state文件 > login_state > cookies
var fullRequest map[string]interface{}
if useStorageStateMode {
// 模式1使用storage_state文件通过手机号查找
fullRequest = map[string]interface{}{
"phone": author.XHSPhone, // 传递手机号Python后端会根据手机号查找文件
"title": article.Title,
"content": article.Content,
"images": imageURLs,
"topics": tags,
}
log.Printf("[调度器] 使用storage_state模式发布手机号: %s", author.XHSPhone)
} else if loginState, ok := cookiesData.(map[string]interface{}); ok {
if _, hasLoginStateStructure := loginState["cookies"]; hasLoginStateStructure {
// 模式2完整的login_state格式
fullRequest = map[string]interface{}{
"login_state": loginState,
"title": article.Title,
"content": article.Content,
"images": imageURLs,
"topics": tags,
}
log.Printf("[调度器] 使用login_state模式发布")
} else {
// 模式3纺cookies格式
fullRequest = map[string]interface{}{
"cookies": loginState,
"title": article.Title,
"content": article.Content,
"images": imageURLs,
"topics": tags,
}
log.Printf("[调度器] 使用cookies模式发布")
}
} else {
// 兜底:直接发送
fullRequest = map[string]interface{}{
"cookies": cookiesData,
"title": article.Title,
"content": article.Content,
"images": imageURLs,
"topics": tags,
}
return fmt.Errorf("%s, output: %s", errMsg, outputStr)
}
// 11. 检查发布是否成功
success, ok := result["success"].(bool)
if !ok || !success {
requestBody, err := json.Marshal(fullRequest)
if err != nil {
return fmt.Errorf("构造请求数据失败: %w", err)
}
// 发送HTTP请求
timeout := time.Duration(s.publishTimeout) * time.Second
if s.publishTimeout <= 0 {
timeout = 120 * time.Second // 默认120秒超时
}
client := &http.Client{Timeout: timeout}
resp, err := client.Post(publishEndpoint, "application/json", bytes.NewBuffer(requestBody))
if err != nil {
s.updateArticleStatus(article.ID, "failed", fmt.Sprintf("调用FastAPI服务失败: %v", err))
return fmt.Errorf("调用FastAPI服务失败: %w", err)
}
defer resp.Body.Close()
// 9. 解析响应
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
s.updateArticleStatus(article.ID, "failed", fmt.Sprintf("解析FastAPI响应失败: %v", err))
return fmt.Errorf("解析FastAPI响应失败: %w", err)
}
// 10. 检查发布是否成功
code, ok := result["code"].(float64)
if !ok || code != 0 {
errMsg := "未知错误"
if errStr, ok := result["error"].(string); ok {
errMsg = errStr
if msg, ok := result["message"].(string); ok {
errMsg = msg
}
s.updateArticleStatus(article.ID, "failed", errMsg)
return fmt.Errorf("发布失败: %s", errMsg)
}
// 12. 更新文章状态为published
// 11. 更新文章状态为published
s.updateArticleStatus(article.ID, "published", "发布成功")
log.Printf("[使用FastAPI] 文章 %d 发布成功,享受浏览器池+预热加速", article.ID)
return nil
}

View File

@@ -0,0 +1,264 @@
package service
import (
"ai_xhs/config"
"crypto/rand"
"errors"
"fmt"
"log"
"math/big"
"sync"
"time"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v4/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
// SmsService 短信服务
type SmsService struct {
client *dysmsapi20170525.Client
signName string
templateCode string
codeCache map[string]*VerificationCode
cacheMutex sync.RWMutex
alertPhone string // 宕机通知手机号
}
// VerificationCode 验证码缓存
type VerificationCode struct {
Code string
ExpireTime time.Time
SentAt time.Time
}
var (
smsServiceInstance *SmsService
smsServiceOnce sync.Once
)
// GetSmsService 获取短信服务单例
func GetSmsService() *SmsService {
smsServiceOnce.Do(func() {
smsServiceInstance = NewSmsService()
})
return smsServiceInstance
}
// NewSmsService 创建短信服务
func NewSmsService() *SmsService {
// 从配置读取阿里云短信配置
accessKeyId := config.AppConfig.AliSms.AccessKeyID
accessKeySecret := config.AppConfig.AliSms.AccessKeySecret
signName := config.AppConfig.AliSms.SignName
templateCode := config.AppConfig.AliSms.TemplateCode
if accessKeyId == "" || accessKeySecret == "" {
log.Printf("[短信服务] 警告: 阿里云短信配置未设置,短信功能将不可用")
return &SmsService{
signName: signName,
templateCode: templateCode,
codeCache: make(map[string]*VerificationCode),
}
}
// 创建阿里云短信客户端
apiConfig := &openapi.Config{
AccessKeyId: tea.String(accessKeyId),
AccessKeySecret: tea.String(accessKeySecret),
}
apiConfig.Endpoint = tea.String("dysmsapi.aliyuncs.com")
client, err := dysmsapi20170525.NewClient(apiConfig)
if err != nil {
log.Printf("[短信服务] 创建阿里云客户端失败: %v", err)
return &SmsService{
signName: signName,
templateCode: templateCode,
codeCache: make(map[string]*VerificationCode),
}
}
log.Printf("[短信服务] 阿里云短信服务初始化成功")
return &SmsService{
client: client,
signName: signName,
templateCode: templateCode,
codeCache: make(map[string]*VerificationCode),
}
}
// generateCode 生成随机6位数字验证码
func (s *SmsService) generateCode() string {
code := ""
for i := 0; i < 6; i++ {
n, _ := rand.Int(rand.Reader, big.NewInt(10))
code += fmt.Sprintf("%d", n.Int64())
}
return code
}
// SendVerificationCode 发送验证码
func (s *SmsService) SendVerificationCode(phone string) (string, error) {
if s.client == nil {
return "", errors.New("短信服务未配置")
}
// 生成验证码
code := s.generateCode()
log.Printf("[短信服务] 正在发送验证码到 %s验证码: %s", phone, code)
// 构建短信请求
sendSmsRequest := &dysmsapi20170525.SendSmsRequest{
PhoneNumbers: tea.String(phone),
SignName: tea.String(s.signName),
TemplateCode: tea.String(s.templateCode),
TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)),
}
runtime := &util.RuntimeOptions{}
// 发送短信
resp, err := s.client.SendSmsWithOptions(sendSmsRequest, runtime)
if err != nil {
log.Printf("[短信服务] 发送短信失败: %v", err)
return "", fmt.Errorf("发送短信失败: %v", err)
}
// 检查返回结果
if resp.Body.Code == nil || *resp.Body.Code != "OK" {
errMsg := "未知错误"
if resp.Body.Message != nil {
errMsg = *resp.Body.Message
}
log.Printf("[短信服务] 短信发送失败: %s", errMsg)
return "", fmt.Errorf("短信发送失败: %s", errMsg)
}
// 缓存验证码
s.cacheMutex.Lock()
s.codeCache[phone] = &VerificationCode{
Code: code,
ExpireTime: time.Now().Add(5 * time.Minute), // 5分钟过期
SentAt: time.Now(),
}
s.cacheMutex.Unlock()
log.Printf("[短信服务] 验证码发送成功,手机号: %s", phone)
return code, nil
}
// VerifyCode 验证验证码
func (s *SmsService) VerifyCode(phone, code string) error {
s.cacheMutex.RLock()
cached, exists := s.codeCache[phone]
s.cacheMutex.RUnlock()
if !exists {
return errors.New("验证码未发送或已过期,请重新获取")
}
// 检查是否过期
if time.Now().After(cached.ExpireTime) {
s.cacheMutex.Lock()
delete(s.codeCache, phone)
s.cacheMutex.Unlock()
return errors.New("验证码已过期,请重新获取")
}
// 验证码匹配
if code != cached.Code {
return errors.New("验证码错误,请重新输入")
}
// 验证成功后删除验证码(一次性使用)
s.cacheMutex.Lock()
delete(s.codeCache, phone)
s.cacheMutex.Unlock()
log.Printf("[短信服务] 验证码验证成功,手机号: %s", phone)
return nil
}
// CleanupExpiredCodes 清理过期的验证码(定时任务调用)
func (s *SmsService) CleanupExpiredCodes() {
s.cacheMutex.Lock()
defer s.cacheMutex.Unlock()
now := time.Now()
expiredPhones := []string{}
for phone, cached := range s.codeCache {
if now.After(cached.ExpireTime) {
expiredPhones = append(expiredPhones, phone)
}
}
for _, phone := range expiredPhones {
delete(s.codeCache, phone)
}
if len(expiredPhones) > 0 {
log.Printf("[短信服务] 已清理 %d 个过期验证码", len(expiredPhones))
}
}
// StartCleanupTask 启动清理过期验证码的定时任务
func (s *SmsService) StartCleanupTask() {
ticker := time.NewTicker(1 * time.Minute) // 每分钟清理一次
go func() {
for range ticker.C {
s.CleanupExpiredCodes()
}
}()
log.Printf("[短信服务] 验证码清理任务已启动")
}
// SendServiceDownAlert 发送服务宕机通知短信
// 向指定手机号发送验证码为11111的通知短信
func (s *SmsService) SendServiceDownAlert(phone string, serviceName string) error {
if s.client == nil {
return errors.New("短信服务未配置")
}
// 固定验证码为11111作为宕机通知标识
alertCode := "11111"
log.Printf("[短信服务] 发送服务宕机通知到 %s服务: %s", phone, serviceName)
// 构建短信请求
sendSmsRequest := &dysmsapi20170525.SendSmsRequest{
PhoneNumbers: tea.String(phone),
SignName: tea.String(s.signName),
TemplateCode: tea.String(s.templateCode),
TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, alertCode)),
}
runtime := &util.RuntimeOptions{}
// 发送短信
resp, err := s.client.SendSmsWithOptions(sendSmsRequest, runtime)
if err != nil {
log.Printf("[短信服务] 发送宕机通知失败: %v", err)
return fmt.Errorf("发送宕机通知失败: %v", err)
}
// 检查返回结果
if resp.Body.Code == nil || *resp.Body.Code != "OK" {
errMsg := "未知错误"
if resp.Body.Message != nil {
errMsg = *resp.Body.Message
}
log.Printf("[短信服务] 宕机通知发送失败: %s", errMsg)
return fmt.Errorf("宕机通知发送失败: %s", errMsg)
}
log.Printf("[短信服务] 服务宕机通知发送成功,手机号: %s通知码: %s", phone, alertCode)
return nil
}