diff --git a/backend/cmd/payment-epay/main.go b/backend/cmd/payment-epay/main.go new file mode 100644 index 000000000..0c4b83a24 --- /dev/null +++ b/backend/cmd/payment-epay/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "net/http" + "os" + "time" + + paymentplugins "infinite-canvas/backend/payment-plugins" +) + +func main() { + provider := paymentplugins.NewEpayProvider(&http.Client{Timeout: 25 * time.Second}) + if err := paymentplugins.RunRPC(provider, os.Stdin, os.Stdout); err != nil { + os.Exit(1) + } +} diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index ea1cc5cd4..88e37b5e2 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -75,10 +75,11 @@ type AdminUserReference struct { } type AdminChannelReference struct { - ID string `json:"id"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - Models []string `json:"models"` + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Models []string `json:"models"` + ModelDisplayNames []string `json:"modelDisplayNames"` } type AdminReferenceData struct { @@ -194,15 +195,19 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error result.Users = append(result.Users, AdminUserReference{ID: user.ID, Username: user.Username, DisplayName: user.DisplayName}) } for _, channel := range channels { - items, itemErr := s.repo.ChannelModels(channel.ID, false) + items, itemErr := s.repo.ChannelModels(channel.ID, true) if itemErr != nil { return nil, itemErr } models := make([]string, 0, len(items)) + displayNames := make([]string, 0, len(items)) for _, item := range items { - models = append(models, item.ModelKey) + if item.Enabled { + models = append(models, item.ModelKey) + } + displayNames = append(displayNames, firstNonEmpty(strings.TrimSpace(item.DisplayName), item.ModelKey)) } - result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models)}) + result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models), ModelDisplayNames: uniqueNonEmpty(displayNames)}) } return result, nil } diff --git a/backend/internal/app/analytics.go b/backend/internal/app/analytics.go index 48a5d4957..2f1150780 100644 --- a/backend/internal/app/analytics.go +++ b/backend/internal/app/analytics.go @@ -38,18 +38,16 @@ type AnalyticsOverview struct { } type AnalyticsKPI struct { - ActiveUsers int `json:"activeUsers"` - DAU int `json:"dau"` - WAU int `json:"wau"` - MAU int `json:"mau"` - GenerationTasks int `json:"generationTasks"` - UpstreamRequests int `json:"upstreamRequests"` - SuccessRate float64 `json:"successRate"` - P95DurationMs int64 `json:"p95DurationMs"` - CurrentQueuedTasks int64 `json:"currentQueuedTasks"` - EstimatedCostMicros int64 `json:"estimatedCostMicros"` - CostAvailable bool `json:"costAvailable"` - Currency string `json:"currency"` + ActiveUsers int `json:"activeUsers"` + DAU int `json:"dau"` + WAU int `json:"wau"` + MAU int `json:"mau"` + GenerationTasks int `json:"generationTasks"` + UpstreamRequests int `json:"upstreamRequests"` + SuccessRate float64 `json:"successRate"` + P95DurationMs int64 `json:"p95DurationMs"` + CurrentQueuedTasks int64 `json:"currentQueuedTasks"` + Finance AnalyticsFinance `json:"finance"` } type AnalyticsTrendPoint struct { @@ -61,24 +59,22 @@ type AnalyticsTrendPoint struct { } type AnalyticsModelRow struct { - Model string `json:"model"` - Capability string `json:"capability"` - Tasks int `json:"tasks"` - Requests int `json:"requests"` - UniqueUsers int `json:"uniqueUsers"` - TaskSuccessRate float64 `json:"taskSuccessRate"` - RequestSuccessRate float64 `json:"requestSuccessRate"` - P50DurationMs int64 `json:"p50DurationMs"` - P95DurationMs int64 `json:"p95DurationMs"` - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - CachedTokens int64 `json:"cachedTokens"` - UsageAvailable bool `json:"usageAvailable"` - MediaCount int `json:"mediaCount"` - VideoSeconds int `json:"videoSeconds"` - EstimatedCostMicros int64 `json:"estimatedCostMicros"` - CostAvailable bool `json:"costAvailable"` - Currency string `json:"currency"` + Model string `json:"model"` + Capability string `json:"capability"` + Tasks int `json:"tasks"` + Requests int `json:"requests"` + UniqueUsers int `json:"uniqueUsers"` + TaskSuccessRate float64 `json:"taskSuccessRate"` + RequestSuccessRate float64 `json:"requestSuccessRate"` + P50DurationMs int64 `json:"p50DurationMs"` + P95DurationMs int64 `json:"p95DurationMs"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + UsageAvailable bool `json:"usageAvailable"` + MediaCount int `json:"mediaCount"` + VideoSeconds int `json:"videoSeconds"` + Finance AnalyticsFinance `json:"finance"` } type AnalyticsUserRow struct { @@ -182,6 +178,11 @@ func (s *Service) AdminAnalytics(actor *model.User, query AnalyticsQuery) (*Anal return nil, err } result := buildAnalyticsOverview(filter, tasks, rollingTasks, rollingLogs, logs, activities, users) + records, err := s.analyticsFinancialRecords(logs) + if err != nil { + return nil, err + } + applyAnalyticsFinance(result, records) result.KPI.CurrentQueuedTasks = queued return result, nil } @@ -448,14 +449,22 @@ func (s *Service) AdminAnalyticsCSV(actor *model.User, query AnalyticsQuery) ([] if err != nil { return nil, err } + records, err := s.analyticsFinancialRecords(logs) + if err != nil { + return nil, err + } var buffer bytes.Buffer buffer.WriteString("\xEF\xBB\xBF") writer := csv.NewWriter(&buffer) - _ = writer.Write([]string{"时间", "用户ID", "渠道ID", "任务ID", "能力", "请求阶段", "模型", "状态", "状态码", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "媒体数量", "视频秒数", "估算费用(微单位)", "币种", "错误类型"}) + _ = writer.Write([]string{"时间", "用户ID", "渠道ID", "任务ID", "能力", "请求阶段", "模型", "状态", "状态码", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "媒体数量", "视频秒数", "收入(微积分)", "成本(微积分)", "利润(微积分)", "错误类型"}) for _, log := range logs { - cost := "" - if log.CostAvailable { - cost = strconv.FormatInt(log.EstimatedCostMicros, 10) + revenue, cost, profit := "", "", "" + if record, exists := records[log.ID]; exists { + revenue = strconv.FormatInt(record.Revenue, 10) + if record.Cost != nil { + cost = strconv.FormatInt(*record.Cost, 10) + profit = strconv.FormatInt(record.Revenue-*record.Cost, 10) + } } inputTokens, outputTokens, cachedTokens := "", "", "" if log.UsageAvailable { @@ -463,7 +472,7 @@ func (s *Service) AdminAnalyticsCSV(actor *model.User, query AnalyticsQuery) ([] outputTokens = strconv.FormatInt(log.OutputTokens, 10) cachedTokens = strconv.FormatInt(log.CachedTokens, 10) } - _ = writer.Write([]string{log.CreatedAt.Format(time.RFC3339), log.UserID, log.ChannelID, log.TaskID, log.Capability, log.RequestKind, log.Model, string(log.Status), strconv.Itoa(log.StatusCode), strconv.FormatInt(log.DurationMs, 10), inputTokens, outputTokens, cachedTokens, strconv.Itoa(log.MediaCount), strconv.Itoa(log.VideoSeconds), cost, log.Currency, classifyAPICallError(log)}) + _ = writer.Write([]string{log.CreatedAt.Format(time.RFC3339), log.UserID, log.ChannelID, log.TaskID, log.Capability, log.RequestKind, log.Model, string(log.Status), strconv.Itoa(log.StatusCode), strconv.FormatInt(log.DurationMs, 10), inputTokens, outputTokens, cachedTokens, strconv.Itoa(log.MediaCount), strconv.Itoa(log.VideoSeconds), revenue, cost, profit, classifyAPICallError(log)}) } writer.Flush() return buffer.Bytes(), writer.Error() @@ -598,16 +607,9 @@ func buildAnalyticsOverview(filter repository.AnalyticsFilter, tasks []model.Tas result.KPI.DAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -1), filter.To) result.KPI.WAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -7), filter.To) result.KPI.MAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -30), filter.To) - currency := "" for _, log := range logs { durations = append(durations, log.DurationMs) - if log.CostAvailable { - result.KPI.CostAvailable = true - result.KPI.EstimatedCostMicros += log.EstimatedCostMicros - currency = mergeCurrency(currency, log.Currency) - } } - result.KPI.Currency = currency result.KPI.P95DurationMs = percentile(durations, 0.95) result.Trend = buildAnalyticsTrend(filter, tasks, logs, activities) result.Models = buildAnalyticsModels(tasks, logs) @@ -724,11 +726,6 @@ func buildAnalyticsModels(tasks []model.Task, logs []model.ApiCallLog) []Analyti } item.row.MediaCount += log.MediaCount item.row.VideoSeconds += log.VideoSeconds - if log.CostAvailable { - item.row.CostAvailable = true - item.row.EstimatedCostMicros += log.EstimatedCostMicros - item.row.Currency = mergeCurrency(item.row.Currency, log.Currency) - } } result := make([]AnalyticsModelRow, 0, len(items)) for _, item := range items { @@ -917,16 +914,6 @@ func percentile(values []int64, quantile float64) int64 { return items[index] } -func mergeCurrency(current string, next string) string { - if next == "" { - return current - } - if current == "" || current == next { - return next - } - return "MIXED" -} - func capabilityFromTaskType(taskType string) string { value := strings.ToLower(taskType) for _, capability := range []string{"video", "image", "audio", "text"} { diff --git a/backend/internal/app/analytics_finance.go b/backend/internal/app/analytics_finance.go new file mode 100644 index 000000000..8043d08c8 --- /dev/null +++ b/backend/internal/app/analytics_finance.go @@ -0,0 +1,90 @@ +package app + +import "infinite-canvas/backend/internal/model" + +type AnalyticsFinance struct { + SettledOrders int `json:"settledOrders"` + CostedOrders int `json:"costedOrders"` + RevenueMicrocredits int64 `json:"revenueMicrocredits"` + CostMicrocredits int64 `json:"costMicrocredits"` + ProfitMicrocredits *int64 `json:"profitMicrocredits"` + ProfitMargin *float64 `json:"profitMargin"` +} + +type analyticsFinancialRecord struct { + Log model.ApiCallLog + Revenue int64 + Cost *int64 +} + +// 一个订单的重试、轮询不能重复计入收入;只归属实际结算渠道。 +// 历史订单使用下单价格快照,缺失成本时不反查当前价格。 +func (s *Service) analyticsFinancialRecords(logs []model.ApiCallLog) (map[string]analyticsFinancialRecord, error) { + ids := make([]string, 0, len(logs)) + for _, log := range logs { + if log.Billable && log.BillingOrderID != "" { + ids = append(ids, log.BillingOrderID) + } + } + orders, err := s.repo.BillingOrdersByIDs(uniqueNonEmpty(ids)) + if err != nil { + return nil, err + } + byOrder := make(map[string]model.ApiCallLog) + for _, log := range logs { + order, exists := orders[log.BillingOrderID] + if !log.Billable || !exists || order.UserID != log.UserID || order.ChannelID != log.ChannelID || order.Status != model.BillingStatusSettled { + continue + } + switch log.RequestKind { + case "poll", "download", "upload", "workflow-schema", "cancel-query", "cancel": + continue + } + previous, exists := byOrder[order.ID] + if !exists || log.CreatedAt.Before(previous.CreatedAt) || (log.CreatedAt.Equal(previous.CreatedAt) && log.ID < previous.ID) { + byOrder[order.ID] = log + } + } + records := make(map[string]analyticsFinancialRecord, len(byOrder)) + for id, log := range byOrder { + order := orders[id] + cost, err := billingCreditCost(order) + if err != nil { + return nil, err + } + records[log.ID] = analyticsFinancialRecord{Log: log, Revenue: order.ActualAmountMicrocredits, Cost: cost} + } + return records, nil +} + +func (finance *AnalyticsFinance) add(record analyticsFinancialRecord) { + finance.SettledOrders++ + finance.RevenueMicrocredits += record.Revenue + if record.Cost != nil { + finance.CostedOrders++ + finance.CostMicrocredits += *record.Cost + } + finance.ProfitMicrocredits, finance.ProfitMargin = nil, nil + if finance.CostedOrders == finance.SettledOrders { + profit := finance.RevenueMicrocredits - finance.CostMicrocredits + finance.ProfitMicrocredits = &profit + if finance.RevenueMicrocredits > 0 { + margin := float64(profit) * 100 / float64(finance.RevenueMicrocredits) + finance.ProfitMargin = &margin + } + } +} + +func applyAnalyticsFinance(overview *AnalyticsOverview, records map[string]analyticsFinancialRecord) { + rows := make(map[[2]string]*AnalyticsFinance, len(overview.Models)) + for index := range overview.Models { + row := &overview.Models[index] + rows[[2]string{row.Model, row.Capability}] = &row.Finance + } + for _, record := range records { + overview.KPI.Finance.add(record) + if row := rows[[2]string{firstNonEmpty(record.Log.Model, "未识别"), record.Log.Capability}]; row != nil { + row.add(record) + } + } +} diff --git a/backend/internal/app/analytics_finance_test.go b/backend/internal/app/analytics_finance_test.go new file mode 100644 index 000000000..16c4b1195 --- /dev/null +++ b/backend/internal/app/analytics_finance_test.go @@ -0,0 +1,215 @@ +package app + +import ( + "encoding/csv" + "strconv" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAnalyticsFinanceAggregatesKnownAndUnknownCosts(t *testing.T) { + cost := int64(300_000) + zero := int64(0) + for _, test := range []struct { + name string + records []analyticsFinancialRecord + costed int + revenue int64 + cost int64 + profit *int64 + }{ + {name: "no orders"}, + {name: "explicit zero", records: []analyticsFinancialRecord{{Cost: &zero}}, costed: 1, profit: &zero}, + {name: "break even", records: []analyticsFinancialRecord{{Revenue: 300_000, Cost: &cost}}, costed: 1, revenue: 300_000, cost: 300_000, profit: &zero}, + {name: "partial cost last", records: []analyticsFinancialRecord{{Revenue: 900_000, Cost: &cost}, {Revenue: 400_000}}, costed: 1, revenue: 1_300_000, cost: 300_000}, + {name: "partial cost first", records: []analyticsFinancialRecord{{Revenue: 400_000}, {Revenue: 900_000, Cost: &cost}}, costed: 1, revenue: 1_300_000, cost: 300_000}, + } { + t.Run(test.name, func(t *testing.T) { + var finance AnalyticsFinance + for _, record := range test.records { + finance.add(record) + } + if finance.SettledOrders != len(test.records) || finance.CostedOrders != test.costed || finance.RevenueMicrocredits != test.revenue || finance.CostMicrocredits != test.cost { + t.Fatalf("finance = %+v", finance) + } + if test.profit == nil { + if finance.ProfitMicrocredits != nil || finance.ProfitMargin != nil { + t.Fatalf("unknown profit = %+v", finance) + } + return + } + if finance.ProfitMicrocredits == nil || *finance.ProfitMicrocredits != *test.profit { + t.Fatalf("profit = %+v", finance) + } + if test.revenue == 0 { + if finance.ProfitMargin != nil { + t.Fatalf("zero revenue margin = %v", *finance.ProfitMargin) + } + } else if finance.ProfitMargin == nil || *finance.ProfitMargin != float64(*test.profit)*100/float64(test.revenue) { + t.Fatalf("margin = %+v", finance) + } + }) + } +} + +func TestAdminAnalyticsFinanceUsesSettledSnapshotsAndDeduplicates(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.BillingOrder{}, &model.ApiCallLog{}, &model.Task{}, &model.UserDailyActivity{}, &model.User{}); err != nil { + t.Fatal(err) + } + svc := &Service{repo: repository.New(db)} + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + now := time.Now().UTC() + orders := []model.BillingOrder{ + {ID: "paid", Status: model.BillingStatusSettled, ActualAmountMicrocredits: 900_000, BillingCostSnapshot: model.BillingCostSnapshot{CostBillingMode: "fixed_request", CostQuantity: 1, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 300_000}}}, + {ID: "free", Status: model.BillingStatusSettled, BillingCostSnapshot: model.BillingCostSnapshot{CostBillingMode: "fixed_request", CostQuantity: 1, CostPricing: model.CreditCostPricing{Configured: true}}}, + {ID: "unknown", Status: model.BillingStatusSettled, ActualAmountMicrocredits: 400_000}, + {ID: "reserved", Status: model.BillingStatusReserved, ReservedAmountMicrocredits: 1_000_000}, + {ID: "refunded", Status: model.BillingStatusRefunded, ActualAmountMicrocredits: 800_000}, + {ID: "loss", Status: model.BillingStatusSettled, ActualAmountMicrocredits: 100_000, BillingCostSnapshot: model.BillingCostSnapshot{CostBillingMode: "per_second", CostQuantity: 3, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 100_000}}}, + } + logs := make([]model.ApiCallLog, 0) + for index := range orders { + order := &orders[index] + order.UserID, order.ChannelID, order.IdempotencyKey = "user", "channel", order.ID + logs = append(logs, model.ApiCallLog{ID: order.ID, BillingOrderID: order.ID, UserID: "user", ChannelID: "channel", Model: order.ID, Capability: "text", Billable: true, RequestKind: "create", Status: model.ApiCallStatusSucceeded, CreatedAt: now}) + } + for _, id := range []string{"retry", "poll", "download", "upload", "workflow-schema", "cancel-query", "cancel", "wrong-user", "old-channel"} { + item := logs[0] + item.ID = id + item.CreatedAt = now.Add(time.Second) + switch id { + case "poll", "download", "upload", "workflow-schema", "cancel-query", "cancel": + item.RequestKind = id + item.Model = "auxiliary" + item.CreatedAt = now.Add(-time.Second) + case "wrong-user": + item.UserID = "other" + case "old-channel": + item.ChannelID = "previous" + } + logs = append(logs, item) + } + if err := db.Create(&orders).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&logs).Error; err != nil { + t.Fatal(err) + } + query := AnalyticsQuery{From: now.Add(-time.Hour).Format(time.RFC3339), To: now.Add(time.Hour).Format(time.RFC3339)} + result, err := svc.AdminAnalytics(admin, query) + if err != nil { + t.Fatal(err) + } + finance := result.KPI.Finance + if finance.SettledOrders != 4 || finance.CostedOrders != 3 || finance.RevenueMicrocredits != 1_400_000 || finance.CostMicrocredits != 600_000 || finance.ProfitMicrocredits != nil || finance.ProfitMargin != nil { + t.Fatalf("partial finance = %+v", finance) + } + for _, row := range result.Models { + f := row.Finance + switch row.Model { + case "paid": + if f.SettledOrders != 1 || f.ProfitMicrocredits == nil || *f.ProfitMicrocredits != 600_000 || f.ProfitMargin == nil || *f.ProfitMargin < 66.6 || *f.ProfitMargin > 66.7 { + t.Fatalf("paid = %+v", f) + } + case "free": + if f.CostedOrders != 1 || f.ProfitMicrocredits == nil || *f.ProfitMicrocredits != 0 || f.ProfitMargin != nil { + t.Fatalf("free = %+v", f) + } + case "loss": + if f.ProfitMicrocredits == nil || *f.ProfitMicrocredits != -200_000 || f.ProfitMargin == nil || *f.ProfitMargin != -200 { + t.Fatalf("loss = %+v", f) + } + } + } + data, err := svc.AdminAnalyticsCSV(admin, query) + if err != nil { + t.Fatal(err) + } + rows, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(string(data), "\xEF\xBB\xBF"))).ReadAll() + if err != nil { + t.Fatal(err) + } + var revenue, cost int64 + for _, row := range rows[1:] { + if row[6] == "auxiliary" && (row[15] != "" || row[16] != "" || row[17] != "") { + t.Fatalf("auxiliary request must not carry order finances: %v", row) + } + for index, target := range map[int]*int64{15: &revenue, 16: &cost} { + if row[index] != "" { + value, err := strconv.ParseInt(row[index], 10, 64) + if err != nil { + t.Fatal(err) + } + *target += value + } + } + } + if revenue != finance.RevenueMicrocredits || cost != finance.CostMicrocredits { + t.Fatalf("CSV mismatch: revenue=%d cost=%d", revenue, cost) + } + query.Model = "paid" + filtered, err := svc.AdminAnalytics(admin, query) + if err != nil || filtered.KPI.Finance.SettledOrders != 1 || filtered.KPI.Finance.ProfitMicrocredits == nil || *filtered.KPI.Finance.ProfitMicrocredits != 600_000 { + t.Fatalf("model filter = %+v, %v", filtered, err) + } + query.ChannelID = "previous" + filtered, err = svc.AdminAnalytics(admin, query) + if err != nil || filtered.KPI.Finance.SettledOrders != 0 { + t.Fatalf("previous channel finance = %+v, %v", filtered, err) + } + query.Model, query.ChannelID = "auxiliary", "" + filtered, err = svc.AdminAnalytics(admin, query) + if err != nil || filtered.KPI.Finance.SettledOrders != 0 { + t.Fatalf("auxiliary request finance = %+v, %v", filtered, err) + } + if _, err := svc.AdminAnalytics(&model.User{Role: model.UserRoleUser}, query); err == nil { + t.Fatal("non-admin must not read costs") + } +} + +func TestAdminReferencesIncludeExistingDisplayNameGroups(t *testing.T) { + svc, db := newChannelModelTestService(t) + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatal(err) + } + channels := []model.ModelChannel{ + {ID: "one", Scope: model.ChannelScopeSystem, Enabled: true}, + {ID: "two", Scope: model.ChannelScopeSystem, Enabled: false}, + } + if err := db.Create(&channels).Error; err != nil { + t.Fatal(err) + } + items := []model.ChannelModel{ + {ID: "a", ChannelID: "one", ModelKey: "a", DisplayName: "Shared", Enabled: true}, + {ID: "b", ChannelID: "one", ModelKey: "b", DisplayName: "Shared", Enabled: false}, + {ID: "c", ChannelID: "two", ModelKey: "c", DisplayName: "Other", Enabled: false}, + {ID: "d", ChannelID: "two", ModelKey: "d"}, + } + if err := db.Create(&items).Error; err != nil { + t.Fatal(err) + } + result, err := svc.AdminReferences(&model.User{Role: model.UserRoleAdmin}) + if err != nil { + t.Fatal(err) + } + groups := map[string]int{} + for _, channel := range result.Channels { + for _, name := range channel.ModelDisplayNames { + groups[name]++ + } + } + if len(groups) != 3 || groups["Shared"] != 1 || groups["Other"] != 1 || groups["d"] != 1 { + t.Fatalf("groups = %#v", groups) + } +} diff --git a/backend/internal/app/payment_plugins.go b/backend/internal/app/payment_plugins.go index 8c3c50b7d..9dd0428f8 100644 --- a/backend/internal/app/payment_plugins.go +++ b/backend/internal/app/payment_plugins.go @@ -9,10 +9,12 @@ const ( PaymentPluginAlipayPage = "official-payment-alipay-page" PaymentPluginXunHuPay = "official-payment-xunhupay" PaymentPluginZhiFuFM = "official-payment-zhifufm" + PaymentPluginEpay = "official-payment-epay" PaymentProviderWeChat = "wechat-native" PaymentProviderAlipay = "alipay-page-pay" PaymentProviderXunHuPay = "xunhupay-aggregate" PaymentProviderZhiFuFM = "zhifufm-pay" + PaymentProviderEpay = "epay" ) func bundledPaymentPluginManifests() []protocol.Manifest { diff --git a/backend/internal/app/payment_plugins_test.go b/backend/internal/app/payment_plugins_test.go index 883fa5c1a..49b91fe29 100644 --- a/backend/internal/app/payment_plugins_test.go +++ b/backend/internal/app/payment_plugins_test.go @@ -186,3 +186,44 @@ func TestZhiFuFMOfficialPackageIsPaymentPlugin(t *testing.T) { t.Fatalf("zhifufm provider type = %T", provider) } } + +func TestEpayOfficialPackageIsPaymentPlugin(t *testing.T) { + center, err := newPluginRuntime(t.TempDir()) + if err != nil { + t.Fatal(err) + } + var plugin PluginView + for _, item := range center.list() { + if item.Manifest.ID == "official-payment-epay" { + plugin = item + plugin.Management = pluginManagementFromView(item) + break + } + } + if plugin.Manifest.ID == "" { + t.Fatal("official-payment-epay is missing") + } + if plugin.Management.Kind != PluginKindPayment || plugin.Source != PluginOriginOfficial { + t.Fatalf("epay plugin = %#v", plugin) + } + if len(plugin.Manifest.Contributes.PaymentProviders) != 1 || plugin.Manifest.Contributes.PaymentProviders[0].ID != "epay" { + t.Fatalf("epay contributions = %#v", plugin.Manifest.Contributes.PaymentProviders) + } + registry := center.paymentRegistrySnapshot() + if registry == nil { + t.Fatal("payment registry is nil") + } + provider, ok := registry.Get("epay") + if !ok { + t.Fatal("epay provider is missing") + } + 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/internal/protocol/payment_artifact_test.go b/backend/internal/protocol/payment_artifact_test.go index ffa8d7f66..71bd1a4eb 100644 --- a/backend/internal/protocol/payment_artifact_test.go +++ b/backend/internal/protocol/payment_artifact_test.go @@ -17,6 +17,7 @@ var officialPaymentPackageIDs = []string{ "official-payment-alipay-page", "official-payment-xunhupay", "official-payment-zhifufm", + "official-payment-epay", } var officialPaymentTaggedArtifacts = []string{ diff --git a/backend/payment-plugins/aliases.go b/backend/payment-plugins/aliases.go index 674ab8914..09e78175d 100644 --- a/backend/payment-plugins/aliases.go +++ b/backend/payment-plugins/aliases.go @@ -26,8 +26,10 @@ const ( ProviderAlipayPage = "alipay-page-pay" ProviderXunHuPay = "xunhupay-aggregate" ProviderZhiFuFM = "zhifufm-pay" + ProviderEpay = "epay" PluginWeChatNative = "official-payment-wechat-native" PluginAlipayPage = "official-payment-alipay-page" PluginXunHuPay = "official-payment-xunhupay" PluginZhiFuFM = "official-payment-zhifufm" + PluginEpay = "official-payment-epay" ) diff --git a/backend/payment-plugins/epay.go b/backend/payment-plugins/epay.go new file mode 100644 index 000000000..eb8a64b2e --- /dev/null +++ b/backend/payment-plugins/epay.go @@ -0,0 +1,518 @@ +package paymentplugins + +import ( + "bytes" + "context" + "crypto/md5" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +const ( + epayPluginVersion = "1.0.9" + epayQRLifetime = 10 * time.Minute + maxEpayResponse = 2 << 20 +) + +type EpayProvider struct { + client *http.Client + now func() time.Time +} + +func NewEpayProvider(client *http.Client) *EpayProvider { + if client == nil { + client = http.DefaultClient + } + return &EpayProvider{client: client, now: time.Now} +} + +func (p *EpayProvider) Descriptor() Descriptor { + return Descriptor{ + ID: ProviderEpay, PluginID: PluginEpay, PluginVersion: epayPluginVersion, + Name: "易支付", Icon: "assets/icon.svg", CheckoutMode: "redirect", + IdentityFields: []string{"pid"}, + NotificationSuccess: NotificationResponse{Status: 200, ContentType: "text/plain; charset=utf-8", Body: "success"}, + NotificationFailure: NotificationResponse{Status: 400, ContentType: "text/plain; charset=utf-8", Body: "failure"}, + } +} + +func (p *EpayProvider) ValidateConfig(config Config) error { + if strings.TrimSpace(config["pid"]) == "" { + return errors.New("易支付配置缺少商户号 pid") + } + version := epayVersion(config) + if version != "0" && version != "1" { + return errors.New("易支付 version 必须是 0 或 1") + } + if version == "0" && strings.TrimSpace(config["key"]) == "" { + return errors.New("易支付 V1 配置缺少商户密钥 key") + } + if version == "1" { + if strings.TrimSpace(config["privateKey"]) == "" || strings.TrimSpace(config["platformPublicKey"]) == "" { + return errors.New("易支付 V2 配置缺少 privateKey 或 platformPublicKey") + } + if _, err := parseRSAPrivateKey(config["privateKey"]); err != nil { + return fmt.Errorf("易支付商户私钥无效: %w", err) + } + if _, err := parseRSAPublicKey(config["platformPublicKey"]); err != nil { + return fmt.Errorf("易支付平台公钥无效: %w", err) + } + } + if _, err := epayGateway(config); err != nil { + return err + } + return nil +} + +func (p *EpayProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) { + if err := p.ValidateConfig(config); err != nil { + return Checkout{}, err + } + if request.AmountFen <= 0 || request.Currency != "CNY" || strings.TrimSpace(request.MerchantOrderNo) == "" || strings.TrimSpace(request.NotifyURL) == "" { + return Checkout{}, errors.New("易支付下单参数无效") + } + + params := map[string]string{ + "pid": strings.TrimSpace(config["pid"]), + "name": epayTitle(request.Description, request.MerchantOrderNo), + "type": epayPayType(config), + "money": formatEpayAmount(request.AmountFen), + "out_trade_no": request.MerchantOrderNo, + "notify_url": request.NotifyURL, + "return_url": request.ReturnURL, + "sitename": request.MerchantOrderNo, + // CreateRequest 不带买家 IP;mapi 仍要求 clientip,占位即可。 + "clientip": "0.0.0.0", + "device": epayDevice(config), + } + + version := epayVersion(config) + path := "/mapi.php" + successCode := "1" + if version == "1" { + params["method"] = "jump" + params["timestamp"] = strconv.FormatInt(p.now().Unix(), 10) + 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" + } else { + params["sign"] = epayMD5Sign(params, config["key"]) + params["sign_type"] = "MD5" + } + + response, status, err := p.postForm(ctx, config, path, params) + if err != nil { + return Checkout{}, err + } + if response["code"] != successCode { + message := strings.TrimSpace(epayString(response["msg"])) + if message == "" { + message = fmt.Sprintf("易支付返回失败状态 code=%s", response["code"]) + } + return Checkout{}, &ProviderError{Code: "epay_order_create_failed", Message: message, Temporary: status >= 500} + } + + mode := epayCheckoutMode(config) + var value string + if version == "1" { + // method=jump 只会返回跳转地址 + value = epayString(response["pay_info"]) + mode = "redirect" + } else { + // payurl、qrcode、urlscheme 三者只会返回其一 + qrcode := epayString(response["qrcode"]) + if mode == "qr_code" && qrcode != "" { + value = qrcode + } else { + // urlscheme 是小程序跳转链接,前端直接跳;没有本地收银台时二维码链接也直接跳过去 + value = firstNonEmpty(epayString(response["payurl"]), epayString(response["urlscheme"]), qrcode) + mode = "redirect" + } + } + if !isEpayURL(value) { + return Checkout{}, &ProviderError{Code: "epay_invalid_checkout", Message: "易支付未返回有效的支付地址"} + } + + expiresAt := request.ExpiresAt + if expiresAt.IsZero() { + expiresAt = p.now().Add(epayQRLifetime) + } + return Checkout{Mode: mode, Value: value, ExpiresAt: expiresAt}, nil +} + +func (p *EpayProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) { + if err := p.ValidateConfig(config); err != nil { + return Result{}, err + } + if strings.TrimSpace(request.MerchantOrderNo) == "" { + return Result{}, errors.New("易支付查单缺少商户订单号") + } + if epayVersion(config) == "1" { + return Result{}, &ProviderError{Code: "epay_query_unsupported", Message: "易支付 V2 查单接口未在当前协议中实现"} + } + + base, _ := epayGateway(config) + query := url.Values{} + query.Set("act", "order") + query.Set("pid", strings.TrimSpace(config["pid"])) + query.Set("key", strings.TrimSpace(config["key"])) + query.Set("out_trade_no", request.MerchantOrderNo) + endpoint := strings.TrimRight(base, "/") + "/api.php?" + query.Encode() + response, status, err := p.getJSON(ctx, endpoint) + if err != nil { + return Result{}, err + } + if response["code"] != "1" { + message := strings.TrimSpace(epayString(response["msg"])) + if message == "" { + message = "易支付订单不存在或查询失败" + } + return Result{}, &ProviderError{Code: "epay_query_failed", Message: message, Temporary: status >= 500} + } + 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) { + if err := p.ValidateConfig(config); err != nil { + return Result{}, err + } + if strings.TrimSpace(request.MerchantOrderNo) == "" { + return Result{}, errors.New("易支付关单缺少商户订单号") + } + if epayVersion(config) == "0" { + result, err := p.QueryOrder(ctx, config, QueryRequest{MerchantOrderNo: request.MerchantOrderNo}) + if err != nil { + return Result{}, err + } + if result.Paid { + return result, nil + } + result.Closed = true + result.ProviderStatus = "TRADE_CLOSED" + return result, nil + } + // 未能查证上游状态时不能把未知订单标为已关闭。 + return Result{}, &ProviderError{Code: "epay_close_unsupported", Message: "易支付 V2 暂不支持查单及安全关单,请在支付网关核对订单"} +} + +func (p *EpayProvider) VerifyNotification(_ context.Context, config Config, _ http.Header, rawBody []byte) (Notification, error) { + if err := p.ValidateConfig(config); err != nil { + return Notification{}, err + } + params, err := parseNotificationValues(rawBody) + if err != nil { + return Notification{}, fmt.Errorf("解析易支付异步通知失败: %w", err) + } + values := make(map[string]string, len(params)) + for key, items := range params { + if len(items) != 1 { + return Notification{}, errors.New("易支付异步通知存在重复参数") + } + values[key] = items[0] + } + if values["pid"] != strings.TrimSpace(config["pid"]) { + return Notification{}, errors.New("易支付异步通知商户号不匹配") + } + if strings.TrimSpace(values["out_trade_no"]) == "" || strings.TrimSpace(values["money"]) == "" { + return Notification{}, errors.New("易支付异步通知缺少订单号或金额") + } + + if epayVersion(config) == "1" { + signType := strings.ToUpper(strings.TrimSpace(values["sign_type"])) + if signType != "" && signType != "RSA" && signType != "RSA2" { + return Notification{}, errors.New("易支付异步通知签名类型无效") + } + publicKey, err := parseRSAPublicKey(config["platformPublicKey"]) + if err != nil { + return Notification{}, err + } + if err := rsaSHA256Verify(publicKey, []byte(epayCanonical(values)), values["sign"]); err != nil { + return Notification{}, errors.New("易支付异步通知 RSA 签名无效") + } + } else { + signType := strings.ToUpper(strings.TrimSpace(values["sign_type"])) + if signType != "" && signType != "MD5" { + return Notification{}, errors.New("易支付异步通知签名类型无效") + } + // 定时安全比较,且只吃字符串(挡掉 sign 传数组/缺失);与其它支付插件一致 + if !epaySafetyEquals(values["sign"], epayMD5Sign(values, config["key"])) { + return Notification{}, errors.New("易支付异步通知 MD5 签名无效") + } + } + + amount, err := parseYuanToFen(values["money"]) + if err != nil || amount <= 0 { + return Notification{}, errors.New("易支付异步通知金额无效") + } + status := strings.TrimSpace(values["trade_status"]) + // 宿主通知队列只处理到账事件,其他状态不得占用同一流水的幂等键。 + 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: true, PaidAt: parseEpayTime(firstNonEmpty(values["endtime"], values["gmt_payment"])), + }, + }, nil +} + +func (p *EpayProvider) DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error) { + return nil, fmt.Errorf("%w: 易支付未提供统一账单下载接口", ErrTradeBillNotFound) +} + +type epayResponse map[string]string + +func (p *EpayProvider) postForm(ctx context.Context, config Config, path string, values map[string]string) (epayResponse, int, error) { + base, err := epayGateway(config) + if err != nil { + return nil, 0, err + } + form := url.Values{} + for key, value := range values { + form.Set(key, value) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+path, strings.NewReader(form.Encode())) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // 网关卡住不能一直等;超时由注入的 http.Client 控制(RPC 入口约 25s) + resp, err := p.client.Do(req) + if err != nil { + return nil, 0, &ProviderError{Code: "epay_transport_error", Message: "易支付网络请求失败", Temporary: true, Cause: err} + } + defer resp.Body.Close() + return epayJSONResponse(resp) +} + +func (p *EpayProvider) getJSON(ctx context.Context, endpoint string) (epayResponse, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, 0, err + } + resp, err := p.client.Do(req) + if err != nil { + return nil, 0, &ProviderError{Code: "epay_transport_error", Message: "易支付网络请求失败", Temporary: true, Cause: err} + } + defer resp.Body.Close() + return epayJSONResponse(resp) +} + +func epayJSONResponse(resp *http.Response) (epayResponse, int, error) { + body, err := io.ReadAll(io.LimitReader(resp.Body, maxEpayResponse+1)) + if err != nil { + return nil, resp.StatusCode, &ProviderError{Code: "epay_response_read_error", Message: "读取易支付响应失败", Temporary: true, Cause: err} + } + if len(body) > maxEpayResponse { + return nil, resp.StatusCode, &ProviderError{Code: "epay_response_too_large", Message: "易支付响应过大"} + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, resp.StatusCode, &ProviderError{Code: "epay_http_error", Message: fmt.Sprintf("易支付 HTTP 状态异常: %d", resp.StatusCode), Temporary: resp.StatusCode >= 500} + } + var raw map[string]any + // 有的网关输出带 BOM + 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) + } + return result, resp.StatusCode, nil +} + +func epayGateway(config Config) (string, error) { + gateway := strings.TrimSpace(config["gateway"]) + if gateway == "" { + 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 != "" { + return "", errors.New("易支付网关必须是有效的 HTTP(S) 根地址") + } + return strings.TrimRight(gateway, "/"), nil +} + +func epayVersion(config Config) string { + version := strings.TrimSpace(config["version"]) + if version == "" { + return "0" + } + return version +} + +func epayCheckoutMode(config Config) string { + if strings.TrimSpace(config["checkoutMode"]) == "qr_code" { + return "qr_code" + } + return "redirect" +} + +func epayPayType(config Config) string { + payType := strings.TrimSpace(config["payType"]) + if payType == "" { + return "alipay" + } + return payType +} + +// mapi 是服务端下单,网关看不到买家的浏览器,只能靠 device 决定给什么支付页。 +// 微信/QQ/支付宝里打开的要报对,不然拿到的是 App 内打不开的 H5 或二维码。 +// 宿主 CreateRequest 不带 UA,可用配置 device 覆盖;留空默认 pc。 +func epayDevice(config Config) string { + if device := strings.TrimSpace(config["device"]); device != "" { + return device + } + return "pc" +} + +func epayTitle(description, orderNo string) string { + title := strings.TrimSpace(description) + if title == "" { + title = "商品订单号:" + orderNo + } + return strings.ReplaceAll(title, "%", "") +} + +func formatEpayAmount(amountFen int64) string { + return strconv.FormatInt(amountFen/100, 10) + "." + fmt.Sprintf("%02d", amountFen%100) +} + +func epayMD5Sign(values map[string]string, key string) string { + return epayMD5Hex(epayCanonical(values) + key) +} + +func epayCanonical(values map[string]string) string { + keys := make([]string, 0, len(values)) + for key, value := range values { + if key == "sign" || key == "sign_type" || value == "" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+values[key]) + } + return strings.Join(parts, "&") +} + +func epayMD5Hex(value string) string { + hash := md5.Sum([]byte(value)) + return hex.EncodeToString(hash[:]) +} + +func epayRSASign(values map[string]string, privateKey string) (string, error) { + key, err := parseRSAPrivateKey(privateKey) + if err != nil { + return "", err + } + 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, expectedOrderNo string) (Result, error) { + status := epayString(response["status"]) + if status == "" && response["trade_status"] != "" { + status = response["trade_status"] + } + 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: 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 { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + default: + return "" + } +} + +// 只允许浏览器支付页和已知支付 App 协议,不接受任意本机协议。 +func isEpayURL(value string) bool { + value = strings.TrimSpace(value) + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil { + return false + } + scheme := strings.ToLower(parsed.Scheme) + switch scheme { + case "http", "https", "weixin", "alipay", "alipays", "mqqapi": + return true + default: + return false + } +} + +func parseEpayTime(value string) time.Time { + value = strings.TrimSpace(value) + for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} { + if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil { + return parsed + } + } + return 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/backend/payment-plugins/epay_test.go b/backend/payment-plugins/epay_test.go new file mode 100644 index 000000000..983a4c5e0 --- /dev/null +++ b/backend/payment-plugins/epay_test.go @@ -0,0 +1,151 @@ +package paymentplugins + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func TestEpayV1CreateAndVerifyNotification(t *testing.T) { + const key = "epay-test-key" + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/mapi.php" { + t.Fatalf("request = %s %s", request.Method, request.URL.Path) + } + values := mustEpayForm(t, request) + if values["device"] != "pc" || values["type"] != "alipay" { + t.Fatalf("form = %#v", values) + } + if !epaySafetyEquals(values["sign"], epayMD5Sign(values, key)) { + t.Fatalf("sign = %#v", values) + } + // BOM 前缀应被剥离 + _, _ = writer.Write([]byte("\xEF\xBB\xBF" + `{"code":1,"msg":"ok","trade_no":"EPAY-1","qrcode":"https://pay.example/qr/1"}`)) + })) + defer server.Close() + + provider := NewEpayProvider(server.Client()) + config := Config{"pid": "1001", "key": key, "gateway": server.URL, "checkoutMode": "qr_code"} + checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{ + MerchantOrderNo: "ORDER-1", Description: "充值 10 积分", AmountFen: 100, + Currency: "CNY", NotifyURL: "https://merchant.example/pay/notify", + }) + if err != nil { + t.Fatal(err) + } + if checkout.Mode != "qr_code" || checkout.Value != "https://pay.example/qr/1" { + t.Fatalf("checkout = %#v", checkout) + } + + values := map[string]string{ + "pid": "1001", "trade_no": "EPAY-1", "out_trade_no": "ORDER-1", "type": "alipay", + "name": "充值 10 积分", "money": "1.00", "trade_status": "TRADE_SUCCESS", "endtime": "2026-09-19 12:01:00", + } + values["sign"] = epayMD5Sign(values, key) + form := url.Values{} + for name, value := range values { + form.Set(name, value) + } + notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(form.Encode())) + if err != nil { + t.Fatal(err) + } + if !notification.Paid || notification.AmountFen != 100 || notification.ProviderTradeNo != "EPAY-1" { + t.Fatalf("notification = %#v", notification) + } +} + +func TestEpayV1QueryAndLocalClose(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api.php" || request.URL.Query().Get("act") != "order" { + t.Fatalf("request = %s %s", request.Method, request.URL.String()) + } + _, _ = writer.Write([]byte(`{"code":1,"out_trade_no":"ORDER-2","trade_no":"EPAY-2","status":0,"money":"2.00"}`)) + })) + defer server.Close() + + provider := NewEpayProvider(server.Client()) + config := Config{"pid": "1001", "key": "key", "gateway": server.URL} + result, err := provider.QueryOrder(context.Background(), config, QueryRequest{MerchantOrderNo: "ORDER-2"}) + if err != nil { + t.Fatal(err) + } + if result.Paid || result.AmountFen != 200 || result.ProviderTradeNo != "EPAY-2" { + t.Fatalf("result = %#v", result) + } + closed, err := provider.CloseOrder(context.Background(), config, CloseRequest{MerchantOrderNo: "ORDER-2"}) + if err != nil { + t.Fatal(err) + } + if !closed.Closed || closed.Paid { + t.Fatalf("closed = %#v", closed) + } +} + +func TestEpayV2CreateUsesRSASignature(t *testing.T) { + privateKey, publicKey := testRSAKeyPair(t) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/pay/create" { + t.Fatalf("path = %s", request.URL.Path) + } + values := mustEpayForm(t, request) + if err := rsaSHA256Verify(publicKey, []byte(epayCanonical(values)), values["sign"]); err != nil { + t.Fatalf("sign: %v", err) + } + _, _ = writer.Write([]byte(`{"code":0,"pay_info":"https://pay.example/v2/1"}`)) + })) + defer server.Close() + + provider := NewEpayProvider(server.Client()) + config := Config{ + "pid": "1001", "version": "1", "gateway": server.URL, + "privateKey": testPrivatePEM(privateKey), "platformPublicKey": testPublicPEM(&privateKey.PublicKey), + } + checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{ + MerchantOrderNo: "ORDER-V2", AmountFen: 100, Currency: "CNY", NotifyURL: "https://merchant.example/notify", + }) + if err != nil { + t.Fatal(err) + } + if checkout.Mode != "redirect" || checkout.Value != "https://pay.example/v2/1" { + t.Fatalf("checkout = %#v", checkout) + } +} + +func TestEpayRejectsUnsafeGatewayAndMissingBills(t *testing.T) { + provider := NewEpayProvider(http.DefaultClient) + if err := provider.ValidateConfig(Config{"pid": "1001", "key": "key", "gateway": "https://user:pass@example.invalid"}); err == nil { + t.Fatal("expected unsafe gateway error") + } + _, err := provider.DownloadTradeBill(context.Background(), Config{}, time.Now()) + if !errors.Is(err, ErrTradeBillNotFound) { + t.Fatalf("error = %v", err) + } + if isEpayURL("javascript:alert(1)") || !isEpayURL("weixin://dl/business/?t=1") { + t.Fatal("isEpayURL scheme rules mismatch") + } +} + +func mustEpayForm(t *testing.T, request *http.Request) map[string]string { + t.Helper() + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatal(err) + } + values := make(map[string]string, len(form)) + for name, items := range form { + if len(items) > 0 { + values[name] = items[0] + } + } + return values +} diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 37303b19f..aa9384fc9 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -23,6 +23,8 @@ description: 信封、错误码与 OpenAPI 同一产品在不同渠道中使用相同的模型展示名即可归组,`modelKey` 可以不同;相同 `modelKey` 但展示名不同会分为不同组。一级顺序与图标按后台渠道和模型排序首次出现的位置确定,二级保持后台渠道与模型顺序。即使二级展示名相同,也保留独立选项及各自能力、价格,不自动改写历史配置或交换两个展示字段。 +`GET /admin/references` 的 `channels[]` 同时返回 `modelDisplayNames`,包含系统渠道已创建模型的一级分组名称(含停用模型,空展示名使用产品模型标识)。编辑器“模型展示名(一级目录)”汇总去重后提供可搜索下拉,并允许输入新分组;保存后刷新引用数据。 + 以下接口需要登录 Cookie,返回 `data.quote`,仅校验和报价,不创建任务、账单或冻结余额,不调用供应商生成接口。 | 接口 | 请求 | @@ -44,6 +46,12 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考 成本字段仅通过经过管理员权限校验的接口显式返回;普通用户的模型目录、会话模型信息、报价、任务与账单 JSON 均不包含成本配置、成本快照或成本金额。底层价格档和账单成本字段禁止 JSON 序列化,不以页面隐藏代替接口隔离。 +数据概览不再提供独立的“模型价格配置”入口。`GET /admin/analytics/overview` 的 `kpi`、`models[]` 使用 `finance` 返回 `settledOrders`、`costedOrders`、`revenueMicrocredits`、`costMicrocredits`、可空的 `profitMicrocredits`、`profitMargin`,取代旧的费用/币种字段。按当前请求日志时间及筛选范围关联已结算订单,以订单 ID 去重并核对用户与实际结算渠道;轮询、下载、未结算、退款和无关联账单请求不计入财务汇总。收入为实际结算积分,成本复用系统模型价格档的订单快照与计费用量,利润为收入减成本,利润率为利润占收入百分比。已知成本可显示部分合计;只要有已结算订单缺失成本,利润和利润率返回 `null`,不把未知成本视为零。无订单时利润为 `null`;零收入时利润率为 `null`。历史无成本快照的订单不按当前价回填。此处为已结算生成订单的积分毛利,不含充值收入或失败请求的上游损耗。 + +`GET /admin/analytics/export.csv` 沿用相同口径,CSV 输出收入、成本、利润(整数微积分);重复请求只在该筛选范围内选定的一行记录金额,未知值留空。 + +上传、工作流结构查询、取消查询和取消请求也不参与财务归属,即使日志被标记为可计费;它们不能代替生成请求承载订单金额。订单是否已结算以账单状态为准,保留人工核账后结算订单的统计能力。 + ## 画布保存、历史与恢复 画布内容使用数据库递增的 `revision` 做乐观并发校验;`updatedAt` 仅用于展示,不作为版本依据。新建 `PUT /canvas-projects/:id` 的 `project.revision` 为 0,已有画布必须带上读取到的版本。数据库在同一次条件更新中匹配 ID、用户与版本,成功后返回 `data.project` 摘要及递增后的版本;缺失版本返回 428,陈旧版本返回 409,非法版本返回 400。客户端不能获取新版号后直接重发旧内容。 @@ -129,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 62b6fe779..fc481b5d7 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -32,6 +32,33 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [x] 登录线上真实画布确认同样的 CSS 加载顺序:Agent 内容区 `px-5 py-5` 计算内边距为 `0px`。仅在调试标签页头部临时声明层顺序后,内容区恢复 `20px`,头部恢复水平 `16px`,输入区外层恢复 `8px 12px 12px`;截图确认留白恢复。撤掉临时声明后内容区再次变为 `0px`,验证因果关系。未修改服务器、提交生成/审批或编辑画布内容,临时样式已移除。 - [ ] 发布后复验画布与 Agent 的消息内边距、待办间距、输入区和工具栏,覆盖冷启动直达、站内导航、明暗主题。本次浏览器验证为暗色空对话面板,未覆盖原截图的历史审批会话;线上尚未部署修复。 +## 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 项通过,前端类型检查与生产构建通过;财务汇总、实际用量成本和无效成本价格专项测试通过。本轮合并复测已完成 SQLite 集成测试,此前本机 GCC/CGO 阻断不再存在;以下真实页面与数据链路仍待验收。 + +缺失字段修复验证:新增 4 项测试直接执行模型表格财务列的渲染函数,覆盖未返回或空财务对象、零金额、部分成本和亏损;与后台 UI 回归测试共 21 项通过,前端类型检查及生产构建通过。 + +- [ ] 在真实数据概览切换时间、模型、渠道和用户,核对订单去重、部分成本提示及 CSV 金额;确认退款、未结算订单不计收入。 +- [ ] 工作流上传、结构查询、轮询、下载和取消相关日志不承载订单金额;核对 CSV 与概览一致。 +- [ ] 后端响应缺少财务统计时,页面应保留其他统计并显示提示,金额为 `--`,不误报零费用或暂无订单;后端更新并重启后刷新,确认财务数据恢复。模型分组列表未返回时编辑器仍可输入名称。 +- [ ] 检查浅色、深色与窄屏下的费用区域、模型表格横向滚动及长金额换行。 +- [ ] 新建或编辑模型,在“模型展示名(一级目录)”选择其他渠道已有分组;输入新分组保存后再次编辑,确认下拉更新且创作端归组正确。 + ## 画布素材链路与工作条改造(2026-09-19) 已实现: diff --git a/plugin-packages/build-packages.sh b/plugin-packages/build-packages.sh index b6d486373..6986650cc 100755 --- a/plugin-packages/build-packages.sh +++ b/plugin-packages/build-packages.sh @@ -3,7 +3,7 @@ set -eu root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo_dir=$(CDPATH= cd -- "$root_dir/.." && pwd) -payment_plugins="official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm" +payment_plugins="official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm official-payment-epay" payments_only=false default_payment_targets="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64" @@ -119,6 +119,7 @@ for target in $payment_targets; do build_payment_provider official-payment-alipay-page ./cmd/payment-alipay "$target_goos" "$target_goarch" build_payment_provider official-payment-xunhupay ./cmd/payment-xunhupay "$target_goos" "$target_goarch" build_payment_provider official-payment-zhifufm ./cmd/payment-zhifufm "$target_goos" "$target_goarch" + build_payment_provider official-payment-epay ./cmd/payment-epay "$target_goos" "$target_goarch" done package_plugin() { diff --git a/plugin-packages/official-payment-epay.yingce-plugin b/plugin-packages/official-payment-epay.yingce-plugin new file mode 100644 index 000000000..9eada714e Binary files /dev/null and b/plugin-packages/official-payment-epay.yingce-plugin differ diff --git a/plugin-packages/official-payment-epay/README.md b/plugin-packages/official-payment-epay/README.md new file mode 100644 index 000000000..20ff818a9 --- /dev/null +++ b/plugin-packages/official-payment-epay/README.md @@ -0,0 +1,3 @@ +# 易支付 + +官方 `yingce.payment/v1` 支付插件。兼容常见易支付网关:V1/MD5 `mapi.php` 与 V2/RSA `/api/pay/create`,支持支付宝/微信/QQ 等通道。协议实现参考 [acg-faka](https://github.com/lizhipay/acg-faka)。 diff --git a/plugin-packages/official-payment-epay/assets/icon.svg b/plugin-packages/official-payment-epay/assets/icon.svg new file mode 100644 index 000000000..2d61bcf5e --- /dev/null +++ b/plugin-packages/official-payment-epay/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugin-packages/official-payment-epay/backend/provider b/plugin-packages/official-payment-epay/backend/provider new file mode 100755 index 000000000..98ba1ab51 Binary files /dev/null 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 new file mode 100755 index 000000000..3a8f9207e Binary files /dev/null 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 new file mode 100755 index 000000000..0dbd0d539 Binary files /dev/null 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 new file mode 100755 index 000000000..98ba1ab51 Binary files /dev/null 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 new file mode 100755 index 000000000..a9f815ed0 Binary files /dev/null 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 new file mode 100755 index 000000000..ba6ea642f Binary files /dev/null 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 new file mode 100755 index 000000000..86681d951 Binary files /dev/null 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 new file mode 100644 index 000000000..38ed3a32b --- /dev/null +++ b/plugin-packages/official-payment-epay/docs/interface.md @@ -0,0 +1,155 @@ +## yingce.payment/v1 + +支持 `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`。 + +必须显式填写可信 `gateway`,不默认向任何第三方网关发送商户信息。V1 查单严格校验商户(响应包含时)、订单号、正数金额、支付状态和成功流水号;不以请求中的订单号或零金额掩盖不完整响应。通知校验拒绝重复参数、无效签名、非正金额及缺失的成功流水号。支付地址只接受 HTTP(S) 与明确允许的微信、支付宝、QQ 支付协议,不接受任意本机协议或含用户名密码的地址。 + +V1 关单先查单:已支付订单返回付款凭据,未付款订单只关闭宿主本地记录,不能据此声称上游支付链接已失效。V2 当前仅实现下单和通知验签,未实现查单、刷新收银台和安全关单;`query_order` 返回 `epay_query_unsupported`,`close_order` 返回 `epay_close_unsupported`,不会把未知订单标成已关闭。启用 V2 前需接受这一限制并准备在网关人工核对订单;本次没有新增或猜测 V2 查单接口。交易账单下载返回 not found,不表示已完成对账。 + + +## Manifest 完整接口定义 + +以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。 + +```json +{ + "apiVersion": "yingce.plugin/v1", + "id": "official-payment-epay", + "name": "易支付", + "version": "1.0.9", + "author": "Epay", + "description": "易支付适配器,支持 V1/MD5 与 V2/RSA 的支付宝/微信/QQ 通道。", + "enabled": true, + "installable": true, + "runtime": { + "backend": "rpc", + "backendEntry": "backend/provider" + }, + "surfaces": [ + "wallet", + "settings" + ], + "permissions": [ + "payment.create", + "payment.query", + "payment.close", + "payment.reconcile" + ], + "configuration": { + "fields": [ + { + "name": "publicBaseUrl", + "type": "url", + "label": "服务器公网地址", + "required": true, + "description": "用于确认回调可达;实际 notify_url 由宿主订单生成。" + }, + { + "name": "pid", + "type": "string", + "label": "商户号 (pid)", + "required": true + }, + { + "name": "key", + "type": "password", + "label": "商户密钥 (key)", + "required": false, + "secret": true, + "description": "V1/MD5 密钥;version=1 可留空。" + }, + { + "name": "gateway", + "type": "url", + "label": "支付网关", + "required": true, + "description": "显式填写可信易支付网关根地址,不要带 /mapi.php;不会默认向第三方网关发送商户信息。" + }, + { + "name": "payType", + "type": "string", + "label": "默认支付方式", + "required": false, + "default": "alipay", + "values": [ + "alipay", + "wxpay", + "qqpay", + "usdt" + ] + }, + { + "name": "version", + "type": "string", + "label": "接口版本", + "required": false, + "default": "0", + "values": [ + "0", + "1" + ], + "description": "0=V1/MD5,1=V2/RSA。V2 仅支持下单和验签通知,暂不支持查单、刷新收银台及自动关单,启用前需确认此限制。" + }, + { + "name": "checkoutMode", + "type": "string", + "label": "收银方式", + "required": false, + "default": "redirect", + "values": [ + "redirect", + "qr_code" + ], + "description": "redirect 跳转;qr_code 展示二维码载荷。" + }, + { + "name": "privateKey", + "type": "textarea", + "label": "V2 商户私钥", + "required": false, + "secret": true, + "description": "仅 version=1,PEM 或裸 Base64。" + }, + { + "name": "platformPublicKey", + "type": "textarea", + "label": "V2 平台公钥", + "required": false, + "description": "仅 version=1,PEM 或裸 Base64。" + } + ] + }, + "contributes": { + "paymentProviders": [ + { + "id": "epay", + "label": "易支付", + "icon": "assets/icon.svg", + "checkoutMode": "redirect", + "identityFields": [ + "pid" + ], + "expiryPolicy": { + "defaultMinutes": 10, + "minMinutes": 5, + "maxMinutes": 1440 + }, + "notificationSuccess": { + "status": 200, + "contentType": "text/plain; charset=utf-8", + "body": "success" + }, + "notificationFailure": { + "status": 400, + "contentType": "text/plain; charset=utf-8", + "body": "failure" + } + } + ] + }, + "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>" +} +``` + diff --git a/plugin-packages/official-payment-epay/manifest.json b/plugin-packages/official-payment-epay/manifest.json new file mode 100644 index 000000000..674b3eba7 --- /dev/null +++ b/plugin-packages/official-payment-epay/manifest.json @@ -0,0 +1,137 @@ +{ + "apiVersion": "yingce.plugin/v1", + "id": "official-payment-epay", + "name": "易支付", + "version": "1.0.9", + "author": "Epay", + "description": "易支付适配器,支持 V1/MD5 与 V2/RSA 的支付宝/微信/QQ 通道。", + "enabled": true, + "installable": true, + "runtime": { + "backend": "rpc", + "backendEntry": "backend/provider" + }, + "surfaces": [ + "wallet", + "settings" + ], + "permissions": [ + "payment.create", + "payment.query", + "payment.close", + "payment.reconcile" + ], + "configuration": { + "fields": [ + { + "name": "publicBaseUrl", + "type": "url", + "label": "服务器公网地址", + "required": true, + "description": "用于确认回调可达;实际 notify_url 由宿主订单生成。" + }, + { + "name": "pid", + "type": "string", + "label": "商户号 (pid)", + "required": true + }, + { + "name": "key", + "type": "password", + "label": "商户密钥 (key)", + "required": false, + "secret": true, + "description": "V1/MD5 密钥;version=1 可留空。" + }, + { + "name": "gateway", + "type": "url", + "label": "支付网关", + "required": true, + "description": "显式填写可信易支付网关根地址,不要带 /mapi.php;不会默认向第三方网关发送商户信息。" + }, + { + "name": "payType", + "type": "string", + "label": "默认支付方式", + "required": false, + "default": "alipay", + "values": [ + "alipay", + "wxpay", + "qqpay", + "usdt" + ] + }, + { + "name": "version", + "type": "string", + "label": "接口版本", + "required": false, + "default": "0", + "values": [ + "0", + "1" + ], + "description": "0=V1/MD5,1=V2/RSA。V2 仅支持下单和验签通知,暂不支持查单、刷新收银台及自动关单,启用前需确认此限制。" + }, + { + "name": "checkoutMode", + "type": "string", + "label": "收银方式", + "required": false, + "default": "redirect", + "values": [ + "redirect", + "qr_code" + ], + "description": "redirect 跳转;qr_code 展示二维码载荷。" + }, + { + "name": "privateKey", + "type": "textarea", + "label": "V2 商户私钥", + "required": false, + "secret": true, + "description": "仅 version=1,PEM 或裸 Base64。" + }, + { + "name": "platformPublicKey", + "type": "textarea", + "label": "V2 平台公钥", + "required": false, + "description": "仅 version=1,PEM 或裸 Base64。" + } + ] + }, + "contributes": { + "paymentProviders": [ + { + "id": "epay", + "label": "易支付", + "icon": "assets/icon.svg", + "checkoutMode": "redirect", + "identityFields": [ + "pid" + ], + "expiryPolicy": { + "defaultMinutes": 10, + "minMinutes": 5, + "maxMinutes": 1440 + }, + "notificationSuccess": { + "status": 200, + "contentType": "text/plain; charset=utf-8", + "body": "success" + }, + "notificationFailure": { + "status": 400, + "contentType": "text/plain; charset=utf-8", + "body": "failure" + } + } + ] + }, + "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/plugin-packages/verify-payment-packages.sh b/plugin-packages/verify-payment-packages.sh index 2d44adec2..9cd4082dd 100644 --- a/plugin-packages/verify-payment-packages.sh +++ b/plugin-packages/verify-payment-packages.sh @@ -118,7 +118,7 @@ if [ "$require_all" = true ]; then fi fi -for package_id in official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm; do +for package_id in official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm official-payment-epay; do backend_dir="$root_dir/$package_id/backend" provider="$backend_dir/provider" package_file="$root_dir/$package_id.yingce-plugin" diff --git a/web/src/pages/admin/components/analytics-finance.ts b/web/src/pages/admin/components/analytics-finance.ts new file mode 100644 index 000000000..4c7f6b34b --- /dev/null +++ b/web/src/pages/admin/components/analytics-finance.ts @@ -0,0 +1,22 @@ +import type { ColumnsType } from "antd/es/table"; +import type { AdminAnalytics, AnalyticsFinance } from "@/services/api/auth"; + +export function formatCredits(value: number | null | undefined) { + return value == null ? "--" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 6 }).format(value / 1_000_000); +} + +export function formatFinanceCost(finance: AnalyticsFinance | null | undefined) { + if (!finance?.costedOrders) return "--"; + return `${formatCredits(finance.costMicrocredits)}${finance.costedOrders < finance.settledOrders ? "(部分)" : ""}`; +} + +export function formatFinanceMargin(value: number | null | undefined) { + return value == null ? "--" : `${value.toFixed(1)}%`; +} + +export const analyticsFinanceColumns: ColumnsType = [ + { title: "收入(积分)", width: 130, render: (_, row) => formatCredits(row.finance?.revenueMicrocredits) }, + { title: "成本(积分)", width: 150, render: (_, row) => formatFinanceCost(row.finance) }, + { title: "利润(积分)", width: 130, render: (_, row) => formatCredits(row.finance?.profitMicrocredits) }, + { title: "利润率", width: 100, render: (_, row) => formatFinanceMargin(row.finance?.profitMargin) }, +]; diff --git a/web/src/pages/admin/components/analytics-panel.tsx b/web/src/pages/admin/components/analytics-panel.tsx index 398f2014a..d1917a6a4 100644 --- a/web/src/pages/admin/components/analytics-panel.tsx +++ b/web/src/pages/admin/components/analytics-panel.tsx @@ -1,47 +1,23 @@ -import { App, Button, DatePicker, Drawer, Form, Input, Modal, Select, Tabs, Tag } from "antd"; +import { Alert, App, Button, DatePicker, Select, Tabs, Tag } from "antd"; import { Tooltip } from "@/pages/admin/ui/controls"; import { useCountUp } from "@/hooks/use-count-up"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import type { ColumnsType } from "antd/es/table"; import dayjs, { type Dayjs } from "dayjs"; -import { AlertTriangle, BarChart3, CircleDollarSign, Clock3, Gauge, Pencil, Plus, RefreshCw, Settings2, Trash2, UsersRound, Workflow } from "lucide-react"; +import { AlertTriangle, BarChart3, CircleDollarSign, Clock3, Gauge, RefreshCw, UsersRound, Workflow } from "lucide-react"; import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip as ChartTooltip, XAxis, YAxis } from "recharts"; import { useSearchParams } from "react-router"; -import { ListToolbar, PaginationBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty, type AdminStatusTone } from "./admin-ui"; -import { - createAdminModelPricing, - deleteAdminModelPricing, - exportAdminAnalytics, - getAdminAnalytics, - listAdminUsers, - listAdminModelPricings, - updateAdminModelPricing, - type AdminReferenceData, - type AdminAnalytics, - type AnalyticsFilters, - type ModelPricing, -} from "@/services/api/auth"; +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 { analyticsFinanceColumns, formatCredits, formatFinanceCost, formatFinanceMargin } from "./analytics-finance"; type Props = { users: AdminReferenceData["users"]; channels: AdminReferenceData["channels"]; }; -type PricingFormValues = { - channelId?: string; - model: string; - capability: ModelPricing["capability"]; - currency: string; - inputPerMillion?: string; - outputPerMillion?: string; - cachedPerMillion?: string; - perRequest?: string; - perMedia?: string; - perVideoSecond?: string; -}; - type TrendMetric = "volume" | "quality" | "activity"; type AnalysisTab = "models" | "users" | "failures"; type RangePreset = "7d" | "30d" | "60d"; @@ -63,23 +39,14 @@ export default function AnalyticsPanel({ users, channels }: Props) { const [channelId, setChannelId] = useState(searchParams.get("channelId") || undefined); const [capability, setCapability] = useState(searchParams.get("capability") || undefined); const [data, setData] = useState(null); - const [pricings, setPricings] = useState([]); const [loading, setLoading] = useState(false); - const [pricingModalOpen, setPricingModalOpen] = useState(false); - const [editingPricing, setEditingPricing] = useState(null); - const [savingPricing, setSavingPricing] = useState(false); const [userOptions, setUserOptions] = useState(users); const [searchingUsers, setSearchingUsers] = useState(false); const [modelPage, setModelPage] = useState(1); const [userPage, setUserPage] = useState(1); const [failurePage, setFailurePage] = useState(1); - const [pricingPage, setPricingPage] = useState(1); const [trendMetric, setTrendMetric] = useState("volume"); const [analysisTab, setAnalysisTab] = useState("models"); - const [pricingWorkspaceOpen, setPricingWorkspaceOpen] = useState(false); - const [pendingPricing, setPendingPricing] = useState(undefined); - const [form] = Form.useForm(); - const pricingChannelId = Form.useWatch("channelId", form); const analyticsPageSize = 20; const filters = useMemo( @@ -97,9 +64,8 @@ export default function AnalyticsPanel({ users, channels }: Props) { const reload = useCallback(async () => { setLoading(true); try { - const [analytics, pricingData] = await Promise.all([getAdminAnalytics(filters), listAdminModelPricings()]); + const analytics = await getAdminAnalytics(filters); setData(analytics); - setPricings(pricingData.pricings); } catch (error) { message.error(error instanceof Error ? error.message : "读取统计数据失败"); } finally { @@ -123,7 +89,6 @@ export default function AnalyticsPanel({ users, channels }: Props) { setModelPage(1); setUserPage(1); setFailurePage(1); - setPricingPage(1); }, [filters]); useEffect(() => { @@ -149,105 +114,6 @@ export default function AnalyticsPanel({ users, channels }: Props) { return [...names].sort().map((name) => ({ label: name, value: name })); }, [channels, data?.models]); - const pricingModelOptions = useMemo(() => { - const names = new Set(); - const sourceChannels = channels.filter((channel) => channel.enabled !== false && (!pricingChannelId || channel.id === pricingChannelId)); - sourceChannels.forEach((channel) => channel.models?.forEach((name) => names.add(name))); - if (editingPricing?.model && (!pricingChannelId || editingPricing.channelId === pricingChannelId)) { - names.add(editingPricing.model); - } - return [...names].sort().map((name) => ({ label: name, value: name })); - }, [channels, editingPricing?.channelId, editingPricing?.model, pricingChannelId]); - - const preparePricingForm = (pricing: ModelPricing | null) => { - setEditingPricing(pricing); - form.resetFields(); - form.setFieldsValue( - pricing - ? { - channelId: pricing.channelId || undefined, - model: pricing.model, - capability: pricing.capability, - currency: pricing.currency, - inputPerMillion: formatPriceInput(pricing.inputPerMillionMicros), - outputPerMillion: formatPriceInput(pricing.outputPerMillionMicros), - cachedPerMillion: formatPriceInput(pricing.cachedPerMillionMicros), - perRequest: formatPriceInput(pricing.perRequestMicros), - perMedia: formatPriceInput(pricing.perMediaMicros), - perVideoSecond: formatPriceInput(pricing.perVideoSecondMicros), - } - : { channelId: undefined, model: "", capability: "text", currency: "USD", inputPerMillion: "", outputPerMillion: "", cachedPerMillion: "", perRequest: "", perMedia: "", perVideoSecond: "" }, - ); - setPricingModalOpen(true); - }; - - const openPricing = (pricing?: ModelPricing) => { - const nextPricing = pricing || null; - if (pricingWorkspaceOpen) { - setPendingPricing(nextPricing); - setPricingWorkspaceOpen(false); - } else { - preparePricingForm(nextPricing); - } - }; - - const handlePricingValuesChange = (changedValues: Partial, values: PricingFormValues) => { - if (Object.prototype.hasOwnProperty.call(changedValues, "channelId")) { - const nextModels = channels.filter((channel) => channel.enabled !== false && (!values.channelId || channel.id === values.channelId)).flatMap((channel) => channel.models || []); - if (values.model && !nextModels.includes(values.model)) form.setFieldValue("model", undefined); - return; - } - - if (!Object.prototype.hasOwnProperty.call(changedValues, "model") || !values.model) return; - const matchingChannels = channels.filter((channel) => channel.enabled !== false && channel.models?.includes(values.model)); - if (values.channelId && matchingChannels.some((channel) => channel.id === values.channelId)) return; - if (matchingChannels.length) form.setFieldValue("channelId", matchingChannels[0].id); - }; - - const handlePricingModelChange = (modelName: string) => { - const currentChannelId = form.getFieldValue("channelId") as string | undefined; - const matchingChannels = channels.filter((channel) => channel.enabled !== false && channel.models?.includes(modelName)); - const matchingChannel = matchingChannels.find((channel) => channel.id === currentChannelId) || matchingChannels[0]; - form.setFieldsValue({ model: modelName, channelId: matchingChannel?.id || currentChannelId }); - }; - - const savePricing = async () => { - const values = await form.validateFields(); - const payload = { - channelId: values.channelId || "", - model: values.model.trim(), - capability: values.capability, - currency: values.currency.trim().toUpperCase(), - inputPerMillionMicros: toMicros(values.inputPerMillion), - outputPerMillionMicros: toMicros(values.outputPerMillion), - cachedPerMillionMicros: toMicros(values.cachedPerMillion), - perRequestMicros: toMicros(values.perRequest), - perMediaMicros: toMicros(values.perMedia), - perVideoSecondMicros: toMicros(values.perVideoSecond), - }; - setSavingPricing(true); - try { - const result = editingPricing ? await updateAdminModelPricing(editingPricing.id, payload) : await createAdminModelPricing(payload); - setPricings((items) => (editingPricing ? items.map((item) => (item.id === result.pricing.id ? result.pricing : item)) : [...items, result.pricing])); - setPricingModalOpen(false); - message.success("模型价格已保存,后续调用将按新价格记录费用快照"); - } catch (error) { - message.error(error instanceof Error ? error.message : "保存价格失败"); - } finally { - setSavingPricing(false); - } - }; - - const removePricing = async (id: string) => { - try { - await deleteAdminModelPricing(id); - setPricings((items) => items.filter((item) => item.id !== id)); - message.success("价格配置已删除"); - } catch (error) { - message.error(error instanceof Error ? error.message : "删除价格失败"); - } - }; - const modelColumns: ColumnsType = [ { title: "模型", @@ -270,7 +136,7 @@ export default function AnalyticsPanel({ users, channels }: Props) { { title: "P50 / P95", width: 145, render: (_, row) => `${formatDuration(row.p50DurationMs)} / ${formatDuration(row.p95DurationMs)}` }, { title: "Token(入 / 出 / 缓存)", width: 190, render: (_, row) => (row.usageAvailable ? `${formatNumber(row.inputTokens)} / ${formatNumber(row.outputTokens)} / ${formatNumber(row.cachedTokens)}` : "--") }, { title: "媒体 / 视频秒", width: 125, render: (_, row) => `${row.mediaCount} / ${row.videoSeconds}` }, - { title: "估算费用", width: 120, render: (_, row) => formatCost(row.estimatedCostMicros, row.currency, row.costAvailable) }, + ...analyticsFinanceColumns, ]; const userColumns: ColumnsType = [ @@ -301,60 +167,16 @@ export default function AnalyticsPanel({ users, channels }: Props) { { title: "最近发生", dataIndex: "lastSeenAt", width: 170, render: (value) => dayjs(value).format("YYYY-MM-DD HH:mm") }, ]; - const pricingColumns: ColumnsType = [ - { - title: "模型", - dataIndex: "model", - width: 210, - render: (value, row) => ( -
-
{value}
-
{row.channelId ? channels.find((channel) => channel.id === row.channelId)?.name || row.channelId : "全部渠道"}
-
- ), - }, - { title: "能力", dataIndex: "capability", width: 90, render: capabilityLabel }, - { - title: "输入 / 输出 / 缓存(每百万 Token)", - width: 250, - render: (_, row) => `${formatMoney(fromMicros(row.inputPerMillionMicros), row.currency)} / ${formatMoney(fromMicros(row.outputPerMillionMicros), row.currency)} / ${formatMoney(fromMicros(row.cachedPerMillionMicros), row.currency)}`, - }, - { - title: "每请求 / 每媒体 / 每视频秒", - width: 220, - render: (_, row) => `${formatMoney(fromMicros(row.perRequestMicros), row.currency)} / ${formatMoney(fromMicros(row.perMediaMicros), row.currency)} / ${formatMoney(fromMicros(row.perVideoSecondMicros), row.currency)}`, - }, - { - title: "操作", - width: 170, - render: (_, row) => ( - , onClick: () => openPricing(row) }} - actions={[ - { - key: "delete", - label: "删除", - icon: , - danger: true, - confirm: { title: "删除价格配置?", description: "删除后新的调用不再使用这条价格配置,历史费用不受影响。", okText: "确认删除" }, - onClick: () => removePricing(row.id), - }, - ]} - /> - ), - }, - ]; - const trend = data?.trend || []; const currentTrend = trend[trend.length - 1]; const previousTrend = trend[trend.length - 2]; const modelRows = data?.models || []; const userRows = data?.users || []; const failureRows = data?.failures || []; - const pricingRows = pricings; const failureTotal = failureRows.reduce((sum, item) => sum + item.count, 0); const topFailure = failureRows.reduce((current, item) => (!current || item.count > current.count ? item : current), undefined); - const pricedModelCount = modelRows.filter((item) => item.costAvailable).length; + const finance = data?.kpi.finance; + const financeUnavailable = data !== null && (!finance || modelRows.some((row) => !row.finance)); const trendHasData = trendMetric === "volume" ? trend.some((item) => item.tasks > 0 || item.requests > 0) : trendMetric === "quality" ? trend.some((item) => item.requests > 0) : trend.some((item) => item.activeUsers > 0); const trendTitle = trendMetric === "volume" ? "任务与请求趋势" : trendMetric === "quality" ? "请求质量趋势" : "用户活跃趋势"; const pageRows = (rows: T[], page: number) => rows.slice((page - 1) * analyticsPageSize, page * analyticsPageSize); @@ -443,6 +265,8 @@ export default function AnalyticsPanel({ users, channels }: Props) { + {financeUnavailable && } +
} @@ -468,10 +292,29 @@ export default function AnalyticsPanel({ users, channels }: Props) { /> } - label="估算费用" - value={data ? (data.kpi.costAvailable ? formatCost(data.kpi.estimatedCostMicros, data.kpi.currency, true) : "待配置") : "--"} - detail={data ? (data.kpi.costAvailable ? `${pricedModelCount}/${modelRows.length} 个模型可估算` : "价格未完整配置,暂不能汇总") : undefined} - tone={data?.kpi.costAvailable ? "neutral" : "warning"} + label="费用统计(积分)" + 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"} />
@@ -553,11 +396,19 @@ export default function AnalyticsPanel({ users, channels }: Props) { /> } - label="费用可估算模型" - value={data ? `${pricedModelCount}/${modelRows.length}` : "--"} - description={data?.kpi.costAvailable ? "当前范围费用可以汇总" : "仍有调用无法完成费用估算"} - tone={data?.kpi.costAvailable ? "success" : "warning"} - onClick={() => setPricingWorkspaceOpen(true)} + label="成本覆盖订单" + value={finance ? `${finance.costedOrders}/${finance.settledOrders}` : "--"} + description={ + !finance + ? "财务统计尚未返回" + : finance.settledOrders + ? finance.costedOrders === finance.settledOrders + ? "已结算订单成本完整" + : `${finance.settledOrders - finance.costedOrders} 笔缺少成本快照或有效用量` + : "当前范围暂无已结算订单" + } + tone={finance && finance.costedOrders < finance.settledOrders ? "warning" : "neutral"} + onClick={() => openAnalysis("models")} /> @@ -569,9 +420,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {

深度分析

按模型、用户或异常类型继续核对当前统计范围。

- `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1250 } }} + table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1600 } }} empty={} - skeletonColumns={9} + skeletonColumns={12} footer={ setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />} /> ), @@ -617,77 +465,6 @@ export default function AnalyticsPanel({ users, channels }: Props) { ]} /> - - setPricingWorkspaceOpen(false)} - afterOpenChange={(open) => { - if (open || pendingPricing === undefined) return; - const nextPricing = pendingPricing; - setPendingPricing(undefined); - preparePricingForm(nextPricing); - }} - extra={ - - } - > -

价格只影响后续调用的费用快照,历史统计不会被重新计算。

- } - skeletonColumns={5} - footer={ setPricingPage(page)} pageSizeOptions={[analyticsPageSize]} />} - /> -
- - setPricingModalOpen(false)} - onOk={() => void savePricing()} - confirmLoading={savingPricing} - okText="保存" - cancelText="取消" - width={760} - zIndex={1200} - destroyOnHidden - > -
-
- - channel.enabled !== false).map((channel) => ({ label: channel.name, value: channel.id }))} /> - - - - - - - - - - -
-
-
); } @@ -765,20 +542,6 @@ function FilterSelect({ ); } -function PriceInput({ name, label }: { name: keyof PricingFormValues; label: string }) { - return ( - - - - ); -} - -function validatePriceInput(_: unknown, value?: string) { - const normalized = value?.trim() || ""; - if (!normalized || /^(?:\d+(?:\.\d{0,6})?|\.\d{1,6})$/.test(normalized)) return Promise.resolve(); - return Promise.reject(new Error("请输入非负价格,最多 6 位小数")); -} - function capabilityLabel(value: string) { return capabilityOptions.find((item) => item.value === value)?.label || "未分类"; } @@ -811,32 +574,6 @@ function formatRateDelta(current?: number, previous?: number) { return { value: `较前一日 ${direction} ${Math.abs(delta).toFixed(1)}pp`, tone: delta >= 0 ? ("success" as const) : ("warning" as const) }; } -function formatCost(micros: number, currency?: string, available?: boolean) { - return available ? formatMoney(fromMicros(micros), currency || "USD") : "--"; -} - -function formatMoney(value: number, currency = "USD") { - if (currency === "MIXED") return `${value.toFixed(6)}(混合币种)`; - try { - return new Intl.NumberFormat("zh-CN", { style: "currency", currency, minimumFractionDigits: 2, maximumFractionDigits: 6 }).format(value); - } catch { - return `${currency} ${value.toFixed(6)}`; - } -} - -function fromMicros(value: number) { - return value / 1_000_000; -} - -function formatPriceInput(micros: number) { - return fromMicros(micros).toFixed(6); -} - -function toMicros(value?: string | number) { - const parsed = typeof value === "number" ? value : Number(value?.trim() || 0); - return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 1_000_000) : 0; -} - function filterDate(value: string | null, fallback: Dayjs) { if (!value) return fallback; const parsed = dayjs(value); diff --git a/web/src/pages/admin/components/channel-model-editor.tsx b/web/src/pages/admin/components/channel-model-editor.tsx index 1f9951b63..5ab5d384a 100644 --- a/web/src/pages/admin/components/channel-model-editor.tsx +++ b/web/src/pages/admin/components/channel-model-editor.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from "react"; -import { Alert, App, Button, Form, Input, Segmented, Switch, Tabs } from "antd"; +import { Alert, App, AutoComplete, Button, Form, Input, Segmented, Switch, Tabs } from "antd"; +import { useAdminContext } from "../admin-context"; import { AdminModal } from "@/pages/admin/ui/overlays"; import { FlaskConical, Plus } from "lucide-react"; import { ModelIconPicker } from "@/components/model-logo"; @@ -34,6 +35,10 @@ export function ChannelModelEditor({ onSaved: () => Promise; }) { const { message, modal } = App.useApp(); + const { references } = useAdminContext(); + const displayNameOptions = [...new Set(references.channels.flatMap((item) => item.modelDisplayNames ?? []))] + .sort((a, b) => a.localeCompare(b, "zh-CN")) + .map((value) => ({ value })); const [form] = Form.useForm(); const [initialValues] = useState(() => initialChannelModelValues(editing, protocols)); const [activeSection, setActiveSection] = useState("identity"); @@ -248,7 +253,12 @@ export function ChannelModelEditor({ - + Boolean(option?.value.toLowerCase().includes(input.toLowerCase()))} + placeholder="选择已有分组或输入新名称" + allowClear + /> 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/admin/theme/admin-chrome.css b/web/src/pages/admin/theme/admin-chrome.css index cf3dc8a9a..a47aff090 100644 --- a/web/src/pages/admin/theme/admin-chrome.css +++ b/web/src/pages/admin/theme/admin-chrome.css @@ -974,7 +974,9 @@ @media (prefers-reduced-motion: reduce) { [data-admin-root] *, body.admin-overlays * { - transition: none !important; - animation: none !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; } } + diff --git a/web/src/pages/plugins/plugin-documentation-modals.tsx b/web/src/pages/plugins/plugin-documentation-modals.tsx index dc28f7c4e..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"; @@ -12,12 +12,30 @@ import "./plugins.css"; type UploadPluginModalProps = { open: boolean; onClose: () => void; - onUpload: (file: File) => void; + onUpload: (file: File) => void | Promise; }; +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(); @@ -40,31 +58,85 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal setIsDraggingPlugin(false); }; + 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 = async () => { + try { + if (!MANIFEST_EXAMPLE) throw new Error("开发规范中缺少清单示例"); + if (!navigator.clipboard) throw new Error("当前环境不支持剪贴板,请从开发规范中手动复制"); + await navigator.clipboard.writeText(MANIFEST_EXAMPLE); + setCopied(true); + clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 2000); + } catch (error) { + message.error(error instanceof Error ? error.message : "复制失败,请手动复制"); + } + }; + + const isCompact = activeTab === "install"; + return ( -
-
- -
- -
+ +
+ 初次制作插件? + +
+ + ) : ( +
+
+ + 以下为开发者技术规范与清单编写参考(只读文档)。安装插件请切换至「安装插件包」。 +
+
+ +
+
+ )}
); } @@ -105,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/src/pages/plugins/plugins.css b/web/src/pages/plugins/plugins.css index 6e7b3a7f1..aa4d373ee 100644 --- a/web/src/pages/plugins/plugins.css +++ b/web/src/pages/plugins/plugins.css @@ -485,7 +485,135 @@ } .workspace-modal.plugin-upload-modal { - width: min(1480px, calc(100vw - var(--space-4))) !important; + width: min(1120px, calc(100vw - var(--space-4))) !important; +} + +.workspace-modal.plugin-upload-modal.plugin-upload-modal-compact { + width: min(620px, calc(100vw - var(--space-4))) !important; +} + +.plugin-upload-tabs-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--workspace-border); +} + +.plugin-upload-tabs-nav { + display: inline-flex; + align-items: center; + padding: 3px; + border-radius: var(--r-md); + background: color-mix(in srgb, var(--foreground) 6%, transparent); + gap: 2px; +} + +.plugin-upload-tab-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border: none; + border-radius: var(--r-sm); + background: transparent; + color: color-mix(in srgb, var(--foreground) 60%, transparent); + font-size: var(--fs-label); + font-weight: 550; + cursor: pointer; + transition: all 0.18s ease; +} + +.plugin-upload-tab-btn:hover { + color: var(--foreground); +} + +.plugin-upload-tab-btn.is-active { + background: var(--workspace-surface-strong, #ffffff); + color: var(--workspace-accent, #1677ff); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.plugin-upload-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: var(--fs-caption); + color: color-mix(in srgb, var(--foreground) 50%, transparent); +} + +.plugin-upload-copy-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid var(--workspace-border); + border-radius: var(--r-sm); + background: transparent; + color: color-mix(in srgb, var(--foreground) 75%, transparent); + font-size: var(--fs-caption); + cursor: pointer; + transition: all 0.15s ease; +} + +.plugin-upload-copy-btn:hover { + border-color: var(--workspace-accent); + color: var(--workspace-accent); +} + +.plugin-upload-install-view { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.plugin-upload-dropzone-shell.is-uploading { + pointer-events: none; + opacity: 0.85; +} + +.plugin-upload-switch-guide-hint { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: var(--fs-label); + color: color-mix(in srgb, var(--foreground) 45%, transparent); + margin-top: var(--space-1); +} + +.plugin-upload-link-btn { + background: transparent; + border: none; + padding: 0; + color: var(--workspace-accent, #1677ff); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; + font-size: inherit; +} + +.plugin-upload-link-btn:hover { + opacity: 0.8; +} + +.plugin-upload-guide-view { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.plugin-upload-guide-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: var(--r-md); + background: color-mix(in srgb, var(--workspace-accent) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--workspace-accent) 20%, transparent); + font-size: var(--fs-label); + color: color-mix(in srgb, var(--foreground) 75%, transparent); } .plugin-upload-layout { diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index 5f8233105..b381e9139 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -160,7 +160,16 @@ export type AnalyticsFilters = { export type AdminReferenceData = { users: Array<{ id: string; username: string; displayName: string }>; - channels: Array<{ id: string; name: string; enabled: boolean; models: string[] }>; + channels: Array<{ id: string; name: string; enabled: boolean; models: string[]; modelDisplayNames?: string[] }>; +}; + +export type AnalyticsFinance = { + settledOrders: number; + costedOrders: number; + revenueMicrocredits: number; + costMicrocredits: number; + profitMicrocredits: number | null; + profitMargin: number | null; }; export type AdminAnalytics = { @@ -176,9 +185,7 @@ export type AdminAnalytics = { successRate: number; p95DurationMs: number; currentQueuedTasks: number; - estimatedCostMicros: number; - costAvailable: boolean; - currency?: string; + finance?: AnalyticsFinance | null; }; trend: Array<{ day: string; tasks: number; requests: number; activeUsers: number; requestSuccessRate: number }>; models: Array<{ @@ -197,9 +204,7 @@ export type AdminAnalytics = { usageAvailable: boolean; mediaCount: number; videoSeconds: number; - estimatedCostMicros: number; - costAvailable: boolean; - currency?: string; + finance?: AnalyticsFinance | null; }>; users: Array<{ userId: string; name: string; activeDays: number; tasks: number; agentMessages: number; canvasDays: number; assets: number; resources: number; commonModel?: string }>; failures: Array<{ type: string; model: string; count: number; lastError?: string; lastSeenAt: string }>; diff --git a/web/src/styles/admin-ui.css b/web/src/styles/admin-ui.css index 167bff352..dab9a530d 100644 --- a/web/src/styles/admin-ui.css +++ b/web/src/styles/admin-ui.css @@ -8485,21 +8485,6 @@ width: 246px; } -.admin-analytics-pricing-modal .admin-analytics-price-field, -.admin-analytics-pricing-modal .admin-analytics-price-field .ant-form-item-control, -.admin-analytics-pricing-modal .admin-analytics-price-field .ant-form-item-control-input, -.admin-analytics-pricing-modal .admin-analytics-price-input, -.admin-analytics-pricing-modal .admin-analytics-price-input input { - width: 100%; - min-width: 0; - pointer-events: auto; - user-select: text; -} - -.admin-analytics-pricing-modal .admin-analytics-price-input { - display: block; -} - .admin-analytics-toolbar .admin-list-toolbar-actions { align-items: center; } @@ -8590,6 +8575,33 @@ line-height: 16px; } +.admin-analytics-finance-values { + display: grid; + gap: 6px; + margin: 0; + font-size: 14px; + letter-spacing: 0; + line-height: 1.4; + white-space: normal; +} + +.admin-analytics-finance-values > div { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; +} + +.admin-analytics-finance-values dt { + color: var(--admin-text-muted); + font-weight: 400; +} + +.admin-analytics-finance-values dd { + margin: 0; + text-align: right; + overflow-wrap: anywhere; +} + .admin-analytics-overview-grid { display: grid; min-width: 0; @@ -8824,21 +8836,6 @@ padding-block: 10px; } -.admin-analytics-pricing-description { - margin-bottom: 14px; - border: 1px solid var(--admin-divider); - border-radius: 8px; - background: var(--admin-layer-2); - padding: 10px 12px; - color: var(--admin-text-secondary); - font-size: 11px; - line-height: 17px; -} - -.admin-analytics-pricing-drawer .admin-table-frame { - min-height: min(620px, calc(100vh - 190px)); -} - @media (min-width: 1280px) { .admin-analytics-overview-grid { grid-template-columns: minmax(0, 2fr) minmax(300px, 0.82fr); diff --git a/web/test/admin-ui-regressions.test.ts b/web/test/admin-ui-regressions.test.ts index 401179ecf..147f0dbae 100644 --- a/web/test/admin-ui-regressions.test.ts +++ b/web/test/admin-ui-regressions.test.ts @@ -115,33 +115,20 @@ test("channel model manager supports bounded atomic batch deletion", async () => expect(component).toContain("批量删除"); }); -test("analytics keeps fixed range presets distinct and uses enabled channel models for pricing", async () => { +test("analytics keeps range presets and uses order finances without a separate pricing editor", async () => { const source = compactSource(await Bun.file(new URL("../src/pages/admin/components/analytics-panel.tsx", import.meta.url)).text()); expect(source).toContain('type RangePreset = "7d" | "30d" | "60d"'); expect(source).toContain('["60d", "60 天"]'); expect(source).toContain('next.set("rangePreset", rangePreset)'); expect(source).toContain("setRangePreset(undefined)"); - expect(source).toContain('placeholder={pricingModelOptions.length ? "选择已启用模型" : "暂无已启用模型"}'); - expect(source).toContain("onValuesChange={handlePricingValuesChange}"); - expect(source).toContain("onChange={handlePricingModelChange}"); - expect(source).toContain('hasOwnProperty.call(changedValues, "model")'); - expect(source).toContain('if (matchingChannels.length) form.setFieldValue("channelId", matchingChannels[0].id)'); - expect(source).toContain("const sourceChannels = channels.filter("); - expect(source).toContain('Form.useWatch("channelId", form)'); - expect(source).toContain("pricingChannelId"); - expect(source).toContain("channel.id === pricingChannelId"); - expect(source).toContain('inputMode="decimal"'); - expect(source).toContain('className="admin-analytics-price-input"'); - expect(source).toContain('className="admin-analytics-price-field"'); - expect(source).toContain('rootClassName="admin-modal-root admin-analytics-pricing-modal"'); - expect(source).toContain("zIndex={1200}"); - expect(source).toContain("setPricingWorkspaceOpen(false)"); - expect(source).toContain("validator: validatePriceInput"); - expect(source).toContain("请输入非负价格,最多 6 位小数"); - expect(source).toContain("function formatPriceInput(micros: number)"); - expect(source).toContain("function toMicros(value?: string | number)"); - expect(source).not.toContain(" { diff --git a/web/test/analytics-finance.test.ts b/web/test/analytics-finance.test.ts new file mode 100644 index 000000000..74501e324 --- /dev/null +++ b/web/test/analytics-finance.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; +import type { AdminAnalytics, AnalyticsFinance } from "../src/services/api/auth"; +import { analyticsFinanceColumns } from "../src/pages/admin/components/analytics-finance"; + +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 }), + }; + return analyticsFinanceColumns.map((column) => ("render" in column ? column.render?.(undefined, row, 0) : undefined)); +} + +test("model finance cells tolerate missing and null backend finance", () => { + expect(renderFinanceCells()).toEqual(["--", "--", "--", "--"]); + expect(renderFinanceCells(null)).toEqual(["--", "--", "--", "--"]); +}); + +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", "--"]); +}); + +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(部分)", "--", "--"]); +}); + +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%"]); +}); 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"); +});