Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ca80aaa
docs: add invite top-up rebate design spec
Jul 12, 2026
ee19306
docs: add invite top-up rebate implementation plan
Jul 12, 2026
6192669
feat: add invite top-up rebate ledger and grant helper
Jul 12, 2026
99fcd76
feat: grant invite rebate on recharge and expose APIs
Jul 12, 2026
91b79a8
feat: add invite top-up rebate user/admin pages
Jul 12, 2026
13a8f63
feat(web-classic): add invite rebate sidebar entries
Jul 12, 2026
86d2f09
fix: invite rebate review findings
Jul 12, 2026
a3f3e64
fix: bind inviter from register ?aff= query fallback
Jul 12, 2026
ef20f73
chore: regenerate routeTree for invite-rebate pages
Jul 12, 2026
fb1dc0c
i18n: Chinese strings for invite top-up rebate UI
Jul 12, 2026
3069f0e
security: harden invite rebate grant and transfer paths
Jul 12, 2026
d4ebed3
feat: invite rebate backfill task and block disabled transfers
Jul 12, 2026
c731487
fix: isolate invite rebate logging from grant failures
Jul 12, 2026
2926424
feat: manual invite rebate backfill API and configurable interval
Jul 12, 2026
e39687f
feat: polish invite rebate UI and add invite leaderboard
Jul 12, 2026
7e5dc06
fix: make invite rebate page tolerate leaderboard API failure
Jul 12, 2026
593bd92
fix: keep leaderboard data on fetch failure and add retry UI
Jul 12, 2026
0821245
security: hide other users' ids on invite leaderboard
Jul 12, 2026
44c27c1
docs: record invite top-up rebate feature completion
Jul 12, 2026
86a512e
docs: add homepage SEO design spec
Jul 12, 2026
dfaf77a
docs: add homepage SEO implementation plan
Jul 12, 2026
7ee0be8
feat: SEO options, status fields, robots.txt and sitemap
Jul 12, 2026
75c364f
feat: homepage SEO meta, admin settings, robots and sitemap
Jul 12, 2026
c26844c
fix: restore main.tsx imports and harden homepage SEO apply
Jul 12, 2026
9d17161
feat: support long-tail SEO titles via full title or suffix
Jul 12, 2026
9021af1
docs: record homepage SEO feature completion
Jul 12, 2026
349ccac
merge: adapt custom features onto upstream v1.0.0-rc.22
Jul 26, 2026
9ab0989
chore: finish upstream adapt — drop web/default dupes, reapply WIP
Jul 26, 2026
6b01d62
merge: update from origin/main — relaykit module, OIDC display name, …
Jul 29, 2026
b47f5f5
fix(seo): improve admin UI, sitemap, robots.txt, og:site_name and sta…
Jul 29, 2026
840eff4
fix: address CodeRabbit review — mutex guards, XML escape, i18n, key …
Jul 29, 2026
d7e496d
fix: address CodeRabbit review — atomic topup, sitemap lastmod, schem…
Jul 29, 2026
f674bbf
merge: sync upstream main into invite-rebate/SEO branch
seven7763 Aug 12, 2026
578b4c2
fix: remove downstream branding from SEO defaults and shell metadata
seven7763 Aug 12, 2026
ea93d87
fix(invite-rebate): credit and record the amount actually granted
seven7763 Aug 12, 2026
99eb9b5
fix(web): surface load failures, stop keystroke filtering, fix SEO form
seven7763 Aug 12, 2026
146a066
chore: drop internal planning docs from the contribution
seven7763 Aug 12, 2026
1949a2a
i18n: translate the invite-rebate strings for fr, ru, ja and vi
seven7763 Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ var TelegramBotName = ""
var QuotaForNewUser = 0
var QuotaForInviter = 0
var QuotaForInvitee = 0

// Invite top-up rebate (invitee top-up → inviter aff_quota). Default off for safe merges.
// Only top-ups completed at/after InviteTopupRebateEnabledAt are eligible.
// Historical top-ups before the feature was turned on are never backfilled.
var InviteTopupRebateEnabled = false
var InviteTopupRebateRatioBp = 100 // 100 basis points = 1.00%
var InviteTopupRebateBackfillMinutes = 5 // scheduled backfill interval; min 1, max 1440
// InviteTopupRebateEnabledAt is unix seconds when the feature was last turned ON.
// 0 means "never enabled" (or unknown) — backfill grants nothing until set on enable.
var InviteTopupRebateEnabledAt int64 = 0
var ChannelDisableThreshold = 5.0
var AutomaticDisableChannelEnabled = false
var AutomaticEnableChannelEnabled = false
Expand Down
63 changes: 63 additions & 0 deletions common/seo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package common

import "strings"

// SEO options (admin-configurable). Empty strings mean "use built-in defaults" on the client.
var (
// SEOTitle is the full document title when set (long-tail OK).
// If empty, title is built as: SystemName + " - " + SEOTitleSuffix (or default suffix).
SEOTitle = ""
// SEOTitleSuffix is appended after SystemName when SEOTitle is empty.
SEOTitleSuffix = ""
SEODescription = ""
SEOKeywords = ""
SEOSiteURL = ""
SEOOGImage = ""
SEORobotsIndex = true
)

// DefaultSEOTitleSuffix returns a long-tail title suffix for search.
func DefaultSEOTitleSuffix(lang string) string {
if strings.HasPrefix(strings.ToLower(lang), "zh") {
return "AI大模型API网关|OpenAI/Claude/Gemini兼容|统一接口管理与分发平台"
}
return "AI LLM API Gateway | OpenAI Claude Gemini Compatible | Unified Model Hub"
}

// BuildSEOTitle builds the final page title from options + system name.
func BuildSEOTitle(systemName, lang string) string {
if t := strings.TrimSpace(SEOTitle); t != "" {
return t
}
name := strings.TrimSpace(systemName)
if name == "" {
name = SystemName
}
if name == "" {
name = "New API"
}
suffix := strings.TrimSpace(SEOTitleSuffix)
if suffix == "" {
suffix = DefaultSEOTitleSuffix(lang)
}
if suffix == "" {
return name
}
return name + " - " + suffix
}

// DefaultSEODescription returns a language-aware fallback meta description.
func DefaultSEODescription(lang string) string {
if strings.HasPrefix(strings.ToLower(lang), "zh") {
return "统一的 AI 模型网关与管理平台,支持 OpenAI / Claude / Gemini 兼容接口,集中管理多模型 API 密钥、渠道分发与用量计费。"
}
return "Unified AI API gateway and admin dashboard with OpenAI / Claude / Gemini compatible APIs, multi-channel routing, key management and usage billing."
}

// DefaultSEOKeywords returns a language-aware fallback keywords string.
func DefaultSEOKeywords(lang string) string {
if strings.HasPrefix(strings.ToLower(lang), "zh") {
return "AI API,大模型API,LLM网关,OpenAI兼容接口,Claude API,Gemini API,API聚合分发,模型管理"
}
return "AI API, LLM API Gateway, OpenAI Compatible API, Claude API, Gemini API, model aggregation, API distribution"
}
109 changes: 109 additions & 0 deletions controller/invite_rebate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package controller

import (
"strconv"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)

func GetSelfInviteRebateSummary(c *gin.Context) {
userId := c.GetInt("id")
inviteeCount, topupSum, rebateSum, err := model.GetInviteRebateSummaryForInviter(userId)
if err != nil {
common.ApiError(c, err)
return
}
user, err := model.GetUserById(userId, true)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"invitee_count": inviteeCount,
"topup_quota_sum": topupSum,
"rebate_quota_sum": rebateSum,
"aff_quota": user.AffQuota,
"aff_history_quota": user.AffHistoryQuota,
"enabled": common.InviteTopupRebateEnabled,
"ratio_bp": common.InviteTopupRebateRatioBp,
})
}

func GetSelfInviteRebateLogs(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
items, total, err := model.ListInviteRebatesForInviter(userId, pageInfo)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}

func GetSelfInviteRebateInvitees(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
items, total, err := model.ListInviteesWithRebateStats(userId, pageInfo)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}

func GetAdminInviteRebates(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
inviterId, _ := strconv.Atoi(c.Query("inviter_id"))
inviteeId, _ := strconv.Atoi(c.Query("invitee_id"))
start, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
end, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
items, total, err := model.ListAllInviteRebates(inviterId, inviteeId, start, end, pageInfo)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}

func GetAdminInviteRebateSummary(c *gin.Context) {
inviterId, _ := strconv.Atoi(c.Query("inviter_id"))
topupSum, rebateSum, rowCount, err := model.GetInviteRebateAdminSummary(inviterId)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"topup_quota_sum": topupSum,
"rebate_quota_sum": rebateSum,
"row_count": rowCount,
"enabled": common.InviteTopupRebateEnabled,
"ratio_bp": common.InviteTopupRebateRatioBp,
})
}

func GetInviteRebateLeaderboard(c *gin.Context) {
userId := c.GetInt("id")
by := c.DefaultQuery("by", "rebate")
if by != "invitees" {
by = "rebate"
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
items, myRank, err := model.ListInviteRebateLeaderboard(by, limit, userId)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"by": by,
"items": items,
"my_rank": myRank,
})
}
70 changes: 70 additions & 0 deletions controller/invite_rebate_task.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package controller

import (
"context"
"fmt"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
)

// inviteRebateBackfillPayload controls one invite_rebate_backfill run.
// Scheduled runs leave Limit empty (defaults to 100). Manual triggers may set Limit.
type inviteRebateBackfillPayload struct {
Limit int `json:"limit,omitempty"`
}

// inviteRebateBackfillHandler periodically re-grants missing invite rebates for
// successful top-ups that have no invite_rebates ledger row (e.g. epay path
// where grant failed after user credit, or historical gaps after enabling the feature).
type inviteRebateBackfillHandler struct{}

func (inviteRebateBackfillHandler) Type() string {
return model.SystemTaskTypeInviteRebateBackfill
}

func (inviteRebateBackfillHandler) Enabled() bool {
return common.InviteTopupRebateEnabled
}

func (inviteRebateBackfillHandler) Interval() time.Duration {
minutes := common.InviteTopupRebateBackfillMinutes
if minutes < 1 {
minutes = 5
}
if minutes > 1440 {
minutes = 1440
}
return time.Duration(minutes) * time.Minute
}

func (inviteRebateBackfillHandler) NewPayload() any {
return inviteRebateBackfillPayload{Limit: 100}
}

func (inviteRebateBackfillHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
payload := inviteRebateBackfillPayload{}
if err := task.DecodePayload(&payload); err != nil {
// tolerate empty/legacy payload
payload.Limit = 100
}
limit := payload.Limit
if limit <= 0 {
limit = 100
}
scanned, granted, err := model.BackfillMissingInviteTopupRebates(limit)
summary := map[string]any{
"scanned": scanned,
"granted": granted,
"limit": limit,
}
if err != nil {
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, summary, err)
return
}
if granted > 0 {
common.SysLog(fmt.Sprintf("invite rebate backfill completed: scanned=%d granted=%d limit=%d", scanned, granted, limit))
}
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
}
7 changes: 7 additions & 0 deletions controller/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ func GetStatus(c *gin.Context) {
"theme": "default",
"system_name": common.SystemName,
"logo": common.Logo,
"seo_title": common.SEOTitle,
"seo_title_suffix": common.SEOTitleSuffix,
"seo_description": common.SEODescription,
"seo_keywords": common.SEOKeywords,
"seo_site_url": common.SEOSiteURL,
"seo_og_image": common.SEOOGImage,
"seo_robots_index": common.SEORobotsIndex,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
Expand Down
11 changes: 10 additions & 1 deletion controller/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,10 +361,19 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled

// Handle affiliate code
// Handle affiliate code (same rules as Register: refuse disabled inviters).
inviterId := 0
affiliateCode = strings.TrimSpace(affiliateCode)
if len(affiliateCode) > 32 {
affiliateCode = affiliateCode[:32]
}
if affiliateCode != "" {
inviterId, _ = model.GetUserIdByAffCode(affiliateCode)
if inviterId > 0 {
if inviter, err := model.GetUserById(inviterId, false); err != nil || inviter == nil || inviter.Status != common.UserStatusEnabled {
inviterId = 0
}
}
}

// Use transaction to ensure user creation and OAuth binding are atomic
Expand Down
100 changes: 100 additions & 0 deletions controller/seo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package controller

import (
"fmt"
"html"
"net/http"
"strings"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"

"github.com/gin-gonic/gin"
)

func seoSiteBase(c *gin.Context) string {
// Prefer configured absolute site URL only. Do not trust Request.Host —
// without SEOSiteURL/ServerAddress, omit absolute links rather than
// emitting attacker-controlled Host into robots/sitemap.
common.OptionMapRWMutex.RLock()
siteURL := common.SEOSiteURL
serverAddr := system_setting.ServerAddress
common.OptionMapRWMutex.RUnlock()

site := strings.TrimRight(strings.TrimSpace(siteURL), "/")
if site == "" {
site = strings.TrimRight(strings.TrimSpace(serverAddr), "/")
}
return site
}

// RobotsTxt serves GET /robots.txt for crawlers (no SPA).
func RobotsTxt(c *gin.Context) {
common.OptionMapRWMutex.RLock()
robotsIndex := common.SEORobotsIndex
common.OptionMapRWMutex.RUnlock()

var b strings.Builder
b.WriteString("User-agent: *\n")
if robotsIndex {
b.WriteString("Allow: /\n")
b.WriteString("Disallow: /console\n")
b.WriteString("Disallow: /dashboard\n")
b.WriteString("Disallow: /api/\n")
b.WriteString("Disallow: /token\n")
b.WriteString("Disallow: /topup\n")
b.WriteString("Disallow: /log\n")
b.WriteString("Disallow: /setting\n")
b.WriteString("Disallow: /channel\n")
b.WriteString("Disallow: /user\n")
b.WriteString("\n")
if site := seoSiteBase(c); site != "" {
b.WriteString("Sitemap: ")
b.WriteString(site)
b.WriteString("/sitemap.xml\n")
}
} else {
b.WriteString("Disallow: /\n")
}
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(b.String()))
}

type sitemapEntry struct {
path string
changefreq string
priority string
}

// SitemapXML serves GET /sitemap.xml with core public URLs.
func SitemapXML(c *gin.Context) {
site := seoSiteBase(c)
if site == "" {
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(
`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>`,
))
return
}

entries := []sitemapEntry{
{"/", "daily", "1.0"},
{"/pricing", "weekly", "0.8"},
{"/about", "monthly", "0.6"},
{"/rankings", "daily", "0.7"},
{"/login", "monthly", "0.3"},
{"/register", "monthly", "0.3"},
}

var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
b.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
escapedSite := html.EscapeString(site)
for _, e := range entries {
b.WriteString("<url>")
b.WriteString(fmt.Sprintf("<loc>%s%s</loc>", escapedSite, e.path))
b.WriteString(fmt.Sprintf("<changefreq>%s</changefreq>", e.changefreq))
b.WriteString(fmt.Sprintf("<priority>%s</priority>", e.priority))
b.WriteString("</url>")
}
b.WriteString(`</urlset>`)
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
}
Loading