Merge branch 'main' of http://124.221.157.197:3345/Novault/Novault-backend
This commit is contained in:
43
internal/handler/health_score_handler.go
Normal file
43
internal/handler/health_score_handler.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"accounting-app/internal/service"
|
||||
"accounting-app/pkg/api"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HealthScoreHandler handles HTTP requests for health score
|
||||
type HealthScoreHandler struct {
|
||||
healthScoreService *service.HealthScoreService
|
||||
}
|
||||
|
||||
// NewHealthScoreHandler creates a new HealthScoreHandler instance
|
||||
func NewHealthScoreHandler(healthScoreService *service.HealthScoreService) *HealthScoreHandler {
|
||||
return &HealthScoreHandler{
|
||||
healthScoreService: healthScoreService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetHealthScore handles GET /api/v1/reports/health-score
|
||||
// Returns the user's financial health score
|
||||
func (h *HealthScoreHandler) GetHealthScore(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
api.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.healthScoreService.CalculateHealthScore(userID.(uint))
|
||||
if err != nil {
|
||||
api.InternalError(c, "Failed to calculate health score: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.Success(c, result)
|
||||
}
|
||||
|
||||
// RegisterRoutes registers health score route
|
||||
func (h *HealthScoreHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("/reports/health-score", h.GetHealthScore)
|
||||
}
|
||||
@@ -1,57 +1,100 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"accounting-app/internal/models"
|
||||
"accounting-app/internal/service"
|
||||
"accounting-app/pkg/api"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NotificationHandler handles HTTP requests for notification operations
|
||||
type NotificationHandler struct {
|
||||
service *service.NotificationService
|
||||
notificationService *service.NotificationService
|
||||
}
|
||||
|
||||
func NewNotificationHandler(service *service.NotificationService) *NotificationHandler {
|
||||
return &NotificationHandler{service: service}
|
||||
// NewNotificationHandler creates a new NotificationHandler instance
|
||||
func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler {
|
||||
return &NotificationHandler{
|
||||
notificationService: notificationService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNotifications returns a list of notifications for the user
|
||||
// GetNotifications handles GET /api/v1/notifications
|
||||
// Returns a list of notifications with pagination and filtering
|
||||
func (h *NotificationHandler) GetNotifications(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
api.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
// Parse query parameters
|
||||
input := service.NotificationListInput{}
|
||||
|
||||
var isRead *bool
|
||||
if val, ok := c.GetQuery("is_read"); ok {
|
||||
parsedVal, err := strconv.ParseBool(val)
|
||||
if err == nil {
|
||||
isRead = &parsedVal
|
||||
}
|
||||
// Parse type filter
|
||||
if typeStr := c.Query("type"); typeStr != "" {
|
||||
notifType := models.NotificationType(typeStr)
|
||||
input.Type = ¬ifType
|
||||
}
|
||||
|
||||
notifications, total, err := h.service.GetNotifications(userID.(uint), page, limit, isRead)
|
||||
// Parse is_read filter
|
||||
if isReadStr := c.Query("is_read"); isReadStr != "" {
|
||||
isRead := isReadStr == "true"
|
||||
input.IsRead = &isRead
|
||||
}
|
||||
|
||||
// Parse pagination
|
||||
if offsetStr := c.Query("offset"); offsetStr != "" {
|
||||
offset, err := strconv.Atoi(offsetStr)
|
||||
if err != nil || offset < 0 {
|
||||
api.BadRequest(c, "Invalid offset")
|
||||
return
|
||||
}
|
||||
input.Offset = offset
|
||||
}
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit < 0 {
|
||||
api.BadRequest(c, "Invalid limit")
|
||||
return
|
||||
}
|
||||
input.Limit = limit
|
||||
}
|
||||
|
||||
result, err := h.notificationService.ListNotifications(userID.(uint), input)
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "FETCH_ERROR", "Failed to fetch notifications")
|
||||
api.InternalError(c, "Failed to get notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.Success(c, gin.H{
|
||||
"notifications": notifications,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
// Calculate total pages
|
||||
totalPages := 0
|
||||
if result.Limit > 0 {
|
||||
totalPages = int((result.Total + int64(result.Limit) - 1) / int64(result.Limit))
|
||||
}
|
||||
|
||||
// Calculate current page (1-indexed)
|
||||
currentPage := 1
|
||||
if result.Limit > 0 {
|
||||
currentPage = (result.Offset / result.Limit) + 1
|
||||
}
|
||||
|
||||
api.SuccessWithMeta(c, result.Notifications, &api.Meta{
|
||||
Page: currentPage,
|
||||
PageSize: result.Limit,
|
||||
TotalCount: result.Total,
|
||||
TotalPages: totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUnreadCount returns the number of unread notifications
|
||||
// GetUnreadCount handles GET /api/v1/notifications/unread-count
|
||||
// Returns the count of unread notifications
|
||||
func (h *NotificationHandler) GetUnreadCount(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
@@ -59,18 +102,17 @@ func (h *NotificationHandler) GetUnreadCount(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.service.GetUnreadCount(userID.(uint))
|
||||
count, err := h.notificationService.GetUnreadCount(userID.(uint))
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "FETCH_ERROR", "Failed to count unread notifications")
|
||||
api.InternalError(c, "Failed to get unread count: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.Success(c, gin.H{
|
||||
"count": count,
|
||||
})
|
||||
api.Success(c, gin.H{"count": count})
|
||||
}
|
||||
|
||||
// MarkAsRead marks a specific notification as read
|
||||
// MarkAsRead handles PUT /api/v1/notifications/:id/read
|
||||
// Marks a notification as read
|
||||
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
@@ -78,21 +120,27 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusBadRequest, "INVALID_ID", "Invalid notification ID")
|
||||
api.BadRequest(c, "Invalid notification ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.MarkAsRead(uint(id), userID.(uint)); err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "UPDATE_ERROR", "Failed to mark as read")
|
||||
err = h.notificationService.MarkAsRead(userID.(uint), uint(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrNotificationNotFound) {
|
||||
api.NotFound(c, "Notification not found")
|
||||
return
|
||||
}
|
||||
api.InternalError(c, "Failed to mark notification as read: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.Success(c, gin.H{"message": "Marked as read"})
|
||||
api.Success(c, gin.H{"message": "Notification marked as read"})
|
||||
}
|
||||
|
||||
// MarkAllAsRead marks all notifications as read
|
||||
// MarkAllAsRead handles POST /api/v1/notifications/read-all
|
||||
// Marks all notifications as read
|
||||
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
@@ -100,59 +148,51 @@ func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.MarkAllAsRead(userID.(uint)); err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "UPDATE_ERROR", "Failed to mark all as read")
|
||||
err := h.notificationService.MarkAllAsRead(userID.(uint))
|
||||
if err != nil {
|
||||
api.InternalError(c, "Failed to mark all notifications as read: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.Success(c, gin.H{"message": "All marked as read"})
|
||||
api.Success(c, gin.H{"message": "All notifications marked as read"})
|
||||
}
|
||||
|
||||
// CreateNotificationRequest represents the request body for creating a notification (internal/admin use primarily)
|
||||
type CreateNotificationRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
// CreateNotification creates a new notification (For testing/admin purposes)
|
||||
func (h *NotificationHandler) CreateNotification(c *gin.Context) {
|
||||
// DeleteNotification handles DELETE /api/v1/notifications/:id
|
||||
// Deletes a notification
|
||||
func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
api.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateNotificationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.CreateNotification(
|
||||
userID.(uint),
|
||||
req.Title,
|
||||
req.Content,
|
||||
models.NotificationType(req.Type),
|
||||
req.Link,
|
||||
)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusInternalServerError, "CREATE_ERROR", "Failed to create notification")
|
||||
api.BadRequest(c, "Invalid notification ID")
|
||||
return
|
||||
}
|
||||
|
||||
api.Success(c, gin.H{"message": "Notification created"})
|
||||
err = h.notificationService.DeleteNotification(userID.(uint), uint(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrNotificationNotFound) {
|
||||
api.NotFound(c, "Notification not found")
|
||||
return
|
||||
}
|
||||
api.InternalError(c, "Failed to delete notification: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.NoContent(c)
|
||||
}
|
||||
|
||||
// RegisterRoutes registers the notification routes
|
||||
func (h *NotificationHandler) RegisterRoutes(r *gin.RouterGroup) {
|
||||
notifications := r.Group("/notifications")
|
||||
// RegisterRoutes registers all notification routes to the given router group
|
||||
func (h *NotificationHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
notifications := rg.Group("/notifications")
|
||||
{
|
||||
notifications.GET("", h.GetNotifications)
|
||||
notifications.GET("/unread-count", h.GetUnreadCount)
|
||||
notifications.PUT("/:id/read", h.MarkAsRead)
|
||||
notifications.PUT("/read-all", h.MarkAllAsRead)
|
||||
notifications.POST("", h.CreateNotification) // Admin/Internal use
|
||||
notifications.POST("/read-all", h.MarkAllAsRead)
|
||||
notifications.DELETE("/:id", h.DeleteNotification)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"accounting-app/pkg/api"
|
||||
"accounting-app/internal/models"
|
||||
"accounting-app/internal/service"
|
||||
"accounting-app/pkg/api"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -155,6 +155,17 @@ func (h *TransactionHandler) GetTransactions(c *gin.Context) {
|
||||
input.AccountID = &accID
|
||||
}
|
||||
|
||||
// Parse ledger filter
|
||||
if ledgerIDStr := c.Query("ledger_id"); ledgerIDStr != "" {
|
||||
ledgerID, err := strconv.ParseUint(ledgerIDStr, 10, 32)
|
||||
if err != nil {
|
||||
api.BadRequest(c, "Invalid ledger_id")
|
||||
return
|
||||
}
|
||||
lid := uint(ledgerID)
|
||||
input.LedgerID = &lid
|
||||
}
|
||||
|
||||
if typeStr := c.Query("type"); typeStr != "" {
|
||||
txnType := models.TransactionType(typeStr)
|
||||
input.Type = &txnType
|
||||
|
||||
@@ -835,6 +835,35 @@ func (AllocationRecordDetail) TableName() string {
|
||||
return "allocation_record_details"
|
||||
}
|
||||
|
||||
// NotificationType represents the type of notification
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
NotificationTypeBudgetAlert NotificationType = "budget_alert" // 预算超支提醒
|
||||
NotificationTypeRecurring NotificationType = "recurring" // 周期交易提醒
|
||||
NotificationTypeSystem NotificationType = "system" // 系统公告
|
||||
NotificationTypePaymentReminder NotificationType = "payment_reminder" // 还款提醒
|
||||
NotificationTypeSavingsGoal NotificationType = "savings_goal" // 储蓄目标提醒
|
||||
)
|
||||
|
||||
// Notification represents a user notification
|
||||
// Feature: notification-center
|
||||
type Notification struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Type NotificationType `gorm:"size:30;not null;index" json:"type"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"size:1000;not null" json:"content"`
|
||||
IsRead bool `gorm:"default:false;index" json:"is_read"`
|
||||
RelatedID *uint `gorm:"index" json:"related_id,omitempty"` // 关联实体 ID (如预算、周期交易等)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for Notification
|
||||
func (Notification) TableName() string {
|
||||
return "notifications"
|
||||
}
|
||||
|
||||
// AllModels returns all models for database migration
|
||||
func AllModels() []interface{} {
|
||||
return []interface{}{
|
||||
@@ -865,6 +894,7 @@ func AllModels() []interface{} {
|
||||
&SystemCategory{}, // Feature: accounting-feature-upgrade
|
||||
&TransactionImage{}, // Feature: accounting-feature-upgrade
|
||||
&UserSettings{}, // Feature: accounting-feature-upgrade
|
||||
&Notification{}, // Feature: notification-center
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +1,160 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"accounting-app/internal/models"
|
||||
"time"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"accounting-app/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Notification repository errors
|
||||
var (
|
||||
ErrNotificationNotFound = errors.New("notification not found")
|
||||
)
|
||||
|
||||
// NotificationRepository handles database operations for notifications
|
||||
type NotificationRepository struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewNotificationRepository creates a new NotificationRepository instance
|
||||
func NewNotificationRepository(db *gorm.DB) *NotificationRepository {
|
||||
return &NotificationRepository{db: db}
|
||||
return &NotificationRepository{db: db}
|
||||
}
|
||||
|
||||
// Create creates a new notification
|
||||
// Create creates a new notification in the database
|
||||
func (r *NotificationRepository) Create(notification *models.Notification) error {
|
||||
return r.db.Create(notification).Error
|
||||
if err := r.db.Create(notification).Error; err != nil {
|
||||
return fmt.Errorf("failed to create notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindAll retrieves notifications for a user with pagination and filtering
|
||||
func (r *NotificationRepository) FindAll(userID uint, page, pageSize int, isRead *bool) ([]models.Notification, int64, error) {
|
||||
var notifications []models.Notification
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.Notification{}).Where("user_id = ?", userID)
|
||||
|
||||
if isRead != nil {
|
||||
query = query.Where("is_read = ?", *isRead)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Order("created_at desc").
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Find(¬ifications).Error
|
||||
|
||||
return notifications, total, err
|
||||
// GetByID retrieves a notification by its ID
|
||||
func (r *NotificationRepository) GetByID(userID uint, id uint) (*models.Notification, error) {
|
||||
var notification models.Notification
|
||||
if err := r.db.Where("user_id = ?", userID).First(¬ification, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotificationNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get notification: %w", err)
|
||||
}
|
||||
return ¬ification, nil
|
||||
}
|
||||
|
||||
// CountUnread counts unread notifications for a user
|
||||
func (r *NotificationRepository) CountUnread(userID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.Notification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, false).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
// NotificationListOptions contains options for listing notifications
|
||||
type NotificationListOptions struct {
|
||||
Type *models.NotificationType
|
||||
IsRead *bool
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// MarkAsRead marks a single notification as read
|
||||
func (r *NotificationRepository) MarkAsRead(id uint, userID uint) error {
|
||||
return r.db.Model(&models.Notification{}).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Updates(map[string]interface{}{
|
||||
"is_read": true,
|
||||
"read_at": time.Now(),
|
||||
}).Error
|
||||
// NotificationListResult contains the result of a paginated notification list query
|
||||
type NotificationListResult struct {
|
||||
Notifications []models.Notification
|
||||
Total int64
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// List retrieves notifications with pagination and filtering
|
||||
func (r *NotificationRepository) List(userID uint, options NotificationListOptions) (*NotificationListResult, error) {
|
||||
query := r.db.Model(&models.Notification{}).Where("user_id = ?", userID)
|
||||
|
||||
// Apply filters
|
||||
if options.Type != nil {
|
||||
query = query.Where("type = ?", *options.Type)
|
||||
}
|
||||
if options.IsRead != nil {
|
||||
query = query.Where("is_read = ?", *options.IsRead)
|
||||
}
|
||||
|
||||
// Count total before pagination
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count notifications: %w", err)
|
||||
}
|
||||
|
||||
// Apply sorting (newest first)
|
||||
query = query.Order("created_at DESC")
|
||||
|
||||
// Apply pagination
|
||||
if options.Limit > 0 {
|
||||
query = query.Limit(options.Limit)
|
||||
}
|
||||
if options.Offset > 0 {
|
||||
query = query.Offset(options.Offset)
|
||||
}
|
||||
|
||||
// Execute query
|
||||
var notifications []models.Notification
|
||||
if err := query.Find(¬ifications).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to list notifications: %w", err)
|
||||
}
|
||||
|
||||
return &NotificationListResult{
|
||||
Notifications: notifications,
|
||||
Total: total,
|
||||
Offset: options.Offset,
|
||||
Limit: options.Limit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MarkAsRead marks a notification as read
|
||||
func (r *NotificationRepository) MarkAsRead(userID uint, id uint) error {
|
||||
result := r.db.Model(&models.Notification{}).
|
||||
Where("user_id = ? AND id = ?", userID, id).
|
||||
Update("is_read", true)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to mark notification as read: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrNotificationNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllAsRead marks all notifications as read for a user
|
||||
func (r *NotificationRepository) MarkAllAsRead(userID uint) error {
|
||||
<<<<<<< HEAD
|
||||
return r.db.Model(&models.Notification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, false).
|
||||
Updates(map[string]interface{}{
|
||||
"is_read": true,
|
||||
"read_at": time.Now(),
|
||||
}).Error
|
||||
=======
|
||||
if err := r.db.Model(&models.Notification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, false).
|
||||
Update("is_read", true).Error; err != nil {
|
||||
return fmt.Errorf("failed to mark all notifications as read: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes a notification by its ID
|
||||
func (r *NotificationRepository) Delete(userID uint, id uint) error {
|
||||
result := r.db.Where("user_id = ? AND id = ?", userID, id).Delete(&models.Notification{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to delete notification: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrNotificationNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUnreadCount returns the count of unread notifications for a user
|
||||
func (r *NotificationRepository) GetUnreadCount(userID uint) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.Model(&models.Notification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, false).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("failed to count unread notifications: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
>>>>>>> 2dc66d478cbe3aafaae601f0939ab448b690365e
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type TransactionFilter struct {
|
||||
// Entity filters
|
||||
CategoryID *uint
|
||||
AccountID *uint
|
||||
LedgerID *uint // 账本过滤
|
||||
TagIDs []uint
|
||||
Type *models.TransactionType
|
||||
Currency *models.Currency
|
||||
@@ -277,6 +278,13 @@ func (r *TransactionRepository) applyFilters(query *gorm.DB, filter TransactionF
|
||||
if filter.RecurringID != nil {
|
||||
query = query.Where("recurring_id = ?", *filter.RecurringID)
|
||||
}
|
||||
// LedgerID filter - requires subquery to get accounts belonging to the ledger
|
||||
if filter.LedgerID != nil {
|
||||
query = query.Where("account_id IN (?)",
|
||||
r.db.Model(&models.Account{}).
|
||||
Select("id").
|
||||
Where("ledger_id = ?", *filter.LedgerID))
|
||||
}
|
||||
// UserID provided in argument takes precedence, but if filter has it, we can redundant check or ignore.
|
||||
// The caller `List` already applied `Where("user_id = ?", userID)`.
|
||||
|
||||
|
||||
@@ -92,6 +92,12 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config)
|
||||
interestService := service.NewInterestService(accountRepo, transactionRepo, db)
|
||||
userSettingsServiceWithAccounts := service.NewUserSettingsServiceWithAccountRepo(userSettingsRepo, accountRepo)
|
||||
|
||||
// Feature: notification-center - Initialize notification service
|
||||
notificationService := service.NewNotificationService(notificationRepo)
|
||||
|
||||
// Feature: health-score - Initialize health score service
|
||||
healthScoreService := service.NewHealthScoreService(reportRepo, accountRepo, budgetRepo)
|
||||
|
||||
// Initialize handlers
|
||||
accountHandler := handler.NewAccountHandler(accountService)
|
||||
categoryHandler := handler.NewCategoryHandler(categoryService)
|
||||
@@ -122,6 +128,12 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config)
|
||||
defaultAccountHandler := handler.NewDefaultAccountHandler(userSettingsServiceWithAccounts)
|
||||
interestHandler := handler.NewInterestHandler(interestService, nil)
|
||||
|
||||
// Feature: notification-center - Initialize notification handler
|
||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||
|
||||
// Feature: health-score - Initialize health score handler
|
||||
healthScoreHandler := handler.NewHealthScoreHandler(healthScoreService)
|
||||
|
||||
// AI Bookkeeping Service and Handler
|
||||
aiBookkeepingService := service.NewAIBookkeepingService(
|
||||
cfg,
|
||||
@@ -219,6 +231,9 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config)
|
||||
// AI bookkeeping routes
|
||||
aiHandler.RegisterRoutes(protected)
|
||||
|
||||
// Feature: notification-center - Register notification routes
|
||||
notificationHandler.RegisterRoutes(protected)
|
||||
|
||||
// Register report routes
|
||||
protected.GET("/reports/summary", reportHandler.GetTransactionSummary)
|
||||
protected.GET("/reports/category", reportHandler.GetCategorySummary)
|
||||
|
||||
381
internal/service/health_score_service.go
Normal file
381
internal/service/health_score_service.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"accounting-app/internal/models"
|
||||
"accounting-app/internal/repository"
|
||||
)
|
||||
|
||||
// HealthScoreService handles financial health score calculation
|
||||
type HealthScoreService struct {
|
||||
reportRepo *repository.ReportRepository
|
||||
accountRepo *repository.AccountRepository
|
||||
budgetRepo *repository.BudgetRepository
|
||||
}
|
||||
|
||||
// NewHealthScoreService creates a new HealthScoreService instance
|
||||
func NewHealthScoreService(
|
||||
reportRepo *repository.ReportRepository,
|
||||
accountRepo *repository.AccountRepository,
|
||||
budgetRepo *repository.BudgetRepository,
|
||||
) *HealthScoreService {
|
||||
return &HealthScoreService{
|
||||
reportRepo: reportRepo,
|
||||
accountRepo: accountRepo,
|
||||
budgetRepo: budgetRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// HealthScoreFactor represents a factor contributing to the health score
|
||||
type HealthScoreFactor struct {
|
||||
Name string `json:"name"`
|
||||
Score float64 `json:"score"` // 0-100
|
||||
Weight float64 `json:"weight"` // 0-1
|
||||
Tip string `json:"tip"` // 改进建议
|
||||
Description string `json:"description"` // 因素描述
|
||||
}
|
||||
|
||||
// HealthScoreResult represents the overall health score result
|
||||
type HealthScoreResult struct {
|
||||
Score int `json:"score"` // 0-100
|
||||
Level string `json:"level"` // 优秀/良好/一般/需改善
|
||||
Description string `json:"description"` // 总体描述
|
||||
Factors []HealthScoreFactor `json:"factors"`
|
||||
}
|
||||
|
||||
// CalculateHealthScore calculates the financial health score for a user
|
||||
func (s *HealthScoreService) CalculateHealthScore(userID uint) (*HealthScoreResult, error) {
|
||||
now := time.Now()
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
threeMonthsAgo := monthStart.AddDate(0, -3, 0)
|
||||
|
||||
factors := []HealthScoreFactor{}
|
||||
totalWeight := 0.0
|
||||
weightedSum := 0.0
|
||||
|
||||
// Factor 1: 储蓄率 (Savings Rate) - 权重 30%
|
||||
savingsScore, savingsTip := s.calculateSavingsRate(userID, threeMonthsAgo, now)
|
||||
savingsFactor := HealthScoreFactor{
|
||||
Name: "储蓄率",
|
||||
Score: savingsScore,
|
||||
Weight: 0.30,
|
||||
Tip: savingsTip,
|
||||
Description: "收入中储蓄的比例",
|
||||
}
|
||||
factors = append(factors, savingsFactor)
|
||||
weightedSum += savingsScore * 0.30
|
||||
totalWeight += 0.30
|
||||
|
||||
// Factor 2: 负债率 (Debt Ratio) - 权重 25%
|
||||
debtScore, debtTip := s.calculateDebtRatio(userID)
|
||||
debtFactor := HealthScoreFactor{
|
||||
Name: "负债率",
|
||||
Score: debtScore,
|
||||
Weight: 0.25,
|
||||
Tip: debtTip,
|
||||
Description: "负债占总资产的比例",
|
||||
}
|
||||
factors = append(factors, debtFactor)
|
||||
weightedSum += debtScore * 0.25
|
||||
totalWeight += 0.25
|
||||
|
||||
// Factor 3: 预算执行率 (Budget Compliance) - 权重 20%
|
||||
budgetScore, budgetTip := s.calculateBudgetCompliance(userID)
|
||||
budgetFactor := HealthScoreFactor{
|
||||
Name: "预算执行",
|
||||
Score: budgetScore,
|
||||
Weight: 0.20,
|
||||
Tip: budgetTip,
|
||||
Description: "预算控制情况",
|
||||
}
|
||||
factors = append(factors, budgetFactor)
|
||||
weightedSum += budgetScore * 0.20
|
||||
totalWeight += 0.20
|
||||
|
||||
// Factor 4: 消费稳定性 (Spending Stability) - 权重 15%
|
||||
stabilityScore, stabilityTip := s.calculateSpendingStability(userID, threeMonthsAgo, now)
|
||||
stabilityFactor := HealthScoreFactor{
|
||||
Name: "消费稳定",
|
||||
Score: stabilityScore,
|
||||
Weight: 0.15,
|
||||
Tip: stabilityTip,
|
||||
Description: "月度支出波动情况",
|
||||
}
|
||||
factors = append(factors, stabilityFactor)
|
||||
weightedSum += stabilityScore * 0.15
|
||||
totalWeight += 0.15
|
||||
|
||||
// Factor 5: 资产多样性 (Asset Diversity) - 权重 10%
|
||||
diversityScore, diversityTip := s.calculateAssetDiversity(userID)
|
||||
diversityFactor := HealthScoreFactor{
|
||||
Name: "资产配置",
|
||||
Score: diversityScore,
|
||||
Weight: 0.10,
|
||||
Tip: diversityTip,
|
||||
Description: "资产分散程度",
|
||||
}
|
||||
factors = append(factors, diversityFactor)
|
||||
weightedSum += diversityScore * 0.10
|
||||
totalWeight += 0.10
|
||||
|
||||
// Calculate final score
|
||||
finalScore := int(weightedSum / totalWeight)
|
||||
if finalScore > 100 {
|
||||
finalScore = 100
|
||||
}
|
||||
if finalScore < 0 {
|
||||
finalScore = 0
|
||||
}
|
||||
|
||||
// Determine level and description
|
||||
level, description := getHealthScoreLevel(finalScore)
|
||||
|
||||
return &HealthScoreResult{
|
||||
Score: finalScore,
|
||||
Level: level,
|
||||
Description: description,
|
||||
Factors: factors,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// calculateSavingsRate calculates the savings rate factor
|
||||
func (s *HealthScoreService) calculateSavingsRate(userID uint, startDate, endDate time.Time) (float64, string) {
|
||||
// Get income and expense totals using existing repository method
|
||||
summaries, err := s.reportRepo.GetTransactionSummaryByCurrency(userID, startDate, endDate)
|
||||
if err != nil || len(summaries) == 0 {
|
||||
return 50.0, "无法获取收支数据"
|
||||
}
|
||||
|
||||
var totalIncome, totalExpense float64
|
||||
for _, cs := range summaries {
|
||||
totalIncome += cs.TotalIncome
|
||||
totalExpense += cs.TotalExpense
|
||||
}
|
||||
|
||||
if totalIncome <= 0 {
|
||||
return 30.0, "暂无收入记录,建议开始记录收入"
|
||||
}
|
||||
|
||||
savingsRate := (totalIncome - totalExpense) / totalIncome * 100
|
||||
|
||||
var score float64
|
||||
var tip string
|
||||
|
||||
switch {
|
||||
case savingsRate >= 30:
|
||||
score = 100
|
||||
tip = "储蓄率非常优秀,继续保持!"
|
||||
case savingsRate >= 20:
|
||||
score = 85
|
||||
tip = "储蓄率良好,可以考虑增加投资"
|
||||
case savingsRate >= 10:
|
||||
score = 70
|
||||
tip = "储蓄率一般,建议适当控制支出"
|
||||
case savingsRate >= 0:
|
||||
score = 50
|
||||
tip = "储蓄率偏低,需要减少非必要支出"
|
||||
default:
|
||||
score = 20
|
||||
tip = "入不敷出,请尽快调整消费习惯"
|
||||
}
|
||||
|
||||
return score, tip
|
||||
}
|
||||
|
||||
// calculateDebtRatio calculates the debt ratio factor
|
||||
func (s *HealthScoreService) calculateDebtRatio(userID uint) (float64, string) {
|
||||
// Use GetTotalBalance which returns assets and liabilities
|
||||
totalAssets, totalLiabilities, err := s.accountRepo.GetTotalBalance(userID)
|
||||
if err != nil {
|
||||
return 50.0, "无法获取账户数据"
|
||||
}
|
||||
|
||||
if totalAssets <= 0 && totalLiabilities <= 0 {
|
||||
return 70.0, "暂无资产负债记录"
|
||||
}
|
||||
|
||||
// Debt ratio = Liabilities / (Assets + Liabilities)
|
||||
total := totalAssets + totalLiabilities
|
||||
if total <= 0 {
|
||||
return 30.0, "净资产为负,需要关注财务状况"
|
||||
}
|
||||
|
||||
debtRatio := totalLiabilities / total * 100
|
||||
|
||||
var score float64
|
||||
var tip string
|
||||
|
||||
switch {
|
||||
case debtRatio <= 10:
|
||||
score = 100
|
||||
tip = "负债率极低,财务非常健康"
|
||||
case debtRatio <= 30:
|
||||
score = 85
|
||||
tip = "负债率健康,保持良好习惯"
|
||||
case debtRatio <= 50:
|
||||
score = 65
|
||||
tip = "负债率中等,建议适度控制"
|
||||
case debtRatio <= 70:
|
||||
score = 40
|
||||
tip = "负债率偏高,需要制定还款计划"
|
||||
default:
|
||||
score = 20
|
||||
tip = "负债率过高,请优先处理债务"
|
||||
}
|
||||
|
||||
return score, tip
|
||||
}
|
||||
|
||||
// calculateBudgetCompliance calculates budget compliance factor
|
||||
func (s *HealthScoreService) calculateBudgetCompliance(userID uint) (float64, string) {
|
||||
now := time.Now()
|
||||
budgets, err := s.budgetRepo.GetActiveBudgets(userID, now)
|
||||
if err != nil || len(budgets) == 0 {
|
||||
return 60.0, "暂无预算设置,建议设置预算"
|
||||
}
|
||||
|
||||
var totalBudgets int
|
||||
var overBudgetCount int
|
||||
|
||||
for _, budget := range budgets {
|
||||
// 获取预算期间的支出
|
||||
spent, err := s.budgetRepo.GetSpentAmount(&budget, budget.StartDate, now)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
totalBudgets++
|
||||
if spent > budget.Amount {
|
||||
overBudgetCount++
|
||||
}
|
||||
}
|
||||
|
||||
if totalBudgets == 0 {
|
||||
return 60.0, "暂无有效预算"
|
||||
}
|
||||
|
||||
complianceRate := float64(totalBudgets-overBudgetCount) / float64(totalBudgets) * 100
|
||||
|
||||
var score float64
|
||||
var tip string
|
||||
|
||||
switch {
|
||||
case complianceRate >= 90:
|
||||
score = 100
|
||||
tip = "预算执行非常出色!"
|
||||
case complianceRate >= 70:
|
||||
score = 80
|
||||
tip = "预算执行良好,继续保持"
|
||||
case complianceRate >= 50:
|
||||
score = 60
|
||||
tip = "部分预算超支,需要加强控制"
|
||||
default:
|
||||
score = 30
|
||||
tip = "预算超支严重,请重新规划预算"
|
||||
}
|
||||
|
||||
return score, tip
|
||||
}
|
||||
|
||||
// calculateSpendingStability calculates spending stability factor
|
||||
func (s *HealthScoreService) calculateSpendingStability(userID uint, startDate, endDate time.Time) (float64, string) {
|
||||
// 获取按月的支出趋势
|
||||
trendData, err := s.reportRepo.GetTrendDataByMonth(userID, startDate, endDate, nil)
|
||||
if err != nil || len(trendData) < 2 {
|
||||
return 70.0, "数据不足以分析消费稳定性"
|
||||
}
|
||||
|
||||
// 计算支出的标准差
|
||||
var sum, sumSq float64
|
||||
n := float64(len(trendData))
|
||||
|
||||
for _, dp := range trendData {
|
||||
sum += dp.TotalExpense
|
||||
sumSq += dp.TotalExpense * dp.TotalExpense
|
||||
}
|
||||
|
||||
mean := sum / n
|
||||
variance := (sumSq / n) - (mean * mean)
|
||||
|
||||
// 变异系数 (CV) = 标准差 / 平均值
|
||||
if mean <= 0 {
|
||||
return 70.0, "暂无支出记录"
|
||||
}
|
||||
|
||||
cv := (variance / (mean * mean)) * 100 // 简化计算
|
||||
|
||||
var score float64
|
||||
var tip string
|
||||
|
||||
switch {
|
||||
case cv <= 10:
|
||||
score = 100
|
||||
tip = "消费非常稳定,财务规划出色"
|
||||
case cv <= 25:
|
||||
score = 80
|
||||
tip = "消费较为稳定,理财意识良好"
|
||||
case cv <= 50:
|
||||
score = 60
|
||||
tip = "消费有一定波动,建议制定月度预算"
|
||||
default:
|
||||
score = 40
|
||||
tip = "消费波动较大,需要加强支出管理"
|
||||
}
|
||||
|
||||
return score, tip
|
||||
}
|
||||
|
||||
// calculateAssetDiversity calculates asset diversity factor
|
||||
func (s *HealthScoreService) calculateAssetDiversity(userID uint) (float64, string) {
|
||||
accounts, err := s.accountRepo.GetAll(userID)
|
||||
if err != nil {
|
||||
return 50.0, "无法获取账户数据"
|
||||
}
|
||||
|
||||
// 统计不同类型的账户数量
|
||||
accountTypes := make(map[models.AccountType]int)
|
||||
for _, acc := range accounts {
|
||||
if acc.Balance > 0 && !acc.IsCredit {
|
||||
accountTypes[acc.Type]++
|
||||
}
|
||||
}
|
||||
|
||||
diversity := len(accountTypes)
|
||||
|
||||
var score float64
|
||||
var tip string
|
||||
|
||||
switch {
|
||||
case diversity >= 4:
|
||||
score = 100
|
||||
tip = "资产配置多样化,抗风险能力强"
|
||||
case diversity >= 3:
|
||||
score = 80
|
||||
tip = "资产配置较好,可考虑增加投资类型"
|
||||
case diversity >= 2:
|
||||
score = 60
|
||||
tip = "资产类型偏少,建议分散配置"
|
||||
default:
|
||||
score = 40
|
||||
tip = "资产过于单一,建议多元化理财"
|
||||
}
|
||||
|
||||
return score, tip
|
||||
}
|
||||
|
||||
// getHealthScoreLevel returns the level and description based on score
|
||||
func getHealthScoreLevel(score int) (string, string) {
|
||||
switch {
|
||||
case score >= 90:
|
||||
return "优秀", "您的财务状况非常健康,继续保持良好的理财习惯!"
|
||||
case score >= 75:
|
||||
return "良好", "您的财务状况良好,还有一些提升空间。"
|
||||
case score >= 60:
|
||||
return "一般", "您的财务状况尚可,建议关注评分较低的方面。"
|
||||
case score >= 40:
|
||||
return "需改善", "您的财务状况需要改善,请查看具体建议。"
|
||||
default:
|
||||
return "警示", "您的财务状况存在风险,请尽快采取行动改善。"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,160 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"accounting-app/internal/models"
|
||||
"accounting-app/internal/repository"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"accounting-app/internal/models"
|
||||
"accounting-app/internal/repository"
|
||||
)
|
||||
|
||||
// Notification service errors
|
||||
var (
|
||||
ErrNotificationNotFound = errors.New("notification not found")
|
||||
)
|
||||
|
||||
// NotificationService handles business logic for notifications
|
||||
type NotificationService struct {
|
||||
repo *repository.NotificationRepository
|
||||
repo *repository.NotificationRepository
|
||||
}
|
||||
|
||||
// NewNotificationService creates a new NotificationService instance
|
||||
func NewNotificationService(repo *repository.NotificationRepository) *NotificationService {
|
||||
return &NotificationService{repo: repo}
|
||||
return &NotificationService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *NotificationService) CreateNotification(userID uint, title, content string, notifType models.NotificationType, link string) error {
|
||||
notification := &models.Notification{
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Type: notifType,
|
||||
Link: link,
|
||||
}
|
||||
return s.repo.Create(notification)
|
||||
// CreateNotificationInput represents input for creating a notification
|
||||
type CreateNotificationInput struct {
|
||||
UserID uint
|
||||
Type models.NotificationType
|
||||
Title string
|
||||
Content string
|
||||
RelatedID *uint
|
||||
}
|
||||
|
||||
func (s *NotificationService) GetNotifications(userID uint, page, limit int, isRead *bool) ([]models.Notification, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 10
|
||||
}
|
||||
return s.repo.FindAll(userID, page, limit, isRead)
|
||||
// CreateNotification creates a new notification
|
||||
func (s *NotificationService) CreateNotification(input CreateNotificationInput) (*models.Notification, error) {
|
||||
notification := &models.Notification{
|
||||
UserID: input.UserID,
|
||||
Type: input.Type,
|
||||
Title: input.Title,
|
||||
Content: input.Content,
|
||||
RelatedID: input.RelatedID,
|
||||
IsRead: false,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(notification); err != nil {
|
||||
return nil, fmt.Errorf("failed to create notification: %w", err)
|
||||
}
|
||||
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) GetUnreadCount(userID uint) (int64, error) {
|
||||
return s.repo.CountUnread(userID)
|
||||
// GetNotification retrieves a notification by ID
|
||||
func (s *NotificationService) GetNotification(userID, id uint) (*models.Notification, error) {
|
||||
notification, err := s.repo.GetByID(userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotificationNotFound) {
|
||||
return nil, ErrNotificationNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) MarkAsRead(id uint, userID uint) error {
|
||||
return s.repo.MarkAsRead(id, userID)
|
||||
// NotificationListInput represents input for listing notifications
|
||||
type NotificationListInput struct {
|
||||
Type *models.NotificationType
|
||||
IsRead *bool
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListNotifications retrieves notifications with pagination
|
||||
func (s *NotificationService) ListNotifications(userID uint, input NotificationListInput) (*repository.NotificationListResult, error) {
|
||||
limit := input.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
options := repository.NotificationListOptions{
|
||||
Type: input.Type,
|
||||
IsRead: input.IsRead,
|
||||
Offset: input.Offset,
|
||||
Limit: limit,
|
||||
}
|
||||
|
||||
return s.repo.List(userID, options)
|
||||
}
|
||||
|
||||
// MarkAsRead marks a notification as read
|
||||
func (s *NotificationService) MarkAsRead(userID, id uint) error {
|
||||
err := s.repo.MarkAsRead(userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotificationNotFound) {
|
||||
return ErrNotificationNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllAsRead marks all notifications as read
|
||||
func (s *NotificationService) MarkAllAsRead(userID uint) error {
|
||||
return s.repo.MarkAllAsRead(userID)
|
||||
return s.repo.MarkAllAsRead(userID)
|
||||
}
|
||||
|
||||
// DeleteNotification deletes a notification
|
||||
func (s *NotificationService) DeleteNotification(userID, id uint) error {
|
||||
err := s.repo.Delete(userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotificationNotFound) {
|
||||
return ErrNotificationNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUnreadCount returns the count of unread notifications
|
||||
func (s *NotificationService) GetUnreadCount(userID uint) (int64, error) {
|
||||
return s.repo.GetUnreadCount(userID)
|
||||
}
|
||||
|
||||
// SendBudgetAlert creates a budget alert notification
|
||||
func (s *NotificationService) SendBudgetAlert(userID uint, budgetName string, usedPercentage float64, budgetID uint) (*models.Notification, error) {
|
||||
title := fmt.Sprintf("预算提醒: %s", budgetName)
|
||||
content := fmt.Sprintf("您的「%s」预算已使用 %.1f%%,请注意控制支出。", budgetName, usedPercentage)
|
||||
|
||||
return s.CreateNotification(CreateNotificationInput{
|
||||
UserID: userID,
|
||||
Type: models.NotificationTypeBudgetAlert,
|
||||
Title: title,
|
||||
Content: content,
|
||||
RelatedID: &budgetID,
|
||||
})
|
||||
}
|
||||
|
||||
// SendRecurringReminder creates a recurring transaction reminder
|
||||
func (s *NotificationService) SendRecurringReminder(userID uint, recurringName string, amount float64, recurringID uint) (*models.Notification, error) {
|
||||
title := fmt.Sprintf("周期交易提醒: %s", recurringName)
|
||||
content := fmt.Sprintf("您的周期交易「%s」(¥%.2f) 已自动执行。", recurringName, amount)
|
||||
|
||||
return s.CreateNotification(CreateNotificationInput{
|
||||
UserID: userID,
|
||||
Type: models.NotificationTypeRecurring,
|
||||
Title: title,
|
||||
Content: content,
|
||||
RelatedID: &recurringID,
|
||||
})
|
||||
}
|
||||
|
||||
// SendSystemNotification creates a system notification
|
||||
func (s *NotificationService) SendSystemNotification(userID uint, title, content string) (*models.Notification, error) {
|
||||
return s.CreateNotification(CreateNotificationInput{
|
||||
UserID: userID,
|
||||
Type: models.NotificationTypeSystem,
|
||||
Title: title,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ type TransactionListInput struct {
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
CategoryID *uint `json:"category_id,omitempty"`
|
||||
AccountID *uint `json:"account_id,omitempty"`
|
||||
LedgerID *uint `json:"ledger_id,omitempty"`
|
||||
TagIDs []uint `json:"tag_ids,omitempty"`
|
||||
Type *models.TransactionType `json:"type,omitempty"`
|
||||
Currency *models.Currency `json:"currency,omitempty"`
|
||||
@@ -490,6 +491,7 @@ func (s *TransactionService) ListTransactions(userID uint, input TransactionList
|
||||
EndDate: input.EndDate,
|
||||
CategoryID: input.CategoryID,
|
||||
AccountID: input.AccountID,
|
||||
LedgerID: input.LedgerID,
|
||||
TagIDs: input.TagIDs,
|
||||
Type: input.Type,
|
||||
Currency: input.Currency,
|
||||
|
||||
Reference in New Issue
Block a user