diff --git a/backend/internal/app/payment_plugins_test.go b/backend/internal/app/payment_plugins_test.go
index f3cff10ff..49b91fe29 100644
--- a/backend/internal/app/payment_plugins_test.go
+++ b/backend/internal/app/payment_plugins_test.go
@@ -220,4 +220,10 @@ func TestEpayOfficialPackageIsPaymentPlugin(t *testing.T) {
if _, ok := provider.(*payment.RPCProvider); !ok {
t.Fatalf("epay provider type = %T", provider)
}
+ if err := provider.ValidateConfig(payment.Config{"pid": "test-merchant", "key": "test-key"}); err == nil {
+ t.Fatal("packaged epay executable must reject a missing gateway")
+ }
+ if err := provider.ValidateConfig(payment.Config{"pid": "test-merchant", "key": "test-key", "gateway": "https://pay.example"}); err != nil {
+ t.Fatalf("packaged epay executable rejected explicit configuration: %v", err)
+ }
}
diff --git a/backend/payment-plugins/epay.go b/backend/payment-plugins/epay.go
index 533101c5f..eb8a64b2e 100644
--- a/backend/payment-plugins/epay.go
+++ b/backend/payment-plugins/epay.go
@@ -1,6 +1,7 @@
package paymentplugins
import (
+ "bytes"
"context"
"crypto/md5"
"crypto/subtle"
@@ -18,10 +19,9 @@ import (
)
const (
- defaultEpayGateway = "https://pay.admincloudai.com"
- epayPluginVersion = "1.0.8"
- epayQRLifetime = 10 * time.Minute
- maxEpayResponse = 2 << 20
+ epayPluginVersion = "1.0.9"
+ epayQRLifetime = 10 * time.Minute
+ maxEpayResponse = 2 << 20
)
type EpayProvider struct {
@@ -102,7 +102,11 @@ func (p *EpayProvider) CreateOrder(ctx context.Context, config Config, request C
if version == "1" {
params["method"] = "jump"
params["timestamp"] = strconv.FormatInt(p.now().Unix(), 10)
- params["sign"] = epayRSASign(params, config["privateKey"])
+ signature, err := epayRSASign(params, config["privateKey"])
+ if err != nil {
+ return Checkout{}, &ProviderError{Code: "epay_sign_failed", Message: "易支付请求签名失败", Cause: err}
+ }
+ params["sign"] = signature
params["sign_type"] = "RSA"
path = "/api/pay/create"
successCode = "0"
@@ -137,6 +141,7 @@ func (p *EpayProvider) CreateOrder(ctx context.Context, config Config, request C
} else {
// urlscheme 是小程序跳转链接,前端直接跳;没有本地收银台时二维码链接也直接跳过去
value = firstNonEmpty(epayString(response["payurl"]), epayString(response["urlscheme"]), qrcode)
+ mode = "redirect"
}
}
if !isEpayURL(value) {
@@ -179,7 +184,10 @@ func (p *EpayProvider) QueryOrder(ctx context.Context, config Config, request Qu
}
return Result{}, &ProviderError{Code: "epay_query_failed", Message: message, Temporary: status >= 500}
}
- return epayResult(response, request.MerchantOrderNo), nil
+ if response["pid"] != "" && response["pid"] != strings.TrimSpace(config["pid"]) {
+ return Result{}, errors.New("易支付查单商户号不匹配")
+ }
+ return epayResult(response, request.MerchantOrderNo)
}
func (p *EpayProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
@@ -201,8 +209,8 @@ func (p *EpayProvider) CloseOrder(ctx context.Context, config Config, request Cl
result.ProviderStatus = "TRADE_CLOSED"
return result, nil
}
- // V2 没有通用远程关单接口,只关闭宿主本地订单。
- return Result{MerchantOrderNo: request.MerchantOrderNo, ProviderStatus: "TRADE_CLOSED", Currency: "CNY", Closed: true}, nil
+ // 未能查证上游状态时不能把未知订单标为已关闭。
+ return Result{}, &ProviderError{Code: "epay_close_unsupported", Message: "易支付 V2 暂不支持查单及安全关单,请在支付网关核对订单"}
}
func (p *EpayProvider) VerifyNotification(_ context.Context, config Config, _ http.Header, rawBody []byte) (Notification, error) {
@@ -215,9 +223,10 @@ func (p *EpayProvider) VerifyNotification(_ context.Context, config Config, _ ht
}
values := make(map[string]string, len(params))
for key, items := range params {
- if len(items) > 0 {
- values[key] = items[0]
+ if len(items) != 1 {
+ return Notification{}, errors.New("易支付异步通知存在重复参数")
}
+ values[key] = items[0]
}
if values["pid"] != strings.TrimSpace(config["pid"]) {
return Notification{}, errors.New("易支付异步通知商户号不匹配")
@@ -250,19 +259,25 @@ func (p *EpayProvider) VerifyNotification(_ context.Context, config Config, _ ht
}
amount, err := parseYuanToFen(values["money"])
- if err != nil {
- return Notification{}, fmt.Errorf("易支付异步通知金额无效: %w", err)
+ if err != nil || amount <= 0 {
+ return Notification{}, errors.New("易支付异步通知金额无效")
}
status := strings.TrimSpace(values["trade_status"])
- paid := status == "TRADE_SUCCESS"
+ // 宿主通知队列只处理到账事件,其他状态不得占用同一流水的幂等键。
+ if status != "TRADE_SUCCESS" {
+ return Notification{}, errors.New("易支付异步通知不是成功状态")
+ }
providerTradeNo := firstNonEmpty(values["trade_no"], values["api_trade_no"])
+ if providerTradeNo == "" {
+ return Notification{}, errors.New("易支付成功通知缺少支付流水号")
+ }
eventID := firstNonEmpty(values["notify_id"], providerTradeNo, values["out_trade_no"]+":"+status)
return Notification{
EventID: eventID,
Result: Result{
MerchantOrderNo: values["out_trade_no"], ProviderTradeNo: providerTradeNo,
ProviderStatus: status, AmountFen: amount, Currency: "CNY",
- Paid: paid, Closed: status == "TRADE_CLOSED", PaidAt: parseEpayTime(firstNonEmpty(values["endtime"], values["gmt_payment"])),
+ Paid: true, PaidAt: parseEpayTime(firstNonEmpty(values["endtime"], values["gmt_payment"])),
},
}, nil
}
@@ -322,9 +337,14 @@ func epayJSONResponse(resp *http.Response) (epayResponse, int, error) {
}
var raw map[string]any
// 有的网关输出带 BOM
- if err := json.Unmarshal([]byte(strings.TrimPrefix(string(body), "\xEF\xBB\xBF")), &raw); err != nil {
+ decoder := json.NewDecoder(bytes.NewReader(bytes.TrimPrefix(body, []byte("\xEF\xBB\xBF"))))
+ decoder.UseNumber()
+ if err := decoder.Decode(&raw); err != nil {
return nil, resp.StatusCode, &ProviderError{Code: "epay_response_parse_error", Message: "易支付响应不是有效 JSON", Temporary: true, Cause: err}
}
+ if err := decoder.Decode(new(any)); err != io.EOF {
+ return nil, resp.StatusCode, &ProviderError{Code: "epay_response_parse_error", Message: "易支付响应包含多余内容"}
+ }
result := make(epayResponse, len(raw))
for key, value := range raw {
result[key] = epayString(value)
@@ -335,7 +355,7 @@ func epayJSONResponse(resp *http.Response) (epayResponse, int, error) {
func epayGateway(config Config) (string, error) {
gateway := strings.TrimSpace(config["gateway"])
if gateway == "" {
- gateway = defaultEpayGateway
+ return "", errors.New("易支付配置缺少支付网关 gateway")
}
parsed, err := url.Parse(gateway)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
@@ -414,35 +434,46 @@ func epayMD5Hex(value string) string {
return hex.EncodeToString(hash[:])
}
-func epayRSASign(values map[string]string, privateKey string) string {
+func epayRSASign(values map[string]string, privateKey string) (string, error) {
key, err := parseRSAPrivateKey(privateKey)
if err != nil {
- return ""
+ return "", err
}
- signature, err := rsaSHA256Sign(key, []byte(epayCanonical(values)))
- if err != nil {
- return ""
- }
- return signature
+ return rsaSHA256Sign(key, []byte(epayCanonical(values)))
}
func epaySafetyEquals(actual, expected string) bool {
return actual != "" && len(actual) == len(expected) && subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
}
-func epayResult(response epayResponse, fallbackOrderNo string) Result {
+func epayResult(response epayResponse, expectedOrderNo string) (Result, error) {
status := epayString(response["status"])
if status == "" && response["trade_status"] != "" {
status = response["trade_status"]
}
- amount, _ := parseYuanToFen(firstNonEmpty(epayString(response["money"]), epayString(response["total_amount"])))
+ switch status {
+ case "0", "1", "WAIT_BUYER_PAY", "TRADE_SUCCESS", "TRADE_CLOSED":
+ default:
+ return Result{}, errors.New("易支付查单支付状态缺失或无效")
+ }
+ if response["out_trade_no"] != expectedOrderNo {
+ return Result{}, errors.New("易支付查单订单号缺失或不匹配")
+ }
+ amount, err := parseYuanToFen(firstNonEmpty(response["money"], response["total_amount"]))
+ if err != nil || amount <= 0 {
+ return Result{}, errors.New("易支付查单金额无效")
+ }
+ providerTradeNo := firstNonEmpty(response["trade_no"], response["api_trade_no"])
+ if (status == "1" || status == "TRADE_SUCCESS") && providerTradeNo == "" {
+ return Result{}, errors.New("易支付成功订单缺少支付流水号")
+ }
return Result{
- MerchantOrderNo: firstNonEmpty(epayString(response["out_trade_no"]), fallbackOrderNo),
- ProviderTradeNo: firstNonEmpty(epayString(response["trade_no"]), epayString(response["api_trade_no"])),
+ MerchantOrderNo: response["out_trade_no"],
+ ProviderTradeNo: providerTradeNo,
ProviderStatus: status, AmountFen: amount, Currency: "CNY",
Paid: status == "1" || status == "TRADE_SUCCESS", Closed: status == "TRADE_CLOSED",
PaidAt: parseEpayTime(firstNonEmpty(epayString(response["endtime"]), epayString(response["gmt_payment"]))),
- }
+ }, nil
}
func epayString(value any) string {
@@ -460,21 +491,20 @@ func epayString(value any) string {
}
}
-// 只认带协议头的地址(http(s),或 weixin:// 这类唤起 App 的),挡掉 javascript: 之类
+// 只允许浏览器支付页和已知支付 App 协议,不接受任意本机协议。
func isEpayURL(value string) bool {
value = strings.TrimSpace(value)
parsed, err := url.Parse(value)
- if err != nil || parsed.Scheme == "" {
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil {
return false
}
scheme := strings.ToLower(parsed.Scheme)
- if scheme == "javascript" || scheme == "data" || scheme == "vbscript" {
+ switch scheme {
+ case "http", "https", "weixin", "alipay", "alipays", "mqqapi":
+ return true
+ default:
return false
}
- if scheme == "http" || scheme == "https" {
- return parsed.Host != ""
- }
- return strings.Contains(value, "://")
}
func parseEpayTime(value string) time.Time {
diff --git a/backend/payment-plugins/epay_merge_test.go b/backend/payment-plugins/epay_merge_test.go
new file mode 100644
index 000000000..566a5316a
--- /dev/null
+++ b/backend/payment-plugins/epay_merge_test.go
@@ -0,0 +1,157 @@
+package paymentplugins
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestEpayRequiresExplicitGateway(t *testing.T) {
+ if err := NewEpayProvider(nil).ValidateConfig(Config{"pid": "merchant", "key": "test-key"}); err == nil {
+ t.Fatal("missing gateway must not send merchant credentials to a default third party")
+ }
+}
+
+func TestEpayCheckoutURLAllowlist(t *testing.T) {
+ for _, value := range []string{"file://host/etc/passwd", "smb://host/share", "vscode://file/tmp/a", "custom://pay", "javascript://alert(1)", "data://host/value", "https://user:pass@pay.example", "weixin:", "//pay.example"} {
+ if isEpayURL(value) {
+ t.Errorf("accepted unsafe checkout: %s", value)
+ }
+ }
+ for _, value := range []string{"https://pay.example/order", "weixin://wxpay/bizpayurl?pr=test", "alipays://platformapi/startapp?appId=1", "mqqapi://wallet/pay"} {
+ if !isEpayURL(value) {
+ t.Errorf("rejected payment URL: %s", value)
+ }
+ }
+}
+
+func TestEpayQueryRejectsIncompleteEvidence(t *testing.T) {
+ for _, test := range []struct{ name, response string }{
+ {"missing order", `{"code":1,"status":1,"money":"1.00","trade_no":"trade"}`},
+ {"different order", `{"code":1,"out_trade_no":"other","status":1,"money":"1.00","trade_no":"trade"}`},
+ {"different merchant", `{"code":1,"pid":"other","out_trade_no":"order","status":1,"money":"1.00","trade_no":"trade"}`},
+ {"invalid amount", `{"code":1,"out_trade_no":"order","status":1,"money":"bad","trade_no":"trade"}`},
+ {"missing amount", `{"code":1,"out_trade_no":"order","status":1,"trade_no":"trade"}`},
+ {"zero amount", `{"code":1,"out_trade_no":"order","status":1,"money":"0","trade_no":"trade"}`},
+ {"missing transaction", `{"code":1,"out_trade_no":"order","status":1,"money":"1.00"}`},
+ {"missing status", `{"code":1,"out_trade_no":"order","money":"1.00","trade_no":"trade"}`},
+ {"trailing JSON", `{"code":1,"out_trade_no":"order","status":1,"money":"1.00","trade_no":"trade"}{}`},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, test.response) }))
+ defer server.Close()
+ provider := NewEpayProvider(server.Client())
+ config := Config{"pid": "merchant", "key": "test-key", "gateway": server.URL}
+ if result, err := provider.QueryOrder(context.Background(), config, QueryRequest{MerchantOrderNo: "order"}); err == nil {
+ t.Fatalf("invalid payment evidence accepted: %+v", result)
+ }
+ if result, err := provider.CloseOrder(context.Background(), config, CloseRequest{MerchantOrderNo: "order"}); err == nil {
+ t.Fatalf("invalid evidence must not close an order: %+v", result)
+ }
+ })
+ }
+}
+
+func TestEpayQueryPreservesNumericTradeID(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, `{"code":1,"out_trade_no":"order","status":1,"money":"1.00","trade_no":1234567890123456789}`)
+ }))
+ defer server.Close()
+ result, err := NewEpayProvider(server.Client()).QueryOrder(context.Background(), Config{"pid": "merchant", "key": "test-key", "gateway": server.URL}, QueryRequest{MerchantOrderNo: "order"})
+ if err != nil || !result.Paid || result.ProviderTradeNo != "1234567890123456789" {
+ t.Fatalf("trade ID lost precision: %+v, %v", result, err)
+ }
+}
+
+func TestEpayQRCodePreferenceFallsBackToRedirectForPayURL(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, `{"code":1,"payurl":"https://pay.example/checkout"}`)
+ }))
+ defer server.Close()
+ checkout, err := NewEpayProvider(server.Client()).CreateOrder(context.Background(), Config{"pid": "merchant", "key": "test-key", "gateway": server.URL, "checkoutMode": "qr_code"}, CreateRequest{
+ MerchantOrderNo: "order", AmountFen: 100, Currency: "CNY", NotifyURL: "https://merchant.example/notify",
+ })
+ if err != nil || checkout.Mode != "redirect" {
+ t.Fatalf("checkout must match returned payload: %+v, %v", checkout, err)
+ }
+}
+
+func TestEpayNotificationRejectsDuplicateAndInvalidEvidence(t *testing.T) {
+ config := Config{"pid": "merchant", "key": "test-key", "gateway": "https://pay.example"}
+ for _, kind := range []string{"duplicate", "invalid amount", "zero amount", "missing transaction", "tampered", "pending", "closed", "missing status"} {
+ t.Run(kind, func(t *testing.T) {
+ values := map[string]string{"pid": "merchant", "out_trade_no": "order", "trade_no": "trade", "money": "1.00", "trade_status": "TRADE_SUCCESS"}
+ switch kind {
+ case "invalid amount":
+ values["money"] = "invalid"
+ case "zero amount":
+ values["money"] = "0"
+ case "missing transaction":
+ delete(values, "trade_no")
+ case "pending":
+ values["trade_status"] = "WAIT_BUYER_PAY"
+ case "closed":
+ values["trade_status"] = "TRADE_CLOSED"
+ case "missing status":
+ delete(values, "trade_status")
+ }
+ values["sign"] = epayMD5Sign(values, config["key"])
+ if kind == "tampered" {
+ values["money"] = "9.00"
+ }
+ form := url.Values{}
+ for key, value := range values {
+ form.Set(key, value)
+ }
+ if kind == "duplicate" {
+ form.Add("money", "9.00")
+ }
+ if _, err := NewEpayProvider(nil).VerifyNotification(context.Background(), config, nil, []byte(form.Encode())); err == nil {
+ t.Fatal("invalid notification accepted")
+ }
+ })
+ }
+}
+
+func TestEpayV2NotificationAndUnsupportedClose(t *testing.T) {
+ privateKey, _ := testRSAKeyPair(t)
+ config := Config{"pid": "merchant", "version": "1", "gateway": "https://pay.example", "privateKey": testPrivatePEM(privateKey), "platformPublicKey": testPublicPEM(&privateKey.PublicKey)}
+ values := map[string]string{"pid": "merchant", "out_trade_no": "order", "trade_no": "trade", "money": "1.00", "trade_status": "TRADE_SUCCESS", "sign_type": "RSA"}
+ signature, err := epayRSASign(values, config["privateKey"])
+ if err != nil {
+ t.Fatal(err)
+ }
+ values["sign"] = signature
+ form := url.Values{}
+ for key, value := range values {
+ form.Set(key, value)
+ }
+ provider := NewEpayProvider(nil)
+ notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(form.Encode()))
+ if err != nil || !notification.Paid || notification.AmountFen != 100 {
+ t.Fatalf("valid RSA notification rejected: %+v, %v", notification, err)
+ }
+ form.Set("money", "9.00")
+ if _, err := provider.VerifyNotification(context.Background(), config, nil, []byte(form.Encode())); err == nil {
+ t.Fatal("tampered RSA notification accepted")
+ }
+ if result, err := provider.CloseOrder(context.Background(), config, CloseRequest{MerchantOrderNo: "order"}); err == nil || result.Closed {
+ t.Fatalf("unsupported V2 close must not report success: %+v, %v", result, err)
+ }
+ if _, err := epayRSASign(values, "invalid-key"); err == nil {
+ t.Fatal("signing error must not be swallowed")
+ }
+}
+
+func TestEpayTransportErrorsDoNotExposeMerchantKey(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+ server.Close()
+ _, err := NewEpayProvider(server.Client()).QueryOrder(context.Background(), Config{"pid": "merchant", "key": "secret-marker", "gateway": server.URL}, QueryRequest{MerchantOrderNo: "order"})
+ if err == nil || strings.Contains(err.Error(), "secret-marker") {
+ t.Fatalf("unsafe transport error: %v", err)
+ }
+}
diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx
index 5bf8d98a8..aa9384fc9 100644
--- a/docs/content/docs/backend/http-api.mdx
+++ b/docs/content/docs/backend/http-api.mdx
@@ -137,6 +137,8 @@ Skills 的 `tool_completed` / `tool_failed` 事件附带 `skillId`、`skillName`
后台支付订单列表展示用户身份,点击名称打开用户详情;订单详情提供完整订单号、渠道交易号、用户 ID 和各阶段时间。同步支付状态与关闭订单位于更多菜单,关闭订单仍需确认。
+官方易支付插件 `official-payment-epay` 通过既有支付接口接入,不新增业务路由。管理员必须显式填写可信支付网关;V1 支持 MD5 下单、查单与通知验签,未付款订单的关单仅关闭本地记录。V2 目前只支持 RSA 下单和通知验签,不支持查单、刷新收银台及自动关单;相应操作返回明确失败,不能按“订单已关闭”处理。启用前需在实际网关验收回调和到账,不能用插件安装成功代替支付链路验收。
+
### 支付对账筛选及 CSV 导出
`GET /api/admin/payments/reconciliations` 支持 `providerId`、`status`(`running` / `completed` / `failed`)、`from`、`to`(账单日期,包含起止日)、`page`、`pageSize`。`GET /api/admin/payments/reconciliations/:id/items` 支持 `result`、`page`、`pageSize`;`result=abnormal` 同时选出所有非 `matched`、非 `recovered` 明细,未指定或 `all` 表示全部。具体结果还支持 `matched`、`recovered`、`local_order_not_found`、`provider_record_missing`、`amount_mismatch`、`trade_no_mismatch`、`credit_failed`。
diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx
index 5b3f3f47a..924a4bc93 100644
--- a/docs/content/docs/progress/pending-test.mdx
+++ b/docs/content/docs/progress/pending-test.mdx
@@ -5,11 +5,24 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
+## PR #544–#546 合并后回归
+
+- 上传弹窗的调用方保留安装 Promise,上传期间防止重复提交和关闭;复制配置复用开发规范中的有效示例,并展示剪贴板失败。减弱动画保留动画完成事件,不以禁用动画名称阻断弹窗退场。
+- 易支付插件更新至 `1.0.9`:必须配置可信网关;查单和通知强校验订单、金额与流水,保留长数字交易号精度;只允许明确的支付跳转协议。非成功通知在入队前拒绝,避免占用到账事件的幂等键。源码、跨平台可执行文件和插件包保持一致。
+- V2 查单协议尚未核实,不新增猜测接口。V2 只实现下单与通知验签,查单、收银台刷新和安全关单不支持,需网关人工核对;V1 本地关单不等于上游链接失效。
+- [ ] 真实管理页面检查上传成功、失败、重复点击、关闭后重开,以及浅色/深色和减弱动画下的弹窗退场;确认剪贴板不可用时有明确提示。
+- [ ] 使用可信测试商户验证 V1 下单、查询、重复/篡改通知、到账幂等及本地关单后的迟到通知;V2 另行验证 RSA 回调,并确认不支持的操作不会显示成功。
+- [ ] 核对数据概览与订单列表、CSV 的财务口径及跨渠道模型分组,详见下一节。
+
+PR #543 本次未合入:其已发布迁移校验和改写、管理员显式图片输入限制被覆盖,以及无关插件定制需要先拆分。主分支的迁移谱系、模型能力强校验和画布实现保持不变。
+
+本轮验证:前端全量 `bun test` 1832 项通过,生产构建(含类型检查)、项目 lint 和 CI 涉及文件的格式检查通过;后端财务统计(含 SQLite 集成)、模型分组、易支付插件与真实插件 RPC 专项通过,六个平台产物及插件包校验通过。后端 `go test ./...` 有一项素材删除测试 `TestDeleteAdminResourcesSupportsPartialSuccessAndBatchedReferences` 遇到 SQLite 表锁,其测试和对应删除逻辑本轮未改动,单独连续复跑 3 次通过;全量结果仍记为未通过,不能用专项复跑替代。尚未做浏览器、真实商户或部署后验收。
+
## 数据概览财务统计与模型一级分组
已实现:移除数据概览的独立价格编辑入口;按系统模型订单快照汇总已结算收入、成本、利润与利润率,并同步 CSV。“模型展示名(一级目录)”支持搜索、选择跨系统渠道已有分组及输入新分组。
-自动化验证:后台 UI 回归测试 17 项通过,前端类型检查与生产构建通过;关闭 CGO 后,财务汇总、实际用量成本和无效成本价格三项专项测试通过。SQLite 集成测试受本机 GCC/CGO 对象解析错误阻断,尚未完成;以下真实页面与数据链路仍待验收。
+自动化验证:后台 UI 回归测试 17 项通过,前端类型检查与生产构建通过;财务汇总、实际用量成本和无效成本价格专项测试通过。本轮合并复测已完成 SQLite 集成测试,此前本机 GCC/CGO 阻断不再存在;以下真实页面与数据链路仍待验收。
缺失字段修复验证:新增 4 项测试直接执行模型表格财务列的渲染函数,覆盖未返回或空财务对象、零金额、部分成本和亏损;与后台 UI 回归测试共 21 项通过,前端类型检查及生产构建通过。
diff --git a/plugin-packages/official-payment-epay.yingce-plugin b/plugin-packages/official-payment-epay.yingce-plugin
index 5c9bfa442..9eada714e 100644
Binary files a/plugin-packages/official-payment-epay.yingce-plugin and b/plugin-packages/official-payment-epay.yingce-plugin differ
diff --git a/plugin-packages/official-payment-epay/backend/provider b/plugin-packages/official-payment-epay/backend/provider
index dcf476e11..98ba1ab51 100755
Binary files a/plugin-packages/official-payment-epay/backend/provider and b/plugin-packages/official-payment-epay/backend/provider differ
diff --git a/plugin-packages/official-payment-epay/backend/provider-darwin-amd64 b/plugin-packages/official-payment-epay/backend/provider-darwin-amd64
index c522dc26f..3a8f9207e 100755
Binary files a/plugin-packages/official-payment-epay/backend/provider-darwin-amd64 and b/plugin-packages/official-payment-epay/backend/provider-darwin-amd64 differ
diff --git a/plugin-packages/official-payment-epay/backend/provider-darwin-arm64 b/plugin-packages/official-payment-epay/backend/provider-darwin-arm64
index 6ae76a2ce..0dbd0d539 100755
Binary files a/plugin-packages/official-payment-epay/backend/provider-darwin-arm64 and b/plugin-packages/official-payment-epay/backend/provider-darwin-arm64 differ
diff --git a/plugin-packages/official-payment-epay/backend/provider-linux-amd64 b/plugin-packages/official-payment-epay/backend/provider-linux-amd64
index dcf476e11..98ba1ab51 100755
Binary files a/plugin-packages/official-payment-epay/backend/provider-linux-amd64 and b/plugin-packages/official-payment-epay/backend/provider-linux-amd64 differ
diff --git a/plugin-packages/official-payment-epay/backend/provider-linux-arm64 b/plugin-packages/official-payment-epay/backend/provider-linux-arm64
index 23741f6b7..a9f815ed0 100755
Binary files a/plugin-packages/official-payment-epay/backend/provider-linux-arm64 and b/plugin-packages/official-payment-epay/backend/provider-linux-arm64 differ
diff --git a/plugin-packages/official-payment-epay/backend/provider-windows-amd64.exe b/plugin-packages/official-payment-epay/backend/provider-windows-amd64.exe
index 98cf31726..ba6ea642f 100755
Binary files a/plugin-packages/official-payment-epay/backend/provider-windows-amd64.exe and b/plugin-packages/official-payment-epay/backend/provider-windows-amd64.exe differ
diff --git a/plugin-packages/official-payment-epay/backend/provider-windows-arm64.exe b/plugin-packages/official-payment-epay/backend/provider-windows-arm64.exe
index 7842130eb..86681d951 100755
Binary files a/plugin-packages/official-payment-epay/backend/provider-windows-arm64.exe and b/plugin-packages/official-payment-epay/backend/provider-windows-arm64.exe differ
diff --git a/plugin-packages/official-payment-epay/docs/interface.md b/plugin-packages/official-payment-epay/docs/interface.md
index 507e0d1d8..38ed3a32b 100644
--- a/plugin-packages/official-payment-epay/docs/interface.md
+++ b/plugin-packages/official-payment-epay/docs/interface.md
@@ -2,7 +2,11 @@
支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。
-易支付渠道协议全部封装在本插件内:V1 下单 `POST /mapi.php`(MD5),查单 `GET /api.php?act=order`;V2 下单 `POST /api/pay/create`(RSA,`method=jump`),异步通知为 form/JSON,成功应答 `success`。配置字段包括 `publicBaseUrl`、`pid`、`key`、`gateway`、`payType`、`version`、`checkoutMode`、`privateKey`、`platformPublicKey`。V2 无查单和交易账单下载:关单只关本地订单,对账账单返回 not found。
+易支付渠道协议全部封装在本插件内:V1 下单 `POST /mapi.php`(MD5),查单 `GET /api.php?act=order`;V2 下单 `POST /api/pay/create`(RSA,`method=jump`),异步通知为 form/JSON,成功应答 `success`。配置字段包括 `publicBaseUrl`、`pid`、`key`、`gateway`、`payType`、`version`、`checkoutMode`、`privateKey`、`platformPublicKey`。
+
+必须显式填写可信 `gateway`,不默认向任何第三方网关发送商户信息。V1 查单严格校验商户(响应包含时)、订单号、正数金额、支付状态和成功流水号;不以请求中的订单号或零金额掩盖不完整响应。通知校验拒绝重复参数、无效签名、非正金额及缺失的成功流水号。支付地址只接受 HTTP(S) 与明确允许的微信、支付宝、QQ 支付协议,不接受任意本机协议或含用户名密码的地址。
+
+V1 关单先查单:已支付订单返回付款凭据,未付款订单只关闭宿主本地记录,不能据此声称上游支付链接已失效。V2 当前仅实现下单和通知验签,未实现查单、刷新收银台和安全关单;`query_order` 返回 `epay_query_unsupported`,`close_order` 返回 `epay_close_unsupported`,不会把未知订单标成已关闭。启用 V2 前需接受这一限制并准备在网关人工核对订单;本次没有新增或猜测 V2 查单接口。交易账单下载返回 not found,不表示已完成对账。
## Manifest 完整接口定义
@@ -14,7 +18,7 @@
"apiVersion": "yingce.plugin/v1",
"id": "official-payment-epay",
"name": "易支付",
- "version": "1.0.8",
+ "version": "1.0.9",
"author": "Epay",
"description": "易支付适配器,支持 V1/MD5 与 V2/RSA 的支付宝/微信/QQ 通道。",
"enabled": true,
@@ -61,8 +65,7 @@
"type": "url",
"label": "支付网关",
"required": true,
- "default": "https://pay.admincloudai.com",
- "description": "易支付根地址,不要带 /mapi.php。"
+ "description": "显式填写可信易支付网关根地址,不要带 /mapi.php;不会默认向第三方网关发送商户信息。"
},
{
"name": "payType",
@@ -87,7 +90,7 @@
"0",
"1"
],
- "description": "0=V1/MD5,1=V2/RSA。"
+ "description": "0=V1/MD5,1=V2/RSA。V2 仅支持下单和验签通知,暂不支持查单、刷新收银台及自动关单,启用前需确认此限制。"
},
{
"name": "checkoutMode",
diff --git a/plugin-packages/official-payment-epay/manifest.json b/plugin-packages/official-payment-epay/manifest.json
index 2c6d8b66c..674b3eba7 100644
--- a/plugin-packages/official-payment-epay/manifest.json
+++ b/plugin-packages/official-payment-epay/manifest.json
@@ -2,7 +2,7 @@
"apiVersion": "yingce.plugin/v1",
"id": "official-payment-epay",
"name": "易支付",
- "version": "1.0.8",
+ "version": "1.0.9",
"author": "Epay",
"description": "易支付适配器,支持 V1/MD5 与 V2/RSA 的支付宝/微信/QQ 通道。",
"enabled": true,
@@ -49,8 +49,7 @@
"type": "url",
"label": "支付网关",
"required": true,
- "default": "https://pay.admincloudai.com",
- "description": "易支付根地址,不要带 /mapi.php。"
+ "description": "显式填写可信易支付网关根地址,不要带 /mapi.php;不会默认向第三方网关发送商户信息。"
},
{
"name": "payType",
@@ -75,7 +74,7 @@
"0",
"1"
],
- "description": "0=V1/MD5,1=V2/RSA。"
+ "description": "0=V1/MD5,1=V2/RSA。V2 仅支持下单和验签通知,暂不支持查单、刷新收银台及自动关单,启用前需确认此限制。"
},
{
"name": "checkoutMode",
@@ -134,5 +133,5 @@
}
]
},
- "documentation": "# 易支付\n\n官方 `yingce.payment/v1` 支付插件。兼容常见易支付网关:V1/MD5 `mapi.php` 与 V2/RSA `/api/pay/create`,支持支付宝/微信/QQ 等通道。协议实现参考 [acg-faka](https://github.com/lizhipay/acg-faka)。\n\n---\n\n## yingce.payment/v1\n\n支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。\n\n易支付渠道协议全部封装在本插件内:V1 下单 `POST /mapi.php`(MD5),查单 `GET /api.php?act=order`;V2 下单 `POST /api/pay/create`(RSA,`method=jump`),异步通知为 form/JSON,成功应答 `success`。配置字段包括 `publicBaseUrl`、`pid`、`key`、`gateway`、`payType`、`version`、`checkoutMode`、`privateKey`、`platformPublicKey`。V2 无查单和交易账单下载:关单只关本地订单,对账账单返回 not found。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-epay\",\n \"name\": \"易支付\",\n \"version\": \"1.0.8\",\n \"author\": \"Epay\",\n \"description\": \"易支付适配器,支持 V1/MD5 与 V2/RSA 的支付宝/微信/QQ 通道。\",\n \"enabled\": true,\n \"installable\": true,\n \"runtime\": {\n \"backend\": \"rpc\",\n \"backendEntry\": \"backend/provider\"\n },\n \"surfaces\": [\n \"wallet\",\n \"settings\"\n ],\n \"permissions\": [\n \"payment.create\",\n \"payment.query\",\n \"payment.close\",\n \"payment.reconcile\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"publicBaseUrl\",\n \"type\": \"url\",\n \"label\": \"服务器公网地址\",\n \"required\": true,\n \"description\": \"用于确认回调可达;实际 notify_url 由宿主订单生成。\"\n },\n {\n \"name\": \"pid\",\n \"type\": \"string\",\n \"label\": \"商户号 (pid)\",\n \"required\": true\n },\n {\n \"name\": \"key\",\n \"type\": \"password\",\n \"label\": \"商户密钥 (key)\",\n \"required\": false,\n \"secret\": true,\n \"description\": \"V1/MD5 密钥;version=1 可留空。\"\n },\n {\n \"name\": \"gateway\",\n \"type\": \"url\",\n \"label\": \"支付网关\",\n \"required\": true,\n \"default\": \"https://pay.admincloudai.com\",\n \"description\": \"易支付根地址,不要带 /mapi.php。\"\n },\n {\n \"name\": \"payType\",\n \"type\": \"string\",\n \"label\": \"默认支付方式\",\n \"required\": false,\n \"default\": \"alipay\",\n \"values\": [\n \"alipay\",\n \"wxpay\",\n \"qqpay\",\n \"usdt\"\n ]\n },\n {\n \"name\": \"version\",\n \"type\": \"string\",\n \"label\": \"接口版本\",\n \"required\": false,\n \"default\": \"0\",\n \"values\": [\n \"0\",\n \"1\"\n ],\n \"description\": \"0=V1/MD5,1=V2/RSA。\"\n },\n {\n \"name\": \"checkoutMode\",\n \"type\": \"string\",\n \"label\": \"收银方式\",\n \"required\": false,\n \"default\": \"redirect\",\n \"values\": [\n \"redirect\",\n \"qr_code\"\n ],\n \"description\": \"redirect 跳转;qr_code 展示二维码载荷。\"\n },\n {\n \"name\": \"privateKey\",\n \"type\": \"textarea\",\n \"label\": \"V2 商户私钥\",\n \"required\": false,\n \"secret\": true,\n \"description\": \"仅 version=1,PEM 或裸 Base64。\"\n },\n {\n \"name\": \"platformPublicKey\",\n \"type\": \"textarea\",\n \"label\": \"V2 平台公钥\",\n \"required\": false,\n \"description\": \"仅 version=1,PEM 或裸 Base64。\"\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"epay\",\n \"label\": \"易支付\",\n \"icon\": \"assets/icon.svg\",\n \"checkoutMode\": \"redirect\",\n \"identityFields\": [\n \"pid\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 10,\n \"minMinutes\": 5,\n \"maxMinutes\": 1440\n },\n \"notificationSuccess\": {\n \"status\": 200,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"success\"\n },\n \"notificationFailure\": {\n \"status\": 400,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"failure\"\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n"
+ "documentation": "# 易支付\n\n官方 `yingce.payment/v1` 支付插件。兼容常见易支付网关:V1/MD5 `mapi.php` 与 V2/RSA `/api/pay/create`,支持支付宝/微信/QQ 等通道。协议实现参考 [acg-faka](https://github.com/lizhipay/acg-faka)。\n\n---\n\n## yingce.payment/v1\n\n支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。\n\n易支付渠道协议全部封装在本插件内:V1 下单 `POST /mapi.php`(MD5),查单 `GET /api.php?act=order`;V2 下单 `POST /api/pay/create`(RSA,`method=jump`),异步通知为 form/JSON,成功应答 `success`。配置字段包括 `publicBaseUrl`、`pid`、`key`、`gateway`、`payType`、`version`、`checkoutMode`、`privateKey`、`platformPublicKey`。\n\n必须显式填写可信 `gateway`,不默认向任何第三方网关发送商户信息。V1 查单严格校验商户(响应包含时)、订单号、正数金额、支付状态和成功流水号;不以请求中的订单号或零金额掩盖不完整响应。通知校验拒绝重复参数、无效签名、非正金额及缺失的成功流水号。支付地址只接受 HTTP(S) 与明确允许的微信、支付宝、QQ 支付协议,不接受任意本机协议或含用户名密码的地址。\n\nV1 关单先查单:已支付订单返回付款凭据,未付款订单只关闭宿主本地记录,不能据此声称上游支付链接已失效。V2 当前仅实现下单和通知验签,未实现查单、刷新收银台和安全关单;`query_order` 返回 `epay_query_unsupported`,`close_order` 返回 `epay_close_unsupported`,不会把未知订单标成已关闭。启用 V2 前需接受这一限制并准备在网关人工核对订单;本次没有新增或猜测 V2 查单接口。交易账单下载返回 not found,不表示已完成对账。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-epay\",\n \"name\": \"易支付\",\n \"version\": \"1.0.9\",\n \"author\": \"Epay\",\n \"description\": \"易支付适配器,支持 V1/MD5 与 V2/RSA 的支付宝/微信/QQ 通道。\",\n \"enabled\": true,\n \"installable\": true,\n \"runtime\": {\n \"backend\": \"rpc\",\n \"backendEntry\": \"backend/provider\"\n },\n \"surfaces\": [\n \"wallet\",\n \"settings\"\n ],\n \"permissions\": [\n \"payment.create\",\n \"payment.query\",\n \"payment.close\",\n \"payment.reconcile\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"publicBaseUrl\",\n \"type\": \"url\",\n \"label\": \"服务器公网地址\",\n \"required\": true,\n \"description\": \"用于确认回调可达;实际 notify_url 由宿主订单生成。\"\n },\n {\n \"name\": \"pid\",\n \"type\": \"string\",\n \"label\": \"商户号 (pid)\",\n \"required\": true\n },\n {\n \"name\": \"key\",\n \"type\": \"password\",\n \"label\": \"商户密钥 (key)\",\n \"required\": false,\n \"secret\": true,\n \"description\": \"V1/MD5 密钥;version=1 可留空。\"\n },\n {\n \"name\": \"gateway\",\n \"type\": \"url\",\n \"label\": \"支付网关\",\n \"required\": true,\n \"description\": \"显式填写可信易支付网关根地址,不要带 /mapi.php;不会默认向第三方网关发送商户信息。\"\n },\n {\n \"name\": \"payType\",\n \"type\": \"string\",\n \"label\": \"默认支付方式\",\n \"required\": false,\n \"default\": \"alipay\",\n \"values\": [\n \"alipay\",\n \"wxpay\",\n \"qqpay\",\n \"usdt\"\n ]\n },\n {\n \"name\": \"version\",\n \"type\": \"string\",\n \"label\": \"接口版本\",\n \"required\": false,\n \"default\": \"0\",\n \"values\": [\n \"0\",\n \"1\"\n ],\n \"description\": \"0=V1/MD5,1=V2/RSA。V2 仅支持下单和验签通知,暂不支持查单、刷新收银台及自动关单,启用前需确认此限制。\"\n },\n {\n \"name\": \"checkoutMode\",\n \"type\": \"string\",\n \"label\": \"收银方式\",\n \"required\": false,\n \"default\": \"redirect\",\n \"values\": [\n \"redirect\",\n \"qr_code\"\n ],\n \"description\": \"redirect 跳转;qr_code 展示二维码载荷。\"\n },\n {\n \"name\": \"privateKey\",\n \"type\": \"textarea\",\n \"label\": \"V2 商户私钥\",\n \"required\": false,\n \"secret\": true,\n \"description\": \"仅 version=1,PEM 或裸 Base64。\"\n },\n {\n \"name\": \"platformPublicKey\",\n \"type\": \"textarea\",\n \"label\": \"V2 平台公钥\",\n \"required\": false,\n \"description\": \"仅 version=1,PEM 或裸 Base64。\"\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"epay\",\n \"label\": \"易支付\",\n \"icon\": \"assets/icon.svg\",\n \"checkoutMode\": \"redirect\",\n \"identityFields\": [\n \"pid\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 10,\n \"minMinutes\": 5,\n \"maxMinutes\": 1440\n },\n \"notificationSuccess\": {\n \"status\": 200,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"success\"\n },\n \"notificationFailure\": {\n \"status\": 400,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"failure\"\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n"
}
diff --git a/web/src/pages/admin/components/analytics-panel.tsx b/web/src/pages/admin/components/analytics-panel.tsx
index 0f4e7a8c8..d1917a6a4 100644
--- a/web/src/pages/admin/components/analytics-panel.tsx
+++ b/web/src/pages/admin/components/analytics-panel.tsx
@@ -10,14 +10,7 @@ import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer,
import { useSearchParams } from "react-router";
import { ListToolbar, PaginationBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminStatusBadge, AdminTableEmpty, type AdminStatusTone } from "./admin-ui";
-import {
- exportAdminAnalytics,
- getAdminAnalytics,
- listAdminUsers,
- type AdminReferenceData,
- type AdminAnalytics,
- type AnalyticsFilters,
-} from "@/services/api/auth";
+import { exportAdminAnalytics, getAdminAnalytics, listAdminUsers, type AdminReferenceData, type AdminAnalytics, type AnalyticsFilters } from "@/services/api/auth";
import { analyticsFinanceColumns, formatCredits, formatFinanceCost, formatFinanceMargin } from "./analytics-finance";
type Props = {
@@ -300,11 +293,26 @@ export default function AnalyticsPanel({ users, channels }: Props) {
选择统一站点插件包,安装后会立即进入插件中心。
@@ -204,13 +206,15 @@ export function PluginDetailsModal({ plugin, restoreFocus, onClose }: PluginDeta return (