ece8ea3b57
server/internal/usage 模块实现(仅字节/分钟,无目的地,符合无日志口径): - aggregator.go:实现 #5 的 ReportUsage 回调接口(UsageReporter), dp_uuid→user_id 走内存+Redis 短缓存,按上报批次 id Redis 去重防重放, 按 At 的 UTC 日期 INSERT ... ON DUPLICATE KEY UPDATE 累加,并发安全。 - store.go:usage_daily 累加/区间查询/当日查询、MarkAdUnlocked(事务+FOR UPDATE 幂等)、EffectivePlan(活跃订阅取最高 tier,无则回落 free)。 - ads.go:AdVerifier 接口 + AdMob SSV 实现(拉取并缓存 Google ECDSA 公钥, ECDSA-SHA256 验签 + custom_data/ad_unit 匹配),Unity 留接口占位。 - service.go:UsageCurve(空日补零、仅当前用户)、TodaySummary(/v1/me)、 UnlockAd(验签→ad_token 一次性 nonce 去重→写 ad_unlocked_at,当日二次幂等)。 - quota.go:CheckFreeConnect 供 #5 connect 使用:ad_gate=true 校验当日 ad_unlocked_at + minutes_used<daily_minutes(free=10),返回剩余分钟; pro/team 不受 ad_gate 限制(返回 Unlimited);带双语 code 错误。 - handler.go:GET /v1/usage?days=7、POST /v1/ads/unlock(204)。 - apierr:新增 AD_NOT_UNLOCKED/QUOTA_EXHAUSTED/AD_VERIFY_FAILED/AD_TOKEN_REPLAY。 - openapi:/ads/unlock 补 409 Conflict(重放)。 测试:ads_test.go 单测覆盖 AdMob 验签全链路(合法/伪造/custom_data 不符/ ad_unit 不允许/畸形 token)+ Unity 未实现;usage_integration_test.go (testcontainers, build tag integration) 覆盖并发多节点累加、批次重放去重、 跨 UTC 日界分桶、未知 dp_uuid 丢弃、free 额度四态、ads 解锁伪造/重放/幂等、 曲线补零/隔离、/me 摘要。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
108 lines
3.3 KiB
Go
108 lines
3.3 KiB
Go
// Package apierr defines the bilingual error response type used across all API modules.
|
|
// Error messages follow the desensitisation rules: no "VPN" / "翻墙" / "科学上网" wording.
|
|
package apierr
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
)
|
|
|
|
// Error is the canonical API error body: {code, message_zh, message_en}.
|
|
type Error struct {
|
|
Code string `json:"code"`
|
|
MessageZH string `json:"message_zh"`
|
|
MessageEn string `json:"message_en"`
|
|
}
|
|
|
|
func (e *Error) Error() string { return e.Code + ": " + e.MessageEn }
|
|
|
|
// Standard code-module errors.
|
|
var (
|
|
ErrInvalidCode = &Error{
|
|
Code: "INVALID_CODE",
|
|
MessageZH: "激活码格式无效,请检查后重试",
|
|
MessageEn: "Invalid code format, please verify and try again",
|
|
}
|
|
ErrCodeNotFound = &Error{
|
|
Code: "CODE_NOT_FOUND",
|
|
MessageZH: "激活码无效或已使用",
|
|
MessageEn: "Code not found or already used",
|
|
}
|
|
ErrCodeRedeemed = &Error{
|
|
Code: "CODE_REDEEMED",
|
|
MessageZH: "该激活码已被其他账户使用",
|
|
MessageEn: "This code has already been redeemed by another account",
|
|
}
|
|
ErrCodeVoid = &Error{
|
|
Code: "CODE_VOID",
|
|
MessageZH: "该激活码已失效",
|
|
MessageEn: "This code is no longer valid",
|
|
}
|
|
ErrRateLimited = &Error{
|
|
Code: "RATE_LIMITED",
|
|
MessageZH: "操作过于频繁,请稍后再试",
|
|
MessageEn: "Too many attempts, please try again later",
|
|
}
|
|
ErrLocked = &Error{
|
|
Code: "ACCOUNT_LOCKED",
|
|
MessageZH: "账户已临时锁定,请1小时后重试",
|
|
MessageEn: "Account temporarily locked, please retry in 1 hour",
|
|
}
|
|
ErrInternal = &Error{
|
|
Code: "INTERNAL_ERROR",
|
|
MessageZH: "服务器内部错误,请稍后重试",
|
|
MessageEn: "Internal server error, please try again later",
|
|
}
|
|
ErrBadRequest = &Error{
|
|
Code: "BAD_REQUEST",
|
|
MessageZH: "请求参数有误",
|
|
MessageEn: "Invalid request parameters",
|
|
}
|
|
|
|
// Usage / ads / quota errors.
|
|
ErrAdNotUnlocked = &Error{
|
|
Code: "AD_NOT_UNLOCKED",
|
|
MessageZH: "请先观看激励视频解锁当日时长",
|
|
MessageEn: "Please watch the rewarded ad to unlock today's minutes",
|
|
}
|
|
ErrQuotaExhausted = &Error{
|
|
Code: "QUOTA_EXHAUSTED",
|
|
MessageZH: "今日免费时长已用尽,请明天再来或升级套餐",
|
|
MessageEn: "Today's free minutes are used up, try tomorrow or upgrade",
|
|
}
|
|
ErrAdVerifyFailed = &Error{
|
|
Code: "AD_VERIFY_FAILED",
|
|
MessageZH: "广告回执校验失败",
|
|
MessageEn: "Ad receipt verification failed",
|
|
}
|
|
ErrAdReplay = &Error{
|
|
Code: "AD_TOKEN_REPLAY",
|
|
MessageZH: "该广告回执已被使用",
|
|
MessageEn: "This ad receipt has already been used",
|
|
}
|
|
|
|
// Webhook-specific errors.
|
|
ErrWebhookSignature = &Error{
|
|
Code: "WEBHOOK_INVALID_SIGNATURE",
|
|
MessageZH: "签名校验失败",
|
|
MessageEn: "Invalid webhook signature",
|
|
}
|
|
ErrWebhookTimestamp = &Error{
|
|
Code: "WEBHOOK_TIMESTAMP_EXPIRED",
|
|
MessageZH: "请求时间戳超出允许窗口",
|
|
MessageEn: "Webhook timestamp outside allowed window",
|
|
}
|
|
ErrWebhookReplay = &Error{
|
|
Code: "WEBHOOK_REPLAY",
|
|
MessageZH: "重复请求已忽略",
|
|
MessageEn: "Duplicate webhook request ignored",
|
|
}
|
|
)
|
|
|
|
// WriteJSON writes the given status code and error body as JSON.
|
|
func WriteJSON(w http.ResponseWriter, status int, e *Error) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(e)
|
|
}
|