diff --git a/.dockerignore b/.dockerignore index 0204d2e89809..2d2d7ed788ad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,16 @@ Makefile docs .eslintcache .gocache -/web/node_modules +# 依赖一律在镜像内 bun install,任何层级的 node_modules 都不需要进构建上下文 +# (Dependencies are installed inside the image; no node_modules at any depth.) +**/node_modules +# web/dist 由 builder 阶段产出并 COPY --from,构建上下文里的产物一律忽略 +# (web/dist is produced by the builder stage; ignore any prebuilt output here.) /web/dist +/web/classic/dist +/web/default/dist +# 运行时目录,由 compose 以 bind mount 挂载 +# (Runtime directories, provided by compose bind mounts.) +/data +/logs !THIRD-PARTY-LICENSES.md diff --git a/Dockerfile b/Dockerfile index 5be311b10e43..037e218395fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,22 @@ FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder WORKDIR /build/web -COPY web/package.json web/bun.lock ./ -RUN bun install --frozen-lockfile +# .npmrc must land before `bun install`, otherwise the registry it configures is +# ignored (it would only arrive with the later `COPY ./web ./`). +COPY web/package.json web/bun.lock web/.npmrc ./ +# Point at a nearby mirror when the default registry is slow, e.g. +# docker build --build-arg NPM_REGISTRY=https://repo.huaweicloud.com/repository/npm/ . +ARG NPM_REGISTRY= +# Bun's default fetch concurrency corrupts large tarballs (react-icons is 22MB, +# @hugeicons/core-free-icons larger still) on constrained links, surfacing as +# "Integrity check failed" on a different package each run. Cap concurrency and +# retry so a single dropped connection doesn't fail the whole build. +RUN if [ -n "$NPM_REGISTRY" ]; then echo "registry=$NPM_REGISTRY" > .npmrc; fi \ + && for attempt in 1 2 3; do \ + echo "bun install attempt $attempt" \ + && BUN_CONFIG_MAX_HTTP_REQUESTS=8 bun install --frozen-lockfile && break \ + || { [ "$attempt" = 3 ] && exit 1; echo "retrying..."; sleep 5; }; \ + done COPY ./web ./ COPY ./VERSION /build/VERSION RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build diff --git a/common/constants.go b/common/constants.go index d6b4fb52284c..5e8063419ff0 100644 --- a/common/constants.go +++ b/common/constants.go @@ -12,7 +12,7 @@ import ( var StartTime = time.Now().Unix() // unit: second var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change -var SystemName = "New API" +var SystemName = "OriginFlow" var Footer = "" var Logo = "" var TopUpLink = "" diff --git a/common/redirect.go b/common/redirect.go new file mode 100644 index 000000000000..6e14657dbdb5 --- /dev/null +++ b/common/redirect.go @@ -0,0 +1,28 @@ +package common + +import ( + "net/url" + "strings" +) + +// IsSafeRedirect 校验重定向目标是否安全,防止开放重定向(Open Redirect)。 +// 允许:空串(无重定向)、同源相对路径(以 / 开头且非协议相对 //host)、指向已知官方域名。 +func IsSafeRedirect(u string) bool { + u = strings.TrimSpace(u) + if u == "" { + return true + } + // 相对路径,但排除协议相对地址(//evil.com) + if strings.HasPrefix(u, "/") { + return !strings.HasPrefix(u, "//") + } + parsed, err := url.Parse(u) + if err != nil { + return false + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return false + } + host := parsed.Host + return host == "91flow.com" || strings.HasSuffix(host, ".91flow.com") +} diff --git a/constant/context_key.go b/constant/context_key.go index b856bc3dda14..0a18ef2bb3c6 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -72,4 +72,23 @@ const ( // fallback in authHelper (finishAdminAudit) skips its record to avoid // duplicate entries. ContextKeyAuditLogged ContextKey = "audit_logged" + + // ContextKeyRequestRegion stores the normalized region of the current request, + // used by region-based channel routing (see model.ResolveRegionRouting). + // It is distinct from the vertex-specific "region" key set in the distributor. + ContextKeyRequestRegion ContextKey = "request_region" + + // ContextKeyUserRegionPreference stores the authenticated user's preferred + // region (see User.RegionPreference). Region-based channel routing falls back + // to it when no X-Region header or request-region context is present. + ContextKeyUserRegionPreference ContextKey = "user_region_preference" + + // ContextKeyUserTeamId stores the authenticated user's enterprise team id + // (see User.TeamId). Consume logs are stamped with it for per-team billing + // aggregation. + ContextKeyUserTeamId ContextKey = "user_team_id" ) + +// HeaderRegion is the request header declaring the caller's region for +// region-based channel routing, e.g. `X-Region: cn`. +const HeaderRegion = "X-Region" diff --git a/controller/distributor.go b/controller/distributor.go new file mode 100644 index 000000000000..dda3cb88c272 --- /dev/null +++ b/controller/distributor.go @@ -0,0 +1,302 @@ +package controller + +import ( + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +// ListDistributors 分页列出分销商。 +func ListDistributors(c *gin.Context) { + page, pageSize := parsePage(c) + keyword := c.Query("keyword") + items, total, err := model.SearchDistributors(page, pageSize, keyword) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"items": items, "total": total}) +} + +// CreateDistributor 创建分销商。 +func CreateDistributor(c *gin.Context) { + var req struct { + UserId int64 `json:"user_id"` + Name string `json:"name"` + Tier string `json:"tier"` + CommissionRate int `json:"commission_rate"` + Status int `json:"status"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if req.UserId <= 0 { + common.ApiErrorMsg(c, "user_id is required") + return + } + if req.Name == "" || len(req.Name) > 64 { + common.ApiErrorMsg(c, "name is required and must be <= 64 characters") + return + } + tier := req.Tier + if tier == "" { + tier = "standard" + } + if !model.AllowedDistributorTiers[tier] { + common.ApiErrorMsg(c, "invalid tier (expected standard|gold|platinum)") + return + } + status := req.Status + if status == 0 { + status = model.DistributorStatusActive + } + if !model.AllowedDistributorStatuses[status] { + common.ApiErrorMsg(c, "invalid status (expected 1 active|2 disabled)") + return + } + m := &model.Distributor{ + UserId: req.UserId, + Name: req.Name, + Tier: tier, + CommissionRate: req.CommissionRate, + Status: status, + } + if err := model.CreateDistributor(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"id": m.Id}) +} + +// GetDistributor 获取单个分销商。 +func GetDistributor(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + m, err := model.GetDistributorById(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, m) +} + +// UpdateDistributor 更新分销商。 +func UpdateDistributor(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + var req struct { + Name string `json:"name"` + Tier string `json:"tier"` + CommissionRate int `json:"commission_rate"` + Status int `json:"status"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if !model.AllowedDistributorTiers[req.Tier] { + common.ApiErrorMsg(c, "invalid tier (expected standard|gold|platinum)") + return + } + if !model.AllowedDistributorStatuses[req.Status] { + common.ApiErrorMsg(c, "invalid status (expected 1 active|2 disabled)") + return + } + m := &model.Distributor{Id: id, Name: req.Name, Tier: req.Tier, CommissionRate: req.CommissionRate, Status: req.Status} + if err := model.UpdateDistributor(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// DeleteDistributor 删除分销商(级联价格覆盖)。 +func DeleteDistributor(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + if err := model.DeleteDistributor(id); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// ListDistributorSubUsers 列出分销商下级用户(基于邀请链)。 +func ListDistributorSubUsers(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + d, err := model.GetDistributorById(id) + if err != nil { + common.ApiError(c, err) + return + } + page, pageSize := parsePage(c) + items, total, err := model.GetUsersByInviterId(int(d.UserId), page, pageSize) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"items": items, "total": total}) +} + +// ListDistributorPrices 列出分销商价格覆盖。 +func ListDistributorPrices(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + items, err := model.SearchDistributorPrices(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"items": items}) +} + +// CreateDistributorPrice 创建价格覆盖。 +func CreateDistributorPrice(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + var req struct { + Model string `json:"model"` + InputPrice int64 `json:"input_price"` + OutputPrice int64 `json:"output_price"` + Currency string `json:"currency"` + Unit string `json:"unit"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if req.Model == "" { + common.ApiErrorMsg(c, "model is required") + return + } + if req.InputPrice < 0 || req.OutputPrice < 0 { + common.ApiErrorMsg(c, "prices must be >= 0") + return + } + currency := req.Currency + if currency == "" { + currency = "CNY" + } + if !model.AllowedDistributorPriceCurrencies[currency] { + common.ApiErrorMsg(c, "invalid currency (expected CNY|USD)") + return + } + unit := req.Unit + if unit == "" { + unit = "token" + } + if !model.AllowedDistributorPriceUnits[unit] { + common.ApiErrorMsg(c, "invalid unit (expected token|image|second|char)") + return + } + m := &model.DistributorPrice{ + DistributorId: id, + Model: req.Model, + InputPrice: req.InputPrice, + OutputPrice: req.OutputPrice, + Currency: currency, + Unit: unit, + } + if err := model.CreateDistributorPrice(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"id": m.Id}) +} + +// UpdateDistributorPrice 更新价格覆盖。 +func UpdateDistributorPrice(c *gin.Context) { + priceId, err := strconv.ParseInt(c.Param("price_id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid price_id") + return + } + var req struct { + Model string `json:"model"` + InputPrice int64 `json:"input_price"` + OutputPrice int64 `json:"output_price"` + Currency string `json:"currency"` + Unit string `json:"unit"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if !model.AllowedDistributorPriceCurrencies[req.Currency] { + common.ApiErrorMsg(c, "invalid currency (expected CNY|USD)") + return + } + if !model.AllowedDistributorPriceUnits[req.Unit] { + common.ApiErrorMsg(c, "invalid unit (expected token|image|second|char)") + return + } + m := &model.DistributorPrice{ + Id: priceId, + Model: req.Model, + InputPrice: req.InputPrice, + OutputPrice: req.OutputPrice, + Currency: req.Currency, + Unit: req.Unit, + } + if err := model.UpdateDistributorPrice(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// DeleteDistributorPrice 删除价格覆盖。 +func DeleteDistributorPrice(c *gin.Context) { + priceId, err := strconv.ParseInt(c.Param("price_id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid price_id") + return + } + if err := model.DeleteDistributorPrice(priceId); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// GetDistributorBilling 分销商下级账单汇总。 +func GetDistributorBilling(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + d, err := model.GetDistributorById(id) + if err != nil { + common.ApiError(c, err) + return + } + billing, err := model.GetDistributorBilling(d.UserId) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, billing) +} diff --git a/controller/market.go b/controller/market.go new file mode 100644 index 000000000000..0a199365925d --- /dev/null +++ b/controller/market.go @@ -0,0 +1,279 @@ +package controller + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +// MarketModel statuses (mirror model.AllowedMarketModelStatuses). +const ( + MarketModelStatusAvailable = 1 + MarketModelStatusComingSoon = 2 + MarketModelStatusDisabled = 3 +) + +var allowedMarketModelStatuses = map[int]bool{ + MarketModelStatusAvailable: true, + MarketModelStatusComingSoon: true, + MarketModelStatusDisabled: true, +} + +var allowedMarketModelUnits = map[string]bool{ + "token": true, + "image": true, + "second": true, + "char": true, +} + +var allowedMarketModelCurrencies = map[string]bool{ + "CNY": true, + "USD": true, +} + +// MarketModelRequest 上架模型的新建/更新请求体。 +type MarketModelRequest struct { + Model string `json:"model"` + Provider string `json:"provider"` + Category string `json:"category"` + Tags string `json:"tags"` + InputPrice int64 `json:"input_price"` + OutputPrice int64 `json:"output_price"` + Currency string `json:"currency"` + Unit string `json:"unit"` + Metadata string `json:"metadata"` + TrialQuota int64 `json:"trial_quota"` + Status int `json:"status"` + Featured bool `json:"featured"` + Sort int `json:"sort"` +} + +func validateMarketModelRequest(req *MarketModelRequest, isCreate bool) (bool, string) { + if isCreate { + if req.Model == "" || len(req.Model) > 255 { + return false, "model is required and must be <= 255 characters" + } + } + if req.Category == "" || len(req.Category) > 32 { + return false, "category is required and must be <= 32 characters" + } + if len(req.Provider) > 64 { + return false, "provider must be <= 64 characters" + } + if len(req.Tags) > 255 { + return false, "tags must be <= 255 characters" + } + if req.InputPrice < 0 || req.OutputPrice < 0 { + return false, "prices must be >= 0" + } + if req.TrialQuota < 0 { + return false, "trial_quota must be >= 0" + } + unit := req.Unit + if unit == "" { + unit = "token" + } + if !allowedMarketModelUnits[unit] { + return false, "invalid unit (expected token|image|second|char)" + } + currency := req.Currency + if currency == "" { + currency = "CNY" + } + if !allowedMarketModelCurrencies[currency] { + return false, "invalid currency (expected CNY|USD)" + } + if req.Metadata != "" { + var tmp interface{} + if err := json.Unmarshal([]byte(req.Metadata), &tmp); err != nil { + return false, "metadata must be valid JSON" + } + } + if !allowedMarketModelStatuses[req.Status] { + return false, "invalid status (expected 1 available|2 coming_soon|3 disabled)" + } + return true, "" +} + +// CreateMarketModel 管理员新建上架模型。 +func CreateMarketModel(c *gin.Context) { + var req MarketModelRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if ok, msg := validateMarketModelRequest(&req, true); !ok { + common.ApiErrorMsg(c, msg) + return + } + // 唯一性校验 + if cnt, err := model.CountMarketModelsByModel(req.Model); err != nil { + common.ApiError(c, err) + return + } else if cnt > 0 { + common.ApiErrorMsg(c, "model already listed in market") + return + } + m := &model.MarketModel{ + Model: req.Model, + Provider: req.Provider, + Category: req.Category, + Tags: req.Tags, + InputPrice: req.InputPrice, + OutputPrice: req.OutputPrice, + Unit: orDefault(req.Unit, "token"), + TrialQuota: req.TrialQuota, + Status: req.Status, + Featured: req.Featured, + Sort: req.Sort, + } + if m.Status == 0 { + m.Status = MarketModelStatusAvailable + } + if m.Currency == "" { + m.Currency = "CNY" + } + if err := model.CreateMarketModel(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"id": m.Id}) +} + +// GetMarketModel 管理员查看单条上架记录。 +func GetMarketModel(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + m, err := model.GetMarketModelById(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, m) +} + +// ListMarketModels 管理员列出上架模型,可选 status/category 过滤。 +func ListMarketModels(c *gin.Context) { + status := c.Query("status") + category := c.Query("category") + items, err := model.SearchMarketModels(status, category) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"items": items}) +} + +// UpdateMarketModel 管理员更新上架记录(Model 唯一键不可变)。 +func UpdateMarketModel(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + var req MarketModelRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if ok, msg := validateMarketModelRequest(&req, false); !ok { + common.ApiErrorMsg(c, msg) + return + } + m := &model.MarketModel{ + Id: id, + Provider: req.Provider, + Category: req.Category, + Tags: req.Tags, + InputPrice: req.InputPrice, + OutputPrice: req.OutputPrice, + Currency: orDefault(req.Currency, "CNY"), + Unit: orDefault(req.Unit, "token"), + Metadata: req.Metadata, + TrialQuota: req.TrialQuota, + Status: req.Status, + Featured: req.Featured, + Sort: req.Sort, + } + if err := model.UpdateMarketModel(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// DeleteMarketModel 管理员删除上架记录。 +func DeleteMarketModel(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + if err := model.DeleteMarketModel(id); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// GetPublicMarketModels 门店公开读取:仅返回已上架(status=available)模型,按分类与排序。 +// 支持 ?locale=zh|en:从 Metadata 解析对应 locale 的展示覆盖(name/description)叠加到 i18n 字段。 +func GetPublicMarketModels(c *gin.Context) { + items, err := model.SearchMarketModels(strconv.Itoa(MarketModelStatusAvailable), "") + if err != nil { + common.ApiError(c, err) + return + } + locale := orDefault(c.Query("locale"), "zh") + result := make([]gin.H, 0, len(items)) + for _, m := range items { + result = append(result, gin.H{ + "model": m, + "i18n": resolveMarketModelI18n(m, locale), + }) + } + common.ApiSuccess(c, gin.H{"items": result}) +} + +// resolveMarketModelI18n 从 Metadata JSON 解析指定 locale 的展示覆盖(name/description)。 +// 无 Metadata 或无对应 locale 时返回 nil。 +func resolveMarketModelI18n(m *model.MarketModel, locale string) map[string]string { + if strings.TrimSpace(m.Metadata) == "" { + return nil + } + var data map[string]map[string]string + if err := json.Unmarshal([]byte(m.Metadata), &data); err != nil { + return nil + } + entry, ok := data[locale] + if !ok { + return nil + } + out := map[string]string{} + if v, ok := entry["name"]; ok && v != "" { + out["name"] = v + } + if v, ok := entry["description"]; ok && v != "" { + out["description"] = v + } + if len(out) == 0 { + return nil + } + out["locale"] = locale + return out +} + +// orDefault 返回非空值或默认值。 +func orDefault(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} diff --git a/controller/public_site.go b/controller/public_site.go new file mode 100644 index 000000000000..79fde50810eb --- /dev/null +++ b/controller/public_site.go @@ -0,0 +1,134 @@ +package controller + +import ( + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// resolvePublicLocale 从 query 读取 locale,仅允许 en/zh,缺省 en。 +func resolvePublicLocale(c *gin.Context) string { + locale := strings.ToLower(strings.TrimSpace(c.Query("locale"))) + if locale != "zh" { + return "en" + } + return "zh" +} + +// GetPublicSiteConfig 返回站点品牌信息,供营销站页头/页脚使用。 +func GetPublicSiteConfig(c *gin.Context) { + common.ApiSuccess(c, gin.H{ + "system_name": common.SystemName, + "logo": common.Logo, + }) +} + +// GetPublicPricing 返回公开定价方案(按 locale)。 +func GetPublicPricing(c *gin.Context) { + locale := resolvePublicLocale(c) + var items []model.PublicPricing + if err := model.DB.Where("locale = ? AND enabled = ?", locale, true). + Order("sort asc").Find(&items).Error; err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + common.ApiSuccess(c, items) +} + +// GetPublicModelCategories 返回公开模型目录(按 locale)。 +func GetPublicModelCategories(c *gin.Context) { + locale := resolvePublicLocale(c) + var items []model.PublicModelCategory + if err := model.DB.Where("locale = ? AND enabled = ?", locale, true). + Order("sort asc").Find(&items).Error; err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + common.ApiSuccess(c, items) +} + +type salesLeadRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Company string `json:"company"` + Region string `json:"region"` + UseCase string `json:"use_case"` + MonthlyVolume string `json:"monthly_volume"` + RequiredModels string `json:"required_models"` + Message string `json:"message"` + Source string `json:"source"` + Redirect string `json:"redirect"` +} + +// PostPublicSalesLead 接收「联系销售」表单,写入销售线索。 +func PostPublicSalesLead(c *gin.Context) { + var req salesLeadRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "请求格式错误") + return + } + + req.Name = strings.TrimSpace(req.Name) + req.Email = strings.TrimSpace(req.Email) + req.Region = strings.TrimSpace(req.Region) + req.UseCase = strings.TrimSpace(req.UseCase) + + if req.Name == "" || len(req.Name) > 64 { + common.ApiErrorMsg(c, "请填写有效的姓名(≤64 字符)") + return + } + if !strings.Contains(req.Email, "@") || len(req.Email) > 128 { + common.ApiErrorMsg(c, "请填写有效的邮箱") + return + } + if req.Region == "" { + common.ApiErrorMsg(c, "请选择所在区域") + return + } + if req.UseCase == "" || len(req.UseCase) > 256 { + common.ApiErrorMsg(c, "请填写使用场景(≤256 字符)") + return + } + if len(req.Company) > 128 || len(req.MonthlyVolume) > 64 || + len(req.RequiredModels) > 512 || len(req.Message) > 2000 { + common.ApiErrorMsg(c, "字段超出长度限制") + return + } + + // 开放重定向防护:redirect 仅允许本站相对路径 + safeRedirect := "" + if r := strings.TrimSpace(req.Redirect); r != "" { + if strings.HasPrefix(r, "/") && !strings.HasPrefix(r, "//") && !strings.Contains(r, ":") { + safeRedirect = r + } + } + + now := time.Now().Unix() + lead := model.SalesLead{ + Name: req.Name, + Email: req.Email, + Company: req.Company, + Region: req.Region, + UseCase: req.UseCase, + MonthlyVolume: req.MonthlyVolume, + RequiredModels: req.RequiredModels, + Message: req.Message, + Status: "new", + Source: req.Source, + CreatedAt: now, + UpdatedAt: now, + } + if err := model.DB.Create(&lead).Error; err != nil { + common.ApiErrorMsg(c, "提交失败,请稍后重试") + return + } + + common.ApiSuccess(c, gin.H{ + "id": lead.Id, + "redirect": safeRedirect, + }) +} diff --git a/controller/region_route.go b/controller/region_route.go new file mode 100644 index 000000000000..3f0e65fcd4ff --- /dev/null +++ b/controller/region_route.go @@ -0,0 +1,150 @@ +package controller + +import ( + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +// ListRegionRoutes 分页列出区域路由策略。 +func ListRegionRoutes(c *gin.Context) { + page, pageSize := parsePage(c) + region := c.Query("region") + modelName := c.Query("model") + items, total, err := model.SearchRegionRoutes(page, pageSize, region, modelName) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"items": items, "total": total}) +} + +// CreateRegionRoute 创建区域路由策略。 +func CreateRegionRoute(c *gin.Context) { + var req struct { + Region string `json:"region"` + Model string `json:"model"` + ChannelIds string `json:"channel_ids"` + Tag string `json:"tag"` + Strategy string `json:"strategy"` + Priority int `json:"priority"` + Weight int `json:"weight"` + Enabled *bool `json:"enabled"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if req.Region == "" { + common.ApiErrorMsg(c, "region is required") + return + } + if req.Model == "" { + req.Model = "*" + } + strategy := req.Strategy + if strategy == "" { + strategy = "availability" + } + if !model.AllowedRegionRouteStrategies[strategy] { + common.ApiErrorMsg(c, "invalid strategy (expected cost|latency|availability|fixed)") + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + m := &model.RegionRoute{ + Region: req.Region, + Model: req.Model, + ChannelIds: req.ChannelIds, + Tag: req.Tag, + Strategy: strategy, + Priority: req.Priority, + Weight: req.Weight, + Enabled: enabled, + } + if err := model.CreateRegionRoute(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"id": m.Id}) +} + +// GetRegionRoute 获取单个策略。 +func GetRegionRoute(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + m, err := model.GetRegionRouteById(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, m) +} + +// UpdateRegionRoute 更新策略。 +func UpdateRegionRoute(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + var req struct { + Region string `json:"region"` + Model string `json:"model"` + ChannelIds string `json:"channel_ids"` + Tag string `json:"tag"` + Strategy string `json:"strategy"` + Priority int `json:"priority"` + Weight int `json:"weight"` + Enabled *bool `json:"enabled"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if !model.AllowedRegionRouteStrategies[req.Strategy] { + common.ApiErrorMsg(c, "invalid strategy (expected cost|latency|availability|fixed)") + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + m := &model.RegionRoute{ + Id: id, + Region: req.Region, + Model: req.Model, + ChannelIds: req.ChannelIds, + Tag: req.Tag, + Strategy: req.Strategy, + Priority: req.Priority, + Weight: req.Weight, + Enabled: enabled, + } + if err := model.UpdateRegionRoute(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// DeleteRegionRoute 删除策略。 +func DeleteRegionRoute(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + if err := model.DeleteRegionRoute(id); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} diff --git a/controller/sla.go b/controller/sla.go new file mode 100644 index 000000000000..276c1b73b273 --- /dev/null +++ b/controller/sla.go @@ -0,0 +1,163 @@ +package controller + +import ( + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +// ListSlaIncidents 管理端分页列出事件。 +func ListSlaIncidents(c *gin.Context) { + page, pageSize := parsePage(c) + status := c.Query("status") + items, total, err := model.SearchSlaIncidents(page, pageSize, status) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"items": items, "total": total}) +} + +// CreateSlaIncident 创建事件。 +func CreateSlaIncident(c *gin.Context) { + var req struct { + Title string `json:"title"` + Description string `json:"description"` + Status int `json:"status"` + Severity string `json:"severity"` + StartedAt int64 `json:"started_at"` + ResolvedAt int64 `json:"resolved_at"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if req.Title == "" || len(req.Title) > 128 { + common.ApiErrorMsg(c, "title is required and must be <= 128 characters") + return + } + if !model.AllowedSlaIncidentStatuses[req.Status] { + common.ApiErrorMsg(c, "invalid status (expected 1 investigating|2 identified|3 monitoring|4 resolved)") + return + } + severity := req.Severity + if severity == "" { + severity = "minor" + } + if !model.AllowedSlaIncidentSeverities[severity] { + common.ApiErrorMsg(c, "invalid severity (expected minor|major|critical)") + return + } + m := &model.SlaIncident{ + Title: req.Title, + Description: req.Description, + Status: req.Status, + Severity: severity, + StartedAt: req.StartedAt, + ResolvedAt: req.ResolvedAt, + } + if err := model.CreateSlaIncident(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"id": m.Id}) +} + +// GetSlaIncident 获取单个事件。 +func GetSlaIncident(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + m, err := model.GetSlaIncidentById(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, m) +} + +// UpdateSlaIncident 更新事件。 +func UpdateSlaIncident(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + var req struct { + Title string `json:"title"` + Description string `json:"description"` + Status int `json:"status"` + Severity string `json:"severity"` + StartedAt int64 `json:"started_at"` + ResolvedAt int64 `json:"resolved_at"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if !model.AllowedSlaIncidentStatuses[req.Status] { + common.ApiErrorMsg(c, "invalid status (expected 1 investigating|2 identified|3 monitoring|4 resolved)") + return + } + if !model.AllowedSlaIncidentSeverities[req.Severity] { + common.ApiErrorMsg(c, "invalid severity (expected minor|major|critical)") + return + } + m := &model.SlaIncident{ + Id: id, + Title: req.Title, + Description: req.Description, + Status: req.Status, + Severity: req.Severity, + StartedAt: req.StartedAt, + ResolvedAt: req.ResolvedAt, + } + if err := model.UpdateSlaIncident(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// DeleteSlaIncident 删除事件。 +func DeleteSlaIncident(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + if err := model.DeleteSlaIncident(id); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// GetPublicSlaIncidents 公开事件列表(状态页展示,匿名可读)。 +func GetPublicSlaIncidents(c *gin.Context) { + // 公开页展示最近事件(含已解决),限制条数避免过大。 + items, _, err := model.SearchSlaIncidents(1, 50, "") + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"items": items}) +} + +// GetPublicSlaStatus 公开服务状态摘要(匿名可读),聚合渠道与性能数据。 +func GetPublicSlaStatus(c *gin.Context) { + window := 24 + if w, err := strconv.Atoi(c.Query("window_hours")); err == nil && w > 0 && w <= 720 { + window = w + } + summary, err := model.GetSlaStatusSummary(window) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, summary) +} diff --git a/controller/team.go b/controller/team.go new file mode 100644 index 000000000000..5fed41106961 --- /dev/null +++ b/controller/team.go @@ -0,0 +1,258 @@ +package controller + +import ( + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +// parsePage 解析分页参数,缺省 page=1, pageSize=10。 +func parsePage(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.Query("page")) + pageSize, _ := strconv.Atoi(c.Query("page_size")) + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + return page, pageSize +} + +// ListTeams 分页列出团队。 +func ListTeams(c *gin.Context) { + page, pageSize := parsePage(c) + keyword := c.Query("keyword") + items, total, err := model.ListTeams(page, pageSize, keyword) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"items": items, "total": total}) +} + +// CreateTeam 创建团队。 +func CreateTeam(c *gin.Context) { + var req struct { + Name string `json:"name"` + Description string `json:"description"` + OwnerId int64 `json:"owner_id"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if req.Name == "" || len(req.Name) > 64 { + common.ApiErrorMsg(c, "name is required and must be <= 64 characters") + return + } + if req.OwnerId <= 0 { + common.ApiErrorMsg(c, "owner_id is required") + return + } + t := &model.Team{Name: req.Name, Description: req.Description, OwnerId: req.OwnerId} + if err := model.CreateTeam(t); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"id": t.Id}) +} + +// GetTeam 获取单个团队。 +func GetTeam(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + t, err := model.GetTeamById(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, t) +} + +// UpdateTeam 更新团队。 +func UpdateTeam(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + var req struct { + Description string `json:"description"` + OwnerId int64 `json:"owner_id"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if err := model.UpdateTeam(&model.Team{Id: id, Description: req.Description, OwnerId: req.OwnerId}); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// DeleteTeam 删除团队(级联成员与项目)。 +func DeleteTeam(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid id") + return + } + if err := model.DeleteTeam(id); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// AddTeamMember 添加团队成员。 +func AddTeamMember(c *gin.Context) { + teamId, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid team id") + return + } + var req struct { + UserId int64 `json:"user_id"` + Role string `json:"role"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if req.UserId <= 0 { + common.ApiErrorMsg(c, "user_id is required") + return + } + role := req.Role + if role == "" { + role = "member" + } + if !model.AllowedTeamMemberRoles[role] { + common.ApiErrorMsg(c, "invalid role (expected admin|member)") + return + } + m := &model.TeamMember{TeamId: teamId, UserId: req.UserId, Role: role} + if err := model.CreateTeamMember(m); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"id": m.Id}) +} + +// ListTeamMembers 分页列出团队成员。 +func ListTeamMembers(c *gin.Context) { + teamId, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid team id") + return + } + page, pageSize := parsePage(c) + items, total, err := model.ListTeamMembers(teamId, page, pageSize) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"items": items, "total": total}) +} + +// RemoveTeamMember 移除成员。 +func RemoveTeamMember(c *gin.Context) { + teamId, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid team id") + return + } + userId, err := strconv.ParseInt(c.Param("user_id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid user_id") + return + } + if err := model.DeleteTeamMember(teamId, userId); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// AddTeamProject 添加团队项目。 +func AddTeamProject(c *gin.Context) { + teamId, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid team id") + return + } + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if req.Name == "" || len(req.Name) > 64 { + common.ApiErrorMsg(c, "name is required and must be <= 64 characters") + return + } + p := &model.TeamProject{TeamId: teamId, Name: req.Name, Description: req.Description} + if err := model.CreateTeamProject(p); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"id": p.Id}) +} + +// ListTeamProjects 列出团队项目。 +func ListTeamProjects(c *gin.Context) { + teamId, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid team id") + return + } + items, err := model.ListTeamProjects(teamId) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"items": items}) +} + +// RemoveTeamProject 删除团队项目。 +func RemoveTeamProject(c *gin.Context) { + teamId, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid team id") + return + } + projectId, err := strconv.ParseInt(c.Param("pid"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid pid") + return + } + if err := model.DeleteTeamProject(teamId, projectId); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{"ok": true}) +} + +// GetTeamBilling 部门账单汇总。 +func GetTeamBilling(c *gin.Context) { + teamId, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + common.ApiErrorMsg(c, "invalid team id") + return + } + billing, err := model.GetTeamBilling(teamId) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, billing) +} diff --git a/deploy/nginx/originflow.conf b/deploy/nginx/originflow.conf new file mode 100644 index 000000000000..697036f94b38 --- /dev/null +++ b/deploy/nginx/originflow.conf @@ -0,0 +1,156 @@ +# ============================================================================= +# OriginFlow (元点流商) — nginx 站点配置 +# ----------------------------------------------------------------------------- +# 域名规划(ADR-1 域名隔离): +# www.91flow.com -> 营销前台(由 SPA 的营销路由树渲染) +# app.91flow.com -> New-API 原生控制台 +# api.91flow.com -> Relay / OpenAI 兼容 API(对外调用入口) +# +# 架构说明: +# 生产构建为单个内嵌 SPA(web/default/dist 由 main.go 的 //go:embed 提供, +# SPA fallback 在 router/web-router.go 的 NoRoute)。同一份构建经两个 server +# block 提供服务。前端按 location.host 以 'www.' 开头切换营销/控制台路由, +# 因此 nginx 只需把全部请求(SPA 静态 + /api + /v1)反代到 Go 后端即可。 +# +# 后端地址: +# 裸机/VM:`server 127.0.0.1:3000;` +# Docker Compose:把下面 upstream 的地址改为 `server new-api:3000;` +# +# 证书(Let's Encrypt / certbot): +# certbot certonly --webroot -w /var/www/certbot \ +# -d www.91flow.com -d app.91flow.com -d api.91flow.com +# 证书路径:/etc/letsencrypt/live//fullchain.pem 与 privkey.pem +# ============================================================================= + +# WebSocket / SSE 升级:仅当客户端发起 Upgrade 时才置为 upgrade +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +upstream originflow_backend { + # 裸机:127.0.0.1:3000 ;Docker:new-api:3000 + server 127.0.0.1:3000; + keepalive 32; +} + +# 通用代理参数(被各 server block 复用) +proxy_read_timeout 300s; +proxy_send_timeout 300s; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $connection_upgrade; + +# ----------------------------------------------------------------------------- +# HTTP -> HTTPS(三个域名统一跳转) +# ----------------------------------------------------------------------------- +server { + listen 80; + server_name www.91flow.com app.91flow.com api.91flow.com; + + # certbot 验证用 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# ----------------------------------------------------------------------------- +# www.91flow.com — 营销前台 +# ----------------------------------------------------------------------------- +server { + listen 443 ssl; + http2 on; + server_name www.91flow.com; + + ssl_certificate /etc/letsencrypt/live/www.91flow.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/www.91flow.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # 安全响应头 + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + client_max_body_size 10m; # 联系销售等表单提交 + + location / { + proxy_pass http://originflow_backend; + } +} + +# ----------------------------------------------------------------------------- +# app.91flow.com — 原生控制台 +# ----------------------------------------------------------------------------- +server { + listen 443 ssl; + http2 on; + server_name app.91flow.com; + + ssl_certificate /etc/letsencrypt/live/app.91flow.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app.91flow.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + client_max_body_size 50m; # 控制台含文件上传/充值等 + + location / { + proxy_pass http://originflow_backend; + } +} + +# ----------------------------------------------------------------------------- +# api.91flow.com — Relay / OpenAI 兼容 API(对外调用入口) +# 开启 CORS 以便第三方前端跨域调用;其余行为与另外两个域名一致(共用同一后端)。 +# ----------------------------------------------------------------------------- +server { + listen 443 ssl; + http2 on; + server_name api.91flow.com; + + ssl_certificate /etc/letsencrypt/live/api.91flow.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.91flow.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + + # 对外 API:开放 CORS(使用 Bearer Token,不涉及 Cookie,故可用 "*") + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type" always; + + client_max_body_size 50m; + + # 预检请求直接返回 204 + location / { + if ($request_method = OPTIONS) { + return 204; + } + proxy_pass http://originflow_backend; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 8e6fe4b57b6d..c6a13204d2f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,13 @@ # New-API Docker Compose Configuration # # Quick Start: -# 1. docker-compose up -d +# 1. docker compose up -d --build # 2. Access at http://localhost:3000 # +# The new-api image is built from this repository's source (see Dockerfile). +# After changing Go or web code, rebuild with: +# docker compose up -d --build new-api +# # Using MySQL instead of PostgreSQL: # 1. Comment out the postgres service and SQL_DSN line 15 # 2. Uncomment the mysql service and SQL_DSN line 16 @@ -16,7 +20,18 @@ version: '3.4' # For compatibility with older Docker versions services: new-api: - image: calciumion/new-api:latest + # 使用本仓库源码构建,而非上游发行镜像,否则二次开发的功能不会包含在内 + # (Build from this repository's source instead of the upstream release image, + # otherwise none of the in-house features are included.) + build: + context: . + dockerfile: Dockerfile + args: + # 网络较慢时用就近镜像源,避免大体积依赖下载超时导致 integrity 校验失败 + # (Use a nearby mirror on slow links; large deps otherwise time out and + # fail bun's integrity check.) Override via NPM_REGISTRY in your shell. + NPM_REGISTRY: ${NPM_REGISTRY:-} + image: new-api:local container_name: new-api restart: always command: --log-dir /app/logs diff --git a/model/ability.go b/model/ability.go index d950a6adbfc4..7e53e688df9f 100644 --- a/model/ability.go +++ b/model/ability.go @@ -105,7 +105,9 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { return channelQuery, nil } -func GetChannel(group string, model string, retry int, requestPath string) (*Channel, error) { +// GetChannel 在未开启内存缓存时直接查库选择渠道。 +// routing 为区域路由解析结果,语义与 GetRandomSatisfiedChannel 一致。 +func GetChannel(group string, model string, retry int, requestPath string, routing RegionRouting) (*Channel, error) { var abilities []Ability var err error = nil @@ -122,6 +124,7 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha return nil, err } abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model) + abilities = filterAbilitiesByRegion(abilities, routing) channel := Channel{} if len(abilities) > 0 { // Randomly choose one @@ -146,6 +149,73 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha return &channel, err } +// filterAbilitiesByRegion 按区域路由收窄候选能力:先用白名单过滤渠道 id, +// 再在区域策略生效时只保留策略分数最高的一批渠道。 +// 任一步骤过滤为空时都回退到过滤前的候选集,避免配置问题导致请求失败。 +func filterAbilitiesByRegion(abilities []Ability, routing RegionRouting) []Ability { + if !routing.Active || len(abilities) == 0 { + return abilities + } + + if len(routing.AllowedIds) > 0 { + allowed := make(map[int]bool, len(routing.AllowedIds)) + for _, id := range routing.AllowedIds { + allowed[int(id)] = true + } + filtered := make([]Ability, 0, len(abilities)) + for _, ability := range abilities { + if allowed[ability.ChannelId] { + filtered = append(filtered, ability) + } + } + if len(filtered) > 0 { + abilities = filtered + } + } + + if routing.Strategy == "" || len(abilities) < 2 { + return abilities + } + + channelIds := make([]int, 0, len(abilities)) + seen := make(map[int]struct{}, len(abilities)) + for _, ability := range abilities { + if _, ok := seen[ability.ChannelId]; ok { + continue + } + seen[ability.ChannelId] = struct{}{} + channelIds = append(channelIds, ability.ChannelId) + } + var channels []*Channel + if err := DB.Where("id IN ?", channelIds).Find(&channels).Error; err != nil { + return abilities + } + scores := make(map[int]int64, len(channels)) + var bestScore int64 + first := true + for _, channel := range channels { + score := regionStrategyScore(channel, routing.Strategy) + scores[channel.Id] = score + if first || score > bestScore { + bestScore = score + first = false + } + } + if first { + return abilities + } + best := make([]Ability, 0, len(abilities)) + for _, ability := range abilities { + if score, ok := scores[ability.ChannelId]; ok && score == bestScore { + best = append(best, ability) + } + } + if len(best) == 0 { + return abilities + } + return best +} + // filterAbilitiesByRequestPathAndModel restricts candidates by request path and // model for the DB (non-memory-cache) selection path. Only Advanced Custom // (type 58) channels are path-checked: kept only when one of their routes matches diff --git a/model/analytics_event.go b/model/analytics_event.go new file mode 100644 index 000000000000..a218cd15f7df --- /dev/null +++ b/model/analytics_event.go @@ -0,0 +1,38 @@ +package model + +import ( + "github.com/QuantumNous/new-api/common" +) + +// AnalyticsEvent records first-party product/Marketing funnel events +// (visit, signup, pricing_click, lead_submit, ...). Lightweight, no PII beyond +// what the client voluntarily sends; used for conversion analytics (P1-06). +type AnalyticsEvent struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + Event string `json:"event" gorm:"type:varchar(32);index"` // visit|signup|pricing_click|lead_submit|... + Path string `json:"path" gorm:"type:varchar(512)"` + Locale string `json:"locale" gorm:"type:varchar(16)"` + Referrer string `json:"referrer" gorm:"type:varchar(512)"` + UserId int64 `json:"user_id" gorm:"index;default:0"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` +} + +// Allowed analytics event names (defense against junk/abuse). +var allowedAnalyticsEvents = map[string]bool{ + "visit": true, + "signup": true, + "pricing_click": true, + "lead_submit": true, + "page_view": true, +} + +func IsValidAnalyticsEvent(name string) bool { + return allowedAnalyticsEvents[name] +} + +func CreateAnalyticsEvent(event *AnalyticsEvent) error { + if event.CreatedAt == 0 { + event.CreatedAt = common.GetTimestamp() + } + return DB.Create(event).Error +} diff --git a/model/channel_cache.go b/model/channel_cache.go index 86c594384d50..430aeab9195c 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -111,28 +111,41 @@ func SyncChannelCache(frequency int) { } } -func GetRandomSatisfiedChannel(group string, model string, retry int, requestPath string) (*Channel, error) { +// GetRandomSatisfiedChannel 选择一个满足条件的渠道。 +// routing 为区域路由解析结果:Active 时先按白名单收窄候选渠道, +// 并用区域策略打分替代默认的渠道优先级;未命中时行为与无区域路由完全一致。 +func GetRandomSatisfiedChannel(group string, model string, retry int, requestPath string, routing RegionRouting) (*Channel, error) { // if memory cache is disabled, get channel directly from database if !common.MemoryCacheEnabled { - return GetChannel(group, model, retry, requestPath) + return GetChannel(group, model, retry, requestPath, routing) } channelSyncLock.RLock() defer channelSyncLock.RUnlock() // First, try to find channels with the exact model name. - channels := filterChannelsByRequestPathAndModel(group2model2channels[group][model], requestPath, model) + channels := filterChannelsByRegion( + filterChannelsByRequestPathAndModel(group2model2channels[group][model], requestPath, model), routing) // If no channels found, try to find channels with the normalized model name. if len(channels) == 0 { normalizedModel := ratio_setting.FormatMatchingModelName(model) - channels = filterChannelsByRequestPathAndModel(group2model2channels[group][normalizedModel], requestPath, model) + channels = filterChannelsByRegion( + filterChannelsByRequestPathAndModel(group2model2channels[group][normalizedModel], requestPath, model), routing) } if len(channels) == 0 { return nil, nil } + // 区域策略生效时用策略分数替代渠道优先级参与分层与筛选 + scoreOf := func(channel *Channel) int64 { + if routing.Active && routing.Strategy != "" { + return regionStrategyScore(channel, routing.Strategy) + } + return channel.GetPriority() + } + if len(channels) == 1 { if channel, ok := channelsIDM[channels[0]]; ok { return channel, nil @@ -143,7 +156,7 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat uniquePriorities := make(map[int]bool) for _, channelId := range channels { if channel, ok := channelsIDM[channelId]; ok { - uniquePriorities[int(channel.GetPriority())] = true + uniquePriorities[int(scoreOf(channel))] = true } else { return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) } @@ -164,7 +177,7 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat var targetChannels []*Channel for _, channelId := range channels { if channel, ok := channelsIDM[channelId]; ok { - if channel.GetPriority() == targetPriority { + if scoreOf(channel) == targetPriority { sumWeight += channel.GetWeight() targetChannels = append(targetChannels, channel) } @@ -236,6 +249,28 @@ func filterChannelsByRequestPathAndModel(channels []int, requestPath string, mod return filtered } +// filterChannelsByRegion 按区域路由白名单收窄候选渠道,保持原有优先级顺序。 +// 交集为空时返回原候选集:宁可让区域策略降级失效,也不因配置错误让请求整体失败。 +func filterChannelsByRegion(channels []int, routing RegionRouting) []int { + if !routing.Active || len(channels) == 0 || len(routing.AllowedIds) == 0 { + return channels + } + allowed := make(map[int]bool, len(routing.AllowedIds)) + for _, id := range routing.AllowedIds { + allowed[int(id)] = true + } + filtered := make([]int, 0, len(channels)) + for _, channelId := range channels { + if allowed[channelId] { + filtered = append(filtered, channelId) + } + } + if len(filtered) == 0 { + return channels + } + return filtered +} + func CacheGetChannel(id int) (*Channel, error) { if !common.MemoryCacheEnabled { return GetChannelById(id, true) diff --git a/model/distributor.go b/model/distributor.go new file mode 100644 index 000000000000..dd0daefdb3d9 --- /dev/null +++ b/model/distributor.go @@ -0,0 +1,194 @@ +package model + +import ( + "strings" + "time" + + "gorm.io/gorm" +) + +// Distributor 分销商(渠道商)实体,关联一个管理员账号(UserId)。 +type Distributor struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + UserId int64 `json:"user_id" gorm:"not null;uniqueIndex"` // 分销商管理员账号 + Name string `json:"name" gorm:"type:varchar(64);not null"` + Tier string `json:"tier" gorm:"type:varchar(16);default:'standard'"` // standard | gold | platinum + CommissionRate int `json:"commission_rate" gorm:"default:0"` // 佣金比例(百分比) + Status int `json:"status" gorm:"not null;default:1;index"` // 1 启用, 2 停用 + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +// 分销商等级 / 状态白名单。 +var AllowedDistributorTiers = map[string]bool{ + "standard": true, + "gold": true, + "platinum": true, +} + +const ( + DistributorStatusActive = 1 + DistributorStatusDisabled = 2 +) + +var AllowedDistributorStatuses = map[int]bool{ + DistributorStatusActive: true, + DistributorStatusDisabled: true, +} + +// DistributorPrice 分销商价格覆盖(下级用户的模型售价)。 +type DistributorPrice struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + DistributorId int64 `json:"distributor_id" gorm:"not null;index"` + Model string `json:"model" gorm:"type:varchar(255);not null"` + InputPrice int64 `json:"input_price" gorm:"not null;default:0"` // 每 1M 单位最小货币单位 + OutputPrice int64 `json:"output_price" gorm:"not null;default:0"` // 每 1M 单位最小货币单位 + Currency string `json:"currency" gorm:"type:varchar(8);not null;default:'CNY'"` + Unit string `json:"unit" gorm:"type:varchar(16);default:'token'"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +// 价格覆盖单位 / 货币白名单(复用市场模型约定)。 +var AllowedDistributorPriceUnits = map[string]bool{ + "token": true, + "image": true, + "second": true, + "char": true, +} + +var AllowedDistributorPriceCurrencies = map[string]bool{ + "CNY": true, + "USD": true, +} + +// CreateDistributor 创建分销商。 +func CreateDistributor(m *Distributor) error { + now := time.Now().Unix() + m.CreatedAt = now + m.UpdatedAt = now + return DB.Create(m).Error +} + +// GetDistributorById 按 id 获取分销商。 +func GetDistributorById(id int64) (*Distributor, error) { + var m Distributor + if err := DB.Where("id = ?", id).First(&m).Error; err != nil { + return nil, err + } + return &m, nil +} + +// GetDistributorByUserId 按关联账号获取分销商。 +func GetDistributorByUserId(userId int64) (*Distributor, error) { + var m Distributor + if err := DB.Where("user_id = ?", userId).First(&m).Error; err != nil { + return nil, err + } + return &m, nil +} + +// SearchDistributors 分页列出分销商;keyword 为空时不过滤。 +func SearchDistributors(page, pageSize int, keyword string) ([]*Distributor, int64, error) { + var items []*Distributor + var total int64 + q := DB.Model(&Distributor{}) + if strings.TrimSpace(keyword) != "" { + q = q.Where("name LIKE ?", "%"+keyword+"%") + } + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + offset := (page - 1) * pageSize + if err := q.Order("id DESC").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +// UpdateDistributor 更新分销商可编辑字段。 +func UpdateDistributor(m *Distributor) error { + updates := map[string]interface{}{ + "name": m.Name, + "tier": m.Tier, + "commission_rate": m.CommissionRate, + "status": m.Status, + "updated_at": time.Now().Unix(), + } + return DB.Model(&Distributor{}).Where("id = ?", m.Id).Updates(updates).Error +} + +// DeleteDistributor 删除分销商。 +func DeleteDistributor(id int64) error { + return DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("distributor_id = ?", id).Delete(&DistributorPrice{}).Error; err != nil { + return err + } + return tx.Where("id = ?", id).Delete(&Distributor{}).Error + }) +} + +// CreateDistributorPrice 创建价格覆盖。 +func CreateDistributorPrice(m *DistributorPrice) error { + now := time.Now().Unix() + m.CreatedAt = now + m.UpdatedAt = now + return DB.Create(m).Error +} + +// SearchDistributorPrices 列出某分销商的全部价格覆盖。 +func SearchDistributorPrices(distributorId int64) ([]*DistributorPrice, error) { + var items []*DistributorPrice + if err := DB.Where("distributor_id = ?", distributorId).Order("id ASC").Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +// UpdateDistributorPrice 更新价格覆盖。 +func UpdateDistributorPrice(m *DistributorPrice) error { + updates := map[string]interface{}{ + "model": m.Model, + "input_price": m.InputPrice, + "output_price": m.OutputPrice, + "currency": m.Currency, + "unit": m.Unit, + "updated_at": time.Now().Unix(), + } + return DB.Model(&DistributorPrice{}).Where("id = ?", m.Id).Updates(updates).Error +} + +// DeleteDistributorPrice 删除价格覆盖。 +func DeleteDistributorPrice(id int64) error { + return DB.Where("id = ?", id).Delete(&DistributorPrice{}).Error +} + +// DistributorBilling 分销商下级账单汇总(基于下级用户额度近似)。 +type DistributorBilling struct { + DistributorId int64 `json:"distributor_id"` + SubUserCount int64 `json:"sub_user_count"` + Allocated int64 `json:"allocated"` + Used int64 `json:"used"` +} + +// GetDistributorBilling 汇总下级用户(直接邀请)的额度与用量。 +func GetDistributorBilling(distributorUserId int64) (*DistributorBilling, error) { + var subUserIds []int64 + if err := DB.Model(&User{}).Where("inviter_id = ?", distributorUserId).Pluck("id", &subUserIds).Error; err != nil { + return nil, err + } + billing := &DistributorBilling{DistributorId: distributorUserId, SubUserCount: int64(len(subUserIds))} + if len(subUserIds) == 0 { + return billing, nil + } + var allocated, used int64 + if err := DB.Model(&User{}). + Where("id IN ?", subUserIds). + Select("COALESCE(SUM(quota),0), COALESCE(SUM(used_quota),0)"). + Row().Scan(&allocated, &used); err != nil { + return nil, err + } + billing.Allocated = allocated + billing.Used = used + return billing, nil +} diff --git a/model/log.go b/model/log.go index 1d2b38fc7c1c..abc4c6c29754 100644 --- a/model/log.go +++ b/model/log.go @@ -8,6 +8,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/types" @@ -74,6 +75,7 @@ type Log struct { ChannelName string `json:"channel_name" gorm:"->"` TokenId int `json:"token_id" gorm:"default:0;index"` Group string `json:"group" gorm:"index"` + TeamId int64 `json:"team_id" gorm:"type:bigint;index:idx_logs_team_id;default:0"` // 所属企业团队,用于团队级用量/计费聚合 Ip string `json:"ip" gorm:"index;default:''"` RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"` UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"` @@ -373,6 +375,12 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) UseTime: params.UseTimeSeconds, IsStream: params.IsStream, Group: params.Group, + TeamId: func() int64 { + if v, ok := common.GetContextKeyType[int64](c, constant.ContextKeyUserTeamId); ok { + return v + } + return 0 + }(), Ip: func() string { if needRecordIp { return c.ClientIP() diff --git a/model/main.go b/model/main.go index 21445593e54e..1b89f55299c0 100644 --- a/model/main.go +++ b/model/main.go @@ -292,10 +292,25 @@ func migrateDB() error { &SystemTaskLock{}, &CasbinRule{}, &AuthzRole{}, + &MarketModel{}, + // P2 平台化能力:企业团队空间 / SLA / 区域路由 / 分销商 + &Team{}, + &TeamMember{}, + &TeamProject{}, + &SlaIncident{}, + &RegionRoute{}, + &Distributor{}, + &DistributorPrice{}, + // MVP 营销站公开数据模型 + &SalesLead{}, + &PublicPricing{}, + &PublicModelCategory{}, ) if err != nil { return err } + // 写入营销站默认定价与模型目录(仅在表为空时) + InitPublicSiteDefaults() if err := InitializeUserAuthVersions(); err != nil { return err } diff --git a/model/market_model.go b/model/market_model.go new file mode 100644 index 000000000000..23220e49d5a4 --- /dev/null +++ b/model/market_model.go @@ -0,0 +1,119 @@ +package model + +import ( + "time" +) + +// MarketModel 模型商店(Model Market)的商品条目:面向客户的单模型商业上架信息。 +// 与内部计费表 Pricing(成本比例,随默认刷新)解耦——此处为管理员可维护的“门店价格”, +// 同时关联实际可路由的模型名(= Pricing.ModelName)与营销分类(= PublicModelCategory.Category)。 +type MarketModel struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + Model string `json:"model" gorm:"type:varchar(255);not null;uniqueIndex"` // 实际模型名,= Pricing.ModelName + Provider string `json:"provider" gorm:"type:varchar(64);index"` // OpenAI / Anthropic / ... + Category string `json:"category" gorm:"type:varchar(32);not null;index"` // = PublicModelCategory.Category + Tags string `json:"tags" gorm:"type:varchar(255)"` // vision,reasoning,streaming + InputPrice int64 `json:"input_price" gorm:"not null;default:0"` // 客户价:每 1M 个计价单位的最小货币单位(如 CNY 分 / USD 美分),见 Currency + OutputPrice int64 `json:"output_price" gorm:"not null;default:0"` // 客户价:同上,输出侧 + Currency string `json:"currency" gorm:"type:varchar(8);not null;default:'CNY'"` // CNY / USD,计价货币 + Unit string `json:"unit" gorm:"type:varchar(16);default:'token'"` // token|image|second|char(计价数量单位) + Metadata string `json:"metadata" gorm:"type:text"` // JSON:按 locale 的展示覆盖,如 {"zh":{"name":...},"en":{"name":...}} + TrialQuota int64 `json:"trial_quota" gorm:"default:0"` // 首次激活赠送的试用额度(v1 仅展示,未启用激活) + Status int `json:"status" gorm:"not null;default:1;index"` // 1 available, 2 coming_soon, 3 disabled + Featured bool `json:"featured" gorm:"default:false;index"` + Sort int `json:"sort" gorm:"default:0"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +// MarketModel 状态常量。 +const ( + MarketModelStatusAvailable = 1 + MarketModelStatusComingSoon = 2 + MarketModelStatusDisabled = 3 +) + +// AllowedMarketModelStatuses 上架状态白名单。 +var AllowedMarketModelStatuses = map[int]bool{ + MarketModelStatusAvailable: true, + MarketModelStatusComingSoon: true, + MarketModelStatusDisabled: true, +} + +// AllowedMarketModelUnits 计价单位白名单。 +var AllowedMarketModelUnits = map[string]bool{ + "token": true, + "image": true, + "second": true, + "char": true, +} + +// AllowedMarketModelCurrencies 计价货币白名单。 +var AllowedMarketModelCurrencies = map[string]bool{ + "CNY": true, + "USD": true, +} + +// CreateMarketModel 写入一条模型上架记录(由调用方负责填充时间戳与默认值)。 +func CreateMarketModel(m *MarketModel) error { + now := time.Now().Unix() + m.CreatedAt = now + m.UpdatedAt = now + return DB.Create(m).Error +} + +// GetMarketModelById 按 id 获取单条上架记录。 +func GetMarketModelById(id int64) (*MarketModel, error) { + var m MarketModel + err := DB.Where("id = ?", id).First(&m).Error + if err != nil { + return nil, err + } + return &m, nil +} + +// SearchMarketModels 列出上架模型;status/category 为空时不过滤。按 category、sort 升序。 +func SearchMarketModels(status, category string) ([]*MarketModel, error) { + var items []*MarketModel + q := DB.Order("category ASC").Order("sort ASC") + if status != "" { + q = q.Where("status = ?", status) + } + if category != "" { + q = q.Where("category = ?", category) + } + err := q.Find(&items).Error + return items, err +} + +// UpdateMarketModel 更新上架记录的可编辑字段(Model 为唯一键,不可变)。 +func UpdateMarketModel(m *MarketModel) error { + updates := map[string]interface{}{ + "provider": m.Provider, + "category": m.Category, + "tags": m.Tags, + "input_price": m.InputPrice, + "output_price": m.OutputPrice, + "currency": m.Currency, + "unit": m.Unit, + "metadata": m.Metadata, + "trial_quota": m.TrialQuota, + "status": m.Status, + "featured": m.Featured, + "sort": m.Sort, + "updated_at": time.Now().Unix(), + } + return DB.Model(&MarketModel{}).Where("id = ?", m.Id).Updates(updates).Error +} + +// DeleteMarketModel 按 id 删除上架记录。 +func DeleteMarketModel(id int64) error { + return DB.Where("id = ?", id).Delete(&MarketModel{}).Error +} + +// CountMarketModelsByModel 判断某模型名是否已上架(用于唯一性校验)。 +func CountMarketModelsByModel(model string) (int64, error) { + var cnt int64 + err := DB.Model(&MarketModel{}).Where("model = ?", model).Count(&cnt).Error + return cnt, err +} diff --git a/model/public_model_category.go b/model/public_model_category.go new file mode 100644 index 000000000000..0b65a34c968d --- /dev/null +++ b/model/public_model_category.go @@ -0,0 +1,15 @@ +package model + +// PublicModelCategory 营销站公开模型目录(按 locale 提供中英双语)。 +type PublicModelCategory struct { + Id uint `gorm:"primaryKey" json:"id"` + Category string `gorm:"type:varchar(32);not null;index" json:"category"` // chinese/global/image/video/audio/embedding + Locale string `gorm:"type:varchar(8);not null;index" json:"locale"` + Title string `gorm:"type:varchar(128);not null" json:"title"` + Description string `gorm:"type:text" json:"description"` + Models string `gorm:"type:text" json:"models"` // JSON 数组: [{name,capability_tags,note}] + Sort int `gorm:"default:0" json:"sort"` + Enabled bool `gorm:"default:true" json:"enabled"` +} + +func (PublicModelCategory) TableName() string { return "public_model_categories" } diff --git a/model/public_pricing.go b/model/public_pricing.go new file mode 100644 index 000000000000..bcc82218a41f --- /dev/null +++ b/model/public_pricing.go @@ -0,0 +1,17 @@ +package model + +// PublicPricing 营销站公开定价方案(按 locale 提供中英双语)。 +type PublicPricing struct { + Id uint `gorm:"primaryKey" json:"id"` + PlanKey string `gorm:"type:varchar(32);not null;index" json:"plan_key"` + Locale string `gorm:"type:varchar(8);not null;index" json:"locale"` + Title string `gorm:"type:varchar(128);not null" json:"title"` + Description string `gorm:"type:text" json:"description"` + BillingMode string `gorm:"type:varchar(32)" json:"billing_mode"` // payg/subscription/custom + PriceText string `gorm:"type:varchar(128)" json:"price_text"` + Features string `gorm:"type:text" json:"features"` // JSON 数组字符串 + Sort int `gorm:"default:0" json:"sort"` + Enabled bool `gorm:"default:true" json:"enabled"` +} + +func (PublicPricing) TableName() string { return "public_pricings" } diff --git a/model/public_site_seed.go b/model/public_site_seed.go new file mode 100644 index 000000000000..fdf1bc4a7722 --- /dev/null +++ b/model/public_site_seed.go @@ -0,0 +1,106 @@ +package model + +import "encoding/json" + +// InitPublicSiteDefaults 在首次启动(表为空)时写入营销站默认的定价方案与模型目录, +// 提供中英双语。仅在表为空时插入,避免重复写入。 +func InitPublicSiteDefaults() { + if DB == nil { + return + } + var pricingCount int64 + if err := DB.Model(&PublicPricing{}).Count(&pricingCount).Error; err != nil { + return + } + if pricingCount == 0 { + pricings := defaultPublicPricings() + _ = DB.Create(&pricings).Error + } + + var catCount int64 + if err := DB.Model(&PublicModelCategory{}).Count(&catCount).Error; err != nil { + return + } + if catCount == 0 { + cats := defaultPublicModelCategories() + _ = DB.Create(&cats).Error + } +} + +func defaultPublicPricings() []PublicPricing { + paygFeatures, _ := json.Marshal([]string{ + "按实际 token 用量计费,无月费", + "全模型统一接入,无需逐家签约", + "即时开通,按量结算", + }) + proFeatures, _ := json.Marshal([]string{ + "每月固定额度,单价更优", + "优先队列与更高并发", + "团队子账号与用量看板", + }) + entFeatures, _ := json.Marshal([]string{ + "专属 SLA 与区域路由", + "定制模型与私有部署", + "专属客户成功经理", + }) + return []PublicPricing{ + {PlanKey: "payg", Locale: "en", Title: "Pay as you go", Description: "Token-based pricing, no monthly fee.", BillingMode: "payg", PriceText: "Per-token", Features: string(paygFeatures), Sort: 1, Enabled: true}, + {PlanKey: "payg", Locale: "zh", Title: "按量计费", Description: "按实际 token 用量计费,无月费。", BillingMode: "payg", PriceText: "按 token", Features: string(paygFeatures), Sort: 1, Enabled: true}, + {PlanKey: "pro", Locale: "en", Title: "Pro", Description: "Monthly quota with better unit price.", BillingMode: "subscription", PriceText: "$49 / month", Features: string(proFeatures), Sort: 2, Enabled: true}, + {PlanKey: "pro", Locale: "zh", Title: "专业版", Description: "每月固定额度,单价更优。", BillingMode: "subscription", PriceText: "¥299 / 月", Features: string(proFeatures), Sort: 2, Enabled: true}, + {PlanKey: "enterprise", Locale: "en", Title: "Enterprise", Description: "Dedicated SLA, regional routing, custom models.", BillingMode: "custom", PriceText: "Contact sales", Features: string(entFeatures), Sort: 3, Enabled: true}, + {PlanKey: "enterprise", Locale: "zh", Title: "企业版", Description: "专属 SLA、区域路由与定制模型。", BillingMode: "custom", PriceText: "联系销售", Features: string(entFeatures), Sort: 3, Enabled: true}, + } +} + +func defaultPublicModelCategories() []PublicModelCategory { + chinese, _ := json.Marshal([]map[string]string{ + {"name": "DeepSeek", "capability_tags": "reasoning, chat", "note": "国产强推理模型"}, + {"name": "Qwen", "capability_tags": "chat, vision", "note": "通义千问全系列"}, + {"name": "GLM", "capability_tags": "chat, agent", "note": "智谱 GLM 系列"}, + {"name": "Doubao", "capability_tags": "chat, vision", "note": "字节豆包"}, + {"name": "Kimi", "capability_tags": "long-context", "note": "月之暗面长上下文"}, + {"name": "Hunyuan", "capability_tags": "chat", "note": "腾讯混元"}, + }) + global, _ := json.Marshal([]map[string]string{ + {"name": "OpenAI", "capability_tags": "chat, vision, audio", "note": "GPT 全系列"}, + {"name": "Anthropic", "capability_tags": "chat, agent", "note": "Claude 全系列"}, + {"name": "Google", "capability_tags": "chat, vision", "note": "Gemini 全系列"}, + {"name": "Meta", "capability_tags": "chat", "note": "Llama 开源系列"}, + {"name": "Mistral", "capability_tags": "chat", "note": "欧洲开源模型"}, + }) + image, _ := json.Marshal([]map[string]string{ + {"name": "DALL·E", "capability_tags": "image", "note": "OpenAI 文生图"}, + {"name": "Midjourney", "capability_tags": "image", "note": "艺术风格图像"}, + {"name": "Stable Diffusion", "capability_tags": "image", "note": "开源文生图"}, + {"name": "Flux", "capability_tags": "image", "note": "高质感图像"}, + }) + video, _ := json.Marshal([]map[string]string{ + {"name": "Runway", "capability_tags": "video", "note": "文生视频"}, + {"name": "Kling", "capability_tags": "video", "note": "可灵文生视频"}, + {"name": "Sora", "capability_tags": "video", "note": "OpenAI 视频生成"}, + }) + audio, _ := json.Marshal([]map[string]string{ + {"name": "Whisper", "capability_tags": "audio", "note": "语音识别"}, + {"name": "TTS", "capability_tags": "audio", "note": "语音合成"}, + {"name": "Suno", "capability_tags": "audio", "note": "音乐生成"}, + }) + embedding, _ := json.Marshal([]map[string]string{ + {"name": "text-embedding", "capability_tags": "embedding", "note": "文本向量"}, + {"name": "bge", "capability_tags": "embedding", "note": "开源嵌入模型"}, + }) + return []PublicModelCategory{ + {Category: "chinese", Locale: "en", Title: "Chinese LLMs", Description: "Top open & commercial large models from China.", Models: string(chinese), Sort: 1, Enabled: true}, + {Category: "chinese", Locale: "zh", Title: "中国大模型", Description: "国内头部开源与商业大模型统一接入。", Models: string(chinese), Sort: 1, Enabled: true}, + {Category: "global", Locale: "en", Title: "Global LLMs", Description: "Leading models from OpenAI, Anthropic, Google and more.", Models: string(global), Sort: 2, Enabled: true}, + {Category: "global", Locale: "zh", Title: "海外大模型", Description: "OpenAI、Anthropic、Google 等全球领先模型。", Models: string(global), Sort: 2, Enabled: true}, + {Category: "image", Locale: "en", Title: "Image Generation", Description: "Text-to-image models for product & creative use.", Models: string(image), Sort: 3, Enabled: true}, + {Category: "image", Locale: "zh", Title: "图像生成", Description: "面向产品与创作的文生图模型。", Models: string(image), Sort: 3, Enabled: true}, + {Category: "video", Locale: "en", Title: "Video Generation", Description: "Text-to-video for marketing and storytelling.", Models: string(video), Sort: 4, Enabled: true}, + {Category: "video", Locale: "zh", Title: "视频生成", Description: "面向营销与叙事的文生视频。", Models: string(video), Sort: 4, Enabled: true}, + {Category: "audio", Locale: "en", Title: "Audio", Description: "Speech recognition, TTS and music generation.", Models: string(audio), Sort: 5, Enabled: true}, + {Category: "audio", Locale: "zh", Title: "语音与音乐", Description: "语音识别、语音合成与音乐生成。", Models: string(audio), Sort: 5, Enabled: true}, + {Category: "embedding", Locale: "en", Title: "Embeddings", Description: "Vector embeddings for RAG & search.", Models: string(embedding), Sort: 6, Enabled: true}, + {Category: "embedding", Locale: "zh", Title: "向量嵌入", Description: "面向 RAG 与检索的向量嵌入。", Models: string(embedding), Sort: 6, Enabled: true}, + } +} diff --git a/model/region_route.go b/model/region_route.go new file mode 100644 index 000000000000..72f574a1ffc7 --- /dev/null +++ b/model/region_route.go @@ -0,0 +1,281 @@ +package model + +import ( + "strconv" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" +) + +// RegionRoute 区域路由策略配置。 +// 由 ResolveRegionRouting 解析后接入 model/channel_cache.go、model/ability.go 的选渠道链路。 +type RegionRoute struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + Region string `json:"region" gorm:"type:varchar(16);not null;index"` // cn | us | eu | global ... + Model string `json:"model" gorm:"type:varchar(64);not null;default:'*'"` // '*' 表示全部模型 + ChannelIds string `json:"channel_ids" gorm:"type:varchar(512)"` // 逗号分隔的渠道 id 列表 + Tag string `json:"tag" gorm:"type:varchar(64)"` // 或按 tag 选择渠道 + Strategy string `json:"strategy" gorm:"type:varchar(16);not null;default:'availability'"` // cost | latency | availability | fixed + Priority int `json:"priority" gorm:"default:0"` + Weight int `json:"weight" gorm:"default:0"` + // 不能使用 gorm default:true:GORM 在 Create 时会忽略零值字段,导致显式禁用的策略被写成启用。 + // 缺省启用由 controller 层负责(请求未带 enabled 时置 true)。 + Enabled bool `json:"enabled" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +// 区域路由策略白名单。 +var AllowedRegionRouteStrategies = map[string]bool{ + "cost": true, + "latency": true, + "availability": true, + "fixed": true, +} + +// CreateRegionRoute 创建区域路由策略。 +func CreateRegionRoute(m *RegionRoute) error { + now := time.Now().Unix() + m.CreatedAt = now + m.UpdatedAt = now + err := DB.Create(m).Error + if err == nil { + InvalidateRegionRoutingCache() + } + return err +} + +// GetRegionRouteById 按 id 获取策略。 +func GetRegionRouteById(id int64) (*RegionRoute, error) { + var m RegionRoute + if err := DB.Where("id = ?", id).First(&m).Error; err != nil { + return nil, err + } + return &m, nil +} + +// SearchRegionRoutes 分页列出策略;region/model 为空时不过滤。 +func SearchRegionRoutes(page, pageSize int, region, model string) ([]*RegionRoute, int64, error) { + var items []*RegionRoute + var total int64 + q := DB.Model(&RegionRoute{}) + if strings.TrimSpace(region) != "" { + q = q.Where("region = ?", region) + } + if strings.TrimSpace(model) != "" { + q = q.Where("model = ?", model) + } + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + offset := (page - 1) * pageSize + if err := q.Order("id DESC").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +// UpdateRegionRoute 更新策略可编辑字段。 +func UpdateRegionRoute(m *RegionRoute) error { + updates := map[string]interface{}{ + "region": m.Region, + "model": m.Model, + "channel_ids": m.ChannelIds, + "tag": m.Tag, + "strategy": m.Strategy, + "priority": m.Priority, + "weight": m.Weight, + "enabled": m.Enabled, + "updated_at": time.Now().Unix(), + } + err := DB.Model(&RegionRoute{}).Where("id = ?", m.Id).Updates(updates).Error + if err == nil { + InvalidateRegionRoutingCache() + } + return err +} + +// DeleteRegionRoute 删除策略。 +func DeleteRegionRoute(id int64) error { + err := DB.Where("id = ?", id).Delete(&RegionRoute{}).Error + if err == nil { + InvalidateRegionRoutingCache() + } + return err +} + +// GetEnabledRegionRoutes 取某区域下启用的策略。 +// region 同时匹配 'global' 兜底策略;model 支持具体模型或 '*'(通配)匹配。 +func GetEnabledRegionRoutes(region, model string) ([]*RegionRoute, error) { + var items []*RegionRoute + err := DB.Where("enabled = ? AND region IN (?) AND (model = ? OR model = '*')", + true, []string{region, RegionGlobal}, model). + Order("priority DESC").Order("id ASC").Find(&items).Error + if err != nil { + return nil, err + } + return items, nil +} + +// RegionGlobal 是兜底区域标识:配置在该区域下的策略对所有区域生效。 +const RegionGlobal = "global" + +// maxRegionLength 限制区域标识长度,避免异常请求头把超长字符串带进 SQL 查询。 +const maxRegionLength = 16 + +// NormalizeRegion 归一化区域标识:去空格、转小写、截断超长值。 +func NormalizeRegion(region string) string { + region = strings.ToLower(strings.TrimSpace(region)) + if len(region) > maxRegionLength { + region = region[:maxRegionLength] + } + return region +} + +// RegionRouting 是区域路由的解析结果,供选渠道链路使用。 +type RegionRouting struct { + // Active 为 false 时调用方应完全退回默认选渠道逻辑。 + Active bool + // Region 归一化后的区域标识。 + Region string + // Strategy 命中的排序策略(cost | latency | availability | fixed),可能为空。 + Strategy string + // AllowedIds 允许使用的渠道 id 白名单,按配置顺序去重。 + AllowedIds []int64 +} + +// 区域路由解析结果缓存。选渠道位于转发热路径,逐请求查库不可接受, +// 因此在开启内存缓存时按 region|model 缓存解析结果,TTL 内管理端改动最多延迟生效 regionRoutingCacheTTL。 +type regionRoutingCacheEntry struct { + routing RegionRouting + expiresAt time.Time +} + +const regionRoutingCacheTTL = 60 * time.Second + +var regionRoutingCache sync.Map // string -> regionRoutingCacheEntry + +// ResolveRegionRouting 解析某区域 + 模型命中的路由策略,返回渠道白名单与排序策略。 +// region 为空、无启用策略、或策略未圈定任何渠道时返回 Active=false, +// 此时调用方保持原有选渠道行为不变。 +func ResolveRegionRouting(region, modelName string) RegionRouting { + region = NormalizeRegion(region) + if region == "" || DB == nil { + return RegionRouting{} + } + if !common.MemoryCacheEnabled { + return resolveRegionRouting(region, modelName) + } + key := region + "|" + modelName + if cached, ok := regionRoutingCache.Load(key); ok { + if entry, ok := cached.(regionRoutingCacheEntry); ok && time.Now().Before(entry.expiresAt) { + return entry.routing + } + } + routing := resolveRegionRouting(region, modelName) + regionRoutingCache.Store(key, regionRoutingCacheEntry{ + routing: routing, + expiresAt: time.Now().Add(regionRoutingCacheTTL), + }) + return routing +} + +// InvalidateRegionRoutingCache 清空区域路由解析缓存,管理端增删改策略后调用。 +func InvalidateRegionRoutingCache() { + regionRoutingCache.Range(func(key, _ any) bool { + regionRoutingCache.Delete(key) + return true + }) +} + +func resolveRegionRouting(region, modelName string) RegionRouting { + result := RegionRouting{} + routes, err := GetEnabledRegionRoutes(region, modelName) + if err != nil || len(routes) == 0 { + return result + } + + seen := make(map[int64]bool) + ids := make([]int64, 0, len(routes)) + tags := make([]string, 0, len(routes)) + strategy := "" + for _, r := range routes { + // routes 已按 priority DESC, id ASC 排序,首个合法策略即最高优先级策略 + if strategy == "" && AllowedRegionRouteStrategies[r.Strategy] { + strategy = r.Strategy + } + for _, id := range r.ChannelIdsAsSlice() { + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + if tag := strings.TrimSpace(r.Tag); tag != "" { + tags = append(tags, tag) + } + } + + if len(tags) > 0 { + var tagged []int64 + if err := DB.Model(&Channel{}).Where("tag IN (?)", tags).Pluck("id", &tagged).Error; err == nil { + for _, id := range tagged { + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + } + } + + if len(ids) == 0 { + return result + } + result.Active = true + result.Region = region + result.Strategy = strategy + result.AllowedIds = ids + return result +} + +// regionStrategyScore 按区域策略给渠道打分,分数越大越优先被选中。 +// 返回值直接替代默认的 channel.GetPriority(),因此需与优先级同量纲(int64)。 +func regionStrategyScore(channel *Channel, strategy string) int64 { + if channel == nil { + return 0 + } + switch strategy { + case "latency": + // 响应时间越短越优先 + return -int64(channel.ResponseTime) + case "availability": + // 可用渠道优先,其次比较响应时间 + var score int64 + if channel.Status == common.ChannelStatusEnabled { + score = 1_000_000_000 + } + return score - int64(channel.ResponseTime) + case "cost": + // 约定:优先级越低的渠道为越便宜的备用渠道,因此反向取优先级 + return -channel.GetPriority() + default: + // fixed / 未知策略:沿用渠道自身优先级 + return channel.GetPriority() + } +} + +// ChannelIdsAsSlice 将逗号分隔的渠道 id 解析为 int64 切片。 +func (r *RegionRoute) ChannelIdsAsSlice() []int64 { + ids := make([]int64, 0) + for _, s := range strings.Split(r.ChannelIds, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if id, err := strconv.ParseInt(s, 10, 64); err == nil { + ids = append(ids, id) + } + } + return ids +} diff --git a/model/region_route_test.go b/model/region_route_test.go new file mode 100644 index 000000000000..0eb8df40388f --- /dev/null +++ b/model/region_route_test.go @@ -0,0 +1,157 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupRegionRouteTest(t *testing.T) { + t.Helper() + require.NoError(t, DB.AutoMigrate(&RegionRoute{})) + require.NoError(t, DB.Exec("DELETE FROM region_routes").Error) + require.NoError(t, DB.Exec("DELETE FROM channels").Error) + InvalidateRegionRoutingCache() + t.Cleanup(func() { + DB.Exec("DELETE FROM region_routes") + DB.Exec("DELETE FROM channels") + InvalidateRegionRoutingCache() + }) +} + +func newTestChannel(t *testing.T, id int, tag string, priority int64, responseTime int, status int) *Channel { + t.Helper() + channel := &Channel{ + Id: id, + Name: tag, + Key: "sk-test", + Status: status, + Priority: &priority, + ResponseTime: responseTime, + } + if tag != "" { + channel.Tag = &tag + } + return channel +} + +func TestNormalizeRegion(t *testing.T) { + assert.Equal(t, "cn", NormalizeRegion(" CN ")) + assert.Equal(t, "", NormalizeRegion(" ")) + assert.Equal(t, "ap-southeast-1", NormalizeRegion("AP-Southeast-1")) + // 超长输入被截断到 maxRegionLength,避免异常请求头进入 SQL + assert.Len(t, NormalizeRegion("abcdefghijklmnopqrstuvwxyz"), maxRegionLength) +} + +func TestResolveRegionRoutingInactiveCases(t *testing.T) { + setupRegionRouteTest(t) + + // 无区域标识 + assert.False(t, ResolveRegionRouting("", "gpt-4").Active) + // 有区域但无任何策略 + assert.False(t, ResolveRegionRouting("cn", "gpt-4").Active) + + // 策略未圈定任何渠道 -> 不生效 + require.NoError(t, CreateRegionRoute(&RegionRoute{ + Region: "cn", Model: "*", Strategy: "latency", Enabled: true, + })) + assert.False(t, ResolveRegionRouting("cn", "gpt-4").Active) + + // 策略被禁用 -> 不生效 + require.NoError(t, CreateRegionRoute(&RegionRoute{ + Region: "cn", Model: "*", ChannelIds: "1,2", Strategy: "latency", Enabled: false, + })) + assert.False(t, ResolveRegionRouting("cn", "gpt-4").Active) +} + +func TestResolveRegionRoutingMergesChannelsAndPicksStrategy(t *testing.T) { + setupRegionRouteTest(t) + + require.NoError(t, DB.Create(newTestChannel(t, 5, "cn-pool", 0, 100, common.ChannelStatusEnabled)).Error) + require.NoError(t, DB.Create(newTestChannel(t, 9, "us-pool", 0, 100, common.ChannelStatusEnabled)).Error) + + // priority 最高的策略决定排序策略 + require.NoError(t, CreateRegionRoute(&RegionRoute{ + Region: "cn", Model: "*", ChannelIds: "1,2", Strategy: "latency", Priority: 10, Enabled: true, + })) + // global 策略对所有区域生效,渠道 id 与上一条去重合并 + require.NoError(t, CreateRegionRoute(&RegionRoute{ + Region: RegionGlobal, Model: "gpt-4", ChannelIds: "2,3", Strategy: "cost", Priority: 1, Enabled: true, + })) + // 按 tag 圈定渠道 + require.NoError(t, CreateRegionRoute(&RegionRoute{ + Region: "cn", Model: "*", Tag: "cn-pool", Strategy: "fixed", Priority: 0, Enabled: true, + })) + // 其他区域的策略不应命中 + require.NoError(t, CreateRegionRoute(&RegionRoute{ + Region: "us", Model: "*", Tag: "us-pool", Strategy: "fixed", Priority: 99, Enabled: true, + })) + + routing := ResolveRegionRouting(" CN ", "gpt-4") + require.True(t, routing.Active) + assert.Equal(t, "cn", routing.Region) + assert.Equal(t, "latency", routing.Strategy) + assert.ElementsMatch(t, []int64{1, 2, 3, 5}, routing.AllowedIds) + + // 模型不匹配 global 那条时,只保留通配策略的渠道 + routing = ResolveRegionRouting("cn", "claude-3") + require.True(t, routing.Active) + assert.ElementsMatch(t, []int64{1, 2, 5}, routing.AllowedIds) +} + +func TestRegionStrategyScore(t *testing.T) { + fast := newTestChannel(t, 1, "", 5, 100, common.ChannelStatusEnabled) + slow := newTestChannel(t, 2, "", 9, 800, common.ChannelStatusEnabled) + down := newTestChannel(t, 3, "", 9, 50, common.ChannelStatusAutoDisabled) + + // latency:响应时间短的分数高 + assert.Greater(t, regionStrategyScore(fast, "latency"), regionStrategyScore(slow, "latency")) + // availability:可用渠道永远优先于不可用渠道,即使后者更快 + assert.Greater(t, regionStrategyScore(slow, "availability"), regionStrategyScore(down, "availability")) + // cost:优先级低的渠道视为低成本备用渠道,分数更高 + assert.Greater(t, regionStrategyScore(fast, "cost"), regionStrategyScore(slow, "cost")) + // fixed / 未知策略:沿用渠道自身优先级 + assert.Equal(t, int64(9), regionStrategyScore(slow, "fixed")) + assert.Equal(t, int64(9), regionStrategyScore(slow, "")) + assert.Equal(t, int64(0), regionStrategyScore(nil, "latency")) +} + +func TestFilterChannelsByRegion(t *testing.T) { + candidates := []int{1, 2, 3} + + // 未命中区域路由时原样返回 + assert.Equal(t, candidates, filterChannelsByRegion(candidates, RegionRouting{})) + + // 命中时按白名单收窄,且保持原有顺序 + routing := RegionRouting{Active: true, AllowedIds: []int64{3, 1}} + assert.Equal(t, []int{1, 3}, filterChannelsByRegion(candidates, routing)) + + // 交集为空时降级为不过滤,避免请求整体失败 + routing = RegionRouting{Active: true, AllowedIds: []int64{99}} + assert.Equal(t, candidates, filterChannelsByRegion(candidates, routing)) +} + +func TestFilterAbilitiesByRegion(t *testing.T) { + setupRegionRouteTest(t) + + require.NoError(t, DB.Create(newTestChannel(t, 1, "", 0, 500, common.ChannelStatusEnabled)).Error) + require.NoError(t, DB.Create(newTestChannel(t, 2, "", 0, 50, common.ChannelStatusEnabled)).Error) + require.NoError(t, DB.Create(newTestChannel(t, 3, "", 0, 10, common.ChannelStatusEnabled)).Error) + + abilities := []Ability{{ChannelId: 1}, {ChannelId: 2}, {ChannelId: 3}} + + // 未命中区域路由时原样返回 + assert.Len(t, filterAbilitiesByRegion(abilities, RegionRouting{}), 3) + + // 白名单收窄 + latency 策略只保留最快的渠道 + routing := RegionRouting{Active: true, AllowedIds: []int64{1, 2}, Strategy: "latency"} + filtered := filterAbilitiesByRegion(abilities, routing) + require.Len(t, filtered, 1) + assert.Equal(t, 2, filtered[0].ChannelId) + + // 白名单与候选无交集时降级为不过滤 + routing = RegionRouting{Active: true, AllowedIds: []int64{99}} + assert.Len(t, filterAbilitiesByRegion(abilities, routing), 3) +} diff --git a/model/sales_lead.go b/model/sales_lead.go new file mode 100644 index 000000000000..3031afe9d959 --- /dev/null +++ b/model/sales_lead.go @@ -0,0 +1,20 @@ +package model + +// SalesLead 存储营销站「联系销售」表单提交的销售线索。 +type SalesLead struct { + Id uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"type:varchar(64);not null" json:"name"` + Email string `gorm:"type:varchar(128);not null;index" json:"email"` + Company string `gorm:"type:varchar(128)" json:"company"` + Region string `gorm:"type:varchar(64);not null" json:"region"` + UseCase string `gorm:"type:varchar(256);not null" json:"use_case"` + MonthlyVolume string `gorm:"type:varchar(64)" json:"monthly_volume"` + RequiredModels string `gorm:"type:varchar(512)" json:"required_models"` + Message string `gorm:"type:text" json:"message"` + Status string `gorm:"type:varchar(32);default:'new'" json:"status"` + Source string `gorm:"type:varchar(128)" json:"source"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (SalesLead) TableName() string { return "sales_leads" } diff --git a/model/sla.go b/model/sla.go new file mode 100644 index 000000000000..727fc9fbfd37 --- /dev/null +++ b/model/sla.go @@ -0,0 +1,180 @@ +package model + +import ( + "time" +) + +// SlaIncident 服务事件记录(故障 / 维护公告)。 +type SlaIncident struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + Title string `json:"title" gorm:"type:varchar(128);not null"` + Description string `json:"description" gorm:"type:text"` + Status int `json:"status" gorm:"not null;default:1;index"` // 1 investigating, 2 identified, 3 monitoring, 4 resolved + Severity string `json:"severity" gorm:"type:varchar(16);default:'minor'"` // minor | major | critical + StartedAt int64 `json:"started_at" gorm:"bigint"` + ResolvedAt int64 `json:"resolved_at" gorm:"bigint"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +// SLA 事件状态常量。 +const ( + SlaIncidentStatusInvestigating = 1 + SlaIncidentStatusIdentified = 2 + SlaIncidentStatusMonitoring = 3 + SlaIncidentStatusResolved = 4 +) + +// AllowedSlaIncidentStatuses 事件状态白名单。 +var AllowedSlaIncidentStatuses = map[int]bool{ + SlaIncidentStatusInvestigating: true, + SlaIncidentStatusIdentified: true, + SlaIncidentStatusMonitoring: true, + SlaIncidentStatusResolved: true, +} + +// AllowedSlaIncidentSeverities 事件严重度白名单。 +var AllowedSlaIncidentSeverities = map[string]bool{ + "minor": true, + "major": true, + "critical": true, +} + +// CreateSlaIncident 创建事件(填充时间戳)。 +func CreateSlaIncident(m *SlaIncident) error { + now := time.Now().Unix() + m.CreatedAt = now + m.UpdatedAt = now + if m.StartedAt == 0 { + m.StartedAt = now + } + return DB.Create(m).Error +} + +// GetSlaIncidentById 按 id 获取事件。 +func GetSlaIncidentById(id int64) (*SlaIncident, error) { + var m SlaIncident + if err := DB.Where("id = ?", id).First(&m).Error; err != nil { + return nil, err + } + return &m, nil +} + +// SearchSlaIncidents 分页列出事件;status 为空时返回全部,按创建时间倒序。 +func SearchSlaIncidents(page, pageSize int, status string) ([]*SlaIncident, int64, error) { + var items []*SlaIncident + var total int64 + q := DB.Model(&SlaIncident{}) + if status != "" { + q = q.Where("status = ?", status) + } + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + offset := (page - 1) * pageSize + if err := q.Order("id DESC").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +// UpdateSlaIncident 更新事件可编辑字段。 +func UpdateSlaIncident(m *SlaIncident) error { + updates := map[string]interface{}{ + "title": m.Title, + "description": m.Description, + "status": m.Status, + "severity": m.Severity, + "started_at": m.StartedAt, + "resolved_at": m.ResolvedAt, + "updated_at": time.Now().Unix(), + } + return DB.Model(&SlaIncident{}).Where("id = ?", m.Id).Updates(updates).Error +} + +// DeleteSlaIncident 删除事件。 +func DeleteSlaIncident(id int64) error { + return DB.Where("id = ?", id).Delete(&SlaIncident{}).Error +} + +// SlaNodeStatus 节点(渠道)状态摘要,用于状态页展示。 +type SlaNodeStatus struct { + Id int `json:"id"` + Name string `json:"name"` + Status int `json:"status"` + ResponseTime int `json:"response_time"` +} + +// SlaStatusSummary 状态页整体摘要(复用既有 Channel 与 PerfMetric 数据,不新增存储)。 +type SlaStatusSummary struct { + Availability float64 `json:"availability"` // 0~1,近 24h 成功/总请求 + WindowHours int `json:"window_hours"` // 统计窗口(小时) + NodeCount int64 `json:"node_count"` // 节点总数 + OkNodeCount int64 `json:"ok_node_count"` // 正常节点数 + ActiveIncidents int64 `json:"active_incidents"` // 未解决事件数 + Nodes []SlaNodeStatus `json:"nodes"` +} + +// GetSlaStatusSummary 聚合整体可用率、节点状态与活跃事件。 +func GetSlaStatusSummary(windowHours int) (*SlaStatusSummary, error) { + if windowHours <= 0 { + windowHours = 24 + } + startTs := time.Now().Unix() - int64(windowHours)*3600 + + // 整体可用率:近窗口内 PerfMetric 的成功/总请求。 + var reqCount, succCount int64 + if err := DB.Model(&PerfMetric{}). + Where("bucket_ts >= ?", startTs). + Select("COALESCE(SUM(request_count),0), COALESCE(SUM(success_count),0)"). + Row().Scan(&reqCount, &succCount); err != nil { + return nil, err + } + availability := 1.0 + if reqCount > 0 { + availability = float64(succCount) / float64(reqCount) + } + + // 节点状态:来自 channels。 + var channels []Channel + if err := DB.Find(&channels).Error; err != nil { + return nil, err + } + nodes := make([]SlaNodeStatus, 0, len(channels)) + var okNodeCount int64 + for _, ch := range channels { + if ch.Status == 1 { + okNodeCount++ + } + nodes = append(nodes, SlaNodeStatus{ + Id: ch.Id, + Name: ch.Name, + Status: ch.Status, + ResponseTime: ch.ResponseTime, + }) + } + + // 活跃事件:未解决(status != resolved)。 + var activeIncidents int64 + if err := DB.Model(&SlaIncident{}). + Where("status <> ?", SlaIncidentStatusResolved). + Count(&activeIncidents).Error; err != nil { + return nil, err + } + + return &SlaStatusSummary{ + Availability: availability, + WindowHours: windowHours, + NodeCount: int64(len(channels)), + OkNodeCount: okNodeCount, + ActiveIncidents: activeIncidents, + Nodes: nodes, + }, nil +} + +// CountActiveSlaIncidents 未解决事件数(公开状态页使用)。 +func CountActiveSlaIncidents() (int64, error) { + var n int64 + err := DB.Model(&SlaIncident{}).Where("status <> ?", SlaIncidentStatusResolved).Count(&n).Error + return n, err +} diff --git a/model/team.go b/model/team.go new file mode 100644 index 000000000000..c0582a959624 --- /dev/null +++ b/model/team.go @@ -0,0 +1,201 @@ +package model + +import ( + "strings" + "time" + + "gorm.io/gorm" +) + +// Team 企业团队空间:团队(部门 / 组织单元)实体。 +type Team struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"type:varchar(64);not null;uniqueIndex"` + Description string `json:"description" gorm:"type:varchar(255)"` + OwnerId int64 `json:"owner_id" gorm:"not null;index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +// TeamMember 团队成员与角色。 +type TeamMember struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + TeamId int64 `json:"team_id" gorm:"not null;index"` + UserId int64 `json:"user_id" gorm:"not null;index"` + Role string `json:"role" gorm:"type:varchar(16);not null;default:'member'"` // admin | member + CreatedAt int64 `json:"created_at" gorm:"bigint"` +} + +// TeamProject 团队内项目。 +type TeamProject struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + TeamId int64 `json:"team_id" gorm:"not null;index"` + Name string `json:"name" gorm:"type:varchar(64);not null"` + Description string `json:"description" gorm:"type:varchar(255)"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +// 团队角色白名单。 +var AllowedTeamMemberRoles = map[string]bool{ + "admin": true, + "member": true, +} + +// CreateTeam 创建团队(调用方负责填充时间戳)。 +func CreateTeam(t *Team) error { + now := time.Now().Unix() + t.CreatedAt = now + t.UpdatedAt = now + return DB.Create(t).Error +} + +// GetTeamById 按 id 获取团队。 +func GetTeamById(id int64) (*Team, error) { + var t Team + err := DB.Where("id = ?", id).First(&t).Error + if err != nil { + return nil, err + } + return &t, nil +} + +// ListTeams 分页列出团队;keyword 为空时不过滤(按名称模糊匹配)。 +func ListTeams(page, pageSize int, keyword string) ([]*Team, int64, error) { + var items []*Team + var total int64 + q := DB.Model(&Team{}) + if strings.TrimSpace(keyword) != "" { + q = q.Where("name LIKE ?", "%"+keyword+"%") + } + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + offset := (page - 1) * pageSize + if err := q.Order("id DESC").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +// UpdateTeam 更新团队可编辑字段(Name 唯一键不可变概念上,此处允许改名)。 +func UpdateTeam(t *Team) error { + updates := map[string]interface{}{ + "description": t.Description, + "owner_id": t.OwnerId, + "updated_at": time.Now().Unix(), + } + return DB.Model(&Team{}).Where("id = ?", t.Id).Updates(updates).Error +} + +// DeleteTeam 删除团队并级联删除其成员与项目。 +func DeleteTeam(id int64) error { + return DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("team_id = ?", id).Delete(&TeamMember{}).Error; err != nil { + return err + } + if err := tx.Where("team_id = ?", id).Delete(&TeamProject{}).Error; err != nil { + return err + } + return tx.Where("id = ?", id).Delete(&Team{}).Error + }) +} + +// CreateTeamMember 添加团队成员。 +func CreateTeamMember(m *TeamMember) error { + m.CreatedAt = time.Now().Unix() + return DB.Create(m).Error +} + +// ListTeamMembers 分页列出团队成员。 +func ListTeamMembers(teamId int64, page, pageSize int) ([]*TeamMember, int64, error) { + var items []*TeamMember + var total int64 + q := DB.Model(&TeamMember{}).Where("team_id = ?", teamId) + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + offset := (page - 1) * pageSize + if err := q.Order("id ASC").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +// DeleteTeamMember 按 team + user 移除成员。 +func DeleteTeamMember(teamId, userId int64) error { + return DB.Where("team_id = ? AND user_id = ?", teamId, userId).Delete(&TeamMember{}).Error +} + +// CreateTeamProject 添加团队项目。 +func CreateTeamProject(p *TeamProject) error { + now := time.Now().Unix() + p.CreatedAt = now + p.UpdatedAt = now + return DB.Create(p).Error +} + +// ListTeamProjects 列出团队全部项目。 +func ListTeamProjects(teamId int64) ([]*TeamProject, error) { + var items []*TeamProject + err := DB.Where("team_id = ?", teamId).Order("id ASC").Find(&items).Error + if err != nil { + return nil, err + } + return items, nil +} + +// DeleteTeamProject 删除团队项目。 +func DeleteTeamProject(teamId, projectId int64) error { + return DB.Where("team_id = ? AND id = ?", teamId, projectId).Delete(&TeamProject{}).Error +} + +// TeamBilling 部门账单汇总:成员额度近似 + 团队实际用量(按 team_id 聚合消耗日志)。 +type TeamBilling struct { + TeamId int64 `json:"team_id"` + MemberCount int64 `json:"member_count"` + Allocated int64 `json:"allocated"` // 成员额度总和 + Used int64 `json:"used"` // 成员已用额度总和 + UsageQuota int64 `json:"usage_quota"` // 团队实际消耗配额(来自消耗日志,按 team_id 聚合) + PromptTokens int64 `json:"prompt_tokens"` // 团队实际 prompt tokens + CompletionTokens int64 `json:"completion_tokens"` // 团队实际 completion tokens + RequestCount int64 `json:"request_count"` // 团队实际请求数 +} + +// GetTeamBilling 汇总团队成员的额度与用量。 +func GetTeamBilling(teamId int64) (*TeamBilling, error) { + var memberIds []int64 + if err := DB.Model(&TeamMember{}).Where("team_id = ?", teamId).Pluck("user_id", &memberIds).Error; err != nil { + return nil, err + } + billing := &TeamBilling{TeamId: teamId, MemberCount: int64(len(memberIds))} + if len(memberIds) == 0 { + return billing, nil + } + var allocated, used int64 + if err := DB.Model(&User{}). + Where("id IN ?", memberIds). + Select("COALESCE(SUM(quota),0), COALESCE(SUM(used_quota),0)"). + Row().Scan(&allocated, &used); err != nil { + return nil, err + } + billing.Allocated = allocated + billing.Used = used + + // 实际用量:按 team_id 聚合消耗日志(LOG_DB 未启用时跳过,避免空指针) + if LOG_DB != nil { + var usageQuota, promptTokens, completionTokens, requestCount int64 + if err := LOG_DB.Table("logs"). + Select("COALESCE(SUM(quota),0), COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), COUNT(*)"). + Where("team_id = ? AND type = ?", teamId, LogTypeConsume). + Row().Scan(&usageQuota, &promptTokens, &completionTokens, &requestCount); err != nil { + return nil, err + } + billing.UsageQuota = usageQuota + billing.PromptTokens = promptTokens + billing.CompletionTokens = completionTokens + billing.RequestCount = requestCount + } + + return billing, nil +} diff --git a/model/user.go b/model/user.go index b25de5e75efa..2c83d0f1a8cf 100644 --- a/model/user.go +++ b/model/user.go @@ -101,6 +101,8 @@ type User struct { AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度 AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度 InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` + TeamId int64 `json:"team_id" gorm:"type:bigint;column:team_id;index"` // 所属企业团队(部门账单聚合) + RegionPreference string `json:"region_preference" gorm:"type:varchar(16);column:region_preference;default:''"` // 区域路由偏好(用户级),空表示不限定,交由 X-Region / 请求上下文决定 DeletedAt gorm.DeletedAt `gorm:"index"` LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` Setting string `json:"setting" gorm:"type:text;column:setting"` @@ -114,16 +116,18 @@ type User struct { func (user *User) ToBaseUser() *UserBase { cache := &UserBase{ - Id: user.Id, - Group: user.Group, - Quota: user.Quota, - Status: user.Status, - Role: user.Role, - Username: user.Username, - Setting: user.Setting, - Email: user.Email, - AuthVersion: user.AuthVersion, - CacheSchema: userCacheSchemaVersion, + Id: user.Id, + Group: user.Group, + Quota: user.Quota, + Status: user.Status, + Role: user.Role, + Username: user.Username, + Setting: user.Setting, + Email: user.Email, + RegionPreference: user.RegionPreference, + TeamId: user.TeamId, + AuthVersion: user.AuthVersion, + CacheSchema: userCacheSchemaVersion, } return cache } @@ -1418,3 +1422,20 @@ func RootUserExists() bool { } return true } + +// GetUsersByInviterId 列出由指定邀请人直接发展的下级用户(分销商下级用户查询)。 +func GetUsersByInviterId(inviterId int, page, pageSize int) ([]*User, int64, error) { + var items []*User + var total int64 + q := DB.Model(&User{}).Where("inviter_id = ?", inviterId) + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + offset := (page - 1) * pageSize + if err := q.Order("id DESC").Offset(offset).Limit(pageSize). + Select("id, username, email, quota, used_quota, status, group, created_at, inviter_id, team_id"). + Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} diff --git a/model/user_cache.go b/model/user_cache.go index aa72fc26ae5f..10b8d5cc0caf 100644 --- a/model/user_cache.go +++ b/model/user_cache.go @@ -11,19 +11,21 @@ import ( "github.com/gin-gonic/gin" ) -const userCacheSchemaVersion = 2 +const userCacheSchemaVersion = 3 type UserBase struct { - Id int `json:"id"` - Group string `json:"group"` - Email string `json:"email"` - Quota int `json:"quota"` - Status int `json:"status"` - Role int `json:"role"` - Username string `json:"username"` - Setting string `json:"setting"` - AuthVersion int64 `json:"-"` - CacheSchema int `json:"-"` + Id int `json:"id"` + Group string `json:"group"` + Email string `json:"email"` + Quota int `json:"quota"` + Status int `json:"status"` + Role int `json:"role"` + Username string `json:"username"` + Setting string `json:"setting"` + RegionPreference string `json:"region_preference"` + TeamId int64 `json:"team_id"` + AuthVersion int64 `json:"-"` + CacheSchema int `json:"-"` } func (user *UserBase) WriteContext(c *gin.Context) { @@ -33,6 +35,8 @@ func (user *UserBase) WriteContext(c *gin.Context) { common.SetContextKey(c, constant.ContextKeyUserEmail, user.Email) common.SetContextKey(c, constant.ContextKeyUserName, user.Username) common.SetContextKey(c, constant.ContextKeyUserSetting, user.GetSetting()) + common.SetContextKey(c, constant.ContextKeyUserRegionPreference, user.RegionPreference) + common.SetContextKey(c, constant.ContextKeyUserTeamId, user.TeamId) } func (user *UserBase) GetSetting() dto.UserSetting { diff --git a/router/api-router.go b/router/api-router.go index 80fd65178c44..1da47e43b850 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -22,6 +22,24 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/setup", controller.GetSetup) apiRouter.POST("/setup", anonymousRequestBodyLimit, controller.PostSetup) apiRouter.GET("/status", controller.GetStatus) + // 营销站公开接口(无需登录,复用全局限流 + 关键接口关键限流) + publicRouter := apiRouter.Group("/public") + { + publicRouter.GET("/site-config", controller.GetPublicSiteConfig) + publicRouter.GET("/pricing", controller.GetPublicPricing) + publicRouter.GET("/model-categories", controller.GetPublicModelCategories) + publicRouter.POST("/sales-lead", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.PostPublicSalesLead) + } + // 模型商店后台管理(管理员,需 AdminAuth) + marketModelRoute := apiRouter.Group("/admin/market-models") + marketModelRoute.Use(middleware.AdminAuth()) + marketModelRoute.GET("/", controller.ListMarketModels) + marketModelRoute.GET("/:id", controller.GetMarketModel) + marketModelRoute.POST("/", controller.CreateMarketModel) + marketModelRoute.PUT("/:id", controller.UpdateMarketModel) + marketModelRoute.DELETE("/:id", controller.DeleteMarketModel) + // 模型商店公开读取(门店展示,仅已上架) + apiRouter.GET("/market-models", controller.GetPublicMarketModels) apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) @@ -233,6 +251,10 @@ func SetApiRouter(router *gin.Engine) { } registerChannelRoutes(apiRouter) registerAuthzRoutes(apiRouter) + registerTeamRoutes(apiRouter) + registerSlaRoutes(apiRouter) + registerRegionRouteRoutes(apiRouter) + registerDistributorRoutes(apiRouter) tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) { diff --git a/router/p2-router.go b/router/p2-router.go new file mode 100644 index 000000000000..3f7773f664a7 --- /dev/null +++ b/router/p2-router.go @@ -0,0 +1,74 @@ +package router + +import ( + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + "github.com/gin-gonic/gin" +) + +// P2 平台化能力:四个功能的管理端路由 + SLA 公开状态页。 +// 前端页面与区域路由真实选渠道逻辑不在本轮范围。 + +func registerTeamRoutes(apiRouter *gin.RouterGroup) { + r := apiRouter.Group("/admin/teams") + r.Use(middleware.AdminAuth()) + { + r.GET("", controller.ListTeams) + r.POST("", controller.CreateTeam) + r.GET("/:id", controller.GetTeam) + r.PUT("/:id", controller.UpdateTeam) + r.DELETE("/:id", controller.DeleteTeam) + r.POST("/:id/members", controller.AddTeamMember) + r.GET("/:id/members", controller.ListTeamMembers) + r.DELETE("/:id/members/:user_id", controller.RemoveTeamMember) + r.POST("/:id/projects", controller.AddTeamProject) + r.GET("/:id/projects", controller.ListTeamProjects) + r.DELETE("/:id/projects/:pid", controller.RemoveTeamProject) + r.GET("/:id/billing", controller.GetTeamBilling) + } +} + +func registerSlaRoutes(apiRouter *gin.RouterGroup) { + admin := apiRouter.Group("/admin/sla-incidents") + admin.Use(middleware.AdminAuth()) + { + admin.GET("", controller.ListSlaIncidents) + admin.POST("", controller.CreateSlaIncident) + admin.GET("/:id", controller.GetSlaIncident) + admin.PUT("/:id", controller.UpdateSlaIncident) + admin.DELETE("/:id", controller.DeleteSlaIncident) + } + // 公开匿名:状态页事件列表与服务状态摘要。 + apiRouter.GET("/sla/incidents", controller.GetPublicSlaIncidents) + apiRouter.GET("/sla/status", controller.GetPublicSlaStatus) +} + +func registerRegionRouteRoutes(apiRouter *gin.RouterGroup) { + r := apiRouter.Group("/admin/region-routes") + r.Use(middleware.AdminAuth()) + { + r.GET("", controller.ListRegionRoutes) + r.POST("", controller.CreateRegionRoute) + r.GET("/:id", controller.GetRegionRoute) + r.PUT("/:id", controller.UpdateRegionRoute) + r.DELETE("/:id", controller.DeleteRegionRoute) + } +} + +func registerDistributorRoutes(apiRouter *gin.RouterGroup) { + r := apiRouter.Group("/admin/distributors") + r.Use(middleware.AdminAuth()) + { + r.GET("", controller.ListDistributors) + r.POST("", controller.CreateDistributor) + r.GET("/:id", controller.GetDistributor) + r.PUT("/:id", controller.UpdateDistributor) + r.DELETE("/:id", controller.DeleteDistributor) + r.GET("/:id/sub-users", controller.ListDistributorSubUsers) + r.GET("/:id/billing", controller.GetDistributorBilling) + r.GET("/:id/prices", controller.ListDistributorPrices) + r.POST("/:id/prices", controller.CreateDistributorPrice) + r.PUT("/:id/prices/:price_id", controller.UpdateDistributorPrice) + r.DELETE("/:id/prices/:price_id", controller.DeleteDistributorPrice) + } +} diff --git a/service/channel_select.go b/service/channel_select.go index 24c4e252bfb3..5fc96dca9944 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -87,6 +87,14 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, selectGroup := param.TokenGroup userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup) + // 区域路由:命中策略时收窄候选渠道并按策略排序,未命中时保持原有选渠道行为 + routing := model.ResolveRegionRouting(detectRegion(param.Ctx), param.ModelName) + if routing.Active { + common.SetContextKey(param.Ctx, constant.ContextKeyRequestRegion, routing.Region) + logger.LogDebug(param.Ctx, "region routing hit: region=%s, strategy=%s, allowed channels=%v", + routing.Region, routing.Strategy, routing.AllowedIds) + } + if param.TokenGroup == "auto" { if len(setting.GetAutoGroups()) == 0 { return nil, selectGroup, errors.New("auto groups is not enabled") @@ -116,7 +124,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, } logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry) - channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.RequestPath) + channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.RequestPath, routing) if channel == nil { // Current group has no available channel for this model, try next group // 当前分组没有该模型的可用渠道,尝试下一个分组 @@ -154,10 +162,27 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, break } } else { - channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath) + channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath, routing) if err != nil { return nil, param.TokenGroup, err } } return channel, selectGroup, nil } + +// detectRegion 解析当前请求的区域标识: +// 优先取请求头 X-Region,其次取上下文中已解析过的区域(重试时复用), +// 最后退回已登录用户的区域偏好(User.RegionPreference)。 +// 三者皆空时返回 "",由调用方保持原有选渠道行为。 +func detectRegion(ctx *gin.Context) string { + if ctx == nil { + return "" + } + if region := model.NormalizeRegion(ctx.GetHeader(constant.HeaderRegion)); region != "" { + return region + } + if region := model.NormalizeRegion(common.GetContextKeyString(ctx, constant.ContextKeyRequestRegion)); region != "" { + return region + } + return model.NormalizeRegion(common.GetContextKeyString(ctx, constant.ContextKeyUserRegionPreference)) +} diff --git a/service/channel_select_test.go b/service/channel_select_test.go new file mode 100644 index 000000000000..e41b172c0464 --- /dev/null +++ b/service/channel_select_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/gin-gonic/gin" +) + +// TestDetectRegionFallbackToUserPreference 验证区域解析的优先级: +// X-Region 请求头 > 请求上下文区域 > 用户区域偏好 > 空。 +func TestDetectRegionFallbackToUserPreference(t *testing.T) { + // 仅设置用户区域偏好 + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c.Set(string(constant.ContextKeyUserRegionPreference), "eu") + if got := detectRegion(c); got != "eu" { + t.Fatalf("expected user preference 'eu', got %q", got) + } + + // 请求头优先于用户偏好 + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + c2.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c2.Request.Header.Set(constant.HeaderRegion, "us") + c2.Set(string(constant.ContextKeyUserRegionPreference), "eu") + if got := detectRegion(c2); got != "us" { + t.Fatalf("expected header 'us' to win, got %q", got) + } + + // 请求上下文区域优先于用户偏好,但让位于请求头 + w3 := httptest.NewRecorder() + c3, _ := gin.CreateTestContext(w3) + c3.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c3.Set(string(constant.ContextKeyRequestRegion), "cn") + c3.Set(string(constant.ContextKeyUserRegionPreference), "eu") + if got := detectRegion(c3); got != "cn" { + t.Fatalf("expected request-region 'cn' to win over preference, got %q", got) + } + + // 全部为空时返回空,保持原有选渠道行为 + w4 := httptest.NewRecorder() + c4, _ := gin.CreateTestContext(w4) + c4.Request = httptest.NewRequest(http.MethodGet, "/", nil) + if got := detectRegion(c4); got != "" { + t.Fatalf("expected empty region, got %q", got) + } + + // 用户偏好被归一化(去空格、转小写、截断超长) + w5 := httptest.NewRecorder() + c5, _ := gin.CreateTestContext(w5) + c5.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c5.Set(string(constant.ContextKeyUserRegionPreference), " EU ") + if got := detectRegion(c5); got != "eu" { + t.Fatalf("expected normalized 'eu', got %q", got) + } +} diff --git a/web/index.html b/web/index.html index 7f280b401afd..e35dcb37c7f3 100644 --- a/web/index.html +++ b/web/index.html @@ -2,16 +2,16 @@ - + - New API - + 元点流商 OriginFlow + diff --git a/web/public/favicon.ico b/web/public/favicon.ico index ab5f17bcdb35..2c09d864c5e5 100644 Binary files a/web/public/favicon.ico and b/web/public/favicon.ico differ diff --git a/web/public/logo.webp b/web/public/logo.webp new file mode 100644 index 000000000000..713a17078029 Binary files /dev/null and b/web/public/logo.webp differ diff --git a/web/src/components/layout/components/footer.tsx b/web/src/components/layout/components/footer.tsx index 438fc6cfe482..f7acd2dccec7 100644 --- a/web/src/components/layout/components/footer.tsx +++ b/web/src/components/layout/components/footer.tsx @@ -158,7 +158,7 @@ export function Footer(props: FooterProps) { demoSiteEnabled, } = useSystemConfig() - const displayLogo = systemLogo || props.logo || '/logo.png' + const displayLogo = systemLogo || props.logo || '/logo.webp' const displayName = systemName || props.name || 'New API' const isDemoSiteMode = Boolean(demoSiteEnabled) const currentYear = new Date().getFullYear() diff --git a/web/src/features/distributors/api.ts b/web/src/features/distributors/api.ts new file mode 100644 index 000000000000..6918c08c9d52 --- /dev/null +++ b/web/src/features/distributors/api.ts @@ -0,0 +1,142 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { api } from '@/lib/api' + +import type { + ApiResponse, + Distributor, + DistributorBilling, + DistributorFormData, + DistributorPrice, + DistributorPriceFormData, + DistributorSubUser, + DistributorUpdateData, + ListDistributorsParams, +} from './types' + +// ============================================================================ +// Distributor Management +// ============================================================================ + +export async function listDistributors( + params: ListDistributorsParams = {} +): Promise> { + const queryParams = new URLSearchParams() + queryParams.set('page', String(params.page ?? 1)) + queryParams.set('page_size', String(params.page_size ?? 10)) + if (params.keyword) queryParams.set('keyword', params.keyword) + const res = await api.get(`/api/admin/distributors?${queryParams.toString()}`) + return res.data +} + +export async function getDistributor( + id: number +): Promise> { + const res = await api.get(`/api/admin/distributors/${id}`) + return res.data +} + +export async function createDistributor( + data: DistributorFormData +): Promise> { + const res = await api.post('/api/admin/distributors', data) + return res.data +} + +// Update distributor (full replace; user_id is immutable) +export async function updateDistributor( + id: number, + data: DistributorUpdateData +): Promise> { + const res = await api.put(`/api/admin/distributors/${id}`, data) + return res.data +} + +export async function deleteDistributor( + id: number +): Promise> { + const res = await api.delete(`/api/admin/distributors/${id}`) + return res.data +} + +// ============================================================================ +// Distributor Sub-Users (read-only) +// ============================================================================ + +export async function listDistributorSubUsers( + id: number, + params: { page?: number; page_size?: number } = {} +): Promise> { + const queryParams = new URLSearchParams() + queryParams.set('page', String(params.page ?? 1)) + queryParams.set('page_size', String(params.page_size ?? 10)) + const res = await api.get( + `/api/admin/distributors/${id}/sub-users?${queryParams.toString()}` + ) + return res.data +} + +// ============================================================================ +// Distributor Billing (read-only) +// ============================================================================ + +export async function getDistributorBilling( + id: number +): Promise> { + const res = await api.get(`/api/admin/distributors/${id}/billing`) + return res.data +} + +// ============================================================================ +// Distributor Price Overrides +// ============================================================================ + +// Returns all price overrides for a distributor (no pagination server-side) +export async function listDistributorPrices( + id: number +): Promise> { + const res = await api.get(`/api/admin/distributors/${id}/prices`) + return res.data +} + +export async function createDistributorPrice( + id: number, + data: DistributorPriceFormData +): Promise> { + const res = await api.post(`/api/admin/distributors/${id}/prices`, data) + return res.data +} + +export async function updateDistributorPrice( + id: number, + priceId: number, + data: DistributorPriceFormData +): Promise> { + const res = await api.put( + `/api/admin/distributors/${id}/prices/${priceId}`, + data + ) + return res.data +} + +export async function deleteDistributorPrice( + id: number, + priceId: number +): Promise> { + const res = await api.delete(`/api/admin/distributors/${id}/prices/${priceId}`) + return res.data +} diff --git a/web/src/features/distributors/components/data-table-row-actions.tsx b/web/src/features/distributors/components/data-table-row-actions.tsx new file mode 100644 index 000000000000..817a5096fcc6 --- /dev/null +++ b/web/src/features/distributors/components/data-table-row-actions.tsx @@ -0,0 +1,100 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { Link } from '@tanstack/react-router' +import type { Row } from '@tanstack/react-table' +import { Edit, ExternalLink, Trash2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu' +import { Button } from '@/components/ui/button' +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, +} from '@/components/ui/dropdown-menu' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' + +import { distributorSchema } from '../types' +import { useDistributors } from './distributors-provider' + +interface DataTableRowActionsProps { + row: Row +} + +export function DataTableRowActions({ + row, +}: DataTableRowActionsProps) { + const { t } = useTranslation() + const distributor = distributorSchema.parse(row.original) + const { setOpen, setCurrentRow } = useDistributors() + + return ( +
+ + { + setCurrentRow(distributor) + setOpen('update') + }} + aria-label={t('Edit')} + /> + } + > + + + {t('Edit')} + + + + + } + > + {t('View Details')} + + + + + + { + setCurrentRow(distributor) + setOpen('delete') + }} + className='text-destructive focus:text-destructive' + > + {t('Delete')} + + + + + +
+ ) +} diff --git a/web/src/features/distributors/components/distributor-billing-tab.tsx b/web/src/features/distributors/components/distributor-billing-tab.tsx new file mode 100644 index 000000000000..9447d7e28102 --- /dev/null +++ b/web/src/features/distributors/components/distributor-billing-tab.tsx @@ -0,0 +1,65 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +import { getDistributorBilling } from '../api' +import { ERROR_MESSAGES } from '../constants' + +export function DistributorBillingTab({ + distributorId, +}: { + distributorId: number +}) { + const { t } = useTranslation() + + const { data, isLoading } = useQuery({ + queryKey: ['distributor-billing', distributorId], + queryFn: async () => { + const result = await getDistributorBilling(distributorId) + if (!result.success) { + toast.error(result.message || t(ERROR_MESSAGES.LOAD_BILLING_FAILED)) + return null + } + return result.data ?? null + }, + }) + + const cards = [ + { title: t('Sub-User Count'), value: data?.sub_user_count ?? 0 }, + { title: t('Allocated Quota'), value: data?.allocated ?? 0 }, + { title: t('Used Quota'), value: data?.used ?? 0 }, + ] + + return ( +
+ {cards.map((card) => ( + + + {card.title} + + + {isLoading ? '…' : card.value} + + + ))} +
+ ) +} diff --git a/web/src/features/distributors/components/distributor-detail.tsx b/web/src/features/distributors/components/distributor-detail.tsx new file mode 100644 index 000000000000..e607cf81345a --- /dev/null +++ b/web/src/features/distributors/components/distributor-detail.tsx @@ -0,0 +1,102 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { useQuery } from '@tanstack/react-query' +import { Link, getRouteApi } from '@tanstack/react-router' +import { ArrowLeft } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { SectionPageLayout } from '@/components/layout' +import { StatusBadge } from '@/components/status-badge' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' + +import { getDistributor } from '../api' +import { DISTRIBUTOR_STATUS_CONFIG, DISTRIBUTOR_TIER_CONFIG } from '../constants' +import { DistributorBillingTab } from './distributor-billing-tab' +import { DistributorPricesTab } from './distributor-prices-tab' +import { DistributorSubUsersTab } from './distributor-sub-users-tab' + +const route = getRouteApi('/_authenticated/distributors/$distributorId/') + +export function DistributorDetail() { + const { t } = useTranslation() + const { distributorId } = route.useParams() + const id = Number(distributorId) + + const { data: distributor } = useQuery({ + queryKey: ['distributor', id], + queryFn: async () => { + const result = await getDistributor(id) + return result.success ? (result.data ?? null) : null + }, + }) + + const tierConfig = distributor + ? DISTRIBUTOR_TIER_CONFIG[distributor.tier] + : undefined + const statusConfig = distributor + ? DISTRIBUTOR_STATUS_CONFIG[distributor.status] + : undefined + + return ( + + + + {distributor?.name ?? t('Distributor')} + {tierConfig && ( + + )} + {statusConfig && ( + + )} + + + + + + + + + {t('Price Overrides')} + {t('Sub-Users')} + {t('Billing')} + + + + + + + + + + + + + + ) +} diff --git a/web/src/features/distributors/components/distributor-price-mutate-drawer.tsx b/web/src/features/distributors/components/distributor-price-mutate-drawer.tsx new file mode 100644 index 000000000000..0bc23600acfd --- /dev/null +++ b/web/src/features/distributors/components/distributor-price-mutate-drawer.tsx @@ -0,0 +1,312 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { zodResolver } from '@hookform/resolvers/zod' +import { type FormEvent, useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { + SideDrawerSection, + sideDrawerContentClassName, + sideDrawerFooterClassName, + sideDrawerFormClassName, + sideDrawerHeaderClassName, +} from '@/components/drawer-layout' +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' + +import { createDistributorPrice, updateDistributorPrice } from '../api' +import { + SUCCESS_MESSAGES, + getDistributorPriceCurrencyOptions, + getDistributorPriceUnitOptions, +} from '../constants' +import { + DISTRIBUTOR_PRICE_FORM_DEFAULT_VALUES, + type DistributorPriceFormValues, + getDistributorPriceFormSchema, + transformPriceFormDataToPayload, + transformPriceToFormDefaults, +} from '../lib' +import type { DistributorPrice } from '../types' + +type DistributorPriceMutateDrawerProps = { + distributorId: number + open: boolean + onOpenChange: (open: boolean) => void + currentRow?: DistributorPrice + onSaved: () => void +} + +export function DistributorPriceMutateDrawer({ + distributorId, + open, + onOpenChange, + currentRow, + onSaved, +}: DistributorPriceMutateDrawerProps) { + const { t } = useTranslation() + const isUpdate = !!currentRow + const [isSubmitting, setIsSubmitting] = useState(false) + + const form = useForm({ + resolver: zodResolver(getDistributorPriceFormSchema(t)), + defaultValues: DISTRIBUTOR_PRICE_FORM_DEFAULT_VALUES, + }) + + useEffect(() => { + if (open && isUpdate && currentRow) { + form.reset(transformPriceToFormDefaults(currentRow)) + } else if (open && !isUpdate) { + form.reset(DISTRIBUTOR_PRICE_FORM_DEFAULT_VALUES) + } + }, [open, isUpdate, currentRow, form]) + + const onSubmit = async (data: DistributorPriceFormValues) => { + setIsSubmitting(true) + try { + const payload = transformPriceFormDataToPayload(data) + if (isUpdate && currentRow) { + const result = await updateDistributorPrice( + distributorId, + currentRow.id, + payload + ) + if (result.success) { + toast.success(t(SUCCESS_MESSAGES.PRICE_UPDATED)) + onOpenChange(false) + onSaved() + } + } else { + const result = await createDistributorPrice(distributorId, payload) + if (result.success) { + toast.success(t(SUCCESS_MESSAGES.PRICE_CREATED)) + onOpenChange(false) + onSaved() + } + } + } finally { + setIsSubmitting(false) + } + } + + const handleSubmit = (event: FormEvent) => { + void form.handleSubmit(onSubmit)(event) + } + + const currencyOptions = getDistributorPriceCurrencyOptions() + const unitOptions = getDistributorPriceUnitOptions(t) + + return ( + { + onOpenChange(v) + if (!v) { + form.reset() + } + }} + > + + + + {isUpdate + ? t('Update Price Override') + : t('Create Price Override')} + + + {t('Set the resale price for a model for this distributor.')}{' '} + {t('Click save when you are done.')} + + +
+ + + ( + + {t('Model')} + + + + + + )} + /> + +
+ ( + + {t('Input Price')} + + + field.onChange( + Number.parseInt(e.target.value, 10) || 0 + ) + } + /> + + + + )} + /> + + ( + + {t('Output Price')} + + + field.onChange( + Number.parseInt(e.target.value, 10) || 0 + ) + } + /> + + + + )} + /> +
+ + ( + + {t('Currency')} + + + {t('Prices are in the smallest currency unit per 1M')} + + + + )} + /> + + ( + + {t('Unit')} + + + + )} + /> +
+
+ + + }> + {t('Close')} + + + +
+
+ ) +} diff --git a/web/src/features/distributors/components/distributor-prices-tab.tsx b/web/src/features/distributors/components/distributor-prices-tab.tsx new file mode 100644 index 000000000000..d4902a3146bc --- /dev/null +++ b/web/src/features/distributors/components/distributor-prices-tab.tsx @@ -0,0 +1,221 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { useQuery } from '@tanstack/react-query' +import { Edit, Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' + +import { + deleteDistributorPrice, + listDistributorPrices, +} from '../api' +import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' +import type { DistributorPrice } from '../types' +import { DistributorPriceMutateDrawer } from './distributor-price-mutate-drawer' + +export function DistributorPricesTab({ + distributorId, +}: { + distributorId: number +}) { + const { t } = useTranslation() + const [refreshTrigger, setRefreshTrigger] = useState(0) + const [drawerOpen, setDrawerOpen] = useState(false) + const [editingPrice, setEditingPrice] = useState( + null + ) + const [deletingPrice, setDeletingPrice] = useState( + null + ) + const [isDeleting, setIsDeleting] = useState(false) + + const triggerRefresh = () => setRefreshTrigger((prev) => prev + 1) + + const { data: prices, isLoading } = useQuery({ + queryKey: ['distributor-prices', distributorId, refreshTrigger], + queryFn: async () => { + const result = await listDistributorPrices(distributorId) + if (!result.success) { + toast.error(result.message || t(ERROR_MESSAGES.LOAD_PRICES_FAILED)) + return [] + } + return result.data?.items ?? [] + }, + }) + + const handleDelete = async () => { + if (!deletingPrice) return + setIsDeleting(true) + try { + const result = await deleteDistributorPrice( + distributorId, + deletingPrice.id + ) + if (result.success) { + toast.success(t(SUCCESS_MESSAGES.PRICE_DELETED)) + setDeletingPrice(null) + triggerRefresh() + } + } finally { + setIsDeleting(false) + } + } + + return ( +
+
+ +
+ +
+ + + + {t('Model')} + {t('Input Price')} + {t('Output Price')} + {t('Currency')} + {t('Unit')} + {t('Actions')} + + + + {isLoading && ( + + + {t('Loading...')} + + + )} + {!isLoading && (prices?.length ?? 0) === 0 && ( + + + {t('No price overrides configured')} + + + )} + {(prices ?? []).map((price) => ( + + + {price.model} + + + {price.input_price} + + + {price.output_price} + + {price.currency} + {t(price.unit)} + +
+ + +
+
+
+ ))} +
+
+
+ + + + !open && setDeletingPrice(null)} + > + + + {t('Are you sure?')} + + {t('This will permanently delete the price override for')}{' '} + {deletingPrice?.model} + {t('. This action cannot be undone.')} + + + + + {t('Cancel')} + + + {isDeleting ? t('Deleting...') : t('Delete')} + + + + +
+ ) +} diff --git a/web/src/features/distributors/components/distributor-sub-users-tab.tsx b/web/src/features/distributors/components/distributor-sub-users-tab.tsx new file mode 100644 index 000000000000..02cc05ba3d0d --- /dev/null +++ b/web/src/features/distributors/components/distributor-sub-users-tab.tsx @@ -0,0 +1,147 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { formatTimestampToDate } from '@/lib/format' + +import { listDistributorSubUsers } from '../api' +import { ERROR_MESSAGES } from '../constants' + +const PAGE_SIZE = 20 + +export function DistributorSubUsersTab({ + distributorId, +}: { + distributorId: number +}) { + const { t } = useTranslation() + const [page, setPage] = useState(1) + + const { data, isLoading } = useQuery({ + queryKey: ['distributor-sub-users', distributorId, page], + queryFn: async () => { + const result = await listDistributorSubUsers(distributorId, { + page, + page_size: PAGE_SIZE, + }) + if (!result.success) { + toast.error(result.message || t(ERROR_MESSAGES.LOAD_SUB_USERS_FAILED)) + return { items: [], total: 0 } + } + return { + items: result.data?.items ?? [], + total: result.data?.total ?? 0, + } + }, + placeholderData: (previousData) => previousData, + }) + + const items = data?.items ?? [] + const total = data?.total ?? 0 + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + return ( +
+
+ + + + {t('ID')} + {t('Username')} + {t('Email')} + {t('Quota')} + {t('Used Quota')} + {t('Created At')} + + + + {isLoading && ( + + + {t('Loading...')} + + + )} + {!isLoading && items.length === 0 && ( + + + {t('No sub-users found')} + + + )} + {items.map((user) => ( + + {user.id} + {user.username} + + {user.email || '-'} + + {user.quota} + + {user.used_quota} + + + {user.created_at > 0 + ? formatTimestampToDate(user.created_at) + : '-'} + + + ))} + +
+
+ + {total > PAGE_SIZE && ( +
+ + {t('Page')} {page} / {totalPages} + +
+ + +
+
+ )} +
+ ) +} diff --git a/web/src/features/distributors/components/distributors-columns.tsx b/web/src/features/distributors/components/distributors-columns.tsx new file mode 100644 index 000000000000..cf8978874437 --- /dev/null +++ b/web/src/features/distributors/components/distributors-columns.tsx @@ -0,0 +1,157 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { Link } from '@tanstack/react-router' +import type { ColumnDef } from '@tanstack/react-table' +import { useTranslation } from 'react-i18next' + +import { StatusBadge } from '@/components/status-badge' +import { TableId } from '@/components/table-id' +import { Checkbox } from '@/components/ui/checkbox' +import { formatTimestampToDate } from '@/lib/format' + +import { DISTRIBUTOR_STATUS_CONFIG, DISTRIBUTOR_TIER_CONFIG } from '../constants' +import type { Distributor } from '../types' +import { DataTableRowActions } from './data-table-row-actions' + +export function useDistributorsColumns(): ColumnDef[] { + const { t } = useTranslation() + return [ + { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={t('Select all')} + className='translate-y-[2px]' + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={t('Select row')} + className='translate-y-[2px]' + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: 'id', + header: t('ID'), + meta: { mobileHidden: true }, + cell: ({ row }) => ( + + ), + size: 80, + }, + { + accessorKey: 'name', + header: t('Name'), + meta: { mobileTitle: true }, + cell: ({ row }) => ( + + {row.getValue('name')} + + ), + size: 180, + }, + { + accessorKey: 'user_id', + header: t('Owner User ID'), + cell: ({ row }) => ( + {row.getValue('user_id') as number} + ), + size: 120, + }, + { + accessorKey: 'tier', + header: t('Tier'), + meta: { mobileBadge: true }, + cell: ({ row }) => { + const tier = row.getValue('tier') as string + const config = DISTRIBUTOR_TIER_CONFIG[tier] + if (!config) return {tier} + return ( + + ) + }, + size: 120, + }, + { + accessorKey: 'commission_rate', + header: t('Commission Rate'), + cell: ({ row }) => ( + + {row.getValue('commission_rate') as number}% + + ), + size: 140, + }, + { + accessorKey: 'status', + header: t('Status'), + meta: { mobileBadge: true }, + cell: ({ row }) => { + const status = row.getValue('status') as number + const config = DISTRIBUTOR_STATUS_CONFIG[status] + if (!config) return null + return ( + + ) + }, + size: 110, + }, + { + accessorKey: 'created_at', + header: t('Created At'), + meta: { mobileHidden: true }, + cell: ({ row }) => { + const createdAt = row.getValue('created_at') as number + return ( + + {createdAt > 0 ? formatTimestampToDate(createdAt) : '-'} + + ) + }, + size: 160, + }, + { + id: 'actions', + header: () => t('Actions'), + cell: ({ row }) => , + meta: { pinned: 'right' as const }, + }, + ] +} diff --git a/web/src/features/distributors/components/distributors-delete-dialog.tsx b/web/src/features/distributors/components/distributors-delete-dialog.tsx new file mode 100644 index 000000000000..2d10f5907760 --- /dev/null +++ b/web/src/features/distributors/components/distributors-delete-dialog.tsx @@ -0,0 +1,88 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' + +import { deleteDistributor } from '../api' +import { SUCCESS_MESSAGES } from '../constants' +import { useDistributors } from './distributors-provider' + +export function DistributorsDeleteDialog() { + const { t } = useTranslation() + const { open, setOpen, currentRow, triggerRefresh } = useDistributors() + const [isDeleting, setIsDeleting] = useState(false) + + const handleDelete = async () => { + if (!currentRow) return + + setIsDeleting(true) + try { + const result = await deleteDistributor(currentRow.id) + if (result.success) { + toast.success(t(SUCCESS_MESSAGES.DISTRIBUTOR_DELETED)) + setOpen(null) + triggerRefresh() + } + } finally { + setIsDeleting(false) + } + } + + return ( + !open && setOpen(null)} + > + + + {t('Are you sure?')} + + {t('This will permanently delete distributor')}{' '} + {currentRow?.name} + {t( + ' and all of its price overrides. This action cannot be undone.' + )} + + + + + {t('Cancel')} + + + {isDeleting ? t('Deleting...') : t('Delete')} + + + + + ) +} diff --git a/web/src/features/distributors/components/distributors-dialogs.tsx b/web/src/features/distributors/components/distributors-dialogs.tsx new file mode 100644 index 000000000000..ef26f55a110f --- /dev/null +++ b/web/src/features/distributors/components/distributors-dialogs.tsx @@ -0,0 +1,35 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { DistributorsDeleteDialog } from './distributors-delete-dialog' +import { DistributorsMutateDrawer } from './distributors-mutate-drawer' +import { useDistributors } from './distributors-provider' + +export function DistributorsDialogs() { + const { open, setOpen, currentRow } = useDistributors() + const isUpdate = open === 'update' + + return ( + <> + !isOpen && setOpen(null)} + currentRow={isUpdate ? currentRow || undefined : undefined} + /> + + + ) +} diff --git a/web/src/features/distributors/components/distributors-mobile-list.tsx b/web/src/features/distributors/components/distributors-mobile-list.tsx new file mode 100644 index 000000000000..adca113501da --- /dev/null +++ b/web/src/features/distributors/components/distributors-mobile-list.tsx @@ -0,0 +1,134 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import type { Table as TanstackTable } from '@tanstack/react-table' +import { Store } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { StatusBadge } from '@/components/status-badge' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty' +import { Skeleton } from '@/components/ui/skeleton' + +import { DISTRIBUTOR_STATUS_CONFIG, DISTRIBUTOR_TIER_CONFIG } from '../constants' +import type { Distributor } from '../types' +import { DataTableRowActions } from './data-table-row-actions' + +interface DistributorsMobileListProps { + table: TanstackTable + isLoading: boolean +} + +const MOBILE_SKELETON_KEYS = [ + 'distributor-mobile-skeleton-1', + 'distributor-mobile-skeleton-2', + 'distributor-mobile-skeleton-3', +] + +function DistributorsMobileSkeleton() { + return ( +
+ {MOBILE_SKELETON_KEYS.map((key) => ( +
+
+ + +
+ +
+ ))} +
+ ) +} + +export function DistributorsMobileList(props: DistributorsMobileListProps) { + const { t } = useTranslation() + const rows = props.table.getRowModel().rows + + if (props.isLoading) return + + if (!rows.length) { + return ( +
+ + + + + + {t('No Distributors Found')} + + {t( + 'No distributors available. Create your first distributor to get started.' + )} + + + +
+ ) + } + + return ( +
+ {rows.map((row) => { + const distributor = row.original + const tierConfig = DISTRIBUTOR_TIER_CONFIG[distributor.tier] + const statusConfig = DISTRIBUTOR_STATUS_CONFIG[distributor.status] + return ( +
+
+
+
+ {distributor.name} +
+
+ {t('Owner User ID')}: {distributor.user_id} +
+
+ {statusConfig && ( + + )} +
+ +
+ + {tierConfig ? t(tierConfig.labelKey) : distributor.tier} + + + {distributor.commission_rate}% + + +
+
+ ) + })} +
+ ) +} diff --git a/web/src/features/distributors/components/distributors-mutate-drawer.tsx b/web/src/features/distributors/components/distributors-mutate-drawer.tsx new file mode 100644 index 000000000000..a90865b8a7e9 --- /dev/null +++ b/web/src/features/distributors/components/distributors-mutate-drawer.tsx @@ -0,0 +1,311 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { zodResolver } from '@hookform/resolvers/zod' +import { type FormEvent, useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { + SideDrawerSection, + sideDrawerContentClassName, + sideDrawerFooterClassName, + sideDrawerFormClassName, + sideDrawerHeaderClassName, +} from '@/components/drawer-layout' +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' + +import { createDistributor, updateDistributor } from '../api' +import { + SUCCESS_MESSAGES, + getDistributorStatusOptions, + getDistributorTierOptions, +} from '../constants' +import { + DISTRIBUTOR_FORM_DEFAULT_VALUES, + type DistributorFormValues, + getDistributorFormSchema, + transformDistributorToFormDefaults, + transformFormDataToPayload, + transformFormDataToUpdatePayload, +} from '../lib' +import type { Distributor } from '../types' +import { useDistributors } from './distributors-provider' + +type DistributorsMutateDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + currentRow?: Distributor +} + +export function DistributorsMutateDrawer({ + open, + onOpenChange, + currentRow, +}: DistributorsMutateDrawerProps) { + const { t } = useTranslation() + const isUpdate = !!currentRow + const { triggerRefresh } = useDistributors() + const [isSubmitting, setIsSubmitting] = useState(false) + + const form = useForm({ + resolver: zodResolver(getDistributorFormSchema(t)), + defaultValues: DISTRIBUTOR_FORM_DEFAULT_VALUES, + }) + + useEffect(() => { + if (open && isUpdate && currentRow) { + form.reset(transformDistributorToFormDefaults(currentRow)) + } else if (open && !isUpdate) { + form.reset(DISTRIBUTOR_FORM_DEFAULT_VALUES) + } + }, [open, isUpdate, currentRow, form]) + + const onSubmit = async (data: DistributorFormValues) => { + setIsSubmitting(true) + try { + if (isUpdate && currentRow) { + const result = await updateDistributor( + currentRow.id, + transformFormDataToUpdatePayload(data) + ) + if (result.success) { + toast.success(t(SUCCESS_MESSAGES.DISTRIBUTOR_UPDATED)) + onOpenChange(false) + triggerRefresh() + } + } else { + const result = await createDistributor(transformFormDataToPayload(data)) + if (result.success) { + toast.success(t(SUCCESS_MESSAGES.DISTRIBUTOR_CREATED)) + onOpenChange(false) + triggerRefresh() + } + } + } finally { + setIsSubmitting(false) + } + } + + const handleSubmit = (event: FormEvent) => { + void form.handleSubmit(onSubmit)(event) + } + + const tierOptions = getDistributorTierOptions(t) + const statusOptions = getDistributorStatusOptions(t) + + return ( + { + onOpenChange(v) + if (!v) { + form.reset() + } + }} + > + + + + {isUpdate ? t('Update Distributor') : t('Create Distributor')} + + + {isUpdate + ? t('Update the distributor by providing necessary info.') + : t('Add a new distributor by providing necessary info.')}{' '} + {t('Click save when you are done.')} + + +
+ + + ( + + {t('Name')} + + + + + + )} + /> + + ( + + {t('Owner User ID')} + + + field.onChange( + Number.parseInt(e.target.value, 10) || 0 + ) + } + /> + + + {isUpdate + ? t('The owner account cannot be changed') + : t('The user account that manages this distributor')} + + + + )} + /> + + ( + + {t('Tier')} + + + + )} + /> + + ( + + {t('Commission Rate')} + + + field.onChange( + Number.parseInt(e.target.value, 10) || 0 + ) + } + /> + + + {t('Commission percentage (0-100)')} + + + + )} + /> + + ( + + {t('Status')} + + + + )} + /> + +
+ + + }> + {t('Close')} + + + +
+
+ ) +} diff --git a/web/src/features/distributors/components/distributors-primary-buttons.tsx b/web/src/features/distributors/components/distributors-primary-buttons.tsx new file mode 100644 index 000000000000..e098b7ad6fff --- /dev/null +++ b/web/src/features/distributors/components/distributors-primary-buttons.tsx @@ -0,0 +1,36 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { Plus } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { Button } from '@/components/ui/button' + +import { useDistributors } from './distributors-provider' + +export function DistributorsPrimaryButtons() { + const { t } = useTranslation() + const { setOpen } = useDistributors() + + return ( +
+ +
+ ) +} diff --git a/web/src/features/distributors/components/distributors-provider.tsx b/web/src/features/distributors/components/distributors-provider.tsx new file mode 100644 index 000000000000..7d219bd57385 --- /dev/null +++ b/web/src/features/distributors/components/distributors-provider.tsx @@ -0,0 +1,74 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import React, { useState } from 'react' + +import useDialogState from '@/hooks/use-dialog' + +import type { Distributor, DistributorsDialogType } from '../types' + +type DistributorsContextType = { + open: DistributorsDialogType | null + setOpen: (str: DistributorsDialogType | null) => void + currentRow: Distributor | null + setCurrentRow: React.Dispatch> + refreshTrigger: number + triggerRefresh: () => void +} + +const DistributorsContext = React.createContext( + null +) + +export function DistributorsProvider({ + children, +}: { + children: React.ReactNode +}) { + const [open, setOpen] = useDialogState(null) + const [currentRow, setCurrentRow] = useState(null) + const [refreshTrigger, setRefreshTrigger] = useState(0) + + const triggerRefresh = () => setRefreshTrigger((prev) => prev + 1) + + return ( + + {children} + + ) +} + +// eslint-disable-next-line react-refresh/only-export-components +export const useDistributors = () => { + const distributorsContext = React.useContext(DistributorsContext) + + if (!distributorsContext) { + throw new Error( + 'useDistributors has to be used within ' + ) + } + + return distributorsContext +} diff --git a/web/src/features/distributors/components/distributors-table.tsx b/web/src/features/distributors/components/distributors-table.tsx new file mode 100644 index 000000000000..60e800907e8c --- /dev/null +++ b/web/src/features/distributors/components/distributors-table.tsx @@ -0,0 +1,118 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { useQuery } from '@tanstack/react-query' +import { getRouteApi } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { DataTablePage, useDataTable } from '@/components/data-table' +import { useMediaQuery } from '@/hooks' +import { useTableUrlState } from '@/hooks/use-table-url-state' + +import { listDistributors } from '../api' +import { ERROR_MESSAGES } from '../constants' +import { useDistributorsColumns } from './distributors-columns' +import { DistributorsMobileList } from './distributors-mobile-list' +import { useDistributors } from './distributors-provider' + +const route = getRouteApi('/_authenticated/distributors/') + +export function DistributorsTable() { + const { t } = useTranslation() + const columns = useDistributorsColumns() + const { refreshTrigger } = useDistributors() + const isMobile = useMediaQuery('(max-width: 640px)') + + const { + globalFilter, + onGlobalFilterChange, + columnFilters, + onColumnFiltersChange, + pagination, + onPaginationChange, + ensurePageInRange, + } = useTableUrlState({ + search: route.useSearch(), + navigate: route.useNavigate(), + pagination: { defaultPage: 1, defaultPageSize: isMobile ? 10 : 20 }, + globalFilter: { enabled: true, key: 'filter' }, + columnFilters: [], + }) + + const { data, isLoading, isFetching } = useQuery({ + queryKey: [ + 'distributors', + pagination.pageIndex + 1, + pagination.pageSize, + globalFilter, + refreshTrigger, + ], + queryFn: async () => { + const result = await listDistributors({ + page: pagination.pageIndex + 1, + page_size: pagination.pageSize, + keyword: globalFilter?.trim() || undefined, + }) + if (!result.success) { + toast.error(result.message || t(ERROR_MESSAGES.LOAD_FAILED)) + return { items: [], total: 0 } + } + return { + items: result.data?.items || [], + total: result.data?.total || 0, + } + }, + placeholderData: (previousData) => previousData, + }) + + const distributors = data?.items || [] + + const { table } = useDataTable({ + data: distributors, + columns, + enableRowSelection: true, + columnFilters, + globalFilter, + pagination, + onPaginationChange, + onGlobalFilterChange, + onColumnFiltersChange, + manualPagination: true, + manualFiltering: true, + totalCount: data?.total || 0, + ensurePageInRange, + }) + + return ( + } + /> + ) +} diff --git a/web/src/features/distributors/constants.ts b/web/src/features/distributors/constants.ts new file mode 100644 index 000000000000..2c675e482bb8 --- /dev/null +++ b/web/src/features/distributors/constants.ts @@ -0,0 +1,144 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import type { TFunction } from 'i18next' + +import type { StatusBadgeProps } from '@/components/status-badge' + +// ============================================================================ +// Distributor Tier Configuration +// ============================================================================ + +export const DISTRIBUTOR_TIER = { + STANDARD: 'standard', + GOLD: 'gold', + PLATINUM: 'platinum', +} as const + +export const DISTRIBUTOR_TIER_CONFIG: Record< + string, + Pick & { labelKey: string; value: string } +> = { + [DISTRIBUTOR_TIER.STANDARD]: { + labelKey: 'Standard', + variant: 'neutral', + value: DISTRIBUTOR_TIER.STANDARD, + }, + [DISTRIBUTOR_TIER.GOLD]: { + labelKey: 'Gold', + variant: 'amber', + value: DISTRIBUTOR_TIER.GOLD, + }, + [DISTRIBUTOR_TIER.PLATINUM]: { + labelKey: 'Platinum', + variant: 'violet', + value: DISTRIBUTOR_TIER.PLATINUM, + }, +} + +export function getDistributorTierOptions(t: TFunction) { + return Object.values(DISTRIBUTOR_TIER_CONFIG).map((config) => ({ + label: t(config.labelKey), + value: config.value, + })) +} + +// ============================================================================ +// Distributor Status Configuration +// ============================================================================ + +export const DISTRIBUTOR_STATUS = { + ACTIVE: 1, + DISABLED: 2, +} as const + +export const DISTRIBUTOR_STATUS_CONFIG: Record< + number, + Pick & { labelKey: string; value: number } +> = { + [DISTRIBUTOR_STATUS.ACTIVE]: { + labelKey: 'Active', + variant: 'success', + value: DISTRIBUTOR_STATUS.ACTIVE, + }, + [DISTRIBUTOR_STATUS.DISABLED]: { + labelKey: 'Disabled', + variant: 'neutral', + value: DISTRIBUTOR_STATUS.DISABLED, + }, +} + +export function getDistributorStatusOptions(t: TFunction) { + return Object.values(DISTRIBUTOR_STATUS_CONFIG).map((config) => ({ + label: t(config.labelKey), + value: String(config.value), + })) +} + +// ============================================================================ +// Distributor Price Configuration +// ============================================================================ + +export const DISTRIBUTOR_PRICE_CURRENCIES = ['CNY', 'USD'] as const + +export const DISTRIBUTOR_PRICE_UNITS = [ + 'token', + 'image', + 'second', + 'char', +] as const + +export function getDistributorPriceCurrencyOptions() { + return DISTRIBUTOR_PRICE_CURRENCIES.map((currency) => ({ + label: currency, + value: currency, + })) +} + +export function getDistributorPriceUnitOptions(t: TFunction) { + return DISTRIBUTOR_PRICE_UNITS.map((unit) => ({ + label: t(unit), + value: unit, + })) +} + +// ============================================================================ +// Validation Constants +// ============================================================================ + +export const DISTRIBUTOR_NAME_MAX_LENGTH = 64 +export const DISTRIBUTOR_COMMISSION_RATE_MIN = 0 +export const DISTRIBUTOR_COMMISSION_RATE_MAX = 100 + +// ============================================================================ +// Error & Success Messages (i18n keys) +// ============================================================================ + +export const ERROR_MESSAGES = { + LOAD_FAILED: 'Failed to load distributors', + LOAD_PRICES_FAILED: 'Failed to load price overrides', + LOAD_SUB_USERS_FAILED: 'Failed to load sub-users', + LOAD_BILLING_FAILED: 'Failed to load billing summary', +} as const + +export const SUCCESS_MESSAGES = { + DISTRIBUTOR_CREATED: 'Distributor created successfully', + DISTRIBUTOR_UPDATED: 'Distributor updated successfully', + DISTRIBUTOR_DELETED: 'Distributor deleted successfully', + PRICE_CREATED: 'Price override created successfully', + PRICE_UPDATED: 'Price override updated successfully', + PRICE_DELETED: 'Price override deleted successfully', +} as const diff --git a/web/src/features/distributors/index.tsx b/web/src/features/distributors/index.tsx new file mode 100644 index 000000000000..da3276e11bdc --- /dev/null +++ b/web/src/features/distributors/index.tsx @@ -0,0 +1,43 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { useTranslation } from 'react-i18next' + +import { SectionPageLayout } from '@/components/layout' + +import { DistributorsDialogs } from './components/distributors-dialogs' +import { DistributorsPrimaryButtons } from './components/distributors-primary-buttons' +import { DistributorsProvider } from './components/distributors-provider' +import { DistributorsTable } from './components/distributors-table' + +export function Distributors() { + const { t } = useTranslation() + return ( + + + {t('Distributors')} + + + + + + + + + + + ) +} diff --git a/web/src/features/distributors/lib/distributors-form.ts b/web/src/features/distributors/lib/distributors-form.ts new file mode 100644 index 000000000000..a89262887ae1 --- /dev/null +++ b/web/src/features/distributors/lib/distributors-form.ts @@ -0,0 +1,165 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import type { TFunction } from 'i18next' +import { z } from 'zod' + +import { + DISTRIBUTOR_COMMISSION_RATE_MAX, + DISTRIBUTOR_COMMISSION_RATE_MIN, + DISTRIBUTOR_NAME_MAX_LENGTH, + DISTRIBUTOR_STATUS, + DISTRIBUTOR_TIER, +} from '../constants' +import type { + Distributor, + DistributorFormData, + DistributorPrice, + DistributorPriceFormData, + DistributorUpdateData, +} from '../types' + +// ============================================================================ +// Distributor Form Schema +// ============================================================================ + +export function getDistributorFormSchema(t: TFunction) { + return z.object({ + user_id: z.number().min(1, t('User ID is required')), + name: z + .string() + .min(1, t('Name is required')) + .max(DISTRIBUTOR_NAME_MAX_LENGTH, t('Name is too long')), + tier: z.string().min(1, t('Tier is required')), + commission_rate: z + .number() + .min( + DISTRIBUTOR_COMMISSION_RATE_MIN, + t('Commission rate must be between 0 and 100') + ) + .max( + DISTRIBUTOR_COMMISSION_RATE_MAX, + t('Commission rate must be between 0 and 100') + ), + status: z.number(), + }) +} + +export type DistributorFormValues = { + user_id: number + name: string + tier: string + commission_rate: number + status: number +} + +export const DISTRIBUTOR_FORM_DEFAULT_VALUES: DistributorFormValues = { + user_id: 0, + name: '', + tier: DISTRIBUTOR_TIER.STANDARD, + commission_rate: 0, + status: DISTRIBUTOR_STATUS.ACTIVE, +} + +export function transformFormDataToPayload( + data: DistributorFormValues +): DistributorFormData { + return { + user_id: data.user_id, + name: data.name.trim(), + tier: data.tier, + commission_rate: data.commission_rate, + status: data.status, + } +} + +export function transformFormDataToUpdatePayload( + data: DistributorFormValues +): DistributorUpdateData { + return { + name: data.name.trim(), + tier: data.tier, + commission_rate: data.commission_rate, + status: data.status, + } +} + +export function transformDistributorToFormDefaults( + distributor: Distributor +): DistributorFormValues { + return { + user_id: distributor.user_id, + name: distributor.name, + tier: distributor.tier, + commission_rate: distributor.commission_rate, + status: distributor.status, + } +} + +// ============================================================================ +// Distributor Price Form Schema +// ============================================================================ + +export function getDistributorPriceFormSchema(t: TFunction) { + return z.object({ + model: z.string().min(1, t('Model is required')), + input_price: z.number().min(0, t('Price must be >= 0')), + output_price: z.number().min(0, t('Price must be >= 0')), + currency: z.string().min(1, t('Currency is required')), + unit: z.string().min(1, t('Unit is required')), + }) +} + +export type DistributorPriceFormValues = { + model: string + input_price: number + output_price: number + currency: string + unit: string +} + +export const DISTRIBUTOR_PRICE_FORM_DEFAULT_VALUES: DistributorPriceFormValues = + { + model: '', + input_price: 0, + output_price: 0, + currency: 'CNY', + unit: 'token', + } + +export function transformPriceFormDataToPayload( + data: DistributorPriceFormValues +): DistributorPriceFormData { + return { + model: data.model.trim(), + input_price: data.input_price, + output_price: data.output_price, + currency: data.currency, + unit: data.unit, + } +} + +export function transformPriceToFormDefaults( + price: DistributorPrice +): DistributorPriceFormValues { + return { + model: price.model, + input_price: price.input_price, + output_price: price.output_price, + currency: price.currency, + unit: price.unit, + } +} diff --git a/web/src/features/distributors/lib/index.ts b/web/src/features/distributors/lib/index.ts new file mode 100644 index 000000000000..35b35844cbac --- /dev/null +++ b/web/src/features/distributors/lib/index.ts @@ -0,0 +1,17 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +export * from './distributors-form' diff --git a/web/src/features/distributors/types.ts b/web/src/features/distributors/types.ts new file mode 100644 index 000000000000..50ecff44555c --- /dev/null +++ b/web/src/features/distributors/types.ts @@ -0,0 +1,111 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import { z } from 'zod' + +// ============================================================================ +// Distributor Schema & Types +// ============================================================================ + +export const distributorSchema = z.object({ + id: z.number(), + user_id: z.number(), + name: z.string(), + tier: z.string(), // standard | gold | platinum + commission_rate: z.number(), // percent + status: z.number(), // 1 active, 2 disabled + created_at: z.number(), + updated_at: z.number(), +}) + +export type Distributor = z.infer + +export const distributorPriceSchema = z.object({ + id: z.number(), + distributor_id: z.number(), + model: z.string(), + input_price: z.number(), + output_price: z.number(), + currency: z.string(), // CNY | USD + unit: z.string(), // token | image | second | char + created_at: z.number(), + updated_at: z.number(), +}) + +export type DistributorPrice = z.infer + +export interface DistributorSubUser { + id: number + username: string + email: string + quota: number + used_quota: number + status: number + group: string + created_at: number + inviter_id: number + team_id: number +} + +export interface DistributorBilling { + distributor_id: number + sub_user_count: number + allocated: number + used: number +} + +// ============================================================================ +// API Request/Response Types +// ============================================================================ + +export interface ApiResponse { + success: boolean + message?: string + data?: T +} + +export interface ListDistributorsParams { + page?: number + page_size?: number + keyword?: string +} + +export interface DistributorFormData { + user_id: number + name: string + tier: string + commission_rate: number + status: number +} + +export interface DistributorUpdateData { + name: string + tier: string + commission_rate: number + status: number +} + +export interface DistributorPriceFormData { + model: string + input_price: number + output_price: number + currency: string + unit: string +} + +export type DistributorsDialogType = 'create' | 'update' | 'delete' + +export type DistributorPricesDialogType = 'create' | 'update' | 'delete' diff --git a/web/src/features/market-models/api.ts b/web/src/features/market-models/api.ts new file mode 100644 index 000000000000..f0573d73c8a1 --- /dev/null +++ b/web/src/features/market-models/api.ts @@ -0,0 +1,94 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { api } from '@/lib/api' + +import type { + ApiResponse, + GetMarketModelsParams, + GetMarketModelsResponse, + MarketModel, + MarketModelFormData, + PublicMarketModelItem, +} from './types' + +// ============================================================================ +// Model Market Management (admin) +// ============================================================================ + +// Get paginated model market items list +export async function getMarketModels( + params: GetMarketModelsParams = {} +): Promise { + const { p = 1, page_size = 20, status = '', category = '' } = params + const query = new URLSearchParams() + query.set('p', String(p)) + query.set('page_size', String(page_size)) + if (status) query.set('status', status) + if (category) query.set('category', category) + const res = await api.get(`/api/admin/market-models/?${query.toString()}`) + return res.data +} + +// Get single model market item by ID +export async function getMarketModel( + id: number +): Promise> { + const res = await api.get(`/api/admin/market-models/${id}`) + return res.data +} + +// Create a model market item +export async function createMarketModel( + data: MarketModelFormData +): Promise { + const res = await api.post('/api/admin/market-models/', data) + return res.data +} + +// Update a model market item +export async function updateMarketModel( + data: MarketModelFormData & { id: number } +): Promise { + const res = await api.put(`/api/admin/market-models/${data.id}`, data) + return res.data +} + +// Delete a model market item +export async function deleteMarketModel(id: number): Promise { + const res = await api.delete(`/api/admin/market-models/${id}`) + return res.data +} + +// ============================================================================ +// Model Market Storefront (public, no auth) +// ============================================================================ + +// Get the public, available-only model market catalog. Errors are swallowed so +// anonymous visitors never see error toasts; falls back to an empty list. +export async function getPublicMarketModels( + locale: string +): Promise { + const res = await api + .get>('/api/market-models', { + params: { locale }, + skipErrorHandler: true, + }) + .catch(() => null) + return res?.data?.data?.items ?? [] +} diff --git a/web/src/features/market-models/components/market-models-columns.tsx b/web/src/features/market-models/components/market-models-columns.tsx new file mode 100644 index 000000000000..832e3192cc83 --- /dev/null +++ b/web/src/features/market-models/components/market-models-columns.tsx @@ -0,0 +1,168 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { ColumnDef } from '@tanstack/react-table' +import { useTranslation } from 'react-i18next' + +import { StatusBadge } from '@/components/status-badge' +import { TableId } from '@/components/table-id' +import { formatTimestampToDate } from '@/lib/format' + +import { MARKET_MODEL_STATUSES, formatMarketPrice } from '../constants' +import type { MarketModel } from '../types' +import { MarketModelsRowActions } from './market-models-row-actions' + +export function useMarketModelsColumns(): ColumnDef[] { + const { t } = useTranslation() + return [ + { + accessorKey: 'id', + header: t('ID'), + meta: { mobileHidden: true }, + cell: ({ row }) => { + return ( + + ) + }, + size: 80, + }, + { + accessorKey: 'model', + header: t('Model'), + meta: { mobileTitle: true }, + cell: ({ row }) => ( + {row.getValue('model')} + ), + size: 200, + }, + { + accessorKey: 'provider', + header: t('Provider'), + cell: ({ row }) => { + const provider = row.getValue('provider') as string + return {provider || '-'} + }, + size: 140, + }, + { + accessorKey: 'category', + header: t('Category'), + cell: ({ row }) => { + const category = row.getValue('category') as string + return ( + + ) + }, + size: 140, + }, + { + accessorKey: 'input_price', + header: t('Input Price'), + cell: ({ row }) => { + const mm = row.original + return {formatMarketPrice(mm.input_price, mm.currency)} + }, + size: 130, + }, + { + accessorKey: 'output_price', + header: t('Output Price'), + cell: ({ row }) => { + const mm = row.original + return {formatMarketPrice(mm.output_price, mm.currency)} + }, + size: 130, + }, + { + accessorKey: 'unit', + header: t('Unit'), + cell: ({ row }) => ( + + {row.getValue('unit')} + + ), + size: 100, + }, + { + accessorKey: 'featured', + header: t('Featured'), + cell: ({ row }) => { + const featured = row.getValue('featured') as boolean + if (!featured) { + return - + } + return ( + + ) + }, + size: 100, + }, + { + accessorKey: 'status', + header: t('Status'), + meta: { mobileBadge: true }, + cell: ({ row }) => { + const statusValue = row.getValue('status') as number + const statusConfig = MARKET_MODEL_STATUSES[statusValue] + + if (!statusConfig) { + return null + } + + return ( + + ) + }, + size: 120, + }, + { + accessorKey: 'created_at', + header: t('Created'), + meta: { mobileHidden: true }, + cell: ({ row }) => { + return ( +
+ {formatTimestampToDate(row.getValue('created_at'))} +
+ ) + }, + size: 180, + }, + { + id: 'actions', + header: () => t('Actions'), + cell: ({ row }) => , + meta: { pinned: 'right' as const }, + }, + ] +} diff --git a/web/src/features/market-models/components/market-models-delete-dialog.tsx b/web/src/features/market-models/components/market-models-delete-dialog.tsx new file mode 100644 index 000000000000..92c3554076ea --- /dev/null +++ b/web/src/features/market-models/components/market-models-delete-dialog.tsx @@ -0,0 +1,88 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' + +import { deleteMarketModel } from '../api' +import { SUCCESS_MESSAGES } from '../constants' +import { useMarketModels } from './market-models-provider' + +export function MarketModelsDeleteDialog() { + const { t } = useTranslation() + const { open, setOpen, currentRow, triggerRefresh } = useMarketModels() + const [isDeleting, setIsDeleting] = useState(false) + + const handleDelete = async () => { + if (!currentRow) return + + setIsDeleting(true) + try { + const result = await deleteMarketModel(currentRow.id) + if (result.success) { + toast.success(t(SUCCESS_MESSAGES.MARKET_MODEL_DELETED)) + setOpen(null) + triggerRefresh() + } + } finally { + setIsDeleting(false) + } + } + + return ( + !open && setOpen(null)} + > + + + {t('Are you sure?')} + + {t('This will permanently delete model market item')}{' '} + {currentRow?.model} + {t('. This action cannot be undone.')} + + + + + {t('Cancel')} + + + {isDeleting ? t('Deleting...') : t('Delete')} + + + + + ) +} diff --git a/web/src/features/market-models/components/market-models-dialogs.tsx b/web/src/features/market-models/components/market-models-dialogs.tsx new file mode 100644 index 000000000000..23ee6c199315 --- /dev/null +++ b/web/src/features/market-models/components/market-models-dialogs.tsx @@ -0,0 +1,37 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { MarketModelsDeleteDialog } from './market-models-delete-dialog' +import { MarketModelsMutateDrawer } from './market-models-mutate-drawer' +import { useMarketModels } from './market-models-provider' + +export function MarketModelsDialogs() { + const { open, setOpen, currentRow } = useMarketModels() + const isUpdate = open === 'update' + + return ( + <> + !isOpen && setOpen(null)} + currentRow={isUpdate ? currentRow || undefined : undefined} + /> + + + ) +} diff --git a/web/src/features/market-models/components/market-models-mutate-drawer.tsx b/web/src/features/market-models/components/market-models-mutate-drawer.tsx new file mode 100644 index 000000000000..dcd18a50dfe0 --- /dev/null +++ b/web/src/features/market-models/components/market-models-mutate-drawer.tsx @@ -0,0 +1,481 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { + SideDrawerSection, + sideDrawerContentClassName, + sideDrawerFooterClassName, + sideDrawerFormClassName, + sideDrawerHeaderClassName, +} from '@/components/drawer-layout' +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' + +import { createMarketModel, getMarketModel, updateMarketModel } from '../api' +import { + SUCCESS_MESSAGES, + getMarketModelCurrencyOptions, + getMarketModelStatusOptions, + getMarketModelUnitOptions, +} from '../constants' +import { + type MarketModelFormValues, + MARKET_MODEL_FORM_DEFAULT_VALUES, + getMarketModelFormSchema, + transformFormDataToPayload, + transformMarketModelToFormDefaults, +} from '../lib/market-model-form' +import type { MarketModel } from '../types' +import { useMarketModels } from './market-models-provider' + +type MarketModelsMutateDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + currentRow?: MarketModel +} + +export function MarketModelsMutateDrawer({ + open, + onOpenChange, + currentRow, +}: MarketModelsMutateDrawerProps) { + const { t } = useTranslation() + const isUpdate = !!currentRow + const { triggerRefresh } = useMarketModels() + const [isSubmitting, setIsSubmitting] = useState(false) + + const form = useForm({ + resolver: zodResolver(getMarketModelFormSchema(t)), + defaultValues: MARKET_MODEL_FORM_DEFAULT_VALUES, + }) + + // Load existing data when updating + useEffect(() => { + if (open && isUpdate && currentRow) { + getMarketModel(currentRow.id) + .then((result) => { + if (result.success && result.data) { + form.reset(transformMarketModelToFormDefaults(result.data)) + } + }) + .catch(() => {}) + } else if (open && !isUpdate) { + form.reset(MARKET_MODEL_FORM_DEFAULT_VALUES) + } + }, [open, isUpdate, currentRow, form]) + + const onSubmit = async (data: MarketModelFormValues) => { + setIsSubmitting(true) + try { + const payload = transformFormDataToPayload(data) + + const result = isUpdate && currentRow + ? await updateMarketModel({ ...payload, id: currentRow.id }) + : await createMarketModel(payload) + + if (result.success) { + toast.success( + t( + isUpdate + ? SUCCESS_MESSAGES.MARKET_MODEL_UPDATED + : SUCCESS_MESSAGES.MARKET_MODEL_CREATED + ) + ) + onOpenChange(false) + triggerRefresh() + } + } finally { + setIsSubmitting(false) + } + } + + const statusOptions = getMarketModelStatusOptions(t) + const unitOptions = getMarketModelUnitOptions(t) + const currencyOptions = getMarketModelCurrencyOptions(t) + + return ( + { + onOpenChange(v) + if (!v) { + form.reset() + } + }} + > + + + + {isUpdate + ? t('Update Model Market Item') + : t('Create Model Market Item')} + + + {isUpdate + ? t('Update the model market item by providing necessary info.') + : t('Add a new model market item by providing necessary info.')}{' '} + {t('Click save when you are done.')} + + +
+ + + ( + + {t('Model')} + + + + + {t( + 'Actual model name, must match a routable model (Pricing.ModelName). Cannot be changed after creation.' + )} + + + + )} + /> + + ( + + {t('Provider')} + + + + + + )} + /> + + ( + + {t('Category')} + + + + + {t('Maps to a PublicModelCategory.')} + + + + )} + /> + + ( + + {t('Tags')} + + + + + + )} + /> + +
+ ( + + {t('Input Price (minor / 1M)')} + + + field.onChange(Number.parseInt(e.target.value, 10) || 0) + } + /> + + + {t( + 'Integer minor units (e.g. fen for CNY) per 1M units.' + )} + + + + )} + /> + ( + + {t('Output Price (minor / 1M)')} + + + field.onChange(Number.parseInt(e.target.value, 10) || 0) + } + /> + + + + )} + /> +
+ +
+ ( + + {t('Currency')} + + + + )} + /> + ( + + {t('Unit')} + + + + )} + /> + ( + + {t('Status')} + + + + )} + /> +
+ +
+ ( + + {t('Trial Quota')} + + + field.onChange(Number.parseInt(e.target.value, 10) || 0) + } + /> + + + + )} + /> + ( + + {t('Sort')} + + + field.onChange(Number.parseInt(e.target.value, 10) || 0) + } + /> + + + + )} + /> +
+ + ( + +
+ {t('Featured')} + + {t('Show this item prominently in the storefront.')} + +
+ + + +
+ )} + /> + + ( + + {t('Metadata (JSON, i18n)')} + +