first commit
This commit is contained in:
39
go_backend/.gitignore
vendored
Normal file
39
go_backend/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# .gitignore for Go Backend
|
||||
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
ai_xhs
|
||||
ai_xhs.exe
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Config (如果包含敏感信息,取消注释下面这行)
|
||||
# config/*.yaml
|
||||
264
go_backend/ENV_CONFIG_GUIDE.md
Normal file
264
go_backend/ENV_CONFIG_GUIDE.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 环境变量配置指南
|
||||
|
||||
## 概述
|
||||
|
||||
Go 服务支持通过**环境变量**来确定配置,优先级为:
|
||||
|
||||
```
|
||||
环境变量 > 配置文件 > 默认值
|
||||
```
|
||||
|
||||
## 1. 环境选择
|
||||
|
||||
### 方式一:环境变量 `APP_ENV`(推荐)
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
$env:APP_ENV="prod"
|
||||
.\start.bat
|
||||
|
||||
# Linux/Mac
|
||||
export APP_ENV=prod
|
||||
./start.sh
|
||||
|
||||
# Docker
|
||||
docker run -e APP_ENV=prod ...
|
||||
```
|
||||
|
||||
### 方式二:命令行参数
|
||||
```bash
|
||||
go run main.go -env=prod
|
||||
```
|
||||
|
||||
### 方式三:默认值
|
||||
不设置任何参数时,默认使用 `dev` 环境
|
||||
|
||||
---
|
||||
|
||||
## 2. 支持的环境变量
|
||||
|
||||
### 服务器配置
|
||||
| 环境变量 | 配置项 | 说明 | 示例 |
|
||||
|---------|--------|------|------|
|
||||
| `SERVER_PORT` | server.port | 服务端口 | 8080 |
|
||||
| `SERVER_MODE` | server.mode | 运行模式 | release |
|
||||
|
||||
### 数据库配置
|
||||
| 环境变量 | 配置项 | 说明 | 示例 |
|
||||
|---------|--------|------|------|
|
||||
| `DB_HOST` | database.host | 数据库地址 | localhost |
|
||||
| `DB_PORT` | database.port | 数据库端口 | 3306 |
|
||||
| `DB_USERNAME` | database.username | 用户名 | root |
|
||||
| `DB_PASSWORD` | database.password | 密码 | your_password |
|
||||
| `DB_NAME` | database.dbname | 数据库名 | ai_wht |
|
||||
| `DB_CHARSET` | database.charset | 字符集 | utf8mb4 |
|
||||
|
||||
### JWT 配置
|
||||
| 环境变量 | 配置项 | 说明 | 示例 |
|
||||
|---------|--------|------|------|
|
||||
| `JWT_SECRET` | jwt.secret | JWT 密钥 | your_secret_key |
|
||||
| `JWT_EXPIRE_HOURS` | jwt.expire_hours | 过期时间(小时) | 168 |
|
||||
|
||||
### 微信配置
|
||||
| 环境变量 | 配置项 | 说明 | 示例 |
|
||||
|---------|--------|------|------|
|
||||
| `WECHAT_APP_ID` | wechat.app_id | 微信 AppID | wx1234567890 |
|
||||
| `WECHAT_APP_SECRET` | wechat.app_secret | 微信 AppSecret | your_secret |
|
||||
|
||||
### 小红书配置
|
||||
| 环境变量 | 配置项 | 说明 | 示例 |
|
||||
|---------|--------|------|------|
|
||||
| `XHS_PYTHON_SERVICE_URL` | xhs.python_service_url | Python服务地址 | http://localhost:8000 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 使用场景
|
||||
|
||||
### 场景一:本地开发(覆盖数据库密码)
|
||||
```bash
|
||||
# Windows
|
||||
$env:DB_PASSWORD="local_password"
|
||||
go run main.go
|
||||
|
||||
# Linux/Mac
|
||||
export DB_PASSWORD=local_password
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### 场景二:生产部署
|
||||
```bash
|
||||
# 设置生产环境
|
||||
$env:APP_ENV="prod"
|
||||
$env:DB_HOST="prod-db.example.com"
|
||||
$env:DB_PASSWORD="prod_secure_password"
|
||||
$env:JWT_SECRET="prod_jwt_secret_key"
|
||||
$env:WECHAT_APP_ID="wx_prod_appid"
|
||||
$env:WECHAT_APP_SECRET="wx_prod_secret"
|
||||
|
||||
.\start.bat
|
||||
```
|
||||
|
||||
### 场景三:Docker 部署
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM golang:1.21-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN go build -o main .
|
||||
|
||||
# 设置环境变量
|
||||
ENV APP_ENV=prod
|
||||
ENV SERVER_PORT=8080
|
||||
ENV DB_HOST=mysql-server
|
||||
|
||||
CMD ["./main"]
|
||||
```
|
||||
|
||||
```bash
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
services:
|
||||
go-backend:
|
||||
build: .
|
||||
environment:
|
||||
- APP_ENV=prod
|
||||
- DB_HOST=mysql
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- WECHAT_APP_ID=${WECHAT_APP_ID}
|
||||
- WECHAT_APP_SECRET=${WECHAT_APP_SECRET}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
```
|
||||
|
||||
### 场景四:CI/CD 流水线
|
||||
```yaml
|
||||
# GitHub Actions
|
||||
env:
|
||||
APP_ENV: prod
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
WECHAT_APP_ID: ${{ secrets.WECHAT_APP_ID }}
|
||||
WECHAT_APP_SECRET: ${{ secrets.WECHAT_APP_SECRET }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置优先级示例
|
||||
|
||||
假设 `config.prod.yaml` 中配置:
|
||||
```yaml
|
||||
database:
|
||||
host: localhost
|
||||
port: 3306
|
||||
password: file_password
|
||||
```
|
||||
|
||||
运行时设置环境变量:
|
||||
```bash
|
||||
$env:DB_HOST="prod-server.com"
|
||||
$env:DB_PASSWORD="env_password"
|
||||
```
|
||||
|
||||
**最终生效的配置:**
|
||||
```
|
||||
host: prod-server.com # 来自环境变量 DB_HOST
|
||||
port: 3306 # 来自配置文件
|
||||
password: env_password # 来自环境变量 DB_PASSWORD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 查看当前配置
|
||||
|
||||
启动服务时,会在日志中输出:
|
||||
```
|
||||
2024/12/15 14:45:00 从环境变量 APP_ENV 读取环境: prod
|
||||
2024/12/15 14:45:00 配置加载成功: prod 环境
|
||||
2024/12/15 14:45:00 数据库配置: root@prod-server.com:3306/ai_wht
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全建议
|
||||
|
||||
1. **敏感信息不要写入配置文件**,使用环境变量:
|
||||
- `DB_PASSWORD`
|
||||
- `JWT_SECRET`
|
||||
- `WECHAT_APP_SECRET`
|
||||
|
||||
2. **生产环境必须覆盖的环境变量**:
|
||||
```bash
|
||||
APP_ENV=prod
|
||||
DB_PASSWORD=<secure_password>
|
||||
JWT_SECRET=<random_secret_key>
|
||||
WECHAT_APP_SECRET=<wechat_secret>
|
||||
```
|
||||
|
||||
3. **使用密钥管理工具**(可选):
|
||||
- Azure Key Vault
|
||||
- AWS Secrets Manager
|
||||
- HashiCorp Vault
|
||||
|
||||
---
|
||||
|
||||
## 7. 快速启动脚本
|
||||
|
||||
### Windows (start_with_env.bat)
|
||||
```bat
|
||||
@echo off
|
||||
set APP_ENV=prod
|
||||
set DB_HOST=localhost
|
||||
set DB_PASSWORD=your_password
|
||||
set JWT_SECRET=your_jwt_secret
|
||||
set WECHAT_APP_ID=your_appid
|
||||
set WECHAT_APP_SECRET=your_secret
|
||||
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### Linux/Mac (start_with_env.sh)
|
||||
```bash
|
||||
#!/bin/bash
|
||||
export APP_ENV=prod
|
||||
export DB_HOST=localhost
|
||||
export DB_PASSWORD=your_password
|
||||
export JWT_SECRET=your_jwt_secret
|
||||
export WECHAT_APP_ID=your_appid
|
||||
export WECHAT_APP_SECRET=your_secret
|
||||
|
||||
go run main.go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见问题
|
||||
|
||||
### Q: 环境变量没生效?
|
||||
**A:** 检查环境变量名称是否正确,区分大小写。
|
||||
|
||||
### Q: 如何查看所有环境变量?
|
||||
**A:**
|
||||
```bash
|
||||
# Windows
|
||||
Get-ChildItem Env:
|
||||
|
||||
# Linux/Mac
|
||||
printenv
|
||||
```
|
||||
|
||||
### Q: 如何临时设置环境变量?
|
||||
**A:**
|
||||
```bash
|
||||
# Windows - 当前会话有效
|
||||
$env:DB_PASSWORD="temp_password"
|
||||
|
||||
# Linux/Mac - 当前会话有效
|
||||
export DB_PASSWORD=temp_password
|
||||
```
|
||||
|
||||
### Q: 如何永久设置环境变量?
|
||||
**A:**
|
||||
- Windows: 系统设置 → 高级系统设置 → 环境变量
|
||||
- Linux/Mac: 添加到 `~/.bashrc` 或 `~/.zshrc`
|
||||
166
go_backend/PYTHON_CROSS_PLATFORM.md
Normal file
166
go_backend/PYTHON_CROSS_PLATFORM.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Python虚拟环境跨平台配置说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
Go服务已支持跨平台调用Python脚本,可以在Windows和Ubuntu/Linux环境下正常运行。
|
||||
|
||||
## 🔄 Windows vs Linux 路径对比
|
||||
|
||||
### Windows环境
|
||||
```
|
||||
backend/
|
||||
├── venv/
|
||||
│ ├── Scripts/ ← Windows使用Scripts目录
|
||||
│ │ ├── python.exe
|
||||
│ │ ├── activate.bat
|
||||
│ │ └── ...
|
||||
│ └── Lib/
|
||||
```
|
||||
|
||||
**Python解释器**: `backend/venv/Scripts/python.exe`
|
||||
|
||||
### Ubuntu/Linux环境
|
||||
```
|
||||
backend/
|
||||
├── venv/
|
||||
│ ├── bin/ ← Linux使用bin目录
|
||||
│ │ ├── python
|
||||
│ │ ├── activate
|
||||
│ │ └── ...
|
||||
│ └── lib/
|
||||
```
|
||||
|
||||
**Python解释器**: `backend/venv/bin/python`
|
||||
|
||||
## 🚀 部署步骤
|
||||
|
||||
### 在Ubuntu服务器上部署
|
||||
|
||||
1. **创建Python虚拟环境**:
|
||||
```bash
|
||||
cd /path/to/backend
|
||||
python3 -m venv venv
|
||||
```
|
||||
|
||||
2. **激活虚拟环境**:
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
3. **安装依赖**:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
4. **启动Go服务**:
|
||||
```bash
|
||||
cd /path/to/go_backend
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### 在Windows上开发
|
||||
|
||||
1. **创建Python虚拟环境**:
|
||||
```cmd
|
||||
cd backend
|
||||
python -m venv venv
|
||||
```
|
||||
|
||||
2. **激活虚拟环境**:
|
||||
```cmd
|
||||
venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **安装依赖**:
|
||||
```cmd
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
4. **启动Go服务**:
|
||||
```cmd
|
||||
cd go_backend
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 跨平台路径检测
|
||||
|
||||
Go代码中使用`runtime.GOOS`自动检测操作系统:
|
||||
|
||||
```go
|
||||
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")
|
||||
}
|
||||
```
|
||||
|
||||
### 使用位置
|
||||
|
||||
该函数在以下服务中被调用:
|
||||
- `service/xhs_service.go` - 小红书登录服务
|
||||
- `service/employee_service.go` - 员工服务(绑定小红书账号)
|
||||
|
||||
## ✅ 验证部署
|
||||
|
||||
### 测试Python环境
|
||||
```bash
|
||||
# Ubuntu
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
python xhs_cli.py --help
|
||||
|
||||
# Windows
|
||||
cd backend
|
||||
venv\Scripts\activate
|
||||
python xhs_cli.py --help
|
||||
```
|
||||
|
||||
### 测试Go调用
|
||||
```bash
|
||||
# 启动Go服务后,测试发送验证码接口
|
||||
curl -X POST http://localhost:8080/api/employee/xhs/send-code \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"13800138000"}'
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **不要提交venv目录到Git**:已在`.gitignore`中配置忽略
|
||||
2. **环境隔离**:Windows和Ubuntu各自维护独立的venv环境
|
||||
3. **依赖一致性**:确保requirements.txt在两个平台上一致
|
||||
4. **Playwright浏览器**:在Ubuntu上需要安装chromium依赖库
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### Q: Ubuntu上提示找不到Python
|
||||
**A**: 确保已安装Python3:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install python3 python3-venv python3-pip
|
||||
```
|
||||
|
||||
### Q: Playwright启动失败
|
||||
**A**: 安装系统依赖:
|
||||
```bash
|
||||
playwright install-deps chromium
|
||||
```
|
||||
|
||||
### Q: Go服务找不到Python脚本
|
||||
**A**: 检查`backend`目录与`go_backend`目录的相对位置,确保为:
|
||||
```
|
||||
project/
|
||||
├── backend/ # Python脚本
|
||||
└── go_backend/ # Go服务
|
||||
```
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [Go服务环境变量配置](ENV_CONFIG_GUIDE.md)
|
||||
- [Python CLI工具文档](../backend/XHS_CLI_README.md)
|
||||
240
go_backend/README.md
Normal file
240
go_backend/README.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# AI小红书 - Go后端服务
|
||||
|
||||
基于Go + Gin + GORM + MySQL开发的小红书营销助手后端服务。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Web框架**: Gin
|
||||
- **ORM**: GORM
|
||||
- **数据库**: MySQL 8.0+
|
||||
- **配置管理**: Viper
|
||||
- **认证**: JWT
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
go_backend/
|
||||
├── config/ # 配置文件
|
||||
│ ├── config.go # 配置加载
|
||||
│ ├── config.dev.yaml # 开发环境配置
|
||||
│ └── config.prod.yaml# 生产环境配置
|
||||
├── models/ # 数据模型
|
||||
│ └── models.go
|
||||
├── database/ # 数据库
|
||||
│ └── database.go
|
||||
├── service/ # 业务逻辑层
|
||||
│ └── employee_service.go
|
||||
├── controller/ # 控制器层
|
||||
│ └── employee_controller.go
|
||||
├── router/ # 路由
|
||||
│ └── router.go
|
||||
├── middleware/ # 中间件
|
||||
│ └── auth.go
|
||||
├── common/ # 公共模块
|
||||
│ └── response.go
|
||||
├── utils/ # 工具函数
|
||||
│ └── jwt.go
|
||||
├── main.go # 入口文件
|
||||
├── go.mod # 依赖管理
|
||||
├── start.sh # Linux/Mac启动脚本
|
||||
├── start.bat # Windows开发环境启动脚本
|
||||
└── start_prod.bat # Windows生产环境启动脚本
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 环境要求
|
||||
|
||||
- Go 1.21+
|
||||
- MySQL 8.0+
|
||||
|
||||
### 2. 数据库准备
|
||||
|
||||
创建数据库:
|
||||
|
||||
```sql
|
||||
-- 开发环境
|
||||
CREATE DATABASE ai_xhs_dev DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 生产环境
|
||||
CREATE DATABASE ai_xhs_prod DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### 3. 配置文件
|
||||
|
||||
配置文件位于 `config/` 目录:
|
||||
|
||||
- `config.dev.yaml` - 开发环境配置
|
||||
- `config.prod.yaml` - 生产环境配置
|
||||
|
||||
根据需要修改数据库连接信息和JWT密钥。
|
||||
|
||||
### 4. 安装依赖
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
### 5. 启动服务
|
||||
|
||||
#### Windows开发环境
|
||||
|
||||
双击运行 `start.bat` 或命令行执行:
|
||||
|
||||
```bash
|
||||
start.bat
|
||||
```
|
||||
|
||||
#### Windows生产环境
|
||||
|
||||
双击运行 `start_prod.bat` 或命令行执行:
|
||||
|
||||
```bash
|
||||
start_prod.bat
|
||||
```
|
||||
|
||||
#### Linux/Mac
|
||||
|
||||
```bash
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
或手动启动:
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
go run main.go -env=dev
|
||||
|
||||
# 生产环境
|
||||
go run main.go -env=prod
|
||||
```
|
||||
|
||||
### 6. 验证服务
|
||||
|
||||
访问健康检查接口:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
所有接口都需要在请求头中携带JWT Token(除登录接口外):
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 员工端接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 获取个人信息 | GET | `/api/employee/profile` | 获取当前员工信息 |
|
||||
| 绑定小红书 | POST | `/api/employee/bind-xhs` | 绑定小红书账号 |
|
||||
| 解绑小红书 | POST | `/api/employee/unbind-xhs` | 解绑小红书账号 |
|
||||
| 可领取文案 | GET | `/api/employee/available-copies` | 获取可领取的文案列表 |
|
||||
| 领取文案 | POST | `/api/employee/claim-copy` | 领取指定文案 |
|
||||
| 随机领取 | POST | `/api/employee/claim-random-copy` | 随机领取文案 |
|
||||
| 发布内容 | POST | `/api/employee/publish` | 发布内容到小红书 |
|
||||
| 发布记录 | GET | `/api/employee/my-publish-records` | 获取我的发布记录 |
|
||||
| 产品列表 | GET | `/api/employee/products` | 获取产品列表 |
|
||||
|
||||
详细接口文档请参考项目需求文档。
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
系统会在启动时自动创建以下数据表:
|
||||
|
||||
- `enterprises` - 企业表
|
||||
- `employees` - 员工表
|
||||
- `products` - 产品表
|
||||
- `copies` - 文案表
|
||||
- `copy_claims` - 文案领取记录表
|
||||
- `publish_records` - 发布记录表
|
||||
|
||||
## 开发说明
|
||||
|
||||
### 添加新接口
|
||||
|
||||
1. 在 `models/` 中定义数据模型
|
||||
2. 在 `service/` 中实现业务逻辑
|
||||
3. 在 `controller/` 中创建控制器方法
|
||||
4. 在 `router/router.go` 中注册路由
|
||||
|
||||
### 环境切换
|
||||
|
||||
通过 `-env` 参数切换环境:
|
||||
|
||||
```bash
|
||||
go run main.go -env=dev # 开发环境
|
||||
go run main.go -env=prod # 生产环境
|
||||
```
|
||||
|
||||
### 日志
|
||||
|
||||
GORM日志级别在 `database/database.go` 中配置,默认为 `Info` 级别。
|
||||
|
||||
## 部署
|
||||
|
||||
### 编译
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
go build -o ai_xhs.exe main.go
|
||||
|
||||
# Linux/Mac
|
||||
go build -o ai_xhs main.go
|
||||
```
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
ai_xhs.exe -env=prod
|
||||
|
||||
# Linux/Mac
|
||||
./ai_xhs -env=prod
|
||||
```
|
||||
|
||||
### 生产环境注意事项
|
||||
|
||||
1. 修改 `config.prod.yaml` 中的JWT密钥
|
||||
2. 配置正确的数据库连接信息
|
||||
3. 设置服务器防火墙规则
|
||||
4. 建议使用进程管理工具(如systemd、supervisor)
|
||||
5. 配置反向代理(如Nginx)
|
||||
6. 启用HTTPS
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 数据库连接失败
|
||||
|
||||
检查:
|
||||
- MySQL服务是否启动
|
||||
- 数据库名称是否正确
|
||||
- 用户名密码是否正确
|
||||
- 数据库字符集是否为 utf8mb4
|
||||
|
||||
### 2. 端口已被占用
|
||||
|
||||
修改配置文件中的 `server.port` 配置项。
|
||||
|
||||
### 3. JWT认证失败
|
||||
|
||||
检查:
|
||||
- Token是否正确携带
|
||||
- Token格式是否为 `Bearer <token>`
|
||||
- Token是否过期
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
412
go_backend/UBUNTU_SCRIPTS_GUIDE.md
Normal file
412
go_backend/UBUNTU_SCRIPTS_GUIDE.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# Ubuntu 启动脚本使用指南
|
||||
|
||||
## 📁 脚本文件列表
|
||||
|
||||
| 脚本文件 | 用途 | 推荐场景 |
|
||||
|---------|------|----------|
|
||||
| `restart.sh` | 智能重启脚本(支持 dev/prod) | **推荐使用** - 开发和生产环境通用 |
|
||||
| `start_prod.sh` | 生产环境快速启动 | 仅生产环境快速部署 |
|
||||
| `stop.sh` | 停止服务脚本 | 停止所有运行的服务 |
|
||||
| `start.sh` | 开发环境启动(原有) | 开发环境直接运行 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 赋予执行权限
|
||||
```bash
|
||||
cd go_backend
|
||||
|
||||
# 一次性赋予所有脚本执行权限
|
||||
chmod +x restart.sh start_prod.sh stop.sh start.sh
|
||||
```
|
||||
|
||||
### 2. 启动开发环境
|
||||
```bash
|
||||
# 方式1: 使用 restart.sh (推荐)
|
||||
./restart.sh dev
|
||||
|
||||
# 方式2: 默认启动开发环境
|
||||
./restart.sh
|
||||
|
||||
# 方式3: 使用原有脚本
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### 3. 启动生产环境
|
||||
```bash
|
||||
# 方式1: 使用 restart.sh (推荐)
|
||||
./restart.sh prod
|
||||
|
||||
# 方式2: 使用专用脚本
|
||||
./start_prod.sh
|
||||
```
|
||||
|
||||
### 4. 停止服务
|
||||
```bash
|
||||
./stop.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 详细说明
|
||||
|
||||
### restart.sh - 智能重启脚本 ⭐推荐
|
||||
|
||||
**功能特点:**
|
||||
- ✅ 自动停止旧服务
|
||||
- ✅ 支持开发/生产环境切换
|
||||
- ✅ 完整的环境检查
|
||||
- ✅ 多重端口清理机制
|
||||
- ✅ 启动验证和错误检测
|
||||
- ✅ 彩色输出,易于阅读
|
||||
|
||||
**使用方法:**
|
||||
```bash
|
||||
# 启动开发环境 (端口 8080)
|
||||
./restart.sh
|
||||
./restart.sh dev
|
||||
|
||||
# 启动生产环境 (端口 8070)
|
||||
./restart.sh prod
|
||||
|
||||
# 查看帮助
|
||||
./restart.sh help
|
||||
```
|
||||
|
||||
**输出示例:**
|
||||
```
|
||||
========================================
|
||||
AI小红书 Go 后端服务重启脚本
|
||||
========================================
|
||||
环境: dev
|
||||
端口: 8080
|
||||
日志: ai_xhs.log
|
||||
|
||||
=== [1/4] 停止现有服务 ===
|
||||
✅ 端口 8080 已释放
|
||||
|
||||
=== [2/4] 环境检查 ===
|
||||
✅ Go 环境: go version go1.21.0 linux/amd64
|
||||
✅ 主文件: main.go
|
||||
✅ 配置文件: config/config.dev.yaml
|
||||
|
||||
=== [3/4] 下载依赖 ===
|
||||
✅ 依赖下载完成
|
||||
|
||||
=== [4/4] 启动服务 ===
|
||||
✅ 服务已启动,进程 PID: 12345
|
||||
|
||||
========================================
|
||||
🎉 服务启动成功!
|
||||
========================================
|
||||
|
||||
服务信息:
|
||||
环境: dev
|
||||
端口: 8080
|
||||
进程PID: 12345
|
||||
日志文件: ai_xhs.log
|
||||
|
||||
快捷命令:
|
||||
查看日志: tail -f ai_xhs.log
|
||||
停止服务: kill -9 12345
|
||||
|
||||
访问地址:
|
||||
本地: http://localhost:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### start_prod.sh - 生产环境快速启动
|
||||
|
||||
**功能特点:**
|
||||
- ✅ 专为生产环境优化
|
||||
- ✅ 简洁快速
|
||||
- ✅ 固定端口 8070
|
||||
- ✅ 独立日志文件 `ai_xhs_prod.log`
|
||||
|
||||
**使用方法:**
|
||||
```bash
|
||||
./start_prod.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### stop.sh - 停止服务脚本
|
||||
|
||||
**功能特点:**
|
||||
- ✅ 同时停止开发和生产环境
|
||||
- ✅ 多种停止方法确保彻底清理
|
||||
- ✅ 自动验证停止结果
|
||||
- ✅ 清理所有相关进程
|
||||
|
||||
**使用方法:**
|
||||
```bash
|
||||
./stop.sh
|
||||
```
|
||||
|
||||
**清理范围:**
|
||||
- 所有 `go run main.go` 进程
|
||||
- 占用 8080 端口的进程(开发环境)
|
||||
- 占用 8070 端口的进程(生产环境)
|
||||
- 其他相关 main.go 进程
|
||||
|
||||
---
|
||||
|
||||
## 🔧 环境变量配置
|
||||
|
||||
### 通过环境变量覆盖配置
|
||||
|
||||
脚本支持通过环境变量覆盖配置文件:
|
||||
|
||||
```bash
|
||||
# 设置环境
|
||||
export APP_ENV=prod
|
||||
|
||||
# 覆盖数据库配置
|
||||
export DB_HOST=prod-server.com
|
||||
export DB_PASSWORD=secure_password
|
||||
|
||||
# 覆盖微信配置
|
||||
export WECHAT_APP_ID=wx_prod_id
|
||||
export WECHAT_APP_SECRET=wx_prod_secret
|
||||
|
||||
# 启动服务
|
||||
./restart.sh
|
||||
```
|
||||
|
||||
**支持的环境变量:** 详见 [ENV_CONFIG_GUIDE.md](ENV_CONFIG_GUIDE.md)
|
||||
|
||||
---
|
||||
|
||||
## 📋 日志管理
|
||||
|
||||
### 查看实时日志
|
||||
```bash
|
||||
# 开发环境
|
||||
tail -f ai_xhs.log
|
||||
|
||||
# 生产环境
|
||||
tail -f ai_xhs_prod.log
|
||||
|
||||
# 查看最后 50 行
|
||||
tail -n 50 ai_xhs.log
|
||||
|
||||
# 搜索错误日志
|
||||
grep -i "error\|fatal\|panic" ai_xhs.log
|
||||
```
|
||||
|
||||
### 日志文件说明
|
||||
|
||||
| 文件 | 用途 | 环境 |
|
||||
|------|------|------|
|
||||
| `ai_xhs.log` | 开发环境日志 | dev |
|
||||
| `ai_xhs_prod.log` | 生产环境日志 | prod |
|
||||
|
||||
---
|
||||
|
||||
## 🛠 常用命令
|
||||
|
||||
### 检查服务状态
|
||||
```bash
|
||||
# 查看 Go 进程
|
||||
ps aux | grep "go run main.go"
|
||||
|
||||
# 查看端口占用
|
||||
lsof -i:8080 # 开发环境
|
||||
lsof -i:8070 # 生产环境
|
||||
|
||||
# 查看所有监听端口
|
||||
netstat -tunlp | grep go
|
||||
```
|
||||
|
||||
### 手动停止服务
|
||||
```bash
|
||||
# 方法1: 使用 PID (推荐)
|
||||
kill -9 <PID>
|
||||
|
||||
# 方法2: 停止所有 go run 进程
|
||||
pkill -f "go run main.go"
|
||||
|
||||
# 方法3: 通过端口停止
|
||||
sudo fuser -k 8080/tcp
|
||||
```
|
||||
|
||||
### 测试服务
|
||||
```bash
|
||||
# 测试服务是否启动
|
||||
curl http://localhost:8080/api/health
|
||||
|
||||
# 查看返回内容
|
||||
curl -i http://localhost:8080/api/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 权限问题
|
||||
某些清理操作需要 sudo 权限:
|
||||
```bash
|
||||
# 如果遇到权限问题,使用 sudo
|
||||
sudo ./stop.sh
|
||||
```
|
||||
|
||||
### 2. 端口冲突
|
||||
如果端口被其他程序占用:
|
||||
```bash
|
||||
# 查看占用端口的程序
|
||||
lsof -i:8080
|
||||
|
||||
# 修改配置文件中的端口
|
||||
vim config/config.dev.yaml
|
||||
```
|
||||
|
||||
### 3. 日志文件过大
|
||||
定期清理日志:
|
||||
```bash
|
||||
# 清空日志文件
|
||||
> ai_xhs.log
|
||||
|
||||
# 或删除旧日志
|
||||
rm ai_xhs.log
|
||||
```
|
||||
|
||||
### 4. 后台运行说明
|
||||
- 脚本使用 `nohup` 在后台运行服务
|
||||
- 关闭终端不会停止服务
|
||||
- 必须使用 `kill` 或 `stop.sh` 停止服务
|
||||
|
||||
---
|
||||
|
||||
## 🔄 系统服务化 (可选)
|
||||
|
||||
### 创建 systemd 服务
|
||||
|
||||
如果需要开机自启动,可以创建系统服务:
|
||||
|
||||
```bash
|
||||
# 创建服务文件
|
||||
sudo vim /etc/systemd/system/ai_xhs.service
|
||||
```
|
||||
|
||||
添加内容:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=AI XHS Go Backend
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your_user
|
||||
WorkingDirectory=/path/to/go_backend
|
||||
Environment="APP_ENV=prod"
|
||||
ExecStart=/usr/local/go/bin/go run main.go
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启用服务:
|
||||
```bash
|
||||
# 重载配置
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# 启动服务
|
||||
sudo systemctl start ai_xhs
|
||||
|
||||
# 设置开机自启
|
||||
sudo systemctl enable ai_xhs
|
||||
|
||||
# 查看状态
|
||||
sudo systemctl status ai_xhs
|
||||
|
||||
# 查看日志
|
||||
sudo journalctl -u ai_xhs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 故障排查
|
||||
|
||||
### 问题1: 服务启动失败
|
||||
```bash
|
||||
# 检查日志
|
||||
tail -f ai_xhs.log
|
||||
|
||||
# 检查配置文件
|
||||
cat config/config.dev.yaml
|
||||
|
||||
# 检查 Go 环境
|
||||
go version
|
||||
```
|
||||
|
||||
### 问题2: 端口无法释放
|
||||
```bash
|
||||
# 强制停止
|
||||
sudo ./stop.sh
|
||||
|
||||
# 检查是否还有进程
|
||||
lsof -i:8080
|
||||
|
||||
# 手动清理
|
||||
sudo fuser -k 8080/tcp
|
||||
```
|
||||
|
||||
### 问题3: 找不到依赖
|
||||
```bash
|
||||
# 重新下载依赖
|
||||
go mod tidy
|
||||
go mod download
|
||||
|
||||
# 清理缓存
|
||||
go clean -modcache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 快速参考
|
||||
|
||||
### 一键部署生产环境
|
||||
```bash
|
||||
# 1. 进入目录
|
||||
cd go_backend
|
||||
|
||||
# 2. 赋予权限
|
||||
chmod +x restart.sh
|
||||
|
||||
# 3. 设置环境变量(可选)
|
||||
export DB_PASSWORD=your_password
|
||||
|
||||
# 4. 启动服务
|
||||
./restart.sh prod
|
||||
|
||||
# 5. 查看日志
|
||||
tail -f ai_xhs.log
|
||||
```
|
||||
|
||||
### 一键停止所有服务
|
||||
```bash
|
||||
chmod +x stop.sh
|
||||
./stop.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
1. **使用 restart.sh** - 功能最完善,错误检查最全面
|
||||
2. **配置环境变量** - 敏感信息不要写入配置文件
|
||||
3. **定期查看日志** - 及时发现问题
|
||||
4. **使用 systemd** - 生产环境推荐系统服务化
|
||||
5. **备份配置文件** - 修改前先备份
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [环境变量配置指南](ENV_CONFIG_GUIDE.md)
|
||||
- [数据库迁移指南](DATABASE_MIGRATION_GUIDE.md)
|
||||
- [微信登录集成指南](WECHAT_LOGIN_GUIDE.md)
|
||||
62
go_backend/common/response.go
Normal file
62
go_backend/common/response.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Success 成功响应
|
||||
func Success(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: 200,
|
||||
Message: "success",
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// SuccessWithMessage 成功响应(自定义消息)
|
||||
func SuccessWithMessage(c *gin.Context, message string, data interface{}) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: 200,
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Error 错误响应
|
||||
func Error(c *gin.Context, code int, message string) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: code,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// ErrorWithData 错误响应(带数据)
|
||||
func ErrorWithData(c *gin.Context, code int, message string, data interface{}) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// 常见错误码
|
||||
const (
|
||||
CodeSuccess = 200
|
||||
CodeInvalidParams = 400
|
||||
CodeUnauthorized = 401
|
||||
CodeForbidden = 403
|
||||
CodeNotFound = 404
|
||||
CodeInternalError = 500
|
||||
CodeServerError = 500 // 服务器错误(别名)
|
||||
CodeBindXHSFailed = 1001
|
||||
CodeCopyNotAvailable = 1002
|
||||
CodeAlreadyClaimed = 1003
|
||||
)
|
||||
42
go_backend/config/config.dev.yaml
Normal file
42
go_backend/config/config.dev.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
server:
|
||||
port: 8080
|
||||
mode: debug # debug, release, test
|
||||
|
||||
database:
|
||||
host: localhost
|
||||
port: 3306
|
||||
username: root
|
||||
password: JKjk20011115
|
||||
dbname: ai_wht
|
||||
charset: utf8mb4
|
||||
parse_time: true
|
||||
loc: Local
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
conn_max_lifetime: 3600
|
||||
|
||||
jwt:
|
||||
secret: dev_secret_key_change_in_production
|
||||
expire_hours: 168 # 7天
|
||||
|
||||
wechat:
|
||||
app_id: "wxa5bf062342ef754d" # 微信小程序AppID,留空则使用默认登录
|
||||
app_secret: "69d2a3ddc902b26f82f4b56a6e277f7a" # 微信小程序AppSecret
|
||||
|
||||
xhs:
|
||||
python_service_url: "http://localhost:8000" # Python服务地址
|
||||
|
||||
scheduler:
|
||||
enabled: true # 是否启用定时任务
|
||||
publish_cron: "* * * * * *" # 每1小时执行一次(开发环境测试用)
|
||||
max_concurrent: 2 # 最大并发发布数
|
||||
publish_timeout: 300 # 发布超时时间(秒)
|
||||
max_articles_per_user_per_run: 2 # 每轮每个用户最大发文数
|
||||
max_failures_per_user_per_run: 3 # 每轮每个用户最大失败次数
|
||||
max_daily_articles_per_user: 6 # 每个用户每日最大发文数(自动发布)
|
||||
max_hourly_articles_per_user: 2 # 每个用户每小时最大发文数(自动发布)
|
||||
proxy: "" # 可选:静态全局代理地址,例如 http://user:pass@ip:port
|
||||
user_agent: "" # 可选:自定义User-Agent,不填则使用默认
|
||||
proxy_pool:
|
||||
enabled: true # 开发环境启用代理池
|
||||
api_url: "http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964"
|
||||
159
go_backend/config/config.go
Normal file
159
go_backend/config/config.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Wechat WechatConfig `mapstructure:"wechat"`
|
||||
XHS XHSConfig `mapstructure:"xhs"`
|
||||
Scheduler SchedulerConfig `mapstructure:"scheduler"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
DBName string `mapstructure:"dbname"`
|
||||
Charset string `mapstructure:"charset"`
|
||||
ParseTime bool `mapstructure:"parse_time"`
|
||||
Loc string `mapstructure:"loc"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpireHours int `mapstructure:"expire_hours"`
|
||||
}
|
||||
|
||||
type WechatConfig struct {
|
||||
AppID string `mapstructure:"app_id"`
|
||||
AppSecret string `mapstructure:"app_secret"`
|
||||
}
|
||||
|
||||
type XHSConfig struct {
|
||||
PythonServiceURL string `mapstructure:"python_service_url"`
|
||||
}
|
||||
|
||||
type SchedulerConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // 是否启用定时任务
|
||||
PublishCron string `mapstructure:"publish_cron"` // 发布任务的Cron表达式
|
||||
MaxConcurrent int `mapstructure:"max_concurrent"` // 最大并发发布数
|
||||
PublishTimeout int `mapstructure:"publish_timeout"` // 发布超时时间(秒)
|
||||
MaxArticlesPerUserPerRun int `mapstructure:"max_articles_per_user_per_run"` // 每轮每个用户最大发文数
|
||||
MaxFailuresPerUserPerRun int `mapstructure:"max_failures_per_user_per_run"` // 每轮每个用户最大失败次数
|
||||
MaxDailyArticlesPerUser int `mapstructure:"max_daily_articles_per_user"` // 每个用户每日最大发文数
|
||||
MaxHourlyArticlesPerUser int `mapstructure:"max_hourly_articles_per_user"` // 每个用户每小时最大发文数
|
||||
Proxy string `mapstructure:"proxy"` // 全局代理地址(可选)
|
||||
UserAgent string `mapstructure:"user_agent"` // 全局User-Agent(可选)
|
||||
ProxyFetchURL string `mapstructure:"proxy_fetch_url"` // 动态获取代理的接口地址(可选)
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
|
||||
// LoadConfig 加载配置文件
|
||||
// 优先级: 环境变量 > 配置文件
|
||||
func LoadConfig(env string) error {
|
||||
// 1. 优先从环境变量 APP_ENV 获取环境配置
|
||||
if envFromEnv := os.Getenv("APP_ENV"); envFromEnv != "" {
|
||||
env = envFromEnv
|
||||
log.Printf("从环境变量 APP_ENV 读取环境: %s", env)
|
||||
}
|
||||
|
||||
if env == "" {
|
||||
env = "dev"
|
||||
}
|
||||
|
||||
// 2. 加载配置文件
|
||||
viper.SetConfigName(fmt.Sprintf("config.%s", env))
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath("./config")
|
||||
viper.AddConfigPath("../config")
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 3. 绑定环境变量(自动读取环境变量覆盖配置)
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// 绑定特定的环境变量到配置项
|
||||
bindEnvVariables()
|
||||
|
||||
AppConfig = &Config{}
|
||||
if err := viper.Unmarshal(AppConfig); err != nil {
|
||||
return fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("配置加载成功: %s 环境", env)
|
||||
log.Printf("数据库配置: %s@%s:%d/%s", AppConfig.Database.Username, AppConfig.Database.Host, AppConfig.Database.Port, AppConfig.Database.DBName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindEnvVariables 绑定环境变量到配置项
|
||||
func bindEnvVariables() {
|
||||
// Server 配置
|
||||
viper.BindEnv("server.port", "SERVER_PORT")
|
||||
viper.BindEnv("server.mode", "SERVER_MODE")
|
||||
|
||||
// Database 配置
|
||||
viper.BindEnv("database.host", "DB_HOST")
|
||||
viper.BindEnv("database.port", "DB_PORT")
|
||||
viper.BindEnv("database.username", "DB_USERNAME")
|
||||
viper.BindEnv("database.password", "DB_PASSWORD")
|
||||
viper.BindEnv("database.dbname", "DB_NAME")
|
||||
viper.BindEnv("database.charset", "DB_CHARSET")
|
||||
|
||||
// JWT 配置
|
||||
viper.BindEnv("jwt.secret", "JWT_SECRET")
|
||||
viper.BindEnv("jwt.expire_hours", "JWT_EXPIRE_HOURS")
|
||||
|
||||
// Wechat 配置
|
||||
viper.BindEnv("wechat.app_id", "WECHAT_APP_ID")
|
||||
viper.BindEnv("wechat.app_secret", "WECHAT_APP_SECRET")
|
||||
|
||||
// XHS 配置
|
||||
viper.BindEnv("xhs.python_service_url", "XHS_PYTHON_SERVICE_URL")
|
||||
|
||||
// Scheduler 配置
|
||||
viper.BindEnv("scheduler.enabled", "SCHEDULER_ENABLED")
|
||||
viper.BindEnv("scheduler.publish_cron", "SCHEDULER_PUBLISH_CRON")
|
||||
viper.BindEnv("scheduler.max_concurrent", "SCHEDULER_MAX_CONCURRENT")
|
||||
viper.BindEnv("scheduler.publish_timeout", "SCHEDULER_PUBLISH_TIMEOUT")
|
||||
viper.BindEnv("scheduler.max_articles_per_user_per_run", "SCHEDULER_MAX_ARTICLES_PER_USER_PER_RUN")
|
||||
viper.BindEnv("scheduler.max_failures_per_user_per_run", "SCHEDULER_MAX_FAILURES_PER_USER_PER_RUN")
|
||||
viper.BindEnv("scheduler.max_daily_articles_per_user", "SCHEDULER_MAX_DAILY_ARTICLES_PER_USER")
|
||||
viper.BindEnv("scheduler.max_hourly_articles_per_user", "SCHEDULER_MAX_HOURLY_ARTICLES_PER_USER")
|
||||
viper.BindEnv("scheduler.proxy", "SCHEDULER_PROXY")
|
||||
viper.BindEnv("scheduler.user_agent", "SCHEDULER_USER_AGENT")
|
||||
viper.BindEnv("scheduler.proxy_fetch_url", "SCHEDULER_PROXY_FETCH_URL")
|
||||
}
|
||||
|
||||
// GetDSN 获取数据库连接字符串
|
||||
func (c *DatabaseConfig) GetDSN() string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=%t&loc=%s",
|
||||
c.Username,
|
||||
c.Password,
|
||||
c.Host,
|
||||
c.Port,
|
||||
c.DBName,
|
||||
c.Charset,
|
||||
c.ParseTime,
|
||||
c.Loc,
|
||||
)
|
||||
}
|
||||
42
go_backend/config/config.prod.yaml
Normal file
42
go_backend/config/config.prod.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
server:
|
||||
port: 8070
|
||||
mode: release
|
||||
|
||||
database:
|
||||
host: 8.149.233.36
|
||||
port: 3306
|
||||
username: ai_wht_write
|
||||
password: 7aK_H2yvokVumr84lLNDt8fDBp6P
|
||||
dbname: ai_wht
|
||||
charset: utf8mb4
|
||||
parse_time: true
|
||||
loc: Local
|
||||
max_idle_conns: 20
|
||||
max_open_conns: 200
|
||||
conn_max_lifetime: 3600
|
||||
|
||||
jwt:
|
||||
secret: prod_secret_key_please_change_this
|
||||
expire_hours: 168
|
||||
|
||||
wechat:
|
||||
app_id: "wxa5bf062342ef754d" # 微信小程序AppID,留空则使用默认登录
|
||||
app_secret: "69d2a3ddc902b26f82f4b56a6e277f7a" # 微信小程序AppSecret
|
||||
|
||||
xhs:
|
||||
python_service_url: "http://localhost:8000" # Python服务地址,生产环境请修改为实际地址
|
||||
|
||||
scheduler:
|
||||
enabled: true # 是否启用定时任务
|
||||
publish_cron: "0 0 */2 * * *" # 每2小时执行一次(防封号策略)
|
||||
max_concurrent: 2 # 最大并发发布数
|
||||
publish_timeout: 300 # 发布超时时间(秒)
|
||||
max_articles_per_user_per_run: 2 # 每轮每个用户最大发文数
|
||||
max_failures_per_user_per_run: 3 # 每轮每个用户最大失败次数
|
||||
max_daily_articles_per_user: 5 # 每个用户每日最大发文数(自动发布)
|
||||
max_hourly_articles_per_user: 1 # 每个用户每小时最大发文数(自动发布)
|
||||
proxy: "" # 可选:静态全局代理地址,例如 http://user:pass@ip:port
|
||||
user_agent: "" # 可选:自定义User-Agent,不填则使用默认
|
||||
proxy_pool:
|
||||
enabled: true # 生产环境启用代理池
|
||||
api_url: "http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964"
|
||||
100
go_backend/controller/auth_controller.go
Normal file
100
go_backend/controller/auth_controller.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"ai_xhs/common"
|
||||
"ai_xhs/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthController struct {
|
||||
authService *service.AuthService
|
||||
}
|
||||
|
||||
func NewAuthController() *AuthController {
|
||||
return &AuthController{
|
||||
authService: service.NewAuthService(),
|
||||
}
|
||||
}
|
||||
|
||||
// WechatLogin 微信小程序登录
|
||||
func (ctrl *AuthController) WechatLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Phone string `json:"phone"` // 可选,员工手机号(直接传明文)
|
||||
PhoneCode string `json:"phone_code"` // 可选,微信手机号加密code
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 调用登录服务
|
||||
token, employee, err := ctrl.authService.WechatLogin(req.Code, req.Phone, req.PhoneCode)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户显示名称(优先使用真实姓名,其次用户名)
|
||||
displayName := employee.RealName
|
||||
if displayName == "" {
|
||||
displayName = employee.Username
|
||||
}
|
||||
|
||||
common.SuccessWithMessage(c, "登录成功", gin.H{
|
||||
"token": token,
|
||||
"employee": gin.H{
|
||||
"id": employee.ID,
|
||||
"name": displayName,
|
||||
"username": employee.Username,
|
||||
"real_name": employee.RealName,
|
||||
"phone": employee.Phone,
|
||||
"role": employee.Role,
|
||||
"enterprise_id": employee.EnterpriseID,
|
||||
"enterprise_name": employee.EnterpriseName,
|
||||
"is_bound_xhs": employee.IsBoundXHS,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// PhoneLogin 手机号登录(用于测试)
|
||||
func (ctrl *AuthController) PhoneLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 调用手机号登录服务
|
||||
token, employee, err := ctrl.authService.PhoneLogin(req.Phone)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户显示名称(优先使用真实姓名,其次用户名)
|
||||
displayName := employee.RealName
|
||||
if displayName == "" {
|
||||
displayName = employee.Username
|
||||
}
|
||||
|
||||
common.SuccessWithMessage(c, "登录成功", gin.H{
|
||||
"token": token,
|
||||
"employee": gin.H{
|
||||
"id": employee.ID,
|
||||
"name": displayName,
|
||||
"username": employee.Username,
|
||||
"real_name": employee.RealName,
|
||||
"phone": employee.Phone,
|
||||
"role": employee.Role,
|
||||
"enterprise_id": employee.EnterpriseID,
|
||||
"enterprise_name": employee.EnterpriseName,
|
||||
"is_bound_xhs": employee.IsBoundXHS,
|
||||
},
|
||||
})
|
||||
}
|
||||
257
go_backend/controller/employee_controller.go
Normal file
257
go_backend/controller/employee_controller.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"ai_xhs/common"
|
||||
"ai_xhs/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
type EmployeeController struct {
|
||||
service *service.EmployeeService
|
||||
}
|
||||
|
||||
func NewEmployeeController() *EmployeeController {
|
||||
return &EmployeeController{
|
||||
service: &service.EmployeeService{},
|
||||
}
|
||||
}
|
||||
|
||||
// SendXHSCode 发送小红书验证码
|
||||
func (ctrl *EmployeeController) SendXHSCode(c *gin.Context) {
|
||||
var req struct {
|
||||
XHSPhone string `json:"xhs_phone" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "参数错误:手机号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
err := ctrl.service.SendXHSCode(req.XHSPhone)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeInternalError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.SuccessWithMessage(c, "验证码已发送,请在小红书APP中查看", nil)
|
||||
}
|
||||
|
||||
// GetProfile 获取员工个人信息
|
||||
func (ctrl *EmployeeController) GetProfile(c *gin.Context) {
|
||||
employeeID := c.GetInt("employee_id")
|
||||
|
||||
employee, err := ctrl.service.GetProfile(employeeID)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeNotFound, "员工不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户显示名称(优先使用真实姓名,其次用户名)
|
||||
displayName := employee.RealName
|
||||
if displayName == "" {
|
||||
displayName = employee.Username
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"id": employee.ID,
|
||||
"name": displayName,
|
||||
"username": employee.Username,
|
||||
"real_name": employee.RealName,
|
||||
"phone": employee.Phone,
|
||||
"role": employee.Role,
|
||||
"enterprise_id": employee.EnterpriseID,
|
||||
"enterprise_name": employee.Enterprise.Name,
|
||||
"is_bound_xhs": employee.IsBoundXHS,
|
||||
"xhs_account": employee.XHSAccount,
|
||||
"xhs_phone": employee.XHSPhone,
|
||||
"has_xhs_cookie": employee.XHSCookie != "", // 标识是否有Cookie,不返回完整Cookie
|
||||
}
|
||||
|
||||
if employee.BoundAt != nil {
|
||||
data["bound_at"] = employee.BoundAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
common.Success(c, data)
|
||||
}
|
||||
|
||||
// BindXHS 绑定小红书账号
|
||||
func (ctrl *EmployeeController) BindXHS(c *gin.Context) {
|
||||
employeeID := c.GetInt("employee_id")
|
||||
|
||||
var req struct {
|
||||
XHSPhone string `json:"xhs_phone" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
xhsAccount, err := ctrl.service.BindXHS(employeeID, req.XHSPhone, req.Code)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeBindXHSFailed, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.SuccessWithMessage(c, "绑定成功", map[string]interface{}{
|
||||
"xhs_account": xhsAccount,
|
||||
})
|
||||
}
|
||||
|
||||
// UnbindXHS 解绑小红书账号
|
||||
func (ctrl *EmployeeController) UnbindXHS(c *gin.Context) {
|
||||
employeeID := c.GetInt("employee_id")
|
||||
|
||||
if err := ctrl.service.UnbindXHS(employeeID); err != nil {
|
||||
common.Error(c, common.CodeInternalError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.SuccessWithMessage(c, "解绑成功", nil)
|
||||
}
|
||||
|
||||
// GetAvailableCopies 获取可领取文案列表
|
||||
func (ctrl *EmployeeController) GetAvailableCopies(c *gin.Context) {
|
||||
employeeID := c.GetInt("employee_id")
|
||||
productID, err := strconv.Atoi(c.Query("product_id"))
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "产品ID参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := ctrl.service.GetAvailableCopies(employeeID, productID)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeInternalError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.Success(c, data)
|
||||
}
|
||||
|
||||
// ClaimCopy 领取文案
|
||||
func (ctrl *EmployeeController) ClaimCopy(c *gin.Context) {
|
||||
employeeID := c.GetInt("employee_id")
|
||||
|
||||
var req struct {
|
||||
CopyID int `json:"copy_id" binding:"required"`
|
||||
ProductID int `json:"product_id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := ctrl.service.ClaimCopy(employeeID, req.CopyID, req.ProductID)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeAlreadyClaimed, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.SuccessWithMessage(c, "领取成功", data)
|
||||
}
|
||||
|
||||
// ClaimRandomCopy 随机领取文案
|
||||
func (ctrl *EmployeeController) ClaimRandomCopy(c *gin.Context) {
|
||||
employeeID := c.GetInt("employee_id")
|
||||
|
||||
var req struct {
|
||||
ProductID int `json:"product_id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := ctrl.service.ClaimRandomCopy(employeeID, req.ProductID)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeCopyNotAvailable, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.SuccessWithMessage(c, "领取成功", data)
|
||||
}
|
||||
|
||||
// Publish 发布内容
|
||||
func (ctrl *EmployeeController) Publish(c *gin.Context) {
|
||||
employeeID := c.GetInt("employee_id")
|
||||
|
||||
var req service.PublishRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
recordID, err := ctrl.service.Publish(employeeID, req)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeInternalError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.SuccessWithMessage(c, "发布成功", map[string]interface{}{
|
||||
"record_id": recordID,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyPublishRecords 获取我的发布记录
|
||||
func (ctrl *EmployeeController) GetMyPublishRecords(c *gin.Context) {
|
||||
employeeID := c.GetInt("employee_id")
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
|
||||
data, err := ctrl.service.GetMyPublishRecords(employeeID, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeInternalError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.Success(c, data)
|
||||
}
|
||||
|
||||
// GetPublishRecordDetail 获取发布记录详情
|
||||
func (ctrl *EmployeeController) GetPublishRecordDetail(c *gin.Context) {
|
||||
employeeID := c.GetInt("employee_id")
|
||||
recordID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "记录ID参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := ctrl.service.GetPublishRecordDetail(employeeID, recordID)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.Success(c, data)
|
||||
}
|
||||
|
||||
// CheckXHSStatus 检查小红书绑定与Cookie状态
|
||||
func (ctrl *EmployeeController) CheckXHSStatus(c *gin.Context) {
|
||||
employeeID := c.GetInt("employee_id")
|
||||
|
||||
status, err := ctrl.service.CheckXHSStatus(employeeID)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeInternalError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.Success(c, status)
|
||||
}
|
||||
|
||||
// GetProducts 获取产品列表
|
||||
func (ctrl *EmployeeController) GetProducts(c *gin.Context) {
|
||||
data, err := ctrl.service.GetProducts()
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeInternalError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
common.Success(c, map[string]interface{}{
|
||||
"list": data,
|
||||
})
|
||||
}
|
||||
75
go_backend/controller/xhs_controller.go
Normal file
75
go_backend/controller/xhs_controller.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"ai_xhs/common"
|
||||
"ai_xhs/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type XHSController struct {
|
||||
service *service.XHSService
|
||||
}
|
||||
|
||||
func NewXHSController() *XHSController {
|
||||
return &XHSController{
|
||||
service: &service.XHSService{},
|
||||
}
|
||||
}
|
||||
|
||||
// SendCode 发送小红书验证码
|
||||
func (ctrl *XHSController) SendCode(c *gin.Context) {
|
||||
var req struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
CountryCode string `json:"country_code"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "参数错误:手机号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 调用Service层发送验证码
|
||||
result, err := ctrl.service.SendVerificationCode(req.Phone, req.CountryCode)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeInternalError, "调用Python服务失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 判断Python服务返回的结果
|
||||
if result.Code != 0 {
|
||||
common.Error(c, result.Code, result.Message)
|
||||
return
|
||||
}
|
||||
|
||||
common.SuccessWithMessage(c, result.Message, result.Data)
|
||||
}
|
||||
|
||||
// VerifyCode 验证小红书验证码并登录
|
||||
func (ctrl *XHSController) VerifyCode(c *gin.Context) {
|
||||
var req struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
CountryCode string `json:"country_code"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, common.CodeInvalidParams, "参数错误:手机号和验证码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 调用Service层验证登录
|
||||
result, err := ctrl.service.VerifyLogin(req.Phone, req.Code, req.CountryCode)
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeInternalError, "调用Python服务失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 判断Python服务返回的结果
|
||||
if result.Code != 0 {
|
||||
common.Error(c, result.Code, result.Message)
|
||||
return
|
||||
}
|
||||
|
||||
common.SuccessWithMessage(c, result.Message, result.Data)
|
||||
}
|
||||
74
go_backend/database/database.go
Normal file
74
go_backend/database/database.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"ai_xhs/config"
|
||||
"ai_xhs/models"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// InitDB 初始化数据库连接
|
||||
func InitDB() error {
|
||||
cfg := config.AppConfig.Database
|
||||
dsn := cfg.GetDSN()
|
||||
|
||||
var err error
|
||||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("数据库连接失败: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取数据库实例失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置连接池
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
||||
|
||||
log.Println("数据库连接成功")
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoMigrate 自动迁移数据库表
|
||||
func AutoMigrate() error {
|
||||
// 注意:由于使用现有数据库,这里只做模型注册,不执行实际迁移
|
||||
// 如果需要同步表结构,请手动执行 AutoMigrate
|
||||
err := DB.AutoMigrate(
|
||||
&models.Enterprise{},
|
||||
&models.User{},
|
||||
&models.Product{},
|
||||
&models.Article{},
|
||||
&models.PublishRecord{},
|
||||
&models.ArticleImage{},
|
||||
&models.ArticleTag{},
|
||||
&models.Log{},
|
||||
// &models.XHSAccount{}, // 不再使用,小红书信息直接存储在 ai_users 表中
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("数据库表迁移失败: %w", err)
|
||||
}
|
||||
log.Println("数据库表迁移成功")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func Close() error {
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
57
go_backend/go.mod
Normal file
57
go_backend/go.mod
Normal file
@@ -0,0 +1,57 @@
|
||||
module ai_xhs
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/spf13/viper v1.18.2
|
||||
gorm.io/driver/mysql v1.5.2
|
||||
gorm.io/gorm v1.25.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.16.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.19.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
144
go_backend/go.sum
Normal file
144
go_backend/go.sum
Normal file
@@ -0,0 +1,144 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
|
||||
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
|
||||
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
70
go_backend/main.go
Normal file
70
go_backend/main.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"ai_xhs/config"
|
||||
"ai_xhs/database"
|
||||
"ai_xhs/middleware"
|
||||
"ai_xhs/router"
|
||||
"ai_xhs/service"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 解析命令行参数
|
||||
env := flag.String("env", "dev", "运行环境: dev, prod")
|
||||
flag.Parse()
|
||||
|
||||
// 加载配置
|
||||
if err := config.LoadConfig(*env); err != nil {
|
||||
log.Fatalf("配置加载失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化数据库
|
||||
if err := database.InitDB(); err != nil {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
|
||||
// 自动迁移数据库表
|
||||
//if err := database.AutoMigrate(); err != nil {
|
||||
// log.Fatalf("数据库迁移失败: %v", err)
|
||||
//}
|
||||
|
||||
// 初始化定时任务
|
||||
if config.AppConfig.Scheduler.Enabled {
|
||||
scheduler := service.NewSchedulerService(
|
||||
config.AppConfig.Scheduler.MaxConcurrent,
|
||||
config.AppConfig.Scheduler.PublishTimeout,
|
||||
)
|
||||
if err := scheduler.Start(config.AppConfig.Scheduler.PublishCron); err != nil {
|
||||
log.Fatalf("定时任务启动失败: %v", err)
|
||||
}
|
||||
log.Println("定时任务服务已启动")
|
||||
defer scheduler.Stop()
|
||||
} else {
|
||||
log.Println("定时任务服务已禁用")
|
||||
}
|
||||
|
||||
// 设置运行模式
|
||||
gin.SetMode(config.AppConfig.Server.Mode)
|
||||
|
||||
// 创建路由
|
||||
r := gin.New()
|
||||
|
||||
// 添加中间件
|
||||
r.Use(gin.Recovery()) // 崩溃恢复
|
||||
r.Use(middleware.RequestLogger()) // API请求日志
|
||||
|
||||
// 设置路由
|
||||
router.SetupRouter(r)
|
||||
|
||||
// 启动服务
|
||||
addr := fmt.Sprintf(":%d", config.AppConfig.Server.Port)
|
||||
log.Printf("服务启动在端口 %s, 环境: %s", addr, *env)
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("服务启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
59
go_backend/middleware/auth.go
Normal file
59
go_backend/middleware/auth.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"ai_xhs/common"
|
||||
"ai_xhs/utils"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware JWT认证中间件
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 从请求头获取token
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
common.Error(c, common.CodeUnauthorized, "未登录或token为空")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查token格式
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if !(len(parts) == 2 && parts[0] == "Bearer") {
|
||||
common.Error(c, common.CodeUnauthorized, "token格式错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析token
|
||||
claims, err := utils.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
common.Error(c, common.CodeUnauthorized, "无效的token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将员工ID存入上下文
|
||||
c.Set("employee_id", claims.EmployeeID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CORS 跨域中间件
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
171
go_backend/middleware/logger.go
Normal file
171
go_backend/middleware/logger.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// responseWriter 包装 gin.ResponseWriter 以捕获响应体
|
||||
type responseWriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (w responseWriter) Write(b []byte) (int, error) {
|
||||
w.body.Write(b)
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// RequestLogger API请求和响应日志中间件
|
||||
func RequestLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
|
||||
// 读取请求体
|
||||
var requestBody []byte
|
||||
if c.Request.Body != nil {
|
||||
requestBody, _ = io.ReadAll(c.Request.Body)
|
||||
// 恢复请求体供后续处理使用
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||
}
|
||||
|
||||
// 包装 ResponseWriter 以捕获响应
|
||||
blw := &responseWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: bytes.NewBufferString(""),
|
||||
}
|
||||
c.Writer = blw
|
||||
|
||||
// 打印请求信息
|
||||
printRequest(c, requestBody)
|
||||
|
||||
// 处理请求
|
||||
c.Next()
|
||||
|
||||
// 计算请求耗时
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// 打印响应信息
|
||||
printResponse(c, blw.body.Bytes(), duration)
|
||||
}
|
||||
}
|
||||
|
||||
// printRequest 打印请求详情
|
||||
func printRequest(c *gin.Context, body []byte) {
|
||||
fmt.Println("\n" + strings.Repeat("=", 100))
|
||||
fmt.Printf("📥 [REQUEST] %s\n", time.Now().Format("2006-01-02 15:04:05"))
|
||||
fmt.Println(strings.Repeat("=", 100))
|
||||
|
||||
// 请求基本信息
|
||||
fmt.Printf("Method: %s\n", c.Request.Method)
|
||||
fmt.Printf("Path: %s\n", c.Request.URL.Path)
|
||||
fmt.Printf("Full URL: %s\n", c.Request.URL.String())
|
||||
fmt.Printf("Client IP: %s\n", c.ClientIP())
|
||||
fmt.Printf("User-Agent: %s\n", c.Request.UserAgent())
|
||||
|
||||
// 请求头
|
||||
if len(c.Request.Header) > 0 {
|
||||
fmt.Println("\n--- Headers ---")
|
||||
for key, values := range c.Request.Header {
|
||||
// 过滤敏感信息
|
||||
if strings.ToLower(key) == "authorization" || strings.ToLower(key) == "cookie" {
|
||||
fmt.Printf("%s: [HIDDEN]\n", key)
|
||||
} else {
|
||||
fmt.Printf("%s: %s\n", key, strings.Join(values, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询参数
|
||||
if len(c.Request.URL.Query()) > 0 {
|
||||
fmt.Println("\n--- Query Parameters ---")
|
||||
for key, values := range c.Request.URL.Query() {
|
||||
fmt.Printf("%s: %s\n", key, strings.Join(values, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// 请求体
|
||||
if len(body) > 0 {
|
||||
fmt.Println("\n--- Request Body ---")
|
||||
// 尝试格式化 JSON
|
||||
var prettyJSON bytes.Buffer
|
||||
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
|
||||
fmt.Println(prettyJSON.String())
|
||||
} else {
|
||||
fmt.Println(string(body))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("-", 100))
|
||||
}
|
||||
|
||||
// printResponse 打印响应详情
|
||||
func printResponse(c *gin.Context, body []byte, duration time.Duration) {
|
||||
fmt.Println("\n" + strings.Repeat("=", 100))
|
||||
fmt.Printf("📤 [RESPONSE] %s | Duration: %v\n", time.Now().Format("2006-01-02 15:04:05"), duration)
|
||||
fmt.Println(strings.Repeat("=", 100))
|
||||
|
||||
// 响应基本信息
|
||||
fmt.Printf("Status Code: %d %s\n", c.Writer.Status(), getStatusText(c.Writer.Status()))
|
||||
fmt.Printf("Size: %d bytes\n", c.Writer.Size())
|
||||
|
||||
// 响应头
|
||||
if len(c.Writer.Header()) > 0 {
|
||||
fmt.Println("\n--- Response Headers ---")
|
||||
for key, values := range c.Writer.Header() {
|
||||
fmt.Printf("%s: %s\n", key, strings.Join(values, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// 响应体
|
||||
if len(body) > 0 {
|
||||
fmt.Println("\n--- Response Body ---")
|
||||
// 尝试格式化 JSON
|
||||
var prettyJSON bytes.Buffer
|
||||
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
|
||||
fmt.Println(prettyJSON.String())
|
||||
} else {
|
||||
fmt.Println(string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 性能提示
|
||||
if duration > 1*time.Second {
|
||||
fmt.Printf("\n⚠️ WARNING: Request took %.2f seconds (>1s)\n", duration.Seconds())
|
||||
} else if duration > 500*time.Millisecond {
|
||||
fmt.Printf("\n⚡ NOTICE: Request took %.0f milliseconds (>500ms)\n", duration.Milliseconds())
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 100))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// getStatusText 获取状态码文本
|
||||
func getStatusText(code int) string {
|
||||
switch code {
|
||||
case 200:
|
||||
return "OK"
|
||||
case 201:
|
||||
return "Created"
|
||||
case 204:
|
||||
return "No Content"
|
||||
case 400:
|
||||
return "Bad Request"
|
||||
case 401:
|
||||
return "Unauthorized"
|
||||
case 403:
|
||||
return "Forbidden"
|
||||
case 404:
|
||||
return "Not Found"
|
||||
case 500:
|
||||
return "Internal Server Error"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
288
go_backend/models/models.go
Normal file
288
go_backend/models/models.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Enterprise 企业表
|
||||
type Enterprise struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
EnterpriseID string `gorm:"type:varchar(255);not null;default:''" json:"enterprise_id" comment:"企业ID"`
|
||||
Name string `gorm:"type:varchar(200);not null;default:''" json:"name" comment:"企业名称"`
|
||||
ShortName string `gorm:"type:varchar(100);not null;default:''" json:"short_name" comment:"企业简称"`
|
||||
Icon string `gorm:"type:varchar(500);not null;default:''" json:"icon" comment:"企业图标URL"`
|
||||
Phone string `gorm:"type:varchar(20);not null;default:'';uniqueIndex:uk_phone" json:"phone" comment:"登录手机号"`
|
||||
Password string `gorm:"type:varchar(255);not null;default:''" json:"-" comment:"登录密码(加密存储)"`
|
||||
Email string `gorm:"type:varchar(128);not null;default:''" json:"email" comment:"企业邮箱"`
|
||||
Website string `gorm:"type:varchar(255);not null;default:''" json:"website" comment:"企业网站"`
|
||||
Address string `gorm:"type:varchar(255);not null;default:''" json:"address" comment:"企业地址"`
|
||||
Status string `gorm:"type:enum('active','disabled');not null;default:'active';index:idx_status" json:"status" comment:"状态"`
|
||||
UsersTotal int `gorm:"type:int(10) unsigned;not null;default:0" json:"users_total" comment:"员工总数"`
|
||||
ProductsTotal int `gorm:"type:int(10) unsigned;not null;default:0" json:"products_total" comment:"产品总数"`
|
||||
ArticlesTotal int `gorm:"type:int(10) unsigned;not null;default:0" json:"articles_total" comment:"文章总数"`
|
||||
ReleasedMonthTotal int `gorm:"type:int(10) unsigned;not null;default:0" json:"released_month_total" comment:"本月发布数量"`
|
||||
LinkedToXHSNum int `gorm:"type:int(10) unsigned;not null;default:0" json:"linked_to_xhs_num" comment:"绑定小红书"`
|
||||
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
|
||||
}
|
||||
|
||||
// User 用户账号表(原Employee,对应ai_users)
|
||||
type User struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
EnterpriseID int `gorm:"not null;default:0;index:idx_enterprise_id" json:"enterprise_id" comment:"所属企业ID"`
|
||||
Enterprise Enterprise `gorm:"foreignKey:EnterpriseID" json:"enterprise,omitempty"`
|
||||
EnterpriseName string `gorm:"type:varchar(255);not null;default:''" json:"enterprise_name" comment:"企业名称"`
|
||||
Username string `gorm:"type:varchar(50);not null;default:'';uniqueIndex:uk_username" json:"username" comment:"用户名"`
|
||||
Password string `gorm:"type:varchar(255);not null;default:''" json:"-" comment:"密码"`
|
||||
RealName string `gorm:"type:varchar(50)" json:"real_name" comment:"真实姓名"`
|
||||
Email string `gorm:"type:varchar(100)" json:"email" comment:"邮箱"`
|
||||
Phone string `gorm:"type:varchar(20)" json:"phone" comment:"手机号"`
|
||||
WechatOpenID *string `gorm:"type:varchar(100);uniqueIndex:uk_wechat_openid" json:"wechat_openid,omitempty" comment:"微信OpenID"`
|
||||
WechatUnionID *string `gorm:"type:varchar(100)" json:"wechat_unionid,omitempty" comment:"微信UnionID"`
|
||||
XHSPhone string `gorm:"type:varchar(20);not null;default:''" json:"xhs_phone" comment:"小红书绑定手机号"`
|
||||
XHSAccount string `gorm:"type:varchar(255);not null;default:''" json:"xhs_account" comment:"小红书账号名称"`
|
||||
XHSCookie string `gorm:"type:text" json:"xhs_cookie" comment:"小红书Cookie"`
|
||||
IsBoundXHS int `gorm:"type:tinyint(1);not null;default:0;index:idx_is_bound_xhs" json:"is_bound_xhs" comment:"是否绑定小红书:0=未绑定,1=已绑定"`
|
||||
BoundAt *time.Time `json:"bound_at" comment:"绑定小红书的时间"`
|
||||
Department string `gorm:"type:varchar(50)" json:"department" comment:"部门"`
|
||||
Role string `gorm:"type:enum('admin','editor','reviewer','publisher','each_title_reviewer');default:'editor'" json:"role" comment:"角色"`
|
||||
Status string `gorm:"type:enum('active','inactive','deleted');default:'active';index:idx_status" json:"status" comment:"状态"`
|
||||
CreatedAt time.Time `json:"created_at" comment:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
|
||||
}
|
||||
|
||||
// Employee 员工表别名,兼容旧代码
|
||||
type Employee = User
|
||||
|
||||
// Product 产品表
|
||||
type Product struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
EnterpriseID int `gorm:"not null;default:0;index:idx_enterprise_id" json:"enterprise_id" comment:"所属企业ID"`
|
||||
Name string `gorm:"type:varchar(200);not null;default:''" json:"name" comment:"产品名称"`
|
||||
TypeName string `gorm:"type:varchar(128);not null;default:''" json:"type_name" comment:"产品类型"`
|
||||
ImageURL string `gorm:"type:varchar(500);not null;default:''" json:"image_url" comment:"产品主图URL"`
|
||||
ImageThumbnailURL string `gorm:"type:varchar(500);not null;default:''" json:"image_thumbnail_url" comment:"缩略图URL"`
|
||||
Knowledge string `gorm:"type:text" json:"knowledge" comment:"产品知识库(纯文字)"`
|
||||
ArticlesTotal int `gorm:"not null;default:0" json:"articles_total" comment:"文章总数"`
|
||||
PublishedTotal int `gorm:"not null;default:0" json:"published_total" comment:"发布总数"`
|
||||
Status string `gorm:"type:enum('draft','active','deleted');not null;default:'draft';index:idx_status" json:"status" comment:"状态:draft=草稿,active=正常,deleted=已删除"`
|
||||
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
|
||||
}
|
||||
|
||||
// Article 文章表(原Copy,对应ai_articles)
|
||||
type Article struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
BatchID uint64 `gorm:"type:bigint unsigned;not null;default:0" json:"batch_id" comment:"批次ID"`
|
||||
EnterpriseID int `gorm:"not null;default:0;index:idx_enterprise_id" json:"enterprise_id" comment:"所属企业ID"`
|
||||
ProductID int `gorm:"not null;default:0;index:idx_product_id" json:"product_id" comment:"关联产品ID"`
|
||||
TopicTypeID int `gorm:"type:int unsigned;not null;default:0" json:"topic_type_id" comment:"topic类型ID"`
|
||||
PromptWorkflowID int `gorm:"type:int unsigned;not null;default:0" json:"prompt_workflow_id" comment:"提示词工作流ID"`
|
||||
Topic string `gorm:"type:varchar(255);not null;default:''" json:"topic" comment:"topic主题"`
|
||||
Title string `gorm:"type:varchar(200);not null;default:''" json:"title" comment:"标题"`
|
||||
Content string `gorm:"type:text" json:"content" comment:"文章内容"`
|
||||
Department string `gorm:"type:varchar(255);not null;default:''" json:"department" comment:"部门"`
|
||||
DepartmentIDs string `gorm:"column:departmentids;type:varchar(255);not null;default:''" json:"department_ids" comment:"部门IDs"`
|
||||
AuthorID *int `json:"author_id" comment:"作者ID"`
|
||||
AuthorName string `gorm:"type:varchar(100)" json:"author_name" comment:"作者名称"`
|
||||
DepartmentID *int `json:"department_id" comment:"部门ID"`
|
||||
DepartmentName string `gorm:"type:varchar(255)" json:"department_name" comment:"部门名称"`
|
||||
CreatedUserID int `gorm:"not null;default:0" json:"created_user_id" comment:"创建用户ID"`
|
||||
ReviewUserID *int `json:"review_user_id" comment:"审核用户ID"`
|
||||
PublishUserID *int `json:"publish_user_id" comment:"发布用户ID"`
|
||||
Status string `gorm:"type:enum('topic','cover_image','generate','generate_failed','draft','pending_review','approved','rejected','published_review','published','failed');default:'draft';index:idx_status" json:"status" comment:"状态"`
|
||||
Channel int `gorm:"type:tinyint(1);not null;default:1" json:"channel" comment:"渠道:1=baidu|2=toutiao|3=weixin"`
|
||||
ReviewComment string `gorm:"type:text" json:"review_comment" comment:"审核评论"`
|
||||
PublishTime *time.Time `json:"publish_time" comment:"发布时间"`
|
||||
BaijiahaoID string `gorm:"type:varchar(100)" json:"baijiahao_id" comment:"百家号ID"`
|
||||
BaijiahaoStatus string `gorm:"type:varchar(50)" json:"baijiahao_status" comment:"百家号状态"`
|
||||
WordCount int `gorm:"default:0" json:"word_count" comment:"字数统计"`
|
||||
ImageCount int `gorm:"default:0" json:"image_count" comment:"图片数量"`
|
||||
CozeTag string `gorm:"type:varchar(500)" json:"coze_tag" comment:"Coze生成的标签"`
|
||||
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
|
||||
UpdatedAt time.Time `gorm:"index:idx_updated_at" json:"updated_at" comment:"更新时间"`
|
||||
}
|
||||
|
||||
// Copy 文案表别名,兼容旧代码
|
||||
type Copy = Article
|
||||
|
||||
// PublishRecord 发布记录表(对应ai_article_published_records)
|
||||
type PublishRecord struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ArticleID *int `gorm:"index:idx_article_id" json:"article_id" comment:"文章ID"`
|
||||
EnterpriseID int `gorm:"not null;default:0;index:idx_enterprise_id" json:"enterprise_id" comment:"所属企业ID"`
|
||||
ProductID int `gorm:"not null;default:0;index:idx_product_id" json:"product_id" comment:"关联产品ID"`
|
||||
Topic string `gorm:"type:varchar(255);not null;default:''" json:"topic" comment:"topic主题"`
|
||||
Title string `gorm:"type:varchar(200);not null;default:''" json:"title" comment:"标题"`
|
||||
CreatedUserID int `gorm:"not null;default:0;index:idx_created_user_id" json:"created_user_id" comment:"创建用户ID"`
|
||||
ReviewUserID *int `json:"review_user_id" comment:"审核用户ID"`
|
||||
PublishUserID *int `json:"publish_user_id" comment:"发布用户ID"`
|
||||
Status string `gorm:"type:enum('topic','cover_image','generate','generate_failed','draft','pending_review','approved','rejected','published_review','published','failed');default:'draft';index:idx_status" json:"status" comment:"状态"`
|
||||
Channel int `gorm:"type:tinyint(1);not null;default:1" json:"channel" comment:"渠道:1=baidu|2=toutiao|3=weixin"`
|
||||
ReviewComment string `gorm:"type:text" json:"review_comment" comment:"审核评论"`
|
||||
PublishTime *time.Time `json:"publish_time" comment:"发布时间"`
|
||||
PublishLink string `gorm:"type:varchar(128);not null;default:''" json:"publish_link" comment:"发布访问链接"`
|
||||
WordCount int `gorm:"default:0" json:"word_count" comment:"字数统计"`
|
||||
ImageCount int `gorm:"default:0" json:"image_count" comment:"图片数量"`
|
||||
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
|
||||
UpdatedAt time.Time `gorm:"index:idx_updated_at" json:"updated_at" comment:"更新时间"`
|
||||
}
|
||||
|
||||
// XHSAccount 小红书账号表(保持兼容)
|
||||
type XHSAccount struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
EmployeeID int `gorm:"not null;default:0;uniqueIndex:uk_employee_id" json:"employee_id"`
|
||||
Employee User `gorm:"foreignKey:EmployeeID" json:"employee,omitempty"`
|
||||
XHSUserID string `gorm:"type:varchar(100);not null;default:'';index:idx_xhs_user_id" json:"xhs_user_id"`
|
||||
XHSNickname string `gorm:"type:varchar(100);not null;default:''" json:"xhs_nickname"`
|
||||
XHSPhone string `gorm:"type:varchar(20);not null;default:''" json:"xhs_phone"`
|
||||
XHSAvatar string `gorm:"type:varchar(500);not null;default:''" json:"xhs_avatar"`
|
||||
FansCount int `gorm:"not null;default:0" json:"fans_count"`
|
||||
NotesCount int `gorm:"not null;default:0" json:"notes_count"`
|
||||
Cookies string `gorm:"type:text" json:"cookies"`
|
||||
AccessToken string `gorm:"type:varchar(500);not null;default:''" json:"access_token"`
|
||||
RefreshToken string `gorm:"type:varchar(500);not null;default:''" json:"refresh_token"`
|
||||
TokenExpireAt *time.Time `json:"token_expire_at"`
|
||||
Status string `gorm:"type:enum('active','expired','banned');default:'active';index:idx_status" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PromptWorkflow 提示词工作流表
|
||||
type PromptWorkflow struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
EnterpriseID int `gorm:"not null;default:0;index:idx_enterprise_id" json:"enterprise_id" comment:"所属企业ID"`
|
||||
PromptWorkflowName string `gorm:"type:varchar(100);not null;default:''" json:"prompt_workflow_name" comment:"提示词工作流名称"`
|
||||
AuthToken string `gorm:"type:varchar(100);not null;default:''" json:"auth_token" comment:"认证Token"`
|
||||
WorkflowID string `gorm:"type:varchar(100);not null;default:'';index:idx_workflow_id" json:"workflow_id" comment:"工作流ID"`
|
||||
Content string `gorm:"type:text" json:"content" comment:"提示词内容"`
|
||||
UsageCount int `gorm:"not null;default:0" json:"usage_count" comment:"使用次数统计"`
|
||||
CreatedUserID int `gorm:"not null;default:0" json:"created_user_id" comment:"创建用户ID"`
|
||||
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
|
||||
}
|
||||
|
||||
// ProductImage 产品图片库表
|
||||
type ProductImage struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
EnterpriseID int `gorm:"not null;default:0;index:idx_enterprise_id" json:"enterprise_id" comment:"所属企业ID"`
|
||||
ProductID int `gorm:"not null;default:0;index:idx_product_id" json:"product_id" comment:"关联产品ID"`
|
||||
ImageID int `gorm:"not null;default:0" json:"image_id" comment:"图片ID"`
|
||||
ImageName string `gorm:"type:varchar(255);not null;default:''" json:"image_name" comment:"图片名称"`
|
||||
ImageURL string `gorm:"type:varchar(500);not null;default:''" json:"image_url" comment:"图片URL"`
|
||||
ThumbnailURL string `gorm:"type:varchar(500);not null;default:''" json:"thumbnail_url" comment:"缩略图URL"`
|
||||
TypeName string `gorm:"type:varchar(50);not null;default:''" json:"type_name" comment:"图片类型"`
|
||||
Description string `gorm:"type:varchar(500);not null;default:''" json:"description" comment:"图片描述"`
|
||||
FileSize *int64 `json:"file_size" comment:"文件大小"`
|
||||
Width *int `json:"width" comment:"图片宽度"`
|
||||
Height *int `json:"height" comment:"图片高度"`
|
||||
UploadUserID int `gorm:"not null;default:0" json:"upload_user_id" comment:"上传用户ID"`
|
||||
Status string `gorm:"type:enum('active','deleted');default:'active';index:idx_status" json:"status" comment:"状态"`
|
||||
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"上传时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
|
||||
}
|
||||
|
||||
// ArticleImage 文章图片表
|
||||
type ArticleImage struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ArticleID int `gorm:"not null;default:0;index:idx_article_id" json:"article_id" comment:"文章ID"`
|
||||
ImageID int `gorm:"not null;default:0;index:idx_image_id" json:"image_id" comment:"图片ID"`
|
||||
ImageURL string `gorm:"type:varchar(500);not null;default:''" json:"image_url" comment:"图片URL"`
|
||||
ImageThumbURL string `gorm:"type:varchar(255);not null;default:''" json:"image_thumb_url" comment:"缩略图URL"`
|
||||
ImageTagID int `gorm:"not null;default:0" json:"image_tag_id" comment:"图片标签ID"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order" comment:"排序"`
|
||||
KeywordsID int `gorm:"not null;default:0" json:"keywords_id" comment:"关键词ID"`
|
||||
KeywordsName string `gorm:"type:varchar(255);not null;default:''" json:"keywords_name" comment:"关键词名称"`
|
||||
DepartmentID int `gorm:"not null;default:0" json:"department_id" comment:"部门ID"`
|
||||
DepartmentName string `gorm:"type:varchar(255);not null;default:''" json:"department_name" comment:"部门名称"`
|
||||
ImageSource int `gorm:"type:tinyint(1);not null;default:0" json:"image_source" comment:"图片来源:1=tag|2=change"`
|
||||
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
|
||||
}
|
||||
|
||||
// ArticleTag 文章标签表
|
||||
type ArticleTag struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ArticleID int `gorm:"not null;default:0;uniqueIndex:uk_article_tag" json:"article_id" comment:"文章ID"`
|
||||
CozeTag string `gorm:"type:varchar(500)" json:"coze_tag" comment:"Coze生成的标签"`
|
||||
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
|
||||
}
|
||||
|
||||
// DataStatistics 数据统计表
|
||||
type DataStatistics struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
EnterpriseID int `gorm:"not null;default:0;index:idx_enterprise_id" json:"enterprise_id" comment:"所属企业ID"`
|
||||
ProductID int `gorm:"not null;default:0;index:idx_product_id" json:"product_id" comment:"关联产品ID"`
|
||||
CumulativeReleasesNum int `gorm:"type:int(10) unsigned;not null;default:0" json:"cumulative_releases_num" comment:"累计发布"`
|
||||
PublishedTodayNum int `gorm:"type:int(10) unsigned;not null;default:0" json:"published_today_num" comment:"今日发布"`
|
||||
PublishedWeekNum int `gorm:"type:int(10) unsigned;not null;default:0" json:"published_week_num" comment:"本周发布"`
|
||||
ParticipatingEmployees int `gorm:"type:int(10) unsigned;not null;default:0" json:"participating_employees_num" comment:"参与员工"`
|
||||
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
|
||||
}
|
||||
|
||||
// Log 操作日志表(对应ai_logs)
|
||||
type Log struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID *int `gorm:"index:idx_user_id" json:"user_id" comment:"用户ID"`
|
||||
Action string `gorm:"type:varchar(100);not null;default:'';index:idx_action" json:"action" comment:"操作动作"`
|
||||
TargetType string `gorm:"type:varchar(50)" json:"target_type" comment:"目标类型"`
|
||||
TargetID *int `json:"target_id" comment:"目标ID"`
|
||||
Description string `gorm:"type:text" json:"description" comment:"描述"`
|
||||
IPAddress string `gorm:"type:varchar(45)" json:"ip_address" comment:"IP地址"`
|
||||
UserAgent string `gorm:"type:text" json:"user_agent" comment:"用户代理"`
|
||||
RequestData string `gorm:"type:json" json:"request_data" comment:"请求数据"`
|
||||
ResponseData string `gorm:"type:json" json:"response_data" comment:"响应数据"`
|
||||
Status string `gorm:"type:enum('success','error','warning');default:'success';index:idx_status" json:"status" comment:"状态"`
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message" comment:"错误消息"`
|
||||
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
|
||||
}
|
||||
|
||||
// TableName 指定表名(带ai_前缀)
|
||||
func (Enterprise) TableName() string {
|
||||
return "ai_enterprises"
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "ai_users"
|
||||
}
|
||||
|
||||
func (Product) TableName() string {
|
||||
return "ai_products"
|
||||
}
|
||||
|
||||
func (Article) TableName() string {
|
||||
return "ai_articles"
|
||||
}
|
||||
|
||||
func (XHSAccount) TableName() string {
|
||||
return "wht_xhs_accounts" // 保持兼容旧表
|
||||
}
|
||||
|
||||
func (PublishRecord) TableName() string {
|
||||
return "ai_article_published_records"
|
||||
}
|
||||
|
||||
func (PromptWorkflow) TableName() string {
|
||||
return "ai_prompt_workflow"
|
||||
}
|
||||
|
||||
func (ProductImage) TableName() string {
|
||||
return "ai_product_images"
|
||||
}
|
||||
|
||||
func (ArticleImage) TableName() string {
|
||||
return "ai_article_images"
|
||||
}
|
||||
|
||||
func (ArticleTag) TableName() string {
|
||||
return "ai_article_tags"
|
||||
}
|
||||
|
||||
func (DataStatistics) TableName() string {
|
||||
return "ai_data_statistics"
|
||||
}
|
||||
|
||||
func (Log) TableName() string {
|
||||
return "ai_logs"
|
||||
}
|
||||
246
go_backend/restart.sh
Normal file
246
go_backend/restart.sh
Normal file
@@ -0,0 +1,246 @@
|
||||
#!/bin/bash
|
||||
|
||||
#########################################
|
||||
# AI小红书 Go 后端服务重启脚本
|
||||
# 用途: Ubuntu/Linux 环境下重启 Go 服务
|
||||
# 支持: 开发环境(dev) 和 生产环境(prod)
|
||||
#########################################
|
||||
|
||||
# 默认配置
|
||||
DEFAULT_ENV="prod"
|
||||
DEFAULT_PORT=8080
|
||||
PROD_PORT=8070
|
||||
LOG_FILE="ai_xhs.log"
|
||||
MAIN_FILE="main.go"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 帮助信息
|
||||
show_help() {
|
||||
echo -e "${BLUE}用法:${NC}"
|
||||
echo " ./restart.sh [环境]"
|
||||
echo ""
|
||||
echo -e "${BLUE}环境参数:${NC}"
|
||||
echo " dev - 开发环境 (默认, 端口 8080)"
|
||||
echo " prod - 生产环境 (端口 8070)"
|
||||
echo ""
|
||||
echo -e "${BLUE}示例:${NC}"
|
||||
echo " ./restart.sh # 启动开发环境"
|
||||
echo " ./restart.sh dev # 启动开发环境"
|
||||
echo " ./restart.sh prod # 启动生产环境"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 解析参数
|
||||
ENV="${1:-$DEFAULT_ENV}"
|
||||
|
||||
if [ "$ENV" = "help" ] || [ "$ENV" = "-h" ] || [ "$ENV" = "--help" ]; then
|
||||
show_help
|
||||
fi
|
||||
|
||||
# 确定端口
|
||||
if [ "$ENV" = "prod" ]; then
|
||||
PORT=$PROD_PORT
|
||||
else
|
||||
PORT=$DEFAULT_PORT
|
||||
ENV="dev"
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE} AI小红书 Go 后端服务重启脚本${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${YELLOW}环境: $ENV${NC}"
|
||||
echo -e "${YELLOW}端口: $PORT${NC}"
|
||||
echo -e "${YELLOW}日志: $LOG_FILE${NC}"
|
||||
echo ""
|
||||
|
||||
#########################################
|
||||
# 1. 停止现有服务
|
||||
#########################################
|
||||
echo -e "${BLUE}=== [1/4] 停止现有服务 ===${NC}"
|
||||
|
||||
# 方法1: 查找 go run main.go 进程
|
||||
GO_PIDS=$(ps aux | grep "go run $MAIN_FILE" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -n "$GO_PIDS" ]; then
|
||||
echo -e "${YELLOW}找到 Go 服务进程: $GO_PIDS${NC}"
|
||||
for PID in $GO_PIDS; do
|
||||
kill -9 $PID 2>/dev/null && echo " 已终止进程: $PID"
|
||||
done
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# 方法2: 查找编译后的可执行文件进程
|
||||
COMPILED_PIDS=$(ps aux | grep "/tmp/go-build.*$MAIN_FILE" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -n "$COMPILED_PIDS" ]; then
|
||||
echo -e "${YELLOW}找到编译后的进程: $COMPILED_PIDS${NC}"
|
||||
for PID in $COMPILED_PIDS; do
|
||||
kill -9 $PID 2>/dev/null && echo " 已终止进程: $PID"
|
||||
done
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# 方法3: 强制清理占用端口的进程
|
||||
echo -e "${YELLOW}清理端口 $PORT...${NC}"
|
||||
|
||||
# 使用 lsof 查找占用端口的进程
|
||||
PORT_PID=$(lsof -ti:$PORT 2>/dev/null)
|
||||
if [ -n "$PORT_PID" ]; then
|
||||
echo " 端口 $PORT 被进程 $PORT_PID 占用"
|
||||
kill -9 $PORT_PID 2>/dev/null && echo " 已终止进程: $PORT_PID"
|
||||
fi
|
||||
|
||||
# 使用 fuser 强制清理 (需要 sudo)
|
||||
if command -v fuser &> /dev/null; then
|
||||
sudo fuser -k $PORT/tcp 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 额外的清理方法
|
||||
sudo pkill -f ":$PORT" 2>/dev/null || true
|
||||
sudo pkill -f "main.go" 2>/dev/null || true
|
||||
|
||||
# 等待端口完全释放
|
||||
sleep 2
|
||||
|
||||
# 验证端口是否释放
|
||||
if lsof -ti:$PORT &> /dev/null; then
|
||||
echo -e "${RED}⚠️ 警告: 端口 $PORT 仍被占用${NC}"
|
||||
lsof -i:$PORT
|
||||
else
|
||||
echo -e "${GREEN}✅ 端口 $PORT 已释放${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
#########################################
|
||||
# 2. 环境检查
|
||||
#########################################
|
||||
echo -e "${BLUE}=== [2/4] 环境检查 ===${NC}"
|
||||
|
||||
# 检查 Go 环境
|
||||
if ! command -v go &> /dev/null; then
|
||||
echo -e "${RED}❌ 错误: 未检测到 Go 环境,请先安装 Go${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GO_VERSION=$(go version)
|
||||
echo -e "${GREEN}✅ Go 环境: $GO_VERSION${NC}"
|
||||
|
||||
# 检查 main.go 是否存在
|
||||
if [ ! -f "$MAIN_FILE" ]; then
|
||||
echo -e "${RED}❌ 错误: 未找到 $MAIN_FILE 文件${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ 主文件: $MAIN_FILE${NC}"
|
||||
|
||||
# 检查配置文件
|
||||
CONFIG_FILE="config/config.${ENV}.yaml"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo -e "${RED}❌ 错误: 未找到配置文件 $CONFIG_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ 配置文件: $CONFIG_FILE${NC}"
|
||||
echo ""
|
||||
|
||||
#########################################
|
||||
# 3. 下载依赖
|
||||
#########################################
|
||||
echo -e "${BLUE}=== [3/4] 下载依赖 ===${NC}"
|
||||
|
||||
if [ -f "go.mod" ]; then
|
||||
go mod tidy
|
||||
echo -e "${GREEN}✅ 依赖下载完成${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ 未找到 go.mod 文件,跳过依赖下载${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
#########################################
|
||||
# 4. 启动服务
|
||||
#########################################
|
||||
echo -e "${BLUE}=== [4/4] 启动服务 ===${NC}"
|
||||
|
||||
# 设置环境变量
|
||||
export APP_ENV=$ENV
|
||||
|
||||
# 清空旧日志
|
||||
> $LOG_FILE
|
||||
|
||||
# 启动服务 (后台运行)
|
||||
echo -e "${YELLOW}启动命令: nohup go run $MAIN_FILE > $LOG_FILE 2>&1 &${NC}"
|
||||
nohup go run $MAIN_FILE > $LOG_FILE 2>&1 &
|
||||
|
||||
# 记录进程 PID
|
||||
NEW_PID=$!
|
||||
echo -e "${GREEN}✅ 服务已启动,进程 PID: $NEW_PID${NC}"
|
||||
|
||||
echo ""
|
||||
|
||||
#########################################
|
||||
# 5. 验证启动
|
||||
#########################################
|
||||
echo -e "${BLUE}=== 启动验证 ===${NC}"
|
||||
echo -e "${YELLOW}等待服务启动 (5秒)...${NC}"
|
||||
sleep 5
|
||||
|
||||
# 检查进程是否存在
|
||||
if ps -p $NEW_PID > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅ 服务进程运行正常 (PID: $NEW_PID)${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 服务进程未找到,可能启动失败${NC}"
|
||||
echo -e "${YELLOW}最近日志:${NC}"
|
||||
tail -n 20 $LOG_FILE
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查端口是否监听
|
||||
if lsof -ti:$PORT &> /dev/null; then
|
||||
echo -e "${GREEN}✅ 端口 $PORT 监听正常${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 端口 $PORT 未监听,服务可能启动失败${NC}"
|
||||
echo -e "${YELLOW}最近日志:${NC}"
|
||||
tail -n 20 $LOG_FILE
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查日志中是否有错误
|
||||
if grep -i "fatal\|panic\|error" $LOG_FILE > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠️ 日志中发现错误信息:${NC}"
|
||||
grep -i "fatal\|panic\|error" $LOG_FILE | head -n 5
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
#########################################
|
||||
# 6. 完成信息
|
||||
#########################################
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} 🎉 服务启动成功!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}服务信息:${NC}"
|
||||
echo -e " 环境: ${YELLOW}$ENV${NC}"
|
||||
echo -e " 端口: ${YELLOW}$PORT${NC}"
|
||||
echo -e " 进程PID: ${YELLOW}$NEW_PID${NC}"
|
||||
echo -e " 日志文件: ${YELLOW}$LOG_FILE${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}快捷命令:${NC}"
|
||||
echo -e " 查看日志: ${YELLOW}tail -f $LOG_FILE${NC}"
|
||||
echo -e " 查看进程: ${YELLOW}ps aux | grep 'go run'${NC}"
|
||||
echo -e " 停止服务: ${YELLOW}kill -9 $NEW_PID${NC}"
|
||||
echo -e " 检查端口: ${YELLOW}lsof -i:$PORT${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}访问地址:${NC}"
|
||||
echo -e " 本地: ${GREEN}http://localhost:$PORT${NC}"
|
||||
echo -e " API测试: ${GREEN}http://localhost:$PORT/api/health${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}提示: 使用 Ctrl+C 不会停止后台服务,请使用 kill 命令停止${NC}"
|
||||
71
go_backend/router/router.go
Normal file
71
go_backend/router/router.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"ai_xhs/controller"
|
||||
"ai_xhs/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupRouter(r *gin.Engine) {
|
||||
// 跨域中间件
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
// 健康检查
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
})
|
||||
|
||||
// API路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 公开接口(不需要认证)
|
||||
authCtrl := controller.NewAuthController()
|
||||
api.POST("/login/wechat", authCtrl.WechatLogin) // 微信登录
|
||||
api.POST("/login/phone", authCtrl.PhoneLogin) // 手机号登录(测试用)
|
||||
|
||||
// 小红书相关公开接口
|
||||
employeeCtrlPublic := controller.NewEmployeeController()
|
||||
api.POST("/xhs/send-code", employeeCtrlPublic.SendXHSCode) // 发送小红书验证码
|
||||
api.GET("/products", employeeCtrlPublic.GetProducts) // 获取产品列表(公开)
|
||||
|
||||
// 员工路由(需要认证)
|
||||
employee := api.Group("/employee")
|
||||
employee.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
employeeCtrl := controller.NewEmployeeController()
|
||||
|
||||
// 10.1 获取员工个人信息
|
||||
employee.GET("/profile", employeeCtrl.GetProfile)
|
||||
|
||||
// 10.2 绑定小红书账号
|
||||
employee.POST("/bind-xhs", employeeCtrl.BindXHS)
|
||||
|
||||
// 10.3 解绑小红书账号
|
||||
employee.POST("/unbind-xhs", employeeCtrl.UnbindXHS)
|
||||
|
||||
// 10.4 获取可领取文案列表
|
||||
employee.GET("/available-copies", employeeCtrl.GetAvailableCopies)
|
||||
|
||||
// 10.5 领取文案
|
||||
employee.POST("/claim-copy", employeeCtrl.ClaimCopy)
|
||||
|
||||
// 10.6 随机领取文案
|
||||
employee.POST("/claim-random-copy", employeeCtrl.ClaimRandomCopy)
|
||||
|
||||
// 10.7 发布内容
|
||||
employee.POST("/publish", employeeCtrl.Publish)
|
||||
|
||||
// 10.8 获取我的发布记录
|
||||
employee.GET("/my-publish-records", employeeCtrl.GetMyPublishRecords)
|
||||
|
||||
// 10.8.1 获取发布记录详情
|
||||
employee.GET("/publish-record/:id", employeeCtrl.GetPublishRecordDetail)
|
||||
|
||||
// 10.9 检查小红书绑定与Cookie状态
|
||||
employee.GET("/xhs/status", employeeCtrl.CheckXHSStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
21
go_backend/start.bat
Normal file
21
go_backend/start.bat
Normal file
@@ -0,0 +1,21 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo 启动AI小红书后端服务(开发环境)...
|
||||
|
||||
:: 检查go环境
|
||||
where go >nul 2>nul
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo 错误: 未检测到Go环境,请先安装Go
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 下载依赖
|
||||
echo 下载依赖...
|
||||
go mod tidy
|
||||
|
||||
:: 启动服务
|
||||
echo 启动开发环境服务...
|
||||
go run main.go -env=dev
|
||||
|
||||
pause
|
||||
18
go_backend/start.sh
Normal file
18
go_backend/start.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "启动AI小红书后端服务(开发环境)..."
|
||||
|
||||
# 检查go环境
|
||||
if ! command -v go &> /dev/null
|
||||
then
|
||||
echo "错误: 未检测到Go环境,请先安装Go"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 下载依赖
|
||||
echo "下载依赖..."
|
||||
go mod tidy
|
||||
|
||||
# 启动服务
|
||||
echo "启动开发环境服务..."
|
||||
go run main.go -env=dev
|
||||
27
go_backend/start_prod.bat
Normal file
27
go_backend/start_prod.bat
Normal file
@@ -0,0 +1,27 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo 启动AI小红书后端服务(生产环境)...
|
||||
|
||||
:: 检查go环境
|
||||
where go >nul 2>nul
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo 错误: 未检测到Go环境,请先安装Go
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 编译项目
|
||||
echo 编译项目...
|
||||
go build -o ai_xhs.exe main.go
|
||||
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo 编译失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 启动服务
|
||||
echo 启动生产环境服务...
|
||||
ai_xhs.exe -env=prod
|
||||
|
||||
pause
|
||||
102
go_backend/start_prod.sh
Normal file
102
go_backend/start_prod.sh
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
|
||||
#########################################
|
||||
# AI小红书 Go 后端 - 生产环境启动脚本
|
||||
# 专用于生产环境快速部署
|
||||
#########################################
|
||||
|
||||
PORT=8070
|
||||
ENV="prod"
|
||||
LOG_FILE="ai_xhs_prod.log"
|
||||
|
||||
echo "=== 停止端口 $PORT 上的 Go 服务 ==="
|
||||
|
||||
# 方法1: 查找 go run 进程
|
||||
GO_PID=$(ps aux | grep "go run main.go" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -n "$GO_PID" ]; then
|
||||
echo "找到 Go 服务进程: $GO_PID"
|
||||
kill -9 $GO_PID 2>/dev/null
|
||||
echo "Go 服务进程已终止"
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# 方法2: 强制清理端口
|
||||
echo "强制清理端口 $PORT..."
|
||||
PORT_PID=$(lsof -ti:$PORT 2>/dev/null)
|
||||
if [ -n "$PORT_PID" ]; then
|
||||
echo "端口被进程 $PORT_PID 占用,正在终止..."
|
||||
kill -9 $PORT_PID 2>/dev/null
|
||||
fi
|
||||
|
||||
sudo fuser -k $PORT/tcp 2>/dev/null || true
|
||||
sudo pkill -f ":$PORT" 2>/dev/null || true
|
||||
|
||||
# 等待端口释放
|
||||
sleep 3
|
||||
|
||||
echo ""
|
||||
echo "=== 环境检查 ==="
|
||||
|
||||
# 检查 Go 环境
|
||||
if ! command -v go &> /dev/null; then
|
||||
echo "❌ 错误: 未检测到 Go 环境"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Go 环境: $(go version)"
|
||||
|
||||
# 检查配置文件
|
||||
if [ ! -f "config/config.prod.yaml" ]; then
|
||||
echo "❌ 错误: 未找到生产环境配置文件"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 配置文件: config/config.prod.yaml"
|
||||
|
||||
echo ""
|
||||
echo "=== 下载依赖 ==="
|
||||
go mod tidy
|
||||
|
||||
echo ""
|
||||
echo "=== 启动生产环境服务 ==="
|
||||
|
||||
# 设置环境变量
|
||||
export APP_ENV=prod
|
||||
|
||||
# 清空旧日志
|
||||
> $LOG_FILE
|
||||
|
||||
# 启动服务
|
||||
nohup go run main.go > $LOG_FILE 2>&1 &
|
||||
NEW_PID=$!
|
||||
|
||||
echo "✅ 服务已启动 (PID: $NEW_PID)"
|
||||
|
||||
# 验证启动
|
||||
echo ""
|
||||
echo "=== 启动验证 (等待 5 秒) ==="
|
||||
sleep 5
|
||||
|
||||
if ps -p $NEW_PID > /dev/null 2>&1; then
|
||||
echo "✅ Go 服务启动成功"
|
||||
echo "📋 日志文件: $LOG_FILE"
|
||||
echo "👀 查看日志: tail -f $LOG_FILE"
|
||||
echo "🌐 服务地址: http://localhost:$PORT"
|
||||
echo "🔍 进程PID: $NEW_PID"
|
||||
|
||||
# 检查端口监听
|
||||
if lsof -ti:$PORT > /dev/null 2>&1; then
|
||||
echo "✅ 端口 $PORT 监听正常"
|
||||
else
|
||||
echo "⚠️ 端口 $PORT 未监听,请检查日志"
|
||||
fi
|
||||
|
||||
# 显示最近日志
|
||||
echo ""
|
||||
echo "=== 最近日志 ==="
|
||||
tail -n 10 $LOG_FILE
|
||||
else
|
||||
echo "❌ Go 服务启动失败,请检查日志"
|
||||
echo ""
|
||||
tail -n 20 $LOG_FILE
|
||||
exit 1
|
||||
fi
|
||||
104
go_backend/stop.sh
Normal file
104
go_backend/stop.sh
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
|
||||
#########################################
|
||||
# AI小红书 Go 后端 - 停止服务脚本
|
||||
#########################################
|
||||
|
||||
# 默认端口
|
||||
DEV_PORT=8080
|
||||
PROD_PORT=8070
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE} 停止 AI小红书 Go 后端服务${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 停止指定端口的服务
|
||||
stop_port() {
|
||||
local PORT=$1
|
||||
echo -e "${YELLOW}正在停止端口 $PORT 上的服务...${NC}"
|
||||
|
||||
# 查找占用端口的进程
|
||||
PORT_PID=$(lsof -ti:$PORT 2>/dev/null)
|
||||
|
||||
if [ -n "$PORT_PID" ]; then
|
||||
echo " 找到进程: $PORT_PID"
|
||||
kill -9 $PORT_PID 2>/dev/null && echo -e " ${GREEN}✅ 已终止进程 $PORT_PID${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}未找到占用端口 $PORT 的进程${NC}"
|
||||
fi
|
||||
|
||||
# 使用 fuser 强制清理
|
||||
sudo fuser -k $PORT/tcp 2>/dev/null || true
|
||||
}
|
||||
|
||||
# 停止所有 go run main.go 进程
|
||||
echo -e "${BLUE}=== 方法1: 停止 go run 进程 ===${NC}"
|
||||
GO_PIDS=$(ps aux | grep "go run main.go" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -n "$GO_PIDS" ]; then
|
||||
echo -e "${YELLOW}找到 Go 服务进程:${NC}"
|
||||
ps aux | grep "go run main.go" | grep -v grep
|
||||
echo ""
|
||||
for PID in $GO_PIDS; do
|
||||
kill -9 $PID 2>/dev/null && echo -e "${GREEN}✅ 已终止进程: $PID${NC}"
|
||||
done
|
||||
else
|
||||
echo -e "${YELLOW}未找到 go run main.go 进程${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 停止开发环境端口
|
||||
echo -e "${BLUE}=== 方法2: 清理开发环境端口 ($DEV_PORT) ===${NC}"
|
||||
stop_port $DEV_PORT
|
||||
|
||||
echo ""
|
||||
|
||||
# 停止生产环境端口
|
||||
echo -e "${BLUE}=== 方法3: 清理生产环境端口 ($PROD_PORT) ===${NC}"
|
||||
stop_port $PROD_PORT
|
||||
|
||||
echo ""
|
||||
|
||||
# 清理所有相关进程
|
||||
echo -e "${BLUE}=== 方法4: 清理所有相关进程 ===${NC}"
|
||||
sudo pkill -f "main.go" 2>/dev/null && echo -e "${GREEN}✅ 已清理所有 main.go 进程${NC}" || echo -e "${YELLOW}未找到其他相关进程${NC}"
|
||||
|
||||
# 等待进程完全退出
|
||||
sleep 2
|
||||
|
||||
echo ""
|
||||
|
||||
# 验证
|
||||
echo -e "${BLUE}=== 验证结果 ===${NC}"
|
||||
|
||||
# 检查端口
|
||||
for PORT in $DEV_PORT $PROD_PORT; do
|
||||
if lsof -ti:$PORT > /dev/null 2>&1; then
|
||||
echo -e "${RED}⚠️ 端口 $PORT 仍被占用:${NC}"
|
||||
lsof -i:$PORT
|
||||
else
|
||||
echo -e "${GREEN}✅ 端口 $PORT 已释放${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查进程
|
||||
if ps aux | grep "go run main.go" | grep -v grep > /dev/null; then
|
||||
echo -e "${RED}⚠️ 仍有 go run 进程在运行:${NC}"
|
||||
ps aux | grep "go run main.go" | grep -v grep
|
||||
else
|
||||
echo -e "${GREEN}✅ 所有 go run 进程已停止${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} ✅ 服务已停止${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
42
go_backend/tools/generate_password.go
Normal file
42
go_backend/tools/generate_password.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// GeneratePassword 生成bcrypt加密密码
|
||||
func GeneratePassword(password string) (string, error) {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hashedPassword), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 为测试数据生成加密密码
|
||||
passwords := []string{
|
||||
"admin123", // 企业管理员密码
|
||||
"user123", // 普通用户密码
|
||||
}
|
||||
|
||||
fmt.Println("生成加密密码:")
|
||||
fmt.Println("=====================================")
|
||||
|
||||
for i, pwd := range passwords {
|
||||
hashed, err := GeneratePassword(pwd)
|
||||
if err != nil {
|
||||
fmt.Printf("生成密码失败: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%d. 原始密码: %s\n", i+1, pwd)
|
||||
fmt.Printf(" 加密后: %s\n\n", hashed)
|
||||
}
|
||||
|
||||
fmt.Println("=====================================")
|
||||
fmt.Println("使用说明:")
|
||||
fmt.Println("1. 复制上面的加密密码")
|
||||
fmt.Println("2. 在 test_data_ai_wht.sql 中替换对应的密码占位符")
|
||||
fmt.Println("3. 执行 SQL 文件导入测试数据")
|
||||
}
|
||||
39
go_backend/tools/generate_token.go
Normal file
39
go_backend/tools/generate_token.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"ai_xhs/config"
|
||||
"ai_xhs/utils"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 解析命令行参数
|
||||
env := flag.String("env", "dev", "运行环境: dev, prod")
|
||||
employeeID := flag.Int("id", 1, "员工ID")
|
||||
flag.Parse()
|
||||
|
||||
// 加载配置
|
||||
if err := config.LoadConfig(*env); err != nil {
|
||||
log.Fatalf("配置加载失败: %v", err)
|
||||
}
|
||||
|
||||
// 生成Token
|
||||
token, err := utils.GenerateToken(*employeeID)
|
||||
if err != nil {
|
||||
log.Fatalf("生成Token失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("环境: %s\n", *env)
|
||||
fmt.Printf("员工ID: %d\n", *employeeID)
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("JWT Token:\n%s\n", token)
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("\n使用方式:")
|
||||
fmt.Println("在请求头中添加: Authorization: Bearer " + token)
|
||||
fmt.Println("\ncURL示例:")
|
||||
fmt.Printf("curl -H \"Authorization: Bearer %s\" http://localhost:8080/api/employee/profile\n", token)
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
46
go_backend/utils/jwt.go
Normal file
46
go_backend/utils/jwt.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"ai_xhs/config"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
EmployeeID int `json:"employee_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken 生成JWT token
|
||||
func GenerateToken(employeeID int) (string, error) {
|
||||
claims := Claims{
|
||||
EmployeeID: employeeID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(config.AppConfig.JWT.ExpireHours) * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(config.AppConfig.JWT.Secret))
|
||||
}
|
||||
|
||||
// ParseToken 解析JWT token
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.AppConfig.JWT.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
Reference in New Issue
Block a user