增加支付逻辑
This commit is contained in:
1 parent
4ac786aafb
commit
dfb2c91b8d
77 files changed
+6370
-45
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user