From 4d024eba8e05a91a3135aa333fb7ea40f415c826 Mon Sep 17 00:00:00 2001 From: admin <1297598740@qq.com> Date: Wed, 28 Jan 2026 09:55:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B=E3=80=81=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=B1=82=E3=80=81=E6=9C=8D=E5=8A=A1=E5=B1=82=E5=92=8C=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=88=9D=E5=A7=8B=E5=8C=96=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/sql/data.sql | 255 ++++++++++++++++++++- database/sql/schema.sql | 21 ++ internal/models/default_category.go | 106 +++++++++ internal/models/models.go | 1 + internal/repository/category_repository.go | 5 + internal/service/category_service.go | 89 ++++--- 6 files changed, 447 insertions(+), 30 deletions(-) create mode 100644 internal/models/default_category.go diff --git a/database/sql/data.sql b/database/sql/data.sql index 106774b..5285042 100644 --- a/database/sql/data.sql +++ b/database/sql/data.sql @@ -1,6 +1,259 @@ --- Insert System Categories +-- ===================================================== +-- System Categories (for refund, reimbursement, etc.) +-- ===================================================== INSERT INTO `system_categories` (`code`, `name`, `icon`, `type`, `is_system`) VALUES ('refund', '退款', 'mdi:cash-refund', 'income', 1), ('reimbursement', '报销', 'mdi:receipt-text-check', 'income', 1) ON DUPLICATE KEY UPDATE `name`=VALUES(`name`), `icon`=VALUES(`icon`), `type`=VALUES(`type`); + +-- ===================================================== +-- Default Categories Template Data +-- These templates will be copied to new users on registration +-- Icon names use Iconify format: mdi:icon-name +-- Reference: https://icon-sets.iconify.design/mdi/ +-- Colors use HEX format for realistic brand colors +-- ===================================================== + +-- Clear existing template data (optional, for re-initialization) +-- DELETE FROM `default_categories`; + +-- ===================================================== +-- 支出类主分类 (Expense Parent Categories) +-- ===================================================== +INSERT INTO `default_categories` (`id`, `name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +-- 餐饮 (id=1) - 橙色系 +(1, '餐饮', 'mdi:silverware-fork-knife', '#FF6B35', 'expense', NULL, 1, 1, NOW()), +-- 交通 (id=2) - 蓝色系 +(2, '交通', 'mdi:bus', '#3B82F6', 'expense', NULL, 2, 1, NOW()), +-- 购物 (id=3) - 粉色系 +(3, '购物', 'mdi:shopping', '#EC4899', 'expense', NULL, 3, 1, NOW()), +-- 居住 (id=4) - 棕色系 +(4, '居住', 'mdi:home', '#92400E', 'expense', NULL, 4, 1, NOW()), +-- 娱乐 (id=5) - 紫色系 +(5, '娱乐', 'mdi:gamepad-variant', '#8B5CF6', 'expense', NULL, 5, 1, NOW()), +-- 医疗 (id=6) - 红色系 +(6, '医疗', 'mdi:hospital-box', '#EF4444', 'expense', NULL, 6, 1, NOW()), +-- 教育 (id=7) - 青色系 +(7, '教育', 'mdi:school', '#06B6D4', 'expense', NULL, 7, 1, NOW()), +-- 通讯 (id=8) - 蓝色系 +(8, '通讯', 'mdi:cellphone', '#0EA5E9', 'expense', NULL, 8, 1, NOW()), +-- 人情 (id=9) - 红色系 +(9, '人情', 'mdi:gift', '#F43F5E', 'expense', NULL, 9, 1, NOW()), +-- 金融 (id=10) - 金色系 +(10, '金融', 'mdi:bank', '#F59E0B', 'expense', NULL, 10, 1, NOW()), +-- 宠物 (id=11) - 棕色系 +(11, '宠物', 'mdi:dog', '#A16207', 'expense', NULL, 11, 1, NOW()), +-- 其他支出 (id=12) - 灰色系 +(12, '其他支出', 'mdi:dots-horizontal-circle', '#6B7280', 'expense', NULL, 99, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`), `icon`=VALUES(`icon`), `color`=VALUES(`color`); + +-- ===================================================== +-- 收入类主分类 (Income Parent Categories) +-- ===================================================== +INSERT INTO `default_categories` (`id`, `name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +-- 工作收入 (id=13) - 绿色系 +(13, '工作收入', 'mdi:briefcase', '#10B981', 'income', NULL, 1, 1, NOW()), +-- 投资收益 (id=14) - 金色系 +(14, '投资收益', 'mdi:chart-line', '#F59E0B', 'income', NULL, 2, 1, NOW()), +-- 被动收入 (id=15) - 紫色系 +(15, '被动收入', 'mdi:cash-multiple', '#8B5CF6', 'income', NULL, 3, 1, NOW()), +-- 其他收入 (id=16) - 蓝色系 +(16, '其他收入', 'mdi:dots-horizontal-circle', '#3B82F6', 'income', NULL, 99, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`), `icon`=VALUES(`icon`), `color`=VALUES(`color`); + +-- ===================================================== +-- 餐饮子分类 (parent_id = 1) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('早餐', 'mdi:food-croissant', '#FBBF24', 'expense', 1, 1, 1, NOW()), +('午餐', 'mdi:food', '#FB923C', 'expense', 1, 2, 1, NOW()), +('晚餐', 'mdi:food-turkey', '#F97316', 'expense', 1, 3, 1, NOW()), +('零食', 'mdi:cookie', '#FDE047', 'expense', 1, 4, 1, NOW()), +('饮料', 'mdi:coffee', '#A16207', 'expense', 1, 5, 1, NOW()), +('水果', 'mdi:fruit-grapes', '#84CC16', 'expense', 1, 6, 1, NOW()), +('外卖', 'mdi:bike-fast', '#EF4444', 'expense', 1, 7, 1, NOW()), +('聚餐', 'mdi:food-fork-drink', '#DC2626', 'expense', 1, 8, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 交通子分类 (parent_id = 2) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('地铁', 'mdi:subway-variant', '#3B82F6', 'expense', 2, 1, 1, NOW()), +('公交', 'mdi:bus', '#60A5FA', 'expense', 2, 2, 1, NOW()), +('打车', 'mdi:taxi', '#FBBF24', 'expense', 2, 3, 1, NOW()), +('滴滴出行', 'mdi:car-connected', '#FF6600', 'expense', 2, 4, 1, NOW()), +('加油', 'mdi:gas-station', '#EF4444', 'expense', 2, 5, 1, NOW()), +('停车', 'mdi:parking', '#6366F1', 'expense', 2, 6, 1, NOW()), +('高铁/火车', 'mdi:train', '#0369A1', 'expense', 2, 7, 1, NOW()), +('飞机', 'mdi:airplane', '#0EA5E9', 'expense', 2, 8, 1, NOW()), +('共享单车', 'mdi:bike', '#22C55E', 'expense', 2, 9, 1, NOW()), +('汽车保养', 'mdi:car-wrench', '#64748B', 'expense', 2, 10, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 购物子分类 (parent_id = 3) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('服饰', 'mdi:tshirt-crew', '#EC4899', 'expense', 3, 1, 1, NOW()), +('日用品', 'mdi:basket', '#F472B6', 'expense', 3, 2, 1, NOW()), +('电子数码', 'mdi:laptop', '#3B82F6', 'expense', 3, 3, 1, NOW()), +('美妆护肤', 'mdi:lipstick', '#F43F5E', 'expense', 3, 4, 1, NOW()), +('超市', 'mdi:cart', '#22C55E', 'expense', 3, 5, 1, NOW()), +('淘宝', 'mdi:shopping', '#FF4400', 'expense', 3, 6, 1, NOW()), +('京东', 'mdi:package-variant', '#E4002B', 'expense', 3, 7, 1, NOW()), +('拼多多', 'mdi:basket-outline', '#E02E24', 'expense', 3, 8, 1, NOW()), +('家电', 'mdi:television', '#0891B2', 'expense', 3, 9, 1, NOW()), +('母婴用品', 'mdi:baby-carriage', '#F9A8D4', 'expense', 3, 10, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 居住子分类 (parent_id = 4) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('房租', 'mdi:home-city', '#92400E', 'expense', 4, 1, 1, NOW()), +('房贷', 'mdi:bank', '#B45309', 'expense', 4, 2, 1, NOW()), +('水费', 'mdi:water', '#0EA5E9', 'expense', 4, 3, 1, NOW()), +('电费', 'mdi:flash', '#FBBF24', 'expense', 4, 4, 1, NOW()), +('燃气费', 'mdi:fire', '#F97316', 'expense', 4, 5, 1, NOW()), +('物业费', 'mdi:office-building', '#64748B', 'expense', 4, 6, 1, NOW()), +('宽带网络', 'mdi:wifi', '#3B82F6', 'expense', 4, 7, 1, NOW()), +('家居装修', 'mdi:hammer-wrench', '#A16207', 'expense', 4, 8, 1, NOW()), +('家政服务', 'mdi:broom', '#10B981', 'expense', 4, 9, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 娱乐子分类 (parent_id = 5) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('游戏', 'mdi:controller-classic', '#8B5CF6', 'expense', 5, 1, 1, NOW()), +('电影', 'mdi:movie-open', '#EF4444', 'expense', 5, 2, 1, NOW()), +('音乐', 'mdi:music', '#22C55E', 'expense', 5, 3, 1, NOW()), +('健身', 'mdi:dumbbell', '#F97316', 'expense', 5, 4, 1, NOW()), +('旅游', 'mdi:airplane', '#0EA5E9', 'expense', 5, 5, 1, NOW()), +('KTV', 'mdi:microphone', '#EC4899', 'expense', 5, 6, 1, NOW()), +('演唱会', 'mdi:party-popper', '#A855F7', 'expense', 5, 7, 1, NOW()), +('视频会员', 'mdi:youtube-subscription', '#EF4444', 'expense', 5, 8, 1, NOW()), +('网易云音乐', 'mdi:music-box', '#E60026', 'expense', 5, 9, 1, NOW()), +('B站', 'mdi:video', '#FB7299', 'expense', 5, 10, 1, NOW()), +('运动', 'mdi:soccer', '#22C55E', 'expense', 5, 11, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 医疗子分类 (parent_id = 6) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('门诊', 'mdi:stethoscope', '#EF4444', 'expense', 6, 1, 1, NOW()), +('药品', 'mdi:pill', '#F87171', 'expense', 6, 2, 1, NOW()), +('体检', 'mdi:clipboard-pulse', '#06B6D4', 'expense', 6, 3, 1, NOW()), +('住院', 'mdi:hospital-building', '#DC2626', 'expense', 6, 4, 1, NOW()), +('保健品', 'mdi:heart-pulse', '#F43F5E', 'expense', 6, 5, 1, NOW()), +('医疗保险', 'mdi:shield-plus', '#3B82F6', 'expense', 6, 6, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 教育子分类 (parent_id = 7) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('培训班', 'mdi:teach', '#06B6D4', 'expense', 7, 1, 1, NOW()), +('书籍', 'mdi:book-open-page-variant', '#92400E', 'expense', 7, 2, 1, NOW()), +('网课', 'mdi:laptop', '#8B5CF6', 'expense', 7, 3, 1, NOW()), +('考试报名', 'mdi:file-document-edit', '#64748B', 'expense', 7, 4, 1, NOW()), +('学费', 'mdi:school', '#3B82F6', 'expense', 7, 5, 1, NOW()), +('文具', 'mdi:pencil', '#FBBF24', 'expense', 7, 6, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 通讯子分类 (parent_id = 8) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('话费', 'mdi:phone', '#0EA5E9', 'expense', 8, 1, 1, NOW()), +('流量', 'mdi:signal-4g', '#3B82F6', 'expense', 8, 2, 1, NOW()), +('微信会员', 'mdi:wechat', '#07C160', 'expense', 8, 3, 1, NOW()), +('QQ会员', 'mdi:qqchat', '#12B7F5', 'expense', 8, 4, 1, NOW()), +('云服务', 'mdi:cloud', '#6366F1', 'expense', 8, 5, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 人情子分类 (parent_id = 9) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('微信红包', 'mdi:wechat', '#07C160', 'expense', 9, 1, 1, NOW()), +('支付宝红包', 'mdi:web', '#1677FF', 'expense', 9, 2, 1, NOW()), +('礼物', 'mdi:gift-outline', '#F43F5E', 'expense', 9, 3, 1, NOW()), +('请客吃饭', 'mdi:food-fork-drink', '#F97316', 'expense', 9, 4, 1, NOW()), +('份子钱', 'mdi:hand-heart', '#EC4899', 'expense', 9, 5, 1, NOW()), +('孝敬长辈', 'mdi:account-heart', '#EF4444', 'expense', 9, 6, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 金融子分类 (parent_id = 10) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('信用卡还款', 'mdi:credit-card', '#6366F1', 'expense', 10, 1, 1, NOW()), +('借款还款', 'mdi:cash-refund', '#EF4444', 'expense', 10, 2, 1, NOW()), +('花呗还款', 'mdi:web', '#1677FF', 'expense', 10, 3, 1, NOW()), +('白条还款', 'mdi:package-variant', '#E4002B', 'expense', 10, 4, 1, NOW()), +('保险费', 'mdi:shield-check', '#0EA5E9', 'expense', 10, 5, 1, NOW()), +('投资亏损', 'mdi:chart-line-variant', '#DC2626', 'expense', 10, 6, 1, NOW()), +('手续费', 'mdi:percent', '#64748B', 'expense', 10, 7, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 宠物子分类 (parent_id = 11) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('宠物食品', 'mdi:food-drumstick', '#A16207', 'expense', 11, 1, 1, NOW()), +('宠物用品', 'mdi:paw', '#D97706', 'expense', 11, 2, 1, NOW()), +('宠物医疗', 'mdi:hospital-marker', '#EF4444', 'expense', 11, 3, 1, NOW()), +('宠物美容', 'mdi:content-cut', '#EC4899', 'expense', 11, 4, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 工作收入子分类 (parent_id = 13) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('工资', 'mdi:briefcase', '#10B981', 'income', 13, 1, 1, NOW()), +('奖金', 'mdi:trophy', '#FBBF24', 'income', 13, 2, 1, NOW()), +('年终奖', 'mdi:gift', '#F59E0B', 'income', 13, 3, 1, NOW()), +('加班费', 'mdi:clock-time-four', '#3B82F6', 'income', 13, 4, 1, NOW()), +('兼职', 'mdi:briefcase-clock', '#8B5CF6', 'income', 13, 5, 1, NOW()), +('提成', 'mdi:percent', '#22C55E', 'income', 13, 6, 1, NOW()), +('稿费', 'mdi:pencil', '#F97316', 'income', 13, 7, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 投资收益子分类 (parent_id = 14) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('股票收益', 'mdi:chart-areaspline', '#EF4444', 'income', 14, 1, 1, NOW()), +('基金收益', 'mdi:chart-bell-curve-cumulative', '#8B5CF6', 'income', 14, 2, 1, NOW()), +('余额宝', 'mdi:wallet', '#1677FF', 'income', 14, 3, 1, NOW()), +('理财产品', 'mdi:cash-multiple', '#F59E0B', 'income', 14, 4, 1, NOW()), +('银行利息', 'mdi:bank', '#3B82F6', 'income', 14, 5, 1, NOW()), +('数字货币', 'mdi:bitcoin', '#F7931A', 'income', 14, 6, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 被动收入子分类 (parent_id = 15) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('房租收入', 'mdi:home-city', '#92400E', 'income', 15, 1, 1, NOW()), +('分红', 'mdi:cash-plus', '#22C55E', 'income', 15, 2, 1, NOW()), +('版权收入', 'mdi:copyright', '#6366F1', 'income', 15, 3, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); + +-- ===================================================== +-- 其他收入子分类 (parent_id = 16) +-- ===================================================== +INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES +('微信红包', 'mdi:wechat', '#07C160', 'income', 16, 1, 1, NOW()), +('支付宝红包', 'mdi:web', '#1677FF', 'income', 16, 2, 1, NOW()), +('报销', 'mdi:receipt-text-check', '#10B981', 'income', 16, 3, 1, NOW()), +('退款', 'mdi:cash-refund', '#3B82F6', 'income', 16, 4, 1, NOW()), +('中奖', 'mdi:star-shooting', '#F59E0B', 'income', 16, 5, 1, NOW()), +('闲鱼', 'mdi:fishbowl', '#FBBF24', 'income', 16, 6, 1, NOW()), +('借款收回', 'mdi:cash-check', '#22C55E', 'income', 16, 7, 1, NOW()), +('其他', 'mdi:dots-horizontal', '#6B7280', 'income', 16, 99, 1, NOW()) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`); diff --git a/database/sql/schema.sql b/database/sql/schema.sql index ec36845..4b7ad91 100644 --- a/database/sql/schema.sql +++ b/database/sql/schema.sql @@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS `categories` ( `user_id` bigint(20) unsigned NOT NULL, `name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, `icon` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `color` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, `parent_id` bigint(20) unsigned DEFAULT NULL, `sort_order` bigint(20) DEFAULT '0', @@ -98,6 +99,7 @@ CREATE TABLE IF NOT EXISTS `categories` ( CONSTRAINT `fk_categories_parent` FOREIGN KEY (`parent_id`) REFERENCES `categories` (`id`) ON DELETE SET NULL ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Tags table CREATE TABLE IF NOT EXISTS `tags` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, @@ -221,6 +223,25 @@ CREATE TABLE IF NOT EXISTS `system_categories` ( UNIQUE KEY `idx_system_categories_code` (`code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Default Categories Template table (for new user initialization) +-- This table stores category templates that will be copied to new users +CREATE TABLE IF NOT EXISTS `default_categories` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `icon` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `color` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `parent_id` bigint(20) unsigned DEFAULT NULL, + `sort_order` bigint(20) DEFAULT '0', + `is_active` tinyint(1) DEFAULT '1', + `created_at` datetime(3) DEFAULT NULL, + `updated_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_default_categories_parent_id` (`parent_id`), + KEY `idx_default_categories_type` (`type`), + CONSTRAINT `fk_default_categories_parent` FOREIGN KEY (`parent_id`) REFERENCES `default_categories` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Budgets table CREATE TABLE IF NOT EXISTS `budgets` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, diff --git a/internal/models/default_category.go b/internal/models/default_category.go new file mode 100644 index 0000000..a303870 --- /dev/null +++ b/internal/models/default_category.go @@ -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 +} diff --git a/internal/models/models.go b/internal/models/models.go index 141bd9e..ff940d0 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` diff --git a/internal/repository/category_repository.go b/internal/repository/category_repository.go index 7f79e06..8fe06e4 100644 --- a/internal/repository/category_repository.go +++ b/internal/repository/category_repository.go @@ -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 { diff --git a/internal/service/category_service.go b/internal/service/category_service.go index 0751e7a..a985def 100644 --- a/internal/service/category_service.go +++ b/internal/service/category_service.go @@ -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, }