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}}
+
+
+
+ {{if .QRDataURL}}

{{else}}
二维码生成失败,请刷新重试
{{end}}
+
+
+
支打开支付宝扫一扫完成付款
+
⏰ 支付剩余 --:--
+
+
+
+
+
+
+
+
+`))
+
+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) }
{{ info!.subject || '商品订单' }}
- ¥{{ fen2yuan(info!.money) }}
+ ¥{{ fen2yuan(info!.money) }}
-
![支付宝扫码]()
+
+
![支付宝扫码]()
+
二维码生成中…
+
+
+
- 请使用 支付宝 扫码支付
+ 支
+ 打开支付宝扫一扫,向「池易」付款
+
+
+
+ 支付剩余时间 {{ remainText }}
+
-
- 在支付宝收银台继续 →
-
{{ statusLabel }}
+
正在跳回商家页面…
{{ info!.cards }}
@@ -135,12 +161,50 @@ function fen2yuan(fen: number): string { return (fen / 100).toFixed(2) }
.cash-logo { font-size: 20px; font-weight: 900; }
.cash-sub { font-size: 11px; color: var(--cb-ink-3, #8a8577); font-family: Consolas, monospace; margin-top: 4px; }
.cash-subject { font-size: 14px; font-weight: 700; }
-.cash-amount { font-size: 34px; font-weight: 900; font-family: Consolas, monospace; margin: 8px 0 18px; }
-.cash-qr-area { display: flex; flex-direction: column; align-items: center; gap: 12px; }
-.cash-qr { border: 3px solid var(--cb-ink, #1c1b1a); border-radius: 12px; }
-.cash-qr-tip { font-size: 13px; color: var(--cb-ink-3, #8a8577); display: flex; align-items: center; gap: 6px; }
+.cash-amount { font-size: 40px; font-weight: 900; font-family: Consolas, monospace; margin: 8px 0 18px; }
+.cash-cny { font-size: 22px; vertical-align: 6px; margin-right: 2px; }
+.cash-qr-area { display: flex; flex-direction: column; align-items: center; gap: 14px; }
+.cash-qr-frame {
+ position: relative; padding: 14px; background: #fff;
+ border: 3px solid var(--cb-ink, #1c1b1a); border-radius: 16px;
+ box-shadow: 5px 5px 0 var(--cb-ink, #1c1b1a);
+}
+.cash-qr { display: block; border-radius: 8px; }
+.cash-qr-empty {
+ width: 240px; height: 240px; display: flex; align-items: center; justify-content: center;
+ color: var(--cb-ink-3, #8a8577); font-size: 13px;
+}
+.cash-qr-corner {
+ position: absolute; width: 18px; height: 18px; border: 3px solid #1677ff;
+}
+.cash-qr-corner.tl { top: -6px; left: -6px; border-right: none; border-bottom: none; border-radius: 8px 0 0 0; }
+.cash-qr-corner.tr { top: -6px; right: -6px; border-left: none; border-bottom: none; border-radius: 0 8px 0 0; }
+.cash-qr-corner.bl { bottom: -6px; left: -6px; border-right: none; border-top: none; border-radius: 0 0 0 8px; }
+.cash-qr-corner.br { bottom: -6px; right: -6px; border-left: none; border-top: none; border-radius: 0 0 8px 0; }
+.cash-qr-tip {
+ font-size: 14px; font-weight: 700; color: var(--cb-ink, #1c1b1a);
+ display: flex; align-items: center; gap: 8px;
+}
+.cash-alipay-badge {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 24px; height: 24px; background: #1677ff; color: #fff;
+ border-radius: 6px; font-size: 14px; font-weight: 900;
+ border: 2px solid var(--cb-ink, #1c1b1a);
+}
+.cash-countdown {
+ font-size: 12px; color: var(--cb-ink-3, #8a8577);
+ display: flex; align-items: center; gap: 6px; width: 100%;
+}
+.cash-countdown-bar {
+ flex: 1; height: 8px; background: var(--cb-cream-2, #e5e0d5);
+ border: 2px solid var(--cb-ink, #1c1b1a); border-radius: 6px; overflow: hidden;
+}
+.cash-countdown-bar i {
+ display: block; height: 100%; background: #1677ff; transition: width 1s linear;
+}
.cash-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 30px 0; font-size: 15px; font-weight: 700; }
.cash-ok { color: #18a058; font-size: 18px; }
+.cash-redirect { font-size: 12px; color: var(--cb-ink-3, #8a8577); font-weight: 400; }
.cash-cards {
text-align: left; font-size: 12px; font-family: Consolas, monospace; max-height: 200px;
overflow: auto; background: var(--cb-cream, #f7f4ec); border-radius: 8px; padding: 10px; margin: 0;