feat: 实现微博超话签到小程序功能
- 实现超话列表页面,显示关注超话和签到状态 - 实现一键批量签到功能,可一次性签到所有未签到超话 - 实现签到记录页面,包含日历视图和历史记录 - 实现个人中心页面,显示用户信息和签到统计 - 后端实现超话列表、签到、记录查询、用户信息四个接口 - 使用 Supabase 存储签到记录数据 - 采用微博风格设计,橙色主题 + 白色背景 - 完成所有接口测试和前后端匹配验证 - 通过 ESLint 检查和编译验证
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from '@/app.controller';
|
||||
import { AppService } from '@/app.service';
|
||||
import { SignInModule } from '@/signin/signin.module';
|
||||
import { SuperTopicModule } from '@/super-topic/super-topic.module';
|
||||
import { UserModule } from '@/user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [SignInModule],
|
||||
imports: [SuperTopicModule, UserModule],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { SignInService } from './signin.service';
|
||||
|
||||
@Controller('signin')
|
||||
export class SignInController {
|
||||
constructor(private readonly signInService: SignInService) {}
|
||||
|
||||
/**
|
||||
* 用户签到
|
||||
* POST /api/signin
|
||||
*/
|
||||
@Post()
|
||||
async signIn() {
|
||||
// 临时使用固定用户ID,实际应从 token 中获取
|
||||
const userId = 'default_user';
|
||||
return await this.signInService.signIn(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到状态
|
||||
* GET /api/signin/status
|
||||
*/
|
||||
@Get('status')
|
||||
async getStatus() {
|
||||
// 临时使用固定用户ID,实际应从 token 中获取
|
||||
const userId = 'default_user';
|
||||
return await this.signInService.getSignInStatus(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到历史记录
|
||||
* GET /api/signin/history
|
||||
*/
|
||||
@Get('history')
|
||||
async getHistory(@Query('limit') limit?: string) {
|
||||
// 临时使用固定用户ID,实际应从 token 中获取
|
||||
const userId = 'default_user';
|
||||
const limitNum = limit ? parseInt(limit, 10) : 30;
|
||||
return await this.signInService.getSignInHistory(userId, limitNum);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SignInController } from './signin.controller';
|
||||
import { SignInService } from './signin.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SignInController],
|
||||
providers: [SignInService],
|
||||
exports: [SignInService],
|
||||
})
|
||||
export class SignInModule {}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { getSupabaseClient } from '@/storage/database/supabase-client';
|
||||
|
||||
@Injectable()
|
||||
export class SignInService {
|
||||
/**
|
||||
* 用户签到
|
||||
*/
|
||||
async signIn(userId: string) {
|
||||
const client = getSupabaseClient();
|
||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
|
||||
// 检查今日是否已签到
|
||||
const { data: existingRecord } = await client
|
||||
.from('sign_in_records')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('sign_date', today)
|
||||
.single();
|
||||
|
||||
if (existingRecord) {
|
||||
return {
|
||||
code: 400,
|
||||
msg: '今日已签到',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 创建签到记录
|
||||
const { data, error } = await client
|
||||
.from('sign_in_records')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
sign_date: today,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('签到失败:', error);
|
||||
return {
|
||||
code: 500,
|
||||
msg: '签到失败',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
msg: '签到成功',
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到状态
|
||||
*/
|
||||
async getSignInStatus(userId: string) {
|
||||
const client = getSupabaseClient();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// 检查今日是否已签到
|
||||
const { data: todayRecord } = await client
|
||||
.from('sign_in_records')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('sign_date', today)
|
||||
.single();
|
||||
|
||||
// 获取所有签到记录
|
||||
const { data: allRecords } = await client
|
||||
.from('sign_in_records')
|
||||
.select('sign_date')
|
||||
.eq('user_id', userId)
|
||||
.order('sign_date', { ascending: false });
|
||||
|
||||
const totalDays = allRecords?.length || 0;
|
||||
|
||||
// 计算连续签到天数
|
||||
let continuousDays = 0;
|
||||
if (allRecords && allRecords.length > 0) {
|
||||
const dates = allRecords.map((r) => r.sign_date).sort().reverse();
|
||||
continuousDays = this.calculateContinuousDays(dates, today);
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
msg: 'success',
|
||||
data: {
|
||||
todaySignedIn: !!todayRecord,
|
||||
continuousDays,
|
||||
totalDays,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算连续签到天数
|
||||
*/
|
||||
private calculateContinuousDays(dates: string[], today: string): number {
|
||||
let continuousDays = 0;
|
||||
let checkDate = new Date(today);
|
||||
|
||||
for (const dateStr of dates) {
|
||||
const date = new Date(dateStr);
|
||||
const diffTime = checkDate.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0 || diffDays === 1) {
|
||||
continuousDays++;
|
||||
checkDate = date;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return continuousDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到历史记录
|
||||
*/
|
||||
async getSignInHistory(userId: string, limit: number = 30) {
|
||||
const client = getSupabaseClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('sign_in_records')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('sign_date', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
console.error('获取签到历史失败:', error);
|
||||
return {
|
||||
code: 500,
|
||||
msg: '获取签到历史失败',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
msg: 'success',
|
||||
data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,21 @@ export const healthCheck = pgTable("health_check", {
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(),
|
||||
});
|
||||
|
||||
// 签到记录表
|
||||
export const signInRecords = pgTable(
|
||||
"sign_in_records",
|
||||
// 超话签到记录表
|
||||
export const superTopicSignin = pgTable(
|
||||
"super_topic_signin",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 128 }).notNull(),
|
||||
topicId: varchar("topic_id", { length: 128 }).notNull(),
|
||||
signDate: date("sign_date").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("sign_in_records_user_id_idx").on(table.userId),
|
||||
index("sign_in_records_sign_date_idx").on(table.signDate),
|
||||
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),
|
||||
]
|
||||
);
|
||||
|
||||
41
server/src/super-topic/super-topic.controller.ts
Normal file
41
server/src/super-topic/super-topic.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
10
server/src/super-topic/super-topic.module.ts
Normal file
10
server/src/super-topic/super-topic.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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 {}
|
||||
195
server/src/super-topic/super-topic.service.ts
Normal file
195
server/src/super-topic/super-topic.service.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
18
server/src/user/user.controller.ts
Normal file
18
server/src/user/user.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
10
server/src/user/user.module.ts
Normal file
10
server/src/user/user.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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 {}
|
||||
59
server/src/user/user.service.ts
Normal file
59
server/src/user/user.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user