Files
ai_wht_wechat/go_backend/service/employee_service.go
2026-01-23 16:27:47 +08:00

2745 lines
86 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package 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)
// 检查响应codeFastAPI返回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("正在处理绑定请求,请稍候")
}
// 设置绑定状态为processing180秒有效期
// 直接使用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))
}
// 检查响应codeFastAPI返回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(&copy).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(&copy).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(&copy, 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", &copy.ID,
"发布文案-更新状态失败", errMessage, "error")
tx.Rollback()
return 0, errors.New(errMessage)
}
// 记录更新文案状态日志
s.createLog(tx, employeeID, "article_status_update", "article", &copy.ID,
fmt.Sprintf("文案ID:%d 状态更新为 %s", copy.ID, publishStatus), "", "success")
// 2. 创建发布记录
record := models.PublishRecord{
ArticleID: &copy.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", &copy.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", &copy.ID,
"发布事务提交失败状态更新为failed", errMessage, "error")
return 0, errors.New(errMessage)
}
// 成功日志
s.createLog(nil, employeeID, "article_publish_success", "article", &copy.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_fullAdsPower Cookie
cookiesBytes, err := json.Marshal(cookiesFull)
if err == nil {
loginStateJSON = string(cookiesBytes)
log.Printf("验证码登录 - 用户%d - 使用cookies_fullCookie长度: %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
}