feat: 实现微博签到小程序功能
- 实现签到主页面,包含签到按钮、连续天数、今日状态展示 - 实现签到记录页面,包含日历视图和签到历史列表 - 实现个人中心页面,包含用户信息和签到统计 - 后端实现签到、查询状态、查询历史三个接口 - 使用 Supabase 存储签到记录数据 - 采用星空主题设计,深蓝紫渐变背景 + 金色星光强调色 - 完成所有接口测试和前后端匹配验证 - 通过 ESLint 检查和编译验证
This commit is contained in:
10
server/nest-cli.json
Normal file
10
server/nest-cli.json
Normal 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
40
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
server/src/app.controller.ts
Normal file
23
server/src/app.controller.ts
Normal 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
11
server/src/app.module.ts
Normal 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 {}
|
||||
8
server/src/app.service.ts
Normal file
8
server/src/app.service.ts
Normal 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!';
|
||||
}
|
||||
}
|
||||
23
server/src/interceptors/http-status.interceptor.ts
Normal file
23
server/src/interceptors/http-status.interceptor.ts
Normal 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
49
server/src/main.ts
Normal 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();
|
||||
41
server/src/signin/signin.controller.ts
Normal file
41
server/src/signin/signin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
server/src/signin/signin.module.ts
Normal file
10
server/src/signin/signin.module.ts
Normal 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 {}
|
||||
148
server/src/signin/signin.service.ts
Normal file
148
server/src/signin/signin.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
3
server/src/storage/database/shared/relations.ts
Normal file
3
server/src/storage/database/shared/relations.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { relations } from "drizzle-orm/relations";
|
||||
import { } from "./schema";
|
||||
|
||||
24
server/src/storage/database/shared/schema.ts
Normal file
24
server/src/storage/database/shared/schema.ts
Normal 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),
|
||||
]
|
||||
);
|
||||
115
server/src/storage/database/supabase-client.ts
Executable file
115
server/src/storage/database/supabase-client.ts
Executable 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
24
server/tsconfig.json
Normal 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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user