增加支付逻辑

This commit is contained in:
andy committed 2026-10-12 13:57:23 +08:00
1 parent 4ac786aafb
commit dfb2c91b8d
77 files changed
+6370 -45

No files matched your search

+41 -3
View File
@@ -20,6 +20,7 @@ import (
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/localstore"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/orchestration"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/prompt"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi"
@@ -56,9 +57,11 @@ type Options struct {
}
type App struct {
handler http.Handler
db *postgres.Module
worker *jobs.WorkerLoop
handler http.Handler
db *postgres.Module
worker *jobs.WorkerLoop
paymentCancel context.CancelFunc
paymentDone <-chan struct{}
}
func New(options Options) (*App, error) {
@@ -79,6 +82,13 @@ func New(options Options) (*App, error) {
if err != nil {
return nil, err
}
payConfig, err := paymentConfig(getenv, readFile)
if err != nil {
return nil, err
}
if payConfig.Enabled && config.Backend != postgres.BackendPostgres {
return nil, fmt.Errorf("WeChat payments require persistent PostgreSQL storage")
}
database, err := postgres.Open(ctx, config)
if err != nil {
return nil, err
@@ -174,6 +184,23 @@ func New(options Options) (*App, error) {
}
cookieSecure := startupGetenv("ZHINIAN_AUTH_COOKIE_SECURE")
publicBaseURL := firstAuthEnv(startupGetenv, "NEXT_PUBLIC_APP_URL", "ZHINIAN_PUBLIC_BASE_URL")
var paymentService *payments.Service
var paymentHTTPService httpapi.PaymentService
if payConfig.Enabled {
if !authConfig.Configured || !authConfig.Required {
return nil, fmt.Errorf("WeChat payments require configured, mandatory authentication")
}
provider, err := payments.NewWeChatProvider(payConfig)
if err != nil {
return nil, fmt.Errorf("initialize WeChat payments: %w", err)
}
paymentService = payments.NewService(database.Store, provider, payConfig)
paymentHTTPService = paymentService
} else if config.Backend == postgres.BackendPostgres {
// Historical payment and refund records remain available when collection is disabled.
paymentHTTPService = payments.NewService(database.Store, nil, payConfig)
}
paymentHandler := httpapi.NewPaymentHandler(platformAuthorizer, paymentHTTPService, publicBaseURL)
authPassword, err := httpapi.NewAuthPasswordHandler(httpapi.PasswordAuthConfig{
Configured: authConfig.Configured,
SessionSecret: authConfig.SessionSecret,
@@ -362,6 +389,7 @@ func New(options Options) (*App, error) {
mux.Handle("/api/billing/", billingHandler)
mux.Handle("/api/admin/billing", billingHandler)
mux.Handle("/api/admin/billing/", billingHandler)
mux.Handle("/api/payments/", paymentHandler)
mux.Handle("/api/usage", usageHandler)
mux.Handle("/api/admin/usage", usageHandler)
mux.Handle("/api/generations/", jobsHandler)
@@ -378,8 +406,14 @@ func New(options Options) (*App, error) {
mux.Handle("/api/v1/openapi.json", miscHandler)
mux.Handle("/", foundation)
closeOnError = false
var paymentCancel context.CancelFunc
var paymentDone <-chan struct{}
if paymentService != nil {
paymentCancel, paymentDone = startPaymentReconciliation(ctx, paymentService)
}
return &App{
db: database, handler: WithHTTPEventLogging(httpapi.WithRouteMethodCompatibility(mux), eventLogger), worker: workerLoop,
paymentCancel: paymentCancel, paymentDone: paymentDone,
}, nil
}
@@ -388,6 +422,10 @@ func (app *App) Handler() http.Handler {
}
func (app *App) Close() {
if app.paymentCancel != nil {
app.paymentCancel()
<-app.paymentDone
}
if app.worker != nil {
app.worker.Stop()
}
+130
View File
@@ -0,0 +1,130 @@
package application
import (
"context"
"fmt"
"log"
"net/url"
"strconv"
"strings"
"time"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres"
)
func paymentConfig(getenv postgres.Getenv, readFile postgres.ReadFile) (payments.Config, error) {
config := payments.Config{Enabled: parseBool(getenv("WECHAT_PAY_ENABLED")), MinAmountFen: 100, MaxAmountFen: 1000000, PresetAmountsFen: []int64{10000, 50000, 100000}, OrderTTL: 15 * time.Minute}
if !config.Enabled {
return config, nil
}
config.MchID = strings.TrimSpace(getenv("WECHAT_PAY_MCH_ID"))
config.AppID = strings.TrimSpace(getenv("WECHAT_PAY_APP_ID"))
config.NotifyURL = strings.TrimSpace(getenv("WECHAT_PAY_NOTIFY_URL"))
config.MerchantSerial = strings.TrimSpace(getenv("WECHAT_PAY_MERCHANT_SERIAL"))
config.APIv3Key = strings.TrimSpace(getenv("WECHAT_PAY_API_V3_KEY"))
config.WechatPublicKeyID = strings.TrimSpace(getenv("WECHAT_PAY_PUBLIC_KEY_ID"))
for _, value := range []struct{ name, value string }{
{"WECHAT_PAY_MCH_ID", config.MchID}, {"WECHAT_PAY_APP_ID", config.AppID},
{"WECHAT_PAY_MERCHANT_SERIAL", config.MerchantSerial}, {"WECHAT_PAY_PUBLIC_KEY_ID", config.WechatPublicKeyID},
} {
if value.value == "" {
return config, fmt.Errorf("%s is required when WECHAT_PAY_ENABLED=true", value.name)
}
}
if len(config.APIv3Key) != 32 {
return config, fmt.Errorf("WECHAT_PAY_API_V3_KEY must contain 32 bytes")
}
notify, err := url.Parse(config.NotifyURL)
if err != nil || notify.Scheme != "https" || notify.Host == "" || notify.User != nil || notify.RawQuery != "" || notify.Fragment != "" || notify.Path != "/api/payments/wechat/notify" {
return config, fmt.Errorf("WECHAT_PAY_NOTIFY_URL must be an HTTPS URL ending in /api/payments/wechat/notify without query or credentials")
}
config.PrivateKeyPEM, err = paymentPEM(getenv, readFile, "WECHAT_PAY_PRIVATE_KEY")
if err != nil {
return config, err
}
config.WechatPublicKeyPEM, err = paymentPEM(getenv, readFile, "WECHAT_PAY_PUBLIC_KEY")
if err != nil {
return config, err
}
for _, bound := range []struct {
name string
dest *int64
}{
{"WECHAT_PAY_MIN_AMOUNT_FEN", &config.MinAmountFen}, {"WECHAT_PAY_MAX_AMOUNT_FEN", &config.MaxAmountFen},
} {
if raw := strings.TrimSpace(getenv(bound.name)); raw != "" {
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 1 || value > 1000000 {
return config, fmt.Errorf("%s must be an integer between 1 and 1000000", bound.name)
}
*bound.dest = value
}
}
if config.MinAmountFen > config.MaxAmountFen {
return config, fmt.Errorf("payment minimum exceeds maximum")
}
if raw := strings.TrimSpace(getenv("WECHAT_PAY_PRESET_AMOUNTS_FEN")); raw != "" {
config.PresetAmountsFen = nil
parts := strings.Split(raw, ",")
if len(parts) > 8 {
return config, fmt.Errorf("WECHAT_PAY_PRESET_AMOUNTS_FEN allows at most 8 amounts")
}
seen := map[int64]bool{}
for _, part := range parts {
value, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || value < config.MinAmountFen || value > config.MaxAmountFen || seen[value] {
return config, fmt.Errorf("WECHAT_PAY_PRESET_AMOUNTS_FEN must contain unique integer amounts within the configured bounds")
}
seen[value] = true
config.PresetAmountsFen = append(config.PresetAmountsFen, value)
}
} else {
valid := make([]int64, 0, len(config.PresetAmountsFen))
for _, value := range config.PresetAmountsFen {
if value >= config.MinAmountFen && value <= config.MaxAmountFen {
valid = append(valid, value)
}
}
config.PresetAmountsFen = valid
}
return config, nil
}
func paymentPEM(getenv postgres.Getenv, readFile postgres.ReadFile, name string) (string, error) {
if path := strings.TrimSpace(getenv(name + "_FILE")); path != "" {
data, err := readFile(path)
if err != nil {
return "", fmt.Errorf("unable to read %s_FILE", name)
}
return string(data), nil
}
if value := strings.TrimSpace(getenv(name + "_PEM")); value != "" {
return strings.ReplaceAll(value, `\n`, "\n"), nil
}
return "", fmt.Errorf("%s_FILE or %s_PEM is required", name, name)
}
func startPaymentReconciliation(parent context.Context, service *payments.Service) (context.CancelFunc, <-chan struct{}) {
ctx, cancel := context.WithCancel(parent)
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
run, stop := context.WithTimeout(ctx, 25*time.Second)
err := service.Reconcile(run)
stop()
if err != nil && ctx.Err() == nil {
log.Print("wechat payment reconciliation encountered an error; pending orders will be retried")
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
return cancel, done
}
@@ -0,0 +1,48 @@
package application
import (
"fmt"
"strings"
"testing"
)
func TestPaymentConfigDisabledDoesNotRequireCredentials(t *testing.T) {
c, err := paymentConfig(func(string) string { return "" }, nil)
if err != nil || c.Enabled {
t.Fatalf("config=%+v err=%v", c, err)
}
}
func TestPaymentConfigLoadsOnlyServerCredentialsAndValidatesLimits(t *testing.T) {
values := map[string]string{
"WECHAT_PAY_ENABLED": "true", "WECHAT_PAY_MCH_ID": "merchant", "WECHAT_PAY_APP_ID": "wxapp",
"WECHAT_PAY_NOTIFY_URL": "https://pay.example.test/api/payments/wechat/notify",
"WECHAT_PAY_MERCHANT_SERIAL": "serial", "WECHAT_PAY_API_V3_KEY": strings.Repeat("a", 32),
"WECHAT_PAY_PUBLIC_KEY_ID": "PUB_KEY_ID_test", "WECHAT_PAY_PRIVATE_KEY_FILE": "/private/key.pem",
"WECHAT_PAY_PUBLIC_KEY_PEM": "PUBLIC\\nKEY", "WECHAT_PAY_MIN_AMOUNT_FEN": "1",
"WECHAT_PAY_PRESET_AMOUNTS_FEN": "1,10000,50000",
}
read := func(path string) ([]byte, error) {
if path != "/private/key.pem" {
return nil, fmt.Errorf("unexpected file")
}
return []byte("PRIVATE"), nil
}
c, err := paymentConfig(func(key string) string { return values[key] }, read)
if err != nil || c.MinAmountFen != 1 || c.PrivateKeyPEM != "PRIVATE" || c.WechatPublicKeyPEM != "PUBLIC\nKEY" || len(c.PresetAmountsFen) != 3 {
t.Fatalf("config load err=%v", err)
}
for _, test := range []struct{ key, value string }{
{"WECHAT_PAY_NOTIFY_URL", "http://pay.example.test/api/payments/wechat/notify"},
{"WECHAT_PAY_NOTIFY_URL", "https://user:secret@pay.example.test/api/payments/wechat/notify"},
{"WECHAT_PAY_API_V3_KEY", "secret"}, {"WECHAT_PAY_MAX_AMOUNT_FEN", "1000001"},
{"WECHAT_PAY_PRESET_AMOUNTS_FEN", "100,100"}, {"WECHAT_PAY_PRESET_AMOUNTS_FEN", "1.50"},
} {
old := values[test.key]
values[test.key] = test.value
_, err := paymentConfig(func(key string) string { return values[key] }, read)
values[test.key] = old
if err == nil || strings.Contains(err.Error(), "user:secret") {
t.Fatalf("key=%s err=%v", test.key, err)
}
}
}