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) { } label="费用统计(积分)" - value={finance ?
-
收入
{formatCredits(finance.revenueMicrocredits)}
-
成本
{formatFinanceCost(finance)}
-
利润
{formatCredits(finance.profitMicrocredits)}
-
: "--"} + value={ + finance ? ( +
+
+
收入
+
{formatCredits(finance.revenueMicrocredits)}
+
+
+
成本
+
{formatFinanceCost(finance)}
+
+
+
利润
+
{formatCredits(finance.profitMicrocredits)}
+
+
+ ) : ( + "--" + ) + } 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={} 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) { ]} /> - ); } diff --git a/web/src/pages/admin/plugins/plugins-page.tsx b/web/src/pages/admin/plugins/plugins-page.tsx index 2d42eb998..55ad042a2 100644 --- a/web/src/pages/admin/plugins/plugins-page.tsx +++ b/web/src/pages/admin/plugins/plugins-page.tsx @@ -322,7 +322,7 @@ export default function AdminPluginsPage() { /> } /> - setUploadOpen(false)} onUpload={(file) => void upload(file)} /> + setUploadOpen(false)} onUpload={upload} /> ); } diff --git a/web/src/pages/plugins/plugin-documentation-modals.tsx b/web/src/pages/plugins/plugin-documentation-modals.tsx index 735cf27d3..f25adbe4d 100644 --- a/web/src/pages/plugins/plugin-documentation-modals.tsx +++ b/web/src/pages/plugins/plugin-documentation-modals.tsx @@ -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; }; -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 | undefined>(undefined); + + useEffect(() => { + if (!open) { + setActiveTab("install"); + setIsDraggingPlugin(false); + setCopied(false); + dragDepth.current = 0; + } + return () => clearTimeout(copyTimer.current); + }, [open]); const handlePluginDragEnter = (event: React.DragEvent) => { 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" } }} >
- -
{activeTab === "guide" ? ( - ) : (
@@ -126,7 +126,9 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal {activeTab === "install" ? (
- + + +

安装插件包

选择统一站点插件包,安装后会立即进入插件中心。

@@ -204,13 +206,15 @@ export function PluginDetailsModal({ plugin, restoreFocus, onClose }: PluginDeta return ( - - {plugin.manifest.name} - v{plugin.manifest.version} -
- ) : null} + title={ + plugin ? ( +
+ + {plugin.manifest.name} + v{plugin.manifest.version} +
+ ) : null + } open={Boolean(plugin)} centered footer={null} diff --git a/web/test/analytics-finance.test.ts b/web/test/analytics-finance.test.ts index 4e50c32ed..74501e324 100644 --- a/web/test/analytics-finance.test.ts +++ b/web/test/analytics-finance.test.ts @@ -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%"]); }); diff --git a/web/test/plugin-upload-merge.test.ts b/web/test/plugin-upload-merge.test.ts new file mode 100644 index 000000000..0d42a0a3e --- /dev/null +++ b/web/test/plugin-upload-merge.test.ts @@ -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"); +});