Files
arcrun-collector/cloudquota.go
T

165 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// cloudquota.go — 雲端資料庫(Cloudflare D1)每日免費額度用完時,講明白、且不再重打
// `inkstone/arcrun-rag#197`,母票 `inkstone/InkStoneCo#132`)。
//
// 病(2026-09-13 實撞,youlin 與 geek6688 兩台免費帳號同一天):
// D1 當日讀取額度用完,雲端每一件事都失敗。使用者在小幫手上看到的是
// 「缺少 credential: kbdb_internal_token」(假的,`inkstone/Arcrun#216` c7066 已查證)
// 與「連續失敗 N 次,已暫停自動重試」——**沒有任何一處說「額度用完了」**。
//
// 而雲端其實早就知道:cypher `/health` 的 `data_layer.probe_error` 當下寫著
//
// D1_ERROR: Your account has exceeded D1's free tier daily row read limit.
// Upgrade to a paid plan or wait until tomorrow (midnight UTC) to continue.
//
// 小幫手每分鐘本來就在打這支 `/health`cloudcheck.go),只是只讀了版本號。
// ⇒ 本檔把「知道的地方」接到「用戶看得到的地方」,並且在額度恢復前**整個帳號不再打雲端寫入**
// (恢復前每一發都只會拿到同一個錯,還會讓免費帳號的其他額度一起被燒)。
//
// 🔴 用量數字:Cloudflare 只在帳號層的 GraphQL 分析 API 給得出「今天用了幾列」,
// 而小幫手身上沒有任何 Cloudflare 金鑰,安裝器要的授權範圍也沒有分析讀取權限
// installer/oauth-prototype/worker.js `OAUTH_SCOPES`)。所以這一版**不假裝查得到**:
// 上限是官方公開的固定值(developers.cloudflare.com/d1/platform/pricing/
// Workers Free:每天讀 500 萬列、寫 10 萬列,00:00 UTC 重置),
// 而「已經用到上限」是錯誤訊息本身證明的事實——兩句都是真的,不必查。
package collector
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
// D1 免費方案每日上限(Cloudflare 官方定價頁,2026-09-13 查)。
const (
d1FreeRowsReadPerDay = 5_000_000
d1FreeRowsWrittenPerDay = 100_000
)
// QuotaNotice.Kind 的值。空字串=舊的 Workers AI 額度(向後相容 status.json)。
const (
QuotaKindD1Read = "d1_read"
QuotaKindD1Write = "d1_write"
)
// d1QuotaKind 從任何一段上游文字認出「D1 每日免費額度用完」,回 readwrite""。
// 只認 Cloudflare 錯誤原文裡的固定片段——「D1_ERROR」單獨出現不算(別的 D1 錯誤也帶它)。
func d1QuotaKind(text string) string {
t := strings.ToLower(text)
if !strings.Contains(t, "free tier") && !strings.Contains(t, "daily") {
return ""
}
switch {
case strings.Contains(t, "row read limit"), strings.Contains(t, "rows read"):
return QuotaKindD1Read
case strings.Contains(t, "row write limit"), strings.Contains(t, "rows written"), strings.Contains(t, "row written"):
return QuotaKindD1Write
}
return ""
}
// d1QuotaFromHealth 讀 cypher `/health` 的回應,回它回報的 D1 額度狀況(""=沒有)。
// 舊版雲端(沒有 data_layer 區塊,例如 1.4.46)一律回 ""——認不出來就不編故事。
func d1QuotaFromHealth(body []byte) string {
var payload struct {
DataLayer *struct {
OK bool `json:"ok"`
ProbeError string `json:"probe_error"`
Summary string `json:"summary"`
} `json:"data_layer"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.DataLayer == nil || payload.DataLayer.OK {
return ""
}
if k := d1QuotaKind(payload.DataLayer.ProbeError); k != "" {
return k
}
return d1QuotaKind(payload.DataLayer.Summary)
}
type d1QuotaState struct {
kind string
seen time.Time // 最後一次親眼看到額度用完的時間
until time.Time // 下一次 00:00 UTC——過了就一定不再相信這筆紀錄
}
var (
d1QuotaMu sync.Mutex
d1QuotaSeen = map[string]d1QuotaState{} // keycloudCheckKey(cypherURL)
)
func resetD1Quota() {
d1QuotaMu.Lock()
defer d1QuotaMu.Unlock()
d1QuotaSeen = map[string]d1QuotaState{}
}
// noteD1Quota 記下「這台知識庫此刻的 D1 額度狀況」。kind==""=雲端親口說資料層正常 ⇒ 清掉。
func noteD1Quota(cypherURL, kind string, now time.Time) {
key := cloudCheckKey(cypherURL)
d1QuotaMu.Lock()
defer d1QuotaMu.Unlock()
if kind == "" {
delete(d1QuotaSeen, key)
return
}
d1QuotaSeen[key] = d1QuotaState{kind: kind, seen: now, until: nextQuotaResetTaiwan(now)}
}
// activeD1Quota 回這台知識庫現在是否處於 D1 額度用完(過了重置時間的舊紀錄一律丟掉)。
func activeD1Quota(cypherURL string, now time.Time) (d1QuotaState, bool) {
key := cloudCheckKey(cypherURL)
d1QuotaMu.Lock()
defer d1QuotaMu.Unlock()
st, ok := d1QuotaSeen[key]
if !ok {
return d1QuotaState{}, false
}
if !now.Before(st.until) {
delete(d1QuotaSeen, key)
return d1QuotaState{}, false
}
return st, true
}
// buildD1QuotaNotice 組出給用戶看的話。欄位沿用 QuotaNotice(App 首頁那張卡、托盤狀態列都吃它)。
//
// 用戶要知道的四件事(票上的驗收條件):哪一種額度/用了多少、上限多少/台北幾點恢復/恢復後要不要做事。
func buildD1QuotaNotice(kind string, now, resetAt time.Time) QuotaNotice {
what, limit := "讀取", fmt.Sprintf("每天 %s列", humanRows(d1FreeRowsReadPerDay))
if kind == QuotaKindD1Write {
what, limit = "寫入", fmt.Sprintf("每天 %s列", humanRows(d1FreeRowsWrittenPerDay))
}
return QuotaNotice{
Kind: kind,
Headline: fmt.Sprintf("你的雲端知識庫今天的免費%s額度用完了", what),
Usage: fmt.Sprintf("Cloudflare 免費方案的資料庫%s上限是%s,今天已經用到上限(這不是小幫手或你的檔案壞掉)", what, limit),
Achievement: fmt.Sprintf("雲端資料庫今天的免費%s額度用完了", what),
ExitOptions: "升級 Cloudflare Workers 付費方案(每月 5 美元起)就沒有每日上限",
Guarantee: fmt.Sprintf("台北時間%s早上 8:00 恢復,恢復後小幫手會自動接著傳,你不用做任何事",
quotaResetDayWord(now, resetAt)),
ResumeAt: resetAt.Format(time.RFC3339),
}
}
func humanRows(n int) string {
if n >= 10_000 && n%10_000 == 0 {
return fmt.Sprintf("%d 萬", n/10_000)
}
return fmt.Sprintf("%d", n)
}
// d1QuotaNote=「這一發不打,因為雲端資料庫額度用完」的一句話;空=可以打。
//
// 🔴 結尾「會自動恢復」是 explainsWhySkipped 的識別字(sync_status.go)——被擋下的檔
// 要帶著這句話出現在畫面上,不能安靜消失。
func d1QuotaNote(cypherURL string, now time.Time) string {
st, ok := activeD1Quota(cypherURL, now)
if !ok {
return ""
}
n := buildD1QuotaNotice(st.kind, now, st.until)
return n.Headline + ",先不送,避免白白重打;" + n.Guarantee + "(會自動恢復)。"
}