feat: 实现减脂体重管理App完整功能
- 实现拍照识别食物功能(集成大语言模型视觉能力) - 实现智能对话功能(集成大语言模型流式输出) - 实现食物记录和卡路里管理功能 - 实现体重记录和统计功能 - 实现健康数据管理页面 - 配置数据库表结构(用户、食物记录、体重记录) - 实现Express后端API路由 - 配置Tab导航和前端页面 - 采用健康运动配色方案
This commit is contained in:
21
server/build.js
Normal file
21
server/build.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('./package.json');
|
||||
const dependencies = pkg.dependencies || {};
|
||||
const externalList = Object.keys(dependencies).filter(dep => dep !== 'dayjs');
|
||||
try {
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/index.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
outdir: 'dist',
|
||||
external: externalList,
|
||||
});
|
||||
console.log('⚡ Build complete!');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
32
server/package.json
Normal file
32
server/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "bash ../.cozeproj/scripts/server_dev_run.sh",
|
||||
"build": "node build.js",
|
||||
"start": "NODE_ENV=production PORT=${PORT:-5000} node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.22.1",
|
||||
"cors": "^2.8.5",
|
||||
"coze-coding-dev-sdk": "^0.7.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.16.3",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"tsx": "^4.21.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"esbuild": "0.27.2",
|
||||
"typescript": "^5.8.3",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
}
|
||||
}
|
||||
31
server/src/index.ts
Normal file
31
server/src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import usersRouter from './routes/users.js';
|
||||
import foodRecordsRouter from './routes/food-records.js';
|
||||
import weightRecordsRouter from './routes/weight-records.js';
|
||||
import aiChatRouter from './routes/ai-chat.js';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 9091;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
// Health check
|
||||
app.get('/api/v1/health', (req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// API Routes
|
||||
// 注意:静态路由必须在动态路由之前
|
||||
app.use('/api/v1/users', usersRouter);
|
||||
app.use('/api/v1/food-records', foodRecordsRouter);
|
||||
app.use('/api/v1/weight-records', weightRecordsRouter);
|
||||
app.use('/api/v1/ai-chat', aiChatRouter);
|
||||
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server listening at http://localhost:${port}/`);
|
||||
});
|
||||
72
server/src/routes/ai-chat.ts
Normal file
72
server/src/routes/ai-chat.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Router } from 'express';
|
||||
import { LLMClient, Config } from 'coze-coding-dev-sdk';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 初始化大语言模型
|
||||
const llmConfig = new Config();
|
||||
const llmClient = new LLMClient(llmConfig);
|
||||
|
||||
// AI 对话接口(流式)
|
||||
router.post('/chat', async (req, res) => {
|
||||
try {
|
||||
const { message, conversationHistory } = req.body;
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Message is required'
|
||||
});
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, no-transform, must-revalidate');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// 构建消息历史
|
||||
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个专业的健康饮食和减脂顾问助手。你的任务是帮助用户进行科学的体重管理,提供以下方面的建议:\n\n1. 饮食建议:根据用户的饮食习惯和目标,提供个性化的饮食方案\n2. 营养分析:分析食物的营养成分,推荐健康的食物选择\n3. 减脂指导:提供科学的减脂方法和运动建议\n4. 激励支持:鼓励用户坚持健康生活方式\n\n请用友好、专业、鼓舞人心的语气与用户交流,避免提供极端或有害的减脂建议。'
|
||||
}
|
||||
];
|
||||
|
||||
// 如果有对话历史,添加到消息列表
|
||||
if (conversationHistory && Array.isArray(conversationHistory)) {
|
||||
messages.push(...conversationHistory);
|
||||
}
|
||||
|
||||
// 添加当前用户消息
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: message
|
||||
});
|
||||
|
||||
// 流式生成响应
|
||||
const stream = llmClient.stream(messages, {
|
||||
model: 'doubao-seed-1-8-251228',
|
||||
temperature: 0.7
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.content) {
|
||||
const text = chunk.content.toString();
|
||||
// 发送 SSE 事件
|
||||
res.write(`data: ${JSON.stringify({ content: text })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送结束标记
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('AI chat error:', error);
|
||||
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
229
server/src/routes/food-records.ts
Normal file
229
server/src/routes/food-records.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { foodRecordManager } from '../storage/database';
|
||||
import { S3Storage } from 'coze-coding-dev-sdk';
|
||||
import { LLMClient, Config } from 'coze-coding-dev-sdk';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 配置 multer 中间件
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 50 * 1024 * 1024 } // 限制 50MB
|
||||
});
|
||||
|
||||
// 初始化对象存储
|
||||
const storage = new S3Storage({
|
||||
endpointUrl: process.env.COZE_BUCKET_ENDPOINT_URL,
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
bucketName: process.env.COZE_BUCKET_NAME,
|
||||
region: 'cn-beijing',
|
||||
});
|
||||
|
||||
// 初始化大语言模型
|
||||
const llmConfig = new Config();
|
||||
const llmClient = new LLMClient(llmConfig);
|
||||
|
||||
// 获取食物记录列表
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.query;
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'userId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const { mealType, startDate, endDate } = req.query;
|
||||
|
||||
const records = await foodRecordManager.getFoodRecords({
|
||||
userId,
|
||||
mealType: mealType as string,
|
||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: records
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 创建食物记录(手动添加)
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const record = await foodRecordManager.createFoodRecord(req.body);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: record
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 删除食物记录
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await foodRecordManager.deleteFoodRecord(id);
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message: success ? 'Record deleted successfully' : 'Record not found'
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 拍照识别食物
|
||||
router.post('/recognize', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'No image file provided'
|
||||
});
|
||||
}
|
||||
|
||||
const { buffer, originalname, mimetype } = req.file;
|
||||
|
||||
// 1. 上传图片到对象存储
|
||||
const imageKey = await storage.uploadFile({
|
||||
fileContent: buffer,
|
||||
fileName: `food-photos/${originalname}`,
|
||||
contentType: mimetype,
|
||||
});
|
||||
|
||||
// 生成图片访问 URL
|
||||
const imageUrl = await storage.generatePresignedUrl({
|
||||
key: imageKey,
|
||||
expireTime: 86400 // 1天有效期
|
||||
});
|
||||
|
||||
// 2. 调用大语言模型识别食物
|
||||
const base64Image = buffer.toString('base64');
|
||||
const dataUri = `data:${mimetype};base64,${base64Image}`;
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: '你是一个专业的食物营养分析助手。请分析图片中的食物,识别食物名称、估算重量,并计算热量。返回JSON格式,包含:foodName(食物名称)、weight(估算重量,单位克)、calories(估算热量,单位千卡)。'
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: '请识别这张图片中的食物,并告诉我食物名称、估算重量和热量。'
|
||||
},
|
||||
{
|
||||
type: 'image_url' as const,
|
||||
image_url: {
|
||||
url: dataUri,
|
||||
detail: 'high' as const
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const response = await llmClient.invoke(messages, {
|
||||
model: 'doubao-seed-1-6-vision-250815',
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
// 3. 解析AI返回的结果
|
||||
let foodInfo: any = {};
|
||||
try {
|
||||
// 尝试从响应中提取JSON
|
||||
const jsonMatch = response.content.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
foodInfo = JSON.parse(jsonMatch[0]);
|
||||
} else {
|
||||
// 如果没有返回JSON,使用默认值
|
||||
foodInfo = {
|
||||
foodName: '未知食物',
|
||||
weight: 100,
|
||||
calories: 100
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
// JSON解析失败,使用默认值
|
||||
foodInfo = {
|
||||
foodName: '未知食物',
|
||||
weight: 100,
|
||||
calories: 100
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 返回识别结果
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
foodName: foodInfo.foodName || '未知食物',
|
||||
weight: Number(foodInfo.weight) || 100,
|
||||
calories: Number(foodInfo.calories) || 100,
|
||||
imageUrl,
|
||||
imageKey,
|
||||
aiResponse: response.content
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Food recognition error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to recognize food'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取今日总热量
|
||||
router.get('/total-calories', async (req, res) => {
|
||||
try {
|
||||
const { userId, date } = req.query;
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'userId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const queryDate = date ? new Date(date as string) : new Date();
|
||||
const totalCalories = await foodRecordManager.getTotalCaloriesByDate(userId, queryDate);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
date: queryDate.toISOString().split('T')[0],
|
||||
totalCalories
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
81
server/src/routes/users.ts
Normal file
81
server/src/routes/users.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Router } from 'express';
|
||||
import { userManager } from '../storage/database';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 获取用户信息
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.query;
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'userId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await userManager.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 创建用户
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const user = await userManager.createUser(req.body);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 更新用户信息
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const user = await userManager.updateUser(id, req.body);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
99
server/src/routes/weight-records.ts
Normal file
99
server/src/routes/weight-records.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Router } from 'express';
|
||||
import { weightRecordManager } from '../storage/database';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 获取体重记录列表
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.query;
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'userId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
const records = await weightRecordManager.getWeightRecords({
|
||||
userId,
|
||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: records
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 创建体重记录
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const record = await weightRecordManager.createWeightRecord(req.body);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: record
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 删除体重记录
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await weightRecordManager.deleteWeightRecord(id);
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message: success ? 'Record deleted successfully' : 'Record not found'
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取体重统计
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.query;
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'userId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const stats = await weightRecordManager.getWeightStats(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
84
server/src/storage/database/foodRecordManager.ts
Normal file
84
server/src/storage/database/foodRecordManager.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { eq, and, SQL, desc, gte, lte } from "drizzle-orm";
|
||||
import { getDb } from "coze-coding-dev-sdk";
|
||||
import { foodRecords, insertFoodRecordSchema } from "./shared/schema";
|
||||
import type { FoodRecord, InsertFoodRecord } from "./shared/schema";
|
||||
|
||||
export class FoodRecordManager {
|
||||
async createFoodRecord(data: InsertFoodRecord): Promise<FoodRecord> {
|
||||
const db = await getDb();
|
||||
const validated = insertFoodRecordSchema.parse(data);
|
||||
const [record] = await db.insert(foodRecords).values(validated).returning();
|
||||
return record;
|
||||
}
|
||||
|
||||
async getFoodRecords(options: {
|
||||
userId: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
mealType?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}): Promise<FoodRecord[]> {
|
||||
const { userId, skip = 0, limit = 100, mealType, startDate, endDate } = options;
|
||||
const db = await getDb();
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
conditions.push(eq(foodRecords.userId, userId));
|
||||
|
||||
if (mealType) {
|
||||
conditions.push(eq(foodRecords.mealType, mealType));
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(gte(foodRecords.recordedAt, startDate));
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(lte(foodRecords.recordedAt, endDate));
|
||||
}
|
||||
|
||||
let query = db.select().from(foodRecords).$dynamic();
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
|
||||
return query.orderBy(desc(foodRecords.recordedAt)).limit(limit).offset(skip);
|
||||
}
|
||||
|
||||
async getFoodRecordById(id: string): Promise<FoodRecord | null> {
|
||||
const db = await getDb();
|
||||
const [record] = await db.select().from(foodRecords).where(eq(foodRecords.id, id));
|
||||
return record || null;
|
||||
}
|
||||
|
||||
async deleteFoodRecord(id: string): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const result = await db.delete(foodRecords).where(eq(foodRecords.id, id));
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async getTotalCaloriesByDate(userId: string, date: Date): Promise<number> {
|
||||
const db = await getDb();
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const records = await db
|
||||
.select()
|
||||
.from(foodRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(foodRecords.userId, userId),
|
||||
gte(foodRecords.recordedAt, startOfDay),
|
||||
lte(foodRecords.recordedAt, endOfDay)
|
||||
)
|
||||
);
|
||||
|
||||
return records.reduce((sum, record) => sum + record.calories, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export const foodRecordManager = new FoodRecordManager();
|
||||
7
server/src/storage/database/index.ts
Normal file
7
server/src/storage/database/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Export instances
|
||||
export { userManager } from "./userManager";
|
||||
export { foodRecordManager } from "./foodRecordManager";
|
||||
export { weightRecordManager } from "./weightRecordManager";
|
||||
|
||||
// Export types and schemas
|
||||
export * from "./shared/schema";
|
||||
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";
|
||||
|
||||
130
server/src/storage/database/shared/schema.ts
Normal file
130
server/src/storage/database/shared/schema.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { pgTable, text, varchar, timestamp, boolean, integer, decimal, jsonb, index } from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { createSchemaFactory } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
|
||||
// 用户表
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: varchar("id", { length: 36 })
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
name: varchar("name", { length: 128 }).notNull(),
|
||||
gender: varchar("gender", { length: 10 }), // 'male' | 'female' | 'other'
|
||||
age: integer("age"),
|
||||
height: integer("height"), // 身高(厘米)
|
||||
targetWeight: decimal("target_weight", { precision: 5, scale: 2 }), // 目标体重(千克)
|
||||
currentWeight: decimal("current_weight", { precision: 5, scale: 2 }), // 当前体重(千克)
|
||||
metadata: jsonb("metadata"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
nameIdx: index("users_name_idx").on(table.name),
|
||||
})
|
||||
);
|
||||
|
||||
// 食物记录表
|
||||
export const foodRecords = pgTable(
|
||||
"food_records",
|
||||
{
|
||||
id: varchar("id", { length: 36 })
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id", { length: 36 }).notNull(),
|
||||
foodName: varchar("food_name", { length: 256 }).notNull(),
|
||||
calories: integer("calories").notNull(), // 热量(千卡)
|
||||
weight: decimal("weight", { precision: 5, scale: 2 }), // 重量(克)
|
||||
imageUrl: text("image_url"), // 食物图片 URL
|
||||
mealType: varchar("meal_type", { length: 20 }).notNull(), // 'breakfast' | 'lunch' | 'dinner' | 'snack'
|
||||
recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(), // 记录时间
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("food_records_user_id_idx").on(table.userId),
|
||||
recordedAtIdx: index("food_records_recorded_at_idx").on(table.recordedAt),
|
||||
})
|
||||
);
|
||||
|
||||
// 体重记录表
|
||||
export const weightRecords = pgTable(
|
||||
"weight_records",
|
||||
{
|
||||
id: varchar("id", { length: 36 })
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id", { length: 36 }).notNull(),
|
||||
weight: decimal("weight", { precision: 5, scale: 2 }).notNull(), // 体重(千克)
|
||||
note: text("note"), // 备注
|
||||
recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(), // 记录时间
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("weight_records_user_id_idx").on(table.userId),
|
||||
recordedAtIdx: index("weight_records_recorded_at_idx").on(table.recordedAt),
|
||||
})
|
||||
);
|
||||
|
||||
// 使用 createSchemaFactory 配置 date coercion(处理前端 string → Date 转换)
|
||||
const { createInsertSchema: createCoercedInsertSchema } = createSchemaFactory({
|
||||
coerce: { date: true },
|
||||
});
|
||||
|
||||
// Users schemas
|
||||
export const insertUserSchema = createCoercedInsertSchema(users).pick({
|
||||
name: true,
|
||||
gender: true,
|
||||
age: true,
|
||||
height: true,
|
||||
targetWeight: true,
|
||||
currentWeight: true,
|
||||
metadata: true,
|
||||
});
|
||||
|
||||
export const updateUserSchema = createCoercedInsertSchema(users)
|
||||
.pick({
|
||||
name: true,
|
||||
gender: true,
|
||||
age: true,
|
||||
height: true,
|
||||
targetWeight: true,
|
||||
currentWeight: true,
|
||||
})
|
||||
.partial();
|
||||
|
||||
// Food records schemas
|
||||
export const insertFoodRecordSchema = createCoercedInsertSchema(foodRecords).pick({
|
||||
userId: true,
|
||||
foodName: true,
|
||||
calories: true,
|
||||
weight: true,
|
||||
imageUrl: true,
|
||||
mealType: true,
|
||||
recordedAt: true,
|
||||
});
|
||||
|
||||
// Weight records schemas
|
||||
export const insertWeightRecordSchema = createCoercedInsertSchema(weightRecords).pick({
|
||||
userId: true,
|
||||
weight: true,
|
||||
note: true,
|
||||
recordedAt: true,
|
||||
});
|
||||
|
||||
// TypeScript types
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||
export type UpdateUser = z.infer<typeof updateUserSchema>;
|
||||
|
||||
export type FoodRecord = typeof foodRecords.$inferSelect;
|
||||
export type InsertFoodRecord = z.infer<typeof insertFoodRecordSchema>;
|
||||
|
||||
export type WeightRecord = typeof weightRecords.$inferSelect;
|
||||
export type InsertWeightRecord = z.infer<typeof insertWeightRecordSchema>;
|
||||
47
server/src/storage/database/userManager.ts
Normal file
47
server/src/storage/database/userManager.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { eq, and, SQL } from "drizzle-orm";
|
||||
import { getDb } from "coze-coding-dev-sdk";
|
||||
import { users, insertUserSchema, updateUserSchema } from "./shared/schema";
|
||||
import type { User, InsertUser, UpdateUser } from "./shared/schema";
|
||||
|
||||
export class UserManager {
|
||||
async createUser(data: InsertUser): Promise<User> {
|
||||
const db = await getDb();
|
||||
const validated = insertUserSchema.parse(data);
|
||||
const [user] = await db.insert(users).values(validated).returning();
|
||||
return user;
|
||||
}
|
||||
|
||||
async getUsers(options: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
} = {}): Promise<User[]> {
|
||||
const { skip = 0, limit = 100 } = options;
|
||||
const db = await getDb();
|
||||
return db.select().from(users).limit(limit).offset(skip);
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
const db = await getDb();
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
async updateUser(id: string, data: UpdateUser): Promise<User | null> {
|
||||
const db = await getDb();
|
||||
const validated = updateUserSchema.parse(data);
|
||||
const [user] = await db
|
||||
.update(users)
|
||||
.set({ ...validated, updatedAt: new Date() })
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
return user || null;
|
||||
}
|
||||
|
||||
async deleteUser(id: string): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const result = await db.delete(users).where(eq(users.id, id));
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const userManager = new UserManager();
|
||||
97
server/src/storage/database/weightRecordManager.ts
Normal file
97
server/src/storage/database/weightRecordManager.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { eq, and, SQL, desc, gte, lte } from "drizzle-orm";
|
||||
import { getDb } from "coze-coding-dev-sdk";
|
||||
import { weightRecords, insertWeightRecordSchema } from "./shared/schema";
|
||||
import type { WeightRecord, InsertWeightRecord } from "./shared/schema";
|
||||
|
||||
export class WeightRecordManager {
|
||||
async createWeightRecord(data: InsertWeightRecord): Promise<WeightRecord> {
|
||||
const db = await getDb();
|
||||
const validated = insertWeightRecordSchema.parse(data);
|
||||
const [record] = await db.insert(weightRecords).values(validated).returning();
|
||||
return record;
|
||||
}
|
||||
|
||||
async getWeightRecords(options: {
|
||||
userId: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}): Promise<WeightRecord[]> {
|
||||
const { userId, skip = 0, limit = 100, startDate, endDate } = options;
|
||||
const db = await getDb();
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
conditions.push(eq(weightRecords.userId, userId));
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(gte(weightRecords.recordedAt, startDate));
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(lte(weightRecords.recordedAt, endDate));
|
||||
}
|
||||
|
||||
let query = db.select().from(weightRecords).$dynamic();
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
|
||||
return query.orderBy(desc(weightRecords.recordedAt)).limit(limit).offset(skip);
|
||||
}
|
||||
|
||||
async getWeightRecordById(id: string): Promise<WeightRecord | null> {
|
||||
const db = await getDb();
|
||||
const [record] = await db.select().from(weightRecords).where(eq(weightRecords.id, id));
|
||||
return record || null;
|
||||
}
|
||||
|
||||
async deleteWeightRecord(id: string): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const result = await db.delete(weightRecords).where(eq(weightRecords.id, id));
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async getLatestWeight(userId: string): Promise<number | null> {
|
||||
const db = await getDb();
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(weightRecords)
|
||||
.where(eq(weightRecords.userId, userId))
|
||||
.orderBy(desc(weightRecords.recordedAt))
|
||||
.limit(1);
|
||||
|
||||
return record ? parseFloat(record.weight) : null;
|
||||
}
|
||||
|
||||
async getWeightStats(userId: string): Promise<{
|
||||
currentWeight: number | null;
|
||||
startWeight: number | null;
|
||||
targetWeight: number | null;
|
||||
}> {
|
||||
const db = await getDb();
|
||||
|
||||
const records = await db
|
||||
.select()
|
||||
.from(weightRecords)
|
||||
.where(eq(weightRecords.userId, userId))
|
||||
.orderBy(weightRecords.recordedAt)
|
||||
.limit(1);
|
||||
|
||||
const currentWeight = await this.getLatestWeight(userId);
|
||||
const startWeight = records.length > 0 ? parseFloat(records[0].weight) : null;
|
||||
|
||||
// 获取用户目标体重
|
||||
// 这里暂时返回 null,后续可以关联用户表
|
||||
const targetWeight = null;
|
||||
|
||||
return {
|
||||
currentWeight,
|
||||
startWeight,
|
||||
targetWeight,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const weightRecordManager = new WeightRecordManager();
|
||||
24
server/tsconfig.json
Normal file
24
server/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"es2024",
|
||||
"ESNext.Array",
|
||||
"ESNext.Collection",
|
||||
"ESNext.Iterator",
|
||||
"ESNext.Promise"
|
||||
],
|
||||
"module": "preserve",
|
||||
"target": "es2024",
|
||||
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user