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 }