feat: 实现分类管理功能,包括数据模型、仓库层、服务层和数据库初始化脚本
This commit is contained in:
106
internal/models/default_category.go
Normal file
106
internal/models/default_category.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DefaultCategory represents a category template for new user initialization
|
||||
// These templates are copied to users' categories table when they first access categories
|
||||
type DefaultCategory struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:50;not null" json:"name"`
|
||||
Icon string `gorm:"size:100" json:"icon"` // Iconify format: mdi:icon-name
|
||||
Color string `gorm:"size:20" json:"color"` // HEX color code (e.g., #FF6B35)
|
||||
Type CategoryType `gorm:"size:20;not null" json:"type"` // income or expense
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relationships
|
||||
Parent *DefaultCategory `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []DefaultCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for DefaultCategory
|
||||
func (DefaultCategory) TableName() string {
|
||||
return "default_categories"
|
||||
}
|
||||
|
||||
// GetAllDefaultCategories retrieves all active default categories from the database
|
||||
func GetAllDefaultCategories(db *gorm.DB) ([]DefaultCategory, error) {
|
||||
var categories []DefaultCategory
|
||||
err := db.Where("is_active = ?", true).Order("sort_order ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// GetDefaultCategoriesWithChildren retrieves all active root categories with their children
|
||||
func GetDefaultCategoriesWithChildren(db *gorm.DB) ([]DefaultCategory, error) {
|
||||
var categories []DefaultCategory
|
||||
err := db.Where("is_active = ? AND parent_id IS NULL", true).
|
||||
Preload("Children", "is_active = ?", true).
|
||||
Order("sort_order ASC").
|
||||
Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// CopyToUserCategories copies default categories to a user's categories
|
||||
// Returns a map of old DefaultCategory ID to new Category ID for parent-child mapping
|
||||
func CopyDefaultCategoriesToUser(db *gorm.DB, userID uint) error {
|
||||
// Get all default categories
|
||||
var defaults []DefaultCategory
|
||||
if err := db.Where("is_active = ?", true).Order("sort_order ASC").Find(&defaults).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(defaults) == 0 {
|
||||
return nil // No default categories to copy
|
||||
}
|
||||
|
||||
// Map to track old ID -> new ID for parent references
|
||||
idMap := make(map[uint]uint)
|
||||
|
||||
// First pass: create all root categories (no parent)
|
||||
for _, dc := range defaults {
|
||||
if dc.ParentID == nil {
|
||||
cat := Category{
|
||||
UserID: userID,
|
||||
Name: dc.Name,
|
||||
Icon: dc.Icon,
|
||||
Color: dc.Color,
|
||||
Type: dc.Type,
|
||||
ParentID: nil,
|
||||
SortOrder: dc.SortOrder,
|
||||
}
|
||||
if err := db.Create(&cat).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
idMap[dc.ID] = cat.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create all child categories
|
||||
for _, dc := range defaults {
|
||||
if dc.ParentID != nil {
|
||||
newParentID := idMap[*dc.ParentID]
|
||||
cat := Category{
|
||||
UserID: userID,
|
||||
Name: dc.Name,
|
||||
Icon: dc.Icon,
|
||||
Color: dc.Color,
|
||||
Type: dc.Type,
|
||||
ParentID: &newParentID,
|
||||
SortOrder: dc.SortOrder,
|
||||
}
|
||||
if err := db.Create(&cat).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
idMap[dc.ID] = cat.ID
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -304,6 +304,7 @@ type Category struct {
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Name string `gorm:"size:50;not null" json:"name"`
|
||||
Icon string `gorm:"size:50" json:"icon"`
|
||||
Color string `gorm:"size:20" json:"color"` // HEX color code (e.g., #FF6B35)
|
||||
Type CategoryType `gorm:"size:20;not null" json:"type"` // income or expense
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
|
||||
@@ -26,6 +26,11 @@ func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
||||
return &CategoryRepository{db: db}
|
||||
}
|
||||
|
||||
// GetDB returns the underlying database connection
|
||||
func (r *CategoryRepository) GetDB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// Create creates a new category in the database
|
||||
func (r *CategoryRepository) Create(category *models.Category) error {
|
||||
if err := r.db.Create(category).Error; err != nil {
|
||||
|
||||
@@ -24,6 +24,7 @@ type CategoryInput struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"` // HEX color code (e.g., #FF6B35)
|
||||
Type models.CategoryType `json:"type" binding:"required"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
@@ -70,6 +71,7 @@ func (s *CategoryService) CreateCategory(input CategoryInput) (*models.Category,
|
||||
UserID: input.UserID,
|
||||
Name: input.Name,
|
||||
Icon: input.Icon,
|
||||
Color: input.Color,
|
||||
Type: input.Type,
|
||||
ParentID: input.ParentID,
|
||||
SortOrder: input.SortOrder,
|
||||
@@ -340,102 +342,129 @@ func (s *CategoryService) GetCategoryPath(userID, id uint) ([]models.Category, e
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// initDefaultCategories seeds default categories for a user
|
||||
// initDefaultCategories seeds default categories for a user from the default_categories template table
|
||||
// If no templates are found in the database, falls back to hardcoded defaults
|
||||
func (s *CategoryService) initDefaultCategories(userID uint) error {
|
||||
db := s.repo.GetDB()
|
||||
|
||||
// Try to copy from default_categories template table first
|
||||
err := models.CopyDefaultCategoriesToUser(db, userID)
|
||||
if err == nil {
|
||||
// Check if any categories were actually created
|
||||
count, countErr := s.repo.CountByType(userID, models.CategoryTypeExpense)
|
||||
if countErr == nil && count > 0 {
|
||||
return nil // Successfully copied from template
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to hardcoded defaults if template table is empty or failed
|
||||
return s.initHardcodedDefaults(userID)
|
||||
}
|
||||
|
||||
// initHardcodedDefaults seeds hardcoded default categories for a user (fallback)
|
||||
func (s *CategoryService) initHardcodedDefaults(userID uint) error {
|
||||
defaults := []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
Children []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}
|
||||
}{
|
||||
// Expenses
|
||||
{
|
||||
Name: "餐饮", Type: models.CategoryTypeExpense, Icon: "restaurant", SortOrder: 1,
|
||||
Name: "餐饮", Type: models.CategoryTypeExpense, Icon: "mdi:silverware-fork-knife", Color: "#FF6B35", SortOrder: 1,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "早餐", Type: models.CategoryTypeExpense, Icon: "breakfast_dining", SortOrder: 1},
|
||||
{Name: "午餐", Type: models.CategoryTypeExpense, Icon: "lunch_dining", SortOrder: 2},
|
||||
{Name: "晚餐", Type: models.CategoryTypeExpense, Icon: "dinner_dining", SortOrder: 3},
|
||||
{Name: "零食", Type: models.CategoryTypeExpense, Icon: "icecream", SortOrder: 4},
|
||||
{Name: "饮料", Type: models.CategoryTypeExpense, Icon: "local_cafe", SortOrder: 5},
|
||||
{Name: "早餐", Type: models.CategoryTypeExpense, Icon: "mdi:food-croissant", Color: "#FBBF24", SortOrder: 1},
|
||||
{Name: "午餐", Type: models.CategoryTypeExpense, Icon: "mdi:food", Color: "#FB923C", SortOrder: 2},
|
||||
{Name: "晚餐", Type: models.CategoryTypeExpense, Icon: "mdi:food-turkey", Color: "#F97316", SortOrder: 3},
|
||||
{Name: "零食", Type: models.CategoryTypeExpense, Icon: "mdi:cookie", Color: "#FDE047", SortOrder: 4},
|
||||
{Name: "饮料", Type: models.CategoryTypeExpense, Icon: "mdi:coffee", Color: "#A16207", SortOrder: 5},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "交通", Type: models.CategoryTypeExpense, Icon: "directions_bus", SortOrder: 2,
|
||||
Name: "交通", Type: models.CategoryTypeExpense, Icon: "mdi:bus", Color: "#3B82F6", SortOrder: 2,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "地铁", Type: models.CategoryTypeExpense, Icon: "subway", SortOrder: 1},
|
||||
{Name: "公交", Type: models.CategoryTypeExpense, Icon: "directions_bus", SortOrder: 2},
|
||||
{Name: "打车", Type: models.CategoryTypeExpense, Icon: "local_taxi", SortOrder: 3},
|
||||
{Name: "加油", Type: models.CategoryTypeExpense, Icon: "local_gas_station", SortOrder: 4},
|
||||
{Name: "地铁", Type: models.CategoryTypeExpense, Icon: "mdi:subway-variant", Color: "#3B82F6", SortOrder: 1},
|
||||
{Name: "公交", Type: models.CategoryTypeExpense, Icon: "mdi:bus", Color: "#60A5FA", SortOrder: 2},
|
||||
{Name: "打车", Type: models.CategoryTypeExpense, Icon: "mdi:taxi", Color: "#FBBF24", SortOrder: 3},
|
||||
{Name: "加油", Type: models.CategoryTypeExpense, Icon: "mdi:gas-station", Color: "#EF4444", SortOrder: 4},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "购物", Type: models.CategoryTypeExpense, Icon: "shopping_bag", SortOrder: 3,
|
||||
Name: "购物", Type: models.CategoryTypeExpense, Icon: "mdi:shopping", Color: "#EC4899", SortOrder: 3,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "服饰", Type: models.CategoryTypeExpense, Icon: "checkroom", SortOrder: 1},
|
||||
{Name: "日用", Type: models.CategoryTypeExpense, Icon: "soap", SortOrder: 2},
|
||||
{Name: "电子数码", Type: models.CategoryTypeExpense, Icon: "devices", SortOrder: 3},
|
||||
{Name: "服饰", Type: models.CategoryTypeExpense, Icon: "mdi:tshirt-crew", Color: "#EC4899", SortOrder: 1},
|
||||
{Name: "日用", Type: models.CategoryTypeExpense, Icon: "mdi:basket", Color: "#F472B6", SortOrder: 2},
|
||||
{Name: "电子数码", Type: models.CategoryTypeExpense, Icon: "mdi:laptop", Color: "#3B82F6", SortOrder: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "居住", Type: models.CategoryTypeExpense, Icon: "home", SortOrder: 4,
|
||||
Name: "居住", Type: models.CategoryTypeExpense, Icon: "mdi:home", Color: "#92400E", SortOrder: 4,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "房租", Type: models.CategoryTypeExpense, Icon: "house", SortOrder: 1},
|
||||
{Name: "水电煤", Type: models.CategoryTypeExpense, Icon: "lightbulb", SortOrder: 2},
|
||||
{Name: "物业", Type: models.CategoryTypeExpense, Icon: "security", SortOrder: 3},
|
||||
{Name: "房租", Type: models.CategoryTypeExpense, Icon: "mdi:home-city", Color: "#92400E", SortOrder: 1},
|
||||
{Name: "水电煤", Type: models.CategoryTypeExpense, Icon: "mdi:lightbulb-on", Color: "#FBBF24", SortOrder: 2},
|
||||
{Name: "物业", Type: models.CategoryTypeExpense, Icon: "mdi:office-building", Color: "#64748B", SortOrder: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "娱乐", Type: models.CategoryTypeExpense, Icon: "sports_esports", SortOrder: 5,
|
||||
Name: "娱乐", Type: models.CategoryTypeExpense, Icon: "mdi:gamepad-variant", Color: "#8B5CF6", SortOrder: 5,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "游戏", Type: models.CategoryTypeExpense, Icon: "gamepad", SortOrder: 1},
|
||||
{Name: "电影", Type: models.CategoryTypeExpense, Icon: "movie", SortOrder: 2},
|
||||
{Name: "运动", Type: models.CategoryTypeExpense, Icon: "fitness_center", SortOrder: 3},
|
||||
{Name: "游戏", Type: models.CategoryTypeExpense, Icon: "mdi:controller-classic", Color: "#8B5CF6", SortOrder: 1},
|
||||
{Name: "电影", Type: models.CategoryTypeExpense, Icon: "mdi:movie-open", Color: "#EF4444", SortOrder: 2},
|
||||
{Name: "运动", Type: models.CategoryTypeExpense, Icon: "mdi:dumbbell", Color: "#22C55E", SortOrder: 3},
|
||||
},
|
||||
},
|
||||
// Income
|
||||
{
|
||||
Name: "收入", Type: models.CategoryTypeIncome, Icon: "payments", SortOrder: 10,
|
||||
Name: "工作收入", Type: models.CategoryTypeIncome, Icon: "mdi:briefcase", Color: "#10B981", SortOrder: 10,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "工资", Type: models.CategoryTypeIncome, Icon: "work", SortOrder: 1},
|
||||
{Name: "奖金", Type: models.CategoryTypeIncome, Icon: "star", SortOrder: 2},
|
||||
{Name: "兼职", Type: models.CategoryTypeIncome, Icon: "access_time", SortOrder: 3},
|
||||
{Name: "理财", Type: models.CategoryTypeIncome, Icon: "trending_up", SortOrder: 4},
|
||||
{Name: "工资", Type: models.CategoryTypeIncome, Icon: "mdi:briefcase", Color: "#10B981", SortOrder: 1},
|
||||
{Name: "奖金", Type: models.CategoryTypeIncome, Icon: "mdi:trophy", Color: "#FBBF24", SortOrder: 2},
|
||||
{Name: "兼职", Type: models.CategoryTypeIncome, Icon: "mdi:briefcase-clock", Color: "#8B5CF6", SortOrder: 3},
|
||||
{Name: "理财", Type: models.CategoryTypeIncome, Icon: "mdi:chart-line", Color: "#F59E0B", SortOrder: 4},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -446,6 +475,7 @@ func (s *CategoryService) initDefaultCategories(userID uint) error {
|
||||
Name: cat.Name,
|
||||
Type: cat.Type,
|
||||
Icon: cat.Icon,
|
||||
Color: cat.Color,
|
||||
SortOrder: cat.SortOrder,
|
||||
}
|
||||
if err := s.repo.Create(parent); err != nil {
|
||||
@@ -458,6 +488,7 @@ func (s *CategoryService) initDefaultCategories(userID uint) error {
|
||||
Name: child.Name,
|
||||
Type: child.Type,
|
||||
Icon: child.Icon,
|
||||
Color: child.Color,
|
||||
SortOrder: child.SortOrder,
|
||||
ParentID: &parent.ID,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user