合并 internal → main: 退款/提现/激活/CI 全面优化 #4

Open
shanshanzhong147 wants to merge 76 commits from internal into main
10 changed files with 717 additions and 0 deletions
Showing only changes of commit 5e4cc33ff6 - Show all commits
+1
View File
@@ -42,6 +42,7 @@ logs/
# ==================== 测试 ====================
/test/
*_test.go
!tests/acceptance/*_test.go
*_test_config.go
**/logtest/
*_test.yaml
+41
View File
@@ -0,0 +1,41 @@
# Acceptance Tests
This package contains black-box Go tests for the staging API surface.
## Local smoke run
```bash
go test ./tests/acceptance/... -staging-url=https://tapi.hifast.biz
```
Without credentials, tests that require admin/user login or NodeSecret are skipped and unauthenticated error-path checks still run.
## Required secrets
The staging workflow should provide these values through GitHub Actions secrets or environment variables:
- `ACCEPTANCE_ADMIN_EMAIL`
- `ACCEPTANCE_ADMIN_PASSWORD`
- `ACCEPTANCE_USER_EMAIL`
- `ACCEPTANCE_USER_PASSWORD`
- `STAGING_DB_HOST`
- `STAGING_DB_USER`
- `STAGING_DB_PASSWORD`
- `STAGING_DB_NAME`
- `STAGING_REDIS_ADDR`
- `STAGING_REDIS_PASSWORD`
- `STAGING_BASE_URL`
`ACCEPTANCE_NODE_SECRET` is optional. When DB credentials are present, the loader reads NodeSecret from the staging `system` table instead of storing it as a long-lived secret.
## Useful flags
- `-staging-url`: API base URL.
- `-run-id`: run identifier used for QA-owned transient data.
- `-report-path`: write a JSON summary artifact.
- `-seed-sql`: seed SQL path, default `fixtures/seed.sql`.
- `-node-server-id` and `-node-protocol`: node smoke target.
## Fixture rules
Seed data must use a `qa_acceptance_` prefix or the provided `RUN_ID`. Cleanup must only delete QA-prefixed rows and Redis keys. Do not update or delete production-like staging data.
+111
View File
@@ -0,0 +1,111 @@
package acceptance
import (
"context"
"net/http"
"net/url"
"os"
"testing"
)
func TestMain(m *testing.M) {
code := m.Run()
if err := writeReport(testConfig); err != nil && code == 0 {
code = 1
}
os.Exit(code)
}
func TestFixtureSeedLoader(t *testing.T) {
c := cfg(t)
ctx := context.Background()
newFixtureLoader(c).SeedIfConfigured(t, ctx)
}
func TestPublicSmoke(t *testing.T) {
c := cfg(t)
ctx := context.Background()
client := newClient(c)
t.Run("happy path user info", func(t *testing.T) {
token := loginUser(t, ctx, client, c)
envelope, raw, status, err := client.WithToken(token).Get(ctx, "/v1/public/user/info", nil)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assertOK(t, envelope, raw, status)
recordCase(t, "public", http.MethodGet, "/v1/public/user/info", "200 envelope")
})
t.Run("error path missing auth", func(t *testing.T) {
envelope, raw, status, err := client.Get(ctx, "/v1/public/user/info", nil)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assertAPIError(t, envelope, raw, status)
recordCase(t, "public", http.MethodGet, "/v1/public/user/info", "non-200 envelope without auth")
})
}
func TestAdminSmoke(t *testing.T) {
c := cfg(t)
ctx := context.Background()
client := newClient(c)
t.Run("happy path current admin", func(t *testing.T) {
token := loginAdmin(t, ctx, client, c)
envelope, raw, status, err := client.WithToken(token).Get(ctx, "/v1/admin/user/current", nil)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assertOK(t, envelope, raw, status)
recordCase(t, "admin", http.MethodGet, "/v1/admin/user/current", "200 envelope")
})
t.Run("error path missing auth", func(t *testing.T) {
envelope, raw, status, err := client.Get(ctx, "/v1/admin/user/current", nil)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assertAPIError(t, envelope, raw, status)
recordCase(t, "admin", http.MethodGet, "/v1/admin/user/current", "non-200 envelope without auth")
})
}
func TestNodeSmoke(t *testing.T) {
c := cfg(t)
ctx := context.Background()
client := newClient(c)
loader := newFixtureLoader(c)
t.Run("happy path server config", func(t *testing.T) {
secret, err := loader.NodeSecret(ctx)
if err != nil {
t.Fatalf("load node secret: %v", err)
}
secret = requireSecret(t, "node secret", secret)
query := url.Values{}
query.Set("secret_key", secret)
query.Set("protocols", c.NodeProtocol)
envelope, raw, status, err := client.Get(ctx, "/v2/server/"+c.NodeServerID, query)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assertOK(t, envelope, raw, status)
recordCase(t, "node", http.MethodGet, "/v2/server/{server_id}", "200 envelope")
})
t.Run("error path invalid secret", func(t *testing.T) {
query := url.Values{}
query.Set("secret_key", "invalid-"+c.RunID)
query.Set("protocols", c.NodeProtocol)
_, raw, status, err := client.Get(ctx, "/v1/server/config", query)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if status != http.StatusForbidden {
t.Fatalf("http status = %d, want 403; body=%s", status, sanitizeBody(raw))
}
recordCase(t, "node", http.MethodGet, "/v1/server/config", "403 with invalid secret")
})
}
+45
View File
@@ -0,0 +1,45 @@
package acceptance
import (
"context"
"encoding/json"
"testing"
)
type loginResponse struct {
Token string `json:"token"`
}
func loginAdmin(t *testing.T, ctx context.Context, client *Client, c Config) string {
t.Helper()
return login(t, ctx, client, "/v1/auth/admin/login", c.AdminEmail, c.AdminPassword)
}
func loginUser(t *testing.T, ctx context.Context, client *Client, c Config) string {
t.Helper()
return login(t, ctx, client, "/v1/auth/login", c.UserEmail, c.UserPassword)
}
func login(t *testing.T, ctx context.Context, client *Client, path string, email string, password string) string {
t.Helper()
requireSecret(t, "login email", email)
requireSecret(t, "login password", password)
envelope, raw, status, err := client.PostJSON(ctx, path, map[string]string{
"email": email,
"password": password,
})
if err != nil {
t.Fatalf("login request failed: %v", err)
}
assertOK(t, envelope, raw, status)
var data loginResponse
if err := json.Unmarshal(envelope.Data, &data); err != nil {
t.Fatalf("decode login response: %v", err)
}
if data.Token == "" {
t.Fatal("login response token is empty")
}
return data.Token
}
+130
View File
@@ -0,0 +1,130 @@
package acceptance
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
)
type Client struct {
baseURL string
httpClient *http.Client
token string
}
type Envelope struct {
Code uint32 `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
func newClient(c Config) *Client {
return &Client{
baseURL: strings.TrimRight(c.StagingURL, "/"),
httpClient: &http.Client{
Timeout: c.HTTPTimeout,
},
}
}
func (c *Client) WithToken(token string) *Client {
next := *c
next.token = token
return &next
}
func (c *Client) Get(ctx context.Context, path string, query url.Values) (*Envelope, []byte, int, error) {
return c.Do(ctx, http.MethodGet, path, query, nil)
}
func (c *Client) PostJSON(ctx context.Context, path string, payload any) (*Envelope, []byte, int, error) {
return c.Do(ctx, http.MethodPost, path, nil, payload)
}
func (c *Client) Do(ctx context.Context, method string, path string, query url.Values, payload any) (*Envelope, []byte, int, error) {
target, err := url.Parse(c.baseURL + "/" + strings.TrimLeft(path, "/"))
if err != nil {
return nil, nil, 0, fmt.Errorf("build request URL: %w", err)
}
if query != nil {
target.RawQuery = query.Encode()
}
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, nil, 0, fmt.Errorf("marshal request body: %w", err)
}
body = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, target.String(), body)
if err != nil {
return nil, nil, 0, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "hifast-acceptance/1.0")
req.Header.Set("Login-Type", "email")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, nil, 0, fmt.Errorf("%s %s: %w", method, target.Redacted(), err)
}
defer func() {
_ = resp.Body.Close()
}()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, nil, resp.StatusCode, fmt.Errorf("read response: %w", err)
}
envelope := &Envelope{}
if strings.Contains(resp.Header.Get("Content-Type"), "application/json") && len(raw) > 0 {
if err := json.Unmarshal(raw, envelope); err != nil {
return nil, raw, resp.StatusCode, fmt.Errorf("decode response envelope: %w", err)
}
}
return envelope, raw, resp.StatusCode, nil
}
func assertOK(t *testing.T, envelope *Envelope, raw []byte, status int) {
t.Helper()
if status != http.StatusOK {
t.Fatalf("http status = %d, want 200; body=%s", status, sanitizeBody(raw))
}
if envelope == nil || envelope.Code != 200 {
t.Fatalf("api code = %d, want 200; msg=%q body=%s", envelope.Code, envelope.Msg, sanitizeBody(raw))
}
}
func assertAPIError(t *testing.T, envelope *Envelope, raw []byte, status int) {
t.Helper()
if status != http.StatusOK {
t.Fatalf("http status = %d, want 200 API envelope; body=%s", status, sanitizeBody(raw))
}
if envelope == nil || envelope.Code == 200 {
t.Fatalf("api code = %d, want non-200; body=%s", envelope.Code, sanitizeBody(raw))
}
}
func sanitizeBody(raw []byte) string {
value := string(raw)
if len(value) > 512 {
value = value[:512] + "...<truncated>"
}
return value
}
+112
View File
@@ -0,0 +1,112 @@
package acceptance
import (
"flag"
"fmt"
"os"
"strings"
"testing"
"time"
)
type Config struct {
StagingURL string
AdminEmail string
AdminPassword string
UserEmail string
UserPassword string
NodeSecret string
NodeServerID string
NodeProtocol string
RunID string
ReportPath string
SeedSQLPath string
DB DBConfig
Redis RedisConfig
HTTPTimeout time.Duration
}
type DBConfig struct {
Host string
User string
Password string
Name string
}
type RedisConfig struct {
Addr string
Password string
DB int
}
var testConfig = Config{
HTTPTimeout: 15 * time.Second,
NodeServerID: "31",
NodeProtocol: "trojan",
SeedSQLPath: "fixtures/seed.sql",
}
func init() {
flag.StringVar(&testConfig.StagingURL, "staging-url", getenv("STAGING_BASE_URL", "https://tapi.hifast.biz"), "staging base URL")
flag.StringVar(&testConfig.AdminEmail, "admin-email", os.Getenv("ACCEPTANCE_ADMIN_EMAIL"), "admin login email")
flag.StringVar(&testConfig.AdminPassword, "admin-password", os.Getenv("ACCEPTANCE_ADMIN_PASSWORD"), "admin login password")
flag.StringVar(&testConfig.UserEmail, "user-email", os.Getenv("ACCEPTANCE_USER_EMAIL"), "user login email")
flag.StringVar(&testConfig.UserPassword, "user-password", os.Getenv("ACCEPTANCE_USER_PASSWORD"), "user login password")
flag.StringVar(&testConfig.NodeSecret, "node-secret", os.Getenv("ACCEPTANCE_NODE_SECRET"), "node secret; read from staging DB when omitted")
flag.StringVar(&testConfig.NodeServerID, "node-server-id", getenv("ACCEPTANCE_NODE_SERVER_ID", testConfig.NodeServerID), "server id for node smoke tests")
flag.StringVar(&testConfig.NodeProtocol, "node-protocol", getenv("ACCEPTANCE_NODE_PROTOCOL", testConfig.NodeProtocol), "server protocol for node smoke tests")
flag.StringVar(&testConfig.RunID, "run-id", getenv("ACCEPTANCE_RUN_ID", defaultRunID()), "acceptance run id")
flag.StringVar(&testConfig.ReportPath, "report-path", os.Getenv("ACCEPTANCE_REPORT_PATH"), "optional JSON report path")
flag.StringVar(&testConfig.SeedSQLPath, "seed-sql", getenv("ACCEPTANCE_SEED_SQL", testConfig.SeedSQLPath), "seed SQL file path")
flag.StringVar(&testConfig.DB.Host, "db-host", os.Getenv("STAGING_DB_HOST"), "staging DB host")
flag.StringVar(&testConfig.DB.User, "db-user", os.Getenv("STAGING_DB_USER"), "staging DB user")
flag.StringVar(&testConfig.DB.Password, "db-password", os.Getenv("STAGING_DB_PASSWORD"), "staging DB password")
flag.StringVar(&testConfig.DB.Name, "db-name", os.Getenv("STAGING_DB_NAME"), "staging DB name")
flag.StringVar(&testConfig.Redis.Addr, "redis-addr", os.Getenv("STAGING_REDIS_ADDR"), "staging Redis addr")
flag.StringVar(&testConfig.Redis.Password, "redis-password", os.Getenv("STAGING_REDIS_PASSWORD"), "staging Redis password")
flag.IntVar(&testConfig.Redis.DB, "redis-db", getenvInt("STAGING_REDIS_DB", 0), "staging Redis DB")
}
func cfg(t *testing.T) Config {
t.Helper()
if strings.TrimSpace(testConfig.StagingURL) == "" {
t.Fatal("staging-url is required")
}
return testConfig
}
func requireSecret(t *testing.T, name string, value string) string {
t.Helper()
if strings.TrimSpace(value) == "" {
t.Skipf("%s is required for this acceptance test", name)
}
return value
}
func getenv(key string, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func getenvInt(key string, fallback int) int {
value := os.Getenv(key)
if value == "" {
return fallback
}
var parsed int
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil {
return fallback
}
return parsed
}
func defaultRunID() string {
runID := os.Getenv("GITHUB_RUN_ID")
attempt := getenv("GITHUB_RUN_ATTEMPT", "1")
if runID == "" {
return "qa_local"
}
return "qa_" + runID + "_" + attempt
}
+132
View File
@@ -0,0 +1,132 @@
package acceptance
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/redis/go-redis/v9"
)
type FixtureLoader struct {
cfg Config
}
func newFixtureLoader(c Config) *FixtureLoader {
return &FixtureLoader{cfg: c}
}
func (l *FixtureLoader) SeedIfConfigured(t *testing.T, ctx context.Context) {
t.Helper()
if !l.hasDBConfig() {
t.Log("staging DB config not provided; skipping seed loader")
return
}
sqlBytes, err := os.ReadFile(l.cfg.SeedSQLPath)
if err != nil {
t.Fatalf("read seed SQL %q: %v", l.cfg.SeedSQLPath, err)
}
db, err := sql.Open("mysql", l.dsn(true))
if err != nil {
t.Fatalf("open staging DB: %v", err)
}
defer func() {
_ = db.Close()
}()
if err := db.PingContext(ctx); err != nil {
t.Fatalf("ping staging DB: %v", err)
}
statements := splitSQL(string(sqlBytes))
for _, statement := range statements {
statement = strings.ReplaceAll(statement, "{{RUN_ID}}", l.cfg.RunID)
if _, err := db.ExecContext(ctx, statement); err != nil {
t.Fatalf("execute seed SQL: %v; statement=%s", err, statement)
}
}
}
func (l *FixtureLoader) NodeSecret(ctx context.Context) (string, error) {
if l.cfg.NodeSecret != "" {
return l.cfg.NodeSecret, nil
}
if !l.hasDBConfig() {
return "", nil
}
db, err := sql.Open("mysql", l.dsn(false))
if err != nil {
return "", fmt.Errorf("open staging DB: %w", err)
}
defer func() {
_ = db.Close()
}()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var secret string
err = db.QueryRowContext(ctx, "SELECT value FROM system WHERE category = 'server' AND `key` = 'NodeSecret' LIMIT 1").Scan(&secret)
if err != nil {
return "", fmt.Errorf("read NodeSecret from staging DB: %w", err)
}
return secret, nil
}
func (l *FixtureLoader) RedisClient() *redis.Client {
if l.cfg.Redis.Addr == "" {
return nil
}
return redis.NewClient(&redis.Options{
Addr: l.cfg.Redis.Addr,
Password: l.cfg.Redis.Password,
DB: l.cfg.Redis.DB,
})
}
func (l *FixtureLoader) hasDBConfig() bool {
return l.cfg.DB.Host != "" && l.cfg.DB.User != "" && l.cfg.DB.Name != ""
}
func (l *FixtureLoader) dsn(multiStatements bool) string {
params := "parseTime=true&timeout=5s&readTimeout=10s&writeTimeout=10s"
if multiStatements {
params += "&multiStatements=true"
}
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s",
l.cfg.DB.User,
l.cfg.DB.Password,
l.cfg.DB.Host,
l.cfg.DB.Name,
params,
)
}
func splitSQL(script string) []string {
parts := strings.Split(script, ";")
statements := make([]string, 0, len(parts))
for _, part := range parts {
lines := strings.Split(part, "\n")
kept := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "--") {
continue
}
kept = append(kept, line)
}
statement := strings.Join(kept, "\n")
if statement == "" || strings.HasPrefix(statement, "--") {
continue
}
statements = append(statements, statement)
}
return statements
}
+4
View File
@@ -0,0 +1,4 @@
-- Idempotent staging acceptance fixture seed.
-- HIF-5 lands the loader and smoke skeleton; HIF-6/HIF-7 can extend this file
-- with concrete qa_acceptance_ rows after the MVP surface is expanded.
SELECT 1;
+77
View File
@@ -0,0 +1,77 @@
package acceptance
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSplitSQLDropsCommentsAndKeepsStatements(t *testing.T) {
script := `
-- comment before first statement
SELECT 1;
-- comment before second statement
SELECT '{{RUN_ID}}';
`
statements := splitSQL(script)
if len(statements) != 2 {
t.Fatalf("len(statements) = %d, want 2: %#v", len(statements), statements)
}
if statements[0] != "SELECT 1" {
t.Fatalf("first statement = %q", statements[0])
}
if statements[1] != "SELECT '{{RUN_ID}}'" {
t.Fatalf("second statement = %q", statements[1])
}
}
func TestSanitizeBodyTruncatesLongBodies(t *testing.T) {
raw := []byte(strings.Repeat("a", 600))
got := sanitizeBody(raw)
if len(got) <= 512 {
t.Fatalf("sanitized body length = %d, want truncation marker", len(got))
}
if !strings.Contains(got, "<truncated>") {
t.Fatalf("sanitized body missing truncation marker: %q", got)
}
}
func TestWriteReportCreatesJSONArtifact(t *testing.T) {
dir := t.TempDir()
reportPath := filepath.Join(dir, "report.json")
original := acceptanceReporter
acceptanceReporter = &Reporter{started: time.Now()}
t.Cleanup(func() {
acceptanceReporter = original
})
recordCase(t, "public", "GET", "/v1/public/user/info", "200 envelope")
if err := writeReport(Config{
RunID: "qa_test",
StagingURL: "https://tapi.hifast.biz",
ReportPath: reportPath,
}); err != nil {
t.Fatalf("write report: %v", err)
}
raw, err := os.ReadFile(reportPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
var report Report
if err := json.Unmarshal(raw, &report); err != nil {
t.Fatalf("decode report: %v", err)
}
if report.RunID != "qa_test" {
t.Fatalf("run id = %q", report.RunID)
}
if len(report.Cases) != 1 {
t.Fatalf("cases len = %d, want 1", len(report.Cases))
}
}
+64
View File
@@ -0,0 +1,64 @@
package acceptance
import (
"encoding/json"
"os"
"sync"
"testing"
"time"
)
type Reporter struct {
mu sync.Mutex
started time.Time
cases []ReportCase
}
type ReportCase struct {
Name string `json:"name"`
Domain string `json:"domain"`
Method string `json:"method"`
Path string `json:"path"`
Expected string `json:"expected"`
}
type Report struct {
RunID string `json:"run_id"`
BaseURL string `json:"base_url"`
Duration string `json:"duration"`
Cases []ReportCase `json:"cases"`
}
var acceptanceReporter = &Reporter{started: time.Now()}
func recordCase(t *testing.T, domain string, method string, path string, expected string) {
t.Helper()
acceptanceReporter.mu.Lock()
defer acceptanceReporter.mu.Unlock()
acceptanceReporter.cases = append(acceptanceReporter.cases, ReportCase{
Name: t.Name(),
Domain: domain,
Method: method,
Path: path,
Expected: expected,
})
}
func writeReport(c Config) error {
if c.ReportPath == "" {
return nil
}
acceptanceReporter.mu.Lock()
defer acceptanceReporter.mu.Unlock()
report := Report{
RunID: c.RunID,
BaseURL: c.StagingURL,
Duration: time.Since(acceptanceReporter.started).String(),
Cases: append([]ReportCase(nil), acceptanceReporter.cases...),
}
out, err := json.MarshalIndent(report, "", " ")
if err != nil {
return err
}
return os.WriteFile(c.ReportPath, out, 0o600)
}