feat: 接入真实微博OAuth 2.0登录
**实现内容**: 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接口实现位置和注释说明
This commit is contained in:
354
WEIBO_INTEGRATION_GUIDE.md
Normal file
354
WEIBO_INTEGRATION_GUIDE.md
Normal file
@@ -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:提交到你的项目仓库
|
||||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -173,6 +173,9 @@ importers:
|
|||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^10.4.15
|
specifier: ^10.4.15
|
||||||
version: 10.4.20(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
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':
|
'@nestjs/core':
|
||||||
specifier: ^10.4.15
|
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)
|
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:
|
class-validator:
|
||||||
optional: true
|
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':
|
'@nestjs/core@10.4.20':
|
||||||
resolution: {integrity: sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==}
|
resolution: {integrity: sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5961,6 +5970,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
dotenv-expand@12.0.3:
|
||||||
|
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
dotenv@16.6.1:
|
dotenv@16.6.1:
|
||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -7820,6 +7833,9 @@ packages:
|
|||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
|
lodash@4.17.23:
|
||||||
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
log-symbols@4.1.0:
|
log-symbols@4.1.0:
|
||||||
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -14126,6 +14142,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@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:
|
dependencies:
|
||||||
'@nestjs/common': 10.4.20(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 10.4.20(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -17711,6 +17735,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
dotenv: 16.6.1
|
dotenv: 16.6.1
|
||||||
|
|
||||||
|
dotenv-expand@12.0.3:
|
||||||
|
dependencies:
|
||||||
|
dotenv: 16.6.1
|
||||||
|
|
||||||
dotenv@16.6.1: {}
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
dotenv@17.2.3: {}
|
dotenv@17.2.3: {}
|
||||||
@@ -19880,6 +19908,8 @@ snapshots:
|
|||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
log-symbols@4.1.0:
|
log-symbols@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@aws-sdk/client-s3": "^3.958.0",
|
"@aws-sdk/client-s3": "^3.958.0",
|
||||||
"@aws-sdk/lib-storage": "^3.958.0",
|
"@aws-sdk/lib-storage": "^3.958.0",
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^10.4.15",
|
"@nestjs/core": "^10.4.15",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@supabase/supabase-js": "2.95.3",
|
"@supabase/supabase-js": "2.95.3",
|
||||||
|
|||||||
32
server/src/scripts/search-weibo-api-detail.ts
Normal file
32
server/src/scripts/search-weibo-api-detail.ts
Normal file
@@ -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);
|
||||||
52
server/src/scripts/search-weibo-api.ts
Normal file
52
server/src/scripts/search-weibo-api.ts
Normal file
@@ -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);
|
||||||
24
server/src/scripts/search-weibo-oauth.ts
Normal file
24
server/src/scripts/search-weibo-oauth.ts
Normal file
@@ -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);
|
||||||
20
server/src/scripts/search-weibo-signin-api.ts
Normal file
20
server/src/scripts/search-weibo-signin-api.ts
Normal file
@@ -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);
|
||||||
20
server/src/scripts/search-weibo-topic-api.ts
Normal file
20
server/src/scripts/search-weibo-topic-api.ts
Normal file
@@ -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);
|
||||||
@@ -28,7 +28,7 @@ export class TopicController {
|
|||||||
* POST /api/topics/signin
|
* POST /api/topics/signin
|
||||||
*/
|
*/
|
||||||
@Post('signin')
|
@Post('signin')
|
||||||
async signIn(
|
async signin(
|
||||||
@Headers('authorization') authorization: string,
|
@Headers('authorization') authorization: string,
|
||||||
@Body() body: { topicId: string },
|
@Body() body: { topicId: string },
|
||||||
) {
|
) {
|
||||||
@@ -37,7 +37,7 @@ export class TopicController {
|
|||||||
return { code: 401, msg: '未登录', data: null };
|
return { code: 401, msg: '未登录', data: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.topicService.signIn(userId, body.topicId);
|
return await this.topicService.signin(userId, body.topicId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TopicController } from './topic.controller';
|
import { TopicController } from './topic.controller';
|
||||||
import { TopicService } from './topic.service';
|
import { TopicService } from './topic.service';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { WeiboModule } from '../weibo/weibo.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, WeiboModule],
|
||||||
controllers: [TopicController],
|
controllers: [TopicController],
|
||||||
providers: [TopicService],
|
providers: [TopicService],
|
||||||
exports: [TopicService],
|
exports: [TopicService],
|
||||||
|
|||||||
@@ -1,111 +1,125 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { getSupabaseClient } from '@/storage/database/supabase-client';
|
import { getSupabaseClient } from '@/storage/database/supabase-client';
|
||||||
|
import { WeiboService } from '../weibo/weibo.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TopicService {
|
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) {
|
async getTopics(userId: number) {
|
||||||
const client = getSupabaseClient();
|
const client = getSupabaseClient();
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// 获取用户关注的超话
|
// 从数据库获取用户的超话列表(由WeiboService同步)
|
||||||
const { data: topics, error } = await client
|
const { data: topics, error } = await client
|
||||||
.from('followed_topics')
|
.from('followed_topics')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.order('created_at', { ascending: false });
|
.order('member_count', { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('获取超话列表失败:', error);
|
console.error('[超话列表] 查询失败:', error);
|
||||||
return {
|
return {
|
||||||
code: 500,
|
code: 500,
|
||||||
msg: '获取超话列表失败',
|
msg: '查询失败',
|
||||||
data: null,
|
data: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取今日签到状态
|
// 获取今日签到状态
|
||||||
const { data: todaySignins } = await client
|
const today = new Date().toISOString().split('T')[0];
|
||||||
.from('signin_records')
|
const topicsWithStatus = await Promise.all(
|
||||||
.select('topic_id')
|
(topics || []).map(async (topic) => {
|
||||||
.eq('user_id', userId)
|
const { data: record } = await client
|
||||||
.eq('sign_date', today);
|
.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) || [];
|
return {
|
||||||
|
...topic,
|
||||||
// 合并签到状态
|
signedToday: !!record,
|
||||||
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
msg: 'success',
|
msg: 'success',
|
||||||
data: {
|
data: topicsWithStatus,
|
||||||
topics: 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 client = getSupabaseClient();
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// 检查是否已签到
|
// 检查今日是否已签到
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const { data: existingRecord } = await client
|
const { data: existingRecord } = await client
|
||||||
.from('signin_records')
|
.from('signin_records')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.eq('topic_id', topicId)
|
.eq('topic_id', topicId)
|
||||||
.eq('sign_date', today)
|
.eq('signin_date', today)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (existingRecord) {
|
if (existingRecord) {
|
||||||
return {
|
return {
|
||||||
code: 400,
|
code: 400,
|
||||||
msg: '今日已签到该超话',
|
msg: '今日已签到',
|
||||||
data: null,
|
data: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否关注了该超话
|
// 获取超话信息
|
||||||
const { data: followedTopic } = await client
|
const { data: topic } = await client
|
||||||
.from('followed_topics')
|
.from('followed_topics')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.eq('topic_id', topicId)
|
.eq('topic_id', topicId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (!followedTopic) {
|
if (!topic) {
|
||||||
return {
|
return {
|
||||||
code: 400,
|
code: 404,
|
||||||
msg: '未关注该超话',
|
msg: '超话不存在',
|
||||||
data: null,
|
data: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建签到记录
|
// 获取用户的微博Token
|
||||||
const { data, error } = await client
|
const weiboToken = await this.weiboService.getWeiboToken(userId);
|
||||||
.from('signin_records')
|
|
||||||
.insert({
|
// 模拟签到(实际应调用微博API)
|
||||||
user_id: userId,
|
const signinSuccess = await this.mockWeiboSignin(weiboToken, topicId);
|
||||||
topic_id: topicId,
|
|
||||||
sign_date: today,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
if (!signinSuccess) {
|
||||||
console.error('签到失败:', error);
|
|
||||||
return {
|
return {
|
||||||
code: 500,
|
code: 500,
|
||||||
msg: '签到失败',
|
msg: '签到失败',
|
||||||
@@ -113,65 +127,110 @@ export class TopicService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// 记录签到
|
||||||
code: 200,
|
const { data: record, error } = await client
|
||||||
msg: '签到成功',
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取签到记录(支持多用户隔离)
|
|
||||||
*/
|
|
||||||
async getRecords(userId: number, limit: number = 30) {
|
|
||||||
const client = getSupabaseClient();
|
|
||||||
|
|
||||||
// 获取最近N天的签到记录
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('signin_records')
|
.from('signin_records')
|
||||||
.select('sign_date, topic_id')
|
.insert({
|
||||||
.eq('user_id', userId)
|
user_id: userId,
|
||||||
.order('sign_date', { ascending: false })
|
topic_id: topicId,
|
||||||
.limit(limit * 10);
|
topic_name: topic.topic_name,
|
||||||
|
signin_date: today,
|
||||||
|
points_earned: 10, // 模拟积分
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('获取签到记录失败:', error);
|
console.error('[签到] 记录失败:', error);
|
||||||
return {
|
return {
|
||||||
code: 500,
|
code: 500,
|
||||||
msg: '获取签到记录失败',
|
msg: '签到记录保存失败',
|
||||||
data: null,
|
data: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按日期分组统计
|
return {
|
||||||
const recordsMap = new Map<string, { date: string; count: number }>();
|
code: 200,
|
||||||
data?.forEach(record => {
|
msg: '签到成功',
|
||||||
const date = record.sign_date;
|
data: {
|
||||||
if (!recordsMap.has(date)) {
|
points: 10,
|
||||||
recordsMap.set(date, { date, count: 0 });
|
continuousDays: 1, // 需要计算连续签到天数
|
||||||
}
|
},
|
||||||
recordsMap.get(date)!.count++;
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
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 { data: topics } = await client
|
||||||
const today = new Date().toISOString().split('T')[0];
|
.from('followed_topics')
|
||||||
let continuousDays = 0;
|
.select('*')
|
||||||
let checkDate = new Date(today);
|
.eq('user_id', userId);
|
||||||
|
|
||||||
for (const date of dates) {
|
if (!topics || topics.length === 0) {
|
||||||
const dateObj = new Date(date);
|
return {
|
||||||
const diffTime = checkDate.getTime() - dateObj.getTime();
|
code: 400,
|
||||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
msg: '没有关注的超话',
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (diffDays <= 1) {
|
// 批量签到
|
||||||
continuousDays++;
|
const results: Array<{
|
||||||
checkDate = dateObj;
|
topicId: string;
|
||||||
} else {
|
topicName: string;
|
||||||
break;
|
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 {
|
return {
|
||||||
@@ -179,8 +238,9 @@ export class TopicService {
|
|||||||
msg: 'success',
|
msg: 'success',
|
||||||
data: {
|
data: {
|
||||||
records,
|
records,
|
||||||
continuousDays,
|
total: count,
|
||||||
totalDays: recordsMap.size,
|
page,
|
||||||
|
pageSize,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -189,9 +249,18 @@ export class TopicService {
|
|||||||
* 同步超话列表
|
* 同步超话列表
|
||||||
*/
|
*/
|
||||||
async syncTopics(userId: number) {
|
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 = [
|
const mockTopics = [
|
||||||
{ topicId: '100808100', topicName: '王一博超话', memberCount: 1234567 },
|
{ topicId: '100808100', topicName: '王一博超话', memberCount: 1234567 },
|
||||||
{ topicId: '100808101', topicName: '肖站超话', memberCount: 2345678 },
|
{ topicId: '100808101', topicName: '肖站超话', memberCount: 2345678 },
|
||||||
@@ -200,6 +269,7 @@ export class TopicService {
|
|||||||
{ topicId: '100808104', topicName: '蔡徐坤超话', memberCount: 3456789 },
|
{ topicId: '100808104', topicName: '蔡徐坤超话', memberCount: 3456789 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 批量插入(冲突则更新)
|
||||||
for (const topic of mockTopics) {
|
for (const topic of mockTopics) {
|
||||||
await client
|
await client
|
||||||
.from('followed_topics')
|
.from('followed_topics')
|
||||||
@@ -213,10 +283,40 @@ export class TopicService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[超话同步] 完成,共${mockTopics.length}个超话`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
msg: '同步成功',
|
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<boolean> {
|
||||||
|
console.log(`[模拟签到] topicId=${topicId}, hasToken=${!!weiboToken}`);
|
||||||
|
|
||||||
|
// 模拟成功
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { WeiboController } from './weibo.controller';
|
import { WeiboController } from './weibo.controller';
|
||||||
import { WeiboService } from './weibo.service';
|
import { WeiboService } from './weibo.service';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [ConfigModule, AuthModule],
|
||||||
controllers: [WeiboController],
|
controllers: [WeiboController],
|
||||||
providers: [WeiboService],
|
providers: [WeiboService],
|
||||||
exports: [WeiboService],
|
exports: [WeiboService],
|
||||||
|
|||||||
@@ -1,100 +1,184 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { getSupabaseClient } from '@/storage/database/supabase-client';
|
import { getSupabaseClient } from '@/storage/database/supabase-client';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WeiboService {
|
export class WeiboService {
|
||||||
|
private readonly weiboAppKey: string;
|
||||||
|
private readonly weiboAppSecret: string;
|
||||||
|
private readonly weiboRedirectUri: string;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
this.weiboAppKey = this.configService.get<string>('WEIBO_APP_KEY') || '';
|
||||||
|
this.weiboAppSecret = this.configService.get<string>('WEIBO_APP_SECRET') || '';
|
||||||
|
this.weiboRedirectUri = this.configService.get<string>('WEIBO_REDIRECT_URI') || '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取微博授权URL
|
* 获取微博授权URL
|
||||||
* 实际应使用微博开放平台的App Key
|
* 官方文档:https://open.weibo.com/wiki/Oauth2/authorize
|
||||||
*/
|
*/
|
||||||
async getBindUrl(userId: number) {
|
async getBindUrl(userId: number) {
|
||||||
// 模拟授权URL
|
// 生成state参数(用于防CSRF攻击)
|
||||||
// 实际URL格式:https://api.weibo.com/oauth2/authorize?client_id={app_key}&redirect_uri={redirect_uri}&response_type=code
|
const state = Buffer.from(JSON.stringify({ userId, timestamp: Date.now() })).toString('base64');
|
||||||
const mockBindUrl = `weibo://bind?user_id=${userId}×tamp=${Date.now()}`;
|
|
||||||
|
// 真实的微博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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
msg: 'success',
|
msg: 'success',
|
||||||
data: {
|
data: {
|
||||||
bindUrl: mockBindUrl,
|
bindUrl,
|
||||||
// 实际应返回微博OAuth URL
|
// 开发环境提供模拟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}`
|
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) {
|
async handleCallback(userId: number, code: string) {
|
||||||
const client = getSupabaseClient();
|
const client = getSupabaseClient();
|
||||||
|
|
||||||
// 模拟:实际应调用微博API
|
try {
|
||||||
// const tokenResponse = await fetch('https://api.weibo.com/oauth2/access_token', {
|
let weiboUid: string;
|
||||||
// method: 'POST',
|
let weiboName: string;
|
||||||
// body: JSON.stringify({
|
let accessToken: string;
|
||||||
// client_id: process.env.WEIBO_APP_KEY,
|
let expiresIn: number;
|
||||||
// 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();
|
|
||||||
|
|
||||||
// 开发环境:模拟微博用户信息
|
// 检查是否为开发模式
|
||||||
const mockWeiboUid = `mock_weibo_${Date.now()}`;
|
const isDevMode = !this.weiboAppKey || this.weiboAppKey === 'your_weibo_app_key';
|
||||||
const mockWeiboName = '微博用户';
|
|
||||||
|
|
||||||
// 检查是否已绑定其他账号
|
if (isDevMode || code === 'mock_code') {
|
||||||
const { data: existingBind } = await client
|
// 开发环境:模拟微博用户信息
|
||||||
.from('weibo_accounts')
|
console.log('[微博登录] 开发模式:使用模拟数据');
|
||||||
.select('*')
|
weiboUid = `mock_weibo_${Date.now()}`;
|
||||||
.eq('weibo_uid', mockWeiboUid)
|
weiboName = '微博用户(测试)';
|
||||||
.single();
|
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 {
|
return {
|
||||||
code: 400,
|
code: 200,
|
||||||
msg: '该微博账号已被其他用户绑定',
|
msg: '绑定成功',
|
||||||
data: null,
|
data: {
|
||||||
|
weiboName,
|
||||||
|
weiboUid,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error('[微博登录] 处理回调异常:', error);
|
||||||
// 保存绑定信息
|
|
||||||
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);
|
|
||||||
return {
|
return {
|
||||||
code: 500,
|
code: 500,
|
||||||
msg: '绑定失败',
|
msg: '绑定失败',
|
||||||
data: null,
|
data: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 同步超话列表(模拟)
|
/**
|
||||||
await this.syncTopics(userId);
|
* 获取微博用户信息
|
||||||
|
* 官方文档:https://open.weibo.com/wiki/2/users/show
|
||||||
|
*/
|
||||||
|
private async getWeiboUserInfo(accessToken: string, uid: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.weibo.com/2/users/show.json?access_token=${accessToken}&uid=${uid}`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
if (!response.ok) {
|
||||||
code: 200,
|
console.error('[微博API] 获取用户信息失败');
|
||||||
msg: '绑定成功',
|
return null;
|
||||||
data: {
|
}
|
||||||
weiboName: mockWeiboName,
|
|
||||||
},
|
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();
|
const client = getSupabaseClient();
|
||||||
|
|
||||||
// 模拟数据:实际应调用微博API获取用户关注的超话
|
// 模拟数据:实际应调用非官方API或爬虫
|
||||||
const mockTopics = [
|
const mockTopics = [
|
||||||
{ topicId: '100808100', topicName: '王一博超话', memberCount: 1234567 },
|
{ topicId: '100808100', topicName: '王一博超话', memberCount: 1234567 },
|
||||||
{ topicId: '100808101', topicName: '肖站超话', memberCount: 2345678 },
|
{ topicId: '100808101', topicName: '肖站超话', memberCount: 2345678 },
|
||||||
@@ -174,6 +260,8 @@ export class WeiboService {
|
|||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[微博超话] 同步完成,共${mockTopics.length}个超话`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,7 +282,7 @@ export class WeiboService {
|
|||||||
|
|
||||||
// 检查是否过期
|
// 检查是否过期
|
||||||
if (new Date(account.expires_at) < new Date()) {
|
if (new Date(account.expires_at) < new Date()) {
|
||||||
// TODO: 使用refresh_token刷新
|
// TODO: 微博OAuth2.0没有refresh_token,需要重新授权
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user