first commit
This commit is contained in:
214
go_backend/service/auth_service.go
Normal file
214
go_backend/service/auth_service.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"ai_xhs/config"
|
||||
"ai_xhs/database"
|
||||
"ai_xhs/models"
|
||||
"ai_xhs/utils"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type AuthService struct{}
|
||||
|
||||
func NewAuthService() *AuthService {
|
||||
return &AuthService{}
|
||||
}
|
||||
|
||||
// 微信手机号响应
|
||||
type WxPhoneResponse struct {
|
||||
PhoneInfo struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
PurePhoneNumber string `json:"purePhoneNumber"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
} `json:"phone_info"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// 微信登录响应
|
||||
type WxLoginResponse struct {
|
||||
OpenID string `json:"openid"`
|
||||
SessionKey string `json:"session_key"`
|
||||
UnionID string `json:"unionid"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// WechatLogin 微信小程序登录
|
||||
func (s *AuthService) WechatLogin(code string, phone string, phoneCode string) (string, *models.User, error) {
|
||||
// 1. 调用微信API验证code
|
||||
// 注意:需要在配置文件中添加小程序的AppID和AppSecret
|
||||
appID := config.AppConfig.Wechat.AppID
|
||||
appSecret := config.AppConfig.Wechat.AppSecret
|
||||
|
||||
// 调试日志:打印配置信息
|
||||
log.Printf("[微信登录] AppID: %s, AppSecret: %s (长度:%d)", appID, appSecret, len(appSecret))
|
||||
|
||||
// 如果没有配置微信AppID,使用手机号登录逻辑
|
||||
if appID == "" || appSecret == "" {
|
||||
if phone == "" {
|
||||
// 没有配置微信且没有手机号,使用默认员工ID=1
|
||||
return s.loginByEmployeeID(1)
|
||||
}
|
||||
// 使用手机号登录
|
||||
return s.PhoneLogin(phone)
|
||||
}
|
||||
|
||||
// 调用微信API
|
||||
url := fmt.Sprintf(
|
||||
"https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
|
||||
appID, appSecret, code,
|
||||
)
|
||||
|
||||
// 调试日志:打印请求URL(隐藏密钥)
|
||||
log.Printf("[微信登录] 请求URL: https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=***&js_code=%s&grant_type=authorization_code", appID, code)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("调用微信API失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 调试日志:打印微信返回的原始响应
|
||||
log.Printf("[微信登录] 微信API响应: %s", string(body))
|
||||
|
||||
var wxResp WxLoginResponse
|
||||
if err := json.Unmarshal(body, &wxResp); err != nil {
|
||||
return "", nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if wxResp.ErrCode != 0 {
|
||||
return "", nil, fmt.Errorf("微信登录失败: %s", wxResp.ErrMsg)
|
||||
}
|
||||
|
||||
// 1.5 如果有 phoneCode,调用微信API获取手机号
|
||||
if phoneCode != "" {
|
||||
accessTokenURL := fmt.Sprintf(
|
||||
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
|
||||
appID, appSecret,
|
||||
)
|
||||
|
||||
// 获取 access_token
|
||||
accessTokenResp, err := http.Get(accessTokenURL)
|
||||
if err != nil {
|
||||
log.Printf("获取access_token失败: %v", err)
|
||||
} else {
|
||||
defer accessTokenResp.Body.Close()
|
||||
accessTokenBody, _ := io.ReadAll(accessTokenResp.Body)
|
||||
|
||||
var tokenResult struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(accessTokenBody, &tokenResult); err == nil && tokenResult.AccessToken != "" {
|
||||
// 获取手机号
|
||||
phoneURL := fmt.Sprintf(
|
||||
"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=%s",
|
||||
tokenResult.AccessToken,
|
||||
)
|
||||
|
||||
phoneReqBody := map[string]string{"code": phoneCode}
|
||||
phoneReqJSON, _ := json.Marshal(phoneReqBody)
|
||||
|
||||
phoneResp, err := http.Post(phoneURL, "application/json", bytes.NewBuffer(phoneReqJSON))
|
||||
if err == nil {
|
||||
defer phoneResp.Body.Close()
|
||||
phoneBody, _ := io.ReadAll(phoneResp.Body)
|
||||
|
||||
var phoneResult WxPhoneResponse
|
||||
if err := json.Unmarshal(phoneBody, &phoneResult); err == nil && phoneResult.ErrCode == 0 {
|
||||
// 获取手机号成功,覆盖 phone 参数
|
||||
phone = phoneResult.PhoneInfo.PurePhoneNumber
|
||||
log.Printf("[微信登录] 获取手机号成功: %s", phone)
|
||||
} else {
|
||||
log.Printf("[微信登录] 获取手机号失败: %s", string(phoneBody))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 根据OpenID查找或创建员工
|
||||
var employee models.User
|
||||
|
||||
// 优先通过OpenID查找(注意:使用IS NOT NULL过滤空值)
|
||||
result := database.DB.Where("wechat_openid = ? AND wechat_openid IS NOT NULL", wxResp.OpenID).First(&employee)
|
||||
|
||||
if result.Error != nil {
|
||||
// OpenID不存在,需要绑定OpenID
|
||||
if phone == "" {
|
||||
return "", nil, errors.New("首次登录请提供手机号")
|
||||
}
|
||||
|
||||
// 通过手机号查找员工
|
||||
result = database.DB.Where("phone = ? AND status = ?", phone, "active").First(&employee)
|
||||
if result.Error != nil {
|
||||
return "", nil, errors.New("员工不存在,请联系管理员添加")
|
||||
}
|
||||
|
||||
// 绑定OpenID和UnionID(使用指针)
|
||||
employee.WechatOpenID = &wxResp.OpenID
|
||||
if wxResp.UnionID != "" {
|
||||
employee.WechatUnionID = &wxResp.UnionID
|
||||
}
|
||||
database.DB.Save(&employee)
|
||||
}
|
||||
|
||||
// 3. 生成JWT token
|
||||
token, err := utils.GenerateToken(employee.ID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("生成token失败: %v", err)
|
||||
}
|
||||
|
||||
return token, &employee, nil
|
||||
}
|
||||
|
||||
// PhoneLogin 手机号登录(用于测试或无微信配置时)
|
||||
func (s *AuthService) PhoneLogin(phone string) (string, *models.User, error) {
|
||||
var employee models.User
|
||||
|
||||
// 查找员工
|
||||
result := database.DB.Where("phone = ? AND status = ?", phone, "active").First(&employee)
|
||||
if result.Error != nil {
|
||||
return "", nil, errors.New("员工不存在或已被禁用")
|
||||
}
|
||||
|
||||
// 生成token
|
||||
token, err := utils.GenerateToken(employee.ID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("生成token失败: %v", err)
|
||||
}
|
||||
|
||||
return token, &employee, nil
|
||||
}
|
||||
|
||||
// loginByEmployeeID 通过员工ID登录(内部方法)
|
||||
func (s *AuthService) loginByEmployeeID(employeeID int) (string, *models.User, error) {
|
||||
var employee models.User
|
||||
|
||||
result := database.DB.Where("id = ? AND status = ?", employeeID, "active").First(&employee)
|
||||
if result.Error != nil {
|
||||
return "", nil, errors.New("员工不存在或已被禁用")
|
||||
}
|
||||
|
||||
// 生成token
|
||||
token, err := utils.GenerateToken(employee.ID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("生成token失败: %v", err)
|
||||
}
|
||||
|
||||
return token, &employee, nil
|
||||
}
|
||||
954
go_backend/service/employee_service.go
Normal file
954
go_backend/service/employee_service.go
Normal file
@@ -0,0 +1,954 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"ai_xhs/database"
|
||||
"ai_xhs/models"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EmployeeService struct{}
|
||||
|
||||
type XHSCookieVerifyResult struct {
|
||||
LoggedIn bool
|
||||
CookieExpired bool
|
||||
}
|
||||
|
||||
// SendXHSCode 发送小红书验证码
|
||||
func (s *EmployeeService) SendXHSCode(phone string) error {
|
||||
// 获取Python脚本路径和venv中的Python解释器
|
||||
backendDir := filepath.Join("..", "backend")
|
||||
pythonScript := filepath.Join(backendDir, "xhs_cli.py")
|
||||
|
||||
// 使用venv中的Python解释器 (跨平台)
|
||||
pythonCmd := getPythonPath(backendDir)
|
||||
|
||||
// 执行Python脚本
|
||||
cmd := exec.Command(pythonCmd, pythonScript, "send_code", phone, "+86")
|
||||
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())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// 获取UTF-8编码的输出
|
||||
outputStr := stdout.String()
|
||||
|
||||
// 解析JSON输出
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(outputStr), &result); err != nil {
|
||||
return fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr)
|
||||
}
|
||||
|
||||
// 检查success字段
|
||||
if success, ok := result["success"].(bool); !ok || !success {
|
||||
if errMsg, ok := result["error"].(string); ok {
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return errors.New("发送验证码失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProfile 获取员工个人信息
|
||||
func (s *EmployeeService) GetProfile(employeeID int) (*models.User, error) {
|
||||
var employee models.User
|
||||
err := database.DB.Preload("Enterprise").First(&employee, employeeID).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果已绑定小红书且有Cookie,验证Cookie是否有效
|
||||
if employee.IsBoundXHS == 1 && employee.XHSCookie != "" {
|
||||
// 检查绑定时间,刚绑定的30秒内不验证(避免与绑定操作冲突)
|
||||
if employee.BoundAt != nil {
|
||||
timeSinceBound := time.Since(*employee.BoundAt)
|
||||
if timeSinceBound < 30*time.Second {
|
||||
log.Printf("GetProfile - 用户%d刚绑定%.0f秒,跳过Cookie验证", employeeID, timeSinceBound.Seconds())
|
||||
return &employee, nil
|
||||
}
|
||||
}
|
||||
// 异步验证Cookie(不阻塞返回) - 暂时禁用自动验证,避免频繁清空Cookie
|
||||
// TODO: 改为定时任务验证,而不是每次GetProfile都验证
|
||||
log.Printf("GetProfile - 用户%d有Cookie,长度: %d(已跳过自动验证)", employeeID, len(employee.XHSCookie))
|
||||
// go s.VerifyCookieAndClear(employeeID)
|
||||
}
|
||||
|
||||
return &employee, nil
|
||||
}
|
||||
|
||||
// BindXHS 绑定小红书账号
|
||||
func (s *EmployeeService) BindXHS(employeeID int, xhsPhone, code string) (string, error) {
|
||||
if code == "" {
|
||||
return "", errors.New("验证码不能为空")
|
||||
}
|
||||
|
||||
// 获取员工信息
|
||||
var employee models.User
|
||||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 检查是否已绑定(如果Cookie已失效,允许重新绑定)
|
||||
if employee.IsBoundXHS == 1 && employee.XHSCookie != "" {
|
||||
return "", errors.New("已绑定小红书账号,请先解绑")
|
||||
}
|
||||
|
||||
// 调用Python服务进行验证码验证和登录
|
||||
loginResult, err := s.callPythonLogin(xhsPhone, code)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("小红书登录失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查Python服务返回结果
|
||||
if loginResult.Code != 0 {
|
||||
return "", fmt.Errorf("小红书登录失败: %s", loginResult.Message)
|
||||
}
|
||||
|
||||
// 从返回结果中提取用户信息和cookies
|
||||
userInfo, _ := loginResult.Data["user_info"].(map[string]interface{})
|
||||
|
||||
// 优先使用 cookies_full(Playwright完整格式),如果没有则使用 cookies(键值对格式)
|
||||
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
|
||||
}
|
||||
|
||||
// 提取小红书账号昵称
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 序列化cookies为JSON字符串(使用完整格式)
|
||||
cookiesJSON := ""
|
||||
if cookiesData != nil {
|
||||
cookiesBytes, err := json.Marshal(cookiesData)
|
||||
if err == nil {
|
||||
cookiesJSON = string(cookiesBytes)
|
||||
log.Printf("绑定小红书 - 用户%d - Cookie长度: %d", employeeID, len(cookiesJSON))
|
||||
} else {
|
||||
log.Printf("绑定小红书 - 用户%d - 序列化Cookie失败: %v", employeeID, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("绑定小红书 - 用户%d - 警告: cookiesData为nil", employeeID)
|
||||
}
|
||||
|
||||
if cookiesJSON == "" {
|
||||
log.Printf("绑定小红书 - 用户%d - 错误: 未能获取到Cookie数据", employeeID)
|
||||
return "", errors.New("登录成功但未能获取到Cookie数据,请重试")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 开启事务
|
||||
tx := database.DB.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// 更新 ai_users 表的绑定状态和cookie信息
|
||||
log.Printf("绑定小红书 - 用户%d - 开始更新数据库", employeeID)
|
||||
err = tx.Model(&employee).Updates(map[string]interface{}{
|
||||
"is_bound_xhs": 1,
|
||||
"xhs_account": xhsNickname,
|
||||
"xhs_phone": xhsPhone,
|
||||
"xhs_cookie": cookiesJSON,
|
||||
"bound_at": &now,
|
||||
}).Error
|
||||
|
||||
if 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)
|
||||
}
|
||||
|
||||
log.Printf("绑定小红书 - 用户%d - 绑定成功 - 账号: %s", employeeID, xhsNickname)
|
||||
return xhsNickname, nil
|
||||
}
|
||||
|
||||
// callPythonLogin 调用Python脚本完成小红书登录
|
||||
func (s *EmployeeService) callPythonLogin(phone, code string) (*PythonLoginResponse, error) {
|
||||
// 获取Python脚本路径和venv中的Python解释器
|
||||
backendDir := filepath.Join("..", "backend")
|
||||
pythonScript := filepath.Join(backendDir, "xhs_cli.py")
|
||||
|
||||
// 使用venv中的Python解释器 (跨平台)
|
||||
pythonCmd := getPythonPath(backendDir)
|
||||
|
||||
// 执行Python脚本
|
||||
cmd := exec.Command(pythonCmd, pythonScript, "login", phone, code, "+86")
|
||||
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())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// 获取UTF-8编码的输出
|
||||
outputStr := stdout.String()
|
||||
|
||||
// 解析JSON输出
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(outputStr), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr)
|
||||
}
|
||||
|
||||
// 检查success字段
|
||||
if success, ok := result["success"].(bool); !ok || !success {
|
||||
errorMsg := "登录失败"
|
||||
if errStr, ok := result["error"].(string); ok {
|
||||
errorMsg = errStr
|
||||
}
|
||||
return &PythonLoginResponse{
|
||||
Code: 1,
|
||||
Message: errorMsg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &PythonLoginResponse{
|
||||
Code: 0,
|
||||
Message: "登录成功",
|
||||
Data: result,
|
||||
}, 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_users 表的绑定信息和cookie
|
||||
err := tx.Model(&employee).Updates(map[string]interface{}{
|
||||
"is_bound_xhs": 0,
|
||||
"xhs_account": "",
|
||||
"xhs_phone": "",
|
||||
"xhs_cookie": "",
|
||||
"bound_at": nil,
|
||||
}).Error
|
||||
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("更新员工绑定状态失败: %w", err)
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return fmt.Errorf("提交事务失败: %w", err)
|
||||
}
|
||||
|
||||
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 || employee.XHSCookie == "" {
|
||||
return nil // 没有绑定或已无Cookie,直接返回
|
||||
}
|
||||
|
||||
// 检查绑定时间,刚绑定的30秒内不验证(避免与绑定操作冲突)
|
||||
if employee.BoundAt != nil {
|
||||
timeSinceBound := time.Since(*employee.BoundAt)
|
||||
if timeSinceBound < 30*time.Second {
|
||||
log.Printf("验证Cookie - 用户%d刚绑定%.0f秒,跳过验证", employeeID, timeSinceBound.Seconds())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 调用Python脚本验证Cookie
|
||||
verifyResult, err := s.verifyCookieWithPython(employee.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) {
|
||||
var employee models.User
|
||||
if err := database.DB.First(&employee, employeeID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status := &XHSStatus{
|
||||
IsBound: employee.IsBoundXHS == 1,
|
||||
HasCookie: employee.XHSCookie != "",
|
||||
CookieValid: false,
|
||||
CookieExpired: false,
|
||||
}
|
||||
|
||||
if employee.IsBoundXHS == 0 {
|
||||
status.Message = "未绑定小红书账号"
|
||||
return status, nil
|
||||
}
|
||||
|
||||
if employee.XHSCookie == "" {
|
||||
status.CookieExpired = true
|
||||
status.Message = "已绑定但无有效Cookie,可直接重新绑定"
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// 刚绑定30秒内视为有效,避免频繁触发验证
|
||||
if employee.BoundAt != nil {
|
||||
timeSinceBound := time.Since(*employee.BoundAt)
|
||||
if timeSinceBound < 30*time.Second {
|
||||
status.CookieValid = true
|
||||
status.Message = "刚绑定,小于30秒,暂不检测,视为有效"
|
||||
return status, nil
|
||||
}
|
||||
}
|
||||
|
||||
verifyResult, err := s.verifyCookieWithPython(employee.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已失效,已清空,可直接重新绑定"
|
||||
return status, nil
|
||||
}
|
||||
|
||||
status.CookieValid = true
|
||||
status.CookieExpired = false
|
||||
status.Message = "Cookie有效,已登录"
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// clearXHSCookie 清空小红书Cookie(保留绑定状态)
|
||||
func (s *EmployeeService) clearXHSCookie(employeeID int) error {
|
||||
// 只清空Cookie,保留is_bound_xhs、xhs_account和xhs_phone
|
||||
err := database.DB.Model(&models.User{}).Where("id = ?", employeeID).Updates(map[string]interface{}{
|
||||
"xhs_cookie": "",
|
||||
}).Error
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("清空Cookie失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("已清空用户%d的XHS Cookie", employeeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAvailableCopies 获取可领取的文案列表
|
||||
func (s *EmployeeService) GetAvailableCopies(employeeID int, productID int) (map[string]interface{}, error) {
|
||||
// 获取产品信息
|
||||
var product models.Product
|
||||
if err := database.DB.First(&product, productID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取该产品下所有可用文案(注意:新数据库中status有更多状态)
|
||||
var copies []models.Article
|
||||
if err := database.DB.Where("product_id = ? AND status IN ?", productID, []string{"draft", "approved"}).Order("created_at DESC").Find(&copies).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"product": map[string]interface{}{
|
||||
"id": product.ID,
|
||||
"name": product.Name,
|
||||
"image": product.ImageURL,
|
||||
},
|
||||
"copies": copies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ClaimCopy 领取文案(新版本:直接返回文案信息,不再创建领取记录)
|
||||
func (s *EmployeeService) ClaimCopy(employeeID int, copyID int, productID int) (map[string]interface{}, error) {
|
||||
// 检查文案是否存在且可用(注意:新数据库中status有更多状态)
|
||||
var copy models.Article
|
||||
if err := database.DB.Where("id = ? AND status IN ?", copyID, []string{"draft", "approved"}).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"})
|
||||
|
||||
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("文案已被发布或处于发布审核中")
|
||||
}
|
||||
|
||||
// 获取员工信息
|
||||
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) {
|
||||
log := models.Log{
|
||||
UserID: &userID,
|
||||
Action: action,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
Description: description,
|
||||
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
|
||||
}
|
||||
|
||||
// 构造返回数据
|
||||
list := make([]map[string]interface{}, 0)
|
||||
for _, record := range records {
|
||||
publishTimeStr := ""
|
||||
if record.PublishTime != nil {
|
||||
publishTimeStr = record.PublishTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// 查询产品名称
|
||||
var product models.Product
|
||||
productName := ""
|
||||
if err := database.DB.Where("id = ?", record.ProductID).First(&product).Error; err == nil {
|
||||
productName = product.Name
|
||||
}
|
||||
|
||||
// 查询文章图片和标签
|
||||
var images []map[string]interface{}
|
||||
var tags []string
|
||||
|
||||
if record.ArticleID != nil && *record.ArticleID > 0 {
|
||||
// 查询文章图片
|
||||
var articleImages []models.ArticleImage
|
||||
if err := database.DB.Where("article_id = ?", *record.ArticleID).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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 查询文章标签
|
||||
var articleTag models.ArticleTag
|
||||
if err := database.DB.Where("article_id = ?", *record.ArticleID).First(&articleTag).Error; err == nil && articleTag.CozeTag != "" {
|
||||
// 解析标签
|
||||
for _, tag := range splitTags(articleTag.CozeTag) {
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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关联查询文章内容
|
||||
var article models.Article
|
||||
content := ""
|
||||
var images []map[string]interface{}
|
||||
var tags []string
|
||||
articleCozeTag := ""
|
||||
|
||||
// 查询产品名称
|
||||
var product models.Product
|
||||
productName := ""
|
||||
if err := database.DB.Where("id = ?", record.ProductID).First(&product).Error; err == nil {
|
||||
productName = product.Name
|
||||
}
|
||||
|
||||
if record.ArticleID != nil && *record.ArticleID > 0 {
|
||||
// 优先使用ArticleID关联
|
||||
if err := database.DB.Where("id = ?", *record.ArticleID).First(&article).Error; err == nil {
|
||||
content = article.Content
|
||||
articleCozeTag = article.CozeTag
|
||||
|
||||
// 查询文章图片
|
||||
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 {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 查询文章标签(ai_article_tags表)
|
||||
var articleTag models.ArticleTag
|
||||
if err := database.DB.Where("article_id = ?", article.ID).First(&articleTag).Error; err == nil && articleTag.CozeTag != "" {
|
||||
// 使用ai_article_tags表的标签
|
||||
articleCozeTag = articleTag.CozeTag
|
||||
}
|
||||
|
||||
// 解析标签(假设标签是逗号分隔的字符串)
|
||||
if articleCozeTag != "" {
|
||||
// 尝试按逗号分割
|
||||
for _, tag := range splitTags(articleCozeTag) {
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 备用方案:通过title和product_id关联(向后兼容)
|
||||
if err := database.DB.Where("title = ? AND product_id = ?", record.Title, record.ProductID).First(&article).Error; err == nil {
|
||||
content = article.Content
|
||||
articleCozeTag = article.CozeTag
|
||||
|
||||
// 解析标签
|
||||
if articleCozeTag != "" {
|
||||
for _, tag := range splitTags(articleCozeTag) {
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() ([]map[string]interface{}, error) {
|
||||
var products []models.Product
|
||||
if err := database.DB.Find(&products).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, 0)
|
||||
for _, product := range products {
|
||||
// 统计该产品下可用文案数量(注意:新数据库中status有更多状态)
|
||||
var totalCopies int64
|
||||
database.DB.Model(&models.Article{}).Where("product_id = ? AND status IN ?", product.ID, []string{"draft", "approved"}).Count(&totalCopies)
|
||||
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": product.ID,
|
||||
"name": product.Name,
|
||||
"image": product.ImageURL,
|
||||
"knowledge": product.Knowledge,
|
||||
"available_copies": totalCopies,
|
||||
})
|
||||
}
|
||||
|
||||
return result, 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"`
|
||||
}
|
||||
16
go_backend/service/python_utils.go
Normal file
16
go_backend/service/python_utils.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// getPythonPath 获取虚拟环境中的Python解释器路径(跨平台)
|
||||
func getPythonPath(backendDir string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows: venv\Scripts\python.exe
|
||||
return filepath.Join(backendDir, "venv", "Scripts", "python.exe")
|
||||
}
|
||||
// Linux/Mac: venv/bin/python
|
||||
return filepath.Join(backendDir, "venv", "bin", "python")
|
||||
}
|
||||
561
go_backend/service/scheduler_service.go
Normal file
561
go_backend/service/scheduler_service.go
Normal file
@@ -0,0 +1,561 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"ai_xhs/config"
|
||||
"ai_xhs/database"
|
||||
"ai_xhs/models"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// SchedulerService 定时任务服务
|
||||
type SchedulerService struct {
|
||||
cron *cron.Cron
|
||||
maxConcurrent int
|
||||
publishTimeout int
|
||||
publishSem chan struct{} // 用于控制并发数的信号量
|
||||
}
|
||||
|
||||
// NewSchedulerService 创建定时任务服务
|
||||
func NewSchedulerService(maxConcurrent, publishTimeout int) *SchedulerService {
|
||||
// 使用WithSeconds选项支持6位Cron表达式(秒 分 时 日 月 周)
|
||||
return &SchedulerService{
|
||||
cron: cron.New(cron.WithSeconds()),
|
||||
maxConcurrent: maxConcurrent,
|
||||
publishTimeout: publishTimeout,
|
||||
publishSem: make(chan struct{}, maxConcurrent),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动定时任务
|
||||
func (s *SchedulerService) Start(cronExpr string) error {
|
||||
// 添加定时任务
|
||||
_, err := s.cron.AddFunc(cronExpr, s.AutoPublishArticles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("添加定时任务失败: %w", err)
|
||||
}
|
||||
|
||||
// 启动cron
|
||||
s.cron.Start()
|
||||
log.Printf("定时发布任务已启动,Cron表达式: %s", cronExpr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 停止定时任务
|
||||
func (s *SchedulerService) Stop() {
|
||||
s.cron.Stop()
|
||||
log.Println("定时发布任务已停止")
|
||||
}
|
||||
|
||||
const (
|
||||
defaultMaxArticlesPerUserPerRun = 5
|
||||
defaultMaxFailuresPerUserPerRun = 3
|
||||
)
|
||||
|
||||
// fetchProxyFromPool 从代理池接口获取一个代理地址(http://ip:port)
|
||||
func fetchProxyFromPool() (string, error) {
|
||||
proxyURL := config.AppConfig.Scheduler.ProxyFetchURL
|
||||
if proxyURL == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(proxyURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求代理池接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("代理池接口返回非200状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取代理池响应失败: %w", err)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(string(bodyBytes))
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("代理池返回内容为空")
|
||||
}
|
||||
|
||||
// 支持多行情况,取第一行 ip:port
|
||||
line := strings.Split(content, "\n")[0]
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return "", fmt.Errorf("代理池首行内容为空")
|
||||
}
|
||||
|
||||
// 如果已经包含协议前缀,则直接返回
|
||||
if strings.HasPrefix(line, "http://") || strings.HasPrefix(line, "https://") {
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// 默认补上 http:// 前缀
|
||||
return "http://" + line, nil
|
||||
}
|
||||
|
||||
func limitArticlesPerUserPerRun(articles []models.Article, perUserLimit int) []models.Article {
|
||||
if perUserLimit <= 0 {
|
||||
return articles
|
||||
}
|
||||
|
||||
grouped := make(map[int][]models.Article)
|
||||
for _, art := range articles {
|
||||
userID := art.CreatedUserID
|
||||
if art.PublishUserID != nil {
|
||||
userID = *art.PublishUserID
|
||||
}
|
||||
grouped[userID] = append(grouped[userID], art)
|
||||
}
|
||||
|
||||
limited := make([]models.Article, 0, len(articles))
|
||||
for _, group := range grouped {
|
||||
if len(group) > perUserLimit {
|
||||
limited = append(limited, group[:perUserLimit]...)
|
||||
} else {
|
||||
limited = append(limited, group...)
|
||||
}
|
||||
}
|
||||
|
||||
return limited
|
||||
}
|
||||
|
||||
// filterByDailyAndHourlyLimit 按每日和每小时上限过滤文章
|
||||
func (s *SchedulerService) filterByDailyAndHourlyLimit(articles []models.Article, maxDaily, maxHourly int) []models.Article {
|
||||
if maxDaily <= 0 && maxHourly <= 0 {
|
||||
return articles
|
||||
}
|
||||
|
||||
// 提取所有涉及的用户ID
|
||||
userIDs := make(map[int]bool)
|
||||
for _, art := range articles {
|
||||
userID := art.CreatedUserID
|
||||
if art.PublishUserID != nil {
|
||||
userID = *art.PublishUserID
|
||||
}
|
||||
userIDs[userID] = true
|
||||
}
|
||||
|
||||
// 批量查询每个用户的当日和当前小时已发布数量
|
||||
userDailyPublished := make(map[int]int)
|
||||
userHourlyPublished := make(map[int]int)
|
||||
|
||||
now := time.Now()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
currentHourStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||||
|
||||
for userID := range userIDs {
|
||||
// 查询当日已发布数量
|
||||
if maxDaily > 0 {
|
||||
var dailyCount int64
|
||||
if err := database.DB.Model(&models.Article{}).
|
||||
Where("status = ? AND publish_time >= ? AND (publish_user_id = ? OR (publish_user_id IS NULL AND created_user_id = ?))",
|
||||
"published", todayStart, userID, userID).
|
||||
Count(&dailyCount).Error; err != nil {
|
||||
log.Printf("[警告] 查询用户 %d 当日已发布数量失败: %v", userID, err)
|
||||
} else {
|
||||
userDailyPublished[userID] = int(dailyCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前小时已发布数量
|
||||
if maxHourly > 0 {
|
||||
var hourlyCount int64
|
||||
if err := database.DB.Model(&models.Article{}).
|
||||
Where("status = ? AND publish_time >= ? AND (publish_user_id = ? OR (publish_user_id IS NULL AND created_user_id = ?))",
|
||||
"published", currentHourStart, userID, userID).
|
||||
Count(&hourlyCount).Error; err != nil {
|
||||
log.Printf("[警告] 查询用户 %d 当前小时已发布数量失败: %v", userID, err)
|
||||
} else {
|
||||
userHourlyPublished[userID] = int(hourlyCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤超限文章
|
||||
filtered := make([]models.Article, 0, len(articles))
|
||||
skippedUsersDailyMap := make(map[int]bool)
|
||||
skippedUsersHourlyMap := make(map[int]bool)
|
||||
|
||||
for _, art := range articles {
|
||||
userID := art.CreatedUserID
|
||||
if art.PublishUserID != nil {
|
||||
userID = *art.PublishUserID
|
||||
}
|
||||
|
||||
// 检查每日上限
|
||||
if maxDaily > 0 && userDailyPublished[userID] >= maxDaily {
|
||||
if !skippedUsersDailyMap[userID] {
|
||||
log.Printf("[频控] 用户 %d 今日已发布 %d 篇,达到每日上限 %d,跳过后续文案", userID, userDailyPublished[userID], maxDaily)
|
||||
skippedUsersDailyMap[userID] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查每小时上限
|
||||
if maxHourly > 0 && userHourlyPublished[userID] >= maxHourly {
|
||||
if !skippedUsersHourlyMap[userID] {
|
||||
log.Printf("[频控] 用户 %d 当前小时已发布 %d 篇,达到每小时上限 %d,跳过后续文案", userID, userHourlyPublished[userID], maxHourly)
|
||||
skippedUsersHourlyMap[userID] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, art)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// AutoPublishArticles 自动发布文案
|
||||
func (s *SchedulerService) AutoPublishArticles() {
|
||||
log.Println("========== 开始执行定时发布任务 ==========")
|
||||
startTime := time.Now()
|
||||
|
||||
// 查询所有待发布的文案(状态为published_review)
|
||||
var articles []models.Article
|
||||
if err := database.DB.Where("status = ?", "published_review").Find(&articles).Error; err != nil {
|
||||
log.Printf("查询待发布文案失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(articles) == 0 {
|
||||
log.Println("没有待发布的文案")
|
||||
return
|
||||
}
|
||||
|
||||
originalTotal := len(articles)
|
||||
|
||||
perUserLimit := config.AppConfig.Scheduler.MaxArticlesPerUserPerRun
|
||||
if perUserLimit <= 0 {
|
||||
perUserLimit = defaultMaxArticlesPerUserPerRun
|
||||
}
|
||||
|
||||
articles = limitArticlesPerUserPerRun(articles, perUserLimit)
|
||||
|
||||
log.Printf("找到 %d 篇待发布文案,按照每个用户每轮最多 %d 篇,本次计划发布 %d 篇", originalTotal, perUserLimit, len(articles))
|
||||
|
||||
// 查询每用户每日/每小时已发布数量,过滤超限用户
|
||||
maxDaily := config.AppConfig.Scheduler.MaxDailyArticlesPerUser
|
||||
maxHourly := config.AppConfig.Scheduler.MaxHourlyArticlesPerUser
|
||||
|
||||
if maxDaily > 0 || maxHourly > 0 {
|
||||
beforeFilterCount := len(articles)
|
||||
articles = s.filterByDailyAndHourlyLimit(articles, maxDaily, maxHourly)
|
||||
log.Printf("应用每日/每小时上限过滤:过滤前 %d 篇,过滤后 %d 篇", beforeFilterCount, len(articles))
|
||||
}
|
||||
|
||||
if len(articles) == 0 {
|
||||
log.Println("所有文案均因频率限制被过滤,本轮无任务")
|
||||
return
|
||||
}
|
||||
|
||||
// 并发发布
|
||||
var wg sync.WaitGroup
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
var mu sync.Mutex
|
||||
userFailCount := make(map[int]int)
|
||||
pausedUsers := make(map[int]bool)
|
||||
|
||||
failLimit := config.AppConfig.Scheduler.MaxFailuresPerUserPerRun
|
||||
if failLimit <= 0 {
|
||||
failLimit = defaultMaxFailuresPerUserPerRun
|
||||
}
|
||||
|
||||
for _, article := range articles {
|
||||
userID := article.CreatedUserID
|
||||
if article.PublishUserID != nil {
|
||||
userID = *article.PublishUserID
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
if pausedUsers[userID] {
|
||||
mu.Unlock()
|
||||
log.Printf("用户 %d 在本轮已暂停,跳过文案 ID: %d", userID, article.ID)
|
||||
continue
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// 获取信号量
|
||||
s.publishSem <- struct{}{}
|
||||
wg.Add(1)
|
||||
|
||||
go func(art models.Article, uid int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-s.publishSem }()
|
||||
|
||||
sleepSeconds := 3 + rand.Intn(8)
|
||||
time.Sleep(time.Duration(sleepSeconds) * time.Second)
|
||||
|
||||
// 发布文案
|
||||
err := s.publishArticle(art)
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
failCount++
|
||||
userFailCount[uid]++
|
||||
if userFailCount[uid] >= failLimit && !pausedUsers[uid] {
|
||||
pausedUsers[uid] = true
|
||||
log.Printf("用户 %d 在本轮定时任务中失败次数达到 %d 次,暂停本轮后续发布", uid, userFailCount[uid])
|
||||
}
|
||||
log.Printf("发布失败 [文案ID: %d, 标题: %s]: %v", art.ID, art.Title, err)
|
||||
} else {
|
||||
successCount++
|
||||
log.Printf("发布成功 [文案ID: %d, 标题: %s]", art.ID, art.Title)
|
||||
}
|
||||
mu.Unlock()
|
||||
}(article, userID)
|
||||
}
|
||||
|
||||
// 等待所有发布完成
|
||||
wg.Wait()
|
||||
|
||||
duration := time.Since(startTime)
|
||||
log.Printf("========== 定时发布任务完成 ==========")
|
||||
log.Printf("总计: %d 篇, 成功: %d 篇, 失败: %d 篇, 耗时: %v",
|
||||
len(articles), successCount, failCount, duration)
|
||||
}
|
||||
|
||||
// publishArticle 发布单篇文案
|
||||
func (s *SchedulerService) publishArticle(article models.Article) error {
|
||||
// 1. 获取用户信息(发布用户)
|
||||
var user models.User
|
||||
if article.PublishUserID != nil {
|
||||
if err := database.DB.First(&user, *article.PublishUserID).Error; err != nil {
|
||||
return fmt.Errorf("获取发布用户信息失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 如果没有发布用户,使用创建用户
|
||||
if err := database.DB.First(&user, article.CreatedUserID).Error; err != nil {
|
||||
return fmt.Errorf("获取创建用户信息失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查用户是否绑定了小红书
|
||||
if user.IsBoundXHS != 1 || user.XHSCookie == "" {
|
||||
return errors.New("用户未绑定小红书账号或Cookie已失效")
|
||||
}
|
||||
|
||||
// 3. 获取文章图片
|
||||
var articleImages []models.ArticleImage
|
||||
if err := database.DB.Where("article_id = ?", article.ID).
|
||||
Order("sort_order ASC").
|
||||
Find(&articleImages).Error; err != nil {
|
||||
return fmt.Errorf("获取文章图片失败: %w", err)
|
||||
}
|
||||
|
||||
// 4. 提取图片URL列表
|
||||
var imageURLs []string
|
||||
for _, img := range articleImages {
|
||||
if img.ImageURL != "" {
|
||||
imageURLs = append(imageURLs, img.ImageURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 获取标签
|
||||
var tags []string
|
||||
var articleTag models.ArticleTag
|
||||
if err := database.DB.Where("article_id = ?", article.ID).First(&articleTag).Error; err == nil {
|
||||
if articleTag.CozeTag != "" {
|
||||
// 解析标签(支持逗号、分号、空格分隔)
|
||||
tags = parseTags(articleTag.CozeTag)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 解析Cookie(数据库中存储的是JSON字符串)
|
||||
var cookies interface{}
|
||||
if err := json.Unmarshal([]byte(user.XHSCookie), &cookies); err != nil {
|
||||
return fmt.Errorf("解析Cookie失败: %w,Cookie内容: %s", err, user.XHSCookie)
|
||||
}
|
||||
|
||||
// 7. 构造发布配置
|
||||
publishConfig := map[string]interface{}{
|
||||
"cookies": cookies, // 解析后的Cookie对象或数组
|
||||
"title": article.Title,
|
||||
"content": article.Content,
|
||||
"images": imageURLs,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
// 决定本次发布使用的代理
|
||||
proxyToUse := config.AppConfig.Scheduler.Proxy
|
||||
if proxyToUse == "" && config.AppConfig.Scheduler.ProxyFetchURL != "" {
|
||||
if dynamicProxy, err := fetchProxyFromPool(); err != nil {
|
||||
log.Printf("[代理池] 获取代理失败: %v", err)
|
||||
} else if dynamicProxy != "" {
|
||||
proxyToUse = dynamicProxy
|
||||
log.Printf("[代理池] 使用动态代理: %s", proxyToUse)
|
||||
}
|
||||
}
|
||||
|
||||
// 注入代理和User-Agent(如果有配置)
|
||||
if proxyToUse != "" {
|
||||
publishConfig["proxy"] = proxyToUse
|
||||
}
|
||||
if ua := config.AppConfig.Scheduler.UserAgent; ua != "" {
|
||||
publishConfig["user_agent"] = ua
|
||||
}
|
||||
|
||||
// 8. 保存临时配置文件
|
||||
tempDir := filepath.Join("..", "backend", "temp")
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
|
||||
configFile := filepath.Join(tempDir, fmt.Sprintf("publish_%d_%d.json", article.ID, time.Now().Unix()))
|
||||
configData, err := json.MarshalIndent(publishConfig, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configFile, configData, 0644); err != nil {
|
||||
return fmt.Errorf("保存配置文件失败: %w", err)
|
||||
}
|
||||
defer os.Remove(configFile) // 发布完成后删除临时文件
|
||||
|
||||
// 9. 调用Python发布脚本
|
||||
backendDir := filepath.Join("..", "backend")
|
||||
pythonScript := filepath.Join(backendDir, "xhs_publish.py")
|
||||
pythonCmd := getPythonPath(backendDir)
|
||||
|
||||
cmd := exec.Command(pythonCmd, pythonScript, "--config", configFile)
|
||||
cmd.Dir = backendDir
|
||||
|
||||
// 设置超时
|
||||
if s.publishTimeout > 0 {
|
||||
timer := time.AfterFunc(time.Duration(s.publishTimeout)*time.Second, func() {
|
||||
cmd.Process.Kill()
|
||||
})
|
||||
defer timer.Stop()
|
||||
}
|
||||
|
||||
// 捕获输出
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
// 执行命令
|
||||
err = cmd.Run()
|
||||
|
||||
// 打印Python脚本日志
|
||||
if stderr.Len() > 0 {
|
||||
log.Printf("[Python日志-发布文案%d] %s", article.ID, stderr.String())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// 更新文章状态为failed
|
||||
s.updateArticleStatus(article.ID, "failed", fmt.Sprintf("发布失败: %v", err))
|
||||
return fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// 10. 解析发布结果
|
||||
// 注意:Python脚本可能输出日志到stdout,需要提取最后一行JSON
|
||||
outputStr := stdout.String()
|
||||
|
||||
// 查找最后一个完整的JSON对象
|
||||
var result map[string]interface{}
|
||||
found := false
|
||||
|
||||
// 尝试从最后一行开始解析JSON
|
||||
lines := strings.Split(strings.TrimSpace(outputStr), "\n")
|
||||
|
||||
// 从后往前找第一个有效的JSON
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 尝试解析为JSON(必须以{开头)
|
||||
if strings.HasPrefix(line, "{") {
|
||||
if err := json.Unmarshal([]byte(line), &result); err == nil {
|
||||
found = true
|
||||
log.Printf("成功解析JSON结果(第%d行): %s", i+1, line)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
errMsg := "Python脚本未返回有效JSON结果"
|
||||
s.updateArticleStatus(article.ID, "failed", errMsg)
|
||||
log.Printf("完整输出内容:\n%s", outputStr)
|
||||
if stderr.Len() > 0 {
|
||||
log.Printf("错误输出:\n%s", stderr.String())
|
||||
}
|
||||
return fmt.Errorf("%s, output: %s", errMsg, outputStr)
|
||||
}
|
||||
|
||||
// 11. 检查发布是否成功
|
||||
success, ok := result["success"].(bool)
|
||||
if !ok || !success {
|
||||
errMsg := "未知错误"
|
||||
if errStr, ok := result["error"].(string); ok {
|
||||
errMsg = errStr
|
||||
}
|
||||
s.updateArticleStatus(article.ID, "failed", errMsg)
|
||||
return fmt.Errorf("发布失败: %s", errMsg)
|
||||
}
|
||||
|
||||
// 12. 更新文章状态为published
|
||||
s.updateArticleStatus(article.ID, "published", "发布成功")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateArticleStatus 更新文章状态
|
||||
func (s *SchedulerService) updateArticleStatus(articleID int, status, message string) {
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
}
|
||||
|
||||
if status == "published" {
|
||||
now := time.Now()
|
||||
updates["publish_time"] = now
|
||||
}
|
||||
|
||||
if message != "" {
|
||||
updates["review_comment"] = message
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&models.Article{}).Where("id = ?", articleID).Updates(updates).Error; err != nil {
|
||||
log.Printf("更新文章%d状态失败: %v", articleID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseTags 解析标签字符串(支持逗号、分号、空格分隔)
|
||||
func parseTags(tagStr string) []string {
|
||||
if tagStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 统一使用逗号分隔符
|
||||
tagStr = strings.ReplaceAll(tagStr, ";", ",")
|
||||
tagStr = strings.ReplaceAll(tagStr, " ", ",")
|
||||
tagStr = strings.ReplaceAll(tagStr, "、", ",")
|
||||
|
||||
tagsRaw := strings.Split(tagStr, ",")
|
||||
var tags []string
|
||||
for _, tag := range tagsRaw {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
165
go_backend/service/xhs_service.go
Normal file
165
go_backend/service/xhs_service.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type XHSService struct{}
|
||||
|
||||
// SendCodeRequest 发送验证码请求
|
||||
type SendCodeRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
CountryCode string `json:"country_code"`
|
||||
}
|
||||
|
||||
// SendCodeResponse 发送验证码响应
|
||||
type SendCodeResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
CountryCode string `json:"country_code"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// SendVerificationCode 调用Python脚本发送验证码
|
||||
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")
|
||||
|
||||
// 使用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())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// 获取UTF-8编码的输出
|
||||
outputStr := stdout.String()
|
||||
|
||||
// 解析JSON输出
|
||||
var result SendCodeResponse
|
||||
if err := json.Unmarshal([]byte(outputStr), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr)
|
||||
}
|
||||
|
||||
// 检查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
|
||||
}
|
||||
|
||||
// VerifyLogin 调用Python脚本验证登录
|
||||
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")
|
||||
|
||||
// 使用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())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// 获取UTF-8编码的输出
|
||||
outputStr := stdout.String()
|
||||
|
||||
// 解析JSON输出
|
||||
var result LoginResponse
|
||||
if err := json.Unmarshal([]byte(outputStr), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr)
|
||||
}
|
||||
|
||||
// 检查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
|
||||
}
|
||||
Reference in New Issue
Block a user