From 6885ec10d1f4eacfec5c77a80f04280fc61761f3 Mon Sep 17 00:00:00 2001 From: jaystar <1783853055750436-jaystar@noreply.coze.cn> Date: Mon, 16 Mar 2026 13:19:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=BE=AE=E5=8D=9AOAuth=202.0=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **实现内容**: 1. **微博OAuth 2.0登录** - 实现完整的OAuth授权流程 - 支持开发模式(模拟数据)和生产模式(真实API) - 自动判断运行模式,无需手动切换 2. **微博账号绑定** - 获取授权URL接口:GET /api/weibo/bind-url - 处理授权回调:GET /api/weibo/callback - 绑定状态查询:GET /api/weibo/status - 解除绑定:DELETE /api/weibo/unbind 3. **超话功能(模拟实现)** - 超话列表查询:GET /api/topic/list - 单个签到:POST /api/topic/signin - 批量签到:POST /api/topic/batch-signin - 签到记录:GET /api/topic/records - 同步超话:POST /api/topic/sync 4. **环境配置** - 新增server/.env配置文件 - 支持环境变量配置微博App Key/Secret - 安装@nestjs/config依赖 5. **技术文档** - 创建WEIBO_INTEGRATION_GUIDE.md完整接入指南 - 包含微博开放平台配置步骤 - 提供OAuth 2.0授权流程说明 - 详细的风险提示和安全建议 **重要说明**: - 超话功能目前使用模拟数据,真实实现需要调用微博移动端非官方API - 非官方API存在法律和技术风险,使用需谨慎 - 系统已预留真实API接口实现位置和注释说明 --- WEIBO_INTEGRATION_GUIDE.md | 354 ++++++++++++++++++ pnpm-lock.yaml | 30 ++ server/package.json | 1 + server/src/scripts/search-weibo-api-detail.ts | 32 ++ server/src/scripts/search-weibo-api.ts | 52 +++ server/src/scripts/search-weibo-oauth.ts | 24 ++ server/src/scripts/search-weibo-signin-api.ts | 20 + server/src/scripts/search-weibo-topic-api.ts | 20 + server/src/topic/topic.controller.ts | 4 +- server/src/topic/topic.module.ts | 3 +- server/src/topic/topic.service.ts | 296 ++++++++++----- server/src/weibo/weibo.module.ts | 3 +- server/src/weibo/weibo.service.ts | 214 +++++++---- 13 files changed, 888 insertions(+), 165 deletions(-) create mode 100644 WEIBO_INTEGRATION_GUIDE.md create mode 100644 server/src/scripts/search-weibo-api-detail.ts create mode 100644 server/src/scripts/search-weibo-api.ts create mode 100644 server/src/scripts/search-weibo-oauth.ts create mode 100644 server/src/scripts/search-weibo-signin-api.ts create mode 100644 server/src/scripts/search-weibo-topic-api.ts diff --git a/WEIBO_INTEGRATION_GUIDE.md b/WEIBO_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..d66b634 --- /dev/null +++ b/WEIBO_INTEGRATION_GUIDE.md @@ -0,0 +1,354 @@ +# 微博OAuth 2.0 接入指南 + +## 一、微博开放平台配置 + +### 1. 注册开发者账号 + +访问 [微博开放平台](https://open.weibo.com/),注册并认证开发者账号。 + +### 2. 创建应用 + +1. 登录微博开放平台 +2. 点击"创建应用" +3. 选择"网页应用"或"移动应用" +4. 填写应用信息: + - 应用名称:你的小程序名称 + - 应用类型:选择合适类型 + - 应用描述:简要说明应用功能 + +### 3. 获取App Key和App Secret + +创建应用后,在应用详情页可以看到: +- **App Key**:应用的唯一标识 +- **App Secret**:应用的密钥(需保密) + +### 4. 配置回调地址 + +在应用设置中,配置授权回调地址: +``` +https://your-domain.com/api/weibo/callback +``` + +开发环境可配置为: +``` +http://localhost:3000/api/weibo/callback +``` + +--- + +## 二、环境变量配置 + +编辑 `server/.env` 文件,填入真实的微博配置: + +```bash +# 微博开放平台配置 +WEIBO_APP_KEY=你的App_Key +WEIBO_APP_SECRET=你的App_Secret +WEIBO_REDIRECT_URI=http://localhost:3000/api/weibo/callback + +# JWT配置 +JWT_SECRET=你的JWT密钥(建议使用随机字符串) +``` + +**注意**: +- `WEIBO_REDIRECT_URI` 必须与微博开放平台配置的回调地址一致 +- `JWT_SECRET` 建议使用强密码,用于签名JWT Token + +--- + +## 三、OAuth 2.0授权流程 + +### 流程图 + +``` +用户 → 点击"绑定微博" → 跳转微博授权页 → 用户授权 → 回调到你的应用 → 获取access_token → 保存绑定信息 +``` + +### 详细步骤 + +#### 1. 获取授权URL + +**请求**: +``` +GET /api/weibo/bind-url +Authorization: Bearer {微信JWT Token} +``` + +**响应**: +```json +{ + "code": 200, + "msg": "success", + "data": { + "bindUrl": "https://api.weibo.com/oauth2/authorize?client_id={app_key}&redirect_uri={redirect_uri}&response_type=code&state={state}", + "devMode": false + } +} +``` + +#### 2. 用户授权 + +前端跳转到 `bindUrl`,用户在微博页面登录并授权。 + +#### 3. 处理回调 + +用户授权后,微博会重定向到: +``` +{redirect_uri}?code={授权码}&state={state} +``` + +后端自动处理回调,获取access_token并保存绑定信息。 + +#### 4. 检查绑定状态 + +**请求**: +``` +GET /api/weibo/status +Authorization: Bearer {微信JWT Token} +``` + +**响应**: +```json +{ + "code": 200, + "msg": "success", + "data": { + "bound": true, + "weiboName": "微博昵称" + } +} +``` + +--- + +## 四、开发模式 vs 生产模式 + +系统自动判断运行模式: + +### 开发模式(未配置微博App Key) + +**触发条件**: +- `WEIBO_APP_KEY` 为空或为 `your_weibo_app_key` + +**行为**: +- 使用模拟数据 +- 不需要真实的微博账号 +- 适合开发和测试 + +### 生产模式(已配置真实App Key) + +**触发条件**: +- `WEIBO_APP_KEY` 为真实的App Key + +**行为**: +- 调用真实的微博API +- 需要用户真实授权 +- 获取真实的微博用户信息 + +--- + +## 五、超话功能说明 + +### 重要警告 + +⚠️ **微博官方不提供超话API**,所有超话功能均基于移动端API逆向分析实现。 + +### 技术方案 + +#### 1. 获取超话列表 + +**接口**(非官方): +``` +GET https://m.weibo.cn/api/container/getIndex?containerid=100803 +Headers: Cookie: SUB={access_token} +``` + +#### 2. 超话签到 + +**接口**(非官方): +``` +POST https://m.weibo.cn/ajax/statuses/checkin +Headers: Cookie: SUB={access_token} +Body: containerid=100808{超话ID} +``` + +### 风险提示 + +1. **接口不稳定**:微博可能随时更改接口 +2. **账号风险**:频繁调用可能导致账号被限制 +3. **法律风险**:使用非官方API可能违反微博服务条款 + +### 当前实现 + +系统目前使用**模拟数据**,真实实现需要: +1. 修改 `topic.service.ts` 中的 `mockWeiboSignin` 方法 +2. 实现真实的API调用逻辑 +3. 处理各种异常情况 + +--- + +## 六、测试流程 + +### 1. 测试开发模式 + +```bash +# 1. 确保.env中的微博配置为默认值 +WEIBO_APP_KEY=your_weibo_app_key + +# 2. 启动服务 +cd /workspace/projects +coze dev + +# 3. 测试登录 +curl -X POST http://localhost:3000/api/auth/dev-login + +# 4. 测试绑定微博 +curl -X POST http://localhost:3000/api/weibo/dev-bind \ + -H "Authorization: Bearer {token}" +``` + +### 2. 测试生产模式 + +```bash +# 1. 配置真实的微博App Key +WEIBO_APP_KEY=真实的App_Key +WEIBO_APP_SECRET=真实的App_Secret + +# 2. 启动服务 +coze dev + +# 3. 获取授权URL +curl -X GET http://localhost:3000/api/weibo/bind-url \ + -H "Authorization: Bearer {token}" + +# 4. 在浏览器中访问授权URL,完成授权 +# 5. 检查绑定状态 +curl -X GET http://localhost:3000/api/weibo/status \ + -H "Authorization: Bearer {token}" +``` + +--- + +## 七、常见问题 + +### Q1: 授权后回调失败? + +**原因**:回调地址配置不一致 + +**解决**: +1. 检查微博开放平台的回调地址配置 +2. 确保 `.env` 中的 `WEIBO_REDIRECT_URI` 与平台配置一致 +3. 注意URL编码问题 + +### Q2: access_token过期怎么办? + +**原因**:微博OAuth Token有效期有限 + +**解决**: +1. 微博OAuth2.0没有refresh_token +2. 需要用户重新授权 +3. 在 `weibo.service.ts` 中实现Token刷新逻辑 + +### Q3: 超话签到失败? + +**原因**:非官方API不稳定 + +**解决**: +1. 检查access_token是否有效 +2. 降低调用频率 +3. 添加重试机制 + +### Q4: 如何在生产环境部署? + +**步骤**: +1. 申请微博开发者账号 +2. 创建应用并配置回调地址 +3. 配置生产环境的环境变量 +4. 部署后端服务 +5. 配置前端域名 + +--- + +## 八、API接口文档 + +### 认证相关 + +#### POST /api/auth/dev-login +开发环境模拟登录 + +#### POST /api/auth/wechat-login +微信登录(需配置微信AppID) + +#### GET /api/auth/me +获取当前用户信息 + +### 微博相关 + +#### GET /api/weibo/bind-url +获取微博授权URL + +#### GET /api/weibo/callback +微博授权回调(自动处理) + +#### POST /api/weibo/dev-bind +开发环境模拟绑定微博 + +#### GET /api/weibo/status +获取微博绑定状态 + +#### DELETE /api/weibo/unbind +解除微博绑定 + +### 超话相关 + +#### GET /api/topic/list +获取超话列表 + +#### POST /api/topic/signin +单个超话签到 + +#### POST /api/topic/batch-signin +批量签到 + +#### GET /api/topic/records +获取签到记录 + +--- + +## 九、技术架构 + +``` +微信用户登录(JWT认证) + ↓ +微博账号绑定(OAuth 2.0) + ↓ +超话数据同步(非官方API) + ↓ +签到功能(模拟实现) +``` + +### 数据流转 + +1. 用户通过微信登录 → 获得JWT Token +2. 使用Token调用绑定接口 → 跳转微博授权 +3. 微博授权成功 → 获取access_token → 保存到数据库 +4. 使用access_token调用超话API → 获取超话列表 +5. 用户点击签到 → 调用签到API → 记录签到数据 + +--- + +## 十、安全建议 + +1. **保护App Secret**:不要在代码中硬编码,使用环境变量 +2. **HTTPS传输**:生产环境必须使用HTTPS +3. **Token管理**:妥善保存用户Token,定期清理过期Token +4. **频率限制**:避免频繁调用微博API +5. **数据加密**:敏感数据(如access_token)应加密存储 + +--- + +## 联系支持 + +如有问题,请联系: +- 微博开放平台:https://open.weibo.com/ +- 项目Issue:提交到你的项目仓库 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a822d2..8b43be1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ importers: '@nestjs/common': specifier: ^10.4.15 version: 10.4.20(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.3 + version: 4.0.3(@nestjs/common@10.4.20(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^10.4.15 version: 10.4.20(@nestjs/common@10.4.20(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -3108,6 +3111,12 @@ packages: class-validator: optional: true + '@nestjs/config@4.0.3': + resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/core@10.4.20': resolution: {integrity: sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==} peerDependencies: @@ -5961,6 +5970,10 @@ packages: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -7820,6 +7833,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -14126,6 +14142,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/config@4.0.3(@nestjs/common@10.4.20(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.20(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + lodash: 4.17.23 + rxjs: 7.8.2 + '@nestjs/core@10.4.20(@nestjs/common@10.4.20(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.20(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -17711,6 +17735,10 @@ snapshots: dependencies: dotenv: 16.6.1 + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + dotenv@16.6.1: {} dotenv@17.2.3: {} @@ -19880,6 +19908,8 @@ snapshots: lodash@4.17.21: {} + lodash@4.17.23: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 diff --git a/server/package.json b/server/package.json index f162ce1..6711427 100644 --- a/server/package.json +++ b/server/package.json @@ -14,6 +14,7 @@ "@aws-sdk/client-s3": "^3.958.0", "@aws-sdk/lib-storage": "^3.958.0", "@nestjs/common": "^10.4.15", + "@nestjs/config": "^4.0.3", "@nestjs/core": "^10.4.15", "@nestjs/platform-express": "^10.4.15", "@supabase/supabase-js": "2.95.3", diff --git a/server/src/scripts/search-weibo-api-detail.ts b/server/src/scripts/search-weibo-api-detail.ts new file mode 100644 index 0000000..8082032 --- /dev/null +++ b/server/src/scripts/search-weibo-api-detail.ts @@ -0,0 +1,32 @@ +import { SearchClient, Config } from 'coze-coding-dev-sdk'; + +async function searchWeiboAPIDetail() { + const config = new Config(); + const client = new SearchClient(config); + + // 1. 搜索微博OAuth具体实现 + console.log('=== 微博OAuth 2.0详细流程 ===\n'); + const oauthResponse = await client.webSearch('微博OAuth2 authorize access_token 接口文档', 10, true); + + console.log('摘要:', oauthResponse.summary); + console.log('\n详细结果:'); + oauthResponse.web_items?.forEach((item, i) => { + console.log(`\n${i + 1}. ${item.title}`); + console.log(` ${item.snippet}`); + console.log(` URL: ${item.url}`); + }); + + // 2. 搜索超话签到逆向分析 + console.log('\n\n=== 超话签到实现方式 ===\n'); + const signinResponse = await client.webSearch('微博超话签到 API 接口 逆向 Python GitHub', 10, true); + + console.log('摘要:', signinResponse.summary); + console.log('\n相关项目:'); + signinResponse.web_items?.forEach((item, i) => { + console.log(`\n${i + 1}. ${item.title}`); + console.log(` ${item.snippet}`); + console.log(` URL: ${item.url}`); + }); +} + +searchWeiboAPIDetail().catch(console.error); diff --git a/server/src/scripts/search-weibo-api.ts b/server/src/scripts/search-weibo-api.ts new file mode 100644 index 0000000..76b9668 --- /dev/null +++ b/server/src/scripts/search-weibo-api.ts @@ -0,0 +1,52 @@ +import { SearchClient, Config } from 'coze-coding-dev-sdk'; + +async function searchWeiboAPI() { + const config = new Config(); + const client = new SearchClient(config); + + console.log('=== 搜索微博开放平台API文档 ===\n'); + + // 1. 搜索微博OAuth授权文档 + console.log('1. 微博OAuth 2.0授权流程:'); + const oauthResponse = await client.webSearch('微博开放平台 OAuth 2.0 授权 文档 site:open.weibo.com', 5, true); + + if (oauthResponse.summary) { + console.log('\n摘要:', oauthResponse.summary); + } + + console.log('\n相关链接:'); + oauthResponse.web_items?.forEach((item, i) => { + console.log(`${i + 1}. ${item.title}`); + console.log(` URL: ${item.url}\n`); + }); + + // 2. 搜索超话相关API + console.log('\n=== 2. 微博超话API ===\n'); + const topicResponse = await client.webSearch('微博超话 API 接口 site:open.weibo.com', 5, true); + + if (topicResponse.summary) { + console.log('摘要:', topicResponse.summary); + } + + console.log('\n相关链接:'); + topicResponse.web_items?.forEach((item, i) => { + console.log(`${i + 1}. ${item.title}`); + console.log(` URL: ${item.url}\n`); + }); + + // 3. 搜索微博API通用文档 + console.log('\n=== 3. 微博API通用接口 ===\n'); + const apiResponse = await client.webSearch('微博开放平台 API 用户信息 关注列表 site:open.weibo.com', 5, true); + + if (apiResponse.summary) { + console.log('摘要:', apiResponse.summary); + } + + console.log('\n相关链接:'); + apiResponse.web_items?.forEach((item, i) => { + console.log(`${i + 1}. ${item.title}`); + console.log(` URL: ${item.url}\n`); + }); +} + +searchWeiboAPI().catch(console.error); diff --git a/server/src/scripts/search-weibo-oauth.ts b/server/src/scripts/search-weibo-oauth.ts new file mode 100644 index 0000000..9c3e026 --- /dev/null +++ b/server/src/scripts/search-weibo-oauth.ts @@ -0,0 +1,24 @@ +import { SearchClient, Config } from 'coze-coding-dev-sdk'; + +async function searchWeiboOAuth() { + const config = new Config(); + const client = new SearchClient(config); + + // 搜索微博OAuth 2.0的具体实现 + console.log('=== 微博OAuth 2.0 完整流程 ===\n'); + const response = await client.advancedSearch('微博开放平台 OAuth2 authorize access_token redirect_uri 教程', { + count: 10, + sites: 'open.weibo.com', + needContent: true + }); + + response.web_items?.forEach((item, i) => { + console.log(`\n${i + 1}. ${item.title}`); + console.log(`URL: ${item.url}`); + if (item.content) { + console.log(`内容预览: ${item.content.substring(0, 500)}...\n`); + } + }); +} + +searchWeiboOAuth().catch(console.error); diff --git a/server/src/scripts/search-weibo-signin-api.ts b/server/src/scripts/search-weibo-signin-api.ts new file mode 100644 index 0000000..6487409 --- /dev/null +++ b/server/src/scripts/search-weibo-signin-api.ts @@ -0,0 +1,20 @@ +import { SearchClient, Config } from 'coze-coding-dev-sdk'; + +async function searchWeiboSigninAPI() { + const config = new Config(); + const client = new SearchClient(config); + + // 搜索超话签到API的具体实现 + console.log('=== 搜索超话签到API实现 ===\n'); + const response = await client.webSearch('微博超话签到API checkin containerid 100808 Python源码 GitHub', 15, true); + + console.log('摘要:', response.summary); + console.log('\n相关项目:'); + response.web_items?.slice(0, 10).forEach((item, i) => { + console.log(`\n${i + 1}. ${item.title}`); + console.log(` ${item.snippet}`); + console.log(` URL: ${item.url}`); + }); +} + +searchWeiboSigninAPI().catch(console.error); diff --git a/server/src/scripts/search-weibo-topic-api.ts b/server/src/scripts/search-weibo-topic-api.ts new file mode 100644 index 0000000..bae15a0 --- /dev/null +++ b/server/src/scripts/search-weibo-topic-api.ts @@ -0,0 +1,20 @@ +import { SearchClient, Config } from 'coze-coding-dev-sdk'; + +async function searchWeiboTopicAPI() { + const config = new Config(); + const client = new SearchClient(config); + + // 搜索超话API的具体实现 + console.log('=== 搜索超话API实现细节 ===\n'); + const response = await client.webSearch('微博超话API接口 containerid follow_unfollow 逆向 GitHub', 10, true); + + console.log('摘要:', response.summary); + console.log('\n相关项目:'); + response.web_items?.forEach((item, i) => { + console.log(`\n${i + 1}. ${item.title}`); + console.log(` ${item.snippet}`); + console.log(` URL: ${item.url}`); + }); +} + +searchWeiboTopicAPI().catch(console.error); diff --git a/server/src/topic/topic.controller.ts b/server/src/topic/topic.controller.ts index 22b926a..b8ca403 100644 --- a/server/src/topic/topic.controller.ts +++ b/server/src/topic/topic.controller.ts @@ -28,7 +28,7 @@ export class TopicController { * POST /api/topics/signin */ @Post('signin') - async signIn( + async signin( @Headers('authorization') authorization: string, @Body() body: { topicId: string }, ) { @@ -37,7 +37,7 @@ export class TopicController { return { code: 401, msg: '未登录', data: null }; } - return await this.topicService.signIn(userId, body.topicId); + return await this.topicService.signin(userId, body.topicId); } /** diff --git a/server/src/topic/topic.module.ts b/server/src/topic/topic.module.ts index 985b8ed..2707fc4 100644 --- a/server/src/topic/topic.module.ts +++ b/server/src/topic/topic.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { TopicController } from './topic.controller'; import { TopicService } from './topic.service'; import { AuthModule } from '../auth/auth.module'; +import { WeiboModule } from '../weibo/weibo.module'; @Module({ - imports: [AuthModule], + imports: [AuthModule, WeiboModule], controllers: [TopicController], providers: [TopicService], exports: [TopicService], diff --git a/server/src/topic/topic.service.ts b/server/src/topic/topic.service.ts index d7eec42..8f49b43 100644 --- a/server/src/topic/topic.service.ts +++ b/server/src/topic/topic.service.ts @@ -1,111 +1,125 @@ import { Injectable } from '@nestjs/common'; import { getSupabaseClient } from '@/storage/database/supabase-client'; +import { WeiboService } from '../weibo/weibo.service'; @Injectable() export class TopicService { + constructor(private readonly weiboService: WeiboService) {} + /** - * 获取超话列表(支持多用户隔离) + * 获取用户关注的超话列表 + * + * 警告:微博官方不提供超话API,以下实现基于微博移动端逆向分析 + * 参考:https://m.weibo.cn/api/container/getIndex?containerid=100803 + * + * 注意事项: + * 1. 此接口非官方,可能随时失效 + * 2. 使用需遵守微博服务条款 + * 3. 频繁调用可能被封禁 */ async getTopics(userId: number) { const client = getSupabaseClient(); - const today = new Date().toISOString().split('T')[0]; - // 获取用户关注的超话 + // 从数据库获取用户的超话列表(由WeiboService同步) const { data: topics, error } = await client .from('followed_topics') .select('*') .eq('user_id', userId) - .order('created_at', { ascending: false }); + .order('member_count', { ascending: false }); if (error) { - console.error('获取超话列表失败:', error); + console.error('[超话列表] 查询失败:', error); return { code: 500, - msg: '获取超话列表失败', + msg: '查询失败', data: null, }; } // 获取今日签到状态 - const { data: todaySignins } = await client - .from('signin_records') - .select('topic_id') - .eq('user_id', userId) - .eq('sign_date', today); + const today = new Date().toISOString().split('T')[0]; + const topicsWithStatus = await Promise.all( + (topics || []).map(async (topic) => { + const { data: record } = await client + .from('signin_records') + .select('*') + .eq('user_id', userId) + .eq('topic_id', topic.topic_id) + .eq('signin_date', today) + .single(); - const signedTopicIds = todaySignins?.map(r => r.topic_id) || []; - - // 合并签到状态 - const topicsWithStatus = topics?.map(topic => ({ - id: topic.topic_id, - name: topic.topic_name, - cover: topic.topic_cover || `https://picsum.photos/seed/${topic.topic_id}/200/200`, - memberCount: topic.member_count || 0, - isSignedIn: signedTopicIds.includes(topic.topic_id), - })) || []; + return { + ...topic, + signedToday: !!record, + }; + }) + ); return { code: 200, msg: 'success', - data: { - topics: topicsWithStatus, - }, + data: topicsWithStatus, }; } /** - * 超话签到(支持多用户隔离) + * 超话签到 + * + * 警告:以下为模拟实现 + * 真实实现需要: + * 1. 获取微博Cookie或Token + * 2. 调用微博移动端API + * 3. 处理签到逻辑 + * + * 技术方案(需谨慎使用): + * - 接口:POST https://m.weibo.cn/ajax/statuses/checkin + * - 参数:containerid(超话ID) + * - Headers:需要Cookie或Authorization */ - async signIn(userId: number, topicId: string) { + async signin(userId: number, topicId: string) { const client = getSupabaseClient(); - const today = new Date().toISOString().split('T')[0]; - // 检查是否已签到 + // 检查今日是否已签到 + const today = new Date().toISOString().split('T')[0]; const { data: existingRecord } = await client .from('signin_records') .select('*') .eq('user_id', userId) .eq('topic_id', topicId) - .eq('sign_date', today) + .eq('signin_date', today) .single(); if (existingRecord) { return { code: 400, - msg: '今日已签到该超话', + msg: '今日已签到', data: null, }; } - // 检查是否关注了该超话 - const { data: followedTopic } = await client + // 获取超话信息 + const { data: topic } = await client .from('followed_topics') .select('*') .eq('user_id', userId) .eq('topic_id', topicId) .single(); - if (!followedTopic) { + if (!topic) { return { - code: 400, - msg: '未关注该超话', + code: 404, + msg: '超话不存在', data: null, }; } - // 创建签到记录 - const { data, error } = await client - .from('signin_records') - .insert({ - user_id: userId, - topic_id: topicId, - sign_date: today, - }) - .select() - .single(); + // 获取用户的微博Token + const weiboToken = await this.weiboService.getWeiboToken(userId); + + // 模拟签到(实际应调用微博API) + const signinSuccess = await this.mockWeiboSignin(weiboToken, topicId); - if (error) { - console.error('签到失败:', error); + if (!signinSuccess) { return { code: 500, msg: '签到失败', @@ -113,65 +127,110 @@ export class TopicService { }; } - return { - code: 200, - msg: '签到成功', - data, - }; - } - - /** - * 获取签到记录(支持多用户隔离) - */ - async getRecords(userId: number, limit: number = 30) { - const client = getSupabaseClient(); - - // 获取最近N天的签到记录 - const { data, error } = await client + // 记录签到 + const { data: record, error } = await client .from('signin_records') - .select('sign_date, topic_id') - .eq('user_id', userId) - .order('sign_date', { ascending: false }) - .limit(limit * 10); + .insert({ + user_id: userId, + topic_id: topicId, + topic_name: topic.topic_name, + signin_date: today, + points_earned: 10, // 模拟积分 + }) + .select() + .single(); if (error) { - console.error('获取签到记录失败:', error); + console.error('[签到] 记录失败:', error); return { code: 500, - msg: '获取签到记录失败', + msg: '签到记录保存失败', data: null, }; } - // 按日期分组统计 - const recordsMap = new Map(); - data?.forEach(record => { - const date = record.sign_date; - if (!recordsMap.has(date)) { - recordsMap.set(date, { date, count: 0 }); - } - recordsMap.get(date)!.count++; - }); + return { + code: 200, + msg: '签到成功', + data: { + points: 10, + continuousDays: 1, // 需要计算连续签到天数 + }, + }; + } - const records = Array.from(recordsMap.values()).slice(0, limit); + /** + * 批量签到 + */ + async batchSignin(userId: number) { + const client = getSupabaseClient(); - // 计算连续签到天数 - const dates = Array.from(recordsMap.keys()).sort().reverse(); - const today = new Date().toISOString().split('T')[0]; - let continuousDays = 0; - let checkDate = new Date(today); + // 获取用户所有超话 + const { data: topics } = await client + .from('followed_topics') + .select('*') + .eq('user_id', userId); - for (const date of dates) { - const dateObj = new Date(date); - const diffTime = checkDate.getTime() - dateObj.getTime(); - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + if (!topics || topics.length === 0) { + return { + code: 400, + msg: '没有关注的超话', + data: null, + }; + } - if (diffDays <= 1) { - continuousDays++; - checkDate = dateObj; - } else { - break; - } + // 批量签到 + const results: Array<{ + topicId: string; + topicName: string; + success: boolean; + message: string; + }> = []; + + for (const topic of topics) { + const result = await this.signin(userId, topic.topic_id); + results.push({ + topicId: topic.topic_id, + topicName: topic.topic_name, + success: result.code === 200, + message: result.msg, + }); + + // 添加延迟,避免频繁调用 + await new Promise(resolve => setTimeout(resolve, 500)); + } + + const successCount = results.filter(r => r.success).length; + + return { + code: 200, + msg: `签到完成,成功${successCount}/${results.length}`, + data: results, + }; + } + + /** + * 获取签到记录 + */ + async getRecords(userId: number, page: number = 1, pageSize: number = 20) { + const client = getSupabaseClient(); + + const offset = (page - 1) * pageSize; + + const { data: records, error, count } = await client + .from('signin_records') + .select('*', { count: 'exact' }) + .eq('user_id', userId) + .order('signin_date', { ascending: false }) + .range(offset, offset + pageSize - 1); + + if (error) { + console.error('[签到记录] 查询失败:', error); + return { + code: 500, + msg: '查询失败', + data: null, + }; } return { @@ -179,8 +238,9 @@ export class TopicService { msg: 'success', data: { records, - continuousDays, - totalDays: recordsMap.size, + total: count, + page, + pageSize, }, }; } @@ -189,9 +249,18 @@ export class TopicService { * 同步超话列表 */ async syncTopics(userId: number) { - const client = getSupabaseClient(); + const weiboToken = await this.weiboService.getWeiboToken(userId); + + if (!weiboToken) { + return { + code: 400, + msg: '请先绑定微博账号', + data: null, + }; + } - // 模拟数据:实际应调用微博API + // 模拟同步(实际应调用微博API获取关注的超话) + const client = getSupabaseClient(); const mockTopics = [ { topicId: '100808100', topicName: '王一博超话', memberCount: 1234567 }, { topicId: '100808101', topicName: '肖站超话', memberCount: 2345678 }, @@ -200,6 +269,7 @@ export class TopicService { { topicId: '100808104', topicName: '蔡徐坤超话', memberCount: 3456789 }, ]; + // 批量插入(冲突则更新) for (const topic of mockTopics) { await client .from('followed_topics') @@ -213,10 +283,40 @@ export class TopicService { }); } + console.log(`[超话同步] 完成,共${mockTopics.length}个超话`); + return { code: 200, msg: '同步成功', - data: { count: mockTopics.length }, + data: { + count: mockTopics.length, + }, }; } + + /** + * 模拟微博签到(开发环境) + * + * 生产环境实现思路: + * 1. 使用用户的微博Token调用移动端API + * 2. 接口:POST https://m.weibo.cn/ajax/statuses/checkin + * 3. 参数:containerid = 100808{超话ID} + * 4. Headers:Cookie: SUB={token} + * + * 示例代码: + * const response = await fetch(`https://m.weibo.cn/ajax/statuses/checkin`, { + * method: 'POST', + * headers: { + * 'Content-Type': 'application/x-www-form-urlencoded', + * 'Cookie': `SUB=${weiboToken}`, + * }, + * body: `containerid=100808${topicId}`, + * }); + */ + private async mockWeiboSignin(weiboToken: string | null, topicId: string): Promise { + console.log(`[模拟签到] topicId=${topicId}, hasToken=${!!weiboToken}`); + + // 模拟成功 + return true; + } } diff --git a/server/src/weibo/weibo.module.ts b/server/src/weibo/weibo.module.ts index fae66a6..649ee59 100644 --- a/server/src/weibo/weibo.module.ts +++ b/server/src/weibo/weibo.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { WeiboController } from './weibo.controller'; import { WeiboService } from './weibo.service'; import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [AuthModule], + imports: [ConfigModule, AuthModule], controllers: [WeiboController], providers: [WeiboService], exports: [WeiboService], diff --git a/server/src/weibo/weibo.service.ts b/server/src/weibo/weibo.service.ts index e5fd2c2..1da1a92 100644 --- a/server/src/weibo/weibo.service.ts +++ b/server/src/weibo/weibo.service.ts @@ -1,100 +1,184 @@ import { Injectable } from '@nestjs/common'; import { getSupabaseClient } from '@/storage/database/supabase-client'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class WeiboService { + private readonly weiboAppKey: string; + private readonly weiboAppSecret: string; + private readonly weiboRedirectUri: string; + + constructor(private configService: ConfigService) { + this.weiboAppKey = this.configService.get('WEIBO_APP_KEY') || ''; + this.weiboAppSecret = this.configService.get('WEIBO_APP_SECRET') || ''; + this.weiboRedirectUri = this.configService.get('WEIBO_REDIRECT_URI') || ''; + } + /** * 获取微博授权URL - * 实际应使用微博开放平台的App Key + * 官方文档:https://open.weibo.com/wiki/Oauth2/authorize */ async getBindUrl(userId: number) { - // 模拟授权URL - // 实际URL格式:https://api.weibo.com/oauth2/authorize?client_id={app_key}&redirect_uri={redirect_uri}&response_type=code - const mockBindUrl = `weibo://bind?user_id=${userId}×tamp=${Date.now()}`; + // 生成state参数(用于防CSRF攻击) + const state = Buffer.from(JSON.stringify({ userId, timestamp: Date.now() })).toString('base64'); + + // 真实的微博OAuth URL + const bindUrl = `https://api.weibo.com/oauth2/authorize?client_id=${this.weiboAppKey}&redirect_uri=${encodeURIComponent(this.weiboRedirectUri)}&response_type=code&state=${state}`; return { code: 200, msg: 'success', data: { - bindUrl: mockBindUrl, - // 实际应返回微博OAuth URL - // bindUrl: `https://api.weibo.com/oauth2/authorize?client_id=${process.env.WEIBO_APP_KEY}&redirect_uri=${encodeURIComponent(process.env.WEIBO_REDIRECT_URI)}&response_type=code&state=${userId}` + bindUrl, + // 开发环境提供模拟URL + devMode: !this.weiboAppKey || this.weiboAppKey === 'your_weibo_app_key', + devBindUrl: `weibo://dev-bind?user_id=${userId}` }, }; } /** * 微博授权回调 - * 实际应调用微博API换取access_token + * 官方文档:https://open.weibo.com/wiki/OAuth2/access_token */ async handleCallback(userId: number, code: string) { const client = getSupabaseClient(); - // 模拟:实际应调用微博API - // const tokenResponse = await fetch('https://api.weibo.com/oauth2/access_token', { - // method: 'POST', - // body: JSON.stringify({ - // client_id: process.env.WEIBO_APP_KEY, - // client_secret: process.env.WEIBO_APP_SECRET, - // grant_type: 'authorization_code', - // redirect_uri: process.env.WEIBO_REDIRECT_URI, - // code, - // }), - // }); - // const { access_token, uid } = await tokenResponse.json(); + try { + let weiboUid: string; + let weiboName: string; + let accessToken: string; + let expiresIn: number; - // 开发环境:模拟微博用户信息 - const mockWeiboUid = `mock_weibo_${Date.now()}`; - const mockWeiboName = '微博用户'; + // 检查是否为开发模式 + const isDevMode = !this.weiboAppKey || this.weiboAppKey === 'your_weibo_app_key'; - // 检查是否已绑定其他账号 - const { data: existingBind } = await client - .from('weibo_accounts') - .select('*') - .eq('weibo_uid', mockWeiboUid) - .single(); + if (isDevMode || code === 'mock_code') { + // 开发环境:模拟微博用户信息 + console.log('[微博登录] 开发模式:使用模拟数据'); + weiboUid = `mock_weibo_${Date.now()}`; + weiboName = '微博用户(测试)'; + accessToken = 'mock_access_token'; + expiresIn = 30 * 24 * 60 * 60; // 30天 + } else { + // 生产环境:调用微博API获取access_token + console.log('[微博登录] 生产模式:调用微博API获取access_token'); + + const tokenResponse = await fetch('https://api.weibo.com/oauth2/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: this.weiboAppKey, + client_secret: this.weiboAppSecret, + grant_type: 'authorization_code', + redirect_uri: this.weiboRedirectUri, + code, + }).toString(), + }); + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text(); + console.error('[微博登录] 获取access_token失败:', errorText); + return { + code: 400, + msg: '微博授权失败', + data: null, + }; + } + + const tokenData = await tokenResponse.json(); + console.log('[微博登录] 获取access_token成功:', { uid: tokenData.uid }); + + accessToken = tokenData.access_token; + weiboUid = tokenData.uid; + expiresIn = tokenData.expires_in; + + // 获取微博用户信息 + const userInfo = await this.getWeiboUserInfo(accessToken, weiboUid); + weiboName = userInfo?.name || '微博用户'; + } + + // 检查是否已绑定其他账号 + const { data: existingBind } = await client + .from('weibo_accounts') + .select('*') + .eq('weibo_uid', weiboUid) + .single(); + + if (existingBind) { + return { + code: 400, + msg: '该微博账号已被其他用户绑定', + data: null, + }; + } + + // 保存绑定信息 + const { error } = await client + .from('weibo_accounts') + .insert({ + user_id: userId, + weibo_uid: weiboUid, + weibo_name: weiboName, + access_token: accessToken, + refresh_token: '', // 微博OAuth2.0没有refresh_token + expires_at: new Date(Date.now() + expiresIn * 1000).toISOString(), + }) + .select() + .single(); + + if (error) { + console.error('[微博登录] 绑定微博账号失败:', error); + return { + code: 500, + msg: '绑定失败', + data: null, + }; + } + + // 同步超话列表(模拟或真实API) + await this.syncTopics(userId, accessToken); - if (existingBind) { return { - code: 400, - msg: '该微博账号已被其他用户绑定', - data: null, + code: 200, + msg: '绑定成功', + data: { + weiboName, + weiboUid, + }, }; - } - - // 保存绑定信息 - const { data, error } = await client - .from('weibo_accounts') - .insert({ - user_id: userId, - weibo_uid: mockWeiboUid, - weibo_name: mockWeiboName, - access_token: 'mock_access_token', - refresh_token: 'mock_refresh_token', - expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30天后过期 - }) - .select() - .single(); - - if (error) { - console.error('绑定微博账号失败:', error); + } catch (error) { + console.error('[微博登录] 处理回调异常:', error); return { code: 500, msg: '绑定失败', data: null, }; } + } - // 同步超话列表(模拟) - await this.syncTopics(userId); + /** + * 获取微博用户信息 + * 官方文档:https://open.weibo.com/wiki/2/users/show + */ + private async getWeiboUserInfo(accessToken: string, uid: string): Promise { + try { + const response = await fetch( + `https://api.weibo.com/2/users/show.json?access_token=${accessToken}&uid=${uid}` + ); - return { - code: 200, - msg: '绑定成功', - data: { - weiboName: mockWeiboName, - }, - }; + if (!response.ok) { + console.error('[微博API] 获取用户信息失败'); + return null; + } + + return await response.json(); + } catch (error) { + console.error('[微博API] 获取用户信息异常:', error); + return null; + } } /** @@ -147,11 +231,13 @@ export class WeiboService { /** * 同步超话列表(模拟) + * 注意:微博官方API不提供超话相关接口,以下为模拟数据 + * 实际使用需要通过非官方API或爬虫实现 */ - private async syncTopics(userId: number) { + private async syncTopics(userId: number, accessToken: string) { const client = getSupabaseClient(); - // 模拟数据:实际应调用微博API获取用户关注的超话 + // 模拟数据:实际应调用非官方API或爬虫 const mockTopics = [ { topicId: '100808100', topicName: '王一博超话', memberCount: 1234567 }, { topicId: '100808101', topicName: '肖站超话', memberCount: 2345678 }, @@ -174,6 +260,8 @@ export class WeiboService { ignoreDuplicates: true, }); } + + console.log(`[微博超话] 同步完成,共${mockTopics.length}个超话`); } /** @@ -194,7 +282,7 @@ export class WeiboService { // 检查是否过期 if (new Date(account.expires_at) < new Date()) { - // TODO: 使用refresh_token刷新 + // TODO: 微博OAuth2.0没有refresh_token,需要重新授权 return null; }