merge: usage 用量 + 广告解锁 [tsk_1taxhtV2k3RP]

# Conflicts:
#	server/internal/apierr/apierr.go
This commit is contained in:
wangjia
2026-06-13 17:30:36 +08:00
17 changed files with 3465 additions and 154 deletions
+48
View File
@@ -0,0 +1,48 @@
---
name: pangolin-design
description: Use this skill to generate well-branded interfaces and assets for 穿山甲 (Pangolin) — for production (Flutter client, Go backend, web) or throwaway prototypes/mocks. Contains the full design system (tokens, type, brand assets), four React UI kits (mobile / desktop / website / usercenter), a ready-to-run Flutter token+widget package, and a backend architecture blueprint. Invoke whenever building or reproducing any Pangolin surface.
user-invocable: true
---
# 穿山甲 · Pangolin — 设计与实现技能
这套技能让你(含 Claude Code)**完全还原**穿山甲的设计并落地为产品。UI 以 `ui_kits/` 的 React 原型为像素基准;客户端生产代码用 Flutter;后端按 `server/ARCHITECTURE.md` 蓝本实现。
## 第一步:必读(顺序固定)
1. **`CLAUDE.md`** — 设计铁律 13 条(§1)、套餐口径单一来源(§7)、页面清单(§5)、**实施工作步骤(§9)**。先读它,§9 就是工作流。
2. **`README.md`** — 品牌语境、内容基础、视觉基础、图标系统、文件索引。
3. 按需:`colors_and_type.css`(令牌真相源)、`flutter/README.md`(Flutter 接入)、`server/ARCHITECTURE.md`(后端)。
## 这是什么品牌
**穿山甲 / Pangolin** — 极简、轻量、亲和的跨平台消费级**网络加速应用**(对外一律不用"VPN"等红线词,见 CLAUDE.md 铁律 13)。暖大地色(穿山甲鳞甲)+ 大量留白 + 双语单显 + 深浅双主题。核心卖点:一键连接、智能选线、即开即用。
## 仓库地图
| 路径 | 内容 | 用途 |
|---|---|---|
| `CLAUDE.md` | 铁律 / 套餐口径 / 页面清单 / **工作步骤 §9** | 一切工作的总纲 |
| `colors_and_type.css` | CSS 令牌(唯一真相源) | HTML/Web 直接链入 |
| `flutter/` | Dart 令牌镜像 + widgets + pubspec + main.dart | Flutter 客户端起步包,拿来即跑 |
| `ui_kits/mobile/` | 移动 App 完整原型(登录/引导/4 Tab/账户子页/滑动切换) | 客户端像素验收标准 |
| `ui_kits/desktop/` | 桌面客户端(920×600 侧栏布局 + 登录/引导) | 同上(桌面) |
| `ui_kits/website/` | 官网(产品/定价/下载/文档/Blog,响应式+i18n) | 官网迁移样板 |
| `ui_kits/usercenter/` | Web 用户中心(概览/订阅导入/兑换/邀请/设置含 2FA,移动适配) | 用户中心样板 |
| `server/ARCHITECTURE.md` | Go 控制面 + WireGuard 数据面蓝本(数据模型/API/流程/实现顺序) | 后端实现总纲 |
| `assets/` `preview/` | 品牌 SVG / 设计系统 specimen 卡 | 资产与规范预览 |
## 工作步骤(交给 Claude Code 的执行计划)
完整版见 **CLAUDE.md §9**,摘要:
1. **进场必读**:CLAUDE.md §1 → §7 → §5;再读 `colors_and_type.css` + 目标端的 UI Kit 源码。
2. **Flutter 客户端**:用 `flutter/` 起步包建工程(pubspec 覆盖 → 拷 theme/widgets/main → 配字体 → `flutter run` 即出演示态);再对照 `ui_kits/mobile/` 逐屏补齐(智能选择推荐卡、免费额度卡+看广告解锁、Tab 滑动切换);先演示数据,后按 `server/ARCHITECTURE.md` §3 契约接 API。
3. **后端(Go)**:按 `server/ARCHITECTURE.md` §7 模块顺序:openapi → auth → codes → devices → nodes → usage → 管理端。每模块:单测 + OpenAPI 同步 + 双语错误文案 + 脱敏。
4. **官网 / 用户中心**:以 `ui_kits/website/``ui_kits/usercenter/` 为样板迁移到正式框架,保留 i18n 单显、响应式与脱敏文案;用户中心接 me/redeem/devices,2FA 用 TOTP。
5. **每个界面提交前自查**(完整清单见 CLAUDE.md §9 第 4 步):语义 token 无硬编码色 / 明暗+中英四态验证 / 无红线词 / 套餐数字与 §7 一致 / Lucide 图标无 emoji / 连接键三态与原型一致 / 无 App 内支付。
## 铁律摘要(完整 13 条见 CLAUDE.md §1)
暖大地色调,**绝不纯黑纯白大面积填充**;主色 clay `#B96A3D`,**绝不蓝紫渐变**;圆角偏大;阴影柔和暖调;**单语言显示**(中/英切换,不并排);状态用色点+文字胶囊(无 emoji);国家用 2 字母码块(无 emoji 国旗);Lucide 细线图标;**App 内无支付**(兑换码+外部渠道:发卡店/USDT/TG/LINE/邮箱);品牌母题=行走穿山甲(拷 `assets/*.svg`,绝不重绘);不堆砌、留白即设计;**全站脱敏**(红线词清单见铁律 13)。
## 套餐口径(单一来源 = CLAUDE.md §7,改数字先改那里)
注册享 **7 天免费试用**(不限时长节点)→ 之后免费版 **1 个基础节点 + 每日 10 分钟 + 每日使用前看激励视频解锁**;PRO ¥25/月(年付 ¥20/月),80+ 线路,5 设备;团队版 ¥99/月 10 席位。
## 无指令时
若用户只调用技能未说要做什么:问他要建/设计什么,问几个聚焦问题,作为本品牌专家输出 —— 按需产出 HTML 原型、Flutter 生产代码或后端模块。
Submodule .claude/worktrees/arch-doc added at 3ae96ef229
Submodule .claude/worktrees/iridescent-sleeping-lampson added at 4bff8593e7
+954 -23
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -323,6 +323,8 @@ paths:
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"409":
$ref: "#/components/responses/Conflict"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
+2 -2
View File
@@ -7,6 +7,7 @@ require (
github.com/bufbuild/buf v1.70.0
github.com/go-chi/chi/v5 v5.2.1
github.com/go-sql-driver/mysql v1.8.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/oapi-codegen/oapi-codegen/v2 v2.7.1
@@ -14,6 +15,7 @@ require (
github.com/testcontainers/testcontainers-go v0.34.0
github.com/testcontainers/testcontainers-go/modules/mysql v0.34.0
github.com/testcontainers/testcontainers-go/modules/redis v0.34.0
golang.org/x/crypto v0.52.0
google.golang.org/grpc v1.81.1
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0
google.golang.org/protobuf v1.36.11
@@ -128,7 +130,6 @@ require (
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
@@ -260,7 +261,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.55.0 // indirect
+23
View File
@@ -108,6 +108,29 @@ var (
// Webhook-specific errors.
var (
// 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: "签名校验失败",
+267
View File
@@ -0,0 +1,267 @@
package usage
import (
"context"
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// AdVerifyRequest carries the inputs an AdVerifier needs to authenticate a
// rewarded-ad receipt.
type AdVerifyRequest struct {
// UserID is the authenticated user requesting the unlock.
UserID int64
// DeviceID is the device UUID that played the ad.
DeviceID string
// AdToken is the provider's server-side verification receipt. For AdMob
// SSV this is the full callback query string (everything after the '?').
AdToken string
// ExpectedCustomData, when non-empty, must match the receipt's custom_data
// field (binds the receipt to this user).
ExpectedCustomData string
}
// AdVerifier authenticates a rewarded-ad receipt with the ad network.
// Implementations must be safe for concurrent use.
type AdVerifier interface {
// Verify returns nil if the receipt is genuine and bound to the request.
Verify(ctx context.Context, req AdVerifyRequest) error
// Provider names the ad network (e.g. "admob", "unity").
Provider() string
}
// --------------------------------------------------------------------------
// AdMob Server-Side Verification (SSV)
// --------------------------------------------------------------------------
// DefaultAdMobKeyServerURL is Google's public ECDSA key endpoint for AdMob SSV.
const DefaultAdMobKeyServerURL = "https://www.gstatic.com/admob/reward/verifier-keys.json"
// AdMobVerifier verifies AdMob SSV callbacks. It fetches and caches Google's
// ECDSA public keys, then checks the ECDSA-SHA256 signature over the callback
// content, that the receipt's custom_data matches the user, and (optionally)
// that the ad_unit is one we recognise.
type AdMobVerifier struct {
keyServerURL string
httpClient *http.Client
allowedAdUnits map[string]struct{}
keyTTL time.Duration
mu sync.RWMutex
keys map[string]*ecdsa.PublicKey
fetchedAt time.Time
}
// NewAdMobVerifier creates an AdMobVerifier. keyServerURL empty → the default
// Google endpoint. keyTTL <= 0 → 12h. allowedAdUnits empty → any ad unit is
// accepted (only the signature and custom_data are enforced).
func NewAdMobVerifier(keyServerURL string, httpClient *http.Client, keyTTL time.Duration, allowedAdUnits []string) *AdMobVerifier {
if keyServerURL == "" {
keyServerURL = DefaultAdMobKeyServerURL
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 5 * time.Second}
}
if keyTTL <= 0 {
keyTTL = 12 * time.Hour
}
allowed := make(map[string]struct{}, len(allowedAdUnits))
for _, u := range allowedAdUnits {
allowed[u] = struct{}{}
}
return &AdMobVerifier{
keyServerURL: keyServerURL,
httpClient: httpClient,
allowedAdUnits: allowed,
keyTTL: keyTTL,
}
}
// Provider implements AdVerifier.
func (v *AdMobVerifier) Provider() string { return "admob" }
// errAdMobVerify is returned for any receipt that fails authentication.
var errAdMobVerify = errors.New("admob: receipt verification failed")
// Verify implements AdVerifier for AdMob SSV.
//
// AdMob signs the callback by computing ECDSA-SHA256 over the query string up
// to (but excluding) "&signature="; the signature (web-safe base64, ASN.1 DER)
// and key_id follow. See Google's SSV documentation.
func (v *AdMobVerifier) Verify(ctx context.Context, req AdVerifyRequest) error {
raw := strings.TrimPrefix(req.AdToken, "?")
if raw == "" {
return errAdMobVerify
}
// Content to verify = everything before "&signature=".
sigMarker := strings.Index(raw, "&signature=")
if sigMarker < 0 {
return errAdMobVerify
}
content := raw[:sigMarker]
params, err := url.ParseQuery(raw)
if err != nil {
return errAdMobVerify
}
sigB64 := params.Get("signature")
keyID := params.Get("key_id")
if sigB64 == "" || keyID == "" {
return errAdMobVerify
}
// Bind the receipt to the user / ad unit.
if req.ExpectedCustomData != "" && params.Get("custom_data") != req.ExpectedCustomData {
return errAdMobVerify
}
if len(v.allowedAdUnits) > 0 {
if _, ok := v.allowedAdUnits[params.Get("ad_unit")]; !ok {
return errAdMobVerify
}
}
sig, err := base64.RawURLEncoding.DecodeString(sigB64)
if err != nil {
// Some senders include padding; fall back to standard URL encoding.
sig, err = base64.URLEncoding.DecodeString(sigB64)
if err != nil {
return errAdMobVerify
}
}
pub, err := v.publicKey(ctx, keyID)
if err != nil {
return err
}
digest := sha256.Sum256([]byte(content))
if !ecdsa.VerifyASN1(pub, digest[:], sig) {
return errAdMobVerify
}
return nil
}
// publicKey returns the cached ECDSA public key for keyID, refreshing the key
// set from the server when the cache is empty, stale, or missing the key.
func (v *AdMobVerifier) publicKey(ctx context.Context, keyID string) (*ecdsa.PublicKey, error) {
v.mu.RLock()
pub, ok := v.keys[keyID]
fresh := time.Since(v.fetchedAt) < v.keyTTL
v.mu.RUnlock()
if ok && fresh {
return pub, nil
}
if err := v.refreshKeys(ctx); err != nil {
// Serve a stale cached key rather than fail outright on a transient
// fetch error.
if ok {
return pub, nil
}
return nil, err
}
v.mu.RLock()
pub, ok = v.keys[keyID]
v.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("admob: unknown key_id %q", keyID)
}
return pub, nil
}
// admobKeySet mirrors the JSON returned by the AdMob verifier-keys endpoint.
type admobKeySet struct {
Keys []struct {
KeyID json.Number `json:"keyId"`
PEM string `json:"pem"`
Base64 string `json:"base64"`
} `json:"keys"`
}
// refreshKeys fetches and parses the public key set from the key server.
func (v *AdMobVerifier) refreshKeys(ctx context.Context) error {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, v.keyServerURL, nil)
if err != nil {
return fmt.Errorf("admob refreshKeys: %w", err)
}
resp, err := v.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("admob refreshKeys: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("admob refreshKeys: status %d", resp.StatusCode)
}
var ks admobKeySet
if err := json.NewDecoder(resp.Body).Decode(&ks); err != nil {
return fmt.Errorf("admob refreshKeys decode: %w", err)
}
parsed := make(map[string]*ecdsa.PublicKey, len(ks.Keys))
for _, k := range ks.Keys {
pub, err := parseECDSAPublicKey(k.PEM)
if err != nil {
continue // skip unparseable keys rather than failing the whole set
}
parsed[k.KeyID.String()] = pub
}
if len(parsed) == 0 {
return errors.New("admob refreshKeys: no usable keys")
}
v.mu.Lock()
v.keys = parsed
v.fetchedAt = time.Now()
v.mu.Unlock()
return nil
}
// parseECDSAPublicKey parses a PEM-encoded PKIX ECDSA public key.
func parseECDSAPublicKey(pemStr string) (*ecdsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, errors.New("admob: invalid PEM")
}
anyKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("admob: parse pkix: %w", err)
}
pub, ok := anyKey.(*ecdsa.PublicKey)
if !ok {
return nil, errors.New("admob: not an ECDSA key")
}
return pub, nil
}
// --------------------------------------------------------------------------
// Unity Ads (interface placeholder)
// --------------------------------------------------------------------------
// UnityVerifier is a placeholder for Unity Ads SSV; the interface is wired now
// and the verification logic will be implemented when Unity is integrated.
type UnityVerifier struct{}
// Provider implements AdVerifier.
func (UnityVerifier) Provider() string { return "unity" }
// errUnityUnimplemented signals that Unity SSV is not yet available.
var errUnityUnimplemented = errors.New("unity: SSV verification not implemented")
// Verify implements AdVerifier.
func (UnityVerifier) Verify(context.Context, AdVerifyRequest) error {
return errUnityUnimplemented
}
+138
View File
@@ -0,0 +1,138 @@
package usage
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// adMobTestKeyServer spins up an httptest server that serves a single ECDSA
// public key in the AdMob verifier-keys.json format, and returns the server,
// the private key, and the key id.
func adMobTestKeyServer(t *testing.T) (*httptest.Server, *ecdsa.PrivateKey, string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("marshal pub: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})
const keyID = "3335741209"
body := map[string]any{
"keys": []map[string]any{
{"keyId": 3335741209, "pem": string(pemBytes), "base64": base64.StdEncoding.EncodeToString(der)},
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(body)
}))
t.Cleanup(srv.Close)
return srv, priv, keyID
}
// signAdMob builds a signed AdMob SSV callback query string.
func signAdMob(t *testing.T, priv *ecdsa.PrivateKey, keyID, content string) string {
t.Helper()
digest := sha256.Sum256([]byte(content))
sig, err := ecdsa.SignASN1(rand.Reader, priv, digest[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return fmt.Sprintf("%s&signature=%s&key_id=%s", content, sigB64, keyID)
}
func TestAdMobVerifyValid(t *testing.T) {
srv, priv, keyID := adMobTestKeyServer(t)
v := NewAdMobVerifier(srv.URL, srv.Client(), time.Hour, []string{"ca-app-pub-123/456"})
content := "ad_network=admob&ad_unit=ca-app-pub-123/456&custom_data=user-uuid-42&reward_amount=1&reward_item=minutes&timestamp=1700000000&transaction_id=abc123&user_id=device-1"
token := signAdMob(t, priv, keyID, content)
err := v.Verify(context.Background(), AdVerifyRequest{
UserID: 42,
DeviceID: "device-1",
AdToken: token,
ExpectedCustomData: "user-uuid-42",
})
if err != nil {
t.Fatalf("expected valid receipt, got %v", err)
}
}
func TestAdMobVerifyForgedSignature(t *testing.T) {
srv, priv, keyID := adMobTestKeyServer(t)
v := NewAdMobVerifier(srv.URL, srv.Client(), time.Hour, nil)
content := "ad_unit=ca-app-pub-123/456&custom_data=u1&transaction_id=abc"
token := signAdMob(t, priv, keyID, content)
// Tamper with the signed content after signing → signature no longer matches.
tampered := "ad_unit=ca-app-pub-123/456&custom_data=u1&transaction_id=EVIL" + token[len(content):]
if err := v.Verify(context.Background(), AdVerifyRequest{AdToken: tampered}); err == nil {
t.Fatal("expected forged signature to be rejected")
}
}
func TestAdMobVerifyCustomDataMismatch(t *testing.T) {
srv, priv, keyID := adMobTestKeyServer(t)
v := NewAdMobVerifier(srv.URL, srv.Client(), time.Hour, nil)
content := "ad_unit=ca-app-pub-123/456&custom_data=u1&transaction_id=abc"
token := signAdMob(t, priv, keyID, content)
if err := v.Verify(context.Background(), AdVerifyRequest{
AdToken: token,
ExpectedCustomData: "someone-else",
}); err == nil {
t.Fatal("expected custom_data mismatch to be rejected")
}
}
func TestAdMobVerifyDisallowedAdUnit(t *testing.T) {
srv, priv, keyID := adMobTestKeyServer(t)
v := NewAdMobVerifier(srv.URL, srv.Client(), time.Hour, []string{"ca-app-pub-allowed/1"})
content := "ad_unit=ca-app-pub-OTHER/9&custom_data=u1&transaction_id=abc"
token := signAdMob(t, priv, keyID, content)
if err := v.Verify(context.Background(), AdVerifyRequest{AdToken: token}); err == nil {
t.Fatal("expected disallowed ad_unit to be rejected")
}
}
func TestAdMobVerifyMalformedToken(t *testing.T) {
srv, _, _ := adMobTestKeyServer(t)
v := NewAdMobVerifier(srv.URL, srv.Client(), time.Hour, nil)
for _, tok := range []string{"", "no-signature-here", "?garbage"} {
if err := v.Verify(context.Background(), AdVerifyRequest{AdToken: tok}); err == nil {
t.Errorf("expected malformed token %q to be rejected", tok)
}
}
}
func TestUnityVerifierUnimplemented(t *testing.T) {
var v AdVerifier = UnityVerifier{}
if v.Provider() != "unity" {
t.Errorf("provider = %q, want unity", v.Provider())
}
if err := v.Verify(context.Background(), AdVerifyRequest{}); err == nil {
t.Error("expected Unity verifier to report unimplemented")
}
}
+184
View File
@@ -0,0 +1,184 @@
package usage
import (
"context"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// Report is one usage delta pushed by the data plane (#5's ReportUsage path).
// It identifies the user only by data-plane UUID; the aggregator resolves it to
// a user_id internally. No destination, domain, or DNS data is ever carried.
type Report struct {
// DPUUID is the data-plane credential UUID the node observed.
DPUUID string
// BytesUp / BytesDown are the incremental byte counts since the last report.
BytesUp uint64
BytesDown uint64
// Minutes is the incremental session minutes since the last report.
Minutes int
// At is the event time used for UTC day bucketing. Zero = time.Now().
At time.Time
// BatchID is #5's upload batch identifier, used for replay-idempotent
// de-duplication. Empty disables dedup for this report.
BatchID string
}
// UsageReporter is the callback interface #5 invokes when a node reports usage.
// Aggregator satisfies it, so #5 can depend on the interface and be wired to a
// real or mock implementation.
type UsageReporter interface {
ReportUsage(ctx context.Context, r Report) error
}
// Aggregator implements UsageReporter: it resolves dp_uuid → user_id (with a
// short in-memory + Redis cache), de-duplicates replayed batches, and folds the
// delta into usage_daily by UTC date.
type Aggregator struct {
store *Store
rdb *redis.Client
cache *dpCache
batchTTL time.Duration
}
// NewAggregator builds an Aggregator. rdb may be nil (batch dedup and the
// Redis tier of the dp_uuid cache are then disabled; the in-memory cache and
// DB lookups still work). dpCacheTTL <= 0 defaults to 5 minutes; batchTTL <= 0
// defaults to 10 minutes.
func NewAggregator(store *Store, rdb *redis.Client, dpCacheTTL, batchTTL time.Duration) *Aggregator {
if dpCacheTTL <= 0 {
dpCacheTTL = 5 * time.Minute
}
if batchTTL <= 0 {
batchTTL = 10 * time.Minute
}
return &Aggregator{
store: store,
rdb: rdb,
cache: newDPCache(dpCacheTTL),
batchTTL: batchTTL,
}
}
// redisKeyBatch returns the Redis key marking a processed upload batch.
func redisKeyBatch(batchID string) string { return "usage:batch:" + batchID }
// redisKeyDP returns the Redis key caching a dp_uuid → user_id resolution.
func redisKeyDP(dpUUID string) string { return "usage:dp:" + dpUUID }
// ReportUsage folds one report into usage_daily. It is safe for concurrent
// use and idempotent across replays of the same BatchID.
func (a *Aggregator) ReportUsage(ctx context.Context, r Report) error {
if r.DPUUID == "" {
return nil
}
if r.BytesUp == 0 && r.BytesDown == 0 && r.Minutes == 0 {
return nil
}
// Replay guard: a batch already seen is a no-op.
if dup, err := a.batchSeen(ctx, r.BatchID); err != nil {
return err
} else if dup {
return nil
}
userID, err := a.resolveUser(ctx, r.DPUUID)
if err != nil {
return err
}
if userID == 0 {
// Unknown/rotated dp_uuid: drop silently (no log of the mapping).
return nil
}
at := r.At
if at.IsZero() {
at = time.Now()
}
return a.store.AggregateUsage(ctx, userID, at.UTC(), r.BytesUp, r.BytesDown, r.Minutes)
}
// batchSeen marks the batch as processed and reports whether it was already
// seen. Returns false when batchID is empty or Redis is unavailable (dedup is
// best-effort; the DB accumulation itself is additive, not idempotent, so #5
// must supply a BatchID + Redis to get exactly-once semantics).
func (a *Aggregator) batchSeen(ctx context.Context, batchID string) (bool, error) {
if batchID == "" || a.rdb == nil {
return false, nil
}
set, err := a.rdb.SetNX(ctx, redisKeyBatch(batchID), "1", a.batchTTL).Result()
if err != nil {
return false, err
}
// SetNX returns true when the key was newly set (i.e. not a duplicate).
return !set, nil
}
// resolveUser maps a dp_uuid to a user_id via the in-memory cache, then Redis,
// then the database (populating both caches on a DB hit).
func (a *Aggregator) resolveUser(ctx context.Context, dpUUID string) (int64, error) {
if id, ok := a.cache.get(dpUUID); ok {
return id, nil
}
if a.rdb != nil {
if id, err := a.rdb.Get(ctx, redisKeyDP(dpUUID)).Int64(); err == nil {
a.cache.set(dpUUID, id)
return id, nil
} else if err != redis.Nil {
return 0, err
}
}
id, err := a.store.LookupUserIDByDPUUID(ctx, dpUUID)
if err != nil {
return 0, err
}
if id == 0 {
return 0, nil
}
a.cache.set(dpUUID, id)
if a.rdb != nil {
_ = a.rdb.Set(ctx, redisKeyDP(dpUUID), id, a.cache.ttl).Err()
}
return id, nil
}
// --------------------------------------------------------------------------
// in-memory dp_uuid → user_id cache (short TTL)
// --------------------------------------------------------------------------
type dpCacheEntry struct {
userID int64
expires time.Time
}
type dpCache struct {
mu sync.RWMutex
m map[string]dpCacheEntry
ttl time.Duration
}
func newDPCache(ttl time.Duration) *dpCache {
return &dpCache{m: make(map[string]dpCacheEntry), ttl: ttl}
}
func (c *dpCache) get(dpUUID string) (int64, bool) {
c.mu.RLock()
e, ok := c.m[dpUUID]
c.mu.RUnlock()
if !ok || time.Now().After(e.expires) {
return 0, false
}
return e.userID, true
}
func (c *dpCache) set(dpUUID string, userID int64) {
c.mu.Lock()
c.m[dpUUID] = dpCacheEntry{userID: userID, expires: time.Now().Add(c.ttl)}
c.mu.Unlock()
}
+134
View File
@@ -0,0 +1,134 @@
package usage
import (
"encoding/json"
"net/http"
"strconv"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// ctxKey is the context key type for request-scoped values. Defined here to
// avoid an import cycle; the JWT auth middleware sets the same key.
type ctxKey string
// CtxKeyUserID is the context key carrying the authenticated int64 user ID.
const CtxKeyUserID ctxKey = "user_id"
// userIDFromContext extracts the authenticated user ID set by the auth
// middleware, or 0 if absent.
func userIDFromContext(r *http.Request) int64 {
id, _ := r.Context().Value(CtxKeyUserID).(int64)
return id
}
// UsageHandler serves GET /v1/usage?days=N.
type UsageHandler struct {
svc *Service
}
// NewUsageHandler creates a UsageHandler.
func NewUsageHandler(svc *Service) *UsageHandler { return &UsageHandler{svc: svc} }
type usageResponse struct {
Points []UsagePoint `json:"points"`
}
// ServeHTTP implements http.Handler.
func (h *UsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
userID := userIDFromContext(r)
if userID == 0 {
apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{
Code: "UNAUTHORIZED",
MessageZH: "请先登录",
MessageEn: "Authentication required",
})
return
}
days := 7
if v := r.URL.Query().Get("days"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 90 {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
days = n
}
points, apiErr := h.svc.UsageCurve(r.Context(), userID, days)
if apiErr != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apiErr)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(usageResponse{Points: points})
}
// AdsUnlockHandler serves POST /v1/ads/unlock.
type AdsUnlockHandler struct {
svc *Service
}
// NewAdsUnlockHandler creates an AdsUnlockHandler.
func NewAdsUnlockHandler(svc *Service) *AdsUnlockHandler { return &AdsUnlockHandler{svc: svc} }
type adsUnlockRequest struct {
DeviceID string `json:"device_id"`
AdToken string `json:"ad_token"`
}
// ServeHTTP implements http.Handler. On success it returns 204 No Content
// (matching the OpenAPI contract), including the idempotent already-unlocked
// case.
func (h *AdsUnlockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
userID := userIDFromContext(r)
if userID == 0 {
apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{
Code: "UNAUTHORIZED",
MessageZH: "请先登录",
MessageEn: "Authentication required",
})
return
}
var req adsUnlockRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&req); err != nil {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
_, apiErr := h.svc.UnlockAd(r.Context(), userID, req.DeviceID, req.AdToken)
if apiErr != nil {
apierr.WriteJSON(w, adsErrorStatus(apiErr.Code), apiErr)
return
}
w.WriteHeader(http.StatusNoContent)
}
// adsErrorStatus maps an ads-unlock error code to an HTTP status.
func adsErrorStatus(code string) int {
switch code {
case "AD_VERIFY_FAILED":
return http.StatusForbidden
case "AD_TOKEN_REPLAY":
return http.StatusConflict
case "BAD_REQUEST":
return http.StatusBadRequest
case "INTERNAL_ERROR":
return http.StatusInternalServerError
default:
return http.StatusBadRequest
}
}
+66
View File
@@ -0,0 +1,66 @@
package usage
import (
"context"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// defaultFreeDailyMinutes is the口径 fallback when the free plan row has a NULL
// daily_minutes (should not happen given the seed, but we stay safe).
const defaultFreeDailyMinutes = 10
// CheckFreeConnect enforces the free-plan connect gate and returns the user's
// remaining minutes for today (UTC). It is called by #5's connect endpoint to
// derive the free credential's TTL.
//
// Rules:
// - plan.ad_gate == false (pro/team): not minute-gated. Returns Unlimited
// when daily_minutes is NULL, otherwise the remaining minutes for the day.
// - plan.ad_gate == true (free): today's ad_unlocked_at must be set and
// minutes_used must be below daily_minutes (free = 10). Returns the
// remaining minutes; otherwise a bilingual semantic error
// (AD_NOT_UNLOCKED / QUOTA_EXHAUSTED).
func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remainingMinutes int, apiErr *apierr.Error) {
plan, err := svc.store.EffectivePlan(ctx, userID)
if err != nil {
return 0, apierr.ErrInternal
}
limit := defaultFreeDailyMinutes
if plan.DailyMinutes.Valid {
limit = int(plan.DailyMinutes.Int64)
}
// Paid plans (no ad gate).
if !plan.AdGate {
if !plan.DailyMinutes.Valid {
return Unlimited, nil
}
day, err := svc.store.GetDay(ctx, userID, utcToday())
if err != nil {
return 0, apierr.ErrInternal
}
remaining := limit
if day != nil {
remaining = limit - day.MinutesUsed
}
if remaining < 0 {
remaining = 0
}
return remaining, nil
}
// Free plan: require ad unlock + remaining minutes.
day, err := svc.store.GetDay(ctx, userID, utcToday())
if err != nil {
return 0, apierr.ErrInternal
}
if day == nil || !day.AdUnlockedAt.Valid {
return 0, apierr.ErrAdNotUnlocked
}
if day.MinutesUsed >= limit {
return 0, apierr.ErrQuotaExhausted
}
return limit - day.MinutesUsed, nil
}
+176
View File
@@ -0,0 +1,176 @@
package usage
import (
"context"
"crypto/sha256"
"encoding/hex"
"time"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// nowFunc is overridable in tests to pin "today".
var nowFunc = time.Now
// utcToday returns the current UTC calendar date (time truncated).
func utcToday() time.Time {
n := nowFunc().UTC()
return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, time.UTC)
}
// Service serves usage queries, the /v1/me usage summary, the ads-unlock flow,
// and the free-plan connect quota check.
type Service struct {
store *Store
rdb *redis.Client
verifier AdVerifier
adNonceTTL time.Duration
}
// NewService builds a Service. rdb may be nil (ad-token replay protection is
// then disabled — not recommended in production). verifier may be nil if the
// ads-unlock endpoint is not mounted. adNonceTTL <= 0 defaults to 1h.
func NewService(store *Store, rdb *redis.Client, verifier AdVerifier, adNonceTTL time.Duration) *Service {
if adNonceTTL <= 0 {
adNonceTTL = time.Hour
}
return &Service{store: store, rdb: rdb, verifier: verifier, adNonceTTL: adNonceTTL}
}
// UsagePoint is one day of the usage curve.
type UsagePoint struct {
Date string `json:"date"` // YYYY-MM-DD (UTC)
BytesUp uint64 `json:"bytes_up"`
BytesDown uint64 `json:"bytes_down"`
MinutesUsed int `json:"minutes_used"`
}
// UsageCurve returns the last `days` daily points for userID (UTC), with
// missing days zero-filled, oldest first. days is clamped to [1, 90].
func (svc *Service) UsageCurve(ctx context.Context, userID int64, days int) ([]UsagePoint, *apierr.Error) {
if days < 1 {
days = 7
}
if days > 90 {
days = 90
}
today := utcToday()
from := today.AddDate(0, 0, -(days - 1))
rows, err := svc.store.GetUsageRange(ctx, userID, from, today)
if err != nil {
return nil, apierr.ErrInternal
}
byDate := make(map[string]DailyUsage, len(rows))
for _, r := range rows {
byDate[r.Date.UTC().Format(dateLayout)] = r
}
points := make([]UsagePoint, 0, days)
for i := 0; i < days; i++ {
d := from.AddDate(0, 0, i).Format(dateLayout)
if u, ok := byDate[d]; ok {
points = append(points, UsagePoint{
Date: d,
BytesUp: u.BytesUp,
BytesDown: u.BytesDown,
MinutesUsed: u.MinutesUsed,
})
} else {
points = append(points, UsagePoint{Date: d})
}
}
return points, nil
}
// TodaySummary is the /v1/me today_usage block.
type TodaySummary struct {
MinutesUsed int `json:"minutes_used"`
MinutesRemaining *int `json:"minutes_remaining"` // nil = unlimited (pro/team)
AdUnlocked bool `json:"ad_unlocked"`
}
// TodaySummary returns the current user's usage summary for today (UTC),
// reflecting plan limits and ad-unlock state.
func (svc *Service) TodaySummary(ctx context.Context, userID int64) (*TodaySummary, *apierr.Error) {
plan, err := svc.store.EffectivePlan(ctx, userID)
if err != nil {
return nil, apierr.ErrInternal
}
day, err := svc.store.GetDay(ctx, userID, utcToday())
if err != nil {
return nil, apierr.ErrInternal
}
used := 0
adUnlocked := false
if day != nil {
used = day.MinutesUsed
adUnlocked = day.AdUnlockedAt.Valid
}
out := &TodaySummary{MinutesUsed: used, AdUnlocked: adUnlocked}
if plan.DailyMinutes.Valid {
remaining := int(plan.DailyMinutes.Int64) - used
if remaining < 0 {
remaining = 0
}
out.MinutesRemaining = &remaining
}
return out, nil
}
// UnlockAd verifies a rewarded-ad receipt and records the day's ad-unlock.
//
// Flow: validate inputs → verify the receipt with the ad network → consume the
// ad_token as a one-time nonce (replay-protected) → set ad_unlocked_at. A
// second unlock on a day already unlocked is idempotent (alreadyUnlocked=true).
func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToken string) (alreadyUnlocked bool, apiErr *apierr.Error) {
if deviceID == "" || adToken == "" {
return false, apierr.ErrBadRequest
}
if svc.verifier == nil {
return false, apierr.ErrInternal
}
// 1. Authenticate the receipt with the ad network.
if err := svc.verifier.Verify(ctx, AdVerifyRequest{
UserID: userID,
DeviceID: deviceID,
AdToken: adToken,
}); err != nil {
return false, apierr.ErrAdVerifyFailed
}
// 2. One-time nonce: a genuine receipt may only be redeemed once.
if dup, err := svc.consumeAdNonce(ctx, adToken); err != nil {
return false, apierr.ErrInternal
} else if dup {
return false, apierr.ErrAdReplay
}
// 3. Record the unlock (idempotent per UTC day).
already, err := svc.store.MarkAdUnlocked(ctx, userID, utcToday())
if err != nil {
return false, apierr.ErrInternal
}
return already, nil
}
// consumeAdNonce atomically records the ad_token so it cannot be reused.
// Returns dup=true if the token was already consumed. No-ops (dup=false) when
// Redis is not configured.
func (svc *Service) consumeAdNonce(ctx context.Context, adToken string) (bool, error) {
if svc.rdb == nil {
return false, nil
}
sum := sha256.Sum256([]byte(adToken))
key := "ads:nonce:" + hex.EncodeToString(sum[:])
set, err := svc.rdb.SetNX(ctx, key, "1", svc.adNonceTTL).Result()
if err != nil {
return false, err
}
return !set, nil
}
+208
View File
@@ -0,0 +1,208 @@
package usage
import (
"context"
"database/sql"
"fmt"
"time"
)
// Unlimited is the sentinel returned by CheckFreeConnect for plans with no
// daily-minute cap (pro / team). Callers (e.g. the connect endpoint) treat it
// as "no minute limit; use the default paid credential TTL".
const Unlimited = -1
// dateLayout is the canonical UTC day format used as the usage_daily.date key
// and in the API response.
const dateLayout = "2006-01-02"
// Plan is the minimal subset of the plans row needed for quota decisions.
type Plan struct {
Code string // free | pro | team
DailyMinutes sql.NullInt64 // free=10; NULL = unlimited
AdGate bool // free=true: daily rewarded-ad unlock required
}
// DailyUsage mirrors a usage_daily row. It carries only aggregate byte and
// minute counts plus the ad-unlock timestamp — never destinations or DNS, per
// the no-log policy.
type DailyUsage struct {
Date time.Time
BytesUp uint64
BytesDown uint64
MinutesUsed int
AdUnlockedAt sql.NullTime
}
// Store wraps a *sql.DB and exposes the usage_daily / plans / users queries the
// usage package needs.
type Store struct {
db *sql.DB
}
// NewStore creates a Store backed by the given MySQL connection pool.
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// AggregateUsage accumulates one usage delta into usage_daily for (userID, day)
// using INSERT … ON DUPLICATE KEY UPDATE so concurrent reports from multiple
// nodes add up correctly. day is truncated to its UTC calendar date.
func (s *Store) AggregateUsage(ctx context.Context, userID int64, day time.Time, bytesUp, bytesDown uint64, minutes int) error {
d := day.UTC().Format(dateLayout)
_, err := s.db.ExecContext(ctx,
`INSERT INTO usage_daily (user_id, date, bytes_up, bytes_down, minutes_used)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
bytes_up = bytes_up + VALUES(bytes_up),
bytes_down = bytes_down + VALUES(bytes_down),
minutes_used = minutes_used + VALUES(minutes_used)`,
userID, d, bytesUp, bytesDown, minutes)
if err != nil {
return fmt.Errorf("store.AggregateUsage: %w", err)
}
return nil
}
// LookupUserIDByDPUUID resolves a data-plane credential UUID to the owning
// user_id. Returns (0, nil) when no user matches (e.g. a rotated/stale dp_uuid)
// so the caller can decide to silently drop the report.
func (s *Store) LookupUserIDByDPUUID(ctx context.Context, dpUUID string) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx,
`SELECT id FROM users WHERE dp_uuid = ? LIMIT 1`, dpUUID).Scan(&id)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("store.LookupUserIDByDPUUID: %w", err)
}
return id, nil
}
// GetUsageRange returns the usage_daily rows for userID with date in
// [from, to] (inclusive, UTC dates), ordered ascending. Missing days are NOT
// filled here; zero-filling is the handler's responsibility.
func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.Time) ([]DailyUsage, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT date, bytes_up, bytes_down, minutes_used, ad_unlocked_at
FROM usage_daily
WHERE user_id = ? AND date BETWEEN ? AND ?
ORDER BY date ASC`,
userID, from.UTC().Format(dateLayout), to.UTC().Format(dateLayout))
if err != nil {
return nil, fmt.Errorf("store.GetUsageRange: %w", err)
}
defer rows.Close()
var out []DailyUsage
for rows.Next() {
var u DailyUsage
if err := rows.Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt); err != nil {
return nil, fmt.Errorf("store.GetUsageRange scan: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
// GetDay returns the usage_daily row for (userID, day), or nil if none exists.
func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*DailyUsage, error) {
var u DailyUsage
err := s.db.QueryRowContext(ctx,
`SELECT date, bytes_up, bytes_down, minutes_used, ad_unlocked_at
FROM usage_daily
WHERE user_id = ? AND date = ?`,
userID, day.UTC().Format(dateLayout)).
Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("store.GetDay: %w", err)
}
return &u, nil
}
// MarkAdUnlocked sets usage_daily.ad_unlocked_at for (userID, day) to now if it
// is not already set. It returns alreadyUnlocked=true when the day was already
// unlocked (making a second call idempotent). Runs inside a transaction with
// SELECT … FOR UPDATE to be safe under concurrent unlock attempts.
func (s *Store) MarkAdUnlocked(ctx context.Context, userID int64, day time.Time) (alreadyUnlocked bool, err error) {
d := day.UTC().Format(dateLayout)
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return false, fmt.Errorf("store.MarkAdUnlocked begin: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
var unlockedAt sql.NullTime
row := tx.QueryRowContext(ctx,
`SELECT ad_unlocked_at FROM usage_daily WHERE user_id = ? AND date = ? FOR UPDATE`,
userID, d)
switch err := row.Scan(&unlockedAt); err {
case nil:
if unlockedAt.Valid {
// Already unlocked today → idempotent success.
if cErr := tx.Commit(); cErr != nil {
return false, fmt.Errorf("store.MarkAdUnlocked commit: %w", cErr)
}
committed = true
return true, nil
}
if _, uErr := tx.ExecContext(ctx,
`UPDATE usage_daily SET ad_unlocked_at = UTC_TIMESTAMP(6)
WHERE user_id = ? AND date = ? AND ad_unlocked_at IS NULL`,
userID, d); uErr != nil {
return false, fmt.Errorf("store.MarkAdUnlocked update: %w", uErr)
}
case sql.ErrNoRows:
if _, iErr := tx.ExecContext(ctx,
`INSERT INTO usage_daily (user_id, date, ad_unlocked_at)
VALUES (?, ?, UTC_TIMESTAMP(6))`,
userID, d); iErr != nil {
return false, fmt.Errorf("store.MarkAdUnlocked insert: %w", iErr)
}
default:
return false, fmt.Errorf("store.MarkAdUnlocked select: %w", err)
}
if cErr := tx.Commit(); cErr != nil {
return false, fmt.Errorf("store.MarkAdUnlocked commit: %w", cErr)
}
committed = true
return false, nil
}
// EffectivePlan returns the plan that currently governs userID: the highest
// tier among the user's non-expired subscriptions, falling back to the free
// plan when the user has no active subscription (trial-expired free state).
func (s *Store) EffectivePlan(ctx context.Context, userID int64) (*Plan, error) {
var p Plan
err := s.db.QueryRowContext(ctx,
`SELECT p.code, p.daily_minutes, p.ad_gate
FROM subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP(6)
ORDER BY FIELD(p.code, 'team', 'pro', 'free'), s.expires_at DESC
LIMIT 1`,
userID).Scan(&p.Code, &p.DailyMinutes, &p.AdGate)
if err == nil {
return &p, nil
}
if err != sql.ErrNoRows {
return nil, fmt.Errorf("store.EffectivePlan: %w", err)
}
// No active subscription → free plan.
if err := s.db.QueryRowContext(ctx,
`SELECT code, daily_minutes, ad_gate FROM plans WHERE code = 'free'`).
Scan(&p.Code, &p.DailyMinutes, &p.AdGate); err != nil {
return nil, fmt.Errorf("store.EffectivePlan free fallback: %w", err)
}
return &p, nil
}
@@ -0,0 +1,516 @@
//go:build integration
package usage_test
import (
"context"
"database/sql"
"fmt"
"sync"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/redis/go-redis/v9"
"github.com/testcontainers/testcontainers-go"
tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
"github.com/wangjia/pangolin/server/internal/usage"
)
// --------------------------------------------------------------------------
// Container setup
// --------------------------------------------------------------------------
func setupMySQL(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
ctr, err := tcmysql.Run(ctx, "mysql:8.0",
tcmysql.WithDatabase("pangolin_test"),
tcmysql.WithUsername("root"),
tcmysql.WithPassword("test"),
)
testcontainers.CleanupContainer(t, ctr)
if err != nil {
t.Fatalf("mysql container: %v", err)
}
dsn, err := ctr.ConnectionString(ctx, "parseTime=true", "loc=UTC", "time_zone='+00:00'")
if err != nil {
t.Fatalf("mysql dsn: %v", err)
}
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatalf("open mysql: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := applySchema(db); err != nil {
t.Fatalf("schema: %v", err)
}
return db
}
func setupRedis(t *testing.T) *redis.Client {
t.Helper()
ctx := context.Background()
ctr, err := tcredis.Run(ctx, "redis:7-alpine")
testcontainers.CleanupContainer(t, ctr)
if err != nil {
t.Fatalf("redis container: %v", err)
}
addr, err := ctr.ConnectionString(ctx)
if err != nil {
t.Fatalf("redis addr: %v", err)
}
for _, p := range []string{"redis://", "rediss://"} {
if len(addr) > len(p) && addr[:len(p)] == p {
addr = addr[len(p):]
}
}
rdb := redis.NewClient(&redis.Options{Addr: addr})
t.Cleanup(func() { rdb.Close() })
return rdb
}
func applySchema(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
pw_hash VARCHAR(255) NOT NULL DEFAULT '',
dp_uuid CHAR(36) NOT NULL,
status ENUM('active','banned') NOT NULL DEFAULT 'active',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX idx_dp (dp_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS plans (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code ENUM('free','pro','team') NOT NULL UNIQUE,
max_devices INT NOT NULL DEFAULT 1,
daily_minutes INT NULL,
ad_gate BOOLEAN NOT NULL DEFAULT FALSE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS subscriptions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
plan_id BIGINT UNSIGNED NOT NULL,
expires_at DATETIME(6) NOT NULL,
source ENUM('trial','code') NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX idx_user_exp (user_id, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS usage_daily (
user_id BIGINT UNSIGNED NOT NULL,
date DATE NOT NULL,
bytes_up BIGINT UNSIGNED NOT NULL DEFAULT 0,
bytes_down BIGINT UNSIGNED NOT NULL DEFAULT 0,
minutes_used INT NOT NULL DEFAULT 0,
ad_unlocked_at DATETIME(6) NULL,
PRIMARY KEY (user_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
VALUES ('free',1,10,TRUE), ('pro',5,NULL,FALSE), ('team',10,NULL,FALSE)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
return fmt.Errorf("exec: %w\nSQL: %s", err, s)
}
}
return nil
}
// createUser inserts a user with the given dp_uuid and returns its id.
func createUser(t *testing.T, db *sql.DB, dpUUID string) int64 {
t.Helper()
uuid := fmt.Sprintf("u-%d", time.Now().UnixNano())
res, err := db.Exec(
`INSERT INTO users (uuid, email, pw_hash, dp_uuid) VALUES (?,?,?,?)`,
uuid, uuid+"@example.com", "x", dpUUID)
if err != nil {
t.Fatalf("createUser: %v", err)
}
id, _ := res.LastInsertId()
return id
}
// giveSubscription grants userID an active subscription on the given plan.
func giveSubscription(t *testing.T, db *sql.DB, userID int64, plan string) {
t.Helper()
var planID int64
if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, plan).Scan(&planID); err != nil {
t.Fatalf("plan lookup: %v", err)
}
if _, err := db.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source)
VALUES (?,?,?,'code')`,
userID, planID, time.Now().UTC().AddDate(0, 0, 30)); err != nil {
t.Fatalf("giveSubscription: %v", err)
}
}
// fakeVerifier is an AdVerifier test double.
type fakeVerifier struct{ ok bool }
func (f fakeVerifier) Provider() string { return "fake" }
func (f fakeVerifier) Verify(context.Context, usage.AdVerifyRequest) error {
if f.ok {
return nil
}
return fmt.Errorf("fake verifier: rejected")
}
// --------------------------------------------------------------------------
// Aggregation
// --------------------------------------------------------------------------
func TestIntAggregateConcurrentMultiNode(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := usage.NewStore(db)
agg := usage.NewAggregator(store, rdb, time.Minute, time.Minute)
uid := createUser(t, db, "dp-agg-1")
const goroutines = 25
const each = 4
var wg sync.WaitGroup
wg.Add(goroutines)
for g := 0; g < goroutines; g++ {
go func(g int) {
defer wg.Done()
for i := 0; i < each; i++ {
err := agg.ReportUsage(context.Background(), usage.Report{
DPUUID: "dp-agg-1",
BytesUp: 100,
BytesDown: 200,
Minutes: 1,
BatchID: fmt.Sprintf("b-%d-%d", g, i), // unique → all counted
})
if err != nil {
t.Errorf("report: %v", err)
}
}
}(g)
}
wg.Wait()
var up, down uint64
var minutes int
db.QueryRow(`SELECT bytes_up, bytes_down, minutes_used FROM usage_daily WHERE user_id=?`, uid).
Scan(&up, &down, &minutes)
wantUp := uint64(goroutines * each * 100)
wantMin := goroutines * each
if up != wantUp || down != wantUp*2 || minutes != wantMin {
t.Errorf("got up=%d down=%d min=%d, want up=%d down=%d min=%d",
up, down, minutes, wantUp, wantUp*2, wantMin)
}
}
func TestIntAggregateReplayDedup(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := usage.NewStore(db)
agg := usage.NewAggregator(store, rdb, time.Minute, time.Minute)
uid := createUser(t, db, "dp-replay")
r := usage.Report{DPUUID: "dp-replay", BytesUp: 500, Minutes: 5, BatchID: "same-batch"}
for i := 0; i < 4; i++ {
if err := agg.ReportUsage(context.Background(), r); err != nil {
t.Fatalf("report %d: %v", i, err)
}
}
var up uint64
var minutes int
db.QueryRow(`SELECT bytes_up, minutes_used FROM usage_daily WHERE user_id=?`, uid).Scan(&up, &minutes)
if up != 500 || minutes != 5 {
t.Errorf("replay not deduped: up=%d minutes=%d, want 500/5", up, minutes)
}
}
func TestIntAggregateCrossUTCDayBucketing(t *testing.T) {
db := setupMySQL(t)
store := usage.NewStore(db)
agg := usage.NewAggregator(store, nil, time.Minute, time.Minute)
uid := createUser(t, db, "dp-days")
day1 := time.Date(2026, 6, 12, 23, 30, 0, 0, time.UTC)
day2 := time.Date(2026, 6, 13, 0, 15, 0, 0, time.UTC)
must := func(at time.Time, minutes int) {
if err := agg.ReportUsage(context.Background(), usage.Report{
DPUUID: "dp-days", BytesUp: 10, Minutes: minutes, At: at,
}); err != nil {
t.Fatalf("report: %v", err)
}
}
must(day1, 3)
must(day1.Add(10*time.Minute), 2) // still 06-12
must(day2, 7) // 06-13
check := func(date string, wantMin int) {
var m int
err := db.QueryRow(`SELECT minutes_used FROM usage_daily WHERE user_id=? AND date=?`, uid, date).Scan(&m)
if err != nil {
t.Fatalf("query %s: %v", date, err)
}
if m != wantMin {
t.Errorf("date %s minutes=%d, want %d", date, m, wantMin)
}
}
check("2026-06-12", 5)
check("2026-06-13", 7)
}
func TestIntAggregateUnknownDPUUIDDropped(t *testing.T) {
db := setupMySQL(t)
store := usage.NewStore(db)
agg := usage.NewAggregator(store, nil, time.Minute, time.Minute)
err := agg.ReportUsage(context.Background(), usage.Report{
DPUUID: "does-not-exist", BytesUp: 1, Minutes: 1,
})
if err != nil {
t.Fatalf("unknown dp_uuid should be a silent no-op, got %v", err)
}
var n int
db.QueryRow(`SELECT COUNT(*) FROM usage_daily`).Scan(&n)
if n != 0 {
t.Errorf("expected no rows for unknown dp_uuid, got %d", n)
}
}
// --------------------------------------------------------------------------
// Quota (CheckFreeConnect)
// --------------------------------------------------------------------------
func TestIntQuotaFreeNotUnlocked(t *testing.T) {
db := setupMySQL(t)
svc := usage.NewService(usage.NewStore(db), nil, fakeVerifier{ok: true}, time.Hour)
uid := createUser(t, db, "dp-q1") // no subscription → free plan
_, apiErr := svc.CheckFreeConnect(context.Background(), uid)
if apiErr == nil || apiErr.Code != "AD_NOT_UNLOCKED" {
t.Fatalf("expected AD_NOT_UNLOCKED, got %v", apiErr)
}
if apiErr.MessageZH == "" || apiErr.MessageEn == "" {
t.Error("expected bilingual error messages")
}
}
func TestIntQuotaFreeUnlockedRemainingDecrements(t *testing.T) {
db := setupMySQL(t)
store := usage.NewStore(db)
agg := usage.NewAggregator(store, nil, time.Minute, time.Minute)
svc := usage.NewService(store, nil, fakeVerifier{ok: true}, time.Hour)
uid := createUser(t, db, "dp-q2")
if _, err := store.MarkAdUnlocked(context.Background(), uid, time.Now().UTC()); err != nil {
t.Fatalf("unlock: %v", err)
}
rem, apiErr := svc.CheckFreeConnect(context.Background(), uid)
if apiErr != nil {
t.Fatalf("unexpected err: %v", apiErr)
}
if rem != 10 {
t.Errorf("remaining=%d, want 10", rem)
}
// Consume 4 minutes.
agg.ReportUsage(context.Background(), usage.Report{DPUUID: "dp-q2", Minutes: 4})
rem, _ = svc.CheckFreeConnect(context.Background(), uid)
if rem != 6 {
t.Errorf("after 4 min: remaining=%d, want 6", rem)
}
}
func TestIntQuotaFreeExhausted(t *testing.T) {
db := setupMySQL(t)
store := usage.NewStore(db)
svc := usage.NewService(store, nil, fakeVerifier{ok: true}, time.Hour)
uid := createUser(t, db, "dp-q3")
store.MarkAdUnlocked(context.Background(), uid, time.Now().UTC())
store.AggregateUsage(context.Background(), uid, time.Now().UTC(), 0, 0, 10)
_, apiErr := svc.CheckFreeConnect(context.Background(), uid)
if apiErr == nil || apiErr.Code != "QUOTA_EXHAUSTED" {
t.Fatalf("expected QUOTA_EXHAUSTED, got %v", apiErr)
}
}
func TestIntQuotaPaidUnlimited(t *testing.T) {
db := setupMySQL(t)
store := usage.NewStore(db)
svc := usage.NewService(store, nil, fakeVerifier{ok: true}, time.Hour)
for _, plan := range []string{"pro", "team"} {
uid := createUser(t, db, "dp-"+plan)
giveSubscription(t, db, uid, plan)
// Even with lots of minutes used, paid plans are not gated.
store.AggregateUsage(context.Background(), uid, time.Now().UTC(), 0, 0, 9999)
rem, apiErr := svc.CheckFreeConnect(context.Background(), uid)
if apiErr != nil {
t.Fatalf("%s: unexpected err %v", plan, apiErr)
}
if rem != usage.Unlimited {
t.Errorf("%s: remaining=%d, want Unlimited(%d)", plan, rem, usage.Unlimited)
}
}
}
// --------------------------------------------------------------------------
// Ads unlock
// --------------------------------------------------------------------------
func TestIntAdsUnlockForgedRejected(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: false}, time.Hour)
uid := createUser(t, db, "dp-ad1")
_, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "forged-token")
if apiErr == nil || apiErr.Code != "AD_VERIFY_FAILED" {
t.Fatalf("expected AD_VERIFY_FAILED, got %v", apiErr)
}
}
func TestIntAdsUnlockReplayRejected(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour)
uid := createUser(t, db, "dp-ad2")
already, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz")
if apiErr != nil || already {
t.Fatalf("first unlock: already=%v err=%v", already, apiErr)
}
// Same token again → replay.
_, apiErr = svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz")
if apiErr == nil || apiErr.Code != "AD_TOKEN_REPLAY" {
t.Fatalf("expected AD_TOKEN_REPLAY, got %v", apiErr)
}
}
func TestIntAdsUnlockSecondTimeIdempotent(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour)
uid := createUser(t, db, "dp-ad3")
if _, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-1"); apiErr != nil {
t.Fatalf("first unlock: %v", apiErr)
}
// Different (valid) token, same day → idempotent success.
already, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-2")
if apiErr != nil {
t.Fatalf("second unlock err: %v", apiErr)
}
if !already {
t.Error("expected already-unlocked idempotent success")
}
// Exactly one unlock timestamp.
var n int
db.QueryRow(`SELECT COUNT(*) FROM usage_daily WHERE user_id=? AND ad_unlocked_at IS NOT NULL`, uid).Scan(&n)
if n != 1 {
t.Errorf("expected 1 unlocked day, got %d", n)
}
}
// --------------------------------------------------------------------------
// Usage curve & today summary
// --------------------------------------------------------------------------
func TestIntUsageCurveZeroFill(t *testing.T) {
db := setupMySQL(t)
store := usage.NewStore(db)
svc := usage.NewService(store, nil, nil, time.Hour)
uid := createUser(t, db, "dp-curve")
today := time.Now().UTC()
// Only two of the last 7 days have data.
store.AggregateUsage(context.Background(), uid, today, 1000, 2000, 5)
store.AggregateUsage(context.Background(), uid, today.AddDate(0, 0, -3), 50, 60, 2)
points, apiErr := svc.UsageCurve(context.Background(), uid, 7)
if apiErr != nil {
t.Fatalf("curve: %v", apiErr)
}
if len(points) != 7 {
t.Fatalf("expected 7 points, got %d", len(points))
}
// Oldest first, dates contiguous.
for i := 1; i < len(points); i++ {
if points[i].Date <= points[i-1].Date {
t.Errorf("points not ascending: %s then %s", points[i-1].Date, points[i].Date)
}
}
// Last point = today with the data.
last := points[len(points)-1]
if last.BytesUp != 1000 || last.BytesDown != 2000 || last.MinutesUsed != 5 {
t.Errorf("today point wrong: %+v", last)
}
// Days with no data are zero-filled.
zeros := 0
for _, p := range points {
if p.BytesUp == 0 && p.BytesDown == 0 && p.MinutesUsed == 0 {
zeros++
}
}
if zeros != 5 {
t.Errorf("expected 5 zero-filled days, got %d", zeros)
}
}
func TestIntUsageCurveOnlyCurrentUser(t *testing.T) {
db := setupMySQL(t)
store := usage.NewStore(db)
svc := usage.NewService(store, nil, nil, time.Hour)
uA := createUser(t, db, "dp-A")
uB := createUser(t, db, "dp-B")
store.AggregateUsage(context.Background(), uA, time.Now().UTC(), 111, 0, 1)
store.AggregateUsage(context.Background(), uB, time.Now().UTC(), 999, 0, 9)
points, _ := svc.UsageCurve(context.Background(), uA, 1)
if len(points) != 1 || points[0].BytesUp != 111 {
t.Errorf("user A curve leaked other user data: %+v", points)
}
}
func TestIntTodaySummary(t *testing.T) {
db := setupMySQL(t)
store := usage.NewStore(db)
svc := usage.NewService(store, nil, nil, time.Hour)
// Free user: limited + ad flag.
uFree := createUser(t, db, "dp-sf")
store.MarkAdUnlocked(context.Background(), uFree, time.Now().UTC())
store.AggregateUsage(context.Background(), uFree, time.Now().UTC(), 0, 0, 3)
sum, apiErr := svc.TodaySummary(context.Background(), uFree)
if apiErr != nil {
t.Fatalf("summary: %v", apiErr)
}
if sum.MinutesUsed != 3 || sum.MinutesRemaining == nil || *sum.MinutesRemaining != 7 || !sum.AdUnlocked {
t.Errorf("free summary wrong: %+v (remaining=%v)", sum, sum.MinutesRemaining)
}
// Pro user: unlimited (nil remaining).
uPro := createUser(t, db, "dp-sp")
giveSubscription(t, db, uPro, "pro")
sum2, _ := svc.TodaySummary(context.Background(), uPro)
if sum2.MinutesRemaining != nil {
t.Errorf("pro remaining should be nil(unlimited), got %v", *sum2.MinutesRemaining)
}
}
+436 -120
View File
@@ -131,6 +131,37 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
}
.reject-date { color: #ef9999; font-size: 12px; }
/* ── 确认闸(方案/改动说明)── */
.gate-block { margin: 10px 0 4px; padding: 10px 14px; border-radius: 8px; font-size: 13px; }
.gate-pending { background: #fff7ed; border: 1px solid #fdba74; }
.gate-granted { background: #f0fdf4; border: 1px solid #bbf7d0; }
.gate-info { background: #f1f5f9; border: 1px solid #e2e8f0; }
.gate-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.gate-badge { padding: 2px 9px; border-radius: 10px; font-size: 11.5px; font-weight: 700; white-space: nowrap; }
.gate-badge.pending { background: #f97316; color: #fff; }
.gate-badge.granted { background: #22c55e; color: #fff; }
.gate-badge.info { background: #94a3b8; color: #fff; }
.gate-kind { font-size: 12px; color: #52606d; }
.gate-date { font-size: 12px; color: #8aa3c4; margin-left: auto; }
.gate-note { color: #52606d; margin: 4px 0; white-space: pre-wrap; }
.gate-ref { font-size: 12.5px; color: #52606d; margin-top: 4px; }
.gate-note code, .gate-ref code {
background: #fff; padding: 1px 5px; border-radius: 3px;
font-family: "JetBrains Mono", monospace; font-size: 12px; color: #b91c1c;
}
.approve-btn {
margin-top: 8px; padding: 5px 14px; border-radius: 6px; border: none;
background: #16a34a; color: #fff; font-size: 12.5px; font-weight: 600; cursor: pointer;
transition: background .15s;
}
.approve-btn:hover { background: #15803d; }
.approve-cmd { margin-top: 10px; padding: 10px 12px; background: #1e293b; border-radius: 8px; }
.approve-cmd-label { font-size: 12px; color: #94a3b8; margin-bottom: 6px; }
.approve-cmd-code { font-family: "JetBrains Mono", monospace; font-size: 13px; color: #86efac; display: block; word-break: break-all; }
.approve-copy-btn { margin-top: 8px; padding: 4px 12px; border-radius: 6px; background: #334155; color: #e2e8f0; border: none; font-size: 12px; cursor: pointer; }
.approve-copy-btn:hover { background: #475569; }
.stat-pill.gate-stat { background: #f97316; }
/* ── 子任务区 ── */
.subtask-block {
margin-top: 12px; padding: 10px 14px;
@@ -221,13 +252,14 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<header>
<div class="wrap">
<h1>doc — 项目 TODO</h1>
<div class="header-meta">生成于 2026-06-11 · 真相源 todo/todo.json</div>
<div class="header-meta">生成于 2026-06-12 · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>18</strong>全部</div>
<div class="stat-pill"><strong>18</strong>待开始</div>
<div class="stat-pill"><strong>0</strong>开发中</div>
<div class="stat-pill"><strong>14</strong>待开始</div>
<div class="stat-pill"><strong>4</strong>开发中</div>
<div class="stat-pill"><strong>0</strong>待验收</div>
<div class="stat-pill"><strong>0</strong>已验收</div>
<div class="stat-pill gate-stat"><strong>4</strong>待确认</div>
</div>
</div>
</header>
@@ -269,68 +301,12 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="section-block" id="section-open">
<div class="section-title st-open" data-toggle="open">
📋 待开始 <span class="s-count">18</span>
📋 待开始 <span class="s-count">14</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-open">
<ul class="todo-list" id="list-open">
<li class="todo-card s-open"
data-id="1"
data-level="high"
data-status="open"
data-tier="1"
data-tags="后端,数据库">
<div class="card-header">
<span class="item-title">OpenAPI 契约 + MySQL migration 基线</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
</div>
</div>
<div class="item-desc">依据 doc/02、doc/03openapi.yaml 展开全部 /v1 契约(connect 返回 sing-box 凭证而非 WG peer);MySQL 8 全量 DDLusers/devices/plans/subscriptions/codes/usage_daily/audit_log + providers/node_events/directory_version)。后端各模块的共同前置。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-11</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="5"
data-level="high"
data-status="open"
data-tier="1"
data-tags="后端">
<div class="card-header">
<span class="item-title">nodes 模块 + agent gRPC 协议设计</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
</div>
</div>
<div class="item-desc">proto 定义(注册/心跳/凭证下发与回收/用量上报,mTLS 双向)、节点目录 version 灰度(if_version 304)、connect/disconnect 下发 REALITY/Hy2 参数、free 凭证 TTL。依赖 #1。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-11</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="9"
data-level="high"
@@ -350,6 +326,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">用 design/flutter/ 起步包建工程:pubspec 覆盖、theme/widgets/main 拷入、字体配置,flutter run 出登录→引导→主框架演示态。前端线起点,可与后端完全并行。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -359,62 +336,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
</li>
<li class="todo-card s-open"
data-id="11"
data-level="high"
data-status="open"
data-tier="1"
data-tags="前端,iOS,Android,mac,Windows">
<div class="card-header">
<span class="item-title">sing-box libbox 桥接 PoC(三端隧道)</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
</div>
</div>
<div class="item-desc">gomobile AAR/XCFramework + iOS NetworkExtension / Android VpnService / 桌面 TUN 子进程;URLTest 智能选线、Kill-switch。全项目最大技术风险,应尽早并行启动。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="Windows">Windows</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-11</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="15"
data-level="high"
data-status="open"
data-tier="1"
data-tags="后端">
<div class="card-header">
<span class="item-title">探针判封 + 自动更换 scheduler</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
</div>
</div>
<div class="item-desc">境内多 ISP 拨测 + 境外对照 + 流量骤降三路互证;blocked_suspect/confirmed 状态机、阈值与熔断、池水位告警、自动补新。依赖 #5、#14。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-11</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="2"
data-level="mid"
@@ -434,6 +355,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">邮箱验证码限频、注册自动 7 天 PRO 试用、argon2id、JWT RS256access 15min + refresh 30d)、Redis 滑窗限流。依赖 #1。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -462,6 +384,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">批量生成(Crockford Base32 + 校验位,库存 hash)、发卡店 webhook(HMAC)、兑换幂等 + 订阅顺延叠加、失败锁定、audit_log。依赖 #1。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -490,6 +413,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">设备登记/移除(同步回收节点侧凭证)、套餐/到期/设备数校验中间件。依赖 #1、#2。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -518,6 +442,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">轻量 agentmTLS 自注册、心跳、sing-box 用户表增删(凭证下发/回收/TTL)、负载上报 Rediscloud-init 一段式安装。依赖 #5 的 proto。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -546,6 +471,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">usage_daily 聚合(仅字节/分钟,无日志口径)、/v1/ads/unlock 验广告 SDK 回执、free 用户 connect 额度校验。依赖 #1。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -574,6 +500,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">对照 ui_kits/mobile + tablet 逐屏补齐:智能选择推荐卡、免费额度卡+看广告解锁、Tab 滑动切换、宽度 ≥900 切侧栏分栏(连接页双栏/节点双列网格);四态验证(明暗×中英)。依赖 #9。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -602,6 +529,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">ui_kits/website → Astro SSG + Cloudflare Pages 多镜像;保留 i18n 单显、响应式断点、全部脱敏文案。完全独立可并行。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
<div class="item-meta">
@@ -630,6 +558,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">ui_kits/usercenter React 组件直接复用;先 mock 数据,后接 me/redeem/devicesTOTP 2FA。UI 部分独立并行,接口联调依赖 #2。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
<div class="item-meta">
@@ -658,6 +587,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">多厂商 API 适配层(providers 池)、cloud-init 模板、replace 一键更换(make-before-break)与全量滚动轮换、换 IP 不换机。依赖 #6 的 agent。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="CI/CD">CI/CD</span> <span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -686,6 +616,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">四组域名隔离与冷备池、源站隐藏(回源鉴权)、Ed25519 签名端点更新与公告 JSON 多镜像、客户端 DoH/IP 直连兜底联动。可独立并行。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -714,6 +645,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">xtrabackup 每日全量 + binlog 15min 增量、age 加密异地(独立身份账号)、月度恢复演练脚本、RPO≤15min/RTO≤4h 验证。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="数据库">数据库</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
<div class="item-meta">
@@ -742,6 +674,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">码批次生成导出、节点操作(replace/draining)、audit_log 查看;独立监听 + 内网白名单 + 2FA。依赖 #1。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -770,6 +703,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">lint + 单测 + OpenAPI 同步校验 + 脱敏红线词扫描(UI 文案资源)+ 容器镜像构建。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
<div class="item-meta">
@@ -783,12 +717,353 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
<div class="section-block" id="section-doing">
<div class="section-title st-doing" data-toggle="doing">
🔨 开发中 <span class="s-count">0</span>
🔨 开发中 <span class="s-count">4</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-doing">
<ul class="todo-list" id="list-doing">
<p class="empty-tip">暂无条目</p>
<li class="todo-card s-doing"
data-id="1"
data-level="high"
data-status="doing"
data-tier="1"
data-tags="后端,数据库">
<div class="card-header">
<span class="item-title">OpenAPI 契约 + MySQL migration 基线</span>
<div class="card-badges">
<span class="tag status-badge s-doing">开发中</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
<span class="tag sub-progress-badge">0/6 子任务</span>
</div>
</div>
<div class="item-desc">依据 doc/02、doc/03openapi.yaml 展开全部 /v1 契约(connect 返回 sing-box 凭证而非 WG peer);MySQL 8 全量 DDLusers/devices/plans/subscriptions/codes/usage_daily/audit_log + providers/node_events/directory_version)。后端各模块的共同前置。</div>
<div class="gate-block gate-pending">
<div class="gate-head"><span class="gate-badge pending">⏸ 待确认</span>
<span class="gate-kind">一级方案规划 · 确认后方可继续开发</span></div>
<div class="gate-note">openapi.yaml 13 端点 + MySQL 12 表 migration + plans seedgolang-migrate/oapi-codegen/chiconnect 返回 sing-box 凭证</div><div class="gate-ref">📄 详见 <code>doc/plans/01-openapi-mysql.md</code></div>
<button class="approve-btn" data-id="1">确认执行 ✓</button>
<div class="approve-cmd" id="approve-cmd-1" style="display:none">
<div class="approve-cmd-label">在终端运行:</div>
<code class="approve-cmd-code">/todo approve 1</code>
<button class="approve-copy-btn" data-cmd="/todo approve 1">复制命令</button>
</div>
</div>
<div class="subtask-block">
<div class="subtask-header">
<span class="subtask-label">子任务</span>
<span class="subtask-progress-text">0 / 6 完成</span>
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:0%"></div></div>
</div>
<ul class="subtask-list"><li class="subtask-item s-open" data-sid="1A">
<span class="sub-icon s-open"></span>
<span class="sub-sid">1A</span>
<span class="sub-title">Go 模块骨架 + 工具链(go.mod/tools.go/Makefile)</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span></div>
</li>
<li class="subtask-item s-open" data-sid="1B">
<span class="sub-icon s-open"></span>
<span class="sub-sid">1B</span>
<span class="sub-title">MySQL migration 7 文件 + plans seed</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span></div>
</li>
<li class="subtask-item s-open" data-sid="1C">
<span class="sub-icon s-open"></span>
<span class="sub-sid">1C</span>
<span class="sub-title">openapi.yaml 契约(13 操作 + 全量 schema)</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span></div>
</li>
<li class="subtask-item s-open" data-sid="1D">
<span class="sub-icon s-open"></span>
<span class="sub-sid">1D</span>
<span class="sub-title">oapi-codegen 接线 + 生成代码入库</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="1A 未完成">1A</span><span class="dep-badge dep-pending" title="1C 未完成">1C</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="1E">
<span class="sub-icon s-open"></span>
<span class="sub-sid">1E</span>
<span class="sub-title">config + store(UTC DSN) + 自动迁移</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="1A 未完成">1A</span><span class="dep-badge dep-pending" title="1B 未完成">1B</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="1F">
<span class="sub-icon s-open"></span>
<span class="sub-sid">1F</span>
<span class="sub-title">apierr + idgen + CONVENTIONS.md</span>
<div class="sub-badges"><span class="tag tier-2">二级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="1A 未完成">1A</span></span></div>
</li></ul>
</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-11</span>
</div>
</div>
</li>
<li class="todo-card s-doing"
data-id="5"
data-level="high"
data-status="doing"
data-tier="1"
data-tags="后端">
<div class="card-header">
<span class="item-title">nodes 模块 + agent gRPC 协议设计</span>
<div class="card-badges">
<span class="tag status-badge s-doing">开发中</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
<span class="tag sub-progress-badge">0/5 子任务</span>
</div>
</div>
<div class="item-desc">proto 定义(注册/心跳/凭证下发与回收/用量上报,mTLS 双向)、节点目录 version 灰度(if_version 304)、connect/disconnect 下发 REALITY/Hy2 参数、free 凭证 TTL。依赖 #1。</div>
<div class="gate-block gate-pending">
<div class="gate-head"><span class="gate-badge pending">⏸ 待确认</span>
<span class="gate-kind">一级方案规划 · 确认后方可继续开发</span></div>
<div class="gate-note">agent gRPC proto(buf) + 控制面 nodes 模块;agent 主动拨号、下发服务端流、mTLS 内部CA、目录 version 灰度</div><div class="gate-ref">📄 详见 <code>doc/plans/05-nodes-grpc.md</code></div>
<button class="approve-btn" data-id="5">确认执行 ✓</button>
<div class="approve-cmd" id="approve-cmd-5" style="display:none">
<div class="approve-cmd-label">在终端运行:</div>
<code class="approve-cmd-code">/todo approve 5</code>
<button class="approve-copy-btn" data-cmd="/todo approve 5">复制命令</button>
</div>
</div>
<div class="subtask-block">
<div class="subtask-header">
<span class="subtask-label">子任务</span>
<span class="subtask-progress-text">0 / 5 完成</span>
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:0%"></div></div>
</div>
<ul class="subtask-list"><li class="subtask-item s-open" data-sid="5A">
<span class="sub-icon s-open"></span>
<span class="sub-sid">5A</span>
<span class="sub-title">proto 契约 + buf 工具链(最先冻结)</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span></div>
</li>
<li class="subtask-item s-open" data-sid="5B">
<span class="sub-icon s-open"></span>
<span class="sub-sid">5B</span>
<span class="sub-title">mTLS/CA + 引导 token 框架</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span></div>
</li>
<li class="subtask-item s-open" data-sid="5C">
<span class="sub-icon s-open"></span>
<span class="sub-sid">5C</span>
<span class="sub-title">gRPC server + hub + 跨实例 pub/sub</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="5A 未完成">5A</span><span class="dep-badge dep-pending" title="5B 未完成">5B</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="5D">
<span class="sub-icon s-open"></span>
<span class="sub-sid">5D</span>
<span class="sub-title">目录灰度 + connect/disconnect + 凭证编排</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="5C 未完成">5C</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="5E">
<span class="sub-icon s-open"></span>
<span class="sub-sid">5E</span>
<span class="sub-title">生命周期状态机接口(供 #15)</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="5C 未完成">5C</span></span></div>
</li></ul>
</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-11</span>
</div>
</div>
</li>
<li class="todo-card s-doing"
data-id="11"
data-level="high"
data-status="doing"
data-tier="1"
data-tags="前端,iOS,Android,mac,Windows">
<div class="card-header">
<span class="item-title">sing-box libbox 桥接 PoC(三端隧道)</span>
<div class="card-badges">
<span class="tag status-badge s-doing">开发中</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
<span class="tag sub-progress-badge">0/8 子任务</span>
</div>
</div>
<div class="item-desc">gomobile AAR/XCFramework + iOS NetworkExtension / Android VpnService / 桌面 TUN 子进程;URLTest 智能选线、Kill-switch。全项目最大技术风险,应尽早并行启动。</div>
<div class="gate-block gate-pending">
<div class="gate-head"><span class="gate-badge pending">⏸ 待确认</span>
<span class="gate-kind">一级方案规划 · 确认后方可继续开发</span></div>
<div class="gate-note">gomobile libbox + Platform Channel + 三端壳;首发桌面→Android→iOS;含数据面定稿 11A</div><div class="gate-ref">📄 详见 <code>doc/plans/11-libbox-bridge.md</code></div>
<button class="approve-btn" data-id="11">确认执行 ✓</button>
<div class="approve-cmd" id="approve-cmd-11" style="display:none">
<div class="approve-cmd-label">在终端运行:</div>
<code class="approve-cmd-code">/todo approve 11</code>
<button class="approve-copy-btn" data-cmd="/todo approve 11">复制命令</button>
</div>
</div>
<div class="subtask-block">
<div class="subtask-header">
<span class="subtask-label">子任务</span>
<span class="subtask-progress-text">0 / 8 完成</span>
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:0%"></div></div>
</div>
<ul class="subtask-list"><li class="subtask-item s-open" data-sid="11A">
<span class="sub-icon s-open"></span>
<span class="sub-sid">11A</span>
<span class="sub-title">数据面定稿 + 修订 ARCHITECTURE.md connect 契约</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span></div>
</li>
<li class="subtask-item s-open" data-sid="11B">
<span class="sub-icon s-open"></span>
<span class="sub-sid">11B</span>
<span class="sub-title">app/kernel 构建管线(AAR/XCFramework/桌面二进制)</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span></div>
</li>
<li class="subtask-item s-open" data-sid="11C">
<span class="sub-icon s-open"></span>
<span class="sub-sid">11C</span>
<span class="sub-title">桥接 API 面(Channel 契约 + 三端原生骨架)</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span></div>
</li>
<li class="subtask-item s-open" data-sid="11D">
<span class="sub-icon s-open"></span>
<span class="sub-sid">11D</span>
<span class="sub-title">桌面端 PoC M1(首发打通)</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="11A 未完成">11A</span><span class="dep-badge dep-pending" title="11B 未完成">11B</span><span class="dep-badge dep-pending" title="11C 未完成">11C</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="11E">
<span class="sub-icon s-open"></span>
<span class="sub-sid">11E</span>
<span class="sub-title">Android 端 PoC M2</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="11B 未完成">11B</span><span class="dep-badge dep-pending" title="11C 未完成">11C</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="11F">
<span class="sub-icon s-open"></span>
<span class="sub-sid">11F</span>
<span class="sub-title">iOS 端 PoC M3 + entitlement 申请</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="11B 未完成">11B</span><span class="dep-badge dep-pending" title="11C 未完成">11C</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="11G">
<span class="sub-icon s-open"></span>
<span class="sub-sid">11G</span>
<span class="sub-title">URLTest + Kill-switch + 弹性重连 M4/M5</span>
<div class="sub-badges"><span class="tag tier-2">二级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="11D 未完成">11D</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="11H">
<span class="sub-icon s-open"></span>
<span class="sub-sid">11H</span>
<span class="sub-title">控制面联调 M6</span>
<div class="sub-badges"><span class="tag tier-2">二级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="11A 未完成">11A</span></span></div>
</li></ul>
</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="Windows">Windows</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-11</span>
</div>
</div>
</li>
<li class="todo-card s-doing"
data-id="15"
data-level="high"
data-status="doing"
data-tier="1"
data-tags="后端">
<div class="card-header">
<span class="item-title">探针判封 + 自动更换 scheduler</span>
<div class="card-badges">
<span class="tag status-badge s-doing">开发中</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-1">一级</span>
<span class="tag sub-progress-badge">0/8 子任务</span>
</div>
</div>
<div class="item-desc">境内多 ISP 拨测 + 境外对照 + 流量骤降三路互证;blocked_suspect/confirmed 状态机、阈值与熔断、池水位告警、自动补新。依赖 #5、#14。</div>
<div class="gate-block gate-pending">
<div class="gate-head"><span class="gate-badge pending">⏸ 待确认</span>
<span class="gate-kind">一级方案规划 · 确认后方可继续开发</span></div>
<div class="gate-note">探针汇聚入口 + 三路互证判定引擎 + make-before-break 自动更换 + 熔断;Go 单体 goroutine + Redis 选主</div><div class="gate-ref">📄 详见 <code>doc/plans/15-probe-scheduler.md</code></div>
<button class="approve-btn" data-id="15">确认执行 ✓</button>
<div class="approve-cmd" id="approve-cmd-15" style="display:none">
<div class="approve-cmd-label">在终端运行:</div>
<code class="approve-cmd-code">/todo approve 15</code>
<button class="approve-copy-btn" data-cmd="/todo approve 15">复制命令</button>
</div>
</div>
<div class="subtask-block">
<div class="subtask-header">
<span class="subtask-label">子任务</span>
<span class="subtask-progress-text">0 / 8 完成</span>
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:0%"></div></div>
</div>
<ul class="subtask-list"><li class="subtask-item s-open" data-sid="15A">
<span class="sub-icon s-open"></span>
<span class="sub-sid">15A</span>
<span class="sub-title">探针汇聚入口 + Redis 探针存储</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span></div>
</li>
<li class="subtask-item s-open" data-sid="15B">
<span class="sub-icon s-open"></span>
<span class="sub-sid">15B</span>
<span class="sub-title">轻量探针客户端 L1/L2/L3</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="15A 未完成">15A</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="15C">
<span class="sub-icon s-open"></span>
<span class="sub-sid">15C</span>
<span class="sub-title">第三方拨测 API 适配</span>
<div class="sub-badges"><span class="tag tier-2">二级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="15A 未完成">15A</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="15D">
<span class="sub-icon s-open"></span>
<span class="sub-sid">15D</span>
<span class="sub-title">判定引擎 signals+rules+streak</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="15A 未完成">15A</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="15E">
<span class="sub-icon s-open"></span>
<span class="sub-sid">15E</span>
<span class="sub-title">自动更换编排 + 养机灰度</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="15D 未完成">15D</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="15F">
<span class="sub-icon s-open"></span>
<span class="sub-sid">15F</span>
<span class="sub-title">容量水位 + 熔断器</span>
<div class="sub-badges"><span class="tag tier-2">二级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="15E 未完成">15E</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="15G">
<span class="sub-icon s-open"></span>
<span class="sub-sid">15G</span>
<span class="sub-title">告警出口 TG bot + runbook</span>
<div class="sub-badges"><span class="tag tier-2">二级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="15D 未完成">15D</span></span></div>
</li>
<li class="subtask-item s-open" data-sid="15H">
<span class="sub-icon s-open"></span>
<span class="sub-sid">15H</span>
<span class="sub-title">scheduler 装配 + 选主 + 优雅退出</span>
<div class="sub-badges"><span class="tag tier-1">一级</span><span class="tag status-badge s-open">待开始</span><span class="dep-section"><span class="dep-label">依赖:</span><span class="dep-badge dep-pending" title="15D 未完成">15D</span><span class="dep-badge dep-pending" title="15E 未完成">15E</span></span></div>
</li></ul>
</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-11</span>
</div>
</div>
</li>
</ul>
</div>
</div>
@@ -844,6 +1119,16 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
let curTier = 'all';
let curTags = new Set();
// 通过本地服务(http)打开时,按钮直接写库;用 file:// 直接打开则回退为「复制命令」
const SERVED = location.protocol === 'http:' || location.protocol === 'https:';
function postAction(url, payload) {
return fetch(url, {
method: 'POST',
headers: payload ? { 'Content-Type': 'application/json' } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
}).then(r => r.json());
}
function applyFilter() {
document.querySelectorAll('.todo-card').forEach(card => {
const lvl = card.dataset.level;
@@ -964,8 +1249,13 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
reasonInput.focus();
return;
}
const escaped = reason.replace(/\/g, '\\').replace(/"/g, '\"');
const cmd = '/todo reject ' + rejectId + ' ' + escaped;
if (SERVED) {
postAction('/api/reject/' + rejectId, { reason })
.then(d => { if (d.ok) { location.reload(); } else { alert('拒绝失败:' + (d.error || '')); } })
.catch(() => alert('请求失败,确认本地服务是否在运行'));
return;
}
const cmd = '/todo reject ' + rejectId + ' ' + JSON.stringify(reason);
cmdText.textContent = cmd;
cmdWrap.style.display = 'block';
document.getElementById('reject-confirm-btn').style.display = 'none';
@@ -983,6 +1273,32 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
copyBtn.textContent = '已复制 ✓';
});
});
// ── 确认闸:服务模式直接写库;file:// 模式回退为展开命令 ──
document.querySelectorAll('.approve-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = btn.dataset.id;
if (SERVED) {
btn.disabled = true; btn.textContent = '确认中…';
postAction('/api/approve/' + id)
.then(d => {
if (d.ok) { location.reload(); }
else { btn.disabled = false; btn.textContent = '确认执行 ✓'; alert('确认失败:' + (d.error || '')); }
})
.catch(() => { btn.disabled = false; btn.textContent = '确认执行 ✓'; alert('请求失败,确认本地服务是否在运行'); });
} else {
const box = document.getElementById('approve-cmd-' + id);
if (box) box.style.display = box.style.display === 'none' ? 'block' : 'none';
}
});
});
document.querySelectorAll('.approve-copy-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
navigator.clipboard.writeText(btn.dataset.cmd).then(() => { btn.textContent = '已复制 ✓'; });
});
});
})();
</script>
</body>
+309 -9
View File
@@ -1,7 +1,7 @@
{
"meta": {
"title": "doc — 项目 TODO",
"updated_at": "2026-06-11T15:48:46.142Z"
"updated_at": "2026-06-11T16:33:27.008Z"
},
"seq": 18,
"items": [
@@ -15,11 +15,77 @@
"后端",
"数据库"
],
"status": "open",
"status": "doing",
"created_at": "2026-06-11T15:48:05.656Z",
"done": false,
"completed_at": null,
"version": null
"version": null,
"subtasks": [
{
"sid": "1A",
"title": "Go 模块骨架 + 工具链(go.mod/tools.go/Makefile)",
"tier": 1,
"deps": [],
"status": "open",
"created_at": "2026-06-11T16:14:18.892Z"
},
{
"sid": "1B",
"title": "MySQL migration 7 文件 + plans seed",
"tier": 1,
"deps": [],
"status": "open",
"created_at": "2026-06-11T16:14:18.952Z"
},
{
"sid": "1C",
"title": "openapi.yaml 契约(13 操作 + 全量 schema)",
"tier": 1,
"deps": [],
"status": "open",
"created_at": "2026-06-11T16:14:19.013Z"
},
{
"sid": "1D",
"title": "oapi-codegen 接线 + 生成代码入库",
"tier": 1,
"deps": [
"1A",
"1C"
],
"status": "open",
"created_at": "2026-06-11T16:14:19.070Z"
},
{
"sid": "1E",
"title": "config + store(UTC DSN) + 自动迁移",
"tier": 1,
"deps": [
"1A",
"1B"
],
"status": "open",
"created_at": "2026-06-11T16:14:19.125Z"
},
{
"sid": "1F",
"title": "apierr + idgen + CONVENTIONS.md",
"tier": 2,
"deps": [
"1A"
],
"status": "open",
"created_at": "2026-06-11T16:14:19.182Z"
}
],
"gate": {
"kind": "plan",
"note": "openapi.yaml 13 端点 + MySQL 12 表 migration + plans seedgolang-migrate/oapi-codegen/chiconnect 返回 sing-box 凭证",
"ref": "doc/plans/01-openapi-mysql.md",
"approval": "pending",
"proposed_at": "2026-06-11T16:33:26.856Z",
"approved_at": null
}
},
{
"id": 2,
@@ -75,11 +141,68 @@
"tags": [
"后端"
],
"status": "open",
"status": "doing",
"created_at": "2026-06-11T15:48:05.872Z",
"done": false,
"completed_at": null,
"version": null
"version": null,
"subtasks": [
{
"sid": "5A",
"title": "proto 契约 + buf 工具链(最先冻结)",
"tier": 1,
"deps": [],
"status": "open",
"created_at": "2026-06-11T16:14:19.245Z"
},
{
"sid": "5B",
"title": "mTLS/CA + 引导 token 框架",
"tier": 1,
"deps": [],
"status": "open",
"created_at": "2026-06-11T16:14:19.299Z"
},
{
"sid": "5C",
"title": "gRPC server + hub + 跨实例 pub/sub",
"tier": 1,
"deps": [
"5A",
"5B"
],
"status": "open",
"created_at": "2026-06-11T16:14:19.356Z"
},
{
"sid": "5D",
"title": "目录灰度 + connect/disconnect + 凭证编排",
"tier": 1,
"deps": [
"5C"
],
"status": "open",
"created_at": "2026-06-11T16:14:19.415Z"
},
{
"sid": "5E",
"title": "生命周期状态机接口(供 #15)",
"tier": 1,
"deps": [
"5C"
],
"status": "open",
"created_at": "2026-06-11T16:14:19.471Z"
}
],
"gate": {
"kind": "plan",
"note": "agent gRPC proto(buf) + 控制面 nodes 模块;agent 主动拨号、下发服务端流、mTLS 内部CA、目录 version 灰度",
"ref": "doc/plans/05-nodes-grpc.md",
"approval": "pending",
"proposed_at": "2026-06-11T16:33:26.908Z",
"approved_at": null
}
},
{
"id": 6,
@@ -174,11 +297,99 @@
"mac",
"Windows"
],
"status": "open",
"status": "doing",
"created_at": "2026-06-11T15:48:25.745Z",
"done": false,
"completed_at": null,
"version": null
"version": null,
"subtasks": [
{
"sid": "11A",
"title": "数据面定稿 + 修订 ARCHITECTURE.md connect 契约",
"tier": 1,
"deps": [],
"status": "open",
"created_at": "2026-06-11T16:14:37.334Z"
},
{
"sid": "11B",
"title": "app/kernel 构建管线(AAR/XCFramework/桌面二进制)",
"tier": 1,
"deps": [],
"status": "open",
"created_at": "2026-06-11T16:14:37.389Z"
},
{
"sid": "11C",
"title": "桥接 API 面(Channel 契约 + 三端原生骨架)",
"tier": 1,
"deps": [],
"status": "open",
"created_at": "2026-06-11T16:14:37.447Z"
},
{
"sid": "11D",
"title": "桌面端 PoC M1(首发打通)",
"tier": 1,
"deps": [
"11A",
"11B",
"11C"
],
"status": "open",
"created_at": "2026-06-11T16:14:37.504Z"
},
{
"sid": "11E",
"title": "Android 端 PoC M2",
"tier": 1,
"deps": [
"11B",
"11C"
],
"status": "open",
"created_at": "2026-06-11T16:14:37.561Z"
},
{
"sid": "11F",
"title": "iOS 端 PoC M3 + entitlement 申请",
"tier": 1,
"deps": [
"11B",
"11C"
],
"status": "open",
"created_at": "2026-06-11T16:14:37.616Z"
},
{
"sid": "11G",
"title": "URLTest + Kill-switch + 弹性重连 M4/M5",
"tier": 2,
"deps": [
"11D"
],
"status": "open",
"created_at": "2026-06-11T16:14:37.672Z"
},
{
"sid": "11H",
"title": "控制面联调 M6",
"tier": 2,
"deps": [
"11A"
],
"status": "open",
"created_at": "2026-06-11T16:14:37.731Z"
}
],
"gate": {
"kind": "plan",
"note": "gomobile libbox + Platform Channel + 三端壳;首发桌面→Android→iOS;含数据面定稿 11A",
"ref": "doc/plans/11-libbox-bridge.md",
"approval": "pending",
"proposed_at": "2026-06-11T16:33:26.957Z",
"approved_at": null
}
},
{
"id": 12,
@@ -237,11 +448,100 @@
"tags": [
"后端"
],
"status": "open",
"status": "doing",
"created_at": "2026-06-11T15:48:45.987Z",
"done": false,
"completed_at": null,
"version": null
"version": null,
"subtasks": [
{
"sid": "15A",
"title": "探针汇聚入口 + Redis 探针存储",
"tier": 1,
"deps": [],
"status": "open",
"created_at": "2026-06-11T16:14:37.789Z"
},
{
"sid": "15B",
"title": "轻量探针客户端 L1/L2/L3",
"tier": 1,
"deps": [
"15A"
],
"status": "open",
"created_at": "2026-06-11T16:14:37.845Z"
},
{
"sid": "15C",
"title": "第三方拨测 API 适配",
"tier": 2,
"deps": [
"15A"
],
"status": "open",
"created_at": "2026-06-11T16:14:37.900Z"
},
{
"sid": "15D",
"title": "判定引擎 signals+rules+streak",
"tier": 1,
"deps": [
"15A"
],
"status": "open",
"created_at": "2026-06-11T16:14:37.959Z"
},
{
"sid": "15E",
"title": "自动更换编排 + 养机灰度",
"tier": 1,
"deps": [
"15D"
],
"status": "open",
"created_at": "2026-06-11T16:14:38.015Z"
},
{
"sid": "15F",
"title": "容量水位 + 熔断器",
"tier": 2,
"deps": [
"15E"
],
"status": "open",
"created_at": "2026-06-11T16:14:38.072Z"
},
{
"sid": "15G",
"title": "告警出口 TG bot + runbook",
"tier": 2,
"deps": [
"15D"
],
"status": "open",
"created_at": "2026-06-11T16:14:38.130Z"
},
{
"sid": "15H",
"title": "scheduler 装配 + 选主 + 优雅退出",
"tier": 1,
"deps": [
"15D",
"15E"
],
"status": "open",
"created_at": "2026-06-11T16:14:38.187Z"
}
],
"gate": {
"kind": "plan",
"note": "探针汇聚入口 + 三路互证判定引擎 + make-before-break 自动更换 + 熔断;Go 单体 goroutine + Redis 选主",
"ref": "doc/plans/15-probe-scheduler.md",
"approval": "pending",
"proposed_at": "2026-06-11T16:33:27.007Z",
"approved_at": null
}
},
{
"id": 16,