파이어 베이스에서 MYSql로 이동>> 이미지 등록 오류 수정, 테이블에 질병 관련 항목 추가

This commit is contained in:
youngbeom 2026-01-26 16:36:31 +09:00
parent 4059ea9af7
commit 7309b92ac6
23 changed files with 1428 additions and 353 deletions

View File

@ -59,25 +59,60 @@ class Pet {
}
factory Pet.fromMap(Map<String, dynamic> map) {
DateTime? parseDate(dynamic value) {
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value);
return null;
}
// Helper to safely parse List<String>
List<String> parseList(dynamic list) {
if (list == null) return [];
if (list is! List) return [];
return list.map((e) {
if (e is String) return e;
if (e is Map) return e['name']?.toString() ?? e.toString();
return e.toString();
}).toList();
}
return Pet(
id: map['id'] ?? '',
ownerId: map['ownerId'] ?? '',
id: map['id']?.toString() ?? '',
ownerId:
map['userId']?.toString() ??
map['ownerId']?.toString() ??
'', // Backend sends userId
name: map['name'] ?? '',
species: map['species'] ?? '',
breed: map['breed'] ?? '',
gender: map['gender'] ?? '',
isNeutered: map['isNeutered'] ?? false,
birthDate: map['birthDate'] != null
? (map['birthDate'] as Timestamp).toDate()
: null,
isDateUnknown: map['isDateUnknown'] ?? false,
isNeutered:
map['isNeutered'] == true ||
map['isNeutered'] == 'true', // Handle string/bool
birthDate: parseDate(map['birthDate']),
isDateUnknown:
map['isDateUnknown'] == true || map['isDateUnknown'] == 'true',
registrationNumber: map['registrationNumber'],
profileImageUrl: map['profileImageUrl'],
weight: map['weight']?.toDouble(),
diseases: List<String>.from(map['diseases'] ?? []),
pastDiseases: List<String>.from(map['pastDiseases'] ?? []),
healthConcerns: List<String>.from(map['healthConcerns'] ?? []),
createdAt: (map['createdAt'] as Timestamp).toDate(),
profileImageUrl: _parseImageUrl(
map['profileImageUrl'] ?? map['profileImagePath'],
),
weight: map['weight'] is int
? (map['weight'] as int).toDouble()
: map['weight']?.toDouble(),
diseases: parseList(map['diseases']),
pastDiseases: parseList(map['pastDiseases']),
healthConcerns: parseList(map['healthConcerns']),
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
);
}
static String? _parseImageUrl(String? url) {
if (url == null || url.isEmpty) return null;
if (url.startsWith('http')) return url;
// Prepend Backend URL for relative paths (MySQL/Multer)
// TODO: Improve architecture to not hardcode interactions in Model
const baseUrl = 'http://10.0.2.2:3000';
return '$baseUrl$url';
}
}

View File

@ -482,7 +482,7 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
// :
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFE0E0E0), width: 1),
top: BorderSide(color: Color(0xFFE0E0E0), width: 1),
),
),
child: Container(

View File

@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'pet_form_screen.dart';
import '../services/firestore_service.dart';
import '../services/api_service.dart';
import '../services/auth_service.dart';
import '../models/pet_model.dart';
import '../theme/app_colors.dart';
import '../widgets/home/pet_profile_card.dart';
@ -15,14 +16,47 @@ class HomeScreen extends StatefulWidget {
}
class _HomeScreenState extends State<HomeScreen> {
final FirestoreService _firestoreService = FirestoreService();
String? _userId;
final ApiService _apiService = ApiService();
final AuthService _authService = AuthService();
int? _userId;
Pet? _selectedPet;
Future<List<Pet>>? _petsFuture;
@override
void initState() {
super.initState();
_userId = _firestoreService.getCurrentUserId();
_loadUserAndPets();
}
Future<void> _loadUserAndPets() async {
final userInfo = await _authService.getUserInfo();
if (userInfo != null) {
setState(() {
_userId = userInfo['id'] is int
? userInfo['id']
: int.tryParse(userInfo['id'].toString());
_petsFuture = _fetchPets();
});
}
}
Future<List<Pet>> _fetchPets() async {
if (_userId == null) return [];
try {
final petsData = await _apiService.getPets(_userId!);
return petsData.map((e) => Pet.fromMap(e)).toList();
} catch (e) {
debugPrint('Error loading pets: $e');
return [];
}
}
void _refreshPets() {
if (_userId != null) {
setState(() {
_petsFuture = _fetchPets();
});
}
}
@override
@ -34,8 +68,8 @@ class _HomeScreenState extends State<HomeScreen> {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: StreamBuilder<List<Pet>>(
stream: _firestoreService.getPets(_userId!),
child: FutureBuilder<List<Pet>>(
future: _petsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
@ -67,10 +101,16 @@ class _HomeScreenState extends State<HomeScreen> {
MaterialPageRoute(
builder: (context) => const PetFormScreen(),
),
);
).then((value) {
if (value == true) _refreshPets();
});
},
child: Padding(
padding: EdgeInsets.symmetric(vertical: 4.h),
padding: EdgeInsets.only(
top: 4.h,
bottom: 4.h,
right: 12.w,
), // Added right padding
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -79,7 +119,7 @@ class _HomeScreenState extends State<HomeScreen> {
width: 40.w,
height: 40.h,
),
SizedBox(width: 10.w),
SizedBox(width: 6.w), // Reduced spacing
Text(
'반려동물 등록 +',
style: TextStyle(
@ -153,7 +193,9 @@ class _HomeScreenState extends State<HomeScreen> {
MaterialPageRoute(
builder: (context) => const PetFormScreen(),
),
);
).then((value) {
if (value == true) _refreshPets();
});
}
},
itemBuilder: (context) {
@ -282,7 +324,14 @@ class _HomeScreenState extends State<HomeScreen> {
),
Expanded(
child: SingleChildScrollView(
child: Column(children: [PetProfileCard(pet: displayPet)]),
child: Column(
children: [
PetProfileCard(
pet: displayPet,
onPetUpdated: _refreshPets,
),
],
),
),
),
],

View File

@ -162,7 +162,7 @@ class _MyInfoScreenState extends State<MyInfoScreen> {
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFFFF7500), //
color: Color(0xFFFF7500),
fontFamily: 'SCDream',
),
),
@ -343,9 +343,9 @@ class _MyInfoScreenState extends State<MyInfoScreen> {
Widget _buildMenuItem({
required String title,
required IconData icon,
VoidCallback? onTap, // onTap을 nullable로
VoidCallback? onTap,
bool isDestructive = false,
String? trailingText, //
String? trailingText,
}) {
return Container(
decoration: BoxDecoration(
@ -370,7 +370,7 @@ class _MyInfoScreenState extends State<MyInfoScreen> {
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 16,
fontWeight: FontWeight.w500, // Medium
fontWeight: FontWeight.w500,
color: isDestructive ? Colors.red : Colors.black,
),
),

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenutil
import 'package:flutter_screenutil/flutter_screenutil.dart';
class NoticeScreen extends StatelessWidget {
const NoticeScreen({super.key});

View File

@ -1,15 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../theme/app_colors.dart';
import '../data/pet_data.dart';
import '../widgets/pet_registration/selection_modal.dart';
import '../widgets/pet_registration/input_formatters.dart';
import '../services/firestore_service.dart';
import '../models/pet_model.dart';
import '../widgets/pet_registration/input_formatters.dart';
class PetDetailScreen extends StatefulWidget {
final Pet pet;
@ -124,6 +124,9 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
String otherText = '';
for (var item in source) {
if (item.trim().isEmpty || item == '[]' || item.contains('['))
continue; // Filter out bad data
if (item.startsWith('기타(') && item.endsWith(')')) {
selected.add('기타');
otherText = item.substring(3, item.length - 1);
@ -153,10 +156,6 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
final TextEditingController _genderController = TextEditingController();
final TextEditingController _weightController = TextEditingController();
// ( )
String? _currentMajorCategory;
String? _currentMinorCategory;
String? _selectedGender;
bool _isNeutered = false;
List<String> _selectedDiseases = [];
@ -482,7 +481,12 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
decoration: const InputDecoration(
hintText: '직접 입력해 주세요',
isDense: true,
border: OutlineInputBorder(),
border: const OutlineInputBorder(),
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: AppColors.highlight,
),
),
),
),
),
@ -546,7 +550,7 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
height: 52.h,
decoration: BoxDecoration(
color: AppColors.highlight,
borderRadius: BorderRadius.circular(12.r),
borderRadius: BorderRadius.circular(30.r),
),
child: Center(
child: Text(
@ -656,8 +660,7 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
setState(() {
_speciesController.text =
speciesInputController.text;
_currentMajorCategory = null;
_currentMinorCategory = null;
_breedController.clear();
});
Navigator.pop(context);
@ -696,8 +699,6 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
setModalState(() => showInput = true);
else {
setState(() {
_currentMajorCategory = selectedMajor;
_currentMinorCategory = minor;
_speciesController.text = minor;
_breedController.clear();
});
@ -940,6 +941,15 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
controller: _yearController,
hint: 'YYYY',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
onChanged: (value) {
if (value.length == 4) {
FocusScope.of(context).requestFocus(_monthFocus);
}
},
),
),
SizedBox(width: 10),
@ -948,6 +958,16 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
controller: _monthController,
hint: 'MM',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(2),
DateRangeInputFormatter(min: 1, max: 12),
],
onChanged: (value) {
if (value.length == 2) {
FocusScope.of(context).requestFocus(_dayFocus);
}
},
),
),
SizedBox(width: 10),
@ -956,6 +976,14 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
controller: _dayController,
hint: 'DD',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(2),
DayInputFormatter(
monthController: _monthController,
yearController: _yearController,
),
],
),
),
],
@ -987,6 +1015,10 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
controller: _registrationNumberController,
hint: '숫자만 입력',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(15),
],
),
SizedBox(height: 24),
@ -996,6 +1028,9 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
hint: '예: 4.5',
suffix: 'kg',
keyboardType: TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
),
SizedBox(height: 24),
@ -1115,12 +1150,18 @@ class _PetDetailScreenState extends State<PetDetailScreen> {
VoidCallback? onTap,
TextInputType? keyboardType,
String? suffix,
List<TextInputFormatter>? inputFormatters,
FocusNode? focusNode, // Added to support focusing logic
ValueChanged<String>? onChanged, // Added to support focus change
}) {
return TextField(
controller: controller,
readOnly: readOnly,
onTap: onTap,
keyboardType: keyboardType,
inputFormatters: inputFormatters, // Added
focusNode: focusNode, // Added
onChanged: onChanged, // Added
decoration: InputDecoration(
hintText: hint,
suffixText: suffix,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,141 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
class ApiService {
// Use 10.0.2.2 for Android Emulator, localhost for iOS/Web
static final String baseUrl = Platform.isAndroid
? 'http://10.0.2.2:3000'
: 'http://localhost:3000';
final Dio _dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
// Get Initial Master Data (Groups, Species, Breeds, Diseases)
Future<Map<String, dynamic>> getInitialData() async {
try {
final response = await _dio.get('/common/initial-data');
return response.data;
} catch (e) {
debugPrint('Error fetching initial data: $e');
rethrow;
}
}
// Register Pet
Future<Map<String, dynamic>> registerPet({
required int userId,
required String name,
required String species,
required String breed,
required String gender,
required bool isNeutered,
DateTime? birthDate,
required bool isDateUnknown,
double? weight,
String? registrationNumber,
File? profileImage,
List<String>? diseases,
List<String>? pastDiseases,
List<String>? healthConcerns,
}) async {
try {
final formData = FormData.fromMap({
'userId': userId,
'name': name,
'species': species,
'breed': breed,
'gender': gender, // "남아", "여아", "기타"
'isNeutered': isNeutered,
'birthDate': birthDate?.toIso8601String(),
'isDateUnknown': isDateUnknown,
'weight': weight,
'registrationNumber': registrationNumber,
'diseases': diseases != null ? jsonEncode(diseases) : '[]',
'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]',
'healthConcerns': healthConcerns != null
? jsonEncode(healthConcerns)
: '[]',
if (profileImage != null)
'profileImage': await MultipartFile.fromFile(
profileImage.path,
filename: profileImage.path.split('/').last,
),
});
final response = await _dio.post('/pets', data: formData);
return response.data;
} catch (e) {
debugPrint('Error registering pet: $e');
rethrow;
}
}
// Update Pet
Future<Map<String, dynamic>> updatePet({
required int petId,
required String name,
required String species,
required String breed,
required String gender,
required bool isNeutered,
DateTime? birthDate,
required bool isDateUnknown,
double? weight,
String? registrationNumber,
File? profileImage,
List<String>? diseases,
List<String>? pastDiseases,
List<String>? healthConcerns,
}) async {
try {
final formData = FormData.fromMap({
'name': name,
'species': species,
'breed': breed,
'gender': gender,
'isNeutered': isNeutered,
'birthDate': birthDate?.toIso8601String(),
'isDateUnknown': isDateUnknown,
'weight': weight,
'registrationNumber': registrationNumber,
'diseases': diseases != null ? jsonEncode(diseases) : '[]',
'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]',
'healthConcerns': healthConcerns != null
? jsonEncode(healthConcerns)
: '[]',
if (profileImage != null)
'profileImage': await MultipartFile.fromFile(
profileImage.path,
filename: profileImage.path.split('/').last,
),
});
final response = await _dio.put('/pets/$petId', data: formData);
return response.data;
} catch (e) {
debugPrint('Error updating pet: $e');
rethrow;
}
}
// Get Pets
Future<List<dynamic>> getPets(int userId) async {
try {
final response = await _dio.get(
'/pets',
queryParameters: {'userId': userId},
);
return response.data;
} catch (e) {
debugPrint('Error fetching pets: $e');
rethrow;
}
}
}

View File

@ -8,25 +8,26 @@ import '../../screens/pet_form_screen.dart';
class PetProfileCard extends StatelessWidget {
final Pet pet;
final VoidCallback? onPetUpdated;
const PetProfileCard({super.key, required this.pet});
const PetProfileCard({super.key, required this.pet, this.onPetUpdated});
// ( & - )
String _calculateAge(DateTime? birthDate) {
if (birthDate == null) return '알 수 없음';
if (birthDate == null) return '??살';
final now = DateTime.now();
int age = now.year - birthDate.year;
if (now.month < birthDate.month ||
(now.month == birthDate.month && now.day < birthDate.day)) {
age--;
}
return '$age';
return '$age';
}
// ( - )
// ( )
String _calculateHumanAge(DateTime? birthDate, String species) {
if (birthDate == null) return '??';
if (birthDate == null) return '??';
final now = DateTime.now();
int ageYears = now.year - birthDate.year;
@ -117,7 +118,7 @@ class PetProfileCard extends StatelessWidget {
break;
}
return '$humanAge';
return '$humanAge';
}
@override
@ -146,7 +147,6 @@ class PetProfileCard extends StatelessWidget {
// :
Container(
width: 120.w,
// height (stretch )
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
color: Colors.grey[200],
@ -176,7 +176,6 @@ class PetProfileCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// &
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -208,8 +207,6 @@ class PetProfileCard extends StatelessWidget {
],
),
),
// ( )
// ( )
Material(
color: Colors.transparent,
child: InkWell(
@ -221,7 +218,11 @@ class PetProfileCard extends StatelessWidget {
builder: (context) =>
PetFormScreen(petToEdit: pet),
),
);
).then((value) {
if (value == true && onPetUpdated != null) {
onPetUpdated!();
}
});
},
child: Padding(
padding: EdgeInsets.all(8.w),
@ -244,7 +245,7 @@ class PetProfileCard extends StatelessWidget {
color: Colors.grey[600],
),
),
SizedBox(height: 8.h), // (12 -> 8)
SizedBox(height: 8.h),
// (, )
Row(
children: [
@ -261,12 +262,13 @@ class PetProfileCard extends StatelessWidget {
),
],
),
SizedBox(height: 8.h), // (12 -> 8)
SizedBox(height: 8.h),
//
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'나이 ',
@ -274,6 +276,7 @@ class PetProfileCard extends StatelessWidget {
fontFamily: 'SCDream',
fontSize: 14.sp,
color: Colors.grey[600],
height: 1.2,
),
),
Text(
@ -283,13 +286,18 @@ class PetProfileCard extends StatelessWidget {
fontSize: 15.sp,
fontWeight: FontWeight.bold,
color: Colors.black,
height: 1.2,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w),
child: Text(
'/',
style: TextStyle(color: Colors.grey[300]),
style: TextStyle(
color: Colors.grey[300],
fontSize: 14.sp,
height: 1.2,
),
),
),
Text(
@ -298,14 +306,17 @@ class PetProfileCard extends StatelessWidget {
fontFamily: 'SCDream',
fontSize: 14.sp,
color: Colors.grey[600],
height: 1.2,
),
),
Text(
'사람 나이 환산 ${_calculateHumanAge(pet.birthDate, pet.species)}',
_calculateHumanAge(pet.birthDate, pet.species),
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 12.sp,
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.bold,
color: Colors.black,
height: 1.2,
),
),
],
@ -323,7 +334,7 @@ class PetProfileCard extends StatelessWidget {
fontFamily: 'SCDream',
fontSize: 14.sp,
color: Colors.grey[600],
height: 1.2, //
height: 1.2,
),
),
Expanded(
@ -365,7 +376,7 @@ class PetProfileCard extends StatelessWidget {
child: Container(
padding: EdgeInsets.all(8.w),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.4), //
color: Colors.white.withOpacity(0.4),
border: Border.all(color: const Color(0xFFEEEEEE), width: 1),
borderRadius: BorderRadius.circular(12.r),
),
@ -377,7 +388,7 @@ class PetProfileCard extends StatelessWidget {
style: TextStyle(
fontFamily: 'SCDream',
fontSize: 12.sp,
color: Colors.grey[600], //
color: Colors.grey[600],
),
),
SizedBox(height: 4.h),

View File

@ -20,16 +20,13 @@ class DateRangeInputFormatter extends TextInputFormatter {
final int? value = int.tryParse(newValue.text);
if (value == null) {
return oldValue; //
return oldValue;
}
if (value < min || value > max) {
// (, - : 3 30 )
// max보다 ( )
if (newValue.text.length > max.toString().length) {
return oldValue;
}
// ,
if (value > max) return oldValue;
}

View File

@ -1,7 +1,11 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const { connectDB, sequelize } = require('./config/db');
const authRoutes = require('./routes/auth');
const commonRoutes = require('./routes/common');
const petRoutes = require('./routes/pets');
const seedData = require('./scripts/seedData');
const app = express();
const port = 3000;
@ -9,9 +13,15 @@ const port = 3000;
// Middleware
app.use(cors());
app.use(express.json()); // Body parser for JSON
app.use(express.urlencoded({ extended: true })); // For multipart form-data handling if needed (though multer handles it)
// Static Files
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Routes
app.use('/auth', authRoutes);
app.use('/common', commonRoutes);
app.use('/pets', petRoutes);
app.get('/', (req, res) => {
res.send('Hello from Express Backend!');
@ -21,11 +31,14 @@ app.get('/', (req, res) => {
const startServer = async () => {
await connectDB();
// Sync models (in production, use migration instead of sync({alter: true}))
// For dev: force: false to keep data, alter: true to update schema
// Sync models
// force: false to keep data, alter: true to update schema
await sequelize.sync({ alter: true });
console.log('Database synced');
// Seed Data
await seedData();
app.listen(port, '0.0.0.0', () => {
console.log(`Backend app listening on port ${port}`);
});

26
backend/manual_sync.js Normal file
View File

@ -0,0 +1,26 @@
const { sequelize } = require('./config/db');
require('./models/Pet'); // Load models
const syncDB = async () => {
try {
console.log('Connecting to DB...');
await sequelize.authenticate();
console.log('Connected. Syncing schema...');
// Force alter to ensure columns are added
await sequelize.sync({ alter: true });
console.log('Schema update complete. Checking Pet table columns...');
// Check columns
const [results] = await sequelize.query('DESCRIBE Pets;');
console.log('Table Structure:', results.map(r => r.Field));
process.exit(0);
} catch (error) {
console.error('Sync failed:', error);
process.exit(1);
}
};
syncDB();

78
backend/models/Pet.js Normal file
View File

@ -0,0 +1,78 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const Pet = sequelize.define('Pet', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
comment: 'Reference to User table',
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
species: {
type: DataTypes.STRING,
allowNull: false,
comment: 'Species Name (e.g. 강아지)',
},
breed: {
type: DataTypes.STRING,
allowNull: false,
comment: 'Breed Name (e.g. 말티즈)',
},
gender: {
type: DataTypes.STRING,
allowNull: false,
comment: '남아, 여아, 기타',
},
isNeutered: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
birthDate: {
type: DataTypes.DATEONLY,
allowNull: true,
},
isDateUnknown: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
weight: {
type: DataTypes.FLOAT,
allowNull: true,
},
registrationNumber: {
type: DataTypes.STRING,
allowNull: true,
},
profileImagePath: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Relative path to uploaded image',
},
diseases: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'JSON string of current diseases',
},
pastDiseases: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'JSON string of past diseases',
},
healthConcerns: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'JSON string of health concerns',
},
}, {
timestamps: true,
});
module.exports = Pet;

View File

@ -0,0 +1,24 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const PetBreed = sequelize.define('PetBreed', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
speciesId: {
type: DataTypes.INTEGER,
allowNull: false,
comment: 'Reference to PetSpecies',
},
name: {
type: DataTypes.STRING,
allowNull: false,
comment: 'e.g., 말티즈, 푸들',
},
}, {
timestamps: false,
});
module.exports = PetBreed;

View File

@ -0,0 +1,20 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const PetDisease = sequelize.define('PetDisease', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
comment: 'e.g., 피부질환, 눈 질환',
},
}, {
timestamps: false,
});
module.exports = PetDisease;

View File

@ -0,0 +1,20 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const PetGroup = sequelize.define('PetGroup', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
comment: 'e.g., 포유류, 파충류',
},
}, {
timestamps: false,
});
module.exports = PetGroup;

View File

@ -0,0 +1,24 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const PetSpecies = sequelize.define('PetSpecies', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
groupId: {
type: DataTypes.INTEGER,
allowNull: false,
comment: 'Reference to PetGroup',
},
name: {
type: DataTypes.STRING,
allowNull: false,
comment: 'e.g., 강아지, 고양이',
},
}, {
timestamps: false,
});
module.exports = PetSpecies;

32
backend/models/index.js Normal file
View File

@ -0,0 +1,32 @@
const { sequelize } = require('../config/db');
const User = require('./user');
const PetGroup = require('./PetGroup');
const PetSpecies = require('./PetSpecies');
const PetBreed = require('./PetBreed');
const PetDisease = require('./PetDisease');
const Pet = require('./Pet');
// Associations
// Master Data Relationships
PetGroup.hasMany(PetSpecies, { foreignKey: 'groupId' });
PetSpecies.belongsTo(PetGroup, { foreignKey: 'groupId' });
PetSpecies.hasMany(PetBreed, { foreignKey: 'speciesId' });
PetBreed.belongsTo(PetSpecies, { foreignKey: 'speciesId' });
// User <-> Pet
User.hasMany(Pet, { foreignKey: 'userId' });
Pet.belongsTo(User, { foreignKey: 'userId' });
module.exports = {
sequelize,
User,
PetGroup,
PetSpecies,
PetBreed,
PetDisease,
Pet,
};

View File

@ -13,6 +13,7 @@
"google-auth-library": "^9.4.1",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.6.5",
"multer": "^1.4.5-lts.1",
"sequelize": "^6.35.1"
}
}

30
backend/routes/common.js Normal file
View File

@ -0,0 +1,30 @@
const express = require('express');
const router = express.Router();
const { PetGroup, PetSpecies, PetBreed, PetDisease } = require('../models');
// GET /common/initial-data
// Returns hierarchical master data for frontend caching
router.get('/initial-data', async (req, res) => {
try {
const groups = await PetGroup.findAll({
include: [
{
model: PetSpecies,
include: [{ model: PetBreed }]
}
]
});
const diseases = await PetDisease.findAll();
res.json({
groups,
diseases,
});
} catch (error) {
console.error('Error fetching initial data:', error);
res.status(500).json({ message: 'Server Error' });
}
});
module.exports = router;

140
backend/routes/pets.js Normal file
View File

@ -0,0 +1,140 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { Pet, User } = require('../models');
// Multer Setup
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// Use absolute path to ensure it works regardless of CWD
const uploadPath = path.join(__dirname, '../uploads/pets/');
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath, { recursive: true });
}
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({ storage: storage });
// POST /pets - Create a new pet with optional image
router.post('/', upload.single('profileImage'), async (req, res) => {
try {
const {
userId, name, species, breed, gender, isNeutered,
birthDate, isDateUnknown, weight, registrationNumber,
diseases, pastDiseases, healthConcerns
} = req.body;
const profileImagePath = req.file ? `/uploads/pets/${req.file.filename}` : null;
const newPet = await Pet.create({
userId,
name,
species,
breed,
gender,
isNeutered: isNeutered === 'true', // Multipart sends strings
birthDate: birthDate ? new Date(birthDate) : null,
isDateUnknown: isDateUnknown === 'true',
weight: weight ? parseFloat(weight) : null,
registrationNumber,
profileImagePath,
diseases: diseases ? diseases : '[]',
pastDiseases: pastDiseases ? pastDiseases : '[]',
healthConcerns: healthConcerns ? healthConcerns : '[]',
});
res.status(201).json(newPet);
} catch (error) {
console.error('Error creating pet:', error);
res.status(500).json({ message: error.message, error: error.toString() });
}
});
// GET /pets - Get pets for a specific user (query param ?userId=...)
router.get('/', async (req, res) => {
try {
const { userId } = req.query;
if (!userId) {
return res.status(400).json({ message: 'Missing userId query parameter' });
}
const pets = await Pet.findAll({
where: { userId },
order: [['createdAt', 'DESC']]
});
// Parse JSON strings back to arrays for the response
const parsedPets = pets.map(pet => {
const p = pet.toJSON();
try { p.diseases = p.diseases ? JSON.parse(p.diseases) : []; } catch (e) { p.diseases = []; }
try { p.pastDiseases = p.pastDiseases ? JSON.parse(p.pastDiseases) : []; } catch (e) { p.pastDiseases = []; }
try { p.healthConcerns = p.healthConcerns ? JSON.parse(p.healthConcerns) : []; } catch (e) { p.healthConcerns = []; }
return p;
});
res.json(parsedPets);
} catch (error) {
console.error('Error fetching pets:', error);
res.status(500).json({ message: 'Server Error' });
}
});
// PUT /pets/:id - Update an existing pet
router.put('/:id', upload.single('profileImage'), async (req, res) => {
try {
const { id } = req.params;
const {
name, species, breed, gender, isNeutered,
birthDate, isDateUnknown, weight, registrationNumber,
diseases, pastDiseases, healthConcerns
} = req.body;
const pet = await Pet.findByPk(id);
if (!pet) {
return res.status(404).json({ message: 'Pet not found' });
}
// Only update fields if they are provided
if (name) pet.name = name;
if (species) pet.species = species;
if (breed) pet.breed = breed;
if (gender) pet.gender = gender;
if (isNeutered !== undefined) pet.isNeutered = isNeutered === 'true';
if (birthDate !== undefined) pet.birthDate = birthDate ? new Date(birthDate) : null;
if (isDateUnknown !== undefined) pet.isDateUnknown = isDateUnknown === 'true';
if (weight !== undefined) pet.weight = weight ? parseFloat(weight) : null;
if (registrationNumber !== undefined) pet.registrationNumber = registrationNumber;
if (diseases !== undefined) pet.diseases = diseases ? diseases : '[]';
if (pastDiseases !== undefined) pet.pastDiseases = pastDiseases ? pastDiseases : '[]';
if (healthConcerns !== undefined) pet.healthConcerns = healthConcerns ? healthConcerns : '[]';
if (req.file) {
pet.profileImagePath = `/uploads/pets/${req.file.filename}`;
// TODO: Delete old image file if exists?
}
await pet.save();
// Parse for response
const p = pet.toJSON();
try { p.diseases = p.diseases ? JSON.parse(p.diseases) : []; } catch (e) { p.diseases = []; }
try { p.pastDiseases = p.pastDiseases ? JSON.parse(p.pastDiseases) : []; } catch (e) { p.pastDiseases = []; }
try { p.healthConcerns = p.healthConcerns ? JSON.parse(p.healthConcerns) : []; } catch (e) { p.healthConcerns = []; }
res.json(p);
} catch (error) {
console.error('Error updating pet:', error);
res.status(500).json({ message: error.message, error: error.toString() });
}
});
module.exports = router;

View File

@ -0,0 +1,88 @@
const { PetGroup, PetSpecies, PetBreed, PetDisease, sequelize } = require('../models');
const seedData = async () => {
try {
const groupCount = await PetGroup.count();
if (groupCount > 0) {
console.log('Master data already exists. Skipping seed.');
return;
}
console.log('Seeding Master Data...');
// Data from pet_data.dart
const diseaseList = [
"피부질환", "눈 질환", "치아 / 구강 질환", "뼈 / 관절 질환", "생식기 / 비뇨기 질환",
"심장 / 혈관 질환", "소화기 질환", "호흡기 질환", "내분비계 질환", "뇌신경 질환",
"생식기 질환", "귀 질환", "코 질환", "기타"
];
const breedsData = {
"포유류": {
"강아지": ["말티즈", "푸들", "포메라니안", "믹스견", "치와와", "시츄", "비숑 프리제", "골든 리트리버", "진돗개", "웰시 코기", "프렌치 불독", "시바견", "닥스후트", "요크셔 테리어", "보더 콜리", "사모예드", "허스키", "말라뮤트", "기타(직접 입력)"],
"고양이": ["코리안 숏헤어", "브리티시 숏헤어", "아메리칸 숏헤어", "뱅갈", "메인쿤", "데본 렉스", "페르시안", "러시안 블루", "샴", "렉돌", "스코티시 폴드", "먼치킨", "노르웨이 숲", "믹스묘", "기타(직접 입력)"],
"햄스터": ["정글리안", "펄", "푸딩", "골든 햄스터", "로보로브스키", "기타(직접 입력)"],
"토끼": ["롭이어", "더치", "라이언 헤드", "드워프", "렉스", "기타(직접 입력)"],
"기니피그": ["잉글리쉬", "아비시니안", "페루비안", "실키", "기타(직접 입력)"],
"고슴도치": ["플라티나", "화이트 초코", "알비노", "핀토", "기타(직접 입력)"],
"기타(직접 입력)": ["기타(직접 입력)"]
},
"파충류": {
"거북이": ["커먼 머스크 터틀", "레이저백", "육지거북", "붉은귀거북", "남생이", "기타(직접 입력)"],
"도마뱀": ["크레스티드 게코", "리키에너스 게코", "가고일 게코", "레오파드 게코", "비어디 드래곤", "블루텅 스킨크", "이구아나", "기타(직접 입력)"],
"뱀": ["볼 파이톤", "가터 스네이크", "호그노즈 스네이크", "콘 스네이크", "킹 스네이크", "밀크 스네이크", "기타(직접 입력)"],
"기타(직접 입력)": ["기타(직접 입력)"]
},
"조류": {
"앵무새(소/중형)": ["사랑앵무(잉꼬)", "코카티엘(왕관앵무)", "모란앵무", "코뉴어", "퀘이커", "카카리키", "사자나미(빗금앵무)", "유리앵무", "기타(직접 입력)"],
"앵무새(대형)": ["뉴기니아", "회색앵무", "금강앵무(마카우)", "유황앵무(코카투)", "아마존앵무", "대본영", "기타(직접 입력)"],
"핀치/관상조": ["카나리아", "십자매", "문조", "금화조", "호금조", "백문조", "기타(직접 입력)"],
"비둘기/닭/메추리": ["애완용 비둘기", "관상닭(실키 등)", "메추리", "미니메추리", "오리/거위", "기타(직접 입력)"],
"기타(직접 입력)": ["직접 입력"]
},
"양서류": {
"개구리": ["청개구리", "팩맨", "다트 프록", "화이트 트리 프록", "기타(직접 입력)"],
"도룡뇽": ["우파루파", "파이어 벨리 뉴트", "타이거 살라만더", "기타(직접 입력)"],
"기타(직접 입력)": ["기타(직접 입력)"]
},
"어류": {
"열대어": ["구피", "베타", "테트라", "디스커스", "엔젤피쉬", "기타(직접 입력)"],
"금붕어/잉어": ["금붕어", "비단잉어", "기타(직접 입력)"],
"해수어": ["크라운피쉬(니모)", "블루탱", "기타(직접 입력)"],
"기타(직접 입력)": ["기타(직접 입력)"]
},
"기타(직접 입력)": {
"기타(직접 입력)": ["기타(직접 입력)"]
}
};
// 1. Seed Diseases
for (const d of diseaseList) {
await PetDisease.findOrCreate({ where: { name: d } });
}
// 2. Seed Group -> Species -> Breeds
for (const [groupName, speciesMap] of Object.entries(breedsData)) {
const [group] = await PetGroup.findOrCreate({ where: { name: groupName } });
for (const [speciesName, breedList] of Object.entries(speciesMap)) {
const [species] = await PetSpecies.findOrCreate({
where: { name: speciesName, groupId: group.id }
});
for (const breedName of breedList) {
await PetBreed.findOrCreate({
where: { name: breedName, speciesId: species.id }
});
}
}
}
console.log('Master Data Seeded Successfully.');
} catch (error) {
console.error('Seeding Failed:', error);
}
};
module.exports = seedData;

0
{ Normal file
View File