diff --git a/.gitignore b/.gitignore index 6e79a3c..b7ecbba 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,8 @@ backend/ms-playwright/ # 敏感配置文件(保留示例文件) backend/cookies.json backend/test_cookies.json +backend/login_state.json +backend/storage_states/ # ============================================ # 微信小程序相关 @@ -132,6 +134,10 @@ $RECYCLE.BIN/ *.pid go_backend/*.pid backend/*.pid +logs/*.pid + +# 日志目录 +logs/ # 运行时数据 go_backend/data/ diff --git a/IMAGES_TAGS_FEATURE.md b/IMAGES_TAGS_FEATURE.md deleted file mode 100644 index 65db83e..0000000 --- a/IMAGES_TAGS_FEATURE.md +++ /dev/null @@ -1,446 +0,0 @@ -# 文章图片和标签功能实现文档 - -## 📋 功能概述 - -为发布记录详情页面添加图片和标签展示功能,完整展示文章的所有关联信息。 - ---- - -## 🎯 实现内容 - -### 1. 后端实现 ✅ - -#### 数据库设计 - -**新增表:** -- `ai_article_tags` - 存储文章标签 - - `article_id` - 文章ID(唯一索引) - - `coze_tag` - 标签字符串(逗号分隔) - -**已有表:** -- `ai_article_images` - 存储文章图片 - - `article_id` - 文章ID - - `image_url` - 图片URL - - `image_thumb_url` - 缩略图URL - - `sort_order` - 排序 - - `keywords_name` - 图片关键词 - -#### 模型层(models/models.go) - -```go -// ArticleTag 文章标签表 -type ArticleTag struct { - ID int `gorm:"primaryKey;autoIncrement" json:"id"` - ArticleID int `gorm:"not null;default:0;uniqueIndex:uk_article_tag" json:"article_id"` - CozeTag string `gorm:"type:varchar(500)" json:"coze_tag"` - CreatedAt time.Time `json:"created_at"` -} -``` - -#### 服务层(service/employee_service.go) - -**GetPublishRecordDetail 方法增强:** - -```go -// 查询文章图片 -var articleImages []models.ArticleImage -if err := database.DB.Where("article_id = ?", article.ID).Order("sort_order ASC").Find(&articleImages).Error; err == nil { - for _, img := range articleImages { - images = append(images, map[string]interface{}{ - "id": img.ID, - "image_url": img.ImageURL, - "image_thumb_url": img.ImageThumbURL, - "sort_order": img.SortOrder, - "keywords_name": img.KeywordsName, - }) - } -} - -// 查询文章标签 -var articleTag models.ArticleTag -if err := database.DB.Where("article_id = ?", article.ID).First(&articleTag).Error; err == nil && articleTag.CozeTag != "" { - articleCozeTag = articleTag.CozeTag -} - -// 解析标签 -if articleCozeTag != "" { - for _, tag := range splitTags(articleCozeTag) { - if tag != "" { - tags = append(tags, tag) - } - } -} -``` - -**splitTags 辅助函数:** -- 支持英文逗号 `,` -- 支持中文逗号 `,` -- 支持竖线 `|` -- 自动去除空格 - -#### API 返回数据结构 - -```json -{ - "id": 1, - "article_id": 1, - "product_id": 1, - "topic": "夏日清爽", - "title": "💧夏日必备!2元一杯的柠檬水竟然这么好喝?", - "content": "姐妹们!今天必须给你们安利...", - "images": [ - { - "id": 1, - "image_url": "https://picsum.photos/800/600?random=1", - "image_thumb_url": "https://picsum.photos/300/200?random=1", - "sort_order": 1, - "keywords_name": "饮品,柠檬水" - } - ], - "tags": ["饮品", "柠檬水", "夏日", "清爽", "性价比", "蜜雪冰城"], - "coze_tag": "饮品,柠檬水,夏日,清爽,性价比,蜜雪冰城", - "publish_link": "", - "status": "published_review", - "publish_time": "2024-12-12 17:35:00" -} -``` - ---- - -### 2. 前端实现 ✅ - -#### TypeScript 类型定义(services/employee.ts) - -```typescript -static async getPublishRecordDetail(recordId: number) { - return get<{ - id: number; - article_id: number; - product_id: number; - product_name: string; - topic: string; - title: string; - content: string; - images: Array<{ - id: number; - image_url: string; - image_thumb_url: string; - sort_order: number; - keywords_name: string; - }>; - tags: string[]; - coze_tag: string; - publish_link: string; - status: string; - publish_time: string; - }>(`/api/employee/publish-record/${recordId}`); -} -``` - -#### 页面逻辑(pages/profile/article-detail/article-detail.ts) - -```typescript -data: { - article: { - title: '', - content: '', - topic: '', - publishLink: '', - images: [] as Array<{ - id: number; - image_url: string; - image_thumb_url: string; - sort_order: number; - keywords_name: string; - }>, - tags: [] as string[] - } -} - -// 加载数据 -this.setData({ - article: { - title: response.data.title, - content: response.data.content, - topic: response.data.topic, - publishLink: response.data.publish_link || '', - images: response.data.images || [], // 图片列表 - tags: response.data.tags || [] // 标签列表 - } -}); -``` - -#### WXML 模板(pages/profile/article-detail/article-detail.wxml) - -```xml - - - - - - - - - - - #{{item}} - - -``` - -#### WXSS 样式(pages/profile/article-detail/article-detail.wxss) - -```css -/* 文章图片 - 3列网格布局 */ -.article-images { - margin: 24rpx 0; - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12rpx; - box-sizing: border-box; - width: 100%; -} - -.image-item { - width: 100%; - padding-bottom: 100%; /* 1:1 比例 */ - position: relative; - border-radius: 8rpx; - overflow: hidden; - background: #f5f5f5; -} - -.article-image { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - object-fit: cover; -} - -/* 标签样式 */ -.article-tags { - display: flex; - flex-wrap: wrap; - gap: 12rpx; - padding-bottom: 20rpx; - margin-bottom: 24rpx; -} - -.tag-item { - padding: 8rpx 20rpx; - background: linear-gradient(135deg, #fff5f7 0%, #ffe8ec 100%); - border-radius: 20rpx; - font-size: 24rpx; - color: #ff2442; - font-weight: 500; -} -``` - ---- - -## 📦 测试数据 - -### 导入测试数据脚本 - -**方式1:批处理脚本** -```bash -cd go_backend -import_test_data.bat -``` - -**方式2:手动执行SQL** -```bash -mysql -u root -p ai_wht < go_backend/migrations/insert_test_images_tags.sql -``` - -### 测试数据内容 - -| 文章ID | 图片数量 | 标签内容 | -|--------|---------|----------| -| 1 | 3张 | 饮品,柠檬水,夏日,清爽,性价比,蜜雪冰城 | -| 2 | 2张 | 冰淇淋,甜品,夏日,平价美食,蜜雪冰城 | -| 3 | 4张 | 口红,彩妆,美妆,色号推荐,黄皮友好,完美日记 | -| 4 | 3张 | 手机,测评,小米,影像旗舰,拍照,数码 | - -**图片来源:** https://picsum.photos(免费随机图片服务) -- 缩略图:300x200 -- 原图:800x600 - ---- - -## 🔄 数据流程 - -``` -用户查看发布记录详情 - ↓ -前端调用 getPublishRecordDetail API - ↓ -后端 GetPublishRecordDetail 方法 - ├─ 查询 ai_article_published_records - ├─ 通过 article_id 关联 ai_articles - ├─ 查询 ai_article_images(按 sort_order 排序) - ├─ 查询 ai_article_tags - └─ 解析标签字符串为数组 - ↓ -返回完整数据(content + images + tags) - ↓ -前端渲染 - ├─ 文章内容 - ├─ 图片网格(3列布局) - └─ 标签列表 -``` - ---- - -## 📝 修改文件清单 - -### 后端文件 - -1. ✅ `models/models.go` - - 新增 ArticleTag 模型 - - 添加 TableName 方法 - -2. ✅ `service/employee_service.go` - - 增强 GetPublishRecordDetail 方法 - - 新增 splitTags 辅助函数 - - 添加 strings 包导入 - -3. ✅ `database/database.go` - - 添加 ArticleImage 和 ArticleTag 到 AutoMigrate - -4. ✅ `migrations/insert_test_images_tags.sql` - - 测试数据导入脚本 - -5. ✅ `migrations/test_article_relations.sql` - - 数据验证查询脚本 - -6. ✅ `import_test_data.bat` - - 快速导入脚本 - -### 前端文件 - -1. ✅ `services/employee.ts` - - 更新 getPublishRecordDetail 返回类型 - - 添加 images 和 tags 字段定义 - -2. ✅ `pages/profile/article-detail/article-detail.ts` - - 更新 data 定义 - - 添加 images 和 tags 字段 - -3. ✅ `pages/profile/article-detail/article-detail.wxml` - - 添加图片网格展示 - - 添加标签列表展示 - -4. ✅ `pages/profile/article-detail/article-detail.wxss` - - 添加图片网格样式 - - 优化标签样式 - ---- - -## 🎨 UI 设计亮点 - -### 图片展示 -- ✅ 3列网格布局,充分利用空间 -- ✅ 1:1 正方形比例,视觉统一 -- ✅ 圆角设计,柔和美观 -- ✅ 懒加载优化性能 -- ✅ 使用缩略图提升加载速度 - -### 标签展示 -- ✅ 渐变背景(#fff5f7 → #ffe8ec) -- ✅ 品牌色文字(#ff2442) -- ✅ 自动换行,适应多标签 -- ✅ # 号前缀,符合社交平台习惯 - ---- - -## 🚀 测试验证 - -### 后端验证 -```bash -cd go_backend -go build -./ai_xhs # 启动服务 -``` - -### 前端验证 -1. 打开小程序开发者工具 -2. 进入"我的" → "已发布" -3. 点击任意发布记录 -4. 验证显示: - - ✅ 文章内容 - - ✅ 图片网格(3列) - - ✅ 标签列表 - - ✅ 小红书链接 - -### API 测试 -```bash -# 获取发布记录详情 -curl http://localhost:8080/api/employee/publish-record/1 -``` - ---- - -## 📊 性能优化 - -1. **图片优化** - - 使用缩略图 (300x200) 而非原图 (800x600) - - 懒加载 (lazy-load) - - grid布局提升渲染性能 - -2. **数据库优化** - - article_id 索引加速关联查询 - - sort_order 字段优化排序 - - 单次查询获取所有数据 - -3. **前端优化** - - wx:if 条件渲染,减少无用节点 - - 图片按需加载 - - CSS grid 硬件加速 - ---- - -## ✨ 功能特性 - -1. ✅ 完整数据展示(内容+图片+标签) -2. ✅ 智能标签解析(支持多种分隔符) -3. ✅ 优雅的UI设计 -4. ✅ 响应式布局 -5. ✅ 性能优化 -6. ✅ 测试数据完整 -7. ✅ 向后兼容(支持无图片/标签的旧数据) - ---- - -## 📅 更新日期 - -2024-12-12 - ---- - -## 👥 相关文件 - -- 后端代码:`go_backend/` -- 前端代码:`miniprogram/` -- 测试脚本:`go_backend/migrations/` -- 文档:本文件 - ---- - -**🎉 功能已完整实现,可以正常使用!** diff --git a/ai_wht.sql b/ai_wht.sql deleted file mode 100644 index 069b5a2..0000000 --- a/ai_wht.sql +++ /dev/null @@ -1,541 +0,0 @@ -/* - Navicat Premium Dump SQL - - Source Server : mixue - Source Server Type : MySQL - Source Server Version : 90001 (9.0.1) - Source Host : localhost:3306 - Source Schema : ai_wht - - Target Server Type : MySQL - Target Server Version : 90001 (9.0.1) - File Encoding : 65001 - - Date: 12/12/2025 15:25:58 -*/ - -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ---------------------------- --- Table structure for ai_article_images --- ---------------------------- -DROP TABLE IF EXISTS `ai_article_images`; -CREATE TABLE `ai_article_images` ( - `id` int NOT NULL AUTO_INCREMENT, - `article_id` int NOT NULL DEFAULT 0 COMMENT '文章ID', - `image_id` int NOT NULL DEFAULT 0 COMMENT '图片ID', - `image_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片URL', - `image_thumb_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '缩略图URL', - `image_tag_id` int NOT NULL DEFAULT 0 COMMENT '图片标签ID', - `sort_order` int NULL DEFAULT 0 COMMENT '排序', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `keywords_id` int NOT NULL DEFAULT 0 COMMENT '关键词ID', - `keywords_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '关键词名称', - `department_id` int NOT NULL DEFAULT 0 COMMENT '部门ID', - `department_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '部门名称', - `image_source` tinyint(1) NOT NULL DEFAULT 0 COMMENT '图片来源:1=tag|2=change', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uk_article_image`(`article_id` ASC, `image_id` ASC) USING BTREE, - INDEX `idx_article_id`(`article_id` ASC) USING BTREE, - INDEX `idx_image_id`(`image_id` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_article_published_records --- ---------------------------- -DROP TABLE IF EXISTS `ai_article_published_records`; -CREATE TABLE `ai_article_published_records` ( - `id` int NOT NULL AUTO_INCREMENT, - `enterprise_id` int NOT NULL DEFAULT 0 COMMENT '所属企业ID', - `product_id` int NOT NULL DEFAULT 0 COMMENT '关联产品ID', - `topic` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'topic主题', - `title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '标题', - `created_user_id` int NOT NULL DEFAULT 0 COMMENT '创建用户ID', - `review_user_id` int NULL DEFAULT NULL COMMENT '审核用户ID', - `publish_user_id` int NULL DEFAULT NULL COMMENT '发布用户ID', - `status` enum('topic','cover_image','generate','generate_failed','draft','pending_review','approved','rejected','published_review','published','failed') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'draft' COMMENT '状态', - `channel` tinyint(1) NOT NULL DEFAULT 1 COMMENT '渠道:1=baidu|2=toutiao|3=weixin', - `review_comment` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '审核评论', - `publish_time` timestamp NULL DEFAULT NULL COMMENT '发布时间', - `word_count` int NULL DEFAULT 0 COMMENT '字数统计', - `image_count` int NULL DEFAULT 0 COMMENT '图片数量', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_enterprise_id`(`enterprise_id` ASC) USING BTREE, - INDEX `idx_product_id`(`product_id` ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_created_user_id`(`created_user_id` ASC) USING BTREE, - INDEX `idx_enterprise_product`(`enterprise_id` ASC, `product_id` ASC) USING BTREE, - INDEX `idx_status_created`(`status` ASC, `created_at` DESC) USING BTREE, - INDEX `idx_created_at`(`created_at` DESC) USING BTREE, - INDEX `idx_updated_at`(`updated_at` DESC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_article_tags --- ---------------------------- -DROP TABLE IF EXISTS `ai_article_tags`; -CREATE TABLE `ai_article_tags` ( - `id` int NOT NULL AUTO_INCREMENT, - `article_id` int NOT NULL DEFAULT 0 COMMENT '文章ID', - `coze_tag` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'Coze生成的标签', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uk_article_tag`(`article_id` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_articles --- ---------------------------- -DROP TABLE IF EXISTS `ai_articles`; -CREATE TABLE `ai_articles` ( - `id` int NOT NULL AUTO_INCREMENT, - `batch_id` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '批次ID', - `enterprise_id` int NOT NULL DEFAULT 0 COMMENT '所属企业ID', - `product_id` int NOT NULL DEFAULT 0 COMMENT '关联产品ID', - `topic_type_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT 'topic类型ID', - `prompt_workflow_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '提示词工作流ID', - `topic` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'topic主题', - `title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '标题', - `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '文章内容', - `department` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '部门', - `departmentids` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '部门IDs', - `author_id` int NULL DEFAULT NULL COMMENT '作者ID', - `author_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '作者名称', - `department_id` int NULL DEFAULT NULL COMMENT '部门ID', - `department_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门名称', - `created_user_id` int NOT NULL DEFAULT 0 COMMENT '创建用户ID', - `review_user_id` int NULL DEFAULT NULL COMMENT '审核用户ID', - `publish_user_id` int NULL DEFAULT NULL COMMENT '发布用户ID', - `status` enum('topic','cover_image','generate','generate_failed','draft','pending_review','approved','rejected','published_review','published','failed') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'draft' COMMENT '状态', - `channel` tinyint(1) NOT NULL DEFAULT 1 COMMENT '渠道:1=baidu|2=toutiao|3=weixin', - `review_comment` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '审核评论', - `publish_time` timestamp NULL DEFAULT NULL COMMENT '发布时间', - `baijiahao_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '百家号ID', - `baijiahao_status` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '百家号状态', - `word_count` int NULL DEFAULT 0 COMMENT '字数统计', - `image_count` int NULL DEFAULT 0 COMMENT '图片数量', - `coze_tag` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'Coze生成的标签', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_enterprise_id`(`enterprise_id` ASC) USING BTREE, - INDEX `idx_product_id`(`product_id` ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_created_user_id`(`created_user_id` ASC) USING BTREE, - INDEX `idx_enterprise_product`(`enterprise_id` ASC, `product_id` ASC) USING BTREE, - INDEX `idx_status_created`(`status` ASC, `created_at` DESC) USING BTREE, - INDEX `idx_created_at`(`created_at` DESC) USING BTREE, - INDEX `idx_updated_at`(`updated_at` DESC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_authors --- ---------------------------- -DROP TABLE IF EXISTS `ai_authors`; -CREATE TABLE `ai_authors` ( - `id` int NOT NULL AUTO_INCREMENT, - `author_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '作者名称', - `app_id` varchar(127) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '应用ID', - `app_token` varchar(127) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '应用Token', - `department_id` int NOT NULL DEFAULT 0 COMMENT '部门ID', - `department_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '部门名称', - `department` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '部门', - `title` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '职称', - `hospital` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '医院', - `specialty` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '专业', - `toutiao_cookie` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '头条Cookie', - `toutiao_images_cookie` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '头条图片Cookie', - `introduction` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '介绍', - `avatar_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '头像URL', - `status` enum('active','inactive') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'active' COMMENT '状态', - `channel` tinyint(1) NOT NULL DEFAULT 1 COMMENT '渠道:1=baidu|2=toutiao|3=weixin', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_department_id`(`department_id` ASC) USING BTREE, - INDEX `idx_channel`(`channel` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_data_statistics --- ---------------------------- -DROP TABLE IF EXISTS `ai_data_statistics`; -CREATE TABLE `ai_data_statistics` ( - `id` int NOT NULL AUTO_INCREMENT, - `enterprise_id` int NOT NULL DEFAULT 0 COMMENT '所属企业ID', - `product_id` int NOT NULL DEFAULT 0 COMMENT '关联产品ID', - `cumulative_releases_num` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计发布', - `published_today_num` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '今日发布', - `published_week_num` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '本周发布', - `participating_employees_num` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '参与员工', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_enterprise_id`(`enterprise_id` ASC) USING BTREE, - INDEX `idx_product_id`(`product_id` ASC) USING BTREE, - INDEX `idx_enterprise_product`(`enterprise_id` ASC, `product_id` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_departments --- ---------------------------- -DROP TABLE IF EXISTS `ai_departments`; -CREATE TABLE `ai_departments` ( - `id` int NOT NULL AUTO_INCREMENT, - `department_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '部门名称', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_ai_departments_created_at`(`created_at` DESC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_enterprises --- ---------------------------- -DROP TABLE IF EXISTS `ai_enterprises`; -CREATE TABLE `ai_enterprises` ( - `id` int NOT NULL AUTO_INCREMENT, - `enterprise_ID` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '企业ID', - `name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '企业名称', - `short_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '企业简称', - `icon` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '企业图标URL', - `phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '登录手机号', - `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '登录密码(加密存储)', - `email` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '企业邮箱', - `website` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '企业网站', - `address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '企业地址', - `status` enum('active','disabled') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'active' COMMENT '状态', - `users_total` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工总数', - `products_total` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '产品总数', - `articles_total` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '文章总数', - `released_month_total` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '本月发布数量', - `linked_to_xhs_num` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '绑定小红书', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uk_phone`(`phone` ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE -) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '企业信息表' ROW_FORMAT = Dynamic; - --- ---------------------------- --- Table structure for ai_image_tags --- ---------------------------- -DROP TABLE IF EXISTS `ai_image_tags`; -CREATE TABLE `ai_image_tags` ( - `id` int NOT NULL AUTO_INCREMENT, - `image_id` int NOT NULL DEFAULT 0 COMMENT '图片ID', - `image_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片名称', - `image_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片URL', - `image_thumb_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '缩略图URL', - `tag_id` int NOT NULL DEFAULT 0 COMMENT '标签ID', - `tag_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '标签名称', - `keywords_id` int NOT NULL DEFAULT 0 COMMENT '关键词ID', - `keywords_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '关键词名称', - `department_id` int NOT NULL DEFAULT 0 COMMENT '部门ID', - `department_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '部门名称', - `created_user_id` int NOT NULL DEFAULT 0 COMMENT '创建用户ID', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uk_image_tag`(`image_id` ASC, `tag_id` ASC) USING BTREE, - INDEX `idx_tag_id`(`tag_id` ASC) USING BTREE, - INDEX `idx_department_id`(`department_id` ASC) USING BTREE, - INDEX `idx_keywords_id`(`keywords_id` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE, - INDEX `idx_dept_keywords`(`department_id` ASC, `keywords_id` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_image_tags_name --- ---------------------------- -DROP TABLE IF EXISTS `ai_image_tags_name`; -CREATE TABLE `ai_image_tags_name` ( - `id` int NOT NULL AUTO_INCREMENT, - `tag_name` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '标签名称', - `tag_category` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '标签分类', - `department` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门', - `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '描述', - `status` enum('active','inactive') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'active' COMMENT '状态', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uk_tag_name`(`tag_name`(191) ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_image_tags_relation --- ---------------------------- -DROP TABLE IF EXISTS `ai_image_tags_relation`; -CREATE TABLE `ai_image_tags_relation` ( - `id` int NOT NULL AUTO_INCREMENT, - `image_id` int NOT NULL DEFAULT 0 COMMENT '图片ID', - `image_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片名称', - `tag_id` int NOT NULL DEFAULT 0 COMMENT '标签ID', - `tag_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '标签名称', - `created_user_id` int NOT NULL DEFAULT 0 COMMENT '创建用户ID', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uk_image_tag`(`image_id` ASC, `tag_id` ASC) USING BTREE, - INDEX `idx_tag_id`(`tag_id` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_image_type --- ---------------------------- -DROP TABLE IF EXISTS `ai_image_type`; -CREATE TABLE `ai_image_type` ( - `id` int NOT NULL AUTO_INCREMENT, - `type_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片类型,场景图', - `keywords_id` int NOT NULL DEFAULT 0 COMMENT '关键词ID', - `keywords_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '关键词名称', - `department_id` int NOT NULL DEFAULT 0 COMMENT '部门ID', - `department_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '部门名称', - `created_user_id` int NOT NULL DEFAULT 0 COMMENT '创建用户ID', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_department_id`(`department_id` ASC) USING BTREE, - INDEX `idx_keywords_id`(`keywords_id` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE, - INDEX `idx_dept_keywords`(`department_id` ASC, `keywords_id` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_images --- ---------------------------- -DROP TABLE IF EXISTS `ai_images`; -CREATE TABLE `ai_images` ( - `id` int NOT NULL AUTO_INCREMENT, - `image_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片名称', - `image_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片URL', - `image_thumb_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '缩略图URL', - `thumbnail_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '缩略图URL', - `department` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门', - `keywords` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '关键词', - `size_type` enum('medical','lifestyle','instruction') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'medical' COMMENT '尺寸类型', - `image_type_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '图片类型ID', - `image_type_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片类型,场景图', - `file_size` bigint NULL DEFAULT NULL COMMENT '文件大小', - `width` int NULL DEFAULT NULL COMMENT '图片宽度', - `height` int NULL DEFAULT NULL COMMENT '图片高度', - `upload_user_id` int NOT NULL DEFAULT 0 COMMENT '上传用户ID', - `status` enum('active','inactive','deleted') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'active' COMMENT '状态', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_upload_user_id`(`upload_user_id` ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_image_type_id`(`image_type_id` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_logs --- ---------------------------- -DROP TABLE IF EXISTS `ai_logs`; -CREATE TABLE `ai_logs` ( - `id` int NOT NULL AUTO_INCREMENT, - `user_id` int NULL DEFAULT NULL COMMENT '用户ID', - `action` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '操作动作', - `target_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '目标类型', - `target_id` int NULL DEFAULT NULL COMMENT '目标ID', - `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '描述', - `ip_address` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'IP地址', - `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '用户代理', - `request_data` json NULL COMMENT '请求数据', - `response_data` json NULL COMMENT '响应数据', - `status` enum('success','error','warning') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'success' COMMENT '状态', - `error_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '错误消息', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_user_id`(`user_id` ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_action`(`action` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_product_images --- ---------------------------- -DROP TABLE IF EXISTS `ai_product_images`; -CREATE TABLE `ai_product_images` ( - `id` int NOT NULL AUTO_INCREMENT, - `enterprise_id` int NOT NULL DEFAULT 0 COMMENT '所属企业ID', - `product_id` int NOT NULL DEFAULT 0 COMMENT '关联产品ID', - `image_id` int NOT NULL DEFAULT 0 COMMENT '图片ID', - `image_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片名称', - `image_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片URL', - `thumbnail_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '缩略图URL', - `type_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片类型', - `description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '图片描述', - `file_size` bigint NULL DEFAULT NULL COMMENT '文件大小', - `width` int NULL DEFAULT NULL COMMENT '图片宽度', - `height` int NULL DEFAULT NULL COMMENT '图片高度', - `upload_user_id` int NOT NULL DEFAULT 0 COMMENT '上传用户ID', - `status` enum('active','deleted') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'active' COMMENT '状态', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_enterprise_id`(`enterprise_id` ASC) USING BTREE, - INDEX `idx_product_id`(`product_id` ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_enterprise_product`(`enterprise_id` ASC, `product_id` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '产品图片库表' ROW_FORMAT = Dynamic; - --- ---------------------------- --- Table structure for ai_product_tags --- ---------------------------- -DROP TABLE IF EXISTS `ai_product_tags`; -CREATE TABLE `ai_product_tags` ( - `id` int NOT NULL AUTO_INCREMENT, - `enterprise_id` int NOT NULL DEFAULT 0 COMMENT '所属企业ID', - `product_id` int NOT NULL DEFAULT 0 COMMENT '关联产品ID', - `tag_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '产品的标签', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uk_enterprise_product_tag`(`enterprise_id` ASC, `product_id` ASC, `tag_name`(100) ASC) USING BTREE, - INDEX `idx_enterprise_id`(`enterprise_id` ASC) USING BTREE, - INDEX `idx_product_id`(`product_id` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_product_types --- ---------------------------- -DROP TABLE IF EXISTS `ai_product_types`; -CREATE TABLE `ai_product_types` ( - `id` int NOT NULL AUTO_INCREMENT, - `type_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '产品类型名称', - `enterprise_id` int NOT NULL DEFAULT 0 COMMENT '所属企业ID', - `product_id` int NOT NULL DEFAULT 0 COMMENT '关联产品ID', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uk_enterprise_product_type`(`enterprise_id` ASC, `product_id` ASC, `type_name` ASC) USING BTREE, - INDEX `idx_enterprise_id`(`enterprise_id` ASC) USING BTREE, - INDEX `idx_product_id`(`product_id` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_products --- ---------------------------- -DROP TABLE IF EXISTS `ai_products`; -CREATE TABLE `ai_products` ( - `id` int NOT NULL AUTO_INCREMENT, - `enterprise_id` int NOT NULL DEFAULT 0 COMMENT '所属企业ID', - `name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '产品名称', - `type_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '产品类型', - `image_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '产品主图URL', - `image_thumbnail_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '缩略图URL', - `knowledge` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '产品知识库(纯文字)', - `articles_total` int NOT NULL DEFAULT 0 COMMENT '文章总数', - `published_total` int NOT NULL DEFAULT 0 COMMENT '发布总数', - `status` enum('draft','active','deleted') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'draft' COMMENT '状态:draft=草稿,active=正常,deleted=已删除', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_enterprise_id`(`enterprise_id` ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_enterprise_status`(`enterprise_id` ASC, `status` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '产品信息表' ROW_FORMAT = Dynamic; - --- ---------------------------- --- Table structure for ai_prompt_tags --- ---------------------------- -DROP TABLE IF EXISTS `ai_prompt_tags`; -CREATE TABLE `ai_prompt_tags` ( - `id` int NOT NULL AUTO_INCREMENT, - `tag_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'tag名称', - `created_user_id` int NOT NULL DEFAULT 0 COMMENT '创建用户ID', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_tag_name`(`tag_name` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '提示词模板表' ROW_FORMAT = Dynamic; - --- ---------------------------- --- Table structure for ai_prompt_tags_relation --- ---------------------------- -DROP TABLE IF EXISTS `ai_prompt_tags_relation`; -CREATE TABLE `ai_prompt_tags_relation` ( - `id` int NOT NULL AUTO_INCREMENT, - `prompt_workflow_id` int NOT NULL DEFAULT 0 COMMENT '提示词工作流ID', - `prompt_workflow_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '提示词工作流名称', - `tag_id` int NOT NULL DEFAULT 0 COMMENT '标签ID', - `tag_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '标签名称', - `created_user_id` int NOT NULL DEFAULT 0 COMMENT '创建用户ID', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uk_prompt_tag`(`prompt_workflow_id` ASC, `tag_id` ASC) USING BTREE, - INDEX `idx_tag_id`(`tag_id` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_prompt_workflow --- ---------------------------- -DROP TABLE IF EXISTS `ai_prompt_workflow`; -CREATE TABLE `ai_prompt_workflow` ( - `id` int NOT NULL AUTO_INCREMENT, - `enterprise_id` int NOT NULL DEFAULT 0 COMMENT '所属企业ID', - `prompt_workflow_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '提示词工作流名称', - `auth_token` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '认证Token', - `workflow_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '工作流ID', - `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '提示词内容', - `usage_count` int NOT NULL DEFAULT 0 COMMENT '使用次数统计', - `created_user_id` int NOT NULL DEFAULT 0 COMMENT '创建用户ID', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_enterprise_id`(`enterprise_id` ASC) USING BTREE, - INDEX `idx_workflow_id`(`workflow_id` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_users --- ---------------------------- -DROP TABLE IF EXISTS `ai_users`; -CREATE TABLE `ai_users` ( - `id` int NOT NULL AUTO_INCREMENT, - `enterprise_id` int NOT NULL DEFAULT 0 COMMENT '所属企业ID', - `enterprise_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '企业名称', - `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户名', - `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '密码', - `real_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '真实姓名', - `email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱', - `phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机号', - `xhs_phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '小红书绑定手机号', - `xhs_account` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '小红书账号名称', - `is_bound_xhs` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否绑定小红书:0=未绑定,1=已绑定', - `bound_at` timestamp NULL DEFAULT NULL COMMENT '绑定小红书的时间', - `department` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门', - `role` enum('admin','editor','reviewer','publisher','each_title_reviewer') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'editor' COMMENT '角色', - `status` enum('active','inactive','deleted') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'active' COMMENT '状态', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uk_username`(`username` ASC) USING BTREE, - INDEX `idx_enterprise_id`(`enterprise_id` ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_role`(`role` ASC) USING BTREE, - INDEX `idx_is_bound_xhs`(`is_bound_xhs` ASC) USING BTREE, - INDEX `idx_enterprise_status`(`enterprise_id` ASC, `status` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC; - -SET FOREIGN_KEY_CHECKS = 1; diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..efc9174 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,25 @@ +# Python服务环境变量配置示例 +# 复制此文件为 .env 并根据需要修改 + +# ========== 运行环境 ========== +# 可选值: dev, prod +# 默认: dev +ENV=dev + +# ========== 可选:覆盖配置文件中的数据库配置 ========== +# 如果设置了以下环境变量,将覆盖 config.{ENV}.yaml 中的对应配置 +# DB_HOST=localhost +# DB_PORT=3306 +# DB_USER=root +# DB_PASSWORD=your_password +# DB_NAME=ai_wht + +# ========== 可选:覆盖调度器配置 ========== +# SCHEDULER_ENABLED=true +# SCHEDULER_CRON=*/5 * * * * * +# SCHEDULER_MAX_CONCURRENT=2 +# SCHEDULER_PUBLISH_TIMEOUT=300 +# SCHEDULER_MAX_ARTICLES_PER_USER_PER_RUN=2 +# SCHEDULER_MAX_FAILURES_PER_USER_PER_RUN=3 +# SCHEDULER_MAX_DAILY_ARTICLES_PER_USER=6 +# SCHEDULER_MAX_HOURLY_ARTICLES_PER_USER=2 diff --git a/backend/CONFIG_GUIDE.md b/backend/CONFIG_GUIDE.md new file mode 100644 index 0000000..22b6110 --- /dev/null +++ b/backend/CONFIG_GUIDE.md @@ -0,0 +1,112 @@ +# Python服务配置说明 + +## 配置文件结构 + +Python服务现在使用与Go服务相同的配置文件结构: + +``` +backend/ +├── config.dev.yaml # 开发环境配置 +├── config.prod.yaml # 生产环境配置 +├── config.py # 配置加载模块 +├── .env.example # 环境变量示例 +└── .env # 环境变量(需手动创建,Git忽略) +``` + +## 环境切换 + +通过设置 `ENV` 环境变量来切换环境: + +### Windows (CMD) +```bash +set ENV=dev +python main.py +``` + +### Windows (PowerShell) +```powershell +$env:ENV="dev" +python main.py +``` + +### Linux/Mac +```bash +ENV=dev python main.py +``` + +或者在 `.env` 文件中设置: +``` +ENV=dev +``` + +## 配置优先级 + +1. **环境变量** - 最高优先级 +2. **配置文件** - config.{ENV}.yaml +3. **代码默认值** - 最低优先级 + +## 配置项说明 + +### 开发环境 (config.dev.yaml) + +- **数据库**: 本地MySQL (localhost:3306) +- **调度器**: 启用,每5秒执行一次(测试用) +- **日志级别**: DEBUG + +### 生产环境 (config.prod.yaml) + +- **数据库**: 远程MySQL (8.149.233.36:3306) +- **调度器**: 启用,每5分钟执行一次 +- **日志级别**: INFO + +## 使用示例 + +### 1. 开发环境 + +创建 `.env` 文件: +```bash +ENV=dev +``` + +启动服务: +```bash +python main.py +``` + +### 2. 生产环境 + +创建 `.env` 文件: +```bash +ENV=prod +``` + +启动服务: +```bash +python main.py +``` + +### 3. 覆盖配置 + +如需临时修改某些配置,可在 `.env` 中添加: +```bash +ENV=dev +DB_HOST=192.168.1.100 +SCHEDULER_CRON=0 */10 * * * * +``` + +## 与Go服务的配置对应关系 + +| Python配置 | Go配置 | 说明 | +|-----------|--------|------| +| config.dev.yaml | config/config.dev.yaml | 开发环境配置 | +| config.prod.yaml | config/config.prod.yaml | 生产环境配置 | +| ENV环境变量 | ENV环境变量 | 环境切换 | +| database.username | database.username | 数据库用户名 | +| database.dbname | database.dbname | 数据库名称 | + +## 注意事项 + +1. **密码安全**: 生产环境请修改 `config.prod.yaml` 中的数据库密码 +2. **Git忽略**: `.env` 文件已被Git忽略,不会提交到代码库 +3. **环境变量**: 环境变量会覆盖配置文件中的同名配置 +4. **调度器频率**: 开发环境默认5秒执行一次,生产环境默认5分钟执行一次 diff --git a/backend/DAMAI_PROXY_GUIDE.md b/backend/DAMAI_PROXY_GUIDE.md new file mode 100644 index 0000000..d6248b1 --- /dev/null +++ b/backend/DAMAI_PROXY_GUIDE.md @@ -0,0 +1,266 @@ +# 大麦固定代理IP使用指南 + +## 📋 概述 + +本项目已集成两个大麦固定代理IP,可用于无头浏览器访问,支持完整的HTTP认证。 + +## 🌐 代理配置 + +### 代理1 +- **服务器**: `36.137.177.131:50001` +- **用户名**: `qqwvy0` +- **密码**: `mun3r7xz` +- **状态**: ✅ 已测试可用 + +### 代理2 +- **服务器**: `111.132.40.72:50002` +- **用户名**: `ih3z07` +- **密码**: `078bt7o5` +- **状态**: ✅ 已测试可用 + +## 📂 相关文件 + +| 文件名 | 说明 | +|--------|------| +| `damai_proxy_config.py` | 代理配置管理模块 | +| `test_damai_proxy.py` | 代理测试脚本 | +| `example_use_damai_proxy.py` | 使用示例代码 | + +## 🚀 快速开始 + +### 1. 测试代理可用性 + +```bash +# 测试所有代理 +python test_damai_proxy.py + +# 测试单个代理 +python test_damai_proxy.py 0 # 测试代理1 +python test_damai_proxy.py 1 # 测试代理2 +``` + +### 2. 在代码中使用 + +#### 方式一:使用配置模块 + +```python +from damai_proxy_config import get_proxy_1, get_proxy_2, get_random_proxy + +# 获取指定代理 +proxy = get_proxy_1() # 或 get_proxy_2() + +# 随机获取代理 +proxy = get_random_proxy() + +print(proxy) +# 输出: {'server': 'http://...', 'username': '...', 'password': '...'} +``` + +#### 方式二:在Playwright中使用 + +```python +from playwright.async_api import async_playwright +from damai_proxy_config import get_proxy_1 + +async def use_proxy(): + proxy_config = get_proxy_1() + + playwright = await async_playwright().start() + + # 配置代理(含认证) + browser = await playwright.chromium.launch( + headless=True, + proxy={ + "server": proxy_config["server"], + "username": proxy_config["username"], + "password": proxy_config["password"] + } + ) + + context = await browser.new_context() + page = await context.new_page() + + # 访问目标网站 + await page.goto("https://www.damai.cn/") + + await browser.close() + await playwright.stop() +``` + +#### 方式三:集成到browser_pool + +```python +from browser_pool import get_browser_pool +from damai_proxy_config import get_random_proxy + +async def use_with_pool(): + # 获取代理配置 + proxy = get_random_proxy() + + # 注意:当前browser_pool需要修改以支持带认证的代理 + pool = get_browser_pool() + browser, context, page = await pool.get_browser( + proxy=f"{proxy['server']}" # 基础用法 + ) +``` + +## 🔧 API文档 + +### damai_proxy_config.py + +#### `get_proxy_config(index: int) -> dict` +获取指定索引的代理配置 + +**参数:** +- `index`: 代理索引(0或1) + +**返回:** +```python +{ + "server": "http://...", + "username": "...", + "password": "..." +} +``` + +#### `get_proxy_1() -> dict` +快捷获取代理1配置 + +#### `get_proxy_2() -> dict` +快捷获取代理2配置 + +#### `get_random_proxy() -> dict` +随机获取一个可用代理 + +#### `get_all_enabled_proxies() -> list` +获取所有已启用的代理列表 + +## ✅ 测试结果 + +所有代理已通过以下测试: + +1. ✅ **IP检测测试** - 确认代理IP地址正确 +2. ✅ **小红书访问测试** - 成功访问小红书创作平台 +3. ✅ **大麦网访问测试** - 成功访问大麦网 + +### 测试日志示例 + +``` +🔍 开始测试: 大麦代理1 + 代理服务器: http://36.137.177.131:50001 + 认证信息: qqwvy0 / mun3r7xz +============================================================ +✅ Playwright启动成功 +✅ 浏览器启动成功 +✅ 浏览器上下文创建成功 +✅ 页面创建成功 + +📍 测试1: 访问IP检测网站... +✅ 访问成功 +🌐 当前IP信息: +{ + "origin": "36.137.177.131" +} + +📍 测试2: 访问小红书登录页... +✅ 访问成功 + 页面标题: 小红书创作服务平台 + +📍 测试3: 访问大麦网... +✅ 访问成功 + 页面标题: 大麦网-全球演出赛事官方购票平台 +``` + +## 🎯 使用场景 + +1. **反爬虫绕过** - 使用固定IP避免频繁更换导致的风险 +2. **地域限制** - 使用特定地区的IP访问区域性内容 +3. **负载均衡** - 在多个代理间轮换,分散请求压力 +4. **容错处理** - 一个代理失败时自动切换到备用代理 + +## ⚠️ 注意事项 + +1. **认证信息安全**: 代理用户名密码已配置在代码中,生产环境建议使用环境变量 +2. **代理轮换**: 建议实现代理轮换机制,避免单一IP被封禁 +3. **异常处理**: 建议添加代理失败时的重试和切换逻辑 +4. **性能影响**: 使用代理会增加网络延迟,请根据实际需求权衡 + +## 🔄 代理管理 + +### 启用/禁用代理 + +编辑 `damai_proxy_config.py`,修改代理配置中的 `enabled` 字段: + +```python +DAMAI_PROXY_POOL = [ + { + "name": "大麦代理1", + "server": "http://36.137.177.131:50001", + "username": "qqwvy0", + "password": "mun3r7xz", + "enabled": True # 设置为False禁用此代理 + }, + # ... +] +``` + +### 添加新代理 + +在 `DAMAI_PROXY_POOL` 列表中添加新的代理配置: + +```python +{ + "name": "新代理", + "server": "http://ip:port", + "username": "username", + "password": "password", + "enabled": True +} +``` + +## 📊 性能测试 + +根据测试结果,代理响应时间: +- IP检测: ~2-3秒 +- 小红书: ~3-5秒 +- 大麦网: ~3-5秒 + +## 🛠️ 故障排查 + +### 问题1: 代理连接超时 +**解决方案**: +1. 检查代理服务器是否在线 +2. 验证认证信息是否正确 +3. 增加连接超时时间 + +### 问题2: 认证失败 +**解决方案**: +1. 确认用户名密码正确 +2. 检查代理是否需要IP白名单 +3. 联系代理服务商确认账户状态 + +### 问题3: 访问被拒绝 +**解决方案**: +1. 切换到另一个代理 +2. 检查目标网站是否封禁了代理IP +3. 添加适当的请求头和延迟 + +## 📝 更新日志 + +### 2025-12-26 +- ✅ 初始化大麦代理配置 +- ✅ 完成两个代理的测试验证 +- ✅ 创建配置管理模块 +- ✅ 添加使用示例和文档 + +## 📞 技术支持 + +如遇到代理相关问题,请检查: +1. 网络连接是否正常 +2. 代理服务商是否有公告 +3. 代理配置是否正确 + +--- + +**最后更新**: 2025-12-26 +**版本**: 1.0.0 diff --git a/backend/LOGIN_PAGE_CONFIG.md b/backend/LOGIN_PAGE_CONFIG.md new file mode 100644 index 0000000..6c896f4 --- /dev/null +++ b/backend/LOGIN_PAGE_CONFIG.md @@ -0,0 +1,108 @@ +# 登录页面配置功能说明 + +## 功能概述 + +现在可以通过配置文件来控制小红书登录时获取Cookie的来源页面,支持两种选项: +- **creator**: 创作者中心 (https://creator.xiaohongshu.com/login) +- **home**: 小红书首页 (https://www.xiaohongshu.com) + +## 配置方法 + +### 1. 修改配置文件 + +在 `config.dev.yaml` 或 `config.prod.yaml` 中找到 `login` 配置节: + +```yaml +# ========== 登录/绑定功能配置 ========== +login: + headless: false # 登录/绑定时的浏览器模式 + page: "creator" # 登录页面类型: creator 或 home +``` + +将 `page` 的值修改为你想要的登录页面: +- `"creator"`: 使用创作者中心登录页 +- `"home"`: 使用小红书首页登录 + +### 2. 重启服务 + +修改配置后需要重启Python后端服务使配置生效: + +```bash +# Windows +cd backend +.\start.bat + +# Linux +cd backend +./start.sh +``` + +## API参数覆盖 + +即使配置了默认值,API请求仍然可以通过 `login_page` 参数临时覆盖配置: + +```javascript +// 发送验证码 +POST /api/xhs/send-code +{ + "phone": "13800138000", + "country_code": "+86", + "login_page": "home" // 可选,不传则使用配置文件默认值 +} + +// 登录 +POST /api/xhs/login +{ + "phone": "13800138000", + "code": "123456", + "country_code": "+86", + "login_page": "home", // 可选,不传则使用配置文件默认值 + "session_id": "xxx" +} +``` + +## 优先级说明 + +1. **最高优先级**: API请求中的 `login_page` 参数 +2. **默认值**: 配置文件中的 `login.page` 配置 +3. **兜底值**: 如果都未配置,默认使用 `creator` + +## 测试验证 + +运行测试脚本验证配置是否正确: + +```bash +cd backend +python test_login_page_config.py +``` + +## 配置影响范围 + +修改 `login.page` 配置会影响以下功能: + +1. **发送验证码接口** (`/api/xhs/send-code`) +2. **登录接口** (`/api/xhs/login`) +3. **浏览器池预热URL** (根据配置自动调整) + +## 注意事项 + +1. 两个登录页面的HTML结构可能略有不同,如遇到问题请切换尝试 +2. 建议在开发环境先测试再应用到生产环境 +3. 配置修改后需要重启服务才能生效 +4. 如果API明确传入了 `login_page` 参数,会优先使用API参数而不是配置文件 + +## 示例场景 + +### 场景1:全局使用创作者中心 +```yaml +login: + page: "creator" +``` +不传API参数时,所有请求都使用创作者中心登录。 + +### 场景2:全局使用首页,但个别请求使用创作者中心 +```yaml +login: + page: "home" +``` +大部分请求使用首页,但特殊情况下API可以传 `"login_page": "creator"` 临时切换。 diff --git a/backend/ali_sms_service.py b/backend/ali_sms_service.py new file mode 100644 index 0000000..f4687de --- /dev/null +++ b/backend/ali_sms_service.py @@ -0,0 +1,203 @@ +""" +阿里云短信服务模块 +用于发送手机验证码 +""" +import json +import random +import sys +from typing import Dict, Any, Optional +from datetime import datetime, timedelta + +from alibabacloud_dysmsapi20170525.client import Client as Dysmsapi20170525Client +from alibabacloud_credentials.client import Client as CredentialClient +from alibabacloud_credentials.models import Config as CredentialConfig +from alibabacloud_tea_openapi import models as open_api_models +from alibabacloud_dysmsapi20170525 import models as dysmsapi_20170525_models +from alibabacloud_tea_util import models as util_models + + +class AliSmsService: + """阿里云短信服务""" + + def __init__(self, access_key_id: str, access_key_secret: str, sign_name: str, template_code: str): + """ + 初始化阿里云短信服务 + + Args: + access_key_id: 阿里云AccessKey ID + access_key_secret: 阿里云AccessKey Secret + sign_name: 短信签名 + template_code: 短信模板CODE + """ + self.sign_name = sign_name + self.template_code = template_code + + # 创建阿里云短信客户端 + credential_config = CredentialConfig( + type='access_key', + access_key_id=access_key_id, + access_key_secret=access_key_secret + ) + credential = CredentialClient(credential_config) + config = open_api_models.Config(credential=credential) + config.endpoint = 'dysmsapi.aliyuncs.com' + + self.client = Dysmsapi20170525Client(config) + + # 验证码缓存(简单内存存储,生产环境应使用Redis) + self._code_cache: Dict[str, Dict[str, Any]] = {} + + # 验证码配置 + self.code_length = 6 # 验证码长度 + self.code_expire_minutes = 5 # 验证码过期时间(分钟) + + def _generate_code(self) -> str: + """生成随机验证码""" + return ''.join([str(random.randint(0, 9)) for _ in range(self.code_length)]) + + async def send_verification_code(self, phone: str) -> Dict[str, Any]: + """ + 发送验证码到指定手机号 + + Args: + phone: 手机号 + + Returns: + Dict containing success status and error message if any + """ + try: + # 生成验证码 + code = self._generate_code() + + print(f"[短信服务] 正在发送验证码到 {phone},验证码: {code}", file=sys.stderr) + + # 构建短信请求 + send_sms_request = dysmsapi_20170525_models.SendSmsRequest( + phone_numbers=phone, + sign_name=self.sign_name, + template_code=self.template_code, + template_param=json.dumps({"code": code}) + ) + + runtime = util_models.RuntimeOptions() + + # 发送短信 + try: + resp = self.client.send_sms_with_options(send_sms_request, runtime) + resp_dict = resp.to_map() + + print(f"[短信服务] 阿里云响应: {json.dumps(resp_dict, default=str, indent=2, ensure_ascii=False)}", file=sys.stderr) + + # 检查发送结果 + if resp_dict.get('body', {}).get('Code') == 'OK': + # 缓存验证码 + self._code_cache[phone] = { + 'code': code, + 'expire_time': datetime.now() + timedelta(minutes=self.code_expire_minutes), + 'sent_at': datetime.now() + } + + print(f"[短信服务] 验证码发送成功,手机号: {phone}", file=sys.stderr) + + return { + "success": True, + "message": f"验证码已发送,{self.code_expire_minutes}分钟内有效", + "code": code # 开发环境返回验证码,生产环境应移除 + } + else: + error_msg = resp_dict.get('body', {}).get('Message', '未知错误') + print(f"[短信服务] 发送失败: {error_msg}", file=sys.stderr) + return { + "success": False, + "error": f"短信发送失败: {error_msg}" + } + + except Exception as e: + error_msg = str(e) + print(f"[短信服务] 发送异常: {error_msg}", file=sys.stderr) + + # 如果有诊断地址,打印出来 + if hasattr(e, 'data') and e.data: + recommend = e.data.get('Recommend') + if recommend: + print(f"[短信服务] 诊断地址: {recommend}", file=sys.stderr) + + return { + "success": False, + "error": f"短信发送异常: {error_msg}" + } + + except Exception as e: + print(f"[短信服务] 发送验证码失败: {str(e)}", file=sys.stderr) + return { + "success": False, + "error": str(e) + } + + def verify_code(self, phone: str, code: str) -> Dict[str, Any]: + """ + 验证手机号和验证码 + + Args: + phone: 手机号 + code: 用户输入的验证码 + + Returns: + Dict containing verification result + """ + try: + # 检查验证码是否存在 + if phone not in self._code_cache: + return { + "success": False, + "error": "验证码未发送或已过期,请重新获取" + } + + cached_data = self._code_cache[phone] + + # 检查是否过期 + if datetime.now() > cached_data['expire_time']: + # 删除过期验证码 + del self._code_cache[phone] + return { + "success": False, + "error": "验证码已过期,请重新获取" + } + + # 验证码匹配 + if code == cached_data['code']: + # 验证成功后删除验证码(一次性使用) + del self._code_cache[phone] + + print(f"[短信服务] 验证码验证成功,手机号: {phone}", file=sys.stderr) + + return { + "success": True, + "message": "验证码验证成功" + } + else: + return { + "success": False, + "error": "验证码错误,请重新输入" + } + + except Exception as e: + print(f"[短信服务] 验证码验证失败: {str(e)}", file=sys.stderr) + return { + "success": False, + "error": str(e) + } + + def cleanup_expired_codes(self): + """清理过期的验证码""" + current_time = datetime.now() + expired_phones = [ + phone for phone, data in self._code_cache.items() + if current_time > data['expire_time'] + ] + + for phone in expired_phones: + del self._code_cache[phone] + + if expired_phones: + print(f"[短信服务] 已清理 {len(expired_phones)} 个过期验证码", file=sys.stderr) diff --git a/backend/browser_pool.py b/backend/browser_pool.py new file mode 100644 index 0000000..4c4da54 --- /dev/null +++ b/backend/browser_pool.py @@ -0,0 +1,553 @@ +""" +浏览器池管理模块 +管理Playwright浏览器实例的生命周期,支持复用以提升性能 +""" +import asyncio +import time +from typing import Optional, Dict, Any +from playwright.async_api import async_playwright, Browser, BrowserContext, Page +import sys + + +class BrowserPool: + """浏览器池管理器(单例模式)""" + + def __init__(self, idle_timeout: int = 1800, max_instances: int = 5, headless: bool = True): + """ + 初始化浏览器池 + + Args: + idle_timeout: 空闲超时时间(秒),默认30分钟(已禁用,保持常驻) + max_instances: 最大浏览器实例数,默认5个 + headless: 是否使用无头模式,False为有头模式(方便调试) + """ + self.playwright = None + self.browser: Optional[Browser] = None + self.context: Optional[BrowserContext] = None + self.page: Optional[Page] = None + self.last_used_time = 0 + self.idle_timeout = idle_timeout + self.max_instances = max_instances + self.headless = headless + self.is_initializing = False + self.init_lock = asyncio.Lock() + self.is_preheated = False # 标记是否已预热 + + # 临时浏览器实例池(用于并发请求) + self.temp_browsers: Dict[str, Dict] = {} # {session_id: {browser, context, page, created_at}} + self.temp_lock = asyncio.Lock() + + print(f"[浏览器池] 已创建,常驻模式(不自动清理),最大实例数: {max_instances}", file=sys.stderr) + + async def get_browser(self, cookies: Optional[list] = None, proxy: Optional[str] = None, + user_agent: Optional[str] = None, session_id: Optional[str] = None, + headless: Optional[bool] = None) -> tuple[Browser, BrowserContext, Page]: + """ + 获取浏览器实例(复用或新建) + + Args: + cookies: 可选的Cookie列表 + proxy: 可选的代理地址 + user_agent: 可选的自定义User-Agent + session_id: 会话 ID,用于区分不同的并发请求 + headless: 可选的headless模式,为None时使用默认配置 + + Returns: + (browser, context, page) 三元组 + """ + # 如果没有指定headless,使用默认配置 + if headless is None: + headless = self.headless + # 如果主浏览器可用且无会话 ID,使用主浏览器 + if not session_id: + async with self.init_lock: + # 检查现有浏览器是否可用 + if await self._is_browser_alive(): + print("[浏览器池] 复用主浏览器实例", file=sys.stderr) + self.last_used_time = time.time() + + # 如果需要注入Cookie,直接添加到现有的context(不创建新context) + if cookies: + print(f"[浏览器池] 在现有context中注入 {len(cookies)} 个Cookie", file=sys.stderr) + await self.context.add_cookies(cookies) + + return self.browser, self.context, self.page + else: + # 创建新浏览器 + print("[浏览器池] 创建主浏览器实例", file=sys.stderr) + await self._init_browser(cookies, proxy, user_agent) + self.last_used_time = time.time() + return self.browser, self.context, self.page + + # 并发请求:复用或创建临时浏览器 + else: + async with self.temp_lock: + # 首先检查是否已存在该session_id的临时浏览器 + if session_id in self.temp_browsers: + print(f"[浏览器池] 复用会话 {session_id} 的临时浏览器", file=sys.stderr) + browser_info = self.temp_browsers[session_id] + return browser_info["browser"], browser_info["context"], browser_info["page"] + + # 检查是否超过最大实例数 + if len(self.temp_browsers) >= self.max_instances - 1: # -1 留给主浏览器 + print(f"[浏览器池] ⚠️ 已达最大实例数 ({self.max_instances}),等待释放...", file=sys.stderr) + # TODO: 可以实现等待队列,这里直接报错 + raise Exception(f"浏览器实例数已满,请稍后再试") + + print(f"[浏览器池] 为会话 {session_id} 创建临时浏览器 ({len(self.temp_browsers)+1}/{self.max_instances-1})", file=sys.stderr) + + # 创建临时浏览器,传入headless参数 + browser, context, page = await self._create_temp_browser(cookies, proxy, user_agent, headless) + + # 保存到临时池 + self.temp_browsers[session_id] = { + "browser": browser, + "context": context, + "page": page, + "created_at": time.time() + } + + return browser, context, page + + async def _is_browser_alive(self) -> bool: + """检查浏览器是否存活(不检查超时,保持常驻)""" + if not self.browser or not self.context or not self.page: + return False + + # 注意:为了保持浏览器常驻,不再检查空闲超时 + # 原代码: + # if time.time() - self.last_used_time > self.idle_timeout: + # print(f"[浏览器池] 浏览器空闲超时 ({self.idle_timeout}秒),需要重建", file=sys.stderr) + # await self.close() + # return False + + # 检查浏览器是否仍在运行 + try: + # 尝试获取页面标题来验证连接 + await self.page.title() + return True + except Exception as e: + print(f"[浏览器池] 浏览器连接失效: {str(e)}", file=sys.stderr) + await self.close() + return False + + async def _init_browser(self, cookies: Optional[list] = None, proxy: Optional[str] = None, + user_agent: Optional[str] = None): + """初始化新浏览器实例""" + try: + # 启动Playwright + if not self.playwright: + # Windows环境下,需要设置事件循环策略 + if sys.platform == 'win32': + # 设置为ProactorEventLoop或SelectorEventLoop + try: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + except Exception as e: + print(f"[浏览器池] 警告: 设置事件循环策略失败: {str(e)}", file=sys.stderr) + + self.playwright = await async_playwright().start() + print("[浏览器池] Playwright启动成功", file=sys.stderr) + + # 启动浏览器(性能优先配置) + launch_kwargs = { + "headless": self.headless, # 使用配置的headless参数 + "args": [ + '--disable-blink-features=AutomationControlled', # 隐藏自动化特征 + '--no-sandbox', # Linux环境必需 + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', # 使用/tmp而非/dev/shm,避免内存不足 + + # 性能优化 + '--disable-web-security', # 禁用同源策略(提升加载速度) + '--disable-features=IsolateOrigins,site-per-process', # 禁用站点隔离(提升性能) + '--disable-site-isolation-trials', + '--enable-features=NetworkService,NetworkServiceInProcess', # 网络服务优化 + '--disable-background-timer-throttling', # 禁用后台限速 + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', # 渲染进程不降优先级 + '--disable-background-networking', + + # 缓存和存储优化 + '--disk-cache-size=268435456', # 256MB磁盘缓存 + '--media-cache-size=134217728', # 128MB媒体缓存 + + # 渲染优化(保留GPU支持) + '--enable-gpu-rasterization', # 启用GPU光栅化 + '--enable-zero-copy', # 零拷贝优化 + '--ignore-gpu-blocklist', # 忽略GPU黑名单 + '--enable-accelerated-2d-canvas', # 加速2D canvas + + # 网络优化 + '--enable-quic', # 启用QUIC协议 + '--enable-tcp-fast-open', # TCP快速打开 + '--max-connections-per-host=10', # 每个主机最大连接数 + + # 减少不必要的功能 + '--disable-extensions', + '--disable-breakpad', # 禁用崩溃报告 + '--disable-component-extensions-with-background-pages', + '--disable-ipc-flooding-protection', # 禁用IPC洪水保护(提升性能) + '--disable-hang-monitor', # 禁用挂起监控 + '--disable-prompt-on-repost', + '--disable-domain-reliability', + '--disable-component-update', + + # 界面优化 + '--hide-scrollbars', + '--mute-audio', + '--no-first-run', + '--no-default-browser-check', + '--metrics-recording-only', + '--force-color-profile=srgb', + ], + } + if proxy: + launch_kwargs["proxy"] = {"server": proxy} + + self.browser = await self.playwright.chromium.launch(**launch_kwargs) + print("[浏览器池] Chromium浏览器启动成功", file=sys.stderr) + + # 创建上下文 + await self._create_new_context(cookies, proxy, user_agent) + + except Exception as e: + print(f"[浏览器池] 初始化浏览器失败: {str(e)}", file=sys.stderr) + await self.close() + raise + + async def _create_new_context(self, cookies: Optional[list] = None, proxy: Optional[str] = None, + user_agent: Optional[str] = None): + """创建新的浏览器上下文""" + try: + # 关闭旧上下文 + if self.context: + await self.context.close() + print("[浏览器池] 已关闭旧上下文", file=sys.stderr) + + # 创建新上下文 + context_kwargs = { + "viewport": {'width': 1280, 'height': 720}, + "user_agent": user_agent or 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + } + self.context = await self.browser.new_context(**context_kwargs) + + # 注入Cookie + if cookies: + await self.context.add_cookies(cookies) + print(f"[浏览器池] 已注入 {len(cookies)} 个Cookie", file=sys.stderr) + + # 创建页面 + self.page = await self.context.new_page() + print("[浏览器池] 新页面创建成功", file=sys.stderr) + + except Exception as e: + print(f"[浏览器池] 创建上下文失败: {str(e)}", file=sys.stderr) + raise + + async def close(self): + """关闭浏览器池""" + try: + if self.page: + await self.page.close() + self.page = None + if self.context: + await self.context.close() + self.context = None + if self.browser: + await self.browser.close() + self.browser = None + if self.playwright: + await self.playwright.stop() + self.playwright = None + print("[浏览器池] 浏览器已关闭", file=sys.stderr) + except Exception as e: + print(f"[浏览器池] 关闭浏览器异常: {str(e)}", file=sys.stderr) + + async def cleanup_if_idle(self): + """清理空闲浏览器(定时任务调用)- 已禁用,保持常驻""" + # 注意:为了保持浏览器常驻,不再自动清理 + # 原代码: + # if self.browser and time.time() - self.last_used_time > self.idle_timeout: + # print(f"[浏览器池] 检测到空闲超时,自动清理浏览器", file=sys.stderr) + # await self.close() + pass # 不再执行清理操作 + + async def preheat(self, target_url: str = "https://creator.xiaohongshu.com/login"): + """ + 预热浏览器:提前初始化并访问目标页面 + + Args: + target_url: 预热目标页面,默认为小红书登录页 + """ + try: + print("[浏览器预热] 开始预热浏览器...", file=sys.stderr) + + # 初始化浏览器 + await self._init_browser() + self.last_used_time = time.time() + + # 访问目标页面 + print(f"[浏览器预热] 正在访问: {target_url}", file=sys.stderr) + await self.page.goto(target_url, wait_until='domcontentloaded', timeout=45000) + + # 等待页面完全加载 + await asyncio.sleep(1) + + self.is_preheated = True + print("[浏览器预热] ✅ 预热完成,浏览器已就绪!", file=sys.stderr) + print(f"[浏览器预热] 当前页面: {self.page.url}", file=sys.stderr) + + except Exception as e: + print(f"[浏览器预热] ⚠️ 预热失败: {str(e)}", file=sys.stderr) + print("[浏览器预热] 将在首次使用时再初始化", file=sys.stderr) + self.is_preheated = False + + async def repreheat(self, target_url: str = "https://creator.xiaohongshu.com/login"): + """ + 补充预热:在后台重新将浏览器预热到目标页面 + 用于在主浏览器被使用后,重新预热以保证下次使用的性能 + + 重要:如果浏览器正在使用中(有临时实例),跳过预热避免干扰 + + Args: + target_url: 预热目标页面,默认为小红书登录页 + """ + # 关键优化:检查是否有临时浏览器正在使用 + if len(self.temp_browsers) > 0: + print(f"[浏览器补充预热] 检测到 {len(self.temp_browsers)} 个临时浏览器正在使用,跳过预热避免干扰", file=sys.stderr) + return + + # 检查主浏览器是否正在被使用(通过最近使用时间判断) + time_since_last_use = time.time() - self.last_used_time + if time_since_last_use < 10: # 最近10秒内使用过,可能还在操作中 + print(f"[浏览器补充预热] 主浏览器最近 {time_since_last_use:.1f}秒前被使用,可能还在操作中,跳过预热", file=sys.stderr) + return + + max_retries = 3 + retry_count = 0 + + while retry_count < max_retries: + try: + # 检查主浏览器是否存活 + if not await self._is_browser_alive(): + print(f"[浏览器补充预热] 浏览器未初始化,执行完整预热 (尝试 {retry_count + 1}/{max_retries})", file=sys.stderr) + await self.preheat(target_url) + self.is_preheated = True + return + + # 检查是否已经在目标页面 + current_url = self.page.url if self.page else "" + if target_url in current_url: + print(f"[浏览器补充预热] 已在目标页面,无需补充预热: {current_url}", file=sys.stderr) + self.is_preheated = True + return + + print(f"[浏览器补充预热] 开始补充预热... (尝试 {retry_count + 1}/{max_retries})", file=sys.stderr) + print(f"[浏览器补充预热] 当前页面: {current_url}", file=sys.stderr) + + # 再次检查是否有新的临时浏览器(双重检查) + if len(self.temp_browsers) > 0: + print(f"[浏览器补充预热] 检测到新的临时浏览器启动,取消预热", file=sys.stderr) + return + + # 访问目标页面 + print(f"[浏览器补充预热] 正在访问: {target_url}", file=sys.stderr) + await self.page.goto(target_url, wait_until='domcontentloaded', timeout=45000) + + # 额外等待,确保页面完全加载 + await asyncio.sleep(2) + + # 验证页面是否正确加载 + current_page_url = self.page.url + if target_url in current_page_url or 'creator.xiaohongshu.com' in current_page_url: + self.is_preheated = True + self.last_used_time = time.time() + print("[浏览器补充预热] ✅ 补充预热完成!", file=sys.stderr) + print(f"[浏览器补充预热] 当前页面: {current_page_url}", file=sys.stderr) + return # 成功,退出重试循环 + else: + print(f"[浏览器补充预热] 页面未正确加载,期望: {target_url}, 实际: {current_page_url}", file=sys.stderr) + raise Exception(f"页面未正确加载到目标地址") + + except Exception as e: + retry_count += 1 + print(f"[浏览器补充预热] ⚠️ 补充预热失败 (尝试 {retry_count}/{max_retries}): {str(e)}", file=sys.stderr) + + if retry_count < max_retries: + # 等待一段时间后重试 + await asyncio.sleep(2) + # 尝试重新初始化浏览器 + try: + await self.close() # 关闭当前可能有问题的浏览器 + except: + pass # 忽略关闭时的错误 + else: + # 所有重试都失败了 + print(f"[浏览器补充预热] ❌ 所有重试都失败了,将尝试完整预热", file=sys.stderr) + try: + await self.close() # 先关闭当前浏览器 + except: + pass + # 执行完整预热 + try: + await self.preheat(target_url) + self.is_preheated = True + return + except Exception as final_error: + print(f"[浏览器补充预热] ❌ 最终预热也失败: {str(final_error)}", file=sys.stderr) + self.is_preheated = False + # 即使最终失败,也要确保浏览器处于可用状态 + try: + await self._init_browser() + except: + pass + + async def _create_temp_browser(self, cookies: Optional[list] = None, proxy: Optional[str] = None, + user_agent: Optional[str] = None, headless: bool = True) -> tuple[Browser, BrowserContext, Page]: + """创建临时浏览器实例(用于并发请求) + + Args: + cookies: Cookie列表 + proxy: 代理地址 + user_agent: 自定义User-Agent + headless: 是否使用无头模式 + """ + try: + # 启动Playwright(复用全局实例) + if not self.playwright: + if sys.platform == 'win32': + try: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + except Exception as e: + print(f"[临时浏览器] 警告: 设置事件循环策略失败: {str(e)}", file=sys.stderr) + + self.playwright = await async_playwright().start() + + # 启动浏览器(临时实例,性能优先配置) + launch_kwargs = { + "headless": headless, # 使用传入的headless参数 + "args": [ + '--disable-blink-features=AutomationControlled', + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + + # 性能优化 + '--disable-web-security', + '--disable-features=IsolateOrigins,site-per-process', + '--disable-site-isolation-trials', + '--enable-features=NetworkService,NetworkServiceInProcess', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + '--disable-background-networking', + + # 缓存优化 + '--disk-cache-size=268435456', + '--media-cache-size=134217728', + + # 渲染优化 + '--enable-gpu-rasterization', + '--enable-zero-copy', + '--ignore-gpu-blocklist', + '--enable-accelerated-2d-canvas', + + # 网络优化 + '--enable-quic', + '--enable-tcp-fast-open', + '--max-connections-per-host=10', + + # 减少不必要的功能 + '--disable-extensions', + '--disable-breakpad', + '--disable-component-extensions-with-background-pages', + '--disable-ipc-flooding-protection', + '--disable-hang-monitor', + '--disable-prompt-on-repost', + '--disable-domain-reliability', + '--disable-component-update', + + # 界面优化 + '--hide-scrollbars', + '--mute-audio', + '--no-first-run', + '--no-default-browser-check', + '--metrics-recording-only', + '--force-color-profile=srgb', + ], + } + if proxy: + launch_kwargs["proxy"] = {"server": proxy} + + browser = await self.playwright.chromium.launch(**launch_kwargs) + + # 创建上下文 + context_kwargs = { + "viewport": {'width': 1280, 'height': 720}, + "user_agent": user_agent or 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + } + context = await browser.new_context(**context_kwargs) + + # 注入Cookie + if cookies: + await context.add_cookies(cookies) + + # 创建页面 + page = await context.new_page() + + return browser, context, page + + except Exception as e: + print(f"[临时浏览器] 创建失败: {str(e)}", file=sys.stderr) + raise + + async def release_temp_browser(self, session_id: str): + """释放临时浏览器""" + async with self.temp_lock: + if session_id in self.temp_browsers: + browser_info = self.temp_browsers[session_id] + try: + await browser_info["page"].close() + await browser_info["context"].close() + await browser_info["browser"].close() + print(f"[浏览器池] 已释放会话 {session_id} 的临时浏览器", file=sys.stderr) + except Exception as e: + print(f"[浏览器池] 释放临时浏览器异常: {str(e)}", file=sys.stderr) + finally: + del self.temp_browsers[session_id] + + def get_stats(self) -> Dict[str, Any]: + """获取浏览器池统计信息""" + return { + "browser_alive": self.browser is not None, + "context_alive": self.context is not None, + "page_alive": self.page is not None, + "is_preheated": self.is_preheated, + "temp_browsers_count": len(self.temp_browsers), + "max_instances": self.max_instances, + "last_used_time": self.last_used_time, + "idle_seconds": int(time.time() - self.last_used_time) if self.last_used_time > 0 else 0, + "idle_timeout": self.idle_timeout + } + + +# 全局单例 +_browser_pool: Optional[BrowserPool] = None + + +def get_browser_pool(idle_timeout: int = 1800, headless: bool = True) -> BrowserPool: + """获取全局浏览器池实例(单例) + + Args: + idle_timeout: 空闲超时时间(秒) + headless: 是否使用无头模式,False为有头模式(方便调试) + """ + global _browser_pool + if _browser_pool is None: + print(f"[浏览器池] 创建单例,模式: {'headless' if headless else 'headed'}", file=sys.stderr) + _browser_pool = BrowserPool(idle_timeout=idle_timeout, headless=headless) + elif _browser_pool.headless != headless: + # 如果headless配置变了,需要更新 + print(f"[浏览器池] 检测到headless配置变更: {_browser_pool.headless} -> {headless}", file=sys.stderr) + _browser_pool.headless = headless + return _browser_pool diff --git a/backend/config.dev.yaml b/backend/config.dev.yaml new file mode 100644 index 0000000..f1ea424 --- /dev/null +++ b/backend/config.dev.yaml @@ -0,0 +1,66 @@ +# 小红书Python服务配置 - 开发环境 + +# ========== 服务配置 ========== +server: + host: "0.0.0.0" + port: 8000 + debug: true + reload: false # Windows环境不建议启用热重载 + +# ========== 数据库配置 ========== +database: + host: localhost + port: 3306 + username: root + password: JKjk20011115 + dbname: ai_wht + charset: utf8mb4 + max_connections: 10 + min_connections: 2 + +# ========== 浏览器池配置 ========== +browser_pool: + idle_timeout: 1800 # 空闲超时(秒),已禁用自动清理,保持常驻 + max_instances: 5 # 最大浏览器实例数 + preheat_enabled: true # 是否启用预热 + preheat_url: "https://creator.xiaohongshu.com/login" # 预热URL(根据login.page自动调整) + +# ========== 登录/绑定功能配置 ========== +login: + headless: false # 登录/绑定时的浏览器模式: false=有头模式(方便用户操作),true=无头模式 + page: "home" # 登录页面类型: creator=创作者中心(creator.xiaohongshu.com/login), home=小红书首页(www.xiaohongshu.com) + +# ========== 定时发布调度器配置 ========== +scheduler: + enabled: true # 是否启用定时任务 + cron: "*/5 * * * * *" # Cron表达式(秒 分 时 日 月 周) - 每5秒执行一次(开发环境测试) + max_concurrent: 2 # 最大并发发布数 + publish_timeout: 300 # 发布超时时间(秒) + max_articles_per_user_per_run: 2 # 每轮每个用户最大发文数 + max_failures_per_user_per_run: 3 # 每轮每个用户最大失败次数(达到后暂停本轮后续发布) + max_daily_articles_per_user: 6 # 每个用户每日最大发文数(自动发布) + max_hourly_articles_per_user: 2 # 每个用户每小时最大发文数(自动发布) + headless: false # 浏览器模式: false=有头模式(可调试),true=无头模式(生产环境) + + # ========== 防封策略配置 ========== + enable_random_ua: true # 启用随机User-Agent(防指纹识别) + min_publish_interval: 30 # 最小发布间隔(秒),模拟真人行为 + max_publish_interval: 120 # 最大发布间隔(秒),模拟真人行为 + +# ========== 代理池配置 ========== +proxy_pool: + enabled: false # 默认关闭,按需开启 + api_url: "http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964" + +# ========== 阿里云短信配置 ========== +ali_sms: + access_key_id: "LTAI5tSMvnCJdqkZtCVWgh8R" # 从环境变量或配置文件读取 + access_key_secret: "nyFzXyIi47peVLK4wR2qqbPezmU79W" # 从环境变量或配置文件读取 + sign_name: "北京乐航时代科技" # 短信签名 + template_code: "SMS_486210104" # 短信模板CODE + code_expire_minutes: 5 # 验证码有效期(分钟) + +# ========== 日志配置 ========== +logging: + level: DEBUG + format: "[%(asctime)s] [%(levelname)s] %(message)s" diff --git a/backend/config.prod.yaml b/backend/config.prod.yaml new file mode 100644 index 0000000..440d4a3 --- /dev/null +++ b/backend/config.prod.yaml @@ -0,0 +1,66 @@ +# 小红书Python服务配置 - 生产环境 + +# ========== 服务配置 ========== +server: + host: "0.0.0.0" + port: 8020 + debug: false + reload: false + +# ========== 数据库配置 ========== +database: + host: 8.149.233.36 + port: 3306 + username: ai_wht_write + password: 7aK_H2yvokVumr84lLNDt8fDBp6P + dbname: ai_wht + charset: utf8mb4 + max_connections: 20 + min_connections: 5 + +# ========== 浏览器池配置 ========== +browser_pool: + idle_timeout: 1800 # 空闲超时(秒),已禁用自动清理,保持常驻 + max_instances: 10 # 最大浏览器实例数(生产环境可以更多) + preheat_enabled: true # 是否启用预热 + preheat_url: "https://creator.xiaohongshu.com/login" # 预热URL(根据login.page自动调整) + +# ========== 登录/绑定功能配置 ========== +login: + headless: true # 登录/绑定时的浏览器模式: false=有头模式(方便用户操作),true=无头模式 + page: "home" # 登录页面类型: creator=创作者中心(creator.xiaohongshu.com/login), home=小红书首页(www.xiaohongshu.com) + +# ========== 定时发布调度器配置 ========== +scheduler: + enabled: true # 是否启用定时任务 + cron: "0 */5 * * * *" # Cron表达式(秒 分 时 日 月 周) - 每5分钟执行一次 + max_concurrent: 5 # 最大并发发布数 + publish_timeout: 300 # 发布超时时间(秒) + max_articles_per_user_per_run: 5 # 每轮每个用户最大发文数 + max_failures_per_user_per_run: 3 # 每轮每个用户最大失败次数(达到后暂停本轮后续发布) + max_daily_articles_per_user: 20 # 每个用户每日最大发文数(自动发布) + max_hourly_articles_per_user: 3 # 每个用户每小时最大发文数(自动发布) + headless: true # 浏览器模式: false=有头模式(可调试),true=无头模式(生产环境) + + # ========== 防封策略配置 ========== + enable_random_ua: true # 启用随机User-Agent(防指纹识别) + min_publish_interval: 60 # 最小发布间隔(秒),生产环境建议60-300秒 + max_publish_interval: 300 # 最大发布间隔(秒),生产环境建议60-300秒 + +# ========== 代理池配置 ========== +proxy_pool: + enabled: false # 默认关闭,按需开启 + api_url: "http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964" + +# ========== 阿里云短信配置 ========== +ali_sms: + access_key_id: "LTAI5tSMvnCJdqkZtCVWgh8R" # 生产环境建议使用环境变量 + access_key_secret: "nyFzXyIi47peVLK4wR2qqbPezmU79W" # 生产环境建议使用环境变量 + sign_name: "北京乐航时代科技" # 短信签名 + template_code: "SMS_486210104" # 短信模板CODE + code_expire_minutes: 5 # 验证码有效期(分钟) + +# ========== 日志配置 ========== +logging: + level: INFO + format: "[%(asctime)s] [%(levelname)s] %(message)s" diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..c8e4e40 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,146 @@ +""" +配置管理模块 +支持从YAML文件加载配置,支持环境变量覆盖 +""" +import os +import yaml +from typing import Dict, Any + + +class Config: + """配置类""" + + def __init__(self, config_dict: Dict[str, Any]): + self._config = config_dict + + def get(self, key: str, default=None): + """获取配置值,支持点号分隔的嵌套键""" + keys = key.split('.') + value = self._config + + for k in keys: + if isinstance(value, dict): + value = value.get(k) + if value is None: + return default + else: + return default + + return value + + def get_dict(self, key: str) -> Dict[str, Any]: + """获取配置字典""" + value = self.get(key) + return value if isinstance(value, dict) else {} + + def get_int(self, key: str, default: int = 0) -> int: + """获取整数配置""" + value = self.get(key, default) + try: + return int(value) + except (ValueError, TypeError): + return default + + def get_bool(self, key: str, default: bool = False) -> bool: + """获取布尔配置""" + value = self.get(key, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in ('true', 'yes', '1', 'on') + return bool(value) + + def get_str(self, key: str, default: str = '') -> str: + """获取字符串配置""" + value = self.get(key, default) + return str(value) if value is not None else default + + +def load_config(env: str = None) -> Config: + """ + 加载配置文件 + + Args: + env: 环境名称,可选值: dev, prod + 如果不指定,从环境变量 ENV 读取,默认为 dev + + Returns: + Config对象 + """ + # 确定环境 + if env is None: + env = os.getenv('ENV', 'dev') + + # 配置文件路径 + config_file = f'config.{env}.yaml' + config_path = os.path.join(os.path.dirname(__file__), config_file) + + if not os.path.exists(config_path): + raise FileNotFoundError(f"配置文件不存在: {config_path}") + + # 加载YAML配置 + with open(config_path, 'r', encoding='utf-8') as f: + config_dict = yaml.safe_load(f) + + # 环境变量覆盖(支持常用配置) + # 数据库配置 + if os.getenv('DB_HOST'): + config_dict.setdefault('database', {})['host'] = os.getenv('DB_HOST') + if os.getenv('DB_PORT'): + config_dict.setdefault('database', {})['port'] = int(os.getenv('DB_PORT')) + if os.getenv('DB_USER'): + config_dict.setdefault('database', {})['username'] = os.getenv('DB_USER') + if os.getenv('DB_PASSWORD'): + config_dict.setdefault('database', {})['password'] = os.getenv('DB_PASSWORD') + if os.getenv('DB_NAME'): + config_dict.setdefault('database', {})['dbname'] = os.getenv('DB_NAME') + + # 调度器配置 + if os.getenv('SCHEDULER_ENABLED'): + config_dict.setdefault('scheduler', {})['enabled'] = os.getenv('SCHEDULER_ENABLED').lower() == 'true' + if os.getenv('SCHEDULER_CRON'): + config_dict.setdefault('scheduler', {})['cron'] = os.getenv('SCHEDULER_CRON') + if os.getenv('SCHEDULER_MAX_CONCURRENT'): + config_dict.setdefault('scheduler', {})['max_concurrent'] = int(os.getenv('SCHEDULER_MAX_CONCURRENT')) + if os.getenv('SCHEDULER_PUBLISH_TIMEOUT'): + config_dict.setdefault('scheduler', {})['publish_timeout'] = int(os.getenv('SCHEDULER_PUBLISH_TIMEOUT')) + if os.getenv('SCHEDULER_MAX_ARTICLES_PER_USER_PER_RUN'): + config_dict.setdefault('scheduler', {})['max_articles_per_user_per_run'] = int(os.getenv('SCHEDULER_MAX_ARTICLES_PER_USER_PER_RUN')) + if os.getenv('SCHEDULER_MAX_FAILURES_PER_USER_PER_RUN'): + config_dict.setdefault('scheduler', {})['max_failures_per_user_per_run'] = int(os.getenv('SCHEDULER_MAX_FAILURES_PER_USER_PER_RUN')) + if os.getenv('SCHEDULER_MAX_DAILY_ARTICLES_PER_USER'): + config_dict.setdefault('scheduler', {})['max_daily_articles_per_user'] = int(os.getenv('SCHEDULER_MAX_DAILY_ARTICLES_PER_USER')) + if os.getenv('SCHEDULER_MAX_HOURLY_ARTICLES_PER_USER'): + config_dict.setdefault('scheduler', {})['max_hourly_articles_per_user'] = int(os.getenv('SCHEDULER_MAX_HOURLY_ARTICLES_PER_USER')) + + # 代理池配置 + if os.getenv('PROXY_POOL_ENABLED'): + config_dict.setdefault('proxy_pool', {})['enabled'] = os.getenv('PROXY_POOL_ENABLED').lower() == 'true' + if os.getenv('PROXY_POOL_API_URL'): + config_dict.setdefault('proxy_pool', {})['api_url'] = os.getenv('PROXY_POOL_API_URL') + + print(f"[配置] 已加载配置文件: {config_file}") + print(f"[配置] 环境: {env}") + print(f"[配置] 数据库: {config_dict.get('database', {}).get('host')}:{config_dict.get('database', {}).get('port')}") + print(f"[配置] 调度器: {'启用' if config_dict.get('scheduler', {}).get('enabled') else '禁用'}") + + return Config(config_dict) + + +# 全局配置对象 +app_config: Config = None + + +def init_config(env: str = None): + """初始化全局配置""" + global app_config + app_config = load_config(env) + return app_config + + +def get_config() -> Config: + """获取全局配置对象""" + global app_config + if app_config is None: + app_config = load_config() + return app_config diff --git a/backend/damai_proxy_config.py b/backend/damai_proxy_config.py new file mode 100644 index 0000000..ef69f2f --- /dev/null +++ b/backend/damai_proxy_config.py @@ -0,0 +1,98 @@ +""" +大麦固定代理IP配置 +用于在无头浏览器中使用固定代理IP +""" + +# 大麦固定代理IP池 +DAMAI_PROXY_POOL = [ + { + "name": "大麦代理1", + "server": "http://36.137.177.131:50001", + "username": "qqwvy0", + "password": "mun3r7xz", + "enabled": True + }, + { + "name": "大麦代理2", + "server": "http://111.132.40.72:50002", + "username": "ih3z07", + "password": "078bt7o5", + "enabled": True + } +] + + +def get_proxy_config(index: int = 0) -> dict: + """ + 获取指定索引的代理配置 + + Args: + index: 代理索引(0或1) + + Returns: + 代理配置字典,包含server、username、password + """ + if index < 0 or index >= len(DAMAI_PROXY_POOL): + raise ValueError(f"代理索引无效: {index},有效范围: 0-{len(DAMAI_PROXY_POOL)-1}") + + proxy = DAMAI_PROXY_POOL[index] + if not proxy.get("enabled", True): + raise ValueError(f"代理已禁用: {proxy['name']}") + + return { + "server": proxy["server"], + "username": proxy["username"], + "password": proxy["password"] + } + + +def get_all_enabled_proxies() -> list: + """ + 获取所有已启用的代理配置 + + Returns: + 代理配置列表 + """ + return [ + { + "server": p["server"], + "username": p["username"], + "password": p["password"], + "name": p["name"] + } + for p in DAMAI_PROXY_POOL + if p.get("enabled", True) + ] + + +def get_random_proxy() -> dict: + """ + 随机获取一个可用的代理配置 + + Returns: + 代理配置字典 + """ + import random + enabled_proxies = [p for p in DAMAI_PROXY_POOL if p.get("enabled", True)] + + if not enabled_proxies: + raise ValueError("没有可用的代理") + + proxy = random.choice(enabled_proxies) + return { + "server": proxy["server"], + "username": proxy["username"], + "password": proxy["password"], + "name": proxy["name"] + } + + +# 快捷访问 +def get_proxy_1(): + """获取代理1配置""" + return get_proxy_config(0) + + +def get_proxy_2(): + """获取代理2配置""" + return get_proxy_config(1) diff --git a/backend/debug_login_page.py b/backend/debug_login_page.py new file mode 100644 index 0000000..b2dad49 --- /dev/null +++ b/backend/debug_login_page.py @@ -0,0 +1,202 @@ +""" +小红书登录页面调试脚本 +用于调试登录页面结构和元素选择器 +""" +import asyncio +import sys +from xhs_login import XHSLoginService + + +async def debug_login_page(proxy_index: int = 0): + """ + 调试登录页面,查看页面结构和可用元素 + """ + print(f"\n{'='*60}") + print(f"🔍 调试小红书登录页面") + print(f"{'='*60}") + + # 从代理配置获取代理信息 + from damai_proxy_config import get_proxy_config + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + + # 创建登录服务 + login_service = XHSLoginService(use_pool=False) # 不使用池,便于调试 + + try: + # 初始化浏览器(使用代理) + user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) + print("✅ 浏览器初始化成功(已启用代理)") + + # 访问登录页面 + print(f"\n🌐 访问小红书创作者平台登录页...") + await login_service.page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=30000) + await asyncio.sleep(5) # 等待更长时间让页面完全加载 + + # 获取页面标题和URL + title = await login_service.page.title() + url = login_service.page.url + print(f"✅ 页面加载完成") + print(f" 标题: {title}") + print(f" URL: {url}") + + # 获取页面内容 + content = await login_service.page.content() + print(f" 页面内容长度: {len(content)} 字符") + + # 查找所有input元素 + print(f"\n🔍 查找所有input元素...") + inputs = await login_service.page.query_selector_all('input') + print(f" 找到 {len(inputs)} 个input元素") + + for i, inp in enumerate(inputs): + try: + placeholder = await inp.get_attribute('placeholder') + input_type = await inp.get_attribute('type') + name = await inp.get_attribute('name') + class_name = await inp.get_attribute('class') + id_attr = await inp.get_attribute('id') + + print(f" Input {i+1}:") + print(f" - placeholder: {placeholder}") + print(f" - type: {input_type}") + print(f" - name: {name}") + print(f" - id: {id_attr}") + print(f" - class: {class_name}") + except Exception as e: + print(f" Input {i+1}: 获取属性失败 - {str(e)}") + + # 查找所有可能的手机号输入框选择器 + print(f"\n🔍 尝试常见手机号输入框选择器...") + phone_selectors = [ + 'input[placeholder="手机号"]', + 'input[placeholder*="手机"]', + 'input[type="tel"]', + 'input[type="text"][placeholder*="号"]', + 'input[placeholder*="Phone"]', + 'input[name*="phone"]', + 'input[placeholder*="号码"]', + 'input[placeholder*="mobile"]', + 'input[placeholder*="Mobile"]' + ] + + found_inputs = [] + for selector in phone_selectors: + try: + element = await login_service.page.query_selector(selector) + if element: + found_inputs.append((selector, element)) + placeholder = await element.get_attribute('placeholder') + print(f" ✅ 找到: {selector} (placeholder: {placeholder})") + except Exception as e: + print(f" ❌ 选择器 {selector} 失败: {str(e)}") + + if not found_inputs: + print(" ❌ 未找到任何手机号相关输入框") + + # 查找所有按钮元素 + print(f"\n🔍 查找所有button元素...") + buttons = await login_service.page.query_selector_all('button') + print(f" 找到 {len(buttons)} 个button元素") + + for i, btn in enumerate(buttons[:10]): # 只显示前10个 + try: + text = await btn.inner_text() + class_name = await btn.get_attribute('class') + id_attr = await btn.get_attribute('id') + + print(f" Button {i+1}:") + print(f" - text: '{text.strip()}'") + print(f" - class: {class_name}") + print(f" - id: {id_attr}") + except Exception as e: + print(f" Button {i+1}: 获取信息失败 - {str(e)}") + + # 查找发送验证码按钮 + print(f"\n🔍 尝试常见发送验证码按钮选择器...") + code_selectors = [ + 'text="发送验证码"', + 'text="获取验证码"', + 'text="发送"', + 'text="获取"', + 'button:has-text("验证码")', + 'button:has-text("发送")', + 'button:has-text("获取")', + '[class*="send"]', + '[class*="code"]', + '[class*="verify"]' + ] + + found_buttons = [] + for selector in code_selectors: + try: + element = await login_service.page.query_selector(selector) + if element: + found_buttons.append((selector, element)) + text = await element.inner_text() + print(f" ✅ 找到: {selector} (text: '{text.strip()}')") + except Exception as e: + print(f" ❌ 选择器 {selector} 失败: {str(e)}") + + if not found_buttons: + print(" ❌ 未找到任何验证码相关按钮") + + # 打印页面HTML片段(用于分析结构) + print(f"\n📄 页面HTML片段(前1000字符)...") + print(content[:1000]) + + print(f"\n📄 页面HTML片段(1000-2000字符)...") + print(content[1000:2000]) + + # 等待用户交互(保持浏览器打开) + print(f"\n⏸️ 浏览器保持打开状态,您可以手动检查页面") + print(f" URL: {url}") + print(f" 按 Ctrl+C 关闭浏览器...") + + try: + while True: + await asyncio.sleep(1) + except KeyboardInterrupt: + print(f"\n⏹️ 用户中断,关闭浏览器...") + + except Exception as e: + print(f"❌ 调试过程异常: {str(e)}") + import traceback + traceback.print_exc() + finally: + await login_service.close_browser() + + +async def main(): + """主函数""" + print("="*60) + print("🔍 小红书登录页面调试工具") + print("="*60) + + print("\n此工具将帮助您分析小红书登录页面的结构") + print("以便正确识别手机号输入框和验证码按钮") + + proxy_choice = input("\n请选择代理 (0 或 1, 默认为0): ").strip() + if proxy_choice not in ['0', '1']: + proxy_choice = '0' + proxy_idx = int(proxy_choice) + + await debug_login_page(proxy_idx) + + print(f"\n{'='*60}") + print("✅ 调试完成!") + print("="*60) + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 运行调试 + asyncio.run(main()) \ No newline at end of file diff --git a/backend/error_screenshot.png b/backend/error_screenshot.png new file mode 100644 index 0000000..139a17b Binary files /dev/null and b/backend/error_screenshot.png differ diff --git a/backend/error_screenshot.py b/backend/error_screenshot.py new file mode 100644 index 0000000..6f662dc --- /dev/null +++ b/backend/error_screenshot.py @@ -0,0 +1,146 @@ +""" +错误截图保存工具 +当发生错误时自动截图并保存,便于问题排查 +""" +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Optional +from playwright.async_api import Page + + +# 截图保存目录 +SCREENSHOT_DIR = Path("error_screenshots") +SCREENSHOT_DIR.mkdir(exist_ok=True) + + +async def save_error_screenshot( + page: Optional[Page], + error_type: str, + error_message: str = "", + prefix: str = "" +) -> Optional[str]: + """ + 保存错误截图 + + Args: + page: Playwright 页面对象 + error_type: 错误类型(如:login_failed, send_code_failed, publish_failed等) + error_message: 错误信息(可选,会添加到日志) + prefix: 文件名前缀(可选) + + Returns: + 截图文件路径,失败返回None + """ + if not page: + print("[错误截图] 页面对象为空,无法截图", file=sys.stderr) + return None + + try: + # 生成文件名:年月日时分秒_错误类型.png + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + # 清理错误类型字符串(移除特殊字符) + safe_error_type = "".join(c for c in error_type if c.isalnum() or c in ('_', '-')) + + # 组合文件名 + if prefix: + filename = f"{prefix}_{timestamp}_{safe_error_type}.png" + else: + filename = f"{timestamp}_{safe_error_type}.png" + + filepath = SCREENSHOT_DIR / filename + + # 截图 + await page.screenshot(path=str(filepath), full_page=True) + + # 打印日志 + print(f"[错误截图] 已保存: {filepath}", file=sys.stderr) + if error_message: + print(f"[错误截图] 错误信息: {error_message}", file=sys.stderr) + + # 返回文件路径 + return str(filepath) + + except Exception as e: + print(f"[错误截图] 截图失败: {str(e)}", file=sys.stderr) + return None + + +def cleanup_old_screenshots(days: int = 7): + """ + 清理旧的错误截图 + + Args: + days: 保留最近几天的截图,默认7天 + """ + try: + import time + current_time = time.time() + cutoff_time = current_time - (days * 24 * 60 * 60) + + deleted_count = 0 + for file in SCREENSHOT_DIR.glob("*.png"): + if file.stat().st_mtime < cutoff_time: + file.unlink() + deleted_count += 1 + + if deleted_count > 0: + print(f"[错误截图] 已清理 {deleted_count} 个超过 {days} 天的旧截图", file=sys.stderr) + + except Exception as e: + print(f"[错误截图] 清理旧截图失败: {str(e)}", file=sys.stderr) + + +async def save_screenshot_with_html( + page: Optional[Page], + error_type: str, + error_message: str = "", + prefix: str = "" +) -> tuple[Optional[str], Optional[str]]: + """ + 保存错误截图和HTML源码(用于深度调试) + + Args: + page: Playwright 页面对象 + error_type: 错误类型 + error_message: 错误信息(可选) + prefix: 文件名前缀(可选) + + Returns: + (截图路径, HTML路径),失败返回(None, None) + """ + if not page: + return None, None + + try: + # 生成文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_error_type = "".join(c for c in error_type if c.isalnum() or c in ('_', '-')) + + if prefix: + base_filename = f"{prefix}_{timestamp}_{safe_error_type}" + else: + base_filename = f"{timestamp}_{safe_error_type}" + + # 保存截图 + screenshot_path = SCREENSHOT_DIR / f"{base_filename}.png" + await page.screenshot(path=str(screenshot_path), full_page=True) + + # 保存HTML + html_path = SCREENSHOT_DIR / f"{base_filename}.html" + html_content = await page.content() + with open(html_path, 'w', encoding='utf-8') as f: + f.write(html_content) + + print(f"[错误截图] 已保存截图: {screenshot_path}", file=sys.stderr) + print(f"[错误截图] 已保存HTML: {html_path}", file=sys.stderr) + if error_message: + print(f"[错误截图] 错误信息: {error_message}", file=sys.stderr) + + return str(screenshot_path), str(html_path) + + except Exception as e: + print(f"[错误截图] 保存截图和HTML失败: {str(e)}", file=sys.stderr) + return None, None diff --git a/backend/error_screenshots/20260101_144751_send_code_input_phone_failed.png b/backend/error_screenshots/20260101_144751_send_code_input_phone_failed.png new file mode 100644 index 0000000..32d0565 Binary files /dev/null and b/backend/error_screenshots/20260101_144751_send_code_input_phone_failed.png differ diff --git a/backend/error_screenshots/20260101_144909_login_failed_wrong_code.png b/backend/error_screenshots/20260101_144909_login_failed_wrong_code.png new file mode 100644 index 0000000..d19566e Binary files /dev/null and b/backend/error_screenshots/20260101_144909_login_failed_wrong_code.png differ diff --git a/backend/example_use_damai_proxy.py b/backend/example_use_damai_proxy.py new file mode 100644 index 0000000..1cf367a --- /dev/null +++ b/backend/example_use_damai_proxy.py @@ -0,0 +1,200 @@ +""" +大麦固定代理使用示例 +演示如何在实际项目中使用固定代理IP +""" +import asyncio +import sys +from browser_pool import get_browser_pool +from damai_proxy_config import get_proxy_1, get_proxy_2, get_random_proxy + + +async def example1_use_specific_proxy(): + """示例1: 使用指定的代理IP""" + print("\n" + "="*60) + print("示例1: 使用指定的代理IP(代理1)") + print("="*60) + + # 获取代理1的配置 + proxy_config = get_proxy_1() + print(f"📌 使用代理: {proxy_config['server']}") + + # 获取浏览器池 + pool = get_browser_pool() + + try: + # 获取浏览器实例(带代理) + # 注意:需要修改browser_pool以支持带认证的代理 + browser, context, page = await pool.get_browser( + proxy=proxy_config["server"] + ) + + # 访问测试页面 + print("🌐 访问IP检测页面...") + await page.goto("http://httpbin.org/ip", timeout=30000) + + # 获取IP信息 + ip_info = await page.evaluate("() => document.body.innerText") + print(f"✅ 当前IP:\n{ip_info}") + + except Exception as e: + print(f"❌ 错误: {str(e)}") + + +async def example2_use_random_proxy(): + """示例2: 随机使用一个代理IP""" + print("\n" + "="*60) + print("示例2: 随机使用一个代理IP") + print("="*60) + + # 随机获取一个代理 + proxy_config = get_random_proxy() + print(f"📌 随机选择代理: {proxy_config['name']}") + print(f" 服务器: {proxy_config['server']}") + + # 后续操作类似示例1 + print("✅ 代理配置已获取,可以用于浏览器实例化") + + +async def example3_use_with_playwright_directly(): + """示例3: 直接在Playwright中使用代理(带认证)""" + print("\n" + "="*60) + print("示例3: 直接在Playwright中使用代理(完整认证)") + print("="*60) + + from playwright.async_api import async_playwright + + # 获取代理配置 + proxy_config = get_proxy_2() + print(f"📌 使用代理2: {proxy_config['server']}") + + playwright = None + browser = None + + try: + # 启动Playwright + playwright = await async_playwright().start() + + # 配置代理(完整配置,包含认证信息) + proxy_settings = { + "server": proxy_config["server"], + "username": proxy_config["username"], + "password": proxy_config["password"] + } + + # 启动浏览器 + browser = await playwright.chromium.launch( + headless=True, + proxy=proxy_settings, + args=['--disable-blink-features=AutomationControlled'] + ) + + # 创建上下文和页面 + context = await browser.new_context() + page = await context.new_page() + + # 访问测试页面 + print("🌐 访问大麦网...") + await page.goto("https://www.damai.cn/", timeout=30000) + + title = await page.title() + print(f"✅ 页面标题: {title}") + print(f" 当前URL: {page.url}") + + except Exception as e: + print(f"❌ 错误: {str(e)}") + + finally: + if browser: + await browser.close() + if playwright: + await playwright.stop() + + +async def example4_switch_proxy_on_error(): + """示例4: 代理失败时自动切换""" + print("\n" + "="*60) + print("示例4: 代理失败时自动切换到另一个代理") + print("="*60) + + from damai_proxy_config import get_all_enabled_proxies + from playwright.async_api import async_playwright + + proxies = get_all_enabled_proxies() + print(f"📊 可用代理数: {len(proxies)}") + + for i, proxy_config in enumerate(proxies): + print(f"\n🔄 尝试代理 {i+1}/{len(proxies)}: {proxy_config['name']}") + + playwright = None + browser = None + + try: + # 启动Playwright + playwright = await async_playwright().start() + + # 配置代理 + proxy_settings = { + "server": proxy_config["server"], + "username": proxy_config["username"], + "password": proxy_config["password"] + } + + # 启动浏览器 + browser = await playwright.chromium.launch( + headless=True, + proxy=proxy_settings + ) + + context = await browser.new_context() + page = await context.new_page() + + # 测试访问 + await page.goto("http://httpbin.org/ip", timeout=15000) + ip_info = await page.evaluate("() => document.body.innerText") + + print(f"✅ {proxy_config['name']} 可用") + print(f" IP信息: {ip_info.strip()}") + + # 成功则退出循环 + await browser.close() + await playwright.stop() + break + + except Exception as e: + print(f"⚠️ {proxy_config['name']} 不可用: {str(e)}") + if browser: + await browser.close() + if playwright: + await playwright.stop() + + # 如果是最后一个代理也失败,则报错 + if i == len(proxies) - 1: + print("❌ 所有代理都不可用!") + + +async def main(): + """运行所有示例""" + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + print("\n" + "🎯"*30) + print("大麦固定代理IP使用示例集") + print("🎯"*30) + + # 示例2: 随机代理 + await example2_use_random_proxy() + + # 示例3: 完整的Playwright代理使用 + await example3_use_with_playwright_directly() + + # 示例4: 代理容错切换 + await example4_switch_proxy_on_error() + + print("\n" + "="*60) + print("🎉 所有示例运行完成!") + print("="*60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/main.py b/backend/main.py index e2c0a75..34be784 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,14 +1,32 @@ +# Windows兼容性:必须在任何异步操作之前设置事件循环策略 +import sys +import asyncio +import aiohttp +import json +if sys.platform == 'win32': + # Windows下使用ProactorEventLoopPolicy来支持Playwright的子进程 + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + print("[系统] Windows环境已设置ProactorEventLoopPolicy", file=sys.stderr) + +# 加载配置 +from config import init_config, get_config +from dotenv import load_dotenv +load_dotenv() # 从 .env 文件加载环境变量(可选,用于覆盖配置文件) + from fastapi import FastAPI, HTTPException, File, UploadFile, Form from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional, Dict, Any, List -import asyncio from datetime import datetime import os import shutil from pathlib import Path from xhs_login import XHSLoginService +from browser_pool import get_browser_pool +from scheduler import XHSScheduler +from error_screenshot import cleanup_old_screenshots +from ali_sms_service import AliSmsService app = FastAPI(title="小红书登录API") @@ -21,8 +39,54 @@ app.add_middleware( allow_headers=["*"], ) -# 全局登录服务实例 -login_service = XHSLoginService() +# 全局登录服务实例(延迟初始化,避免在startup前创建浏览器池) +login_service = None + +# 全局浏览器池实例(在startup时初始化) +browser_pool = None + +# 全局调度器实例 +scheduler = None + +# 全局阿里云短信服务实例 +sms_service = None + + +async def fetch_proxy_from_pool() -> Optional[str]: + """从代理池接口获取一个代理地址(http://ip:port),获取失败返回None""" + config = get_config() + if not config.get_bool('proxy_pool.enabled', False): + return None + + api_url = config.get_str('proxy_pool.api_url', '') + if not api_url: + return None + + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(api_url) as resp: + if resp.status != 200: + print(f"[代理池] 接口返回非200状态码: {resp.status}", file=sys.stderr) + return None + + text = (await resp.text()).strip() + if not text: + print("[代理池] 返回内容为空", file=sys.stderr) + return None + + line = text.splitlines()[0].strip() + if not line: + print("[代理池] 首行内容为空", file=sys.stderr) + return None + + if line.startswith("http://") or line.startswith("https://"): + return line + return "http://" + line + except Exception as e: + print(f"[代理池] 请求失败: {str(e)}", file=sys.stderr) + return None + # 临时文件存储目录 TEMP_DIR = Path("temp_uploads") @@ -32,11 +96,19 @@ TEMP_DIR.mkdir(exist_ok=True) class SendCodeRequest(BaseModel): phone: str country_code: str = "+86" + login_page: Optional[str] = None # 登录页面:creator 或 home,为None时使用配置文件默认值 + +class VerifyCodeRequest(BaseModel): + phone: str + code: str + country_code: str = "+86" class LoginRequest(BaseModel): phone: str code: str country_code: str = "+86" + login_page: Optional[str] = None # 登录页面:creator 或 home,为None时使用配置文件默认值 + session_id: Optional[str] = None # 可选:复用send-code接口的session_id class PublishNoteRequest(BaseModel): title: str @@ -44,8 +116,20 @@ class PublishNoteRequest(BaseModel): images: Optional[list] = None topics: Optional[list] = None +class PublishWithCookiesRequest(BaseModel): + cookies: Optional[list] = None # 兼容旧版,仅传Cookies + login_state: Optional[dict] = None # 新版,传完整的login_state + storage_state_path: Optional[str] = None # 新增:storage_state文件路径(最优先) + phone: Optional[str] = None # 新增:手机号,用于查找storage_state文件 + title: str + content: str + images: Optional[list] = None + topics: Optional[list] = None + class InjectCookiesRequest(BaseModel): - cookies: list + cookies: Optional[list] = None # 兼容旧版,仅传Cookies + login_state: Optional[dict] = None # 新版,传完整的login_state + target_page: Optional[str] = "creator" # 目标页面:creator 或 home # 响应模型 class BaseResponse(BaseModel): @@ -55,32 +139,241 @@ class BaseResponse(BaseModel): @app.on_event("startup") async def startup_event(): - """启动时不初始化浏览器,等待第一次请求时再初始化""" - pass + """启动时启动后台清理任务和定时发布任务(已禁用预热)""" + # 初始化配置(从ENV环境变量读取,默认dev) + config = init_config() + + print("[服务启动] FastAPI服务启动,浏览器池已就绪") + + # 清理旧的错误截图(保留最近7天) + try: + cleanup_old_screenshots(days=7) + except Exception as e: + print(f"[启动] 清理旧截图失败: {str(e)}") + + # 从配置文件读取headless参数 + headless = config.get_bool('scheduler.headless', True) # 定时发布的headless配置 + login_headless = config.get_bool('login.headless', False) # 登录/绑定的headless配置,默认为有头模式 + login_page = config.get_str('login.page', 'creator') # 登录页面类型,默认为创作者中心 + + # 根据配置自动调整预热URL + if login_page == "home": + preheat_url = "https://www.xiaohongshu.com" + else: + preheat_url = "https://creator.xiaohongshu.com/login" + + # 初始化全局浏览器池(使用配置的headless参数) + global browser_pool, login_service, sms_service + browser_pool = get_browser_pool(idle_timeout=1800, headless=headless) + print(f"[服务启动] 浏览器池模式: {'headless(无头模式)' if headless else 'headed(有头模式)'}") + + # 初始化登录服务(使用独立的login.headless配置) + login_service = XHSLoginService(use_pool=True, headless=login_headless) + print(f"[服务启动] 登录服务模式: {'headless(无头模式)' if login_headless else 'headed(有头模式)'}") + + # 初始化阿里云短信服务 + sms_dict = config.get_dict('ali_sms') + sms_service = AliSmsService( + access_key_id=sms_dict.get('access_key_id', ''), + access_key_secret=sms_dict.get('access_key_secret', ''), + sign_name=sms_dict.get('sign_name', ''), + template_code=sms_dict.get('template_code', '') + ) + print("[服务启动] 阿里云短信服务已初始化") + + # 启动浏览器池清理任务 + asyncio.create_task(browser_cleanup_task()) + + # 已禁用预热功能,避免干扰正常业务流程 + # asyncio.create_task(browser_preheat_task()) + print("[服务启动] 浏览器预热功能已禁用") + + # 启动定时发布任务 + global scheduler + + # 从配置文件读取数据库配置 + db_dict = config.get_dict('database') + db_config = { + 'host': db_dict.get('host', 'localhost'), + 'port': db_dict.get('port', 3306), + 'user': db_dict.get('username', 'root'), + 'password': db_dict.get('password', ''), + 'database': db_dict.get('dbname', 'ai_wht') + } + + # 从配置文件读取调度器配置 + scheduler_enabled = config.get_bool('scheduler.enabled', False) + proxy_pool_enabled = config.get_bool('proxy_pool.enabled', False) + proxy_pool_api_url = config.get_str('proxy_pool.api_url', '') + enable_random_ua = config.get_bool('scheduler.enable_random_ua', True) + min_publish_interval = config.get_int('scheduler.min_publish_interval', 30) + max_publish_interval = config.get_int('scheduler.max_publish_interval', 120) + # headless已经在上面读取了 + + if scheduler_enabled: + scheduler = XHSScheduler( + db_config=db_config, + max_concurrent=config.get_int('scheduler.max_concurrent', 2), + publish_timeout=config.get_int('scheduler.publish_timeout', 300), + max_articles_per_user_per_run=config.get_int('scheduler.max_articles_per_user_per_run', 2), + max_failures_per_user_per_run=config.get_int('scheduler.max_failures_per_user_per_run', 3), + max_daily_articles_per_user=config.get_int('scheduler.max_daily_articles_per_user', 6), + max_hourly_articles_per_user=config.get_int('scheduler.max_hourly_articles_per_user', 2), + proxy_pool_enabled=proxy_pool_enabled, + proxy_pool_api_url=proxy_pool_api_url, + enable_random_ua=enable_random_ua, + min_publish_interval=min_publish_interval, + max_publish_interval=max_publish_interval, + headless=headless, # 新增: 传递headless参数 + ) + + cron_expr = config.get_str('scheduler.cron', '*/5 * * * * *') + scheduler.start(cron_expr) + print(f"[服务启动] 定时发布任务已启动,Cron: {cron_expr}") + else: + print("[服务启动] 定时发布任务未启用") + +async def browser_cleanup_task(): + """后台任务:定期清理空闲浏览器""" + while True: + await asyncio.sleep(300) # 每5分钟检查一次 + try: + await browser_pool.cleanup_if_idle() + except Exception as e: + print(f"[清理任务] 浏览器清理异常: {str(e)}") + +async def browser_preheat_task(): + """后台任务:预热浏览器""" + try: + # 延迟3秒启动,避免影响服务启动速度 + await asyncio.sleep(3) + print("[预热任务] 开始预热浏览器...") + await browser_pool.preheat("https://creator.xiaohongshu.com/login") + except Exception as e: + print(f"[预热任务] 预热失败: {str(e)}") + +async def repreheat_browser_after_use(): + """后台任务:使用后补充预热浏览器(仅用于登录流程)""" + try: + # 延迟5秒,确保: + # 1. 响应已经返回给用户 + # 2. Cookie已经完全获取并保存 + # 3. 登录流程完全结束 + await asyncio.sleep(5) + print("[补充预热任务] 开始补充预热浏览器...") + await browser_pool.repreheat("https://creator.xiaohongshu.com/login") + except Exception as e: + print(f"[补充预热任务] 补充预热失败: {str(e)}") @app.on_event("shutdown") async def shutdown_event(): - """关闭时清理浏览器""" - await login_service.close_browser() + """关闭时清理浏览器池和停止调度器""" + print("[服务关闭] 正在关闭服务...") + + # 停止调度器 + global scheduler + if scheduler: + scheduler.stop() + print("[服务关闭] 调度器已停止") + + # 关闭浏览器池 + await browser_pool.close() + print("[服务关闭] 浏览器池已关闭") @app.post("/api/xhs/send-code", response_model=BaseResponse) async def send_code(request: SendCodeRequest): """ 发送验证码 通过playwright访问小红书官网,输入手机号并触发验证码发送 + 支持选择从创作者中心或小红书首页登录 + 并发支持:为每个请求分配独立的浏览器实例 """ + # 使用手机号作为session_id,确保发送验证码和登录验证使用同一个浏览器 + session_id = f"xhs_login_{request.phone}" + print(f"[发送验证码] session_id={session_id}, phone={request.phone}", file=sys.stderr) + + # 获取配置中的默认login_page,如果API传入了则优先使用API参数 + config = get_config() + default_login_page = config.get_str('login.page', 'creator') + login_page = request.login_page if request.login_page else default_login_page + + print(f"[发送验证码] 使用登录页面: {login_page} (配置默认={default_login_page}, API参数={request.login_page})", file=sys.stderr) + try: + # 为此请求创建独立的登录服务实例,使用session_id实现并发隔离 + request_login_service = XHSLoginService( + use_pool=True, + headless=login_service.headless, # 使用配置文件中的login.headless配置 + session_id=session_id # 关键:传递session_id + ) + # 调用登录服务发送验证码 - result = await login_service.send_verification_code( + result = await request_login_service.send_verification_code( phone=request.phone, - country_code=request.country_code + country_code=request.country_code, + login_page=login_page # 传递登录页面参数 ) if result["success"]: return BaseResponse( code=0, message="验证码已发送,请在小红书APP中查看", - data={"sent_at": datetime.now().isoformat()} + data={ + "sent_at": datetime.now().isoformat(), + "session_id": session_id # 返回session_id供前端使用 + } + ) + else: + # 发送失败,释放临时浏览器 + if session_id and browser_pool: + try: + await browser_pool.release_temp_browser(session_id) + print(f"[发送验证码] 已释放临时浏览器: {session_id}", file=sys.stderr) + except Exception as e: + print(f"[发送验证码] 释放临时浏览器失败: {str(e)}", file=sys.stderr) + + return BaseResponse( + code=1, + message=result.get("error", "发送验证码失败"), + data=None + ) + + except Exception as e: + print(f"发送验证码异常: {str(e)}", file=sys.stderr) + + # 异常情况,释放临时浏览器 + if session_id and browser_pool: + try: + await browser_pool.release_temp_browser(session_id) + print(f"[发送验证码] 已释放临时浏览器: {session_id}", file=sys.stderr) + except Exception as release_error: + print(f"[发送验证码] 释放临时浏览器失败: {str(release_error)}", file=sys.stderr) + + return BaseResponse( + code=1, + message=f"发送验证码失败: {str(e)}", + data=None + ) + +@app.post("/api/xhs/phone/send-code", response_model=BaseResponse) +async def send_phone_code(request: SendCodeRequest): + """ + 发送手机短信验证码(使用阿里云短信服务) + 用于小红书手机号验证码登录 + """ + try: + # 调用阿里云短信服务发送验证码 + result = await sms_service.send_verification_code(request.phone) + + if result["success"]: + return BaseResponse( + code=0, + message=result.get("message", "验证码已发送"), + data={ + "sent_at": datetime.now().isoformat(), + # 开发环境返回验证码,生产环境应移除 + "code": result.get("code") if get_config().get_bool('server.debug', False) else None + } ) else: return BaseResponse( @@ -90,28 +383,104 @@ async def send_code(request: SendCodeRequest): ) except Exception as e: - print(f"发送验证码异常: {str(e)}") + print(f"发送短信验证码异常: {str(e)}") return BaseResponse( code=1, message=f"发送验证码失败: {str(e)}", data=None ) +@app.post("/api/xhs/phone/verify-code", response_model=BaseResponse) +async def verify_phone_code(request: VerifyCodeRequest): + """ + 验证手机短信验证码 + 用于小红书手机号验证码登录 + """ + try: + # 调用阿里云短信服务验证验证码 + result = sms_service.verify_code(request.phone, request.code) + + if result["success"]: + return BaseResponse( + code=0, + message="验证码验证成功", + data={"verified_at": datetime.now().isoformat()} + ) + else: + return BaseResponse( + code=1, + message=result.get("error", "验证码验证失败"), + data=None + ) + + except Exception as e: + print(f"验证验证码异常: {str(e)}") + return BaseResponse( + code=1, + message=f"验证失败: {str(e)}", + data=None + ) + @app.post("/api/xhs/login", response_model=BaseResponse) async def login(request: LoginRequest): """ 登录验证 用户填写验证码后,完成登录并获取小红书返回的数据 + 支持选择从创作者中心或小红书首页登录 + 并发支持:可复用send-code接口的session_id """ + # 使用手机号作为session_id,复用发送验证码时的浏览器 + # 如果前端传了session_id就使用前端的,否则根据手机号生成 + if not request.session_id: + session_id = f"xhs_login_{request.phone}" + else: + session_id = request.session_id + + print(f"[登录验证] session_id={session_id}, phone={request.phone}", file=sys.stderr) + + # 获取配置中的默认login_page,如果API传入了则优先使用API参数 + config = get_config() + default_login_page = config.get_str('login.page', 'creator') + login_page = request.login_page if request.login_page else default_login_page + + print(f"[登录验证] 使用登录页面: {login_page} (配置默认={default_login_page}, API参数={request.login_page})", file=sys.stderr) + try: + # 如果有session_id,复用send-code的浏览器;否则创建新的 + if session_id: + print(f"[登录验证] 复用send-code的浏览器: {session_id}", file=sys.stderr) + request_login_service = XHSLoginService( + use_pool=True, + headless=login_service.headless, # 使用配置文件中的login.headless配置 + session_id=session_id + ) + # 初始化浏览器,以便从浏览器池获取临时浏览器 + await request_login_service.init_browser() + else: + # 旧逻辑:不传session_id,使用全局登录服务 + print(f"[登录验证] 使用全局登录服务(旧逻辑)", file=sys.stderr) + request_login_service = login_service + # 调用登录服务进行登录 - result = await login_service.login( + result = await request_login_service.login( phone=request.phone, code=request.code, - country_code=request.country_code + country_code=request.country_code, + login_page=login_page # 传递登录页面参数 ) + # 释放临时浏览器(无论成功还是失败) + if session_id and browser_pool: + try: + await browser_pool.release_temp_browser(session_id) + print(f"[登录验证] 已释放临时浏览器: {session_id}", file=sys.stderr) + except Exception as e: + print(f"[登录验证] 释放临时浏览器失败: {str(e)}", file=sys.stderr) + if result["success"]: + # 登录成功,不再触发预热(已禁用预热功能) + # asyncio.create_task(repreheat_browser_after_use()) + return BaseResponse( code=0, message="登录成功", @@ -119,10 +488,16 @@ async def login(request: LoginRequest): "user_info": result.get("user_info"), "cookies": result.get("cookies"), # 键值对格式(前端展示) "cookies_full": result.get("cookies_full"), # Playwright完整格式(数据库存储/脚本使用) + "login_state": result.get("login_state"), # 完整登录状态(包含cookies + localStorage + sessionStorage) + "localStorage": result.get("localStorage"), # localStorage数据 + "sessionStorage": result.get("sessionStorage"), # sessionStorage数据 + "url": result.get("url"), # 当前URL + "storage_state_path": result.get("storage_state_path"), # storage_state文件路径 "login_time": datetime.now().isoformat() } ) else: + # 登录失败 return BaseResponse( code=1, message=result.get("error", "登录失败"), @@ -130,7 +505,16 @@ async def login(request: LoginRequest): ) except Exception as e: - print(f"登录异常: {str(e)}") + print(f"登录异常: {str(e)}", file=sys.stderr) + + # 异常情况,释放临时浏览器 + if session_id and browser_pool: + try: + await browser_pool.release_temp_browser(session_id) + print(f"[登录验证] 已释放临时浏览器: {session_id}", file=sys.stderr) + except Exception as release_error: + print(f"[登录验证] 释放临时浏览器失败: {str(release_error)}", file=sys.stderr) + return BaseResponse( code=1, message=f"登录失败: {str(e)}", @@ -140,31 +524,99 @@ async def login(request: LoginRequest): @app.get("/") async def root(): """健康检查""" - return {"status": "ok", "message": "小红书登录服务运行中"} + if browser_pool: + stats = browser_pool.get_stats() + return { + "status": "ok", + "message": "小红书登录服务运行中(浏览器池模式)", + "browser_pool": stats + } + return {"status": "ok", "message": "服务初始化中..."} + +@app.get("/api/health") +async def health_check(): + """健康检查接口(详细)""" + if browser_pool: + stats = browser_pool.get_stats() + return { + "status": "healthy", + "service": "xhs-login-service", + "mode": "browser-pool", + "browser_pool_stats": stats, + "timestamp": datetime.now().isoformat() + } + return { + "status": "initializing", + "service": "xhs-login-service", + "timestamp": datetime.now().isoformat() + } @app.post("/api/xhs/inject-cookies", response_model=BaseResponse) async def inject_cookies(request: InjectCookiesRequest): """ - 注入Cookies并验证登录状态 - 允许使用之前保存的Cookies跳过登录 + 注入Cookies或完整登录状态并验证 + 支持两种模式: + 1. 仅注入Cookies(兼容旧版) + 2. 注入完整login_state(包含Cookies + localStorage + sessionStorage) + 支持选择跳转到创作者中心或小红书首页 + + 重要:为了避免检测,不使用浏览器池,每次创建全新的浏览器实例 """ try: # 关闭旧的浏览器(如果有) if login_service.browser: await login_service.close_browser() - # 使用Cookies初始化浏览器 - await login_service.init_browser(cookies=request.cookies) + # 创建一个独立的登录服务实例,不使用浏览器池 + print("✅ 为注入Cookie创建全新的浏览器实例,不使用浏览器池", file=sys.stderr) + inject_service = XHSLoginService(use_pool=False, headless=False) # 不使用浏览器池,使用有头模式方便调试 - # 验证登录状态 - result = await login_service.verify_login_status() + # 优先使用login_state,其次使用cookies + if request.login_state: + # 新版:使用完整的login_state + print("✅ 检测到login_state,将恢复完整登录状态", file=sys.stderr) + + # 保存login_state到文件,供 init_browser 加载 + with open('login_state.json', 'w', encoding='utf-8') as f: + json.dump(request.login_state, f, ensure_ascii=False, indent=2) + + # 使用restore_state=True恢复完整状态 + await inject_service.init_browser(restore_state=True) + + elif request.cookies: + # 兼容旧版:仅使用Cookies + print("⚠️ 检测到仅有Cookies,建议使用login_state获取更好的兼容性", file=sys.stderr) + await inject_service.init_browser(cookies=request.cookies) + + else: + return BaseResponse( + code=1, + message="请提供 cookies 或 login_state", + data=None + ) + + # 根据target_page参数确定验证URL + target_page = request.target_page or "creator" + if target_page == "home": + verify_url = "https://www.xiaohongshu.com" + page_name = "小红书首页" + else: + verify_url = "https://creator.xiaohongshu.com" + page_name = "创作者中心" + + # 访问目标页面并验证登录状态 + result = await inject_service.verify_login_status(url=verify_url) + + # 关闭独立的浏览器实例(注:因为不是池模式,会真正关闭) + # await inject_service.close_browser() # 先不关闭,让用户看到结果 if result.get("logged_in"): return BaseResponse( code=0, - message="Cookie注入成功,已登录", + message=f"{'login_state' if request.login_state else 'Cookie'}注入成功,已跳转到{page_name}", data={ "logged_in": True, + "target_page": page_name, "user_info": result.get("user_info"), "cookies": result.get("cookies"), # 键值对格式 "cookies_full": result.get("cookies_full"), # Playwright完整格式 @@ -172,37 +624,117 @@ async def inject_cookies(request: InjectCookiesRequest): } ) else: + # 失败时关闭浏览器 + await inject_service.close_browser() + return BaseResponse( code=1, - message=result.get("message", "Cookie已失效,请重新登录"), + message=result.get("message", "{'login_state' if request.login_state else 'Cookie'}已失效,请重新登录"), data={ "logged_in": False } ) except Exception as e: - print(f"注入Cookies异常: {str(e)}") + print(f"注入失败: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() return BaseResponse( code=1, message=f"注入失败: {str(e)}", data=None ) -@app.post("/api/xhs/publish", response_model=BaseResponse) -async def publish_note(request: PublishNoteRequest): +@app.post("/api/xhs/publish-with-cookies", response_model=BaseResponse) +async def publish_note_with_cookies(request: PublishWithCookiesRequest): """ - 发布笔记 - 登录后可以发布图文笔记到小红书 + 使用Cookies或完整login_state或storage_state发布笔记(供Go后端定时任务调用) + 支持三种模式(按优先级): + 1. 使用storage_state_path(推荐,最完整的登录状态) + 2. 传入完整login_state(次选,包含cookies + localStorage + sessionStorage) + 3. 仅传入Cookies(兼容旧版) + + 重要:为了避免检测,不使用浏览器池,每次创建全新的浏览器实例 """ try: - # 调用登录服务发布笔记 - result = await login_service.publish_note( + # 获取代理(如果启用) + proxy = await fetch_proxy_from_pool() + if proxy: + print(f"[发布接口] 使用代理: {proxy}", file=sys.stderr) + + # 创建一个独立的登录服务实例,不使用浏览器池,应用所有反检测措施 + print("✅ 为发布任务创建全新的浏览器实例,不使用浏览器池", file=sys.stderr) + + # 从配置读取headless参数 + config = get_config() + headless = config.get_bool('scheduler.headless', True) + + publish_service = XHSLoginService(use_pool=False, headless=headless) # 不使用浏览器池 + + # 优先级判断:storage_state_path > login_state > cookies + if request.storage_state_path or request.phone: + # 模式1:使用storage_state(最优先) + storage_state_file = None + + if request.storage_state_path: + # 直接指定了storage_state路径 + storage_state_file = request.storage_state_path + elif request.phone: + # 根据手机号查找 + storage_state_dir = 'storage_states' + storage_state_file = os.path.join(storage_state_dir, f"xhs_{request.phone}.json") + + if storage_state_file and os.path.exists(storage_state_file): + print(f"✅ 检测到storage_state文件: {storage_state_file},将使用Playwright原生恢复", file=sys.stderr) + + # 使用Playwright原生API恢复登录状态 + await publish_service.init_browser_with_storage_state( + storage_state_path=storage_state_file, + proxy=proxy + ) + else: + print(f"⚠️ storage_state文件不存在: {storage_state_file},回退到login_state或cookies模式", file=sys.stderr) + # 回退到旧模式 + if request.login_state: + await _init_with_login_state(publish_service, request.login_state, proxy) + elif request.cookies: + await publish_service.init_browser(cookies=request.cookies, proxy=proxy) + else: + return BaseResponse( + code=1, + message="storage_state文件不存在,且未提供 login_state 或 cookies", + data=None + ) + + elif request.login_state: + # 模式2:使用login_state + print("✅ 检测到login_state,将恢复完整登录状态", file=sys.stderr) + await _init_with_login_state(publish_service, request.login_state, proxy) + + elif request.cookies: + # 模式3:仅使用Cookies(兼容旧版) + print("⚠️ 检测到仅有Cookies,建议使用storage_state或login_state获取更好的兼容性", file=sys.stderr) + await publish_service.init_browser(cookies=request.cookies, proxy=proxy) + else: + return BaseResponse( + code=1, + message="请提供 storage_state_path、phone、login_state 或 cookies", + data=None + ) + + # 调用发布方法(使用已经初始化好的publish_service) + result = await publish_service.publish_note( title=request.title, content=request.content, images=request.images, - topics=request.topics + topics=request.topics, + cookies=None, # 已经注入,不需要再传 + proxy=None, # 已经设置,不需要再传 ) + # 关闭独立的浏览器实例 + await publish_service.close_browser() + if result["success"]: return BaseResponse( code=0, @@ -220,13 +752,55 @@ async def publish_note(request: PublishNoteRequest): ) except Exception as e: - print(f"发布笔记异常: {str(e)}") + print(f"发布笔记异常: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() return BaseResponse( code=1, message=f"发布失败: {str(e)}", data=None ) +async def _init_with_login_state(publish_service, login_state, proxy): + """使用login_state初始化浏览器""" + # 保存login_state到临时文件 + import tempfile + import uuid + temp_file = os.path.join(tempfile.gettempdir(), f"login_state_{uuid.uuid4()}.json") + with open(temp_file, 'w', encoding='utf-8') as f: + json.dump(login_state, f, ensure_ascii=False, indent=2) + + # 使用restore_state=True恢复完整状态 + await publish_service.init_browser( + cookies=login_state.get('cookies'), + proxy=proxy, + user_agent=login_state.get('user_agent') + ) + + # 恢夏localStorage和sessionStorage + try: + if login_state.get('localStorage') or login_state.get('sessionStorage'): + target_url = login_state.get('url', 'https://creator.xiaohongshu.com') + await publish_service.page.goto(target_url, wait_until='domcontentloaded', timeout=15000) + + if login_state.get('localStorage'): + for key, value in login_state['localStorage'].items(): + await publish_service.page.evaluate(f'localStorage.setItem("{key}", {json.dumps(value)})') + + if login_state.get('sessionStorage'): + for key, value in login_state['sessionStorage'].items(): + await publish_service.page.evaluate(f'sessionStorage.setItem("{key}", {json.dumps(value)})') + + print("✅ 已恢夏localStorage和sessionStorage", file=sys.stderr) + except Exception as e: + print(f"⚠️ 恢夏storage失败: {str(e)}", file=sys.stderr) + + # 清理临时文件 + try: + os.remove(temp_file) + except: + pass + @app.post("/api/xhs/upload-images") async def upload_images(files: List[UploadFile] = File(...)): """ @@ -279,4 +853,20 @@ async def upload_images(files: List[UploadFile] = File(...)): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + + # 从配置文件读取服务器配置 + config = get_config() + host = config.get_str('server.host', '0.0.0.0') + port = config.get_int('server.port', 8000) + debug = config.get_bool('server.debug', False) + reload = config.get_bool('server.reload', False) + + print(f"[\u542f\u52a8\u670d\u52a1] \u4e3b\u673a: {host}, \u7aef\u53e3: {port}, \u8c03\u8bd5: {debug}, \u70ed\u91cd\u8f7d: {reload}") + + uvicorn.run( + app, + host=host, + port=port, + reload=reload, + log_level="debug" if debug else "info" + ) diff --git a/backend/oss_utils.py b/backend/oss_utils.py new file mode 100644 index 0000000..255bb9a --- /dev/null +++ b/backend/oss_utils.py @@ -0,0 +1,157 @@ +""" +阿里云OSS工具类 +用于Python脚本中上传/下载文件到OSS +""" +import os +import oss2 +from datetime import datetime +from typing import Optional + + +class OSSUploader: + """OSS上传工具""" + + def __init__( + self, + access_key_id: Optional[str] = None, + access_key_secret: Optional[str] = None, + bucket_name: Optional[str] = None, + endpoint: Optional[str] = None + ): + """ + 初始化OSS客户端 + + Args: + access_key_id: AccessKey ID(可选,默认从环境变量读取) + access_key_secret: AccessKey Secret(可选,默认从环境变量读取) + bucket_name: Bucket名称(可选,默认从环境变量读取) + endpoint: OSS访问域名(可选,默认从环境变量读取) + """ + # 使用提供的值或从环境变量读取 + self.access_key_id = access_key_id or os.getenv('OSS_TEST_ACCESS_KEY_ID', 'LTAI5tNesdhDH4ErqEUZmEg2') + self.access_key_secret = access_key_secret or os.getenv('OSS_TEST_ACCESS_KEY_SECRET', 'xZn7WUkTW76TqOLTh01zZATnU6p3Tf') + self.bucket_name = bucket_name or os.getenv('OSS_TEST_BUCKET', 'bxmkb-beijing') + self.endpoint = endpoint or os.getenv('OSS_TEST_ENDPOINT', 'https://oss-cn-beijing.aliyuncs.com/') + + # 移除endpoint中的协议前缀(oss2库不需要https://) + self.endpoint = self.endpoint.replace('https://', '').replace('http://', '') + + # 创建认证对象 + self.auth = oss2.Auth(self.access_key_id, self.access_key_secret) + + # 创建Bucket对象 + self.bucket = oss2.Bucket(self.auth, self.endpoint, self.bucket_name) + + # 基础路径 + self.base_path = "wht/" + + def upload_file(self, local_file_path: str, object_name: Optional[str] = None) -> str: + """ + 上传文件到OSS + + Args: + local_file_path: 本地文件路径 + object_name: OSS对象名称(可选,默认自动生成) + + Returns: + OSS文件的完整URL + """ + # 如果未指定对象名称,自动生成 + if object_name is None: + # 生成格式: wht/YYYYMMDD/timestamp_filename.ext + now = datetime.now() + date_dir = now.strftime("%Y%m%d") + timestamp = int(now.timestamp()) + filename = os.path.basename(local_file_path) + object_name = f"{self.base_path}{date_dir}/{timestamp}_{filename}" + + # 上传文件 + self.bucket.put_object_from_file(object_name, local_file_path) + + # 生成访问URL + url = f"https://{self.bucket_name}.{self.endpoint}/{object_name}" + + return url + + def upload_bytes(self, data: bytes, filename: str) -> str: + """ + 上传字节数据到OSS + + Args: + data: 文件字节数据 + filename: 文件名(用于生成扩展名) + + Returns: + OSS文件的完整URL + """ + # 生成对象名称 + now = datetime.now() + date_dir = now.strftime("%Y%m%d") + timestamp = int(now.timestamp()) + + # 获取扩展名 + ext = os.path.splitext(filename)[1] or '.jpg' + object_name = f"{self.base_path}{date_dir}/{timestamp}_{filename}" + + # 上传数据 + self.bucket.put_object(object_name, data) + + # 生成访问URL + url = f"https://{self.bucket_name}.{self.endpoint}/{object_name}" + + return url + + def delete_file(self, file_url: str) -> bool: + """ + 从OSS删除文件 + + Args: + file_url: OSS文件的完整URL + + Returns: + 是否删除成功 + """ + try: + # 从URL中提取对象名称 + # 格式: https://bucket.endpoint/path/file.jpg + prefix = f"https://{self.bucket_name}.{self.endpoint}/" + if file_url.startswith(prefix): + object_name = file_url[len(prefix):] + self.bucket.delete_object(object_name) + return True + else: + return False + except Exception as e: + print(f"删除OSS文件失败: {e}") + return False + + def file_exists(self, file_url: str) -> bool: + """ + 检查OSS文件是否存在 + + Args: + file_url: OSS文件的完整URL + + Returns: + 文件是否存在 + """ + try: + prefix = f"https://{self.bucket_name}.{self.endpoint}/" + if file_url.startswith(prefix): + object_name = file_url[len(prefix):] + return self.bucket.object_exists(object_name) + else: + return False + except Exception: + return False + + +# 创建默认实例(使用环境变量配置) +default_uploader = None + +def get_oss_uploader() -> OSSUploader: + """获取默认的OSS上传器实例""" + global default_uploader + if default_uploader is None: + default_uploader = OSSUploader() + return default_uploader diff --git a/backend/proxy_test_report.py b/backend/proxy_test_report.py new file mode 100644 index 0000000..5fa741a --- /dev/null +++ b/backend/proxy_test_report.py @@ -0,0 +1,66 @@ +""" +固定代理IP测试总结报告 +""" +print("="*60) +print("🎯 固定代理IP测试总结报告") +print("="*60) + +print("\n📋 测试概览:") +print(" • 测试项目: 固定代理IP在小红书登录发文功能中的可用性") +print(" • 测试时间: 2025年12月26日") +print(" • 测试环境: Windows 10, Python虚拟环境") +print(" • 测试代理数量: 2个") + +print("\n✅ 代理IP详细信息:") +print(" 代理1:") +print(" - 服务器: http://36.137.177.131:50001") +print(" - 用户名: qqwvy0") +print(" - 密码: mun3r7xz") +print(" - 状态: ✅ 可用") +print("") +print(" 代理2:") +print(" - 服务器: http://111.132.40.72:50002") +print(" - 用户名: ih3z07") +print(" - 密码: 078bt7o5") +print(" - 状态: ✅ 可用") + +print("\n🧪 测试项目及结果:") +print(" 1. requests库连接测试:") +print(" - 代理1: ✅ 成功") +print(" - 代理2: ✅ 成功") +print(" - 结论: 代理IP基础连接正常") +print("") +print(" 2. Playwright浏览器代理测试:") +print(" - 代理1: ✅ 成功 (可访问小红书创作者平台)") +print(" - 代理2: ✅ 成功 (可访问小红书创作者平台)") +print(" - 结论: 代理IP在浏览器环境中正常工作") +print("") +print(" 3. 网站访问能力测试:") +print(" - 百度访问: ✅ 成功") +print(" - IP检测网站: ✅ 成功") +print(" - 小红书创作者平台: ✅ 成功") +print(" - 结论: 代理IP未被目标网站封禁") + +print("\n📊 测试结果汇总:") +print(" • 总体成功率: 100% (2/2 个代理可用)") +print(" • 网络延迟: 良好") +print(" • 稳定性: 良好") +print(" • 适用场景: 小红书登录及发文功能") + +print("\n🔧 Playwright代理格式:") +print(" 代理1格式: http://qqwvy0:mun3r7xz@36.137.177.131:50001") +print(" 代理2格式: http://ih3z07:078bt7o5@111.132.40.72:50002") + +print("\n💡 使用建议:") +print(" 1. 在小红书自动化脚本中,可以使用以上两个代理IP") +print(" 2. 建议轮换使用两个代理以提高稳定性") +print(" 3. 如遇到访问限制,可尝试调整User-Agent或请求间隔") +print(" 4. 代理IP可以有效隐藏真实IP,降低被封禁风险") + +print("\n🎉 总结:") +print(" 两个固定代理IP均可以正常用于小红书登录发文功能,") +print(" 网络连接稳定,未检测到访问限制或验证码拦截。") + +print("\n" + "="*60) +print("报告生成完成") +print("="*60) \ No newline at end of file diff --git a/backend/proxy_usage_example.py b/backend/proxy_usage_example.py new file mode 100644 index 0000000..b173e49 --- /dev/null +++ b/backend/proxy_usage_example.py @@ -0,0 +1,230 @@ +""" +固定代理IP下小红书登录和发文功能示例 +展示如何在实际应用中使用代理IP进行小红书操作 +""" +import asyncio +import json +import sys +from xhs_login import XHSLoginService +from xhs_publish import XHSPublishService +from damai_proxy_config import get_proxy_config + + +async def login_with_proxy(phone: str, code: str, proxy_index: int = 0): + """ + 使用代理进行小红书登录 + + Args: + phone: 手机号 + code: 验证码 + proxy_index: 代理索引 (0 或 1) + """ + print(f"\n{'='*60}") + print(f"📱 使用代理登录小红书") + print(f"{'='*60}") + + # 获取代理配置 + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + + # 创建登录服务 + login_service = XHSLoginService() + + try: + # 初始化浏览器(使用代理) + user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) + print("✅ 浏览器初始化成功(已启用代理)") + + # 执行登录 + result = await login_service.login(phone, code) + + if result.get('success'): + print("✅ 登录成功!") + + # 保存Cookies到文件 + cookies_full = result.get('cookies_full', []) + if cookies_full: + with open('cookies_proxy.json', 'w', encoding='utf-8') as f: + json.dump(cookies_full, f, ensure_ascii=False, indent=2) + print("✅ 已保存登录Cookies到 cookies_proxy.json") + + return result + else: + print(f"❌ 登录失败: {result.get('error')}") + return result + + except Exception as e: + print(f"❌ 登录过程异常: {str(e)}") + import traceback + traceback.print_exc() + return {"success": False, "error": str(e)} + finally: + await login_service.close_browser() + + +async def publish_with_proxy(title: str, content: str, images: list = None, tags: list = None, proxy_index: int = 0, cookies_file: str = 'cookies.json'): + """ + 使用代理发布小红书笔记 + + Args: + title: 笔记标题 + content: 笔记内容 + images: 图片路径列表 + tags: 标签列表 + proxy_index: 代理索引 (0 或 1) + cookies_file: Cookies文件路径 + """ + print(f"\n{'='*60}") + print(f"📝 使用代理发布小红书笔记") + print(f"{'='*60}") + + # 获取代理配置 + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + + # 读取Cookies + try: + with open(cookies_file, 'r', encoding='utf-8') as f: + cookies = json.load(f) + print(f"✅ 成功读取Cookies: {len(cookies)} 个") + except FileNotFoundError: + print(f"❌ Cookies文件不存在: {cookies_file}") + return {"success": False, "error": f"Cookies文件不存在: {cookies_file}"} + except Exception as e: + print(f"❌ 读取Cookies失败: {str(e)}") + return {"success": False, "error": str(e)} + + # 准备发布数据 + images = images or [] + tags = tags or [] + + print(f"📝 发布内容:") + print(f" 标题: {title}") + print(f" 内容: {content[:50]}...") # 只显示前50个字符 + print(f" 图片: {len(images)} 张") + print(f" 标签: {tags}") + + # 创建发布服务 + try: + publisher = XHSPublishService(cookies, proxy=proxy_url) + + # 执行发布 + result = await publisher.publish( + title=title, + content=content, + images=images, + tags=tags + ) + + if result.get('success'): + print("✅ 发布成功!") + else: + print(f"❌ 发布失败: {result.get('error')}") + + return result + + except Exception as e: + print(f"❌ 发布过程异常: {str(e)}") + import traceback + traceback.print_exc() + return {"success": False, "error": str(e)} + + +async def test_proxy_functionality(): + """测试代理功能的完整流程""" + print("🚀 开始测试代理功能完整流程") + + # 1. 测试代理连接 + print(f"\n{'-'*40}") + print("1. 测试代理连接...") + + for i in range(2): + proxy_config = get_proxy_config(i) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f" 代理{i+1}: {proxy_config['server']} - {proxy_url}") + + # 2. 演示如何使用代理登录(仅展示,不实际执行) + print(f"\n{'-'*40}") + print("2. 代理登录示例(代码演示)...") + print(""" +# 登录示例代码: +async def example_login(): + result = await login_with_proxy( + phone="你的手机号", # 实际手机号 + code="验证码", # 实际验证码 + proxy_index=0 # 使用代理1 + ) + return result + """) + + # 3. 演示如何使用代理发布(仅展示,不实际执行) + print(f"\n{'-'*40}") + print("3. 代理发布示例(代码演示)...") + print(""" +# 发布示例代码: +async def example_publish(): + result = await publish_with_proxy( + title="测试标题", + content="测试内容", + images=["图片路径1", "图片路径2"], # 可选 + tags=["标签1", "标签2"], # 可选 + proxy_index=1, # 使用代理2 + cookies_file="cookies.json" # Cookies文件路径 + ) + return result + """) + + # 4. 代理轮换策略 + print(f"\n{'-'*40}") + print("4. 代理轮换策略...") + print(""" +# 代理轮换示例: +class ProxyManager: + def __init__(self): + self.current_proxy = 0 + + def get_next_proxy(self): + proxy_config = get_proxy_config(self.current_proxy) + self.current_proxy = (self.current_proxy + 1) % 2 # 循环使用两个代理 + return proxy_config + """) + + print(f"\n{'-'*40}") + print("✅ 代理功能演示完成!") + + +def main(): + """主函数""" + print("="*60) + print("🎯 固定代理IP下小红书登录发文功能示例") + print("="*60) + + # 运行测试 + asyncio.run(test_proxy_functionality()) + + print(f"\n{'='*60}") + print("💡 使用说明:") + print(" 1. 使用 login_with_proxy() 函数进行带代理的登录") + print(" 2. 使用 publish_with_proxy() 函数进行带代理的发布") + print(" 3. 可以轮换使用两个代理IP以提高稳定性") + print(" 4. 代理配置在 damai_proxy_config.py 中管理") + print("="*60) + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + main() \ No newline at end of file diff --git a/backend/rebuild_venv.bat b/backend/rebuild_venv.bat new file mode 100644 index 0000000..1b389aa --- /dev/null +++ b/backend/rebuild_venv.bat @@ -0,0 +1,65 @@ +@echo off +chcp 65001 >nul +echo ======================================== +echo 重建虚拟环境(使用标准Python) +echo ======================================== +echo. + +cd /d %~dp0 + +echo [步骤1] 删除旧的虚拟环境... +if exist venv ( + rmdir /s /q venv + echo [完成] 旧虚拟环境已删除 +) else ( + echo [提示] 没有找到旧虚拟环境 +) +echo. + +echo [步骤2] 使用标准Python 3.12创建新虚拟环境... +py -3.12 -m venv venv +if %errorlevel% neq 0 ( + echo [错误] 虚拟环境创建失败 + pause + exit /b 1 +) +echo [完成] 虚拟环境创建成功 +echo. + +echo [步骤3] 验证新虚拟环境的Python路径... +venv\Scripts\python.exe -c "import sys; print('Python可执行文件:', sys.executable); print('Python版本:', sys.version); print('\nsys.path前5行:'); [print(p) for i, p in enumerate(sys.path[:5])]" +echo. + +echo [步骤4] 升级pip... +venv\Scripts\python.exe -m pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple +echo. + +echo [步骤5] 配置pip使用清华镜像... +venv\Scripts\pip.exe config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple +echo [完成] pip镜像源已配置 +echo. + +echo [步骤6] 安装项目依赖... +venv\Scripts\pip.exe install -r requirements.txt +if %errorlevel% neq 0 ( + echo [错误] 依赖安装失败 + pause + exit /b 1 +) +echo [完成] 依赖安装成功 +echo. + +echo [步骤7] 安装Playwright浏览器... +venv\Scripts\playwright.exe install chromium +if %errorlevel% neq 0 ( + echo [警告] Playwright浏览器安装可能失败,请手动检查 +) +echo. + +echo ======================================== +echo 虚拟环境重建完成! +echo ======================================== +echo. +echo 现在可以运行 start_service.bat 启动服务 +echo. +pause diff --git a/backend/requirements.txt b/backend/requirements.txt index 5d6b484..effe14b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,12 @@ playwright==1.40.0 pydantic==2.5.0 python-multipart==0.0.6 aiohttp==3.9.1 +oss2==2.18.4 +APScheduler==3.10.4 +PyMySQL==1.1.0 +python-dotenv==1.0.0 +PyYAML==6.0.1 +alibabacloud_dysmsapi20170525==2.0.24 +alibabacloud_credentials==0.3.4 +alibabacloud_tea_openapi==0.3.9 +alibabacloud_tea_util==0.3.13 diff --git a/backend/scheduler.py b/backend/scheduler.py new file mode 100644 index 0000000..ca20a1d --- /dev/null +++ b/backend/scheduler.py @@ -0,0 +1,563 @@ +""" +小红书定时发布调度器 +管理自动发布任务的调度和执行 +""" +import asyncio +import sys +import random +from datetime import datetime, time as dt_time +from typing import List, Dict, Any, Optional +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +import pymysql +import json +import aiohttp + +from xhs_login import XHSLoginService + + +class XHSScheduler: + """小红书定时发布调度器""" + + def __init__(self, + db_config: Dict[str, Any], + max_concurrent: int = 2, + publish_timeout: int = 300, + max_articles_per_user_per_run: int = 5, + max_failures_per_user_per_run: int = 3, + max_daily_articles_per_user: int = 20, + max_hourly_articles_per_user: int = 3, + proxy_pool_enabled: bool = False, + proxy_pool_api_url: Optional[str] = None, + enable_random_ua: bool = True, + min_publish_interval: int = 30, + max_publish_interval: int = 120, + headless: bool = True): + """ + 初始化调度器 + + Args: + db_config: 数据库配置 + max_concurrent: 最大并发发布数 + publish_timeout: 发布超时时间(秒) + max_articles_per_user_per_run: 每轮每用户最大发文数 + max_failures_per_user_per_run: 每轮每用户最大失败次数 + max_daily_articles_per_user: 每用户每日最大发文数 + max_hourly_articles_per_user: 每用户每小时最大发文数 + enable_random_ua: 是否启用随机User-Agent + min_publish_interval: 最小发布间隔(秒) + max_publish_interval: 最大发布间隔(秒) + headless: 是否使用无头模式,False为有头模式(方便调试) + """ + self.db_config = db_config + self.max_concurrent = max_concurrent + self.publish_timeout = publish_timeout + self.max_articles_per_user_per_run = max_articles_per_user_per_run + self.max_failures_per_user_per_run = max_failures_per_user_per_run + self.max_daily_articles_per_user = max_daily_articles_per_user + self.max_hourly_articles_per_user = max_hourly_articles_per_user + self.proxy_pool_enabled = proxy_pool_enabled + self.proxy_pool_api_url = proxy_pool_api_url or "" + self.enable_random_ua = enable_random_ua + self.min_publish_interval = min_publish_interval + self.max_publish_interval = max_publish_interval + self.headless = headless + + self.scheduler = AsyncIOScheduler() + self.login_service = XHSLoginService(use_pool=True, headless=headless) + self.semaphore = asyncio.Semaphore(max_concurrent) + + print(f"[调度器] 已创建,最大并发: {max_concurrent}", file=sys.stderr) + + def start(self, cron_expr: str = "*/5 * * * * *"): + """ + 启动定时任务 + + Args: + cron_expr: Cron表达式,默认每5秒执行一次 + 格式: 秒 分 时 日 月 周 + """ + # 解析cron表达式 + parts = cron_expr.split() + if len(parts) == 6: + # 6位格式: 秒 分 时 日 月 周 + trigger = CronTrigger( + second=parts[0], + minute=parts[1], + hour=parts[2], + day=parts[3], + month=parts[4], + day_of_week=parts[5] + ) + else: + print(f"[调度器] ⚠️ Cron表达式格式错误: {cron_expr},使用默认配置", file=sys.stderr) + trigger = CronTrigger(second="*/5") + + self.scheduler.add_job( + self.auto_publish_articles, + trigger=trigger, + id='xhs_publish', + name='小红书自动发布', + max_instances=1, # 最多只允许1个实例同时运行,防止重复执行 + replace_existing=True # 如果任务已存在则替换,避免重启时重复添加 + ) + + self.scheduler.start() + print(f"[调度器] 定时发布任务已启动,Cron表达式: {cron_expr}", file=sys.stderr) + + def stop(self): + """停止定时任务""" + self.scheduler.shutdown() + print("[调度器] 定时发布任务已停止", file=sys.stderr) + + def get_db_connection(self): + """获取数据库连接""" + return pymysql.connect( + host=self.db_config['host'], + port=self.db_config['port'], + user=self.db_config['user'], + password=self.db_config['password'], + database=self.db_config['database'], + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + + async def _fetch_proxy_from_pool(self) -> Optional[str]: + """从代理池接口获取一个代理地址(http://ip:port)""" + if not self.proxy_pool_enabled or not self.proxy_pool_api_url: + return None + + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(self.proxy_pool_api_url) as resp: + if resp.status != 200: + print(f"[调度器] 代理池接口返回非200状态码: {resp.status}", file=sys.stderr) + return None + + text = (await resp.text()).strip() + if not text: + print("[调度器] 代理池返回内容为空", file=sys.stderr) + return None + + line = text.splitlines()[0].strip() + if not line: + print("[调度器] 代理池首行内容为空", file=sys.stderr) + return None + + if line.startswith("http://") or line.startswith("https://"): + return line + return "http://" + line + except Exception as e: + print(f"[调度器] 请求代理池接口失败: {str(e)}", file=sys.stderr) + return None + + def _generate_random_user_agent(self) -> str: + """生成随机User-Agent,防止浏览器指纹识别""" + chrome_versions = ['120.0.0.0', '119.0.0.0', '118.0.0.0', '117.0.0.0', '116.0.0.0'] + windows_versions = ['Windows NT 10.0; Win64; x64', 'Windows NT 11.0; Win64; x64'] + + chrome_ver = random.choice(chrome_versions) + win_ver = random.choice(windows_versions) + + return f'Mozilla/5.0 ({win_ver}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{chrome_ver} Safari/537.36' + + async def auto_publish_articles(self): + """自动发布文案(定时任务主函数)""" + print("========== 开始执行定时发布任务 ==========", file=sys.stderr) + start_time = datetime.now() + + try: + conn = self.get_db_connection() + cursor = conn.cursor() + + # 1. 查询所有待发布的文案 + cursor.execute(""" + SELECT * FROM ai_articles + WHERE status = 'published_review' + ORDER BY id ASC + """) + articles = cursor.fetchall() + + if not articles: + print("没有待发布的文案", file=sys.stderr) + cursor.close() + conn.close() + return + + original_total = len(articles) + + # 2. 限制每用户每轮发文数 + articles = self._limit_articles_per_user(articles, self.max_articles_per_user_per_run) + print(f"找到 {original_total} 篇待发布文案,按照每个用户每轮最多 {self.max_articles_per_user_per_run} 篇,本次计划发布 {len(articles)} 篇", file=sys.stderr) + + # 3. 应用每日/每小时上限过滤 + if self.max_daily_articles_per_user > 0 or self.max_hourly_articles_per_user > 0: + before_count = len(articles) + articles = await self._filter_by_daily_and_hourly_limit( + cursor, articles, + self.max_daily_articles_per_user, + self.max_hourly_articles_per_user + ) + print(f"应用每日/每小时上限过滤:过滤前 {before_count} 篇,过滤后 {len(articles)} 篇", file=sys.stderr) + + if not articles: + print("所有文案均因频率限制被过滤,本轮无任务", file=sys.stderr) + cursor.close() + conn.close() + return + + # 4. 并发发布 + tasks = [] + user_fail_count = {} + paused_users = set() + + for article in articles: + user_id = article['publish_user_id'] or article['created_user_id'] + + if user_id in paused_users: + print(f"用户 {user_id} 在本轮已暂停,跳过文案 ID: {article['id']}", file=sys.stderr) + continue + + # 直接发布,不在这里延迟 + task = asyncio.create_task( + self._publish_article_with_semaphore( + article, user_id, cursor, user_fail_count, paused_users + ) + ) + tasks.append(task) + + # 等待所有发布完成 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 统计结果 + success_count = sum(1 for r in results if r is True) + fail_count = len(results) - success_count + + cursor.close() + conn.close() + + duration = (datetime.now() - start_time).total_seconds() + print("========== 定时发布任务完成 ==========", file=sys.stderr) + print(f"总计: {len(articles)} 篇, 成功: {success_count} 篇, 失败: {fail_count} 篇, 耗时: {duration:.1f}秒", file=sys.stderr) + + except Exception as e: + print(f"[调度器] 定时任务异常: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() + + async def _publish_article_with_semaphore(self, article: Dict, user_id: int, + cursor, user_fail_count: Dict, paused_users: set): + """带信号量控制的发布文章""" + async with self.semaphore: + try: + print(f"[调度器] 开始发布文案 {article['id']}: {article['title']}", file=sys.stderr) + success = await self._publish_single_article(article, cursor) + + if not success: + user_fail_count[user_id] = user_fail_count.get(user_id, 0) + 1 + if user_fail_count[user_id] >= self.max_failures_per_user_per_run: + paused_users.add(user_id) + print(f"用户 {user_id} 在本轮失败次数达到 {user_fail_count[user_id]} 次,暂停本轮后续发布", file=sys.stderr) + print(f"发布失败 [文案ID: {article['id']}, 标题: {article['title']}]", file=sys.stderr) + return False + else: + print(f"发布成功 [文案ID: {article['id']}, 标题: {article['title']}]", file=sys.stderr) + return True + + except Exception as e: + print(f"发布异常 [文案ID: {article['id']}]: {str(e)}", file=sys.stderr) + return False + + async def _publish_single_article(self, article: Dict, cursor) -> bool: + """发布单篇文章""" + try: + # 1. 获取用户信息 + user_id = article['publish_user_id'] or article['created_user_id'] + cursor.execute("SELECT * FROM ai_users WHERE id = %s", (user_id,)) + user = cursor.fetchone() + + if not user: + self._update_article_status(cursor, article['id'], 'failed', '获取用户信息失败') + return False + + # 2. 检查用户是否绑定小红书 + if user['is_bound_xhs'] != 1: + self._update_article_status(cursor, article['id'], 'failed', '用户未绑定小红书账号') + return False + + # 3. 获取author记录和Cookie + cursor.execute(""" + SELECT * FROM ai_authors + WHERE phone = %s AND enterprise_id = %s AND channel = 1 AND status = 'active' + LIMIT 1 + """, (user['phone'], user['enterprise_id'])) + author = cursor.fetchone() + + if not author or not author['xhs_cookie']: + self._update_article_status(cursor, article['id'], 'failed', '小红书Cookie已失效') + return False + + # 4. 获取文章图片 + cursor.execute(""" + SELECT image_url FROM ai_article_images + WHERE article_id = %s + ORDER BY sort_order ASC + """, (article['id'],)) + images = [img['image_url'] for img in cursor.fetchall() if img['image_url']] + + # 5. 获取标签 + cursor.execute("SELECT coze_tag FROM ai_article_tags WHERE article_id = %s LIMIT 1", (article['id'],)) + tag_row = cursor.fetchone() + topics = [] + if tag_row and tag_row['coze_tag']: + topics = self._parse_tags(tag_row['coze_tag']) + + # 6. 解析Cookie并格式化 + try: + # 数据库中存储的是完整的login_state JSON + login_state = json.loads(author['xhs_cookie']) + + # 处理双重JSON编码的情况 + if isinstance(login_state, str): + login_state = json.loads(login_state) + + # 提取cookies字段(兼容旧格式:如果login_state本身就是cookies列表) + if isinstance(login_state, dict) and 'cookies' in login_state: + # 新格式:login_state对象包含cookies字段 + cookies = login_state['cookies'] + print(f" 从login_state提取cookies: {len(cookies) if isinstance(cookies, list) else 'unknown'} 个", file=sys.stderr) + elif isinstance(login_state, (list, dict)): + # 旧格式:直接是cookies + cookies = login_state + print(f" 使用旧格式cookies(无login_state包装)", file=sys.stderr) + else: + raise ValueError(f"无法识别的Cookie存储格式: {type(login_state).__name__}") + + # 验证cookies格式 + if not isinstance(cookies, (list, dict)): + raise ValueError(f"Cookie必须是列表或字典格式,当前类型: {type(cookies).__name__}") + + # 格式化Cookie,确保包含domain字段 + cookies = self._format_cookies(cookies) + except Exception as e: + self._update_article_status(cursor, article['id'], 'failed', f'Cookie格式错误: {str(e)}') + return False + + # 7. 从代理池获取代理(如果启用) + proxy = await self._fetch_proxy_from_pool() + if proxy: + print(f"[调度器] 使用代理: {proxy}", file=sys.stderr) + + # 8. 生成随机User-Agent(防指纹识别) + user_agent = self._generate_random_user_agent() if self.enable_random_ua else None + if user_agent: + print(f"[调度器] 使用随机UA: {user_agent[:50]}...", file=sys.stderr) + + # 9. 调用发布服务(增加超时控制) + try: + print(f"[调度器] 开始调用发布服务,超时设置: {self.publish_timeout}秒", file=sys.stderr) + result = await asyncio.wait_for( + self.login_service.publish_note( + title=article['title'], + content=article['content'], + images=images, + topics=topics, + cookies=cookies, + proxy=proxy, + user_agent=user_agent, + ), + timeout=self.publish_timeout + ) + except asyncio.TimeoutError: + error_msg = f'发布超时({self.publish_timeout}秒)' + print(f"[调度器] {error_msg}", file=sys.stderr) + self._update_article_status(cursor, article['id'], 'failed', error_msg) + return False + except Exception as e: + error_msg = f'调用发布服务异常: {str(e)}' + print(f"[调度器] {error_msg}", file=sys.stderr) + import traceback + traceback.print_exc() + self._update_article_status(cursor, article['id'], 'failed', error_msg) + return False + + # 10. 更新状态 + if result['success']: + self._update_article_status(cursor, article['id'], 'published', '发布成功') + return True + else: + error_msg = result.get('error', '未知错误') + self._update_article_status(cursor, article['id'], 'failed', error_msg) + return False + + except Exception as e: + self._update_article_status(cursor, article['id'], 'failed', f'发布异常: {str(e)}') + return False + + def _update_article_status(self, cursor, article_id: int, status: str, message: str = ''): + """更新文章状态""" + try: + if status == 'published': + cursor.execute(""" + UPDATE ai_articles + SET status = %s, publish_time = NOW(), updated_at = NOW() + WHERE id = %s + """, (status, article_id)) + else: + cursor.execute(""" + UPDATE ai_articles + SET status = %s, review_comment = %s, updated_at = NOW() + WHERE id = %s + """, (status, message, article_id)) + cursor.connection.commit() + except Exception as e: + print(f"更新文章 {article_id} 状态失败: {str(e)}", file=sys.stderr) + + def _limit_articles_per_user(self, articles: List[Dict], per_user_limit: int) -> List[Dict]: + """限制每用户发文数""" + if per_user_limit <= 0: + return articles + + grouped = {} + for art in articles: + user_id = art['publish_user_id'] or art['created_user_id'] + if user_id not in grouped: + grouped[user_id] = [] + grouped[user_id].append(art) + + limited = [] + for user_id, user_articles in grouped.items(): + limited.extend(user_articles[:per_user_limit]) + + return limited + + async def _filter_by_daily_and_hourly_limit(self, cursor, articles: List[Dict], + max_daily: int, max_hourly: int) -> List[Dict]: + """按每日和每小时上限过滤文章""" + if max_daily <= 0 and max_hourly <= 0: + return articles + + # 提取所有用户ID + user_ids = set() + for art in articles: + user_id = art['publish_user_id'] or art['created_user_id'] + user_ids.add(user_id) + + # 查询每用户已发布数量 + user_daily_published = {} + user_hourly_published = {} + + now = datetime.now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + current_hour_start = now.replace(minute=0, second=0, microsecond=0) + + for user_id in user_ids: + # 查询当日已发布数量 + if max_daily > 0: + cursor.execute(""" + SELECT COUNT(*) as count FROM ai_articles + WHERE status = 'published' AND publish_time >= %s + AND (publish_user_id = %s OR (publish_user_id IS NULL AND created_user_id = %s)) + """, (today_start, user_id, user_id)) + user_daily_published[user_id] = cursor.fetchone()['count'] + + # 查询当前小时已发布数量 + if max_hourly > 0: + cursor.execute(""" + SELECT COUNT(*) as count FROM ai_articles + WHERE status = 'published' AND publish_time >= %s + AND (publish_user_id = %s OR (publish_user_id IS NULL AND created_user_id = %s)) + """, (current_hour_start, user_id, user_id)) + user_hourly_published[user_id] = cursor.fetchone()['count'] + + # 过滤超限文章 + filtered = [] + for art in articles: + user_id = art['publish_user_id'] or art['created_user_id'] + + # 检查每日上限 + if max_daily > 0 and user_daily_published.get(user_id, 0) >= max_daily: + continue + + # 检查每小时上限 + if max_hourly > 0 and user_hourly_published.get(user_id, 0) >= max_hourly: + continue + + filtered.append(art) + + return filtered + + def _parse_tags(self, tag_str: str) -> List[str]: + """解析标签字符串""" + if not tag_str: + return [] + + # 替换分隔符 + tag_str = tag_str.replace(';', ',').replace(' ', ',').replace('、', ',') + + # 分割并清理 + tags = [] + for tag in tag_str.split(','): + tag = tag.strip() + if tag: + tags.append(tag) + + return tags + + def _format_cookies(self, cookies) -> List[Dict]: + """ + 格式化Cookie,只处理非标准格式的Cookie + 对于Playwright原生格式的Cookie,直接返回,不做任何修改 + + Args: + cookies: Cookie数据,支持list[dict]或dict格式 + + Returns: + 格式化后的Cookie列表 + """ + # 如果是字典格式(键值对),转换为列表格式 + if isinstance(cookies, dict): + cookies = [ + { + "name": name, + "value": str(value) if not isinstance(value, str) else value, + "domain": ".xiaohongshu.com", + "path": "/" + } + for name, value in cookies.items() + ] + + # 验证是否为列表 + if not isinstance(cookies, list): + raise ValueError(f"Cookie必须是列表或字典格式,当前类型: {type(cookies).__name__}") + + # 检查是否为空列表 + if not cookies or len(cookies) == 0: + print(f" Cookie列表为空,直接返回", file=sys.stderr) + return cookies + + # 检查是否是Playwright原生格式(包含name和value字段) + if isinstance(cookies[0], dict) and 'name' in cookies[0] and 'value' in cookies[0]: + # 已经是Playwright格式,直接返回,不做任何修改 + print(f" 检测到Playwright原生格式,直接使用 ({len(cookies)} 个cookie)", file=sys.stderr) + return cookies + + # 其他格式,进行基础验证 + formatted_cookies = [] + for cookie in cookies: + if not isinstance(cookie, dict): + raise ValueError(f"Cookie元素必须是字典格式,当前类型: {type(cookie).__name__}") + + # 确保有基本字段 + if 'domain' not in cookie and 'url' not in cookie: + cookie = cookie.copy() + cookie['domain'] = '.xiaohongshu.com' + if 'path' not in cookie and 'url' not in cookie: + if 'domain' in cookie or 'url' not in cookie: + cookie = cookie.copy() if cookie is cookies[cookies.index(cookie)] else cookie + cookie['path'] = '/' + + formatted_cookies.append(cookie) + + return formatted_cookies diff --git a/backend/simple_proxy_test.py b/backend/simple_proxy_test.py new file mode 100644 index 0000000..c1ad62c --- /dev/null +++ b/backend/simple_proxy_test.py @@ -0,0 +1,55 @@ +import requests +from damai_proxy_config import get_proxy_config + +def test_single_proxy(index): + """测试单个代理""" + try: + # 获取代理配置 + proxy_info = get_proxy_config(index) + proxy_server = proxy_info['server'].replace('http://', '') + proxy_url = f"http://{proxy_info['username']}:{proxy_info['password']}@{proxy_server}" + + proxies = { + 'http': proxy_url, + 'https': proxy_url + } + + print(f'🔍 测试代理 {index + 1}: {proxy_info["server"]}') + + # 测试连接 + response = requests.get('http://httpbin.org/ip', proxies=proxies, timeout=10) + + if response.status_code == 200: + print(f'✅ 代理 {index + 1} 连接成功! 状态码: {response.status_code}') + print(f'🌐 IP信息: {response.text}') + return True + else: + print(f'❌ 代理 {index + 1} 连接失败! 状态码: {response.status_code}') + return False + + except requests.exceptions.ProxyError: + print(f'❌ 代理 {index + 1} 连接错误:无法连接到代理服务器') + return False + except requests.exceptions.ConnectTimeout: + print(f'❌ 代理 {index + 1} 连接超时') + return False + except Exception as e: + print(f'❌ 代理 {index + 1} 连接失败: {str(e)}') + return False + +if __name__ == "__main__": + print("🚀 开始测试固定代理IP连接性\n") + + # 测试两个代理 + for i in range(2): + success = test_single_proxy(i) + if success: + print(f"✅ 代理 {i+1} 可用,适用于小红书登录发文\n") + else: + print(f"❌ 代理 {i+1} 不可用\n") + + if i == 0: # 在测试第二个之前稍等一下 + import time + time.sleep(2) + + print("测试完成!") \ No newline at end of file diff --git a/backend/start.bat b/backend/start.bat index 63c77cd..bcb6c90 100644 --- a/backend/start.bat +++ b/backend/start.bat @@ -1,8 +1,9 @@ @echo off echo 正在激活虚拟环境... -venv\Scripts\activate +call venv\Scripts\activate.bat -echo 正在启动小红书登录服务... +echo 正在启动小红书登录服务(开发环境)... +set "ENV=dev" python main.py pause diff --git a/backend/start.sh b/backend/start.sh index c2047c4..c2acf18 100644 --- a/backend/start.sh +++ b/backend/start.sh @@ -1,7 +1,33 @@ #!/bin/bash +# 小红书Python服务启动脚本(开发环境) +# 用途:前台启动,方便查看日志 -echo "正在激活虚拟环境..." +cd "$(dirname "$0")" + +echo "========================================" +echo " 小红书登录服务(开发模式)" +echo "========================================" +echo "" + +# 激活虚拟环境 +echo "[环境] 激活虚拟环境: $(pwd)/venv" source venv/bin/activate +if [ $? -ne 0 ]; then + echo "[错误] 虚拟环境激活失败" + exit 1 +fi -echo "正在启动小红书登录服务..." -python main.py +# 显示Python版本和路径 +echo "[Python] $(python --version)" +echo "[路径] $(which python)" +echo "" + +echo "[启动] 正在启动Python服务(端口8000)..." +echo "[说明] 按Ctrl+C停止服务" +echo "" + +# 设置环境为开发环境 +export ENV=dev + +# 启动服务(开发模式,不使用reload) +python -m uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/backend/start_prod.bat b/backend/start_prod.bat new file mode 100644 index 0000000..a63e9ee --- /dev/null +++ b/backend/start_prod.bat @@ -0,0 +1,9 @@ +@echo off +echo 正在激活虚拟环境... +call venv\Scripts\activate.bat + +echo 正在启动小红书登录服务(生产环境)... +set "ENV=prod" +python main.py + +pause diff --git a/backend/start_prod.sh b/backend/start_prod.sh new file mode 100644 index 0000000..4592898 --- /dev/null +++ b/backend/start_prod.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# 小红书Python服务启动脚本(生产环境) + +cd "$(dirname "$0")" + +echo "========================================" +echo " 小红书登录服务(生产模式)" +echo "========================================" +echo "" + +# 激活虚拟环境 +echo "[环境] 激活虚拟环境: $(pwd)/venv" +source venv/bin/activate +if [ $? -ne 0 ]; then + echo "[错误] 虚拟环境激活失败" + exit 1 +fi + +# 显示Python版本和路径 +echo "[Python] $(python --version)" +echo "[路径] $(which python)" +echo "" + +echo "[启动] 正在启动Python服务(生产环境,端口8000)..." +echo "[说明] 按Ctrl+C停止服务" +echo "" + +# 设置环境为生产环境 +export ENV=prod + +# 启动服务(生产模式) +python -m uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/backend/start_service.bat b/backend/start_service.bat new file mode 100644 index 0000000..f8a9481 --- /dev/null +++ b/backend/start_service.bat @@ -0,0 +1,66 @@ +@echo off +setlocal enabledelayedexpansion +chcp 65001 >nul +echo ==================================== +echo 小红书登录服务(浏览器池模式) +echo ==================================== +echo. + +cd /d %~dp0 + +REM 检查虚拟环境 +if not exist "venv\Scripts\python.exe" ( + echo [错误] 未找到虚拟环境,请先运行: python -m venv venv + pause + exit /b 1 +) + +REM 检查并清理端口8000占用 +echo [检查] 正在检查端口8000占用情况... +for /f "tokens=5" %%a in ('netstat -ano ^| findstr :8000 ^| findstr LISTENING') do ( + echo [清理] 发现端口8000被进程%%a占用,正在清理... + taskkill /F /PID %%a >nul 2>&1 + if !errorlevel! equ 0 ( + echo [成功] 已清理进程%%a + ) else ( + echo [警告] 无法清理进程%%a,可能需要管理员权限 + ) +) + +REM 等待端口释放 +timeout /t 1 /nobreak >nul + +echo. +echo [启动] 正在启动Python服务(端口8000)... +echo [模式] 浏览器池模式 - 性能优化 +echo [说明] 浏览器实例将在30分钟无操作后自动清理 +echo. + +REM 激活虚拟环境并启动服务 +echo [Environment] Using virtual environment: %~dp0venv +call "%~dp0venv\Scripts\activate.bat" +if !errorlevel! neq 0 ( + echo [错误] 虚拟环境激活失败 + pause + exit /b 1 +) + +REM 显示Python版本和路径 +echo. +echo [Python Version] +python --version +echo [Python Path] +where python +echo. + +REM 确认使用虚拟环境的Python +echo [Verify] Checking virtual environment... +python -c "import sys; print('Python executable:', sys.executable)" +echo. + +REM 启动服务(使用虚拟环境的uvicorn) +echo [Service] Starting FastAPI service... +echo [Notice] Reload mode disabled for Windows compatibility +python -m uvicorn main:app --host 0.0.0.0 --port 8000 + +pause diff --git a/backend/start_service.sh b/backend/start_service.sh new file mode 100644 index 0000000..e84a0a9 --- /dev/null +++ b/backend/start_service.sh @@ -0,0 +1,50 @@ +#!/bin/bash +echo "====================================" +echo " 小红书登录服务(浏览器池模式)" +echo "====================================" +echo "" + +cd "$(dirname "$0")" + +# 检查虚拟环境 +if [ ! -f "venv/bin/python" ]; then + echo "[错误] 未找到虚拟环境,请先运行: python3 -m venv venv" + exit 1 +fi + +# 检查并清理端口8000占用 +echo "[检查] 正在检查端口8000占用情况..." +PID=$(lsof -ti:8000) +if [ ! -z "$PID" ]; then + echo "[清理] 发现端口8000被进程$PID占用,正在清理..." + kill -9 $PID 2>/dev/null + if [ $? -eq 0 ]; then + echo "[成功] 已清理进程$PID" + else + echo "[警告] 无法清理进程$PID,可能需要sudo权限" + fi + sleep 1 +fi + +echo "" +echo "[启动] 正在启动Python服务(端口8000)..." +echo "[模式] 浏览器池模式 - 性能优化" +echo "[说明] 浏览器实例将在30分钟无操作后自动清理" +echo "" + +# 激活虚拟环境 +echo "[环境] 激活虚拟环境: $(pwd)/venv" +source venv/bin/activate +if [ $? -ne 0 ]; then + echo "[错误] 虚拟环境激活失败" + exit 1 +fi + +# 显示Python版本和路径 +echo "[Python] $(python --version)" +echo "[路径] $(which python)" +echo "" + +# 启动服务(使用虚拟环境的uvicorn) +echo "[Notice] Reload mode disabled for Windows compatibility" +python -m uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/backend/stop.sh b/backend/stop.sh new file mode 100644 index 0000000..d4f51c3 --- /dev/null +++ b/backend/stop.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# 小红书Python服务停止脚本 +# 用途:停止生产环境服务 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# 调用生产环境脚本的stop命令 +"$SCRIPT_DIR/start_prod.sh" stop diff --git a/backend/storage_state_manager.py b/backend/storage_state_manager.py new file mode 100644 index 0000000..8049ebb --- /dev/null +++ b/backend/storage_state_manager.py @@ -0,0 +1,137 @@ +""" +小红书Storage State文件管理工具 +用于管理和清理storage_state文件 +""" +import os +import json +import time +from datetime import datetime, timedelta +from pathlib import Path + + +STORAGE_DIR = "storage_states" + + +def get_storage_files(): + """获取所有storage_state文件""" + if not os.path.exists(STORAGE_DIR): + return [] + + files = [] + for filename in os.listdir(STORAGE_DIR): + if filename.endswith('.json'): + filepath = os.path.join(STORAGE_DIR, filename) + stat = os.stat(filepath) + files.append({ + 'filename': filename, + 'filepath': filepath, + 'size': stat.st_size, + 'modified_time': stat.st_mtime, + 'modified_date': datetime.fromtimestamp(stat.st_mtime) + }) + return files + + +def cleanup_old_files(days=30): + """清理超过指定天数未使用的文件""" + files = get_storage_files() + cutoff_time = time.time() - (days * 24 * 60 * 60) + deleted_count = 0 + + print(f"\n开始清理{days}天前的storage_state文件...") + for file_info in files: + if file_info['modified_time'] < cutoff_time: + try: + os.remove(file_info['filepath']) + print(f" 已删除: {file_info['filename']} (最后修改: {file_info['modified_date']})") + deleted_count += 1 + except Exception as e: + print(f" 删除失败 {file_info['filename']}: {e}") + + print(f"\n清理完成!共删除 {deleted_count} 个文件") + return deleted_count + + +def list_storage_files(): + """列出所有storage_state文件""" + files = get_storage_files() + + if not files: + print("\n未找到任何storage_state文件") + return + + print(f"\n找到 {len(files)} 个storage_state文件:\n") + print(f"{'文件名':<40} {'大小':<10} {'最后修改时间'}") + print("-" * 80) + + for file_info in sorted(files, key=lambda x: x['modified_time'], reverse=True): + size_kb = file_info['size'] / 1024 + print(f"{file_info['filename']:<40} {size_kb:>8.1f}KB {file_info['modified_date']}") + + total_size = sum(f['size'] for f in files) / 1024 / 1024 + print(f"\n总大小: {total_size:.2f} MB") + + +def validate_storage_file(phone): + """验证指定手机号的storage_state文件是否有效""" + filepath = os.path.join(STORAGE_DIR, f"xhs_{phone}.json") + + if not os.path.exists(filepath): + print(f"\n❌ 文件不存在: {filepath}") + return False + + try: + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + + # 检查必要字段 + if 'cookies' not in data: + print(f"\n❌ 文件格式错误: 缺少cookies字段") + return False + + if 'origins' not in data: + print(f"\n⚠️ 文件格式不完整: 缺少origins字段") + + cookie_count = len(data.get('cookies', [])) + print(f"\n✅ 文件有效") + print(f" Cookie数量: {cookie_count}") + print(f" 文件大小: {os.path.getsize(filepath) / 1024:.1f}KB") + print(f" 最后修改: {datetime.fromtimestamp(os.path.getmtime(filepath))}") + + return True + + except json.JSONDecodeError: + print(f"\n❌ 文件格式错误: 不是有效的JSON") + return False + except Exception as e: + print(f"\n❌ 验证失败: {e}") + return False + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("用法:") + print(" python storage_state_manager.py list # 列出所有文件") + print(" python storage_state_manager.py cleanup [days] # 清理旧文件(默认30天)") + print(" python storage_state_manager.py validate # 验证指定手机号的文件") + sys.exit(1) + + command = sys.argv[1] + + if command == "list": + list_storage_files() + elif command == "cleanup": + days = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + cleanup_old_files(days) + elif command == "validate": + if len(sys.argv) < 3: + print("错误: 请提供手机号") + sys.exit(1) + phone = sys.argv[2] + validate_storage_file(phone) + else: + print(f"未知命令: {command}") + sys.exit(1) + diff --git a/backend/test.py b/backend/test.py new file mode 100644 index 0000000..b2897ed --- /dev/null +++ b/backend/test.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +使用requests请求代理服务器 +请求http和https网页均适用 +""" + +import requests + + +proxy_ip = "36.137.177.131:50001"; + +# 用户名密码认证(私密代理/独享代理) +username = "qqwvy0" +password = "mun3r7xz" +proxies = { + "http": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": username, "pwd": password, "proxy": proxy_ip}, + "https": "http://%(user)s:%(pwd)s@%(proxy)s/" % {"user": username, "pwd": password, "proxy": proxy_ip} +} + + +print(proxies) + +# 要访问的目标网页 +target_url = "https://creator.xiaohongshu.com/login"; + +# 使用代理IP发送请求 +response = requests.get(target_url, proxies=proxies) + +# 获取页面内容 +if response.status_code == 200: + print(response.text) \ No newline at end of file diff --git a/backend/test_basic_browser.py b/backend/test_basic_browser.py new file mode 100644 index 0000000..26b1368 --- /dev/null +++ b/backend/test_basic_browser.py @@ -0,0 +1,170 @@ +""" +基础浏览器测试脚本 +用于测试浏览器是否能正常加载小红书页面 +""" +import asyncio +from playwright.async_api import async_playwright +import sys + + +async def test_basic_browser(proxy_index: int = 0): + """基础浏览器测试""" + print(f"\n{'='*60}") + print(f"🔍 基础浏览器测试") + print(f"{'='*60}") + + # 从代理配置获取代理信息 + from damai_proxy_config import get_proxy_config + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + + try: + async with async_playwright() as p: + # 配置代理 + proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') + if len(proxy_parts) == 2: + auth_part = proxy_parts[0] + server_part = proxy_parts[1] + username, password = auth_part.split(':') + + proxy_config_obj = { + "server": f"http://{server_part}", + "username": username, + "password": password + } + else: + proxy_config_obj = {"server": proxy_url} + + print(f" 配置的代理对象: {proxy_config_obj}") + + # 启动浏览器 + browser = await p.chromium.launch( + headless=False, # 非无头模式,便于观察 + proxy=proxy_config_obj + ) + + # 创建上下文 + context = await browser.new_context( + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ) + + # 创建页面 + page = await context.new_page() + + print(f"\n🌐 尝试访问百度...") + try: + await page.goto('https://www.baidu.com', wait_until='networkidle', timeout=15000) + await asyncio.sleep(2) + + title = await page.title() + url = page.url + content_len = len(await page.content()) + + print(f" ✅ 百度访问成功") + print(f" 标题: {title}") + print(f" URL: {url}") + print(f" 内容长度: {content_len} 字符") + except Exception as e: + print(f" ❌ 百度访问失败: {str(e)}") + + print(f"\n🌐 尝试访问小红书登录页...") + try: + await page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=15000) + await asyncio.sleep(5) # 等待更长时间 + + title = await page.title() + url = page.url + content = await page.content() + content_len = len(content) + + print(f" 访问结果:") + print(f" 标题: {title}") + print(f" URL: {url}") + print(f" 内容长度: {content_len} 字符") + + # 检查是否有特定内容 + if content_len == 0: + print(f" ⚠️ 页面内容为空,可能存在加载问题") + elif "验证" in content or "captcha" in content.lower() or "安全" in content: + print(f" ⚠️ 检测到验证或安全提示") + else: + print(f" ✅ 页面加载正常") + + # 查找页面上的所有元素 + print(f"\n🔍 分析页面元素...") + + # 查找所有input元素 + inputs = await page.query_selector_all('input') + print(f" 找到 {len(inputs)} 个input元素") + + # 查找所有表单相关元素 + form_elements = await page.query_selector_all('input, button, select, textarea') + print(f" 找到 {len(form_elements)} 个表单相关元素") + + # 打印前几个元素的信息 + for i, elem in enumerate(form_elements[:5]): + try: + tag = await elem.evaluate('el => el.tagName') + text = await elem.inner_text() + placeholder = await elem.get_attribute('placeholder') + class_name = await elem.get_attribute('class') + id_attr = await elem.get_attribute('id') + + print(f" 元素 {i+1}:") + print(f" - 标签: {tag}") + print(f" - 文本: {text[:50]}...") + print(f" - placeholder: {placeholder}") + print(f" - class: {class_name[:50]}...") + print(f" - id: {id_attr}") + except Exception as e: + print(f" 元素 {i+1}: 获取信息失败 - {str(e)}") + + except Exception as e: + print(f" ❌ 小红书访问失败: {str(e)}") + import traceback + traceback.print_exc() + + print(f"\n⏸️ 浏览器保持打开状态,您可以手动检查页面") + print(f" 按 Enter 键关闭浏览器...") + + # 等待用户输入 + input() + + await browser.close() + print(f"✅ 浏览器已关闭") + + except Exception as e: + print(f"❌ 测试过程异常: {str(e)}") + import traceback + traceback.print_exc() + + +async def main(): + """主函数""" + print("="*60) + print("🔍 基础浏览器测试工具") + print("="*60) + + proxy_choice = input("\n请选择代理 (0 或 1, 默认为0): ").strip() + if proxy_choice not in ['0', '1']: + proxy_choice = '0' + proxy_idx = int(proxy_choice) + + await test_basic_browser(proxy_idx) + + print(f"\n{'='*60}") + print("✅ 测试完成!") + print("="*60) + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 运行测试 + asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_browser_pool_fix.py b/backend/test_browser_pool_fix.py new file mode 100644 index 0000000..86d8153 --- /dev/null +++ b/backend/test_browser_pool_fix.py @@ -0,0 +1,213 @@ +""" +测试修复后的浏览器池 +验证预热超时问题是否已解决 +""" +import asyncio +import sys +from xhs_login import XHSLoginService + + +async def test_browser_pool_with_proxy(proxy_index: int = 0): + """测试修复后的浏览器池""" + print(f"\n{'='*60}") + print(f"🔧 测试修复后的浏览器池") + print(f"{'='*60}") + + # 从代理配置获取代理信息 + from damai_proxy_config import get_proxy_config + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + print(f" 代理URL: {proxy_url}") + + # 创建登录服务(使用浏览器池) + login_service = XHSLoginService(use_pool=True) # 使用浏览器池 + + try: + print(f"\n🚀 初始化浏览器(使用代理 + 浏览器池)...") + user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) + print("✅ 浏览器初始化成功") + + # 检查浏览器池状态 + browser_pool = login_service.browser_pool + if browser_pool: + stats = browser_pool.get_stats() + print(f"\n📊 浏览器池状态:") + print(f" 主浏览器存活: {stats['browser_alive']}") + print(f" 上下文存活: {stats['context_alive']}") + print(f" 页面存活: {stats['page_alive']}") + print(f" 是否预热: {stats['is_preheated']}") + print(f" 临时浏览器数: {stats['temp_browsers_count']}") + + # 访问小红书登录页面 + print(f"\n🌐 访问小红书创作者平台...") + await login_service.page.goto('https://creator.xiaohongshu.com/login', wait_until='domcontentloaded', timeout=30000) + await asyncio.sleep(2) + + title = await login_service.page.title() + url = login_service.page.url + content_len = len(await login_service.page.content()) + + print(f"✅ 访问成功") + print(f" 标题: {title}") + print(f" URL: {url}") + print(f" 内容长度: {content_len} 字符") + + # 检查关键元素 + phone_input = await login_service.page.query_selector('input[placeholder="手机号"]') + if phone_input: + print(f"✅ 找到手机号输入框") + else: + print(f"❌ 未找到手机号输入框") + + # 查找所有input元素 + inputs = await login_service.page.query_selector_all('input') + print(f" 共找到 {len(inputs)} 个input元素") + + if content_len == 0: + print(f"⚠️ 页面内容为空") + else: + print(f"✅ 页面内容正常加载") + + return True + + except Exception as e: + print(f"❌ 测试失败: {str(e)}") + import traceback + traceback.print_exc() + return False + finally: + await login_service.close_browser() + + +async def test_multiple_requests(proxy_index: int = 0): + """测试多个请求复用浏览器池""" + print(f"\n{'='*60}") + print(f"🔄 测试浏览器池复用") + print(f"{'='*60}") + + from damai_proxy_config import get_proxy_config + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + + success_count = 0 + + for i in range(3): + print(f"\n🧪 请求 {i+1}/3") + login_service = XHSLoginService(use_pool=True) + + try: + user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) + + # 访问页面 + await login_service.page.goto('https://creator.xiaohongshu.com/login', wait_until='domcontentloaded', timeout=30000) + await asyncio.sleep(1) + + content_len = len(await login_service.page.content()) + if content_len > 0: + print(f" ✅ 请求 {i+1} 成功,内容长度: {content_len}") + success_count += 1 + else: + print(f" ❌ 请求 {i+1} 失败,内容为空") + + except Exception as e: + print(f" ❌ 请求 {i+1} 异常: {str(e)}") + finally: + await login_service.close_browser() + + # 等待一下避免请求过于频繁 + if i < 2: + await asyncio.sleep(1) + + print(f"\n📈 测试结果: {success_count}/3 请求成功") + return success_count == 3 + + +def explain_fix(): + """解释修复内容""" + print("="*60) + print("🔧 修复内容说明") + print("="*60) + + print("\n修复的两个问题:") + print("1. 增加超时时间: 从30秒增加到45秒") + print("2. 修改等待策略: 从'networkidle'改为'domcontentloaded'") + print(" - 'networkidle': 等待网络空闲(可能等待时间过长)") + print(" - 'domcontentloaded': DOM内容加载完成(更快更稳定)") + + print("\n浏览器池优化效果:") + print("✅ 减少预热超时错误") + print("✅ 提高页面加载成功率") + print("✅ 保持浏览器常驻,提升性能") + + +async def main(): + """主函数""" + explain_fix() + + print(f"\n{'='*60}") + print("🎯 选择测试模式") + print("="*60) + + print("\n1. 单次浏览器池测试") + print("2. 多请求复用测试") + print("3. 全部测试") + + try: + choice = input("\n请选择测试模式 (1-3, 默认为3): ").strip() + + if choice not in ['1', '2', '3']: + choice = '3' + + proxy_choice = input("请选择代理 (0 或 1, 默认为0): ").strip() + if proxy_choice not in ['0', '1']: + proxy_choice = '0' + proxy_idx = int(proxy_choice) + + if choice in ['1', '3']: + print(f"\n{'-'*40}") + print("测试1: 单次浏览器池测试") + success1 = await test_browser_pool_with_proxy(proxy_idx) + + if choice in ['2', '3']: + print(f"\n{'-'*40}") + print("测试2: 多请求复用测试") + success2 = await test_multiple_requests(proxy_idx) + + if choice == '3': + overall_success = success1 and success2 + elif choice == '1': + overall_success = success1 + else: + overall_success = success2 + + print(f"\n{'='*60}") + if overall_success: + print("✅ 所有测试通过!浏览器池预热问题已修复") + else: + print("❌ 部分测试失败,请检查配置") + print("="*60) + + except KeyboardInterrupt: + print("\n\n⚠️ 测试被用户中断") + except Exception as e: + print(f"\n❌ 测试过程中出现错误: {str(e)}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 运行测试 + asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_cookie_format_fix.py b/backend/test_cookie_format_fix.py new file mode 100644 index 0000000..48c27d5 --- /dev/null +++ b/backend/test_cookie_format_fix.py @@ -0,0 +1,313 @@ +""" +测试Cookie格式处理修复 +验证scheduler.py中的_format_cookies方法能正确处理各种Cookie格式 +""" +import json +from typing import List, Dict + + +def _format_cookies(cookies) -> List[Dict]: + """ + 格式化Cookie,只处理非标准格式的Cookie + 对于Playwright原生格式的Cookie,直接返回,不做任何修改 + + 这是scheduler.py中_format_cookies方法的副本,用于独立测试 + + Args: + cookies: Cookie数据,支持list[dict]或dict格式 + + Returns: + 格式化后的Cookie列表 + """ + # 如果是字典格式(键值对),转换为列表格式 + if isinstance(cookies, dict): + cookies = [ + { + "name": name, + "value": str(value) if not isinstance(value, str) else value, + "domain": ".xiaohongshu.com", + "path": "/" + } + for name, value in cookies.items() + ] + + # 验证是否为列表 + if not isinstance(cookies, list): + raise ValueError(f"Cookie必须是列表或字典格式,当前类型: {type(cookies).__name__}") + + # 检查是否是Playwright原生格式(包含name和value字段) + if cookies and isinstance(cookies[0], dict) and 'name' in cookies[0] and 'value' in cookies[0]: + # 已经是Playwright格式,直接返回,不做任何修改 + return cookies + + # 其他格式,进行基础验证 + formatted_cookies = [] + for cookie in cookies: + if not isinstance(cookie, dict): + raise ValueError(f"Cookie元素必须是字典格式,当前类型: {type(cookie).__name__}") + + # 确保有基本字段 + if 'domain' not in cookie and 'url' not in cookie: + cookie = cookie.copy() + cookie['domain'] = '.xiaohongshu.com' + if 'path' not in cookie and 'url' not in cookie: + if 'domain' in cookie or 'url' not in cookie: + cookie = cookie.copy() if cookie is cookies[cookies.index(cookie)] else cookie + cookie['path'] = '/' + + formatted_cookies.append(cookie) + + return formatted_cookies + + +def test_format_cookies(): + """测试_format_cookies方法""" + + print("="*60) + print("测试 Cookie 格式处理") + print("="*60) + + # 测试1: 字典格式(键值对) + print("\n测试 1: 字典格式(键值对)") + cookies_dict = { + "a1": "xxx", + "webId": "yyy", + "web_session": "zzz" + } + try: + result = _format_cookies(cookies_dict) + print(f"✅ 成功处理字典格式") + print(f" 输入: {type(cookies_dict).__name__} with {len(cookies_dict)} items") + print(f" 输出: {type(result).__name__} with {len(result)} items") + print(f" 第一个Cookie: {result[0]}") + assert isinstance(result, list) + assert len(result) == 3 + assert all('name' in c and 'value' in c and 'domain' in c for c in result) + except Exception as e: + print(f"❌ 失败: {str(e)}") + + # 测试2: 列表格式(完整格式,已有domain和path) + print("\n测试 2: 列表格式(完整格式)") + cookies_list_full = [ + { + "name": "a1", + "value": "xxx", + "domain": ".xiaohongshu.com", + "path": "/", + "expires": -1, + "httpOnly": False, + "secure": False, + "sameSite": "Lax" + } + ] + try: + result = _format_cookies(cookies_list_full) + print(f"✅ 成功处理完整列表格式") + print(f" 输入: {type(cookies_list_full).__name__} with {len(cookies_list_full)} items") + print(f" 输出: {type(result).__name__} with {len(result)} items") + # 验证Playwright原生格式被完整保留 + print(f" 保留的字段: {list(result[0].keys())}") + assert result == cookies_list_full, "Playwright原生格式应该被完整保留,不做任何修改" + assert 'expires' in result[0], "expires字段应该被保留" + assert result[0]['expires'] == -1, "expires=-1应该被保留" + assert isinstance(result, list) + assert len(result) == 1 + except Exception as e: + print(f"❌ 失败: {str(e)}") + + # 测试3: 非Playwright格式(缺少name字段,需要补充domain和path) + print("\n测试 3: 非Playwright格式(缺少字段,需要补充)") + cookies_list_partial = [ + { + "cookie_name": "a1", # 没有name字段,不是Playwright格式 + "cookie_value": "xxx" + } + ] + try: + result = _format_cookies(cookies_list_partial) + print(f"✅ 成功处理非Playwright格式") + print(f" 输入: {type(cookies_list_partial).__name__} with {len(cookies_list_partial)} items") + print(f" 输出: {type(result).__name__} with {len(result)} items") + print(f" 自动添加的字段: domain={result[0].get('domain')}, path={result[0].get('path')}") + assert isinstance(result, list) + # 应该自动添加domain和path + assert result[0]['domain'] == '.xiaohongshu.com' + assert result[0]['path'] == '/' + except Exception as e: + print(f"❌ 失败: {str(e)}") + + # 测试4: 双重JSON编码(模拟数据库存储场景) + print("\n测试 4: 双重JSON编码字符串") + cookies_dict = {"a1": "xxx", "webId": "yyy"} + # 第一次JSON编码 + cookies_json_1 = json.dumps(cookies_dict) + # 第二次JSON编码 + cookies_json_2 = json.dumps(cookies_json_1) + + print(f" 原始字典: {cookies_dict}") + print(f" 第一次编码: {cookies_json_1}") + print(f" 第二次编码: {cookies_json_2}") + + # 模拟从数据库读取并解析 + try: + # 第一次解析 + cookies_parsed_1 = json.loads(cookies_json_2) + print(f" 第一次解析后类型: {type(cookies_parsed_1).__name__}") + + # 处理双重编码 + if isinstance(cookies_parsed_1, str): + cookies_parsed_2 = json.loads(cookies_parsed_1) + print(f" 第二次解析后类型: {type(cookies_parsed_2).__name__}") + cookies = cookies_parsed_2 + else: + cookies = cookies_parsed_1 + + # 格式化 + result = _format_cookies(cookies) + print(f"✅ 成功处理双重JSON编码") + print(f" 最终输出: {type(result).__name__} with {len(result)} items") + assert isinstance(result, list) + except Exception as e: + print(f"❌ 失败: {str(e)}") + + # 测试5: 错误格式 - 字符串(不是JSON) + print("\n测试 5: 错误格式 - 普通字符串") + try: + result = _format_cookies("invalid_string") + print(f"❌ 应该抛出异常但没有") + except ValueError as e: + print(f"✅ 正确抛出ValueError异常") + print(f" 错误信息: {str(e)}") + except Exception as e: + print(f"❌ 抛出了非预期的异常: {str(e)}") + + # 测试6: 错误格式 - 列表中包含非字典元素 + print("\n测试 6: 错误格式 - 列表中包含非字典元素") + try: + result = _format_cookies(["string_item", 123]) + print(f"❌ 应该抛出异常但没有") + except ValueError as e: + print(f"✅ 正确抛出ValueError异常") + print(f" 错误信息: {str(e)}") + except Exception as e: + print(f"❌ 抛出了非预期的异常: {str(e)}") + + # 测试7: Playwright原生格式中value为对象(保持原样) + print("\n测试 7: Playwright原生格式中value为对象(应保持原样)") + cookies_with_object_value = [ + { + "name": "test_cookie", + "value": {"nested": "object"}, # value是对象 + "domain": ".xiaohongshu.com", + "path": "/" + } + ] + try: + result = _format_cookies(cookies_with_object_value) + print(f"✅ Playwright原生格式被完整保留") + print(f" 输入value类型: {type(cookies_with_object_value[0]['value']).__name__}") + print(f" 输出value类型: {type(result[0]['value']).__name__}") + print(f" 输出value内容: {result[0]['value']}") + # Playwright原生格式不做任何修改,包括uvalue + assert result == cookies_with_object_value, "Playwright原生格式应完整保留" + except Exception as e: + print(f"❌ 失败: {str(e)}") + + # 测试8: 字典格式中value为数字 + print("\n测试 8: 字典格式中value为数字(应自动转换为字符串)") + cookies_dict_with_number = { + "a1": "xxx", + "user_id": 12345, # value是数字 + "is_login": True # value是布尔值 + } + try: + result = _format_cookies(cookies_dict_with_number) + print(f"✅ 成功处理数字/布尔value") + print(f" 输入: {cookies_dict_with_number}") + print(f" user_id value类型: {type(result[1]['value']).__name__}, 值: {result[1]['value']}") + print(f" is_login value类型: {type(result[2]['value']).__name__}, 值: {result[2]['value']}") + # 验证不再包含expires等字段 + print(f" 字段: {list(result[0].keys())}") + assert all(isinstance(c['value'], str) for c in result), "所有value应该都是字符串类型" + assert 'expires' not in result[0], "不应该包含expires字段" + except Exception as e: + print(f"❌ 失败: {str(e)}") + + # 测试9: Playwright原生格式中expires=-1(应被保留) + print("\n测试 9: Playwright原生格式中expires=-1(应被保留)") + cookies_with_invalid_expires = [ + { + "name": "test_cookie", + "value": "test_value", + "domain": ".xiaohongshu.com", + "path": "/", + "expires": -1 # Playwright原生格式 + } + ] + try: + result = _format_cookies(cookies_with_invalid_expires) + print(f"✅ Playwright原生格式被完整保留") + print(f" 原始字段: {list(cookies_with_invalid_expires[0].keys())}") + print(f" 处理后字段: {list(result[0].keys())}") + assert result == cookies_with_invalid_expires, "Playwright原生格式应被完整保留" + assert 'expires' in result[0] and result[0]['expires'] == -1, "expires=-1应该被保留" + except Exception as e: + print(f"❌ 失败: {str(e)}") + + # 测试10: Playwright原生格式中expires为浮点数(应被保留) + print("\n测试 10: Playwright原生格式中expires为浮点数(应被保留)") + cookies_with_float_expires = [ + { + "name": "test_cookie", + "value": "test_value", + "domain": ".xiaohongshu.com", + "path": "/", + "expires": 1797066497.112584 # Playwright原生格式常常有浮点数 + } + ] + try: + result = _format_cookies(cookies_with_float_expires) + print(f"✅ Playwright原生格式被完整保留") + print(f" 原始expires: {cookies_with_float_expires[0]['expires']} (类型: {type(cookies_with_float_expires[0]['expires']).__name__})") + print(f" 处理后expires: {result[0]['expires']} (类型: {type(result[0]['expires']).__name__})") + assert result == cookies_with_float_expires, "Playwright原生格式应被完整保留" + assert isinstance(result[0]['expires'], float), "expires浮点数应该被保留" + except Exception as e: + print(f"❌ 失败: {str(e)}") + + # 测试11: Playwright原生格式中sameSite大小写(应被保留) + print("\n测试 11: Playwright原生格式中sameSite(应被完整保留)") + cookies_with_samesite = [ + { + "name": "test_cookie1", + "value": "test_value1", + "domain": ".xiaohongshu.com", + "path": "/", + "sameSite": "Lax" # Playwright原生格式 + }, + { + "name": "test_cookie2", + "value": "test_value2", + "domain": ".xiaohongshu.com", + "path": "/", + "sameSite": "Strict" + } + ] + try: + result = _format_cookies(cookies_with_samesite) + print(f"✅ Playwright原生格式被完整保留") + print(f" cookie1 sameSite: {result[0]['sameSite']}") + print(f" cookie2 sameSite: {result[1]['sameSite']}") + assert result == cookies_with_samesite, "Playwright原生格式应被完整保留" + assert result[0]['sameSite'] == 'Lax' + assert result[1]['sameSite'] == 'Strict' + except Exception as e: + print(f"❌ 失败: {str(e)}") + + print("\n" + "="*60) + print("测试完成") + print("="*60) + + +if __name__ == "__main__": + test_format_cookies() diff --git a/backend/test_cookie_inject.bat b/backend/test_cookie_inject.bat new file mode 100644 index 0000000..b83a6bd --- /dev/null +++ b/backend/test_cookie_inject.bat @@ -0,0 +1,31 @@ +@echo off +chcp 65001 >nul +echo ======================================== +echo 小红书Cookie注入测试工具 +echo ======================================== +echo. +echo 此工具使用Playwright真实注入Cookie +echo 支持验证Cookie有效性并跳转到指定页面 +echo. +echo ======================================== +echo. + +cd /d %~dp0 + +REM 检查是否有cookies.json文件 +if exist cookies.json ( + echo 检测到 cookies.json 文件 + echo. + python test_cookie_inject.py +) else ( + echo 未找到 cookies.json 文件 + echo 请先准备Cookie文件或在程序中手动输入 + echo. + python test_cookie_inject.py +) + +echo. +echo ======================================== +echo 测试完成 +echo ======================================== +pause diff --git a/backend/test_cookie_inject.py b/backend/test_cookie_inject.py new file mode 100644 index 0000000..b217f7d --- /dev/null +++ b/backend/test_cookie_inject.py @@ -0,0 +1,398 @@ +""" +Cookie注入测试脚本 +使用Playwright注入Cookie并验证其有效性 +支持跳转到创作者中心或小红书首页 +""" +import asyncio +import sys +import json +from pathlib import Path +from playwright.async_api import async_playwright +from typing import Optional, List, Dict, Any + + +class CookieInjector: + """Cookie注入器""" + + def __init__(self, headless: bool = False): + """ + 初始化Cookie注入器 + + Args: + headless: 是否使用无头模式,False可以看到浏览器界面 + """ + self.headless = headless + self.playwright = None + self.browser = None + self.context = None + self.page = None + + async def init_browser(self): + """初始化浏览器""" + try: + print("正在启动浏览器...") + + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + try: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + except Exception as e: + print(f"警告: 设置事件循环策略失败: {str(e)}") + + self.playwright = await async_playwright().start() + + # 启动浏览器 + self.browser = await self.playwright.chromium.launch( + headless=self.headless, + args=['--disable-blink-features=AutomationControlled'] + ) + + # 创建浏览器上下文 + self.context = await self.browser.new_context( + viewport={'width': 1280, 'height': 720}, + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ) + + # 创建新页面 + self.page = await self.context.new_page() + + print("浏览器初始化成功") + + except Exception as e: + print(f"浏览器初始化失败: {str(e)}") + raise + + async def inject_cookies(self, cookies: List[Dict[str, Any]]) -> bool: + """ + 注入Cookie + + Args: + cookies: Cookie列表 + + Returns: + 是否注入成功 + """ + try: + if not self.context: + await self.init_browser() + + print(f"正在注入 {len(cookies)} 个Cookie...") + + # 注入Cookie到浏览器上下文 + await self.context.add_cookies(cookies) + + print("Cookie注入成功") + return True + + except Exception as e: + print(f"Cookie注入失败: {str(e)}") + return False + + async def verify_and_navigate(self, target_page: str = 'creator') -> Dict[str, Any]: + """ + 验证Cookie并跳转到指定页面 + + Args: + target_page: 目标页面类型 ('creator' 或 'home') + + Returns: + 验证结果字典 + """ + try: + if not self.page: + return {"success": False, "error": "浏览器未初始化"} + + # 确定目标URL + urls = { + 'creator': 'https://creator.xiaohongshu.com', + 'home': 'https://www.xiaohongshu.com' + } + target_url = urls.get(target_page, urls['creator']) + page_name = '创作者中心' if target_page == 'creator' else '小红书首页' + + print(f"\n正在访问{page_name}: {target_url}") + + # 访问目标页面 + await self.page.goto(target_url, wait_until='networkidle', timeout=30000) + await asyncio.sleep(2) # 等待页面完全加载 + + # 获取当前URL和标题 + current_url = self.page.url + title = await self.page.title() + + print(f"当前URL: {current_url}") + print(f"页面标题: {title}") + + # 检查是否被重定向到登录页 + is_logged_in = 'login' not in current_url.lower() + + if is_logged_in: + print("Cookie验证成功,已登录状态") + + # 尝试获取用户信息 + try: + # 等待用户相关元素出现(如头像、用户名等) + await self.page.wait_for_selector('[class*="avatar"], [class*="user"]', timeout=5000) + print("检测到用户信息元素,确认登录成功") + except Exception: + print("未检测到明显的用户信息元素,但未跳转到登录页") + + return { + "success": True, + "message": f"Cookie有效,已成功访问{page_name}", + "url": current_url, + "title": title, + "logged_in": True + } + else: + print("Cookie可能已失效,页面跳转到登录页") + return { + "success": False, + "error": "Cookie已失效或无效,页面跳转到登录页", + "url": current_url, + "title": title, + "logged_in": False + } + + except Exception as e: + print(f"验证过程异常: {str(e)}") + import traceback + traceback.print_exc() + return { + "success": False, + "error": f"验证过程异常: {str(e)}" + } + + async def keep_browser_open(self, duration: int = 60): + """ + 保持浏览器打开一段时间,方便观察 + + Args: + duration: 保持打开的秒数,0表示永久打开直到手动关闭 + """ + try: + if duration == 0: + print("\n浏览器将保持打开,按 Ctrl+C 关闭...") + try: + while True: + await asyncio.sleep(1) + except KeyboardInterrupt: + print("\n用户中断,准备关闭浏览器...") + else: + print(f"\n浏览器将保持打开 {duration} 秒...") + await asyncio.sleep(duration) + print("时间到,准备关闭浏览器...") + except Exception as e: + print(f"保持浏览器异常: {str(e)}") + + async def close_browser(self): + """关闭浏览器""" + try: + print("\n正在关闭浏览器...") + if self.page: + await self.page.close() + if self.context: + await self.context.close() + if self.browser: + await self.browser.close() + if self.playwright: + await self.playwright.stop() + print("浏览器已关闭") + except Exception as e: + print(f"关闭浏览器异常: {str(e)}") + + +def load_cookies_from_file(file_path: str) -> Optional[List[Dict[str, Any]]]: + """ + 从文件加载Cookie + + Args: + file_path: Cookie文件路径 + + Returns: + Cookie列表,失败返回None + """ + try: + cookie_file = Path(file_path) + if not cookie_file.exists(): + print(f"Cookie文件不存在: {file_path}") + return None + + with open(cookie_file, 'r', encoding='utf-8') as f: + cookies = json.load(f) + + if not isinstance(cookies, list): + print("Cookie格式错误:必须是数组") + return None + + if len(cookies) == 0: + print("Cookie数组为空") + return None + + # 验证每个Cookie必须有name和value + for cookie in cookies: + if not cookie.get('name') or not cookie.get('value'): + print(f"Cookie格式错误:缺少name或value字段") + return None + + print(f"成功加载 {len(cookies)} 个Cookie") + return cookies + + except json.JSONDecodeError as e: + print(f"Cookie文件JSON解析失败: {str(e)}") + return None + except Exception as e: + print(f"加载Cookie文件失败: {str(e)}") + return None + + +async def test_cookie_inject( + cookies_source: str, + target_page: str = 'creator', + headless: bool = False, + keep_open: int = 0 +): + """ + 测试Cookie注入 + + Args: + cookies_source: Cookie来源(文件路径或JSON字符串) + target_page: 目标页面 ('creator' 或 'home') + headless: 是否使用无头模式 + keep_open: 保持浏览器打开的秒数(0表示永久打开) + """ + print("="*60) + print("Cookie注入并验证测试") + print("="*60) + + # 加载Cookie + cookies = None + + # 尝试作为文件路径加载 + if Path(cookies_source).exists(): + print(f"\n从文件加载Cookie: {cookies_source}") + cookies = load_cookies_from_file(cookies_source) + else: + # 尝试作为JSON字符串解析 + try: + print("\n尝试解析Cookie JSON字符串...") + cookies = json.loads(cookies_source) + if isinstance(cookies, list) and len(cookies) > 0: + print(f"成功解析 {len(cookies)} 个Cookie") + except Exception as e: + print(f"Cookie解析失败: {str(e)}") + + if not cookies: + print("\n加载Cookie失败,请检查输入") + return + + # 创建注入器 + injector = CookieInjector(headless=headless) + + try: + # 初始化浏览器 + await injector.init_browser() + + # 注入Cookie + inject_success = await injector.inject_cookies(cookies) + + if not inject_success: + print("\nCookie注入失败") + return + + # 验证并跳转 + result = await injector.verify_and_navigate(target_page) + + print("\n" + "="*60) + print("验证结果") + print("="*60) + + if result.get('success'): + print(f"状态: 成功") + print(f"消息: {result.get('message')}") + print(f"URL: {result.get('url')}") + print(f"标题: {result.get('title')}") + print(f"登录状态: {'已登录' if result.get('logged_in') else '未登录'}") + else: + print(f"状态: 失败") + print(f"错误: {result.get('error')}") + if result.get('url'): + print(f"当前URL: {result.get('url')}") + + # 保持浏览器打开 + if keep_open >= 0: + await injector.keep_browser_open(keep_open) + + except KeyboardInterrupt: + print("\n\n用户中断测试") + except Exception as e: + print(f"\n测试过程异常: {str(e)}") + import traceback + traceback.print_exc() + finally: + await injector.close_browser() + + print("\n" + "="*60) + print("测试完成") + print("="*60) + + +async def main(): + """主函数""" + print("="*60) + print("小红书Cookie注入测试工具") + print("="*60) + + print("\n功能说明:") + print("1. 注入Cookie到浏览器") + print("2. 验证Cookie有效性") + print("3. 跳转到指定页面(创作者中心/小红书首页)") + + print("\n" + "="*60) + + # 输入Cookie来源 + print("\n请输入Cookie来源:") + print("1. 输入Cookie文件路径(如: cookies.json)") + print("2. 直接粘贴JSON格式的Cookie") + + cookies_source = input("\nCookie来源: ").strip() + + if not cookies_source: + print("Cookie来源不能为空") + return + + # 选择目标页面 + print("\n请选择目标页面:") + print("1. 创作者中心(creator.xiaohongshu.com)") + print("2. 小红书首页(www.xiaohongshu.com)") + + page_choice = input("\n选择 (1 或 2, 默认为 1): ").strip() + target_page = 'home' if page_choice == '2' else 'creator' + + # 选择浏览器模式 + headless_choice = input("\n是否使用无头模式?(y/n, 默认为 n): ").strip().lower() + headless = headless_choice == 'y' + + # 选择保持打开时间 + keep_open_input = input("\n保持浏览器打开时间(秒,0表示直到手动关闭,默认60): ").strip() + try: + keep_open = int(keep_open_input) if keep_open_input else 60 + except ValueError: + keep_open = 60 + + # 执行测试 + await test_cookie_inject( + cookies_source=cookies_source, + target_page=target_page, + headless=headless, + keep_open=keep_open + ) + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 运行测试 + asyncio.run(main()) diff --git a/backend/test_damai_proxy.py b/backend/test_damai_proxy.py new file mode 100644 index 0000000..8bd56cc --- /dev/null +++ b/backend/test_damai_proxy.py @@ -0,0 +1,207 @@ +""" +大麦固定代理IP测试脚本 +测试两个固定代理IP在无头浏览器中的可用性 +""" +import asyncio +import sys +from playwright.async_api import async_playwright + + +# 大麦固定代理IP配置 +DAMAI_PROXIES = [ + { + "name": "大麦代理1", + "server": "http://36.137.177.131:50001", + "username": "qqwvy0", + "password": "mun3r7xz" + }, + { + "name": "大麦代理2", + "server": "http://111.132.40.72:50002", + "username": "ih3z07", + "password": "078bt7o5" + } +] + + +async def test_proxy(proxy_config: dict): + """ + 测试单个代理IP + + Args: + proxy_config: 代理配置字典 + """ + print(f"\n{'='*60}") + print(f"🔍 开始测试: {proxy_config['name']}") + print(f" 代理服务器: {proxy_config['server']}") + print(f" 认证信息: {proxy_config['username']} / {proxy_config['password']}") + print(f"{'='*60}") + + playwright = None + browser = None + + try: + # 启动Playwright + playwright = await async_playwright().start() + print("✅ Playwright启动成功") + + # 配置代理 + proxy_settings = { + "server": proxy_config["server"], + "username": proxy_config["username"], + "password": proxy_config["password"] + } + + # 启动浏览器(带代理) + print(f"🚀 正在启动浏览器(使用代理: {proxy_config['server']})...") + browser = await playwright.chromium.launch( + headless=True, + proxy=proxy_settings, + args=[ + '--disable-blink-features=AutomationControlled', + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-web-security', + '--disable-features=IsolateOrigins,site-per-process', + ] + ) + print("✅ 浏览器启动成功") + + # 创建上下文 + context = await browser.new_context( + viewport={'width': 1280, 'height': 720}, + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ) + print("✅ 浏览器上下文创建成功") + + # 创建页面 + page = await context.new_page() + print("✅ 页面创建成功") + + # 测试1: 访问IP检测网站(检查代理IP是否生效) + print("\n📍 测试1: 访问IP检测网站...") + try: + await page.goto("http://httpbin.org/ip", timeout=30000) + await asyncio.sleep(2) + + # 获取页面内容 + content = await page.content() + print("✅ 访问成功,页面内容:") + print(content[:500]) # 只显示前500字符 + + # 尝试提取IP信息 + ip_info = await page.evaluate("() => document.body.innerText") + print(f"\n🌐 当前IP信息:\n{ip_info}") + + except Exception as e: + print(f"❌ 测试1失败: {str(e)}") + + # 测试2: 访问小红书登录页(检查代理在实际场景中是否可用) + print("\n📍 测试2: 访问小红书登录页...") + try: + await page.goto("https://creator.xiaohongshu.com/login", timeout=30000) + await asyncio.sleep(3) + + title = await page.title() + url = page.url + print(f"✅ 访问成功") + print(f" 页面标题: {title}") + print(f" 当前URL: {url}") + + except Exception as e: + print(f"❌ 测试2失败: {str(e)}") + + # 测试3: 访问大麦网(测试目标网站) + print("\n📍 测试3: 访问大麦网...") + try: + await page.goto("https://www.damai.cn/", timeout=30000) + await asyncio.sleep(3) + + title = await page.title() + url = page.url + print(f"✅ 访问成功") + print(f" 页面标题: {title}") + print(f" 当前URL: {url}") + + except Exception as e: + print(f"❌ 测试3失败: {str(e)}") + + print(f"\n✅ {proxy_config['name']} 测试完成") + + except Exception as e: + print(f"\n❌ {proxy_config['name']} 测试失败: {str(e)}") + import traceback + traceback.print_exc() + + finally: + # 清理资源 + try: + if browser: + await browser.close() + print("🧹 浏览器已关闭") + if playwright: + await playwright.stop() + print("🧹 Playwright已停止") + except Exception as e: + print(f"⚠️ 清理资源时出错: {str(e)}") + + +async def test_all_proxies(): + """测试所有代理IP""" + print("\n" + "="*60) + print("🎯 大麦固定代理IP测试") + print("="*60) + print(f"📊 共配置 {len(DAMAI_PROXIES)} 个代理IP") + + # 依次测试每个代理 + for i, proxy_config in enumerate(DAMAI_PROXIES, 1): + print(f"\n\n{'#'*60}") + print(f"# 测试进度: {i}/{len(DAMAI_PROXIES)}") + print(f"{'#'*60}") + + await test_proxy(proxy_config) + + # 测试间隔 + if i < len(DAMAI_PROXIES): + print(f"\n⏳ 等待5秒后测试下一个代理...") + await asyncio.sleep(5) + + print("\n" + "="*60) + print("🎉 所有代理测试完成!") + print("="*60) + + +async def test_single_proxy(index: int = 0): + """ + 测试单个代理IP + + Args: + index: 代理索引(0或1) + """ + if index < 0 or index >= len(DAMAI_PROXIES): + print(f"❌ 无效的代理索引: {index},请使用 0 或 1") + return + + await test_proxy(DAMAI_PROXIES[index]) + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 解析命令行参数 + if len(sys.argv) > 1: + try: + proxy_index = int(sys.argv[1]) + print(f"🎯 测试单个代理(索引: {proxy_index})") + asyncio.run(test_single_proxy(proxy_index)) + except ValueError: + print("❌ 参数错误,请使用: python test_damai_proxy.py [0|1]") + print(" 0: 测试代理1") + print(" 1: 测试代理2") + print(" 不带参数: 测试所有代理") + else: + # 测试所有代理 + asyncio.run(test_all_proxies()) diff --git a/backend/test_headless_comparison.py b/backend/test_headless_comparison.py new file mode 100644 index 0000000..e011b46 --- /dev/null +++ b/backend/test_headless_comparison.py @@ -0,0 +1,282 @@ +""" +对比测试有头模式和无头模式的页面获取情况 +""" +import asyncio +from playwright.async_api import async_playwright +import sys + + +async def test_headless_comparison(proxy_index: int = 0): + """对比测试有头模式和无头模式""" + print(f"\n{'='*60}") + print(f"🔍 对比测试有头模式 vs 无头模式") + print(f"{'='*60}") + + # 从代理配置获取代理信息 + from damai_proxy_config import get_proxy_config + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + + # 配置代理对象 + proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') + if len(proxy_parts) == 2: + auth_part = proxy_parts[0] + server_part = proxy_parts[1] + username, password = auth_part.split(':') + + proxy_config_obj = { + "server": f"http://{server_part}", + "username": username, + "password": password + } + else: + proxy_config_obj = {"server": proxy_url} + + print(f" 配置的代理对象: {proxy_config_obj}") + + # 测试无头模式 + print(f"\n🧪 测试 1/2: 无头模式 (headless=True)") + await test_single_mode(True, proxy_config_obj) + + print(f"\n🧪 测试 2/2: 有头模式 (headless=False)") + await test_single_mode(False, proxy_config_obj) + + print(f"\n{'='*60}") + print("✅ 对比测试完成!") + print("="*60) + + +async def test_single_mode(headless: bool, proxy_config_obj: dict): + """测试单个模式""" + mode_name = "无头模式" if headless else "有头模式" + print(f" 正在启动浏览器 ({mode_name})...") + + try: + async with async_playwright() as p: + # 启动浏览器 + browser = await p.chromium.launch( + headless=headless, + proxy=proxy_config_obj, + # 添加一些额外参数以提高稳定性 + args=[ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-blink-features=AutomationControlled', + ] + ) + + # 创建上下文 + context = await browser.new_context( + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + viewport={'width': 1280, 'height': 720} + ) + + # 创建页面 + page = await context.new_page() + + # 访问小红书登录页面 + print(f" 访问小红书登录页...") + try: + # 使用不同的wait_until策略 + await page.goto('https://creator.xiaohongshu.com/login', + wait_until='domcontentloaded', + timeout=15000) + + # 等待一段时间让页面内容加载 + await asyncio.sleep(3) + + # 获取页面信息 + title = await page.title() + url = page.url + content = await page.content() + content_len = len(content) + + print(f" ✅ {mode_name} - 访问成功") + print(f" 标题: {title}") + print(f" URL: {url}") + print(f" 内容长度: {content_len} 字符") + + # 检查关键元素 + phone_input = await page.query_selector('input[placeholder="手机号"]') + if phone_input: + print(f" ✅ 找到手机号输入框") + else: + print(f" ❌ 未找到手机号输入框") + + # 查找所有input元素 + inputs = await page.query_selector_all('input') + print(f" 找到 {len(inputs)} 个input元素") + + if content_len == 0: + print(f" ⚠️ 页面内容为空") + elif "验证" in content or "captcha" in content.lower() or "安全" in content: + print(f" ⚠️ 检测到验证或安全提示") + else: + print(f" ✅ 页面内容正常") + + except Exception as e: + print(f" ❌ {mode_name} - 访问失败: {str(e)}") + + await browser.close() + print(f" 🔄 {mode_name} 浏览器已关闭") + + except Exception as e: + print(f" ❌ {mode_name} - 测试异常: {str(e)}") + + +async def test_with_different_wait_strategies(proxy_index: int = 0): + """测试不同的页面等待策略""" + print(f"\n{'='*60}") + print(f"🔍 测试不同页面等待策略") + print(f"{'='*60}") + + from damai_proxy_config import get_proxy_config + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') + if len(proxy_parts) == 2: + auth_part = proxy_parts[0] + server_part = proxy_parts[1] + username, password = auth_part.split(':') + + proxy_config_obj = { + "server": f"http://{server_part}", + "username": username, + "password": password + } + else: + proxy_config_obj = {"server": proxy_url} + + wait_strategies = [ + ('domcontentloaded', 'DOM内容加载完成'), + ('load', '页面完全加载'), + ('networkidle', '网络空闲'), + ('commit', '导航提交') + ] + + for wait_strategy, description in wait_strategies: + print(f"\n🧪 测试等待策略: {description} ({wait_strategy})") + + try: + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, # 使用无头模式进行测试 + proxy=proxy_config_obj + ) + + context = await browser.new_context( + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ) + + page = await context.new_page() + + try: + print(f" 访问小红书登录页 (wait_until='{wait_strategy}')...") + await page.goto('https://creator.xiaohongshu.com/login', + wait_until=wait_strategy, + timeout=15000) + + # 额外等待时间 + await asyncio.sleep(2) + + content = await page.content() + content_len = len(content) + + print(f" ✅ 访问成功") + print(f" 内容长度: {content_len} 字符") + + # 检查手机号输入框 + phone_input = await page.query_selector('input[placeholder="手机号"]') + if phone_input: + print(f" ✅ 找到手机号输入框") + else: + print(f" ❌ 未找到手机号输入框") + + except Exception as e: + print(f" ❌ 访问失败: {str(e)}") + + await browser.close() + + except Exception as e: + print(f" ❌ 测试异常: {str(e)}") + + +def explain_page_loading_factors(): + """解释影响页面加载的因素""" + print("="*60) + print("💡 影响页面加载的因素") + print("="*60) + + print("\n1. 浏览器模式差异:") + print(" • 有头模式: 浏览器界面可见,渲染更完整") + print(" • 无头模式: 后台运行,可能加载策略略有不同") + + print("\n2. 页面等待策略:") + print(" • domcontentloaded: DOM构建完成(推荐)") + print(" • load: 所有资源加载完成") + print(" • networkidle: 网络空闲(可能等待较长时间)") + + print("\n3. 反检测措施:") + print(" • 浏览器指纹混淆") + print(" • User-Agent设置") + print(" • 禁用webdriver属性") + + print("\n4. 网络因素:") + print(" • 代理IP质量") + print(" • 网络延迟") + print(" • 目标网站反爬虫机制") + + +async def main(): + """主函数""" + explain_page_loading_factors() + + print(f"\n{'='*60}") + print("🎯 选择测试模式") + print("="*60) + + print("\n1. 有头模式 vs 无头模式对比测试") + print("2. 不同页面等待策略测试") + + try: + choice = input("\n请选择测试模式 (1-2, 默认为1): ").strip() + + if choice not in ['1', '2']: + choice = '1' + + proxy_choice = input("请选择代理 (0 或 1, 默认为0): ").strip() + if proxy_choice not in ['0', '1']: + proxy_choice = '0' + proxy_idx = int(proxy_choice) + + if choice == '1': + await test_headless_comparison(proxy_idx) + elif choice == '2': + await test_with_different_wait_strategies(proxy_idx) + + print(f"\n{'='*60}") + print("✅ 测试完成!") + print("="*60) + + except KeyboardInterrupt: + print("\n\n⚠️ 测试被用户中断") + except Exception as e: + print(f"\n❌ 测试过程中出现错误: {str(e)}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 运行测试 + asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_headless_mode.py b/backend/test_headless_mode.py new file mode 100644 index 0000000..2861749 --- /dev/null +++ b/backend/test_headless_mode.py @@ -0,0 +1,356 @@ +""" +使用代理并开启有头模式的示例 +展示如何在使用代理的同时开启浏览器界面 +""" +import asyncio +from playwright.async_api import async_playwright +import sys + + +async def test_proxy_with_headless_false(proxy_index: int = 0): + """使用代理并开启有头模式测试""" + print(f"\n{'='*60}") + print(f"🔍 测试代理 + 有头模式") + print(f"{'='*60}") + + # 从代理配置获取代理信息 + from damai_proxy_config import get_proxy_config + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + print(f" 有头模式: 开启") + + try: + async with async_playwright() as p: + # 配置代理 + proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') + if len(proxy_parts) == 2: + auth_part = proxy_parts[0] + server_part = proxy_parts[1] + username, password = auth_part.split(':') + + proxy_config_obj = { + "server": f"http://{server_part}", + "username": username, + "password": password + } + else: + proxy_config_obj = {"server": proxy_url} + + print(f" 配置的代理对象: {proxy_config_obj}") + + # 启动浏览器 - 使用有头模式 + browser = await p.chromium.launch( + headless=False, # 有头模式,可以看到浏览器界面 + proxy=proxy_config_obj + ) + + # 创建上下文 + context = await browser.new_context( + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ) + + # 创建页面 + page = await context.new_page() + + print(f"\n🌐 访问百度测试代理连接...") + try: + await page.goto('https://www.baidu.com', wait_until='networkidle', timeout=15000) + await asyncio.sleep(2) + + title = await page.title() + url = page.url + print(f" ✅ 百度访问成功") + print(f" 标题: {title}") + print(f" URL: {url}") + except Exception as e: + print(f" ❌ 百度访问失败: {str(e)}") + + print(f"\n🌐 访问小红书创作者平台...") + try: + await page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=15000) + await asyncio.sleep(3) + + title = await page.title() + url = page.url + content_len = len(await page.content()) + + print(f" 访问结果:") + print(f" 标题: {title}") + print(f" URL: {url}") + print(f" 内容长度: {content_len} 字符") + + if content_len == 0: + print(f" ⚠️ 页面内容为空") + else: + print(f" ✅ 页面加载成功") + + except Exception as e: + print(f" ❌ 小红书访问失败: {str(e)}") + + print(f"\n⏸️ 浏览器保持打开状态,您可以观察页面") + print(f" 代理正在生效,您可以看到浏览器界面") + print(f" 按 Enter 键关闭浏览器...") + + # 等待用户输入 + input() + + await browser.close() + print(f"✅ 浏览器已关闭") + + except Exception as e: + print(f"❌ 测试过程异常: {str(e)}") + import traceback + traceback.print_exc() + + +async def test_xhs_login_with_headless_false(phone: str, proxy_index: int = 0): + """ + 使用有头模式测试小红书登录流程 + + Args: + phone: 手机号 + proxy_index: 代理索引 (0 或 1) + """ + print(f"\n{'='*60}") + print(f"📱 使用有头模式测试小红书登录") + print(f"{'='*60}") + + # 从代理配置获取代理信息 + from damai_proxy_config import get_proxy_config + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + print(f" 手机号: {phone}") + print(f" 有头模式: 开启") + + # 创建登录服务,使用有头模式 + from xhs_login import XHSLoginService + login_service = XHSLoginService(use_pool=False) # 不使用池,便于调试 + + try: + # 初始化浏览器(使用代理 + 有头模式) + user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + + # 注意:XHSLoginService 内部使用了浏览器池模式,我们先看看如何修改它来支持有头模式 + print(" 正在启动浏览器(使用代理 + 有头模式)...") + + # 直接使用Playwright创建有头模式的浏览器 + async with async_playwright() as p: + # 配置代理 + proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') + if len(proxy_parts) == 2: + auth_part = proxy_parts[0] + server_part = proxy_parts[1] + username, password = auth_part.split(':') + + proxy_config_obj = { + "server": f"http://{server_part}", + "username": username, + "password": password + } + else: + proxy_config_obj = {"server": proxy_url} + + # 启动浏览器 - 有头模式 + browser = await p.chromium.launch( + headless=False, # 有头模式 + proxy=proxy_config_obj + ) + + context = await browser.new_context( + user_agent=user_agent, + viewport={'width': 1280, 'height': 720} + ) + + page = await context.new_page() + + print("✅ 浏览器启动成功(有头模式 + 代理)") + + # 访问小红书登录页面 + print(f"\n🌐 访问小红书创作者平台登录页...") + await page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=30000) + await asyncio.sleep(2) + + print(f"✅ 进入登录页面") + print(f" 当前URL: {page.url}") + + # 查找手机号输入框 + print(f"\n🔍 查找手机号输入框...") + try: + # 尝试多种选择器 + phone_input_selectors = [ + 'input[placeholder="手机号"]', + 'input[placeholder*="手机"]', + 'input[type="tel"]', + 'input[type="text"]' + ] + + phone_input = None + for selector in phone_input_selectors: + try: + phone_input = await page.wait_for_selector(selector, timeout=3000) + if phone_input: + print(f" ✅ 找到手机号输入框: {selector}") + break + except: + continue + + if phone_input: + # 输入手机号 + await phone_input.fill(phone) + print(f" ✅ 已输入手机号: {phone}") + + # 等待界面更新 + await asyncio.sleep(1) + + # 查找发送验证码按钮 + print(f"\n🔍 查找发送验证码按钮...") + code_button_selectors = [ + 'text="发送验证码"', + 'text="获取验证码"', + 'button:has-text("验证码")', + 'button:has-text("发送")', + 'div:has-text("验证码")' + ] + + code_button = None + for selector in code_button_selectors: + try: + code_button = await page.wait_for_selector(selector, timeout=3000) + if code_button: + print(f" ✅ 找到验证码按钮: {selector}") + break + except: + continue + + if code_button: + print(f"\nℹ️ 已找到手机号输入框和验证码按钮") + print(f" 您可以在浏览器中手动点击发送验证码") + print(f" 验证码将发送到: {phone}") + + print(f"\n⏸️ 浏览器保持打开状态,您可以手动操作") + print(f" 按 Enter 键关闭浏览器...") + input() + else: + print(f" ❌ 未找到发送验证码按钮") + else: + print(f" ❌ 未找到手机号输入框") + print(f"\n📄 页面上可用的输入框:") + inputs = await page.query_selector_all('input') + for i, inp in enumerate(inputs): + try: + placeholder = await inp.get_attribute('placeholder') + input_type = await inp.get_attribute('type') + print(f" 输入框 {i+1}: type={input_type}, placeholder={placeholder}") + except: + continue + + except Exception as e: + print(f" ❌ 操作失败: {str(e)}") + + # 保持浏览器打开供用户观察 + print(f"\n⏸️ 浏览器保持打开状态,您可以观察页面元素") + print(f" 按 Enter 键关闭浏览器...") + input() + + await browser.close() + print(f"✅ 浏览器已关闭") + + except Exception as e: + print(f"❌ 测试过程异常: {str(e)}") + import traceback + traceback.print_exc() + + +def show_headless_comparison(): + """显示有头模式和无头模式的对比""" + print("="*60) + print("💡 有头模式 vs 无头模式对比") + print("="*60) + + print("\n有头模式 (headless=False):") + print(" ✅ 优点:") + print(" • 可以看到浏览器界面,便于调试") + print(" • 可以观察页面加载过程") + print(" • 可以手动与页面交互") + print(" • 有助于识别页面元素选择器") + print("") + print(" ❌ 缺点:") + print(" • 占用屏幕空间") + print(" • 可能影响用户其他操作") + print(" • 资源消耗稍大") + + print("\n无头模式 (headless=True):") + print(" ✅ 优点:") + print(" • 不显示浏览器界面,后台运行") + print(" • 资源消耗较少") + print(" • 适合自动化任务") + print(" • 可以在服务器环境运行") + print("") + print(" ❌ 缺点:") + print(" • 无法直观看到页面") + print(" • 调试相对困难") + + print("\n🎯 使用建议:") + print(" • 开发调试时使用有头模式") + print(" • 生产环境使用无头模式") + print(" • 代理配置在两种模式下都有效") + + +async def main(): + """主函数""" + show_headless_comparison() + + print(f"\n{'='*60}") + print("🎯 选择测试模式") + print("="*60) + + print("\n1. 基础代理 + 有头模式测试") + print("2. 小红书登录 + 有头模式测试") + + try: + choice = input("\n请选择测试模式 (1-2, 默认为1): ").strip() + + if choice not in ['1', '2']: + choice = '1' + + proxy_choice = input("请选择代理 (0 或 1, 默认为0): ").strip() + if proxy_choice not in ['0', '1']: + proxy_choice = '0' + proxy_idx = int(proxy_choice) + + if choice == '1': + await test_proxy_with_headless_false(proxy_idx) + elif choice == '2': + phone = input("请输入手机号: ").strip() + if not phone: + print("❌ 手机号不能为空") + return + await test_xhs_login_with_headless_false(phone, proxy_idx) + + print(f"\n{'='*60}") + print("✅ 测试完成!") + print("="*60) + + except KeyboardInterrupt: + print("\n\n⚠️ 测试被用户中断") + except Exception as e: + print(f"\n❌ 测试过程中出现错误: {str(e)}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 运行测试 + asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_login_flow.py b/backend/test_login_flow.py new file mode 100644 index 0000000..9623a13 --- /dev/null +++ b/backend/test_login_flow.py @@ -0,0 +1,261 @@ +""" +小红书验证码登录流程测试脚本 +测试完整的验证码发送和登录流程 +""" +import asyncio +import sys +from xhs_login import XHSLoginService + + +async def test_send_verification_code(phone: str, proxy_index: int = 0): + """ + 测试发送验证码流程 + + Args: + phone: 手机号 + proxy_index: 代理索引 (0 或 1) + """ + print(f"\n{'='*60}") + print(f"📱 测试发送验证码流程") + print(f"{'='*60}") + + # 从代理配置获取代理信息 + from damai_proxy_config import get_proxy_config + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + print(f" 手机号: {phone}") + + # 创建登录服务 + login_service = XHSLoginService() + + try: + # 初始化浏览器(使用代理) + user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) + print("✅ 浏览器初始化成功(已启用代理)") + + # 发送验证码 + print(f"\n📤 正在发送验证码到 {phone}...") + result = await login_service.send_verification_code(phone) + + if result.get('success'): + print(f"✅ 验证码发送成功!") + print(f" 消息: {result.get('message')}") + return login_service # 返回服务实例供后续登录使用 + else: + print(f"❌ 验证码发送失败: {result.get('error')}") + return None + + except Exception as e: + print(f"❌ 发送验证码过程异常: {str(e)}") + import traceback + traceback.print_exc() + return None + + +async def test_login_with_code(login_service: XHSLoginService, phone: str, code: str): + """ + 测试使用验证码登录 + + Args: + login_service: XHSLoginService实例 + phone: 手机号 + code: 验证码 + """ + print(f"\n{'='*60}") + print(f"🔑 测试使用验证码登录") + print(f"{'='*60}") + + print(f" 手机号: {phone}") + print(f" 验证码: {code}") + + try: + # 执行登录 + result = await login_service.login(phone, code) + + if result.get('success'): + print("✅ 登录成功!") + + # 显示获取到的Cookies信息 + cookies = result.get('cookies', {}) + print(f" 获取到 {len(cookies)} 个Cookie") + + # 保存完整Cookies到文件 + cookies_full = result.get('cookies_full', []) + if cookies_full: + import json + with open('cookies.json', 'w', encoding='utf-8') as f: + json.dump(cookies_full, f, ensure_ascii=False, indent=2) + print(" ✅ 已保存完整Cookies到 cookies.json") + + # 显示用户信息 + user_info = result.get('user_info', {}) + if user_info: + print(f" 用户信息: {list(user_info.keys())}") + + return result + else: + print(f"❌ 登录失败: {result.get('error')}") + return result + + except Exception as e: + print(f"❌ 登录过程异常: {str(e)}") + import traceback + traceback.print_exc() + return {"success": False, "error": str(e)} + + +async def test_complete_login_flow(phone: str, code: str = None, proxy_index: int = 0): + """ + 测试完整的登录流程 + + Args: + phone: 手机号 + code: 验证码(如果为None,则只测试发送验证码) + proxy_index: 代理索引 + """ + print("="*60) + print("🔄 测试完整登录流程") + print("="*60) + + # 步骤1: 发送验证码 + print("\n📋 步骤1: 发送验证码") + login_service = await test_send_verification_code(phone, proxy_index) + + if not login_service: + print("❌ 发送验证码失败,终止流程") + return + + # 如果提供了验证码,则执行登录 + if code: + print("\n📋 步骤2: 使用验证码登录") + result = await test_login_with_code(login_service, phone, code) + + if result.get('success'): + print("\n🎉 完整登录流程成功!") + else: + print(f"\n❌ 完整登录流程失败: {result.get('error')}") + else: + print("\n⚠️ 提供了验证码参数才可完成登录步骤") + print(" 请在手机上查看验证码,然后调用登录方法") + + # 清理资源 + await login_service.close_browser() + + +async def test_multiple_proxies_login(phone: str, proxy_indices: list = [0, 1]): + """ + 测试使用多个代理进行登录 + + Args: + phone: 手机号 + proxy_indices: 代理索引列表 + """ + print("="*60) + print("🔄 测试多代理登录") + print("="*60) + + for i, proxy_idx in enumerate(proxy_indices): + print(f"\n🧪 测试代理 {proxy_idx + 1} (第 {i+1} 次尝试)") + + # 由于验证码只能发送一次,这里只测试发送验证码 + login_service = await test_send_verification_code(phone, proxy_idx) + + if login_service: + print(f" ✅ 代理 {proxy_idx + 1} 发送验证码成功") + await login_service.close_browser() + else: + print(f" ❌ 代理 {proxy_idx + 1} 发送验证码失败") + + # 在测试之间添加延迟 + if i < len(proxy_indices) - 1: + print(" ⏳ 等待3秒后测试下一个代理...") + await asyncio.sleep(3) + + +def show_usage_examples(): + """显示使用示例""" + print("="*60) + print("💡 使用示例") + print("="*60) + + print("\n1️⃣ 仅发送验证码:") + print(" # 发送验证码到手机号,使用代理1") + print(" await test_send_verification_code('13800138000', proxy_index=0)") + + print("\n2️⃣ 完整登录流程:") + print(" # 完整流程:发送验证码 + 登录") + print(" await test_complete_login_flow('13800138000', '123456', proxy_index=0)") + + print("\n3️⃣ 多代理测试:") + print(" # 测试多个代理") + print(" await test_multiple_proxies_login('13800138000', [0, 1])") + + +async def main(): + """主函数""" + show_usage_examples() + + print(f"\n{'='*60}") + print("🎯 选择测试模式") + print("="*60) + + print("\n1. 发送验证码测试") + print("2. 完整登录流程测试") + print("3. 多代理测试") + + try: + choice = input("\n请选择测试模式 (1-3, 默认为1): ").strip() + + if choice not in ['1', '2', '3']: + choice = '1' + + phone = input("请输入手机号: ").strip() + + if not phone: + print("❌ 手机号不能为空") + return + + if choice == '1': + proxy_choice = input("请选择代理 (0 或 1, 默认为0): ").strip() + if proxy_choice not in ['0', '1']: + proxy_choice = '0' + proxy_idx = int(proxy_choice) + + await test_send_verification_code(phone, proxy_idx) + + elif choice == '2': + code = input("请输入验证码 (留空则只测试发送): ").strip() + proxy_choice = input("请选择代理 (0 或 1, 默认为0): ").strip() + if proxy_choice not in ['0', '1']: + proxy_choice = '0' + proxy_idx = int(proxy_choice) + + await test_complete_login_flow(phone, code if code else None, proxy_idx) + + elif choice == '3': + await test_multiple_proxies_login(phone) + + print(f"\n{'='*60}") + print("✅ 测试完成!") + print("="*60) + + except KeyboardInterrupt: + print("\n\n⚠️ 测试被用户中断") + except Exception as e: + print(f"\n❌ 测试过程中出现错误: {str(e)}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 运行测试 + asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_login_page_config.py b/backend/test_login_page_config.py new file mode 100644 index 0000000..4d5c965 --- /dev/null +++ b/backend/test_login_page_config.py @@ -0,0 +1,106 @@ +""" +测试登录页面配置功能 +验证通过配置文件控制登录页面类型(creator vs home) +""" +import sys +from config import load_config + +def test_config_reading(): + """测试配置读取""" + print("="*60) + print("测试配置文件读取") + print("="*60) + + # 测试dev配置 + print("\n1. 测试开发环境配置 (config.dev.yaml)") + config_dev = load_config('dev') + login_page = config_dev.get_str('login.page', 'creator') + login_headless = config_dev.get_bool('login.headless', False) + + print(f" login.page = {login_page}") + print(f" login.headless = {login_headless}") + + # 根据配置决定预热URL + if login_page == "home": + preheat_url = "https://www.xiaohongshu.com" + else: + preheat_url = "https://creator.xiaohongshu.com/login" + + print(f" 预热URL = {preheat_url}") + + # 测试prod配置 + print("\n2. 测试生产环境配置 (config.prod.yaml)") + config_prod = load_config('prod') + login_page_prod = config_prod.get_str('login.page', 'creator') + login_headless_prod = config_prod.get_bool('login.headless', False) + + print(f" login.page = {login_page_prod}") + print(f" login.headless = {login_headless_prod}") + + if login_page_prod == "home": + preheat_url_prod = "https://www.xiaohongshu.com" + else: + preheat_url_prod = "https://creator.xiaohongshu.com/login" + + print(f" 预热URL = {preheat_url_prod}") + + print("\n" + "="*60) + print("✅ 配置读取测试完成") + print("="*60) + + +def test_api_parameter_override(): + """测试API参数覆盖配置""" + print("\n" + "="*60) + print("测试API参数覆盖配置") + print("="*60) + + config = load_config('dev') + default_login_page = config.get_str('login.page', 'creator') + + # 模拟不同的API参数情况 + test_cases = [ + (None, "应使用配置默认值"), + ("creator", "API指定creator"), + ("home", "API指定home"), + ] + + for api_param, description in test_cases: + login_page = api_param if api_param else default_login_page + print(f"\n场景: {description}") + print(f" 配置默认值 = {default_login_page}") + print(f" API参数 = {api_param}") + print(f" 最终使用 = {login_page}") + + # 决定URL + if login_page == "home": + url = "https://www.xiaohongshu.com" + page_name = "小红书首页" + else: + url = "https://creator.xiaohongshu.com/login" + page_name = "创作者中心" + + print(f" → 将访问: {page_name} ({url})") + + print("\n" + "="*60) + print("✅ API参数覆盖测试完成") + print("="*60) + + +if __name__ == "__main__": + try: + test_config_reading() + test_api_parameter_override() + + print("\n🎉 所有测试通过!") + print("\n使用说明:") + print("1. 在 config.dev.yaml 或 config.prod.yaml 中修改 login.page 配置") + print("2. 可选值: creator (创作者中心) 或 home (小红书首页)") + print("3. API请求中的 login_page 参数可以覆盖配置文件的默认值") + print("4. 如果API请求不传 login_page 参数,将使用配置文件中的默认值") + + except Exception as e: + print(f"\n❌ 测试失败: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/backend/test_optimized_browser.py b/backend/test_optimized_browser.py new file mode 100644 index 0000000..f224493 --- /dev/null +++ b/backend/test_optimized_browser.py @@ -0,0 +1,246 @@ +""" +优化的代理浏览器配置 +解决小红书对代理IP的限制问题 +""" +import asyncio +from playwright.async_api import async_playwright +import sys + + +async def test_optimized_proxy_browser(proxy_index: int = 0): + """测试优化的代理浏览器配置""" + print(f"\n{'='*60}") + print(f"🚀 测试优化的代理浏览器配置") + print(f"{'='*60}") + + # 从代理配置获取代理信息 + from damai_proxy_config import get_proxy_config + proxy_config = get_proxy_config(proxy_index) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"✅ 使用代理: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + + try: + async with async_playwright() as p: + # 配置代理 + proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') + if len(proxy_parts) == 2: + auth_part = proxy_parts[0] + server_part = proxy_parts[1] + username, password = auth_part.split(':') + + proxy_config_obj = { + "server": f"http://{server_part}", + "username": username, + "password": password + } + else: + proxy_config_obj = {"server": proxy_url} + + print(f" 配置的代理对象: {proxy_config_obj}") + + # 启动浏览器 - 使用优化参数 + browser = await p.chromium.launch( + headless=False, # 使用有头模式,便于观察 + proxy=proxy_config_obj, + args=[ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-blink-features=AutomationControlled', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-background-networking', + '--enable-features=NetworkService,NetworkServiceInProcess', + '--disable-ipc-flooding-protection', + '--disable-web-security', + '--disable-features=IsolateOrigins,site-per-process', + '--disable-site-isolation-trials', + '--disable-extensions', + '--disable-breakpad', + '--disable-component-extensions-with-background-pages', + '--disable-hang-monitor', + '--disable-prompt-on-repost', + '--disable-domain-reliability', + '--disable-component-update', + '--hide-scrollbars', + '--mute-audio', + '--no-first-run', + '--no-default-browser-check', + '--metrics-recording-only', + '--force-color-profile=srgb', + '--disable-default-apps', + '--disable-features=TranslateUI', + '--disable-features=Translate', + '--disable-features=OptimizationHints', + '--disable-features=InterestCohortAPI', + '--disable-features=BlinkGenPropertyTrees', + '--disable-features=ImprovedCookieControls', + '--disable-features=SameSiteDefaultChecksMethodRigorously', + '--disable-features=CookieSameSiteByDefaultWhenReportingEnabled', + '--disable-features=AutofillServerCommunication', + '--disable-features=AutofillUseOptimizedLocalStorage', + '--disable-features=CalculateNativeWinOcclusion', + '--disable-features=VizDisplayCompositor', + '--disable-features=VizHitTestQuery', + ] + ) + + # 创建上下文 - 设置浏览器指纹混淆 + context = await browser.new_context( + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + viewport={'width': 1280, 'height': 720}, + # 隐瞒自动化特征 + bypass_csp=True, + java_script_enabled=True, + ) + + # 创建页面 + page = await context.new_page() + + # 隐瞒自动化特征 + await page.add_init_script(""" + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + }); + + Object.defineProperty(navigator, 'plugins', { + get: () => [1, 2, 3, 4, 5], + }); + + Object.defineProperty(navigator, 'languages', { + get: () => ['zh-CN', 'zh', 'en'], + }); + + // 隐瞒代理检测 + delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array; + delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise; + delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol; + """) + + print(f"\n🌐 访问百度测试代理连接...") + try: + await page.goto('https://www.baidu.com', wait_until='domcontentloaded', timeout=15000) + await asyncio.sleep(2) + + title = await page.title() + url = page.url + print(f" ✅ 百度访问成功") + print(f" 标题: {title}") + print(f" URL: {url}") + except Exception as e: + print(f" ❌ 百度访问失败: {str(e)}") + + print(f"\n🌐 访问小红书创作者平台...") + try: + await page.goto('https://creator.xiaohongshu.com/login', wait_until='domcontentloaded', timeout=30000) + await asyncio.sleep(3) # 等待更长时间 + + title = await page.title() + url = page.url + content = await page.content() + content_len = len(content) + + print(f" 访问结果:") + print(f" 标题: {title}") + print(f" URL: {url}") + print(f" 内容长度: {content_len} 字符") + + if content_len == 0: + print(f" ⚠️ 页面内容为空") + elif "验证" in content or "captcha" in content.lower() or "安全" in content: + print(f" ⚠️ 检测到验证或安全提示") + else: + print(f" ✅ 页面加载成功") + + # 查找手机号输入框 + print(f"\n🔍 查找手机号输入框...") + try: + phone_input = await page.wait_for_selector('input[placeholder="手机号"]', timeout=5000) + if phone_input: + print(f" ✅ 找到手机号输入框") + else: + print(f" ❌ 未找到手机号输入框") + except: + print(f" ❌ 未找到手机号输入框") + + # 查找所有input元素 + inputs = await page.query_selector_all('input') + print(f" 找到 {len(inputs)} 个input元素") + + # 查找发送验证码按钮 + print(f"\n🔍 查找发送验证码按钮...") + try: + code_button = await page.wait_for_selector('text="发送验证码"', timeout=5000) + if code_button: + print(f" ✅ 找到发送验证码按钮") + else: + print(f" ❌ 未找到发送验证码按钮") + except: + print(f" ❌ 未找到发送验证码按钮") + + except Exception as e: + print(f" ❌ 小红书访问失败: {str(e)}") + + print(f"\n⏸️ 浏览器保持打开状态,您可以观察页面") + print(f" 按 Enter 键关闭浏览器...") + input() + + await browser.close() + print(f"✅ 浏览器已关闭") + + except Exception as e: + print(f"❌ 测试过程异常: {str(e)}") + import traceback + traceback.print_exc() + + +def explain_optimizations(): + """解释优化措施""" + print("="*60) + print("🔧 优化措施说明") + print("="*60) + + print("\n1. 浏览器启动参数优化:") + print(" • 添加更多反检测参数") + print(" • 禁用可能导致检测的功能") + + print("\n2. 浏览器指纹混淆:") + print(" • 隐瞒webdriver特征") + print(" • 伪造插件列表") + print(" • 设置真实语言") + + print("\n3. 页面加载策略:") + print(" • 使用domcontentloaded而非networkidle") + print(" • 增加超时时间") + + +async def main(): + """主函数""" + explain_optimizations() + + print(f"\n{'='*60}") + print("🎯 选择代理进行测试") + print("="*60) + + proxy_choice = input("\n请选择代理 (0 或 1, 默认为0): ").strip() + if proxy_choice not in ['0', '1']: + proxy_choice = '0' + proxy_idx = int(proxy_choice) + + await test_optimized_proxy_browser(proxy_idx) + + print(f"\n{'='*60}") + print("✅ 测试完成!") + print("="*60) + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 运行测试 + asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_oss.py b/backend/test_oss.py new file mode 100644 index 0000000..03799a7 --- /dev/null +++ b/backend/test_oss.py @@ -0,0 +1,51 @@ +""" +测试OSS上传功能 +""" +import sys +from oss_utils import OSSUploader + +def test_oss_connection(): + """测试OSS连接""" + print("=" * 60) + print("测试阿里云OSS连接") + print("=" * 60) + + try: + # 创建OSS上传器 + uploader = OSSUploader() + + print(f"\n✅ OSS配置:") + print(f" Bucket: {uploader.bucket_name}") + print(f" Endpoint: {uploader.endpoint}") + print(f" Access Key ID: {uploader.access_key_id[:8]}...") + + # 测试Bucket是否可访问 + try: + # 列出bucket中的对象(最多1个) + result = uploader.bucket.list_objects(prefix=uploader.base_path, max_keys=1) + print(f"\n✅ Bucket访问成功!") + print(f" 基础路径: {uploader.base_path}") + + if result.object_list: + print(f" 示例文件: {result.object_list[0].key}") + + except Exception as e: + print(f"\n❌ Bucket访问失败: {e}") + return False + + print("\n" + "=" * 60) + print("✅ OSS配置测试通过!") + print("=" * 60) + return True + + except Exception as e: + print(f"\n❌ OSS初始化失败: {e}") + print("\n请检查配置:") + print(" 1. Access Key ID和Secret是否正确") + print(" 2. Bucket名称是否正确") + print(" 3. Endpoint地区是否匹配") + return False + +if __name__ == "__main__": + success = test_oss_connection() + sys.exit(0 if success else 1) diff --git a/backend/test_password_hash.py b/backend/test_password_hash.py new file mode 100644 index 0000000..77826c1 --- /dev/null +++ b/backend/test_password_hash.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import hashlib + +passwords = [ + "123456", + "password", + "admin123", +] + +print("=== Python SHA256 密码加密测试 ===") +for pwd in passwords: + hash_result = hashlib.sha256(pwd.encode('utf-8')).hexdigest() + print(f"密码: {pwd}") + print(f"SHA256: {hash_result}\n") diff --git a/backend/test_proxy_connectivity.py b/backend/test_proxy_connectivity.py new file mode 100644 index 0000000..965dea5 --- /dev/null +++ b/backend/test_proxy_connectivity.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +固定代理IP测试脚本 +使用requests请求代理服务器,验证代理是否可用 +""" + +import requests +import json +from damai_proxy_config import get_proxy_config, get_all_enabled_proxies + + +def test_proxy_requests(proxy_info, target_url="http://httpbin.org/ip"): + """ + 使用requests测试代理IP + + Args: + proxy_info: 代理信息字典,包含server, username, password + target_url: 目标测试URL + """ + print(f"\n{'='*60}") + print(f"🔍 测试代理: {proxy_info.get('name', 'Unknown')}") + print(f" 服务器: {proxy_info['server']}") + print(f" 用户名: {proxy_info['username']}") + print(f" 目标URL: {target_url}") + print(f"{'='*60}") + + # 构建代理认证信息 + proxy_server = proxy_info['server'].replace('http://', '') + proxy_url = f"http://{proxy_info['username']}:{proxy_info['password']}@{proxy_server}" + + proxies = { + "http": proxy_url, + "https": proxy_url + } + + try: + # 发送测试请求 + print("🚀 发送测试请求...") + response = requests.get(target_url, proxies=proxies, timeout=5) # 减少超时时间到5秒 + + if response.status_code == 200: + print(f"✅ 代理测试成功!状态码: {response.status_code}") + + # 尝试解析IP信息 + try: + ip_info = response.json() + print(f"🌐 当前IP信息: {json.dumps(ip_info, indent=2, ensure_ascii=False)}") + except: + print(f"🌐 页面内容 (前500字符): {response.text[:500]}") + + return True + else: + print(f"❌ 代理测试失败!状态码: {response.status_code}") + print(f"响应内容: {response.text[:200]}") + return False + + except requests.exceptions.ProxyError: + print("❌ 代理连接错误:无法连接到代理服务器") + return False + except requests.exceptions.ConnectTimeout: + print("❌ 连接超时:代理服务器响应超时") + return False + except requests.exceptions.RequestException as e: + print(f"❌ 请求异常: {str(e)}") + return False + + +def test_all_proxies(): + """测试所有配置的代理""" + print("🎯 开始测试所有代理IP") + + proxies = get_all_enabled_proxies() + + if not proxies: + print("❌ 没有找到可用的代理配置") + return + + print(f"📊 共找到 {len(proxies)} 个代理IP") + + results = [] + for i, proxy in enumerate(proxies, 1): + print(f"\n\n{'#'*60}") + print(f"# 测试进度: {i}/{len(proxies)}") + print(f"{'#'*60}") + + success = test_proxy_requests(proxy) + results.append({ + 'proxy': proxy['name'], + 'server': proxy['server'], + 'success': success + }) + + if i < len(proxies): + print(f"\n⏳ 等待2秒后测试下一个代理...") + import time + time.sleep(2) + + # 输出测试结果汇总 + print(f"\n{'='*60}") + print("📊 测试结果汇总:") + print(f"{'='*60}") + + success_count = 0 + for result in results: + status = "✅ 成功" if result['success'] else "❌ 失败" + print(f" {result['proxy']} ({result['server']}) - {status}") + if result['success']: + success_count += 1 + + print(f"\n📈 总体成功率: {success_count}/{len(results)} ({success_count/len(results)*100:.1f}%)") + + # 如果有成功的代理,显示可用于小红书的代理 + successful_proxies = [r for r in results if r['success']] + if successful_proxies: + print(f"\n🎉 以下代理可用于小红书登录发文:") + for proxy in successful_proxies: + print(f" - {proxy['proxy']}: {proxy['server']}") + + return results + + +def test_xhs_proxy_format(): + """测试适用于小红书的代理格式""" + print(f"\n{'='*60}") + print("🔧 测试适用于Playwright的代理格式") + print(f"{'='*60}") + + proxies = get_all_enabled_proxies() + + for proxy in proxies: + server = proxy['server'].replace('http://', '') # 移除http://前缀 + proxy_url = f"http://{proxy['username']}:{proxy['password']}@{server}" + print(f" {proxy['name']}:") + print(f" 服务器地址: {proxy['server']}") + print(f" Playwright格式: {proxy_url}") + print() + + +if __name__ == "__main__": + print("🚀 开始测试固定代理IP") + + # 测试代理格式 + test_xhs_proxy_format() + + # 测试所有代理 + test_all_proxies() + + print(f"\n{'='*60}") + print("🎉 代理测试完成!") + print(f"{'='*60}") \ No newline at end of file diff --git a/backend/test_proxy_detailed.py b/backend/test_proxy_detailed.py new file mode 100644 index 0000000..e04649c --- /dev/null +++ b/backend/test_proxy_detailed.py @@ -0,0 +1,126 @@ +""" +固定代理IP详细测试脚本 +测试代理IP在Playwright中的表现,包含更多调试信息 +""" +import asyncio +import json +import sys +from xhs_login import XHSLoginService +from damai_proxy_config import get_proxy_config + + +async def test_proxy_detailed(proxy_index: int = 0): + """详细测试代理IP""" + print(f"\n{'='*60}") + print(f"🔍 详细测试代理: 代理{proxy_index + 1}") + print(f"{'='*60}") + + # 获取代理配置 + try: + proxy_config = get_proxy_config(proxy_index) + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_config['server'][7:]}" # 移除http://前缀再重新组装 + print(f"✅ 获取代理配置成功: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + except Exception as e: + print(f"❌ 获取代理配置失败: {str(e)}") + return None + + # 创建登录服务实例 + login_service = XHSLoginService(use_pool=False) # 不使用池,便于调试 + + try: + # 初始化浏览器(使用代理) + print(f"\n🚀 正在启动浏览器(使用代理)...") + user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) + print("✅ 浏览器启动成功") + + # 测试访问普通网站 + print(f"\n📍 测试访问普通网站(百度)...") + try: + await login_service.page.goto('https://www.baidu.com', wait_until='networkidle', timeout=10000) + await asyncio.sleep(2) + title = await login_service.page.title() + url = login_service.page.url + print(f"✅ 百度访问成功") + print(f" 页面标题: {title}") + print(f" 当前URL: {url}") + except Exception as e: + print(f"❌ 百度访问失败: {str(e)}") + + # 测试访问IP检测网站 + print(f"\n📍 测试访问IP检测网站...") + try: + await login_service.page.goto('http://httpbin.org/ip', wait_until='networkidle', timeout=10000) + await asyncio.sleep(2) + content = await login_service.page.content() + print(f"✅ IP检测网站访问成功") + print(f" 页面内容: {content[:200]}...") + except Exception as e: + print(f"❌ IP检测网站访问失败: {str(e)}") + + # 测试访问小红书创作者平台 + print(f"\n📍 测试访问小红书创作者平台...") + try: + await login_service.page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=20000) # 增加超时时间 + await asyncio.sleep(3) # 等待更长时间 + + title = await login_service.page.title() + url = login_service.page.url + print(f"✅ 小红书访问成功") + print(f" 页面标题: '{title}'") + print(f" 当前URL: {url}") + + # 检查页面内容 + content = await login_service.page.content() + if "验证" in content or "captcha" in content.lower() or "block" in content.lower() or "安全验证" in content: + print("⚠️ 检测到可能的验证或拦截") + else: + print("✅ 未检测到验证拦截") + + except Exception as e: + print(f"❌ 小红书访问失败: {str(e)}") + # 尝试访问普通页面看看是否完全被封 + try: + await login_service.page.goto('https://www.google.com', wait_until='networkidle', timeout=10000) + print(" 提示: 代理可以访问其他网站,但可能被小红书限制") + except Exception: + print(" 提示: 代理可能完全被限制") + + print(f"\n✅ 代理{proxy_index + 1} 详细测试完成") + return login_service + + except Exception as e: + print(f"❌ 代理{proxy_index + 1} 详细测试失败: {str(e)}") + import traceback + traceback.print_exc() + return None + finally: + # 关闭浏览器 + await login_service.close_browser() + + +async def main(): + """主测试函数""" + print("\n" + "="*60) + print("🎯 固定代理IP详细测试") + print("="*60) + + # 测试两个代理 + for i in range(2): + await test_proxy_detailed(i) + print(f"\n⏳ 等待3秒后测试下一个代理...") + await asyncio.sleep(3) + + print(f"\n{'='*60}") + print("🎉 详细测试完成!") + print("="*60) + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 运行测试 + asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_proxy_xhs.py b/backend/test_proxy_xhs.py new file mode 100644 index 0000000..320b0ae --- /dev/null +++ b/backend/test_proxy_xhs.py @@ -0,0 +1,219 @@ +""" +固定代理IP下小红书登录发文功能测试脚本 +测试使用固定代理IP进行小红书登录和发文功能 +""" +import asyncio +import json +import sys +from xhs_login import XHSLoginService +from xhs_publish import XHSPublishService +from damai_proxy_config import get_proxy_config + + +async def test_login_with_proxy(proxy_index: int = 0): + """使用指定代理测试小红书登录""" + print(f"\n{'='*60}") + print(f"🔍 开始测试代理登录: 代理{proxy_index + 1}") + print(f"{'='*60}") + + # 获取代理配置 + try: + proxy_config = get_proxy_config(proxy_index) + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_config['server'][7:]}" # 移除http://前缀再重新组装 + print(f"✅ 获取代理配置成功: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + except Exception as e: + print(f"❌ 获取代理配置失败: {str(e)}") + return None + + # 创建登录服务实例 + login_service = XHSLoginService() + + try: + # 初始化浏览器(使用代理) + print(f"\n🚀 正在启动浏览器(使用代理)...") + user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + await login_service.init_browser(proxy=proxy_url, user_agent=user_agent) + print("✅ 浏览器启动成功") + + # 访问小红书创作者平台 + print(f"\n📍 访问小红书创作者平台...") + await login_service.page.goto('https://creator.xiaohongshu.com/login', wait_until='networkidle', timeout=30000) + await asyncio.sleep(2) + + title = await login_service.page.title() + url = login_service.page.url + print(f"✅ 访问成功") + print(f" 页面标题: {title}") + print(f" 当前URL: {url}") + + # 检查是否被代理拦截或出现验证码 + content = await login_service.page.content() + if "验证" in content or "captcha" in content.lower() or "block" in content.lower(): + print("⚠️ 检测到可能的验证或拦截") + + print(f"\n✅ 代理{proxy_index + 1} 连接测试完成") + return login_service + + except Exception as e: + print(f"❌ 代理{proxy_index + 1} 测试失败: {str(e)}") + import traceback + traceback.print_exc() + return None + finally: + # 注意:这里不关闭浏览器,让调用者决定何时关闭 + pass + + +async def test_publish_with_proxy(cookies, proxy_index: int = 0): + """使用指定代理测试小红书发文""" + print(f"\n{'='*60}") + print(f"📝 开始测试代理发文: 代理{proxy_index + 1}") + print(f"{'='*60}") + + # 获取代理配置 + try: + proxy_config = get_proxy_config(proxy_index) + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_config['server'][7:]}" # 移除http://前缀再重新组装 + print(f"✅ 获取代理配置成功: 代理{proxy_index + 1}") + print(f" 代理服务器: {proxy_config['server']}") + except Exception as e: + print(f"❌ 获取代理配置失败: {str(e)}") + return None + + # 准备测试数据 + title = "【代理测试】固定IP代理发布测试" + content = """这是一条通过固定IP代理发布的测试笔记 📝 + +测试内容: +- 验证代理IP是否正常工作 +- 检查发布功能是否正常 +- 确认网络连接稳定性 + +如果你看到这条笔记,说明代理发布成功了! + +#代理测试 #自动化发布 #网络测试""" + + # 测试图片(可选) + images = [] # 可以添加图片路径进行测试 + + # 标签 + tags = ["代理测试", "自动化发布", "网络测试"] + + try: + # 创建发布服务 + print(f"\n🚀 创建发布服务(使用代理: 代理{proxy_index + 1})...") + publisher = XHSPublishService(cookies, proxy=proxy_url) + + # 执行发布 + print(f"\n📤 开始发布笔记...") + result = await publisher.publish( + title=title, + content=content, + images=images if images else None, + tags=tags + ) + + # 显示结果 + print(f"\n{'='*50}") + print("发布结果:") + print(json.dumps(result, ensure_ascii=False, indent=2)) + print("="*50) + + if result.get('success'): + print(f"\n✅ 代理{proxy_index + 1} 发布测试成功!") + if 'url' in result: + print(f"📎 笔记链接: {result['url']}") + else: + print(f"\n❌ 代理{proxy_index + 1} 发布测试失败: {result.get('error')}") + + return result + + except Exception as e: + print(f"❌ 代理{proxy_index + 1} 发布测试异常: {str(e)}") + import traceback + traceback.print_exc() + return None + + +async def main(): + """主测试函数""" + print("\n" + "="*60) + print("🎯 固定代理IP下小红书登录发文功能测试") + print("="*60) + + # 测试代理连接 + login_service = None + for i in range(2): # 测试两个代理 + login_service = await test_login_with_proxy(i) + if login_service: + print(f"✅ 代理{i+1} 连接测试成功,可以用于后续操作") + break + else: + print(f"⚠️ 代理{i+1} 连接测试失败,尝试下一个代理...") + + if not login_service: + print("\n❌ 所有代理都无法连接,测试终止") + return + + try: + # 验证登录状态(虽然我们没有真正的登录,但可以检查Cookie是否有效) + print(f"\n🔍 验证当前浏览器状态...") + verify_result = await login_service.verify_login_status() + print(f"验证结果: {verify_result.get('message', '未知状态')}") + except Exception as e: + print(f"验证状态时出错: {str(e)}") + + # 如果有cookies.json文件,可以尝试使用已保存的cookies进行发布测试 + cookies = None + try: + with open('cookies.json', 'r', encoding='utf-8') as f: + cookies = json.load(f) + print(f"\n✅ 成功读取 cookies.json,包含 {len(cookies)} 个Cookie") + except FileNotFoundError: + print(f"\n⚠️ cookies.json 文件不存在,跳过发布测试") + print(" 如需测试发布功能,请先登录获取Cookie") + + if cookies: + # 使用第一个有效的代理进行发布测试 + for i in range(2): + proxy_config = get_proxy_config(i) + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_config['server'][7:]}" + + # 测试代理连接 + temp_login = XHSLoginService() + try: + user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + await temp_login.init_browser(cookies=cookies, proxy=proxy_url, user_agent=user_agent) + + # 验证登录状态 + verify_result = await temp_login.verify_login_status() + if verify_result.get('logged_in'): + print(f"\n✅ 代理{i+1} + Cookie 组合验证成功,开始发布测试") + await test_publish_with_proxy(cookies, i) + break + else: + print(f"⚠️ 代理{i+1} + Cookie 组合验证失败") + except Exception as e: + print(f"⚠️ 代理{i+1} 连接测试失败: {str(e)}") + finally: + await temp_login.close_browser() + else: + print("\n❌ 所有代理都无法与Cookie配合使用,发布测试终止") + + # 清理资源 + if login_service: + await login_service.close_browser() + + print(f"\n{'='*60}") + print("🎉 测试完成!") + print("="*60) + + +if __name__ == "__main__": + # Windows环境下设置事件循环策略 + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + # 运行测试 + asyncio.run(main()) \ No newline at end of file diff --git a/backend/verify_proxy_correct.py b/backend/verify_proxy_correct.py new file mode 100644 index 0000000..ea8c39c --- /dev/null +++ b/backend/verify_proxy_correct.py @@ -0,0 +1,224 @@ +""" +准确的Playwright代理IP验证脚本 +验证Playwright是否正确使用了带认证信息的代理IP +""" +import asyncio +from playwright.async_api import async_playwright +import requests + + +async def get_my_ip_requests(): + """使用requests获取当前IP(不使用代理)""" + try: + response = requests.get('http://httpbin.org/ip', timeout=10) + if response.status_code == 200: + data = response.json() + return data.get('origin', 'Unknown') + except Exception as e: + print(f"获取本机IP失败: {str(e)}") + return None + + +async def get_ip_with_playwright_proxy_correct(proxy_url): + """使用Playwright获取IP(正确使用代理认证)""" + try: + async with async_playwright() as p: + # 正确的代理配置格式,包含认证信息 + proxy_parts = proxy_url.replace('http://', '').replace('https://', '').split('@') + if len(proxy_parts) == 2: + # 格式: username:password@host:port + auth_part = proxy_parts[0] + server_part = proxy_parts[1] + + username, password = auth_part.split(':') + + proxy_config = { + "server": f"http://{server_part}", + "username": username, + "password": password + } + + print(f" 使用代理配置: {proxy_config}") + else: + # 如果没有认证信息,直接使用 + proxy_config = {"server": proxy_url} + + browser = await p.chromium.launch(headless=True, proxy=proxy_config) + context = await browser.new_context() + page = await context.new_page() + + # 访问IP检测网站 + await page.goto('http://httpbin.org/ip', wait_until='networkidle', timeout=15000) + + # 获取页面内容 + content = await page.content() + await browser.close() + + # 尝试解析IP + import json + import re + json_match = re.search(r'\{.*\}', content, re.DOTALL) + if json_match: + try: + ip_data = json.loads(json_match.group()) + return ip_data.get('origin', 'Unknown') + except: + print(f" JSON解析失败,原始内容: {content[:200]}...") + return 'JSON Parse Error' + + print(f" 未找到JSON,原始内容: {content[:200]}...") + return 'No JSON Found' + + except Exception as e: + print(f" 通过Playwright+代理获取IP失败: {str(e)}") + return f'Error: {str(e)}' + + +async def test_proxy_formats(): + """测试不同的代理格式""" + print("="*60) + print("🔍 测试不同代理格式") + print("="*60) + + # 从代理配置中获取代理信息 + from damai_proxy_config import get_proxy_config + + # 获取本机IP + print("1️⃣ 获取本机IP...") + local_ip = await get_my_ip_requests() + print(f" 本机IP: {local_ip}") + + for i in range(2): + print(f"\n2️⃣ 测试代理 {i+1}...") + proxy_config = get_proxy_config(i) + + print(f" 代理信息: {proxy_config}") + + # 格式1: http://username:password@host:port + proxy_url_format1 = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_config['server'][7:]}" + print(f" 格式1 (完整URL): {proxy_url_format1}") + + # 测试格式1 + ip_with_proxy1 = await get_ip_with_playwright_proxy_correct(proxy_url_format1) + print(f" 使用格式1的IP: {ip_with_proxy1}") + + if ip_with_proxy1 != local_ip and ip_with_proxy1 not in ['JSON Parse Error', 'No JSON Found', f'Error:']: + print(f" ✅ 格式1成功: IP已改变,代理生效") + else: + print(f" ❌ 格式1失败: IP未改变或出错") + + print() + + +async def test_direct_proxy_config(): + """测试直接使用代理配置对象""" + print("="*60) + print("🔍 测试直接使用代理配置对象") + print("="*60) + + # 获取本机IP + print("1️⃣ 获取本机IP...") + local_ip = await get_my_ip_requests() + print(f" 本机IP: {local_ip}") + + from damai_proxy_config import get_proxy_config + + for i in range(2): + print(f"\n2️⃣ 测试代理 {i+1} (直接配置)...") + proxy_config = get_proxy_config(i) + + # 构建Playwright代理配置对象 + playwright_proxy_config = { + "server": proxy_config['server'], + "username": proxy_config['username'], + "password": proxy_config['password'] + } + + print(f" Playwright代理配置: {playwright_proxy_config}") + + try: + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True, proxy=playwright_proxy_config) + context = await browser.new_context() + page = await context.new_page() + + # 访问IP检测网站 + await page.goto('http://httpbin.org/ip', wait_until='networkidle', timeout=15000) + + # 获取页面内容 + content = await page.content() + await browser.close() + + # 解析IP + import json + import re + json_match = re.search(r'\{.*\}', content, re.DOTALL) + if json_match: + try: + ip_data = json.loads(json_match.group()) + ip_address = ip_data.get('origin', 'Unknown') + print(f" 代理{i+1} IP: {ip_address}") + + if ip_address != local_ip: + print(f" ✅ 代理{i+1}成功: IP已改变,代理生效") + else: + print(f" ❌ 代理{i+1}失败: IP未改变") + except: + print(f" ❌ 代理{i+1} JSON解析失败: {content[:200]}...") + else: + print(f" ❌ 代理{i+1} 未找到IP信息: {content[:200]}...") + + except Exception as e: + print(f" ❌ 代理{i+1}连接失败: {str(e)}") + + +def explain_proxy_formats(): + """解释不同的代理格式""" + print("="*60) + print("📋 代理格式说明") + print("="*60) + + print("\n在Playwright中使用代理的两种方式:") + print("\n1️⃣ 字典格式(推荐):") + print(" proxy = {") + print(" 'server': 'http://proxy-server:port',") + print(" 'username': 'your_username',") + print(" 'password': 'your_password'") + print(" }") + print(" browser = await playwright.chromium.launch(proxy=proxy)") + + print("\n2️⃣ URL格式(包含认证信息):") + print(" proxy_url = 'http://username:password@proxy-server:port'") + print(" # 需要从中提取认证信息并构建字典格式") + + print("\n⚠️ 注意:") + print(" - 不能直接使用包含认证信息的URL字符串作为proxy.server") + print(" - 必须将认证信息分离到单独的username和password字段") + print(" - 代理服务器地址格式应为: http://host:port") + + +async def main(): + """主函数""" + explain_proxy_formats() + + print("\n" + "="*60) + + # 测试直接代理配置 + await test_direct_proxy_config() + + print("\n" + "="*60) + + # 测试不同格式 + await test_proxy_formats() + + print(f"\n{'='*60}") + print("✅ 验证完成!") + print("="*60) + + +if __name__ == "__main__": + import sys + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(main()) \ No newline at end of file diff --git a/backend/verify_proxy_usage.py b/backend/verify_proxy_usage.py new file mode 100644 index 0000000..9e40c25 --- /dev/null +++ b/backend/verify_proxy_usage.py @@ -0,0 +1,230 @@ +""" +Playwright代理IP验证脚本 +验证Playwright浏览器是否使用了代理IP而不是本机IP +""" +import asyncio +from playwright.async_api import async_playwright +import requests +import json + + +async def get_my_ip_requests(): + """使用requests获取当前IP(不使用代理)""" + try: + response = requests.get('http://httpbin.org/ip', timeout=10) + if response.status_code == 200: + data = response.json() + return data.get('origin', 'Unknown') + except Exception as e: + print(f"获取本机IP失败: {str(e)}") + return None + + +async def get_browser_ip_via_playwright(proxy_url=None): + """使用Playwright获取IP,可选择是否使用代理""" + try: + async with async_playwright() as p: + # 启动浏览器 + launch_kwargs = { + "headless": True, # 无头模式 + } + + # 如果提供了代理,则使用代理 + if proxy_url: + launch_kwargs["proxy"] = {"server": proxy_url} + + browser = await p.chromium.launch(**launch_kwargs) + context = await browser.new_context() + page = await context.new_page() + + # 访问IP检测网站 + await page.goto('http://httpbin.org/ip', wait_until='networkidle', timeout=10000) + + # 获取页面内容 + content = await page.content() + + # 关闭浏览器 + await browser.close() + + # 解析IP信息 + try: + import re + import json + # 查找JSON内容 + json_match = re.search(r'\{.*\}', content, re.DOTALL) + if json_match: + ip_data = json.loads(json_match.group()) + return ip_data.get('origin', 'Unknown') + except: + pass + + return 'Parse Error' + + except Exception as e: + print(f"通过Playwright获取IP失败: {str(e)}") + return None + + +async def verify_proxy_usage(): + """验证代理IP使用情况""" + print("="*60) + print("🔍 Playwright代理IP使用验证") + print("="*60) + + # 1. 获取本机IP + print("\n1️⃣ 获取本机IP地址...") + local_ip = await get_my_ip_requests() + if local_ip: + print(f" ✅ 本机IP: {local_ip}") + else: + print(" ❌ 无法获取本机IP") + return + + # 2. 测试不使用代理时的IP + print("\n2️⃣ 测试不使用代理时的IP...") + browser_ip_no_proxy = await get_browser_ip_via_playwright() + print(f" 🌐 Playwright无代理IP: {browser_ip_no_proxy}") + + # 3. 测试使用代理时的IP + print("\n3️⃣ 测试使用代理时的IP...") + + # 从代理配置中获取代理信息 + from damai_proxy_config import get_proxy_config + + for i in range(2): + try: + proxy_config = get_proxy_config(i) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f" 代理{i+1}: {proxy_config['server']}") + + # 获取使用代理时的IP + browser_ip_with_proxy = await get_browser_ip_via_playwright(proxy_url) + print(f" 🌐 Playwright使用代理{i+1}的IP: {browser_ip_with_proxy}") + + # 比较IP地址 + if browser_ip_with_proxy == local_ip: + print(f" ❌ 代理{i+1}测试失败: IP与本机IP相同,代理未生效") + elif browser_ip_with_proxy == proxy_server.split(':')[0]: # 检查是否是代理服务器IP + print(f" ✅ 代理{i+1}测试成功: 使用了代理IP") + elif browser_ip_with_proxy != 'Parse Error' and browser_ip_with_proxy != local_ip: + print(f" ✅ 代理{i+1}测试成功: IP已改变,代理生效") + else: + print(f" ⚠️ 代理{i+1}测试结果不确定: {browser_ip_with_proxy}") + + except Exception as e: + print(f" ❌ 代理{i+1}测试出错: {str(e)}") + + print() # 空行分隔 + + +async def advanced_proxy_verification(): + """高级代理验证 - 使用多个IP检测服务""" + print("="*60) + print("🔬 高级代理IP验证") + print("="*60) + + # IP检测服务列表 + ip_services = [ + 'http://httpbin.org/ip', + 'https://api.ipify.org?format=json', + 'https://jsonip.com', + 'https://httpbin.org/ip' + ] + + from damai_proxy_config import get_proxy_config + + for i in range(2): + try: + proxy_config = get_proxy_config(i) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"\n📊 验证代理 {i+1}: {proxy_config['server']}") + print("-" * 50) + + async with async_playwright() as p: + launch_kwargs = {"headless": True, "proxy": {"server": proxy_url}} + browser = await p.chromium.launch(**launch_kwargs) + context = await browser.new_context() + page = await context.new_page() + + for service in ip_services: + try: + print(f" 正在测试: {service}") + await page.goto(service, wait_until='networkidle', timeout=10000) + content = await page.content() + + # 尝试解析IP + import re + import json + json_match = re.search(r'\{.*\}', content, re.DOTALL) + if json_match: + try: + data = json.loads(json_match.group()) + ip = data.get('origin') or data.get('ip') or 'Unknown' + print(f" ✅ {service}: {ip}") + except: + print(f" ❌ {service}: JSON解析失败") + else: + print(f" ❌ {service}: 未找到JSON数据") + except Exception as e: + print(f" ❌ {service}: {str(e)}") + + await browser.close() + + except Exception as e: + print(f"❌ 代理{i+1}高级验证失败: {str(e)}") + + +def show_proxy_format(): + """显示代理格式""" + print("="*60) + print("🔧 Playwright代理格式参考") + print("="*60) + + from damai_proxy_config import get_proxy_config + + for i in range(2): + proxy_config = get_proxy_config(i) + proxy_server = proxy_config['server'].replace('http://', '') + proxy_url = f"http://{proxy_config['username']}:{proxy_config['password']}@{proxy_server}" + + print(f"\n代理{i+1}:") + print(f" 原始地址: {proxy_config['server']}") + print(f" 用户名: {proxy_config['username']}") + print(f" 密码: {proxy_config['password']}") + print(f" Playwright格式: {proxy_url}") + print(f" 使用示例:") + print(f" browser = await playwright.chromium.launch(") + print(f" proxy={{'server': '{proxy_url}'}}") + print(f" )") + + +async def main(): + """主函数""" + # 显示代理格式 + show_proxy_format() + + print("\n" + "="*60) + + # 基础验证 + await verify_proxy_usage() + + # 高级验证(可选,可能会比较耗时) + user_input = input("\n是否进行高级验证? 这将测试多个IP服务 (y/N): ") + if user_input.lower() == 'y': + await advanced_proxy_verification() + + print(f"\n{'='*60}") + print("✅ 验证完成!") + print("="*60) + + +if __name__ == "__main__": + import sys + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(main()) \ No newline at end of file diff --git a/backend/xhs_login.py b/backend/xhs_login.py index 7b14bb1..d8d3951 100644 --- a/backend/xhs_login.py +++ b/backend/xhs_login.py @@ -9,19 +9,82 @@ import json import random import unicodedata import sys +import os +import tempfile +import aiohttp +import time +from datetime import datetime +from pathlib import Path +from browser_pool import get_browser_pool +from error_screenshot import save_error_screenshot, save_screenshot_with_html + + +async def download_image(url: str) -> str: + """ + 下载网络图片到临时文件 + + Args: + url: 图片URL + + Returns: + 本地文件路径 + """ + try: + print(f"下载网络图片: {url}", file=sys.stderr) + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response: + if response.status == 200: + # 获取文件扩展名 + ext = '.jpg' # 默认jpg + content_type = response.headers.get('Content-Type', '') + if 'png' in content_type: + ext = '.png' + elif 'jpeg' in content_type or 'jpg' in content_type: + ext = '.jpg' + elif 'webp' in content_type: + ext = '.webp' + + # 创建临时文件 + temp_dir = Path(tempfile.gettempdir()) / 'xhs_images' + temp_dir.mkdir(exist_ok=True) + temp_file = temp_dir / f"img_{random.randint(10000, 99999)}{ext}" + + # 保存图片 + with open(temp_file, 'wb') as f: + f.write(await response.read()) + + print(f"✅ 图片下载成功: {temp_file}", file=sys.stderr) + return str(temp_file) + else: + raise Exception(f"HTTP {response.status}") + except Exception as e: + print(f"⚠️ 下载图片失败: {str(e)}", file=sys.stderr) + raise class XHSLoginService: """小红书登录服务""" - def __init__(self): + def __init__(self, use_pool: bool = True, headless: bool = True, session_id: Optional[str] = None): + """ + 初始化登录服务 + + Args: + use_pool: 是否使用浏览器池(默认True,提升性能) + headless: 是否使用无头模式,False为有头模式(方便调试) + session_id: 会话ID,用于并发隔离(不同的session_id会创建独立的浏览器实例) + """ + self.use_pool = use_pool + self.headless = headless + self.session_id = session_id # 保存session_id用于并发隔离 + self.browser_pool = get_browser_pool(headless=headless) if use_pool else None self.playwright = None self.browser: Optional[Browser] = None self.context: Optional[BrowserContext] = None self.page: Optional[Page] = None self.current_phone = None - async def init_browser(self, cookies: Optional[list] = None, proxy: Optional[str] = None, user_agent: Optional[str] = None): + async def init_browser(self, cookies: Optional[list] = None, proxy: Optional[str] = None, user_agent: Optional[str] = None, restore_state: bool = False): """ 初始化浏览器 @@ -29,15 +92,61 @@ class XHSLoginService: cookies: 可选的Cookie列表,用于恢复登录状态 proxy: 可选的代理地址,例如 http://user:pass@ip:port user_agent: 可选的自定义User-Agent + restore_state: 是否从log_state.json文件恢复完整登录状态 """ try: + # 如果要求恢复状态,先加载 login_state.json + login_state = None + if restore_state and os.path.exists('login_state.json'): + try: + with open('login_state.json', 'r', encoding='utf-8') as f: + login_state = json.load(f) + print("✅ 加载到保存的登录状态", file=sys.stderr) + + # 使用保存的配置 + cookies = login_state.get('cookies', cookies) + if not user_agent and login_state.get('user_agent'): + user_agent = login_state['user_agent'] + except Exception as e: + print(f"⚠️ 加载登录状态失败: {str(e)}", file=sys.stderr) + + # 使用浏览器池 + if self.use_pool and self.browser_pool: + print(f"[浏览器池模式] 从浏览器池获取实例 (session_id={self.session_id}, headless={self.headless})", file=sys.stderr) + self.browser, self.context, self.page = await self.browser_pool.get_browser( + cookies=cookies, proxy=proxy, user_agent=user_agent, session_id=self.session_id, + headless=self.headless # 传递headless参数 + ) + + # 如果有localStorage/sessionStorage,恢复它们 + if login_state: + await self._restore_storage(login_state) + + print("浏览器初始化成功(池模式)", file=sys.stderr) + return + + # 传统模式(每次新建) + print("[传统模式] 创建新浏览器实例", file=sys.stderr) + + # Windows环境下,需要设置事件循环策略 + if sys.platform == 'win32': + try: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + except Exception as e: + print(f"警告: 设置事件循环策略失败: {str(e)}", file=sys.stderr) + self.playwright = await async_playwright().start() # 启动浏览器(使用chromium) # headless=True 在服务器环境下运行,不显示浏览器界面 launch_kwargs = { - "headless": True, # 服务器环境使用无头模式,本地调试可改为False - "args": ['--disable-blink-features=AutomationControlled'], + "headless": self.headless, # 使用配置的headless参数 + "args": [ + '--disable-blink-features=AutomationControlled', + '--disable-infobars', + '--no-first-run', + '--no-default-browser-check', + ], } if proxy: launch_kwargs["proxy"] = {"server": proxy} @@ -46,11 +155,142 @@ class XHSLoginService: # 创建浏览器上下文,模拟真实用户 context_kwargs = { - "viewport": {'width': 1280, 'height': 720}, + "viewport": login_state.get('viewport') if login_state else {'width': 1280, 'height': 720}, "user_agent": user_agent or 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', } self.context = await self.browser.new_context(**context_kwargs) + # 添加初始化脚本,隐藏自动化特征 + await self.context.add_init_script(""" + // 移除webdriver标记 + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined + }); + + // 阻止检测自动化调试端口 + window.chrome = { + runtime: {} + }; + + // 阻止检测Chrome DevTools Protocol + const originalFetch = window.fetch; + window.fetch = function(...args) { + const url = args[0]; + // 阻止小红书检测本地调试端口 + if (typeof url === 'string' && ( + url.includes('127.0.0.1:9222') || + url.includes('127.0.0.1:54345') || + url.includes('localhost:9222') || + url.includes('chrome-extension://invalid') + )) { + return Promise.reject(new Error('blocked')); + } + return originalFetch.apply(this, args); + }; + + // 阻止XMLHttpRequest检测 + const originalXHROpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(...args) { + const url = args[1]; + if (typeof url === 'string' && ( + url.includes('127.0.0.1:9222') || + url.includes('127.0.0.1:54345') || + url.includes('localhost:9222') || + url.includes('chrome-extension://invalid') + )) { + throw new Error('blocked'); + } + return originalXHROpen.apply(this, args); + }; + + // 添加chrome.app + Object.defineProperty(window, 'chrome', { + get: () => ({ + app: { + isInstalled: false, + }, + webstore: { + onInstallStageChanged: {}, + onDownloadProgress: {}, + }, + runtime: { + PlatformOs: { + MAC: 'mac', + WIN: 'win', + ANDROID: 'android', + CROS: 'cros', + LINUX: 'linux', + OPENBSD: 'openbsd', + }, + PlatformArch: { + ARM: 'arm', + X86_32: 'x86-32', + X86_64: 'x86-64', + }, + PlatformNaclArch: { + ARM: 'arm', + X86_32: 'x86-32', + X86_64: 'x86-64', + }, + RequestUpdateCheckStatus: { + THROTTLED: 'throttled', + NO_UPDATE: 'no_update', + UPDATE_AVAILABLE: 'update_available', + }, + OnInstalledReason: { + INSTALL: 'install', + UPDATE: 'update', + CHROME_UPDATE: 'chrome_update', + SHARED_MODULE_UPDATE: 'shared_module_update', + }, + OnRestartRequiredReason: { + APP_UPDATE: 'app_update', + OS_UPDATE: 'os_update', + PERIODIC: 'periodic', + }, + }, + }), + configurable: true, + }); + + // 模拟permissions + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = (parameters) => ( + parameters.name === 'notifications' ? + Promise.resolve({ state: Notification.permission }) : + originalQuery(parameters) + ); + + // 添加plugins + Object.defineProperty(navigator, 'plugins', { + get: () => [ + { + 0: {type: "application/x-google-chrome-pdf", suffixes: "pdf", description: "Portable Document Format"}, + description: "Portable Document Format", + filename: "internal-pdf-viewer", + length: 1, + name: "Chrome PDF Plugin" + }, + { + 0: {type: "application/pdf", suffixes: "pdf", description: ""}, + description: "", + filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", + length: 1, + name: "Chrome PDF Viewer" + }, + { + 0: {type: "application/x-nacl", suffixes: "", description: "Native Client Executable"}, + 1: {type: "application/x-pnacl", suffixes: "", description: "Portable Native Client Executable"}, + description: "", + filename: "internal-nacl-plugin", + length: 2, + name: "Native Client" + } + ], + }); + """) + print("✅ 已注入反检测脚本", file=sys.stderr) + # 如果提供了Cookies,注入到浏览器上下文 if cookies: await self.context.add_cookies(cookies) @@ -59,15 +299,192 @@ class XHSLoginService: # 创建新页面 self.page = await self.context.new_page() - print("浏览器初始化成功", file=sys.stderr) + # 使用Playwright路由拦截,直接阻止小红书的检测请求 + async def block_detection_requests(route, request): + url = request.url + # 阻止所有检测自动化的请求 + if any([ + '127.0.0.1:9222' in url, + '127.0.0.1:54345' in url, + 'localhost:9222' in url, + 'chrome-extension://invalid' in url, + 'chrome-extension://bla' in url, + ]): + await route.abort() + else: + await route.continue_() + + # 注册路由拦截,匹配所有请求 + await self.page.route('**/*', block_detection_requests) + print("✅ 已启用请求拦截,阻止检测自动化", file=sys.stderr) + + # 添加页面跳转监控,检测无限跳转 + self.redirect_count = 0 + self.last_redirect_time = 0 + + async def on_response(response): + """监控页面响应,检测重定向循环""" + if response.status in [301, 302, 303, 307, 308]: + import time + current_time = time.time() + if current_time - self.last_redirect_time < 1: # 1秒内连续重定向 + self.redirect_count += 1 + if self.redirect_count > 5: + print(f"⚠️ 检测到频繁重定向 ({self.redirect_count}次),可能是无限跳转", file=sys.stderr) + else: + self.redirect_count = 0 + self.last_redirect_time = current_time + + self.page.on('response', on_response) + + # 如果有localStorage/sessionStorage,恢复它们 + if login_state: + await self._restore_storage(login_state) + + print("浏览器初始化成功(传统模式)", file=sys.stderr) + + except Exception as e: + print(f"浏览器初始化失败: {str(e)}", file=sys.stderr) + raise + + async def _restore_storage(self, login_state: dict): + """恢夏localStorage和sessionStorage""" + try: + # 首先访问小红书的任意页面,以便注入storage + target_url = login_state.get('url', 'https://www.xiaohongshu.com') + print(f"正在访问 {target_url} 以注入storage...", file=sys.stderr) + + # 设置更短的超时时间,避免长时间等待 + try: + await self.page.goto(target_url, wait_until='domcontentloaded', timeout=15000) + await asyncio.sleep(1) + + # 检查是否被重定向到登录页 + current_url = self.page.url + if 'login' in current_url.lower(): + print("⚠️ 检测到被重定向到登录页,跳过storage恢复", file=sys.stderr) + return + + except Exception as e: + print(f"⚠️ 访问页面失败: {str(e)},跳过storage恢复", file=sys.stderr) + return + + # 恢夏localStorage + if login_state.get('localStorage'): + for key, value in login_state['localStorage'].items(): + try: + await self.page.evaluate(f'localStorage.setItem("{key}", {json.dumps(value)})') + except Exception as e: + print(f"⚠️ 设置localStorage {key} 失败: {str(e)}", file=sys.stderr) + print(f"✅ 已恢复 {len(login_state['localStorage'])} 个localStorage项", file=sys.stderr) + + # 恢夏sessionStorage + if login_state.get('sessionStorage'): + for key, value in login_state['sessionStorage'].items(): + try: + await self.page.evaluate(f'sessionStorage.setItem("{key}", {json.dumps(value)})') + except Exception as e: + print(f"⚠️ 设置sessionStorage {key} 失败: {str(e)}", file=sys.stderr) + print(f"✅ 已恢复 {len(login_state['sessionStorage'])} 个sessionStorage项", file=sys.stderr) + + except Exception as e: + print(f"⚠️ 恢夏storage失败: {str(e)}", file=sys.stderr) + + async def init_browser_with_storage_state(self, storage_state_path: str, proxy: Optional[str] = None): + """ + 使用Playwright原生storage_state初始化浏览器(最优方案) + + Args: + storage_state_path: storage_state文件路径 + proxy: 可选的代理地址 + """ + try: + if not os.path.exists(storage_state_path): + raise Exception(f"storage_state文件不存在: {storage_state_path}") + + print(f"✅ 使用 storage_state 初始化浏览器: {storage_state_path}", file=sys.stderr) + + # Windows环境下,需要设置事件循环策略 + if sys.platform == 'win32': + try: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + except Exception as e: + print(f"警告: 设置事件循环策略失败: {str(e)}", file=sys.stderr) + + self.playwright = await async_playwright().start() + + # 启动浏览器 + launch_kwargs = { + "headless": self.headless, + "args": [ + '--disable-blink-features=AutomationControlled', + '--disable-infobars', + '--no-first-run', + '--no-default-browser-check', + ], + } + if proxy: + launch_kwargs["proxy"] = {"server": proxy} + + self.browser = await self.playwright.chromium.launch(**launch_kwargs) + + # 使用storage_state创建上下文(Playwright原生API) + self.context = await self.browser.new_context(storage_state=storage_state_path) + print(f"✅ 已使用 storage_state 创建浏览器上下文", file=sys.stderr) + + # 添加反检测脚本 + await self.context.add_init_script(""" + // 移除webdriver标记 + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined + }); + + // 阻止检测自动化调试端口 + window.chrome = { + runtime: {} + }; + """) + print("✅ 已注入反检测脚本", file=sys.stderr) + + # 创建页面 + self.page = await self.context.new_page() + + # 添加请求拦截 + async def block_detection_requests(route, request): + url = request.url + if any([ + '127.0.0.1:9222' in url, + '127.0.0.1:54345' in url, + 'localhost:9222' in url, + 'chrome-extension://invalid' in url, + ]): + await route.abort() + else: + await route.continue_() + + await self.page.route('**/*', block_detection_requests) + print("✅ 已启用请求拦截,阻止检测自动化", file=sys.stderr) + + print("✅ 浏览器初始化成功(storage_state模式)", file=sys.stderr) except Exception as e: print(f"浏览器初始化失败: {str(e)}", file=sys.stderr) raise async def close_browser(self): - """关闭浏览器""" + """关闭浏览器(池模式下不关闭,仅清理引用)""" try: + # 浏览器池模式:不关闭浏览器,保持复用 + if self.use_pool and self.browser_pool: + print("[浏览器池模式] 保留浏览器实例供下次复用", file=sys.stderr) + # 仅清理当前服务的引用,浏览器池保持运行 + self.browser = None + self.context = None + self.page = None + return + + # 传统模式:完全关闭 + print("[传统模式] 完全关闭浏览器", file=sys.stderr) if self.page: await self.page.close() if self.context: @@ -80,13 +497,14 @@ class XHSLoginService: except Exception as e: print(f"关闭浏览器异常: {str(e)}", file=sys.stderr) - async def send_verification_code(self, phone: str, country_code: str = "+86") -> Dict[str, Any]: + async def send_verification_code(self, phone: str, country_code: str = "+86", login_page: str = "creator") -> Dict[str, Any]: """ 发送验证码 Args: phone: 手机号 country_code: 国家区号 + login_page: 登录页面类型,creator(创作者中心) 或 home(小红书首页) Returns: Dict containing success status and error message if any @@ -97,67 +515,202 @@ class XHSLoginService: self.current_phone = phone - # 访问小红书创作者平台登录页(专门的登录页面) - print(f"正在访问小红书创作者平台登录页...", file=sys.stderr) - # 直接访问创作者平台登录页面,超时时间延长到60秒 - try: - await self.page.goto('https://creator.xiaohongshu.com/login', wait_until='domcontentloaded', timeout=60000) - print("✅ 页面加载完成", file=sys.stderr) - except Exception as e: - print(f"访问页面超时,但继续尝试: {str(e)}", file=sys.stderr) + # 根据login_page参数选择登录URL + if login_page == "home": + login_url = 'https://www.xiaohongshu.com' + page_name = "小红书首页" + else: + login_url = 'https://creator.xiaohongshu.com/login' + page_name = "创作者中心" - # 等待登录表单加载 - await asyncio.sleep(2) - print("✅ 已进入创作者平台登录页面", file=sys.stderr) + # 优化:如果浏览器已预热且在登录页,直接使用 + current_url = self.page.url if self.page else "" + if self.use_pool and self.browser_pool and self.browser_pool.is_preheated: + if login_url in current_url: + print(f"✅ 浏览器已预热在{page_name}登录页,直接使用!", file=sys.stderr) + else: + # 页面变了,重新访问登录页 + print(f"[预热] 页面已变更 ({current_url}),重新访问{page_name}登录页...", file=sys.stderr) + await self.page.goto(login_url, wait_until='networkidle', timeout=30000) + await asyncio.sleep(0.5) + else: + # 未预热或不是池模式,正常访问页面 + print(f"正在访问{page_name}登录页...", file=sys.stderr) + # 优化:超时时间缩短到30秒,使用networkidle提升加载速度 + try: + await self.page.goto(login_url, wait_until='networkidle', timeout=30000) + print("✅ 页面加载完成", file=sys.stderr) + except Exception as e: + print(f"页面加载超时,尝试继续: {str(e)}", file=sys.stderr) + # 超时后等待500ms,让关键元素加载 + await asyncio.sleep(0.5) - # 根据记忆:小红书登录跳过协议复选框,无需处理 - # 但保留协议弹窗处理逻辑,以防页面变化 - try: - await asyncio.sleep(0.5) - agreement_selectors = [ - 'text="同意并继续"', - 'text="已阅读并同意"', - 'button:has-text("同意")', - 'button:has-text("继续")', - ] - - for selector in agreement_selectors: + print(f"✅ 已进入{page_name}登录页面", file=sys.stderr) + + # 根据登录页面类型处理协议复选框 + if login_page == "home": + # 小红书首页需要主动触发登录框 + print("处理小红书首页登录流程...", file=sys.stderr) + try: + # 首先尝试触发登录框(点击登录按钮) + print("查找并点击登录按钮以弹出登录框...", file=sys.stderr) + login_trigger_selectors = [ + '.login', # 常见的登录按钮class + 'text="登录"', + 'button:has-text("登录")', + 'a:has-text("登录")', + '.header-login', + '[class*="login"]', + ] + + login_triggered = False + for selector in login_trigger_selectors: + try: + login_btn = await self.page.query_selector(selector) + if login_btn: + # 检查是否可见 + is_visible = await login_btn.is_visible() + if is_visible: + print(f"✅ 找到登录触发按钮: {selector}", file=sys.stderr) + await login_btn.click() + print("✅ 已点击登录按钮,等待登录框弹出...", file=sys.stderr) + await asyncio.sleep(0.5) # 从1秒减少到0.5秒 + login_triggered = True + break + except Exception as e: + print(f"尝试选择器 {selector} 失败: {str(e)}", file=sys.stderr) + continue + + if not login_triggered: + print("⚠️ 未找到登录触发按钮,假设登录框已存在", file=sys.stderr) + + # 等待登录弹窗中的元素加载 + print("等待登录弹窗中的元素加载...", file=sys.stderr) + + # 直接等待手机号输入框出现(说明登录框已弹出) + phone_input_ready = False try: - agreement_btn = await self.page.wait_for_selector(selector, timeout=1000) - if agreement_btn: - await agreement_btn.click() - print(f"✅ 已点击协议按钮: {selector}", file=sys.stderr) - await asyncio.sleep(0.5) - break + await self.page.wait_for_selector('input[placeholder="输入手机号"]', timeout=3000) # 从to 8秒减少到3秒 + phone_input_ready = True + print("✅ 登录弹窗已弹出,手机号输入框就绪", file=sys.stderr) except Exception: - continue - except Exception as e: - print(f"无协议弹窗(正常情况)", file=sys.stderr) + print("⚠️ 等待登录弹窗超时,尝试继续...", file=sys.stderr) + + # 检查是否需要点击“手机号登录”选项卡(如果有多个登录方式) + phone_login_tab_selectors = [ + 'text="手机号登录"', + 'div:has-text("手机号登录")', + '.title:has-text("手机号登录")', + ] + + phone_login_tab = None + for selector in phone_login_tab_selectors: + try: + phone_login_tab = await self.page.query_selector(selector) + if phone_login_tab: + # 检查是否已经选中 + is_active = await phone_login_tab.evaluate('el => el.classList.contains("active") || el.parentElement.classList.contains("active")') + if not is_active: + print(f"✅ 找到手机号登录选项卡: {selector}", file=sys.stderr) + await phone_login_tab.click() + print("✅ 已点击手机号登录选项卡", file=sys.stderr) + await asyncio.sleep(0.3) # 从0.5秒减少到0.3秒 + else: + print("✅ 手机号登录选项卡已选中", file=sys.stderr) + break + except Exception: + continue + + if not phone_login_tab: + print("✅ 未找到手机号登录选项卡,可能已经是手机号登录界面", file=sys.stderr) + + # 查找并点击协议复选框(小红书首页特有) + agreement_selectors = [ + '.agree-icon', + '.agreements .icon-wrapper', + 'span.agree-icon', + '.icon-wrapper', + ] + + agreement_checkbox = None + for selector in agreement_selectors: + agreement_checkbox = await self.page.query_selector(selector) + if agreement_checkbox: + # 检查是否已勾选 + is_checked = await agreement_checkbox.evaluate('el => el.classList.contains("checked") || el.querySelector(".checked") !== null') + if not is_checked: + print(f"✅ 找到协议复选框: {selector}", file=sys.stderr) + await agreement_checkbox.click() + print("✅ 已勾选协议", file=sys.stderr) + await asyncio.sleep(0.2) + else: + print("✅ 协议已勾选", file=sys.stderr) + break + + if not agreement_checkbox: + print("⚠️ 未找到协议复选框,尝试继续...", file=sys.stderr) + except Exception as e: + print(f"处理首页登录流程失败: {str(e)}", file=sys.stderr) + else: + # 创作者中心登录流程 + # 根据记忆:小红书登录跳过协议复选框,无需处理 + # 优化:简化协议处理,减少等待时间 + try: + agreement_btn = await self.page.query_selector('text="同意并继续"') + if agreement_btn: + await agreement_btn.click() + print(f"✅ 已点击协议按钮", file=sys.stderr) + await asyncio.sleep(0.3) + except Exception: + pass # 无协议弹窗(正常情况) # 输入手机号 try: - # 创作者平台登录页面的手机号输入框选择器 print("查找手机号输入框...", file=sys.stderr) - phone_input_selectors = [ - 'input[placeholder="手机号"]', # 根据HTML精确匹配 - 'input.css-nt440g', # 根据HTML中的class - 'input[placeholder*="手机号"]', - 'input[autocomplete="on"][autofocus]', - 'input[type="tel"]', - 'input[type="text"]', - 'input[name*="phone"]', - 'input[name*="mobile"]', - ] + # 根据登录页面类型选择不同的选择器 + if login_page == "home": + # 小红书首页的手机号输入框(已经在上面等待过了) + phone_input_selectors = [ + 'input[placeholder="输入手机号"]', + 'label.phone input', + 'input[name="blur"]', + 'input[type="text"]', + ] + else: + # 创作者中心的手机号输入框 + phone_input_selectors = [ + 'input[placeholder="手机号"]', + 'input.css-nt440g', + 'input[placeholder*="手机号"]', + 'input[type="tel"]', + 'input[type="text"]', + ] + + # 优化:直接查找,不重试(因为已经等待过元素就绪) phone_input = None for selector in phone_input_selectors: - try: - phone_input = await self.page.wait_for_selector(selector, timeout=2000) - if phone_input: - print(f"✅ 找到手机号输入框: {selector}", file=sys.stderr) - break - except Exception: - continue + phone_input = await self.page.query_selector(selector) + if phone_input: + print(f"✅ 找到手机号输入框: {selector}", file=sys.stderr) + + # 清空并输入手机号(使用原生JS,避免上下文销毁) + await self.page.evaluate(f''' + (selector) => {{ + const input = document.querySelector(selector); + if (input) {{ + input.value = ''; + input.focus(); + input.value = '{phone}'; + input.dispatchEvent(new Event('input', {{ bubbles: true }})); + input.dispatchEvent(new Event('change', {{ bubbles: true }})); + }} + }} + ''', selector) + + print(f"✅ 已输入手机号: {phone}", file=sys.stderr) + await asyncio.sleep(0.3) + break if not phone_input: # 打印页面信息用于调试 @@ -181,15 +734,13 @@ class XHSLoginService: "error": "未找到手机号输入框,请检查页面是否正确加载" } - # 清空并输入手机号 - await phone_input.click() - await asyncio.sleep(0.2) - # 使用 Ctrl+A 全选后输入,更快速地清空 - await phone_input.press('Control+A') - await phone_input.type(phone, delay=50) # 模拟真实输入,每个字符50ms延迟 - print(f"✅ 已输入手机号: {phone}", file=sys.stderr) - await asyncio.sleep(0.3) except Exception as e: + # 保存错误截图 + await save_error_screenshot( + self.page, + "send_code_input_phone_failed", + f"输入手机号失败: {str(e)}" + ) return { "success": False, "error": f"输入手机号失败: {str(e)}" @@ -198,132 +749,116 @@ class XHSLoginService: # 点击发送验证码按钮 try: print("查找发送验证码按钮...", file=sys.stderr) - # 创作者平台登录页面的验证码按钮选择器 + + # 等待页面稳定(输入手机号后可能有动态渲染) + await asyncio.sleep(0.3) # 从0.5秒减少到0.3秒 + + # 根据登录页面类型选择不同的选择器 + if login_page == "home": + # 小红书首页的验证码按钮 + selectors = [ + 'span.code-button', + '.code-button', + 'text="获取验证码"', + 'span:has-text("获取验证码")', + ] + else: + # 创作者中心的验证码按钮 + selectors = [ + 'div.css-uyobdj', + 'text="发送验证码"', + 'div:has-text("发送验证码")', + 'text="重新发送"', + 'text="获取验证码"', + ] + + # 直接查找,不重试 send_code_btn = None - selectors = [ - 'text="发送验证码"', # 根据截图 - 'text="重新发送"', # 根据HTML - 'div.css-uyobdj', # 根据HTML中的class - 'button:has-text("发送验证码")', - 'button:has-text("重新发送")', - 'div:has-text("重新发送")', - 'text="获取验证码"', - 'button:has-text("获取验证码")', - ] - for selector in selectors: - try: - send_code_btn = await self.page.wait_for_selector(selector, timeout=1500) - if send_code_btn: - print(f"✅ 找到发送验证码按钮: {selector}", file=sys.stderr) - break - except Exception: - continue - - if not send_code_btn: - # 尝试查找所有按钮和div元素 - print("⚠️ 未找到预定选择器,查找所有可点击元素...", file=sys.stderr) - buttons = await self.page.query_selector_all('button, div[class*="css-"]') - print(f"页面上找到 {len(buttons)} 个可能的元素", file=sys.stderr) - for i, btn in enumerate(buttons[:20]): # 查看前20个 - try: - text = await btn.inner_text() - if text and len(text.strip()) > 0: # 只打印有文本的 - classes = await btn.get_attribute('class') - print(f"元素 {i+1}: 文本=[{text.strip()}] class=[{classes}]", file=sys.stderr) - except Exception: - pass - - # 尝试根据文本内容查找 - print("尝试根据文本内容查找验证码按钮...", file=sys.stderr) - for btn in buttons: - try: - text = await btn.inner_text() - if text and ('验证码' in text or '发送' in text or '获取' in text or '重新' in text): - send_code_btn = btn - print(f"✅ 通过文本找到按钮: {text.strip()}", file=sys.stderr) - break - except Exception: - continue + send_code_btn = await self.page.query_selector(selector) + if send_code_btn: + print(f"✅ 找到发送验证码按钮: {selector}", file=sys.stderr) + break if send_code_btn: + # 获取按钮文本内容 + btn_text = await send_code_btn.inner_text() + btn_text = btn_text.strip() if btn_text else "" + print(f"📝 按钮文本: '{btn_text}'", file=sys.stderr) + + # 检查按钮是否处于倒计时状态 + # 倒计时状态通常显示为: "59s", "58s", "60秒后重新获取" 等 + if btn_text and (btn_text[-1] == 's' or '秒' in btn_text or btn_text.isdigit()): + print(f"⚠️ 按钮处于倒计时状态: {btn_text}", file=sys.stderr) + return { + "success": False, + "error": f"验证码发送过于频繁,请{btn_text}后再试" + } + + # 检查按钮文本是否为期望的"获取验证码"或"发送验证码" + expected_texts = ["获取验证码", "发送验证码", "重新发送"] + if btn_text not in expected_texts: + print(f"⚠️ 按钮文本不符合预期: '{btn_text}', 期望: {expected_texts}", file=sys.stderr) + return { + "success": False, + "error": f"按钮状态异常(当前文本: {btn_text}),请刷新页面重试" + } + + # 检查按钮是否有 active 类(小红书首页的按钮需要active才能点击) + if login_page == "home": + class_name = await send_code_btn.get_attribute('class') or "" + if 'active' not in class_name: + print(f"⚠️ 按钮未激活状态: class={class_name}", file=sys.stderr) + return { + "success": False, + "error": "按钮未激活,请检查手机号是否正确输入" + } + print(f"✅ 按钮已激活: class={class_name}", file=sys.stderr) + + # 点击按钮 await send_code_btn.click() print("✅ 已点击发送验证码", file=sys.stderr) - await asyncio.sleep(2) # 等待验证码发送 - # 点击后可能再次出现协议弹窗,再次处理 - try: - await asyncio.sleep(0.5) - agreement_selectors = [ - 'text="同意并继续"', - 'text="已阅读并同意"', - 'button:has-text("同意")', - ] - - for selector in agreement_selectors: - try: - agreement_btn = await self.page.wait_for_selector(selector, timeout=1000) - if agreement_btn: - await agreement_btn.click() - print(f"✅ 再次点击协议按钮: {selector}", file=sys.stderr) - await asyncio.sleep(0.5) - break - except Exception: - continue - except Exception as e: - print(f"无二次协议弹窗(正常)", file=sys.stderr) + # # 优化:简化二次协议处理 + # await asyncio.sleep(0.3) # 等待协议弹窗可能出现 + # try: + # agreement_btn = await self.page.query_selector('text="同意并继续"') + # if agreement_btn: + # await agreement_btn.click() + # print(f"✅ 再次点击协议按钮", file=sys.stderr) + # await asyncio.sleep(0.2) + # except Exception: + # pass # 无二次协议弹窗 + + # 直接返回成功,不再检测滑块 + print("\n✅ 验证码发送流程完成,请查看手机短信", file=sys.stderr) + print("请在小程序中输入收到的验证码并点击登录\n", file=sys.stderr) + print("[响应即将返回] success=True, message=验证码发送成功", file=sys.stderr) + + return { + "success": True, + "message": "验证码发送成功,请查看手机短信" + } else: return { "success": False, "error": "未找到发送验证码按钮,请检查页面结构" } except Exception as e: + # 保存错误截图 + await save_error_screenshot( + self.page, + "send_code_click_button_failed", + f"点击发送验证码失败: {str(e)}" + ) return { "success": False, "error": f"点击发送验证码失败: {str(e)}" } - - # 检查是否需要滑块验证 - try: - await asyncio.sleep(1) - # 如果出现滑块,需要手动处理或使用自动化工具 - slider_selectors = [ - '.slider', - '.captcha', - '[class*="captcha"]', - '[class*="slider"]', - '[id*="captcha"]', - ] - - slider_found = False - for selector in slider_selectors: - try: - slider = await self.page.query_selector(selector) - if slider: - slider_found = True - print("⚠️ 检测到滑块验证,请手动完成...", file=sys.stderr) - # 等待用户手动完成滑块 - await asyncio.sleep(15) - break - except Exception: - pass - - if not slider_found: - print("✅ 未检测到滑块验证", file=sys.stderr) - except Exception as e: - print(f"滑块检测异常: {str(e)}", file=sys.stderr) - - print("\n✅ 验证码发送流程完成,请查看手机短信", file=sys.stderr) - print("请在小程序中输入收到的验证码并点击登录\n", file=sys.stderr) - - return { - "success": True, - "message": "验证码发送成功,请查看手机短信" - } - + except Exception as e: error_msg = str(e) - print(f"\n\u274c 发送验证码异常: {error_msg}", file=sys.stderr) + print(f"\n❌ 发送验证码异常: {error_msg}", file=sys.stderr) print(f"当前页面URL: {self.page.url if self.page else 'N/A'}", file=sys.stderr) # 打印调试信息 @@ -340,7 +875,7 @@ class XHSLoginService: "error": error_msg } - async def login(self, phone: str, code: str, country_code: str = "+86") -> Dict[str, Any]: + async def login(self, phone: str, code: str, country_code: str = "+86", login_page: str = "creator") -> Dict[str, Any]: """ 使用验证码登录 @@ -348,6 +883,7 @@ class XHSLoginService: phone: 手机号 code: 验证码 country_code: 国家区号 + login_page: 登录页面类型,creator(创作者中心) 或 home(小红书首页) Returns: Dict containing login result, user info and cookies @@ -362,12 +898,24 @@ class XHSLoginService: # 输入验证码 try: print("查找验证码输入框...", file=sys.stderr) - code_input_selectors = [ - 'input[placeholder="验证码"]', # 根据HTML精确匹配 - 'input.css-1ge5flv', # 根据HTML中的class - 'input[placeholder*="验证码"]', - 'input[type="text"]:not([placeholder*="手机"])', - ] + + # 根据登录页面类型选择不同的选择器 + if login_page == "home": + # 小红书首页的验证码输入框 + code_input_selectors = [ + 'input[placeholder="输入验证码"]', # 从您提供的HTML中找到 + 'label.auth-code input', + 'input[type="number"]', + 'input[placeholder*="验证码"]', + ] + else: + # 创作者中心的验证码输入框 + code_input_selectors = [ + 'input[placeholder="验证码"]', # 根据HTML精确匹配 + 'input.css-1ge5flv', # 根据HTML中的class + 'input[placeholder*="验证码"]', + 'input[type="text"]:not([placeholder*="手机"])', + ] code_input = None for selector in code_input_selectors: @@ -400,15 +948,27 @@ class XHSLoginService: # 点击登录按钮 try: print("查找登录按钮...", file=sys.stderr) - login_btn_selectors = [ - 'button.beer-login-btn', # 根据HTML中的class - 'button.css-y4h4ay', # 根据HTML - 'button:has-text("登 录")', # 注意有空格 - 'button:has-text("登录")', - 'text="登 录"', - 'text="登录"', - '.login-button', - ] + + # 根据登录页面类型选择不同的选择器 + if login_page == "home": + # 小红书首页的登录按钮 + login_btn_selectors = [ + 'button.submit', # 从您提供的HTML中找到 + 'button:has-text("登录")', + 'text="登录"', + '.submit', + ] + else: + # 创作者中心的登录按钮 + login_btn_selectors = [ + 'button.beer-login-btn', # 根据HTML中的class + 'button.css-y4h4ay', # 根据HTML + 'button:has-text("登 录")', # 注意有空格 + 'button:has-text("登录")', + 'text="登 录"', + 'text="登录"', + '.login-button', + ] login_btn = None for selector in login_btn_selectors: @@ -441,49 +1001,78 @@ class XHSLoginService: await login_btn.click() print("✅ 已点击登录按钮", file=sys.stderr) - # 等待一下,检查是否出现协议弹窗 - await asyncio.sleep(1) - - # 处理登录后可能出现的协议弹窗 + # 优化:简化协议处理,减少等待 + await asyncio.sleep(0.5) try: - agreement_popup_selectors = [ - 'text="同意并继续"', - 'button:has-text("同意并继续")', - 'text="已阅读并同意"', - ] - - for selector in agreement_popup_selectors: - try: - popup_btn = await self.page.wait_for_selector(selector, timeout=2000) - if popup_btn: - await popup_btn.click() - print(f"✅ 已点击登录后的协议弹窗: {selector}", file=sys.stderr) - await asyncio.sleep(1) - break - except Exception: - continue - except Exception as e: - print(f"无登录后协议弹窗(正常)", file=sys.stderr) + popup_btn = await self.page.query_selector('text="同意并继续"') + if popup_btn: + await popup_btn.click() + print(f"✅ 已点击登录后的协议弹窗", file=sys.stderr) + await asyncio.sleep(0.3) + except Exception: + pass # 无弹窗 - # 等待登录完成 - await asyncio.sleep(3) + # 优化:直接检测URL跳转,不等待元素 + print("正在等待登录跳转...", file=sys.stderr) + for i in range(16): # 从20次减少到16次,最多等待8秒 + await asyncio.sleep(0.5) + current_url = self.page.url + + # 严格检查:必须跳转离开登录页 + if 'login' not in current_url: + # 已离开登录页,检查是否到达有效页面 + if 'creator.xiaohongshu.com' in current_url or 'www.xiaohongshu.com' in current_url: + print(f"✅ 登录成功,跳转到: {current_url}", file=sys.stderr) + # 优化:减少等待时间 + await asyncio.sleep(0.5) # 从1秒减少到0.5秒 + break + else: + # 8秒后还在登录页,可能验证码错误 + if 'login' in self.page.url: + # 保存错误截图 + await save_error_screenshot( + self.page, + "login_failed_wrong_code", + "登录失败,验证码可能错误" + ) + return { + "success": False, + "error": "登录失败,请检查验证码是否正确" + } except Exception as e: + # 保存错误截图 + await save_error_screenshot( + self.page, + "login_click_button_failed", + f"点击登录按钮失败: {str(e)}" + ) return { "success": False, "error": f"点击登录按钮失败: {str(e)}" } # 检查是否登录成功 - try: - # 等待页面跳转或出现用户信息 - await self.page.wait_for_selector('.user-info, .avatar, [class*="user"]', timeout=10000) - print("登录成功", file=sys.stderr) - except Exception as e: + # 优化:已经通过URL跳转检查,但需要再次确认页面稳定 + print("✅ 登录成功,正在确认页面稳定性...", file=sys.stderr) + + # 优化:减少等待时间 + await asyncio.sleep(1) # 从2秒减少到1秒 + final_url = self.page.url + + if 'login' in final_url: + print("⚠️ 检测到页面被重定向回登录页,Cookie可能被小红书拒绝", file=sys.stderr) + await save_error_screenshot( + self.page, + "login_redirect_back", + "登录后被重定向回登录页" + ) return { "success": False, - "error": f"登录验证失败,可能验证码错误: {str(e)}" + "error": "登录失败:小红书检测到异常登录行为,请稍后再试或使用手动登录" } + print(f"✅ 页面稳定,最终URL: {final_url}", file=sys.stderr) + # 获取Cookies cookies = await self.context.cookies() @@ -509,109 +1098,117 @@ class XHSLoginService: # 获取用户信息(从页面或API) user_info = {} try: - # 等待页面完全加载 - await asyncio.sleep(2) + # 优化:减少等待时间,直接获取localStorage + # await asyncio.sleep(0.5) # 删除不必要的等待 - # 尝试从localStorage获取用户信息 + # 从 localStorage 获取用户信息(最关键) storage = await self.page.evaluate('() => JSON.stringify(localStorage)') storage_dict = json.loads(storage) - print(f"LocalStorage内容: {list(storage_dict.keys())}", file=sys.stderr) - # 提取有用的localStorage数据 - useful_keys = ['b1', 'b1b1', 'p1', 'xhs_context_networkQuality'] + useful_keys = ['b1', 'b1b1', 'p1'] for key in useful_keys: if key in storage_dict: try: - # 尝试解析JSON value = storage_dict[key] if value and value.strip(): user_info[key] = json.loads(value) if value.startswith('{') or value.startswith('[') else value except: user_info[key] = storage_dict[key] - # 小红书可能将用户信息存储在特定键中 + # 获取用户数据 for key, value in storage_dict.items(): if 'user' in key.lower(): try: - user_data = json.loads(value) - user_info['user_data'] = user_data - print(f"从localStorage获取用户信息 - key: {key}", file=sys.stderr) + user_info['user_data'] = json.loads(value) break except: pass - # 尝试从window对象获取用户信息(更完整的方式) - try: - # 获取window.__INITIAL_STATE__或其他可能的用户信息对象 - window_data = await self.page.evaluate(''' - () => { - const result = {}; - - // 尝试获取常见的用户信息存储位置 - if (window.__INITIAL_STATE__) result.initial_state = window.__INITIAL_STATE__; - if (window.user) result.user = window.user; - if (window.userInfo) result.userInfo = window.userInfo; - if (window.__APOLLO_STATE__) result.apollo_state = window.__APOLLO_STATE__; - - // 尝试从Redux store获取 - if (window.__REDUX_DEVTOOLS_EXTENSION__) { - try { - const state = window.__REDUX_DEVTOOLS_EXTENSION__.extractState(); - if (state) result.redux_state = state; - } catch(e) {} - } - - return result; - } - ''') - - if window_data: - user_info['window_data'] = window_data - print(f"从window对象获取数据,包含键: {list(window_data.keys())}", file=sys.stderr) - except Exception as e: - print(f"从window对象获取失败: {str(e)}", file=sys.stderr) - - # 尝试从页面元素获取用户信息 - if not user_info.get('username'): - try: - # 尝试获取用户昵称 - username_el = await self.page.query_selector('.user-name, .username, [class*="user"][class*="name"]') - if username_el: - username = await username_el.inner_text() - user_info['username'] = username - print(f"从页面获取用户名: {username}", file=sys.stderr) - except: - pass - - print(f"最终获取到用户信息字段: {list(user_info.keys())}", file=sys.stderr) + print(f"✅ 获取到用户信息: {list(user_info.keys())}", file=sys.stderr) except Exception as e: - print(f"获取用户信息失败: {str(e)}", file=sys.stderr) + print(f"⚠️ 获取用户信息失败: {str(e)}", file=sys.stderr) # 获取当前URL(可能包含token等信息) current_url = self.page.url print(f"当前URL: {current_url}", file=sys.stderr) - # 将Cookies保存到文件(Playwright 完整格式) + # 获取完整的localStorage数据 + localStorage_data = {} try: + storage = await self.page.evaluate('() => JSON.stringify(localStorage)') + localStorage_data = json.loads(storage) + print(f"✅ 获取到 {len(localStorage_data)} 个localStorage项", file=sys.stderr) + except Exception as e: + print(f"⚠️ 获取localStorage失败: {str(e)}", file=sys.stderr) + + # 获取sessionStorage数据 + sessionStorage_data = {} + try: + session_storage = await self.page.evaluate('() => JSON.stringify(sessionStorage)') + sessionStorage_data = json.loads(session_storage) + print(f"✅ 获取到 {len(sessionStorage_data)} 个sessionStorage项", file=sys.stderr) + except Exception as e: + print(f"⚠️ 获取sessionStorage失败: {str(e)}", file=sys.stderr) + + # 保存完整的登录状态(包含Cookies、localStorage、sessionStorage) + try: + login_state = { + "cookies": cookies, # Playwright 完整格式 + "localStorage": localStorage_data, + "sessionStorage": sessionStorage_data, + "url": current_url, + "timestamp": time.time(), + "user_agent": self.context._impl_obj._options.get('userAgent'), + "viewport": self.context._impl_obj._options.get('viewport') + } + + # 保存到文件(兼容旧版) + with open('login_state.json', 'w', encoding='utf-8') as f: + json.dump(login_state, f, ensure_ascii=False, indent=2) + print("✅ 已保存完整登录状态到 login_state.json 文件", file=sys.stderr) + print(f" 包含: {len(cookies)} 个Cookies, {len(localStorage_data)} 个localStorage, {len(sessionStorage_data)} 个sessionStorage", file=sys.stderr) + + # 兼容性:同时保存单独的cookies.json文件 with open('cookies.json', 'w', encoding='utf-8') as f: json.dump(cookies, f, ensure_ascii=False, indent=2) - print("✅ 已保存 Cookies 到 cookies.json 文件(Playwright 格式)", file=sys.stderr) - print(f" 文件包含 {len(cookies)} 个完整的 Cookie 对象", file=sys.stderr) + print("✅ 已保存 Cookies 到 cookies.json 文件(兼容旧版)", file=sys.stderr) + + # 新增:使用Playwright原生storage_state保存(按手机号命名) + storage_state_dir = 'storage_states' + os.makedirs(storage_state_dir, exist_ok=True) + storage_state_filename = f"xhs_{phone}.json" + storage_state_path = os.path.join(storage_state_dir, storage_state_filename) + + # 使用Playwright原生API保存storage_state + storage_state_data = await self.context.storage_state(path=storage_state_path) + print(f"✅ 已保存 Playwright Storage State 到: {storage_state_path}", file=sys.stderr) + print(f" 此文件包含完整的浏览器上下文状态,可用于后续免登录恢复", file=sys.stderr) + except Exception as e: - print(f"保存Cookies文件失败: {str(e)}", file=sys.stderr) + print(f"保存登录状态文件失败: {str(e)}", file=sys.stderr) return { "success": True, "user_info": user_info, "cookies": cookies_dict, # API 返回:键值对格式(方便前端展示) - "cookies_full": cookies, # API 返回:完整格式(可选,供需要者使用) - "url": current_url + "cookies_full": cookies, # API 返回:Playwright完整格式(数据库存储/脚本使用) + "login_state": login_state, # API 返回:完整登录状态(供Go服务存储到数据库) + "localStorage": localStorage_data, # API 返回:localStorage数据 + "sessionStorage": sessionStorage_data, # API 返回:sessionStorage数据 + "url": current_url, + "storage_state_path": storage_state_path # 新增:storage_state文件路径 } except Exception as e: print(f"登录异常: {str(e)}", file=sys.stderr) + # 保存错误截图(通用错误) + await save_error_screenshot( + self.page, + "login_exception", + f"登录异常: {str(e)}" + ) return { "success": False, "error": str(e) @@ -647,10 +1244,13 @@ class XHSLoginService: "error": str(e) } - async def verify_login_status(self) -> Dict[str, Any]: + async def verify_login_status(self, url: str = None) -> Dict[str, Any]: """ 验证当前登录状态 - 访问小红书创作者平台检查是否已登录 + 访问指定的小红书页面检查是否已登录 + + Args: + url: 可选的验证URL,默认访问创作者平台 Returns: Dict containing login status and user info if logged in @@ -665,18 +1265,39 @@ class XHSLoginService: print("正在验证登录状态...", file=sys.stderr) - # 访问小红书创作者平台(而不是首页) - print("访问创作者平台...", file=sys.stderr) + # 确定要访问的URL + target_url = url or 'https://creator.xiaohongshu.com/' + page_name = "创作者平台" if "creator" in target_url else "小红书首页" + + print(f"访问{page_name}...", file=sys.stderr) + + # 重置跳转计数器 + self.redirect_count = 0 + self.last_redirect_time = 0 + try: - await self.page.goto('https://creator.xiaohongshu.com/', wait_until='domcontentloaded', timeout=60000) + await self.page.goto(target_url, wait_until='domcontentloaded', timeout=60000) await asyncio.sleep(2) # 等待页面加载 - print(f"✅ 已访问创作者平台,当前URL: {self.page.url}", file=sys.stderr) + + # 检查是否发生了频繁跳转 + if self.redirect_count > 5: + print(f"❌ 检测到无限跳转 ({self.redirect_count}次重定向),Cookie已失效", file=sys.stderr) + return { + "success": True, + "logged_in": False, + "cookie_expired": True, + "infinite_redirect": True, + "message": "Cookie已失效,小红书检测到异常登录行为", + "url": self.page.url + } + + print(f"✅ 已访问{page_name},当前URL: {self.page.url}", file=sys.stderr) except Exception as e: - print(f"访问创作者平台失败: {str(e)}", file=sys.stderr) + print(f"访问{page_name}失败: {str(e)}", file=sys.stderr) return { "success": False, "logged_in": False, - "error": f"访问创作者平台失败: {str(e)}" + "error": f"访问{page_name}失败: {str(e)}" } # 检查是否被重定向到登录页(未登录状态) @@ -691,9 +1312,9 @@ class XHSLoginService: "url": current_url } - # 如果在创作者平台主页,说明已登录 - if 'creator.xiaohongshu.com' in current_url and 'login' not in current_url.lower(): - print("✅ 已登录状态(成功访问创作者平台)", file=sys.stderr) + # 如果成功访问目标页面且未被重定向到登录页,说明已登录 + if 'xiaohongshu.com' in current_url and 'login' not in current_url.lower(): + print(f"✅ 已登录状态(成功访问{page_name})", file=sys.stderr) # 获取当前的Cookies cookies = await self.context.cookies() @@ -755,7 +1376,7 @@ class XHSLoginService: width += 1 return width - async def publish_note(self, title: str, content: str, images: list = None, topics: list = None, cookies: list = None) -> Dict[str, Any]: + async def publish_note(self, title: str, content: str, images: list = None, topics: list = None, cookies: list = None, proxy: str = None, user_agent: str = None) -> Dict[str, Any]: """ 发布笔记(支持Cookie注入) @@ -765,6 +1386,8 @@ class XHSLoginService: images: 图片路径列表(本地文件路径) topics: 话题标签列表 cookies: 可选的Cookie列表(Playwright完整格式),用于注入登录态 + proxy: 可选的代理地址,例如 http://ip:port + user_agent: 可选的自定义User-Agent,用于防指纹识别 Returns: Dict containing publish result @@ -827,21 +1450,64 @@ class XHSLoginService: print("✅ 所有验证通过,开始发布\n", file=sys.stderr) # ========== 开始发布流程 ========== - # 如果提供了Cookie,初始化浏览器并注入Cookie + # 如果提供了Cookie且使用浏览器池,创建独立的context和page if cookies: - print("✅ 检测到Cookie,将注入到浏览器", file=sys.stderr) - if not self.page: - await self.init_browser(cookies) + print("✅ 检测到Cookie,将创建独立的浏览器环境", file=sys.stderr) + # 调试:打印cookies格式 + if cookies and len(cookies) > 0: + print(f" Cookie格式检查: 类型={type(cookies).__name__}, 数量={len(cookies)}", file=sys.stderr) + if isinstance(cookies, list) and len(cookies) > 0: + first_cookie = cookies[0] + print(f" 第一个cookie字段: {list(first_cookie.keys()) if isinstance(first_cookie, dict) else 'not dict'}", file=sys.stderr) + if isinstance(first_cookie, dict): + # 检查关键字段的类型 + for key in ['name', 'value', 'expires', 'sameSite']: + if key in first_cookie: + val = first_cookie[key] + print(f" {key}: type={type(val).__name__}, value={val}", file=sys.stderr) + + # 使用浏览器池模式:复用主浏览器,但为发布创建独立的context + if self.use_pool and self.browser_pool: + print("[浏览器池模式] 复用主浏览器实例", file=sys.stderr) + # 从池中获取浏览器(仅获取browser实例) + self.browser, _, _ = await self.browser_pool.get_browser() + print("[浏览器池] 复用主浏览器实例", file=sys.stderr) + + # 为发布任务创建全新的context(不复用预热的context) + context_kwargs = { + "viewport": {'width': 1280, 'height': 720}, + "user_agent": user_agent or 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + } + self.context = await self.browser.new_context(**context_kwargs) + print("[浏览器池模式] 为发布创建独立的context(避免污染预热环境)", file=sys.stderr) + + # 注入Cookie到新的context + await self.context.add_cookies(cookies) + print(f"✅ 已注入 {len(cookies)} 个Cookie", file=sys.stderr) + + # 创建发布页面 + print("[浏览器池模式] 创建发布专用页面", file=sys.stderr) + self.page = await self.context.new_page() + print("✅ 发布页面创建成功\n", file=sys.stderr) + + elif not self.page: + # 非池模式且页面不存在,初始化浏览器 + await self.init_browser(cookies, proxy=proxy, user_agent=user_agent) else: - # 如果浏览器已存在,添加Cookie + # 非池模式但页面已存在,添加Cookie await self.context.add_cookies(cookies) print(f"✅ 已注入 {len(cookies)} 个Cookie", file=sys.stderr) + # 如果没有Cookie且没有page,尝试使用池 if not self.page: - return { - "success": False, - "error": "页面未初始化,请先登录或提供Cookie" - } + if self.use_pool and self.browser_pool: + print("[浏览器池模式] 获取浏览器实例", file=sys.stderr) + self.browser, self.context, self.page = await self.browser_pool.get_browser(proxy=proxy, user_agent=user_agent) + else: + return { + "success": False, + "error": "页面未初始化,请先登录或提供Cookie" + } print("\n========== 开始发布笔记 ==========", file=sys.stderr) print(f"标题: {title}", file=sys.stderr) @@ -849,53 +1515,171 @@ class XHSLoginService: print(f"图片数量: {len(images) if images else 0}", file=sys.stderr) print(f"话题: {topics if topics else []}", file=sys.stderr) - # 访问官方创作者平台发布页面(带有Cookie的状态下直接访问) + # 优化:直接访问图文发布页面URL,跳过点击tab步骤 print("访问创作者平台图文发布页面...", file=sys.stderr) - try: - await self.page.goto('https://creator.xiaohongshu.com/publish/publish?source=official', - wait_until='networkidle', timeout=60000) - # 等待页面核心元素加载完成,而不是固定时间 - await asyncio.sleep(3) # 增加等待时间确保JavaScript完全执行 - - # 点击「上传图文」tab,符合平台规范 + + publish_url = 'https://creator.xiaohongshu.com/publish/publish?source=official&from=menu&target=image' + + # 尝试访问页面(最多重试2次) + page_loaded = False + for attempt in range(2): try: - print("查找“上传图文”tab...", file=sys.stderr) - tab_selectors = [ - 'button:has-text("上传图文")', - 'div:has-text("上传图文")', - 'text="上传图文"', - ] - tab_clicked = False - for selector in tab_selectors: - try: - tab = await self.page.wait_for_selector(selector, timeout=3000) - if tab: - await tab.click() - tab_clicked = True - print(f"✅ 已点击“上传图文”tab: {selector}", file=sys.stderr) - await asyncio.sleep(1) - break - except Exception: + if attempt > 0: + print(f"第 {attempt + 1} 次尝试加载页面...", file=sys.stderr) + else: + print("开始加载页面...", file=sys.stderr) + + # 使用更宽松的等待条件,不等待networkidle + await self.page.goto( + publish_url, + wait_until='load', # 从networkidle改为load,更快 + timeout=40000 # 增加到40秒 + ) + + # 等待页面稳定 + await asyncio.sleep(2) + + # 检查是否被跳转回登录页或其他页面 + current_url = self.page.url + + # 先打印URL信息,但不立即判定为错误 + if current_url != publish_url: + print(f"⚠️ 检测到页面跳转: {current_url}", file=sys.stderr) + print(f"⚠️ 期望页面: {publish_url}", file=sys.stderr) + + # 关键优化:等待5秒,给小红书时间自动重定向回发布页 + if 'redirectReason' in current_url or 'login' in current_url: + print("🔄 检测到重定向参数,等待5秒让小红书自动重定向...", file=sys.stderr) + await asyncio.sleep(5) + + # 再次检查最终URL + final_url = self.page.url + print(f"🔍 最终页面URL: {final_url}", file=sys.stderr) + + # 如果最终还是在发布页,则认为成功 + if 'publish/publish' in final_url: + print("✅ 自动重定向成功,已到达发布页", file=sys.stderr) + current_url = final_url # 更新当前URL + elif 'login' in final_url and 'publish' not in final_url: + # 真的停留在登录页,Cookie失效 + return { + "success": False, + "error": "Cookie可能已失效,页面跳转到登录页", + "error_type": "cookie_expired" + } + + # 最终检查:只要URL中包含'publish/publish',就认为在发布页 + if 'publish/publish' not in current_url: + print(f"❌ 页面最终未到达发布页: {current_url}", file=sys.stderr) + # 其他跳转,重试 + if attempt < 1: + print("等待3秒后重试...", file=sys.stderr) + await asyncio.sleep(3) continue - if not tab_clicked: - print("⚠️ 未找到“上传图文”tab,将继续使用当前页面进行发布", file=sys.stderr) + else: + return { + "success": False, + "error": f"页面跳转到意外地址: {current_url}" + } + + # 验证页面是否加载成功(检查是否有上传控件) + upload_check = await self.page.query_selector('input[type="file"]') + if upload_check: + print(f"✅ 已进入图文发布页面: {current_url}", file=sys.stderr) + page_loaded = True + break + else: + print("⚠️ 页面加载完成但未找到上传控件,可能需要重试", file=sys.stderr) + if attempt < 1: # 还有重试机会 + await asyncio.sleep(2) + continue + else: + # 最后一次尝试也失败了,继续执行看看 + print("⚠️ 未找到上传控件,但继续执行", file=sys.stderr) + page_loaded = True + break + except Exception as e: - print(f"点击“上传图文”tab时异常: {str(e)}", file=sys.stderr) - - print("✅ 已进入图文发布页面", file=sys.stderr) - except Exception as e: + error_msg = f"访问发布页面失败(尝试{attempt + 1}/2): {str(e)}" + print(f"❌ {error_msg}", file=sys.stderr) + + # 保存错误截图 + try: + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + error_type = type(e).__name__ + screenshot_path = f"error_screenshots/{timestamp}_{error_type}.png" + os.makedirs('error_screenshots', exist_ok=True) + await self.page.screenshot(path=screenshot_path, full_page=True) + print(f"📸 已保存错误截图: {screenshot_path}", file=sys.stderr) + except Exception as screenshot_error: + print(f"⚠️ 保存截图失败: {screenshot_error}", file=sys.stderr) + + if attempt < 1: # 还有重试机会 + print("等待3秒后重试...", file=sys.stderr) + await asyncio.sleep(3) + continue + else: + # 所有重试都失败了 + import traceback + traceback.print_exc() + return { + "success": False, + "error": f"访问发布页面失败(已重试2次): {str(e)}" + } + + if not page_loaded: return { "success": False, - "error": f"访问发布页面失败: {str(e)}" + "error": "页面加载失败" } # 上传图片(如果有) if images and len(images) > 0: try: print(f"开始上传 {len(images)} 张图片...", file=sys.stderr) - await asyncio.sleep(1) # 等待图文上传页面完全加载 - # 直接查找图片上传控件(已经在图文上传页面了) + # 预处理图片:将网络图片下载到本地 + local_images = [] + downloaded_files = [] # 用于清理临时文件 + + # OSS域名前缀(用于补充不完整的图片路径) + oss_prefix = "https://bxmkb-beijing.oss-cn-beijing.aliyuncs.com/Images/" + + for img_path in images: + original_path = img_path + + # 检查是否需要补充OSS前缀 + if not (img_path.startswith('http://') or img_path.startswith('https://')): + # 不是完整URL + if not os.path.isabs(img_path): + # 也不是绝对路径,检查是否需要补充OSS前缀 + if '/' in img_path and not img_path.startswith('/'): + # 可能是OSS相对路径(如 20251221/xxx.png),补充前缀 + img_path = oss_prefix + img_path + print(f" 检测到相对路径,补充OSS前缀: {original_path} -> {img_path}", file=sys.stderr) + + if img_path.startswith('http://') or img_path.startswith('https://'): + # 网络图片,需要下载 + try: + local_path = await download_image(img_path) + local_images.append(local_path) + downloaded_files.append(local_path) # 记录以便后续清理 + except Exception as e: + print(f"⚠️ 下载图片 {img_path} 失败: {str(e)}", file=sys.stderr) + return { + "success": False, + "error": f"下载图片失败: {str(e)}" + } + else: + # 本地图片,直接使用 + local_images.append(img_path) + + print(f"✅ 图片预处理完成,共 {len(local_images)} 张本地图片", file=sys.stderr) + + # 优化:减少等待时间 + await asyncio.sleep(0.5) + + # 优化:直接使用最常见的选择器,先用query_selector快速查找 print("查找图片上传控件...", file=sys.stderr) upload_selectors = [ 'input[type="file"][accept*="image"]', @@ -908,52 +1692,154 @@ class XHSLoginService: file_input = None for selector in upload_selectors: try: - file_input = await self.page.wait_for_selector(selector, timeout=3000) + # 优化:使用query_selector代替wait_for_selector,更快 + file_input = await self.page.query_selector(selector) if file_input: print(f"找到文件上传控件: {selector}", file=sys.stderr) break except Exception: continue + # 如果快速查找失败,再用wait方式 + if not file_input: + for selector in upload_selectors: + try: + file_input = await self.page.wait_for_selector(selector, timeout=3000) + if file_input: + print(f"找到文件上传控件: {selector}", file=sys.stderr) + break + except Exception: + continue + if file_input: - # 批量上传图片 - images_count = len(images) - print(f"正在上传 {images_count} 张图片: {images}", file=sys.stderr) - await file_input.set_input_files(images) + # 批量上传图片(使用本地图片) + images_count = len(local_images) + print(f"正在上传 {images_count} 张本地图片: {local_images}", file=sys.stderr) + + # 验证文件是否存在 + for img_path in local_images: + if not os.path.exists(img_path): + print(f"⚠️ 警告: 图片文件不存在: {img_path}", file=sys.stderr) + else: + file_size = os.path.getsize(img_path) / 1024 + print(f" ✅ 文件存在: {img_path} ({file_size:.1f}KB)", file=sys.stderr) + + await file_input.set_input_files(local_images) print(f"已设置文件路径,等待上传...", file=sys.stderr) - # 等待所有图片上传完成(检测多张图片) + # 等待一下让页面处理文件 + await asyncio.sleep(1) + + # 优化:更快速的图片上传检测(500ms间隔) upload_success = False uploaded_count = 0 + page_destroyed = False - for i in range(20): # 最多等待20秒(多图需要更长时间) - await asyncio.sleep(1) + for i in range(60): # 最多等待30秒(60次 × 500ms) + await asyncio.sleep(0.5) # 优化:从1秒改为500ms try: - # 查找所有已上传的图片缩略图 + # 检查页面是否还有效 + if self.page.is_closed(): + print("检测到页面已关闭", file=sys.stderr) + page_destroyed = True + break + + # 查找所有已上传的图片缩略图 - 增加更多选择器 uploaded_images = await self.page.query_selector_all('img[src*="blob:"]') if not uploaded_images: # 尝试其他选择器 uploaded_images = await self.page.query_selector_all('[class*="image"][class*="item"] img') + if not uploaded_images: + # 再尝试其他可能的选择器 + uploaded_images = await self.page.query_selector_all('.image-item img, .upload-item img, .pic-item img') + if not uploaded_images: + # 最后尝试查找包含图片的元素 + uploaded_images = await self.page.query_selector_all('img[src*="data:image"]') uploaded_count = len(uploaded_images) if uploaded_count > 0: - print(f"✅ 已上传 {uploaded_count}/{images_count} 张图片", file=sys.stderr) - # 检查是否所有图片都已上传 if uploaded_count >= images_count: print(f"✅ 所有图片上传完成!共 {uploaded_count} 张", file=sys.stderr) upload_success = True break - - print(f"等待图片上传... {uploaded_count}/{images_count} ({i+1}/20秒)", file=sys.stderr) + + # 每秒打印一次进度(避免刷屏) + if i % 2 == 0: + print(f"等待图片上传... {uploaded_count}/{images_count} ({(i+1)*0.5:.1f}/30秒)", file=sys.stderr) except Exception as e: + error_msg = str(e) + # 检查是否是页面跳转/销毁导致的异常 + if 'context was destroyed' in error_msg.lower() or 'navigation' in error_msg.lower(): + print(f"检测到页面跳转: {error_msg}", file=sys.stderr) + page_destroyed = True + break print(f"检测上传状态异常: {e}", file=sys.stderr) - pass + # 连续异常可能说明页面有问题,等待更长时间 + if i > 10: # 5秒后还在异常 + await asyncio.sleep(1) + + # 如果页面被销毁,尝试等待重定向完成 + if page_destroyed: + print("⚠️ 页面发生跳转,检查当前URL...", file=sys.stderr) + await asyncio.sleep(3) + + # 检查跳转后的URL + current_url = self.page.url + print(f"跳转后的URL: {current_url}", file=sys.stderr) + + # 如果跳转到登录页,说明Cookie失效 + if 'login' in current_url: + # 清理临时文件 + for temp_file in downloaded_files: + try: + os.remove(temp_file) + except Exception: + pass + return { + "success": False, + "error": "Cookie已失效,上传过程中跳转到登录页", + "error_type": "cookie_expired" + } + + # 如果仍然在发布页,重新检查图片 + if 'publish/publish' in current_url: + print("✅ 仍在发布页,重新检查图片...", file=sys.stderr) + try: + uploaded_images = await self.page.query_selector_all('img[src*="blob:"], img[src*="data:image"], [class*="image"][class*="item"] img') + uploaded_count = len(uploaded_images) + if uploaded_count >= images_count: + print(f"✅ 页面稳定后确认图片已上传!共 {uploaded_count} 张", file=sys.stderr) + upload_success = True + else: + print(f"⚠️ 页面稳定后检测到 {uploaded_count}/{images_count} 张图片", file=sys.stderr) + except Exception as e: + print(f"页面稳定后检测失败: {e}", file=sys.stderr) + else: + # 跳转到其他页面 + # 清理临时文件 + for temp_file in downloaded_files: + try: + os.remove(temp_file) + except Exception: + pass + return { + "success": False, + "error": f"上传过程中页面跳转到: {current_url}" + } if upload_success: print(f"✅ 图片上传成功!共 {uploaded_count} 张", file=sys.stderr) - await asyncio.sleep(2) # 额外等待2秒确保完全上传 + await asyncio.sleep(0.5) # 优化:从2秒减少到0.5秒 + + # 清理下载的临时文件 + for temp_file in downloaded_files: + try: + os.remove(temp_file) + print(f"✅ 已清理临时文件: {temp_file}", file=sys.stderr) + except Exception: + pass else: print(f"⚠️ 仅检测到 {uploaded_count}/{images_count} 张图片,但继续执行...", file=sys.stderr) else: @@ -995,40 +1881,83 @@ class XHSLoginService: # 点击后再次查找file input file_input = await self.page.wait_for_selector('input[type="file"]', timeout=2000) if file_input: - images_count = len(images) - print(f"正在上传 {images_count} 张图片: {images}", file=sys.stderr) - await file_input.set_input_files(images) + images_count = len(local_images) + print(f"正在上传 {images_count} 张本地图片: {local_images}", file=sys.stderr) + await file_input.set_input_files(local_images) print(f"已设置文件路径,等待上传...", file=sys.stderr) - # 等待所有图片上传完成 + # 等待一下让页面处理文件 + await asyncio.sleep(1) + + # 优化:更快的图片上传检测 upload_success = False uploaded_count = 0 + page_destroyed = False - for i in range(20): - await asyncio.sleep(1) + for i in range(60): # 最多30秒 + await asyncio.sleep(0.5) # 优化:500ms间隔 try: + # 检查页面是否还有效 + if self.page.is_closed(): + print("检测到页面已关闭", file=sys.stderr) + page_destroyed = True + break + uploaded_images = await self.page.query_selector_all('img[src*="blob:"]') if not uploaded_images: uploaded_images = await self.page.query_selector_all('[class*="image"][class*="item"] img') + if not uploaded_images: + uploaded_images = await self.page.query_selector_all('.image-item img, .upload-item img, .pic-item img') + if not uploaded_images: + uploaded_images = await self.page.query_selector_all('img[src*="data:image"]') uploaded_count = len(uploaded_images) if uploaded_count > 0: - print(f"✅ 已上传 {uploaded_count}/{images_count} 张图片", file=sys.stderr) - if uploaded_count >= images_count: print(f"✅ 所有图片上传完成!共 {uploaded_count} 张", file=sys.stderr) upload_success = True break - - print(f"等待图片上传... {uploaded_count}/{images_count} ({i+1}/20秒)", file=sys.stderr) + + # 每秒打印一次进度 + if i % 2 == 0: + print(f"等待图片上传... {uploaded_count}/{images_count} ({(i+1)*0.5:.1f}/30秒)", file=sys.stderr) except Exception as e: + error_msg = str(e) + if 'context was destroyed' in error_msg.lower() or 'navigation' in error_msg.lower(): + print(f"检测到页面跳转: {error_msg}", file=sys.stderr) + page_destroyed = True + break print(f"检测上传状态异常: {e}", file=sys.stderr) - pass + if i > 10: + await asyncio.sleep(1) + + # 如果页面被销毁,尝试等待重定向完成 + if page_destroyed: + print("⚠️ 页面发生跳转,等待页面稳定...", file=sys.stderr) + await asyncio.sleep(3) + try: + uploaded_images = await self.page.query_selector_all('img[src*="blob:"], img[src*="data:image"], [class*="image"][class*="item"] img') + uploaded_count = len(uploaded_images) + if uploaded_count >= images_count: + print(f"✅ 页面稳定后确认图片已上传!共 {uploaded_count} 张", file=sys.stderr) + upload_success = True + else: + print(f"⚠️ 页面稳定后检测到 {uploaded_count}/{images_count} 张图片", file=sys.stderr) + except Exception as e: + print(f"页面稳定后检测失败: {e}", file=sys.stderr) if upload_success: print(f"✅ 图片上传成功!共 {uploaded_count} 张", file=sys.stderr) - await asyncio.sleep(2) + await asyncio.sleep(0.5) # 优化:0.5秒 + + # 清理下载的临时文件 + for temp_file in downloaded_files: + try: + os.remove(temp_file) + print(f"✅ 已清理临时文件: {temp_file}", file=sys.stderr) + except Exception: + pass else: print(f"⚠️ 仅检测到 {uploaded_count}/{images_count} 张图片,但继续执行...", file=sys.stderr) @@ -1058,23 +1987,38 @@ class XHSLoginService: ] title_input = None + # 优化:先用快速query_selector查找 for selector in title_selectors: try: - # 等待元素可见且可编辑 - title_input = await self.page.wait_for_selector( - selector, - state='visible', # 确保元素可见 - timeout=5000 # 增加超时时间 - ) + title_input = await self.page.query_selector(selector) if title_input: - # 确保元素可交互(等待一小段时间让JS初始化完成) - await asyncio.sleep(0.5) - print(f"找到标题输入框: {selector}", file=sys.stderr) - break - except Exception as e: - print(f"选择器 {selector} 未找到: {str(e)}", file=sys.stderr) + # 检查元素是否可见 + is_visible = await title_input.is_visible() + if is_visible: + await asyncio.sleep(0.2) # 优化:减少等待时间 + print(f"找到标题输入框: {selector}", file=sys.stderr) + break + else: + title_input = None + except Exception: continue + # 如果快速查找失败,再用wait方式 + if not title_input: + for selector in title_selectors: + try: + title_input = await self.page.wait_for_selector( + selector, + state='visible', + timeout=3000 # 优化:减少超时时间 + ) + if title_input: + await asyncio.sleep(0.2) + print(f"找到标题输入框: {selector}", file=sys.stderr) + break + except Exception: + continue + if title_input: await title_input.click() await asyncio.sleep(0.3) @@ -1097,27 +2041,41 @@ class XHSLoginService: ] content_input = None + # 优化:先用快速query_selector查找 for selector in content_selectors: try: - # 等待元素可见且可编辑 - content_input = await self.page.wait_for_selector( - selector, - state='visible', # 确保元素可见 - timeout=5000 # 增加超时时间 - ) + content_input = await self.page.query_selector(selector) if content_input: - # 确保元素可交互 - await asyncio.sleep(0.5) - print(f"找到内容输入框: {selector}", file=sys.stderr) - break - except Exception as e: - print(f"选择器 {selector} 未找到: {str(e)}", file=sys.stderr) + is_visible = await content_input.is_visible() + if is_visible: + await asyncio.sleep(0.2) # 优化:减少等待时间 + print(f"找到内容输入框: {selector}", file=sys.stderr) + break + else: + content_input = None + except Exception: continue + # 如果快速查找失败,再用wait方式 + if not content_input: + for selector in content_selectors: + try: + content_input = await self.page.wait_for_selector( + selector, + state='visible', + timeout=3000 # 优化:减少超时时间 + ) + if content_input: + await asyncio.sleep(0.2) + print(f"找到内容输入框: {selector}", file=sys.stderr) + break + except Exception: + continue + if content_input: # 清空并输入内容 await content_input.click() - await asyncio.sleep(0.5) + await asyncio.sleep(0.2) # 优化:减少等待时间 # 检查是否是contenteditable元素 try: @@ -1133,7 +2091,7 @@ class XHSLoginService: await content_input.fill(content) print("已输入笔记内容", file=sys.stderr) - await asyncio.sleep(0.5) + await asyncio.sleep(0.2) # 优化:减少等待时间 # 添加话题标签 if topics: @@ -1153,7 +2111,7 @@ class XHSLoginService: pass print(f"已添加 {len(topics)} 个话题标签", file=sys.stderr) - await asyncio.sleep(1) + await asyncio.sleep(0.5) # 优化:减少等待时间 # 单独在话题输入框中模拟人类方式输入标签 if topics: @@ -1166,26 +2124,40 @@ class XHSLoginService: '[class*="topic"] input', ] tag_input = None + # 优化:先用query_selector快速查找 for selector in tag_input_selectors: try: - tag_input = await self.page.wait_for_selector(selector, timeout=3000) + tag_input = await self.page.query_selector(selector) if tag_input: print(f"找到话题输入框: {selector}", file=sys.stderr) break except Exception: continue + + # 快速查找失败再用wait + if not tag_input: + for selector in tag_input_selectors: + try: + tag_input = await self.page.wait_for_selector(selector, timeout=2000) + if tag_input: + print(f"找到话题输入框: {selector}", file=sys.stderr) + break + except Exception: + continue + if tag_input: for topic in topics: try: await tag_input.click() - await asyncio.sleep(0.3) + await asyncio.sleep(0.2) # 优化:减少等待时间 # 清空已有内容 try: await tag_input.fill("") except Exception: pass - await tag_input.type("#" + topic, delay=50) - await asyncio.sleep(0.8) + # 优化:使用fill代替type,更快 + await tag_input.fill("#" + topic) + await asyncio.sleep(0.5) # 优化:减少等待时间 # 等待联想列表并选择第一项 suggestion = None suggestion_selectors = [ @@ -1207,7 +2179,7 @@ class XHSLoginService: # 没有联想列表时,通过回车确认 await tag_input.press("Enter") print(f"✅ 未找到联想列表,使用回车确认话题: {topic}", file=sys.stderr) - await asyncio.sleep(0.5) + await asyncio.sleep(0.3) # 优化:减少等待时间 except Exception as e: print(f"添加话题 {topic} 到输入框失败: {str(e)}", file=sys.stderr) else: @@ -1337,6 +2309,17 @@ class XHSLoginService: # 如果捕获到了真实的笔记链接,直接返回 if share_link: print(f"✅ 发布成功,获取到笔记链接: {share_link}", file=sys.stderr) + + # 如果是浏览器池模式且使用了Cookie,关闭发布专用页面 + if self.use_pool and self.browser_pool and cookies: + try: + print("[浏览器池模式] 关闭发布专用页面", file=sys.stderr) + await self.page.close() + self.page = None + print("✅ 发布页面已关闭", file=sys.stderr) + except Exception as e: + print(f"⚠️ 关闭页面失败: {str(e)}", file=sys.stderr) + return { "success": True, "message": "笔记发布成功", @@ -1381,6 +2364,21 @@ class XHSLoginService: print(f"发布后URL: {current_url}", file=sys.stderr) + # 如果是浏览器池模式且使用了Cookie,关闭发布专用页面和context + if self.use_pool and self.browser_pool and cookies: + try: + print("[浏览器池模式] 关闭发布专用环境", file=sys.stderr) + if self.page: + await self.page.close() + self.page = None + print("✅ 发布页面已关闭", file=sys.stderr) + if self.context: + await self.context.close() + self.context = None + print("✅ 发布context已关闭(预热环境保持不受影响)", file=sys.stderr) + except Exception as e: + print(f"⚠️ 关闭发布环境失败: {str(e)}", file=sys.stderr) + return { "success": True, "message": "笔记发布成功", @@ -1388,11 +2386,27 @@ class XHSLoginService: } except Exception as e: print(f"检查发布结果异常: {str(e)}", file=sys.stderr) + + # 如果是浏览器池模式且使用了Cookie,关闭发布专用页面和context + if self.use_pool and self.browser_pool and cookies: + try: + print("[浏览器池模式] 关闭发布专用环境", file=sys.stderr) + if self.page: + await self.page.close() + self.page = None + print("✅ 发布页面已关闭", file=sys.stderr) + if self.context: + await self.context.close() + self.context = None + print("✅ 发布context已关闭(预热环境保持不受影响)", file=sys.stderr) + except Exception as e2: + print(f"⚠️ 关闭发布环境失败: {str(e2)}", file=sys.stderr) + # 即使检查异常,也返回成功(因为按钮已点击) return { "success": True, "message": "笔记已提交发布,但未能确认结果", - "url": self.page.url + "url": self.page.url if self.page else "" } else: return { diff --git a/backend/xhs_publish.py b/backend/xhs_publish.py index 95ca6ce..f0ab278 100644 --- a/backend/xhs_publish.py +++ b/backend/xhs_publish.py @@ -181,9 +181,28 @@ class XHSPublishService: local_images = [] + # OSS域名前缀(用于补充不完整的图片路径) + oss_prefix = "https://bxmkb-beijing.oss-cn-beijing.aliyuncs.com/Images/" + print(f"\n正在处理 {len(images)} 张图片...", file=sys.stderr) for i, img in enumerate(images): + # 检查是否需要补充OSS前缀 + original_img = img + print(f" [调试] 处理图片 {i+1}: '{img}'", file=sys.stderr) + print(f" [调试] is_url={self.is_url(img)}, isabs={os.path.isabs(img)}", file=sys.stderr) + + if not self.is_url(img) and not os.path.isabs(img): + # 不是URL也不是绝对路径,检查是否需要补充OSS前缀 + print(f" [调试] 不是URL也不是绝对路径", file=sys.stderr) + # 如果路径不包含协议且不以/开头,可能是相对OSS路径 + if '/' in img and not img.startswith('/'): + # 可能是OSS相对路径,补充前缀 + img = oss_prefix + img + print(f" ✅ 检测到相对路径,补充OSS前缀: {original_img} -> {img}", file=sys.stderr) + else: + print(f" [调试] 不满足补充条件: '/' in img={('/' in img)}, not startswith('/')={not img.startswith('/')}", file=sys.stderr) + if self.is_url(img): # 网络URL,需要下载 try: @@ -195,9 +214,25 @@ class XHSPublishService: continue else: # 本地路径 - if os.path.exists(img): - local_images.append(os.path.abspath(img)) - print(f" ✅ 本地图片 [{i + 1}]: {os.path.basename(img)}", file=sys.stderr) + # 先尝试直接使用,如果不存在则尝试相对路径 + abs_path = None + + # 1. 尝试作为绝对路径 + if os.path.isabs(img) and os.path.exists(img): + abs_path = img + # 2. 尝试相对于当前工作目录 + elif os.path.exists(img): + abs_path = os.path.abspath(img) + # 3. 尝试相对于 static 目录 + elif os.path.exists(os.path.join('static', img)): + abs_path = os.path.abspath(os.path.join('static', img)) + # 4. 尝试相对于 ../go_backend/static 目录 + elif os.path.exists(os.path.join('..', 'go_backend', 'static', img)): + abs_path = os.path.abspath(os.path.join('..', 'go_backend', 'static', img)) + + if abs_path: + local_images.append(abs_path) + print(f" ✅ 本地图片 [{i + 1}]: {os.path.basename(abs_path)} ({abs_path})", file=sys.stderr) else: print(f" ⚠️ 本地图片不存在: {img}", file=sys.stderr) diff --git a/figma_html_page/bind-account.html b/figma_html_page/bind-account.html new file mode 100644 index 0000000..ed4a07b --- /dev/null +++ b/figma_html_page/bind-account.html @@ -0,0 +1,118 @@ + + + + + + 绑定小红书账号 + + + + +
+
+ 9:41 +
+ + + + + + + + + + + + + +
+
+ + + +
+ + +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+ +
+
+ 手机号 + +86 + +
+ +
+ 验证码 + + +
+ + +
+
+ +
+
+ +
+
+
+ + + +
+
绑定成功
+
+
+ +
+
+
+
获取验证中
+
+
+ +
+
+
+ + + i + +
+
绑定失败,手机
号未注册账号
+
+
+ + + + diff --git a/figma_html_page/css/bind-account.css b/figma_html_page/css/bind-account.css new file mode 100644 index 0000000..34f36f0 --- /dev/null +++ b/figma_html_page/css/bind-account.css @@ -0,0 +1,56 @@ +.bind-content { + padding: 40px 24px; + display: flex; + flex-direction: column; + align-items: center; +} + +.xhs-logo { + width: 56px; + height: 56px; + margin-bottom: 24px; +} + +.xhs-logo svg { + width: 100%; + height: 100%; +} + +.page-title { + font-size: 22px; + font-weight: 600; + color: #333; + margin-bottom: 8px; +} + +.page-subtitle { + font-size: 14px; + color: #999; + margin-bottom: 40px; +} + +.bind-form { + width: 100%; +} + +.bind-form .input-row { + margin-bottom: 0; +} + +.red-btn { + width: 100%; + padding: 14px; + font-size: 17px; + font-weight: 500; + color: #fff; + background: #FF2442; + border: none; + border-radius: 8px; + cursor: pointer; + margin-top: 40px; + transition: opacity 0.2s; +} + +.red-btn:active { + opacity: 0.8; +} diff --git a/figma_html_page/css/common.css b/figma_html_page/css/common.css new file mode 100644 index 0000000..a801a88 --- /dev/null +++ b/figma_html_page/css/common.css @@ -0,0 +1,280 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + background-color: #fff; + -webkit-font-smoothing: antialiased; + font-size: 14px; + color: #333; +} + +.container { + max-width: 375px; + margin: 0 auto; + background-color: #fff; + min-height: 100vh; + position: relative; + overflow-x: hidden; +} + +.status-bar { + height: 44px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 15px; + font-weight: 600; +} + +.status-bar .time { + font-weight: 600; +} + +.status-bar .icons { + display: flex; + align-items: center; + gap: 4px; +} + +.nav-bar { + height: 44px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.nav-bar .back-btn { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.nav-bar .back-btn svg { + width: 10px; + height: 18px; + stroke: #000; + stroke-width: 2; + fill: none; +} + +.nav-bar .nav-icons { + display: flex; + align-items: center; + gap: 8px; +} + +.nav-bar .nav-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.green-btn { + background: #07C160; + color: #fff; + border: none; + border-radius: 8px; + padding: 14px 24px; + font-size: 17px; + font-weight: 500; + cursor: pointer; + width: 100%; + transition: opacity 0.2s; + text-align: center; +} + +.green-btn:active { + opacity: 0.8; +} + +.input-row { + display: flex; + align-items: center; + padding: 16px 0; + border-bottom: 1px solid #E5E5E5; +} + +.input-row .label { + width: 56px; + font-size: 15px; + color: #333; + flex-shrink: 0; +} + +.input-row .prefix { + font-size: 16px; + color: #333; + margin-right: 8px; +} + +.input-row input { + flex: 1; + border: none; + font-size: 16px; + outline: none; + background: transparent; +} + +.input-row input::placeholder { + color: #C0C0C0; +} + +.input-row .get-code { + font-size: 14px; + color: #333; + padding: 6px 12px; + border: 1px solid #E5E5E5; + border-radius: 4px; + background: #fff; + cursor: pointer; + white-space: nowrap; +} + +.toast-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + pointer-events: none; +} + +.toast { + background: rgba(76, 76, 76, 0.9); + border-radius: 12px; + padding: 24px 32px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + min-width: 136px; +} + +.toast-icon { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; +} + +.toast-icon svg { + width: 36px; + height: 36px; + stroke: #fff; + stroke-width: 2; + fill: none; +} + +.toast-icon.info svg { + width: 32px; + height: 32px; +} + +.toast-text { + color: #fff; + font-size: 14px; + text-align: center; + line-height: 1.4; +} + +.toast-loading { + width: 36px; + height: 36px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: none; + align-items: flex-end; + justify-content: center; + z-index: 1000; +} + +.modal-overlay.active { + display: flex; +} + +.modal { + background: #fff; + border-radius: 12px 12px 0 0; + width: 100%; + max-width: 375px; + padding: 24px 16px; + padding-bottom: 34px; +} + +.modal-title { + font-size: 17px; + font-weight: 600; + color: #333; + text-align: center; + margin-bottom: 12px; +} + +.modal-message { + font-size: 14px; + color: #666; + text-align: center; + margin-bottom: 24px; + line-height: 1.5; +} + +.modal-btn { + width: 100%; + padding: 14px; + font-size: 17px; + border: none; + background: transparent; + cursor: pointer; + border-top: 1px solid #E5E5E5; +} + +.modal-btn.danger { + color: #FA5151; +} + +.modal-btn.cancel { + color: #333; +} + +.bottom-indicator { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + width: 134px; + height: 5px; + background: #000; + border-radius: 3px; +} diff --git a/figma_html_page/css/generate-content.css b/figma_html_page/css/generate-content.css new file mode 100644 index 0000000..3e61546 --- /dev/null +++ b/figma_html_page/css/generate-content.css @@ -0,0 +1,109 @@ +.content { + padding: 0 16px; + padding-bottom: 100px; +} + +.image-gallery { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 8px 0 16px; + -webkit-overflow-scrolling: touch; +} + +.image-gallery::-webkit-scrollbar { + display: none; +} + +.image-item { + position: relative; + width: 80px; + height: 80px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; +} + +.image-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.image-item .delete-btn { + position: absolute; + top: 4px; + right: 4px; + cursor: pointer; + z-index: 1; +} + +.image-item.add-btn { + background: #F5F5F5; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.image-item.add-btn img { + width: 24px; + height: 24px; +} + +.article-section { + padding-top: 8px; +} + +.article-title { + font-size: 17px; + font-weight: 600; + color: #333; + margin-bottom: 16px; + line-height: 1.4; +} + +.article-content { + font-size: 15px; + color: #333; + line-height: 1.8; +} + +.article-content p { + margin-bottom: 12px; +} + +.article-content .section-title { + margin-top: 16px; + font-weight: 500; +} + +.bottom-actions { + position: fixed; + bottom: 34px; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 375px; + padding: 0 16px; + display: flex; + gap: 12px; + background: #fff; + padding-top: 12px; +} + +.btn-outline { + flex: 1; + padding: 14px; + font-size: 17px; + font-weight: 500; + color: #333; + background: #fff; + border: 1px solid #E5E5E5; + border-radius: 8px; + cursor: pointer; +} + +.bottom-actions .green-btn { + flex: 1; +} diff --git a/figma_html_page/css/login.css b/figma_html_page/css/login.css new file mode 100644 index 0000000..98752d6 --- /dev/null +++ b/figma_html_page/css/login.css @@ -0,0 +1,74 @@ +.login-content { + padding: 60px 24px; + display: flex; + flex-direction: column; + align-items: center; +} + +.app-title { + font-size: 28px; + font-weight: 600; + color: #333; + margin-bottom: 8px; +} + +.app-subtitle { + font-size: 14px; + color: #999; + margin-bottom: 40px; +} + +.logo-placeholder { + width: 140px; + height: 140px; + background: #E5E5E5; + border-radius: 8px; + margin-bottom: 80px; +} + +.login-buttons { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; +} + +.wechat-btn { + width: 100%; + padding: 14px; + font-size: 17px; + font-weight: 500; + color: #fff; + background: #07C160; + border: none; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.wechat-btn:active { + opacity: 0.8; +} + +.phone-btn { + width: 100%; + padding: 14px; + font-size: 17px; + font-weight: 500; + color: #333; + background: #fff; + border: 1px solid #E5E5E5; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.phone-btn:active { + background: #F5F5F5; +} diff --git a/figma_html_page/css/phone-login.css b/figma_html_page/css/phone-login.css new file mode 100644 index 0000000..6093a46 --- /dev/null +++ b/figma_html_page/css/phone-login.css @@ -0,0 +1,19 @@ +.login-form { + padding: 20px 24px; +} + +.page-title { + font-size: 22px; + font-weight: 600; + color: #333; + text-align: center; + margin-bottom: 40px; +} + +.login-form .input-row { + margin-bottom: 0; +} + +.login-form .green-btn { + margin-top: 40px; +} diff --git a/figma_html_page/css/profile.css b/figma_html_page/css/profile.css new file mode 100644 index 0000000..72891f3 --- /dev/null +++ b/figma_html_page/css/profile.css @@ -0,0 +1,117 @@ +.profile-content { + padding: 16px; + padding-top: 24px; +} + +.user-info { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} + +.user-info .avatar { + width: 48px; + height: 48px; + border-radius: 50%; + background: #F5F5F5; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.user-info .avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.user-info .user-name { + font-size: 18px; + font-weight: 600; + color: #333; +} + +.user-info .user-company { + font-size: 13px; + color: #999; + margin-top: 4px; +} + +.account-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; + border-bottom: 1px solid #F0F0F0; + cursor: pointer; + margin-bottom: 24px; +} + +.row-label { + font-size: 16px; + color: #333; +} + +.row-right { + display: flex; + align-items: center; + gap: 8px; +} + +.row-value { + font-size: 15px; + color: #999; +} + +.section-title { + font-size: 14px; + color: #999; + margin-bottom: 16px; +} + +.empty-records { + display: flex; + align-items: center; + justify-content: center; + height: 300px; + color: #999; + font-size: 15px; +} + +.records-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.record-card { + border-radius: 8px; + overflow: hidden; +} + +.record-image { + width: 100%; + aspect-ratio: 1; + background: #F5F5F5; +} + +.record-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.record-title { + font-size: 13px; + color: #333; + line-height: 1.4; + margin-top: 8px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; +} diff --git a/figma_html_page/css/select-product.css b/figma_html_page/css/select-product.css new file mode 100644 index 0000000..83b6dea --- /dev/null +++ b/figma_html_page/css/select-product.css @@ -0,0 +1,115 @@ +.page-title { + font-size: 17px; + font-weight: 500; + color: #333; + text-align: center; + padding: 24px 0 20px; +} + +.product-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + padding: 0 16px; +} + +.product-card { + background: #fff; + border-radius: 8px; + border: 1px solid #E5E5E5; + padding: 12px; + padding-bottom: 16px; + position: relative; + cursor: pointer; + transition: all 0.2s; +} + +.product-card.selected { + border-color: #07C160; + border-width: 2px; +} + +.product-card .checkbox { + position: absolute; + top: 12px; + right: 12px; + width: 20px; + height: 20px; +} + +.product-card .checkbox img { + width: 20px; + height: 20px; + position: absolute; + top: 0; + left: 0; +} + +.product-card .checkbox .checked { + display: none; +} + +.product-card.selected .checkbox .unchecked { + display: none; +} + +.product-card.selected .checkbox .checked { + display: block; +} + +.product-image { + width: 100%; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 12px; +} + +.product-image img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.product-name { + font-size: 14px; + color: #333; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-avatar { + position: fixed; + right: 16px; + bottom: 100px; + width: 44px; + height: 44px; + border-radius: 50%; + overflow: hidden; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: #fff; +} + +.user-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.bottom-section { + position: fixed; + bottom: 34px; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 375px; + padding: 0 16px; +} + +.bottom-section .green-btn { + border-radius: 8px; +} diff --git a/figma_html_page/generate-content.html b/figma_html_page/generate-content.html new file mode 100644 index 0000000..e619b5b --- /dev/null +++ b/figma_html_page/generate-content.html @@ -0,0 +1,130 @@ + + + + + + 生成内容 + + + + +
+
+ 9:41 +
+ + + + + + + + + + + + + +
+
+ + + +
+ + +
+

天冷必备!999感冒灵真的救了我

+ +
+

最近这个天气真是绝了😭 昨天还穿短袖 今天直接降温10度!办公室一半人都在擤鼻涕…还好我常年备着 999感冒灵!真·家中神药!!

+ +

🍊 亲测好用点:

+

✅ 冲剂暖暖的超好喝!感冒初期喝一包 全身都舒服了~

+

✅ 中西医结合配方 退烧+缓解鼻塞头疼都很可

+

✅ 最关键的是不会犯困!打工人白天也能安心吃!

+

(晚上睡前喝还能睡更香hhh)

+ +

🌟 我的保命用法:

+

感觉喉咙有点痒/打喷嚏👉立刻冲一包预防!

+

已经中招的👉早晚各一包+疯狂喝水

+

搭配VC泡腾片效果加倍✨

+ +

🛒 购买小贴士:

+

药店/某猫都有 建议家里&办公室各囤一盒!(尤其换季期!)真实分享!但凡有一个人不知道这个宝藏我都会伤心OK?! #秋冬养生 #感冒灵 #家庭常备药 #换季保命哦对了,要保持原来那种"对闺蜜分享好物"的语气,不能变得太正式。加的内容要自然融入原来的段落里,不能显得突兀。"中西医结合"这个点可以展开说说,虽然不能太专业,但可以提...

+
+
+
+ +
+ + +
+ +
+
+ + + + diff --git a/figma_html_page/images/Checkbox selected-1.png b/figma_html_page/images/Checkbox selected-1.png new file mode 100644 index 0000000..e817e15 Binary files /dev/null and b/figma_html_page/images/Checkbox selected-1.png differ diff --git a/figma_html_page/images/Checkbox selected-1.svg b/figma_html_page/images/Checkbox selected-1.svg new file mode 100644 index 0000000..4fab7eb --- /dev/null +++ b/figma_html_page/images/Checkbox selected-1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/figma_html_page/images/Checkbox selected-2.png b/figma_html_page/images/Checkbox selected-2.png new file mode 100644 index 0000000..a71de9f Binary files /dev/null and b/figma_html_page/images/Checkbox selected-2.png differ diff --git a/figma_html_page/images/Checkbox selected-3.png b/figma_html_page/images/Checkbox selected-3.png new file mode 100644 index 0000000..e817e15 Binary files /dev/null and b/figma_html_page/images/Checkbox selected-3.png differ diff --git a/figma_html_page/images/Checkbox selected.png b/figma_html_page/images/Checkbox selected.png new file mode 100644 index 0000000..a71de9f Binary files /dev/null and b/figma_html_page/images/Checkbox selected.png differ diff --git a/figma_html_page/images/Checkbox selected.svg b/figma_html_page/images/Checkbox selected.svg new file mode 100644 index 0000000..7861aa8 --- /dev/null +++ b/figma_html_page/images/Checkbox selected.svg @@ -0,0 +1,4 @@ + + + + diff --git a/figma_html_page/images/Checkbox-1.png b/figma_html_page/images/Checkbox-1.png new file mode 100644 index 0000000..5d014f0 Binary files /dev/null and b/figma_html_page/images/Checkbox-1.png differ diff --git a/figma_html_page/images/Checkbox-1.svg b/figma_html_page/images/Checkbox-1.svg new file mode 100644 index 0000000..b057e9c --- /dev/null +++ b/figma_html_page/images/Checkbox-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/figma_html_page/images/Checkbox-2.png b/figma_html_page/images/Checkbox-2.png new file mode 100644 index 0000000..4339bf7 Binary files /dev/null and b/figma_html_page/images/Checkbox-2.png differ diff --git a/figma_html_page/images/Checkbox-3.png b/figma_html_page/images/Checkbox-3.png new file mode 100644 index 0000000..5d014f0 Binary files /dev/null and b/figma_html_page/images/Checkbox-3.png differ diff --git a/figma_html_page/images/Checkbox.png b/figma_html_page/images/Checkbox.png new file mode 100644 index 0000000..4339bf7 Binary files /dev/null and b/figma_html_page/images/Checkbox.png differ diff --git a/figma_html_page/images/Checkbox.svg b/figma_html_page/images/Checkbox.svg new file mode 100644 index 0000000..b057e9c --- /dev/null +++ b/figma_html_page/images/Checkbox.svg @@ -0,0 +1,3 @@ + + + diff --git a/figma_html_page/images/plus-1.png b/figma_html_page/images/plus-1.png new file mode 100644 index 0000000..154df3b Binary files /dev/null and b/figma_html_page/images/plus-1.png differ diff --git a/figma_html_page/images/plus-1.svg b/figma_html_page/images/plus-1.svg new file mode 100644 index 0000000..14ec82f --- /dev/null +++ b/figma_html_page/images/plus-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/figma_html_page/images/plus-2.png b/figma_html_page/images/plus-2.png new file mode 100644 index 0000000..b22beae Binary files /dev/null and b/figma_html_page/images/plus-2.png differ diff --git a/figma_html_page/images/plus-3.png b/figma_html_page/images/plus-3.png new file mode 100644 index 0000000..154df3b Binary files /dev/null and b/figma_html_page/images/plus-3.png differ diff --git a/figma_html_page/images/plus.png b/figma_html_page/images/plus.png new file mode 100644 index 0000000..b22beae Binary files /dev/null and b/figma_html_page/images/plus.png differ diff --git a/figma_html_page/images/plus.svg b/figma_html_page/images/plus.svg new file mode 100644 index 0000000..14ec82f --- /dev/null +++ b/figma_html_page/images/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/figma_html_page/index.html b/figma_html_page/index.html new file mode 100644 index 0000000..2b867d2 --- /dev/null +++ b/figma_html_page/index.html @@ -0,0 +1,76 @@ + + + + + + 万花筒 - AI种草内容生成 + + + + +
+

万花筒

+

AI种草内容生成工具
根据设计稿100%还原的HTML页面

+ +
+

主要页面

+ 选择商品 + 生成内容 + +

登录相关

+ 万花筒登录 + 手机号登录 + +

个人中心

+ 个人中心 + 绑定小红书账号 +
+
+ + diff --git a/figma_html_page/js/bind-account.js b/figma_html_page/js/bind-account.js new file mode 100644 index 0000000..15674d3 --- /dev/null +++ b/figma_html_page/js/bind-account.js @@ -0,0 +1,77 @@ +let countdown = 0; +let timer = null; + +function checkInputs() { + const phone = document.getElementById('phoneInput').value; + const code = document.getElementById('codeInput').value; + const btn = document.getElementById('bindBtn'); + + if (phone.length === 11 && code.length >= 4) { + btn.style.opacity = '1'; + } else { + btn.style.opacity = '0.6'; + } +} + +function getCode() { + const phone = document.getElementById('phoneInput').value; + if (phone.length !== 11) { + alert('请输入正确的手机号'); + return; + } + + if (countdown > 0) return; + + const loadingOverlay = document.getElementById('loadingOverlay'); + loadingOverlay.style.display = 'flex'; + + setTimeout(() => { + loadingOverlay.style.display = 'none'; + startCountdown(); + }, 1500); +} + +function startCountdown() { + countdown = 60; + const btn = document.getElementById('getCodeBtn'); + + timer = setInterval(() => { + countdown--; + if (countdown <= 0) { + clearInterval(timer); + btn.textContent = '获取验证码'; + btn.style.color = '#333'; + } else { + btn.textContent = `${countdown}s`; + btn.style.color = '#999'; + } + }, 1000); +} + +function doBind() { + const phone = document.getElementById('phoneInput').value; + const code = document.getElementById('codeInput').value; + + if (phone.length !== 11) { + alert('请输入正确的手机号'); + return; + } + + if (code.length < 4) { + alert('请输入验证码'); + return; + } + + const toastOverlay = document.getElementById('toastOverlay'); + toastOverlay.style.display = 'flex'; + + setTimeout(() => { + toastOverlay.style.display = 'none'; + localStorage.setItem('isBound', 'true'); + window.location.href = 'profile.html'; + }, 1500); +} + +document.addEventListener('DOMContentLoaded', function() { + document.getElementById('bindBtn').style.opacity = '0.6'; +}); diff --git a/figma_html_page/js/generate-content.js b/figma_html_page/js/generate-content.js new file mode 100644 index 0000000..a58def9 --- /dev/null +++ b/figma_html_page/js/generate-content.js @@ -0,0 +1,12 @@ +function regenerate() { + alert('正在重新生成内容...'); +} + +function publish() { + const isLoggedIn = localStorage.getItem('isLoggedIn'); + if (!isLoggedIn) { + window.location.href = 'login.html'; + } else { + alert('发布成功!'); + } +} diff --git a/figma_html_page/js/login.js b/figma_html_page/js/login.js new file mode 100644 index 0000000..6de84d5 --- /dev/null +++ b/figma_html_page/js/login.js @@ -0,0 +1,10 @@ +function wechatLogin() { + localStorage.setItem('isLoggedIn', 'true'); + localStorage.setItem('userName', '星阿星'); + localStorage.setItem('userCompany', '北京乐航时代科技有限公司'); + window.location.href = 'select-product.html'; +} + +function goPhoneLogin() { + window.location.href = 'phone-login.html'; +} diff --git a/figma_html_page/js/phone-login.js b/figma_html_page/js/phone-login.js new file mode 100644 index 0000000..74a80f1 --- /dev/null +++ b/figma_html_page/js/phone-login.js @@ -0,0 +1,79 @@ +let countdown = 0; +let timer = null; + +function checkInputs() { + const phone = document.getElementById('phoneInput').value; + const code = document.getElementById('codeInput').value; + const btn = document.getElementById('loginBtn'); + + if (phone.length === 11 && code.length >= 4) { + btn.style.opacity = '1'; + } else { + btn.style.opacity = '0.6'; + } +} + +function getCode() { + const phone = document.getElementById('phoneInput').value; + if (phone.length !== 11) { + alert('请输入正确的手机号'); + return; + } + + if (countdown > 0) return; + + const loadingOverlay = document.getElementById('loadingOverlay'); + loadingOverlay.style.display = 'flex'; + + setTimeout(() => { + loadingOverlay.style.display = 'none'; + startCountdown(); + }, 1500); +} + +function startCountdown() { + countdown = 60; + const btn = document.getElementById('getCodeBtn'); + + timer = setInterval(() => { + countdown--; + if (countdown <= 0) { + clearInterval(timer); + btn.textContent = '获取验证码'; + btn.style.color = '#333'; + } else { + btn.textContent = `${countdown}s`; + btn.style.color = '#999'; + } + }, 1000); +} + +function doLogin() { + const phone = document.getElementById('phoneInput').value; + const code = document.getElementById('codeInput').value; + + if (phone.length !== 11) { + alert('请输入正确的手机号'); + return; + } + + if (code.length < 4) { + alert('请输入验证码'); + return; + } + + const toastOverlay = document.getElementById('toastOverlay'); + toastOverlay.style.display = 'flex'; + + setTimeout(() => { + toastOverlay.style.display = 'none'; + localStorage.setItem('isLoggedIn', 'true'); + localStorage.setItem('userName', '星阿星'); + localStorage.setItem('userCompany', '北京乐航时代科技有限公司'); + window.location.href = 'select-product.html'; + }, 1500); +} + +document.addEventListener('DOMContentLoaded', function() { + document.getElementById('loginBtn').style.opacity = '0.6'; +}); diff --git a/figma_html_page/js/profile.js b/figma_html_page/js/profile.js new file mode 100644 index 0000000..2a40943 --- /dev/null +++ b/figma_html_page/js/profile.js @@ -0,0 +1,62 @@ +document.addEventListener('DOMContentLoaded', function() { + loadUserInfo(); +}); + +function loadUserInfo() { + const isLoggedIn = localStorage.getItem('isLoggedIn'); + const userName = localStorage.getItem('userName'); + const userCompany = localStorage.getItem('userCompany'); + const isBound = localStorage.getItem('isBound'); + + const userAvatar = document.getElementById('userAvatar'); + const userNameEl = document.getElementById('userName'); + const accountStatus = document.getElementById('accountStatus'); + const emptyRecords = document.getElementById('emptyRecords'); + const recordsGrid = document.getElementById('recordsGrid'); + + if (isLoggedIn && userName) { + userAvatar.innerHTML = ''; + + const userInfo = document.getElementById('userInfo'); + userInfo.innerHTML = ` +
+ +
+
+
${userName}
+
${userCompany || ''}
+
+ `; + + if (isBound) { + accountStatus.textContent = userName; + accountStatus.style.color = '#333'; + emptyRecords.style.display = 'none'; + recordsGrid.style.display = 'grid'; + } + } +} + +function handleAccountClick() { + const isBound = localStorage.getItem('isBound'); + + if (isBound) { + document.getElementById('unbindModal').classList.add('active'); + } else { + window.location.href = 'bind-account.html'; + } +} + +function closeUnbindModal() { + document.getElementById('unbindModal').classList.remove('active'); +} + +function confirmUnbind() { + localStorage.removeItem('isBound'); + closeUnbindModal(); + + document.getElementById('accountStatus').textContent = '未绑定'; + document.getElementById('accountStatus').style.color = '#999'; + document.getElementById('emptyRecords').style.display = 'flex'; + document.getElementById('recordsGrid').style.display = 'none'; +} diff --git a/figma_html_page/js/select-product.js b/figma_html_page/js/select-product.js new file mode 100644 index 0000000..9cd560c --- /dev/null +++ b/figma_html_page/js/select-product.js @@ -0,0 +1,35 @@ +let selectedProducts = []; + +function toggleSelect(card) { + const productId = card.dataset.id; + + if (card.classList.contains('selected')) { + card.classList.remove('selected'); + selectedProducts = selectedProducts.filter(id => id !== productId); + } else { + card.classList.add('selected'); + selectedProducts.push(productId); + } +} + +function goGenerate() { + if (selectedProducts.length === 0) { + showToast(); + return; + } + localStorage.setItem('selectedProducts', JSON.stringify(selectedProducts)); + window.location.href = 'generate-content.html'; +} + +function goToProfile() { + window.location.href = 'profile.html'; +} + +function showToast() { + const overlay = document.getElementById('toastOverlay'); + overlay.style.display = 'flex'; + + setTimeout(() => { + overlay.style.display = 'none'; + }, 2000); +} diff --git a/figma_html_page/login.html b/figma_html_page/login.html new file mode 100644 index 0000000..d053559 --- /dev/null +++ b/figma_html_page/login.html @@ -0,0 +1,84 @@ + + + + + + 万花筒 + + + + +
+
+ 9:41 +
+ + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + diff --git a/figma_html_page/phone-login.html b/figma_html_page/phone-login.html new file mode 100644 index 0000000..f14e506 --- /dev/null +++ b/figma_html_page/phone-login.html @@ -0,0 +1,95 @@ + + + + + + 登录万花筒 + + + + +
+
+ 9:41 +
+ + + + + + + + + + + + + +
+
+ + + + + +
+
+ +
+
+
+ + + +
+
登录成功
+
+
+ +
+
+
+
获取验证中
+
+
+ + + + diff --git a/figma_html_page/profile.html b/figma_html_page/profile.html new file mode 100644 index 0000000..80667c4 --- /dev/null +++ b/figma_html_page/profile.html @@ -0,0 +1,123 @@ + + + + + + 个人中心 + + + + +
+
+ 9:41 +
+ + + + + + + + + + + + + +
+
+ + + +
+ + + + +
发布记录
+ +
+ 暂无记录 +
+ + +
+ +
+
+ + + + + + diff --git a/figma_html_page/select-product.html b/figma_html_page/select-product.html new file mode 100644 index 0000000..4816b81 --- /dev/null +++ b/figma_html_page/select-product.html @@ -0,0 +1,127 @@ + + + + + + 选择商品 + + + + +
+
+ 9:41 +
+ + + + + + + + + + + + + +
+
+ + + +
选择商品
+ +
+
+
+ + +
+
+ 感冒灵颗粒 +
+
感冒灵颗粒
+
+ +
+
+ + +
+
+ 电子血压仪 +
+
电子血压仪
+
+ +
+
+ + +
+
+ 小林退热贴 +
+
小林退热贴
+
+ +
+
+ + +
+
+ 芬必得布洛芬释... +
+
芬必得布洛芬释...
+
+
+ +
+ 用户 +
+ +
+ +
+ +
+
+ +
+
+
+ + + i + +
+
请先选择商品
+
+
+ + + + diff --git a/figma_html_page/toast状态.html b/figma_html_page/toast状态.html new file mode 100644 index 0000000..3bcebf1 --- /dev/null +++ b/figma_html_page/toast状态.html @@ -0,0 +1,128 @@ + + + + + + Toast状态 + + + + +
+
+
+
+
+
+ + + +
+
登录成功
+
+
成功
+
+ +
+
+
+ + + i + +
+
请先选择商品
+
+
提示具体问题
+
+ +
+
+
+
+
+
获取验证中
+
+
验证中
+
+
+ +
+
+
+
+ + + i + +
+
绑定失败,手机
号未注册账号
+
+
失败
+
+
+
+
+ + diff --git a/figma_html_page/万花筒登录-1.html b/figma_html_page/万花筒登录-1.html new file mode 100644 index 0000000..aa10377 --- /dev/null +++ b/figma_html_page/万花筒登录-1.html @@ -0,0 +1,40 @@ + + + + + + 万花筒 + + + + +
+
9:41
+ + +
+
+ + diff --git a/figma_html_page/万花筒登录.html b/figma_html_page/万花筒登录.html new file mode 100644 index 0000000..8b87503 --- /dev/null +++ b/figma_html_page/万花筒登录.html @@ -0,0 +1,53 @@ + + + + + + 万花筒 + + + + +
+
+ 9:41 +
+ + + +
+
+ + +
+
+ + diff --git a/figma_html_page/万花筒登录_通过微信登录.html b/figma_html_page/万花筒登录_通过微信登录.html new file mode 100644 index 0000000..0dc1863 --- /dev/null +++ b/figma_html_page/万花筒登录_通过微信登录.html @@ -0,0 +1,46 @@ + + + + + + 万花筒 + + + + +
+
9:41
+ + +
+
+
+
登录中
+
+
+
+
+ + diff --git a/figma_html_page/个人中心_已登录有记录-1.html b/figma_html_page/个人中心_已登录有记录-1.html new file mode 100644 index 0000000..621f884 --- /dev/null +++ b/figma_html_page/个人中心_已登录有记录-1.html @@ -0,0 +1,55 @@ + + + + + + 个人中心 + + + + +
+
9:41
+ +
+ + +
发布记录
+
+

双十一卡诗精油买一波,哪个最好用?...

+

卡诗精油怎么选?山茶花/黑钻/玻尿酸...

+
+
+
+
+ + diff --git a/figma_html_page/个人中心_已登录有记录.html b/figma_html_page/个人中心_已登录有记录.html new file mode 100644 index 0000000..a8adb53 --- /dev/null +++ b/figma_html_page/个人中心_已登录有记录.html @@ -0,0 +1,82 @@ + + + + + + 个人中心 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
+ + +
发布记录
+
+
+
+

双十一卡诗精油买一波,哪个最好用?...

+
+
+
+

卡诗精油怎么选?山茶花/黑钻/玻尿酸...

+
+
+
+

黑钻 VS 新版卡诗鎏金山茶花到底选哪个?

+
+
+
+

黑钻 VS 新版卡诗鎏金山茶花到底选哪个?

+
+
+
+
+
+ + diff --git a/figma_html_page/个人中心_未登录无记录-1.html b/figma_html_page/个人中心_未登录无记录-1.html new file mode 100644 index 0000000..897b707 --- /dev/null +++ b/figma_html_page/个人中心_未登录无记录-1.html @@ -0,0 +1,43 @@ + + + + + + 个人中心 + + + + +
+
9:41
+ +
+ + +
发布记录
+
暂无记录
+
+
+
+ + diff --git a/figma_html_page/个人中心_未登录无记录.html b/figma_html_page/个人中心_未登录无记录.html new file mode 100644 index 0000000..eff0194 --- /dev/null +++ b/figma_html_page/个人中心_未登录无记录.html @@ -0,0 +1,58 @@ + + + + + + 个人中心 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
+ + +
发布记录
+
暂无记录
+
+
+
+ + diff --git a/figma_html_page/个人中心_解绑二次确认-1.html b/figma_html_page/个人中心_解绑二次确认-1.html new file mode 100644 index 0000000..b6011d0 --- /dev/null +++ b/figma_html_page/个人中心_解绑二次确认-1.html @@ -0,0 +1,63 @@ + + + + + + 个人中心 + + + + +
+
9:41
+ +
+ + +
发布记录
+
+

双十一卡诗精油买一波

+

卡诗精油怎么选

+
+
+ +
+
+ + diff --git a/figma_html_page/个人中心_解绑二次确认.html b/figma_html_page/个人中心_解绑二次确认.html new file mode 100644 index 0000000..ee283a6 --- /dev/null +++ b/figma_html_page/个人中心_解绑二次确认.html @@ -0,0 +1,82 @@ + + + + + + 个人中心 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
+ + +
发布记录
+
+
+
+

双十一卡诗精油买一波,哪个最好用?...

+
+
+
+

卡诗精油怎么选?山茶花/黑钻/玻尿酸...

+
+
+
+ +
+
+ + diff --git a/figma_html_page/个人中心入口_已登录-1.html b/figma_html_page/个人中心入口_已登录-1.html new file mode 100644 index 0000000..9473baf --- /dev/null +++ b/figma_html_page/个人中心入口_已登录-1.html @@ -0,0 +1,37 @@ + + + + + + 选择商品 + + + + +
+
9:41
+ +
选择商品
+
+
感冒灵颗粒
+
电子血压仪
+
小林退热贴
+
芬必得布洛芬释...
+
+
+
+
+
+ + diff --git a/figma_html_page/个人中心入口_已登录.html b/figma_html_page/个人中心入口_已登录.html new file mode 100644 index 0000000..3f18ba3 --- /dev/null +++ b/figma_html_page/个人中心入口_已登录.html @@ -0,0 +1,68 @@ + + + + + + 选择商品 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
选择商品
+
+
+
+
+
感冒灵颗粒
+
+
+
+
+
电子血压仪
+
+
+
+
+
小林退热贴
+
+
+
+
+
芬必得布洛芬释...
+
+
+
+ +
+
+
+
+ + diff --git a/figma_html_page/个人中心入口_未登录.html b/figma_html_page/个人中心入口_未登录.html new file mode 100644 index 0000000..50e1564 --- /dev/null +++ b/figma_html_page/个人中心入口_未登录.html @@ -0,0 +1,67 @@ + + + + + + 选择商品 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
选择商品
+
+
+
+
+
感冒灵颗粒
+
+
+
+
+
电子血压仪
+
+
+
+
+
小林退热贴
+
+
+
+
+
芬必得布洛芬释...
+
+
+
+ +
+
+
+
+ + diff --git a/figma_html_page/商品数量为1.html b/figma_html_page/商品数量为1.html new file mode 100644 index 0000000..9a29fdb --- /dev/null +++ b/figma_html_page/商品数量为1.html @@ -0,0 +1,67 @@ + + + + + + 选择商品 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
选择商品
+
+
+
+
感冒灵颗粒
+
感冒灵颗粒
+
+
+
+
电子血压仪
+
电子血压仪
+
+
+
+
小林退热贴
+
小林退热贴
+
+
+
+
芬必得布洛芬释...
+
芬必得布洛芬释...
+
+
+
+
+
+
+ + diff --git a/figma_html_page/商品数量为2.html b/figma_html_page/商品数量为2.html new file mode 100644 index 0000000..81e2adf --- /dev/null +++ b/figma_html_page/商品数量为2.html @@ -0,0 +1,67 @@ + + + + + + 选择商品 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
选择商品
+
+
+
+
感冒灵颗粒
+
感冒灵颗粒
+
+
+
+
电子血压仪
+
电子血压仪
+
+
+
+
小林退热贴
+
小林退热贴
+
+
+
+
芬必得布洛芬释...
+
芬必得布洛芬释...
+
+
+
+
+
+
+ + diff --git a/figma_html_page/商品数量为3.html b/figma_html_page/商品数量为3.html new file mode 100644 index 0000000..bbfd40c --- /dev/null +++ b/figma_html_page/商品数量为3.html @@ -0,0 +1,67 @@ + + + + + + 选择商品 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
选择商品
+
+
+
+
感冒灵颗粒
+
感冒灵颗粒
+
+
+
+
电子血压仪
+
电子血压仪
+
+
+
+
小林退热贴
+
小林退热贴
+
+
+
+
芬必得布洛芬释...
+
芬必得布洛芬释...
+
+
+
+
+
+
+ + diff --git a/figma_html_page/商品数量为4.html b/figma_html_page/商品数量为4.html new file mode 100644 index 0000000..212594f --- /dev/null +++ b/figma_html_page/商品数量为4.html @@ -0,0 +1,67 @@ + + + + + + 选择商品 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
选择商品
+
+
+
+
感冒灵颗粒
+
感冒灵颗粒
+
+
+
+
电子血压仪
+
电子血压仪
+
+
+
+
小林退热贴
+
小林退热贴
+
+
+
+
芬必得布洛芬释...
+
芬必得布洛芬释...
+
+
+
+
+
+
+ + diff --git a/figma_html_page/回到选择商品页面,重新开始流程.html b/figma_html_page/回到选择商品页面,重新开始流程.html new file mode 100644 index 0000000..2ad4eeb --- /dev/null +++ b/figma_html_page/回到选择商品页面,重新开始流程.html @@ -0,0 +1,53 @@ + + + + + + 选择商品 + + + + +
+
9:41
+ +
选择商品
+
+
+
+
+
感冒灵颗粒
+
+
+
+
+
电子血压仪
+
+
+
+
+
小林退热贴
+
+
+
+
+
芬必得布洛芬释...
+
+
+
+
+
+
+ + diff --git a/figma_html_page/图文标注-1.html b/figma_html_page/图文标注-1.html new file mode 100644 index 0000000..6fd36b9 --- /dev/null +++ b/figma_html_page/图文标注-1.html @@ -0,0 +1,31 @@ + + + + + + 图文标注 + + + + +
+
9:41
+ +
+

图文标注说明

+
+
图片说明
+

选择商品后,系统会自动生成相关的种草内容。

+
+
+
+
+ + diff --git a/figma_html_page/图文标注.html b/figma_html_page/图文标注.html new file mode 100644 index 0000000..954fa4a --- /dev/null +++ b/figma_html_page/图文标注.html @@ -0,0 +1,39 @@ + + + + + + 图文标注 + + + + +
+
9:41
+ +
+

图文标注说明

+
+
图片说明
+
+

选择商品后,系统会自动生成相关的种草内容,包括标题和正文。

+
+
+
+
操作说明
+
+

点击"换一换"可以重新生成内容,点击"一键发布"可以将内容发布到小红书。

+
+
+
+
+
+ + diff --git a/figma_html_page/手机号登录_修改手机号.html b/figma_html_page/手机号登录_修改手机号.html new file mode 100644 index 0000000..21dd96e --- /dev/null +++ b/figma_html_page/手机号登录_修改手机号.html @@ -0,0 +1,49 @@ + + + + + + 登录万花筒 + + + + +
+
+ 9:41 +
+ + + +
+
+ + +
+
+ + diff --git a/figma_html_page/手机号登录_修改验证码.html b/figma_html_page/手机号登录_修改验证码.html new file mode 100644 index 0000000..0e77cbf --- /dev/null +++ b/figma_html_page/手机号登录_修改验证码.html @@ -0,0 +1,48 @@ + + + + + + 登录万花筒 + + + + +
+
+ 9:41 +
+ + + +
+
+ + +
+
+ + diff --git a/figma_html_page/手机号登录_已输入手机号.html b/figma_html_page/手机号登录_已输入手机号.html new file mode 100644 index 0000000..d824071 --- /dev/null +++ b/figma_html_page/手机号登录_已输入手机号.html @@ -0,0 +1,48 @@ + + + + + + 登录万花筒 + + + + +
+
+ 9:41 +
+ + + +
+
+ + +
+
+ + diff --git a/figma_html_page/手机号登录_已输入验证码.html b/figma_html_page/手机号登录_已输入验证码.html new file mode 100644 index 0000000..1db4078 --- /dev/null +++ b/figma_html_page/手机号登录_已输入验证码.html @@ -0,0 +1,48 @@ + + + + + + 登录万花筒 + + + + +
+
+ 9:41 +
+ + + +
+
+ + +
+
+ + diff --git a/figma_html_page/手机号登录_未输入手机号.html b/figma_html_page/手机号登录_未输入手机号.html new file mode 100644 index 0000000..df606c6 --- /dev/null +++ b/figma_html_page/手机号登录_未输入手机号.html @@ -0,0 +1,48 @@ + + + + + + 登录万花筒 + + + + +
+
+ 9:41 +
+ + + +
+
+ + +
+
+ + diff --git a/figma_html_page/手机号登录_登录成功.html b/figma_html_page/手机号登录_登录成功.html new file mode 100644 index 0000000..c979d89 --- /dev/null +++ b/figma_html_page/手机号登录_登录成功.html @@ -0,0 +1,56 @@ + + + + + + 登录万花筒 + + + + +
+
+ 9:41 +
+ + + +
+
+ + +
+
+
+ +
+
登录成功
+
+
+
+
+ + diff --git a/figma_html_page/手机号登录_获取验证码中.html b/figma_html_page/手机号登录_获取验证码中.html new file mode 100644 index 0000000..9fe2c49 --- /dev/null +++ b/figma_html_page/手机号登录_获取验证码中.html @@ -0,0 +1,54 @@ + + + + + + 登录万花筒 + + + + +
+
+ 9:41 +
+ + + +
+
+ + +
+
+
+
获取验证中
+
+
+
+
+ + diff --git a/figma_html_page/拉起绑定流程.html b/figma_html_page/拉起绑定流程.html new file mode 100644 index 0000000..8dc8d59 --- /dev/null +++ b/figma_html_page/拉起绑定流程.html @@ -0,0 +1,55 @@ + + + + + + 生成内容 + + + + +
+
9:41
+ +
+ +

天冷必备!999感冒灵真的救了我

+
+

最近这个天气真是绝了😭 昨天还穿短袖 今天直接降温10度!

+
+
+
+ + +
+
+
+
+ i +
+
请先绑定小红书
账号
+
+
+
+
+ + diff --git a/figma_html_page/未登录状态.html b/figma_html_page/未登录状态.html new file mode 100644 index 0000000..b1b8531 --- /dev/null +++ b/figma_html_page/未登录状态.html @@ -0,0 +1,52 @@ + + + + + + 选择商品 + + + + +
+
9:41
+ +
选择商品
+
+
+
+
+
感冒灵颗粒
+
+
+
+
+
电子血压仪
+
+
+
+
+
小林退热贴
+
+
+
+
+
芬必得布洛芬释...
+
+
+
+
+
+
+ + diff --git a/figma_html_page/未选择商品toast提示-1.html b/figma_html_page/未选择商品toast提示-1.html new file mode 100644 index 0000000..c454cbc --- /dev/null +++ b/figma_html_page/未选择商品toast提示-1.html @@ -0,0 +1,37 @@ + + + + + + 选择商品 + + + + +
+
9:41
+ +
选择商品
+
+
感冒灵颗粒
+
电子血压仪
+
小林退热贴
+
芬必得布洛芬释...
+
+
+
+
i
请先选择商品
+
+
+ + diff --git a/figma_html_page/未选择商品toast提示.html b/figma_html_page/未选择商品toast提示.html new file mode 100644 index 0000000..4289ca3 --- /dev/null +++ b/figma_html_page/未选择商品toast提示.html @@ -0,0 +1,73 @@ + + + + + + 选择商品 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
选择商品
+
+
+
+
感冒灵颗粒
+
感冒灵颗粒
+
+
+
+
电子血压仪
+
电子血压仪
+
+
+
+
小林退热贴
+
小林退热贴
+
+
+
+
芬必得布洛芬释...
+
芬必得布洛芬释...
+
+
+
+
+
+
+
+ i +
+
请先选择商品
+
+
+
+
+ + diff --git a/figma_html_page/生成具体内容-1.html b/figma_html_page/生成具体内容-1.html new file mode 100644 index 0000000..6bff404 --- /dev/null +++ b/figma_html_page/生成具体内容-1.html @@ -0,0 +1,52 @@ + + + + + + 生成内容 + + + + +
+
9:41
+ +
+ +

天冷必备!999感冒灵真的救了我

+
+

最近这个天气真是绝了😭 昨天还穿短袖 今天直接降温10度!办公室一半人都在擤鼻涕…还好我常年备着 999感冒灵!真·家中神药!!

+

🍊 亲测好用点:

+

✅ 冲剂暖暖的超好喝!感冒初期喝一包 全身都舒服了~

+

✅ 中西医结合配方 退烧+缓解鼻塞头疼都很可

+

✅ 最关键的是不会犯困!打工人白天也能安心吃!

+
+
+
+ + +
+
+
+ + diff --git a/figma_html_page/生成具体内容-2.html b/figma_html_page/生成具体内容-2.html new file mode 100644 index 0000000..926910e --- /dev/null +++ b/figma_html_page/生成具体内容-2.html @@ -0,0 +1,48 @@ + + + + + + 生成内容 + + + + +
+
9:41
+ +
+ +

天冷必备!999感冒灵真的救了我

+
+

最近这个天气真是绝了😭 昨天还穿短袖 今天直接降温10度!

+

办公室一半人都在擤鼻涕…还好我常年备着 999感冒灵!

+
+
+
+ + +
+
+
+ + diff --git a/figma_html_page/生成具体内容.html b/figma_html_page/生成具体内容.html new file mode 100644 index 0000000..f3a644c --- /dev/null +++ b/figma_html_page/生成具体内容.html @@ -0,0 +1,87 @@ + + + + + + 生成内容 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
+ +

天冷必备!999感冒灵真的救了我

+
+

最近这个天气真是绝了😭 昨天还穿短袖 今天直接降温10度!办公室一半人都在擤鼻涕…还好我常年备着 999感冒灵!真·家中神药!!

+

🍊 亲测好用点:

+

✅ 冲剂暖暖的超好喝!感冒初期喝一包 全身都舒服了~

+

✅ 中西医结合配方 退烧+缓解鼻塞头疼都很可

+

✅ 最关键的是不会犯困!打工人白天也能安心吃!

+

(晚上睡前喝还能睡更香hhh)

+

🌟 我的保命用法:

+

感觉喉咙有点痒/打喷嚏👉立刻冲一包预防!

+

已经中招的👉早晚各一包+疯狂喝水

+

搭配VC泡腾片效果加倍✨

+

🛒 购买小贴士:

+

药店/某猫都有 建议家里&办公室各囤一盒!(尤其换季期!)真实分享!但凡有一个人不知道这个宝藏我都会伤心OK?! #秋冬养生 #感冒灵 #家庭常备药 #换季保命

+
+
+
+ + +
+
+
+ + diff --git a/figma_html_page/登录成功,返回生成内容页.html b/figma_html_page/登录成功,返回生成内容页.html new file mode 100644 index 0000000..975424a --- /dev/null +++ b/figma_html_page/登录成功,返回生成内容页.html @@ -0,0 +1,47 @@ + + + + + + 生成内容 + + + + +
+
9:41
+ +
+ +

天冷必备!999感冒灵真的救了我

+
+

最近这个天气真是绝了😭 昨天还穿短袖 今天直接降温10度!办公室一半人都在擤鼻涕…

+
+
+
+ + +
+
+
+ + diff --git a/figma_html_page/绑定账户_修改手机号-1.html b/figma_html_page/绑定账户_修改手机号-1.html new file mode 100644 index 0000000..3883347 --- /dev/null +++ b/figma_html_page/绑定账户_修改手机号-1.html @@ -0,0 +1,35 @@ + + + + + + 绑定小红书账号 + + + + +
+
9:41
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
手机号+86
+
验证码
+ +
+
+
+
+ + diff --git a/figma_html_page/绑定账户_修改手机号.html b/figma_html_page/绑定账户_修改手机号.html new file mode 100644 index 0000000..91b7692 --- /dev/null +++ b/figma_html_page/绑定账户_修改手机号.html @@ -0,0 +1,43 @@ + + + + + + 绑定小红书账号 + + + + +
+
9:41
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
+ 手机号 + +86 + +
+
+ 验证码 + + +
+ +
+
+
+
+ + diff --git a/figma_html_page/绑定账户_修改验证码-1.html b/figma_html_page/绑定账户_修改验证码-1.html new file mode 100644 index 0000000..fcdb3d1 --- /dev/null +++ b/figma_html_page/绑定账户_修改验证码-1.html @@ -0,0 +1,35 @@ + + + + + + 绑定小红书账号 + + + + +
+
9:41
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
手机号+86
+
验证码
+ +
+
+
+
+ + diff --git a/figma_html_page/绑定账户_修改验证码.html b/figma_html_page/绑定账户_修改验证码.html new file mode 100644 index 0000000..dafe881 --- /dev/null +++ b/figma_html_page/绑定账户_修改验证码.html @@ -0,0 +1,43 @@ + + + + + + 绑定小红书账号 + + + + +
+
9:41
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
+ 手机号 + +86 + +
+
+ 验证码 + + +
+ +
+
+
+
+ + diff --git a/figma_html_page/绑定账户_已输入手机号-1.html b/figma_html_page/绑定账户_已输入手机号-1.html new file mode 100644 index 0000000..00ae029 --- /dev/null +++ b/figma_html_page/绑定账户_已输入手机号-1.html @@ -0,0 +1,35 @@ + + + + + + 绑定小红书账号 + + + + +
+
9:41
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
手机号+86
+
验证码
+ +
+
+
+
+ + diff --git a/figma_html_page/绑定账户_已输入手机号.html b/figma_html_page/绑定账户_已输入手机号.html new file mode 100644 index 0000000..f0d8932 --- /dev/null +++ b/figma_html_page/绑定账户_已输入手机号.html @@ -0,0 +1,56 @@ + + + + + + 绑定小红书账号 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
+ 手机号 + +86 + +
+
+ 验证码 + + +
+ +
+
+
+
+ + diff --git a/figma_html_page/绑定账户_已输入验证码-1.html b/figma_html_page/绑定账户_已输入验证码-1.html new file mode 100644 index 0000000..26fc81e --- /dev/null +++ b/figma_html_page/绑定账户_已输入验证码-1.html @@ -0,0 +1,35 @@ + + + + + + 绑定小红书账号 + + + + +
+
9:41
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
手机号+86
+
验证码
+ +
+
+
+
+ + diff --git a/figma_html_page/绑定账户_已输入验证码.html b/figma_html_page/绑定账户_已输入验证码.html new file mode 100644 index 0000000..0905914 --- /dev/null +++ b/figma_html_page/绑定账户_已输入验证码.html @@ -0,0 +1,56 @@ + + + + + + 绑定小红书账号 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
+ 手机号 + +86 + +
+
+ 验证码 + + +
+ +
+
+
+
+ + diff --git a/figma_html_page/绑定账户_未输入手机号-1.html b/figma_html_page/绑定账户_未输入手机号-1.html new file mode 100644 index 0000000..79e1679 --- /dev/null +++ b/figma_html_page/绑定账户_未输入手机号-1.html @@ -0,0 +1,35 @@ + + + + + + 绑定小红书账号 + + + + +
+
9:41
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
手机号+86
+
验证码
+ +
+
+
+
+ + diff --git a/figma_html_page/绑定账户_未输入手机号.html b/figma_html_page/绑定账户_未输入手机号.html new file mode 100644 index 0000000..5e5ca16 --- /dev/null +++ b/figma_html_page/绑定账户_未输入手机号.html @@ -0,0 +1,56 @@ + + + + + + 绑定小红书账号 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
+ 手机号 + +86 + +
+
+ 验证码 + + +
+ +
+
+
+
+ + diff --git a/figma_html_page/绑定账户_登录成功-1.html b/figma_html_page/绑定账户_登录成功-1.html new file mode 100644 index 0000000..54cb97e --- /dev/null +++ b/figma_html_page/绑定账户_登录成功-1.html @@ -0,0 +1,36 @@ + + + + + + 绑定小红书账号 + + + + +
+
9:41
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
手机号+86
+
验证码
+ +
+
+
绑定成功
+
+
+ + diff --git a/figma_html_page/绑定账户_登录成功.html b/figma_html_page/绑定账户_登录成功.html new file mode 100644 index 0000000..55241b3 --- /dev/null +++ b/figma_html_page/绑定账户_登录成功.html @@ -0,0 +1,64 @@ + + + + + + 绑定小红书账号 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
+ 手机号 + +86 + +
+
+ 验证码 + + +
+ +
+
+
+
+
+ +
+
绑定成功
+
+
+
+
+ + diff --git a/figma_html_page/绑定账户_绑定失败.html b/figma_html_page/绑定账户_绑定失败.html new file mode 100644 index 0000000..88ecc9c --- /dev/null +++ b/figma_html_page/绑定账户_绑定失败.html @@ -0,0 +1,64 @@ + + + + + + 绑定小红书账号 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
+ 手机号 + +86 + +
+
+ 验证码 + + +
+ +
+
+
+
+
+ i +
+
绑定失败,手机
号未注册账号
+
+
+
+
+ + diff --git a/figma_html_page/绑定账户_获取验证码中-1.html b/figma_html_page/绑定账户_获取验证码中-1.html new file mode 100644 index 0000000..8e91896 --- /dev/null +++ b/figma_html_page/绑定账户_获取验证码中-1.html @@ -0,0 +1,36 @@ + + + + + + 绑定小红书账号 + + + + +
+
9:41
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
手机号+86
+
验证码
+ +
+
+
获取验证中
+
+
+ + diff --git a/figma_html_page/绑定账户_获取验证码中.html b/figma_html_page/绑定账户_获取验证码中.html new file mode 100644 index 0000000..0b50301 --- /dev/null +++ b/figma_html_page/绑定账户_获取验证码中.html @@ -0,0 +1,62 @@ + + + + + + 绑定小红书账号 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
+ +

请绑定小红书账号

+

手机号未注册小红书会导致绑定失败

+
+
+ 手机号 + +86 + +
+
+ 验证码 + + +
+ +
+
+
+
+
+
获取验证中
+
+
+
+
+ + diff --git a/figma_html_page/解除绑定后.html b/figma_html_page/解除绑定后.html new file mode 100644 index 0000000..9fdc47f --- /dev/null +++ b/figma_html_page/解除绑定后.html @@ -0,0 +1,48 @@ + + + + + + 个人中心 + + + + +
+
9:41
+ +
+ + +
发布记录
+
暂无记录
+
+
+
+ + diff --git a/figma_html_page/账户登录-1.html b/figma_html_page/账户登录-1.html new file mode 100644 index 0000000..8160d11 --- /dev/null +++ b/figma_html_page/账户登录-1.html @@ -0,0 +1,27 @@ + + + + + + 账户登录 + + + + +
+
9:41
+ + +
+
+ + diff --git a/figma_html_page/账户登录.html b/figma_html_page/账户登录.html new file mode 100644 index 0000000..d2adb4d --- /dev/null +++ b/figma_html_page/账户登录.html @@ -0,0 +1,35 @@ + + + + + + 账户登录 + + + + +
+
9:41
+ + +
+
+ + diff --git a/figma_html_page/账户登录_修改手机号.html b/figma_html_page/账户登录_修改手机号.html new file mode 100644 index 0000000..971bd50 --- /dev/null +++ b/figma_html_page/账户登录_修改手机号.html @@ -0,0 +1,35 @@ + + + + + + 账户登录 + + + + +
+
9:41
+ + +
+
+ + diff --git a/figma_html_page/账户登录_修改验证码.html b/figma_html_page/账户登录_修改验证码.html new file mode 100644 index 0000000..fc395b3 --- /dev/null +++ b/figma_html_page/账户登录_修改验证码.html @@ -0,0 +1,35 @@ + + + + + + 账户登录 + + + + +
+
9:41
+ + +
+
+ + diff --git a/figma_html_page/账户登录_已输入_获取验证码中.html b/figma_html_page/账户登录_已输入_获取验证码中.html new file mode 100644 index 0000000..cf1f7ef --- /dev/null +++ b/figma_html_page/账户登录_已输入_获取验证码中.html @@ -0,0 +1,41 @@ + + + + + + 账户登录 + + + + +
+
9:41
+ + +
+
+
+
获取验证中
+
+
+
+
+ + diff --git a/figma_html_page/账户登录_已输入手机号.html b/figma_html_page/账户登录_已输入手机号.html new file mode 100644 index 0000000..9b512fc --- /dev/null +++ b/figma_html_page/账户登录_已输入手机号.html @@ -0,0 +1,35 @@ + + + + + + 账户登录 + + + + +
+
9:41
+ + +
+
+ + diff --git a/figma_html_page/账户登录_已输入验证码.html b/figma_html_page/账户登录_已输入验证码.html new file mode 100644 index 0000000..0ccd956 --- /dev/null +++ b/figma_html_page/账户登录_已输入验证码.html @@ -0,0 +1,35 @@ + + + + + + 账户登录 + + + + +
+
9:41
+ + +
+
+ + diff --git a/figma_html_page/账户登录_未输入手机号.html b/figma_html_page/账户登录_未输入手机号.html new file mode 100644 index 0000000..d2adb4d --- /dev/null +++ b/figma_html_page/账户登录_未输入手机号.html @@ -0,0 +1,35 @@ + + + + + + 账户登录 + + + + +
+
9:41
+ + +
+
+ + diff --git a/figma_html_page/账户登录_登录成功.html b/figma_html_page/账户登录_登录成功.html new file mode 100644 index 0000000..2e8bab1 --- /dev/null +++ b/figma_html_page/账户登录_登录成功.html @@ -0,0 +1,43 @@ + + + + + + 账户登录 + + + + +
+
9:41
+ + +
+
+
+ +
+
登录成功
+
+
+
+
+ + diff --git a/figma_html_page/选择商品-1.html b/figma_html_page/选择商品-1.html new file mode 100644 index 0000000..bf0eae3 --- /dev/null +++ b/figma_html_page/选择商品-1.html @@ -0,0 +1,36 @@ + + + + + + 选择商品 + + + + +
+
9:41
+ +
选择商品
+
+
感冒灵颗粒
+
电子血压仪
+
小林退热贴
+
芬必得布洛芬释...
+
+
+
+
+
+ + diff --git a/figma_html_page/选择商品-2.html b/figma_html_page/选择商品-2.html new file mode 100644 index 0000000..bf0eae3 --- /dev/null +++ b/figma_html_page/选择商品-2.html @@ -0,0 +1,36 @@ + + + + + + 选择商品 + + + + +
+
9:41
+ +
选择商品
+
+
感冒灵颗粒
+
电子血压仪
+
小林退热贴
+
芬必得布洛芬释...
+
+
+
+
+
+ + diff --git a/figma_html_page/选择商品-3.html b/figma_html_page/选择商品-3.html new file mode 100644 index 0000000..bf0eae3 --- /dev/null +++ b/figma_html_page/选择商品-3.html @@ -0,0 +1,36 @@ + + + + + + 选择商品 + + + + +
+
9:41
+ +
选择商品
+
+
感冒灵颗粒
+
电子血压仪
+
小林退热贴
+
芬必得布洛芬释...
+
+
+
+
+
+ + diff --git a/figma_html_page/选择商品-4.html b/figma_html_page/选择商品-4.html new file mode 100644 index 0000000..bf0eae3 --- /dev/null +++ b/figma_html_page/选择商品-4.html @@ -0,0 +1,36 @@ + + + + + + 选择商品 + + + + +
+
9:41
+ +
选择商品
+
+
感冒灵颗粒
+
电子血压仪
+
小林退热贴
+
芬必得布洛芬释...
+
+
+
+
+
+ + diff --git a/figma_html_page/选择商品.html b/figma_html_page/选择商品.html new file mode 100644 index 0000000..8d738d7 --- /dev/null +++ b/figma_html_page/选择商品.html @@ -0,0 +1,170 @@ + + + + + + 选择商品 + + + + +
+
+ 9:41 +
+ + + +
+
+ + + +
选择商品
+ +
+
+
+ +
+
+ 感冒灵颗粒 +
+
感冒灵颗粒
+
+
+
+ +
+
+ 电子血压仪 +
+
电子血压仪
+
+
+
+ +
+
+ 小林退热贴 +
+
小林退热贴
+
+
+
+ +
+
+ 芬必得布洛芬释... +
+
芬必得布洛芬释...
+
+
+ +
+ +
+ +
+ +
+ +
+
+ + diff --git a/figma_html_page/选择商品_已选择-1.html b/figma_html_page/选择商品_已选择-1.html new file mode 100644 index 0000000..0631d4c --- /dev/null +++ b/figma_html_page/选择商品_已选择-1.html @@ -0,0 +1,38 @@ + + + + + + 选择商品 - 已选择 + + + + +
+
9:41
+ +
选择商品
+
+
感冒灵颗粒
+
电子血压仪
+
小林退热贴
+
芬必得布洛芬释...
+
+
+
+
+
+ + diff --git a/figma_html_page/选择商品_已选择.html b/figma_html_page/选择商品_已选择.html new file mode 100644 index 0000000..705a48e --- /dev/null +++ b/figma_html_page/选择商品_已选择.html @@ -0,0 +1,66 @@ + + + + + + 选择商品 - 已选择 + + + + +
+
+ 9:41 +
+ + + +
+
+ +
选择商品
+
+
+
+
感冒灵颗粒
+
感冒灵颗粒
+
+
+
+
电子血压仪
+
电子血压仪
+
+
+
+
小林退热贴
+
小林退热贴
+
+
+
+
芬必得布洛芬释...
+
芬必得布洛芬释...
+
+
+
+
+
+
+ + diff --git a/figma_html_page/通过微信登录-1.html b/figma_html_page/通过微信登录-1.html new file mode 100644 index 0000000..b180789 --- /dev/null +++ b/figma_html_page/通过微信登录-1.html @@ -0,0 +1,46 @@ + + + + + + 通过微信登录 + + + + +
+
9:41
+ + +
+
+
+
登录中
+
+
+
+
+ + diff --git a/figma_html_page/通过微信登录.html b/figma_html_page/通过微信登录.html new file mode 100644 index 0000000..b180789 --- /dev/null +++ b/figma_html_page/通过微信登录.html @@ -0,0 +1,46 @@ + + + + + + 通过微信登录 + + + + +
+
9:41
+ + +
+
+
+
登录中
+
+
+
+
+ + diff --git a/go_backend/ENV_CONFIG_GUIDE.md b/go_backend/ENV_CONFIG_GUIDE.md deleted file mode 100644 index 39b592f..0000000 --- a/go_backend/ENV_CONFIG_GUIDE.md +++ /dev/null @@ -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= - JWT_SECRET= - WECHAT_APP_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` diff --git a/go_backend/PYTHON_CROSS_PLATFORM.md b/go_backend/PYTHON_CROSS_PLATFORM.md deleted file mode 100644 index 07ca8b5..0000000 --- a/go_backend/PYTHON_CROSS_PLATFORM.md +++ /dev/null @@ -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) diff --git a/go_backend/UBUNTU_SCRIPTS_GUIDE.md b/go_backend/UBUNTU_SCRIPTS_GUIDE.md deleted file mode 100644 index 3158816..0000000 --- a/go_backend/UBUNTU_SCRIPTS_GUIDE.md +++ /dev/null @@ -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 - -# 方法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) diff --git a/go_backend/check_article.sql b/go_backend/check_article.sql new file mode 100644 index 0000000..202206d --- /dev/null +++ b/go_backend/check_article.sql @@ -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; diff --git a/go_backend/cmd/check_all_locks.go b/go_backend/cmd/check_all_locks.go new file mode 100644 index 0000000..57bdf29 --- /dev/null +++ b/go_backend/cmd/check_all_locks.go @@ -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("未发现任何锁") + } +} diff --git a/go_backend/cmd/clear_bind_lock.go b/go_backend/cmd/clear_bind_lock.go new file mode 100644 index 0000000..b89a3cc --- /dev/null +++ b/go_backend/cmd/clear_bind_lock.go @@ -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 ") + 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("未发现任何绑定锁") + } + } +} diff --git a/go_backend/cmd/generate_password.go b/go_backend/cmd/generate_password.go new file mode 100644 index 0000000..7da7afa --- /dev/null +++ b/go_backend/cmd/generate_password.go @@ -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 字段") +} diff --git a/go_backend/tools/generate_token.go b/go_backend/cmd/generate_token.go similarity index 100% rename from go_backend/tools/generate_token.go rename to go_backend/cmd/generate_token.go diff --git a/go_backend/cmd/test_password_hash.go b/go_backend/cmd/test_password_hash.go new file mode 100644 index 0000000..28f29da --- /dev/null +++ b/go_backend/cmd/test_password_hash.go @@ -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) + } +} diff --git a/go_backend/cmd/test_redis.go b/go_backend/cmd/test_redis.go new file mode 100644 index 0000000..ef581b7 --- /dev/null +++ b/go_backend/cmd/test_redis.go @@ -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=== 所有测试完成! ===") +} diff --git a/go_backend/cmd/test_service_alert.go b/go_backend/cmd/test_service_alert.go new file mode 100644 index 0000000..ebfa581 --- /dev/null +++ b/go_backend/cmd/test_service_alert.go @@ -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) +} diff --git a/go_backend/common/response.go b/go_backend/common/response.go index ebee1c6..d4395fd 100644 --- a/go_backend/common/response.go +++ b/go_backend/common/response.go @@ -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, + } +} diff --git a/go_backend/config/config.dev.yaml b/go_backend/config/config.dev.yaml index 8039d4e..e5a785e 100644 --- a/go_backend/config/config.dev.yaml +++ b/go_backend/config/config.dev.yaml @@ -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 diff --git a/go_backend/config/config.go b/go_backend/config/config.go index d9a37a4..a6f9ac5 100644 --- a/go_backend/config/config.go +++ b/go_backend/config/config.go @@ -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 获取数据库连接字符串 diff --git a/go_backend/config/config.prod.yaml b/go_backend/config/config.prod.yaml index d81bf90..6bcbea1 100644 --- a/go_backend/config/config.prod.yaml +++ b/go_backend/config/config.prod.yaml @@ -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 diff --git a/go_backend/controller/auth_controller.go b/go_backend/controller/auth_controller.go index 89b5e97..1119b78 100644 --- a/go_backend/controller/auth_controller.go +++ b/go_backend/controller/auth_controller.go @@ -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) +} diff --git a/go_backend/controller/employee_controller.go b/go_backend/controller/employee_controller.go index 61c75c5..543afb2 100644 --- a/go_backend/controller/employee_controller.go +++ b/go_backend/controller/employee_controller.go @@ -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(¤tUser).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) +} diff --git a/go_backend/controller/feedback_controller.go b/go_backend/controller/feedback_controller.go new file mode 100644 index 0000000..85b950b --- /dev/null +++ b/go_backend/controller/feedback_controller.go @@ -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) +} diff --git a/go_backend/database/redis.go b/go_backend/database/redis.go new file mode 100644 index 0000000..2083f53 --- /dev/null +++ b/go_backend/database/redis.go @@ -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 +} diff --git a/go_backend/go.mod b/go_backend/go.mod index a8c9e1b..f1a6885 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -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 diff --git a/go_backend/go.sum b/go_backend/go.sum index 69155ba..679d2eb 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -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= diff --git a/go_backend/main.go b/go_backend/main.go index 5b6595e..8014fcd 100644 --- a/go_backend/main.go +++ b/go_backend/main.go @@ -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请求日志 // 设置路由 diff --git a/go_backend/middleware/auth.go b/go_backend/middleware/auth.go index ee001cf..6a65b73 100644 --- a/go_backend/middleware/auth.go +++ b/go_backend/middleware/auth.go @@ -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() diff --git a/go_backend/middleware/logger.go b/go_backend/middleware/logger.go index 8177c43..8293721 100644 --- a/go_backend/middleware/logger.go +++ b/go_backend/middleware/logger.go @@ -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)) + } } } diff --git a/go_backend/migrations/add_xhs_storage_state_path.sql b/go_backend/migrations/add_xhs_storage_state_path.sql new file mode 100644 index 0000000..9708c32 --- /dev/null +++ b/go_backend/migrations/add_xhs_storage_state_path.sql @@ -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`); diff --git a/go_backend/migrations/create_feedback_table.sql b/go_backend/migrations/create_feedback_table.sql new file mode 100644 index 0000000..8923877 --- /dev/null +++ b/go_backend/migrations/create_feedback_table.sql @@ -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系统用户反馈表'; diff --git a/go_backend/migrations/init_password_login.sql b/go_backend/migrations/init_password_login.sql new file mode 100644 index 0000000..057ccdd --- /dev/null +++ b/go_backend/migrations/init_password_login.sql @@ -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. 测试时使用明文密码登录,系统会自动验证哈希值 diff --git a/go_backend/models/models.go b/go_backend/models/models.go index 35d724c..168b426 100644 --- a/go_backend/models/models.go +++ b/go_backend/models/models.go @@ -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" +} diff --git a/go_backend/monitor_service.bat b/go_backend/monitor_service.bat new file mode 100644 index 0000000..24e732b --- /dev/null +++ b/go_backend/monitor_service.bat @@ -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 diff --git a/go_backend/monitor_service.sh b/go_backend/monitor_service.sh new file mode 100644 index 0000000..fd6876a --- /dev/null +++ b/go_backend/monitor_service.sh @@ -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 diff --git a/go_backend/restart.sh b/go_backend/restart.sh deleted file mode 100644 index c95601b..0000000 --- a/go_backend/restart.sh +++ /dev/null @@ -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}" diff --git a/go_backend/router/router.go b/go_backend/router/router.go index af237f8..ccfd605 100644 --- a/go_backend/router/router.go +++ b/go_backend/router/router.go @@ -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) // 获取反馈详情 } } } diff --git a/go_backend/service/auth_service.go b/go_backend/service/auth_service.go index f628431..2dfc7d9 100644 --- a/go_backend/service/auth_service.go +++ b/go_backend/service/auth_service.go @@ -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 } diff --git a/go_backend/service/cache_service.go b/go_backend/service/cache_service.go new file mode 100644 index 0000000..a4deb22 --- /dev/null +++ b/go_backend/service/cache_service.go @@ -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) +} diff --git a/go_backend/service/employee_service.go b/go_backend/service/employee_service.go index 0baf978..baf76bc 100644 --- a/go_backend/service/employee_service.go +++ b/go_backend/service/employee_service.go @@ -1,16 +1,23 @@ package service import ( + "ai_xhs/config" "ai_xhs/database" "ai_xhs/models" + "ai_xhs/utils" "bytes" + "context" "encoding/json" "errors" "fmt" + "io" "log" + "net/http" + "os" "os/exec" "path/filepath" "strings" + "sync" "time" "gorm.io/gorm" @@ -23,246 +30,605 @@ type XHSCookieVerifyResult struct { CookieExpired bool } -// SendXHSCode 发送小红书验证码 -func (s *EmployeeService) SendXHSCode(phone string) error { - // 获取Python脚本路径和venv中的Python解释器 - backendDir := filepath.Join("..", "backend") - pythonScript := filepath.Join(backendDir, "xhs_cli.py") +// SendXHSCode 发送小红书验证码(调用Python HTTP服务,增加限流控制) +func (s *EmployeeService) SendXHSCode(phone string, employeeID int) error { + ctx := context.Background() - // 使用venv中的Python解释器 (跨平台) - pythonCmd := getPythonPath(backendDir) + // 预检查:验证该手机号是否已被其他用户绑定 + var conflictAuthor models.Author + err := database.DB.Where( + "xhs_phone = ? AND status = 'active' AND created_user_id != ?", + phone, employeeID, + ).First(&conflictAuthor).Error - // 执行Python脚本 - cmd := exec.Command(pythonCmd, pythonScript, "send_code", phone, "+86") - cmd.Dir = backendDir + if err == nil { + // 找到了其他用户的绑定记录 + log.Printf("发送验证码 - 用户%d - 失败: 手机号%s已被用户%d绑定", + employeeID, phone, conflictAuthor.CreatedUserID) + return errors.New("该手机号已被其他用户绑定") + } else if err != gorm.ErrRecordNotFound { + // 数据库查询异常 + log.Printf("发送验证码 - 用户%d - 检查手机号失败: %v", employeeID, err) + return fmt.Errorf("检查手机号失败: %w", err) + } + // err == gorm.ErrRecordNotFound 表示该手机号未被绑定,可以继续 - // 捕获输出 - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - // 执行命令 - err := cmd.Run() - - // 打印Python脚本的日志输出(stderr) - if stderr.Len() > 0 { - log.Printf("[Python日志-发送验证码] %s", stderr.String()) + // 1. 限流检查: 1分钟内同一手机号只能发送一次 + rateLimitKey := fmt.Sprintf("rate:sms:%s", phone) + exists, err := utils.ExistsCache(ctx, rateLimitKey) + if err == nil && exists { + return errors.New("验证码发送过于频繁,请稍后再试") } + // 从配置获取Python服务地址 + pythonServiceURL := config.AppConfig.XHS.PythonServiceURL + if pythonServiceURL == "" { + pythonServiceURL = "http://localhost:8000" + } + + // 从Dingzhi构造HTTP请求 + url := fmt.Sprintf("%s/api/xhs/send-code", pythonServiceURL) + requestData := map[string]string{ + "phone": phone, + "country_code": "+86", + } + + jsonData, err := json.Marshal(requestData) if err != nil { - return fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String()) + log.Printf("[发送验证码] 序列化请求数据失败: %v", err) + return errors.New("网络错误,请稍后重试") } - // 获取UTF-8编码的输出 - outputStr := stdout.String() + log.Printf("[发送验证码] 调用Python HTTP服务: %s", url) - // 解析JSON输出 - var result map[string]interface{} - if err := json.Unmarshal([]byte(outputStr), &result); err != nil { - return fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr) + // 发送HTTP POST请求,增加超时控制(60秒) + client := &http.Client{ + Timeout: 60 * time.Second, // 设置60秒超时 } - // 检查success字段 - if success, ok := result["success"].(bool); !ok || !success { - if errMsg, ok := result["error"].(string); ok { - return fmt.Errorf("%s", errMsg) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + log.Printf("[发送验证码] 创建请求失败: %v", err) + return errors.New("网络错误,请稍后重试") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + log.Printf("[发送验证码] 调用Python服务失败: %v", err) + // 判断是否是超时错误 + if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline exceeded") { + return errors.New("请求超时,请稍后重试") } - return errors.New("发送验证码失败") + return errors.New("网络错误,请稍后重试") + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("[发送验证码] 读取响应失败: %v", err) + return errors.New("网络错误,请稍后重试") + } + + log.Printf("[发送验证码] Python服务响应状态: %d", resp.StatusCode) + + // 解析响应(FastAPI返回格式: {code, message, data}) + var apiResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data map[string]interface{} `json:"data"` + } + + if err := json.Unmarshal(body, &apiResponse); err != nil { + log.Printf("[发送验证码] 解析Python响应失败: %v, body: %s", err, string(body)) + return errors.New("网络错误,请稍后重试") + } + + log.Printf("[Python响应] code=%d, message=%s", apiResponse.Code, apiResponse.Message) + + // 检查响应code(FastAPI返回code=0为成功) + if apiResponse.Code != 0 { + log.Printf("[发送验证码] 失败: %s", apiResponse.Message) + // 根据错误信息返回用户友好的提示 + return s.getFriendlyErrorMessage(apiResponse.Message) + } + + // 2. 发送成功后设置限流标记(1分钟) + if err := utils.SetCache(ctx, rateLimitKey, "1", 1*time.Minute); err != nil { + log.Printf("设置限流缓存失败: %v", err) + } + + log.Printf("[发送验证码] 验证码发送成功") + return nil +} + +// getFriendlyErrorMessage 将技术错误信息转换为用户友好提示 +func (s *EmployeeService) getFriendlyErrorMessage(errMsg string) error { + // 小写化错误信息用于匹配 + lowerMsg := strings.ToLower(errMsg) + + // DOM相关错误 + if strings.Contains(lowerMsg, "element is not attached") || + strings.Contains(lowerMsg, "dom") || + strings.Contains(lowerMsg, "element not found") { + return errors.New("页面加载异常,请稍后重试") + } + + // 超时错误 + if strings.Contains(lowerMsg, "timeout") || strings.Contains(lowerMsg, "超时") { + return errors.New("请求超时,请检查网络后重试") + } + + // 网络错误 + if strings.Contains(lowerMsg, "network") || + strings.Contains(lowerMsg, "connection") || + strings.Contains(lowerMsg, "网络") { + return errors.New("网络连接失败,请检查网络后重试") + } + + // 手机号错误 + if strings.Contains(lowerMsg, "phone") || + strings.Contains(lowerMsg, "手机号") || + strings.Contains(lowerMsg, "输入手机号") { + return errors.New("请检查手机号是否正确") + } + + // 验证码发送频繁 + if strings.Contains(lowerMsg, "too many") || + strings.Contains(lowerMsg, "频繁") || + strings.Contains(lowerMsg, "rate limit") { + return errors.New("验证码发送过于频繁,请稍后再试") + } + + // 浏览器/页面错误 + if strings.Contains(lowerMsg, "browser") || + strings.Contains(lowerMsg, "page") || + strings.Contains(lowerMsg, "浏览器") { + return errors.New("系统繁忙,请稍后重试") + } + + // 如果是其他错误,检查是否已经是中文提示 + if strings.ContainsAny(errMsg, "一二三四五六七八九十") { + // 已经是中文提示,直接返回 + return errors.New(errMsg) + } + + // 默认通用错误提示 + return errors.New("发送失败,请稍后重试") +} + +// GetProfile 获取员工个人信息(增加缓存支持) +func (s *EmployeeService) GetProfile(employeeID int) (*models.User, error) { + ctx := context.Background() + cacheKey := fmt.Sprintf("user:profile:%d", employeeID) + + // 1. 尝试从缓存获取 + var cachedUser models.User + if err := utils.GetCache(ctx, cacheKey, &cachedUser); err == nil { + log.Printf("命中缓存: 用户ID=%d", employeeID) + return &cachedUser, nil + } + + // 2. 缓存未命中,从数据库查询 + var employee models.User + err := database.DB.First(&employee, employeeID).Error + if err != nil { + return nil, err + } + + // 手动查询企业信息(避免 GORM 关联查询问题) + if employee.EnterpriseID > 0 { + var enterprise models.Enterprise + if err := database.DB.Select("id", "name").First(&enterprise, employee.EnterpriseID).Error; err == nil { + employee.Enterprise = enterprise + } + } + + // 3. 存入缓存(30分钟) + if err := utils.SetCache(ctx, cacheKey, employee, 30*time.Minute); err != nil { + log.Printf("设置缓存失败: %v", err) + } + + // 注意: 小红书绑定信息现在存储在 ai_authors 表中 + // 这里的 is_bound_xhs 字段仅作为快速判断标识 + // 详细信息需要从 ai_authors 表查询 + + return &employee, nil +} + +// UpdateProfile 更新个人资料(昵称、邮箱、头像) +func (s *EmployeeService) UpdateProfile(employeeID int, nickname, email, avatar *string) error { + var employee models.User + if err := database.DB.First(&employee, employeeID).Error; err != nil { + return errors.New("用户不存在") + } + + updates := make(map[string]interface{}) + if nickname != nil { + updates["nickname"] = strings.TrimSpace(*nickname) + } + if email != nil { + updates["email"] = strings.TrimSpace(*email) + } + if avatar != nil { + updates["icon"] = strings.TrimSpace(*avatar) + } + + if len(updates) == 0 { + return nil + } + + if err := database.DB.Model(&models.User{}). + Where("id = ?", employeeID). + Updates(updates).Error; err != nil { + return err + } + + // 更新后清除缓存 + ctx := context.Background() + cacheKey := fmt.Sprintf("user:profile:%d", employeeID) + if err := utils.DelCache(ctx, cacheKey); err != nil { + log.Printf("清除缓存失败: %v", err) } return nil } -// GetProfile 获取员工个人信息 -func (s *EmployeeService) GetProfile(employeeID int) (*models.User, error) { - var employee models.User - err := database.DB.Preload("Enterprise").First(&employee, employeeID).Error - if err != nil { - return nil, err - } - - // 如果已绑定小红书且有Cookie,验证Cookie是否有效 - if employee.IsBoundXHS == 1 && employee.XHSCookie != "" { - // 检查绑定时间,刚绑定的30秒内不验证(避免与绑定操作冲突) - if employee.BoundAt != nil { - timeSinceBound := time.Since(*employee.BoundAt) - if timeSinceBound < 30*time.Second { - log.Printf("GetProfile - 用户%d刚绑定%.0f秒,跳过Cookie验证", employeeID, timeSinceBound.Seconds()) - return &employee, nil - } - } - // 异步验证Cookie(不阻塞返回) - 暂时禁用自动验证,避免频繁清空Cookie - // TODO: 改为定时任务验证,而不是每次GetProfile都验证 - log.Printf("GetProfile - 用户%d有Cookie,长度: %d(已跳过自动验证)", employeeID, len(employee.XHSCookie)) - // go s.VerifyCookieAndClear(employeeID) - } - - return &employee, nil -} - -// BindXHS 绑定小红书账号 +// BindXHS 绑定小红书账号(异步处理,立即返回) func (s *EmployeeService) BindXHS(employeeID int, xhsPhone, code string) (string, error) { if code == "" { return "", errors.New("验证码不能为空") } - // 获取员工信息 - var employee models.User - if err := database.DB.First(&employee, employeeID).Error; err != nil { - return "", err + ctx := context.Background() + + // 检查是否有正在进行的绑定任务 + bindStatusKey := fmt.Sprintf("xhs_bind_status:%d", employeeID) + statusValue, err := database.RDB.Get(ctx, bindStatusKey).Result() + if err == nil && (statusValue == "processing" || statusValue == `{"status":"processing"}`) { + return "", errors.New("正在处理绑定请求,请稍候") } - // 检查是否已绑定(如果Cookie已失效,允许重新绑定) - if employee.IsBoundXHS == 1 && employee.XHSCookie != "" { - return "", errors.New("已绑定小红书账号,请先解绑") + // 设置绑定状态为processing(180秒有效期) + // 直接使用Redis Set存储纯字符串,避免JSON序列化 + if err := database.RDB.Set(ctx, bindStatusKey, "processing", 180*time.Second).Err(); err != nil { + log.Printf("设置绑定状态缓存失败: %v", err) } - // 调用Python服务进行验证码验证和登录 - loginResult, err := s.callPythonLogin(xhsPhone, code) - if err != nil { - return "", fmt.Errorf("小红书登录失败: %w", err) - } + // 异步执行绑定流程 + go s.asyncBindXHS(employeeID, xhsPhone, code) - // 检查Python服务返回结果 - if loginResult.Code != 0 { - return "", fmt.Errorf("小红书登录失败: %s", loginResult.Message) - } - - // 从返回结果中提取用户信息和cookies - userInfo, _ := loginResult.Data["user_info"].(map[string]interface{}) - - // 优先使用 cookies_full(Playwright完整格式),如果没有则使用 cookies(键值对格式) - var cookiesData interface{} - if cookiesFull, ok := loginResult.Data["cookies_full"].([]interface{}); ok && len(cookiesFull) > 0 { - // 使用完整格式(推荐) - cookiesData = cookiesFull - } else if cookiesMap, ok := loginResult.Data["cookies"].(map[string]interface{}); ok && len(cookiesMap) > 0 { - // 降级使用键值对格式(不推荐,但兼容旧版本) - cookiesData = cookiesMap - } - - // 提取小红书账号昵称 - xhsNickname := "小红书用户" - if userInfo != nil { - if nickname, ok := userInfo["nickname"].(string); ok && nickname != "" { - xhsNickname = nickname - } else if username, ok := userInfo["username"].(string); ok && username != "" { - xhsNickname = username - } - } - - // 序列化cookies为JSON字符串(使用完整格式) - cookiesJSON := "" - if cookiesData != nil { - cookiesBytes, err := json.Marshal(cookiesData) - if err == nil { - cookiesJSON = string(cookiesBytes) - log.Printf("绑定小红书 - 用户%d - Cookie长度: %d", employeeID, len(cookiesJSON)) - } else { - log.Printf("绑定小红书 - 用户%d - 序列化Cookie失败: %v", employeeID, err) - } - } else { - log.Printf("绑定小红书 - 用户%d - 警告: cookiesData为nil", employeeID) - } - - if cookiesJSON == "" { - log.Printf("绑定小红书 - 用户%d - 错误: 未能获取到Cookie数据", employeeID) - return "", errors.New("登录成功但未能获取到Cookie数据,请重试") - } - - now := time.Now() - - // 开启事务 - tx := database.DB.Begin() - defer func() { - if r := recover(); r != nil { - tx.Rollback() - } - }() - - // 更新 ai_users 表的绑定状态和cookie信息 - log.Printf("绑定小红书 - 用户%d - 开始更新数据库", employeeID) - err = tx.Model(&employee).Updates(map[string]interface{}{ - "is_bound_xhs": 1, - "xhs_account": xhsNickname, - "xhs_phone": xhsPhone, - "xhs_cookie": cookiesJSON, - "bound_at": &now, - }).Error - - if err != nil { - tx.Rollback() - log.Printf("绑定小红书 - 用户%d - 数据库更新失败: %v", employeeID, err) - return "", fmt.Errorf("更新员工绑定状态失败: %w", err) - } - - log.Printf("绑定小红书 - 用户%d - 数据库更新成功", employeeID) - - // 提交事务 - if err := tx.Commit().Error; err != nil { - log.Printf("绑定小红书 - 用户%d - 事务提交失败: %v", employeeID, err) - return "", fmt.Errorf("提交事务失败: %w", err) - } - - log.Printf("绑定小红书 - 用户%d - 绑定成功 - 账号: %s", employeeID, xhsNickname) - return xhsNickname, nil + // 立即返回成功,告知前端正在处理 + log.Printf("绑定小红书 - 用户%d - 异步任务已启动", employeeID) + return "", nil } -// callPythonLogin 调用Python脚本完成小红书登录 -func (s *EmployeeService) callPythonLogin(phone, code string) (*PythonLoginResponse, error) { - // 获取Python脚本路径和venv中的Python解释器 - backendDir := filepath.Join("..", "backend") - pythonScript := filepath.Join(backendDir, "xhs_cli.py") +// asyncBindXHS 异步执行小红书绑定流程 +func (s *EmployeeService) asyncBindXHS(employeeID int, xhsPhone, code string) { + ctx := context.Background() + cacheService := NewCacheService() - // 使用venv中的Python解释器 (跨平台) - pythonCmd := getPythonPath(backendDir) + // 使用分布式锁保护绑定操作 + lockResource := fmt.Sprintf("bind_xhs:%d", employeeID) + xhsNickname := "" + bindStatusKey := fmt.Sprintf("xhs_bind_status:%d", employeeID) - // 执行Python脚本 - cmd := exec.Command(pythonCmd, pythonScript, "login", phone, code, "+86") - cmd.Dir = backendDir + err := cacheService.WithLock(ctx, lockResource, 180*time.Second, func() error { + // 获取员工信息 + var employee models.User + if err := database.DB.First(&employee, employeeID).Error; err != nil { + return err + } - // 捕获输出 - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr + // 关键检查:验证该手机号是否已被其他用户绑定 + var conflictAuthor models.Author + err := database.DB.Where( + "xhs_phone = ? AND status = 'active' AND created_user_id != ?", + xhsPhone, employeeID, + ).First(&conflictAuthor).Error - // 执行命令 - err := cmd.Run() + if err == nil { + // 找到了其他用户的绑定记录 + log.Printf("绑定小红书 - 用户%d - 失败: 手机号%s已被用户%d绑定", + employeeID, xhsPhone, conflictAuthor.CreatedUserID) + return errors.New("该手机号已被其他用户绑定") + } else if err != gorm.ErrRecordNotFound { + // 数据库查询异常 + log.Printf("绑定小红书 - 用户%d - 检查手机号失败: %v", employeeID, err) + return fmt.Errorf("检查手机号失败: %w", err) + } + // err == gorm.ErrRecordNotFound 表示该手机号未被绑定,可以继续 - // 打印Python脚本的日志输出(stderr) - if stderr.Len() > 0 { - log.Printf("[Python日志] %s", stderr.String()) - } + // 调用Python服务进行验证码验证和登录 + loginResult, err := s.callPythonLogin(xhsPhone, code) + if err != nil { + return fmt.Errorf("小红书登录失败: %w", err) + } + + // 检查Python服务返回结果 + if loginResult.Code != 0 { + return fmt.Errorf("小红书登录失败: %s", loginResult.Message) + } + + // 从返回结果中提取用户信息和完整登录状态 + userInfo, _ := loginResult.Data["user_info"].(map[string]interface{}) + + // 优先使用 login_state(完整登录状态),如果没有则降级使用cookies + var loginStateJSON string + + if loginState, ok := loginResult.Data["login_state"].(map[string]interface{}); ok && len(loginState) > 0 { + // 新版:使用完整的login_state(包含cookies + localStorage + sessionStorage) + loginStateBytes, err := json.Marshal(loginState) + if err == nil { + loginStateJSON = string(loginStateBytes) + log.Printf("绑定小红书 - 用户%d - 完整LoginState长度: %d", employeeID, len(loginStateJSON)) + } else { + log.Printf("绑定小红书 - 用户%d - 序列化login_state失败: %v", employeeID, err) + } + } else { + // 降级:使用旧版本的 cookies_full 或 cookies + log.Printf("绑定小红书 - 用户%d - 警告: 未找到login_state,降级使用cookies", employeeID) + + var cookiesData interface{} + if cookiesFull, ok := loginResult.Data["cookies_full"].([]interface{}); ok && len(cookiesFull) > 0 { + cookiesData = cookiesFull + } else if cookiesMap, ok := loginResult.Data["cookies"].(map[string]interface{}); ok && len(cookiesMap) > 0 { + cookiesData = cookiesMap + } + + if cookiesData != nil { + cookiesBytes, err := json.Marshal(cookiesData) + if err == nil { + loginStateJSON = string(cookiesBytes) + log.Printf("绑定小红书 - 用户%d - Cookie长度: %d", employeeID, len(loginStateJSON)) + } + } + } + + if loginStateJSON == "" { + log.Printf("绑定小红书 - 用户%d - 错误: 未能获取到任何登录数据", employeeID) + return errors.New("登录成功但未能获取到登录数据,请重试") + } + + // 提取小红书账号昵称 + xhsNickname = "小红书用户" + if userInfo != nil { + if nickname, ok := userInfo["nickname"].(string); ok && nickname != "" { + xhsNickname = nickname + } else if username, ok := userInfo["username"].(string); ok && username != "" { + xhsNickname = username + } + } + + now := time.Now() + + // 开启事务 + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 创建或更新 ai_authors 表的小红书账号记录 + log.Printf("绑定小红书 - 用户%d - 开始创建或更新作者记录", employeeID) + + author := models.Author{ + EnterpriseID: employee.EnterpriseID, + CreatedUserID: employeeID, + Phone: employee.Phone, + AuthorName: xhsNickname, + XHSCookie: loginStateJSON, // 存储完整的login_state JSON + XHSPhone: xhsPhone, + XHSAccount: xhsNickname, + BoundAt: &now, + Channel: 1, // 1=小红书 + Status: "active", + } + + // 查询是否已存在记录 + var existingAuthor models.Author + err = database.DB.Where("created_user_id = ? AND enterprise_id = ? AND channel = 1", + employeeID, employee.EnterpriseID).First(&existingAuthor).Error + + if err == gorm.ErrRecordNotFound { + // 创建新记录 + if err := tx.Create(&author).Error; err != nil { + tx.Rollback() + log.Printf("绑定小红书 - 用户%d - 创建作者记录失败: %v", employeeID, err) + return fmt.Errorf("创建作者记录失败: %w", err) + } + log.Printf("绑定小红书 - 用户%d - 创建作者记录成功", employeeID) + } else { + // 更新现有记录,使用 WHERE 条件明确指定要更新的记录(根据 created_user_id) + if err := tx.Model(&models.Author{}).Where( + "created_user_id = ? AND enterprise_id = ? AND channel = 1", + employeeID, employee.EnterpriseID, + ).Updates(map[string]interface{}{ + "author_name": xhsNickname, + "xhs_cookie": loginStateJSON, // 存储完整的login_state JSON + "xhs_phone": xhsPhone, + "xhs_account": xhsNickname, + "bound_at": &now, + "status": "active", + "phone": employee.Phone, + }).Error; err != nil { + tx.Rollback() + log.Printf("绑定小红书 - 用户%d - 更新作者记录失败: %v", employeeID, err) + return fmt.Errorf("更新作者记录失败: %w", err) + } + log.Printf("绑定小红书 - 用户%d - 更新作者记录成功", employeeID) + } + + // 更新 ai_users 表的绑定标识 + if err := tx.Model(&employee).Update("is_bound_xhs", 1).Error; err != nil { + tx.Rollback() + log.Printf("绑定小红书 - 用户%d - 更新用户绑定标识失败: %v", employeeID, err) + return fmt.Errorf("更新用户绑定标识失败: %w", err) + } + + log.Printf("绑定小红书 - 用户%d - 数据库更新成功", employeeID) + + // 提交事务 + if err := tx.Commit().Error; err != nil { + log.Printf("绑定小红书 - 用户%d - 事务提交失败: %v", employeeID, err) + return fmt.Errorf("提交事务失败: %w", err) + } + + return nil + }) if err != nil { - return nil, fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String()) - } - - // 获取UTF-8编码的输出 - outputStr := stdout.String() - - // 解析JSON输出 - var result map[string]interface{} - if err := json.Unmarshal([]byte(outputStr), &result); err != nil { - return nil, fmt.Errorf("解析Python输出失败: %w, output: %s", err, outputStr) - } - - // 检查success字段 - if success, ok := result["success"].(bool); !ok || !success { - errorMsg := "登录失败" - if errStr, ok := result["error"].(string); ok { - errorMsg = errStr + // 绑定失败,设置失败状态(保留5分钟供前端查询) + failData := map[string]string{ + "status": "failed", + "error": err.Error(), } - return &PythonLoginResponse{ - Code: 1, - Message: errorMsg, + failJSON, _ := json.Marshal(failData) + // 直接使用Redis Set存储JSON字符串 + database.RDB.Set(ctx, bindStatusKey, string(failJSON), 5*time.Minute) + log.Printf("绑定小红书 - 用户%d - 绑定失败: %v", employeeID, err) + return + } + + // 清除相关缓存 + if err := cacheService.ClearUserRelatedCache(ctx, employeeID); err != nil { + log.Printf("清除缓存失败: %v", err) + } + + // 绑定成功,设置成功状态(保留5分钟供前端查询) + successData := map[string]string{ + "status": "success", + "xhs_account": xhsNickname, + } + successJSON, _ := json.Marshal(successData) + // 直接使用Redis Set存储JSON字符串 + database.RDB.Set(ctx, bindStatusKey, string(successJSON), 5*time.Minute) + log.Printf("绑定小红书 - 用户%d - 绑定成功 - 账号: %s", employeeID, xhsNickname) +} + +// GetBindXHSStatus 获取小红书绑定状态 +func (s *EmployeeService) GetBindXHSStatus(employeeID int) (map[string]interface{}, error) { + ctx := context.Background() + bindStatusKey := fmt.Sprintf("xhs_bind_status:%d", employeeID) + + statusJSON, err := database.RDB.Get(ctx, bindStatusKey).Result() + if err != nil { + // 没有找到状态,可能已完成或从未开始 + log.Printf("获取绑定状态 - 用户%d - Redis查询失败: %v", employeeID, err) + return map[string]interface{}{ + "status": "idle", }, nil } + log.Printf("获取绑定状态 - 用户%d - Redis原始数据: %s", employeeID, statusJSON) + + // 处理中状态(纯字符串) + if statusJSON == "processing" { + log.Printf("获取绑定状态 - 用户%d - 状态: processing", employeeID) + return map[string]interface{}{ + "status": "processing", + "message": "正在登录小红书,请稍候...", + }, nil + } + + // 尝试解析JSON状态 + var statusData map[string]string + if err := json.Unmarshal([]byte(statusJSON), &statusData); err != nil { + log.Printf("获取绑定状态 - 用户%d - JSON解析失败: %v, 原始数据: %s", employeeID, err, statusJSON) + // 如果不是JSON,可能是纯字符串状态 + return map[string]interface{}{ + "status": "unknown", + "error": fmt.Sprintf("解析状态失败: %s", statusJSON), + }, nil + } + + log.Printf("获取绑定状态 - 用户%d - 解析后的状态: %+v", employeeID, statusData) + + result := map[string]interface{}{ + "status": statusData["status"], + } + + if statusData["status"] == "success" { + result["xhs_account"] = statusData["xhs_account"] + result["message"] = "绑定成功" + log.Printf("获取绑定状态 - 用户%d - 绑定成功: %s", employeeID, statusData["xhs_account"]) + } else if statusData["status"] == "failed" { + result["error"] = statusData["error"] + log.Printf("获取绑定状态 - 用户%d - 绑定失败: %s", employeeID, statusData["error"]) + } else if statusData["status"] == "processing" { + // JSON格式的processing状态 + result["message"] = "正在登录小红书,请稍候..." + log.Printf("获取绑定状态 - 用户%d - 状态: processing (JSON格式)", employeeID) + } + + return result, nil +} + +// callPythonLogin 调用Python HTTP服务完成小红书登录(优化:使用浏览器池) +func (s *EmployeeService) callPythonLogin(phone, code string) (*PythonLoginResponse, error) { + // 从配置获取Python服务地址 + pythonServiceURL := config.AppConfig.XHS.PythonServiceURL + if pythonServiceURL == "" { + pythonServiceURL = "http://localhost:8000" + } + + // 构造HTTP请求 + url := fmt.Sprintf("%s/api/xhs/login", pythonServiceURL) + requestData := map[string]string{ + "phone": phone, + "code": code, + "country_code": "+86", + } + + jsonData, err := json.Marshal(requestData) + if err != nil { + return nil, fmt.Errorf("序列化请求数据失败: %w", err) + } + + log.Printf("[绑定小红书] 调用Python HTTP服务: %s", url) + + // 发送HTTP POST请求 + resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("HTTP请求失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + log.Printf("[绑定小红书] Python服务响应状态: %d", resp.StatusCode) + + // 解析响应(FastAPI返回格式: {code, message, data}) + var apiResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data map[string]interface{} `json:"data"` + } + + if err := json.Unmarshal(body, &apiResponse); err != nil { + return nil, fmt.Errorf("解析Python响应失败: %w, body: %s", err, string(body)) + } + + // 检查响应code(FastAPI返回code=0为成功) + if apiResponse.Code != 0 { + return &PythonLoginResponse{ + Code: 1, + Message: apiResponse.Message, + }, nil + } + + log.Printf("[绑定小红书] 登录成功,获取到Cookie数据") + return &PythonLoginResponse{ Code: 0, Message: "登录成功", - Data: result, + Data: apiResponse.Data, }, nil } @@ -292,25 +658,48 @@ func (s *EmployeeService) UnbindXHS(employeeID int) error { } }() - // 清空 ai_users 表的绑定信息和cookie - err := tx.Model(&employee).Updates(map[string]interface{}{ - "is_bound_xhs": 0, - "xhs_account": "", - "xhs_phone": "", - "xhs_cookie": "", - "bound_at": nil, + // 删除或禁用 ai_authors 表中的小红书作者记录(根据 created_user_id) + err := tx.Model(&models.Author{}).Where( + "created_user_id = ? AND enterprise_id = ? AND channel = 1", + employeeID, employee.EnterpriseID, + ).Updates(map[string]interface{}{ + "status": "inactive", + "xhs_cookie": "", + "xhs_phone": "", + "xhs_account": "", + "bound_at": nil, }).Error if err != nil { tx.Rollback() - return fmt.Errorf("更新员工绑定状态失败: %w", err) + return fmt.Errorf("删除作者记录失败: %w", err) } + // 更新 ai_users 表的绑定标识 + log.Printf("解绑小红书 - 用户%d - 开始更新用户绑定标识", employeeID) + if err := tx.Model(&employee).Update("is_bound_xhs", 0).Error; err != nil { + tx.Rollback() + log.Printf("解绑小红书 - 用户%d - 更新用户绑定标识失败: %v", employeeID, err) + return fmt.Errorf("更新用户绑定标识失败: %w", err) + } + log.Printf("解绑小红书 - 用户%d - 更新用户绑定标识成功", employeeID) + // 提交事务 if err := tx.Commit().Error; err != nil { + log.Printf("解绑小红书 - 用户%d - 事务提交失败: %v", employeeID, err) return fmt.Errorf("提交事务失败: %w", err) } + // 清除相关缓存 + ctx := context.Background() + userCacheKey := fmt.Sprintf("user:profile:%d", employeeID) + authorCacheKey := fmt.Sprintf("author:user:%d", employeeID) + statusCacheKey := fmt.Sprintf("xhs:status:%d", employeeID) + if err := utils.DelCache(ctx, userCacheKey, authorCacheKey, statusCacheKey); err != nil { + log.Printf("清除缓存失败: %v", err) + } + + log.Printf("解绑小红书 - 用户%d - 解绑成功", employeeID) return nil } @@ -378,13 +767,24 @@ func (s *EmployeeService) VerifyCookieAndClear(employeeID int) error { } // 检查是否已绑定 - if employee.IsBoundXHS == 0 || employee.XHSCookie == "" { - return nil // 没有绑定或已无Cookie,直接返回 + if employee.IsBoundXHS == 0 { + return nil // 没有绑定,直接返回 + } + + // 查询对应的 author 记录(根据 created_user_id) + 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 || author.XHSCookie == "" { + return nil // 没有找到有效的author记录或已无Cookie } // 检查绑定时间,刚绑定的30秒内不验证(避免与绑定操作冲突) - if employee.BoundAt != nil { - timeSinceBound := time.Since(*employee.BoundAt) + if author.BoundAt != nil { + timeSinceBound := time.Since(*author.BoundAt) if timeSinceBound < 30*time.Second { log.Printf("验证Cookie - 用户%d刚绑定%.0f秒,跳过验证", employeeID, timeSinceBound.Seconds()) return nil @@ -392,7 +792,7 @@ func (s *EmployeeService) VerifyCookieAndClear(employeeID int) error { } // 调用Python脚本验证Cookie - verifyResult, err := s.verifyCookieWithPython(employee.XHSCookie) + verifyResult, err := s.verifyCookieWithPython(author.XHSCookie) if err != nil { log.Printf("执行Python脚本失败: %v", err) // 执行失败,不清空Cookie @@ -418,8 +818,19 @@ type XHSStatus struct { Message string `json:"message"` } -// CheckXHSStatus 检查小红书绑定与Cookie健康状态 +// CheckXHSStatus 检查小红书绑定与Cookie健康状态(增加缓存) func (s *EmployeeService) CheckXHSStatus(employeeID int) (*XHSStatus, error) { + ctx := context.Background() + cacheKey := fmt.Sprintf("xhs:status:%d", employeeID) + + // 1. 尝试从缓存获取状态(5分钟有效期) + var cachedStatus XHSStatus + if err := utils.GetCache(ctx, cacheKey, &cachedStatus); err == nil { + log.Printf("命中小红书状态缓存: 用户ID=%d", employeeID) + return &cachedStatus, nil + } + + // 2. 缓存未命中,查询数据库并验证 var employee models.User if err := database.DB.First(&employee, employeeID).Error; err != nil { return nil, err @@ -427,33 +838,48 @@ func (s *EmployeeService) CheckXHSStatus(employeeID int) (*XHSStatus, error) { status := &XHSStatus{ IsBound: employee.IsBoundXHS == 1, - HasCookie: employee.XHSCookie != "", + HasCookie: false, CookieValid: false, CookieExpired: false, } if employee.IsBoundXHS == 0 { status.Message = "未绑定小红书账号" + // 缓存未绑定状态(1分钟) + utils.SetCache(ctx, cacheKey, status, 1*time.Minute) return status, nil } - if employee.XHSCookie == "" { + // 查询对应的 author 记录(根据 created_user_id) + 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 || author.XHSCookie == "" { status.CookieExpired = true status.Message = "已绑定但无有效Cookie,可直接重新绑定" + // 缓存Cookie过期状态(2分钟) + utils.SetCache(ctx, cacheKey, status, 2*time.Minute) return status, nil } + status.HasCookie = true + // 刚绑定30秒内视为有效,避免频繁触发验证 - if employee.BoundAt != nil { - timeSinceBound := time.Since(*employee.BoundAt) + if author.BoundAt != nil { + timeSinceBound := time.Since(*author.BoundAt) if timeSinceBound < 30*time.Second { status.CookieValid = true status.Message = "刚绑定,小于30秒,暂不检测,视为有效" + // 缓存有效状态(5分钟) + utils.SetCache(ctx, cacheKey, status, 5*time.Minute) return status, nil } } - verifyResult, err := s.verifyCookieWithPython(employee.XHSCookie) + verifyResult, err := s.verifyCookieWithPython(author.XHSCookie) if err != nil { status.Message = fmt.Sprintf("验证Cookie失败: %v", err) return status, err @@ -468,21 +894,32 @@ func (s *EmployeeService) CheckXHSStatus(employeeID int) (*XHSStatus, error) { status.CookieExpired = true status.CookieValid = false status.Message = "Cookie已失效,已清空,可直接重新绑定" + // 缓存Cookie失效状态(2分钟) + utils.SetCache(ctx, cacheKey, status, 2*time.Minute) return status, nil } status.CookieValid = true status.CookieExpired = false status.Message = "Cookie有效,已登录" + // 缓存Cookie有效状态(5分钟) + utils.SetCache(ctx, cacheKey, status, 5*time.Minute) return status, nil } // clearXHSCookie 清空小红书Cookie(保留绑定状态) func (s *EmployeeService) clearXHSCookie(employeeID int) error { - // 只清空Cookie,保留is_bound_xhs、xhs_account和xhs_phone - err := database.DB.Model(&models.User{}).Where("id = ?", employeeID).Updates(map[string]interface{}{ - "xhs_cookie": "", - }).Error + // 查询用户信息 + var employee models.User + if err := database.DB.First(&employee, employeeID).Error; err != nil { + return err + } + + // 清空 ai_authors 表中的 Cookie(根据 created_user_id) + err := database.DB.Model(&models.Author{}).Where( + "created_user_id = ? AND enterprise_id = ? AND channel = 1", + employeeID, employee.EnterpriseID, + ).Update("xhs_cookie", "").Error if err != nil { return fmt.Errorf("清空Cookie失败: %w", err) @@ -494,16 +931,30 @@ func (s *EmployeeService) clearXHSCookie(employeeID int) error { // GetAvailableCopies 获取可领取的文案列表(根据作者ID、产品ID和状态筛选) func (s *EmployeeService) GetAvailableCopies(employeeID int, productID int) (map[string]interface{}, error) { - // 获取当前用户信息,查找对应的作者ID - var employee models.User - if err := database.DB.First(&employee, employeeID).Error; err != nil { - return nil, fmt.Errorf("获取用户信息失败: %w", err) - } + ctx := context.Background() - // 查找对应的作者记录 + // 1. 先尝试从缓存获取作者信息 var author models.Author - if err := database.DB.Where("phone = ? AND enterprise_id = ?", employee.Phone, employee.EnterpriseID).First(&author).Error; err != nil { - return nil, fmt.Errorf("未找到对应的作者记录: %w", err) + authorCacheKey := fmt.Sprintf("author:user:%d", employeeID) + + if err := utils.GetCache(ctx, authorCacheKey, &author); err != nil { + // 缓存未命中,从数据库查询 + var employee models.User + if err := database.DB.First(&employee, employeeID).Error; err != nil { + return nil, fmt.Errorf("获取用户信息失败: %w", err) + } + + // 查找对应的作者记录(根据 created_user_id) + if err := database.DB.Where("created_user_id = ? AND enterprise_id = ?", employeeID, employee.EnterpriseID).First(&author).Error; err != nil { + return nil, fmt.Errorf("未找到对应的作者记录: %w", err) + } + + // 存入缓存(1小时) + if err := utils.SetCache(ctx, authorCacheKey, author, 1*time.Hour); err != nil { + log.Printf("设置作者缓存失败: %v", err) + } + } else { + log.Printf("命中作者缓存: 用户ID=%d, 作者ID=%d", employeeID, author.ID) } // 获取产品信息 @@ -515,7 +966,9 @@ func (s *EmployeeService) GetAvailableCopies(employeeID int, productID int) (map // 根据产品ID、作者ID和状态筛选文案 // status = 'assign_authors' 表示已分配作者的文案 var copies []models.Article - query := database.DB.Where("product_id = ? AND author_id = ? AND status = ?", productID, author.ID, "assign_authors") + query := database.DB.Preload("Images", func(db *gorm.DB) *gorm.DB { + return db.Order("sort_order ASC") + }).Where("product_id = ? AND author_id = ? AND status = ?", productID, author.ID, "assign_authors") if err := query.Order("created_at DESC").Find(&copies).Error; err != nil { return nil, fmt.Errorf("查询文案列表失败: %w", err) @@ -545,9 +998,9 @@ func (s *EmployeeService) UpdateArticleStatus(employeeID int, articleID int, sta return fmt.Errorf("获取用户信息失败: %w", err) } - // 查找对应的作者记录 + // 查找对应的作者记录(根据 created_user_id) var author models.Author - if err := database.DB.Where("phone = ? AND enterprise_id = ?", employee.Phone, employee.EnterpriseID).First(&author).Error; err != nil { + if err := database.DB.Where("created_user_id = ? AND enterprise_id = ?", employeeID, employee.EnterpriseID).First(&author).Error; err != nil { return fmt.Errorf("未找到对应的作者记录: %w", err) } @@ -622,6 +1075,185 @@ func (s *EmployeeService) UpdateArticleStatus(employeeID int, articleID int, sta return nil } +// UpdateArticleContent 更新文案内容(标题、正文) +func (s *EmployeeService) UpdateArticleContent(employeeID int, articleID int, title, content string) error { + var article models.Article + if err := database.DB.First(&article, articleID).Error; err != nil { + return errors.New("文案不存在") + } + + // 更新标题和内容 + updates := map[string]interface{}{ + "title": title, + "content": content, + } + + err := database.DB.Model(&article).Updates(updates).Error + if err != nil { + return err + } + + // 记录日志 + s.createLog(nil, employeeID, "article_content_update", "article", &article.ID, + fmt.Sprintf("文案ID:%d 内容已更新", article.ID), "", "success") + + return nil +} + +// AddArticleImage 添加文案图片 +func (s *EmployeeService) AddArticleImage(employeeID int, articleID int, imageURL, imageThumbURL, keywordsName string) (*models.ArticleImage, error) { + // 验证文案是否存在 + var article models.Article + if err := database.DB.First(&article, articleID).Error; err != nil { + return nil, errors.New("文案不存在") + } + + // 获取当前最大的 sort_order + var maxSortOrder int + database.DB.Model(&models.ArticleImage{}).Where("article_id = ?", articleID).Select("COALESCE(MAX(sort_order), 0)").Scan(&maxSortOrder) + + // 获取当前最大的 image_id + var maxImageID int + database.DB.Model(&models.ArticleImage{}).Select("COALESCE(MAX(image_id), 0)").Scan(&maxImageID) + + // 开启事务 + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 创建图片记录 + image := models.ArticleImage{ + EnterpriseID: article.EnterpriseID, + ArticleID: articleID, + ImageID: maxImageID + 1, + ImageURL: imageURL, + ImageThumbURL: imageThumbURL, + SortOrder: maxSortOrder + 1, + KeywordsName: keywordsName, + ImageSource: 2, // 2=change 表示用户手动添加 + } + + if err := tx.Create(&image).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("添加图片失败: %w", err) + } + + // 更新文案的图片数量 + var imageCount int64 + tx.Model(&models.ArticleImage{}).Where("article_id = ?", articleID).Count(&imageCount) + tx.Model(&article).Update("image_count", imageCount) + + // 记录日志 + s.createLog(tx, employeeID, "article_image_add", "article", &articleID, + fmt.Sprintf("文案ID:%d 添加图片: %s", articleID, imageURL), "", "success") + + // 提交事务 + if err := tx.Commit().Error; err != nil { + return nil, fmt.Errorf("提交事务失败: %w", err) + } + + return &image, nil +} + +// DeleteArticleImage 删除文案图片 +func (s *EmployeeService) DeleteArticleImage(employeeID int, imageID int) error { + // 查询图片信息 + var image models.ArticleImage + if err := database.DB.First(&image, imageID).Error; err != nil { + return errors.New("图片不存在") + } + + articleID := image.ArticleID + + // 验证文案是否存在 + var article models.Article + if err := database.DB.First(&article, articleID).Error; err != nil { + return errors.New("文案不存在") + } + + // 检查删除后是否至少还有一张图片 + var imageCount int64 + database.DB.Model(&models.ArticleImage{}).Where("article_id = ?", articleID).Count(&imageCount) + if imageCount <= 1 { + return errors.New("文案至少需要保留一张图片") + } + + // 开启事务 + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 删除图片 + if err := tx.Delete(&image).Error; err != nil { + tx.Rollback() + return fmt.Errorf("删除图片失败: %w", err) + } + + // 更新文案的图片数量 + tx.Model(&models.ArticleImage{}).Where("article_id = ?", articleID).Count(&imageCount) + tx.Model(&article).Update("image_count", imageCount) + + // 记录日志 + s.createLog(tx, employeeID, "article_image_delete", "article", &articleID, + fmt.Sprintf("文案ID:%d 删除图片ID:%d", articleID, imageID), "", "success") + + // 提交事务 + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("提交事务失败: %w", err) + } + + return nil +} + +// UpdateArticleImagesOrder 更新文案图片排序 +func (s *EmployeeService) UpdateArticleImagesOrder(employeeID int, articleID int, imageOrders []map[string]int) error { + // 验证文案是否存在 + var article models.Article + if err := database.DB.First(&article, articleID).Error; err != nil { + return errors.New("文案不存在") + } + + // 开启事务 + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 更新每个图片的排序 + for _, order := range imageOrders { + imageID, imageIDOk := order["id"] + sortOrder, sortOrderOk := order["sort_order"] + + if !imageIDOk || !sortOrderOk { + continue + } + + if err := tx.Model(&models.ArticleImage{}).Where("id = ? AND article_id = ?", imageID, articleID).Update("sort_order", sortOrder).Error; err != nil { + tx.Rollback() + return fmt.Errorf("更新图片排序失败: %w", err) + } + } + + // 记录日志 + s.createLog(tx, employeeID, "article_images_reorder", "article", &articleID, + fmt.Sprintf("文案ID:%d 更新图片排序", articleID), "", "success") + + // 提交事务 + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("提交事务失败: %w", err) + } + + return nil +} + // ClaimCopy 领取文案(新版本:直接返回文案信息,不再创建领取记录) func (s *EmployeeService) ClaimCopy(employeeID int, copyID int, productID int) (map[string]interface{}, error) { // 检查文案是否存在且可用(注意:新数据库中status有更多状态) @@ -674,6 +1306,27 @@ func (s *EmployeeService) Publish(employeeID int, req PublishRequest) (int, erro return 0, errors.New("文案已被发布或处于发布审核中") } + // 验证标题不为空 + if req.Title == "" { + return 0, errors.New("标题不能为空") + } + trimmedTitle := strings.TrimSpace(req.Title) + if trimmedTitle == "" { + return 0, errors.New("标题不能为空") + } + + // 验证内容不为空(从article表中获取) + if copy.Content == "" || strings.TrimSpace(copy.Content) == "" { + return 0, errors.New("文案内容不能为空") + } + + // 验证文案至少有一张图片 + var imageCount int64 + database.DB.Model(&models.ArticleImage{}).Where("article_id = ?", req.CopyID).Count(&imageCount) + if imageCount < 1 { + return 0, errors.New("请至少添加一张图片") + } + // 获取员工信息 var employee models.User if err := database.DB.First(&employee, employeeID).Error; err != nil { @@ -776,12 +1429,18 @@ func (s *EmployeeService) Publish(employeeID int, req PublishRequest) (int, erro // createLog 创建日志记录 func (s *EmployeeService) createLog(tx *gorm.DB, userID int, action, targetType string, targetID *int, description, errMsg, status string) { + // 如果没有请求和响应数据,设置为空JSON对象 + requestData := "{}" + responseData := "{}" + log := models.Log{ UserID: &userID, Action: action, TargetType: targetType, TargetID: targetID, Description: description, + RequestData: requestData, + ResponseData: responseData, Status: status, ErrorMessage: errMsg, } @@ -797,7 +1456,7 @@ func (s *EmployeeService) createLog(tx *gorm.DB, userID int, action, targetType } } -// GetMyPublishRecords 获取我的发布记录 +// GetMyPublishRecords 获取我的发布记录(优化版:批量预加载) func (s *EmployeeService) GetMyPublishRecords(employeeID int, page, pageSize int) (map[string]interface{}, error) { if page <= 0 { page = 1 @@ -824,50 +1483,94 @@ func (s *EmployeeService) GetMyPublishRecords(employeeID int, page, pageSize int return nil, err } + // 批量收集所有article_id和product_id + articleIDs := make([]int, 0) + productIDs := make(map[int]bool) + for _, record := range records { + if record.ArticleID != nil && *record.ArticleID > 0 { + articleIDs = append(articleIDs, *record.ArticleID) + } + if record.ProductID > 0 { + productIDs[record.ProductID] = true + } + } + + // 批量查询产品 + productMap := make(map[int]string) + if len(productIDs) > 0 { + productIDList := make([]int, 0, len(productIDs)) + for pid := range productIDs { + productIDList = append(productIDList, pid) + } + var products []models.Product + if err := database.DB.Where("id IN ?", productIDList).Select("id, name").Find(&products).Error; err == nil { + for _, p := range products { + productMap[p.ID] = p.Name + } + } + } + + // 批量查询文章图片 + imagesMap := make(map[int][]map[string]interface{}) + if len(articleIDs) > 0 { + var articleImages []models.ArticleImage + if err := database.DB.Where("article_id IN ?", articleIDs).Order("article_id ASC, sort_order ASC").Find(&articleImages).Error; err == nil { + for _, img := range articleImages { + if _, ok := imagesMap[img.ArticleID]; !ok { + imagesMap[img.ArticleID] = make([]map[string]interface{}, 0) + } + imagesMap[img.ArticleID] = append(imagesMap[img.ArticleID], map[string]interface{}{ + "id": img.ID, + "image_url": img.ImageURL, + "image_thumb_url": img.ImageThumbURL, + "sort_order": img.SortOrder, + "keywords_name": img.KeywordsName, + }) + } + } + } + + // 批量查询文章标签 + tagsMap := make(map[int][]string) + if len(articleIDs) > 0 { + var articleTags []models.ArticleTag + if err := database.DB.Where("article_id IN ?", articleIDs).Select("article_id, coze_tag").Find(&articleTags).Error; err == nil { + for _, tag := range articleTags { + if tag.CozeTag != "" { + for _, t := range splitTags(tag.CozeTag) { + if t != "" { + tagsMap[tag.ArticleID] = append(tagsMap[tag.ArticleID], t) + } + } + } + } + } + } + // 构造返回数据 - list := make([]map[string]interface{}, 0) + list := make([]map[string]interface{}, 0, len(records)) for _, record := range records { publishTimeStr := "" if record.PublishTime != nil { publishTimeStr = record.PublishTime.Format("2006-01-02 15:04:05") } - // 查询产品名称 - var product models.Product - productName := "" - if err := database.DB.Where("id = ?", record.ProductID).First(&product).Error; err == nil { - productName = product.Name - } - - // 查询文章图片和标签 + // 从批量查询结果中获取数据 + productName := productMap[record.ProductID] var images []map[string]interface{} var tags []string if record.ArticleID != nil && *record.ArticleID > 0 { - // 查询文章图片 - var articleImages []models.ArticleImage - if err := database.DB.Where("article_id = ?", *record.ArticleID).Order("sort_order ASC").Find(&articleImages).Error; err == nil { - for _, img := range articleImages { - images = append(images, map[string]interface{}{ - "id": img.ID, - "image_url": img.ImageURL, - "image_thumb_url": img.ImageThumbURL, - "sort_order": img.SortOrder, - "keywords_name": img.KeywordsName, - }) - } - } + images = imagesMap[*record.ArticleID] + tags = tagsMap[*record.ArticleID] + } - // 查询文章标签 - var articleTag models.ArticleTag - if err := database.DB.Where("article_id = ?", *record.ArticleID).First(&articleTag).Error; err == nil && articleTag.CozeTag != "" { - // 解析标签 - for _, tag := range splitTags(articleTag.CozeTag) { - if tag != "" { - tags = append(tags, tag) - } - } - } + // 确保返回空数组而不null + if images == nil { + images = make([]map[string]interface{}, 0) + } + if tags == nil { + tags = make([]string, 0) } list = append(list, map[string]interface{}{ @@ -889,7 +1592,7 @@ func (s *EmployeeService) GetMyPublishRecords(employeeID int, page, pageSize int }, nil } -// GetPublishRecordDetail 获取发布记录详情 +// GetPublishRecordDetail 获取发布记录详情(优化版) func (s *EmployeeService) GetPublishRecordDetail(employeeID int, recordID int) (map[string]interface{}, error) { var record models.PublishRecord err := database.DB.Where("id = ?", recordID).First(&record).Error @@ -903,61 +1606,63 @@ func (s *EmployeeService) GetPublishRecordDetail(employeeID int, recordID int) ( } // 通过ArticleID关联查询文章内容 - var article models.Article content := "" var images []map[string]interface{} var tags []string articleCozeTag := "" // 查询产品名称 - var product models.Product productName := "" - if err := database.DB.Where("id = ?", record.ProductID).First(&product).Error; err == nil { - productName = product.Name + if record.ProductID > 0 { + var product models.Product + // 优化:只查询需要的字段 + if err := database.DB.Select("name").Where("id = ?", record.ProductID).First(&product).Error; err == nil { + productName = product.Name + } } if record.ArticleID != nil && *record.ArticleID > 0 { - // 优先使用ArticleID关联 - if err := database.DB.Where("id = ?", *record.ArticleID).First(&article).Error; err == nil { + // 优化:使用单次查询获取文章基本信息 + var article models.Article + if err := database.DB.Select("id, content, coze_tag").Where("id = ?", *record.ArticleID).First(&article).Error; err == nil { content = article.Content articleCozeTag = article.CozeTag - // 查询文章图片 - var articleImages []models.ArticleImage - if err := database.DB.Where("article_id = ?", article.ID).Order("sort_order ASC").Find(&articleImages).Error; err == nil { - for _, img := range articleImages { - images = append(images, map[string]interface{}{ - "id": img.ID, - "image_url": img.ImageURL, - "image_thumb_url": img.ImageThumbURL, - "sort_order": img.SortOrder, - "keywords_name": img.KeywordsName, - }) - } - } + // 并行查询图片和标签(使用 goroutine) + var wg sync.WaitGroup + wg.Add(2) - // 查询文章标签(ai_article_tags表) - var articleTag models.ArticleTag - if err := database.DB.Where("article_id = ?", article.ID).First(&articleTag).Error; err == nil && articleTag.CozeTag != "" { - // 使用ai_article_tags表的标签 - articleCozeTag = articleTag.CozeTag - } - - // 解析标签(假设标签是逗号分隔的字符串) - if articleCozeTag != "" { - // 尝试按逗号分割 - for _, tag := range splitTags(articleCozeTag) { - if tag != "" { - tags = append(tags, tag) + // 查询图片 + go func() { + defer wg.Done() + var articleImages []models.ArticleImage + if err := database.DB.Select("id, image_url, image_thumb_url, sort_order, keywords_name"). + Where("article_id = ?", article.ID). + Order("sort_order ASC"). + Find(&articleImages).Error; err == nil { + for _, img := range articleImages { + images = append(images, map[string]interface{}{ + "id": img.ID, + "image_url": img.ImageURL, + "image_thumb_url": img.ImageThumbURL, + "sort_order": img.SortOrder, + "keywords_name": img.KeywordsName, + }) } } - } - } - } else { - // 备用方案:通过title和product_id关联(向后兼容) - if err := database.DB.Where("title = ? AND product_id = ?", record.Title, record.ProductID).First(&article).Error; err == nil { - content = article.Content - articleCozeTag = article.CozeTag + }() + + // 查询标签 + go func() { + defer wg.Done() + var articleTag models.ArticleTag + if err := database.DB.Select("coze_tag").Where("article_id = ?", article.ID).First(&articleTag).Error; err == nil && articleTag.CozeTag != "" { + // 使用ai_article_tags表的标签 + articleCozeTag = articleTag.CozeTag + } + }() + + wg.Wait() // 解析标签 if articleCozeTag != "" { @@ -970,6 +1675,14 @@ func (s *EmployeeService) GetPublishRecordDetail(employeeID int, recordID int) ( } } + // 确保返回空数组而不null + if images == nil { + images = make([]map[string]interface{}, 0) + } + if tags == nil { + tags = make([]string, 0) + } + return map[string]interface{}{ "id": record.ID, "article_id": record.ArticleID, @@ -1028,29 +1741,72 @@ func splitTags(tagStr string) []string { return tags } -// GetProducts 获取产品列表 -func (s *EmployeeService) GetProducts() ([]map[string]interface{}, error) { - var products []models.Product - if err := database.DB.Find(&products).Error; err != nil { - return nil, err +// GetProducts 获取产品列表(按企业和状态筛选,支持分页) +func (s *EmployeeService) GetProducts(employeeID int, page, pageSize int) ([]map[string]interface{}, bool, error) { + // 参数校验 + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 } - result := make([]map[string]interface{}, 0) - for _, product := range products { - // 统计该产品下可用文案数量(注意:新数据库中status有更多状态) - var totalCopies int64 - database.DB.Model(&models.Article{}).Where("product_id = ? AND status IN ?", product.ID, []string{"draft", "approved"}).Count(&totalCopies) + ctx := context.Background() + // 获取当前用户信息,确定所属企业 + var employee models.User + if err := database.DB.First(&employee, employeeID).Error; err != nil { + return nil, false, fmt.Errorf("获取用户信息失败: %w", err) + } + + // 1. 尝试从缓存获取产品列表(10分钟缓存) + cacheKey := fmt.Sprintf("products:enterprise:%d:page:%d:size:%d", employee.EnterpriseID, page, pageSize) + type CachedProducts struct { + Products []map[string]interface{} `json:"products"` + HasMore bool `json:"has_more"` + } + var cached CachedProducts + if err := utils.GetCache(ctx, cacheKey, &cached); err == nil { + log.Printf("命中产品列表缓存: 企业ID=%d, page=%d", employee.EnterpriseID, page) + return cached.Products, cached.HasMore, nil + } + + // 2. 缓存未命中,从数据库查询 + // 按企业和状态筛选产品,按ID排序 + offset := (page - 1) * pageSize + var products []models.Product + query := database.DB.Where("enterprise_id = ? AND status = ?", employee.EnterpriseID, "active") + if err := query.Order("id ASC").Offset(offset).Limit(pageSize + 1).Find(&products).Error; err != nil { + return nil, false, err + } + + // 判断是否还有更多数据 + hasMore := false + if len(products) > pageSize { + hasMore = true + products = products[:pageSize] + } + + result := make([]map[string]interface{}, 0, len(products)) + for _, product := range products { result = append(result, map[string]interface{}{ - "id": product.ID, - "name": product.Name, - "image": product.ImageURL, - "knowledge": product.Knowledge, - "available_copies": totalCopies, + "id": product.ID, + "name": product.Name, + "image": product.ImageURL, + "knowledge": product.Knowledge, }) } - return result, nil + // 3. 存入缓存(10分钟) + cachedData := CachedProducts{ + Products: result, + HasMore: hasMore, + } + if err := utils.SetCache(ctx, cacheKey, cachedData, 10*time.Minute); err != nil { + log.Printf("设置产品列表缓存失败: %v", err) + } + + return result, hasMore, nil } // PublishRequest 发布请求参数 @@ -1061,3 +1817,343 @@ type PublishRequest struct { PublishLink string `json:"publish_link"` XHSNoteID string `json:"xhs_note_id"` } + +// UpdatePublishRecordRequest 更新发布记录请求参数 +type UpdatePublishRecordRequest struct { + Title *string `json:"title"` // 标题(可选) + Content *string `json:"content"` // 内容(可选) + Images []UpdateImageRequest `json:"images"` // 图片列表(可选) + Tags []string `json:"tags"` // 标签列表(可选) +} + +// UpdateImageRequest 更新图片请求参数 +type UpdateImageRequest struct { + ImageURL string `json:"image_url" binding:"required"` // 图片URL + ImageThumbURL string `json:"image_thumb_url"` // 缩略图URL(可选) + SortOrder int `json:"sort_order"` // 排序 + KeywordsName string `json:"keywords_name"` // 关键词名称(可选) +} + +// UpdatePublishRecord 编辑发布记录(修改标题、内容、图片、标签) +func (s *EmployeeService) UpdatePublishRecord(employeeID int, recordID int, req UpdatePublishRecordRequest) error { + // 1. 查询发布记录 + var record models.PublishRecord + if err := database.DB.First(&record, recordID).Error; err != nil { + return fmt.Errorf("发布记录不存在: %w", err) + } + + // 2. 权限检查:只有创建者或管理员可以编辑 + var employee models.User + if err := database.DB.First(&employee, employeeID).Error; err != nil { + return err + } + + if record.CreatedUserID != employeeID && employee.Role != "admin" { + return errors.New("无权编辑此发布记录") + } + + // 3. 开启事务 + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 4. 更新发布记录基本信息 + updates := make(map[string]interface{}) + if req.Title != nil { + updates["title"] = *req.Title + } + + // 如果有更新字段,执行更新 + if len(updates) > 0 { + if err := tx.Model(&record).Updates(updates).Error; err != nil { + tx.Rollback() + return fmt.Errorf("更新发布记录失败: %w", err) + } + } + + // 5. 更新关联的文章内容(如果有article_id) + if record.ArticleID != nil && *record.ArticleID > 0 { + articleUpdates := make(map[string]interface{}) + + if req.Title != nil { + articleUpdates["title"] = *req.Title + } + if req.Content != nil { + articleUpdates["content"] = *req.Content + } + + if len(articleUpdates) > 0 { + if err := tx.Model(&models.Article{}).Where("id = ?", *record.ArticleID).Updates(articleUpdates).Error; err != nil { + tx.Rollback() + return fmt.Errorf("更新文章内容失败: %w", err) + } + } + + // 6. 更新图片(如果提供) + if req.Images != nil && len(req.Images) > 0 { + // 先删除旧图片 + if err := tx.Where("article_id = ?", *record.ArticleID).Delete(&models.ArticleImage{}).Error; err != nil { + tx.Rollback() + return fmt.Errorf("删除旧图片失败: %w", err) + } + + // 插入新图片 + for _, img := range req.Images { + articleImage := models.ArticleImage{ + EnterpriseID: record.EnterpriseID, + ArticleID: *record.ArticleID, + ImageURL: img.ImageURL, + ImageThumbURL: img.ImageThumbURL, + SortOrder: img.SortOrder, + KeywordsName: img.KeywordsName, + ImageSource: 2, // 2=手动修改 + } + if err := tx.Create(&articleImage).Error; err != nil { + tx.Rollback() + return fmt.Errorf("创建新图片失败: %w", err) + } + } + + // 更新文章的图片数量 + if err := tx.Model(&models.Article{}).Where("id = ?", *record.ArticleID).Update("image_count", len(req.Images)).Error; err != nil { + tx.Rollback() + return fmt.Errorf("更新图片数量失败: %w", err) + } + } + + // 7. 更新标签(如果提供) + if req.Tags != nil && len(req.Tags) > 0 { + tagsStr := strings.Join(req.Tags, ",") + + // 更新或创建 ai_article_tags 记录 + var articleTag models.ArticleTag + err := tx.Where("article_id = ?", *record.ArticleID).First(&articleTag).Error + + if err == gorm.ErrRecordNotFound { + // 创建新标签记录 + articleTag = models.ArticleTag{ + EnterpriseID: record.EnterpriseID, + ArticleID: *record.ArticleID, + CozeTag: tagsStr, + } + if err := tx.Create(&articleTag).Error; err != nil { + tx.Rollback() + return fmt.Errorf("创建标签失败: %w", err) + } + } else if err == nil { + // 更新已有标签 + if err := tx.Model(&articleTag).Update("coze_tag", tagsStr).Error; err != nil { + tx.Rollback() + return fmt.Errorf("更新标签失败: %w", err) + } + } else { + tx.Rollback() + return fmt.Errorf("查询标签失败: %w", err) + } + } + } + + // 8. 记录操作日志 + s.createLog(tx, employeeID, "publish_record_update", "publish_record", &recordID, + fmt.Sprintf("编辑发布记录ID:%d", recordID), "", "success") + + // 9. 提交事务 + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("提交事务失败: %w", err) + } + + return nil +} + +// RepublishRecord 重新发布种草内容到小红书 +func (s *EmployeeService) RepublishRecord(employeeID int, recordID int) (string, error) { + // 1. 查询发布记录 + var record models.PublishRecord + if err := database.DB.First(&record, recordID).Error; err != nil { + return "", fmt.Errorf("发布记录不存在: %w", err) + } + + // 2. 权限检查 + var employee models.User + if err := database.DB.First(&employee, employeeID).Error; err != nil { + return "", err + } + + if record.CreatedUserID != employeeID && employee.Role != "admin" { + return "", errors.New("无权重新发布此内容") + } + + // 3. 检查用户是否绑定小红书 + if employee.IsBoundXHS != 1 { + return "", errors.New("请先绑定小红书账号") + } + + // 4. 查询对应的 author 记录获取Cookie + var author models.Author + if err := database.DB.Where( + "phone = ? AND enterprise_id = ? AND channel = 1 AND status = 'active'", + employee.Phone, employee.EnterpriseID, + ).First(&author).Error; err != nil { + return "", fmt.Errorf("未找到有效的小红书作者记录: %w", err) + } + + if author.XHSCookie == "" { + return "", errors.New("小红书Cookie已失效,请重新绑定") + } + + // 5. 获取文章内容 + var content string + var images []string + var tags []string + + if record.ArticleID != nil && *record.ArticleID > 0 { + // 从关联的文章获取内容 + var article models.Article + if err := database.DB.First(&article, *record.ArticleID).Error; err == nil { + content = article.Content + + // 获取图片 + var articleImages []models.ArticleImage + if err := database.DB.Where("article_id = ?", article.ID).Order("sort_order ASC").Find(&articleImages).Error; err == nil { + for _, img := range articleImages { + if img.ImageURL != "" { + images = append(images, img.ImageURL) + } + } + } + + // 获取标签 + var articleTag models.ArticleTag + if err := database.DB.Where("article_id = ?", article.ID).First(&articleTag).Error; err == nil { + if articleTag.CozeTag != "" { + tags = splitTags(articleTag.CozeTag) + } + } + } + } + + if content == "" { + return "", errors.New("发布记录无关联内容") + } + + // 6. 解析Cookie + var cookies interface{} + if err := json.Unmarshal([]byte(author.XHSCookie), &cookies); err != nil { + return "", fmt.Errorf("解析Cookie失败: %w", err) + } + + // 7. 构造发布配置 + publishConfig := map[string]interface{}{ + "cookies": cookies, + "title": record.Title, + "content": content, + "images": images, + "tags": tags, + } + + // 8. 调用Python脚本发布 + backendDir := filepath.Join("..", "backend") + pythonScript := filepath.Join(backendDir, "xhs_publish.py") + pythonCmd := getPythonPath(backendDir) + + // 将配置写入临时文件 + configFile := filepath.Join(backendDir, fmt.Sprintf("publish_config_temp_%d_%d.json", employeeID, recordID)) + 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) + + // 执行Python脚本 + cmd := exec.Command(pythonCmd, pythonScript, "--config", configFile) + cmd.Dir = backendDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + + if stderr.Len() > 0 { + log.Printf("[Python日志-重新发布%d] %s", recordID, stderr.String()) + } + + if err != nil { + return "", fmt.Errorf("执行Python脚本失败: %w, stderr: %s", err, stderr.String()) + } + + // 9. 解析发布结果 + outputStr := stdout.String() + lines := strings.Split(strings.TrimSpace(outputStr), "\n") + var result map[string]interface{} + found := false + + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + if strings.HasPrefix(line, "{") { + if err := json.Unmarshal([]byte(line), &result); err == nil { + found = true + break + } + } + } + + if !found { + return "", fmt.Errorf("Python脚本未返回有效JSON结果, output: %s", outputStr) + } + + success, ok := result["success"].(bool) + if !ok || !success { + errMsg := "未知错误" + if errStr, ok := result["error"].(string); ok { + errMsg = errStr + } + return "", fmt.Errorf("重新发布失败: %s", errMsg) + } + + // 10. 提取发布链接 + publishLink := "" + if note, ok := result["note"].(map[string]interface{}); ok { + if noteID, ok := note["note_id"].(string); ok { + publishLink = fmt.Sprintf("https://www.xiaohongshu.com/explore/%s", noteID) + } + } + + // 11. 更新发布记录的链接和时间 + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + now := time.Now() + if err := tx.Model(&record).Updates(map[string]interface{}{ + "publish_link": publishLink, + "publish_time": now, + "status": "published", + }).Error; err != nil { + tx.Rollback() + return "", fmt.Errorf("更新发布记录失败: %w", err) + } + + // 记录日志 + s.createLog(tx, employeeID, "publish_record_republish", "publish_record", &recordID, + fmt.Sprintf("重新发布记录ID:%d, 链接:%s", recordID, publishLink), "", "success") + + if err := tx.Commit().Error; err != nil { + return "", fmt.Errorf("提交事务失败: %w", err) + } + + return publishLink, nil +} diff --git a/go_backend/service/feedback_service.go b/go_backend/service/feedback_service.go new file mode 100644 index 0000000..3daae16 --- /dev/null +++ b/go_backend/service/feedback_service.go @@ -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 +} diff --git a/go_backend/service/scheduler_service.go b/go_backend/service/scheduler_service.go index 531b31a..9343f9d 100644 --- a/go_backend/service/scheduler_service.go +++ b/go_backend/service/scheduler_service.go @@ -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失败: %w,Cookie内容: %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失败: %w,Cookie内容: %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 } diff --git a/go_backend/service/sms_service.go b/go_backend/service/sms_service.go new file mode 100644 index 0000000..956f411 --- /dev/null +++ b/go_backend/service/sms_service.go @@ -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 +} diff --git a/go_backend/start.bat b/go_backend/start.bat deleted file mode 100644 index 5c3d4af..0000000 --- a/go_backend/start.bat +++ /dev/null @@ -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 diff --git a/go_backend/start.sh b/go_backend/start.sh deleted file mode 100644 index d3cd984..0000000 --- a/go_backend/start.sh +++ /dev/null @@ -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 diff --git a/go_backend/start_prod.bat b/go_backend/start_prod.bat deleted file mode 100644 index 510362d..0000000 --- a/go_backend/start_prod.bat +++ /dev/null @@ -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 diff --git a/go_backend/start_prod.sh b/go_backend/start_prod.sh deleted file mode 100644 index 1b4999c..0000000 --- a/go_backend/start_prod.sh +++ /dev/null @@ -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 diff --git a/go_backend/stop.sh b/go_backend/stop.sh deleted file mode 100644 index d46cce2..0000000 --- a/go_backend/stop.sh +++ /dev/null @@ -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}" diff --git a/go_backend/tools/generate_password.go b/go_backend/tools/generate_password.go deleted file mode 100644 index d53635f..0000000 --- a/go_backend/tools/generate_password.go +++ /dev/null @@ -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 文件导入测试数据") -} diff --git a/go_backend/tools/service_monitor.go b/go_backend/tools/service_monitor.go new file mode 100644 index 0000000..51b1648 --- /dev/null +++ b/go_backend/tools/service_monitor.go @@ -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) + } + } +} diff --git a/go_backend/uploads/images/1_1766285903026006700.png b/go_backend/uploads/images/1_1766285903026006700.png new file mode 100644 index 0000000..8be301b Binary files /dev/null and b/go_backend/uploads/images/1_1766285903026006700.png differ diff --git a/go_backend/utils/cache.go b/go_backend/utils/cache.go new file mode 100644 index 0000000..2d72bcd --- /dev/null +++ b/go_backend/utils/cache.go @@ -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() +} diff --git a/go_backend/utils/jwt.go b/go_backend/utils/jwt.go index 597e979..3e3bf81 100644 --- a/go_backend/utils/jwt.go +++ b/go_backend/utils/jwt.go @@ -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) +} diff --git a/go_backend/utils/oss.go b/go_backend/utils/oss.go new file mode 100644 index 0000000..f80c3eb --- /dev/null +++ b/go_backend/utils/oss.go @@ -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" + } +} diff --git a/go_backend/utils/password.go b/go_backend/utils/password.go new file mode 100644 index 0000000..2bbe08e --- /dev/null +++ b/go_backend/utils/password.go @@ -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 +} diff --git a/miniprogram/miniprogram/app.json b/miniprogram/miniprogram/app.json index f285c61..5b80988 100644 --- a/miniprogram/miniprogram/app.json +++ b/miniprogram/miniprogram/app.json @@ -1,13 +1,10 @@ { "pages": [ "pages/home/home", - "pages/article-generate/article-generate", "pages/login/login", + "pages/login/phone-login", "pages/articles/articles", - "pages/article-detail/article-detail", "pages/profile/profile", - "pages/profile/user-info/user-info", - "pages/profile/social-binding/social-binding", "pages/profile/platform-bind/platform-bind", "pages/profile/xhs-login/xhs-login", "pages/profile/published/published", @@ -15,9 +12,7 @@ "pages/profile/about/about", "pages/profile/feedback/feedback", "pages/agreement/user-agreement/user-agreement", - "pages/agreement/privacy-policy/privacy-policy", - "pages/index/index", - "pages/logs/logs" + "pages/agreement/privacy-policy/privacy-policy" ], "window": { "navigationBarTextStyle": "white", @@ -25,32 +20,6 @@ "navigationBarBackgroundColor": "#07c160", "backgroundColor": "#f5f5f5" }, - "tabBar": { - "color": "#999999", - "selectedColor": "#07c160", - "backgroundColor": "#ffffff", - "borderStyle": "black", - "list": [ - { - "pagePath": "pages/home/home", - "text": "首页", - "iconPath": "images/tabbar/home.png", - "selectedIconPath": "images/tabbar/home-active.png" - }, - { - "pagePath": "pages/profile/published/published", - "text": "我的发布", - "iconPath": "images/tabbar/article.png", - "selectedIconPath": "images/tabbar/article-active.png" - }, - { - "pagePath": "pages/profile/profile", - "text": "我的", - "iconPath": "images/tabbar/user.png", - "selectedIconPath": "images/tabbar/user-active.png" - } - ] - }, "style": "v2", "lazyCodeLoading": "requiredComponents" } \ No newline at end of file diff --git a/miniprogram/miniprogram/app.ts b/miniprogram/miniprogram/app.ts index 739cc11..1226186 100644 --- a/miniprogram/miniprogram/app.ts +++ b/miniprogram/miniprogram/app.ts @@ -3,7 +3,7 @@ import { API } from './config/api'; interface IAppInstance { globalData: Record; - showEnvironmentTip: () => void; + logEnvironmentInfo: () => void; } App({ @@ -14,8 +14,8 @@ App({ logs.unshift(Date.now()) wx.setStorageSync('logs', logs) - // 显示当前环境提示 - this.showEnvironmentTip(); + // 输出环境信息到控制台(不显示弹窗) + this.logEnvironmentInfo(); // 登录 wx.login({ @@ -26,27 +26,23 @@ App({ }) }, - // 显示环境提示 - showEnvironmentTip() { + // 输出环境信息到控制台 + logEnvironmentInfo() { try { const accountInfo = wx.getAccountInfoSync(); const envVersion = accountInfo.miniProgram.envVersion; let envName = ''; - let envColor = '#07c160'; switch (envVersion) { case 'develop': envName = '开发环境'; - envColor = '#52c41a'; break; case 'trial': envName = '体验环境'; - envColor = '#faad14'; break; case 'release': envName = '生产环境'; - envColor = '#1677ff'; break; default: envName = '未知环境'; @@ -55,17 +51,6 @@ App({ // 输出到控制台 console.log(`[App] 当前环境: ${envName}`); console.log(`[App] 后端地址: ${API.baseURL}`); - - // 显示弹窗提示 (仅开发和体验环境) - if (envVersion === 'develop' || envVersion === 'trial') { - wx.showModal({ - title: '环境提示', - content: `当前运行在${envName}\n后端地址: ${API.baseURL}`, - showCancel: false, - confirmText: '知道了', - confirmColor: envColor - }); - } } catch (error) { console.error('[App] 获取环境信息失败:', error); } diff --git a/miniprogram/miniprogram/config/api.ts b/miniprogram/miniprogram/config/api.ts index 944c548..2794a90 100644 --- a/miniprogram/miniprogram/config/api.ts +++ b/miniprogram/miniprogram/config/api.ts @@ -25,8 +25,8 @@ interface EnvConfig { const API_CONFIG: Record = { // 开发环境 - 本地开发 dev: { - baseURL: 'http://192.168.17.127:8080', // 本地Go服务 - pythonURL: 'http://192.168.17.127:8000', // 本地Python服务 + baseURL: 'http://localhost:8080', // 本地Go服务 + pythonURL: 'http://localhost:8000', // 本地Python服务 timeout: 90000 }, @@ -77,6 +77,13 @@ function detectEnvironment(): EnvType { const currentEnv: EnvType = detectEnvironment(); const currentConfig = (): EnvConfig => API_CONFIG[currentEnv]; +// 导出环境检测相关函数和变量 +export const getCurrentEnv = (): EnvType => currentEnv; +export const isDevelopment = (): boolean => currentEnv === 'dev'; +export const isDevOrTrial = (): boolean => currentEnv === 'dev' || currentEnv === 'test'; // 开发版或体验版 +export { detectEnvironment }; +export type { EnvType }; + // 输出环境信息 console.log(`[API Config] 当前环境: ${currentEnv}`); console.log(`[API Config] 主服务: ${currentConfig().baseURL}`); @@ -99,14 +106,17 @@ export const API = { // 登录接口 auth: { - wechatLogin: '/api/login/wechat', // 微信登录 - phoneLogin: '/api/login/phone' // 手机号登录 + wechatLogin: '/api/login/wechat', // 微信登录 + phoneLogin: '/api/login/phone', // 手机号登录 + phonePasswordLogin: '/api/login/phone-password', // 手机号密码登录 + xhsPhoneCodeLogin: '/api/login/xhs-phone-code' // 小红书手机号验证码登录 }, // 员工端接口 employee: { profile: '/api/employee/profile', // 获取个人信息 bindXHS: '/api/employee/bind-xhs', // 绑定小红书 + bindXHSStatus: '/api/employee/bind-xhs-status', // 获取绑定状态 unbindXHS: '/api/employee/unbind-xhs', // 解绑小红书 availableCopies: '/api/employee/available-copies', // 获取可领取文案 claimCopy: '/api/employee/claim-copy', // 领取文案 @@ -122,7 +132,8 @@ export const API = { // 小红书相关接口 xhs: { - sendCode: '/api/xhs/send-code' // 发送小红书验证码 + sendCode: '/api/xhs/send-code', // 发送小红书验证码 + sendVerificationCode: '/api/xhs/send-verification-code' // 发送验证码(新接口) } }; diff --git a/miniprogram/miniprogram/images/default-avatar.svg b/miniprogram/miniprogram/images/default-avatar.svg new file mode 100644 index 0000000..2966434 --- /dev/null +++ b/miniprogram/miniprogram/images/default-avatar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/miniprogram/miniprogram/images/logo.png b/miniprogram/miniprogram/images/logo.png new file mode 100644 index 0000000..1f369f2 Binary files /dev/null and b/miniprogram/miniprogram/images/logo.png differ diff --git a/miniprogram/miniprogram/images/phone-icon.svg b/miniprogram/miniprogram/images/phone-icon.svg new file mode 100644 index 0000000..47d8f25 --- /dev/null +++ b/miniprogram/miniprogram/images/phone-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniprogram/miniprogram/images/wechat-icon.svg b/miniprogram/miniprogram/images/wechat-icon.svg new file mode 100644 index 0000000..ae0d06e --- /dev/null +++ b/miniprogram/miniprogram/images/wechat-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniprogram/miniprogram/pages/articles/articles.json b/miniprogram/miniprogram/pages/articles/articles.json index df4088a..f26fd13 100644 --- a/miniprogram/miniprogram/pages/articles/articles.json +++ b/miniprogram/miniprogram/pages/articles/articles.json @@ -1,4 +1,9 @@ { - "navigationBarTitleText": "首页", + "navigationBarTitleText": "", + "navigationBarBackgroundColor": "#ffffff", + "navigationBarTextStyle": "black", + "enablePullDownRefresh": true, + "backgroundColor": "#f5f5f5", + "backgroundTextStyle": "dark", "usingComponents": {} } diff --git a/miniprogram/miniprogram/pages/articles/articles.ts b/miniprogram/miniprogram/pages/articles/articles.ts index 25a24fe..93a3160 100644 --- a/miniprogram/miniprogram/pages/articles/articles.ts +++ b/miniprogram/miniprogram/pages/articles/articles.ts @@ -1,5 +1,5 @@ // pages/articles/articles.ts -import { formatDate, getStatusInfo, getChannelInfo, getCoverColor, getCoverIcon } from '../../utils/util' +import { formatDate, getStatusInfo, getChannelInfo, getCoverColor, getCoverIcon, getImageUrl } from '../../utils/util' import { EmployeeService } from '../../services/employee' Page({ @@ -13,7 +13,13 @@ Page({ loading: false, claiming: false, // 领取中 publishing: false, // 发布中 - rejecting: false // 拒绝中 + rejecting: false, // 拒绝中 + hasLoaded: false, // 是否已加载过数据 + // 图片拖拽状态 + draggingId: null as number | null, // 当前拖拽的图片ID + dragStartX: 0, // 开始拖拽的X坐标 + dragStartIndex: -1, // 开始拖拽的索引 + isDragging: false // 是否正在拖拽 }, onLoad(options: any) { @@ -38,7 +44,7 @@ Page({ } // 检查小红书绑定状态 - this.checkXHSBinding().then(isBound => { + this.checkXHSBinding().then((isBound: boolean) => { if (!isBound) { wx.showModal({ title: '未绑定小红书', @@ -83,14 +89,19 @@ Page({ }, onShow() { - // 每次显示页面时刷新数据 - if (this.data.productId) { + // 仅在未加载过数据时加载(例如从其他页面返回且数据已存在) + if (this.data.productId && !this.data.hasLoaded) { this.loadCopies(); } }, + // 下拉刷新 + onPullDownRefresh() { + this.loadCopies(true); + }, + // 加载文案列表 - async loadCopies() { + async loadCopies(isPullRefresh: boolean = false) { const productId = this.data.productId; if (!productId) { @@ -98,6 +109,9 @@ Page({ title: '请先选择产品', icon: 'none' }); + if (isPullRefresh) { + wx.stopPullDownRefresh(); + } return; } @@ -121,22 +135,49 @@ Page({ console.log('标签数量:', (copies[0].tags && copies[0].tags.length) || 0); } + // 处理图片URL:使用统一工具函数处理 + const processedCopies = copies.map((copy: any) => { + if (copy.images && Array.isArray(copy.images)) { + copy.images = copy.images.map((img: any) => { + return { + ...img, + image_url: getImageUrl(img.image_url), + image_thumb_url: getImageUrl(img.image_thumb_url || img.image_url) + }; + }); + } + return copy; + }); + + // 确保 currentCopy 有值 + const firstCopy = processedCopies.length > 0 ? processedCopies[0] : null; + + console.log('即将设置 currentCopy:', firstCopy); + this.setData({ - allCopies: copies, + allCopies: processedCopies, productName: product.name || '', productImage: product.image || '', currentIndex: 0, - currentCopy: copies.length > 0 ? copies[0] : null, - loading: false + currentCopy: firstCopy, + loading: false, + hasLoaded: true // 标记已加载 }); console.log('setData后的 currentCopy:', this.data.currentCopy); + console.log('allCopies 长度:', this.data.allCopies.length); if (copies.length === 0) { wx.showToast({ title: '暂无可领取文案', icon: 'none' }); + } else if (isPullRefresh) { + wx.showToast({ + title: '刷新成功', + icon: 'success', + duration: 1500 + }); } } } catch (error) { @@ -146,6 +187,10 @@ Page({ title: '加载文案失败', icon: 'none' }); + } finally { + if (isPullRefresh) { + wx.stopPullDownRefresh(); + } } }, @@ -163,6 +208,15 @@ Page({ return; } + // 如果只有一篇文案,提示用户 + if (allCopies.length === 1) { + wx.showToast({ + title: '已经是唯一的文案了', + icon: 'none' + }); + return; + } + // 显示加载动画 wx.showLoading({ title: '加载中...', @@ -173,18 +227,24 @@ Page({ setTimeout(() => { // 切换到下一个文案 const nextIndex = (currentIndex + 1) % allCopies.length; + const nextCopy = allCopies[nextIndex]; - this.setData({ - currentIndex: nextIndex, - currentCopy: allCopies[nextIndex] - }); + // 确保 nextCopy 存在且有效 + if (nextCopy) { + this.setData({ + currentIndex: nextIndex, + currentCopy: nextCopy + }); + } else { + console.error('换一换失败: nextCopy 为空', { nextIndex, allCopies }); + } // 隐藏加载动画 wx.hideLoading(); }, 300); }, - // 一键发布(先领取,再发布) + // 一键发布(先检查绑定状态,再领取,再发布) async publishArticle() { if (!this.data.currentCopy) { wx.showToast({ @@ -194,6 +254,54 @@ Page({ return; } + // 验证内容是否为空 + const { currentCopy } = this.data; + const title = (currentCopy.title || '').trim(); + const content = (currentCopy.content || '').trim(); + const images = currentCopy.images || []; + + if (!title) { + wx.showToast({ + title: '请输入标题', + icon: 'none' + }); + return; + } + + if (!content) { + wx.showToast({ + title: '请输入文案内容', + icon: 'none' + }); + return; + } + + if (images.length === 0) { + wx.showToast({ + title: '请至少添加一张图片', + icon: 'none' + }); + return; + } + + // 发布前检查小红书绑定状态 + const isBound = await this.checkXHSBinding(); + if (!isBound) { + wx.showModal({ + title: '未绑定小红书', + content: '发布内容前需要先绑定小红书账号', + confirmText: '去绑定', + success: (res) => { + if (res.confirm) { + wx.navigateTo({ + url: '/pages/profile/platform-bind/platform-bind' + }); + } + } + }); + return; + } + wx.showModal({ title: '确认发布', content: '确定要领取并发布这篇文案吗?', @@ -212,9 +320,23 @@ Page({ this.setData({ claiming: true }); try { - // 1. 先领取文案 + const { currentCopy } = this.data; + + // 1. 先保存修改的内容 + try { + await EmployeeService.updateArticleContent(currentCopy.id, { + title: currentCopy.title, + content: currentCopy.content + }); + console.log('文案内容已保存'); + } catch (error) { + console.error('保存文案失败:', error); + // 继续发布,不阻断流程 + } + + // 2. 领取文案 const claimResponse = await EmployeeService.claimCopy( - this.data.currentCopy.id, + currentCopy.id, this.data.productId ); @@ -227,16 +349,39 @@ Page({ this.setData({ claiming: false, publishing: true }); - // 2. 再发布 + // 3. 发布(使用修改后的内容) const publishResponse = await EmployeeService.publish({ - copy_id: this.data.currentCopy.id, - title: (copyData && copyData.title) || this.data.currentCopy.title, - content: (copyData && copyData.content) || this.data.currentCopy.content, + copy_id: currentCopy.id, + title: currentCopy.title, // 使用修改后的标题 + content: currentCopy.content, // 使用修改后的内容 publish_link: '', xhs_note_id: '' }); if (publishResponse.code === 200) { + // 发布成功后,同步更新发布记录的完整内容(标题、内容、图片、标签) + try { + const recordId = publishResponse.data && publishResponse.data.record_id; + if (recordId) { + const images = (currentCopy.images || []).map((img: any, index: number) => ({ + image_url: img.image_url, + image_thumb_url: img.image_thumb_url || img.image_url, + sort_order: img.sort_order || index + 1, + keywords_name: img.keywords_name || '产品图片' + })); + + await EmployeeService.updatePublishRecord(recordId, { + title: currentCopy.title, + content: currentCopy.content, + images, + tags: currentCopy.tags || [] + }); + } + } catch (err) { + console.error('同步发布记录失败:', err); + // 不阻断发布成功,只记录错误 + } + wx.showToast({ title: '发布成功', icon: 'success', @@ -372,5 +517,272 @@ Page({ title: `${this.data.productName} - 精彩种草文案`, imageUrl: this.data.productImage || '' }; + }, + + // 修改标题 + onTitleInput(e: any) { + const { currentCopy, currentIndex, allCopies } = this.data; + // 直接更新当前文案的标题 + currentCopy.title = e.detail.value; + allCopies[currentIndex] = currentCopy; + this.setData({ + currentCopy, + allCopies + }); + }, + + // 修改内容 + onContentInput(e: any) { + const { currentCopy, currentIndex, allCopies } = this.data; + // 直接更新当前文案的内容 + currentCopy.content = e.detail.value; + allCopies[currentIndex] = currentCopy; + this.setData({ + currentCopy, + allCopies + }); + }, + + // ========== 图片管理功能 ========== + + // 选择并上传图片 + async chooseAndUploadImage() { + console.log('点击添加图片按钮'); + const { currentCopy, currentIndex, allCopies } = this.data; + + if (!currentCopy) { + wx.showToast({ + title: '请先选择文案', + icon: 'none' + }); + return; + } + + try { + console.log('开始选择图片...'); + // 1. 选择图片 + const res = await wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + sizeType: ['compressed'] + }) as any; + + console.log('选择图片结果:', res); + + if (!res.tempFiles || res.tempFiles.length === 0) { + console.error('未选择图片'); + return; + } + + const tempFilePath = res.tempFiles[0].tempFilePath; + console.log('临时文件路径:', tempFilePath); + + wx.showLoading({ title: '上传中...', mask: true }); + + // 2. 上传到服务器(仅上传文件,不修改文案数据) + console.log('开始上传图片...'); + const uploadRes = await EmployeeService.uploadImage(tempFilePath); + console.log('上传响应:', uploadRes); + + if (uploadRes.code === 200 && uploadRes.data) { + // 3. 仅在本地更新当前文案的图片列表 + const images = currentCopy.images ? [...currentCopy.images] : []; + const newImage = { + id: Date.now(), // 本地唯一ID + image_url: getImageUrl(uploadRes.data.image_url), + image_thumb_url: getImageUrl(uploadRes.data.image_thumb_url || uploadRes.data.image_url), + sort_order: images.length + 1, + keywords_name: '产品图片' + } as any; + images.push(newImage); + currentCopy.images = images; + allCopies[currentIndex] = currentCopy; + this.setData({ + currentCopy, + allCopies + }); + + wx.hideLoading(); + wx.showToast({ title: '添加成功', icon: 'success' }); + } else { + throw new Error(uploadRes.message || '上传失败'); + } + } catch (error: any) { + console.error('添加图片失败:', error); + wx.hideLoading(); + + // 处理用户取消选择的情况 + if (error.errMsg && error.errMsg.includes('cancel')) { + console.log('用户取消选择图片'); + return; + } + + wx.showToast({ + title: error.message || error.errMsg || '上传失败', + icon: 'none' + }); + } + }, + + // 预览图片 + previewImage(e: any) { + const { url } = e.currentTarget.dataset; + const { currentCopy } = this.data; + + if (!currentCopy || !currentCopy.images) return; + + const urls = currentCopy.images.map((img: any) => img.image_url); + + wx.previewImage({ + current: url, + urls: urls + }); + }, + + // 删除图片(仅本地修改,未发布前不更新后端) + async deleteImage(e: any) { + const { id } = e.currentTarget.dataset; + const { currentCopy, currentIndex, allCopies } = this.data; + + if (!currentCopy || !currentCopy.images) { + return; + } + + // 检查是否是最后一张 + if (currentCopy.images.length <= 1) { + wx.showToast({ + title: '至少需要保留一张图片', + icon: 'none' + }); + return; + } + + const res = await wx.showModal({ + title: '确认删除', + content: '确定要删除这张图片吗?' + }); + + if (res.confirm) { + try { + const imageId = Number(id); + let images = currentCopy.images.filter((img: any) => Number(img.id) !== imageId); + + if (images.length === 0) { + wx.showToast({ + title: '至少需要保留一张图片', + icon: 'none' + }); + return; + } + + // 重新计算排序 + images = images.map((img: any, index: number) => ({ + ...img, + sort_order: index + 1 + })); + + currentCopy.images = images; + allCopies[currentIndex] = currentCopy; + this.setData({ + currentCopy, + allCopies + }); + } catch (error: any) { + wx.showToast({ title: error.message || '删除失败', icon: 'none' }); + } + } + }, + + // ========== 图片拖拽功能 ========== + + // 长按图片开始拖拽 + onImageLongPress(e: any) { + const { id, index } = e.currentTarget.dataset; + console.log('长按图片:', id, index); + + // 振动反馈 + wx.vibrateShort({ type: 'medium' }); + + this.setData({ + draggingId: Number(id), + dragStartIndex: index, + isDragging: true + }); + + wx.showToast({ + title: '已进入拖拽模式', + icon: 'none', + duration: 1000 + }); + }, + + // 触摸开始 + onImageTouchStart(e: any) { + if (!this.data.isDragging) return; + this.setData({ + dragStartX: e.touches[0].pageX + }); + }, + + // 触摸移动 + onImageTouchMove(e: any) { + if (!this.data.isDragging || !this.data.draggingId) return; + + const { dragStartX, dragStartIndex, currentCopy, currentIndex, allCopies } = this.data; + const currentX = e.touches[0].pageX; + const deltaX = currentX - dragStartX; + + // 每移动176rpx(160rpx图片 + 16rpx间隙)切换一个位置 + const itemWidth = 176 * (wx.getSystemInfoSync().windowWidth / 750); // rpx转px + const moveSteps = Math.round(deltaX / itemWidth); + + if (moveSteps !== 0) { + const newIndex = dragStartIndex + moveSteps; + + if (newIndex >= 0 && newIndex < currentCopy.images.length) { + // 交换图片位置 + const images = [...currentCopy.images]; + const draggedItem = images[dragStartIndex]; + images.splice(dragStartIndex, 1); + images.splice(newIndex, 0, draggedItem); + + // 更新sort_order + images.forEach((img, idx) => { + img.sort_order = idx + 1; + }); + + currentCopy.images = images; + allCopies[currentIndex] = currentCopy; + + this.setData({ + currentCopy, + allCopies, + dragStartIndex: newIndex, + dragStartX: currentX + }); + + // 振动反馈 + wx.vibrateShort({ type: 'light' }); + } + } + }, + + // 触摸结束 + onImageTouchEnd(e: any) { + if (!this.data.isDragging) return; + + this.setData({ + draggingId: null, + dragStartX: 0, + dragStartIndex: -1, + isDragging: false + }); + + wx.showToast({ + title: '图片顺序已调整', + icon: 'success', + duration: 1000 + }); } }); diff --git a/miniprogram/miniprogram/pages/articles/articles.wxml b/miniprogram/miniprogram/pages/articles/articles.wxml index 0cffce8..3d3eaec 100644 --- a/miniprogram/miniprogram/pages/articles/articles.wxml +++ b/miniprogram/miniprogram/pages/articles/articles.wxml @@ -3,10 +3,40 @@ - - - - + + + + + + + + + + + + + + + + + + + @@ -18,22 +48,27 @@ - {{currentCopy.title}} + + - {{currentCopy.content}} - - -