diff --git a/docs/user-system-design.md b/docs/user-system-design.md new file mode 100644 index 0000000..6008737 --- /dev/null +++ b/docs/user-system-design.md @@ -0,0 +1,214 @@ +# 用户系统与数据隔离设计方案 + +## 架构设计 + +### 用户体系 + +``` +微信用户(小程序用户) + └── 绑定微博账号 + └── 管理微博超话 + └── 超话签到记录 +``` + +### 数据隔离原则 + +- 每个微信用户通过 `openid` 唯一标识 +- 每个微信用户可以绑定一个微博账号 +- 微博账号通过 `weibo_uid` 唯一标识 +- 所有数据通过 `user_id`(微信用户ID)进行隔离 + +--- + +## 数据库表结构 + +### 1. 用户表 (users) + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + wechat_openid VARCHAR(64) NOT NULL UNIQUE, -- 微信openid + wechat_unionid VARCHAR(64), -- 微信unionid(可选) + nickname VARCHAR(128), -- 微信昵称 + avatar VARCHAR(512), -- 微信头像 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_users_wechat_openid ON users(wechat_openid); +``` + +### 2. 微博账号绑定表 (weibo_accounts) + +```sql +CREATE TABLE weibo_accounts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), -- 关联用户 + weibo_uid VARCHAR(64) NOT NULL, -- 微博用户ID + weibo_name VARCHAR(128), -- 微博昵称 + access_token TEXT, -- 微博访问令牌 + refresh_token TEXT, -- 刷新令牌 + expires_at TIMESTAMP WITH TIME ZONE, -- 令牌过期时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id), -- 一个微信用户只能绑定一个微博账号 + UNIQUE(weibo_uid) -- 一个微博账号只能被绑定一次 +); + +CREATE INDEX idx_weibo_accounts_user_id ON weibo_accounts(user_id); +CREATE INDEX idx_weibo_accounts_weibo_uid ON weibo_accounts(weibo_uid); +``` + +### 3. 关注的超话表 (followed_topics) + +```sql +CREATE TABLE followed_topics ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), -- 用户ID + topic_id VARCHAR(64) NOT NULL, -- 超话ID + topic_name VARCHAR(256) NOT NULL, -- 超话名称 + topic_cover VARCHAR(512), -- 超话封面 + member_count INTEGER DEFAULT 0, -- 成员数 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, topic_id) -- 防止重复关注 +); + +CREATE INDEX idx_followed_topics_user_id ON followed_topics(user_id); +CREATE INDEX idx_followed_topics_topic_id ON followed_topics(topic_id); +``` + +### 4. 签到记录表 (signin_records) + +```sql +CREATE TABLE signin_records ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), -- 用户ID + topic_id VARCHAR(64) NOT NULL, -- 超话ID + sign_date DATE NOT NULL, -- 签到日期 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, topic_id, sign_date) -- 每天每个超话只能签到一次 +); + +CREATE INDEX idx_signin_records_user_id ON signin_records(user_id); +CREATE INDEX idx_signin_records_topic_id ON signin_records(topic_id); +CREATE INDEX idx_signin_records_sign_date ON signin_records(sign_date); +``` + +--- + +## 认证流程 + +### 1. 微信小程序登录流程 + +``` +用户打开小程序 + ↓ +调用 wx.login() 获取 code + ↓ +发送 code 到后端 /api/auth/wechat-login + ↓ +后端调用微信API换取 openid + ↓ +创建/查询用户记录 + ↓ +返回 JWT token(包含 user_id) + ↓ +前端存储 token,后续请求携带 +``` + +### 2. 微博账号绑定流程 + +``` +用户点击"绑定微博" + ↓ +前端请求 /api/weibo/bind-url + ↓ +后端生成微博OAuth授权URL + ↓ +前端展示二维码或跳转授权页 + ↓ +用户扫码授权 + ↓ +微博回调到 /api/weibo/callback + ↓ +后端换取 access_token 和用户信息 + ↓ +保存微博账号绑定关系 + ↓ +同步用户关注的超话列表 +``` + +### 3. 微博API调用流程 + +``` +前端请求需要微博数据的接口 + ↓ +后端验证 JWT token,获取 user_id + ↓ +查询该用户的微博账号绑定信息 + ↓ +检查 access_token 是否过期 + ↓ +使用 access_token 调用微博API + ↓ +返回数据给前端 +``` + +--- + +## API设计 + +### 认证相关 + +- `POST /api/auth/wechat-login` - 微信登录 +- `POST /api/auth/logout` - 登出 +- `GET /api/auth/me` - 获取当前用户信息 + +### 微博绑定相关 + +- `GET /api/weibo/bind-url` - 获取微博授权URL +- `GET /api/weibo/callback` - 微博授权回调 +- `GET /api/weibo/status` - 查询微博绑定状态 +- `DELETE /api/weibo/unbind` - 解除微博绑定 + +### 超话管理相关 + +- `GET /api/topics` - 获取关注的超话列表(真实微博数据) +- `POST /api/topics/sync` - 同步超话列表 +- `POST /api/topics/signin` - 超话签到 +- `GET /api/topics/records` - 获取签到记录 + +--- + +## 安全考虑 + +1. **Token安全**:JWT token 设置合理过期时间,敏感操作需要验证 +2. **微博Token加密**:access_token 加密存储,定期检查过期 +3. **数据隔离**:所有查询必须带 user_id 条件 +4. **权限验证**:每个接口验证用户身份和微博绑定状态 + +--- + +## 前端存储 + +- 使用 Taro.setStorageSync 存储 JWT token +- 每次请求通过 header 携带 token +- 退出登录时清除 token + +--- + +## 注意事项 + +### 微博开放平台申请 + +需要申请以下权限: +- 用户基本信息读取 +- 用户关注的超话列表读取 +- 超话签到功能 + +### 开发环境模拟 + +由于微博API需要申请权限,开发阶段: +1. 模拟OAuth流程 +2. 使用模拟数据测试 +3. 预留真实API接口 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a45e857..1a822d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: express: specifier: 5.2.1 version: 5.2.1 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 pg: specifier: ^8.16.3 version: 8.17.2 @@ -225,6 +228,9 @@ importers: '@types/express': specifier: 5.0.6 version: 5.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^22.10.2 version: 22.19.6 @@ -4281,6 +4287,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -5193,6 +5202,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-equal@0.0.1: resolution: {integrity: sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==} engines: {node: '>=0.4.0'} @@ -6092,6 +6104,9 @@ packages: ecc-jsbn@0.1.2: resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -7481,6 +7496,10 @@ packages: jsonschema@1.5.0: resolution: {integrity: sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jsprim@1.4.2: resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} engines: {node: '>=0.6.0'} @@ -7495,6 +7514,12 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@3.0.0: resolution: {integrity: sha512-eguHnq22OE3uVoSYG0LVWNP+4ppamWr9+zWBe1bsNcovIMy6huUJFPgy4mGwCd/rnl3vOLGW1MTlu4c57CT1xA==} @@ -7737,15 +7762,30 @@ packages: lodash.flatten@4.4.0: resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} lodash.isarray@3.0.4: resolution: {integrity: sha512-JwObCrNJuT0Nnbuecmqr5DgtuBppuCvGD9lxjFpAzwnVtdGoDQ1zig+5W8k5/6Gcn0gZ3936HDAlGd28i7sOGQ==} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.keys@3.1.2: resolution: {integrity: sha512-CuBsapFjcubOGMn3VD+24HOAPxM79tH+V6ivJL3CHYjtrawauDJHUk//Yew9Hvc6e9rbCrURGk8z6PC+8WJBfQ==} @@ -7755,6 +7795,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.restparam@3.6.1: resolution: {integrity: sha512-L4/arjjuq4noiUJpt3yS6KIKDtJwNe2fIYgMqyYYKoeIfV1iEqvPwhCx23o+R9dzouGihDAPN1dTIRWa7zk8tw==} @@ -15420,6 +15463,11 @@ snapshots: '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.6 + '@types/keyv@3.1.4': dependencies: '@types/node': 22.19.6 @@ -16810,6 +16858,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-equal@0.0.1: {} buffer-fill@1.0.0: {} @@ -17749,6 +17799,10 @@ snapshots: jsbn: 0.1.1 safer-buffer: 2.1.2 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.267: {} @@ -19504,6 +19558,19 @@ snapshots: jsonschema@1.5.0: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + jsprim@1.4.2: dependencies: assert-plus: 1.0.0 @@ -19527,6 +19594,17 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@3.0.0: dependencies: json-buffer: 3.0.0 @@ -19747,12 +19825,22 @@ snapshots: lodash.flatten@4.4.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} lodash.isarray@3.0.4: {} + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + lodash.isplainobject@4.0.6: {} + lodash.isstring@4.0.1: {} + lodash.keys@3.1.2: dependencies: lodash._getnative: 3.9.1 @@ -19763,6 +19851,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.restparam@3.6.1: {} lodash.template@3.6.2: diff --git a/server/package.json b/server/package.json index b458538..f162ce1 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", "express": "5.2.1", + "jsonwebtoken": "^9.0.3", "pg": "^8.16.3", "rxjs": "^7.8.1", "zod": "^4.3.5" @@ -33,6 +34,7 @@ "@nestjs/schematics": "^10.2.3", "@types/better-sqlite3": "^7.6.13", "@types/express": "5.0.6", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.10.2", "drizzle-kit": "^0.31.8", "typescript": "^5.7.2" diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 6ff7fc8..af09e3c 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AppController } from '@/app.controller'; import { AppService } from '@/app.service'; -import { SuperTopicModule } from '@/super-topic/super-topic.module'; -import { UserModule } from '@/user/user.module'; +import { AuthModule } from '@/auth/auth.module'; +import { WeiboModule } from '@/weibo/weibo.module'; +import { TopicModule } from '@/topic/topic.module'; @Module({ - imports: [SuperTopicModule, UserModule], + imports: [AuthModule, WeiboModule, TopicModule], controllers: [AppController], providers: [AppService], }) diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts new file mode 100644 index 0000000..306afd8 --- /dev/null +++ b/server/src/auth/auth.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Post, Get, Headers, Body } from '@nestjs/common'; +import { AuthService } from './auth.service'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + /** + * 微信登录 + * POST /api/auth/wechat-login + */ + @Post('wechat-login') + async wechatLogin(@Body() body: { code: string }) { + return await this.authService.wechatLogin(body.code || 'default_code'); + } + + /** + * 获取当前用户信息 + * GET /api/auth/me + */ + @Get('me') + async getCurrentUser(@Headers('authorization') authorization: string) { + const token = authorization?.replace('Bearer ', ''); + if (!token) { + return { + code: 401, + msg: '未登录', + data: null, + }; + } + + const payload = this.authService.verifyToken(token); + if (!payload) { + return { + code: 401, + msg: 'token无效或已过期', + data: null, + }; + } + + return await this.authService.getCurrentUser(payload.userId); + } + + /** + * 开发环境:模拟登录(无需微信授权) + * POST /api/auth/dev-login + */ + @Post('dev-login') + async devLogin() { + return await this.authService.wechatLogin('dev_user'); + } +} diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts new file mode 100644 index 0000000..679a6f6 --- /dev/null +++ b/server/src/auth/auth.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +@Module({ + controllers: [AuthController], + providers: [AuthService], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts new file mode 100644 index 0000000..0b44e68 --- /dev/null +++ b/server/src/auth/auth.service.ts @@ -0,0 +1,127 @@ +import { Injectable } from '@nestjs/common'; +import { getSupabaseClient } from '@/storage/database/supabase-client'; +import * as jwt from 'jsonwebtoken'; + +@Injectable() +export class AuthService { + private readonly JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + private readonly JWT_EXPIRES_IN = '7d'; + + /** + * 微信登录(模拟) + * 实际应调用微信API:https://api.weixin.qq.com/sns/jscode2session + */ + async wechatLogin(code: string) { + const client = getSupabaseClient(); + + // 模拟:实际应调用微信API获取openid + // const response = await fetch(`https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`); + // const { openid } = await response.json(); + + // 开发环境:使用code作为openid(模拟) + const openid = `mock_openid_${code || Date.now()}`; + + // 查询或创建用户 + let { data: user, error } = await client + .from('users') + .select('*') + .eq('wechat_openid', openid) + .single(); + + if (!user) { + // 创建新用户 + const { data: newUser, error: createError } = await client + .from('users') + .insert({ + wechat_openid: openid, + nickname: '微信用户', + }) + .select() + .single(); + + if (createError) { + console.error('创建用户失败:', createError); + return { + code: 500, + msg: '创建用户失败', + data: null, + }; + } + user = newUser; + } + + // 生成JWT token + const token = jwt.sign( + { userId: user.id, openid: user.wechat_openid }, + this.JWT_SECRET, + { expiresIn: this.JWT_EXPIRES_IN } + ); + + return { + code: 200, + msg: '登录成功', + data: { + token, + user: { + id: user.id, + nickname: user.nickname, + avatar: user.avatar, + }, + }, + }; + } + + /** + * 获取当前用户信息 + */ + async getCurrentUser(userId: number) { + const client = getSupabaseClient(); + + const { data: user, error } = await client + .from('users') + .select(` + id, + nickname, + avatar, + weibo_accounts ( + weibo_uid, + weibo_name + ) + `) + .eq('id', userId) + .single(); + + if (error || !user) { + return { + code: 404, + msg: '用户不存在', + data: null, + }; + } + + const weiboAccount = (user as any).weibo_accounts?.[0]; + + return { + code: 200, + msg: 'success', + data: { + id: user.id, + nickname: user.nickname, + avatar: user.avatar, + weiboBound: !!weiboAccount, + weiboName: weiboAccount?.weibo_name || null, + }, + }; + } + + /** + * 验证JWT token + */ + verifyToken(token: string): { userId: number; openid: string } | null { + try { + return jwt.verify(token, this.JWT_SECRET) as any; + } catch (error) { + return null; + } + } +} diff --git a/server/src/storage/database/shared/schema.ts b/server/src/storage/database/shared/schema.ts index 82112bc..851f9cb 100644 --- a/server/src/storage/database/shared/schema.ts +++ b/server/src/storage/database/shared/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, serial, timestamp, varchar, date, index } from "drizzle-orm/pg-core" +import { pgTable, serial, timestamp, varchar, date, integer, text, uniqueIndex } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" export const healthCheck = pgTable("health_check", { @@ -6,21 +6,83 @@ export const healthCheck = pgTable("health_check", { updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(), }); -// 超话签到记录表 -export const superTopicSignin = pgTable( - "super_topic_signin", +// 用户表 +export const users = pgTable( + "users", { id: serial("id").primaryKey(), - userId: varchar("user_id", { length: 128 }).notNull(), - topicId: varchar("topic_id", { length: 128 }).notNull(), + wechatOpenid: varchar("wechat_openid", { length: 64 }).notNull().unique(), + wechatUnionid: varchar("wechat_unionid", { length: 64 }), + nickname: varchar("nickname", { length: 128 }), + avatar: varchar("avatar", { length: 512 }), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("users_wechat_openid_idx").on(table.wechatOpenid), + ] +); + +// 微博账号绑定表 +export const weiboAccounts = pgTable( + "weibo_accounts", + { + id: serial("id").primaryKey(), + userId: integer("user_id").notNull().references(() => users.id), + weiboUid: varchar("weibo_uid", { length: 64 }).notNull().unique(), + weiboName: varchar("weibo_name", { length: 128 }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("weibo_accounts_user_id_idx").on(table.userId), + uniqueIndex("weibo_accounts_weibo_uid_idx").on(table.weiboUid), + ] +); + +// 关注的超话表 +export const followedTopics = pgTable( + "followed_topics", + { + id: serial("id").primaryKey(), + userId: integer("user_id").notNull().references(() => users.id), + topicId: varchar("topic_id", { length: 64 }).notNull(), + topicName: varchar("topic_name", { length: 256 }).notNull(), + topicCover: varchar("topic_cover", { length: 512 }), + memberCount: integer("member_count").default(0), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("followed_topics_user_topic_idx").on(table.userId, table.topicId), + ] +); + +// 签到记录表 +export const signinRecords = pgTable( + "signin_records", + { + id: serial("id").primaryKey(), + userId: integer("user_id").notNull().references(() => users.id), + topicId: varchar("topic_id", { length: 64 }).notNull(), signDate: date("sign_date").notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }) .defaultNow() .notNull(), }, (table) => [ - index("super_topic_signin_user_id_idx").on(table.userId), - index("super_topic_signin_topic_id_idx").on(table.topicId), - index("super_topic_signin_sign_date_idx").on(table.signDate), + uniqueIndex("signin_records_user_topic_date_idx").on(table.userId, table.topicId, table.signDate), ] ); diff --git a/server/src/super-topic/super-topic.controller.ts b/server/src/super-topic/super-topic.controller.ts deleted file mode 100644 index 225bbc8..0000000 --- a/server/src/super-topic/super-topic.controller.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Controller, Get, Post, Body, Query } from '@nestjs/common'; -import { SuperTopicService } from './super-topic.service'; - -@Controller('super-topics') -export class SuperTopicController { - constructor(private readonly superTopicService: SuperTopicService) {} - - /** - * 获取超话列表 - * GET /api/super-topics - */ - @Get() - async getTopics() { - // 临时使用固定用户ID,实际应从 token 中获取 - const userId = 'default_user'; - return await this.superTopicService.getSuperTopics(userId); - } - - /** - * 超话签到 - * POST /api/super-topics/signin - */ - @Post('signin') - async signIn(@Body() body: { topicId: string }) { - // 临时使用固定用户ID,实际应从 token 中获取 - const userId = 'default_user'; - return await this.superTopicService.signIn(userId, body.topicId); - } - - /** - * 获取签到记录 - * GET /api/super-topics/records - */ - @Get('records') - async getRecords(@Query('limit') limit?: string) { - // 临时使用固定用户ID,实际应从 token 中获取 - const userId = 'default_user'; - const limitNum = limit ? parseInt(limit, 10) : 30; - return await this.superTopicService.getRecords(userId, limitNum); - } -} diff --git a/server/src/super-topic/super-topic.module.ts b/server/src/super-topic/super-topic.module.ts deleted file mode 100644 index 69eb915..0000000 --- a/server/src/super-topic/super-topic.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SuperTopicController } from './super-topic.controller'; -import { SuperTopicService } from './super-topic.service'; - -@Module({ - controllers: [SuperTopicController], - providers: [SuperTopicService], - exports: [SuperTopicService], -}) -export class SuperTopicModule {} diff --git a/server/src/super-topic/super-topic.service.ts b/server/src/super-topic/super-topic.service.ts deleted file mode 100644 index cbc8fea..0000000 --- a/server/src/super-topic/super-topic.service.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { getSupabaseClient } from '@/storage/database/supabase-client'; - -@Injectable() -export class SuperTopicService { - /** - * 获取超话列表(模拟数据) - * TODO: 接入真实微博API - */ - async getSuperTopics(userId: string) { - // 模拟数据 - 实际应调用微博API获取用户关注的超话列表 - const mockTopics = [ - { - id: '100808100', - name: '王一博超话', - cover: 'https://picsum.photos/seed/topic1/200/200', - memberCount: 1234567, - isSignedIn: false, - }, - { - id: '100808101', - name: '肖站超话', - cover: 'https://picsum.photos/seed/topic2/200/200', - memberCount: 2345678, - isSignedIn: true, - }, - { - id: '100808102', - name: '杨幂超话', - cover: 'https://picsum.photos/seed/topic3/200/200', - memberCount: 987654, - isSignedIn: false, - }, - { - id: '100808103', - name: '迪丽热巴超话', - cover: 'https://picsum.photos/seed/topic4/200/200', - memberCount: 1567890, - isSignedIn: true, - }, - { - id: '100808104', - name: '蔡徐坤超话', - cover: 'https://picsum.photos/seed/topic5/200/200', - memberCount: 3456789, - isSignedIn: false, - }, - ]; - - // 从数据库获取今日签到状态 - const client = getSupabaseClient(); - const today = new Date().toISOString().split('T')[0]; - - const { data: todayRecords } = await client - .from('super_topic_signin') - .select('topic_id') - .eq('user_id', userId) - .eq('sign_date', today); - - const signedTopicIds = todayRecords?.map(r => r.topic_id) || []; - - // 合并签到状态 - const topics = mockTopics.map(topic => ({ - ...topic, - isSignedIn: signedTopicIds.includes(topic.id), - })); - - return { - code: 200, - msg: 'success', - data: { - topics, - }, - }; - } - - /** - * 超话签到 - */ - async signIn(userId: string, topicId: string) { - const client = getSupabaseClient(); - const today = new Date().toISOString().split('T')[0]; - - // 检查是否已签到 - const { data: existingRecord } = await client - .from('super_topic_signin') - .select('*') - .eq('user_id', userId) - .eq('topic_id', topicId) - .eq('sign_date', today) - .single(); - - if (existingRecord) { - return { - code: 400, - msg: '今日已签到该超话', - data: null, - }; - } - - // 创建签到记录 - const { data, error } = await client - .from('super_topic_signin') - .insert({ - user_id: userId, - topic_id: topicId, - sign_date: today, - }) - .select() - .single(); - - if (error) { - console.error('签到失败:', error); - console.error('错误详情:', JSON.stringify(error, null, 2)); - return { - code: 500, - msg: `签到失败: ${error.message || '未知错误'}`, - data: null, - }; - } - - return { - code: 200, - msg: '签到成功', - data, - }; - } - - /** - * 获取签到记录 - */ - async getRecords(userId: string, limit: number = 30) { - const client = getSupabaseClient(); - - // 获取最近N天的签到记录 - const { data, error } = await client - .from('super_topic_signin') - .select('sign_date') - .eq('user_id', userId) - .order('sign_date', { ascending: false }) - .limit(limit * 10); // 假设每天最多签到10个超话 - - if (error) { - console.error('获取签到记录失败:', error); - console.error('错误详情:', JSON.stringify(error, null, 2)); - return { - code: 500, - msg: `获取签到记录失败: ${error.message || '未知错误'}`, - data: null, - }; - } - - // 按日期分组统计 - const recordsMap = new Map(); - data?.forEach(record => { - const date = record.sign_date; - if (!recordsMap.has(date)) { - recordsMap.set(date, { date, count: 0, topicIds: [] }); - } - const item = recordsMap.get(date)!; - item.count++; - }); - - const records = Array.from(recordsMap.values()).slice(0, limit); - - // 计算连续签到天数 - const dates = Array.from(recordsMap.keys()).sort().reverse(); - const today = new Date().toISOString().split('T')[0]; - let continuousDays = 0; - let checkDate = new Date(today); - - 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 (diffDays === 0 || diffDays === 1) { - continuousDays++; - checkDate = dateObj; - } else { - break; - } - } - - return { - code: 200, - msg: 'success', - data: { - records, - continuousDays, - totalDays: recordsMap.size, - }, - }; - } -} diff --git a/server/src/topic/topic.controller.ts b/server/src/topic/topic.controller.ts new file mode 100644 index 0000000..22b926a --- /dev/null +++ b/server/src/topic/topic.controller.ts @@ -0,0 +1,82 @@ +import { Controller, Get, Post, Body, Query, Headers } from '@nestjs/common'; +import { TopicService } from './topic.service'; +import { AuthService } from '../auth/auth.service'; + +@Controller('topics') +export class TopicController { + constructor( + private readonly topicService: TopicService, + private readonly authService: AuthService, + ) {} + + /** + * 获取超话列表 + * GET /api/topics + */ + @Get() + async getTopics(@Headers('authorization') authorization: string) { + const userId = this.extractUserId(authorization); + if (!userId) { + return { code: 401, msg: '未登录', data: null }; + } + + return await this.topicService.getTopics(userId); + } + + /** + * 超话签到 + * POST /api/topics/signin + */ + @Post('signin') + async signIn( + @Headers('authorization') authorization: string, + @Body() body: { topicId: string }, + ) { + const userId = this.extractUserId(authorization); + if (!userId) { + return { code: 401, msg: '未登录', data: null }; + } + + return await this.topicService.signIn(userId, body.topicId); + } + + /** + * 获取签到记录 + * GET /api/topics/records + */ + @Get('records') + async getRecords( + @Headers('authorization') authorization: string, + @Query('limit') limit?: string, + ) { + const userId = this.extractUserId(authorization); + if (!userId) { + return { code: 401, msg: '未登录', data: null }; + } + + const limitNum = limit ? parseInt(limit, 10) : 30; + return await this.topicService.getRecords(userId, limitNum); + } + + /** + * 同步超话列表 + * POST /api/topics/sync + */ + @Post('sync') + async syncTopics(@Headers('authorization') authorization: string) { + const userId = this.extractUserId(authorization); + if (!userId) { + return { code: 401, msg: '未登录', data: null }; + } + + return await this.topicService.syncTopics(userId); + } + + private extractUserId(authorization: string): number | null { + const token = authorization?.replace('Bearer ', ''); + if (!token) return null; + + const payload = this.authService.verifyToken(token); + return payload?.userId || null; + } +} diff --git a/server/src/topic/topic.module.ts b/server/src/topic/topic.module.ts new file mode 100644 index 0000000..985b8ed --- /dev/null +++ b/server/src/topic/topic.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TopicController } from './topic.controller'; +import { TopicService } from './topic.service'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [TopicController], + providers: [TopicService], + exports: [TopicService], +}) +export class TopicModule {} diff --git a/server/src/topic/topic.service.ts b/server/src/topic/topic.service.ts new file mode 100644 index 0000000..d7eec42 --- /dev/null +++ b/server/src/topic/topic.service.ts @@ -0,0 +1,222 @@ +import { Injectable } from '@nestjs/common'; +import { getSupabaseClient } from '@/storage/database/supabase-client'; + +@Injectable() +export class TopicService { + /** + * 获取超话列表(支持多用户隔离) + */ + async getTopics(userId: number) { + const client = getSupabaseClient(); + const today = new Date().toISOString().split('T')[0]; + + // 获取用户关注的超话 + const { data: topics, error } = await client + .from('followed_topics') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (error) { + console.error('获取超话列表失败:', error); + return { + code: 500, + msg: '获取超话列表失败', + data: null, + }; + } + + // 获取今日签到状态 + const { data: todaySignins } = await client + .from('signin_records') + .select('topic_id') + .eq('user_id', userId) + .eq('sign_date', today); + + 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 { + code: 200, + msg: 'success', + data: { + topics: topicsWithStatus, + }, + }; + } + + /** + * 超话签到(支持多用户隔离) + */ + async signIn(userId: number, topicId: string) { + const client = getSupabaseClient(); + 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) + .single(); + + if (existingRecord) { + return { + code: 400, + msg: '今日已签到该超话', + data: null, + }; + } + + // 检查是否关注了该超话 + const { data: followedTopic } = await client + .from('followed_topics') + .select('*') + .eq('user_id', userId) + .eq('topic_id', topicId) + .single(); + + if (!followedTopic) { + return { + code: 400, + msg: '未关注该超话', + data: null, + }; + } + + // 创建签到记录 + const { data, error } = await client + .from('signin_records') + .insert({ + user_id: userId, + topic_id: topicId, + sign_date: today, + }) + .select() + .single(); + + if (error) { + console.error('签到失败:', error); + return { + code: 500, + msg: '签到失败', + data: null, + }; + } + + return { + code: 200, + msg: '签到成功', + data, + }; + } + + /** + * 获取签到记录(支持多用户隔离) + */ + async getRecords(userId: number, limit: number = 30) { + const client = getSupabaseClient(); + + // 获取最近N天的签到记录 + const { data, error } = await client + .from('signin_records') + .select('sign_date, topic_id') + .eq('user_id', userId) + .order('sign_date', { ascending: false }) + .limit(limit * 10); + + if (error) { + console.error('获取签到记录失败:', error); + return { + code: 500, + 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++; + }); + + const records = Array.from(recordsMap.values()).slice(0, limit); + + // 计算连续签到天数 + const dates = Array.from(recordsMap.keys()).sort().reverse(); + const today = new Date().toISOString().split('T')[0]; + let continuousDays = 0; + let checkDate = new Date(today); + + 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 (diffDays <= 1) { + continuousDays++; + checkDate = dateObj; + } else { + break; + } + } + + return { + code: 200, + msg: 'success', + data: { + records, + continuousDays, + totalDays: recordsMap.size, + }, + }; + } + + /** + * 同步超话列表 + */ + async syncTopics(userId: number) { + const client = getSupabaseClient(); + + // 模拟数据:实际应调用微博API + const mockTopics = [ + { topicId: '100808100', topicName: '王一博超话', memberCount: 1234567 }, + { topicId: '100808101', topicName: '肖站超话', memberCount: 2345678 }, + { topicId: '100808102', topicName: '杨幂超话', memberCount: 987654 }, + { topicId: '100808103', topicName: '迪丽热巴超话', memberCount: 1567890 }, + { topicId: '100808104', topicName: '蔡徐坤超话', memberCount: 3456789 }, + ]; + + for (const topic of mockTopics) { + await client + .from('followed_topics') + .upsert({ + user_id: userId, + topic_id: topic.topicId, + topic_name: topic.topicName, + member_count: topic.memberCount, + }, { + onConflict: 'user_id,topic_id', + }); + } + + return { + code: 200, + msg: '同步成功', + data: { count: mockTopics.length }, + }; + } +} diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts deleted file mode 100644 index 7b7f5b3..0000000 --- a/server/src/user/user.controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { UserService } from './user.service'; - -@Controller('user') -export class UserController { - constructor(private readonly userService: UserService) {} - - /** - * 获取用户信息 - * GET /api/user/info - */ - @Get('info') - async getUserInfo() { - // 临时使用固定用户ID,实际应从 token 中获取 - const userId = 'default_user'; - return await this.userService.getUserInfo(userId); - } -} diff --git a/server/src/user/user.module.ts b/server/src/user/user.module.ts deleted file mode 100644 index ca5060e..0000000 --- a/server/src/user/user.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { UserController } from './user.controller'; -import { UserService } from './user.service'; - -@Module({ - controllers: [UserController], - providers: [UserService], - exports: [UserService], -}) -export class UserModule {} diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts deleted file mode 100644 index d8a2f69..0000000 --- a/server/src/user/user.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { getSupabaseClient } from '@/storage/database/supabase-client'; - -@Injectable() -export class UserService { - /** - * 获取用户信息(模拟数据) - * TODO: 接入真实微博API - */ - async getUserInfo(userId: string) { - const client = getSupabaseClient(); - - // 获取签到统计 - const { data: signinData } = await client - .from('super_topic_signin') - .select('sign_date, topic_id') - .eq('user_id', userId); - - // 计算统计数据 - const uniqueDates = new Set(signinData?.map(r => r.sign_date) || []); - const uniqueTopics = new Set(signinData?.map(r => r.topic_id) || []); - - // 计算最长连续签到天数 - let maxContinuousDays = 0; - if (uniqueDates.size > 0) { - const sortedDates = Array.from(uniqueDates).sort().reverse(); - let currentStreak = 0; - let checkDate = new Date(); - - for (const date of sortedDates) { - const dateObj = new Date(date); - const diffTime = checkDate.getTime() - dateObj.getTime(); - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); - - if (diffDays <= 1) { - currentStreak++; - checkDate = dateObj; - } else { - break; - } - } - maxContinuousDays = currentStreak; - } - - // 模拟用户信息 - 实际应从微博API获取 - return { - code: 200, - msg: 'success', - data: { - nickname: '微博用户', - avatar: '', - userId: userId, - totalTopics: uniqueTopics.size || 5, // 模拟关注超话数 - totalDays: uniqueDates.size || 0, - maxContinuousDays, - }, - }; - } -} diff --git a/server/src/weibo/weibo.controller.ts b/server/src/weibo/weibo.controller.ts new file mode 100644 index 0000000..5321d17 --- /dev/null +++ b/server/src/weibo/weibo.controller.ts @@ -0,0 +1,92 @@ +import { Controller, Get, Post, Delete, Headers, Query } from '@nestjs/common'; +import { WeiboService } from './weibo.service'; +import { AuthService } from '../auth/auth.service'; + +@Controller('weibo') +export class WeiboController { + constructor( + private readonly weiboService: WeiboService, + private readonly authService: AuthService, + ) {} + + /** + * 获取微博授权URL + * GET /api/weibo/bind-url + */ + @Get('bind-url') + async getBindUrl(@Headers('authorization') authorization: string) { + const userId = this.extractUserId(authorization); + if (!userId) { + return { code: 401, msg: '未登录', data: null }; + } + + return await this.weiboService.getBindUrl(userId); + } + + /** + * 微博授权回调 + * GET /api/weibo/callback + */ + @Get('callback') + async handleCallback( + @Headers('authorization') authorization: string, + @Query('code') code: string, + ) { + const userId = this.extractUserId(authorization); + if (!userId) { + return { code: 401, msg: '未登录', data: null }; + } + + return await this.weiboService.handleCallback(userId, code); + } + + /** + * 获取微博绑定状态 + * GET /api/weibo/status + */ + @Get('status') + async getBindStatus(@Headers('authorization') authorization: string) { + const userId = this.extractUserId(authorization); + if (!userId) { + return { code: 401, msg: '未登录', data: null }; + } + + return await this.weiboService.getBindStatus(userId); + } + + /** + * 解除微博绑定 + * DELETE /api/weibo/unbind + */ + @Delete('unbind') + async unbind(@Headers('authorization') authorization: string) { + const userId = this.extractUserId(authorization); + if (!userId) { + return { code: 401, msg: '未登录', data: null }; + } + + return await this.weiboService.unbind(userId); + } + + /** + * 开发环境:模拟绑定微博 + * POST /api/weibo/dev-bind + */ + @Post('dev-bind') + async devBind(@Headers('authorization') authorization: string) { + const userId = this.extractUserId(authorization); + if (!userId) { + return { code: 401, msg: '未登录', data: null }; + } + + return await this.weiboService.handleCallback(userId, 'mock_code'); + } + + private extractUserId(authorization: string): number | null { + const token = authorization?.replace('Bearer ', ''); + if (!token) return null; + + const payload = this.authService.verifyToken(token); + return payload?.userId || null; + } +} diff --git a/server/src/weibo/weibo.module.ts b/server/src/weibo/weibo.module.ts new file mode 100644 index 0000000..fae66a6 --- /dev/null +++ b/server/src/weibo/weibo.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WeiboController } from './weibo.controller'; +import { WeiboService } from './weibo.service'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [WeiboController], + providers: [WeiboService], + exports: [WeiboService], +}) +export class WeiboModule {} diff --git a/server/src/weibo/weibo.service.ts b/server/src/weibo/weibo.service.ts new file mode 100644 index 0000000..e5fd2c2 --- /dev/null +++ b/server/src/weibo/weibo.service.ts @@ -0,0 +1,203 @@ +import { Injectable } from '@nestjs/common'; +import { getSupabaseClient } from '@/storage/database/supabase-client'; + +@Injectable() +export class WeiboService { + /** + * 获取微博授权URL + * 实际应使用微博开放平台的App Key + */ + 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()}`; + + 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}` + }, + }; + } + + /** + * 微博授权回调 + * 实际应调用微博API换取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(); + + // 开发环境:模拟微博用户信息 + const mockWeiboUid = `mock_weibo_${Date.now()}`; + const mockWeiboName = '微博用户'; + + // 检查是否已绑定其他账号 + const { data: existingBind } = await client + .from('weibo_accounts') + .select('*') + .eq('weibo_uid', mockWeiboUid) + .single(); + + if (existingBind) { + return { + code: 400, + msg: '该微博账号已被其他用户绑定', + data: null, + }; + } + + // 保存绑定信息 + 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 { + code: 500, + msg: '绑定失败', + data: null, + }; + } + + // 同步超话列表(模拟) + await this.syncTopics(userId); + + return { + code: 200, + msg: '绑定成功', + data: { + weiboName: mockWeiboName, + }, + }; + } + + /** + * 获取微博绑定状态 + */ + async getBindStatus(userId: number) { + const client = getSupabaseClient(); + + const { data: weiboAccount } = await client + .from('weibo_accounts') + .select('*') + .eq('user_id', userId) + .single(); + + return { + code: 200, + msg: 'success', + data: { + bound: !!weiboAccount, + weiboName: weiboAccount?.weibo_name || null, + }, + }; + } + + /** + * 解除微博绑定 + */ + async unbind(userId: number) { + const client = getSupabaseClient(); + + const { error } = await client + .from('weibo_accounts') + .delete() + .eq('user_id', userId); + + if (error) { + return { + code: 500, + msg: '解绑失败', + data: null, + }; + } + + return { + code: 200, + msg: '解绑成功', + data: null, + }; + } + + /** + * 同步超话列表(模拟) + */ + private async syncTopics(userId: number) { + const client = getSupabaseClient(); + + // 模拟数据:实际应调用微博API获取用户关注的超话 + const mockTopics = [ + { topicId: '100808100', topicName: '王一博超话', memberCount: 1234567 }, + { topicId: '100808101', topicName: '肖站超话', memberCount: 2345678 }, + { topicId: '100808102', topicName: '杨幂超话', memberCount: 987654 }, + { topicId: '100808103', topicName: '迪丽热巴超话', memberCount: 1567890 }, + { topicId: '100808104', topicName: '蔡徐坤超话', memberCount: 3456789 }, + ]; + + // 批量插入(冲突则忽略) + for (const topic of mockTopics) { + await client + .from('followed_topics') + .upsert({ + user_id: userId, + topic_id: topic.topicId, + topic_name: topic.topicName, + member_count: topic.memberCount, + }, { + onConflict: 'user_id,topic_id', + ignoreDuplicates: true, + }); + } + } + + /** + * 获取用户微博Token + */ + async getWeiboToken(userId: number): Promise { + const client = getSupabaseClient(); + + const { data: account } = await client + .from('weibo_accounts') + .select('access_token, expires_at') + .eq('user_id', userId) + .single(); + + if (!account) { + return null; + } + + // 检查是否过期 + if (new Date(account.expires_at) < new Date()) { + // TODO: 使用refresh_token刷新 + return null; + } + + return account.access_token; + } +} diff --git a/src/app.config.ts b/src/app.config.ts index 61388e6..daedfa2 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -1,5 +1,6 @@ export default defineAppConfig({ pages: [ + 'pages/login/index', 'pages/index/index', 'pages/record/index', 'pages/profile/index' diff --git a/src/network.ts b/src/network.ts index 88107ff..1fcbf26 100644 --- a/src/network.ts +++ b/src/network.ts @@ -4,6 +4,7 @@ import Taro from '@tarojs/taro' * 网络请求模块 * 封装 Taro.request、Taro.uploadFile、Taro.downloadFile,自动添加项目域名前缀 * 如果请求的 url 以 http:// 或 https:// 开头,则不会添加域名前缀 + * 自动携带 JWT token * * IMPORTANT: 项目已经全局注入 PROJECT_DOMAIN * IMPORTANT: 除非你需要添加全局参数,如给所有请求加上 header,否则不能修改此文件 @@ -16,23 +17,37 @@ export namespace Network { return `${PROJECT_DOMAIN}${url}` } + const addAuthHeader = (option: any): any => { + const token = Taro.getStorageSync('token') + if (token) { + return { + ...option, + header: { + ...option.header, + Authorization: `Bearer ${token}` + } + } + } + return option + } + export const request: typeof Taro.request = option => { return Taro.request({ - ...option, + ...addAuthHeader(option), url: createUrl(option.url), }) } export const uploadFile: typeof Taro.uploadFile = option => { return Taro.uploadFile({ - ...option, + ...addAuthHeader(option), url: createUrl(option.url), }) } export const downloadFile: typeof Taro.downloadFile = option => { return Taro.downloadFile({ - ...option, + ...addAuthHeader(option), url: createUrl(option.url), }) } diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index 30fe9ca..95224a8 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -1,5 +1,5 @@ import { View, Text, Image } from '@tarojs/components' -import { useDidShow, usePullDownRefresh } from '@tarojs/taro' +import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro' import { FC, useState } from 'react' import { Network } from '@/network' import './index.css' @@ -34,6 +34,28 @@ const IndexPage: FC = () => { // 页面显示时获取超话列表 useDidShow(async () => { + // 检查登录状态 + const token = Taro.getStorageSync('token') + if (!token) { + Taro.redirectTo({ url: '/pages/login/index' }) + return + } + + // 检查微博绑定状态 + try { + const statusRes = await Network.request({ + url: '/api/weibo/status', + method: 'GET' + }) + + if (statusRes.data?.code === 200 && !statusRes.data.data.bound) { + Taro.redirectTo({ url: '/pages/login/index' }) + return + } + } catch (error) { + console.error('检查绑定状态失败:', error) + } + await fetchTopics() }) @@ -47,7 +69,7 @@ const IndexPage: FC = () => { try { setLoading(true) const res = await Network.request({ - url: '/api/super-topics', + url: '/api/topics', method: 'GET' }) console.log('超话列表:', res.data) @@ -84,7 +106,7 @@ const IndexPage: FC = () => { for (const topic of unsignedTopics) { try { const res = await Network.request({ - url: '/api/super-topics/signin', + url: '/api/topics/signin', method: 'POST', data: { topicId: topic.id } }) diff --git a/src/pages/login/index.config.ts b/src/pages/login/index.config.ts new file mode 100644 index 0000000..fd85973 --- /dev/null +++ b/src/pages/login/index.config.ts @@ -0,0 +1,11 @@ +export default typeof definePageConfig === 'function' + ? definePageConfig({ + navigationBarTitleText: '登录', + navigationBarBackgroundColor: '#f97316', + navigationBarTextStyle: 'white' + }) + : { + navigationBarTitleText: '登录', + navigationBarBackgroundColor: '#f97316', + navigationBarTextStyle: 'white' + } diff --git a/src/pages/login/index.css b/src/pages/login/index.css new file mode 100644 index 0000000..0cf944d --- /dev/null +++ b/src/pages/login/index.css @@ -0,0 +1 @@ +/* 登录页面样式 */ diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx new file mode 100644 index 0000000..1853b5c --- /dev/null +++ b/src/pages/login/index.tsx @@ -0,0 +1,224 @@ +import { View, Text, Button } from '@tarojs/components' +import { FC, useState, useEffect } from 'react' +import Taro from '@tarojs/taro' +import { Network } from '@/network' +import './index.css' + +/** + * 登录页面 + */ +const LoginPage: FC = () => { + const [loading, setLoading] = useState(false) + const [weiboBound, setWeiboBound] = useState(false) + const [userInfo, setUserInfo] = useState(null) + + useEffect(() => { + checkLogin() + }, []) + + // 检查登录状态 + const checkLogin = async () => { + const token = Taro.getStorageSync('token') + if (!token) { + return + } + + try { + const res = await Network.request({ + url: '/api/auth/me', + method: 'GET', + header: { + Authorization: `Bearer ${token}` + } + }) + + if (res.data?.code === 200) { + setUserInfo(res.data.data) + setWeiboBound(res.data.data.weiboBound) + + // 如果已绑定微博,跳转到首页 + if (res.data.data.weiboBound) { + Taro.switchTab({ url: '/pages/index/index' }) + } + } else { + // token无效,清除 + Taro.removeStorageSync('token') + } + } catch (error) { + console.error('检查登录状态失败:', error) + } + } + + // 微信登录 + const handleWechatLogin = async () => { + if (loading) return + + setLoading(true) + try { + // 开发环境:使用模拟登录 + const res = await Network.request({ + url: '/api/auth/dev-login', + method: 'POST' + }) + + console.log('登录结果:', res.data) + + if (res.data?.code === 200 && res.data?.data?.token) { + // 保存token + Taro.setStorageSync('token', res.data.data.token) + setUserInfo(res.data.data.user) + + // 检查微博绑定状态 + await checkWeiboBind() + } else { + Taro.showToast({ + title: '登录失败', + icon: 'none' + }) + } + } catch (error) { + console.error('登录失败:', error) + Taro.showToast({ + title: '登录失败', + icon: 'none' + }) + } finally { + setLoading(false) + } + } + + // 检查微博绑定状态 + const checkWeiboBind = async () => { + const token = Taro.getStorageSync('token') + try { + const res = await Network.request({ + url: '/api/weibo/status', + method: 'GET', + header: { + Authorization: `Bearer ${token}` + } + }) + + if (res.data?.code === 200) { + setWeiboBound(res.data.data.bound) + if (res.data.data.bound) { + // 已绑定,跳转首页 + setTimeout(() => { + Taro.switchTab({ url: '/pages/index/index' }) + }, 1000) + } + } + } catch (error) { + console.error('检查微博绑定状态失败:', error) + } + } + + // 绑定微博 + const handleBindWeibo = async () => { + if (loading) return + + setLoading(true) + try { + const token = Taro.getStorageSync('token') + const res = await Network.request({ + url: '/api/weibo/dev-bind', + method: 'POST', + header: { + Authorization: `Bearer ${token}` + } + }) + + console.log('绑定结果:', res.data) + + if (res.data?.code === 200) { + Taro.showToast({ + title: '绑定成功', + icon: 'success' + }) + + setTimeout(() => { + Taro.switchTab({ url: '/pages/index/index' }) + }, 1500) + } else { + Taro.showToast({ + title: res.data?.msg || '绑定失败', + icon: 'none' + }) + } + } catch (error) { + console.error('绑定失败:', error) + Taro.showToast({ + title: '绑定失败', + icon: 'none' + }) + } finally { + setLoading(false) + } + } + + return ( + + {/* 顶部区域 */} + + + + + 微博超话签到 + 每天签到,轻松管理超话 + + + {/* 登录区域 */} + + {!userInfo ? ( + // 未登录 + + + + 登录即表示同意用户协议和隐私政策 + + + ) : !weiboBound ? ( + // 已登录但未绑定微博 + + + + 欢迎,{userInfo.nickname || '用户'} + + + 请绑定微博账号以使用超话签到功能 + + + + + 绑定后可同步超话列表并一键签到 + + + ) : ( + // 已绑定 + + + ✓ 微博账号已绑定 + + + 正在跳转... + + + )} + + + ) +} + +export default LoginPage diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index fd40d92..4aa38f8 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,5 +1,5 @@ import { View, Text } from '@tarojs/components' -import { useDidShow } from '@tarojs/taro' +import Taro, { useDidShow } from '@tarojs/taro' import { FC, useState } from 'react' import { Network } from '@/network' import './index.css' @@ -8,6 +8,8 @@ interface UserInfo { nickname: string avatar: string userId: string + weiboBound?: boolean + weiboName?: string } interface UserStats { @@ -33,6 +35,13 @@ const ProfilePage: FC = () => { // 页面显示时获取用户信息 useDidShow(async () => { + // 检查登录状态 + const token = Taro.getStorageSync('token') + if (!token) { + Taro.redirectTo({ url: '/pages/login/index' }) + return + } + await fetchUserInfo() }) @@ -40,7 +49,7 @@ const ProfilePage: FC = () => { const fetchUserInfo = async () => { try { const res = await Network.request({ - url: '/api/user/info', + url: '/api/auth/me', method: 'GET' }) console.log('用户信息:', res.data) @@ -61,6 +70,20 @@ const ProfilePage: FC = () => { } } + // 退出登录 + const handleLogout = () => { + Taro.showModal({ + title: '提示', + content: '确定要退出登录吗?', + success: (res) => { + if (res.confirm) { + Taro.removeStorageSync('token') + Taro.redirectTo({ url: '/pages/login/index' }) + } + } + }) + } + return ( {/* 用户信息卡片 */} @@ -75,8 +98,10 @@ const ProfilePage: FC = () => { {userInfo.nickname} - {userInfo.userId && ( - ID: {userInfo.userId} + {userInfo.weiboBound && ( + + 微博:{userInfo.weiboName} + )} @@ -120,11 +145,21 @@ const ProfilePage: FC = () => { {/* 提示信息 */} - + - 温馨提示:本工具需要登录微博账号才能使用。签到功能依赖于微博官方接口,请合理使用。 + 温馨提示:本工具需要登录并绑定微博账号才能使用。签到功能依赖于微博官方接口,请合理使用。 + + {/* 退出登录按钮 */} + + + 退出登录 + + ) } diff --git a/src/pages/record/index.tsx b/src/pages/record/index.tsx index 101a720..b706430 100644 --- a/src/pages/record/index.tsx +++ b/src/pages/record/index.tsx @@ -1,5 +1,5 @@ import { View, Text } from '@tarojs/components' -import { useDidShow } from '@tarojs/taro' +import Taro, { useDidShow } from '@tarojs/taro' import { FC, useState } from 'react' import { Network } from '@/network' import './index.css' @@ -24,6 +24,13 @@ const RecordPage: FC = () => { // 页面显示时获取记录 useDidShow(async () => { + // 检查登录状态 + const token = Taro.getStorageSync('token') + if (!token) { + Taro.redirectTo({ url: '/pages/login/index' }) + return + } + await fetchRecords() }) @@ -32,7 +39,7 @@ const RecordPage: FC = () => { try { setLoading(true) const res = await Network.request({ - url: '/api/super-topics/records', + url: '/api/topics/records', method: 'GET' }) console.log('签到记录:', res.data)