Merge pull request #1193 from trheyi/main

Enhance user type management with pricing and status fields
This commit is contained in:
Max 2025-10-13 15:56:07 +08:00 committed by GitHub
commit 6f85e78a57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 723 additions and 159 deletions

File diff suppressed because one or more lines are too long

View file

@ -116,15 +116,18 @@ var (
// DefaultTypeFields contains basic type fields
DefaultTypeFields = []interface{}{
"id", "type_id", "name", "description", "is_active", "is_default", "sort_order",
"default_role_id", "max_sessions", "session_timeout", "created_at", "updated_at",
"id", "type_id", "name", "description", "is_active", "is_default", "sort_order", "status",
"default_role_id", "max_sessions", "session_timeout", "price_daily", "price_monthly",
"price_yearly", "credits_monthly", "created_at", "updated_at",
}
// DefaultTypeDetailFields contains all type fields including configuration and metadata
DefaultTypeDetailFields = []interface{}{
"id", "type_id", "name", "description", "default_role_id", "schema", "metadata",
"is_active", "is_default", "sort_order", "max_sessions", "session_timeout",
"password_policy", "features", "limits", "created_at", "updated_at",
"is_active", "is_default", "sort_order", "status", "max_sessions", "session_timeout",
"password_policy", "features", "limits", "price_daily", "price_monthly", "price_yearly",
"credits_monthly", "introduction", "sale_type", "sale_link", "sale_price_label",
"sale_description", "created_at", "updated_at",
}
// DefaultTeamFields contains basic team fields

View file

@ -290,3 +290,167 @@ func (u *DefaultUser) SetTypeConfiguration(ctx context.Context, typeID string, c
return nil
}
// GetTypePricing retrieves pricing information for a type
func (u *DefaultUser) GetTypePricing(ctx context.Context, typeID string) (maps.MapStrAny, error) {
m := model.Select(u.typeModel)
types, err := m.Get(model.QueryParam{
Select: []interface{}{
"type_id", "name", "price_daily", "price_monthly", "price_yearly",
"credits_monthly", "introduction", "sale_type", "sale_link",
"sale_price_label", "sale_description", "status",
},
Wheres: []model.QueryWhere{
{Column: "type_id", Value: typeID},
},
Limit: 1,
})
if err != nil {
return nil, fmt.Errorf(ErrFailedToGetType, err)
}
if len(types) == 0 {
return nil, fmt.Errorf(ErrTypeNotFound)
}
return types[0], nil
}
// GetPublishedTypes retrieves all published types with pricing information
func (u *DefaultUser) GetPublishedTypes(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error) {
// Add published status filter
param.Wheres = append(param.Wheres, model.QueryWhere{
Column: "status",
Value: "published",
})
// Add active filter
param.Wheres = append(param.Wheres, model.QueryWhere{
Column: "is_active",
Value: true,
})
// Set default select fields if not provided
if param.Select == nil {
param.Select = []interface{}{
"type_id", "name", "description", "price_daily", "price_monthly", "price_yearly",
"credits_monthly", "introduction", "sale_type", "sale_link",
"sale_price_label", "sale_description", "sort_order", "status", "is_active", "features", "limits",
}
}
// Default ordering by sort_order
if param.Orders == nil {
param.Orders = []model.QueryOrder{
{Column: "sort_order", Option: "asc"},
}
}
m := model.Select(u.typeModel)
types, err := m.Get(param)
if err != nil {
return nil, fmt.Errorf(ErrFailedToGetType, err)
}
return types, nil
}
// SetTypePricing updates pricing information for a type
func (u *DefaultUser) SetTypePricing(ctx context.Context, typeID string, pricing maps.MapStrAny) error {
// Prepare update data - only allow pricing-related fields
updateData := maps.MapStrAny{}
if priceDaily, ok := pricing["price_daily"]; ok {
updateData["price_daily"] = priceDaily
}
if priceMonthly, ok := pricing["price_monthly"]; ok {
updateData["price_monthly"] = priceMonthly
}
if priceYearly, ok := pricing["price_yearly"]; ok {
updateData["price_yearly"] = priceYearly
}
if creditsMonthly, ok := pricing["credits_monthly"]; ok {
updateData["credits_monthly"] = creditsMonthly
}
if introduction, ok := pricing["introduction"]; ok {
updateData["introduction"] = introduction
}
if saleType, ok := pricing["sale_type"]; ok {
updateData["sale_type"] = saleType
}
if saleLink, ok := pricing["sale_link"]; ok {
updateData["sale_link"] = saleLink
}
if salePriceLabel, ok := pricing["sale_price_label"]; ok {
updateData["sale_price_label"] = salePriceLabel
}
if saleDescription, ok := pricing["sale_description"]; ok {
updateData["sale_description"] = saleDescription
}
// Skip update if no pricing fields provided
if len(updateData) == 0 {
return nil
}
m := model.Select(u.typeModel)
affected, err := m.UpdateWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "type_id", Value: typeID},
},
Limit: 1, // Safety: ensure only one record is updated
}, updateData)
if err != nil {
return fmt.Errorf(ErrFailedToUpdateType, err)
}
if affected == 0 {
return fmt.Errorf(ErrTypeNotFound)
}
return nil
}
// UpdateTypeStatus updates the status of a type (draft/published/archived)
func (u *DefaultUser) UpdateTypeStatus(ctx context.Context, typeID string, status string) error {
// Validate status
validStatuses := map[string]bool{
"draft": true,
"published": true,
"archived": true,
}
if !validStatuses[status] {
return fmt.Errorf("invalid status: %s, must be one of: draft, published, archived", status)
}
m := model.Select(u.typeModel)
affected, err := m.UpdateWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "type_id", Value: typeID},
},
Limit: 1,
}, maps.MapStrAny{
"status": status,
})
if err != nil {
return fmt.Errorf(ErrFailedToUpdateType, err)
}
if affected == 0 {
return fmt.Errorf(ErrTypeNotFound)
}
return nil
}

View file

@ -13,20 +13,30 @@ import (
// TestTypeData represents test type data structure
type TestTypeData struct {
TypeID string `json:"type_id"`
Name string `json:"name"`
Description string `json:"description"`
IsActive bool `json:"is_active"`
IsDefault bool `json:"is_default"`
SortOrder int `json:"sort_order"`
DefaultRoleID string `json:"default_role_id"`
MaxSessions *int `json:"max_sessions"`
SessionTimeout int `json:"session_timeout"`
Schema map[string]interface{} `json:"schema"`
Features map[string]interface{} `json:"features"`
Limits map[string]interface{} `json:"limits"`
PasswordPolicy map[string]interface{} `json:"password_policy"`
Metadata map[string]interface{} `json:"metadata"`
TypeID string `json:"type_id"`
Name string `json:"name"`
Description string `json:"description"`
IsActive bool `json:"is_active"`
IsDefault bool `json:"is_default"`
SortOrder int `json:"sort_order"`
Status string `json:"status"`
DefaultRoleID string `json:"default_role_id"`
MaxSessions *int `json:"max_sessions"`
SessionTimeout int `json:"session_timeout"`
PriceDaily int `json:"price_daily"`
PriceMonthly int `json:"price_monthly"`
PriceYearly int `json:"price_yearly"`
CreditsMonthly int `json:"credits_monthly"`
Introduction string `json:"introduction"`
SaleType string `json:"sale_type"`
SaleLink string `json:"sale_link"`
SalePriceLabel string `json:"sale_price_label"`
SaleDescription string `json:"sale_description"`
Schema map[string]interface{} `json:"schema"`
Features map[string]interface{} `json:"features"`
Limits map[string]interface{} `json:"limits"`
PasswordPolicy map[string]interface{} `json:"password_policy"`
Metadata map[string]interface{} `json:"metadata"`
}
func TestTypeBasicOperations(t *testing.T) {
@ -810,3 +820,307 @@ func TestTypeErrorHandling(t *testing.T) {
assert.GreaterOrEqual(t, count, int64(0)) // Should handle complex filters without error
})
}
func TestTypePricingOperations(t *testing.T) {
prepare(t)
defer clean()
ctx := context.Background()
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create test types with pricing information
testTypes := []struct {
TypeID string
Name string
PriceDaily int
PriceMonthly int
PriceYearly int
CreditsMonthly int
Introduction string
SaleType string
SaleLink string
SalePriceLabel string
SaleDescription string
Status string
}{
{
TypeID: "free_" + testUUID,
Name: "Free Plan",
PriceDaily: 0,
PriceMonthly: 0,
PriceYearly: 0,
CreditsMonthly: 1000,
Introduction: "Perfect for personal use and small projects",
SaleType: "online",
SaleLink: "",
Status: "published",
},
{
TypeID: "pro_" + testUUID,
Name: "Pro Plan",
PriceDaily: 100,
PriceMonthly: 2900,
PriceYearly: 29900,
CreditsMonthly: 10000,
Introduction: "Perfect for professionals and team collaboration",
SaleType: "online",
SaleLink: "",
Status: "published",
},
{
TypeID: "enterprise_" + testUUID,
Name: "Enterprise Plan",
PriceDaily: 0,
PriceMonthly: 0,
PriceYearly: 0,
CreditsMonthly: 0,
Introduction: "For large-scale deployments",
SaleType: "offline",
SaleLink: "https://example.com/contact-sales",
SalePriceLabel: "$999 - $4999 /month",
SaleDescription: "Pricing based on deployment scale",
Status: "published",
},
{
TypeID: "beta_" + testUUID,
Name: "Beta Plan",
PriceDaily: 50,
PriceMonthly: 1500,
PriceYearly: 15000,
CreditsMonthly: 5000,
Introduction: "Beta testing plan",
SaleType: "online",
Status: "draft",
},
}
// Create all test types
for _, testType := range testTypes {
typeData := maps.MapStrAny{
"type_id": testType.TypeID,
"name": testType.Name,
"price_daily": testType.PriceDaily,
"price_monthly": testType.PriceMonthly,
"price_yearly": testType.PriceYearly,
"credits_monthly": testType.CreditsMonthly,
"introduction": testType.Introduction,
"sale_type": testType.SaleType,
"sale_link": testType.SaleLink,
"sale_price_label": testType.SalePriceLabel,
"sale_description": testType.SaleDescription,
"status": testType.Status,
"is_active": true,
}
_, err := testProvider.CreateType(ctx, typeData)
assert.NoError(t, err)
}
// Test GetTypePricing
t.Run("GetTypePricing", func(t *testing.T) {
pricing, err := testProvider.GetTypePricing(ctx, "pro_"+testUUID)
assert.NoError(t, err)
assert.NotNil(t, pricing)
assert.Equal(t, "pro_"+testUUID, pricing["type_id"])
assert.Equal(t, "Pro Plan", pricing["name"])
// Handle different numeric types from database
priceMonthlyInterface := pricing["price_monthly"]
switch v := priceMonthlyInterface.(type) {
case int:
assert.Equal(t, 2900, v)
case int32:
assert.Equal(t, int32(2900), v)
case int64:
assert.Equal(t, int64(2900), v)
default:
t.Errorf("unexpected price_monthly type: %T, value: %v", priceMonthlyInterface, priceMonthlyInterface)
}
assert.Equal(t, "online", pricing["sale_type"])
assert.Equal(t, "published", pricing["status"])
})
// Test GetTypePricing for offline sales type
t.Run("GetTypePricing_OfflineSales", func(t *testing.T) {
pricing, err := testProvider.GetTypePricing(ctx, "enterprise_"+testUUID)
assert.NoError(t, err)
assert.NotNil(t, pricing)
assert.Equal(t, "offline", pricing["sale_type"])
assert.Equal(t, "https://example.com/contact-sales", pricing["sale_link"])
assert.Equal(t, "$999 - $4999 /month", pricing["sale_price_label"])
assert.Equal(t, "Pricing based on deployment scale", pricing["sale_description"])
})
// Test SetTypePricing
t.Run("SetTypePricing", func(t *testing.T) {
newPricing := maps.MapStrAny{
"price_monthly": 3900,
"price_yearly": 39900,
"credits_monthly": 15000,
"introduction": "Updated Pro Plan - Now with more features!",
"sale_price_label": "Special Offer",
}
err := testProvider.SetTypePricing(ctx, "pro_"+testUUID, newPricing)
assert.NoError(t, err)
// Verify pricing was updated
pricing, err := testProvider.GetTypePricing(ctx, "pro_"+testUUID)
assert.NoError(t, err)
priceMonthlyInterface := pricing["price_monthly"]
switch v := priceMonthlyInterface.(type) {
case int:
assert.Equal(t, 3900, v)
case int32:
assert.Equal(t, int32(3900), v)
case int64:
assert.Equal(t, int64(3900), v)
default:
t.Errorf("unexpected price_monthly type: %T, value: %v", priceMonthlyInterface, priceMonthlyInterface)
}
creditsInterface := pricing["credits_monthly"]
switch v := creditsInterface.(type) {
case int:
assert.Equal(t, 15000, v)
case int32:
assert.Equal(t, int32(15000), v)
case int64:
assert.Equal(t, int64(15000), v)
default:
t.Errorf("unexpected credits_monthly type: %T, value: %v", creditsInterface, creditsInterface)
}
assert.Equal(t, "Updated Pro Plan - Now with more features!", pricing["introduction"])
})
// Test UpdateTypeStatus
t.Run("UpdateTypeStatus", func(t *testing.T) {
// Update from draft to published
err := testProvider.UpdateTypeStatus(ctx, "beta_"+testUUID, "published")
assert.NoError(t, err)
// Verify status was updated
typeRecord, err := testProvider.GetType(ctx, "beta_"+testUUID)
assert.NoError(t, err)
assert.Equal(t, "published", typeRecord["status"])
// Test archive status
err = testProvider.UpdateTypeStatus(ctx, "beta_"+testUUID, "archived")
assert.NoError(t, err)
typeRecord, err = testProvider.GetType(ctx, "beta_"+testUUID)
assert.NoError(t, err)
assert.Equal(t, "archived", typeRecord["status"])
})
// Test UpdateTypeStatus with invalid status
t.Run("UpdateTypeStatus_InvalidStatus", func(t *testing.T) {
err := testProvider.UpdateTypeStatus(ctx, "pro_"+testUUID, "invalid_status")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid status")
})
// Test GetPublishedTypes
t.Run("GetPublishedTypes", func(t *testing.T) {
param := model.QueryParam{}
types, err := testProvider.GetPublishedTypes(ctx, param)
assert.NoError(t, err)
assert.NotNil(t, types)
// Should have at least the 3 published types we created
publishedCount := 0
for _, typeRecord := range types {
typeID := typeRecord["type_id"].(string)
if strings.Contains(typeID, testUUID) {
publishedCount++
// Verify status is published
assert.Equal(t, "published", typeRecord["status"])
// Verify is_active is true
isActive := typeRecord["is_active"]
switch v := isActive.(type) {
case bool:
assert.True(t, v)
case int, int32, int64:
assert.NotEqual(t, 0, v)
}
}
}
assert.GreaterOrEqual(t, publishedCount, 3) // free, pro, enterprise
})
// Test GetPublishedTypes ordering
t.Run("GetPublishedTypes_Ordering", func(t *testing.T) {
// Update sort order for testing
_ = testProvider.UpdateType(ctx, "free_"+testUUID, maps.MapStrAny{"sort_order": 10})
_ = testProvider.UpdateType(ctx, "pro_"+testUUID, maps.MapStrAny{"sort_order": 20})
_ = testProvider.UpdateType(ctx, "enterprise_"+testUUID, maps.MapStrAny{"sort_order": 30})
param := model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "type_id", OP: "like", Value: "%_" + testUUID},
},
}
types, err := testProvider.GetPublishedTypes(ctx, param)
assert.NoError(t, err)
assert.GreaterOrEqual(t, len(types), 3)
// Verify ordering by sort_order
if len(types) >= 2 {
prevSortOrder := -1
for _, typeRecord := range types {
sortOrderInterface := typeRecord["sort_order"]
var sortOrder int
switch v := sortOrderInterface.(type) {
case int:
sortOrder = v
case int32:
sortOrder = int(v)
case int64:
sortOrder = int(v)
default:
continue
}
if prevSortOrder >= 0 {
assert.GreaterOrEqual(t, sortOrder, prevSortOrder)
}
prevSortOrder = sortOrder
}
}
})
// Test SetTypePricing with empty data
t.Run("SetTypePricing_EmptyData", func(t *testing.T) {
emptyPricing := maps.MapStrAny{}
err := testProvider.SetTypePricing(ctx, "pro_"+testUUID, emptyPricing)
assert.NoError(t, err) // Should not error, just skip update
})
// Test GetTypePricing for non-existent type
t.Run("GetTypePricing_NotFound", func(t *testing.T) {
_, err := testProvider.GetTypePricing(ctx, "nonexistent_"+testUUID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "type not found")
})
// Test SetTypePricing for non-existent type
t.Run("SetTypePricing_NotFound", func(t *testing.T) {
pricing := maps.MapStrAny{"price_monthly": 1000}
err := testProvider.SetTypePricing(ctx, "nonexistent_"+testUUID, pricing)
assert.Error(t, err)
assert.Contains(t, err.Error(), "type not found")
})
// Test UpdateTypeStatus for non-existent type
t.Run("UpdateTypeStatus_NotFound", func(t *testing.T) {
err := testProvider.UpdateTypeStatus(ctx, "nonexistent_"+testUUID, "published")
assert.Error(t, err)
assert.Contains(t, err.Error(), "type not found")
})
}

View file

@ -110,6 +110,7 @@ func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, login
"family_name": userinfo.FamilyName,
"picture": userinfo.Picture,
"role_id": provider.Register.Role,
"type_id": provider.Register.Type,
"status": "active",
}

View file

@ -81,6 +81,7 @@ type ThirdParty struct {
type RegisterConfig struct {
Auto bool `json:"auto,omitempty"`
Role string `json:"role,omitempty"`
Type string `json:"type,omitempty"` // User type id
}
// YaoClientConfig represents the Yao OpenAPI Client config

View file

@ -98,6 +98,87 @@
"default": 0,
"nullable": true
},
{
"name": "status",
"type": "enum",
"label": "Status",
"comment": "Publishing status for plan management",
"option": ["draft", "published", "archived"],
"default": "draft",
"index": true
},
// ============================================================================
// Pricing & Subscription Fields
// ============================================================================
{
"name": "price_daily",
"type": "integer",
"label": "Daily Price",
"comment": "Daily subscription price in cents (e.g., 100 for $1.00)",
"nullable": true
},
{
"name": "price_monthly",
"type": "integer",
"label": "Monthly Price",
"comment": "Monthly subscription price in cents (e.g., 2900 for $29.00)",
"nullable": true
},
{
"name": "price_yearly",
"type": "integer",
"label": "Yearly Price",
"comment": "Yearly subscription price in cents (e.g., 29900 for $299.00)",
"nullable": true
},
{
"name": "credits_monthly",
"type": "integer",
"label": "Monthly Credits",
"comment": "Monthly credits/quota allocation",
"nullable": true
},
{
"name": "introduction",
"type": "text",
"label": "Introduction",
"comment": "Plan introduction (supports HTML/Markdown)",
"nullable": true
},
{
"name": "sale_type",
"type": "enum",
"label": "Sale Type",
"comment": "Sales method: online or offline",
"option": ["online", "offline"],
"default": "online",
"nullable": true
},
{
"name": "sale_link",
"type": "string",
"label": "Sale Link",
"comment": "Sales link URL (for offline purchases or external payment)",
"length": 500,
"nullable": true
},
{
"name": "sale_price_label",
"type": "string",
"label": "Sale Price Label",
"comment": "Custom price label for offline sales (e.g., '$999 - $4999 /month')",
"length": 200,
"nullable": true
},
{
"name": "sale_description",
"type": "string",
"label": "Sale Description",
"comment": "Brief description for offline sales (e.g., 'Pricing based on deployment scale')",
"length": 500,
"nullable": true
},
// ============================================================================
// Access Control Fields