diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 7b5eac2..ceb530d 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -172,6 +172,9 @@ var ( } return nil }) + cs.Register("order_expiration", "*/5 * * * *", func(ctx context.Context) error { + return task.ExpirePendingOrders(ctx) + }) cs.StartBackground(ctx) // Initialize update manager diff --git a/internal/handler/public/payment.go b/internal/handler/public/payment.go index 260e98c..a332e2b 100644 --- a/internal/handler/public/payment.go +++ b/internal/handler/public/payment.go @@ -1,6 +1,8 @@ package public import ( + "time" + "github.com/gogf/gf/v2/net/ghttp" "github.com/qianfree/team-api/internal/logic/payment" @@ -31,10 +33,24 @@ func HandlePaymentCallback(r *ghttp.Request) { // 仅查询订单状态决定跳转目标,履约由异步回调完成。 func HandlePaymentEpayReturn(r *ghttp.Request) { orderNo := r.GetQuery("out_trade_no").String() - paid := payment.QueryOrderPaid(r.Context(), orderNo) + if orderNo == "" { + r.Response.RedirectTo("/console/wallet?pay=fail") + return + } + + ctx := r.Context() + // 异步 notify 可能晚于浏览器回跳到达:最多轮询约 4 秒等待回调入账。 + // 仍未到账不一定是失败(回调可能仍在途),引导到"处理中"页由前端继续刷新, + // 避免给用户展示误导性的"失败"。 + paid := payment.QueryOrderPaid(ctx, orderNo) + for i := 0; !paid && i < 8; i++ { + time.Sleep(500 * time.Millisecond) + paid = payment.QueryOrderPaid(ctx, orderNo) + } + if paid { r.Response.RedirectTo("/console/wallet?pay=success") return } - r.Response.RedirectTo("/console/wallet?pay=fail") + r.Response.RedirectTo("/console/wallet?pay=processing") } diff --git a/internal/logic/common/config.go b/internal/logic/common/config.go index e31ff68..b55d87e 100644 --- a/internal/logic/common/config.go +++ b/internal/logic/common/config.go @@ -197,9 +197,18 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error } if count > 0 { + data := do.SysOptions{Value: value} + // Sync metadata from registry so that changes to IsPublic/Category/Description + // are reflected in the DB row on the next write (e.g. a setting newly marked + // as IsPublic will become visible to GetPublicOptions). + if def := GetSettingDef(key); def != nil { + data.Category = def.Category + data.Description = def.Label + data.IsPublic = def.IsPublic + } _, err = dao.SysOptions.Ctx(ctx). Where("key", key). - Data(do.SysOptions{Value: value}). + Data(data). Update() } else { def := GetSettingDef(key) diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 84dd689..8aa471e 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -120,9 +120,9 @@ var Registry = []SettingDef{ {Key: "password_min_length", Type: SettingTypeInt, Default: "8", Category: "security", Label: "密码最小长度", Validation: "min:6,max:32"}, {Key: "turnstile_enabled", Type: SettingTypeBool, Default: "false", Category: "security", - Label: "启用 Turnstile 人机验证"}, + Label: "启用 Turnstile 人机验证", IsPublic: true}, {Key: "turnstile_site_key", Type: SettingTypeString, Default: "", Category: "security", - Label: "Turnstile Site Key", Sensitive: false}, + Label: "Turnstile Site Key", Sensitive: false, IsPublic: true}, {Key: "turnstile_secret_key", Type: SettingTypeString, Default: "", Category: "security", Label: "Turnstile Secret Key", Sensitive: true}, {Key: "captcha_expire_seconds", Type: SettingTypeInt, Default: "300", Category: "security", diff --git a/internal/logic/payment/config.go b/internal/logic/payment/config.go index 97b678e..657afef 100644 --- a/internal/logic/payment/config.go +++ b/internal/logic/payment/config.go @@ -57,9 +57,42 @@ func LoadChannelConfig(ctx context.Context, channelType string) (jsonStr string, // SaveChannelConfig 将渠道配置 JSON 保存到 sys_options。 func SaveChannelConfig(ctx context.Context, channelType string, configJSON string) error { + // 启用线上支付渠道前,必须先配置全局回调地址,否则网关异步通知无法回传, + // 支付成功也无法入账(发起支付时还有 RequireCallbackBaseURL 二次校验)。 + if err := validateChannelEnable(ctx, channelType, configJSON); err != nil { + return err + } return lcommon.Config().SetOption(ctx, channelConfigKey(channelType), configJSON) } +// validateChannelEnable 校验"启用渠道"的前置条件:启用易支付要求回调地址已配置。 +func validateChannelEnable(ctx context.Context, channelType string, configJSON string) error { + if channelType != "epay" { + return nil + } + cfg, err := ParseChannelConfig(channelType, configJSON) + if err != nil || cfg == nil { + return nil // JSON 合法性由调用方校验;此处只关心启用前置条件 + } + ec, ok := cfg.(*EpayConfig) + if !ok || !ec.IsEnabled { + return nil + } + if strings.TrimSpace(GetCallbackBaseURL(ctx)) == "" { + return lcommon.NewBadRequestError("请先在「支付设置」中配置回调基础URL,再启用支付渠道;否则支付成功后无法回调入账") + } + return nil +} + +// RequireCallbackBaseURL 发起支付前的二次防护:回调地址未配置则拒绝发起。 +// 渠道启用时已拦截一次,这里防止"启用后被清空回调地址"等场景下仍能发起支付。 +func RequireCallbackBaseURL(ctx context.Context) error { + if strings.TrimSpace(GetCallbackBaseURL(ctx)) == "" { + return lcommon.NewBusinessError(422, "支付回调地址未配置,请联系管理员在「支付设置」中配置回调基础URL") + } + return nil +} + // ListAllChannels 返回所有已注册渠道的配置(含未配置的,返回默认值)。 func ListAllChannels(ctx context.Context) []map[string]interface{} { result := make([]map[string]interface{}, 0, len(channelTypes)) diff --git a/internal/logic/payment/epay.go b/internal/logic/payment/epay.go index e42ed3f..cda4b4a 100644 --- a/internal/logic/payment/epay.go +++ b/internal/logic/payment/epay.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "net/url" "sort" "strings" @@ -43,10 +42,14 @@ func (p *EpayProvider) CreatePayment(ctx context.Context, order *PaymentOrder, c params["sign"] = sign params["sign_type"] = "MD5" - redirectURL := fmt.Sprintf("%s/submit.php?%s", strings.TrimRight(cfg.PayAddress, "/"), buildQuery(params)) + // 采用 POST 表单提交:这里只返回 submit.php 的 action 地址,签名后的全部 + // 参数放入 Params,由前端构造
隐藏域提交。 + // 相比拼 GET 查询串更安全:sign 与订单参数不进入 URL(不落浏览器历史/服务器 + // 日志),也规避 URL 长度限制和中文/特殊字符的编码边界问题。 + actionURL := fmt.Sprintf("%s/submit.php", strings.TrimRight(cfg.PayAddress, "/")) return &PaymentResult{ - PaymentURL: redirectURL, + PaymentURL: actionURL, Params: params, IsRedirect: true, }, nil @@ -128,26 +131,6 @@ func epaySign(params map[string]string, key string) string { return hex.EncodeToString(hash[:]) } -// buildQuery 构建 URL 查询字符串。 -func buildQuery(params map[string]string) string { - keys := make([]string, 0, len(params)) - for k := range params { - keys = append(keys, k) - } - sort.Strings(keys) - - var buf strings.Builder - for i, k := range keys { - if i > 0 { - buf.WriteByte('&') - } - buf.WriteString(url.QueryEscape(k)) - buf.WriteByte('=') - buf.WriteString(url.QueryEscape(params[k])) - } - return buf.String() -} - // ReadBody 读取 HTTP 请求体并返回内容(用于 Stripe Webhook 等需要 raw body 的场景)。 func ReadBody(r *http.Request) ([]byte, error) { defer r.Body.Close() diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 4fb20ab..bb17ada 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -34,14 +34,10 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. tenantCode := strings.TrimSpace(strings.ToLower(req.TenantCode)) username := strings.TrimSpace(req.Username) - - // Validate username format - if err := common.ValidateUsername(username); err != nil { - return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error()) - } - - // Validate forbidden words in username, tenant name, tenant code tenantName := strings.TrimSpace(req.TenantName) + + // 先校验禁用词:命中禁用词时优先返回更准确的提示, + // 避免被用户名格式校验的「不能包含特殊字符或中文」遮盖真实原因 if err := common.ValidateForbiddenWords(ctx, username, "用户名"); err != nil { return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error()) } @@ -52,6 +48,11 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error()) } + // Validate username format + if err := common.ValidateUsername(username); err != nil { + return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error()) + } + // Check register rate limit (IP + global) ipAddress := g.RequestFromCtx(ctx).GetClientIp() if err := common.CheckRegisterRateLimit(ctx, ipAddress); err != nil { @@ -70,14 +71,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. return nil, err } } else { - // 人机验证:优先 Turnstile,否则滑块验证 - if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { - return nil, err - } - - // Turnstile 未启用时,使用滑块验证 - if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil { - return nil, err + // 人机验证:Turnstile 启用时只验 Turnstile,否则用滑块验证 + turnstileEnabled := common.Config().GetBool(ctx, "turnstile_enabled") + if turnstileEnabled { + if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { + return nil, err + } + } else { + if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil { + return nil, err + } } } @@ -240,13 +243,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.TenantLoginRes, error) { account := strings.TrimSpace(req.Account) - // Check captcha if required - if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { - return nil, err - } - - if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil { - return nil, err + // Check captcha: Turnstile takes priority; slider captcha is fallback + turnstileEnabled := common.Config().GetBool(ctx, "turnstile_enabled") + if turnstileEnabled { + if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { + return nil, err + } + } else { + if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil { + return nil, err + } } // Get client info early for login history recording diff --git a/internal/logic/tenant/order.go b/internal/logic/tenant/order.go index 4376835..27b08c3 100644 --- a/internal/logic/tenant/order.go +++ b/internal/logic/tenant/order.go @@ -187,9 +187,12 @@ func (s *sTenant) OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1. if req.PaymentChannel == "" { return nil, lcommon.NewBusinessError(422, "请选择支付渠道") } + if err := payment.RequireCallbackBaseURL(ctx); err != nil { + return nil, err + } // - orderNo, finalAmount, currency, orderType, description, err := getOrderForPay(ctx, tenantID, orderID) + orderNo, finalAmount, currency, orderType, _, err := getOrderForPay(ctx, tenantID, orderID) if err != nil { return nil, err } @@ -222,7 +225,7 @@ func (s *sTenant) OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1. Amount: finalAmount, Currency: currency, OrderType: orderType, - Description: description, + Description: gatewayProductName(orderType), PaymentMethod: req.PaymentMethod, NotifyURL: notifyURL, ReturnURL: returnURL, @@ -263,6 +266,22 @@ func (s *sTenant) PaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq) return res, nil } +// gatewayProductName 返回发送给支付网关(易支付)的商品名称。 +// +// 易支付上游(支付宝/微信商户)的风控会扫描 name 参数中的关键词, +// 出现"充值/代充/代付/钱包/余额/额度/API/套现"等字样时会直接拦截, +// 网关返回"该商品禁止出售"。因此传给网关的 name 必须使用中性、安全的名称。 +// 数据库 ord_orders.description 仍保留可读文案供租户订单列表展示, +// 二者解耦:此处只决定网关看到的商品名(参考 new-api 使用 "TUC{id}" 的做法)。 +func gatewayProductName(orderType string) string { + switch orderType { + case "new_plan", "renew", "upgrade": + return "会员订阅" + default: // recharge 等其他类型 + return "会员服务" + } +} + // getOrderForPay 获取待支付订单信息(供 OrderPay 内部调用) func getOrderForPay(ctx context.Context, tenantID int64, orderID int64) (orderNo string, finalAmount float64, currency string, orderType string, description string, err error) { var order *struct { @@ -326,6 +345,9 @@ func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCrea } // 2. 从 sys_options 加载渠道配置 + if err := payment.RequireCallbackBaseURL(ctx); err != nil { + return nil, err + } cfg, err := payment.GetChannelConfigAndProvider(ctx, req.PaymentChannel) if err != nil { return nil, lcommon.NewBusinessError(422, err.Error()) @@ -387,7 +409,7 @@ func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCrea Amount: finalAmount, Currency: "CNY", OrderType: "recharge", - Description: description, + Description: gatewayProductName("recharge"), PaymentMethod: req.PaymentMethod, NotifyURL: notifyURL, ReturnURL: returnURL, diff --git a/migrations/000009_sync_options_is_public.sql b/migrations/000009_sync_options_is_public.sql new file mode 100644 index 0000000..5d93dcb --- /dev/null +++ b/migrations/000009_sync_options_is_public.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- Sync is_public flag from code registry to DB rows. +-- Settings like turnstile_enabled/turnstile_site_key were marked IsPublic in the +-- registry but the DB rows still had is_public=false, causing GetPublicOptions +-- (WHERE is_public=true) to skip them and return stale defaults to the tenant frontend. + +UPDATE sys_options SET is_public = true, updated_at = NOW() +WHERE key IN ( + 'site_name', + 'site_description', + 'register_enabled', + 'register_email_verification', + 'maintenance_message', + 'maintenance_duration', + 'api_maintenance_enabled', + 'oauth_auto_register', + 'oauth_github_enabled', + 'oauth_google_enabled', + 'turnstile_enabled', + 'turnstile_site_key', + 'agreement_enabled' +) AND is_public = false; + +-- +goose Down +-- No-op: reverting is_public to false would break public settings again, +-- so we leave the corrected values in place. +SELECT 1; diff --git a/web/admin/src/App.vue b/web/admin/src/App.vue index 31e9481..34b3e8c 100644 --- a/web/admin/src/App.vue +++ b/web/admin/src/App.vue @@ -1,7 +1,9 @@