fix: 修复编译错误并完成全栈验证
**前端修复**: - 移除重复的@tarojs/taro导入 - 完善UserInfo接口类型定义(新增weiboBound/weiboName字段) **后端修复**: - 安装jsonwebtoken及@types/jsonwebtoken依赖 **构建验证**: - 前端所有平台构建通过(H5/微信小程序/抖音小程序) - 后端NestJS构建通过 - ESLint检查通过 - TypeScript编译通过
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
52
server/src/auth/auth.controller.ts
Normal file
52
server/src/auth/auth.controller.ts
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
10
server/src/auth/auth.module.ts
Normal file
10
server/src/auth/auth.module.ts
Normal file
@@ -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 {}
|
||||
127
server/src/auth/auth.service.ts
Normal file
127
server/src/auth/auth.service.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<string, { date: string; count: number; topicIds: string[] }>();
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
82
server/src/topic/topic.controller.ts
Normal file
82
server/src/topic/topic.controller.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
12
server/src/topic/topic.module.ts
Normal file
12
server/src/topic/topic.module.ts
Normal file
@@ -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 {}
|
||||
222
server/src/topic/topic.service.ts
Normal file
222
server/src/topic/topic.service.ts
Normal file
@@ -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<string, { date: string; count: number }>();
|
||||
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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
92
server/src/weibo/weibo.controller.ts
Normal file
92
server/src/weibo/weibo.controller.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
12
server/src/weibo/weibo.module.ts
Normal file
12
server/src/weibo/weibo.module.ts
Normal file
@@ -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 {}
|
||||
203
server/src/weibo/weibo.service.ts
Normal file
203
server/src/weibo/weibo.service.ts
Normal file
@@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user