feat: 添加账单软删除功能

- 新增删除按钮(带二次确认)到账单详情抽屉
- 后端实现软删除(设置 is_deleted 标记)
- 所有查询方法自动过滤已删除记录
- 账单列表和复核页面都支持删除
- 版本更新至 1.2.0
This commit is contained in:
clz
2026-01-25 18:49:07 +08:00
parent a97a8d6a20
commit bacbabc0a5
12 changed files with 373 additions and 8 deletions

View File

@@ -0,0 +1,42 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"billai-server/repository"
)
type DeleteBillResponse struct {
Result bool `json:"result"`
Message string `json:"message,omitempty"`
}
// DeleteBill DELETE /api/bills/:id 删除清洗后的账单记录
func DeleteBill(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, DeleteBillResponse{Result: false, Message: "缺少账单 ID"})
return
}
repo := repository.GetRepository()
if repo == nil {
c.JSON(http.StatusInternalServerError, DeleteBillResponse{Result: false, Message: "数据库未连接"})
return
}
err := repo.DeleteCleanedBillByID(id)
if err != nil {
if err == repository.ErrNotFound {
c.JSON(http.StatusNotFound, DeleteBillResponse{Result: false, Message: "账单不存在"})
return
}
c.JSON(http.StatusInternalServerError, DeleteBillResponse{Result: false, Message: "删除失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, DeleteBillResponse{Result: true, Message: "删除成功"})
}

View File

@@ -23,6 +23,7 @@ type UpdateBillRequest struct {
PayMethod *string `json:"pay_method,omitempty"`
Status *string `json:"status,omitempty"`
Remark *string `json:"remark,omitempty"`
ReviewLevel *string `json:"review_level,omitempty"`
}
type UpdateBillResponse struct {
@@ -119,6 +120,16 @@ func UpdateBill(c *gin.Context) {
updates["remark"] = strings.TrimSpace(*req.Remark)
}
if req.ReviewLevel != nil {
// 允许设置为空字符串(清除复核等级)或 HIGH/LOW
v := strings.TrimSpace(*req.ReviewLevel)
if v != "" && v != "HIGH" && v != "LOW" {
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "review_level 只能是空、HIGH 或 LOW"})
return
}
updates["review_level"] = v
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, UpdateBillResponse{Result: false, Message: "没有可更新的字段"})
return

View File

@@ -94,6 +94,7 @@ type CleanedBill struct {
Status string `bson:"status" json:"status"` // 交易状态
Remark string `bson:"remark" json:"remark"` // 备注
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空
IsDeleted bool `bson:"is_deleted" json:"is_deleted"` // 是否已删除(软删除)
CreatedAt time.Time `bson:"created_at" json:"created_at"` // 创建时间
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` // 更新时间
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名

View File

@@ -192,6 +192,9 @@ func (r *Repository) GetCleanedBills(filter map[string]interface{}) ([]model.Cle
bsonFilter[k] = v
}
// 排除已删除的记录
bsonFilter["is_deleted"] = bson.M{"$ne": true}
// 按时间倒序排列
opts := options.Find().SetSort(bson.D{{Key: "time", Value: -1}})
@@ -220,6 +223,9 @@ func (r *Repository) GetCleanedBillsPaged(filter map[string]interface{}, page, p
bsonFilter[k] = v
}
// 排除已删除的记录
bsonFilter["is_deleted"] = bson.M{"$ne": true}
// 计算总数
total, err := r.cleanedCollection.CountDocuments(ctx, bsonFilter)
if err != nil {
@@ -260,6 +266,9 @@ func (r *Repository) GetBillsAggregate(filter map[string]interface{}) (totalExpe
bsonFilter[k] = v
}
// 排除已删除的记录
bsonFilter["is_deleted"] = bson.M{"$ne": true}
// 使用聚合管道按 income_expense 分组统计金额
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bsonFilter}},
@@ -300,6 +309,7 @@ func (r *Repository) GetBillsAggregate(filter map[string]interface{}) (totalExpe
func (r *Repository) GetBillsNeedReview() ([]model.CleanedBill, error) {
filter := map[string]interface{}{
"review_level": bson.M{"$in": []string{"HIGH", "LOW"}},
"is_deleted": bson.M{"$ne": true},
}
return r.GetCleanedBills(filter)
}
@@ -312,6 +322,8 @@ func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) {
// 使用聚合管道按月份分组统计
// 先按月份和收支类型分组,再汇总
pipeline := mongo.Pipeline{
// 排除已删除的记录
{{Key: "$match", Value: bson.M{"is_deleted": bson.M{"$ne": true}}}},
// 添加月份字段
{{Key: "$addFields", Value: bson.D{
{Key: "month", Value: bson.D{
@@ -418,6 +430,34 @@ func (r *Repository) UpdateCleanedBillByID(id string, updates map[string]interfa
return &updated, nil
}
// DeleteCleanedBillByID 按 ID 软删除清洗后的账单(设置 is_deleted = true
func (r *Repository) DeleteCleanedBillByID(id string) error {
if r.cleanedCollection == nil {
return fmt.Errorf("cleaned collection not initialized")
}
oid, err := primitive.ObjectIDFromHex(id)
if err != nil {
return fmt.Errorf("invalid id: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
filter := bson.M{"_id": oid}
update := bson.M{"$set": bson.M{"is_deleted": true}}
result, err := r.cleanedCollection.UpdateOne(ctx, filter, update)
if err != nil {
return fmt.Errorf("soft delete bill failed: %w", err)
}
if result.MatchedCount == 0 {
return repository.ErrNotFound
}
return nil
}
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
func (r *Repository) GetClient() *mongo.Client {
return r.client

View File

@@ -46,6 +46,9 @@ type BillRepository interface {
// UpdateCleanedBillByID 按 ID 更新清洗后的账单,并返回更新后的记录
UpdateCleanedBillByID(id string, updates map[string]interface{}) (*model.CleanedBill, error)
// DeleteCleanedBillByID 按 ID 删除清洗后的账单
DeleteCleanedBillByID(id string) error
// CountRawByField 按字段统计原始数据数量
CountRawByField(fieldName, value string) (int64, error)
}

View File

@@ -62,6 +62,9 @@ func setupAPIRoutes(r *gin.Engine) {
// 编辑账单
authed.POST("/bills/:id", handler.UpdateBill)
// 删除账单(软删除)
authed.DELETE("/bills/:id", handler.DeleteBill)
// 手动创建账单
authed.POST("/bills/manual", handler.CreateManualBills)