2745 lines
86 KiB
Go
2745 lines
86 KiB
Go
package service
|
||
|
||
import (
|
||
"ai_xhs/config"
|
||
"ai_xhs/database"
|
||
"ai_xhs/models"
|
||
"ai_xhs/utils"
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type EmployeeService struct{}
|
||
|
||
type XHSCookieVerifyResult struct {
|
||
LoggedIn bool
|
||
CookieExpired bool
|
||
}
|
||
|
||
// SendXHSCode 发送小红书验证码(调用Python HTTP服务,增加限流控制)
|
||
func (s *EmployeeService) SendXHSCode(phone string, employeeID int) (map[string]interface{}, error) {
|
||
ctx := context.Background()
|
||
|
||
// 预检查:验证该手机号是否已被其他用户绑定
|
||
var conflictAuthor models.Author
|
||
err := database.DB.Where(
|
||
"xhs_phone = ? AND status = 'active' AND created_user_id != ?",
|
||
phone, employeeID,
|
||
).First(&conflictAuthor).Error
|
||
|
||
if err == nil {
|
||
// 找到了其他用户的绑定记录
|
||
log.Printf("发送验证码 - 用户%d - 失败: 手机号%s已被用户%d绑定",
|
||
employeeID, phone, conflictAuthor.CreatedUserID)
|
||
return nil, errors.New("该手机号已被其他用户绑定")
|
||
} else if err != gorm.ErrRecordNotFound {
|
||
// 数据库查询异常
|
||
log.Printf("发送验证码 - 用户%d - 检查手机号失败: %v", employeeID, err)
|
||
return nil, fmt.Errorf("检查手机号失败: %w", err)
|
||
}
|
||
// err == gorm.ErrRecordNotFound 表示该手机号未被绑定,可以继续
|
||
|
||
// 1. 限流检查: 1分钟内同一手机号只能发送一次
|
||
rateLimitKey := fmt.Sprintf("rate:sms:%s", phone)
|
||
exists, err := utils.ExistsCache(ctx, rateLimitKey)
|
||
if err == nil && exists {
|
||
return nil, errors.New("验证码发送过于频繁,请稍后再试")
|
||
}
|
||
|
||
// 从配置获取Python服务地址
|
||
pythonServiceURL := config.AppConfig.XHS.PythonServiceURL
|
||
if pythonServiceURL == "" {
|
||
pythonServiceURL = "http://localhost:8000"
|
||
}
|
||
|
||
// 从Dingzhi构造HTTP请求
|
||
url := fmt.Sprintf("%s/api/xhs/send-code", pythonServiceURL)
|
||
requestData := map[string]string{
|
||
"phone": phone,
|
||
"country_code": "+86",
|
||
}
|
||
|
||
jsonData, err := json.Marshal(requestData)
|
||
if err != nil {
|
||
log.Printf("[发送验证码] 序列化请求数据失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
log.Printf("[发送验证码] 调用Python HTTP服务: %s, 请求参数: phone=%s", url, phone)
|
||
startTime := time.Now()
|
||
|
||
// 发送HTTP POST请求,增加超时控制(60秒)
|
||
client := &http.Client{
|
||
Timeout: 60 * time.Second, // 设置60秒超时
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
log.Printf("[发送验证码] 创建请求失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
log.Printf("[发送验证码] 调用Python服务失败: %v", err)
|
||
// 判断是否是超时错误
|
||
if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline exceeded") {
|
||
return nil, errors.New("请求超时,请稍后重试")
|
||
}
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 读取响应
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
log.Printf("[发送验证码] 读取响应失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
log.Printf("[发送验证码] Python服务响应: 状态码=%d, 耗时=%.2fs", resp.StatusCode, time.Since(startTime).Seconds())
|
||
|
||
// 解析响应(FastAPI返回格式: {code, message, data})
|
||
var apiResponse struct {
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
Data map[string]interface{} `json:"data"`
|
||
}
|
||
|
||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||
log.Printf("[发送验证码] 解析Python响应失败: %v, body: %s", err, string(body))
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
log.Printf("[Python响应] code=%d, message=%s, data=%v", apiResponse.Code, apiResponse.Message, apiResponse.Data)
|
||
|
||
// 检查响应code(FastAPI返回code=0为成功)
|
||
if apiResponse.Code != 0 {
|
||
log.Printf("[发送验证码] 失败: %s", apiResponse.Message)
|
||
// 根据错误信息返回用户友好的提示
|
||
return nil, s.getFriendlyErrorMessage(apiResponse.Message)
|
||
}
|
||
|
||
// 返回完整的data,包括need_captcha、qrcode_image、session_id
|
||
log.Printf("[发送验证码] 成功, 返回数据: %v", apiResponse.Data)
|
||
|
||
// 2. 发送成功后设置限流标记(1分钟)
|
||
if err := utils.SetCache(ctx, rateLimitKey, "1", 1*time.Minute); err != nil {
|
||
log.Printf("设置限流缓存失败: %v", err)
|
||
}
|
||
|
||
return apiResponse.Data, nil
|
||
}
|
||
|
||
// getFriendlyErrorMessage 将技术错误信息转换为用户友好提示
|
||
func (s *EmployeeService) getFriendlyErrorMessage(errMsg string) error {
|
||
// 小写化错误信息用于匹配
|
||
lowerMsg := strings.ToLower(errMsg)
|
||
|
||
// DOM相关错误
|
||
if strings.Contains(lowerMsg, "element is not attached") ||
|
||
strings.Contains(lowerMsg, "dom") ||
|
||
strings.Contains(lowerMsg, "element not found") {
|
||
return errors.New("页面加载异常,请稍后重试")
|
||
}
|
||
|
||
// 超时错误
|
||
if strings.Contains(lowerMsg, "timeout") || strings.Contains(lowerMsg, "超时") {
|
||
return errors.New("请求超时,请检查网络后重试")
|
||
}
|
||
|
||
// 网络错误
|
||
if strings.Contains(lowerMsg, "network") ||
|
||
strings.Contains(lowerMsg, "connection") ||
|
||
strings.Contains(lowerMsg, "网络") {
|
||
return errors.New("网络连接失败,请检查网络后重试")
|
||
}
|
||
|
||
// 手机号错误
|
||
if strings.Contains(lowerMsg, "phone") ||
|
||
strings.Contains(lowerMsg, "手机号") ||
|
||
strings.Contains(lowerMsg, "输入手机号") {
|
||
return errors.New("请检查手机号是否正确")
|
||
}
|
||
|
||
// 验证码发送频繁
|
||
if strings.Contains(lowerMsg, "too many") ||
|
||
strings.Contains(lowerMsg, "频繁") ||
|
||
strings.Contains(lowerMsg, "rate limit") {
|
||
return errors.New("验证码发送过于频繁,请稍后再试")
|
||
}
|
||
|
||
// 浏览器/页面错误
|
||
if strings.Contains(lowerMsg, "browser") ||
|
||
strings.Contains(lowerMsg, "page") ||
|
||
strings.Contains(lowerMsg, "浏览器") {
|
||
return errors.New("系统繁忙,请稍后重试")
|
||
}
|
||
|
||
// 如果是其他错误,检查是否已经是中文提示
|
||
if strings.ContainsAny(errMsg, "一二三四五六七八九十") {
|
||
// 已经是中文提示,直接返回
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// 默认通用错误提示
|
||
return errors.New("发送失败,请稍后重试")
|
||
}
|
||
|
||
// GetProfile 获取员工个人信息(增加缓存支持)
|
||
func (s *EmployeeService) GetProfile(employeeID int) (*models.User, error) {
|
||
ctx := context.Background()
|
||
cacheKey := fmt.Sprintf("user:profile:%d", employeeID)
|
||
|
||
// 1. 尝试从缓存获取
|
||
var cachedUser models.User
|
||
if err := utils.GetCache(ctx, cacheKey, &cachedUser); err == nil {
|
||
log.Printf("命中缓存: 用户ID=%d", employeeID)
|
||
return &cachedUser, nil
|
||
}
|
||
|
||
// 2. 缓存未命中,从数据库查询
|
||
var employee models.User
|
||
err := database.DB.First(&employee, employeeID).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 手动查询企业信息(避免 GORM 关联查询问题)
|
||
if employee.EnterpriseID > 0 {
|
||
var enterprise models.Enterprise
|
||
if err := database.DB.Select("id", "name").First(&enterprise, employee.EnterpriseID).Error; err == nil {
|
||
employee.Enterprise = enterprise
|
||
}
|
||
}
|
||
|
||
// 3. 存入缓存(30分钟)
|
||
if err := utils.SetCache(ctx, cacheKey, employee, 30*time.Minute); err != nil {
|
||
log.Printf("设置缓存失败: %v", err)
|
||
}
|
||
|
||
// 注意: 小红书绑定信息现在存储在 ai_authors 表中
|
||
// 这里的 is_bound_xhs 字段仅作为快速判断标识
|
||
// 详细信息需要从 ai_authors 表查询
|
||
|
||
return &employee, nil
|
||
}
|
||
|
||
// UpdateProfile 更新个人资料(昵称、邮箱、头像)
|
||
func (s *EmployeeService) UpdateProfile(employeeID int, nickname, email, avatar *string) error {
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return errors.New("用户不存在")
|
||
}
|
||
|
||
updates := make(map[string]interface{})
|
||
if nickname != nil {
|
||
updates["nickname"] = strings.TrimSpace(*nickname)
|
||
}
|
||
if email != nil {
|
||
updates["email"] = strings.TrimSpace(*email)
|
||
}
|
||
if avatar != nil {
|
||
updates["icon"] = strings.TrimSpace(*avatar)
|
||
}
|
||
|
||
if len(updates) == 0 {
|
||
return nil
|
||
}
|
||
|
||
if err := database.DB.Model(&models.User{}).
|
||
Where("id = ?", employeeID).
|
||
Updates(updates).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
// 更新后清除缓存
|
||
ctx := context.Background()
|
||
cacheKey := fmt.Sprintf("user:profile:%d", employeeID)
|
||
if err := utils.DelCache(ctx, cacheKey); err != nil {
|
||
log.Printf("清除缓存失败: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// BindXHS 绑定小红书账号(异步处理,立即返回)
|
||
func (s *EmployeeService) BindXHS(employeeID int, xhsPhone, code, sessionID string) (string, error) {
|
||
if code == "" {
|
||
return "", errors.New("验证码不能为空")
|
||
}
|
||
|
||
ctx := context.Background()
|
||
|
||
// 检查是否有正在进行的绑定任务
|
||
bindStatusKey := fmt.Sprintf("xhs_bind_status:%d", employeeID)
|
||
statusValue, err := database.RDB.Get(ctx, bindStatusKey).Result()
|
||
if err == nil && (statusValue == "processing" || statusValue == `{"status":"processing"}`) {
|
||
return "", errors.New("正在处理绑定请求,请稍候")
|
||
}
|
||
|
||
// 设置绑定状态为processing(180秒有效期)
|
||
// 直接使用Redis Set存储纯字符串,避免JSON序列化
|
||
if err := database.RDB.Set(ctx, bindStatusKey, "processing", 180*time.Second).Err(); err != nil {
|
||
log.Printf("设置绑定状态缓存失败: %v", err)
|
||
}
|
||
|
||
// 异步执行绑定流程
|
||
go s.asyncBindXHS(employeeID, xhsPhone, code, sessionID)
|
||
|
||
// 立即返回成功,告知前端正在处理
|
||
log.Printf("绑定小红书 - 用户%d - 异步任务已启动 (session_id=%s)", employeeID, sessionID)
|
||
return "", nil
|
||
}
|
||
|
||
// asyncBindXHS 异步执行小红书绑定流程
|
||
func (s *EmployeeService) asyncBindXHS(employeeID int, xhsPhone, code, sessionID string) {
|
||
ctx := context.Background()
|
||
cacheService := NewCacheService()
|
||
|
||
// 使用分布式锁保护绑定操作
|
||
lockResource := fmt.Sprintf("bind_xhs:%d", employeeID)
|
||
xhsNickname := ""
|
||
bindStatusKey := fmt.Sprintf("xhs_bind_status:%d", employeeID)
|
||
|
||
err := cacheService.WithLock(ctx, lockResource, 180*time.Second, func() error {
|
||
// 获取员工信息
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
// 关键检查:验证该手机号是否已被其他用户绑定
|
||
var conflictAuthor models.Author
|
||
err := database.DB.Where(
|
||
"xhs_phone = ? AND status = 'active' AND created_user_id != ?",
|
||
xhsPhone, employeeID,
|
||
).First(&conflictAuthor).Error
|
||
|
||
if err == nil {
|
||
// 找到了其他用户的绑定记录
|
||
log.Printf("绑定小红书 - 用户%d - 失败: 手机号%s已被用户%d绑定",
|
||
employeeID, xhsPhone, conflictAuthor.CreatedUserID)
|
||
return errors.New("该手机号已被其他用户绑定")
|
||
} else if err != gorm.ErrRecordNotFound {
|
||
// 数据库查询异常
|
||
log.Printf("绑定小红书 - 用户%d - 检查手机号失败: %v", employeeID, err)
|
||
return fmt.Errorf("检查手机号失败: %w", err)
|
||
}
|
||
// err == gorm.ErrRecordNotFound 表示该手机号未被绑定,可以继续
|
||
|
||
// 调用Python服务进行验证码验证和登录,传递session_id
|
||
loginResult, err := s.callPythonLogin(xhsPhone, code, sessionID)
|
||
if err != nil {
|
||
return fmt.Errorf("小红书登录失败: %w", err)
|
||
}
|
||
|
||
// 检柦Python服务返回结果
|
||
if loginResult.Code != 0 {
|
||
// 检查是否需要扫码验证
|
||
if needCaptcha, ok := loginResult.Data["need_captcha"].(bool); ok && needCaptcha {
|
||
// 出现验证码,更新绑定状态为"need_captcha"
|
||
captchaData := map[string]interface{}{
|
||
"status": "need_captcha",
|
||
"captcha_type": loginResult.Data["captcha_type"],
|
||
"message": loginResult.Data["message"],
|
||
}
|
||
|
||
// 如果有二维码图片,也一并返回
|
||
if qrcodeImage, ok := loginResult.Data["qrcode_image"].(string); ok {
|
||
captchaData["qrcode_image"] = qrcodeImage
|
||
}
|
||
|
||
// 将captchaData序列化并存入Redis
|
||
captchaJSON, _ := json.Marshal(captchaData)
|
||
if err := database.RDB.Set(ctx, bindStatusKey, string(captchaJSON), 180*time.Second).Err(); err != nil {
|
||
log.Printf("绑定小红书 - 用户%d - 更新验证状态失败: %v", employeeID, err)
|
||
}
|
||
log.Printf("绑定小红书 - 用户%d - 需要验证码验证: %s", employeeID, loginResult.Data["captcha_type"])
|
||
return fmt.Errorf("需要验证码验证")
|
||
}
|
||
return fmt.Errorf("小红书登录失败: %s", loginResult.Message)
|
||
}
|
||
|
||
// 从返回结果中提取用户信息和完整登录状态
|
||
userInfo, _ := loginResult.Data["user_info"].(map[string]interface{})
|
||
|
||
// 优先使用 login_state(完整登录状态),如果没有则降级使用cookies
|
||
var loginStateJSON string
|
||
|
||
if loginState, ok := loginResult.Data["login_state"].(map[string]interface{}); ok && len(loginState) > 0 {
|
||
// 新版:使用完整的login_state(包含cookies + localStorage + sessionStorage)
|
||
loginStateBytes, err := json.Marshal(loginState)
|
||
if err == nil {
|
||
loginStateJSON = string(loginStateBytes)
|
||
log.Printf("绑定小红书 - 用户%d - 完整LoginState长度: %d", employeeID, len(loginStateJSON))
|
||
} else {
|
||
log.Printf("绑定小红书 - 用户%d - 序列化login_state失败: %v", employeeID, err)
|
||
}
|
||
} else {
|
||
// 降级:使用旧版本的 cookies_full 或 cookies
|
||
log.Printf("绑定小红书 - 用户%d - 警告: 未找到login_state,降级使用cookies", employeeID)
|
||
|
||
var cookiesData interface{}
|
||
if cookiesFull, ok := loginResult.Data["cookies_full"].([]interface{}); ok && len(cookiesFull) > 0 {
|
||
cookiesData = cookiesFull
|
||
} else if cookiesMap, ok := loginResult.Data["cookies"].(map[string]interface{}); ok && len(cookiesMap) > 0 {
|
||
cookiesData = cookiesMap
|
||
}
|
||
|
||
if cookiesData != nil {
|
||
cookiesBytes, err := json.Marshal(cookiesData)
|
||
if err == nil {
|
||
loginStateJSON = string(cookiesBytes)
|
||
log.Printf("绑定小红书 - 用户%d - Cookie长度: %d", employeeID, len(loginStateJSON))
|
||
}
|
||
}
|
||
}
|
||
|
||
if loginStateJSON == "" {
|
||
log.Printf("绑定小红书 - 用户%d - 错误: 未能获取到任何登录数据", employeeID)
|
||
return errors.New("登录成功但未能获取到登录数据,请重试")
|
||
}
|
||
|
||
// 提取小红书账号昵称
|
||
xhsNickname = "小红书用户"
|
||
if userInfo != nil {
|
||
if nickname, ok := userInfo["nickname"].(string); ok && nickname != "" {
|
||
xhsNickname = nickname
|
||
} else if username, ok := userInfo["username"].(string); ok && username != "" {
|
||
xhsNickname = username
|
||
}
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
// 开启事务
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 创建或更新 ai_authors 表的小红书账号记录
|
||
log.Printf("绑定小红书 - 用户%d - 开始创建或更新作者记录", employeeID)
|
||
|
||
author := models.Author{
|
||
EnterpriseID: employee.EnterpriseID,
|
||
CreatedUserID: employeeID,
|
||
Phone: employee.Phone,
|
||
AuthorName: xhsNickname,
|
||
XHSCookie: loginStateJSON, // 存储完整的login_state JSON
|
||
XHSPhone: xhsPhone,
|
||
XHSAccount: xhsNickname,
|
||
BoundAt: &now,
|
||
Channel: 1, // 1=小红书
|
||
Status: "active",
|
||
}
|
||
|
||
// 查询是否已存在记录
|
||
var existingAuthor models.Author
|
||
err = database.DB.Where("created_user_id = ? AND enterprise_id = ? AND channel = 1",
|
||
employeeID, employee.EnterpriseID).First(&existingAuthor).Error
|
||
|
||
if err == gorm.ErrRecordNotFound {
|
||
// 创建新记录
|
||
if err := tx.Create(&author).Error; err != nil {
|
||
tx.Rollback()
|
||
log.Printf("绑定小红书 - 用户%d - 创建作者记录失败: %v", employeeID, err)
|
||
return fmt.Errorf("创建作者记录失败: %w", err)
|
||
}
|
||
log.Printf("绑定小红书 - 用户%d - 创建作者记录成功", employeeID)
|
||
} else {
|
||
// 更新现有记录,使用 WHERE 条件明确指定要更新的记录(根据 created_user_id)
|
||
if err := tx.Model(&models.Author{}).Where(
|
||
"created_user_id = ? AND enterprise_id = ? AND channel = 1",
|
||
employeeID, employee.EnterpriseID,
|
||
).Updates(map[string]interface{}{
|
||
"author_name": xhsNickname,
|
||
"xhs_cookie": loginStateJSON, // 存储完整的login_state JSON
|
||
"xhs_phone": xhsPhone,
|
||
"xhs_account": xhsNickname,
|
||
"bound_at": &now,
|
||
"status": "active",
|
||
"phone": employee.Phone,
|
||
}).Error; err != nil {
|
||
tx.Rollback()
|
||
log.Printf("绑定小红书 - 用户%d - 更新作者记录失败: %v", employeeID, err)
|
||
return fmt.Errorf("更新作者记录失败: %w", err)
|
||
}
|
||
log.Printf("绑定小红书 - 用户%d - 更新作者记录成功", employeeID)
|
||
}
|
||
|
||
// 更新 ai_users 表的绑定标识
|
||
if err := tx.Model(&employee).Update("is_bound_xhs", 1).Error; err != nil {
|
||
tx.Rollback()
|
||
log.Printf("绑定小红书 - 用户%d - 更新用户绑定标识失败: %v", employeeID, err)
|
||
return fmt.Errorf("更新用户绑定标识失败: %w", err)
|
||
}
|
||
|
||
log.Printf("绑定小红书 - 用户%d - 数据库更新成功", employeeID)
|
||
|
||
// 提交事务
|
||
if err := tx.Commit().Error; err != nil {
|
||
log.Printf("绑定小红书 - 用户%d - 事务提交失败: %v", employeeID, err)
|
||
return fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
// 绑定失败,设置失败状态(保留5分钟供前端查询)
|
||
failData := map[string]string{
|
||
"status": "failed",
|
||
"error": err.Error(),
|
||
}
|
||
failJSON, _ := json.Marshal(failData)
|
||
// 直接使用Redis Set存储JSON字符串
|
||
database.RDB.Set(ctx, bindStatusKey, string(failJSON), 5*time.Minute)
|
||
log.Printf("绑定小红书 - 用户%d - 绑定失败: %v", employeeID, err)
|
||
return
|
||
}
|
||
|
||
// 清除相关缓存
|
||
if err := cacheService.ClearUserRelatedCache(ctx, employeeID); err != nil {
|
||
log.Printf("清除缓存失败: %v", err)
|
||
}
|
||
|
||
// 绑定成功,设置成功状态(保留5分钟供前端查询)
|
||
successData := map[string]string{
|
||
"status": "success",
|
||
"xhs_account": xhsNickname,
|
||
}
|
||
successJSON, _ := json.Marshal(successData)
|
||
// 直接使用Redis Set存储JSON字符串
|
||
database.RDB.Set(ctx, bindStatusKey, string(successJSON), 5*time.Minute)
|
||
log.Printf("绑定小红书 - 用户%d - 绑定成功 - 账号: %s", employeeID, xhsNickname)
|
||
}
|
||
|
||
// GetBindXHSStatus 获取小红书绑定状态
|
||
func (s *EmployeeService) GetBindXHSStatus(employeeID int) (map[string]interface{}, error) {
|
||
ctx := context.Background()
|
||
bindStatusKey := fmt.Sprintf("xhs_bind_status:%d", employeeID)
|
||
|
||
statusJSON, err := database.RDB.Get(ctx, bindStatusKey).Result()
|
||
if err != nil {
|
||
// 没有找到状态,可能已完成或从未开始
|
||
log.Printf("获取绑定状态 - 用户%d - Redis查询失败: %v", employeeID, err)
|
||
return map[string]interface{}{
|
||
"status": "idle",
|
||
}, nil
|
||
}
|
||
|
||
log.Printf("获取绑定状态 - 用户%d - Redis原始数据: %s", employeeID, statusJSON)
|
||
|
||
// 处理中状态(纯字符串)
|
||
if statusJSON == "processing" {
|
||
log.Printf("获取绑定状态 - 用户%d - 状态: processing", employeeID)
|
||
return map[string]interface{}{
|
||
"status": "processing",
|
||
"message": "正在登录小红书,请稍候...",
|
||
}, nil
|
||
}
|
||
|
||
// 尝试解析JSON状态
|
||
var statusData map[string]interface{}
|
||
if err := json.Unmarshal([]byte(statusJSON), &statusData); err != nil {
|
||
log.Printf("获取绑定状态 - 用户%d - JSON解析失败: %v, 原始数据: %s", employeeID, err, statusJSON)
|
||
// 如果不是JSON,可能是纯字符串状态
|
||
return map[string]interface{}{
|
||
"status": "unknown",
|
||
"error": fmt.Sprintf("解析状态失败: %s", statusJSON),
|
||
}, nil
|
||
}
|
||
|
||
log.Printf("获取绑定状态 - 用户%d - 解析后的状态: %+v", employeeID, statusData)
|
||
|
||
result := map[string]interface{}{
|
||
"status": statusData["status"],
|
||
}
|
||
|
||
if status, ok := statusData["status"].(string); ok {
|
||
switch status {
|
||
case "success":
|
||
if xhsAccount, ok := statusData["xhs_account"].(string); ok {
|
||
result["xhs_account"] = xhsAccount
|
||
}
|
||
result["message"] = "绑定成功"
|
||
log.Printf("获取绑定状态 - 用户%d - 绑定成功", employeeID)
|
||
case "failed":
|
||
if errorMsg, ok := statusData["error"].(string); ok {
|
||
result["error"] = errorMsg
|
||
}
|
||
log.Printf("获取绑定状态 - 用户%d - 绑定失败", employeeID)
|
||
case "processing":
|
||
result["message"] = "正在登录小红书,请稍候..."
|
||
log.Printf("获取绑定状态 - 用户%d - 状态: processing", employeeID)
|
||
case "need_captcha":
|
||
// 需要验证码验证
|
||
if message, ok := statusData["message"].(string); ok {
|
||
result["message"] = message
|
||
}
|
||
if captchaType, ok := statusData["captcha_type"].(string); ok {
|
||
result["captcha_type"] = captchaType
|
||
}
|
||
// 如果有二维码图片,也返回
|
||
if qrcodeImage, ok := statusData["qrcode_image"].(string); ok {
|
||
result["qrcode_image"] = qrcodeImage
|
||
}
|
||
log.Printf("获取绑定状态 - 用户%d - 需要验证码: %s", employeeID, statusData["captcha_type"])
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// callPythonLogin 调用Python HTTP服务完成小红书登录(优化:使用浏览器池)
|
||
func (s *EmployeeService) callPythonLogin(phone, code, sessionID string) (*PythonLoginResponse, error) {
|
||
// 从配置获取Python服务地址
|
||
pythonServiceURL := config.AppConfig.XHS.PythonServiceURL
|
||
if pythonServiceURL == "" {
|
||
pythonServiceURL = "http://localhost:8000"
|
||
}
|
||
|
||
// 构造HTTP请求
|
||
url := fmt.Sprintf("%s/api/xhs/login", pythonServiceURL)
|
||
requestData := map[string]string{
|
||
"phone": phone,
|
||
"code": code,
|
||
"country_code": "+86",
|
||
"session_id": sessionID, // 关键:传递session_id用于复用浏览器
|
||
}
|
||
|
||
jsonData, err := json.Marshal(requestData)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("序列化请求数据失败: %w", err)
|
||
}
|
||
|
||
log.Printf("[绑定小红书] 调用Python HTTP服务: %s, session_id=%s", url, sessionID)
|
||
|
||
// 发送HTTP POST请求
|
||
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("HTTP请求失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 读取响应
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||
}
|
||
|
||
log.Printf("[绑定小红书] Python服务响应状态: %d", resp.StatusCode)
|
||
|
||
// 解析响应(FastAPI返回格式: {code, message, data})
|
||
var apiResponse struct {
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
Data map[string]interface{} `json:"data"`
|
||
}
|
||
|
||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||
return nil, fmt.Errorf("解析Python响应失败: %w, body: %s", err, string(body))
|
||
}
|
||
|
||
// 检查响应code(FastAPI返回code=0为成功)
|
||
if apiResponse.Code != 0 {
|
||
return &PythonLoginResponse{
|
||
Code: 1,
|
||
Message: apiResponse.Message,
|
||
}, nil
|
||
}
|
||
|
||
log.Printf("[绑定小红书] 登录成功,获取到Cookie数据")
|
||
|
||
return &PythonLoginResponse{
|
||
Code: 0,
|
||
Message: "登录成功",
|
||
Data: apiResponse.Data,
|
||
}, nil
|
||
}
|
||
|
||
// PythonLoginResponse Python服务登录响应
|
||
type PythonLoginResponse struct {
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
Data map[string]interface{} `json:"data"`
|
||
}
|
||
|
||
// UnbindXHS 解绑小红书账号
|
||
func (s *EmployeeService) UnbindXHS(employeeID int) error {
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
if employee.IsBoundXHS == 0 {
|
||
return errors.New("未绑定小红书账号")
|
||
}
|
||
|
||
// 开启事务
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 删除或禁用 ai_authors 表中的小红书作者记录(根据 created_user_id)
|
||
err := tx.Model(&models.Author{}).Where(
|
||
"created_user_id = ? AND enterprise_id = ? AND channel = 1",
|
||
employeeID, employee.EnterpriseID,
|
||
).Updates(map[string]interface{}{
|
||
"status": "inactive",
|
||
"xhs_cookie": "",
|
||
"xhs_phone": "",
|
||
"xhs_account": "",
|
||
"bound_at": nil,
|
||
}).Error
|
||
|
||
if err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("删除作者记录失败: %w", err)
|
||
}
|
||
|
||
// 更新 ai_users 表的绑定标识
|
||
log.Printf("解绑小红书 - 用户%d - 开始更新用户绑定标识", employeeID)
|
||
if err := tx.Model(&employee).Update("is_bound_xhs", 0).Error; err != nil {
|
||
tx.Rollback()
|
||
log.Printf("解绑小红书 - 用户%d - 更新用户绑定标识失败: %v", employeeID, err)
|
||
return fmt.Errorf("更新用户绑定标识失败: %w", err)
|
||
}
|
||
log.Printf("解绑小红书 - 用户%d - 更新用户绑定标识成功", employeeID)
|
||
|
||
// 提交事务
|
||
if err := tx.Commit().Error; err != nil {
|
||
log.Printf("解绑小红书 - 用户%d - 事务提交失败: %v", employeeID, err)
|
||
return fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
|
||
// 清除相关缓存
|
||
ctx := context.Background()
|
||
userCacheKey := fmt.Sprintf("user:profile:%d", employeeID)
|
||
authorCacheKey := fmt.Sprintf("author:user:%d", employeeID)
|
||
statusCacheKey := fmt.Sprintf("xhs:status:%d", employeeID)
|
||
if err := utils.DelCache(ctx, userCacheKey, authorCacheKey, statusCacheKey); err != nil {
|
||
log.Printf("清除缓存失败: %v", err)
|
||
}
|
||
|
||
log.Printf("解绑小红书 - 用户%d - 解绑成功", employeeID)
|
||
return nil
|
||
}
|
||
|
||
// verifyCookieWithPython 使用Python脚本验证Cookie,并返回登录与过期状态
|
||
func (s *EmployeeService) verifyCookieWithPython(rawCookie string) (*XHSCookieVerifyResult, error) {
|
||
// 解析Cookie
|
||
var cookies []interface{}
|
||
if err := json.Unmarshal([]byte(rawCookie), &cookies); err != nil {
|
||
return nil, fmt.Errorf("解析Cookie失败: %w", err)
|
||
}
|
||
|
||
// 调用Python脚本验证Cookie
|
||
backendDir := filepath.Join("..", "backend")
|
||
pythonScript := filepath.Join(backendDir, "xhs_cli.py")
|
||
pythonCmd := getPythonPath(backendDir)
|
||
|
||
// 将cookies序列化为JSON字符串
|
||
cookiesJSON, err := json.Marshal(cookies)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("序列化Cookie失败: %w", err)
|
||
}
|
||
|
||
// 执行Python脚本: inject_cookies
|
||
cmd := exec.Command(pythonCmd, pythonScript, "inject_cookies", string(cookiesJSON))
|
||
cmd.Dir = backendDir
|
||
|
||
// 捕获输出
|
||
var stdout, stderr bytes.Buffer
|
||
cmd.Stdout = &stdout
|
||
cmd.Stderr = &stderr
|
||
|
||
// 执行命令
|
||
err = cmd.Run()
|
||
|
||
// 打印Python脚本的日志输出(stderr)
|
||
if stderr.Len() > 0 {
|
||
log.Printf("[Python日志-验证Cookie] %s", stderr.String())
|
||
}
|
||
|
||
if err != nil {
|
||
return nil, fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
|
||
}
|
||
|
||
// 解析返回结果
|
||
outputStr := stdout.String()
|
||
var result map[string]interface{}
|
||
if err := json.Unmarshal([]byte(outputStr), &result); err != nil {
|
||
return nil, fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr)
|
||
}
|
||
|
||
loggedIn, _ := result["logged_in"].(bool)
|
||
cookieExpired, _ := result["cookie_expired"].(bool)
|
||
|
||
return &XHSCookieVerifyResult{
|
||
LoggedIn: loggedIn,
|
||
CookieExpired: cookieExpired,
|
||
}, nil
|
||
}
|
||
|
||
// VerifyCookieAndClear 验证Cookie并在失效时清空
|
||
func (s *EmployeeService) VerifyCookieAndClear(employeeID int) error {
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
// 检查是否已绑定
|
||
if employee.IsBoundXHS == 0 {
|
||
return nil // 没有绑定,直接返回
|
||
}
|
||
|
||
// 查询对应的 author 记录(根据 created_user_id)
|
||
var author models.Author
|
||
err := database.DB.Where(
|
||
"created_user_id = ? AND enterprise_id = ? AND channel = 1 AND status = 'active'",
|
||
employeeID, employee.EnterpriseID,
|
||
).First(&author).Error
|
||
|
||
if err != nil || author.XHSCookie == "" {
|
||
return nil // 没有找到有效的author记录或已无Cookie
|
||
}
|
||
|
||
// 检查绑定时间,刚绑定的30秒内不验证(避免与绑定操作冲突)
|
||
if author.BoundAt != nil {
|
||
timeSinceBound := time.Since(*author.BoundAt)
|
||
if timeSinceBound < 30*time.Second {
|
||
log.Printf("验证Cookie - 用户%d刚绑定%.0f秒,跳过验证", employeeID, timeSinceBound.Seconds())
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// 调用Python脚本验证Cookie
|
||
verifyResult, err := s.verifyCookieWithPython(author.XHSCookie)
|
||
if err != nil {
|
||
log.Printf("执行Python脚本失败: %v", err)
|
||
// 执行失败,不清空Cookie
|
||
return err
|
||
}
|
||
|
||
if !verifyResult.LoggedIn || verifyResult.CookieExpired {
|
||
// Cookie已失效,清空数据库
|
||
log.Printf("检测到Cookie已失效,清空用户%d的Cookie", employeeID)
|
||
return s.clearXHSCookie(employeeID)
|
||
}
|
||
|
||
log.Printf("用户%d的Cookie有效", employeeID)
|
||
return nil
|
||
}
|
||
|
||
// XHSStatus 小红书绑定及Cookie状态
|
||
type XHSStatus struct {
|
||
IsBound bool `json:"is_bound"`
|
||
HasCookie bool `json:"has_cookie"`
|
||
CookieValid bool `json:"cookie_valid"`
|
||
CookieExpired bool `json:"cookie_expired"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// CheckXHSStatus 检查小红书绑定与Cookie健康状态(增加缓存)
|
||
func (s *EmployeeService) CheckXHSStatus(employeeID int) (*XHSStatus, error) {
|
||
ctx := context.Background()
|
||
cacheKey := fmt.Sprintf("xhs:status:%d", employeeID)
|
||
|
||
// 1. 尝试从缓存获取状态(5分钟有效期)
|
||
var cachedStatus XHSStatus
|
||
if err := utils.GetCache(ctx, cacheKey, &cachedStatus); err == nil {
|
||
log.Printf("命中小红书状态缓存: 用户ID=%d", employeeID)
|
||
return &cachedStatus, nil
|
||
}
|
||
|
||
// 2. 缓存未命中,查询数据库并验证
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
status := &XHSStatus{
|
||
IsBound: employee.IsBoundXHS == 1,
|
||
HasCookie: false,
|
||
CookieValid: false,
|
||
CookieExpired: false,
|
||
}
|
||
|
||
if employee.IsBoundXHS == 0 {
|
||
status.Message = "未绑定小红书账号"
|
||
// 缓存未绑定状态(1分钟)
|
||
utils.SetCache(ctx, cacheKey, status, 1*time.Minute)
|
||
return status, nil
|
||
}
|
||
|
||
// 查询对应的 author 记录(根据 created_user_id)
|
||
var author models.Author
|
||
err := database.DB.Where(
|
||
"created_user_id = ? AND enterprise_id = ? AND channel = 1 AND status = 'active'",
|
||
employeeID, employee.EnterpriseID,
|
||
).First(&author).Error
|
||
|
||
if err != nil || author.XHSCookie == "" {
|
||
status.CookieExpired = true
|
||
status.Message = "已绑定但无有效Cookie,可直接重新绑定"
|
||
// 缓存Cookie过期状态(2分钟)
|
||
utils.SetCache(ctx, cacheKey, status, 2*time.Minute)
|
||
return status, nil
|
||
}
|
||
|
||
status.HasCookie = true
|
||
|
||
// 刚绑定30秒内视为有效,避免频繁触发验证
|
||
if author.BoundAt != nil {
|
||
timeSinceBound := time.Since(*author.BoundAt)
|
||
if timeSinceBound < 30*time.Second {
|
||
status.CookieValid = true
|
||
status.Message = "刚绑定,小于30秒,暂不检测,视为有效"
|
||
// 缓存有效状态(5分钟)
|
||
utils.SetCache(ctx, cacheKey, status, 5*time.Minute)
|
||
return status, nil
|
||
}
|
||
}
|
||
|
||
verifyResult, err := s.verifyCookieWithPython(author.XHSCookie)
|
||
if err != nil {
|
||
status.Message = fmt.Sprintf("验证Cookie失败: %v", err)
|
||
return status, err
|
||
}
|
||
|
||
if !verifyResult.LoggedIn || verifyResult.CookieExpired {
|
||
// Cookie已失效,清空后允许直接重新绑定
|
||
if err := s.clearXHSCookie(employeeID); err != nil {
|
||
return nil, err
|
||
}
|
||
status.HasCookie = false
|
||
status.CookieExpired = true
|
||
status.CookieValid = false
|
||
status.Message = "Cookie已失效,已清空,可直接重新绑定"
|
||
// 缓存Cookie失效状态(2分钟)
|
||
utils.SetCache(ctx, cacheKey, status, 2*time.Minute)
|
||
return status, nil
|
||
}
|
||
|
||
status.CookieValid = true
|
||
status.CookieExpired = false
|
||
status.Message = "Cookie有效,已登录"
|
||
// 缓存Cookie有效状态(5分钟)
|
||
utils.SetCache(ctx, cacheKey, status, 5*time.Minute)
|
||
return status, nil
|
||
}
|
||
|
||
// clearXHSCookie 清空小红书Cookie(保留绑定状态)
|
||
func (s *EmployeeService) clearXHSCookie(employeeID int) error {
|
||
// 查询用户信息
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
// 清空 ai_authors 表中的 Cookie(根据 created_user_id)
|
||
err := database.DB.Model(&models.Author{}).Where(
|
||
"created_user_id = ? AND enterprise_id = ? AND channel = 1",
|
||
employeeID, employee.EnterpriseID,
|
||
).Update("xhs_cookie", "").Error
|
||
|
||
if err != nil {
|
||
return fmt.Errorf("清空Cookie失败: %w", err)
|
||
}
|
||
|
||
log.Printf("已清空用户%d的XHS Cookie", employeeID)
|
||
return nil
|
||
}
|
||
|
||
// GetAvailableCopies 获取可领取的文案列表(根据作者ID、产品ID和状态筛选)
|
||
func (s *EmployeeService) GetAvailableCopies(employeeID int, productID int) (map[string]interface{}, error) {
|
||
ctx := context.Background()
|
||
|
||
// 1. 先尝试从缓存获取作者信息
|
||
var author models.Author
|
||
authorCacheKey := fmt.Sprintf("author:user:%d", employeeID)
|
||
|
||
if err := utils.GetCache(ctx, authorCacheKey, &author); err != nil {
|
||
// 缓存未命中,从数据库查询
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return nil, fmt.Errorf("获取用户信息失败: %w", err)
|
||
}
|
||
|
||
// 查找对应的作者记录(根据 created_user_id)
|
||
if err := database.DB.Where("created_user_id = ? AND enterprise_id = ?", employeeID, employee.EnterpriseID).First(&author).Error; err != nil {
|
||
return nil, fmt.Errorf("未找到对应的作者记录: %w", err)
|
||
}
|
||
|
||
// 存入缓存(1小时)
|
||
if err := utils.SetCache(ctx, authorCacheKey, author, 1*time.Hour); err != nil {
|
||
log.Printf("设置作者缓存失败: %v", err)
|
||
}
|
||
} else {
|
||
log.Printf("命中作者缓存: 用户ID=%d, 作者ID=%d", employeeID, author.ID)
|
||
}
|
||
|
||
// 获取产品信息
|
||
var product models.Product
|
||
if err := database.DB.First(&product, productID).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 根据产品ID、作者ID和状态筛选文案
|
||
// status = 'assign_authors' 表示已分配作者的文案
|
||
var copies []models.Article
|
||
query := database.DB.Preload("Images", func(db *gorm.DB) *gorm.DB {
|
||
return db.Order("sort_order ASC")
|
||
}).Where("product_id = ? AND author_id = ? AND status = ?", productID, author.ID, "assign_authors")
|
||
|
||
if err := query.Order("created_at DESC").Find(&copies).Error; err != nil {
|
||
return nil, fmt.Errorf("查询文案列表失败: %w", err)
|
||
}
|
||
|
||
log.Printf("[获取文案列表] 用户ID=%d, 作者ID=%d, 产品ID=%d, 筛选到 %d 条文案", employeeID, author.ID, productID, len(copies))
|
||
|
||
return map[string]interface{}{
|
||
"product": map[string]interface{}{
|
||
"id": product.ID,
|
||
"name": product.Name,
|
||
"image": product.ImageURL,
|
||
},
|
||
"copies": copies,
|
||
"author": map[string]interface{}{
|
||
"id": author.ID,
|
||
"name": author.AuthorName,
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
// UpdateArticleStatus 更新文案状态(通过/拒绝)
|
||
func (s *EmployeeService) UpdateArticleStatus(employeeID int, articleID int, status string) error {
|
||
// 获取当前用户信息
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return fmt.Errorf("获取用户信息失败: %w", err)
|
||
}
|
||
|
||
// 查找对应的作者记录(根据 created_user_id)
|
||
var author models.Author
|
||
if err := database.DB.Where("created_user_id = ? AND enterprise_id = ?", employeeID, employee.EnterpriseID).First(&author).Error; err != nil {
|
||
return fmt.Errorf("未找到对应的作者记录: %w", err)
|
||
}
|
||
|
||
// 获取文案信息
|
||
var article models.Article
|
||
if err := database.DB.First(&article, articleID).Error; err != nil {
|
||
return fmt.Errorf("获取文案信息失败: %w", err)
|
||
}
|
||
|
||
// 验证文案是否属于当前作者且状态为assign_authors
|
||
if article.AuthorID == nil || *article.AuthorID != author.ID {
|
||
return fmt.Errorf("无权操作此文案")
|
||
}
|
||
|
||
if article.Status != "assign_authors" {
|
||
return fmt.Errorf("文案当前状态为%s,无法操作", article.Status)
|
||
}
|
||
|
||
// 开启事务
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
now := time.Now()
|
||
|
||
// 更新文案状态
|
||
err := tx.Model(&article).Updates(map[string]interface{}{
|
||
"status": status,
|
||
"review_user_id": employeeID,
|
||
}).Error
|
||
if err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("更新状态失败: %w", err)
|
||
}
|
||
|
||
// 创建操作记录到 ai_article_published_records
|
||
actionType := "通过"
|
||
if status == "rejected" {
|
||
actionType = "拒绝"
|
||
}
|
||
|
||
record := models.PublishRecord{
|
||
ArticleID: &article.ID,
|
||
EnterpriseID: employee.EnterpriseID,
|
||
ProductID: article.ProductID,
|
||
Topic: article.Topic,
|
||
Title: article.Title,
|
||
CreatedUserID: article.CreatedUserID,
|
||
ReviewUserID: &employeeID,
|
||
Status: status,
|
||
PublishTime: &now,
|
||
WordCount: article.WordCount,
|
||
ImageCount: article.ImageCount,
|
||
Channel: article.Channel,
|
||
ReviewComment: fmt.Sprintf("作者%s于%s", actionType, now.Format("2006-01-02 15:04:05")),
|
||
}
|
||
|
||
if err := tx.Create(&record).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("创建操作记录失败: %w", err)
|
||
}
|
||
|
||
// 提交事务
|
||
if err := tx.Commit().Error; err != nil {
|
||
return fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
|
||
log.Printf("[更新文案状态] 用户ID=%d, 作者ID=%d, 文案ID=%d, 状态: assign_authors => %s, 记录ID=%d", employeeID, author.ID, articleID, status, record.ID)
|
||
return nil
|
||
}
|
||
|
||
// UpdateArticleContent 更新文案内容(标题、正文)
|
||
func (s *EmployeeService) UpdateArticleContent(employeeID int, articleID int, title, content string) error {
|
||
var article models.Article
|
||
if err := database.DB.First(&article, articleID).Error; err != nil {
|
||
return errors.New("文案不存在")
|
||
}
|
||
|
||
// 更新标题和内容
|
||
updates := map[string]interface{}{
|
||
"title": title,
|
||
"content": content,
|
||
}
|
||
|
||
err := database.DB.Model(&article).Updates(updates).Error
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录日志
|
||
s.createLog(nil, employeeID, "article_content_update", "article", &article.ID,
|
||
fmt.Sprintf("文案ID:%d 内容已更新", article.ID), "", "success")
|
||
|
||
return nil
|
||
}
|
||
|
||
// AddArticleImage 添加文案图片
|
||
func (s *EmployeeService) AddArticleImage(employeeID int, articleID int, imageURL, imageThumbURL, keywordsName string) (*models.ArticleImage, error) {
|
||
// 验证文案是否存在
|
||
var article models.Article
|
||
if err := database.DB.First(&article, articleID).Error; err != nil {
|
||
return nil, errors.New("文案不存在")
|
||
}
|
||
|
||
// 获取当前最大的 sort_order
|
||
var maxSortOrder int
|
||
database.DB.Model(&models.ArticleImage{}).Where("article_id = ?", articleID).Select("COALESCE(MAX(sort_order), 0)").Scan(&maxSortOrder)
|
||
|
||
// 获取当前最大的 image_id
|
||
var maxImageID int
|
||
database.DB.Model(&models.ArticleImage{}).Select("COALESCE(MAX(image_id), 0)").Scan(&maxImageID)
|
||
|
||
// 开启事务
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 创建图片记录
|
||
image := models.ArticleImage{
|
||
EnterpriseID: article.EnterpriseID,
|
||
ArticleID: articleID,
|
||
ImageID: maxImageID + 1,
|
||
ImageURL: imageURL,
|
||
ImageThumbURL: imageThumbURL,
|
||
SortOrder: maxSortOrder + 1,
|
||
KeywordsName: keywordsName,
|
||
ImageSource: 2, // 2=change 表示用户手动添加
|
||
}
|
||
|
||
if err := tx.Create(&image).Error; err != nil {
|
||
tx.Rollback()
|
||
return nil, fmt.Errorf("添加图片失败: %w", err)
|
||
}
|
||
|
||
// 更新文案的图片数量
|
||
var imageCount int64
|
||
tx.Model(&models.ArticleImage{}).Where("article_id = ?", articleID).Count(&imageCount)
|
||
tx.Model(&article).Update("image_count", imageCount)
|
||
|
||
// 记录日志
|
||
s.createLog(tx, employeeID, "article_image_add", "article", &articleID,
|
||
fmt.Sprintf("文案ID:%d 添加图片: %s", articleID, imageURL), "", "success")
|
||
|
||
// 提交事务
|
||
if err := tx.Commit().Error; err != nil {
|
||
return nil, fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
|
||
return &image, nil
|
||
}
|
||
|
||
// DeleteArticleImage 删除文案图片
|
||
func (s *EmployeeService) DeleteArticleImage(employeeID int, imageID int) error {
|
||
// 查询图片信息
|
||
var image models.ArticleImage
|
||
if err := database.DB.First(&image, imageID).Error; err != nil {
|
||
return errors.New("图片不存在")
|
||
}
|
||
|
||
articleID := image.ArticleID
|
||
|
||
// 验证文案是否存在
|
||
var article models.Article
|
||
if err := database.DB.First(&article, articleID).Error; err != nil {
|
||
return errors.New("文案不存在")
|
||
}
|
||
|
||
// 检查删除后是否至少还有一张图片
|
||
var imageCount int64
|
||
database.DB.Model(&models.ArticleImage{}).Where("article_id = ?", articleID).Count(&imageCount)
|
||
if imageCount <= 1 {
|
||
return errors.New("文案至少需要保留一张图片")
|
||
}
|
||
|
||
// 开启事务
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 删除图片
|
||
if err := tx.Delete(&image).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("删除图片失败: %w", err)
|
||
}
|
||
|
||
// 更新文案的图片数量
|
||
tx.Model(&models.ArticleImage{}).Where("article_id = ?", articleID).Count(&imageCount)
|
||
tx.Model(&article).Update("image_count", imageCount)
|
||
|
||
// 记录日志
|
||
s.createLog(tx, employeeID, "article_image_delete", "article", &articleID,
|
||
fmt.Sprintf("文案ID:%d 删除图片ID:%d", articleID, imageID), "", "success")
|
||
|
||
// 提交事务
|
||
if err := tx.Commit().Error; err != nil {
|
||
return fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// UpdateArticleImagesOrder 更新文案图片排序
|
||
func (s *EmployeeService) UpdateArticleImagesOrder(employeeID int, articleID int, imageOrders []map[string]int) error {
|
||
// 验证文案是否存在
|
||
var article models.Article
|
||
if err := database.DB.First(&article, articleID).Error; err != nil {
|
||
return errors.New("文案不存在")
|
||
}
|
||
|
||
// 开启事务
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 更新每个图片的排序
|
||
for _, order := range imageOrders {
|
||
imageID, imageIDOk := order["id"]
|
||
sortOrder, sortOrderOk := order["sort_order"]
|
||
|
||
if !imageIDOk || !sortOrderOk {
|
||
continue
|
||
}
|
||
|
||
if err := tx.Model(&models.ArticleImage{}).Where("id = ? AND article_id = ?", imageID, articleID).Update("sort_order", sortOrder).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("更新图片排序失败: %w", err)
|
||
}
|
||
}
|
||
|
||
// 记录日志
|
||
s.createLog(tx, employeeID, "article_images_reorder", "article", &articleID,
|
||
fmt.Sprintf("文案ID:%d 更新图片排序", articleID), "", "success")
|
||
|
||
// 提交事务
|
||
if err := tx.Commit().Error; err != nil {
|
||
return fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ClaimCopy 领取文案(新版本:直接返回文案信息,不再创建领取记录)
|
||
func (s *EmployeeService) ClaimCopy(employeeID int, copyID int, productID int) (map[string]interface{}, error) {
|
||
// 检查文案是否存在且可用(注意:新数据库中status有更多状态)
|
||
// assign_authors: 已分配给作者,可以直接发布
|
||
// draft: 草稿状态
|
||
// approved: 已审核通过
|
||
var copy models.Article
|
||
if err := database.DB.Where("id = ? AND status IN ?", copyID, []string{"draft", "approved", "assign_authors"}).First(©).Error; err != nil {
|
||
return nil, errors.New("文案不存在或不可用")
|
||
}
|
||
|
||
// 获取关联的图片(如果有ai_article_images表)
|
||
var images []string
|
||
// TODO: 从 ai_article_images 表获取图片
|
||
|
||
return map[string]interface{}{
|
||
"copy": map[string]interface{}{
|
||
"id": copy.ID,
|
||
"title": copy.Title,
|
||
"content": copy.Content,
|
||
"images": images,
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
// ClaimRandomCopy 随机领取文案
|
||
func (s *EmployeeService) ClaimRandomCopy(employeeID int, productID int) (map[string]interface{}, error) {
|
||
// 查询未领取的可用文案(注意:新数据库中status有更多状态)
|
||
var copy models.Article
|
||
query := database.DB.Where("product_id = ? AND status IN ?", productID, []string{"draft", "approved", "assign_authors"})
|
||
|
||
if err := query.Order("RAND()").First(©).Error; err != nil {
|
||
return nil, errors.New("暂无可领取的文案")
|
||
}
|
||
|
||
// 领取该文案
|
||
return s.ClaimCopy(employeeID, copy.ID, productID)
|
||
}
|
||
|
||
// Publish 发布内容
|
||
func (s *EmployeeService) Publish(employeeID int, req PublishRequest) (int, error) {
|
||
// 检查文案是否存在
|
||
var copy models.Article
|
||
if err := database.DB.First(©, req.CopyID).Error; err != nil {
|
||
return 0, errors.New("文案不存在")
|
||
}
|
||
|
||
// 检查文案是否已被发布
|
||
if copy.Status == "published" || copy.Status == "published_review" {
|
||
return 0, errors.New("文案已被发布或处于发布审核中")
|
||
}
|
||
|
||
// 验证标题不为空
|
||
if req.Title == "" {
|
||
return 0, errors.New("标题不能为空")
|
||
}
|
||
trimmedTitle := strings.TrimSpace(req.Title)
|
||
if trimmedTitle == "" {
|
||
return 0, errors.New("标题不能为空")
|
||
}
|
||
|
||
// 验证内容不为空(从article表中获取)
|
||
if copy.Content == "" || strings.TrimSpace(copy.Content) == "" {
|
||
return 0, errors.New("文案内容不能为空")
|
||
}
|
||
|
||
// 验证文案至少有一张图片
|
||
var imageCount int64
|
||
database.DB.Model(&models.ArticleImage{}).Where("article_id = ?", req.CopyID).Count(&imageCount)
|
||
if imageCount < 1 {
|
||
return 0, errors.New("请至少添加一张图片")
|
||
}
|
||
|
||
// 获取员工信息
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
// 开启事务
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
now := time.Now()
|
||
var recordID int
|
||
var publishStatus string = "published_review" // 默认为发布审核中
|
||
var errMessage string
|
||
|
||
// 1. 更新文案状态为 published_review
|
||
if err := tx.Model(&models.Article{}).Where("id = ?", req.CopyID).Updates(map[string]interface{}{
|
||
"status": publishStatus,
|
||
"publish_user_id": employeeID,
|
||
"publish_time": now,
|
||
}).Error; err != nil {
|
||
publishStatus = "failed"
|
||
errMessage = "更新文案状态失败: " + err.Error()
|
||
|
||
// 记录失败日志
|
||
s.createLog(tx, employeeID, "article_publish_update_failed", "article", ©.ID,
|
||
"发布文案-更新状态失败", errMessage, "error")
|
||
|
||
tx.Rollback()
|
||
return 0, errors.New(errMessage)
|
||
}
|
||
|
||
// 记录更新文案状态日志
|
||
s.createLog(tx, employeeID, "article_status_update", "article", ©.ID,
|
||
fmt.Sprintf("文案ID:%d 状态更新为 %s", copy.ID, publishStatus), "", "success")
|
||
|
||
// 2. 创建发布记录
|
||
record := models.PublishRecord{
|
||
ArticleID: ©.ID,
|
||
EnterpriseID: employee.EnterpriseID,
|
||
ProductID: copy.ProductID,
|
||
Topic: copy.Topic,
|
||
Title: req.Title,
|
||
CreatedUserID: employeeID,
|
||
PublishUserID: &employeeID,
|
||
Status: publishStatus,
|
||
PublishTime: &now,
|
||
PublishLink: req.PublishLink,
|
||
WordCount: copy.WordCount,
|
||
ImageCount: copy.ImageCount,
|
||
Channel: copy.Channel,
|
||
}
|
||
|
||
if err := tx.Create(&record).Error; err != nil {
|
||
publishStatus = "failed"
|
||
errMessage = "创建发布记录失败: " + err.Error()
|
||
|
||
// 记录失败日志
|
||
s.createLog(tx, employeeID, "publish_record_create_failed", "publish_record", nil,
|
||
"创建发布记录失败", errMessage, "error")
|
||
|
||
// 回滚文案状态为failed
|
||
tx.Model(&models.Article{}).Where("id = ?", req.CopyID).Update("status", "failed")
|
||
s.createLog(tx, employeeID, "article_status_rollback", "article", ©.ID,
|
||
fmt.Sprintf("文案ID:%d 状态回滚为 failed", copy.ID), errMessage, "warning")
|
||
|
||
tx.Rollback()
|
||
return 0, errors.New(errMessage)
|
||
}
|
||
|
||
recordID = record.ID
|
||
|
||
// 记录创建发布记录日志
|
||
s.createLog(tx, employeeID, "publish_record_create", "publish_record", &recordID,
|
||
fmt.Sprintf("创建发布记录ID:%d, 文案ID:%d, 状态:%s", recordID, copy.ID, publishStatus), "", "success")
|
||
|
||
// 提交事务
|
||
if err := tx.Commit().Error; err != nil {
|
||
publishStatus = "failed"
|
||
errMessage = "提交事务失败: " + err.Error()
|
||
|
||
// 事务提交失败,需要在新事务中更新状态为failed
|
||
database.DB.Model(&models.Article{}).Where("id = ?", req.CopyID).Update("status", "failed")
|
||
s.createLog(nil, employeeID, "publish_transaction_failed", "article", ©.ID,
|
||
"发布事务提交失败,状态更新为failed", errMessage, "error")
|
||
|
||
return 0, errors.New(errMessage)
|
||
}
|
||
|
||
// 成功日志
|
||
s.createLog(nil, employeeID, "article_publish_success", "article", ©.ID,
|
||
fmt.Sprintf("文案ID:%d 发布成功,记录ID:%d", copy.ID, recordID), "", "success")
|
||
|
||
return recordID, nil
|
||
}
|
||
|
||
// createLog 创建日志记录
|
||
func (s *EmployeeService) createLog(tx *gorm.DB, userID int, action, targetType string, targetID *int, description, errMsg, status string) {
|
||
// 如果没有请求和响应数据,设置为空JSON对象
|
||
requestData := "{}"
|
||
responseData := "{}"
|
||
|
||
log := models.Log{
|
||
UserID: &userID,
|
||
Action: action,
|
||
TargetType: targetType,
|
||
TargetID: targetID,
|
||
Description: description,
|
||
RequestData: requestData,
|
||
ResponseData: responseData,
|
||
Status: status,
|
||
ErrorMessage: errMsg,
|
||
}
|
||
|
||
db := database.DB
|
||
if tx != nil {
|
||
db = tx
|
||
}
|
||
|
||
if err := db.Create(&log).Error; err != nil {
|
||
// 日志创建失败不影响主流程,只输出错误
|
||
fmt.Printf("创建日志失败: %v\n", err)
|
||
}
|
||
}
|
||
|
||
// GetMyPublishRecords 获取我的发布记录(优化版:批量预加载)
|
||
func (s *EmployeeService) GetMyPublishRecords(employeeID int, page, pageSize int) (map[string]interface{}, error) {
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = 10
|
||
}
|
||
|
||
var total int64
|
||
var records []models.PublishRecord
|
||
|
||
// 查询总数(使用publish_user_id字段)
|
||
database.DB.Model(&models.PublishRecord{}).Where("publish_user_id = ?", employeeID).Count(&total)
|
||
|
||
// 查询列表(不使用Preload,直接使用冗余字段)
|
||
offset := (page - 1) * pageSize
|
||
err := database.DB.Where("publish_user_id = ?", employeeID).
|
||
Order("publish_time DESC").
|
||
Limit(pageSize).
|
||
Offset(offset).
|
||
Find(&records).Error
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 批量收集所有article_id和product_id
|
||
articleIDs := make([]int, 0)
|
||
productIDs := make(map[int]bool)
|
||
for _, record := range records {
|
||
if record.ArticleID != nil && *record.ArticleID > 0 {
|
||
articleIDs = append(articleIDs, *record.ArticleID)
|
||
}
|
||
if record.ProductID > 0 {
|
||
productIDs[record.ProductID] = true
|
||
}
|
||
}
|
||
|
||
// 批量查询产品
|
||
productMap := make(map[int]string)
|
||
if len(productIDs) > 0 {
|
||
productIDList := make([]int, 0, len(productIDs))
|
||
for pid := range productIDs {
|
||
productIDList = append(productIDList, pid)
|
||
}
|
||
var products []models.Product
|
||
if err := database.DB.Where("id IN ?", productIDList).Select("id, name").Find(&products).Error; err == nil {
|
||
for _, p := range products {
|
||
productMap[p.ID] = p.Name
|
||
}
|
||
}
|
||
}
|
||
|
||
// 批量查询文章图片
|
||
imagesMap := make(map[int][]map[string]interface{})
|
||
if len(articleIDs) > 0 {
|
||
var articleImages []models.ArticleImage
|
||
if err := database.DB.Where("article_id IN ?", articleIDs).Order("article_id ASC, sort_order ASC").Find(&articleImages).Error; err == nil {
|
||
for _, img := range articleImages {
|
||
if _, ok := imagesMap[img.ArticleID]; !ok {
|
||
imagesMap[img.ArticleID] = make([]map[string]interface{}, 0)
|
||
}
|
||
imagesMap[img.ArticleID] = append(imagesMap[img.ArticleID], map[string]interface{}{
|
||
"id": img.ID,
|
||
"image_url": img.ImageURL,
|
||
"image_thumb_url": img.ImageThumbURL,
|
||
"sort_order": img.SortOrder,
|
||
"keywords_name": img.KeywordsName,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 批量查询文章标签
|
||
tagsMap := make(map[int][]string)
|
||
if len(articleIDs) > 0 {
|
||
var articleTags []models.ArticleTag
|
||
if err := database.DB.Where("article_id IN ?", articleIDs).Select("article_id, coze_tag").Find(&articleTags).Error; err == nil {
|
||
for _, tag := range articleTags {
|
||
if tag.CozeTag != "" {
|
||
for _, t := range splitTags(tag.CozeTag) {
|
||
if t != "" {
|
||
tagsMap[tag.ArticleID] = append(tagsMap[tag.ArticleID], t)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 构造返回数据
|
||
list := make([]map[string]interface{}, 0, len(records))
|
||
for _, record := range records {
|
||
publishTimeStr := ""
|
||
if record.PublishTime != nil {
|
||
publishTimeStr = record.PublishTime.Format("2006-01-02 15:04:05")
|
||
}
|
||
|
||
// 从批量查询结果中获取数据
|
||
productName := productMap[record.ProductID]
|
||
var images []map[string]interface{}
|
||
var tags []string
|
||
|
||
if record.ArticleID != nil && *record.ArticleID > 0 {
|
||
images = imagesMap[*record.ArticleID]
|
||
tags = tagsMap[*record.ArticleID]
|
||
}
|
||
|
||
// 确保返回空数组而不null
|
||
if images == nil {
|
||
images = make([]map[string]interface{}, 0)
|
||
}
|
||
if tags == nil {
|
||
tags = make([]string, 0)
|
||
}
|
||
|
||
list = append(list, map[string]interface{}{
|
||
"id": record.ID,
|
||
"product_id": record.ProductID,
|
||
"product_name": productName,
|
||
"topic": record.Topic,
|
||
"title": record.Title,
|
||
"publish_link": record.PublishLink,
|
||
"publish_time": publishTimeStr,
|
||
"images": images,
|
||
"tags": tags,
|
||
})
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"total": total,
|
||
"list": list,
|
||
}, nil
|
||
}
|
||
|
||
// GetPublishRecordDetail 获取发布记录详情(优化版)
|
||
func (s *EmployeeService) GetPublishRecordDetail(employeeID int, recordID int) (map[string]interface{}, error) {
|
||
var record models.PublishRecord
|
||
err := database.DB.Where("id = ?", recordID).First(&record).Error
|
||
if err != nil {
|
||
return nil, errors.New("发布记录不存在")
|
||
}
|
||
|
||
publishTimeStr := ""
|
||
if record.PublishTime != nil {
|
||
publishTimeStr = record.PublishTime.Format("2006-01-02 15:04:05")
|
||
}
|
||
|
||
// 通过ArticleID关联查询文章内容
|
||
content := ""
|
||
var images []map[string]interface{}
|
||
var tags []string
|
||
articleCozeTag := ""
|
||
|
||
// 查询产品名称
|
||
productName := ""
|
||
if record.ProductID > 0 {
|
||
var product models.Product
|
||
// 优化:只查询需要的字段
|
||
if err := database.DB.Select("name").Where("id = ?", record.ProductID).First(&product).Error; err == nil {
|
||
productName = product.Name
|
||
}
|
||
}
|
||
|
||
if record.ArticleID != nil && *record.ArticleID > 0 {
|
||
// 优化:使用单次查询获取文章基本信息
|
||
var article models.Article
|
||
if err := database.DB.Select("id, content, coze_tag").Where("id = ?", *record.ArticleID).First(&article).Error; err == nil {
|
||
content = article.Content
|
||
articleCozeTag = article.CozeTag
|
||
|
||
// 并行查询图片和标签(使用 goroutine)
|
||
var wg sync.WaitGroup
|
||
wg.Add(2)
|
||
|
||
// 查询图片
|
||
go func() {
|
||
defer wg.Done()
|
||
var articleImages []models.ArticleImage
|
||
if err := database.DB.Select("id, image_url, image_thumb_url, sort_order, keywords_name").
|
||
Where("article_id = ?", article.ID).
|
||
Order("sort_order ASC").
|
||
Find(&articleImages).Error; err == nil {
|
||
for _, img := range articleImages {
|
||
images = append(images, map[string]interface{}{
|
||
"id": img.ID,
|
||
"image_url": img.ImageURL,
|
||
"image_thumb_url": img.ImageThumbURL,
|
||
"sort_order": img.SortOrder,
|
||
"keywords_name": img.KeywordsName,
|
||
})
|
||
}
|
||
}
|
||
}()
|
||
|
||
// 查询标签
|
||
go func() {
|
||
defer wg.Done()
|
||
var articleTag models.ArticleTag
|
||
if err := database.DB.Select("coze_tag").Where("article_id = ?", article.ID).First(&articleTag).Error; err == nil && articleTag.CozeTag != "" {
|
||
// 使用ai_article_tags表的标签
|
||
articleCozeTag = articleTag.CozeTag
|
||
}
|
||
}()
|
||
|
||
wg.Wait()
|
||
|
||
// 解析标签
|
||
if articleCozeTag != "" {
|
||
for _, tag := range splitTags(articleCozeTag) {
|
||
if tag != "" {
|
||
tags = append(tags, tag)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 确保返回空数组而不null
|
||
if images == nil {
|
||
images = make([]map[string]interface{}, 0)
|
||
}
|
||
if tags == nil {
|
||
tags = make([]string, 0)
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"id": record.ID,
|
||
"article_id": record.ArticleID,
|
||
"product_id": record.ProductID,
|
||
"product_name": productName,
|
||
"topic": record.Topic,
|
||
"title": record.Title,
|
||
"content": content,
|
||
"images": images,
|
||
"tags": tags,
|
||
"coze_tag": articleCozeTag,
|
||
"publish_link": record.PublishLink,
|
||
"status": record.Status,
|
||
"publish_time": publishTimeStr,
|
||
}, nil
|
||
}
|
||
|
||
// splitTags 分割标签字符串
|
||
func splitTags(tagStr string) []string {
|
||
if tagStr == "" {
|
||
return []string{}
|
||
}
|
||
|
||
// 尝试多种分隔符
|
||
var tags []string
|
||
|
||
// 先尝试逗号分割
|
||
if strings.Contains(tagStr, ",") {
|
||
for _, tag := range strings.Split(tagStr, ",") {
|
||
tag = strings.TrimSpace(tag)
|
||
if tag != "" {
|
||
tags = append(tags, tag)
|
||
}
|
||
}
|
||
} else if strings.Contains(tagStr, ",") {
|
||
// 中文逗号
|
||
for _, tag := range strings.Split(tagStr, ",") {
|
||
tag = strings.TrimSpace(tag)
|
||
if tag != "" {
|
||
tags = append(tags, tag)
|
||
}
|
||
}
|
||
} else if strings.Contains(tagStr, "|") {
|
||
// 竪线分隔
|
||
for _, tag := range strings.Split(tagStr, "|") {
|
||
tag = strings.TrimSpace(tag)
|
||
if tag != "" {
|
||
tags = append(tags, tag)
|
||
}
|
||
}
|
||
} else {
|
||
// 单个标签
|
||
tags = append(tags, strings.TrimSpace(tagStr))
|
||
}
|
||
|
||
return tags
|
||
}
|
||
|
||
// GetProducts 获取产品列表(按企业和状态筛选,支持分页)
|
||
func (s *EmployeeService) GetProducts(employeeID int, page, pageSize int) ([]map[string]interface{}, bool, error) {
|
||
// 参数校验
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = 10
|
||
}
|
||
|
||
ctx := context.Background()
|
||
|
||
// 获取当前用户信息,确定所属企业
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return nil, false, fmt.Errorf("获取用户信息失败: %w", err)
|
||
}
|
||
|
||
// 1. 尝试从缓存获取产品列表(10分钟缓存)
|
||
cacheKey := fmt.Sprintf("products:enterprise:%d:page:%d:size:%d", employee.EnterpriseID, page, pageSize)
|
||
type CachedProducts struct {
|
||
Products []map[string]interface{} `json:"products"`
|
||
HasMore bool `json:"has_more"`
|
||
}
|
||
var cached CachedProducts
|
||
if err := utils.GetCache(ctx, cacheKey, &cached); err == nil {
|
||
log.Printf("命中产品列表缓存: 企业ID=%d, page=%d", employee.EnterpriseID, page)
|
||
return cached.Products, cached.HasMore, nil
|
||
}
|
||
|
||
// 2. 缓存未命中,从数据库查询
|
||
// 按企业和状态筛选产品,按ID排序
|
||
offset := (page - 1) * pageSize
|
||
var products []models.Product
|
||
query := database.DB.Where("enterprise_id = ? AND status = ?", employee.EnterpriseID, "active")
|
||
if err := query.Order("id ASC").Offset(offset).Limit(pageSize + 1).Find(&products).Error; err != nil {
|
||
return nil, false, err
|
||
}
|
||
|
||
// 判断是否还有更多数据
|
||
hasMore := false
|
||
if len(products) > pageSize {
|
||
hasMore = true
|
||
products = products[:pageSize]
|
||
}
|
||
|
||
result := make([]map[string]interface{}, 0, len(products))
|
||
for _, product := range products {
|
||
result = append(result, map[string]interface{}{
|
||
"id": product.ID,
|
||
"name": product.Name,
|
||
"image": product.ImageURL,
|
||
"knowledge": product.Knowledge,
|
||
})
|
||
}
|
||
|
||
// 3. 存入缓存(10分钟)
|
||
cachedData := CachedProducts{
|
||
Products: result,
|
||
HasMore: hasMore,
|
||
}
|
||
if err := utils.SetCache(ctx, cacheKey, cachedData, 10*time.Minute); err != nil {
|
||
log.Printf("设置产品列表缓存失败: %v", err)
|
||
}
|
||
|
||
return result, hasMore, nil
|
||
}
|
||
|
||
// PublishRequest 发布请求参数
|
||
type PublishRequest struct {
|
||
CopyID int `json:"copy_id" binding:"required"`
|
||
Title string `json:"title" binding:"required"`
|
||
Content string `json:"content" binding:"required"`
|
||
PublishLink string `json:"publish_link"`
|
||
XHSNoteID string `json:"xhs_note_id"`
|
||
}
|
||
|
||
// UpdatePublishRecordRequest 更新发布记录请求参数
|
||
type UpdatePublishRecordRequest struct {
|
||
Title *string `json:"title"` // 标题(可选)
|
||
Content *string `json:"content"` // 内容(可选)
|
||
Images []UpdateImageRequest `json:"images"` // 图片列表(可选)
|
||
Tags []string `json:"tags"` // 标签列表(可选)
|
||
}
|
||
|
||
// UpdateImageRequest 更新图片请求参数
|
||
type UpdateImageRequest struct {
|
||
ImageURL string `json:"image_url" binding:"required"` // 图片URL
|
||
ImageThumbURL string `json:"image_thumb_url"` // 缩略图URL(可选)
|
||
SortOrder int `json:"sort_order"` // 排序
|
||
KeywordsName string `json:"keywords_name"` // 关键词名称(可选)
|
||
}
|
||
|
||
// UpdatePublishRecord 编辑发布记录(修改标题、内容、图片、标签)
|
||
func (s *EmployeeService) UpdatePublishRecord(employeeID int, recordID int, req UpdatePublishRecordRequest) error {
|
||
// 1. 查询发布记录
|
||
var record models.PublishRecord
|
||
if err := database.DB.First(&record, recordID).Error; err != nil {
|
||
return fmt.Errorf("发布记录不存在: %w", err)
|
||
}
|
||
|
||
// 2. 权限检查:只有创建者或管理员可以编辑
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
if record.CreatedUserID != employeeID && employee.Role != "admin" {
|
||
return errors.New("无权编辑此发布记录")
|
||
}
|
||
|
||
// 3. 开启事务
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 4. 更新发布记录基本信息
|
||
updates := make(map[string]interface{})
|
||
if req.Title != nil {
|
||
updates["title"] = *req.Title
|
||
}
|
||
|
||
// 如果有更新字段,执行更新
|
||
if len(updates) > 0 {
|
||
if err := tx.Model(&record).Updates(updates).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("更新发布记录失败: %w", err)
|
||
}
|
||
}
|
||
|
||
// 5. 更新关联的文章内容(如果有article_id)
|
||
if record.ArticleID != nil && *record.ArticleID > 0 {
|
||
articleUpdates := make(map[string]interface{})
|
||
|
||
if req.Title != nil {
|
||
articleUpdates["title"] = *req.Title
|
||
}
|
||
if req.Content != nil {
|
||
articleUpdates["content"] = *req.Content
|
||
}
|
||
|
||
if len(articleUpdates) > 0 {
|
||
if err := tx.Model(&models.Article{}).Where("id = ?", *record.ArticleID).Updates(articleUpdates).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("更新文章内容失败: %w", err)
|
||
}
|
||
}
|
||
|
||
// 6. 更新图片(如果提供)
|
||
if req.Images != nil && len(req.Images) > 0 {
|
||
// 先删除旧图片
|
||
if err := tx.Where("article_id = ?", *record.ArticleID).Delete(&models.ArticleImage{}).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("删除旧图片失败: %w", err)
|
||
}
|
||
|
||
// 插入新图片
|
||
for _, img := range req.Images {
|
||
articleImage := models.ArticleImage{
|
||
EnterpriseID: record.EnterpriseID,
|
||
ArticleID: *record.ArticleID,
|
||
ImageURL: img.ImageURL,
|
||
ImageThumbURL: img.ImageThumbURL,
|
||
SortOrder: img.SortOrder,
|
||
KeywordsName: img.KeywordsName,
|
||
ImageSource: 2, // 2=手动修改
|
||
}
|
||
if err := tx.Create(&articleImage).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("创建新图片失败: %w", err)
|
||
}
|
||
}
|
||
|
||
// 更新文章的图片数量
|
||
if err := tx.Model(&models.Article{}).Where("id = ?", *record.ArticleID).Update("image_count", len(req.Images)).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("更新图片数量失败: %w", err)
|
||
}
|
||
}
|
||
|
||
// 7. 更新标签(如果提供)
|
||
if req.Tags != nil && len(req.Tags) > 0 {
|
||
tagsStr := strings.Join(req.Tags, ",")
|
||
|
||
// 更新或创建 ai_article_tags 记录
|
||
var articleTag models.ArticleTag
|
||
err := tx.Where("article_id = ?", *record.ArticleID).First(&articleTag).Error
|
||
|
||
if err == gorm.ErrRecordNotFound {
|
||
// 创建新标签记录
|
||
articleTag = models.ArticleTag{
|
||
EnterpriseID: record.EnterpriseID,
|
||
ArticleID: *record.ArticleID,
|
||
CozeTag: tagsStr,
|
||
}
|
||
if err := tx.Create(&articleTag).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("创建标签失败: %w", err)
|
||
}
|
||
} else if err == nil {
|
||
// 更新已有标签
|
||
if err := tx.Model(&articleTag).Update("coze_tag", tagsStr).Error; err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("更新标签失败: %w", err)
|
||
}
|
||
} else {
|
||
tx.Rollback()
|
||
return fmt.Errorf("查询标签失败: %w", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 8. 记录操作日志
|
||
s.createLog(tx, employeeID, "publish_record_update", "publish_record", &recordID,
|
||
fmt.Sprintf("编辑发布记录ID:%d", recordID), "", "success")
|
||
|
||
// 9. 提交事务
|
||
if err := tx.Commit().Error; err != nil {
|
||
return fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// RepublishRecord 重新发布种草内容到小红书
|
||
func (s *EmployeeService) RepublishRecord(employeeID int, recordID int) (string, error) {
|
||
// 1. 查询发布记录
|
||
var record models.PublishRecord
|
||
if err := database.DB.First(&record, recordID).Error; err != nil {
|
||
return "", fmt.Errorf("发布记录不存在: %w", err)
|
||
}
|
||
|
||
// 2. 权限检查
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if record.CreatedUserID != employeeID && employee.Role != "admin" {
|
||
return "", errors.New("无权重新发布此内容")
|
||
}
|
||
|
||
// 3. 检查用户是否绑定小红书
|
||
if employee.IsBoundXHS != 1 {
|
||
return "", errors.New("请先绑定小红书账号")
|
||
}
|
||
|
||
// 4. 查询对应的 author 记录获取Cookie
|
||
var author models.Author
|
||
if err := database.DB.Where(
|
||
"phone = ? AND enterprise_id = ? AND channel = 1 AND status = 'active'",
|
||
employee.Phone, employee.EnterpriseID,
|
||
).First(&author).Error; err != nil {
|
||
return "", fmt.Errorf("未找到有效的小红书作者记录: %w", err)
|
||
}
|
||
|
||
if author.XHSCookie == "" {
|
||
return "", errors.New("小红书Cookie已失效,请重新绑定")
|
||
}
|
||
|
||
// 5. 获取文章内容
|
||
var content string
|
||
var images []string
|
||
var tags []string
|
||
|
||
if record.ArticleID != nil && *record.ArticleID > 0 {
|
||
// 从关联的文章获取内容
|
||
var article models.Article
|
||
if err := database.DB.First(&article, *record.ArticleID).Error; err == nil {
|
||
content = article.Content
|
||
|
||
// 获取图片
|
||
var articleImages []models.ArticleImage
|
||
if err := database.DB.Where("article_id = ?", article.ID).Order("sort_order ASC").Find(&articleImages).Error; err == nil {
|
||
for _, img := range articleImages {
|
||
if img.ImageURL != "" {
|
||
images = append(images, img.ImageURL)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取标签
|
||
var articleTag models.ArticleTag
|
||
if err := database.DB.Where("article_id = ?", article.ID).First(&articleTag).Error; err == nil {
|
||
if articleTag.CozeTag != "" {
|
||
tags = splitTags(articleTag.CozeTag)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if content == "" {
|
||
return "", errors.New("发布记录无关联内容")
|
||
}
|
||
|
||
// 6. 解析Cookie
|
||
var cookies interface{}
|
||
if err := json.Unmarshal([]byte(author.XHSCookie), &cookies); err != nil {
|
||
return "", fmt.Errorf("解析Cookie失败: %w", err)
|
||
}
|
||
|
||
// 7. 构造发布配置
|
||
publishConfig := map[string]interface{}{
|
||
"cookies": cookies,
|
||
"title": record.Title,
|
||
"content": content,
|
||
"images": images,
|
||
"tags": tags,
|
||
}
|
||
|
||
// 8. 调用Python脚本发布
|
||
backendDir := filepath.Join("..", "backend")
|
||
pythonScript := filepath.Join(backendDir, "xhs_publish.py")
|
||
pythonCmd := getPythonPath(backendDir)
|
||
|
||
// 将配置写入临时文件
|
||
configFile := filepath.Join(backendDir, fmt.Sprintf("publish_config_temp_%d_%d.json", employeeID, recordID))
|
||
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)
|
||
|
||
// 执行Python脚本
|
||
cmd := exec.Command(pythonCmd, pythonScript, "--config", configFile)
|
||
cmd.Dir = backendDir
|
||
|
||
var stdout, stderr bytes.Buffer
|
||
cmd.Stdout = &stdout
|
||
cmd.Stderr = &stderr
|
||
|
||
err = cmd.Run()
|
||
|
||
if stderr.Len() > 0 {
|
||
log.Printf("[Python日志-重新发布%d] %s", recordID, stderr.String())
|
||
}
|
||
|
||
if err != nil {
|
||
return "", fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
|
||
}
|
||
|
||
// 9. 解析发布结果
|
||
outputStr := stdout.String()
|
||
lines := strings.Split(strings.TrimSpace(outputStr), "\n")
|
||
var result map[string]interface{}
|
||
found := false
|
||
|
||
for i := len(lines) - 1; i >= 0; i-- {
|
||
line := strings.TrimSpace(lines[i])
|
||
if line == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(line, "{") {
|
||
if err := json.Unmarshal([]byte(line), &result); err == nil {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if !found {
|
||
return "", fmt.Errorf("Python脚本未返回有效JSON结果, output: %s", outputStr)
|
||
}
|
||
|
||
success, ok := result["success"].(bool)
|
||
if !ok || !success {
|
||
errMsg := "未知错误"
|
||
if errStr, ok := result["error"].(string); ok {
|
||
errMsg = errStr
|
||
}
|
||
return "", fmt.Errorf("重新发布失败: %s", errMsg)
|
||
}
|
||
|
||
// 10. 提取发布链接
|
||
publishLink := ""
|
||
if note, ok := result["note"].(map[string]interface{}); ok {
|
||
if noteID, ok := note["note_id"].(string); ok {
|
||
publishLink = fmt.Sprintf("https://www.xiaohongshu.com/explore/%s", noteID)
|
||
}
|
||
}
|
||
|
||
// 11. 更新发布记录的链接和时间
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
now := time.Now()
|
||
if err := tx.Model(&record).Updates(map[string]interface{}{
|
||
"publish_link": publishLink,
|
||
"publish_time": now,
|
||
"status": "published",
|
||
}).Error; err != nil {
|
||
tx.Rollback()
|
||
return "", fmt.Errorf("更新发布记录失败: %w", err)
|
||
}
|
||
|
||
// 记录日志
|
||
s.createLog(tx, employeeID, "publish_record_republish", "publish_record", &recordID,
|
||
fmt.Sprintf("重新发布记录ID:%d, 链接:%s", recordID, publishLink), "", "success")
|
||
|
||
if err := tx.Commit().Error; err != nil {
|
||
return "", fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
|
||
return publishLink, nil
|
||
}
|
||
|
||
// SaveQRCodeLogin 保存扫码登录的绑定信息
|
||
// 复用BindXHS的保存逻辑,但不需要调用Python后端,直接保存数据
|
||
func (s *EmployeeService) SaveQRCodeLogin(employeeID int, cookiesFull []interface{}, userInfo map[string]interface{}, loginState map[string]interface{}) error {
|
||
ctx := context.Background()
|
||
|
||
// 查询用户信息
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return fmt.Errorf("获取用户信息失败: %w", err)
|
||
}
|
||
|
||
// 优先使用 login_state(完整登录状态),如果没有则降级使用cookies
|
||
var loginStateJSON string
|
||
|
||
if len(loginState) > 0 {
|
||
// 新版:使用完整的login_state(包含cookies + localStorage + sessionStorage)
|
||
loginStateBytes, err := json.Marshal(loginState)
|
||
if err == nil {
|
||
loginStateJSON = string(loginStateBytes)
|
||
log.Printf("扫码登录 - 用户%d - 完整LoginState长度: %d", employeeID, len(loginStateJSON))
|
||
} else {
|
||
log.Printf("扫码登录 - 用户%d - 序列化login_state失败: %v", employeeID, err)
|
||
}
|
||
} else if len(cookiesFull) > 0 {
|
||
// 降级:使用旧版本的 cookies_full
|
||
log.Printf("扫码登录 - 用户%d - 警告: 未找到login_state,降级使用cookies", employeeID)
|
||
cookiesBytes, err := json.Marshal(cookiesFull)
|
||
if err == nil {
|
||
loginStateJSON = string(cookiesBytes)
|
||
log.Printf("扫码登录 - 用户%d - Cookie长度: %d", employeeID, len(loginStateJSON))
|
||
}
|
||
}
|
||
|
||
if loginStateJSON == "" {
|
||
log.Printf("扫码登录 - 用户%d - 错误: 未能获取到任何登录数据", employeeID)
|
||
return errors.New("登录成功但未能获取到登录数据,请重试")
|
||
}
|
||
|
||
// 提取小红书账号昵称
|
||
xhsNickname := "小红书用户"
|
||
xhsPhone := "" // 扫码登录没有手机号
|
||
|
||
if nickname, ok := userInfo["nickname"].(string); ok && nickname != "" {
|
||
xhsNickname = nickname
|
||
} else if username, ok := userInfo["username"].(string); ok && username != "" {
|
||
xhsNickname = username
|
||
}
|
||
|
||
// 尝试从 userInfo 提取 red_id 作为 phone
|
||
if redID, ok := userInfo["red_id"].(string); ok && redID != "" {
|
||
xhsPhone = redID
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
// 开启事务
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 创建或更新 ai_authors 表的小红书账号记录
|
||
log.Printf("扫码登录 - 用户%d - 开始创建或更新作者记录", employeeID)
|
||
|
||
author := models.Author{
|
||
EnterpriseID: employee.EnterpriseID,
|
||
CreatedUserID: employeeID,
|
||
Phone: employee.Phone,
|
||
AuthorName: xhsNickname,
|
||
XHSCookie: loginStateJSON,
|
||
XHSPhone: xhsPhone,
|
||
XHSAccount: xhsNickname,
|
||
BoundAt: &now,
|
||
Channel: 1, // 1=小红书
|
||
Status: "active",
|
||
}
|
||
|
||
// 查询是否已存在记录
|
||
var existingAuthor models.Author
|
||
err := database.DB.Where("created_user_id = ? AND enterprise_id = ? AND channel = 1",
|
||
employeeID, employee.EnterpriseID).First(&existingAuthor).Error
|
||
|
||
if err == gorm.ErrRecordNotFound {
|
||
// 创建新记录
|
||
if err := tx.Create(&author).Error; err != nil {
|
||
tx.Rollback()
|
||
log.Printf("扫码登录 - 用户%d - 创建作者记录失败: %v", employeeID, err)
|
||
return fmt.Errorf("创建作者记录失败: %w", err)
|
||
}
|
||
log.Printf("扫码登录 - 用户%d - 创建作者记录成功", employeeID)
|
||
} else {
|
||
// 更新现有记录
|
||
if err := tx.Model(&models.Author{}).Where(
|
||
"created_user_id = ? AND enterprise_id = ? AND channel = 1",
|
||
employeeID, employee.EnterpriseID,
|
||
).Updates(map[string]interface{}{
|
||
"author_name": xhsNickname,
|
||
"xhs_cookie": loginStateJSON,
|
||
"xhs_phone": xhsPhone,
|
||
"xhs_account": xhsNickname,
|
||
"bound_at": &now,
|
||
"status": "active",
|
||
"phone": employee.Phone,
|
||
}).Error; err != nil {
|
||
tx.Rollback()
|
||
log.Printf("扫码登录 - 用户%d - 更新作者记录失败: %v", employeeID, err)
|
||
return fmt.Errorf("更新作者记录失败: %w", err)
|
||
}
|
||
log.Printf("扫码登录 - 用户%d - 更新作者记录成功", employeeID)
|
||
}
|
||
|
||
// 更新 ai_users 表的绑定标识
|
||
if err := tx.Model(&employee).Update("is_bound_xhs", 1).Error; err != nil {
|
||
tx.Rollback()
|
||
log.Printf("扫码登录 - 用户%d - 更新用户绑定标识失败: %v", employeeID, err)
|
||
return fmt.Errorf("更新用户绑定标识失败: %w", err)
|
||
}
|
||
|
||
log.Printf("扫码登录 - 用户%d - 数据库更新成功", employeeID)
|
||
|
||
// 提交事务
|
||
if err := tx.Commit().Error; err != nil {
|
||
log.Printf("扫码登录 - 用户%d - 事务提交失败: %v", employeeID, err)
|
||
return fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
|
||
// 清除相关缓存
|
||
cacheService := NewCacheService()
|
||
if err := cacheService.ClearUserRelatedCache(ctx, employeeID); err != nil {
|
||
log.Printf("清除缓存失败: %v", err)
|
||
}
|
||
|
||
log.Printf("扫码登录 - 用户%d - 绑定成功 - 账号: %s", employeeID, xhsNickname)
|
||
return nil
|
||
}
|
||
|
||
// SaveLogin 保存验证码登录的信息
|
||
func (s *EmployeeService) SaveLogin(employeeID int, cookiesFull []interface{}, storageState map[string]interface{}, storageStatePath string, userInfo map[string]interface{}) error {
|
||
ctx := context.Background()
|
||
|
||
// 查询用户信息
|
||
var employee models.User
|
||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||
return fmt.Errorf("获取用户信息失败: %w", err)
|
||
}
|
||
|
||
// 优先使用 cookies_full,如果没有则降级使用 storage_state
|
||
var loginStateJSON string
|
||
|
||
if len(cookiesFull) > 0 {
|
||
// 优先:直接使用 cookies_full(AdsPower Cookie)
|
||
cookiesBytes, err := json.Marshal(cookiesFull)
|
||
if err == nil {
|
||
loginStateJSON = string(cookiesBytes)
|
||
log.Printf("验证码登录 - 用户%d - 使用cookies_full,Cookie长度: %d", employeeID, len(loginStateJSON))
|
||
} else {
|
||
log.Printf("验证码登录 - 用户%d - 序列化cookies_full失败: %v", employeeID, err)
|
||
}
|
||
} else if len(storageState) > 0 {
|
||
// 降级:从 storage_state 中提取 cookies
|
||
log.Printf("验证码登录 - 用户%d - 警告: 未找到cookies_full,从storage_state提取cookies", employeeID)
|
||
if cookies, ok := storageState["cookies"].([]interface{}); ok && len(cookies) > 0 {
|
||
cookiesBytes, err := json.Marshal(cookies)
|
||
if err == nil {
|
||
loginStateJSON = string(cookiesBytes)
|
||
log.Printf("验证码登录 - 用户%d - 从storage_state提取的Cookie长度: %d", employeeID, len(loginStateJSON))
|
||
}
|
||
} else {
|
||
// 最终降级:保存整个 storage_state
|
||
log.Printf("验证码登录 - 用户%d - 无法提取cookies,保存整个storage_state", employeeID)
|
||
storageStateBytes, err := json.Marshal(storageState)
|
||
if err == nil {
|
||
loginStateJSON = string(storageStateBytes)
|
||
log.Printf("验证码登录 - 用户%d - StorageState长度: %d", employeeID, len(loginStateJSON))
|
||
}
|
||
}
|
||
}
|
||
|
||
if loginStateJSON == "" {
|
||
log.Printf("验证码登录 - 用户%d - 错误: 未能获取到任何登录数据", employeeID)
|
||
return errors.New("登录成功但未能获取到登录数据,请重试")
|
||
}
|
||
|
||
// 从 userInfo 提取小红书账号信息
|
||
xhsNickname := "小红书用户" // 默认值
|
||
xhsPhone := "" // red_id
|
||
xhsUserId := "" // user_id
|
||
|
||
if len(userInfo) > 0 {
|
||
// 优先使用 nickname
|
||
if nickname, ok := userInfo["nickname"].(string); ok && nickname != "" {
|
||
xhsNickname = nickname
|
||
}
|
||
// 提取 red_id 作为 xhs_phone
|
||
if redID, ok := userInfo["red_id"].(string); ok && redID != "" {
|
||
xhsPhone = redID
|
||
}
|
||
// 提取 user_id
|
||
if userID, ok := userInfo["user_id"].(string); ok && userID != "" {
|
||
xhsUserId = userID
|
||
}
|
||
log.Printf("验证码登录 - 用户%d - 提取的用户信息: nickname=%s, red_id=%s, user_id=%s", employeeID, xhsNickname, xhsPhone, xhsUserId)
|
||
} else {
|
||
log.Printf("验证码登录 - 用户%d - 警告: userInfo为空,使用默认值", employeeID)
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
// 开启事务
|
||
tx := database.DB.Begin()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
// 创建或更新 ai_authors 表的小红书账号记录
|
||
log.Printf("验证码登录 - 用户%d - 开始创建或更新作者记录", employeeID)
|
||
|
||
author := models.Author{
|
||
EnterpriseID: employee.EnterpriseID,
|
||
CreatedUserID: employeeID,
|
||
Phone: employee.Phone,
|
||
AuthorName: xhsNickname,
|
||
XHSCookie: loginStateJSON,
|
||
XHSPhone: xhsPhone,
|
||
XHSAccount: xhsNickname,
|
||
BoundAt: &now,
|
||
Channel: 1, // 1=小红书
|
||
Status: "active",
|
||
}
|
||
|
||
// 查询是否已存在记录
|
||
var existingAuthor models.Author
|
||
err := database.DB.Where("created_user_id = ? AND enterprise_id = ? AND channel = 1",
|
||
employeeID, employee.EnterpriseID).First(&existingAuthor).Error
|
||
|
||
if err == gorm.ErrRecordNotFound {
|
||
// 创建新记录
|
||
if err := tx.Create(&author).Error; err != nil {
|
||
tx.Rollback()
|
||
log.Printf("验证码登录 - 用户%d - 创建作者记录失败: %v", employeeID, err)
|
||
return fmt.Errorf("创建作者记录失败: %w", err)
|
||
}
|
||
log.Printf("验证码登录 - 用户%d - 创建作者记录成功", employeeID)
|
||
} else {
|
||
// 更新现有记录
|
||
if err := tx.Model(&models.Author{}).Where(
|
||
"created_user_id = ? AND enterprise_id = ? AND channel = 1",
|
||
employeeID, employee.EnterpriseID,
|
||
).Updates(map[string]interface{}{
|
||
"author_name": xhsNickname,
|
||
"xhs_cookie": loginStateJSON,
|
||
"xhs_phone": xhsPhone,
|
||
"xhs_account": xhsNickname,
|
||
"bound_at": &now,
|
||
"status": "active",
|
||
"phone": employee.Phone,
|
||
}).Error; err != nil {
|
||
tx.Rollback()
|
||
log.Printf("验证码登录 - 用户%d - 更新作者记录失败: %v", employeeID, err)
|
||
return fmt.Errorf("更新作者记录失败: %w", err)
|
||
}
|
||
log.Printf("验证码登录 - 用户%d - 更新作者记录成功", employeeID)
|
||
}
|
||
|
||
// 更新 ai_users 表的绑定标识
|
||
if err := tx.Model(&employee).Update("is_bound_xhs", 1).Error; err != nil {
|
||
tx.Rollback()
|
||
log.Printf("验证码登录 - 用户%d - 更新用户绑定标识失败: %v", employeeID, err)
|
||
return fmt.Errorf("更新用户绑定标识失败: %w", err)
|
||
}
|
||
|
||
log.Printf("验证码登录 - 用户%d - 数据库更新成功", employeeID)
|
||
|
||
// 提交事务
|
||
if err := tx.Commit().Error; err != nil {
|
||
log.Printf("验证码登录 - 用户%d - 事务提交失败: %v", employeeID, err)
|
||
return fmt.Errorf("提交事务失败: %w", err)
|
||
}
|
||
|
||
// 清除相关缓存
|
||
cacheService := NewCacheService()
|
||
if err := cacheService.ClearUserRelatedCache(ctx, employeeID); err != nil {
|
||
log.Printf("清除缓存失败: %v", err)
|
||
}
|
||
|
||
log.Printf("验证码登录 - 用户%d - 绑定成功", employeeID)
|
||
return nil
|
||
}
|
||
|
||
// StartQRCodeLogin 启动扫码登录,转发到Python服务
|
||
func (s *EmployeeService) StartQRCodeLogin(employeeID int) (map[string]interface{}, error) {
|
||
log.Printf("[启动扫码登录] 用户ID: %d", employeeID)
|
||
|
||
// 从配置获取Python服务地址
|
||
pythonServiceURL := config.AppConfig.XHS.PythonServiceURL
|
||
if pythonServiceURL == "" {
|
||
pythonServiceURL = "http://localhost:8000"
|
||
}
|
||
|
||
url := fmt.Sprintf("%s/api/xhs/qrcode/start", pythonServiceURL)
|
||
log.Printf("[启动扫码登录] 调用Python服务: %s", url)
|
||
|
||
// 发送HTTP POST请求,启动扫码需要启动浏览器+加载页面+获取二维码,设置90秒超时
|
||
client := &http.Client{
|
||
Timeout: 90 * time.Second,
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", url, nil)
|
||
if err != nil {
|
||
log.Printf("[启动扫码登录] 创建请求失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
log.Printf("[启动扫码登录] 调用Python服务失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 读取响应
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
log.Printf("[启动扫码登录] 读取响应失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
// 解析响应,直接返回完整响应体
|
||
var apiResponse map[string]interface{}
|
||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||
log.Printf("[启动扫码登录] 解析响应失败: %v, body: %s", err, string(body))
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
// 检查Python响应的code字段
|
||
if code, ok := apiResponse["code"].(float64); ok && code != 0 {
|
||
if msg, ok := apiResponse["message"].(string); ok {
|
||
log.Printf("[启动扫码登录] 失败: %s", msg)
|
||
return nil, errors.New(msg)
|
||
}
|
||
return nil, errors.New("启动失败")
|
||
}
|
||
|
||
log.Printf("[启动扫码登录] 成功")
|
||
// 返回完整的Python响应,保持code=0格式
|
||
return apiResponse, nil
|
||
}
|
||
|
||
// GetQRCodeStatus 获取扫码状态,转发到Python服务
|
||
func (s *EmployeeService) GetQRCodeStatus(employeeID int, sessionID string) (map[string]interface{}, error) {
|
||
// 从配置获取Python服务地址
|
||
pythonServiceURL := config.AppConfig.XHS.PythonServiceURL
|
||
if pythonServiceURL == "" {
|
||
pythonServiceURL = "http://localhost:8000"
|
||
}
|
||
|
||
url := fmt.Sprintf("%s/api/xhs/qrcode/status", pythonServiceURL)
|
||
requestData := map[string]string{
|
||
"session_id": sessionID,
|
||
}
|
||
|
||
jsonData, err := json.Marshal(requestData)
|
||
if err != nil {
|
||
log.Printf("[扫码状态] 序列化请求数据失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
// 发送HTTP POST请求
|
||
client := &http.Client{
|
||
Timeout: 30 * time.Second,
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
log.Printf("[扫码状态] 创建请求失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
log.Printf("[扫码状态] 调用Python服务失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 读取响应
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
log.Printf("[扫码状态] 读取响应失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
// 解析响应,直接返回完整响应体
|
||
var apiResponse map[string]interface{}
|
||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||
log.Printf("[扫码状态] 解析响应失败: %v, body: %s", err, string(body))
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
// 扫码状态接口可能返回 code=2 表示 session 失效
|
||
// 这种情况不算错误,直接返回给前端处理
|
||
// 直接返回完整的Python响应,让前端自己判断
|
||
return apiResponse, nil
|
||
}
|
||
|
||
// RefreshQRCode 刷新二维码,转发到Python服务
|
||
func (s *EmployeeService) RefreshQRCode(employeeID int, sessionID string) (map[string]interface{}, error) {
|
||
log.Printf("[刷新二维码] 用户ID: %d, SessionID: %s", employeeID, sessionID)
|
||
|
||
// 从配置获取Python服务地址
|
||
pythonServiceURL := config.AppConfig.XHS.PythonServiceURL
|
||
if pythonServiceURL == "" {
|
||
pythonServiceURL = "http://localhost:8000"
|
||
}
|
||
|
||
url := fmt.Sprintf("%s/api/xhs/qrcode/refresh", pythonServiceURL)
|
||
requestData := map[string]string{
|
||
"session_id": sessionID,
|
||
}
|
||
|
||
jsonData, err := json.Marshal(requestData)
|
||
if err != nil {
|
||
log.Printf("[刷新二维码] 序列化请求数据失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
// 发送HTTP POST请求,刷新二维码需要重新加载页面,设置60秒超时
|
||
client := &http.Client{
|
||
Timeout: 60 * time.Second,
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
log.Printf("[刷新二维码] 创建请求失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
log.Printf("[刷新二维码] 调用Python服务失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 读取响应
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
log.Printf("[刷新二维码] 读取响应失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
// 解析响应,直接返回完整响应体
|
||
var apiResponse map[string]interface{}
|
||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||
log.Printf("[刷新二维码] 解析响应失败: %v, body: %s", err, string(body))
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
// 刷新接口可能返回 code=3 表示需要重启
|
||
// 这种情况不算错误,直接返回给前端处理
|
||
// 直接返回完整的Python响应,让前端自己判断
|
||
log.Printf("[刷新二维码] 成功")
|
||
return apiResponse, nil
|
||
}
|
||
|
||
// CancelQRCodeLogin 取消扫码登录,转发到Python服务
|
||
func (s *EmployeeService) CancelQRCodeLogin(sessionID string) (map[string]interface{}, error) {
|
||
log.Printf("[取消扫码] SessionID: %s", sessionID)
|
||
|
||
// 从配置获取Python服务地址
|
||
pythonServiceURL := config.AppConfig.XHS.PythonServiceURL
|
||
if pythonServiceURL == "" {
|
||
pythonServiceURL = "http://localhost:8000"
|
||
}
|
||
|
||
url := fmt.Sprintf("%s/api/xhs/qrcode/cancel", pythonServiceURL)
|
||
requestData := map[string]string{
|
||
"session_id": sessionID,
|
||
}
|
||
|
||
jsonData, err := json.Marshal(requestData)
|
||
if err != nil {
|
||
log.Printf("[取消扫码] 序列化请求数据失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
|
||
// 发送HTTP POST请求
|
||
client := &http.Client{
|
||
Timeout: 10 * time.Second, // 短超时,取消操作应该很快
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
log.Printf("[取消扫码] 创建请求失败: %v", err)
|
||
return nil, errors.New("网络错误,请稍后重试")
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
log.Printf("[取消扫码] 调用Python服务失败: %v", err)
|
||
// 取消失败也返回成功,不影响用户体验
|
||
return map[string]interface{}{
|
||
"code": 0,
|
||
"message": "已取消扫码登录",
|
||
}, nil
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 读取响应
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
log.Printf("[取消扫码] 读取响应失败: %v", err)
|
||
return map[string]interface{}{
|
||
"code": 0,
|
||
"message": "已取消扫码登录",
|
||
}, nil
|
||
}
|
||
|
||
// 解析响应,直接返回完整响应体
|
||
var apiResponse map[string]interface{}
|
||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||
log.Printf("[取消扫码] 解析响应失败: %v, body: %s", err, string(body))
|
||
return map[string]interface{}{
|
||
"code": 0,
|
||
"message": "已取消扫码登录",
|
||
}, nil
|
||
}
|
||
|
||
log.Printf("[取消扫码] 成功")
|
||
return apiResponse, nil
|
||
}
|