This commit is contained in:
sjk
2026-01-06 19:36:42 +08:00
parent 15b579d64a
commit 19942144fb
261 changed files with 24034 additions and 5477 deletions

View File

@@ -1,264 +0,0 @@
# 环境变量配置指南
## 概述
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`

View File

@@ -1,166 +0,0 @@
# 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)

View File

@@ -1,412 +0,0 @@
# 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)

View File

@@ -0,0 +1,16 @@
-- 检查文案ID为1的详细信息
SELECT
a.id AS article_id,
a.title,
a.status,
a.review_comment, -- 这里会显示失败原因
a.created_user_id,
a.publish_user_id,
u.phone,
u.is_bound_xhs,
au.xhs_cookie IS NOT NULL AS has_cookie,
LENGTH(au.xhs_cookie) AS cookie_length
FROM ai_articles a
LEFT JOIN ai_users u ON u.id = COALESCE(a.publish_user_id, a.created_user_id)
LEFT JOIN ai_authors au ON au.phone = u.phone AND au.enterprise_id = u.enterprise_id AND au.channel = 1
WHERE a.id = 1;

View File

@@ -0,0 +1,51 @@
package main
import (
"ai_xhs/config"
"ai_xhs/database"
"context"
"fmt"
"log"
)
func main() {
// 加载配置
if err := config.LoadConfig("dev"); err != nil {
log.Fatalf("配置加载失败: %v", err)
}
// 连接Redis
if err := database.InitRedis(); err != nil {
log.Fatalf("Redis连接失败: %v", err)
}
ctx := context.Background()
// 列出所有lock相关的键
fmt.Println("=== 检查所有锁相关的键 ===")
keys, err := database.RDB.Keys(ctx, "lock:*").Result()
if err != nil {
log.Fatalf("查询锁失败: %v", err)
}
if len(keys) > 0 {
fmt.Printf("发现 %d 个锁:\n", len(keys))
for _, key := range keys {
ttl, _ := database.RDB.TTL(ctx, key).Result()
value, _ := database.RDB.Get(ctx, key).Result()
fmt.Printf(" - %s (TTL: %v, Value: %s)\n", key, ttl, value)
}
fmt.Println("\n是否要清除所有锁? (y/n)")
var answer string
fmt.Scanln(&answer)
if answer == "y" || answer == "Y" {
for _, key := range keys {
database.RDB.Del(ctx, key)
}
fmt.Println("✓ 已清除所有锁")
}
} else {
fmt.Println("未发现任何锁")
}
}

View File

@@ -0,0 +1,83 @@
package main
import (
"ai_xhs/config"
"ai_xhs/database"
"context"
"fmt"
"log"
"os"
"strconv"
)
func main() {
// 加载配置
if err := config.LoadConfig("dev"); err != nil {
log.Fatalf("配置加载失败: %v", err)
}
// 连接Redis
if err := database.InitRedis(); err != nil {
log.Fatalf("Redis连接失败: %v", err)
}
ctx := context.Background()
// 获取命令行参数
if len(os.Args) < 2 {
fmt.Println("用法: go run cmd/clear_bind_lock.go <employee_id>")
fmt.Println("示例: go run cmd/clear_bind_lock.go 1")
os.Exit(1)
}
employeeID, err := strconv.Atoi(os.Args[1])
if err != nil {
log.Fatalf("无效的员工ID: %v", err)
}
// 构造锁的key
lockKey := fmt.Sprintf("lock:bind_xhs:%d", employeeID)
// 检查锁是否存在
exists, err := database.RDB.Exists(ctx, lockKey).Result()
if err != nil {
log.Fatalf("检查锁失败: %v", err)
}
if exists > 0 {
// 获取锁的TTL
ttl, err := database.RDB.TTL(ctx, lockKey).Result()
if err != nil {
log.Printf("获取锁TTL失败: %v", err)
} else {
log.Printf("发现锁: %s, 剩余时间: %v", lockKey, ttl)
}
// 删除锁
err = database.RDB.Del(ctx, lockKey).Err()
if err != nil {
log.Fatalf("删除锁失败: %v", err)
}
log.Printf("✓ 成功删除锁: %s", lockKey)
} else {
log.Printf("未发现锁: %s", lockKey)
}
// 列出所有相关的锁
fmt.Println("\n=== 检查所有绑定相关的锁 ===")
keys, err := database.RDB.Keys(ctx, "lock:bind_xhs:*").Result()
if err != nil {
log.Printf("查询锁失败: %v", err)
} else {
if len(keys) > 0 {
fmt.Printf("发现 %d 个绑定锁:\n", len(keys))
for _, key := range keys {
ttl, _ := database.RDB.TTL(ctx, key).Result()
fmt.Printf(" - %s (TTL: %v)\n", key, ttl)
}
} else {
fmt.Println("未发现任何绑定锁")
}
}
}

View File

@@ -0,0 +1,47 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
)
// HashPassword 密码加密使用SHA256与Python版本保持一致
func HashPassword(password string) string {
hash := sha256.Sum256([]byte(password))
return hex.EncodeToString(hash[:])
}
func main() {
// 如果有命令行参数,加密该密码
if len(os.Args) > 1 {
password := os.Args[1]
hashed := HashPassword(password)
fmt.Printf("原始密码: %s\n", password)
fmt.Printf("加密后: %s\n", hashed)
return
}
// 为测试数据生成加密密码
passwords := []string{
"admin123", // 企业管理员密码
"user123", // 普通用户密码
"123456", // 默认密码
}
fmt.Println("生成加密密码SHA256")
fmt.Println("=====================================")
for i, pwd := range passwords {
hashed := HashPassword(pwd)
fmt.Printf("%d. 原始密码: %s\n", i+1, pwd)
fmt.Printf(" 加密后: %s\n\n", hashed)
}
fmt.Println("=====================================")
fmt.Println("使用说明:")
fmt.Println("方式1直接运行此程序查看常用密码的加密结果")
fmt.Println("方式2传入密码参数如: go run generate_password.go mypassword")
fmt.Println("注意:请将加密后的密码保存到数据库的 password 字段")
}

View File

@@ -0,0 +1,24 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
func main() {
// 测试密码
passwords := []string{
"123456",
"password",
"admin123",
}
fmt.Println("=== Go SHA256 密码加密测试 ===")
for _, pwd := range passwords {
hash := sha256.Sum256([]byte(pwd))
hashStr := hex.EncodeToString(hash[:])
fmt.Printf("密码: %s\n", pwd)
fmt.Printf("SHA256: %s\n\n", hashStr)
}
}

View File

@@ -0,0 +1,168 @@
package main
import (
"ai_xhs/config"
"ai_xhs/database"
"ai_xhs/utils"
"context"
"fmt"
"log"
"time"
)
// TestData 测试数据结构
type TestData struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email"`
}
func main() {
// 加载配置
if err := config.LoadConfig("dev"); err != nil {
log.Fatalf("配置加载失败: %v", err)
}
// 初始化Redis
if err := database.InitRedis(); err != nil {
log.Fatalf("Redis初始化失败: %v", err)
}
defer database.CloseRedis()
ctx := context.Background()
fmt.Println("\n=== Redis缓存功能测试 ===\n")
// 1. 测试基本的Set/Get
fmt.Println("1. 测试基本Set/Get操作:")
testData := TestData{
Name: "张三",
Age: 25,
Email: "zhangsan@example.com",
}
if err := utils.SetCache(ctx, "user:1001", testData, 5*time.Minute); err != nil {
log.Fatalf("设置缓存失败: %v", err)
}
fmt.Println("✓ 缓存设置成功")
var result TestData
if err := utils.GetCache(ctx, "user:1001", &result); err != nil {
log.Fatalf("获取缓存失败: %v", err)
}
fmt.Printf("✓ 缓存获取成功: %+v\n", result)
// 2. 测试Exists
fmt.Println("\n2. 测试缓存是否存在:")
exists, err := utils.ExistsCache(ctx, "user:1001")
if err != nil {
log.Fatalf("检查缓存失败: %v", err)
}
fmt.Printf("✓ 缓存存在性检查: %v\n", exists)
// 3. 测试TTL
fmt.Println("\n3. 测试获取TTL:")
ttl, err := utils.GetTTL(ctx, "user:1001")
if err != nil {
log.Fatalf("获取TTL失败: %v", err)
}
fmt.Printf("✓ 缓存TTL: %v\n", ttl)
// 4. 测试计数器
fmt.Println("\n4. 测试计数器操作:")
count, err := utils.IncrCache(ctx, "counter:test")
if err != nil {
log.Fatalf("递增失败: %v", err)
}
fmt.Printf("✓ 递增后计数: %d\n", count)
count, err = utils.IncrCache(ctx, "counter:test")
if err != nil {
log.Fatalf("递增失败: %v", err)
}
fmt.Printf("✓ 再次递增后计数: %d\n", count)
// 5. 测试Hash操作
fmt.Println("\n5. 测试Hash操作:")
if err := utils.HSetCache(ctx, "user:hash:1001", "name", "李四"); err != nil {
log.Fatalf("HSet失败: %v", err)
}
if err := utils.HSetCache(ctx, "user:hash:1001", "age", "30"); err != nil {
log.Fatalf("HSet失败: %v", err)
}
fmt.Println("✓ Hash字段设置成功")
name, err := utils.HGetCache(ctx, "user:hash:1001", "name")
if err != nil {
log.Fatalf("HGet失败: %v", err)
}
fmt.Printf("✓ 获取Hash字段 name: %s\n", name)
allFields, err := utils.HGetAllCache(ctx, "user:hash:1001")
if err != nil {
log.Fatalf("HGetAll失败: %v", err)
}
fmt.Printf("✓ 获取所有Hash字段: %+v\n", allFields)
// 6. 测试Set操作
fmt.Println("\n6. 测试Set操作:")
if err := utils.SAddCache(ctx, "tags:1001", "golang", "redis", "mysql"); err != nil {
log.Fatalf("SAdd失败: %v", err)
}
fmt.Println("✓ Set成员添加成功")
members, err := utils.SMembersCache(ctx, "tags:1001")
if err != nil {
log.Fatalf("SMembers失败: %v", err)
}
fmt.Printf("✓ 获取Set所有成员: %v\n", members)
// 7. 测试ZSet操作
fmt.Println("\n7. 测试ZSet操作:")
if err := utils.ZAddCache(ctx, "rank:score", 100.5, "user1"); err != nil {
log.Fatalf("ZAdd失败: %v", err)
}
if err := utils.ZAddCache(ctx, "rank:score", 95.0, "user2"); err != nil {
log.Fatalf("ZAdd失败: %v", err)
}
if err := utils.ZAddCache(ctx, "rank:score", 105.5, "user3"); err != nil {
log.Fatalf("ZAdd失败: %v", err)
}
fmt.Println("✓ ZSet成员添加成功")
rangeResult, err := utils.ZRangeCache(ctx, "rank:score", 0, -1)
if err != nil {
log.Fatalf("ZRange失败: %v", err)
}
fmt.Printf("✓ 获取ZSet所有成员(按分数排序): %v\n", rangeResult)
// 8. 测试删除操作
fmt.Println("\n8. 测试删除操作:")
if err := utils.DelCache(ctx, "counter:test", "tags:1001", "rank:score"); err != nil {
log.Fatalf("删除缓存失败: %v", err)
}
fmt.Println("✓ 缓存删除成功")
// 9. 测试SetNX (仅当key不存在时设置)
fmt.Println("\n9. 测试SetNX操作:")
success, err := utils.SetCacheNX(ctx, "lock:test", "locked", 10*time.Second)
if err != nil {
log.Fatalf("SetNX失败: %v", err)
}
fmt.Printf("✓ SetNX首次设置: %v\n", success)
success, err = utils.SetCacheNX(ctx, "lock:test", "locked", 10*time.Second)
if err != nil {
log.Fatalf("SetNX失败: %v", err)
}
fmt.Printf("✓ SetNX重复设置(应该失败): %v\n", success)
// 清理测试数据
fmt.Println("\n10. 清理测试数据:")
if err := utils.DelCache(ctx, "user:1001", "user:hash:1001", "lock:test"); err != nil {
log.Fatalf("清理失败: %v", err)
}
fmt.Println("✓ 测试数据清理完成")
fmt.Println("\n=== 所有测试完成! ===")
}

View File

@@ -0,0 +1,33 @@
package main
import (
"ai_xhs/config"
"ai_xhs/service"
"fmt"
"log"
)
// 测试发送服务宕机通知短信
func main() {
// 加载配置
config.InitConfig()
// 初始化短信服务
smsService := service.GetSmsService()
// 发送宕机通知到指定手机号
alertPhone := "15707023967"
serviceName := "AI小红书服务"
fmt.Printf("正在发送服务宕机通知到 %s...\n", alertPhone)
err := smsService.SendServiceDownAlert(alertPhone, serviceName)
if err != nil {
log.Fatalf("发送宕机通知失败: %v", err)
}
fmt.Printf("✅ 宕机通知发送成功!\n")
fmt.Printf("手机号: %s\n", alertPhone)
fmt.Printf("通知码: 11111\n")
fmt.Printf("服务名: %s\n", serviceName)
}

View File

@@ -60,3 +60,42 @@ const (
CodeCopyNotAvailable = 1002
CodeAlreadyClaimed = 1003
)
// ResponseData 带分页的响应数据结构
type ResponseData struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Total int64 `json:"total,omitempty"`
Page int `json:"page,omitempty"`
PageSize int `json:"page_size,omitempty"`
}
// ErrorResponse 返回错误响应对象(不直接发送)
func ErrorResponse(message string) Response {
return Response{
Code: CodeInternalError,
Message: message,
}
}
// SuccessResponse 返回成功响应对象(不直接发送)
func SuccessResponse(data interface{}, message string) Response {
return Response{
Code: CodeSuccess,
Message: message,
Data: data,
}
}
// SuccessResponseWithPage 返回带分页的成功响应对象(不直接发送)
func SuccessResponseWithPage(data interface{}, total int64, page, pageSize int, message string) ResponseData {
return ResponseData{
Code: CodeSuccess,
Message: message,
Data: data,
Total: total,
Page: page,
PageSize: pageSize,
}
}

View File

@@ -15,6 +15,13 @@ database:
max_open_conns: 100
conn_max_lifetime: 3600
redis:
host: 127.0.0.1
port: 6379
password:
db: 0
pool_size: 10
jwt:
secret: dev_secret_key_change_in_production
expire_hours: 168 # 7天
@@ -24,11 +31,11 @@ wechat:
app_secret: "69d2a3ddc902b26f82f4b56a6e277f7a" # 微信小程序AppSecret
xhs:
python_service_url: "http://localhost:8000" # Python服务地址
python_service_url: "http://localhost:8000" # Python FastAPI服务地址用于登录和发布享受浏览器池+预热加速)
scheduler:
enabled: false # 是否启用定时任务
publish_cron: "* * * * * *" # 每1小时执行一次(开发环境测试用)
publish_cron: "*/5 * * * * *" # 每5秒执行一次
max_concurrent: 2 # 最大并发发布数
publish_timeout: 300 # 发布超时时间(秒)
max_articles_per_user_per_run: 2 # 每轮每个用户最大发文数
@@ -39,4 +46,28 @@ scheduler:
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"
api_url: "http://api.tianqiip.com/getip?secret=xo0uhiz5&num=1&type=txt&port=1&mr=1&sign=d82157fb70c21bae87437ec17eb3e0aa"
upload:
max_image_size: 5242880 # 5MB (5 * 1024 * 1024)
max_file_size: 10485760 # 10MB (10 * 1024 * 1024)
image_types: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]
static_path: "./static"
base_url: "http://localhost:8080"
storage_type: "oss" # local(本地存储) 或 oss(阿里云OSS)
# 阿里云OSS配置当storageType为oss时生效
oss:
endpoint: "https://oss-cn-beijing.aliyuncs.com/" # OSS访问域名
access_key_id: "LTAI5tNesdhDH4ErqEUZmEg2" # AccessKey ID
access_key_secret: "xZn7WUkTW76TqOLTh01zZATnU6p3Tf" # AccessKey Secret
bucket_name: "bxmkb-beijing" # Bucket名称
base_path: "wht/" # 文件存储基础路径
domain: "" # 自定义域名(可选)
# ========== 阿里云短信配置 ==========
ali_sms:
access_key_id: "LTAI5tSMvnCJdqkZtCVWgh8R" # AccessKey ID
access_key_secret: "nyFzXyIi47peVLK4wR2qqbPezmU79W" # AccessKey Secret
sign_name: "北京乐航时代科技" # 短信签名
template_code: "SMS_486210104" # 短信模板CODE

View File

@@ -11,10 +11,13 @@ import (
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
Wechat WechatConfig `mapstructure:"wechat"`
XHS XHSConfig `mapstructure:"xhs"`
Scheduler SchedulerConfig `mapstructure:"scheduler"`
Upload UploadConfig `mapstructure:"upload"`
AliSms AliSmsConfig `mapstructure:"ali_sms"`
}
type ServerConfig struct {
@@ -36,6 +39,14 @@ type DatabaseConfig struct {
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
}
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
@@ -64,6 +75,34 @@ type SchedulerConfig struct {
ProxyFetchURL string `mapstructure:"proxy_fetch_url"` // 动态获取代理的接口地址(可选)
}
// UploadConfig 文件上传配置
type UploadConfig struct {
MaxImageSize int64 `mapstructure:"max_image_size"` // 图片最大大小(字节)
MaxFileSize int64 `mapstructure:"max_file_size"` // 文件最大大小(字节)
ImageTypes []string `mapstructure:"image_types"` // 允许的图片类型
StaticPath string `mapstructure:"static_path"` // 静态文件路径(本地存储)
BaseURL string `mapstructure:"base_url"` // 静态文件访问基础URL本地存储
StorageType string `mapstructure:"storage_type"` // 存储类型local(本地) 或 oss(阿里云OSS)
OSS OSSConfig `mapstructure:"oss"` // OSS配置
}
type OSSConfig struct {
Endpoint string `mapstructure:"endpoint"` // OSS访问域名
AccessKeyID string `mapstructure:"access_key_id"` // AccessKey ID
AccessKeySecret string `mapstructure:"access_key_secret"` // AccessKey Secret
BucketName string `mapstructure:"bucket_name"` // Bucket名称
BasePath string `mapstructure:"base_path"` // 文件存储基础路径
Domain string `mapstructure:"domain"` // 自定义域名(可选)
}
// AliSmsConfig 阿里云短信配置
type AliSmsConfig struct {
AccessKeyID string `mapstructure:"access_key_id"` // AccessKey ID
AccessKeySecret string `mapstructure:"access_key_secret"` // AccessKey Secret
SignName string `mapstructure:"sign_name"` // 短信签名
TemplateCode string `mapstructure:"template_code"` // 短信模板CODE
}
var AppConfig *Config
// LoadConfig 加载配置文件
@@ -100,8 +139,16 @@ func LoadConfig(env string) error {
return fmt.Errorf("解析配置文件失败: %w", err)
}
// 打印OSS配置来源调试信息
log.Printf("\n=== OSS配置来源检查 ===")
log.Printf("upload.oss.access_key_secret 配置值: [%s]", AppConfig.Upload.OSS.AccessKeySecret)
log.Printf("环境变量 OSS_ACCESS_KEY_SECRET: [%s]", os.Getenv("OSS_ACCESS_KEY_SECRET"))
log.Printf("环境变量 OSS_TEST_ACCESS_KEY_SECRET: [%s]", os.Getenv("OSS_TEST_ACCESS_KEY_SECRET"))
log.Printf("====================\n")
log.Printf("配置加载成功: %s 环境", env)
log.Printf("数据库配置: %s@%s:%d/%s", AppConfig.Database.Username, AppConfig.Database.Host, AppConfig.Database.Port, AppConfig.Database.DBName)
log.Printf("Python服务地址: %s", AppConfig.XHS.PythonServiceURL)
return nil
}
@@ -119,6 +166,13 @@ func bindEnvVariables() {
viper.BindEnv("database.dbname", "DB_NAME")
viper.BindEnv("database.charset", "DB_CHARSET")
// Redis 配置
viper.BindEnv("redis.host", "REDIS_HOST")
viper.BindEnv("redis.port", "REDIS_PORT")
viper.BindEnv("redis.password", "REDIS_PASSWORD")
viper.BindEnv("redis.db", "REDIS_DB")
viper.BindEnv("redis.pool_size", "REDIS_POOL_SIZE")
// JWT 配置
viper.BindEnv("jwt.secret", "JWT_SECRET")
viper.BindEnv("jwt.expire_hours", "JWT_EXPIRE_HOURS")
@@ -142,6 +196,27 @@ func bindEnvVariables() {
viper.BindEnv("scheduler.proxy", "SCHEDULER_PROXY")
viper.BindEnv("scheduler.user_agent", "SCHEDULER_USER_AGENT")
viper.BindEnv("scheduler.proxy_fetch_url", "SCHEDULER_PROXY_FETCH_URL")
// OSS 配置 - 强制从配置文件读取,不使用环境变量
// viper.BindEnv("upload.oss.endpoint", "OSS_ENDPOINT")
// viper.BindEnv("upload.oss.access_key_id", "OSS_ACCESS_KEY_ID")
// viper.BindEnv("upload.oss.access_key_secret", "OSS_ACCESS_KEY_SECRET")
// viper.BindEnv("upload.oss.bucket_name", "OSS_BUCKET_NAME")
// viper.BindEnv("upload.oss.base_path", "OSS_BASE_PATH")
// viper.BindEnv("upload.oss.domain", "OSS_DOMAIN")
// Upload 配置
viper.BindEnv("upload.max_image_size", "UPLOAD_MAX_IMAGE_SIZE")
viper.BindEnv("upload.max_file_size", "UPLOAD_MAX_FILE_SIZE")
viper.BindEnv("upload.static_path", "UPLOAD_STATIC_PATH")
viper.BindEnv("upload.base_url", "UPLOAD_BASE_URL")
viper.BindEnv("upload.storage_type", "UPLOAD_STORAGE_TYPE")
// AliSms 配置
viper.BindEnv("ali_sms.access_key_id", "ALI_SMS_ACCESS_KEY_ID")
viper.BindEnv("ali_sms.access_key_secret", "ALI_SMS_ACCESS_KEY_SECRET")
viper.BindEnv("ali_sms.sign_name", "ALI_SMS_SIGN_NAME")
viper.BindEnv("ali_sms.template_code", "ALI_SMS_TEMPLATE_CODE")
}
// GetDSN 获取数据库连接字符串

View File

@@ -1,12 +1,12 @@
server:
port: 8070
mode: release
mode: release # debug, release, test
database:
host: 8.149.233.36
port: 3306
username: ai_wht_write
password: 7aK_H2yvokVumr84lLNDt8fDBp6P
password: 7aK_H2yvokVumr84lLNDt8fDBp6P # 生产环境请修改密码
dbname: ai_wht
charset: utf8mb4
parse_time: true
@@ -15,28 +15,58 @@ database:
max_open_conns: 200
conn_max_lifetime: 3600
redis:
host: 8.140.194.184
port: 6379
password: Redis@123456
db: 0
pool_size: 20
jwt:
secret: prod_secret_key_please_change_this
expire_hours: 168
secret: your_production_secret_key_change_this # 生产环境请修改密钥
expire_hours: 168 # 7天
wechat:
app_id: "wxa5bf062342ef754d" # 微信小程序AppID留空则使用默认登录
app_secret: "69d2a3ddc902b26f82f4b56a6e277f7a" # 微信小程序AppSecret
app_id: "wxa5bf062342ef754d"
app_secret: "69d2a3ddc902b26f82f4b56a6e277f7a"
xhs:
python_service_url: "http://localhost:8000" # Python服务地址,生产环境请修改为实际地址
python_service_url: "http://127.0.0.1:8020" # Python FastAPI服务地址用于登录和发布享受浏览器池+预热加速)
scheduler:
enabled: false # 是否启用定时任务
publish_cron: "0 0 */2 * * *" # 每2小时执行一次(防封号策略)
max_concurrent: 2 # 最大并发发布数
enabled: false # 生产环境启用定时任务
publish_cron: "0 0 * * * *" # 每小时执行一次
max_concurrent: 5 # 最大并发发布数
publish_timeout: 300 # 发布超时时间(秒)
max_articles_per_user_per_run: 2 # 每轮每个用户最大发文数
max_articles_per_user_per_run: 5 # 每轮每个用户最大发文数
max_failures_per_user_per_run: 3 # 每轮每个用户最大失败次数
max_daily_articles_per_user: 5 # 每个用户每日最大发文数(自动发布)
max_hourly_articles_per_user: 1 # 每个用户每小时最大发文数(自动发布)
max_daily_articles_per_user: 20 # 每个用户每日最大发文数(自动发布)
max_hourly_articles_per_user: 3 # 每个用户每小时最大发文数(自动发布)
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"
api_url: "http://api.tianqiip.com/getip?secret=xo0uhiz5&num=1&type=txt&port=1&mr=1&sign=d82157fb70c21bae87437ec17eb3e0aa"
upload:
max_image_size: 5242880 # 5MB
max_file_size: 10485760 # 10MB
image_types: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]
static_path: "./static"
base_url: "https://your-domain.com" # 生产环境域名
storage_type: "oss" # 生产环境使用OSS
oss:
endpoint: "oss-cn-beijing.aliyuncs.com"
access_key_id: "LTAI5tNesdhDH4ErqEUZmEg2"
access_key_secret: "xZn7WUkTW76TqOLTh01zZATnU6p3Tf"
bucket_name: "bxmkb-beijing"
base_path: "wht/"
domain: ""
# ========== 阿里云短信配置 ==========
ali_sms:
access_key_id: "LTAI5tSMvnCJdqkZtCVWgh8R" # 生产环境建议使用环境变量
access_key_secret: "nyFzXyIi47peVLK4wR2qqbPezmU79W" # 生产环境建议使用环境变量
sign_name: "北京乐航时代科技" # 短信签名
template_code: "SMS_486210104" # 短信模板CODE

View File

@@ -2,7 +2,10 @@ package controller
import (
"ai_xhs/common"
"ai_xhs/config"
"ai_xhs/service"
"ai_xhs/utils"
"context"
"github.com/gin-gonic/gin"
)
@@ -98,3 +101,137 @@ func (ctrl *AuthController) PhoneLogin(c *gin.Context) {
},
})
}
// PhonePasswordLogin 手机号密码登录
func (ctrl *AuthController) PhonePasswordLogin(c *gin.Context) {
var req struct {
Phone string `json:"phone" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, common.CodeInvalidParams, "参数错误: "+err.Error())
return
}
// 调用手机号密码登录服务
token, employee, err := ctrl.authService.PhonePasswordLogin(req.Phone, req.Password)
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,
},
})
}
// XHSPhoneCodeLogin 小红书手机号验证码登录
func (ctrl *AuthController) XHSPhoneCodeLogin(c *gin.Context) {
var req struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, common.CodeInvalidParams, "参数错误: "+err.Error())
return
}
// 调用手机号验证码登录服务
token, employee, err := ctrl.authService.XHSPhoneCodeLogin(req.Phone, req.Code)
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,
},
})
}
// SendXHSVerificationCode 发送小红书手机号验证码
func (ctrl *AuthController) SendXHSVerificationCode(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
}
// 预检查验证手机号是否存在于user表中
if err := ctrl.authService.CheckPhoneExists(req.Phone); err != nil {
common.Error(c, common.CodeServerError, err.Error())
return
}
// 调用短信服务发送验证码
smsService := service.GetSmsService()
code, err := smsService.SendVerificationCode(req.Phone)
if err != nil {
common.Error(c, common.CodeServerError, err.Error())
return
}
// 开发环境返回验证码,生产环境不返回
response := gin.H{
"message": "验证码已发送5分钟内有效",
}
if config.AppConfig.Server.Mode == "debug" {
response["code"] = code // 仅开发环境返回
}
common.SuccessWithMessage(c, "验证码已发送", response)
}
// Logout 退出登录删除Redis中的Token
func (ctrl *AuthController) Logout(c *gin.Context) {
employeeID := c.GetInt("employee_id")
// 从Redis删除token
ctx := context.Background()
if err := utils.RevokeToken(ctx, employeeID); err != nil {
// 即使删除失败也返回成功因为token有过期时间
common.SuccessWithMessage(c, "退出成功", nil)
return
}
common.SuccessWithMessage(c, "退出成功", nil)
}

View File

@@ -2,8 +2,17 @@ package controller
import (
"ai_xhs/common"
"ai_xhs/database"
"ai_xhs/models"
"ai_xhs/service"
"ai_xhs/utils"
"bytes"
"context"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
@@ -29,7 +38,10 @@ func (ctrl *EmployeeController) SendXHSCode(c *gin.Context) {
return
}
err := ctrl.service.SendXHSCode(req.XHSPhone)
// 获取当前登录用户ID
employeeID := c.GetInt("employee_id")
err := ctrl.service.SendXHSCode(req.XHSPhone, employeeID)
if err != nil {
common.Error(c, common.CodeInternalError, err.Error())
return
@@ -59,24 +71,149 @@ func (ctrl *EmployeeController) GetProfile(c *gin.Context) {
"name": displayName,
"username": employee.Username,
"real_name": employee.RealName,
"nickname": employee.Nickname,
"email": employee.Email,
"phone": employee.Phone,
"role": employee.Role,
"enterprise_id": employee.EnterpriseID,
"enterprise_name": employee.Enterprise.Name,
"avatar": employee.Icon,
"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")
// 如果已绑定,从 ai_authors 表获取小红书账号信息(根据 created_user_id 查询)
if employee.IsBoundXHS == 1 {
var author models.Author
err := database.DB.Where(
"created_user_id = ? AND enterprise_id = ? AND channel = 1 AND status = 'active'",
employeeID, employee.EnterpriseID,
).First(&author).Error
if err == nil {
data["xhs_account"] = author.XHSAccount
data["xhs_phone"] = author.XHSPhone
data["has_xhs_cookie"] = author.XHSCookie != ""
if author.BoundAt != nil {
data["bound_at"] = author.BoundAt.Format("2006-01-02 15:04:05")
}
} else {
// 没有找到author记录返回默认值
data["xhs_account"] = ""
data["xhs_phone"] = ""
data["has_xhs_cookie"] = false
}
} else {
data["xhs_account"] = ""
data["xhs_phone"] = ""
data["has_xhs_cookie"] = false
}
common.Success(c, data)
}
// BindXHS 绑定小红书账号
// UpdateProfile 更新个人资料(昵称、邮箱、头像)
func (ctrl *EmployeeController) UpdateProfile(c *gin.Context) {
employeeID := c.GetInt("employee_id")
var req struct {
Nickname *string `json:"nickname"`
Email *string `json:"email"`
Avatar *string `json:"avatar"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, common.CodeInvalidParams, "参数错误")
return
}
if req.Nickname == nil && req.Email == nil && req.Avatar == nil {
common.Error(c, common.CodeInvalidParams, "没有可更新的字段")
return
}
// 简单校验邮箱格式
if req.Email != nil && *req.Email != "" {
if !strings.Contains(*req.Email, "@") {
common.Error(c, common.CodeInvalidParams, "邮箱格式不正确")
return
}
}
if err := ctrl.service.UpdateProfile(employeeID, req.Nickname, req.Email, req.Avatar); err != nil {
common.Error(c, common.CodeInternalError, err.Error())
return
}
common.SuccessWithMessage(c, "更新成功", nil)
}
// UploadAvatar 上传头像
func (ctrl *EmployeeController) UploadAvatar(c *gin.Context) {
employeeID := c.GetInt("employee_id")
// 获取上传的文件
file, err := c.FormFile("file")
if err != nil {
common.Error(c, common.CodeInvalidParams, "请选择要上传的图片")
return
}
// 校验文件类型
contentType := file.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
common.Error(c, common.CodeInvalidParams, "只能上传图片文件")
return
}
// 校验文件大小5MB
if file.Size > 5*1024*1024 {
common.Error(c, common.CodeInvalidParams, "图片大小不能超过5MB")
return
}
// 打开文件
src, err := file.Open()
if err != nil {
common.Error(c, common.CodeInternalError, "打开文件失败")
return
}
defer src.Close()
// 读取文件内容
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(src)
if err != nil {
common.Error(c, common.CodeInternalError, "读取文件失败")
return
}
// 上传到 OSS
fileExt := ".jpg"
if strings.Contains(contentType, "png") {
fileExt = ".png"
} else if strings.Contains(contentType, "webp") {
fileExt = ".webp"
}
fileName := fmt.Sprintf("avatar_%d_%d%s", employeeID, time.Now().Unix(), fileExt)
ossURL, err := utils.UploadToOSS(bytes.NewReader(buf.Bytes()), fileName)
if err != nil {
common.Error(c, common.CodeInternalError, fmt.Sprintf("上传失败: %s", err.Error()))
return
}
// 更新数据库
if err := ctrl.service.UpdateProfile(employeeID, nil, nil, &ossURL); err != nil {
common.Error(c, common.CodeInternalError, "更新头像失败")
return
}
common.Success(c, map[string]interface{}{
"url": ossURL,
})
}
// BindXHS 绑定小红书账号(异步处理)
func (ctrl *EmployeeController) BindXHS(c *gin.Context) {
employeeID := c.GetInt("employee_id")
@@ -90,17 +227,31 @@ func (ctrl *EmployeeController) BindXHS(c *gin.Context) {
return
}
xhsAccount, err := ctrl.service.BindXHS(employeeID, req.XHSPhone, req.Code)
_, 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,
// 立即返回成功,告知前端正在处理
common.SuccessWithMessage(c, "正在验证登录,请稍候...", map[string]interface{}{
"status": "processing",
})
}
// GetBindXHSStatus 获取小红书绑定状态
func (ctrl *EmployeeController) GetBindXHSStatus(c *gin.Context) {
employeeID := c.GetInt("employee_id")
status, err := ctrl.service.GetBindXHSStatus(employeeID)
if err != nil {
common.Error(c, common.CodeInternalError, err.Error())
return
}
common.Success(c, status)
}
// UnbindXHS 解绑小红书账号
func (ctrl *EmployeeController) UnbindXHS(c *gin.Context) {
employeeID := c.GetInt("employee_id")
@@ -246,14 +397,24 @@ func (ctrl *EmployeeController) CheckXHSStatus(c *gin.Context) {
// GetProducts 获取产品列表
func (ctrl *EmployeeController) GetProducts(c *gin.Context) {
data, err := ctrl.service.GetProducts()
employeeID := c.GetInt("employee_id")
if employeeID == 0 {
common.Error(c, common.CodeUnauthorized, "未登录或token无效")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
data, hasMore, err := ctrl.service.GetProducts(employeeID, page, pageSize)
if err != nil {
common.Error(c, common.CodeInternalError, err.Error())
return
}
common.Success(c, map[string]interface{}{
"list": data,
"list": data,
"has_more": hasMore,
})
}
@@ -294,3 +455,294 @@ func (ctrl *EmployeeController) UpdateArticleStatus(c *gin.Context) {
common.SuccessWithMessage(c, message, nil)
}
// UpdateArticleContent 更新文案内容(标题、正文)
func (ctrl *EmployeeController) UpdateArticleContent(c *gin.Context) {
employeeID := c.GetInt("employee_id")
articleID, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.Error(c, common.CodeInvalidParams, "文案ID参数错误")
return
}
var req struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, common.CodeInvalidParams, "参数错误")
return
}
// 验证标题和内容字数
if len([]rune(req.Title)) > 20 {
common.Error(c, common.CodeInvalidParams, "标题最多20字")
return
}
if len([]rune(req.Content)) > 1000 {
common.Error(c, common.CodeInvalidParams, "内容最多1000字")
return
}
err = ctrl.service.UpdateArticleContent(employeeID, articleID, req.Title, req.Content)
if err != nil {
common.Error(c, common.CodeInternalError, err.Error())
return
}
common.SuccessWithMessage(c, "更新成功", nil)
}
// UpdatePublishRecord 编辑发布记录(修改标题、内容、图片、标签)
func (ctrl *EmployeeController) UpdatePublishRecord(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
}
var req service.UpdatePublishRecordRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, common.CodeInvalidParams, "参数错误")
return
}
// 验证标题和内容字数
if req.Title != nil && len([]rune(*req.Title)) > 20 {
common.Error(c, common.CodeInvalidParams, "标题最多20字")
return
}
if req.Content != nil && len([]rune(*req.Content)) > 1000 {
common.Error(c, common.CodeInvalidParams, "内容最多1000字")
return
}
if err := ctrl.service.UpdatePublishRecord(employeeID, recordID, req); err != nil {
common.Error(c, common.CodeInternalError, err.Error())
return
}
common.SuccessWithMessage(c, "更新成功", nil)
}
// RepublishRecord 重新发布种草内容
func (ctrl *EmployeeController) RepublishRecord(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
}
publishLink, err := ctrl.service.RepublishRecord(employeeID, recordID)
if err != nil {
common.Error(c, common.CodeInternalError, err.Error())
return
}
common.SuccessWithMessage(c, "重新发布成功", map[string]interface{}{
"publish_link": publishLink,
})
}
// AddArticleImage 添加文案图片
func (ctrl *EmployeeController) AddArticleImage(c *gin.Context) {
employeeID := c.GetInt("employee_id")
articleID, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.Error(c, common.CodeInvalidParams, "文案ID参数错误")
return
}
var req struct {
ImageURL string `json:"image_url" binding:"required"`
ImageThumbURL string `json:"image_thumb_url"`
KeywordsName string `json:"keywords_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, common.CodeInvalidParams, "参数错误")
return
}
// 如果没有缩略图,使用原图
if req.ImageThumbURL == "" {
req.ImageThumbURL = req.ImageURL
}
image, err := ctrl.service.AddArticleImage(employeeID, articleID, req.ImageURL, req.ImageThumbURL, req.KeywordsName)
if err != nil {
common.Error(c, common.CodeInternalError, err.Error())
return
}
common.SuccessWithMessage(c, "添加成功", image)
}
// DeleteArticleImage 删除文案图片
func (ctrl *EmployeeController) DeleteArticleImage(c *gin.Context) {
employeeID := c.GetInt("employee_id")
imageID, err := strconv.Atoi(c.Param("imageId"))
if err != nil {
common.Error(c, common.CodeInvalidParams, "图片ID参数错误")
return
}
err = ctrl.service.DeleteArticleImage(employeeID, imageID)
if err != nil {
common.Error(c, common.CodeInternalError, err.Error())
return
}
common.SuccessWithMessage(c, "删除成功", nil)
}
// UpdateArticleImagesOrder 更新文案图片排序
func (ctrl *EmployeeController) UpdateArticleImagesOrder(c *gin.Context) {
employeeID := c.GetInt("employee_id")
articleID, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.Error(c, common.CodeInvalidParams, "文案ID参数错误")
return
}
var req struct {
ImageOrders []map[string]int `json:"image_orders" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, common.CodeInvalidParams, "参数错误")
return
}
err = ctrl.service.UpdateArticleImagesOrder(employeeID, articleID, req.ImageOrders)
if err != nil {
common.Error(c, common.CodeInternalError, err.Error())
return
}
common.SuccessWithMessage(c, "更新成功", nil)
}
// UploadImage 上传图片支持base64和multipart/form-data
func (ctrl *EmployeeController) UploadImage(c *gin.Context) {
// 尝试从表单获取文件
file, header, err := c.Request.FormFile("file")
if err == nil {
// 处理文件上传
defer file.Close()
// 验证文件类型
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
common.Error(c, common.CodeInvalidParams, "只支持图片文件")
return
}
// 上传到OSS
imageURL, err := utils.UploadToOSS(file, header.Filename)
if err != nil {
common.Error(c, common.CodeInternalError, fmt.Sprintf("上传失败: %v", err))
return
}
common.SuccessWithMessage(c, "上传成功", map[string]interface{}{
"image_url": imageURL,
"image_thumb_url": imageURL, // 简化处理,缩略图与原图相同
})
return
}
// 尝试介ase64获取
var req struct {
Base64 string `json:"base64" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, common.CodeInvalidParams, "请上传文件或base64数据")
return
}
// 解析base64
var imageData []byte
if strings.Contains(req.Base64, "base64,") {
// 移除data:image/xxx;base64,前缀
parts := strings.Split(req.Base64, "base64,")
if len(parts) != 2 {
common.Error(c, common.CodeInvalidParams, "base64格式错误")
return
}
imageData, err = base64.StdEncoding.DecodeString(parts[1])
} else {
imageData, err = base64.StdEncoding.DecodeString(req.Base64)
}
if err != nil {
common.Error(c, common.CodeInvalidParams, "base64解码失败")
return
}
// 上传到OSS
reader := bytes.NewReader(imageData)
imageURL, err := utils.UploadToOSS(reader, "image.jpg")
if err != nil {
common.Error(c, common.CodeInternalError, fmt.Sprintf("上传失败: %v", err))
return
}
common.SuccessWithMessage(c, "上传成功", map[string]interface{}{
"image_url": imageURL,
"image_thumb_url": imageURL,
})
}
// RevokeUserToken 禁用用户撤销Token
func (ctrl *EmployeeController) RevokeUserToken(c *gin.Context) {
// 只有管理员可以禁用用户
employeeID := c.GetInt("employee_id")
// 获取当前用户信息,检查是否为管理员
var currentUser models.User
if err := database.DB.Where("id = ?", employeeID).First(&currentUser).Error; err != nil {
common.Error(c, common.CodeUnauthorized, "用户不存在")
return
}
if currentUser.Role != "admin" {
common.Error(c, common.CodeUnauthorized, "无权操作,只有管理员可以禁用用户")
return
}
var req struct {
TargetUserID int `json:"target_user_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, common.CodeInvalidParams, "参数错误需要提供目标用户ID")
return
}
// 不能禁用自己
if req.TargetUserID == employeeID {
common.Error(c, common.CodeInvalidParams, "不能禁用自己")
return
}
// 检查目标用户是否存在
var targetUser models.User
if err := database.DB.Where("id = ?", req.TargetUserID).First(&targetUser).Error; err != nil {
common.Error(c, common.CodeNotFound, "目标用户不存在")
return
}
// 撤销该用户的Token
ctx := context.Background()
if err := utils.RevokeToken(ctx, req.TargetUserID); err != nil {
common.Error(c, common.CodeInternalError, fmt.Sprintf("禁用失败: %v", err))
return
}
common.SuccessWithMessage(c, fmt.Sprintf("已禁用用户 %s (手机号: %s),该用户需要重新登录", targetUser.Username, targetUser.Phone), nil)
}

View File

@@ -0,0 +1,104 @@
package controller
import (
"ai_xhs/common"
"ai_xhs/models"
"ai_xhs/service"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// CreateFeedbackRequest 创建反馈请求
type CreateFeedbackRequest struct {
FeedbackType string `json:"feedback_type" binding:"required"`
Description string `json:"description" binding:"required,max=500"`
ContactInfo string `json:"contact_info"`
Nickname string `json:"nickname"`
}
// FeedbackController 反馈控制器
type FeedbackController struct {
feedbackService *service.FeedbackService
}
// NewFeedbackController 创建反馈控制器
func NewFeedbackController(feedbackService *service.FeedbackService) *FeedbackController {
return &FeedbackController{
feedbackService: feedbackService,
}
}
// CreateFeedback 创建反馈
func (fc *FeedbackController) CreateFeedback(c *gin.Context) {
var req CreateFeedbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, common.CodeInvalidParams, "参数错误: "+err.Error())
return
}
// 从上下文获取员工ID
employeeID, exists := c.Get("employee_id")
if !exists {
common.Error(c, common.CodeUnauthorized, "未登录")
return
}
feedback := &models.Feedback{
FeedbackType: req.FeedbackType,
Description: req.Description,
ContactInfo: req.ContactInfo,
Nickname: req.Nickname,
CreatedUserID: employeeID.(int),
Status: "待处理",
}
if err := fc.feedbackService.CreateFeedback(feedback); err != nil {
common.Error(c, common.CodeInternalError, "提交反馈失败: "+err.Error())
return
}
common.SuccessWithMessage(c, "反馈提交成功", feedback)
}
// GetFeedbackList 获取反馈列表
func (fc *FeedbackController) GetFeedbackList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
feedbackType := c.Query("feedback_type")
status := c.Query("status")
// 从上下文获取员工ID仅查看自己的反馈
employeeID, exists := c.Get("employee_id")
if !exists {
common.Error(c, common.CodeUnauthorized, "未登录")
return
}
feedbacks, total, err := fc.feedbackService.GetFeedbackList(employeeID.(int), page, pageSize, feedbackType, status)
if err != nil {
common.Error(c, common.CodeInternalError, "获取反馈列表失败: "+err.Error())
return
}
c.JSON(http.StatusOK, common.SuccessResponseWithPage(feedbacks, total, page, pageSize, "获取成功"))
}
// GetFeedbackDetail 获取反馈详情
func (fc *FeedbackController) GetFeedbackDetail(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.Error(c, common.CodeInvalidParams, "无效的反馈ID")
return
}
feedback, err := fc.feedbackService.GetFeedbackByID(id)
if err != nil {
common.Error(c, common.CodeNotFound, "反馈不存在")
return
}
common.SuccessWithMessage(c, "获取成功", feedback)
}

View File

@@ -0,0 +1,44 @@
package database
import (
"ai_xhs/config"
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
var RDB *redis.Client
// InitRedis 初始化Redis连接
func InitRedis() error {
cfg := config.AppConfig.Redis
RDB = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: cfg.DB,
PoolSize: cfg.PoolSize,
})
// 测试连接
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := RDB.Ping(ctx).Err(); err != nil {
return fmt.Errorf("Redis连接失败: %w", err)
}
log.Printf("Redis连接成功: %s:%d (DB:%d)", cfg.Host, cfg.Port, cfg.DB)
return nil
}
// CloseRedis 关闭Redis连接
func CloseRedis() error {
if RDB != nil {
return RDB.Close()
}
return nil
}

View File

@@ -3,16 +3,32 @@ module ai_xhs
go 1.21
require (
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3
github.com/alibabacloud-go/tea v1.3.14
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.17.2
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.18.2
gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.25.5
)
require (
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect
github.com/alibabacloud-go/openapi-util v0.1.1 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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
@@ -33,7 +49,6 @@ require (
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
@@ -41,16 +56,18 @@ require (
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/tjfoc/gmsm v1.4.1 // 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/crypto v0.24.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
golang.org/x/net v0.26.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.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

View File

@@ -1,13 +1,85 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.11/go.mod h1:wHxkgZT1ClZdcwEVP/pDgYK/9HucsnCfMipmJgCz4xY=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3 h1:32N2pGk28weVZ5/rjNk9gPx/jrRkR0rX9i8Id6IlyUY=
github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3/go.mod h1:gPbHx4BTxLIDNRfYNGGmp6aIpeNBamtdDlPcK4UTUto=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/openapi-util v0.1.1 h1:ujGErJjG8ncRW6XtBBMphzHTvCxn4DjrVw4m04HsS28=
github.com/alibabacloud-go/openapi-util v0.1.1/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea v1.3.14 h1:/Uzj5ZCFPpbPR+Bs7jfzsyXkYIVsi5TOIuQNOWwc/9c=
github.com/alibabacloud-go/tea v1.3.14/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
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=
@@ -32,24 +104,47 @@ 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/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
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.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
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/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
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=
@@ -63,13 +158,20 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
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 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
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=
@@ -78,6 +180,9 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
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/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
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=
@@ -89,9 +194,11 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
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.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
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=
@@ -101,10 +208,16 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
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/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
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=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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=
@@ -112,27 +225,153 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV
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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
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/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/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/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
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/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=
@@ -141,4 +380,6 @@ gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb
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=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -6,6 +6,8 @@ import (
"ai_xhs/middleware"
"ai_xhs/router"
"ai_xhs/service"
// "ai_xhs/tools" // 临时注释,避免包冲突
"ai_xhs/utils"
"flag"
"fmt"
"log"
@@ -28,6 +30,35 @@ func main() {
log.Fatalf("数据库初始化失败: %v", err)
}
// 初始化Redis
if err := database.InitRedis(); err != nil {
log.Fatalf("Redis初始化失败: %v", err)
}
defer database.CloseRedis()
// 初始化OSS
log.Printf("OSS配置: Endpoint=%s, AccessKeyID=%s..., AccessKeySecret=%s..., BucketName=%s",
config.AppConfig.Upload.OSS.Endpoint,
config.AppConfig.Upload.OSS.AccessKeyID[:8],
config.AppConfig.Upload.OSS.AccessKeySecret[:8],
config.AppConfig.Upload.OSS.BucketName)
if err := utils.InitOSS(); err != nil {
log.Fatalf("OSS初始化失败: %v", err)
}
log.Println("OSS客户端初始化成功")
// 初始化短信服务
smsService := service.GetSmsService()
smsService.StartCleanupTask()
log.Println("短信服务已初始化")
// 启动服务监控(宕机时发送短信通知)
// 临时注释避免tools包冲突导致编译失败
// monitor := tools.GetServiceMonitor("15707023967", "AI小红书服务")
// monitor.StartMonitoring()
// log.Println("服务监控已启动")
// 自动迁移数据库表
//if err := database.AutoMigrate(); err != nil {
// log.Fatalf("数据库迁移失败: %v", err)
@@ -53,9 +84,9 @@ func main() {
// 创建路由
r := gin.New()
// 添加中间件
r.Use(gin.Recovery()) // 崩溃恢复
r.Use(gin.Recovery()) // 崩溃恢复
r.Use(middleware.RequestLogger()) // API请求日志
// 设置路由

View File

@@ -3,6 +3,7 @@ package middleware
import (
"ai_xhs/common"
"ai_xhs/utils"
"context"
"strings"
"github.com/gin-gonic/gin"
@@ -35,6 +36,14 @@ func AuthMiddleware() gin.HandlerFunc {
return
}
// 新增验证token是否在Redis中存在校验是否被禁用
ctx := context.Background()
if err := utils.ValidateTokenInRedis(ctx, claims.EmployeeID, parts[1]); err != nil {
common.Error(c, common.CodeUnauthorized, err.Error())
c.Abort()
return
}
// 将员工ID存入上下文
c.Set("employee_id", claims.EmployeeID)
c.Next()

View File

@@ -27,9 +27,12 @@ func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
startTime := time.Now()
// 读取请求体
// 读取请求体(跳过文件上传)
var requestBody []byte
if c.Request.Body != nil {
contentType := c.GetHeader("Content-Type")
// 如果不是文件上传,才读取请求体
if c.Request.Body != nil && !strings.HasPrefix(contentType, "multipart/form-data") {
requestBody, _ = io.ReadAll(c.Request.Body)
// 恢复请求体供后续处理使用
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
@@ -61,14 +64,14 @@ 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 ---")
@@ -100,6 +103,9 @@ func printRequest(c *gin.Context, body []byte) {
} else {
fmt.Println(string(body))
}
} else if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
fmt.Println("\n--- Request Body ---")
fmt.Println("[File upload: multipart/form-data]")
}
fmt.Println(strings.Repeat("-", 100))
@@ -110,11 +116,11 @@ 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 ---")
@@ -126,12 +132,21 @@ func printResponse(c *gin.Context, body []byte, duration time.Duration) {
// 响应体
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())
// 检查Content-Type跳过二进制数据
contentType := c.Writer.Header().Get("Content-Type")
if strings.Contains(contentType, "image/") ||
strings.Contains(contentType, "application/octet-stream") ||
len(body) > 10240 { // 超过10KB的响应不打印
fmt.Printf("[Binary data: %d bytes, Content-Type: %s]\n", len(body), contentType)
} else {
fmt.Println(string(body))
// 尝试格式化 JSON
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
fmt.Println(prettyJSON.String())
} else {
fmt.Println(string(body))
}
}
}

View File

@@ -0,0 +1,8 @@
-- 添加小红书storage_state文件路径字段
-- 用于存储Playwright storage_state文件的路径提升登录态恢复的可靠性
ALTER TABLE `ai_authors`
ADD COLUMN `xhs_storage_state_path` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '小红书storage_state文件路径' AFTER `xhs_cookie`;
-- 为方便查询,添加索引
CREATE INDEX `idx_xhs_storage_state_path` ON `ai_authors` (`xhs_storage_state_path`);

View File

@@ -0,0 +1,16 @@
-- 创建意见反馈表
CREATE TABLE IF NOT EXISTS ai_feedback (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
feedback_type ENUM('功能建议', 'Bug反馈', '体验问题', '其他') NOT NULL COMMENT '反馈类型',
description TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '问题描述最多500字',
contact_info VARCHAR(255) DEFAULT NULL COMMENT '联系方式(如邮箱),选填',
nickname VARCHAR(255) NOT NULL DEFAULT '' COMMENT '填写用户昵称',
created_user_id INT(10) UNSIGNED NOT NULL COMMENT '创建该反馈的用户ID关联用户表',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
status ENUM('待处理', '处理中', '已解决', '已关闭') DEFAULT '待处理' COMMENT '反馈状态',
INDEX idx_created_user_id (created_user_id),
INDEX idx_feedback_type (feedback_type),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI系统用户反馈表';

View File

@@ -0,0 +1,40 @@
-- 手机号密码登录 - 测试数据初始化脚本
-- 为测试用户设置密码(使用 SHA256 加密)
-- 常用密码哈希值SHA256
-- admin123: 240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9
-- user123: e606e38b0d8c19b24cf0ee3808183162ea7cd63ff7912dbb22b5e803286b4446
-- 123456: 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
-- 示例1为手机号 13800138000 的用户设置密码为 123456
UPDATE ai_users
SET password = '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92'
WHERE phone = '13800138000' AND status = 'active';
-- 示例2为手机号 13800138001 的用户设置密码为 user123
UPDATE ai_users
SET password = 'e606e38b0d8c19b24cf0ee3808183162ea7cd63ff7912dbb22b5e803286b4446'
WHERE phone = '13800138001' AND status = 'active';
-- 示例3为手机号 13800138002 的用户设置密码为 admin123
UPDATE ai_users
SET password = '240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9'
WHERE phone = '13800138002' AND status = 'active';
-- 查询验证password 字段默认不显示,需要手动选择)
SELECT
id,
phone,
username,
real_name,
LEFT(password, 20) as password_preview,
status,
created_at
FROM ai_users
WHERE phone IN ('13800138000', '13800138001', '13800138002')
ORDER BY id;
-- 注意事项:
-- 1. 密码使用 SHA256 加密存储,不可逆
-- 2. 如需生成新密码哈希使用工具go run tools/generate_password.go [密码]
-- 3. 测试时使用明文密码登录,系统会自动验证哈希值

View File

@@ -6,8 +6,8 @@ import (
// 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"`
ID int `gorm:"primaryKey;column:id;autoIncrement" json:"id"`
EnterpriseID string `gorm:"column:enterprise_id;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"`
@@ -27,24 +27,22 @@ type Enterprise struct {
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
}
// User 用户账号表原Employee对应ai_users
// 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"`
Enterprise Enterprise `gorm:"foreignKey:EnterpriseID;references:ID" 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:"真实姓名"`
Nickname string `gorm:"type:varchar(256);not null;default:''" json:"nickname" comment:"用户昵称"`
Icon string `gorm:"type:varchar(512);not null;default:''" json:"icon" comment:"企业图标URL"`
Email string `gorm:"type:varchar(100)" json:"email" comment:"邮箱"`
Phone string `gorm:"type:varchar(20)" json:"phone" comment:"手机号"`
WechatOpenID *string `gorm:"column:wechat_openid;type:varchar(100);uniqueIndex:uk_wechat_openid" json:"wechat_openid,omitempty" comment:"微信OpenID"`
WechatUnionID *string `gorm:"column:wechat_unionid;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','enterprise');default:'editor'" json:"role" comment:"角色"`
Status string `gorm:"type:enum('active','inactive','deleted');default:'active';index:idx_status" json:"status" comment:"状态"`
@@ -72,37 +70,43 @@ type Product struct {
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
}
// Article 文章表原Copy对应ai_articles
// 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','assign_authors','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:"更新时间"`
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"`
ProductName string `gorm:"type:varchar(256);not null;default:''" json:"product_name" comment:"产品名称"`
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"`
PromptWorkflowName string `gorm:"type:varchar(100);not null;default:''" json:"prompt_workflow_name" comment:"提示词工作流名称"`
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:"标题"`
ContextSummary string `gorm:"type:varchar(1024);not null;default:''" json:"context_summary" 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','assign_authors','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=小红书|2=douyin|3=toutiao|4=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:"更新时间"`
// 关联字段
Images []ArticleImage `gorm:"foreignKey:ArticleID" json:"images,omitempty" comment:"文章图片"`
}
// Copy 文案表别名,兼容旧代码
@@ -120,10 +124,10 @@ type PublishRecord struct {
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','assign_authors','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"`
Channel int `gorm:"type:tinyint(1);not null;default:1" json:"channel" comment:"渠道1=小红书|2=douyin|3=toutiao|4=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:"发布访问链接"`
PublishLink string `gorm:"type:varchar(255);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:"创建时间"`
@@ -243,29 +247,46 @@ type Log struct {
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
}
// Author 作者表对应ai_authors
// Author 作者表(对应ai_authors)
type Author struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
EnterpriseID int `gorm:"not null;default:0" json:"enterprise_id" comment:"所属企业ID"`
CreatedUserID int `gorm:"not null;default:0" json:"created_user_id" comment:"创建用户ID"`
Phone string `gorm:"type:varchar(20)" json:"phone" comment:"手机号"`
AuthorName string `gorm:"type:varchar(100);not null;default:''" json:"author_name" comment:"作者名称"`
AppID string `gorm:"type:varchar(127);not null;default:''" json:"app_id" comment:"应用ID"`
AppToken string `gorm:"type:varchar(127);not null;default:''" json:"app_token" comment:"应用Token"`
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:"部门名称"`
Department string `gorm:"type:varchar(50);not null;default:''" json:"department" comment:"部门"`
Title string `gorm:"type:varchar(50)" json:"title" comment:"职称"`
Hospital string `gorm:"type:varchar(100)" json:"hospital" comment:"医院"`
Specialty string `gorm:"type:text" json:"specialty" comment:"专业"`
ToutiaoCookie string `gorm:"type:text" json:"toutiao_cookie" comment:"头条Cookie"`
ToutiaoImagesCookie string `gorm:"type:text" json:"toutiao_images_cookie" comment:"头条图片Cookie"`
Introduction string `gorm:"type:text" json:"introduction" comment:"介绍"`
AvatarURL string `gorm:"type:varchar(255)" json:"avatar_url" comment:"头像URL"`
Status string `gorm:"type:enum('active','inactive');default:'active';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"`
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
EnterpriseID int `gorm:"not null;default:0" json:"enterprise_id" comment:"所属企业ID"`
CreatedUserID int `gorm:"not null;default:0" json:"created_user_id" comment:"创建用户ID"`
Phone string `gorm:"type:varchar(20)" json:"phone" comment:"手机号"`
AuthorName string `gorm:"type:varchar(100);not null;default:''" json:"author_name" comment:"作者名称"`
XHSCookie string `gorm:"type:text" json:"xhs_cookie" comment:"小红书登录状态login_state JSON"`
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:"小红书账号名称"`
BoundAt *time.Time `json:"bound_at" comment:"绑定的时间"`
AppID string `gorm:"type:varchar(127);not null;default:''" json:"app_id" comment:"应用ID"`
AppToken string `gorm:"type:varchar(127);not null;default:''" json:"app_token" comment:"应用Token"`
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:"部门名称"`
Department string `gorm:"type:varchar(50);not null;default:''" json:"department" comment:"部门"`
Title string `gorm:"type:varchar(50)" json:"title" comment:"职称"`
Hospital string `gorm:"type:varchar(100)" json:"hospital" comment:"医院"`
Specialty string `gorm:"type:text" json:"specialty" comment:"专业"`
ToutiaoCookie string `gorm:"type:text" json:"toutiao_cookie" comment:"头条Cookie"`
ToutiaoImagesCookie string `gorm:"type:text" json:"toutiao_images_cookie" comment:"头条图片Cookie"`
Introduction string `gorm:"type:text" json:"introduction" comment:"介绍"`
AvatarURL string `gorm:"type:varchar(255)" json:"avatar_url" comment:"头像URL"`
Status string `gorm:"type:enum('active','inactive');default:'active';index:idx_status" json:"status" comment:"状态"`
Channel int `gorm:"type:tinyint(1);not null;default:1" json:"channel" comment:"渠道1=小红书|2=douyin|3=toutiao|4=weixin"`
CreatedAt time.Time `gorm:"index:idx_created_at" json:"created_at" comment:"创建时间"`
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
}
// Feedback 用户反馈表
type Feedback struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
FeedbackType string `gorm:"type:enum('功能建议','Bug反馈','体验问题','其他');not null;index:idx_feedback_type" json:"feedback_type" comment:"反馈类型"`
Description string `gorm:"type:text;charset=utf8mb4;collate=utf8mb4_unicode_ci" json:"description" comment:"问题描述最多500字"`
ContactInfo string `gorm:"type:varchar(255)" json:"contact_info" comment:"联系方式(如邮箱),选填"`
Nickname string `gorm:"type:varchar(255);not null;default:''" json:"nickname" comment:"填写用户昵称"`
CreatedUserID int `gorm:"type:int unsigned;not null;index:idx_created_user_id" 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:"更新时间"`
Status string `gorm:"type:enum('待处理','处理中','已解决','已关闭');default:'待处理'" json:"status" comment:"反馈状态"`
}
// TableName 指定表名带ai_前缀
@@ -320,3 +341,7 @@ func (Log) TableName() string {
func (Author) TableName() string {
return "ai_authors"
}
func (Feedback) TableName() string {
return "ai_feedback"
}

View File

@@ -0,0 +1,83 @@
@echo off
chcp 65001 >nul
REM 服务监控脚本 - Windows版本
REM 用于外部监控服务状态
setlocal enabledelayedexpansion
set "SERVICE_NAME=AI小红书服务"
set "ALERT_PHONE=15707023967"
set "HEARTBEAT_FILE=%TEMP%\ai_xhs_service_heartbeat.json"
set "CHECK_INTERVAL=120"
echo ========================================
echo 服务监控检查 - %date% %time%
echo ========================================
echo.
REM 检查心跳文件是否存在
if not exist "%HEARTBEAT_FILE%" (
echo [错误] 心跳文件不存在: %HEARTBEAT_FILE%
echo [错误] 服务可能未启动或已宕机
goto :SEND_ALERT
)
echo [信息] 心跳文件: %HEARTBEAT_FILE%
REM 读取心跳文件内容
for /f "delims=" %%i in ('powershell -Command "Get-Content '%HEARTBEAT_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty last_heartbeat"') do (
set "LAST_HEARTBEAT=%%i"
)
if "!LAST_HEARTBEAT!"=="" (
echo [错误] 无法读取心跳信息
goto :SEND_ALERT
)
echo [信息] 上次心跳: !LAST_HEARTBEAT!
REM 计算时间差使用PowerShell
for /f %%i in ('powershell -Command "$now=[DateTime]::Now; $last=[DateTime]::Parse('!LAST_HEARTBEAT!'); ($now - $last).TotalSeconds"') do (
set "TIME_DIFF=%%i"
)
REM 去除小数点
for /f "tokens=1 delims=." %%a in ("!TIME_DIFF!") do set "TIME_DIFF_INT=%%a"
echo [信息] 距离上次心跳: !TIME_DIFF_INT!
REM 检查是否超时
if !TIME_DIFF_INT! GTR %CHECK_INTERVAL% (
echo [错误] 服务可能已宕机(超过%CHECK_INTERVAL%秒未更新心跳)
goto :SEND_ALERT
)
echo [信息] 服务运行正常
echo.
echo ========================================
echo 检查完成 - 状态正常
echo ========================================
exit /b 0
:SEND_ALERT
echo.
echo [警告] 检测到服务异常,正在发送通知...
echo.
REM 发送宕机通知
cd /d %~dp0
go run test_service_alert.go
if %ERRORLEVEL% EQU 0 (
echo [信息] 宕机通知发送成功
) else (
echo [错误] 宕机通知发送失败
)
echo.
echo ========================================
echo 检查完成 - 服务异常
echo ========================================
pause
exit /b 1

View File

@@ -0,0 +1,124 @@
#!/bin/bash
# 服务监控脚本 - 用于外部监控服务状态
# 可以配合cron定时任务使用
# 配置
SERVICE_NAME="AI小红书服务"
ALERT_PHONE="15707023967"
HEARTBEAT_FILE="/tmp/ai_xhs_service_heartbeat.json"
CHECK_INTERVAL=120 # 检查间隔(秒),心跳超过这个时间未更新则认为服务宕机
MONITOR_DIR="$(cd "$(dirname "$0")" && pwd)"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() {
echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"
}
# 检查心跳文件是否存在
check_heartbeat_file() {
if [ ! -f "$HEARTBEAT_FILE" ]; then
log_error "心跳文件不存在: $HEARTBEAT_FILE"
return 1
fi
return 0
}
# 获取最后心跳时间
get_last_heartbeat() {
if ! check_heartbeat_file; then
echo "0"
return
fi
# 从JSON文件中提取last_heartbeat时间
last_heartbeat=$(grep -o '"last_heartbeat":"[^"]*"' "$HEARTBEAT_FILE" | cut -d'"' -f4)
if [ -z "$last_heartbeat" ]; then
echo "0"
return
fi
# 转换为Unix时间戳
heartbeat_timestamp=$(date -d "$last_heartbeat" +%s 2>/dev/null)
if [ $? -ne 0 ]; then
echo "0"
return
fi
echo "$heartbeat_timestamp"
}
# 检查服务是否运行
check_service_status() {
log_info "开始检查服务状态..."
last_heartbeat_ts=$(get_last_heartbeat)
if [ "$last_heartbeat_ts" = "0" ]; then
log_error "无法获取心跳信息"
return 1
fi
current_ts=$(date +%s)
time_diff=$((current_ts - last_heartbeat_ts))
log_info "距离上次心跳: ${time_diff}"
if [ $time_diff -gt $CHECK_INTERVAL ]; then
log_error "服务可能已宕机(超过${CHECK_INTERVAL}秒未更新心跳)"
return 1
else
log_info "服务运行正常"
return 0
fi
}
# 发送宕机通知
send_alert() {
log_warn "尝试发送宕机通知..."
# 调用Go程序发送通知
cd "$MONITOR_DIR"
go run test_service_alert.go
if [ $? -eq 0 ]; then
log_info "宕机通知发送成功"
return 0
else
log_error "宕机通知发送失败"
return 1
fi
}
# 主函数
main() {
echo "========================================"
echo "服务监控检查 - $(date '+%Y-%m-%d %H:%M:%S')"
echo "========================================"
if ! check_service_status; then
log_error "检测到服务异常"
send_alert
exit 1
fi
log_info "服务状态正常"
exit 0
}
# 运行主函数
main

View File

@@ -1,246 +0,0 @@
#!/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}"

View File

@@ -3,6 +3,7 @@ package router
import (
"ai_xhs/controller"
"ai_xhs/middleware"
"ai_xhs/service"
"github.com/gin-gonic/gin"
)
@@ -18,18 +19,25 @@ func SetupRouter(r *gin.Engine) {
})
})
// 静态文件服务(上传的图片)
r.Static("/uploads", "./uploads")
// API路由组
api := r.Group("/api")
{
// 公开接口(不需要认证)
authCtrl := controller.NewAuthController()
api.POST("/login/wechat", authCtrl.WechatLogin) // 微信登录
api.POST("/login/phone", authCtrl.PhoneLogin) // 手机号登录(测试用)
api.POST("/login/wechat", authCtrl.WechatLogin) // 微信登录
api.POST("/login/phone", authCtrl.PhoneLogin) // 手机号登录(测试用)
api.POST("/login/phone-password", authCtrl.PhonePasswordLogin) // 手机号密码登录
api.POST("/login/xhs-phone-code", authCtrl.XHSPhoneCodeLogin) // 小红书手机号验证码登录
api.POST("/xhs/send-verification-code", authCtrl.SendXHSVerificationCode) // 发送小红书验证码
api.POST("/logout", middleware.AuthMiddleware(), authCtrl.Logout) // 退出登录(需要认证)
// 小红书相关公开接口
// 小红书相关接口
employeeCtrlPublic := controller.NewEmployeeController()
api.POST("/xhs/send-code", employeeCtrlPublic.SendXHSCode) // 发送小红书验证码
api.GET("/products", employeeCtrlPublic.GetProducts) // 获取产品列表(公开)
api.POST("/xhs/send-code", employeeCtrlPublic.SendXHSCode) // 发送小红书验证码
api.GET("/products", middleware.AuthMiddleware(), employeeCtrlPublic.GetProducts) // 获取产品列表
// 员工路由(需要认证)
employee := api.Group("/employee")
@@ -39,10 +47,17 @@ func SetupRouter(r *gin.Engine) {
// 10.1 获取员工个人信息
employee.GET("/profile", employeeCtrl.GetProfile)
// 10.1.1 更新个人信息
employee.PUT("/profile", employeeCtrl.UpdateProfile)
// 10.1.2 上传头像
employee.POST("/upload-avatar", employeeCtrl.UploadAvatar)
// 10.2 绑定小红书账号
employee.POST("/bind-xhs", employeeCtrl.BindXHS)
// 10.2.1 获取绑定状态
employee.GET("/bind-xhs-status", employeeCtrl.GetBindXHSStatus)
// 10.3 解绑小红书账号
employee.POST("/unbind-xhs", employeeCtrl.UnbindXHS)
@@ -69,6 +84,36 @@ func SetupRouter(r *gin.Engine) {
// 10.10 更新文案状态(通过/拒绝)
employee.POST("/article/:id/status", employeeCtrl.UpdateArticleStatus)
// 10.10.1 更新文案内容(标题、正文)
employee.PUT("/article/:id", employeeCtrl.UpdateArticleContent)
// 10.10.2 添加文案图片
employee.POST("/article/:id/image", employeeCtrl.AddArticleImage)
// 10.10.3 删除文案图片
employee.DELETE("/article/image/:imageId", employeeCtrl.DeleteArticleImage)
// 10.10.4 更新文案图片排序
employee.PUT("/article/:id/images/order", employeeCtrl.UpdateArticleImagesOrder)
// 10.10.5 上传图片
employee.POST("/upload/image", employeeCtrl.UploadImage)
// 10.11 编辑发布记录
employee.PUT("/publish-record/:id", employeeCtrl.UpdatePublishRecord)
// 10.12 重新发布种草内容
employee.POST("/publish-record/:id/republish", employeeCtrl.RepublishRecord)
// 10.13 禁用用户撤销Token
employee.POST("/revoke-token", employeeCtrl.RevokeUserToken)
// 反馈相关接口
feedbackCtrl := controller.NewFeedbackController(service.NewFeedbackService())
employee.POST("/feedback", feedbackCtrl.CreateFeedback) // 创建反馈
employee.GET("/feedback", feedbackCtrl.GetFeedbackList) // 获取反馈列表
employee.GET("/feedback/:id", feedbackCtrl.GetFeedbackDetail) // 获取反馈详情
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"ai_xhs/models"
"ai_xhs/utils"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -188,9 +189,9 @@ func (s *AuthService) WechatLogin(code string, phone string, phoneCode string) (
return fmt.Errorf("保存微信绑定信息失败: %v", err)
}
// 2. 检查是否已存在作者记录(通过手机号和企业ID
// 2. 检查是否已存在作者记录(通过 created_user_id 和企业ID
var existingAuthor models.Author
result := tx.Where("phone = ? AND enterprise_id = ?", employee.Phone, employee.EnterpriseID).First(&existingAuthor)
result := tx.Where("created_user_id = ? AND enterprise_id = ?", employee.ID, employee.EnterpriseID).First(&existingAuthor)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
// 作者记录不存在,创建新记录
@@ -201,7 +202,7 @@ func (s *AuthService) WechatLogin(code string, phone string, phoneCode string) (
AuthorName: employee.RealName,
Department: employee.Department,
Status: "active",
Channel: 3, // 3=weixin (微信小程序
Channel: 1, // 1=小红书(默认渠道
}
// 如果真实姓名为空,使用用户名
@@ -213,7 +214,7 @@ func (s *AuthService) WechatLogin(code string, phone string, phoneCode string) (
return fmt.Errorf("创建作者记录失败: %v", err)
}
log.Printf("[微信登录] 创建作者记录成功: ID=%d, Name=%s", author.ID, author.AuthorName)
log.Printf("[微信登录] 创建作者记录成功: ID=%d, Name=%s, Channel=1(小红书)", author.ID, author.AuthorName)
} else if result.Error != nil {
// 其他数据库错误
return fmt.Errorf("检查作者记录失败: %v", result.Error)
@@ -237,6 +238,15 @@ func (s *AuthService) WechatLogin(code string, phone string, phoneCode string) (
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 5. 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[微信登录] 存储token到Redis失败: %v", err)
// 不阻断登录流程,但记录错误
} else {
log.Printf("[微信登录] 用户%d token已存入Redis", employee.ID)
}
return token, &employee, nil
}
@@ -256,6 +266,12 @@ func (s *AuthService) PhoneLogin(phone string) (string, *models.User, error) {
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[手机号登录] 存储token到Redis失败: %v", err)
}
return token, &employee, nil
}
@@ -274,5 +290,154 @@ func (s *AuthService) loginByEmployeeID(employeeID int) (string, *models.User, e
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[ID登录] 存储token到Redis失败: %v", err)
}
return token, &employee, nil
}
// PhonePasswordLogin 手机号密码登录
func (s *AuthService) PhonePasswordLogin(phone string, password string) (string, *models.User, error) {
if phone == "" || password == "" {
return "", nil, errors.New("手机号和密码不能为空")
}
var employee models.User
// 查找员工
result := database.DB.Where("phone = ? AND status = ?", phone, "active").First(&employee)
if result.Error != nil {
return "", nil, errors.New("手机号或密码错误")
}
// 验证密码
if !utils.VerifyPassword(password, employee.Password) {
return "", nil, errors.New("手机号或密码错误")
}
// 生成token
token, err := utils.GenerateToken(employee.ID)
if err != nil {
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[密码登录] 存储token到Redis失败: %v", err)
}
return token, &employee, nil
}
// CheckPhoneExists 检查手机号是否存在于user表中
func (s *AuthService) CheckPhoneExists(phone string) error {
var count int64
result := database.DB.Model(&models.User{}).Where("phone = ? AND status = ?", phone, "active").Count(&count)
if result.Error != nil {
return fmt.Errorf("查询用户信息失败: %v", result.Error)
}
if count == 0 {
return errors.New("手机号未注册,请联系管理员添加")
}
return nil
}
// XHSPhoneCodeLogin 小红书手机号验证码登录
func (s *AuthService) XHSPhoneCodeLogin(phone string, code string) (string, *models.User, error) {
if phone == "" || code == "" {
return "", nil, errors.New("手机号和验证码不能为空")
}
// 调用短信服务验证验证码
smsService := GetSmsService()
if err := smsService.VerifyCode(phone, code); err != nil {
return "", nil, err
}
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)
}
// 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[验证码登录] 存储token到Redis失败: %v", err)
}
return token, &employee, nil
}
// createNewUserFromPhone 从手机号创建新用户
func (s *AuthService) createNewUserFromPhone(phone string) (string, *models.User, error) {
// 使用事务创建用户和作者记录
var employee models.User
err := database.DB.Transaction(func(tx *gorm.DB) error {
// 1. 创建用户记录
employee = models.User{
Phone: phone,
Username: phone, // 默认用户名为手机号
Role: "user",
Status: "active",
EnterpriseID: 1, // 默认企业ID可根据实际调整
EnterpriseName: "默认企业", // 默认企业名称
}
if err := tx.Create(&employee).Error; err != nil {
return fmt.Errorf("创建用户失败: %v", err)
}
// 2. 创建作者记录
author := models.Author{
EnterpriseID: employee.EnterpriseID,
CreatedUserID: employee.ID,
Phone: employee.Phone,
AuthorName: employee.Username,
Department: "",
Status: "active",
Channel: 1, // 1=小红书
}
if err := tx.Create(&author).Error; err != nil {
return fmt.Errorf("创建作者记录失败: %v", err)
}
log.Printf("[手机号登录] 创建新用户成功: Phone=%s, UserID=%d, AuthorID=%d", phone, employee.ID, author.ID)
return nil
})
if err != nil {
return "", nil, err
}
// 生成token
token, err := utils.GenerateToken(employee.ID)
if err != nil {
return "", nil, fmt.Errorf("生成token失败: %v", err)
}
// 将token存入Redis
ctx := context.Background()
if err := utils.StoreTokenInRedis(ctx, employee.ID, token); err != nil {
log.Printf("[新用户登录] 存储token到Redis失败: %v", err)
}
return token, &employee, nil
}

View File

@@ -0,0 +1,169 @@
package service
import (
"ai_xhs/database"
"ai_xhs/utils"
"context"
"fmt"
"log"
"time"
)
// CacheService 缓存管理服务 - 统一管理缓存键和清除策略
type CacheService struct{}
func NewCacheService() *CacheService {
return &CacheService{}
}
// 缓存键前缀常量
const (
CacheKeyPrefixUser = "user:profile:"
CacheKeyPrefixAuthor = "author:user:"
CacheKeyPrefixXHSStatus = "xhs:status:"
CacheKeyPrefixProducts = "products:enterprise:"
CacheKeyPrefixRateLimit = "rate:sms:"
CacheKeyPrefixLock = "lock:"
)
// GetUserCacheKey 获取用户缓存键
func (s *CacheService) GetUserCacheKey(userID int) string {
return fmt.Sprintf("%s%d", CacheKeyPrefixUser, userID)
}
// GetAuthorCacheKey 获取作者缓存键
func (s *CacheService) GetAuthorCacheKey(userID int) string {
return fmt.Sprintf("%s%d", CacheKeyPrefixAuthor, userID)
}
// GetXHSStatusCacheKey 获取小红书状态缓存键
func (s *CacheService) GetXHSStatusCacheKey(userID int) string {
return fmt.Sprintf("%s%d", CacheKeyPrefixXHSStatus, userID)
}
// GetProductsCacheKey 获取产品列表缓存键
func (s *CacheService) GetProductsCacheKey(enterpriseID, page, pageSize int) string {
return fmt.Sprintf("%spage:%d:size:%d", CacheKeyPrefixProducts+fmt.Sprintf("%d:", enterpriseID), page, pageSize)
}
// GetRateLimitKey 获取限流键
func (s *CacheService) GetRateLimitKey(phone string) string {
return fmt.Sprintf("%s%s", CacheKeyPrefixRateLimit, phone)
}
// GetLockKey 获取分布式锁键
func (s *CacheService) GetLockKey(resource string) string {
return fmt.Sprintf("%s%s", CacheKeyPrefixLock, resource)
}
// ClearUserRelatedCache 清除用户相关的所有缓存
func (s *CacheService) ClearUserRelatedCache(ctx context.Context, userID int) error {
keys := []string{
s.GetUserCacheKey(userID),
s.GetAuthorCacheKey(userID),
s.GetXHSStatusCacheKey(userID),
}
if err := utils.DelCache(ctx, keys...); err != nil {
log.Printf("清除用户缓存失败 (userID=%d): %v", userID, err)
return err
}
log.Printf("已清除用户相关缓存: userID=%d", userID)
return nil
}
// ClearProductsCache 清除企业的产品列表缓存
func (s *CacheService) ClearProductsCache(ctx context.Context, enterpriseID int) error {
// 使用模糊匹配删除所有分页缓存
pattern := fmt.Sprintf("%s%d:*", CacheKeyPrefixProducts, enterpriseID)
// 注意: 这需要扫描所有键,生产环境建议记录所有已创建的缓存键
// 这里简化处理,实际应该维护一个产品缓存键集合
log.Printf("需要清除产品缓存: enterpriseID=%d, pattern=%s", enterpriseID, pattern)
log.Printf("建议: 在产品更新时调用此方法")
// 简化版: 只清除前几页的缓存
for page := 1; page <= 10; page++ {
for _, pageSize := range []int{10, 20, 50} {
key := s.GetProductsCacheKey(enterpriseID, page, pageSize)
utils.DelCache(ctx, key)
}
}
return nil
}
// AcquireLock 获取分布式锁
func (s *CacheService) AcquireLock(ctx context.Context, resource string, ttl time.Duration) (bool, error) {
lockKey := s.GetLockKey(resource)
return utils.SetCacheNX(ctx, lockKey, "locked", ttl)
}
// ReleaseLock 释放分布式锁
func (s *CacheService) ReleaseLock(ctx context.Context, resource string) error {
lockKey := s.GetLockKey(resource)
return utils.DelCache(ctx, lockKey)
}
// WithLock 使用分布式锁执行函数
func (s *CacheService) WithLock(ctx context.Context, resource string, ttl time.Duration, fn func() error) error {
// 尝试获取锁
log.Printf("[分布式锁] 尝试获取锁: %s (TTL: %v)", resource, ttl)
acquired, err := s.AcquireLock(ctx, resource, ttl)
if err != nil {
log.Printf("[分布式锁] 获取锁失败: %s, 错误: %v", resource, err)
return fmt.Errorf("获取锁失败: %w", err)
}
if !acquired {
log.Printf("[分布式锁] 锁已被占用: %s", resource)
// 检查锁的剩余时间
lockKey := s.GetLockKey(resource)
ttl, _ := database.RDB.TTL(ctx, lockKey).Result()
return fmt.Errorf("资源被锁定,请稍后重试(剩余时间: %v", ttl)
}
log.Printf("[分布式锁] 成功获取锁: %s", resource)
// 确保释放锁
defer func() {
if err := s.ReleaseLock(ctx, resource); err != nil {
log.Printf("[分布式锁] 释放锁失败 (resource=%s): %v", resource, err)
} else {
log.Printf("[分布式锁] 成功释放锁: %s", resource)
}
}()
// 执行函数
log.Printf("[分布式锁] 开始执行受保护的函数: %s", resource)
return fn()
}
// SetCacheWithNullProtection 设置缓存(带空值保护,防止缓存穿透)
func (s *CacheService) SetCacheWithNullProtection(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
if value == nil {
// 缓存空值,但使用较短的过期时间(1分钟)
return utils.SetCache(ctx, key, "NULL", 1*time.Minute)
}
return utils.SetCache(ctx, key, value, ttl)
}
// GetCacheWithNullCheck 获取缓存(检查空值标记)
func (s *CacheService) GetCacheWithNullCheck(ctx context.Context, key string, dest interface{}) (bool, error) {
var tempValue interface{}
err := utils.GetCache(ctx, key, &tempValue)
if err != nil {
// 缓存不存在
return false, err
}
// 检查是否是空值标记
if strValue, ok := tempValue.(string); ok && strValue == "NULL" {
return true, fmt.Errorf("cached null value")
}
// 正常获取缓存
return true, utils.GetCache(ctx, key, dest)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
package service
import (
"ai_xhs/database"
"ai_xhs/models"
)
// FeedbackService 反馈服务
type FeedbackService struct{}
// NewFeedbackService 创建反馈服务
func NewFeedbackService() *FeedbackService {
return &FeedbackService{}
}
// CreateFeedback 创建反馈
func (fs *FeedbackService) CreateFeedback(feedback *models.Feedback) error {
return database.DB.Create(feedback).Error
}
// GetFeedbackList 获取反馈列表
func (fs *FeedbackService) GetFeedbackList(userID, page, pageSize int, feedbackType, status string) ([]models.Feedback, int64, error) {
var feedbacks []models.Feedback
var total int64
query := database.DB.Model(&models.Feedback{}).Where("created_user_id = ?", userID)
// 筛选条件
if feedbackType != "" {
query = query.Where("feedback_type = ?", feedbackType)
}
if status != "" {
query = query.Where("status = ?", status)
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&feedbacks).Error; err != nil {
return nil, 0, err
}
return feedbacks, total, nil
}
// GetFeedbackByID 根据ID获取反馈
func (fs *FeedbackService) GetFeedbackByID(id int) (*models.Feedback, error) {
var feedback models.Feedback
if err := database.DB.First(&feedback, id).Error; err != nil {
return nil, err
}
return &feedback, nil
}
// UpdateFeedbackStatus 更新反馈状态(管理员使用)
func (fs *FeedbackService) UpdateFeedbackStatus(id int, status string) error {
return database.DB.Model(&models.Feedback{}).Where("id = ?", id).Update("status", status).Error
}

View File

@@ -13,8 +13,6 @@ import (
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
@@ -332,7 +330,7 @@ func (s *SchedulerService) AutoPublishArticles() {
len(articles), successCount, failCount, duration)
}
// publishArticle 发布单篇文案
// publishArticle 发布单篇文案使用FastAPI服务
func (s *SchedulerService) publishArticle(article models.Article) error {
// 1. 获取用户信息(发布用户)
var user models.User
@@ -347,9 +345,22 @@ func (s *SchedulerService) publishArticle(article models.Article) error {
}
}
// 2. 检查用户是否绑定了小红书
if user.IsBoundXHS != 1 || user.XHSCookie == "" {
return errors.New("用户未绑定小红书账号或Cookie已失效")
// 2. 检查用户是否绑定了小红书并获取author记录
if user.IsBoundXHS != 1 {
return errors.New("用户未绑定小红书账号")
}
// 查询对应的 author 记录获取Cookie
var author models.Author
if err := database.DB.Where(
"phone = ? AND enterprise_id = ? AND channel = 1 AND status = 'active'",
user.Phone, user.EnterpriseID,
).First(&author).Error; err != nil {
return fmt.Errorf("未找到有效的小红书作者记录: %w", err)
}
if author.XHSCookie == "" {
return errors.New("小红书Cookie已失效")
}
// 3. 获取文章图片
@@ -378,142 +389,130 @@ func (s *SchedulerService) publishArticle(article models.Article) error {
}
}
// 6. 解析Cookie数据库中存储的是JSON字符串
var cookies interface{}
if err := json.Unmarshal([]byte(user.XHSCookie), &cookies); err != nil {
return fmt.Errorf("解析Cookie失败: %wCookie内容: %s", err, user.XHSCookie)
}
// 6. 准备发布数据优先使用storage_state文件其次使用login_state
var cookiesData interface{}
var loginStateData map[string]interface{}
var useStorageStateMode bool
// 7. 构造发布配置
publishConfig := map[string]interface{}{
"cookies": cookies, // 解析后的Cookie对象或数组
"title": article.Title,
"content": article.Content,
"images": imageURLs,
"tags": tags,
}
// 检查storage_state文件是否存在根据手机号查找
storageStateFile := fmt.Sprintf("../backend/storage_states/xhs_%s.json", author.XHSPhone)
if _, err := os.Stat(storageStateFile); err == nil {
log.Printf("[调度器] 检测到storage_state文件: %s", storageStateFile)
useStorageStateMode = true
} else {
log.Printf("[调度器] storage_state文件不存在使用login_state或cookies模式")
useStorageStateMode = false
// 决定本次发布使用的代理
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
// 尝试解析为JSON对象
if err := json.Unmarshal([]byte(author.XHSCookie), &loginStateData); err == nil {
// 检查是否是login_state格式包含cookies字段
if _, ok := loginStateData["cookies"]; ok {
log.Printf("[调度器] 检测到login_state格式将使用完整登录状态")
cookiesData = loginStateData // 使用完整的login_state
} else {
// 可能是cookies数组
log.Printf("[调度器] 检测到纯cookies格式")
cookiesData = loginStateData
}
} else {
return fmt.Errorf("解析Cookie失败: %wCookie内容: %s", err, author.XHSCookie[:100])
}
}
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())
// 7. 调用FastAPI服务使用浏览器池+预热)
fastAPIURL := config.AppConfig.XHS.PythonServiceURL
if fastAPIURL == "" {
fastAPIURL = "http://localhost:8000" // 默认地址
}
publishEndpoint := fastAPIURL + "/api/xhs/publish-with-cookies"
// 构造请求体
// 优先级storage_state文件 > login_state > cookies
var fullRequest map[string]interface{}
if useStorageStateMode {
// 模式1使用storage_state文件通过手机号查找
fullRequest = map[string]interface{}{
"phone": author.XHSPhone, // 传递手机号Python后端会根据手机号查找文件
"title": article.Title,
"content": article.Content,
"images": imageURLs,
"topics": tags,
}
log.Printf("[调度器] 使用storage_state模式发布手机号: %s", author.XHSPhone)
} else if loginState, ok := cookiesData.(map[string]interface{}); ok {
if _, hasLoginStateStructure := loginState["cookies"]; hasLoginStateStructure {
// 模式2完整的login_state格式
fullRequest = map[string]interface{}{
"login_state": loginState,
"title": article.Title,
"content": article.Content,
"images": imageURLs,
"topics": tags,
}
log.Printf("[调度器] 使用login_state模式发布")
} else {
// 模式3纺cookies格式
fullRequest = map[string]interface{}{
"cookies": loginState,
"title": article.Title,
"content": article.Content,
"images": imageURLs,
"topics": tags,
}
log.Printf("[调度器] 使用cookies模式发布")
}
} else {
// 兜底:直接发送
fullRequest = map[string]interface{}{
"cookies": cookiesData,
"title": article.Title,
"content": article.Content,
"images": imageURLs,
"topics": tags,
}
return fmt.Errorf("%s, output: %s", errMsg, outputStr)
}
// 11. 检查发布是否成功
success, ok := result["success"].(bool)
if !ok || !success {
requestBody, err := json.Marshal(fullRequest)
if err != nil {
return fmt.Errorf("构造请求数据失败: %w", err)
}
// 发送HTTP请求
timeout := time.Duration(s.publishTimeout) * time.Second
if s.publishTimeout <= 0 {
timeout = 120 * time.Second // 默认120秒超时
}
client := &http.Client{Timeout: timeout}
resp, err := client.Post(publishEndpoint, "application/json", bytes.NewBuffer(requestBody))
if err != nil {
s.updateArticleStatus(article.ID, "failed", fmt.Sprintf("调用FastAPI服务失败: %v", err))
return fmt.Errorf("调用FastAPI服务失败: %w", err)
}
defer resp.Body.Close()
// 9. 解析响应
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
s.updateArticleStatus(article.ID, "failed", fmt.Sprintf("解析FastAPI响应失败: %v", err))
return fmt.Errorf("解析FastAPI响应失败: %w", err)
}
// 10. 检查发布是否成功
code, ok := result["code"].(float64)
if !ok || code != 0 {
errMsg := "未知错误"
if errStr, ok := result["error"].(string); ok {
errMsg = errStr
if msg, ok := result["message"].(string); ok {
errMsg = msg
}
s.updateArticleStatus(article.ID, "failed", errMsg)
return fmt.Errorf("发布失败: %s", errMsg)
}
// 12. 更新文章状态为published
// 11. 更新文章状态为published
s.updateArticleStatus(article.ID, "published", "发布成功")
log.Printf("[使用FastAPI] 文章 %d 发布成功,享受浏览器池+预热加速", article.ID)
return nil
}

View File

@@ -0,0 +1,264 @@
package service
import (
"ai_xhs/config"
"crypto/rand"
"errors"
"fmt"
"log"
"math/big"
"sync"
"time"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v4/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
// SmsService 短信服务
type SmsService struct {
client *dysmsapi20170525.Client
signName string
templateCode string
codeCache map[string]*VerificationCode
cacheMutex sync.RWMutex
alertPhone string // 宕机通知手机号
}
// VerificationCode 验证码缓存
type VerificationCode struct {
Code string
ExpireTime time.Time
SentAt time.Time
}
var (
smsServiceInstance *SmsService
smsServiceOnce sync.Once
)
// GetSmsService 获取短信服务单例
func GetSmsService() *SmsService {
smsServiceOnce.Do(func() {
smsServiceInstance = NewSmsService()
})
return smsServiceInstance
}
// NewSmsService 创建短信服务
func NewSmsService() *SmsService {
// 从配置读取阿里云短信配置
accessKeyId := config.AppConfig.AliSms.AccessKeyID
accessKeySecret := config.AppConfig.AliSms.AccessKeySecret
signName := config.AppConfig.AliSms.SignName
templateCode := config.AppConfig.AliSms.TemplateCode
if accessKeyId == "" || accessKeySecret == "" {
log.Printf("[短信服务] 警告: 阿里云短信配置未设置,短信功能将不可用")
return &SmsService{
signName: signName,
templateCode: templateCode,
codeCache: make(map[string]*VerificationCode),
}
}
// 创建阿里云短信客户端
apiConfig := &openapi.Config{
AccessKeyId: tea.String(accessKeyId),
AccessKeySecret: tea.String(accessKeySecret),
}
apiConfig.Endpoint = tea.String("dysmsapi.aliyuncs.com")
client, err := dysmsapi20170525.NewClient(apiConfig)
if err != nil {
log.Printf("[短信服务] 创建阿里云客户端失败: %v", err)
return &SmsService{
signName: signName,
templateCode: templateCode,
codeCache: make(map[string]*VerificationCode),
}
}
log.Printf("[短信服务] 阿里云短信服务初始化成功")
return &SmsService{
client: client,
signName: signName,
templateCode: templateCode,
codeCache: make(map[string]*VerificationCode),
}
}
// generateCode 生成随机6位数字验证码
func (s *SmsService) generateCode() string {
code := ""
for i := 0; i < 6; i++ {
n, _ := rand.Int(rand.Reader, big.NewInt(10))
code += fmt.Sprintf("%d", n.Int64())
}
return code
}
// SendVerificationCode 发送验证码
func (s *SmsService) SendVerificationCode(phone string) (string, error) {
if s.client == nil {
return "", errors.New("短信服务未配置")
}
// 生成验证码
code := s.generateCode()
log.Printf("[短信服务] 正在发送验证码到 %s验证码: %s", phone, code)
// 构建短信请求
sendSmsRequest := &dysmsapi20170525.SendSmsRequest{
PhoneNumbers: tea.String(phone),
SignName: tea.String(s.signName),
TemplateCode: tea.String(s.templateCode),
TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)),
}
runtime := &util.RuntimeOptions{}
// 发送短信
resp, err := s.client.SendSmsWithOptions(sendSmsRequest, runtime)
if err != nil {
log.Printf("[短信服务] 发送短信失败: %v", err)
return "", fmt.Errorf("发送短信失败: %v", err)
}
// 检查返回结果
if resp.Body.Code == nil || *resp.Body.Code != "OK" {
errMsg := "未知错误"
if resp.Body.Message != nil {
errMsg = *resp.Body.Message
}
log.Printf("[短信服务] 短信发送失败: %s", errMsg)
return "", fmt.Errorf("短信发送失败: %s", errMsg)
}
// 缓存验证码
s.cacheMutex.Lock()
s.codeCache[phone] = &VerificationCode{
Code: code,
ExpireTime: time.Now().Add(5 * time.Minute), // 5分钟过期
SentAt: time.Now(),
}
s.cacheMutex.Unlock()
log.Printf("[短信服务] 验证码发送成功,手机号: %s", phone)
return code, nil
}
// VerifyCode 验证验证码
func (s *SmsService) VerifyCode(phone, code string) error {
s.cacheMutex.RLock()
cached, exists := s.codeCache[phone]
s.cacheMutex.RUnlock()
if !exists {
return errors.New("验证码未发送或已过期,请重新获取")
}
// 检查是否过期
if time.Now().After(cached.ExpireTime) {
s.cacheMutex.Lock()
delete(s.codeCache, phone)
s.cacheMutex.Unlock()
return errors.New("验证码已过期,请重新获取")
}
// 验证码匹配
if code != cached.Code {
return errors.New("验证码错误,请重新输入")
}
// 验证成功后删除验证码(一次性使用)
s.cacheMutex.Lock()
delete(s.codeCache, phone)
s.cacheMutex.Unlock()
log.Printf("[短信服务] 验证码验证成功,手机号: %s", phone)
return nil
}
// CleanupExpiredCodes 清理过期的验证码(定时任务调用)
func (s *SmsService) CleanupExpiredCodes() {
s.cacheMutex.Lock()
defer s.cacheMutex.Unlock()
now := time.Now()
expiredPhones := []string{}
for phone, cached := range s.codeCache {
if now.After(cached.ExpireTime) {
expiredPhones = append(expiredPhones, phone)
}
}
for _, phone := range expiredPhones {
delete(s.codeCache, phone)
}
if len(expiredPhones) > 0 {
log.Printf("[短信服务] 已清理 %d 个过期验证码", len(expiredPhones))
}
}
// StartCleanupTask 启动清理过期验证码的定时任务
func (s *SmsService) StartCleanupTask() {
ticker := time.NewTicker(1 * time.Minute) // 每分钟清理一次
go func() {
for range ticker.C {
s.CleanupExpiredCodes()
}
}()
log.Printf("[短信服务] 验证码清理任务已启动")
}
// SendServiceDownAlert 发送服务宕机通知短信
// 向指定手机号发送验证码为11111的通知短信
func (s *SmsService) SendServiceDownAlert(phone string, serviceName string) error {
if s.client == nil {
return errors.New("短信服务未配置")
}
// 固定验证码为11111作为宕机通知标识
alertCode := "11111"
log.Printf("[短信服务] 发送服务宕机通知到 %s服务: %s", phone, serviceName)
// 构建短信请求
sendSmsRequest := &dysmsapi20170525.SendSmsRequest{
PhoneNumbers: tea.String(phone),
SignName: tea.String(s.signName),
TemplateCode: tea.String(s.templateCode),
TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, alertCode)),
}
runtime := &util.RuntimeOptions{}
// 发送短信
resp, err := s.client.SendSmsWithOptions(sendSmsRequest, runtime)
if err != nil {
log.Printf("[短信服务] 发送宕机通知失败: %v", err)
return fmt.Errorf("发送宕机通知失败: %v", err)
}
// 检查返回结果
if resp.Body.Code == nil || *resp.Body.Code != "OK" {
errMsg := "未知错误"
if resp.Body.Message != nil {
errMsg = *resp.Body.Message
}
log.Printf("[短信服务] 宕机通知发送失败: %s", errMsg)
return fmt.Errorf("宕机通知发送失败: %s", errMsg)
}
log.Printf("[短信服务] 服务宕机通知发送成功,手机号: %s通知码: %s", phone, alertCode)
return nil
}

View File

@@ -1,21 +0,0 @@
@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

View File

@@ -1,18 +0,0 @@
#!/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

View File

@@ -1,27 +0,0 @@
@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

View File

@@ -1,102 +0,0 @@
#!/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

View File

@@ -1,104 +0,0 @@
#!/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}"

View File

@@ -1,42 +0,0 @@
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 文件导入测试数据")
}

View File

@@ -0,0 +1,257 @@
package tools
import (
"ai_xhs/service"
"encoding/json"
"io/ioutil"
"log"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
)
// ServiceMonitor 服务监控器
type ServiceMonitor struct {
alertPhone string
serviceName string
smsService *service.SmsService
isRunning bool
mutex sync.Mutex
shutdownChan chan os.Signal
alertSent bool // 标记是否已发送通知,避免重复发送
heartbeatFile string // 心跳文件路径
lastHeartbeat time.Time // 最后心跳时间
}
// HeartbeatData 心跳数据
type HeartbeatData struct {
ServiceName string `json:"service_name"`
LastHeartbeat time.Time `json:"last_heartbeat"`
PID int `json:"pid"`
StartTime time.Time `json:"start_time"`
GracefulShut bool `json:"graceful_shutdown"` // 是否为正常关闭
}
var (
monitorInstance *ServiceMonitor
monitorOnce sync.Once
)
// GetServiceMonitor 获取服务监控器单例
func GetServiceMonitor(alertPhone string, serviceName string) *ServiceMonitor {
monitorOnce.Do(func() {
heartbeatFile := filepath.Join(os.TempDir(), "ai_xhs_service_heartbeat.json")
monitorInstance = &ServiceMonitor{
alertPhone: alertPhone,
serviceName: serviceName,
smsService: service.GetSmsService(),
isRunning: true,
shutdownChan: make(chan os.Signal, 1),
alertSent: false,
heartbeatFile: heartbeatFile,
lastHeartbeat: time.Now(),
}
})
return monitorInstance
}
// StartMonitoring 启动服务监控
// 监听系统信号,在服务异常退出时发送短信通知
func (m *ServiceMonitor) StartMonitoring() {
// 检查上次启动是否异常关闭
m.checkLastShutdown()
// 启动心跳任务
m.startHeartbeat()
// 监听退出信号
signal.Notify(m.shutdownChan,
os.Interrupt, // Ctrl+C
syscall.SIGTERM, // kill命令
syscall.SIGQUIT, // Ctrl+\
syscall.SIGABRT, // abort
)
go func() {
sig := <-m.shutdownChan
log.Printf("[服务监控] 捕获到退出信号: %v", sig)
m.mutex.Lock()
m.isRunning = false
m.mutex.Unlock()
// 标记为正常关闭
m.markGracefulShutdown()
// 发送宕机通知
if !m.alertSent {
m.sendAlert("服务接收到退出信号")
}
// 给短信发送一些时间
time.Sleep(2 * time.Second)
// 退出程序
os.Exit(0)
}()
log.Printf("[服务监控] 服务监控已启动,监控电话: %s", m.alertPhone)
log.Printf("[服务监控] 心跳文件: %s", m.heartbeatFile)
}
// SetAlertSent 设置通知已发送标记(供外部调用,避免重复发送)
func (m *ServiceMonitor) SetAlertSent() {
m.mutex.Lock()
m.alertSent = true
m.mutex.Unlock()
}
// SendManualAlert 手动发送服务宕机通知
func (m *ServiceMonitor) SendManualAlert(reason string) error {
return m.sendAlert(reason)
}
// sendAlert 发送宕机通知
func (m *ServiceMonitor) sendAlert(reason string) error {
if m.alertSent {
log.Printf("[服务监控] 宕机通知已发送,跳过重复发送")
return nil
}
log.Printf("[服务监控] 服务宕机,原因: %s", reason)
err := m.smsService.SendServiceDownAlert(m.alertPhone, m.serviceName)
if err != nil {
log.Printf("[服务监控] 发送宕机通知失败: %v", err)
return err
}
m.alertSent = true
log.Printf("[服务监控] 宕机通知已发送到 %s", m.alertPhone)
return nil
}
// IsRunning 检查服务是否运行中
func (m *ServiceMonitor) IsRunning() bool {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.isRunning
}
// Shutdown 优雅关闭
func (m *ServiceMonitor) Shutdown() {
if m.shutdownChan != nil {
m.shutdownChan <- syscall.SIGTERM
}
}
// startHeartbeat 启动心跳任务每30秒更新一次
func (m *ServiceMonitor) startHeartbeat() {
// 立即写入一次
m.updateHeartbeat()
// 启动定时任务
ticker := time.NewTicker(30 * time.Second)
go func() {
for range ticker.C {
if !m.IsRunning() {
break
}
m.updateHeartbeat()
}
}()
log.Printf("[服务监控] 心跳任务已启动每30秒更新一次")
}
// updateHeartbeat 更新心跳文件
func (m *ServiceMonitor) updateHeartbeat() {
m.mutex.Lock()
m.lastHeartbeat = time.Now()
m.mutex.Unlock()
data := HeartbeatData{
ServiceName: m.serviceName,
LastHeartbeat: m.lastHeartbeat,
PID: os.Getpid(),
StartTime: time.Now(), // 在实际应用中应该记录启动时间
GracefulShut: false, // 默认未正常关闭
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
log.Printf("[服务监控] 序列化心跳数据失败: %v", err)
return
}
if err := ioutil.WriteFile(m.heartbeatFile, jsonData, 0644); err != nil {
log.Printf("[服务监控] 写入心跳文件失败: %v", err)
}
}
// markGracefulShutdown 标记为正常关闭
func (m *ServiceMonitor) markGracefulShutdown() {
data := HeartbeatData{
ServiceName: m.serviceName,
LastHeartbeat: time.Now(),
PID: os.Getpid(),
StartTime: m.lastHeartbeat,
GracefulShut: true, // 标记为正常关闭
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
log.Printf("[服务监控] 序列化关闭数据失败: %v", err)
return
}
if err := ioutil.WriteFile(m.heartbeatFile, jsonData, 0644); err != nil {
log.Printf("[服务监控] 写入关闭标记失败: %v", err)
}
log.Printf("[服务监控] 已标记为正常关闭")
}
// checkLastShutdown 检查上次关闭是否异常
func (m *ServiceMonitor) checkLastShutdown() {
// 读取心跳文件
if _, err := os.Stat(m.heartbeatFile); os.IsNotExist(err) {
log.Printf("[服务监控] 未找到历史心跳文件,可能是首次启动")
return
}
fileData, err := ioutil.ReadFile(m.heartbeatFile)
if err != nil {
log.Printf("[服务监控] 读取心跳文件失败: %v", err)
return
}
var lastData HeartbeatData
if err := json.Unmarshal(fileData, &lastData); err != nil {
log.Printf("[服务监控] 解析心跳数据失败: %v", err)
return
}
log.Printf("[服务监控] 上次心跳: %v, PID: %d, 正常关闭: %v",
lastData.LastHeartbeat.Format("2006-01-02 15:04:05"),
lastData.PID,
lastData.GracefulShut)
// 如果上次不是正常关闭,发送通知
if !lastData.GracefulShut {
timeSinceLastHeartbeat := time.Since(lastData.LastHeartbeat)
// 如果距离上次心跳超过2分钟认为是异常关闭
if timeSinceLastHeartbeat > 2*time.Minute {
log.Printf("[服务监控] 检测到上次服务异常关闭(%v前发送通知", timeSinceLastHeartbeat)
err := m.smsService.SendServiceDownAlert(m.alertPhone, m.serviceName)
if err != nil {
log.Printf("[服务监控] 发送异常关闭通知失败: %v", err)
} else {
log.Printf("[服务监控] 已发送异常关闭通知")
}
} else {
log.Printf("[服务监控] 距离上次心跳仅%v可能是快速重启不发送通知", timeSinceLastHeartbeat)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

125
go_backend/utils/cache.go Normal file
View File

@@ -0,0 +1,125 @@
package utils
import (
"ai_xhs/database"
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
// SetCache 设置缓存
func SetCache(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return database.RDB.Set(ctx, key, data, expiration).Err()
}
// GetCache 获取缓存
func GetCache(ctx context.Context, key string, dest interface{}) error {
data, err := database.RDB.Get(ctx, key).Bytes()
if err != nil {
return err
}
return json.Unmarshal(data, dest)
}
// DelCache 删除缓存
func DelCache(ctx context.Context, keys ...string) error {
return database.RDB.Del(ctx, keys...).Err()
}
// ExistsCache 检查缓存是否存在
func ExistsCache(ctx context.Context, key string) (bool, error) {
count, err := database.RDB.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return count > 0, nil
}
// ExpireCache 设置缓存过期时间
func ExpireCache(ctx context.Context, key string, expiration time.Duration) error {
return database.RDB.Expire(ctx, key, expiration).Err()
}
// GetTTL 获取缓存剩余生存时间
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
return database.RDB.TTL(ctx, key).Result()
}
// IncrCache 递增计数器
func IncrCache(ctx context.Context, key string) (int64, error) {
return database.RDB.Incr(ctx, key).Result()
}
// DecrCache 递减计数器
func DecrCache(ctx context.Context, key string) (int64, error) {
return database.RDB.Decr(ctx, key).Result()
}
// SetCacheNX 设置缓存(仅当key不存在时)
func SetCacheNX(ctx context.Context, key string, value interface{}, expiration time.Duration) (bool, error) {
data, err := json.Marshal(value)
if err != nil {
return false, err
}
return database.RDB.SetNX(ctx, key, data, expiration).Result()
}
// HSetCache 设置哈希字段
func HSetCache(ctx context.Context, key, field string, value interface{}) error {
return database.RDB.HSet(ctx, key, field, value).Err()
}
// HGetCache 获取哈希字段
func HGetCache(ctx context.Context, key, field string) (string, error) {
return database.RDB.HGet(ctx, key, field).Result()
}
// HGetAllCache 获取哈希所有字段
func HGetAllCache(ctx context.Context, key string) (map[string]string, error) {
return database.RDB.HGetAll(ctx, key).Result()
}
// HDelCache 删除哈希字段
func HDelCache(ctx context.Context, key string, fields ...string) error {
return database.RDB.HDel(ctx, key, fields...).Err()
}
// SAddCache 添加集合成员
func SAddCache(ctx context.Context, key string, members ...interface{}) error {
return database.RDB.SAdd(ctx, key, members...).Err()
}
// SMembersCache 获取集合所有成员
func SMembersCache(ctx context.Context, key string) ([]string, error) {
return database.RDB.SMembers(ctx, key).Result()
}
// SRemCache 删除集合成员
func SRemCache(ctx context.Context, key string, members ...interface{}) error {
return database.RDB.SRem(ctx, key, members...).Err()
}
// ZAddCache 添加有序集合成员
func ZAddCache(ctx context.Context, key string, score float64, member interface{}) error {
z := redis.Z{
Score: score,
Member: member,
}
return database.RDB.ZAdd(ctx, key, z).Err()
}
// ZRangeCache 获取有序集合指定范围成员
func ZRangeCache(ctx context.Context, key string, start, stop int64) ([]string, error) {
return database.RDB.ZRange(ctx, key, start, stop).Result()
}
// ZRemCache 删除有序集合成员
func ZRemCache(ctx context.Context, key string, members ...interface{}) error {
return database.RDB.ZRem(ctx, key, members...).Err()
}

View File

@@ -2,7 +2,9 @@ package utils
import (
"ai_xhs/config"
"context"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -44,3 +46,45 @@ func ParseToken(tokenString string) (*Claims, error) {
return nil, errors.New("invalid token")
}
// StoreTokenInRedis 将Token存入Redis
func StoreTokenInRedis(ctx context.Context, employeeID int, tokenString string) error {
// Redis key: token:employee:{employeeID}
key := fmt.Sprintf("token:employee:%d", employeeID)
// 存储token过期时间与JWT一致
expiration := time.Duration(config.AppConfig.JWT.ExpireHours) * time.Hour
return SetCache(ctx, key, tokenString, expiration)
}
// ValidateTokenInRedis 验证Token是否在Redis中存在校验是否被禁用
func ValidateTokenInRedis(ctx context.Context, employeeID int, tokenString string) error {
key := fmt.Sprintf("token:employee:%d", employeeID)
// 从Redis获取存储的token
var storedToken string
err := GetCache(ctx, key, &storedToken)
if err != nil {
return errors.New("token已失效或用户已被禁用")
}
// 比对token是否一致
if storedToken != tokenString {
return errors.New("token不匹配用户可能已重新登录")
}
return nil
}
// RevokeToken 撤销Token禁用用户
func RevokeToken(ctx context.Context, employeeID int) error {
key := fmt.Sprintf("token:employee:%d", employeeID)
return DelCache(ctx, key)
}
// RevokeAllUserTokens 撤销用户的所有Token如果有多设备登录
func RevokeAllUserTokens(ctx context.Context, employeeID int) error {
// 当前实现一个用户只保存一个token
// 如果需要支持多设备,可以改为 token:employee:{employeeID}:{deviceID}
return RevokeToken(ctx, employeeID)
}

351
go_backend/utils/oss.go Normal file
View File

@@ -0,0 +1,351 @@
package utils
import (
"ai_xhs/config"
"fmt"
"io"
"mime"
"mime/multipart"
"path/filepath"
"strings"
"time"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/google/uuid"
)
// OSSStorage 阿里云OSS存储服务
type OSSStorage struct {
client *oss.Client
bucket *oss.Bucket
config *config.OSSConfig
}
var ossStorage *OSSStorage
// InitOSS 初始化OSS客户端
func InitOSS() error {
cfg := &config.AppConfig.Upload.OSS
// 打印详细配置信息用于调试
fmt.Printf("\n=== OSS初始化配置 ===\n")
fmt.Printf("Endpoint: [%s]\n", cfg.Endpoint)
fmt.Printf("AccessKeyID: [%s]\n", cfg.AccessKeyID)
fmt.Printf("AccessKeySecret: [%s] (长度: %d)\n", cfg.AccessKeySecret, len(cfg.AccessKeySecret))
fmt.Printf("BucketName: [%s]\n", cfg.BucketName)
fmt.Printf("BasePath: [%s]\n", cfg.BasePath)
fmt.Printf("Domain: [%s]\n", cfg.Domain)
fmt.Printf("==================\n\n")
// 创建OSSClient实例
client, err := oss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
if err != nil {
return fmt.Errorf("创建OSS客户端失败: %w", err)
}
// 获取存储空间
bucket, err := client.Bucket(cfg.BucketName)
if err != nil {
return fmt.Errorf("获取OSS Bucket失败: %w", err)
}
ossStorage = &OSSStorage{
client: client,
bucket: bucket,
config: cfg,
}
return nil
}
// min 辅助函数
func min(a, b int) int {
if a < b {
return a
}
return b
}
// UploadFile 上传文件到OSS
func (s *OSSStorage) UploadFile(file multipart.File, filename string, objectPath string) (string, error) {
// 生成OSS对象路径
objectKey := s.generateObjectKey(objectPath, filename)
// 获取文件MIME类型
contentType := getContentType(filename)
// 上传文件到OSS设置Content-Type和其他元数据
// 使用 ObjectACL 设置为公共读,确保文件可以直接访问
err := s.bucket.PutObject(objectKey, file,
oss.ContentType(contentType),
oss.ObjectACL(oss.ACLPublicRead),
oss.ContentDisposition("inline"), // 关键设置为inline而不是attachment
)
if err != nil {
return "", fmt.Errorf("上传文件到OSS失败: %w", err)
}
// 获取OSS返回的文件URL
fileURL, err := s.GetFileURL(objectKey)
if err != nil {
return "", fmt.Errorf("获取OSS文件URL失败: %w", err)
}
return fileURL, nil
}
// UploadFromBytes 从字节数组上传文件到OSS
func (s *OSSStorage) UploadFromBytes(data []byte, filename string, objectPath string) (string, error) {
// 生成OSS对象路径
objectKey := s.generateObjectKey(objectPath, filename)
// 获取文件MIME类型
contentType := getContentType(filename)
// 上传文件到OSS设置Content-Type和其他元数据
err := s.bucket.PutObject(objectKey, strings.NewReader(string(data)),
oss.ContentType(contentType),
oss.ObjectACL(oss.ACLPublicRead),
oss.ContentDisposition("inline"),
)
if err != nil {
return "", fmt.Errorf("上传文件到OSS失败: %w", err)
}
// 获取OSS返回的文件URL
fileURL, err := s.GetFileURL(objectKey)
if err != nil {
return "", fmt.Errorf("获取OSS文件URL失败: %w", err)
}
return fileURL, nil
}
// UploadFromReader 从Reader上传文件到OSS
func (s *OSSStorage) UploadFromReader(reader io.Reader, filename string, objectPath string) (string, error) {
// 生成OSS对象路径
objectKey := s.generateObjectKey(objectPath, filename)
// 获取文件MIME类型
contentType := getContentType(filename)
// 打印详细上传信息
fmt.Printf("\n=== OSS上传请求 ===\n")
fmt.Printf("ObjectKey: [%s]\n", objectKey)
fmt.Printf("ContentType: [%s]\n", contentType)
fmt.Printf("Endpoint: [%s]\n", s.config.Endpoint)
fmt.Printf("BucketName: [%s]\n", s.config.BucketName)
fmt.Printf("AccessKeyID: [%s]\n", s.config.AccessKeyID)
fmt.Printf("AccessKeySecret: [%s]\n", s.config.AccessKeySecret)
fmt.Printf("==================\n\n")
// 上传文件到OSS设置Content-Type和其他元数据
err := s.bucket.PutObject(objectKey, reader,
oss.ContentType(contentType),
oss.ObjectACL(oss.ACLPublicRead),
oss.ContentDisposition("inline"),
)
if err != nil {
fmt.Printf("\n!!! OSS上传失败 !!!\n")
fmt.Printf("错误详情: %v\n", err)
fmt.Printf("错误类型: %T\n", err)
fmt.Printf("==================\n\n")
return "", fmt.Errorf("上传文件到OSS失败: %w", err)
}
// 获取OSS返回的文件URL
fileURL, err := s.GetFileURL(objectKey)
if err != nil {
return "", fmt.Errorf("获取OSS文件URL失败: %w", err)
}
fmt.Printf("✓ OSS上传成功: %s\n\n", fileURL)
return fileURL, nil
}
// DeleteFile 删除OSS中的文件
func (s *OSSStorage) DeleteFile(objectKey string) error {
err := s.bucket.DeleteObject(objectKey)
if err != nil {
return fmt.Errorf("删除OSS文件失败: %w", err)
}
return nil
}
// IsObjectExist 检查对象是否存在
func (s *OSSStorage) IsObjectExist(objectKey string) (bool, error) {
exist, err := s.bucket.IsObjectExist(objectKey)
if err != nil {
return false, fmt.Errorf("检查OSS对象是否存在失败: %w", err)
}
return exist, nil
}
// GetFileURL 获取文件访问URL使用OSS SDK标准方法
func (s *OSSStorage) GetFileURL(objectKey string) (string, error) {
// 如果配置了自定义域名,使用自定义域名
if s.config.Domain != "" {
// 确保 Domain 以 https:// 开头
domain := s.config.Domain
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") {
domain = "https://" + domain
}
url := fmt.Sprintf("%s/%s", strings.TrimRight(domain, "/"), objectKey)
return url, nil
}
// 使用OSS SDK获取对象的公共URL
// 正确格式https://bucket-name.endpoint/objectKey
// Endpoint 不应该包含 https://
endpoint := s.config.Endpoint
// 移除 endpoint 中可能存在的协议前缀
endpoint = strings.TrimPrefix(endpoint, "https://")
endpoint = strings.TrimPrefix(endpoint, "http://")
endpoint = strings.TrimRight(endpoint, "/")
// 移除 objectKey 开头的斜杠
objectKey = strings.TrimLeft(objectKey, "/")
url := fmt.Sprintf("https://%s.%s/%s", s.config.BucketName, endpoint, objectKey)
return url, nil
}
// GeneratePresignedURL 生成临时访问URL带签名
func (s *OSSStorage) GeneratePresignedURL(objectKey string, expireSeconds int64) (string, error) {
signedURL, err := s.bucket.SignURL(objectKey, oss.HTTPGet, expireSeconds)
if err != nil {
return "", fmt.Errorf("生成预签名URL失败: %w", err)
}
return signedURL, nil
}
// generateObjectKey 生成OSS对象键名
func (s *OSSStorage) generateObjectKey(objectPath string, filename string) string {
// 组合基础路径和对象路径
basePath := strings.TrimRight(s.config.BasePath, "/")
objectPath = strings.TrimLeft(objectPath, "/")
// OSS 路径统一使用斜杠,避免 Windows 下使用反斜杠
if basePath == "" {
return strings.ReplaceAll(filepath.ToSlash(filepath.Join(objectPath, filename)), "\\", "/")
}
return strings.ReplaceAll(filepath.ToSlash(filepath.Join(basePath, objectPath, filename)), "\\", "/")
}
// UploadToOSS 上传文件到OSS兼容旧接口
func UploadToOSS(reader io.Reader, originalFilename string) (string, error) {
if ossStorage == nil {
return "", fmt.Errorf("OSS未初始化请先调用InitOSS()")
}
// 生成唯一文件名
filename := GenerateFilename(originalFilename)
// 使用日期目录作为路径
objectPath := time.Now().Format("20060102")
return ossStorage.UploadFromReader(reader, filename, objectPath)
}
// DeleteFromOSS 从OSS删除文件兼容旧接口
func DeleteFromOSS(fileURL string) error {
if ossStorage == nil {
return fmt.Errorf("OSS未初始化")
}
cfg := ossStorage.config
// 从URL中提取ObjectKey
var objectKey string
if cfg.Domain != "" {
// 自定义域名格式: https://domain.com/path/file.jpg
domain := cfg.Domain
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") {
domain = "https://" + domain
}
objectKey = strings.TrimPrefix(fileURL, fmt.Sprintf("%s/", strings.TrimRight(domain, "/")))
} else {
// 默认域名格式: https://bucket.endpoint/path/file.jpg
endpoint := strings.TrimPrefix(cfg.Endpoint, "https://")
endpoint = strings.TrimPrefix(endpoint, "http://")
objectKey = strings.TrimPrefix(fileURL, fmt.Sprintf("https://%s.%s/", cfg.BucketName, endpoint))
}
return ossStorage.DeleteFile(objectKey)
}
// GenerateFilename 生成唯一文件名
func GenerateFilename(originalFilename string) string {
ext := filepath.Ext(originalFilename)
name := strings.TrimSuffix(originalFilename, ext)
// 生成时间戳和UUID
timestamp := time.Now().Format("20060102150405")
uuidStr := uuid.New().String()[:8]
// 清理文件名,移除特殊字符
name = strings.ReplaceAll(name, " ", "_")
name = strings.ReplaceAll(name, "(", "")
name = strings.ReplaceAll(name, ")", "")
return fmt.Sprintf("%s_%s_%s%s", name, timestamp, uuidStr, ext)
}
// IsValidImageType 验证图片文件类型
func IsValidImageType(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
validExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}
for _, validExt := range validExts {
if ext == validExt {
return true
}
}
return false
}
// getContentType 根据文件名获取MIME类型
func getContentType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
// 常见图片类型
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".svg":
return "image/svg+xml"
case ".bmp":
return "image/bmp"
case ".ico":
return "image/x-icon"
// 常见文档类型
case ".pdf":
return "application/pdf"
case ".doc":
return "application/msword"
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xls":
return "application/vnd.ms-excel"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".txt":
return "text/plain; charset=utf-8"
case ".json":
return "application/json"
case ".xml":
return "application/xml"
default:
// 使用Go标准库自动检测
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
return mimeType
}
// 默认类型
return "application/octet-stream"
}
}

View File

@@ -0,0 +1,19 @@
package utils
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
// HashPassword 密码加密使用SHA256与Python版本保持一致
func HashPassword(password string) string {
hash := sha256.Sum256([]byte(password))
return hex.EncodeToString(hash[:])
}
// VerifyPassword 验证密码
func VerifyPassword(password, hashedPassword string) bool {
fmt.Printf(HashPassword(password))
return HashPassword(password) == hashedPassword
}