feat: 实现微博签到小程序功能

- 实现签到主页面,包含签到按钮、连续天数、今日状态展示
- 实现签到记录页面,包含日历视图和签到历史列表
- 实现个人中心页面,包含用户信息和签到统计
- 后端实现签到、查询状态、查询历史三个接口
- 使用 Supabase 存储签到记录数据
- 采用星空主题设计,深蓝紫渐变背景 + 金色星光强调色
- 完成所有接口测试和前后端匹配验证
- 通过 ESLint 检查和编译验证
This commit is contained in:
jaystar
2026-03-16 11:17:17 +08:00
commit e209fe02a4
64 changed files with 26475 additions and 0 deletions

10
server/nest-cli.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"exclude": ["node_modules", "dist", ".git", "../dist", "../src"]
},
"webpack": true
}

40
server/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "server",
"version": "1.0.0",
"private": true,
"description": "NestJS server application",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "nest start",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.958.0",
"@aws-sdk/lib-storage": "^3.958.0",
"@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@supabase/supabase-js": "2.95.3",
"better-sqlite3": "^11.9.1",
"coze-coding-dev-sdk": "^0.7.16",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3",
"express": "5.2.1",
"pg": "^8.16.3",
"rxjs": "^7.8.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "5.0.6",
"@types/node": "^22.10.2",
"drizzle-kit": "^0.31.8",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,23 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from '@/app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('hello')
getHello(): { status: string; data: string } {
return {
status: 'success',
data: this.appService.getHello()
};
}
@Get('health')
getHealth(): { status: string; data: string } {
return {
status: 'success',
data: new Date().toISOString(),
};
}
}

11
server/src/app.module.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AppController } from '@/app.controller';
import { AppService } from '@/app.service';
import { SignInModule } from '@/signin/signin.module';
@Module({
imports: [SignInModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello, welcome to coze coding mini-program server!';
}
}

View File

@@ -0,0 +1,23 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class HttpStatusInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
// 如果是 POST 请求且状态码为 201改为 200
if (request.method === 'POST' && response.statusCode === 201) {
response.status(200);
}
return next.handle();
}
}

49
server/src/main.ts Normal file
View File

@@ -0,0 +1,49 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '@/app.module';
import * as express from 'express';
import { HttpStatusInterceptor } from '@/interceptors/http-status.interceptor';
function parsePort(): number {
const args = process.argv.slice(2);
const portIndex = args.indexOf('-p');
if (portIndex !== -1 && args[portIndex + 1]) {
const port = parseInt(args[portIndex + 1], 10);
if (!isNaN(port) && port > 0 && port < 65536) {
return port;
}
}
return 3000;
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: true,
credentials: true,
});
app.setGlobalPrefix('api');
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// 全局拦截器:统一将 POST 请求的 201 状态码改为 200
app.useGlobalInterceptors(new HttpStatusInterceptor());
// 1. 开启优雅关闭 Hooks (关键!)
app.enableShutdownHooks();
// 2. 解析端口
const port = parsePort();
try {
await app.listen(port);
console.log(`Server running on http://localhost:${port}`);
} catch (err) {
if (err.code === 'EADDRINUSE') {
console.error(`❌ 端口 \({port} 被占用! 请运行 'npx kill-port \){port}' 然后重试。`);
process.exit(1);
} else {
throw err;
}
}
console.log(`Application is running on: http://localhost:3000`);
}
bootstrap();

View File

@@ -0,0 +1,41 @@
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);
}
}

View File

@@ -0,0 +1,10 @@
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 {}

View File

@@ -0,0 +1,148 @@
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,
};
}
}

View File

@@ -0,0 +1,3 @@
import { relations } from "drizzle-orm/relations";
import { } from "./schema";

View File

@@ -0,0 +1,24 @@
import { pgTable, serial, timestamp, varchar, date, index } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const healthCheck = pgTable("health_check", {
id: serial().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(),
});
// 签到记录表
export const signInRecords = pgTable(
"sign_in_records",
{
id: serial("id").primaryKey(),
userId: varchar("user_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),
]
);

View File

@@ -0,0 +1,115 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { execSync } from 'child_process';
let envLoaded = false;
interface SupabaseCredentials {
url: string;
anonKey: string;
}
function loadEnv(): void {
if (envLoaded || (process.env.COZE_SUPABASE_URL && process.env.COZE_SUPABASE_ANON_KEY)) {
return;
}
try {
try {
require('dotenv').config();
if (process.env.COZE_SUPABASE_URL && process.env.COZE_SUPABASE_ANON_KEY) {
envLoaded = true;
return;
}
} catch {
// dotenv not available
}
const pythonCode = `
import os
import sys
try:
from coze_workload_identity import Client
client = Client()
env_vars = client.get_project_env_vars()
client.close()
for env_var in env_vars:
print(f"{env_var.key}={env_var.value}")
except Exception as e:
print(f"# Error: {e}", file=sys.stderr)
`;
const output = execSync(`python3 -c '${pythonCode.replace(/'/g, "'\"'\"'")}'`, {
encoding: 'utf-8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const lines = output.trim().split('\n');
for (const line of lines) {
if (line.startsWith('#')) continue;
const eqIndex = line.indexOf('=');
if (eqIndex > 0) {
const key = line.substring(0, eqIndex);
let value = line.substring(eqIndex + 1);
if ((value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))) {
value = value.slice(1, -1);
}
if (!process.env[key]) {
process.env[key] = value;
}
}
}
envLoaded = true;
} catch {
// Silently fail
}
}
function getSupabaseCredentials(): SupabaseCredentials {
loadEnv();
const url = process.env.COZE_SUPABASE_URL;
const anonKey = process.env.COZE_SUPABASE_ANON_KEY;
if (!url) {
throw new Error('COZE_SUPABASE_URL is not set');
}
if (!anonKey) {
throw new Error('COZE_SUPABASE_ANON_KEY is not set');
}
return { url, anonKey };
}
function getSupabaseClient(token?: string): SupabaseClient {
const { url, anonKey } = getSupabaseCredentials();
if (token) {
return createClient(url, anonKey, {
global: {
headers: { Authorization: `Bearer ${token}` },
},
db: {
timeout: 60000,
},
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
return createClient(url, anonKey, {
db: {
timeout: 60000,
},
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
export { loadEnv, getSupabaseCredentials, getSupabaseClient };

24
server/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"@/*": ["./src/*"]
}
}
}