commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
169
go_backend/service/cache_service.go
Normal file
169
go_backend/service/cache_service.go
Normal 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
62
go_backend/service/feedback_service.go
Normal file
62
go_backend/service/feedback_service.go
Normal 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
|
||||
}
|
||||
@@ -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失败: %w,Cookie内容: %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失败: %w,Cookie内容: %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
|
||||
}
|
||||
|
||||
|
||||
264
go_backend/service/sms_service.go
Normal file
264
go_backend/service/sms_service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user