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:
ddCat
2026-09-20 05:18:16 +08:00
parent 6eaeddc77c
commit b7bd29c79a
20 changed files with 398 additions and 117 deletions

View File

@@ -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)
}
}

View File

@@ -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 {

View 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)
}
}

View File

@@ -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`。

View File

@@ -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 项通过,前端类型检查及生产构建通过。

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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%"]);
});

View 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");
});