955 lines
28 KiB
Go
955 lines
28 KiB
Go
|
|
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"`
|
|||
|
|
}
|