feat(server/membership): add ai_points plan policy fields

Introduces ai_points_monthly and ai_point_base_chars on the membership
plan policy, exposes them through TenantPlanAccess and MembershipInfo,
and seeds Pro with a 1500-point monthly allowance across the local,
server, and deploy configs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 11:33:30 +08:00
parent f5254f6a27
commit 794a2d89db
7 changed files with 81 additions and 0 deletions
+6
View File
@@ -48,6 +48,8 @@ membership:
name: Free
article_generation: 3
article_quota_cycle: lifetime
ai_points_monthly: 0
ai_point_base_chars: 1000
image_storage_bytes: 10485760
company_limit: 1
subscription_duration: 72h
@@ -56,6 +58,8 @@ membership:
name: Plus
article_generation: 200
article_quota_cycle: monthly
ai_points_monthly: 0
ai_point_base_chars: 1000
image_storage_bytes: 524288000
company_limit: 1
subscription_duration: 720h
@@ -63,6 +67,8 @@ membership:
name: Pro
article_generation: 400
article_quota_cycle: monthly
ai_points_monthly: 1500
ai_point_base_chars: 1000
image_storage_bytes: 1073741824
company_limit: 2
subscription_duration: 720h
+6
View File
@@ -72,6 +72,8 @@ membership:
name: Free
article_generation: 3
article_quota_cycle: lifetime
ai_points_monthly: 0
ai_point_base_chars: 1000
image_storage_bytes: 10485760
company_limit: 1
subscription_duration: 72h
@@ -80,6 +82,8 @@ membership:
name: Plus
article_generation: 200
article_quota_cycle: monthly
ai_points_monthly: 0
ai_point_base_chars: 1000
image_storage_bytes: 524288000
company_limit: 1
subscription_duration: 720h
@@ -87,6 +91,8 @@ membership:
name: Pro
article_generation: 400
article_quota_cycle: monthly
ai_points_monthly: 1500
ai_point_base_chars: 1000
image_storage_bytes: 1073741824
company_limit: 2
subscription_duration: 720h
+6
View File
@@ -92,6 +92,8 @@ membership:
name: Free
article_generation: 3
article_quota_cycle: lifetime
ai_points_monthly: 0
ai_point_base_chars: 1000
image_storage_bytes: 10485760
company_limit: 1
subscription_duration: 72h
@@ -100,6 +102,8 @@ membership:
name: Plus
article_generation: 200
article_quota_cycle: monthly
ai_points_monthly: 0
ai_point_base_chars: 1000
image_storage_bytes: 524288000
company_limit: 1
subscription_duration: 720h
@@ -107,6 +111,8 @@ membership:
name: Pro
article_generation: 400
article_quota_cycle: monthly
ai_points_monthly: 1500
ai_point_base_chars: 1000
image_storage_bytes: 1073741824
company_limit: 2
subscription_duration: 720h
+21
View File
@@ -145,6 +145,8 @@ type MembershipPlanConfig struct {
Name string `mapstructure:"name"`
ArticleGeneration int `mapstructure:"article_generation"`
ArticleQuotaCycle string `mapstructure:"article_quota_cycle"`
AIPointsMonthly int `mapstructure:"ai_points_monthly"`
AIPointBaseChars int `mapstructure:"ai_point_base_chars"`
ImageStorageBytes int64 `mapstructure:"image_storage_bytes"`
CompanyLimit int `mapstructure:"company_limit"`
SubscriptionDuration time.Duration `mapstructure:"subscription_duration"`
@@ -154,6 +156,8 @@ type MembershipPlanConfig struct {
type PlanQuotaPolicy struct {
ArticleGeneration int `json:"article_generation"`
ArticleQuotaCycle string `json:"article_quota_cycle"`
AIPointsMonthly int `json:"ai_points_monthly"`
AIPointBaseChars int `json:"ai_point_base_chars"`
ImageStorageBytes int64 `json:"image_storage_bytes"`
BrandLimit int `json:"brand_limit"`
SubscriptionDurationSecs int64 `json:"subscription_duration_seconds,omitempty"`
@@ -174,6 +178,8 @@ func (p MembershipPlanConfig) QuotaPolicy() PlanQuotaPolicy {
policy := PlanQuotaPolicy{
ArticleGeneration: maxInt(p.ArticleGeneration, 0),
ArticleQuotaCycle: normalizeArticleQuotaCycle(p.ArticleQuotaCycle, p.Code),
AIPointsMonthly: maxInt(p.AIPointsMonthly, 0),
AIPointBaseChars: normalizeAIPointBaseChars(p.AIPointBaseChars),
ImageStorageBytes: maxInt64(p.ImageStorageBytes, 0),
BrandLimit: maxInt(p.CompanyLimit, 0),
ContactAdminOnExpiry: p.ContactAdminOnExpiry,
@@ -695,6 +701,10 @@ func normalizeMembershipConfig(cfg *MembershipConfig) {
if plan.ArticleGeneration < 0 {
plan.ArticleGeneration = 0
}
if plan.AIPointsMonthly < 0 {
plan.AIPointsMonthly = 0
}
plan.AIPointBaseChars = normalizeAIPointBaseChars(plan.AIPointBaseChars)
if plan.ImageStorageBytes < 0 {
plan.ImageStorageBytes = 0
}
@@ -725,6 +735,7 @@ func defaultMembershipPlans() []MembershipPlanConfig {
Name: "Free",
ArticleGeneration: 3,
ArticleQuotaCycle: ArticleQuotaCycleLifetime,
AIPointBaseChars: 1000,
ImageStorageBytes: 10 * 1024 * 1024,
CompanyLimit: 1,
SubscriptionDuration: 72 * time.Hour,
@@ -735,6 +746,7 @@ func defaultMembershipPlans() []MembershipPlanConfig {
Name: "Plus",
ArticleGeneration: 200,
ArticleQuotaCycle: ArticleQuotaCycleMonthly,
AIPointBaseChars: 1000,
ImageStorageBytes: 500 * 1024 * 1024,
CompanyLimit: 1,
SubscriptionDuration: 720 * time.Hour,
@@ -744,6 +756,8 @@ func defaultMembershipPlans() []MembershipPlanConfig {
Name: "Pro",
ArticleGeneration: 400,
ArticleQuotaCycle: ArticleQuotaCycleMonthly,
AIPointsMonthly: 1500,
AIPointBaseChars: 1000,
ImageStorageBytes: 1024 * 1024 * 1024,
CompanyLimit: 2,
SubscriptionDuration: 720 * time.Hour,
@@ -751,6 +765,13 @@ func defaultMembershipPlans() []MembershipPlanConfig {
}
}
func normalizeAIPointBaseChars(value int) int {
if value <= 0 {
return 1000
}
return value
}
func normalizeArticleQuotaCycle(value, planCode string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case ArticleQuotaCycleLifetime:
@@ -237,6 +237,12 @@ membership: {}
if pro.ArticleGeneration != 400 {
t.Fatalf("expected pro article generation 400, got %d", pro.ArticleGeneration)
}
if pro.AIPointsMonthly != 1500 {
t.Fatalf("expected pro ai points 1500, got %d", pro.AIPointsMonthly)
}
if pro.AIPointBaseChars != 1000 {
t.Fatalf("expected pro ai point base chars 1000, got %d", pro.AIPointBaseChars)
}
if pro.CompanyLimit != 2 {
t.Fatalf("expected pro company limit 2, got %d", pro.CompanyLimit)
}
@@ -71,6 +71,8 @@ type MembershipInfo struct {
EndAt *string `json:"end_at"`
ArticleGenerationLimit int `json:"article_generation_limit"`
ArticleQuotaCycle string `json:"article_quota_cycle"`
AIPointsMonthly int `json:"ai_points_monthly"`
AIPointBaseChars int `json:"ai_point_base_chars"`
CompanyLimit int `json:"company_limit"`
ImageStorageBytes int64 `json:"image_storage_bytes"`
ContactAdminOnExpiry bool `json:"contact_admin_on_expiry"`
@@ -271,6 +273,8 @@ func (s *AuthService) loadMembershipInfo(ctx context.Context, tenantID int64) (*
EndAt: formatTimeValuePtr(access.EndAt),
ArticleGenerationLimit: access.ArticleGenerationLimit(),
ArticleQuotaCycle: access.ArticleQuotaCycle(),
AIPointsMonthly: access.AIPointsMonthlyLimit(),
AIPointBaseChars: access.AIPointBaseChars(),
CompanyLimit: access.BrandLimit(),
ImageStorageBytes: access.ImageStorageBytes(),
ContactAdminOnExpiry: access.Policy.ContactAdminOnExpiry,
@@ -98,6 +98,38 @@ func (a *TenantPlanAccess) ArticleQuotaCycle() string {
}
}
func (a *TenantPlanAccess) AIPointsMonthlyLimit() int {
if a == nil {
return 0
}
return maxInt(a.Policy.AIPointsMonthly, 0)
}
func (a *TenantPlanAccess) AIPointBaseChars() int {
if a == nil || a.Policy.AIPointBaseChars <= 0 {
return 1000
}
return a.Policy.AIPointBaseChars
}
func (a *TenantPlanAccess) AIPointsWindow(now time.Time) (time.Time, time.Time, *time.Time) {
if a == nil {
return time.Time{}, time.Time{}, nil
}
windowStart := a.StartAt.UTC()
windowEnd := a.EndAt.UTC()
cycleStart, cycleEnd := monthlyCycleBounds(windowStart, now.UTC())
if cycleStart.Before(windowStart) {
cycleStart = windowStart
}
if !windowEnd.IsZero() && cycleEnd.After(windowEnd) {
cycleEnd = windowEnd
}
resetAt := cycleEnd
return cycleStart, cycleEnd, &resetAt
}
func (a *TenantPlanAccess) ArticleQuotaWindow(now time.Time) (time.Time, time.Time, *time.Time) {
if a == nil {
return time.Time{}, time.Time{}, nil