diff --git a/controller/charge.go b/controller/charge.go new file mode 100644 index 0000000..0a7d932 --- /dev/null +++ b/controller/charge.go @@ -0,0 +1,101 @@ +package controller + +import ( + "electricity_bill_calc/logger" + "electricity_bill_calc/model" + "electricity_bill_calc/repository" + "electricity_bill_calc/response" + "electricity_bill_calc/service" + "net/http" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +var chargeLog = logger.Named("Handler", "Charge") + +func InitializeChargeHandlers(router *fiber.App) { + router.Get("/charge", searchCharges) + router.Post("/charge", createNewUserChargeRecord) + router.Put("/charge/:uid/:seq", modifyUserChargeState) +} + +// 检索用户的充值记录列表 +func searchCharges(c *fiber.Ctx) error { + chargeLog.Info("检索用户的充值记录列表。") + result := response.NewResult(c) + keyword := c.Query("keyword", "") + page := c.QueryInt("page", 1) + beginTime, err := model.ParseDate(c.Query("begin")) + if err != nil { + chargeLog.Error("无法解析查询起始时间。", zap.Error(err)) + return result.Error(http.StatusInternalServerError, err.Error()) + } + endTime, err := model.ParseDate(c.Query("end")) + if err != nil { + chargeLog.Error("无法解析查询结束时间。", zap.Error(err)) + return result.Error(http.StatusInternalServerError, err.Error()) + } + charges, total, err := repository.ChargeRepository.FindCharges(uint(page), &beginTime.Time, &endTime.Time, &keyword) + if err != nil { + chargeLog.Error("检索用户的充值记录列表失败。", zap.Error(err)) + return result.Error(http.StatusInternalServerError, err.Error()) + } + return result.Success( + "已经获取到符合条件的计费记录。", + response.NewPagedResponse(page, total).ToMap(), + fiber.Map{"records": charges}, + ) +} + +// 创建一条新的用户充值记录 +func createNewUserChargeRecord(c *fiber.Ctx) error { + chargeLog.Info("创建一条新的用户充值记录。") + result := response.NewResult(c) + createionForm := new(model.ChargeRecordCreationForm) + if err := c.BodyParser(createionForm); err != nil { + chargeLog.Error("无法解析创建充值记录的请求数据。", zap.Error(err)) + return result.Error(http.StatusBadRequest, err.Error()) + } + ok, err := service.ChargeService.RecordUserCharge( + createionForm.UserId, + createionForm.Fee, + createionForm.Discount, + createionForm.Amount, + createionForm.ChargeTo, + true, + ) + if err != nil { + chargeLog.Error("创建用户充值记录失败。", zap.Error(err)) + return result.Error(http.StatusInternalServerError, err.Error()) + } + if !ok { + chargeLog.Error("创建用户充值记录失败。") + return result.NotAccept("创建用户充值记录失败。") + } else { + return result.Success("创建用户充值记录成功, 指定用户的服务已延期。") + } +} + +// 改变用户充值记录的状态 +func modifyUserChargeState(c *fiber.Ctx) error { + chargeLog.Info("改变用户充值记录的状态。") + result := response.NewResult(c) + uid := c.Params("uid") + seq, err := c.ParamsInt("seq") + if err != nil { + chargeLog.Error("无法解析请求参数。", zap.Error(err)) + return result.Error(http.StatusBadRequest, err.Error()) + } + ok, err := service.ChargeService.CancelUserCharge(uid, int64(seq)) + if err != nil { + chargeLog.Error("取消用户充值记录失败。", zap.Error(err)) + return result.Error(http.StatusInternalServerError, err.Error()) + } + if !ok { + chargeLog.Error("取消用户充值记录失败。") + return result.NotAccept("取消用户充值记录失败。") + } else { + return result.Success("取消用户充值记录成功。") + } +} diff --git a/logger/logger.go b/logger/logger.go index 53d0493..d5a9877 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -1,8 +1,10 @@ package logger import ( + "electricity_bill_calc/model" "os" + "github.com/shopspring/decimal" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) @@ -130,3 +132,11 @@ func With(fields ...zap.Field) *zap.Logger { func WithSugar(fields ...zap.Field) *zap.SugaredLogger { return logger.With(fields...).Sugar() } + +func DecimalField(key string, val *decimal.Decimal) zap.Field { + return zap.String(key, val.String()) +} + +func DateField(key string, val *model.Date) zap.Field { + return zap.String(key, val.Format("2006-01-02")) +} diff --git a/model/charge.go b/model/charge.go new file mode 100644 index 0000000..d3d6599 --- /dev/null +++ b/model/charge.go @@ -0,0 +1,30 @@ +package model + +import ( + "time" + + "github.com/shopspring/decimal" +) + +type UserChargeDetail struct { + Seq int64 `json:"seq"` + UserId string `json:"user_id"` + Name string `json:"name"` + Fee *decimal.Decimal `json:"fee"` + Discount *decimal.Decimal `json:"discount"` + Amount *decimal.Decimal `json:"amount"` + ChargeTo Date `json:"charge_to"` + Settled bool `json:"settled"` + SettledAt *time.Time `json:"settled_at"` + Cancelled bool `json:"cancelled"` + CancelledAt *time.Time `json:"cancelled_at"` + CreatedAt time.Time `json:"created_at"` +} + +type ChargeRecordCreationForm struct { + UserId string `json:"userId"` + Fee *decimal.Decimal `json:"fee"` + Discount *decimal.Decimal `json:"discount"` + Amount *decimal.Decimal `json:"amount"` + ChargeTo Date `json:"chargeTo"` +} diff --git a/repository/charge.go b/repository/charge.go new file mode 100644 index 0000000..d3706cb --- /dev/null +++ b/repository/charge.go @@ -0,0 +1,167 @@ +package repository + +import ( + "context" + "electricity_bill_calc/cache" + "electricity_bill_calc/config" + "electricity_bill_calc/global" + "electricity_bill_calc/logger" + "electricity_bill_calc/model" + "electricity_bill_calc/tools" + "electricity_bill_calc/tools/time" + "fmt" + st "time" + + "github.com/doug-martin/goqu/v9" + _ "github.com/doug-martin/goqu/v9/dialect/postgres" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/samber/lo" + "github.com/shopspring/decimal" + "go.uber.org/zap" +) + +type _ChargeRepository struct { + log *zap.Logger + ds goqu.DialectWrapper +} + +var ChargeRepository = &_ChargeRepository{ + log: logger.Named("Repository", "Charge"), + ds: goqu.Dialect("postgres"), +} + +// 分页查询用户的充值记录 +func (cr _ChargeRepository) FindCharges(page uint, beginTime, endTime *st.Time, keyword *string) ([]*model.UserChargeDetail, int64, error) { + cr.log.Info("查询用户的充值记录。", zap.Timep("beginTime", beginTime), zap.Timep("endTime", endTime), zap.Stringp("keyword", keyword), zap.Uint("page", page)) + cacheConditions := []string{ + fmt.Sprintf("%d", page), + tools.DefaultTo(keyword, ""), + tools.CondFn(func(t *st.Time) bool { return t != nil }, beginTime, beginTime.Format("2006-01-02"), "UNDEF"), + tools.CondFn(func(t *st.Time) bool { return t != nil }, endTime, endTime.Format("2006-01-02"), "UNDEF"), + } + if charges, total, err := cache.RetrievePagedSearch[[]*model.UserChargeDetail]("charges", cacheConditions...); err == nil { + cr.log.Info("从缓存中获取用户的充值记录成功。", zap.Int("count", len(*charges)), zap.Int64("total", total)) + return *charges, total, nil + } + ctx, cancel := global.TimeoutContext() + defer cancel() + + chargeQuery := cr.ds. + From(goqu.T("user_charge").As("c")). + Join(goqu.T("user_detail").As("ud"), goqu.On(goqu.I("c.user_id").Eq(goqu.I("ud.id")))). + Join(goqu.T("user").As("u"), goqu.On(goqu.I("ud.user_id").Eq(goqu.I("u.id")))). + Select( + "c.seq", "c.user_id", "ud.name", "c.fee", "c.discount", "c.amount", "c.charge_to", + "c.settled", "c.settled_at", "c.cancelled", "c.cancelled_at", "c.created_at", + ) + countQuery := cr.ds. + From(goqu.T("user_charge").As("c")). + Join(goqu.T("user_detail").As("ud"), goqu.On(goqu.I("c.user_id").Eq(goqu.I("ud.id")))). + Join(goqu.T("user").As("u"), goqu.On(goqu.I("ud.user_id").Eq(goqu.I("u.id")))). + Select(goqu.COUNT("*")) + + if keyword != nil && len(*keyword) > 0 { + pattern := fmt.Sprintf("%%%s%%", *keyword) + chargeQuery = chargeQuery.Where(goqu.Or( + goqu.I("ud.name").ILike(pattern), + goqu.I("ud.abbr").ILike(pattern), + goqu.I("u.username").ILike(pattern), + )) + countQuery = countQuery.Where(goqu.Or( + goqu.I("ud.name").ILike(pattern), + goqu.I("ud.abbr").ILike(pattern), + goqu.I("u.username").ILike(pattern), + )) + } + + if beginTime != nil { + chargeQuery = chargeQuery.Where(goqu.I("c.charge_to").Gte(*beginTime)) + countQuery = countQuery.Where(goqu.I("c.charge_to").Gte(*beginTime)) + } + + if endTime != nil { + chargeQuery = chargeQuery.Where(goqu.I("c.charge_to").Lte(*endTime)) + countQuery = countQuery.Where(goqu.I("c.charge_to").Lte(*endTime)) + } + + chargeQuery = chargeQuery.Order(goqu.I("c.created_by").Desc()) + + currentPostion := (page - 1) * config.ServiceSettings.ItemsPageSize + chargeQuery = chargeQuery.Offset(currentPostion).Limit(config.ServiceSettings.ItemsPageSize) + + chargeSql, chargeArgs, _ := chargeQuery.Prepared(true).ToSQL() + countSql, countArgs, _ := countQuery.Prepared(true).ToSQL() + + var ( + charges []*model.UserChargeDetail + total int64 + ) + if err := pgxscan.Select(ctx, global.DB, &charges, chargeSql, chargeArgs...); err != nil { + cr.log.Error("查询用户的充值记录失败。", zap.Error(err)) + return make([]*model.UserChargeDetail, 0), 0, err + } + if err := pgxscan.Get(ctx, global.DB, &total, countSql, countArgs...); err != nil { + cr.log.Error("查询用户的充值记录总数失败。", zap.Error(err)) + return make([]*model.UserChargeDetail, 0), 0, err + } + cache.CachePagedSearch(charges, total, []string{"charges"}, "charges", cacheConditions...) + return charges, total, nil +} + +// 在用户充值记录中创建一条新的记录 +func (cr _ChargeRepository) CreateChargeRecord(tx pgx.Tx, ctx context.Context, uid string, fee, discount, amount *decimal.Decimal, chargeTo model.Date) (bool, error) { + createQuery, createArgs, _ := cr.ds. + Insert(goqu.T("user_charge")). + Cols("user_id", "fee", "discount", "amount", "charge_to", "created_at"). + Vals(goqu.Vals{uid, fee, discount, amount, chargeTo, time.Now()}). + Prepared(true).ToSQL() + + rs, err := tx.Exec(ctx, createQuery, createArgs...) + if err != nil { + cr.log.Error("创建用户充值记录失败。", zap.Error(err)) + return false, err + } + return rs.RowsAffected() > 0, nil +} + +// 撤销用户的充值记录 +func (cr _ChargeRepository) CancelCharge(tx pgx.Tx, ctx context.Context, uid string, seq int64) (bool, error) { + updateQuerySql, updateArgs, _ := cr.ds. + Update(goqu.T("user_charge")). + Set(goqu.Record{"cancelled": true, "cancelled_at": time.Now()}). + Where(goqu.I("user_id").Eq(uid), goqu.I("seq").Eq(seq)). + Prepared(true).ToSQL() + + rs, err := tx.Exec(ctx, updateQuerySql, updateArgs...) + if err != nil { + cr.log.Error("撤销用户的充值记录失败。", zap.Error(err)) + return false, err + } + return rs.RowsAffected() > 0, nil +} + +// 检索用户最近有效的服务期限 +func (cr _ChargeRepository) LatestValidChargeTo(tx pgx.Tx, ctx context.Context, uid string) (*model.Date, error) { + searchSql, searchArgs, _ := cr.ds. + From(goqu.T("user_charge")). + Select("charge_to"). + Where( + goqu.I("settled").Eq(true), + goqu.I("cancelled").Eq(false), + goqu.I("refunded").Eq(false), + goqu.I("user_id").Eq(uid), + ). + Prepared(true).ToSQL() + + var chargeTo []*model.Date + if err := pgxscan.Select(ctx, tx, &chargeTo, searchSql, searchArgs...); err != nil { + cr.log.Error("检索用户有效服务期限列表失败。", zap.Error(err)) + return nil, err + } + if len(chargeTo) == 0 { + return nil, fmt.Errorf("无法找到用户最近的有效服务期限。") + } + lastCharge := lo.MaxBy(chargeTo, func(a, b *model.Date) bool { return a.Time.After(b.Time) }) + return lastCharge, nil +} diff --git a/repository/user.go b/repository/user.go index 5293bf5..d451673 100644 --- a/repository/user.go +++ b/repository/user.go @@ -1,6 +1,7 @@ package repository import ( + "context" "electricity_bill_calc/cache" "electricity_bill_calc/config" "electricity_bill_calc/global" @@ -16,6 +17,7 @@ import ( _ "github.com/doug-martin/goqu/v9/dialect/postgres" "github.com/fufuok/utils" "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" "github.com/samber/lo" "go.uber.org/zap" ) @@ -449,10 +451,8 @@ func (ur _UserRepository) SearchUsersWithLimit(userType *int16, keyword *string, } // 更新指定用户的服务有效期限 -func (ur _UserRepository) UpdateServiceExpiration(uid string, expiration stdTime.Time) (bool, error) { +func (ur _UserRepository) UpdateServiceExpiration(tx pgx.Tx, ctx context.Context, uid string, expiration stdTime.Time) (bool, error) { ur.log.Info("更新指定用户的服务有效期限。", zap.String("user id", uid)) - ctx, cancel := global.TimeoutContext() - defer cancel() userDetailUpdateQuery := ur.ds. Update("user_detail"). @@ -462,7 +462,7 @@ func (ur _UserRepository) UpdateServiceExpiration(uid string, expiration stdTime userDetailSql, userDetailParams, _ := userDetailUpdateQuery. Prepared(true).ToSQL() - if res, err := global.DB.Exec(ctx, userDetailSql, userDetailParams...); err != nil { + if res, err := tx.Exec(ctx, userDetailSql, userDetailParams...); err != nil { ur.log.Error("向数据库更新指定用户的服务有效期限失败。", zap.String("user id", uid), zap.Error(err)) return false, err } else { diff --git a/router/router.go b/router/router.go index 795d48d..def7992 100644 --- a/router/router.go +++ b/router/router.go @@ -46,6 +46,7 @@ func App() *fiber.App { controller.InitializeUserHandlers(app) controller.InitializeRegionHandlers(app) + controller.InitializeChargeHandlers(app) return app } diff --git a/service/charge.go b/service/charge.go new file mode 100644 index 0000000..fc962fc --- /dev/null +++ b/service/charge.go @@ -0,0 +1,121 @@ +package service + +import ( + "electricity_bill_calc/cache" + "electricity_bill_calc/global" + "electricity_bill_calc/logger" + "electricity_bill_calc/model" + "electricity_bill_calc/repository" + "fmt" + + "github.com/shopspring/decimal" + "go.uber.org/zap" +) + +type _ChargeService struct { + log *zap.Logger +} + +var ChargeService = &_ChargeService{ + log: logger.Named("Service", "Charge"), +} + +// 创建一条新的用户充值记录,同时更新用户的服务期限 +func (cs _ChargeService) RecordUserCharge(uid string, fee, discount, amount *decimal.Decimal, chargeTo model.Date, extendExpriationIgnoringSettle bool) (bool, error) { + cs.log.Info( + "创建一条新的用户充值记录。", + zap.String("uid", uid), + logger.DecimalField("fee", fee), + logger.DecimalField("discount", discount), + logger.DecimalField("amount", amount), + logger.DateField("chargeTo", &chargeTo), + zap.Bool("extendExpriationIgnoringSettle", extendExpriationIgnoringSettle), + ) + + ctx, cancel := global.TimeoutContext() + defer cancel() + + tx, err := global.DB.Begin(ctx) + if err != nil { + cs.log.Error("开启数据库事务失败。", zap.Error(err)) + return false, err + } + ok, err := repository.ChargeRepository.CreateChargeRecord(tx, ctx, uid, fee, discount, amount, chargeTo) + switch { + case err == nil && !ok: + cs.log.Error("未能成功创建用户充值记录", zap.Error(err)) + tx.Rollback(ctx) + return false, fmt.Errorf("未能成功创建用户充值记录") + case err != nil: + cs.log.Error("创建用户充值记录失败。", zap.Error(err)) + tx.Rollback(ctx) + return false, err + } + if extendExpriationIgnoringSettle { + ok, err = repository.UserRepository.UpdateServiceExpiration(tx, ctx, uid, chargeTo.Time) + switch { + case err != nil: + cs.log.Error("更新用户服务期限失败。", zap.Error(err)) + tx.Rollback(ctx) + return false, err + case !ok: + cs.log.Error("未能成功更新用户服务期限", zap.Error(err)) + tx.Rollback(ctx) + return false, fmt.Errorf("未能成功更新用户服务期限") + } + } + err = tx.Commit(ctx) + if err != nil { + cs.log.Error("提交数据库事务失败。", zap.Error(err)) + return false, err + } + cache.AbolishRelation("charge") + cache.AbolishRelation(fmt.Sprintf("user:%s", uid)) + return true, nil +} + +// 撤销用户的某一条充值记录,同时重新设置用户的服务期限 +func (cs _ChargeService) CancelUserCharge(uid string, seq int64) (bool, error) { + cs.log.Info("撤销用户的充值记录。", zap.String("uid", uid), zap.Int64("seq", seq)) + ctx, cancel := global.TimeoutContext() + defer cancel() + + tx, err := global.DB.Begin(ctx) + if err != nil { + cs.log.Error("开启数据库事务失败。", zap.Error(err)) + return false, err + } + ok, err := repository.ChargeRepository.CancelCharge(tx, ctx, uid, seq) + switch { + case err == nil && !ok: + cs.log.Error("未能成功撤销用户充值记录", zap.Error(err)) + tx.Rollback(ctx) + return false, fmt.Errorf("未能成功撤销用户充值记录") + case err != nil: + cs.log.Error("撤销用户充值记录失败。", zap.Error(err)) + tx.Rollback(ctx) + return false, err + } + if ok { + lastValidCharge, err := repository.ChargeRepository.LatestValidChargeTo(tx, ctx, uid) + if err != nil { + cs.log.Error("查询用户最近一次有效的充值记录失败。", zap.Error(err)) + tx.Rollback(ctx) + return false, err + } + ok, err = repository.UserRepository.UpdateServiceExpiration(tx, ctx, uid, lastValidCharge.Time) + if err != nil || !ok { + cs.log.Error("更新用户服务期限失败。", zap.Error(err)) + tx.Rollback(ctx) + return false, err + } + } + err = tx.Commit(ctx) + if err != nil { + cs.log.Error("提交数据库事务失败。", zap.Error(err)) + return false, err + } + cache.AbolishRelation("charge") + cache.AbolishRelation(fmt.Sprintf("user:%s", uid)) + return true, nil +}