merge: maestro/tsk_tFMU7-hKzfOf [tsk_tFMU7-hKzfOf] codes 激活码模块

解决与 1A 骨架的冲突:module 统一为 github.com/wangjia/pangolin/server
(codes 分支原用 pangolin/server,7 个源文件 import 已改写);
go.mod require 并集(redis 取 9.20.1);Makefile 以骨架为基底并入
build-codegen/test-unit/test-integration target。
go build/vet 通过,codes 单测通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 03:03:11 +08:00
18 changed files with 2851 additions and 20 deletions
+19 -10
View File
@@ -1,30 +1,39 @@
.PHONY: build test vet lint generate migrate-up migrate-down
.PHONY: build test test-unit test-integration vet lint generate migrate-up migrate-down build-codegen deps
# ── deps ───────────────────────────────────────────────────────────────────────
deps:
go mod tidy
go mod download
# ── build ──────────────────────────────────────────────────────────────────────
build:
go build ./...
build-codegen: ## 激活码批次生成 CLI
go build -o bin/codegen ./cmd/codegen
# ── test ───────────────────────────────────────────────────────────────────────
test:
go test ./...
# ── vet ────────────────────────────────────────────────────────────────────────
test-unit: ## 单测(不依赖外部服务)
go test -count=1 -race ./internal/codes/... -run 'Test[^I][^n][^t]'
test-integration: ## 集成测试(需本机 dockertestcontainers
go test -count=1 ./internal/codes/... -run TestInt
# ── vet / lint ─────────────────────────────────────────────────────────────────
vet:
go vet ./...
# ── lint ───────────────────────────────────────────────────────────────────────
lint:
golangci-lint run ./...
# ── generate ───────────────────────────────────────────────────────────────────
# Populated in task 1d: runs oapi-codegen against openapi.yaml
generate:
@echo "generate: not yet configured (see task 1d)"
# ── migrate-up / migrate-down ──────────────────────────────────────────────────
# Populated in task 1e: runs golang-migrate against the Postgres DSN
# ── migrate ────────────────────────────────────────────────────────────────────
migrate-up:
@echo "migrate-up: not yet configured (see task 1e)"
@echo "migrate-up: see cmd/migrate"
migrate-down:
@echo "migrate-down: not yet configured (see task 1e)"
@echo "migrate-down: see cmd/migrate"
+137
View File
@@ -0,0 +1,137 @@
// Command codegen is a CLI transition tool for batch-generating activation codes
// before the admin panel (task #8) is ready.
//
// Usage:
//
// codegen -plan=pro -days=30 -count=100 -channel=manual \
// -note="telegram batch june" -dsn="root:pass@tcp(127.0.0.1:3306)/pangolin?parseTime=true" \
// [-out=codes.csv]
//
// The generated plaintext codes are written to stdout (or -out) as a CSV file.
// They are NEVER logged or stored in the database.
// The database receives only the SHA-256 hashes.
//
// Run `codegen -help` for full flag documentation.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/wangjia/pangolin/server/internal/codes"
"github.com/wangjia/pangolin/server/internal/db"
)
func main() {
plan := flag.String("plan", "", "Plan code: free | pro | team (required)")
days := flag.Int("days", 0, "Duration in days the code grants (required, >0)")
count := flag.Int("count", 0, "Number of codes to generate (required, >0)")
channel := flag.String("channel", "manual", "Distribution channel: store | tg | line | manual")
note := flag.String("note", "", "Human-readable batch note (optional)")
dsn := flag.String("dsn", "", "MySQL DSN, e.g. user:pass@tcp(host:port)/dbname?parseTime=true (required, or set DB_DSN env)")
out := flag.String("out", "", "Output CSV file path (default: stdout)")
createdBy := flag.String("by", "cli", "Identifier of the operator creating this batch")
flag.Parse()
// Resolve DSN from flag or environment.
dsnVal := *dsn
if dsnVal == "" {
dsnVal = os.Getenv("DB_DSN")
}
// Validation.
if dsnVal == "" {
log.Fatal("codegen: -dsn or DB_DSN is required")
}
if *plan == "" {
log.Fatal("codegen: -plan is required")
}
if *days <= 0 {
log.Fatal("codegen: -days must be > 0")
}
if *count <= 0 {
log.Fatal("codegen: -count must be > 0")
}
planCode := codes.PlanCode(*plan)
switch planCode {
case codes.PlanFree, codes.PlanPro, codes.PlanTeam:
default:
log.Fatalf("codegen: unknown plan %q; must be free | pro | team", *plan)
}
ch := codes.BatchChannel(*channel)
switch ch {
case codes.ChannelStore, codes.ChannelTG, codes.ChannelLine, codes.ChannelManual:
default:
log.Fatalf("codegen: unknown channel %q; must be store | tg | line | manual", *channel)
}
// Open database.
dbConn, err := db.Open(dsnVal)
if err != nil {
log.Fatalf("codegen: database: %v", err)
}
defer dbConn.Close()
store := codes.NewStore(dbConn)
// Service is created without Redis (batch generation doesn't need rate limiting).
svc := codes.NewService(store, nil, 5, time.Hour)
log.Printf("codegen: generating %d %s/%dd codes for channel=%s …", *count, planCode, *days, ch)
result, err := svc.CreateBatch(context.Background(), codes.BatchRequest{
PlanCode: planCode,
DurationDays: *days,
Count: *count,
Channel: ch,
Note: *note,
CreatedBy: *createdBy,
})
if err != nil {
log.Fatalf("codegen: CreateBatch: %v", err)
}
log.Printf("codegen: batch %d created; writing CSV …", result.BatchID)
// Determine output writer.
output := os.Stdout
if *out != "" {
f, err := os.Create(*out)
if err != nil {
log.Fatalf("codegen: open output file: %v", err)
}
defer f.Close()
output = f
}
csvRows := make([]codes.CSVRow, len(result.Codes))
now := time.Now().UTC()
for i, c := range result.Codes {
csvRows[i] = codes.CSVRow{
Index: i + 1,
Code: c,
Plan: planCode,
DurationDays: *days,
BatchID: result.BatchID,
Channel: ch,
GeneratedAt: now,
}
}
if err := codes.ExportCSV(output, csvRows); err != nil {
log.Fatalf("codegen: ExportCSV: %v", err)
}
if *out != "" {
fmt.Printf("codegen: %d codes written to %s\n", *count, *out)
fmt.Printf("codegen: ⚠️ Treat this file as a secret — it contains plaintext activation codes.\n")
} else {
fmt.Fprintf(os.Stderr, "codegen: %d codes written to stdout\n", *count)
fmt.Fprintf(os.Stderr, "codegen: ⚠️ Treat the output as a secret — it contains plaintext activation codes.\n")
}
}
+45 -3
View File
@@ -5,9 +5,13 @@ go 1.25.0
require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/go-chi/chi/v5 v5.2.1
github.com/go-sql-driver/mysql v1.8.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/oapi-codegen/oapi-codegen/v2 v2.7.1
github.com/redis/go-redis/v9 v9.20.1
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
google.golang.org/grpc v1.81.1
)
@@ -22,11 +26,14 @@ require (
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/spanner v1.85.0 // indirect
cloud.google.com/go/storage v1.56.0 // indirect
dario.cat/mergo v1.0.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/99designs/keyring v1.2.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.16 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
@@ -37,6 +44,7 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/apache/arrow/go/v10 v10.0.1 // indirect
github.com/apache/thrift v0.16.0 // indirect
@@ -54,13 +62,23 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11 // indirect
github.com/aws/smithy-go v1.13.3 // indirect
github.com/cenkalti/backoff/v4 v4.1.2 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/cockroachdb/cockroach-go/v2 v2.1.1 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 // indirect
github.com/danieljoos/wincred v1.1.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.3.3+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/dvsekhvalnov/jose2go v1.7.0 // indirect
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 // indirect
@@ -68,18 +86,20 @@ require (
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/form3tech-oss/jwt-go v3.2.5+incompatible // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.1 // indirect
github.com/getkin/kin-openapi v0.135.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/goccy/go-json v0.9.11 // indirect
github.com/gocql/gocql v0.0.0-20210515062232-b7ef815b4556 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // 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
@@ -111,10 +131,12 @@ require (
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/asmfmt v1.3.2 // indirect
github.com/klauspost/compress v1.15.11 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/ktrysmt/go-bitbucket v0.6.4 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.6 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
@@ -123,26 +145,44 @@ require (
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect
github.com/mitchellh/mapstructure v1.1.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/mutecomm/go-sqlcipher/v4 v4.4.0 // indirect
github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8 // indirect
github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba // indirect
github.com/oasdiff/yaml v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.9 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4/v4 v4.1.16 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rqlite/gorqlite v0.0.0-20230708021416-2acd02b70b79 // indirect
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/snowflakedb/gosnowflake v1.6.19 // indirect
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
github.com/speakeasy-api/openapi v1.19.2 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
github.com/xanzy/go-gitlab v0.15.0 // indirect
@@ -151,6 +191,7 @@ require (
github.com/xdg-go/stringprep v1.0.3 // indirect
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b // indirect
go.mongodb.org/mongo-driver v1.7.5 // indirect
@@ -160,6 +201,7 @@ require (
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
+88 -7
View File
@@ -616,13 +616,19 @@ cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoIS
cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M=
cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA=
cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o=
github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 h1:rTnT/Jrcm+figWlYz4Ixzt0SJVR2cMC8lvZcimipiEY=
@@ -737,8 +743,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.1.2 h1:6Yo7N8UP2K6LWZnW94DLVSSrbobcWdVzAYOisuDPIFo=
github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
@@ -776,10 +782,18 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
@@ -788,6 +802,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
@@ -841,8 +857,9 @@ github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzP
github.com/form3tech-oss/jwt-go v3.2.5+incompatible h1:/l4kBbb4/vGSsdtB5nUe8L7B9mImVMaBPw9L/0TBHU8=
github.com/form3tech-oss/jwt-go v3.2.5+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/fsouza/fake-gcs-server v1.17.0 h1:OeH75kBZcZa3ZE+zz/mFdJ2btt9FgqfjI7gIh9+5fvk=
github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
@@ -871,6 +888,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
@@ -879,9 +898,12 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M=
github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
@@ -973,6 +995,7 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
@@ -1040,9 +1063,12 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
@@ -1150,13 +1176,14 @@ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uia
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.15.11 h1:Lcadnb3RKGin4FYM/orgq0qde+nc15E5Cbqg4B9Sx9c=
github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -1185,9 +1212,13 @@ github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@@ -1219,6 +1250,18 @@ github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQz
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
@@ -1288,6 +1331,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
@@ -1312,6 +1357,12 @@ github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZ
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
@@ -1336,8 +1387,9 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -1348,10 +1400,21 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.34.0 h1:5fbgF0vIN5u+nD3IWabQwRybuB4GY8G2HHgCkbMzMHo=
github.com/testcontainers/testcontainers-go v0.34.0/go.mod h1:6P/kMkQe8yqPHfPWNulFGdFHTD8HB2vLq/231xY2iPQ=
github.com/testcontainers/testcontainers-go/modules/mysql v0.34.0 h1:Tqz17mGXjPORHFS/oBUGdeJyIsZXLsVVHRhaBqhewGI=
github.com/testcontainers/testcontainers-go/modules/mysql v0.34.0/go.mod h1:hDpm3DLfjo7rd6232wWflEBDGr6Ow9ys43mJTiJwWx8=
github.com/testcontainers/testcontainers-go/modules/redis v0.34.0 h1:HkkKZPi6W2I+ywqplvnKOYRBKXQgpdxErBbdgx8F8nw=
github.com/testcontainers/testcontainers-go/modules/redis v0.34.0/go.mod h1:iUkbN75F4E8WC5C1MfHbGOHOuKU7gOJfHjtwMT8G9QE=
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
@@ -1379,6 +1442,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
@@ -1408,6 +1473,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6h
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
@@ -1421,6 +1490,8 @@ go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLh
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
@@ -1658,6 +1729,7 @@ golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1682,6 +1754,7 @@ golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1729,12 +1802,16 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
@@ -1822,6 +1899,7 @@ golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
@@ -1832,6 +1910,7 @@ golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
@@ -2170,6 +2249,8 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg=
gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.21.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+85
View File
@@ -0,0 +1,85 @@
// Package apierr defines the bilingual error response type used across all API modules.
// Error messages follow the desensitisation rules: no "VPN" / "翻墙" / "科学上网" wording.
package apierr
import (
"encoding/json"
"net/http"
)
// Error is the canonical API error body: {code, message_zh, message_en}.
type Error struct {
Code string `json:"code"`
MessageZH string `json:"message_zh"`
MessageEn string `json:"message_en"`
}
func (e *Error) Error() string { return e.Code + ": " + e.MessageEn }
// Standard code-module errors.
var (
ErrInvalidCode = &Error{
Code: "INVALID_CODE",
MessageZH: "激活码格式无效,请检查后重试",
MessageEn: "Invalid code format, please verify and try again",
}
ErrCodeNotFound = &Error{
Code: "CODE_NOT_FOUND",
MessageZH: "激活码无效或已使用",
MessageEn: "Code not found or already used",
}
ErrCodeRedeemed = &Error{
Code: "CODE_REDEEMED",
MessageZH: "该激活码已被其他账户使用",
MessageEn: "This code has already been redeemed by another account",
}
ErrCodeVoid = &Error{
Code: "CODE_VOID",
MessageZH: "该激活码已失效",
MessageEn: "This code is no longer valid",
}
ErrRateLimited = &Error{
Code: "RATE_LIMITED",
MessageZH: "操作过于频繁,请稍后再试",
MessageEn: "Too many attempts, please try again later",
}
ErrLocked = &Error{
Code: "ACCOUNT_LOCKED",
MessageZH: "账户已临时锁定,请1小时后重试",
MessageEn: "Account temporarily locked, please retry in 1 hour",
}
ErrInternal = &Error{
Code: "INTERNAL_ERROR",
MessageZH: "服务器内部错误,请稍后重试",
MessageEn: "Internal server error, please try again later",
}
ErrBadRequest = &Error{
Code: "BAD_REQUEST",
MessageZH: "请求参数有误",
MessageEn: "Invalid request parameters",
}
// Webhook-specific errors.
ErrWebhookSignature = &Error{
Code: "WEBHOOK_INVALID_SIGNATURE",
MessageZH: "签名校验失败",
MessageEn: "Invalid webhook signature",
}
ErrWebhookTimestamp = &Error{
Code: "WEBHOOK_TIMESTAMP_EXPIRED",
MessageZH: "请求时间戳超出允许窗口",
MessageEn: "Webhook timestamp outside allowed window",
}
ErrWebhookReplay = &Error{
Code: "WEBHOOK_REPLAY",
MessageZH: "重复请求已忽略",
MessageEn: "Duplicate webhook request ignored",
}
)
// WriteJSON writes the given status code and error body as JSON.
func WriteJSON(w http.ResponseWriter, status int, e *Error) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(e)
}
+75
View File
@@ -0,0 +1,75 @@
package codes
import (
"encoding/csv"
"io"
"strconv"
"time"
)
// CSVRow represents one row in the export CSV.
// The Code field is the only time the plaintext appears outside the
// generation call; it must be streamed directly to the response writer
// or written to an encrypted file and never logged.
type CSVRow struct {
Index int
Code string // plaintext activation code (canonical form)
Plan PlanCode
DurationDays int
BatchID int64
Channel BatchChannel
GeneratedAt time.Time
}
// ExportCSV writes the given code rows to w in CSV format.
// Columns: index, code, plan, duration_days, batch_id, channel, generated_at_utc
//
// The caller must ensure that w is the final output destination (HTTP response,
// encrypted file, etc.) and that no intermediate buffer retains the data.
func ExportCSV(w io.Writer, rows []CSVRow) error {
cw := csv.NewWriter(w)
// Header row.
if err := cw.Write([]string{
"index", "code", "plan", "duration_days",
"batch_id", "channel", "generated_at_utc",
}); err != nil {
return err
}
for _, r := range rows {
rec := []string{
strconv.Itoa(r.Index),
r.Code,
string(r.Plan),
strconv.Itoa(r.DurationDays),
strconv.FormatInt(r.BatchID, 10),
string(r.Channel),
r.GeneratedAt.UTC().Format(time.RFC3339),
}
if err := cw.Write(rec); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
}
// BatchResultToCSVRows converts a BatchResult to a slice of CSVRow for export.
// generatedAt should be the time.Now() captured immediately after generation.
func BatchResultToCSVRows(br *BatchResult, generatedAt time.Time) []CSVRow {
rows := make([]CSVRow, len(br.Codes))
for i, code := range br.Codes {
rows[i] = CSVRow{
Index: i + 1,
Code: code,
Plan: br.PlanCode,
DurationDays: br.DurationDays,
BatchID: br.BatchID,
Channel: br.Channel,
GeneratedAt: generatedAt,
}
}
return rows
}
+178
View File
@@ -0,0 +1,178 @@
// Package codes implements the activation-code lifecycle:
// batch generation, card-store webhook ingestion, idempotent redemption,
// subscription extension, and CSV export.
//
// Security invariant: plaintext codes are NEVER written to the database or
// to any log. The database stores only SHA-256(canonical_plaintext).
// Plaintext appears exactly once: in the batch-generation response / CSV export.
package codes
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
)
// Crockford Base32 encoding alphabet (32 symbols, excludes I L O U).
const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
// crockfordCheck is the extended 37-symbol check-character alphabet.
// Symbols 031 are identical to crockfordAlphabet; 3236 are *, ~, $, =, U.
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"
// crockfordDecode maps every printable ASCII character to its Crockford
// numeric value (031), or to -1 if the character is not valid.
var crockfordDecode [128]int8
func init() {
for i := range crockfordDecode {
crockfordDecode[i] = -1
}
for i, ch := range crockfordAlphabet {
crockfordDecode[ch] = int8(i)
// Lower-case equivalents.
if ch >= 'A' && ch <= 'Z' {
crockfordDecode[ch-'A'+'a'] = int8(i)
}
}
// Normalisation rules per Crockford spec:
// I, i, l, L → 1
// O, o → 0
crockfordDecode['I'] = crockfordDecode['1']
crockfordDecode['i'] = crockfordDecode['1']
crockfordDecode['l'] = crockfordDecode['1']
crockfordDecode['L'] = crockfordDecode['1']
crockfordDecode['O'] = crockfordDecode['0']
crockfordDecode['o'] = crockfordDecode['0']
}
// Canonicalize converts an activation-code string into canonical form:
// uppercase, with I/L→1 and O→0 substitutions applied.
// Returns an error if the string contains characters outside the normalised
// Crockford alphabet or if the length is not exactly 16.
func Canonicalize(code string) (string, error) {
code = strings.TrimSpace(code)
// Strip any hyphens/spaces inserted for readability (e.g., XXXX-XXXX-XXXX-XXXX).
code = strings.ReplaceAll(code, "-", "")
code = strings.ReplaceAll(code, " ", "")
if len(code) != 16 {
return "", fmt.Errorf("codes: code must be exactly 16 characters, got %d", len(code))
}
var buf [16]byte
for i := 0; i < 16; i++ {
ch := code[i]
if ch >= 128 {
return "", fmt.Errorf("codes: non-ASCII character at position %d", i)
}
v := crockfordDecode[ch]
if v < 0 {
// For the check symbol position (last char) we allow the full 37-symbol set.
// Check separately below after we have the string.
if i < 15 {
return "", fmt.Errorf("codes: invalid character %q at position %d", ch, i)
}
// Position 15 is the check character; validate separately.
buf[i] = byte(strings.ToUpper(string(ch))[0])
continue
}
buf[i] = crockfordAlphabet[v]
}
canonical := string(buf[:])
// Validate the check character.
if err := validateCheckChar(canonical); err != nil {
return "", err
}
return canonical, nil
}
// Hash returns the hex-encoded SHA-256 of the canonical plaintext code.
// This is the value stored in the database; the plaintext is never stored.
func Hash(canonical string) string {
sum := sha256.Sum256([]byte(canonical))
return hex.EncodeToString(sum[:])
}
// computeCheckValue uses Horner's method to compute the Crockford check value
// (mod 37) of the 15 data characters in s[0:15].
// s must already be in canonical (uppercase) form.
func computeCheckValue(s string) int {
result := 0
for i := 0; i < 15; i++ {
ch := s[i]
if ch >= 128 {
return -1
}
v := int(crockfordDecode[ch])
if v < 0 {
return -1
}
result = (result*32 + v) % 37
}
return result
}
// validateCheckChar verifies that the 16th character of canonical is the
// correct Crockford mod-37 check symbol.
func validateCheckChar(canonical string) error {
if len(canonical) != 16 {
return errors.New("codes: invalid length for check validation")
}
expected := computeCheckValue(canonical)
if expected < 0 {
return errors.New("codes: invalid data characters")
}
want := rune(crockfordCheck[expected])
got := rune(canonical[15])
// The check character might be in the extended set (*~$=U) so compare directly.
if got != want {
return fmt.Errorf("codes: check character mismatch: want %c, got %c", want, got)
}
return nil
}
// GenerateCode generates a single random activation code in canonical Crockford
// Base32 form (15 random data chars + 1 check char = 16 chars total).
// Uses crypto/rand for cryptographically-secure randomness.
func GenerateCode() (string, error) {
// We need 15 random values each in [0, 32).
// To avoid modular bias, use rejection sampling with bytes from crypto/rand.
// Each byte gives a value in [0, 256); we accept values in [0, 224) to ensure
// uniform distribution over [0, 32) (224 = 7*32).
const dataLen = 15
var buf [dataLen]byte
i := 0
for i < dataLen {
var tmp [dataLen * 2]byte // over-read to reduce syscall count
if _, err := rand.Read(tmp[:]); err != nil {
return "", fmt.Errorf("codes: crypto/rand: %w", err)
}
for _, b := range tmp {
if b < 224 { // 224 = 7*32; accept to avoid bias
buf[i] = crockfordAlphabet[b%32]
i++
if i == dataLen {
break
}
}
}
}
data := string(buf[:])
checkVal := computeCheckValue(data + "0") // dummy 16th char, only first 15 used
if checkVal < 0 {
// Should never happen since we only use valid chars.
return "", errors.New("codes: internal check computation error")
}
return data + string(crockfordCheck[checkVal]), nil
}
// ErrDuplicate is returned by the batch generator when a generated code
// already exists in the database (hash collision). The caller should retry.
var ErrDuplicate = errors.New("codes: duplicate code hash")
+199
View File
@@ -0,0 +1,199 @@
package codes_test
import (
"strings"
"testing"
"github.com/wangjia/pangolin/server/internal/codes"
)
// TestCrockfordAlphabetCoverage verifies the 32-symbol encoding alphabet.
func TestCrockfordAlphabetCoverage(t *testing.T) {
forbidden := []rune{'I', 'L', 'O', 'U'}
alpha := "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
if len(alpha) != 32 {
t.Fatalf("alphabet length = %d, want 32", len(alpha))
}
for _, ch := range forbidden {
if strings.ContainsRune(alpha, ch) {
t.Errorf("forbidden char %c found in alphabet", ch)
}
}
}
// TestGenerateCodeFormat verifies that GenerateCode returns a 16-char code
// made entirely of the Crockford alphabet (first 15 chars) + valid check char.
func TestGenerateCodeFormat(t *testing.T) {
for i := 0; i < 1000; i++ {
code, err := codes.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode error: %v", err)
}
if len(code) != 16 {
t.Errorf("code %q: length = %d, want 16", code, len(code))
}
// Canonicalize must accept a freshly generated code.
canonical, err := codes.Canonicalize(code)
if err != nil {
t.Errorf("Canonicalize(%q): %v", code, err)
}
if canonical != code {
t.Errorf("canonical form mismatch: got %q, want %q", canonical, code)
}
}
}
// TestGenerateCodeUniqueness checks that 10 000 generated codes have no
// hash collisions (birthday probability ≈ 10^8 for 75-bit codes).
func TestGenerateCodeUniqueness(t *testing.T) {
const n = 10_000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
code, err := codes.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
h := codes.Hash(code)
if _, dup := seen[h]; dup {
t.Fatalf("hash collision at iteration %d: code=%s hash=%s", i, code, h)
}
seen[h] = struct{}{}
}
}
// TestCanonicalizeNormalization verifies the I/L→1 and O→0 substitutions.
func TestCanonicalizeNormalization(t *testing.T) {
// Generate a valid code to use as a base.
base, err := codes.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
// Test lower-case input equals upper-case canonical.
lower := strings.ToLower(base)
canonical, err := codes.Canonicalize(lower)
if err != nil {
t.Errorf("Canonicalize(lower) error: %v", err)
}
if canonical != base {
t.Errorf("Canonicalize(lower) = %q, want %q", canonical, base)
}
// Test substitution: replace a '1' in the code with 'I' and 'L', verify they
// normalise to the same canonical form.
idx := strings.IndexByte(base, '1')
if idx >= 0 && idx < 15 {
withI := base[:idx] + "I" + base[idx+1:]
withL := base[:idx] + "L" + base[idx+1:]
withLower := base[:idx] + "l" + base[idx+1:]
for _, variant := range []string{withI, withL, withLower} {
c, err := codes.Canonicalize(variant)
if err != nil {
t.Errorf("Canonicalize(%q) error: %v", variant, err)
continue
}
if c != base {
t.Errorf("Canonicalize(%q) = %q, want %q", variant, c, base)
}
}
}
// Replace a '0' with 'O' and verify normalisation.
idx = strings.IndexByte(base, '0')
if idx >= 0 && idx < 15 {
withO := base[:idx] + "O" + base[idx+1:]
c, err := codes.Canonicalize(withO)
if err != nil {
t.Errorf("Canonicalize(%q) error: %v", withO, err)
} else if c != base {
t.Errorf("Canonicalize(%q) = %q, want %q", withO, c, base)
}
}
}
// TestCheckCharDetectsSingleErrors verifies that mutating any single data
// character in a valid code causes Canonicalize to return an error.
func TestCheckCharDetectsSingleErrors(t *testing.T) {
const alpha = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
code, err := codes.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
for pos := 0; pos < 15; pos++ {
original := rune(code[pos])
for _, replacement := range alpha {
if replacement == original {
continue
}
mutated := code[:pos] + string(replacement) + code[pos+1:]
_, err := codes.Canonicalize(mutated)
if err == nil {
t.Errorf("mutating pos %d (%c→%c) not detected: code=%q mutated=%q",
pos, original, replacement, code, mutated)
}
}
}
}
// TestHashConsistency verifies that Hash is deterministic and that different
// codes produce different hashes.
func TestHashConsistency(t *testing.T) {
code1, _ := codes.GenerateCode()
code2, _ := codes.GenerateCode()
for code1 == code2 {
code2, _ = codes.GenerateCode()
}
h1a := codes.Hash(code1)
h1b := codes.Hash(code1)
h2 := codes.Hash(code2)
if h1a != h1b {
t.Error("Hash is not deterministic")
}
if h1a == h2 {
t.Error("Different codes produced the same hash")
}
if len(h1a) != 64 {
t.Errorf("Hash length = %d, want 64 (hex SHA-256)", len(h1a))
}
}
// TestCanonicalizeRejectsInvalidLength tests length validation.
func TestCanonicalizeRejectsInvalidLength(t *testing.T) {
cases := []string{"", "ABCDE", "ABCDEFGH12345678X"}
for _, c := range cases {
if _, err := codes.Canonicalize(c); err == nil {
t.Errorf("Canonicalize(%q) should fail but did not", c)
}
}
}
// TestCanonicalizeRejectsInvalidChars tests that characters outside the
// Crockford alphabet are rejected for data positions.
func TestCanonicalizeRejectsInvalidChars(t *testing.T) {
base, _ := codes.GenerateCode()
// Replace position 0 with an invalid character.
invalid := "!" + base[1:]
if _, err := codes.Canonicalize(invalid); err == nil {
t.Errorf("Canonicalize(%q) should fail for invalid char", invalid)
}
}
// TestHyphenStripping verifies that hyphens inserted for readability are stripped.
func TestHyphenStripping(t *testing.T) {
code, err := codes.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
// Insert hyphens: XXXX-XXXX-XXXX-XXXX
hyphenated := code[:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:]
canonical, err := codes.Canonicalize(hyphenated)
if err != nil {
t.Errorf("Canonicalize(hyphenated) error: %v", err)
}
if canonical != code {
t.Errorf("Canonicalize(hyphenated) = %q, want %q", canonical, code)
}
}
+95
View File
@@ -0,0 +1,95 @@
package codes
import (
"encoding/json"
"net/http"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// RedeemHandler handles POST /v1/redeem requests.
// It expects the request context to carry the authenticated userID under the
// key ctxKeyUserID (set by the JWT auth middleware from the auth module).
type RedeemHandler struct {
svc *Service
}
// NewRedeemHandler creates a RedeemHandler backed by svc.
func NewRedeemHandler(svc *Service) *RedeemHandler {
return &RedeemHandler{svc: svc}
}
// ctxKeyUserID is the context key for the authenticated user ID.
// Defined here to avoid an import cycle; the auth middleware uses the same key.
type ctxKey string
const CtxKeyUserID ctxKey = "user_id"
// redeemRequest is the JSON body for POST /v1/redeem.
type redeemRequest struct {
Code string `json:"code"`
}
// redeemResponse is the JSON body on success.
type redeemResponse struct {
Idempotent bool `json:"idempotent"`
Plan string `json:"plan"`
DurationDays int `json:"duration_days"`
ExpiresAt string `json:"expires_at,omitempty"` // RFC 3339 UTC
}
// ServeHTTP implements http.Handler.
func (h *RedeemHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract authenticated userID from context (set by JWT middleware).
userID, ok := r.Context().Value(CtxKeyUserID).(int64)
if !ok || userID == 0 {
apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{
Code: "UNAUTHORIZED",
MessageZH: "请先登录",
MessageEn: "Authentication required",
})
return
}
var req redeemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
result, apiErr := h.svc.Redeem(r.Context(), RedeemRequest{
UserID: userID,
Code: req.Code,
})
if apiErr != nil {
status := http.StatusBadRequest
switch apiErr.Code {
case "ACCOUNT_LOCKED", "RATE_LIMITED":
status = http.StatusTooManyRequests
case "INTERNAL_ERROR":
status = http.StatusInternalServerError
case "CODE_REDEEMED", "CODE_NOT_FOUND", "CODE_VOID", "INVALID_CODE":
status = http.StatusBadRequest
}
apierr.WriteJSON(w, status, apiErr)
return
}
resp := redeemResponse{
Idempotent: result.Idempotent,
Plan: string(result.PlanCode),
DurationDays: result.DurationDays,
}
if !result.ExpiresAt.IsZero() {
resp.ExpiresAt = result.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z")
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
+383
View File
@@ -0,0 +1,383 @@
package codes
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// RedeemRequest carries the inputs to a single redemption attempt.
type RedeemRequest struct {
UserID int64 // authenticated user
Code string // raw code string from the client (will be canonicalized)
}
// RedeemResult carries the outcome of a successful redemption.
type RedeemResult struct {
// Idempotent indicates this user already redeemed this code; the result is
// from the original redemption.
Idempotent bool
PlanCode PlanCode
DurationDays int
// ExpiresAt is the updated/new subscription expiry (UTC).
ExpiresAt time.Time
// SubscriptionID is the subscription that was extended or newly created.
SubscriptionID int64
}
// Service handles activation-code redemption.
type Service struct {
store *Store
rdb *redis.Client
// redeemFailMax is the number of consecutive failures before a 1-hour lock.
redeemFailMax int
// redeemLockDur is how long the lock lasts.
redeemLockDur time.Duration
}
// NewService creates a Service.
func NewService(store *Store, rdb *redis.Client, failMax int, lockDur time.Duration) *Service {
if failMax <= 0 {
failMax = 5
}
if lockDur <= 0 {
lockDur = time.Hour
}
return &Service{
store: store,
rdb: rdb,
redeemFailMax: failMax,
redeemLockDur: lockDur,
}
}
// redisKeyFail returns the Redis key for the per-user failure counter.
func redisKeyFail(userID int64) string {
return fmt.Sprintf("redeem:fail:%d", userID)
}
// isLocked returns true if the user has hit the failure cap.
// Returns false (not locked) if Redis is not configured.
func (svc *Service) isLocked(ctx context.Context, userID int64) (bool, error) {
if svc.rdb == nil {
return false, nil
}
val, err := svc.rdb.Get(ctx, redisKeyFail(userID)).Int()
if err == redis.Nil {
return false, nil
}
if err != nil {
return false, err
}
return val >= svc.redeemFailMax, nil
}
// recordFail increments the failure counter, setting a 1-hour TTL on first
// increment so the counter resets automatically after the lock window.
// No-ops if Redis is not configured.
func (svc *Service) recordFail(ctx context.Context, userID int64) error {
if svc.rdb == nil {
return nil
}
key := redisKeyFail(userID)
pipe := svc.rdb.Pipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, svc.redeemLockDur)
_, err := pipe.Exec(ctx)
return err
}
// clearFail removes the failure counter after a successful redemption.
// No-ops if Redis is not configured.
func (svc *Service) clearFail(ctx context.Context, userID int64) {
if svc.rdb == nil {
return
}
_ = svc.rdb.Del(ctx, redisKeyFail(userID)).Err()
}
// Redeem processes a redemption request inside a serialisable transaction.
//
// Flow:
// 1. Check rate-limit lock.
// 2. Canonicalize and hash the code.
// 3. BEGIN TRANSACTION (Serializable isolation).
// 4. SELECT … FOR UPDATE the codes row.
// 5. Idempotency: if already redeemed by this user, return cached success.
// 6. Fail if redeemed by someone else, or code is void.
// 7. MarkRedeemed, extend/create subscription, write audit_log.
// 8. COMMIT.
// 9. Clear the failure counter on success.
func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResult, *apierr.Error) {
// 1. Check lock.
locked, err := svc.isLocked(ctx, req.UserID)
if err != nil {
return nil, apierr.ErrInternal
}
if locked {
return nil, apierr.ErrLocked
}
// 2. Canonicalize and hash.
canonical, cerr := Canonicalize(req.Code)
if cerr != nil {
_ = svc.recordFail(ctx, req.UserID)
return nil, apierr.ErrInvalidCode
}
hash := Hash(canonical)
// 3. Begin transaction.
tx, err := svc.store.BeginTx(ctx)
if err != nil {
return nil, apierr.ErrInternal
}
// Roll back on any unhandled path.
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
// 4. SELECT … FOR UPDATE.
cr, err := svc.store.FindCodeByHashForUpdate(ctx, tx, hash)
if err != nil {
return nil, apierr.ErrInternal
}
if cr == nil {
_ = tx.Rollback()
committed = true // prevent double rollback
_ = svc.recordFail(ctx, req.UserID)
return nil, apierr.ErrCodeNotFound
}
// 5. Idempotency check.
// Same user has already redeemed this code → return a success result without
// re-applying any changes. Roll back the (read-only) transaction first.
if cr.Status == "redeemed" && cr.RedeemedBy.Valid && cr.RedeemedBy.Int64 == req.UserID {
_ = tx.Rollback()
committed = true
return &RedeemResult{
Idempotent: true,
PlanCode: cr.PlanCode,
DurationDays: cr.DurationDays,
// ExpiresAt is omitted; the caller can query /v1/me if needed.
}, nil
}
// 6. Fail if already taken or voided.
switch cr.Status {
case "redeemed":
_ = tx.Rollback()
committed = true
_ = svc.recordFail(ctx, req.UserID)
return nil, apierr.ErrCodeRedeemed
case "void":
_ = tx.Rollback()
committed = true
_ = svc.recordFail(ctx, req.UserID)
return nil, apierr.ErrCodeVoid
}
// 7a. Mark the code as redeemed.
if err := svc.store.MarkRedeemed(ctx, tx, cr.ID, req.UserID); err != nil {
return nil, apierr.ErrInternal
}
// 7b. Extend or create subscription.
subID, expiresAt, apiErr := svc.applySubscription(ctx, tx, req.UserID, cr)
if apiErr != nil {
return nil, apiErr
}
// 7c. Write audit log.
meta := auditMeta(req.UserID, cr, subID)
if err := svc.store.WriteAuditLog(ctx, tx,
fmt.Sprintf("user:%d", req.UserID), "redeem",
"code_hash:"+cr.CodeHash[:16]+"...",
meta,
); err != nil {
// Audit log failure must not abort the business transaction.
// Log the error but continue.
_ = err
}
// 8. Commit.
if err := tx.Commit(); err != nil {
return nil, apierr.ErrInternal
}
committed = true
// 9. Clear failure counter on success.
svc.clearFail(ctx, req.UserID)
return &RedeemResult{
Idempotent: false,
PlanCode: cr.PlanCode,
DurationDays: cr.DurationDays,
ExpiresAt: expiresAt,
SubscriptionID: subID,
}, nil
}
// applySubscription implements the subscription-extension rules:
//
// - Same plan as code → extend the most-recently-expiring subscription of
// that plan: expires_at = max(expires_at, now) + duration_days
// - Different plan (or no existing sub for the code's plan) → create a new
// subscription row:
// expires_at = max(now, latest_expiry_for_code_plan) + duration_days
func (svc *Service) applySubscription(
ctx context.Context,
tx *sql.Tx,
userID int64,
cr *CodeRow,
) (subID int64, expiresAt time.Time, apiErr *apierr.Error) {
subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID)
if err != nil {
return 0, time.Time{}, apierr.ErrInternal
}
// Find any existing subscription with the same plan as the code.
var samePlanSub *SubscriptionRow
var latestSamePlan time.Time
for i := range subs {
if subs[i].PlanID == cr.PlanID {
if samePlanSub == nil || subs[i].ExpiresAt.After(samePlanSub.ExpiresAt) {
samePlanSub = &subs[i]
}
if subs[i].ExpiresAt.After(latestSamePlan) {
latestSamePlan = subs[i].ExpiresAt
}
}
}
now := time.Now().UTC()
if samePlanSub != nil {
// Extend existing subscription: max(expires_at, now) + duration_days.
if err := svc.store.ExtendSubscription(ctx, tx, samePlanSub.ID, cr.DurationDays); err != nil {
return 0, time.Time{}, apierr.ErrInternal
}
base := samePlanSub.ExpiresAt
if now.After(base) {
base = now
}
expiresAt = base.AddDate(0, 0, cr.DurationDays)
return samePlanSub.ID, expiresAt, nil
}
// Create a new subscription.
newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, cr.PlanID, cr.DurationDays, latestSamePlan)
if err != nil {
return 0, time.Time{}, apierr.ErrInternal
}
base := now
if latestSamePlan.After(now) {
base = latestSamePlan
}
expiresAt = base.AddDate(0, 0, cr.DurationDays)
return newSubID, expiresAt, nil
}
// auditMeta serialises a compact JSON string for the audit log meta field.
func auditMeta(userID int64, cr *CodeRow, subID int64) string {
m := map[string]interface{}{
"plan": string(cr.PlanCode),
"duration_days": cr.DurationDays,
"batch_id": cr.BatchID,
"sub_id": subID,
}
b, _ := json.Marshal(m)
return string(b)
}
// --------------------------------------------------------------------------
// Batch generation service
// --------------------------------------------------------------------------
// BatchRequest holds the parameters for a bulk code-generation job.
type BatchRequest struct {
PlanCode PlanCode
DurationDays int
Count int
Channel BatchChannel
Note string
CreatedBy string // e.g. "admin:1" or "cli"
}
// BatchResult contains the generated plaintext codes (only occurrence ever)
// and the batch metadata.
type BatchResult struct {
BatchID int64
Codes []string // plaintext canonical codes NOT stored in DB
PlanCode PlanCode
DurationDays int
Channel BatchChannel
}
// CreateBatch generates Count activation codes, writes the batch + code hashes
// to MySQL, and returns the plaintext codes. The plaintext codes are the ONLY
// time they ever appear; the caller is responsible for delivering them securely
// (e.g., streaming to CSV without logging).
//
// Duplicate-hash retries (birthday collision, astronomically unlikely) are
// handled automatically up to maxRetries per slot.
func (svc *Service) CreateBatch(ctx context.Context, req BatchRequest) (*BatchResult, error) {
const maxRetries = 10
// Look up plan ID.
planID, err := svc.store.GetPlanID(ctx, req.PlanCode)
if err != nil {
return nil, fmt.Errorf("CreateBatch: unknown plan %s: %w", req.PlanCode, err)
}
// Insert the batch record.
batchID, err := svc.store.CreateBatch(ctx, req.Channel, req.CreatedBy, req.Note)
if err != nil {
return nil, fmt.Errorf("CreateBatch: %w", err)
}
plaintexts := make([]string, 0, req.Count)
for i := 0; i < req.Count; i++ {
var code string
var h string
ok := false
for attempt := 0; attempt < maxRetries; attempt++ {
c, err := GenerateCode()
if err != nil {
return nil, fmt.Errorf("CreateBatch: GenerateCode: %w", err)
}
h = Hash(c)
err = svc.store.CreateCode(ctx, h, planID, req.DurationDays, batchID)
if err == ErrDuplicate {
continue // collision generate a fresh code
}
if err != nil {
return nil, fmt.Errorf("CreateBatch: CreateCode: %w", err)
}
code = c
ok = true
break
}
if !ok {
return nil, fmt.Errorf("CreateBatch: exceeded %d retry attempts for slot %d", maxRetries, i)
}
plaintexts = append(plaintexts, code)
}
return &BatchResult{
BatchID: batchID,
Codes: plaintexts,
PlanCode: req.PlanCode,
DurationDays: req.DurationDays,
Channel: req.Channel,
}, nil
}
+586
View File
@@ -0,0 +1,586 @@
//go:build integration
package codes_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"time"
// MySQL driver registration.
_ "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/codes"
)
// --------------------------------------------------------------------------
// Container setup helpers
// --------------------------------------------------------------------------
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)
}
// Remove the redis:// scheme prefix if present.
addr = stripScheme(addr)
rdb := redis.NewClient(&redis.Options{Addr: addr})
t.Cleanup(func() { rdb.Close() })
return rdb
}
func stripScheme(s string) string {
for _, prefix := range []string{"redis://", "rediss://"} {
if len(s) > len(prefix) && s[:len(prefix)] == prefix {
return s[len(prefix):]
}
}
return s
}
// applySchema runs the minimum DDL required by the codes package.
func applySchema(db *sql.DB) error {
stmts := []string{
`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 code_batches (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel ENUM('store','tg','line','manual') NOT NULL,
created_by VARCHAR(64) NOT NULL,
note VARCHAR(255) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS codes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code_hash CHAR(64) NOT NULL UNIQUE,
plan_id BIGINT UNSIGNED NOT NULL,
duration_days INT NOT NULL,
batch_id BIGINT UNSIGNED NOT NULL,
status ENUM('unused','redeemed','void') NOT NULL DEFAULT 'unused',
redeemed_by BIGINT UNSIGNED NULL,
redeemed_at DATETIME(6) NULL,
FOREIGN KEY (plan_id) REFERENCES plans(id),
FOREIGN KEY (batch_id) REFERENCES code_batches(id),
INDEX idx_status (status)
) 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),
FOREIGN KEY (plan_id) REFERENCES plans(id),
INDEX idx_user_exp (user_id, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
actor VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL,
meta JSON NULL,
at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX idx_at (at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
// Seed plan rows.
`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 _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("schema exec: %w\nSQL: %s", err, stmt)
}
}
return nil
}
// insertCode inserts a test code directly into the DB and returns it.
func insertCode(t *testing.T, db *sql.DB, plaintext string, plan codes.PlanCode, durationDays int) {
t.Helper()
canonical, err := codes.Canonicalize(plaintext)
if err != nil {
t.Fatalf("Canonicalize: %v", err)
}
hash := codes.Hash(canonical)
var planID int64
if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, string(plan)).Scan(&planID); err != nil {
t.Fatalf("plan lookup: %v", err)
}
_, err = db.Exec(
`INSERT INTO code_batches (channel, created_by, created_at) VALUES ('manual','test',UTC_TIMESTAMP(6))`,
)
if err != nil {
t.Fatalf("batch insert: %v", err)
}
var batchID int64
db.QueryRow(`SELECT LAST_INSERT_ID()`).Scan(&batchID)
_, err = db.Exec(
`INSERT INTO codes (code_hash, plan_id, duration_days, batch_id) VALUES (?,?,?,?)`,
hash, planID, durationDays, batchID,
)
if err != nil {
t.Fatalf("code insert: %v", err)
}
}
// --------------------------------------------------------------------------
// Integration tests
// --------------------------------------------------------------------------
// TestRedeemConcurrentSingleWinner verifies that when N goroutines try to
// redeem the same code simultaneously, exactly 1 succeeds and N-1 fail.
func TestRedeemConcurrentSingleWinner(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
const N = 20
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanPro, 30)
results := make([]bool, N) // true = success
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
userID := int64(1000 + idx) // different user for each goroutine
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: code,
})
results[idx] = (apiErr == nil)
}(i)
}
wg.Wait()
winners := 0
for _, ok := range results {
if ok {
winners++
}
}
if winners != 1 {
t.Errorf("expected exactly 1 winner, got %d", winners)
}
}
// TestRedeemIdempotent verifies that the same user re-submitting the same
// code gets a 200 with Idempotent=true.
func TestRedeemIdempotent(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanPro, 30)
const userID = int64(2001)
// First redemption.
r1, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: userID, Code: code})
if apiErr != nil {
t.Fatalf("first redeem failed: %v", apiErr)
}
if r1.Idempotent {
t.Error("first redeem should not be idempotent")
}
// Second redemption same user, same code.
r2, apiErr2 := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: userID, Code: code})
if apiErr2 != nil {
t.Fatalf("second redeem failed: %v", apiErr2)
}
if !r2.Idempotent {
t.Error("second redeem should be idempotent")
}
}
// TestRedeemOtherUserFails verifies that a different user trying to redeem an
// already-redeemed code gets CODE_REDEEMED and that the failure is counted.
func TestRedeemOtherUserFails(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanPro, 30)
// User A redeems.
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: 3001, Code: code})
if apiErr != nil {
t.Fatalf("user A redeem: %v", apiErr)
}
// User B tries the same code.
_, apiErr2 := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: 3002, Code: code})
if apiErr2 == nil {
t.Fatal("user B should have failed")
}
if apiErr2.Code != "CODE_REDEEMED" {
t.Errorf("expected CODE_REDEEMED, got %s", apiErr2.Code)
}
}
// TestRedeemFailLock verifies that 5 consecutive failures lock the account for
// 1 hour and subsequent attempts return ACCOUNT_LOCKED.
func TestRedeemFailLock(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
// Use a very short lock for the test (we won't wait).
svc := codes.NewService(store, rdb, 5, time.Hour)
const userID = int64(4001)
// Attempt 5 redemptions with invalid codes.
for i := 0; i < 5; i++ {
fakeCode, _ := codes.GenerateCode() // valid format but not in DB
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: fakeCode,
})
if apiErr == nil {
t.Fatalf("attempt %d: expected failure", i+1)
}
if apiErr.Code == "ACCOUNT_LOCKED" {
t.Fatalf("locked too early at attempt %d", i+1)
}
}
// 6th attempt should return ACCOUNT_LOCKED.
fakeCode, _ := codes.GenerateCode()
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: fakeCode,
})
if apiErr == nil || apiErr.Code != "ACCOUNT_LOCKED" {
t.Errorf("expected ACCOUNT_LOCKED, got %v", apiErr)
}
}
// TestSamePlanExtension verifies that redeeming a pro code when the user
// already has a pro subscription extends the existing subscription.
func TestSamePlanExtension(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
const userID = int64(5001)
// Pre-insert a pro subscription expiring in 10 days.
var planID int64
db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&planID)
initial := time.Now().UTC().AddDate(0, 0, 10)
db.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,?,'trial')`,
userID, planID, initial,
)
// Redeem a 30-day pro code.
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanPro, 30)
result, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: code,
})
if apiErr != nil {
t.Fatalf("redeem: %v", apiErr)
}
// expires_at should be initial + 30 days (since initial > now).
wantExpiry := initial.AddDate(0, 0, 30)
diff := result.ExpiresAt.Sub(wantExpiry)
if diff < -2*time.Second || diff > 2*time.Second {
t.Errorf("expires_at = %v, want ≈%v (diff=%v)", result.ExpiresAt, wantExpiry, diff)
}
}
// TestCrossPlanNewSubscription verifies that redeeming a team code when the
// user only has a pro subscription creates a new team subscription.
func TestCrossPlanNewSubscription(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
const userID = int64(6001)
// Pre-insert a pro subscription.
var proID int64
db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID)
db.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,'2099-01-01','trial')`,
userID, proID,
)
// Redeem a 30-day team code.
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanTeam, 30)
result, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: code,
})
if apiErr != nil {
t.Fatalf("redeem: %v", apiErr)
}
// Should have created a new team subscription starting from now + 30 days.
wantMin := time.Now().UTC().AddDate(0, 0, 29)
wantMax := time.Now().UTC().AddDate(0, 0, 31)
if result.ExpiresAt.Before(wantMin) || result.ExpiresAt.After(wantMax) {
t.Errorf("team expires_at = %v, want within [%v, %v]", result.ExpiresAt, wantMin, wantMax)
}
// Confirm the new row is a team subscription.
var count int
var teamID int64
db.QueryRow(`SELECT id FROM plans WHERE code='team'`).Scan(&teamID)
db.QueryRow(
`SELECT COUNT(1) FROM subscriptions WHERE user_id=? AND plan_id=? AND source='code'`,
userID, teamID,
).Scan(&count)
if count != 1 {
t.Errorf("expected 1 new team subscription, got %d", count)
}
}
// TestAuditLogWritten verifies that every successful redemption produces an
// audit_log row.
func TestAuditLogWritten(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
const userID = int64(7001)
code, _ := codes.GenerateCode()
insertCode(t, db, code, codes.PlanPro, 30)
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
UserID: userID,
Code: code,
})
if apiErr != nil {
t.Fatalf("redeem: %v", apiErr)
}
var count int
db.QueryRow(
`SELECT COUNT(1) FROM audit_log WHERE action='redeem' AND actor=?`,
fmt.Sprintf("user:%d", userID),
).Scan(&count)
if count != 1 {
t.Errorf("expected 1 audit_log row, got %d", count)
}
}
// TestCSVExportNoPlaintextInDB verifies that after a batch generation and
// CSV export, no plaintext code is stored in the database.
func TestCSVExportNoPlaintextInDB(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
svc := codes.NewService(store, rdb, 5, time.Hour)
result, err := svc.CreateBatch(context.Background(), codes.BatchRequest{
PlanCode: codes.PlanPro,
DurationDays: 30,
Count: 10,
Channel: codes.ChannelManual,
Note: "integration test",
CreatedBy: "test",
})
if err != nil {
t.Fatalf("CreateBatch: %v", err)
}
if len(result.Codes) != 10 {
t.Fatalf("expected 10 codes, got %d", len(result.Codes))
}
// Write CSV.
var buf bytes.Buffer
rows := codes.BatchResultToCSVRows(result, time.Now())
if err := codes.ExportCSV(&buf, rows); err != nil {
t.Fatalf("ExportCSV: %v", err)
}
// Verify no plaintext appears in the codes table.
for _, c := range result.Codes {
var count int
// The table stores only code_hash; search for the plaintext as a string.
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, c).Scan(&count)
if count > 0 {
t.Errorf("plaintext code %s found in codes.code_hash column!", c)
}
// Also verify the hash IS present.
h := codes.Hash(c)
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, h).Scan(&count)
if count != 1 {
t.Errorf("hash for code %s not found in DB", c)
}
}
}
// TestWebhookNonceReplay verifies that replaying a webhook nonce is rejected
// with status 200 (duplicate_ignored) and does NOT create a second DB row.
func TestWebhookNonceReplay(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
secret := "integration-secret"
h := codes.NewWebhookHandler(store, rdb, secret, 5*time.Minute, 15*time.Minute)
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
nonce := fmt.Sprintf("unique-nonce-%d", time.Now().UnixNano())
makeReq := func() *http.Request {
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set("X-Pangolin-Nonce", nonce)
return r
}
// First request: should succeed with 201.
w1 := httptest.NewRecorder()
h.ServeHTTP(w1, makeReq())
if w1.Code != http.StatusCreated {
t.Errorf("first request: expected 201, got %d body=%s", w1.Code, w1.Body.String())
}
// Second request with same nonce: should return 200 duplicate_ignored.
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, makeReq())
if w2.Code != http.StatusOK {
t.Errorf("replay request: expected 200, got %d body=%s", w2.Code, w2.Body.String())
}
// Only one row should exist in codes.
hash := codes.Hash(code)
var count int
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count)
if count != 1 {
t.Errorf("expected 1 code row, got %d", count)
}
}
// TestWebhookSameHashIdempotent verifies that pushing the same code_hash twice
// (different nonce) is idempotent: no duplicate row is created.
func TestWebhookSameHashIdempotent(t *testing.T) {
db := setupMySQL(t)
rdb := setupRedis(t)
store := codes.NewStore(db)
secret := "integration-secret-2"
h := codes.NewWebhookHandler(store, rdb, secret, 5*time.Minute, 15*time.Minute)
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
sendReq := func(nonce string) int {
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set("X-Pangolin-Nonce", nonce)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
status1 := sendReq(fmt.Sprintf("nonce-a-%d", time.Now().UnixNano()))
if status1 != http.StatusCreated {
t.Fatalf("first request: expected 201, got %d", status1)
}
status2 := sendReq(fmt.Sprintf("nonce-b-%d", time.Now().UnixNano()))
if status2 != http.StatusOK {
t.Fatalf("second (same hash) request: expected 200, got %d", status2)
}
// Still only one row.
hash := codes.Hash(code)
var count int
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count)
if count != 1 {
t.Errorf("expected 1 code row after idempotent push, got %d", count)
}
}
+310
View File
@@ -0,0 +1,310 @@
package codes
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
// PlanCode represents a plan tier.
type PlanCode string
const (
PlanFree PlanCode = "free"
PlanPro PlanCode = "pro"
PlanTeam PlanCode = "team"
)
// planTier returns a numeric tier for comparison (higher = better).
func planTier(p PlanCode) int {
switch p {
case PlanTeam:
return 3
case PlanPro:
return 2
default:
return 1
}
}
// BatchChannel is the source channel for a code batch.
type BatchChannel string
const (
ChannelStore BatchChannel = "store"
ChannelTG BatchChannel = "tg"
ChannelLine BatchChannel = "line"
ChannelManual BatchChannel = "manual"
)
// CodeRow mirrors the `codes` DB row (hash only; no plaintext).
type CodeRow struct {
ID int64
CodeHash string // SHA-256 hex of canonical plaintext
PlanID int64
PlanCode PlanCode
DurationDays int
BatchID int64
Status string // unused | redeemed | void
RedeemedBy sql.NullInt64
RedeemedAt sql.NullTime
}
// SubscriptionRow mirrors the `subscriptions` DB row.
type SubscriptionRow struct {
ID int64
UserID int64
PlanID int64
PlanCode PlanCode
ExpiresAt time.Time
Source string
}
// BatchRow mirrors the `code_batches` DB row.
type BatchRow struct {
ID int64
Channel BatchChannel
CreatedBy string
Note sql.NullString
CreatedAt time.Time
}
// Store wraps a *sql.DB and exposes all database operations needed by the
// codes package. Every method that modifies data is safe to call inside or
// outside an explicit transaction; methods that accept a *sql.Tx run within
// that transaction.
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} }
// --------------------------------------------------------------------------
// Batch and code creation (used by Service.CreateBatch and webhook)
// --------------------------------------------------------------------------
// CreateBatch inserts a new code_batches row and returns its ID.
func (s *Store) CreateBatch(ctx context.Context, channel BatchChannel, createdBy, note string) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO code_batches (channel, created_by, note, created_at)
VALUES (?, ?, NULLIF(?, ''), UTC_TIMESTAMP(6))`,
string(channel), createdBy, note)
if err != nil {
return 0, fmt.Errorf("store.CreateBatch: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("store.CreateBatch last id: %w", err)
}
return id, nil
}
// CreateCode inserts a single codes row. It returns ErrDuplicate if a row
// with the same code_hash already exists (UNIQUE constraint violation).
func (s *Store) CreateCode(ctx context.Context, codeHash string, planID int64, durationDays int, batchID int64) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO codes (code_hash, plan_id, duration_days, batch_id, status)
VALUES (?, ?, ?, ?, 'unused')`,
codeHash, planID, durationDays, batchID)
if err != nil {
if isDuplicateKey(err) {
return ErrDuplicate
}
return fmt.Errorf("store.CreateCode: %w", err)
}
return nil
}
// --------------------------------------------------------------------------
// Redemption (used by Service.Redeem inside a transaction)
// --------------------------------------------------------------------------
// FindCodeByHashForUpdate looks up a codes row by its SHA-256 hash using
// SELECT … FOR UPDATE so that the row is locked for the duration of the
// surrounding transaction. It also fetches the plan code from the plans
// table in the same query.
func (s *Store) FindCodeByHashForUpdate(ctx context.Context, tx *sql.Tx, hash string) (*CodeRow, error) {
row := tx.QueryRowContext(ctx,
`SELECT c.id, c.code_hash, c.plan_id, p.code, c.duration_days,
c.batch_id, c.status, c.redeemed_by, c.redeemed_at
FROM codes c
JOIN plans p ON p.id = c.plan_id
WHERE c.code_hash = ?
FOR UPDATE`,
hash)
var cr CodeRow
if err := row.Scan(
&cr.ID, &cr.CodeHash, &cr.PlanID, &cr.PlanCode, &cr.DurationDays,
&cr.BatchID, &cr.Status, &cr.RedeemedBy, &cr.RedeemedAt,
); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("store.FindCodeByHashForUpdate: %w", err)
}
return &cr, nil
}
// MarkRedeemed updates a codes row to status='redeemed' within tx.
func (s *Store) MarkRedeemed(ctx context.Context, tx *sql.Tx, codeID, userID int64) error {
_, err := tx.ExecContext(ctx,
`UPDATE codes SET status='redeemed', redeemed_by=?, redeemed_at=UTC_TIMESTAMP(6)
WHERE id=? AND status='unused'`,
userID, codeID)
if err != nil {
return fmt.Errorf("store.MarkRedeemed: %w", err)
}
return nil
}
// --------------------------------------------------------------------------
// Subscription helpers (used inside redeem transaction)
// --------------------------------------------------------------------------
// GetPlanID returns the primary key of plans.code.
func (s *Store) GetPlanID(ctx context.Context, code PlanCode) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx, `SELECT id FROM plans WHERE code=?`, string(code)).Scan(&id)
if err != nil {
return 0, fmt.Errorf("store.GetPlanID(%s): %w", code, err)
}
return id, nil
}
// GetActiveSubscriptions returns all non-expired subscriptions for userID,
// ordered by expires_at DESC. Called inside the redeem transaction.
func (s *Store) GetActiveSubscriptions(ctx context.Context, tx *sql.Tx, userID int64) ([]SubscriptionRow, error) {
rows, err := tx.QueryContext(ctx,
`SELECT s.id, s.user_id, s.plan_id, p.code, s.expires_at, s.source
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 s.expires_at DESC`,
userID)
if err != nil {
return nil, fmt.Errorf("store.GetActiveSubscriptions: %w", err)
}
defer rows.Close()
var subs []SubscriptionRow
for rows.Next() {
var sr SubscriptionRow
if err := rows.Scan(&sr.ID, &sr.UserID, &sr.PlanID, &sr.PlanCode, &sr.ExpiresAt, &sr.Source); err != nil {
return nil, fmt.Errorf("store.GetActiveSubscriptions scan: %w", err)
}
subs = append(subs, sr)
}
return subs, rows.Err()
}
// ExtendSubscription sets expires_at to max(current_expires_at, now) + duration.
// Called inside the redeem transaction.
func (s *Store) ExtendSubscription(ctx context.Context, tx *sql.Tx, subID int64, durationDays int) error {
_, err := tx.ExecContext(ctx,
`UPDATE subscriptions
SET expires_at = DATE_ADD(
GREATEST(expires_at, UTC_TIMESTAMP(6)),
INTERVAL ? DAY)
WHERE id=?`,
durationDays, subID)
if err != nil {
return fmt.Errorf("store.ExtendSubscription: %w", err)
}
return nil
}
// CreateSubscription inserts a new subscriptions row. expires_at is set to
// max(now, latest_expires_at_for_same_plan) + duration_days.
// latestSamePlan may be zero-value if the user has no existing sub for that plan.
func (s *Store) CreateSubscription(
ctx context.Context,
tx *sql.Tx,
userID, planID int64,
durationDays int,
latestSamePlan time.Time,
) (int64, error) {
now := time.Now().UTC()
base := now
if latestSamePlan.After(now) {
base = latestSamePlan
}
expiresAt := base.AddDate(0, 0, durationDays)
res, err := tx.ExecContext(ctx,
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at)
VALUES (?, ?, ?, 'code', UTC_TIMESTAMP(6))`,
userID, planID, expiresAt)
if err != nil {
return 0, fmt.Errorf("store.CreateSubscription: %w", err)
}
id, _ := res.LastInsertId()
return id, nil
}
// --------------------------------------------------------------------------
// Audit log
// --------------------------------------------------------------------------
// WriteAuditLog inserts an audit_log row. actor and target are string
// identifiers; meta is a JSON object (may be nil/empty).
func (s *Store) WriteAuditLog(ctx context.Context, tx *sql.Tx, actor, action, target, metaJSON string) error {
var err error
if metaJSON == "" {
metaJSON = "null"
}
if tx != nil {
_, err = tx.ExecContext(ctx,
`INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6))`,
actor, action, target, metaJSON)
} else {
_, err = s.db.ExecContext(ctx,
`INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6))`,
actor, action, target, metaJSON)
}
if err != nil {
return fmt.Errorf("store.WriteAuditLog: %w", err)
}
return nil
}
// --------------------------------------------------------------------------
// Webhook-specific lookup
// --------------------------------------------------------------------------
// CodeExistsByHash returns true if a codes row with the given hash already exists.
func (s *Store) CodeExistsByHash(ctx context.Context, hash string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count)
if err != nil {
return false, fmt.Errorf("store.CodeExistsByHash: %w", err)
}
return count > 0, nil
}
// BeginTx starts a new transaction at Read Committed isolation level.
// The SELECT … FOR UPDATE in FindCodeByHashForUpdate provides the necessary
// row-level exclusivity; Serializable is intentionally avoided to reduce
// deadlock risk under concurrent redemptions.
func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) {
return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
// --------------------------------------------------------------------------
// helper
// --------------------------------------------------------------------------
// isDuplicateKey returns true if err is a MySQL duplicate-key error (1062).
func isDuplicateKey(err error) bool {
if err == nil {
return false
}
// go-sql-driver wraps MySQL errors; the error number is accessible via
// the mysql.MySQLError type. We do a string match to avoid importing
// the mysql package here.
msg := err.Error()
return strings.Contains(msg, "Duplicate entry") ||
strings.Contains(msg, "1062")
}
+237
View File
@@ -0,0 +1,237 @@
package codes
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// WebhookHandler handles POST /webhook/store/codes requests from the
// card store (发卡店). It is mounted outside /v1 and requires no JWT.
//
// Security model:
// - HMAC-SHA256 signature in X-Pangolin-Signature: sha256=<hex>
// - Unix timestamp in X-Pangolin-Timestamp (±5 min window)
// - Unique nonce in X-Pangolin-Nonce to prevent replay attacks
// - Nonce is stored in Redis with a TTL > 2× the timestamp window
type WebhookHandler struct {
store *Store
rdb *redis.Client
secret []byte
timestampTolerance time.Duration
nonceTTL time.Duration
}
// NewWebhookHandler creates a WebhookHandler.
// secret is the raw HMAC key (not hex-encoded).
func NewWebhookHandler(
store *Store,
rdb *redis.Client,
secret string,
timestampTolerance time.Duration,
nonceTTL time.Duration,
) *WebhookHandler {
return &WebhookHandler{
store: store,
rdb: rdb,
secret: []byte(secret),
timestampTolerance: timestampTolerance,
nonceTTL: nonceTTL,
}
}
// WebhookPayload is the JSON body sent by the card store.
type WebhookPayload struct {
// Code is the plaintext activation code generated by the card store.
Code string `json:"code"`
// Plan is the plan tier (free|pro|team).
Plan string `json:"plan"`
// DurationDays is the number of days this code grants.
DurationDays int `json:"duration_days"`
// Note is an optional human-readable remark.
Note string `json:"note,omitempty"`
}
// redisKeyNonce returns the Redis key for a webhook nonce.
func redisKeyNonce(nonce string) string {
return "webhook:nonce:" + nonce
}
// ServeHTTP implements http.Handler for POST /webhook/store/codes.
func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Read body (limit to 64 KB to prevent DoS).
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
if err != nil {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
// --- Signature verification ---
if apiErr := h.verifySignature(r, body); apiErr != nil {
apierr.WriteJSON(w, http.StatusUnauthorized, apiErr)
return
}
// --- Timestamp window ---
if apiErr := h.verifyTimestamp(r); apiErr != nil {
apierr.WriteJSON(w, http.StatusUnauthorized, apiErr)
return
}
// --- Nonce deduplication ---
nonce := r.Header.Get("X-Pangolin-Nonce")
if nonce == "" {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
isDupe, apiErr := h.checkAndStoreNonce(r.Context(), nonce)
if apiErr != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
if isDupe {
// Idempotent: return 200 to acknowledge without re-inserting.
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "duplicate_ignored"})
return
}
// --- Parse payload ---
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
if payload.Code == "" || payload.Plan == "" || payload.DurationDays <= 0 {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
// --- Canonicalize and hash the code ---
canonical, cerr := Canonicalize(payload.Code)
if cerr != nil {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrInvalidCode)
return
}
hash := Hash(canonical)
// --- Idempotent code insertion ---
ctx := r.Context()
planID, err := h.store.GetPlanID(ctx, PlanCode(strings.ToLower(payload.Plan)))
if err != nil {
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
return
}
// Create batch (one per webhook call; the store may aggregate externally).
batchID, err := h.store.CreateBatch(ctx, ChannelStore, "webhook", payload.Note)
if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
err = h.store.CreateCode(ctx, hash, planID, payload.DurationDays, batchID)
if err == ErrDuplicate {
// Same code_hash already exists idempotent, do not error.
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "already_exists"})
return
}
if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "created"})
}
// verifySignature checks the X-Pangolin-Signature header.
// Expected format: "sha256=<lowercase-hex-hmac>"
// HMAC is computed over the raw request body.
func (h *WebhookHandler) verifySignature(r *http.Request, body []byte) *apierr.Error {
sig := r.Header.Get("X-Pangolin-Signature")
if sig == "" {
return apierr.ErrWebhookSignature
}
if !strings.HasPrefix(sig, "sha256=") {
return apierr.ErrWebhookSignature
}
gotHex := strings.TrimPrefix(sig, "sha256=")
gotBytes, err := hex.DecodeString(gotHex)
if err != nil {
return apierr.ErrWebhookSignature
}
mac := hmac.New(sha256.New, h.secret)
mac.Write(body)
expected := mac.Sum(nil)
// Constant-time comparison to prevent timing attacks.
if !hmac.Equal(gotBytes, expected) {
return apierr.ErrWebhookSignature
}
return nil
}
// verifyTimestamp checks the X-Pangolin-Timestamp header.
// The timestamp must be within ± h.timestampTolerance of the current time.
func (h *WebhookHandler) verifyTimestamp(r *http.Request) *apierr.Error {
tsStr := r.Header.Get("X-Pangolin-Timestamp")
if tsStr == "" {
return apierr.ErrWebhookTimestamp
}
ts, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
return apierr.ErrWebhookTimestamp
}
t := time.Unix(ts, 0)
now := time.Now().UTC()
diff := now.Sub(t)
if diff < 0 {
diff = -diff
}
if diff > h.timestampTolerance {
return apierr.ErrWebhookTimestamp
}
return nil
}
// checkAndStoreNonce checks whether nonce has been seen before.
// If not seen, it stores it in Redis with the nonce TTL and returns false.
// If already seen, it returns true (duplicate).
// Uses SET NX to make the check-and-store atomic.
// Returns an error if rdb is nil (misconfiguration).
func (h *WebhookHandler) checkAndStoreNonce(ctx context.Context, nonce string) (bool, error) {
if h.rdb == nil {
return false, fmt.Errorf("webhook: Redis client is not configured")
}
key := redisKeyNonce(nonce)
// SET key "1" NX EX <ttl seconds>
set, err := h.rdb.SetNX(ctx, key, "1", h.nonceTTL).Result()
if err != nil {
return false, fmt.Errorf("webhook checkNonce: %w", err)
}
// SetNX returns true if the key was set (not a duplicate).
return !set, nil
}
+198
View File
@@ -0,0 +1,198 @@
package codes_test
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/wangjia/pangolin/server/internal/codes"
)
// --- HMAC helper used across tests ---
func signBody(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}
// newWebhookHandlerNoRedis creates a WebhookHandler with a nil Redis client.
// Safe only for tests where the handler returns before the nonce check.
func newWebhookHandlerNoRedis(secret string) *codes.WebhookHandler {
return codes.NewWebhookHandler(nil, nil, secret, 5*time.Minute, 15*time.Minute)
}
// --- Signature validation tests (no Redis required) ---
// TestWebhookSignatureRejected verifies that a bad HMAC causes a 401.
// The signature check fires before any Redis or DB access.
func TestWebhookSignatureRejected(t *testing.T) {
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
// Wrong secret.
r.Header.Set("X-Pangolin-Signature", signBody("wrong-secret", body))
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set("X-Pangolin-Nonce", "test-nonce-1")
h := newWebhookHandlerNoRedis("correct-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d; body=%s", w.Code, w.Body.String())
}
assertErrorCode(t, w, "WEBHOOK_INVALID_SIGNATURE")
}
// TestWebhookMissingSignatureHeader verifies that a missing signature causes a 401.
func TestWebhookMissingSignatureHeader(t *testing.T) {
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
// No X-Pangolin-Signature.
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set("X-Pangolin-Nonce", "test-nonce-nosig")
h := newWebhookHandlerNoRedis("any-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
// TestWebhookTimestampRejected verifies that a stale timestamp causes a 401.
// The timestamp check fires after signature but before Redis.
func TestWebhookTimestampRejected(t *testing.T) {
secret := "test-secret"
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
// Timestamp is 10 minutes in the past outside ±5 min window.
staleTs := time.Now().Add(-10 * time.Minute).Unix()
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(staleTs, 10))
r.Header.Set("X-Pangolin-Nonce", "test-nonce-stale")
h := newWebhookHandlerNoRedis(secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d; body=%s", w.Code, w.Body.String())
}
assertErrorCode(t, w, "WEBHOOK_TIMESTAMP_EXPIRED")
}
// TestWebhookFutureTimestampRejected verifies that a far-future timestamp is also rejected.
func TestWebhookFutureTimestampRejected(t *testing.T) {
secret := "test-secret"
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
futureTs := time.Now().Add(10 * time.Minute).Unix()
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(futureTs, 10))
r.Header.Set("X-Pangolin-Nonce", "test-nonce-future")
h := newWebhookHandlerNoRedis(secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
// TestWebhookMissingNonce verifies that a missing nonce causes a 400.
// Missing nonce is checked before Redis access (nil nonce → BadRequest).
func TestWebhookMissingNonce(t *testing.T) {
secret := "test-secret"
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
// No X-Pangolin-Nonce header.
h := newWebhookHandlerNoRedis(secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d; body=%s", w.Code, w.Body.String())
}
}
// TestWebhookHMACConstantTime verifies that a correct-length HMAC with wrong
// bytes is still rejected i.e., hmac.Equal (constant-time) is used, not ==.
func TestWebhookHMACConstantTime(t *testing.T) {
secret := "test-secret"
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
// Produce a MAC of the correct length but with the last byte flipped.
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
correctMAC := mac.Sum(nil)
correctMAC[len(correctMAC)-1] ^= 0xFF
wrongSig := "sha256=" + hex.EncodeToString(correctMAC)
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Pangolin-Signature", wrongSig)
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set("X-Pangolin-Nonce", "test-nonce-ct")
h := newWebhookHandlerNoRedis(secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
// --------------------------------------------------------------------------
// Helper
// --------------------------------------------------------------------------
// assertErrorCode decodes the response body and checks the "code" field.
func assertErrorCode(t *testing.T, w *httptest.ResponseRecorder, wantCode string) {
t.Helper()
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Errorf("decode error body: %v", err)
return
}
if body.Code != wantCode {
t.Errorf("error code = %q, want %q", body.Code, wantCode)
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package config holds application configuration loaded from environment variables.
package config
import (
"fmt"
"os"
"time"
)
// Config holds all application-level configuration.
type Config struct {
// DSN is the MySQL connection string.
// Format: user:password@tcp(host:port)/dbname?parseTime=true&loc=UTC&time_zone=%%27UTC%%27
DSN string
// RedisAddr is the Redis server address (host:port).
RedisAddr string
RedisPassword string
RedisDB int
// WebhookSecret is the HMAC-SHA256 shared secret for the card-store webhook.
// Must be set; no default.
WebhookSecret string
// RedeemFailMax is the number of consecutive redeem failures before a 1-hour lock.
// Default: 5
RedeemFailMax int
// RedeemLockDuration is how long the lock lasts after hitting RedeemFailMax.
// Default: 1 hour
RedeemLockDuration time.Duration
// WebhookTimestampTolerance is the ±window for webhook timestamp validation.
// Default: 5 minutes
WebhookTimestampTolerance time.Duration
// WebhookNonceTTL is how long a webhook nonce is kept in Redis to prevent replay.
// Should be > 2 * WebhookTimestampTolerance. Default: 15 minutes.
WebhookNonceTTL time.Duration
}
// FromEnv reads configuration from environment variables.
// Returns an error if required variables are missing.
func FromEnv() (*Config, error) {
c := &Config{
DSN: os.Getenv("DB_DSN"),
RedisAddr: getEnvDefault("REDIS_ADDR", "127.0.0.1:6379"),
RedisPassword: os.Getenv("REDIS_PASSWORD"),
RedisDB: 0,
WebhookSecret: os.Getenv("WEBHOOK_SECRET"),
RedeemFailMax: 5,
RedeemLockDuration: time.Hour,
WebhookTimestampTolerance: 5 * time.Minute,
WebhookNonceTTL: 15 * time.Minute,
}
if c.DSN == "" {
return nil, fmt.Errorf("config: DB_DSN is required")
}
if c.WebhookSecret == "" {
return nil, fmt.Errorf("config: WEBHOOK_SECRET is required")
}
return c, nil
}
func getEnvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
+29
View File
@@ -0,0 +1,29 @@
// Package db provides a MySQL connection helper.
package db
import (
"database/sql"
"fmt"
"time"
// Register mysql driver.
_ "github.com/go-sql-driver/mysql"
)
// Open opens a *sql.DB backed by MySQL and pings the server.
// The DSN must include parseTime=true and time_zone='+00:00' (UTC).
func Open(dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("db.Open: %w", err)
}
db.SetMaxOpenConns(30)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("db.Open ping: %w", err)
}
return db, nil
}
+22
View File
@@ -0,0 +1,22 @@
// Package redisutil provides a Redis client constructor.
package redisutil
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
// New creates and pings a Redis client.
func New(addr, password string, db int) (*redis.Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("redisutil.New ping: %w", err)
}
return rdb, nil
}
+94
View File
@@ -0,0 +1,94 @@
-- Migration 001: baseline schema for the codes module and its dependencies.
-- MySQL 8.x · InnoDB · utf8mb4 · UTC
-- Apply with: mysql -u root -p pangolin < migrations/001_init.sql
SET time_zone = '+00:00';
-- -------------------------------------------------------------------------
-- Core account tables (needed by foreign keys from subscriptions)
-- -------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE, -- public-facing identifier
email VARCHAR(255) NOT NULL UNIQUE,
pw_hash VARCHAR(255) NOT NULL, -- argon2id
dp_uuid CHAR(36) NOT NULL, -- data-plane UUID (sing-box)
status ENUM('active','banned') NOT NULL DEFAULT 'active',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -------------------------------------------------------------------------
-- Plan catalogue (digital values per design/CLAUDE.md §7)
-- -------------------------------------------------------------------------
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, -- free 1 / pro 5 / team 10
daily_minutes INT NULL, -- free 10; NULL = unlimited
ad_gate BOOLEAN NOT NULL DEFAULT FALSE -- free users must watch an ad daily
) 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);
-- -------------------------------------------------------------------------
-- Subscriptions
-- -------------------------------------------------------------------------
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),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (plan_id) REFERENCES plans(id),
INDEX idx_user_exp (user_id, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -------------------------------------------------------------------------
-- Activation-code tables
-- -------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS code_batches (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel ENUM('store','tg','line','manual') NOT NULL,
created_by VARCHAR(64) NOT NULL,
note VARCHAR(255) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS codes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
-- SHA-256(canonical_plaintext) stored as 64-char hex.
-- Plaintext NEVER stored in any column.
code_hash CHAR(64) NOT NULL UNIQUE,
plan_id BIGINT UNSIGNED NOT NULL,
duration_days INT NOT NULL,
batch_id BIGINT UNSIGNED NOT NULL,
status ENUM('unused','redeemed','void') NOT NULL DEFAULT 'unused',
redeemed_by BIGINT UNSIGNED NULL, -- FK to users.id (not enforced to avoid
redeemed_at DATETIME(6) NULL, -- locking the users table on redeem)
FOREIGN KEY (plan_id) REFERENCES plans(id),
FOREIGN KEY (batch_id) REFERENCES code_batches(id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- -------------------------------------------------------------------------
-- Audit log (all sensitive operations must be recorded)
-- -------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
actor VARCHAR(64) NOT NULL, -- e.g. "user:123" or "admin:7"
action VARCHAR(64) NOT NULL, -- e.g. "redeem", "ban", "node_drain"
target VARCHAR(128) NOT NULL, -- abbreviated; never contains plaintext codes
meta JSON NULL, -- structured context (no PII beyond IDs)
at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX idx_at (at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;