diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8eddcd2..f1e3a71 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -99,6 +99,9 @@ func main() { f.BindRoute("POST", "/mapi.php", service.PayMapiHandler(st, up)), f.BindRoute("GET", "/api.php", service.PayApiHandler(st)), f.BindRoute("POST", "/api.php", service.PayApiHandler(st)), + // submit.php 直出收银台页的状态轮询(经代理走 /api 前缀,直连后端也兼容) + f.BindRoute("GET", "/cashier-status", service.PayCashierStatusHandler(st)), + f.BindRoute("GET", "/api/cashier-status", service.PayCashierStatusHandler(st)), f.BindRoute("GET", "/healthz", func(c *fun.RouteCtx) error { c.RequestCtx.WriteString("ok") return nil diff --git a/backend/go.mod b/backend/go.mod index a445734..e196e85 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,6 +9,7 @@ require ( github.com/jackc/pgx/v5 v5.11.0 github.com/mark3labs/mcp-go v1.1.1 github.com/pkg/sftp v1.13.11 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/valyala/fasthttp v1.73.0 github.com/xuri/excelize/v2 v2.11.0 golang.org/x/crypto v0.54.0 diff --git a/backend/go.sum b/backend/go.sum index 45fe83b..fc9d354 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -114,6 +114,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= diff --git a/backend/internal/service/cashier_page.go b/backend/internal/service/cashier_page.go new file mode 100644 index 0000000..5d289e5 --- /dev/null +++ b/backend/internal/service/cashier_page.go @@ -0,0 +1,194 @@ +// cashier_page.go submit.php 直出的自包含收银台页(不 302、不依赖前端 SPA), +// 二维码由服务端生成 PNG data URL;状态经 /cashier-status 轮询。 +package service + +import ( + "bytes" + "context" + "encoding/base64" + "html/template" + "strings" + + "github.com/cyi-cc/fun" + "github.com/skip2/go-qrcode" + + "github.com/cyi-cc/cyi-box/backend/internal/db" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// cashierTmpl 收银台页面模板(新粗野主义风格,与前端 Cashier.vue 一致)。 +var cashierTmpl = template.Must(template.New("cashier").Parse(` + + + + + +收银台 · ¥{{.Money}} + + + +
+
+ +
订单号 {{.TradeNo}}
+
+
{{or .Subject "商品订单"}}
+
¥{{.Money}}
+ + + + + + +
+ + +`)) + +type cashierPageData struct { + TradeNo string + Subject string + Money string + QRDataURL string + ExpiredAt int64 + Status int64 +} + +// renderCashier 渲染收银台 HTML(二维码内容 → PNG data URL)。 +func renderCashier(c *fun.RouteCtx, o db.PayOrder) error { + d := cashierPageData{ + TradeNo: o.TradeNo, + Subject: o.Subject, + Money: fenToYuan(o.Money), + ExpiredAt: o.ExpiredAt, + Status: o.Status, + } + if o.Qrcode != "" { + if png, err := qrcode.Encode(o.Qrcode, qrcode.Medium, 256); err == nil { + d.QRDataURL = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) + } + } + var buf bytes.Buffer + if err := cashierTmpl.Execute(&buf, d); err != nil { + return err + } + c.RequestCtx.Response.Header.Set("Content-Type", "text/html; charset=utf-8") + c.RequestCtx.SetBody(buf.Bytes()) + return nil +} + +// PayCashierStatusHandler GET /cashier-status?trade_no=:收银台页轮询 +// (公开接口,trade_no 不可枚举即凭证;前端经 /api 前缀代理访问)。 +func PayCashierStatusHandler(st *store.Store) fun.RouteHandler { + return func(c *fun.RouteCtx) error { + status, returnURL, err := cashierStatusReply(st, strings.TrimSpace(c.Param("trade_no"))) + if err != nil { + return epayJSON(c, map[string]any{"status": -1}) + } + return epayJSON(c, map[string]any{"status": status, "return_url": returnURL}) + } +} + +// cashierStatusReply 收银台状态查询:订单态 + 已支付时的签名回跳地址(RPC 与页面轮询共用)。 +func cashierStatusReply(st *store.Store, tradeNo string) (int64, string, error) { + o, err := st.Q().GetPayOrderByTradeNo(context.Background(), tradeNo) + if err != nil { + return 0, "", err + } + lazyCloseExpired(st, &o) + returnURL := "" + if o.Status == payStatusPaid && o.ReturnUrl != "" { + if pid, key, err := payMerchant(st); err == nil { + returnURL = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key) + } + } + return o.Status, returnURL, nil +} diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index 8facf24..19cdc3e 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -402,18 +402,11 @@ func (s *PaySvc) watchLoop(st *fun.Stream[dto.PayCashierStatusView], tradeNo str // CashierStatus 收银台轮询:已支付且配置了 return_url 时返回签名回跳地址。 func (s *PaySvc) CashierStatus(d dto.PayCashierDto) (dto.PayCashierStatusView, error) { - o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), strings.TrimSpace(d.TradeNo)) + status, returnURL, err := cashierStatusReply(s.Store, strings.TrimSpace(d.TradeNo)) if err != nil { return dto.PayCashierStatusView{}, fun.Error(4004, "订单不存在") } - res := dto.PayCashierStatusView{Status: o.Status} - if o.Status == payStatusPaid && o.ReturnUrl != "" { - pid, key, err := payMerchant(s.Store) - if err == nil { - res.ReturnUrl = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key) - } - } - return res, nil + return dto.PayCashierStatusView{Status: status, ReturnUrl: returnURL}, nil } func toPayOrderView(r db.PayOrder) dto.PayOrderView { diff --git a/backend/internal/service/pay_routes.go b/backend/internal/service/pay_routes.go index f16c6cf..2e22a1b 100644 --- a/backend/internal/service/pay_routes.go +++ b/backend/internal/service/pay_routes.go @@ -1,4 +1,4 @@ -// pay_routes.go 易支付兼容协议端点:/submit.php(跳收银台)、/mapi.php(JSON下单)、/api.php(查单)。 +// pay_routes.go 易支付兼容协议端点:/submit.php(验签建单+直出收银台页)、/mapi.php(JSON下单)、/api.php(查单)。 package service import ( @@ -12,20 +12,18 @@ import ( "github.com/cyi-cc/cyi-box/backend/internal/store" ) -// paySubmitHandler GET/POST /submit.php:验签建单后 302 到收银台。 +// paySubmitHandler GET/POST /submit.php:验签建单后直接渲染收银台页(无跳转)。 func PaySubmitHandler(st *store.Store, up *payx.Client) fun.RouteHandler { return func(c *fun.RouteCtx) error { order, err := epayCreateOrder(c, st, up) if err != nil { return epayFail(c, err) } - // 当码支付:优先跳二维码支付链接(qr.alipay.com),退化到本地收银台 - target := order.Qrcode - if target == "" { - target = "/cashier/" + order.TradeNo + o, err := st.Q().GetPayOrderByTradeNo(context.Background(), order.TradeNo) + if err != nil { + return epayFail(c, err) } - c.RequestCtx.Redirect(target, 302) - return nil + return renderCashier(c, o) } } diff --git a/frontend/src/views/Cashier.vue b/frontend/src/views/Cashier.vue index ca9d61c..a9d1561 100644 --- a/frontend/src/views/Cashier.vue +++ b/frontend/src/views/Cashier.vue @@ -1,7 +1,7 @@ @@ -94,21 +111,30 @@ function fen2yuan(fen: number): string { return (fen / 100).toFixed(2) }