fix(merge): PR 集成 - fix merge 上传状态与易支付校验
在 #544、#545、#546 的原作者 merge commit 之后独立修复:保留上传 Promise、处理重复上传与复制失败、格式对齐,以及易支付网关、订单证据、签名、通知幂等与安全关单校验。同步重建六平台插件产物和包。 验证:前端 1832 tests、build/typecheck、lint、相关格式检查通过;财务 SQLite、支付、插件 RPC 与产物专项通过。后端全量出现一项未改动的素材删除 SQLite 表锁失败,单独复跑 3 次通过,保留全量未通过记录。浏览器与真实商户验收待完成。#543 因迁移历史与能力限制回退暂缓。
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
157
backend/payment-plugins/epay_merge_test.go
Normal file
157
backend/payment-plugins/epay_merge_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 项通过,前端类型检查及生产构建通过。
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,不表示已完成对账。
|
||||
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||
## 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",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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) {
|
||||
<AnalyticsHealthCard
|
||||
icon={<CircleDollarSign className="size-4" />}
|
||||
label="费用统计(积分)"
|
||||
value={finance ? <dl className="admin-analytics-finance-values">
|
||||
<div><dt>收入</dt><dd>{formatCredits(finance.revenueMicrocredits)}</dd></div>
|
||||
<div><dt>成本</dt><dd>{formatFinanceCost(finance)}</dd></div>
|
||||
<div><dt>利润</dt><dd>{formatCredits(finance.profitMicrocredits)}</dd></div>
|
||||
</dl> : "--"}
|
||||
value={
|
||||
finance ? (
|
||||
<dl className="admin-analytics-finance-values">
|
||||
<div>
|
||||
<dt>收入</dt>
|
||||
<dd>{formatCredits(finance.revenueMicrocredits)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>成本</dt>
|
||||
<dd>{formatFinanceCost(finance)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>利润</dt>
|
||||
<dd>{formatCredits(finance.profitMicrocredits)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
"--"
|
||||
)
|
||||
}
|
||||
detail={finance ? `已结算 ${finance.settledOrders} 笔 · 利润率 ${formatFinanceMargin(finance.profitMargin)}` : undefined}
|
||||
tone={finance && (finance.costedOrders < finance.settledOrders || (finance.profitMicrocredits ?? 0) < 0) ? "warning" : "neutral"}
|
||||
/>
|
||||
@@ -390,7 +398,15 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
icon={<CircleDollarSign className="size-4" />}
|
||||
label="成本覆盖订单"
|
||||
value={finance ? `${finance.costedOrders}/${finance.settledOrders}` : "--"}
|
||||
description={!finance ? "财务统计尚未返回" : finance.settledOrders ? (finance.costedOrders === finance.settledOrders ? "已结算订单成本完整" : `${finance.settledOrders - finance.costedOrders} 笔缺少成本快照或有效用量`) : "当前范围暂无已结算订单"}
|
||||
description={
|
||||
!finance
|
||||
? "财务统计尚未返回"
|
||||
: finance.settledOrders
|
||||
? finance.costedOrders === finance.settledOrders
|
||||
? "已结算订单成本完整"
|
||||
: `${finance.settledOrders - finance.costedOrders} 笔缺少成本快照或有效用量`
|
||||
: "当前范围暂无已结算订单"
|
||||
}
|
||||
tone={finance && finance.costedOrders < finance.settledOrders ? "warning" : "neutral"}
|
||||
onClick={() => openAnalysis("models")}
|
||||
/>
|
||||
@@ -449,7 +465,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ export default function AdminPluginsPage() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<UploadPluginModal open={uploadOpen} onClose={() => setUploadOpen(false)} onUpload={(file) => void upload(file)} />
|
||||
<UploadPluginModal open={uploadOpen} onClose={() => setUploadOpen(false)} onUpload={upload} />
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Modal, Upload } from "antd";
|
||||
import { App, Modal, Upload } from "antd";
|
||||
import { CloudUpload, FileText, ShieldCheck } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { RegisteredPlugin } from "@/lib/plugins/plugin-types";
|
||||
|
||||
@@ -15,26 +15,27 @@ type UploadPluginModalProps = {
|
||||
onUpload: (file: File) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const MINIMAL_MANIFEST_SNIPPET = `{
|
||||
"apiVersion": "yingce.plugin/v1",
|
||||
"id": "my-custom-plugin",
|
||||
"name": "我的自定义插件",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer",
|
||||
"description": "通过统一清单扩展平台功能",
|
||||
"permissions": ["generation.run", "media.read"],
|
||||
"contributes": {
|
||||
"providers": [],
|
||||
"workflows": []
|
||||
}
|
||||
}`;
|
||||
const MANIFEST_EXAMPLE = pluginDevelopmentGuideMarkdown.match(/```json\s*([\s\S]*?)```/)?.[1].trim();
|
||||
|
||||
export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const [activeTab, setActiveTab] = useState<"install" | "guide">("install");
|
||||
const [isDraggingPlugin, setIsDraggingPlugin] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const dragDepth = useRef(0);
|
||||
const uploadInFlight = useRef(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveTab("install");
|
||||
setIsDraggingPlugin(false);
|
||||
setCopied(false);
|
||||
dragDepth.current = 0;
|
||||
}
|
||||
return () => clearTimeout(copyTimer.current);
|
||||
}, [open]);
|
||||
|
||||
const handlePluginDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -58,19 +59,30 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
|
||||
};
|
||||
|
||||
const handleExecuteUpload = async (file: File) => {
|
||||
if (uploadInFlight.current) return;
|
||||
uploadInFlight.current = true;
|
||||
setUploading(true);
|
||||
try {
|
||||
await onUpload(file);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "安装插件失败");
|
||||
} finally {
|
||||
uploadInFlight.current = false;
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copySnippet = () => {
|
||||
void navigator.clipboard?.writeText(MINIMAL_MANIFEST_SNIPPET).then(() => {
|
||||
const copySnippet = async () => {
|
||||
try {
|
||||
if (!MANIFEST_EXAMPLE) throw new Error("开发规范中缺少清单示例");
|
||||
if (!navigator.clipboard) throw new Error("当前环境不支持剪贴板,请从开发规范中手动复制");
|
||||
await navigator.clipboard.writeText(MANIFEST_EXAMPLE);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "复制失败,请手动复制");
|
||||
}
|
||||
};
|
||||
|
||||
const isCompact = activeTab === "install";
|
||||
@@ -83,37 +95,25 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
|
||||
centered
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
closable={!uploading}
|
||||
mask={{ closable: !uploading }}
|
||||
onCancel={uploading ? undefined : onClose}
|
||||
styles={{ body: { maxHeight: "min(84vh, 900px)", overflowY: "auto", overscrollBehavior: "contain", padding: "16px 20px 24px" } }}
|
||||
>
|
||||
<div className="plugin-upload-tabs-bar">
|
||||
<div className="plugin-upload-tabs-nav">
|
||||
<button
|
||||
type="button"
|
||||
className={`plugin-upload-tab-btn ${activeTab === "install" ? "is-active" : ""}`}
|
||||
onClick={() => setActiveTab("install")}
|
||||
>
|
||||
<button type="button" className={`plugin-upload-tab-btn ${activeTab === "install" ? "is-active" : ""}`} aria-pressed={activeTab === "install"} onClick={() => setActiveTab("install")}>
|
||||
<CloudUpload className="size-4" />
|
||||
<span>安装插件包</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`plugin-upload-tab-btn ${activeTab === "guide" ? "is-active" : ""}`}
|
||||
onClick={() => setActiveTab("guide")}
|
||||
>
|
||||
<button type="button" className={`plugin-upload-tab-btn ${activeTab === "guide" ? "is-active" : ""}`} aria-pressed={activeTab === "guide"} onClick={() => setActiveTab("guide")}>
|
||||
<FileText className="size-4" />
|
||||
<span>开发规范与示例</span>
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === "guide" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="plugin-upload-copy-btn"
|
||||
onClick={copySnippet}
|
||||
title="复制最小 manifest.json 配置模板"
|
||||
>
|
||||
<span>{copied ? "已复制清单" : "复制最小清单"}</span>
|
||||
<button type="button" className="plugin-upload-copy-btn" onClick={() => void copySnippet()} title="复制开发规范中的 manifest.json 配置示例">
|
||||
<span>{copied ? "已复制清单" : "复制清单示例"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="plugin-upload-badge">
|
||||
@@ -126,7 +126,9 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
|
||||
{activeTab === "install" ? (
|
||||
<div className="plugin-upload-install-view">
|
||||
<div className="plugin-upload-panel-heading">
|
||||
<span className="plugin-upload-panel-icon"><CloudUpload className="size-5" /></span>
|
||||
<span className="plugin-upload-panel-icon">
|
||||
<CloudUpload className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2>安装插件包</h2>
|
||||
<p>选择统一站点插件包,安装后会立即进入插件中心。</p>
|
||||
@@ -204,13 +206,15 @@ export function PluginDetailsModal({ plugin, restoreFocus, onClose }: PluginDeta
|
||||
return (
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-wide plugin-details-modal"
|
||||
title={plugin ? (
|
||||
<div className="plugin-details-title">
|
||||
<FileText className="size-4" />
|
||||
<span>{plugin.manifest.name}</span>
|
||||
<span className="plugin-version">v{plugin.manifest.version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
title={
|
||||
plugin ? (
|
||||
<div className="plugin-details-title">
|
||||
<FileText className="size-4" />
|
||||
<span>{plugin.manifest.name}</span>
|
||||
<span className="plugin-version">v{plugin.manifest.version}</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
open={Boolean(plugin)}
|
||||
centered
|
||||
footer={null}
|
||||
|
||||
@@ -4,12 +4,24 @@ import { analyticsFinanceColumns } from "../src/pages/admin/components/analytics
|
||||
|
||||
function renderFinanceCells(finance?: AnalyticsFinance | null) {
|
||||
const row: AdminAnalytics["models"][number] = {
|
||||
model: "example", capability: "text", tasks: 1, requests: 1, uniqueUsers: 1,
|
||||
taskSuccessRate: 100, requestSuccessRate: 100, p50DurationMs: 100, p95DurationMs: 100,
|
||||
inputTokens: 10, outputTokens: 10, cachedTokens: 0, usageAvailable: true,
|
||||
mediaCount: 0, videoSeconds: 0, ...(finance === undefined ? {} : { finance }),
|
||||
model: "example",
|
||||
capability: "text",
|
||||
tasks: 1,
|
||||
requests: 1,
|
||||
uniqueUsers: 1,
|
||||
taskSuccessRate: 100,
|
||||
requestSuccessRate: 100,
|
||||
p50DurationMs: 100,
|
||||
p95DurationMs: 100,
|
||||
inputTokens: 10,
|
||||
outputTokens: 10,
|
||||
cachedTokens: 0,
|
||||
usageAvailable: true,
|
||||
mediaCount: 0,
|
||||
videoSeconds: 0,
|
||||
...(finance === undefined ? {} : { finance }),
|
||||
};
|
||||
return analyticsFinanceColumns.map((column) => "render" in column ? column.render?.(undefined, row, 0) : undefined);
|
||||
return analyticsFinanceColumns.map((column) => ("render" in column ? column.render?.(undefined, row, 0) : undefined));
|
||||
}
|
||||
|
||||
test("model finance cells tolerate missing and null backend finance", () => {
|
||||
@@ -18,16 +30,13 @@ test("model finance cells tolerate missing and null backend finance", () => {
|
||||
});
|
||||
|
||||
test("model finance cells preserve real zero values and unknown margins", () => {
|
||||
expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 0, costMicrocredits: 0, profitMicrocredits: 0, profitMargin: null }))
|
||||
.toEqual(["0", "0", "0", "--"]);
|
||||
expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 0, costMicrocredits: 0, profitMicrocredits: 0, profitMargin: null })).toEqual(["0", "0", "0", "--"]);
|
||||
});
|
||||
|
||||
test("model finance cells show partial costs without inventing profits", () => {
|
||||
expect(renderFinanceCells({ settledOrders: 2, costedOrders: 1, revenueMicrocredits: 1500000, costMicrocredits: 250000, profitMicrocredits: null, profitMargin: null }))
|
||||
.toEqual(["1.5", "0.25(部分)", "--", "--"]);
|
||||
expect(renderFinanceCells({ settledOrders: 2, costedOrders: 1, revenueMicrocredits: 1500000, costMicrocredits: 250000, profitMicrocredits: null, profitMargin: null })).toEqual(["1.5", "0.25(部分)", "--", "--"]);
|
||||
});
|
||||
|
||||
test("model finance cells display known losses", () => {
|
||||
expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 1000000, costMicrocredits: 1500000, profitMicrocredits: -500000, profitMargin: -50 }))
|
||||
.toEqual(["1", "1.5", "-0.5", "-50.0%"]);
|
||||
expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 1000000, costMicrocredits: 1500000, profitMicrocredits: -500000, profitMargin: -50 })).toEqual(["1", "1.5", "-0.5", "-50.0%"]);
|
||||
});
|
||||
|
||||
43
web/test/plugin-upload-merge.test.ts
Normal file
43
web/test/plugin-upload-merge.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { registerPlugin, unregisterPlugin } from "../src/lib/plugins/plugin-registry";
|
||||
import type { PluginManifest } from "../src/lib/plugins/plugin-types";
|
||||
|
||||
test("plugin upload caller preserves the installation promise", async () => {
|
||||
const page = await Bun.file(new URL("../src/pages/admin/plugins/plugins-page.tsx", import.meta.url)).text();
|
||||
expect(page).toContain("onUpload={upload}");
|
||||
expect(page).not.toContain("onUpload={(file) => void upload(file)}");
|
||||
});
|
||||
|
||||
test("plugin upload guards concurrent events and exposes installation failures", async () => {
|
||||
const modal = await Bun.file(new URL("../src/pages/plugins/plugin-documentation-modals.tsx", import.meta.url)).text();
|
||||
expect(modal).toContain("if (uploadInFlight.current) return;");
|
||||
expect(modal.indexOf("uploadInFlight.current = true")).toBeLessThan(modal.indexOf("await onUpload(file)"));
|
||||
expect(modal).toContain("uploadInFlight.current = false");
|
||||
expect(modal).toContain("closable={!uploading}");
|
||||
expect(modal).toContain('message.error(error instanceof Error ? error.message : "安装插件失败")');
|
||||
expect(modal).toContain('aria-pressed={activeTab === "install"}');
|
||||
expect(modal).toContain('aria-pressed={activeTab === "guide"}');
|
||||
expect(modal).toContain("if (!navigator.clipboard) throw new Error");
|
||||
expect(modal).toContain("clearTimeout(copyTimer.current)");
|
||||
});
|
||||
|
||||
test("copied guide manifest is accepted by the real plugin registry", async () => {
|
||||
const guide = await Bun.file(new URL("../src/pages/plugins/plugin-development-guide.md", import.meta.url)).text();
|
||||
const example = guide.match(/```json\s*([\s\S]*?)```/)?.[1].trim();
|
||||
expect(example).toBeTruthy();
|
||||
const manifest = { ...(JSON.parse(example!) as PluginManifest), id: "merge-test-manifest-example" };
|
||||
try {
|
||||
expect(() => registerPlugin({ manifest })).not.toThrow();
|
||||
} finally {
|
||||
unregisterPlugin(manifest.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("reduced motion preserves modal completion events", async () => {
|
||||
const css = await Bun.file(new URL("../src/pages/admin/theme/admin-chrome.css", import.meta.url)).text();
|
||||
const reducedMotion = css.slice(css.lastIndexOf("@media (prefers-reduced-motion: reduce)"));
|
||||
expect(reducedMotion).toContain("animation-duration: 0.01ms !important");
|
||||
expect(reducedMotion).toContain("transition-duration: 0.01ms !important");
|
||||
expect(reducedMotion).not.toContain("animation: none");
|
||||
expect(reducedMotion).not.toContain("transition: none");
|
||||
});
|
||||
Reference in New Issue
Block a user