This commit is contained in:
sjk
2026-01-07 22:55:12 +08:00
parent cb267e8d5e
commit 4720ab2a15
76 changed files with 3110 additions and 7168 deletions

View File

@@ -79,7 +79,8 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error {
return errors.New("网络错误,请稍后重试")
}
log.Printf("[发送验证码] 调用Python HTTP服务: %s", url)
log.Printf("[发送验证码] 调用Python HTTP服务: %s, 请求参数: phone=%s", url, phone)
startTime := time.Now()
// 发送HTTP POST请求增加超时控制60秒
client := &http.Client{
@@ -111,7 +112,7 @@ func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error {
return errors.New("网络错误,请稍后重试")
}
log.Printf("[发送验证码] Python服务响应状态: %d", resp.StatusCode)
log.Printf("[发送验证码] Python服务响应: 状态码=%d, 耗时=%.2fs", resp.StatusCode, time.Since(startTime).Seconds())
// 解析响应FastAPI返回格式: {code, message, data}
var apiResponse struct {
@@ -276,7 +277,7 @@ func (s *EmployeeService) UpdateProfile(employeeID int, nickname, email, avatar
}
// BindXHS 绑定小红书账号(异步处理,立即返回)
func (s *EmployeeService) BindXHS(employeeID int, xhsPhone, code string) (string, error) {
func (s *EmployeeService) BindXHS(employeeID int, xhsPhone, code, sessionID string) (string, error) {
if code == "" {
return "", errors.New("验证码不能为空")
}
@@ -297,15 +298,15 @@ func (s *EmployeeService) BindXHS(employeeID int, xhsPhone, code string) (string
}
// 异步执行绑定流程
go s.asyncBindXHS(employeeID, xhsPhone, code)
go s.asyncBindXHS(employeeID, xhsPhone, code, sessionID)
// 立即返回成功,告知前端正在处理
log.Printf("绑定小红书 - 用户%d - 异步任务已启动", employeeID)
log.Printf("绑定小红书 - 用户%d - 异步任务已启动 (session_id=%s)", employeeID, sessionID)
return "", nil
}
// asyncBindXHS 异步执行小红书绑定流程
func (s *EmployeeService) asyncBindXHS(employeeID int, xhsPhone, code string) {
func (s *EmployeeService) asyncBindXHS(employeeID int, xhsPhone, code, sessionID string) {
ctx := context.Background()
cacheService := NewCacheService()
@@ -340,8 +341,8 @@ func (s *EmployeeService) asyncBindXHS(employeeID int, xhsPhone, code string) {
}
// err == gorm.ErrRecordNotFound 表示该手机号未被绑定,可以继续
// 调用Python服务进行验证码验证和登录
loginResult, err := s.callPythonLogin(xhsPhone, code)
// 调用Python服务进行验证码验证和登录传递session_id
loginResult, err := s.callPythonLogin(xhsPhone, code, sessionID)
if err != nil {
return fmt.Errorf("小红书登录失败: %w", err)
}
@@ -608,7 +609,7 @@ func (s *EmployeeService) GetBindXHSStatus(employeeID int) (map[string]interface
}
// callPythonLogin 调用Python HTTP服务完成小红书登录优化使用浏览器池
func (s *EmployeeService) callPythonLogin(phone, code string) (*PythonLoginResponse, error) {
func (s *EmployeeService) callPythonLogin(phone, code, sessionID string) (*PythonLoginResponse, error) {
// 从配置获取Python服务地址
pythonServiceURL := config.AppConfig.XHS.PythonServiceURL
if pythonServiceURL == "" {
@@ -621,6 +622,7 @@ func (s *EmployeeService) callPythonLogin(phone, code string) (*PythonLoginRespo
"phone": phone,
"code": code,
"country_code": "+86",
"session_id": sessionID, // 关键传递session_id用于复用浏览器
}
jsonData, err := json.Marshal(requestData)
@@ -628,7 +630,7 @@ func (s *EmployeeService) callPythonLogin(phone, code string) (*PythonLoginRespo
return nil, fmt.Errorf("序列化请求数据失败: %w", err)
}
log.Printf("[绑定小红书] 调用Python HTTP服务: %s", url)
log.Printf("[绑定小红书] 调用Python HTTP服务: %s, session_id=%s", url, sessionID)
// 发送HTTP POST请求
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
@@ -2198,3 +2200,388 @@ func (s *EmployeeService) RepublishRecord(employeeID int, recordID int) (string,
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
}
// 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
}

View File

@@ -1,12 +1,14 @@
package service
import (
"ai_xhs/config"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"os/exec"
"path/filepath"
"net/http"
"time"
)
type XHSService struct{}
@@ -38,128 +40,93 @@ type LoginResponse struct {
Data map[string]interface{} `json:"data"`
}
// SendVerificationCode 调用Python脚本发送验证码
// SendVerificationCode 调用Python HTTP API发送验证码
func (s *XHSService) SendVerificationCode(phone, countryCode string) (*SendCodeResponse, error) {
// 如果没有传国家码,默认使用+86
if countryCode == "" {
countryCode = "+86"
}
// 获取Python脚本路径和venv中的Python解释器
backendDir := filepath.Join("..", "backend")
pythonScript := filepath.Join(backendDir, "xhs_cli.py")
// 获取Python服务地址
pythonURL := config.GetPythonServiceURL()
apiURL := fmt.Sprintf("%s/api/xhs/send-code", pythonURL)
// 使用venv中的Python解释器 (跨平台)
pythonCmd := getPythonPath(backendDir)
// 执行Python脚本
cmd := exec.Command(pythonCmd, pythonScript, "send_code", phone, countryCode)
// 设置工作目录为Python脚本所在目录
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日志-发送验证码] %s", stderr.String())
// 构造请求体
reqData := map[string]interface{}{
"phone": phone,
"country_code": countryCode,
}
reqBody, _ := json.Marshal(reqData)
// 发送HTTP POST请求
client := &http.Client{
Timeout: 60 * time.Second, // 60秒超时
}
resp, err := client.Post(apiURL, "application/json", bytes.NewBuffer(reqBody))
if err != nil {
return nil, fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
return nil, fmt.Errorf("调用Python服务失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 获取UTF-8编码的输出
outputStr := stdout.String()
// 解析JSON输出
log.Printf("[Python API-发送验证码] 响应: %s", string(body))
// 解析JSON响应
var result SendCodeResponse
if err := json.Unmarshal([]byte(outputStr), &result); err != nil {
return nil, fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr)
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w, body: %s", err, string(body))
}
// 检查Python脚本返回的success字段
if !result.Data["success"].(bool) {
return &SendCodeResponse{
Code: 1,
Message: result.Data["error"].(string),
}, nil
}
return &SendCodeResponse{
Code: 0,
Message: "验证码已发送",
Data: result.Data,
}, nil
return &result, nil
}
// VerifyLogin 调用Python脚本验证登录
// VerifyLogin 调用Python HTTP API验证登录
func (s *XHSService) VerifyLogin(phone, code, countryCode string) (*LoginResponse, error) {
// 如果没有传国家码,默认使用+86
if countryCode == "" {
countryCode = "+86"
}
// 获取Python脚本路径和venv中的Python解释器
backendDir := filepath.Join("..", "backend")
pythonScript := filepath.Join(backendDir, "xhs_cli.py")
// 获取Python服务地址
pythonURL := config.GetPythonServiceURL()
apiURL := fmt.Sprintf("%s/api/xhs/login", pythonURL)
// 使用venv中的Python解释器 (跨平台)
pythonCmd := getPythonPath(backendDir)
// 执行Python脚本
cmd := exec.Command(pythonCmd, pythonScript, "login", phone, code, countryCode)
// 设置工作目录
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日志-登录] %s", stderr.String())
// 构造请求体
reqData := map[string]interface{}{
"phone": phone,
"code": code,
"country_code": countryCode,
}
reqBody, _ := json.Marshal(reqData)
// 发送HTTP POST请求
client := &http.Client{
Timeout: 120 * time.Second, // 120秒超时登录可能较慢
}
resp, err := client.Post(apiURL, "application/json", bytes.NewBuffer(reqBody))
if err != nil {
return nil, fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
return nil, fmt.Errorf("调用Python服务失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 获取UTF-8编码的输出
outputStr := stdout.String()
// 解析JSON输出
log.Printf("[Python API-验证登录] 响应: %s", string(body))
// 解析JSON响应
var result LoginResponse
if err := json.Unmarshal([]byte(outputStr), &result); err != nil {
return nil, fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr)
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w, body: %s", err, string(body))
}
// 检查Python脚本返回的success字段
if !result.Data["success"].(bool) {
errorMsg := "登录失败"
if errStr, ok := result.Data["error"].(string); ok {
errorMsg = errStr
}
return &LoginResponse{
Code: 1,
Message: errorMsg,
}, nil
}
return &LoginResponse{
Code: 0,
Message: "登录成功",
Data: result.Data,
}, nil
return &result, nil
}