diff --git a/internal/db/models/log_model.go b/internal/db/models/log_model.go index bb2c050e..d5545338 100644 --- a/internal/db/models/log_model.go +++ b/internal/db/models/log_model.go @@ -13,6 +13,7 @@ type Log struct { Ip string `field:"ip"` // IP地址 Type string `field:"type"` // 类型:admin, user Day string `field:"day"` // 日期 + BillId uint32 `field:"billId"` // 账单ID } type LogOperator struct { @@ -27,6 +28,7 @@ type LogOperator struct { Ip interface{} // IP地址 Type interface{} // 类型:admin, user Day interface{} // 日期 + BillId interface{} // 账单ID } func NewLogOperator() *LogOperator { diff --git a/internal/db/models/node_price_item_dao.go b/internal/db/models/node_price_item_dao.go index 4013d604..4acaf24c 100644 --- a/internal/db/models/node_price_item_dao.go +++ b/internal/db/models/node_price_item_dao.go @@ -10,6 +10,8 @@ import ( const ( NodePriceItemStateEnabled = 1 // 已启用 NodePriceItemStateDisabled = 0 // 已禁用 + + NodePriceTypeTraffic = "traffic" // 价格类型之流量 ) type NodePriceItemDAO dbs.DAO @@ -118,3 +120,15 @@ func (this *NodePriceItemDAO) FindAllEnabledAndOnRegionPrices(priceType string) FindAll() return } + +// 根据字节查找付费项目 +func (this *NodePriceItemDAO) SearchItemsWithBytes(items []*NodePriceItem, bytes int64) int64 { + bytes *= 8 + + for _, item := range items { + if bytes >= int64(item.BitsFrom) && (bytes < int64(item.BitsTo) || item.BitsTo == 0) { + return int64(item.Id) + } + } + return 0 +} diff --git a/internal/db/models/node_region_dao.go b/internal/db/models/node_region_dao.go index 20b7f02c..831f27fc 100644 --- a/internal/db/models/node_region_dao.go +++ b/internal/db/models/node_region_dao.go @@ -108,6 +108,18 @@ func (this *NodeRegionDAO) FindAllEnabledRegions() (result []*NodeRegion, err er return } +// 列出所有价格 +func (this *NodeRegionDAO) FindAllEnabledRegionPrices() (result []*NodeRegion, err error) { + _, err = this.Query(). + State(NodeRegionStateEnabled). + Desc("order"). + AscPk(). + Result("id", "prices"). + Slice(&result). + FindAll() + return +} + // 列出所有启用的区域 func (this *NodeRegionDAO) FindAllEnabledAndOnRegions() (result []*NodeRegion, err error) { _, err = this.Query(). diff --git a/internal/db/models/server_daily_stat_dao.go b/internal/db/models/server_daily_stat_dao.go index 72026e34..2d2a16b9 100644 --- a/internal/db/models/server_daily_stat_dao.go +++ b/internal/db/models/server_daily_stat_dao.go @@ -55,3 +55,14 @@ func (this *ServerDailyStatDAO) SaveStats(stats []*pb.ServerDailyStat) error { } return nil } + +// 根据用户计算某月合计 +// month 格式为YYYYMM +func (this *ServerDailyStatDAO) SumUserMonthly(userId int64, regionId int64, month string) (int64, error) { + return this.Query(). + Attr("regionId", regionId). + Between("day", month+"01", month+"32"). + Where("serverId IN (SELECT id FROM "+SharedServerDAO.Table+" WHERE userId=:userId)"). + Param("userId", userId). + SumInt64("bytes", 0) +} diff --git a/internal/db/models/server_daily_stat_dao_test.go b/internal/db/models/server_daily_stat_dao_test.go index 35a0241f..99c33f07 100644 --- a/internal/db/models/server_daily_stat_dao_test.go +++ b/internal/db/models/server_daily_stat_dao_test.go @@ -3,6 +3,8 @@ package models import ( "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/dbs" + timeutil "github.com/iwind/TeaGo/utils/time" "testing" ) @@ -37,3 +39,12 @@ func TestServerDailyStatDAO_SaveStats2(t *testing.T) { } t.Log("ok") } + +func TestServerDailyStatDAO_SumUserMonthly(t *testing.T) { + dbs.NotifyReady() + bytes, err := NewServerDailyStatDAO().SumUserMonthly(1, 1, timeutil.Format("Ym")) + if err != nil { + t.Fatal(err) + } + t.Log("bytes:", bytes) +} diff --git a/internal/db/models/server_daily_stat_model.go b/internal/db/models/server_daily_stat_model.go index af6ce23e..be3e4041 100644 --- a/internal/db/models/server_daily_stat_model.go +++ b/internal/db/models/server_daily_stat_model.go @@ -2,23 +2,25 @@ package models // 计费流量统计 type ServerDailyStat struct { - Id uint64 `field:"id"` // ID - ServerId uint32 `field:"serverId"` // 服务ID - RegionId uint32 `field:"regionId"` // 区域ID - Bytes uint64 `field:"bytes"` // 流量 - Day string `field:"day"` // 日期YYYYMMDD - TimeFrom string `field:"timeFrom"` // 开始时间HHMMSS - TimeTo string `field:"timeTo"` // 结束时间 + Id uint64 `field:"id"` // ID + ServerId uint32 `field:"serverId"` // 服务ID + RegionId uint32 `field:"regionId"` // 区域ID + Bytes uint64 `field:"bytes"` // 流量 + Day string `field:"day"` // 日期YYYYMMDD + TimeFrom string `field:"timeFrom"` // 开始时间HHMMSS + TimeTo string `field:"timeTo"` // 结束时间 + IsCharged uint8 `field:"isCharged"` // 是否已计算费用 } type ServerDailyStatOperator struct { - Id interface{} // ID - ServerId interface{} // 服务ID - RegionId interface{} // 区域ID - Bytes interface{} // 流量 - Day interface{} // 日期YYYYMMDD - TimeFrom interface{} // 开始时间HHMMSS - TimeTo interface{} // 结束时间 + Id interface{} // ID + ServerId interface{} // 服务ID + RegionId interface{} // 区域ID + Bytes interface{} // 流量 + Day interface{} // 日期YYYYMMDD + TimeFrom interface{} // 开始时间HHMMSS + TimeTo interface{} // 结束时间 + IsCharged interface{} // 是否已计算费用 } func NewServerDailyStatOperator() *ServerDailyStatOperator { diff --git a/internal/db/models/user_bill_dao.go b/internal/db/models/user_bill_dao.go new file mode 100644 index 00000000..f0a8b2b4 --- /dev/null +++ b/internal/db/models/user_bill_dao.go @@ -0,0 +1,206 @@ +package models + +import ( + "encoding/json" + "github.com/TeaOSLab/EdgeAPI/internal/utils/numberutils" + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" +) + +type BillType = string + +const ( + BillTypeTraffic BillType = "traffic" // 按流量计费 +) + +type UserBillDAO dbs.DAO + +func NewUserBillDAO() *UserBillDAO { + return dbs.NewDAO(&UserBillDAO{ + DAOObject: dbs.DAOObject{ + DB: Tea.Env, + Table: "edgeUserBills", + Model: new(UserBill), + PkName: "id", + }, + }).(*UserBillDAO) +} + +var SharedUserBillDAO *UserBillDAO + +func init() { + dbs.OnReady(func() { + SharedUserBillDAO = NewUserBillDAO() + }) +} + +// 计算账单数量 +func (this *UserBillDAO) CountAllUserBills(isPaid int32, userId int64, month string) (int64, error) { + query := this.Query() + if isPaid == 0 { + query.Attr("isPaid", 0) + } else if isPaid > 0 { + query.Attr("isPaid", 1) + } + if userId > 0 { + query.Attr("userId", userId) + } + if len(month) > 0 { + query.Attr("month", month) + } + return query.Count() +} + +// 列出单页账单 +func (this *UserBillDAO) ListUserBills(isPaid int32, userId int64, month string, offset, size int64) (result []*UserBill, err error) { + query := this.Query() + if isPaid == 0 { + query.Attr("isPaid", 0) + } else if isPaid > 0 { + query.Attr("isPaid", 1) + } + if userId > 0 { + query.Attr("userId", userId) + } + if len(month) > 0 { + query.Attr("month", month) + } + _, err = query. + Offset(offset). + Limit(size). + Slice(&result). + DescPk(). + FindAll() + return +} + +// 创建账单 +func (this *UserBillDAO) CreateBill(userId int64, billType BillType, description string, amount float32, month string) (int64, error) { + op := NewUserBillOperator() + op.UserId = userId + op.Type = billType + op.Description = description + op.Amount = amount + op.Month = month + op.IsPaid = false + return this.SaveInt64(op) +} + +// 检查是否有当月账单 +func (this *UserBillDAO) ExistBill(userId int64, billType BillType, month string) (bool, error) { + return this.Query(). + Attr("userId", userId). + Attr("month", month). + Attr("type", billType). + Exist() +} + +// 生成账单 +// month 格式YYYYMM +func (this *UserBillDAO) GenerateBills(month string) error { + // 用户 + offset := int64(0) + size := int64(100) // 每次只查询N次,防止由于执行时间过长而锁表 + for { + userIds, err := SharedUserDAO.ListEnabledUserIds(offset, size) + if err != nil { + return err + } + offset += size + if len(userIds) == 0 { + break + } + + for _, userId := range userIds { + // CDN流量账单 + err := this.generateTrafficBill(userId, month) + if err != nil { + return err + } + } + } + + return nil +} + +// 生成CDN流量账单 +// month 格式YYYYMM +func (this *UserBillDAO) generateTrafficBill(userId int64, month string) error { + // 检查是否已经有账单了 + b, err := this.ExistBill(userId, BillTypeTraffic, month) + if err != nil { + return err + } + if b { + return nil + } + + // TODO 优化使用缓存 + regions, err := SharedNodeRegionDAO.FindAllEnabledRegionPrices() + if err != nil { + return err + } + if len(regions) == 0 { + return nil + } + + priceItems, err := SharedNodePriceItemDAO.FindAllEnabledRegionPrices(NodePriceTypeTraffic) + if err != nil { + return err + } + if len(priceItems) == 0 { + return nil + } + + cost := float32(0) + for _, region := range regions { + if len(region.Prices) == 0 || region.Prices == "null" { + continue + } + priceMap := map[string]float32{} + err = json.Unmarshal([]byte(region.Prices), &priceMap) + if err != nil { + return err + } + + trafficBytes, err := SharedServerDailyStatDAO.SumUserMonthly(userId, int64(region.Id), month) + if err != nil { + return err + } + if trafficBytes == 0 { + continue + } + + itemId := SharedNodePriceItemDAO.SearchItemsWithBytes(priceItems, trafficBytes) + if itemId == 0 { + continue + } + + price, ok := priceMap[numberutils.FormatInt64(itemId)] + if !ok { + continue + } + + // 计算钱 + // 这里采用1000进制 + cost += (float32(trafficBytes*8) / 1_000_000_000) * price + } + + if cost == 0 { + return nil + } + + // 创建账单 + _, err = this.CreateBill(userId, BillTypeTraffic, "按流量计费", cost, month) + return err +} + +// 获取账单类型名称 +func (this *UserBillDAO) BillTypeName(billType BillType) string { + switch billType { + case BillTypeTraffic: + return "流量" + } + return "" +} diff --git a/internal/db/models/user_bill_dao_test.go b/internal/db/models/user_bill_dao_test.go new file mode 100644 index 00000000..ed0204e2 --- /dev/null +++ b/internal/db/models/user_bill_dao_test.go @@ -0,0 +1,18 @@ +package models + +import ( + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/dbs" + timeutil "github.com/iwind/TeaGo/utils/time" + "testing" +) + +func TestUserBillDAO_GenerateBills(t *testing.T) { + dbs.NotifyReady() + + err := SharedUserBillDAO.GenerateBills(timeutil.Format("Ym")) + if err != nil { + t.Fatal(err) + } + t.Log("ok") +} diff --git a/internal/db/models/user_bill_model.go b/internal/db/models/user_bill_model.go new file mode 100644 index 00000000..1d991190 --- /dev/null +++ b/internal/db/models/user_bill_model.go @@ -0,0 +1,30 @@ +package models + +// 用户账单 +type UserBill struct { + Id uint64 `field:"id"` // ID + UserId uint32 `field:"userId"` // 用户ID + Type string `field:"type"` // 消费类型 + Description string `field:"description"` // 描述 + Amount float64 `field:"amount"` // 消费数额 + Month string `field:"month"` // 帐期YYYYMM + IsPaid uint8 `field:"isPaid"` // 是否已支付 + PaidAt uint64 `field:"paidAt"` // 支付时间 + CreatedAt uint64 `field:"createdAt"` // 创建时间 +} + +type UserBillOperator struct { + Id interface{} // ID + UserId interface{} // 用户ID + Type interface{} // 消费类型 + Description interface{} // 描述 + Amount interface{} // 消费数额 + Month interface{} // 帐期YYYYMM + IsPaid interface{} // 是否已支付 + PaidAt interface{} // 支付时间 + CreatedAt interface{} // 创建时间 +} + +func NewUserBillOperator() *UserBillOperator { + return &UserBillOperator{} +} diff --git a/internal/db/models/user_bill_model_ext.go b/internal/db/models/user_bill_model_ext.go new file mode 100644 index 00000000..2640e7f9 --- /dev/null +++ b/internal/db/models/user_bill_model_ext.go @@ -0,0 +1 @@ +package models diff --git a/internal/db/models/user_dao.go b/internal/db/models/user_dao.go index 68fa6bfd..0d7a0802 100644 --- a/internal/db/models/user_dao.go +++ b/internal/db/models/user_dao.go @@ -160,3 +160,22 @@ func (this *UserDAO) ExistUser(userId int64, username string) (bool, error) { Neq("id", userId). Exist() } + +// 列出单页的用户ID +func (this *UserDAO) ListEnabledUserIds(offset, size int64) ([]int64, error) { + ones, _, err := this.Query(). + ResultPk(). + State(UserStateEnabled). + Offset(offset). + Limit(size). + AscPk(). + FindOnes() + if err != nil { + return nil, err + } + result := []int64{} + for _, one := range ones { + result = append(result, one.GetInt64("id")) + } + return result, nil +} diff --git a/internal/nodes/api_node.go b/internal/nodes/api_node.go index 1ea6bea8..071b534d 100644 --- a/internal/nodes/api_node.go +++ b/internal/nodes/api_node.go @@ -204,6 +204,7 @@ func (this *APINode) listenRPC(listener net.Listener, tlsConfig *tls.Config) err pb.RegisterACMEAuthenticationServiceServer(rpcServer, &services.ACMEAuthenticationService{}) pb.RegisterUserServiceServer(rpcServer, &services.UserService{}) pb.RegisterServerDailyStatServiceServer(rpcServer, &services.ServerDailyStatService{}) + pb.RegisterUserBillServiceServer(rpcServer, &services.UserBillService{}) err := rpcServer.Serve(listener) if err != nil { return errors.New("[API_NODE]start rpc failed: " + err.Error()) diff --git a/internal/rpc/services/service_user_bill.go b/internal/rpc/services/service_user_bill.go new file mode 100644 index 00000000..78637a1a --- /dev/null +++ b/internal/rpc/services/service_user_bill.go @@ -0,0 +1,88 @@ +package services + +import ( + "context" + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/errors" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + timeutil "github.com/iwind/TeaGo/utils/time" + "regexp" +) + +// 账单相关服务 +type UserBillService struct { + BaseService +} + +// 手工生成订单 +func (this *UserBillService) GenerateAllUserBills(ctx context.Context, req *pb.GenerateAllUserBillsRequest) (*pb.RPCSuccess, error) { + _, err := this.ValidateAdmin(ctx, 0) + if err != nil { + return nil, err + } + + // 校验Month + if !regexp.MustCompile(`^\d{6}$`).MatchString(req.Month) { + return nil, errors.New("invalid month '" + req.Month + "'") + } + if req.Month >= timeutil.Format("Ym") { + return nil, errors.New("invalid month '" + req.Month + "'") + } + + err = models.SharedUserBillDAO.GenerateBills(req.Month) + if err != nil { + return nil, err + } + + return this.Success() +} + +// 计算所有账单数量 +func (this *UserBillService) CountAllUserBills(ctx context.Context, req *pb.CountAllUserBillsRequest) (*pb.RPCCountResponse, error) { + _, err := this.ValidateAdmin(ctx, 0) + if err != nil { + return nil, err + } + + count, err := models.SharedUserBillDAO.CountAllUserBills(req.PaidFlag, req.UserId, req.Month) + if err != nil { + return nil, err + } + return this.SuccessCount(count) +} + +// 列出单页账单 +func (this *UserBillService) ListUserBills(ctx context.Context, req *pb.ListUserBillsRequest) (*pb.ListUserBillsResponse, error) { + _, err := this.ValidateAdmin(ctx, 0) + if err != nil { + return nil, err + } + + bills, err := models.SharedUserBillDAO.ListUserBills(req.PaidFlag, req.UserId, req.Month, req.Offset, req.Size) + if err != nil { + return nil, err + } + result := []*pb.UserBill{} + for _, bill := range bills { + userFullname, err := models.SharedUserDAO.FindUserFullname(int64(bill.UserId)) + if err != nil { + return nil, err + } + + result = append(result, &pb.UserBill{ + Id: int64(bill.Id), + User: &pb.User{ + Id: int64(bill.UserId), + Fullname: userFullname, + }, + Type: bill.Type, + TypeName: models.SharedUserBillDAO.BillTypeName(bill.Type), + Description: bill.Description, + Amount: float32(bill.Amount), + Month: bill.Month, + IsPaid: bill.IsPaid == 1, + PaidAt: int64(bill.PaidAt), + }) + } + return &pb.ListUserBillsResponse{UserBills: result}, nil +}