billing_export.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "database/sql"
  6. "encoding/csv"
  7. "errors"
  8. "fmt"
  9. "strconv"
  10. "time"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. )
  13. // billingExportMaxRows CSV 导出上限(防大结果集打爆内存/连接)。
  14. const billingExportMaxRows = 10000
  15. // BillingStatementRow 月账单单模型行。
  16. type BillingStatementRow struct {
  17. Model string `json:"model"`
  18. Requests int64 `json:"requests"`
  19. InputTokens int64 `json:"input_tokens"`
  20. OutputTokens int64 `json:"output_tokens"`
  21. CacheTokens int64 `json:"cache_tokens"`
  22. TotalTokens int64 `json:"total_tokens"`
  23. Cost float64 `json:"cost"`
  24. }
  25. // BillingStatement 月账单。
  26. type BillingStatement struct {
  27. UserID int64 `json:"user_id"`
  28. Year int `json:"year"`
  29. Month int `json:"month"`
  30. Rows []BillingStatementRow `json:"rows"`
  31. Requests int64 `json:"requests"`
  32. Cost float64 `json:"cost"`
  33. }
  34. // escapeCSVCell 防 CSV 公式注入:首字符为 = + - @ |(或空白/制表/换行开头)时
  35. // 加单引号前缀,Excel 将其视为纯文本。
  36. func escapeCSVCell(s string) string {
  37. if s == "" {
  38. return s
  39. }
  40. switch s[0] {
  41. case '=', '+', '-', '@', '|', '\t', '\r', '\n', ' ':
  42. return "'" + s
  43. }
  44. return s
  45. }
  46. // BillingExportService 账单导出服务(SQL 直连)。
  47. type BillingExportService struct {
  48. db *sql.DB
  49. }
  50. // NewBillingExportService 创建账单导出服务。
  51. func NewBillingExportService(db *sql.DB) *BillingExportService {
  52. return &BillingExportService{db: db}
  53. }
  54. func parseBillingYearMonth(year, month int) (time.Time, time.Time, error) {
  55. if month < 1 || month > 12 {
  56. return time.Time{}, time.Time{}, infraerrors.BadRequest("BILLING_MONTH_INVALID", "month must be 1-12")
  57. }
  58. now := time.Now().UTC()
  59. if year < 2020 || year > now.Year()+1 {
  60. return time.Time{}, time.Time{}, infraerrors.BadRequest("BILLING_YEAR_INVALID", "year out of range")
  61. }
  62. start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
  63. return start, start.AddDate(0, 1, 0), nil
  64. }
  65. // Statement 按模型汇总月账单(收入口径:actual_cost 用户实扣)。
  66. func (s *BillingExportService) Statement(ctx context.Context, userID int64, year, month int) (*BillingStatement, error) {
  67. if s == nil || s.db == nil {
  68. return nil, errors.New("billing export service unavailable")
  69. }
  70. start, end, err := parseBillingYearMonth(year, month)
  71. if err != nil {
  72. return nil, err
  73. }
  74. rows, err := s.db.QueryContext(ctx, `
  75. SELECT COALESCE(NULLIF(requested_model, ''), model),
  76. COUNT(*),
  77. COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
  78. COALESCE(SUM(cache_read_tokens + cache_creation_tokens),0),
  79. COALESCE(SUM(actual_cost),0)
  80. FROM usage_logs
  81. WHERE user_id = $1 AND created_at >= $2 AND created_at < $3
  82. GROUP BY 1 ORDER BY SUM(actual_cost) DESC`,
  83. userID, start, end)
  84. if err != nil {
  85. return nil, err
  86. }
  87. defer func() { _ = rows.Close() }()
  88. stmt := &BillingStatement{UserID: userID, Year: year, Month: month, Rows: []BillingStatementRow{}}
  89. for rows.Next() {
  90. var r BillingStatementRow
  91. var inTok, outTok, cacheTok int64
  92. if err := rows.Scan(&r.Model, &r.Requests, &inTok, &outTok, &cacheTok, &r.Cost); err != nil {
  93. return nil, err
  94. }
  95. r.InputTokens = inTok
  96. r.OutputTokens = outTok
  97. r.CacheTokens = cacheTok
  98. r.TotalTokens = inTok + outTok + cacheTok
  99. stmt.Rows = append(stmt.Rows, r)
  100. stmt.Requests += r.Requests
  101. stmt.Cost += r.Cost
  102. }
  103. return stmt, rows.Err()
  104. }
  105. // ExportCSV 导出用量明细 CSV(起止最多 31 天,上限 1 万行)。
  106. func (s *BillingExportService) ExportCSV(ctx context.Context, userID int64, start, end time.Time) ([]byte, string, error) {
  107. if s == nil || s.db == nil {
  108. return nil, "", errors.New("billing export service unavailable")
  109. }
  110. if end.Before(start) || end.Sub(start) > 31*24*time.Hour {
  111. return nil, "", infraerrors.BadRequest("BILLING_RANGE_INVALID", "range must be within 31 days")
  112. }
  113. rows, err := s.db.QueryContext(ctx, `
  114. SELECT created_at, COALESCE(NULLIF(requested_model, ''), model),
  115. COALESCE(input_tokens,0), COALESCE(output_tokens,0),
  116. COALESCE(cache_read_tokens + cache_creation_tokens,0),
  117. COALESCE(actual_cost,0), COALESCE(request_id,'')
  118. FROM usage_logs
  119. WHERE user_id = $1 AND created_at >= $2 AND created_at < $3
  120. ORDER BY id DESC LIMIT $4`,
  121. userID, start.UTC(), end.UTC(), billingExportMaxRows)
  122. if err != nil {
  123. return nil, "", err
  124. }
  125. defer func() { _ = rows.Close() }()
  126. var buf bytes.Buffer
  127. w := csv.NewWriter(&buf)
  128. _ = w.Write([]string{"created_at", "model", "input_tokens", "output_tokens", "cache_tokens", "cost_usd", "request_id"})
  129. n := 0
  130. for rows.Next() {
  131. var ts time.Time
  132. var model string
  133. var inTok, outTok, cacheTok int64
  134. var cost float64
  135. var reqID string
  136. if err := rows.Scan(&ts, &model, &inTok, &outTok, &cacheTok, &cost, &reqID); err != nil {
  137. return nil, "", err
  138. }
  139. _ = w.Write([]string{
  140. ts.UTC().Format(time.RFC3339),
  141. escapeCSVCell(model),
  142. strconv.FormatInt(inTok, 10),
  143. strconv.FormatInt(outTok, 10),
  144. strconv.FormatInt(cacheTok, 10),
  145. strconv.FormatFloat(cost, 'f', 8, 64),
  146. escapeCSVCell(reqID),
  147. })
  148. n++
  149. }
  150. w.Flush()
  151. if err := rows.Err(); err != nil {
  152. return nil, "", err
  153. }
  154. if err := w.Error(); err != nil {
  155. return nil, "", err
  156. }
  157. filename := fmt.Sprintf("billing-%d-%s.csv", userID, start.UTC().Format("20060102"))
  158. _ = n
  159. return buf.Bytes(), filename, nil
  160. }