Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
106e59b7
Commit
106e59b7
authored
Jan 01, 2026
by
shaw
Browse files
Merge PR #122: feat: 用户自定义属性系统 + Wechat 字段迁移
parents
2c71c8b9
759291db
Changes
71
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/wire.go
View file @
106e59b7
...
@@ -23,6 +23,7 @@ func ProvideAdminHandlers(
...
@@ -23,6 +23,7 @@ func ProvideAdminHandlers(
systemHandler
*
admin
.
SystemHandler
,
systemHandler
*
admin
.
SystemHandler
,
subscriptionHandler
*
admin
.
SubscriptionHandler
,
subscriptionHandler
*
admin
.
SubscriptionHandler
,
usageHandler
*
admin
.
UsageHandler
,
usageHandler
*
admin
.
UsageHandler
,
userAttributeHandler
*
admin
.
UserAttributeHandler
,
)
*
AdminHandlers
{
)
*
AdminHandlers
{
return
&
AdminHandlers
{
return
&
AdminHandlers
{
Dashboard
:
dashboardHandler
,
Dashboard
:
dashboardHandler
,
...
@@ -39,6 +40,7 @@ func ProvideAdminHandlers(
...
@@ -39,6 +40,7 @@ func ProvideAdminHandlers(
System
:
systemHandler
,
System
:
systemHandler
,
Subscription
:
subscriptionHandler
,
Subscription
:
subscriptionHandler
,
Usage
:
usageHandler
,
Usage
:
usageHandler
,
UserAttribute
:
userAttributeHandler
,
}
}
}
}
...
@@ -107,6 +109,7 @@ var ProviderSet = wire.NewSet(
...
@@ -107,6 +109,7 @@ var ProviderSet = wire.NewSet(
ProvideSystemHandler
,
ProvideSystemHandler
,
admin
.
NewSubscriptionHandler
,
admin
.
NewSubscriptionHandler
,
admin
.
NewUsageHandler
,
admin
.
NewUsageHandler
,
admin
.
NewUserAttributeHandler
,
// AdminHandlers and Handlers constructors
// AdminHandlers and Handlers constructors
ProvideAdminHandlers
,
ProvideAdminHandlers
,
...
...
backend/internal/repository/api_key_repo.go
View file @
106e59b7
...
@@ -294,7 +294,6 @@ func userEntityToService(u *dbent.User) *service.User {
...
@@ -294,7 +294,6 @@ func userEntityToService(u *dbent.User) *service.User {
ID
:
u
.
ID
,
ID
:
u
.
ID
,
Email
:
u
.
Email
,
Email
:
u
.
Email
,
Username
:
u
.
Username
,
Username
:
u
.
Username
,
Wechat
:
u
.
Wechat
,
Notes
:
u
.
Notes
,
Notes
:
u
.
Notes
,
PasswordHash
:
u
.
PasswordHash
,
PasswordHash
:
u
.
PasswordHash
,
Role
:
u
.
Role
,
Role
:
u
.
Role
,
...
...
backend/internal/repository/fixtures_integration_test.go
View file @
106e59b7
...
@@ -40,7 +40,6 @@ func mustCreateUser(t *testing.T, client *dbent.Client, u *service.User) *servic
...
@@ -40,7 +40,6 @@ func mustCreateUser(t *testing.T, client *dbent.Client, u *service.User) *servic
SetBalance
(
u
.
Balance
)
.
SetBalance
(
u
.
Balance
)
.
SetConcurrency
(
u
.
Concurrency
)
.
SetConcurrency
(
u
.
Concurrency
)
.
SetUsername
(
u
.
Username
)
.
SetUsername
(
u
.
Username
)
.
SetWechat
(
u
.
Wechat
)
.
SetNotes
(
u
.
Notes
)
SetNotes
(
u
.
Notes
)
if
!
u
.
CreatedAt
.
IsZero
()
{
if
!
u
.
CreatedAt
.
IsZero
()
{
create
.
SetCreatedAt
(
u
.
CreatedAt
)
create
.
SetCreatedAt
(
u
.
CreatedAt
)
...
...
backend/internal/repository/migrations_schema_integration_test.go
View file @
106e59b7
...
@@ -23,7 +23,6 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
...
@@ -23,7 +23,6 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
// users: columns required by repository queries
// users: columns required by repository queries
requireColumn
(
t
,
tx
,
"users"
,
"username"
,
"character varying"
,
100
,
false
)
requireColumn
(
t
,
tx
,
"users"
,
"username"
,
"character varying"
,
100
,
false
)
requireColumn
(
t
,
tx
,
"users"
,
"wechat"
,
"character varying"
,
100
,
false
)
requireColumn
(
t
,
tx
,
"users"
,
"notes"
,
"text"
,
0
,
false
)
requireColumn
(
t
,
tx
,
"users"
,
"notes"
,
"text"
,
0
,
false
)
// accounts: schedulable and rate-limit fields
// accounts: schedulable and rate-limit fields
...
...
backend/internal/repository/user_attribute_repo.go
0 → 100644
View file @
106e59b7
package
repository
import
(
"context"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/userattributedefinition"
"github.com/Wei-Shaw/sub2api/ent/userattributevalue"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// UserAttributeDefinitionRepository implementation
type
userAttributeDefinitionRepository
struct
{
client
*
dbent
.
Client
}
// NewUserAttributeDefinitionRepository creates a new repository instance
func
NewUserAttributeDefinitionRepository
(
client
*
dbent
.
Client
)
service
.
UserAttributeDefinitionRepository
{
return
&
userAttributeDefinitionRepository
{
client
:
client
}
}
func
(
r
*
userAttributeDefinitionRepository
)
Create
(
ctx
context
.
Context
,
def
*
service
.
UserAttributeDefinition
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
created
,
err
:=
client
.
UserAttributeDefinition
.
Create
()
.
SetKey
(
def
.
Key
)
.
SetName
(
def
.
Name
)
.
SetDescription
(
def
.
Description
)
.
SetType
(
string
(
def
.
Type
))
.
SetOptions
(
toEntOptions
(
def
.
Options
))
.
SetRequired
(
def
.
Required
)
.
SetValidation
(
toEntValidation
(
def
.
Validation
))
.
SetPlaceholder
(
def
.
Placeholder
)
.
SetEnabled
(
def
.
Enabled
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
translatePersistenceError
(
err
,
nil
,
service
.
ErrAttributeKeyExists
)
}
def
.
ID
=
created
.
ID
def
.
DisplayOrder
=
created
.
DisplayOrder
def
.
CreatedAt
=
created
.
CreatedAt
def
.
UpdatedAt
=
created
.
UpdatedAt
return
nil
}
func
(
r
*
userAttributeDefinitionRepository
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
UserAttributeDefinition
,
error
)
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
e
,
err
:=
client
.
UserAttributeDefinition
.
Query
()
.
Where
(
userattributedefinition
.
IDEQ
(
id
))
.
Only
(
ctx
)
if
err
!=
nil
{
return
nil
,
translatePersistenceError
(
err
,
service
.
ErrAttributeDefinitionNotFound
,
nil
)
}
return
defEntityToService
(
e
),
nil
}
func
(
r
*
userAttributeDefinitionRepository
)
GetByKey
(
ctx
context
.
Context
,
key
string
)
(
*
service
.
UserAttributeDefinition
,
error
)
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
e
,
err
:=
client
.
UserAttributeDefinition
.
Query
()
.
Where
(
userattributedefinition
.
KeyEQ
(
key
))
.
Only
(
ctx
)
if
err
!=
nil
{
return
nil
,
translatePersistenceError
(
err
,
service
.
ErrAttributeDefinitionNotFound
,
nil
)
}
return
defEntityToService
(
e
),
nil
}
func
(
r
*
userAttributeDefinitionRepository
)
Update
(
ctx
context
.
Context
,
def
*
service
.
UserAttributeDefinition
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
updated
,
err
:=
client
.
UserAttributeDefinition
.
UpdateOneID
(
def
.
ID
)
.
SetName
(
def
.
Name
)
.
SetDescription
(
def
.
Description
)
.
SetType
(
string
(
def
.
Type
))
.
SetOptions
(
toEntOptions
(
def
.
Options
))
.
SetRequired
(
def
.
Required
)
.
SetValidation
(
toEntValidation
(
def
.
Validation
))
.
SetPlaceholder
(
def
.
Placeholder
)
.
SetDisplayOrder
(
def
.
DisplayOrder
)
.
SetEnabled
(
def
.
Enabled
)
.
Save
(
ctx
)
if
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrAttributeDefinitionNotFound
,
service
.
ErrAttributeKeyExists
)
}
def
.
UpdatedAt
=
updated
.
UpdatedAt
return
nil
}
func
(
r
*
userAttributeDefinitionRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
_
,
err
:=
client
.
UserAttributeDefinition
.
Delete
()
.
Where
(
userattributedefinition
.
IDEQ
(
id
))
.
Exec
(
ctx
)
return
translatePersistenceError
(
err
,
service
.
ErrAttributeDefinitionNotFound
,
nil
)
}
func
(
r
*
userAttributeDefinitionRepository
)
List
(
ctx
context
.
Context
,
enabledOnly
bool
)
([]
service
.
UserAttributeDefinition
,
error
)
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
q
:=
client
.
UserAttributeDefinition
.
Query
()
if
enabledOnly
{
q
=
q
.
Where
(
userattributedefinition
.
EnabledEQ
(
true
))
}
entities
,
err
:=
q
.
Order
(
dbent
.
Asc
(
userattributedefinition
.
FieldDisplayOrder
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
result
:=
make
([]
service
.
UserAttributeDefinition
,
0
,
len
(
entities
))
for
_
,
e
:=
range
entities
{
result
=
append
(
result
,
*
defEntityToService
(
e
))
}
return
result
,
nil
}
func
(
r
*
userAttributeDefinitionRepository
)
UpdateDisplayOrders
(
ctx
context
.
Context
,
orders
map
[
int64
]
int
)
error
{
tx
,
err
:=
r
.
client
.
Tx
(
ctx
)
if
err
!=
nil
{
return
err
}
defer
func
()
{
_
=
tx
.
Rollback
()
}()
for
id
,
order
:=
range
orders
{
if
_
,
err
:=
tx
.
UserAttributeDefinition
.
UpdateOneID
(
id
)
.
SetDisplayOrder
(
order
)
.
Save
(
ctx
);
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrAttributeDefinitionNotFound
,
nil
)
}
}
return
tx
.
Commit
()
}
func
(
r
*
userAttributeDefinitionRepository
)
ExistsByKey
(
ctx
context
.
Context
,
key
string
)
(
bool
,
error
)
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
return
client
.
UserAttributeDefinition
.
Query
()
.
Where
(
userattributedefinition
.
KeyEQ
(
key
))
.
Exist
(
ctx
)
}
// UserAttributeValueRepository implementation
type
userAttributeValueRepository
struct
{
client
*
dbent
.
Client
}
// NewUserAttributeValueRepository creates a new repository instance
func
NewUserAttributeValueRepository
(
client
*
dbent
.
Client
)
service
.
UserAttributeValueRepository
{
return
&
userAttributeValueRepository
{
client
:
client
}
}
func
(
r
*
userAttributeValueRepository
)
GetByUserID
(
ctx
context
.
Context
,
userID
int64
)
([]
service
.
UserAttributeValue
,
error
)
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
entities
,
err
:=
client
.
UserAttributeValue
.
Query
()
.
Where
(
userattributevalue
.
UserIDEQ
(
userID
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
result
:=
make
([]
service
.
UserAttributeValue
,
0
,
len
(
entities
))
for
_
,
e
:=
range
entities
{
result
=
append
(
result
,
service
.
UserAttributeValue
{
ID
:
e
.
ID
,
UserID
:
e
.
UserID
,
AttributeID
:
e
.
AttributeID
,
Value
:
e
.
Value
,
CreatedAt
:
e
.
CreatedAt
,
UpdatedAt
:
e
.
UpdatedAt
,
})
}
return
result
,
nil
}
func
(
r
*
userAttributeValueRepository
)
GetByUserIDs
(
ctx
context
.
Context
,
userIDs
[]
int64
)
([]
service
.
UserAttributeValue
,
error
)
{
if
len
(
userIDs
)
==
0
{
return
[]
service
.
UserAttributeValue
{},
nil
}
client
:=
clientFromContext
(
ctx
,
r
.
client
)
entities
,
err
:=
client
.
UserAttributeValue
.
Query
()
.
Where
(
userattributevalue
.
UserIDIn
(
userIDs
...
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
result
:=
make
([]
service
.
UserAttributeValue
,
0
,
len
(
entities
))
for
_
,
e
:=
range
entities
{
result
=
append
(
result
,
service
.
UserAttributeValue
{
ID
:
e
.
ID
,
UserID
:
e
.
UserID
,
AttributeID
:
e
.
AttributeID
,
Value
:
e
.
Value
,
CreatedAt
:
e
.
CreatedAt
,
UpdatedAt
:
e
.
UpdatedAt
,
})
}
return
result
,
nil
}
func
(
r
*
userAttributeValueRepository
)
UpsertBatch
(
ctx
context
.
Context
,
userID
int64
,
inputs
[]
service
.
UpdateUserAttributeInput
)
error
{
if
len
(
inputs
)
==
0
{
return
nil
}
tx
,
err
:=
r
.
client
.
Tx
(
ctx
)
if
err
!=
nil
{
return
err
}
defer
func
()
{
_
=
tx
.
Rollback
()
}()
for
_
,
input
:=
range
inputs
{
// Use upsert (ON CONFLICT DO UPDATE)
err
:=
tx
.
UserAttributeValue
.
Create
()
.
SetUserID
(
userID
)
.
SetAttributeID
(
input
.
AttributeID
)
.
SetValue
(
input
.
Value
)
.
OnConflictColumns
(
userattributevalue
.
FieldUserID
,
userattributevalue
.
FieldAttributeID
)
.
UpdateValue
()
.
UpdateUpdatedAt
()
.
Exec
(
ctx
)
if
err
!=
nil
{
return
err
}
}
return
tx
.
Commit
()
}
func
(
r
*
userAttributeValueRepository
)
DeleteByAttributeID
(
ctx
context
.
Context
,
attributeID
int64
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
_
,
err
:=
client
.
UserAttributeValue
.
Delete
()
.
Where
(
userattributevalue
.
AttributeIDEQ
(
attributeID
))
.
Exec
(
ctx
)
return
err
}
func
(
r
*
userAttributeValueRepository
)
DeleteByUserID
(
ctx
context
.
Context
,
userID
int64
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
_
,
err
:=
client
.
UserAttributeValue
.
Delete
()
.
Where
(
userattributevalue
.
UserIDEQ
(
userID
))
.
Exec
(
ctx
)
return
err
}
// Helper functions for entity to service conversion
func
defEntityToService
(
e
*
dbent
.
UserAttributeDefinition
)
*
service
.
UserAttributeDefinition
{
if
e
==
nil
{
return
nil
}
return
&
service
.
UserAttributeDefinition
{
ID
:
e
.
ID
,
Key
:
e
.
Key
,
Name
:
e
.
Name
,
Description
:
e
.
Description
,
Type
:
service
.
UserAttributeType
(
e
.
Type
),
Options
:
toServiceOptions
(
e
.
Options
),
Required
:
e
.
Required
,
Validation
:
toServiceValidation
(
e
.
Validation
),
Placeholder
:
e
.
Placeholder
,
DisplayOrder
:
e
.
DisplayOrder
,
Enabled
:
e
.
Enabled
,
CreatedAt
:
e
.
CreatedAt
,
UpdatedAt
:
e
.
UpdatedAt
,
}
}
// Type conversion helpers (map types <-> service types)
func
toEntOptions
(
opts
[]
service
.
UserAttributeOption
)
[]
map
[
string
]
any
{
if
opts
==
nil
{
return
[]
map
[
string
]
any
{}
}
result
:=
make
([]
map
[
string
]
any
,
len
(
opts
))
for
i
,
o
:=
range
opts
{
result
[
i
]
=
map
[
string
]
any
{
"value"
:
o
.
Value
,
"label"
:
o
.
Label
}
}
return
result
}
func
toServiceOptions
(
opts
[]
map
[
string
]
any
)
[]
service
.
UserAttributeOption
{
if
opts
==
nil
{
return
[]
service
.
UserAttributeOption
{}
}
result
:=
make
([]
service
.
UserAttributeOption
,
len
(
opts
))
for
i
,
o
:=
range
opts
{
result
[
i
]
=
service
.
UserAttributeOption
{
Value
:
getString
(
o
,
"value"
),
Label
:
getString
(
o
,
"label"
),
}
}
return
result
}
func
toEntValidation
(
v
service
.
UserAttributeValidation
)
map
[
string
]
any
{
result
:=
map
[
string
]
any
{}
if
v
.
MinLength
!=
nil
{
result
[
"min_length"
]
=
*
v
.
MinLength
}
if
v
.
MaxLength
!=
nil
{
result
[
"max_length"
]
=
*
v
.
MaxLength
}
if
v
.
Min
!=
nil
{
result
[
"min"
]
=
*
v
.
Min
}
if
v
.
Max
!=
nil
{
result
[
"max"
]
=
*
v
.
Max
}
if
v
.
Pattern
!=
nil
{
result
[
"pattern"
]
=
*
v
.
Pattern
}
if
v
.
Message
!=
nil
{
result
[
"message"
]
=
*
v
.
Message
}
return
result
}
func
toServiceValidation
(
v
map
[
string
]
any
)
service
.
UserAttributeValidation
{
result
:=
service
.
UserAttributeValidation
{}
if
val
:=
getInt
(
v
,
"min_length"
);
val
!=
nil
{
result
.
MinLength
=
val
}
if
val
:=
getInt
(
v
,
"max_length"
);
val
!=
nil
{
result
.
MaxLength
=
val
}
if
val
:=
getInt
(
v
,
"min"
);
val
!=
nil
{
result
.
Min
=
val
}
if
val
:=
getInt
(
v
,
"max"
);
val
!=
nil
{
result
.
Max
=
val
}
if
val
:=
getStringPtr
(
v
,
"pattern"
);
val
!=
nil
{
result
.
Pattern
=
val
}
if
val
:=
getStringPtr
(
v
,
"message"
);
val
!=
nil
{
result
.
Message
=
val
}
return
result
}
// Helper functions for type conversion
func
getString
(
m
map
[
string
]
any
,
key
string
)
string
{
if
v
,
ok
:=
m
[
key
];
ok
{
if
s
,
ok
:=
v
.
(
string
);
ok
{
return
s
}
}
return
""
}
func
getStringPtr
(
m
map
[
string
]
any
,
key
string
)
*
string
{
if
v
,
ok
:=
m
[
key
];
ok
{
if
s
,
ok
:=
v
.
(
string
);
ok
{
return
&
s
}
}
return
nil
}
func
getInt
(
m
map
[
string
]
any
,
key
string
)
*
int
{
if
v
,
ok
:=
m
[
key
];
ok
{
switch
n
:=
v
.
(
type
)
{
case
int
:
return
&
n
case
int64
:
i
:=
int
(
n
)
return
&
i
case
float64
:
i
:=
int
(
n
)
return
&
i
}
}
return
nil
}
backend/internal/repository/user_repo.go
View file @
106e59b7
...
@@ -9,6 +9,7 @@ import (
...
@@ -9,6 +9,7 @@ import (
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbuser
"github.com/Wei-Shaw/sub2api/ent/user"
dbuser
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
"github.com/Wei-Shaw/sub2api/ent/userattributevalue"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
...
@@ -50,7 +51,6 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
...
@@ -50,7 +51,6 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
created
,
err
:=
txClient
.
User
.
Create
()
.
created
,
err
:=
txClient
.
User
.
Create
()
.
SetEmail
(
userIn
.
Email
)
.
SetEmail
(
userIn
.
Email
)
.
SetUsername
(
userIn
.
Username
)
.
SetUsername
(
userIn
.
Username
)
.
SetWechat
(
userIn
.
Wechat
)
.
SetNotes
(
userIn
.
Notes
)
.
SetNotes
(
userIn
.
Notes
)
.
SetPasswordHash
(
userIn
.
PasswordHash
)
.
SetPasswordHash
(
userIn
.
PasswordHash
)
.
SetRole
(
userIn
.
Role
)
.
SetRole
(
userIn
.
Role
)
.
...
@@ -133,7 +133,6 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
...
@@ -133,7 +133,6 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
updated
,
err
:=
txClient
.
User
.
UpdateOneID
(
userIn
.
ID
)
.
updated
,
err
:=
txClient
.
User
.
UpdateOneID
(
userIn
.
ID
)
.
SetEmail
(
userIn
.
Email
)
.
SetEmail
(
userIn
.
Email
)
.
SetUsername
(
userIn
.
Username
)
.
SetUsername
(
userIn
.
Username
)
.
SetWechat
(
userIn
.
Wechat
)
.
SetNotes
(
userIn
.
Notes
)
.
SetNotes
(
userIn
.
Notes
)
.
SetPasswordHash
(
userIn
.
PasswordHash
)
.
SetPasswordHash
(
userIn
.
PasswordHash
)
.
SetRole
(
userIn
.
Role
)
.
SetRole
(
userIn
.
Role
)
.
...
@@ -171,28 +170,38 @@ func (r *userRepository) Delete(ctx context.Context, id int64) error {
...
@@ -171,28 +170,38 @@ func (r *userRepository) Delete(ctx context.Context, id int64) error {
}
}
func
(
r
*
userRepository
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
User
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
r
*
userRepository
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
User
,
*
pagination
.
PaginationResult
,
error
)
{
return
r
.
ListWithFilters
(
ctx
,
params
,
""
,
""
,
""
)
return
r
.
ListWithFilters
(
ctx
,
params
,
service
.
UserListFilters
{}
)
}
}
func
(
r
*
userRepository
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
status
,
role
,
search
string
)
([]
service
.
User
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
r
*
userRepository
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
service
.
UserListFilters
)
([]
service
.
User
,
*
pagination
.
PaginationResult
,
error
)
{
q
:=
r
.
client
.
User
.
Query
()
q
:=
r
.
client
.
User
.
Query
()
if
s
tatus
!=
""
{
if
filters
.
S
tatus
!=
""
{
q
=
q
.
Where
(
dbuser
.
StatusEQ
(
s
tatus
))
q
=
q
.
Where
(
dbuser
.
StatusEQ
(
filters
.
S
tatus
))
}
}
if
r
ole
!=
""
{
if
filters
.
R
ole
!=
""
{
q
=
q
.
Where
(
dbuser
.
RoleEQ
(
r
ole
))
q
=
q
.
Where
(
dbuser
.
RoleEQ
(
filters
.
R
ole
))
}
}
if
s
earch
!=
""
{
if
filters
.
S
earch
!=
""
{
q
=
q
.
Where
(
q
=
q
.
Where
(
dbuser
.
Or
(
dbuser
.
Or
(
dbuser
.
EmailContainsFold
(
search
),
dbuser
.
EmailContainsFold
(
filters
.
Search
),
dbuser
.
UsernameContainsFold
(
search
),
dbuser
.
UsernameContainsFold
(
filters
.
Search
),
dbuser
.
WechatContainsFold
(
search
),
),
),
)
)
}
}
// If attribute filters are specified, we need to filter by user IDs first
var
allowedUserIDs
[]
int64
if
len
(
filters
.
Attributes
)
>
0
{
allowedUserIDs
=
r
.
filterUsersByAttributes
(
ctx
,
filters
.
Attributes
)
if
len
(
allowedUserIDs
)
==
0
{
// No users match the attribute filters
return
[]
service
.
User
{},
paginationResultFromTotal
(
0
,
params
),
nil
}
q
=
q
.
Where
(
dbuser
.
IDIn
(
allowedUserIDs
...
))
}
total
,
err
:=
q
.
Clone
()
.
Count
(
ctx
)
total
,
err
:=
q
.
Clone
()
.
Count
(
ctx
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
nil
,
err
return
nil
,
nil
,
err
...
@@ -252,6 +261,59 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
...
@@ -252,6 +261,59 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
return
outUsers
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
return
outUsers
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
}
}
// filterUsersByAttributes returns user IDs that match ALL the given attribute filters
func
(
r
*
userRepository
)
filterUsersByAttributes
(
ctx
context
.
Context
,
attrs
map
[
int64
]
string
)
[]
int64
{
if
len
(
attrs
)
==
0
{
return
nil
}
// For each attribute filter, get the set of matching user IDs
// Then intersect all sets to get users matching ALL filters
var
resultSet
map
[
int64
]
struct
{}
first
:=
true
for
attrID
,
value
:=
range
attrs
{
// Query user_attribute_values for this attribute
values
,
err
:=
r
.
client
.
UserAttributeValue
.
Query
()
.
Where
(
userattributevalue
.
AttributeIDEQ
(
attrID
),
userattributevalue
.
ValueContainsFold
(
value
),
)
.
All
(
ctx
)
if
err
!=
nil
{
continue
}
currentSet
:=
make
(
map
[
int64
]
struct
{},
len
(
values
))
for
_
,
v
:=
range
values
{
currentSet
[
v
.
UserID
]
=
struct
{}{}
}
if
first
{
resultSet
=
currentSet
first
=
false
}
else
{
// Intersect with previous results
for
userID
:=
range
resultSet
{
if
_
,
ok
:=
currentSet
[
userID
];
!
ok
{
delete
(
resultSet
,
userID
)
}
}
}
// Early exit if no users match
if
len
(
resultSet
)
==
0
{
return
nil
}
}
result
:=
make
([]
int64
,
0
,
len
(
resultSet
))
for
userID
:=
range
resultSet
{
result
=
append
(
result
,
userID
)
}
return
result
}
func
(
r
*
userRepository
)
UpdateBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
{
func
(
r
*
userRepository
)
UpdateBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
client
:=
clientFromContext
(
ctx
,
r
.
client
)
n
,
err
:=
client
.
User
.
Update
()
.
Where
(
dbuser
.
IDEQ
(
id
))
.
AddBalance
(
amount
)
.
Save
(
ctx
)
n
,
err
:=
client
.
User
.
Update
()
.
Where
(
dbuser
.
IDEQ
(
id
))
.
AddBalance
(
amount
)
.
Save
(
ctx
)
...
...
backend/internal/repository/user_repo_integration_test.go
View file @
106e59b7
...
@@ -166,7 +166,7 @@ func (s *UserRepoSuite) TestListWithFilters_Status() {
...
@@ -166,7 +166,7 @@ func (s *UserRepoSuite) TestListWithFilters_Status() {
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"active@test.com"
,
Status
:
service
.
StatusActive
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"active@test.com"
,
Status
:
service
.
StatusActive
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"disabled@test.com"
,
Status
:
service
.
StatusDisabled
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"disabled@test.com"
,
Status
:
service
.
StatusDisabled
})
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
service
.
StatusActive
,
""
,
""
)
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
service
.
UserListFilters
{
Status
:
service
.
StatusActive
}
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Len
(
users
,
1
)
s
.
Require
()
.
Len
(
users
,
1
)
s
.
Require
()
.
Equal
(
service
.
StatusActive
,
users
[
0
]
.
Status
)
s
.
Require
()
.
Equal
(
service
.
StatusActive
,
users
[
0
]
.
Status
)
...
@@ -176,7 +176,7 @@ func (s *UserRepoSuite) TestListWithFilters_Role() {
...
@@ -176,7 +176,7 @@ func (s *UserRepoSuite) TestListWithFilters_Role() {
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"user@test.com"
,
Role
:
service
.
RoleUser
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"user@test.com"
,
Role
:
service
.
RoleUser
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"admin@test.com"
,
Role
:
service
.
RoleAdmin
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"admin@test.com"
,
Role
:
service
.
RoleAdmin
})
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
""
,
service
.
RoleAdmin
,
""
)
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
service
.
UserListFilters
{
Role
:
service
.
RoleAdmin
}
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Len
(
users
,
1
)
s
.
Require
()
.
Len
(
users
,
1
)
s
.
Require
()
.
Equal
(
service
.
RoleAdmin
,
users
[
0
]
.
Role
)
s
.
Require
()
.
Equal
(
service
.
RoleAdmin
,
users
[
0
]
.
Role
)
...
@@ -186,7 +186,7 @@ func (s *UserRepoSuite) TestListWithFilters_Search() {
...
@@ -186,7 +186,7 @@ func (s *UserRepoSuite) TestListWithFilters_Search() {
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"alice@test.com"
,
Username
:
"Alice"
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"alice@test.com"
,
Username
:
"Alice"
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"bob@test.com"
,
Username
:
"Bob"
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"bob@test.com"
,
Username
:
"Bob"
})
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
""
,
""
,
"alice"
)
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
service
.
UserListFilters
{
Search
:
"alice"
}
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Len
(
users
,
1
)
s
.
Require
()
.
Len
(
users
,
1
)
s
.
Require
()
.
Contains
(
users
[
0
]
.
Email
,
"alice"
)
s
.
Require
()
.
Contains
(
users
[
0
]
.
Email
,
"alice"
)
...
@@ -196,22 +196,12 @@ func (s *UserRepoSuite) TestListWithFilters_SearchByUsername() {
...
@@ -196,22 +196,12 @@ func (s *UserRepoSuite) TestListWithFilters_SearchByUsername() {
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"u1@test.com"
,
Username
:
"JohnDoe"
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"u1@test.com"
,
Username
:
"JohnDoe"
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"u2@test.com"
,
Username
:
"JaneSmith"
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"u2@test.com"
,
Username
:
"JaneSmith"
})
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
""
,
""
,
"john"
)
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
service
.
UserListFilters
{
Search
:
"john"
}
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Len
(
users
,
1
)
s
.
Require
()
.
Len
(
users
,
1
)
s
.
Require
()
.
Equal
(
"JohnDoe"
,
users
[
0
]
.
Username
)
s
.
Require
()
.
Equal
(
"JohnDoe"
,
users
[
0
]
.
Username
)
}
}
func
(
s
*
UserRepoSuite
)
TestListWithFilters_SearchByWechat
()
{
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"w1@test.com"
,
Wechat
:
"wx_hello"
})
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"w2@test.com"
,
Wechat
:
"wx_world"
})
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
""
,
""
,
"wx_hello"
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Len
(
users
,
1
)
s
.
Require
()
.
Equal
(
"wx_hello"
,
users
[
0
]
.
Wechat
)
}
func
(
s
*
UserRepoSuite
)
TestListWithFilters_LoadsActiveSubscriptions
()
{
func
(
s
*
UserRepoSuite
)
TestListWithFilters_LoadsActiveSubscriptions
()
{
user
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"sub@test.com"
,
Status
:
service
.
StatusActive
})
user
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"sub@test.com"
,
Status
:
service
.
StatusActive
})
groupActive
:=
s
.
mustCreateGroup
(
"g-sub-active"
)
groupActive
:=
s
.
mustCreateGroup
(
"g-sub-active"
)
...
@@ -226,7 +216,7 @@ func (s *UserRepoSuite) TestListWithFilters_LoadsActiveSubscriptions() {
...
@@ -226,7 +216,7 @@ func (s *UserRepoSuite) TestListWithFilters_LoadsActiveSubscriptions() {
c
.
SetExpiresAt
(
time
.
Now
()
.
Add
(
-
1
*
time
.
Hour
))
c
.
SetExpiresAt
(
time
.
Now
()
.
Add
(
-
1
*
time
.
Hour
))
})
})
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
""
,
""
,
"sub@"
)
users
,
_
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
service
.
UserListFilters
{
Search
:
"sub@"
}
)
s
.
Require
()
.
NoError
(
err
,
"ListWithFilters"
)
s
.
Require
()
.
NoError
(
err
,
"ListWithFilters"
)
s
.
Require
()
.
Len
(
users
,
1
,
"expected 1 user"
)
s
.
Require
()
.
Len
(
users
,
1
,
"expected 1 user"
)
s
.
Require
()
.
Len
(
users
[
0
]
.
Subscriptions
,
1
,
"expected 1 active subscription"
)
s
.
Require
()
.
Len
(
users
[
0
]
.
Subscriptions
,
1
,
"expected 1 active subscription"
)
...
@@ -238,7 +228,6 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
...
@@ -238,7 +228,6 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
s
.
mustCreateUser
(
&
service
.
User
{
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"a@example.com"
,
Email
:
"a@example.com"
,
Username
:
"Alice"
,
Username
:
"Alice"
,
Wechat
:
"wx_a"
,
Role
:
service
.
RoleUser
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
Status
:
service
.
StatusActive
,
Balance
:
10
,
Balance
:
10
,
...
@@ -246,7 +235,6 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
...
@@ -246,7 +235,6 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
target
:=
s
.
mustCreateUser
(
&
service
.
User
{
target
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"b@example.com"
,
Email
:
"b@example.com"
,
Username
:
"Bob"
,
Username
:
"Bob"
,
Wechat
:
"wx_b"
,
Role
:
service
.
RoleAdmin
,
Role
:
service
.
RoleAdmin
,
Status
:
service
.
StatusActive
,
Status
:
service
.
StatusActive
,
Balance
:
1
,
Balance
:
1
,
...
@@ -257,7 +245,7 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
...
@@ -257,7 +245,7 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
Status
:
service
.
StatusDisabled
,
Status
:
service
.
StatusDisabled
,
})
})
users
,
page
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
service
.
StatusActive
,
service
.
RoleAdmin
,
"b@"
)
users
,
page
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
},
service
.
UserListFilters
{
Status
:
service
.
StatusActive
,
Role
:
service
.
RoleAdmin
,
Search
:
"b@"
}
)
s
.
Require
()
.
NoError
(
err
,
"ListWithFilters"
)
s
.
Require
()
.
NoError
(
err
,
"ListWithFilters"
)
s
.
Require
()
.
Equal
(
int64
(
1
),
page
.
Total
,
"ListWithFilters total mismatch"
)
s
.
Require
()
.
Equal
(
int64
(
1
),
page
.
Total
,
"ListWithFilters total mismatch"
)
s
.
Require
()
.
Len
(
users
,
1
,
"ListWithFilters len mismatch"
)
s
.
Require
()
.
Len
(
users
,
1
,
"ListWithFilters len mismatch"
)
...
@@ -448,7 +436,6 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
...
@@ -448,7 +436,6 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
user1
:=
s
.
mustCreateUser
(
&
service
.
User
{
user1
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"a@example.com"
,
Email
:
"a@example.com"
,
Username
:
"Alice"
,
Username
:
"Alice"
,
Wechat
:
"wx_a"
,
Role
:
service
.
RoleUser
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
Status
:
service
.
StatusActive
,
Balance
:
10
,
Balance
:
10
,
...
@@ -456,7 +443,6 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
...
@@ -456,7 +443,6 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
user2
:=
s
.
mustCreateUser
(
&
service
.
User
{
user2
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"b@example.com"
,
Email
:
"b@example.com"
,
Username
:
"Bob"
,
Username
:
"Bob"
,
Wechat
:
"wx_b"
,
Role
:
service
.
RoleAdmin
,
Role
:
service
.
RoleAdmin
,
Status
:
service
.
StatusActive
,
Status
:
service
.
StatusActive
,
Balance
:
1
,
Balance
:
1
,
...
@@ -501,7 +487,7 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
...
@@ -501,7 +487,7 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
s
.
Require
()
.
Equal
(
user1
.
Concurrency
+
3
,
got5
.
Concurrency
)
s
.
Require
()
.
Equal
(
user1
.
Concurrency
+
3
,
got5
.
Concurrency
)
params
:=
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
}
params
:=
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
10
}
users
,
page
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
params
,
service
.
StatusActive
,
service
.
RoleAdmin
,
"b@"
)
users
,
page
,
err
:=
s
.
repo
.
ListWithFilters
(
s
.
ctx
,
params
,
service
.
UserListFilters
{
Status
:
service
.
StatusActive
,
Role
:
service
.
RoleAdmin
,
Search
:
"b@"
}
)
s
.
Require
()
.
NoError
(
err
,
"ListWithFilters"
)
s
.
Require
()
.
NoError
(
err
,
"ListWithFilters"
)
s
.
Require
()
.
Equal
(
int64
(
1
),
page
.
Total
,
"ListWithFilters total mismatch"
)
s
.
Require
()
.
Equal
(
int64
(
1
),
page
.
Total
,
"ListWithFilters total mismatch"
)
s
.
Require
()
.
Len
(
users
,
1
,
"ListWithFilters len mismatch"
)
s
.
Require
()
.
Len
(
users
,
1
,
"ListWithFilters len mismatch"
)
...
...
backend/internal/repository/wire.go
View file @
106e59b7
...
@@ -36,6 +36,8 @@ var ProviderSet = wire.NewSet(
...
@@ -36,6 +36,8 @@ var ProviderSet = wire.NewSet(
NewUsageLogRepository
,
NewUsageLogRepository
,
NewSettingRepository
,
NewSettingRepository
,
NewUserSubscriptionRepository
,
NewUserSubscriptionRepository
,
NewUserAttributeDefinitionRepository
,
NewUserAttributeValueRepository
,
// Cache implementations
// Cache implementations
NewGatewayCache
,
NewGatewayCache
,
...
...
backend/internal/server/api_contract_test.go
View file @
106e59b7
...
@@ -51,7 +51,6 @@ func TestAPIContracts(t *testing.T) {
...
@@ -51,7 +51,6 @@ func TestAPIContracts(t *testing.T) {
"id": 1,
"id": 1,
"email": "alice@example.com",
"email": "alice@example.com",
"username": "alice",
"username": "alice",
"wechat": "wx_alice",
"notes": "hello",
"notes": "hello",
"role": "user",
"role": "user",
"balance": 12.5,
"balance": 12.5,
...
@@ -348,7 +347,6 @@ func newContractDeps(t *testing.T) *contractDeps {
...
@@ -348,7 +347,6 @@ func newContractDeps(t *testing.T) *contractDeps {
ID
:
1
,
ID
:
1
,
Email
:
"alice@example.com"
,
Email
:
"alice@example.com"
,
Username
:
"alice"
,
Username
:
"alice"
,
Wechat
:
"wx_alice"
,
Notes
:
"hello"
,
Notes
:
"hello"
,
Role
:
service
.
RoleUser
,
Role
:
service
.
RoleUser
,
Balance
:
12.5
,
Balance
:
12.5
,
...
@@ -503,7 +501,7 @@ func (r *stubUserRepo) List(ctx context.Context, params pagination.PaginationPar
...
@@ -503,7 +501,7 @@ func (r *stubUserRepo) List(ctx context.Context, params pagination.PaginationPar
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
}
}
func
(
r
*
stubUserRepo
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
status
,
role
,
search
string
)
([]
service
.
User
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
r
*
stubUserRepo
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
service
.
UserListFilters
)
([]
service
.
User
,
*
pagination
.
PaginationResult
,
error
)
{
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
}
}
...
...
backend/internal/server/routes/admin.go
View file @
106e59b7
...
@@ -54,6 +54,9 @@ func RegisterAdminRoutes(
...
@@ -54,6 +54,9 @@ func RegisterAdminRoutes(
// 使用记录管理
// 使用记录管理
registerUsageRoutes
(
admin
,
h
)
registerUsageRoutes
(
admin
,
h
)
// 用户属性管理
registerUserAttributeRoutes
(
admin
,
h
)
}
}
}
}
...
@@ -82,6 +85,10 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
...
@@ -82,6 +85,10 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users
.
POST
(
"/:id/balance"
,
h
.
Admin
.
User
.
UpdateBalance
)
users
.
POST
(
"/:id/balance"
,
h
.
Admin
.
User
.
UpdateBalance
)
users
.
GET
(
"/:id/api-keys"
,
h
.
Admin
.
User
.
GetUserAPIKeys
)
users
.
GET
(
"/:id/api-keys"
,
h
.
Admin
.
User
.
GetUserAPIKeys
)
users
.
GET
(
"/:id/usage"
,
h
.
Admin
.
User
.
GetUserUsage
)
users
.
GET
(
"/:id/usage"
,
h
.
Admin
.
User
.
GetUserUsage
)
// User attribute values
users
.
GET
(
"/:id/attributes"
,
h
.
Admin
.
UserAttribute
.
GetUserAttributes
)
users
.
PUT
(
"/:id/attributes"
,
h
.
Admin
.
UserAttribute
.
UpdateUserAttributes
)
}
}
}
}
...
@@ -244,3 +251,15 @@ func registerUsageRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
...
@@ -244,3 +251,15 @@ func registerUsageRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
usage
.
GET
(
"/search-api-keys"
,
h
.
Admin
.
Usage
.
SearchApiKeys
)
usage
.
GET
(
"/search-api-keys"
,
h
.
Admin
.
Usage
.
SearchApiKeys
)
}
}
}
}
func
registerUserAttributeRoutes
(
admin
*
gin
.
RouterGroup
,
h
*
handler
.
Handlers
)
{
attrs
:=
admin
.
Group
(
"/user-attributes"
)
{
attrs
.
GET
(
""
,
h
.
Admin
.
UserAttribute
.
ListDefinitions
)
attrs
.
POST
(
""
,
h
.
Admin
.
UserAttribute
.
CreateDefinition
)
attrs
.
POST
(
"/batch"
,
h
.
Admin
.
UserAttribute
.
GetBatchUserAttributes
)
attrs
.
PUT
(
"/reorder"
,
h
.
Admin
.
UserAttribute
.
ReorderDefinitions
)
attrs
.
PUT
(
"/:id"
,
h
.
Admin
.
UserAttribute
.
UpdateDefinition
)
attrs
.
DELETE
(
"/:id"
,
h
.
Admin
.
UserAttribute
.
DeleteDefinition
)
}
}
backend/internal/service/admin_service.go
View file @
106e59b7
...
@@ -13,7 +13,7 @@ import (
...
@@ -13,7 +13,7 @@ import (
// AdminService interface defines admin management operations
// AdminService interface defines admin management operations
type
AdminService
interface
{
type
AdminService
interface
{
// User management
// User management
ListUsers
(
ctx
context
.
Context
,
page
,
pageSize
int
,
status
,
role
,
search
string
)
([]
User
,
int64
,
error
)
ListUsers
(
ctx
context
.
Context
,
page
,
pageSize
int
,
filters
UserListFilters
)
([]
User
,
int64
,
error
)
GetUser
(
ctx
context
.
Context
,
id
int64
)
(
*
User
,
error
)
GetUser
(
ctx
context
.
Context
,
id
int64
)
(
*
User
,
error
)
CreateUser
(
ctx
context
.
Context
,
input
*
CreateUserInput
)
(
*
User
,
error
)
CreateUser
(
ctx
context
.
Context
,
input
*
CreateUserInput
)
(
*
User
,
error
)
UpdateUser
(
ctx
context
.
Context
,
id
int64
,
input
*
UpdateUserInput
)
(
*
User
,
error
)
UpdateUser
(
ctx
context
.
Context
,
id
int64
,
input
*
UpdateUserInput
)
(
*
User
,
error
)
...
@@ -70,7 +70,6 @@ type CreateUserInput struct {
...
@@ -70,7 +70,6 @@ type CreateUserInput struct {
Email
string
Email
string
Password
string
Password
string
Username
string
Username
string
Wechat
string
Notes
string
Notes
string
Balance
float64
Balance
float64
Concurrency
int
Concurrency
int
...
@@ -81,7 +80,6 @@ type UpdateUserInput struct {
...
@@ -81,7 +80,6 @@ type UpdateUserInput struct {
Email
string
Email
string
Password
string
Password
string
Username
*
string
Username
*
string
Wechat
*
string
Notes
*
string
Notes
*
string
Balance
*
float64
// 使用指针区分"未提供"和"设置为0"
Balance
*
float64
// 使用指针区分"未提供"和"设置为0"
Concurrency
*
int
// 使用指针区分"未提供"和"设置为0"
Concurrency
*
int
// 使用指针区分"未提供"和"设置为0"
...
@@ -252,9 +250,9 @@ func NewAdminService(
...
@@ -252,9 +250,9 @@ func NewAdminService(
}
}
// User management implementations
// User management implementations
func
(
s
*
adminServiceImpl
)
ListUsers
(
ctx
context
.
Context
,
page
,
pageSize
int
,
status
,
role
,
search
string
)
([]
User
,
int64
,
error
)
{
func
(
s
*
adminServiceImpl
)
ListUsers
(
ctx
context
.
Context
,
page
,
pageSize
int
,
filters
UserListFilters
)
([]
User
,
int64
,
error
)
{
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
users
,
result
,
err
:=
s
.
userRepo
.
ListWithFilters
(
ctx
,
params
,
status
,
role
,
search
)
users
,
result
,
err
:=
s
.
userRepo
.
ListWithFilters
(
ctx
,
params
,
filters
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
0
,
err
return
nil
,
0
,
err
}
}
...
@@ -269,7 +267,6 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
...
@@ -269,7 +267,6 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
user
:=
&
User
{
user
:=
&
User
{
Email
:
input
.
Email
,
Email
:
input
.
Email
,
Username
:
input
.
Username
,
Username
:
input
.
Username
,
Wechat
:
input
.
Wechat
,
Notes
:
input
.
Notes
,
Notes
:
input
.
Notes
,
Role
:
RoleUser
,
// Always create as regular user, never admin
Role
:
RoleUser
,
// Always create as regular user, never admin
Balance
:
input
.
Balance
,
Balance
:
input
.
Balance
,
...
@@ -311,9 +308,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
...
@@ -311,9 +308,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
if
input
.
Username
!=
nil
{
if
input
.
Username
!=
nil
{
user
.
Username
=
*
input
.
Username
user
.
Username
=
*
input
.
Username
}
}
if
input
.
Wechat
!=
nil
{
user
.
Wechat
=
*
input
.
Wechat
}
if
input
.
Notes
!=
nil
{
if
input
.
Notes
!=
nil
{
user
.
Notes
=
*
input
.
Notes
user
.
Notes
=
*
input
.
Notes
}
}
...
...
backend/internal/service/admin_service_create_user_test.go
View file @
106e59b7
...
@@ -18,7 +18,6 @@ func TestAdminService_CreateUser_Success(t *testing.T) {
...
@@ -18,7 +18,6 @@ func TestAdminService_CreateUser_Success(t *testing.T) {
Email
:
"user@test.com"
,
Email
:
"user@test.com"
,
Password
:
"strong-pass"
,
Password
:
"strong-pass"
,
Username
:
"tester"
,
Username
:
"tester"
,
Wechat
:
"wx"
,
Notes
:
"note"
,
Notes
:
"note"
,
Balance
:
12.5
,
Balance
:
12.5
,
Concurrency
:
7
,
Concurrency
:
7
,
...
@@ -31,7 +30,6 @@ func TestAdminService_CreateUser_Success(t *testing.T) {
...
@@ -31,7 +30,6 @@ func TestAdminService_CreateUser_Success(t *testing.T) {
require
.
Equal
(
t
,
int64
(
10
),
user
.
ID
)
require
.
Equal
(
t
,
int64
(
10
),
user
.
ID
)
require
.
Equal
(
t
,
input
.
Email
,
user
.
Email
)
require
.
Equal
(
t
,
input
.
Email
,
user
.
Email
)
require
.
Equal
(
t
,
input
.
Username
,
user
.
Username
)
require
.
Equal
(
t
,
input
.
Username
,
user
.
Username
)
require
.
Equal
(
t
,
input
.
Wechat
,
user
.
Wechat
)
require
.
Equal
(
t
,
input
.
Notes
,
user
.
Notes
)
require
.
Equal
(
t
,
input
.
Notes
,
user
.
Notes
)
require
.
Equal
(
t
,
input
.
Balance
,
user
.
Balance
)
require
.
Equal
(
t
,
input
.
Balance
,
user
.
Balance
)
require
.
Equal
(
t
,
input
.
Concurrency
,
user
.
Concurrency
)
require
.
Equal
(
t
,
input
.
Concurrency
,
user
.
Concurrency
)
...
...
backend/internal/service/admin_service_delete_test.go
View file @
106e59b7
...
@@ -66,7 +66,7 @@ func (s *userRepoStub) List(ctx context.Context, params pagination.PaginationPar
...
@@ -66,7 +66,7 @@ func (s *userRepoStub) List(ctx context.Context, params pagination.PaginationPar
panic
(
"unexpected List call"
)
panic
(
"unexpected List call"
)
}
}
func
(
s
*
userRepoStub
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
status
,
role
,
search
string
)
([]
User
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
s
*
userRepoStub
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
UserListFilters
)
([]
User
,
*
pagination
.
PaginationResult
,
error
)
{
panic
(
"unexpected ListWithFilters call"
)
panic
(
"unexpected ListWithFilters call"
)
}
}
...
...
backend/internal/service/user.go
View file @
106e59b7
...
@@ -10,7 +10,6 @@ type User struct {
...
@@ -10,7 +10,6 @@ type User struct {
ID
int64
ID
int64
Email
string
Email
string
Username
string
Username
string
Wechat
string
Notes
string
Notes
string
PasswordHash
string
PasswordHash
string
Role
string
Role
string
...
...
backend/internal/service/user_attribute.go
0 → 100644
View file @
106e59b7
package
service
import
(
"context"
"time"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// Error definitions for user attribute operations
var
(
ErrAttributeDefinitionNotFound
=
infraerrors
.
NotFound
(
"ATTRIBUTE_DEFINITION_NOT_FOUND"
,
"attribute definition not found"
)
ErrAttributeKeyExists
=
infraerrors
.
Conflict
(
"ATTRIBUTE_KEY_EXISTS"
,
"attribute key already exists"
)
ErrInvalidAttributeType
=
infraerrors
.
BadRequest
(
"INVALID_ATTRIBUTE_TYPE"
,
"invalid attribute type"
)
ErrAttributeValidationFailed
=
infraerrors
.
BadRequest
(
"ATTRIBUTE_VALIDATION_FAILED"
,
"attribute value validation failed"
)
)
// UserAttributeType represents supported attribute types
type
UserAttributeType
string
const
(
AttributeTypeText
UserAttributeType
=
"text"
AttributeTypeTextarea
UserAttributeType
=
"textarea"
AttributeTypeNumber
UserAttributeType
=
"number"
AttributeTypeEmail
UserAttributeType
=
"email"
AttributeTypeURL
UserAttributeType
=
"url"
AttributeTypeDate
UserAttributeType
=
"date"
AttributeTypeSelect
UserAttributeType
=
"select"
AttributeTypeMultiSelect
UserAttributeType
=
"multi_select"
)
// UserAttributeOption represents a select option for select/multi_select types
type
UserAttributeOption
struct
{
Value
string
`json:"value"`
Label
string
`json:"label"`
}
// UserAttributeValidation represents validation rules for an attribute
type
UserAttributeValidation
struct
{
MinLength
*
int
`json:"min_length,omitempty"`
MaxLength
*
int
`json:"max_length,omitempty"`
Min
*
int
`json:"min,omitempty"`
Max
*
int
`json:"max,omitempty"`
Pattern
*
string
`json:"pattern,omitempty"`
Message
*
string
`json:"message,omitempty"`
}
// UserAttributeDefinition represents a custom attribute definition
type
UserAttributeDefinition
struct
{
ID
int64
Key
string
Name
string
Description
string
Type
UserAttributeType
Options
[]
UserAttributeOption
Required
bool
Validation
UserAttributeValidation
Placeholder
string
DisplayOrder
int
Enabled
bool
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
}
// UserAttributeValue represents a user's attribute value
type
UserAttributeValue
struct
{
ID
int64
UserID
int64
AttributeID
int64
Value
string
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
}
// CreateAttributeDefinitionInput for creating new definition
type
CreateAttributeDefinitionInput
struct
{
Key
string
Name
string
Description
string
Type
UserAttributeType
Options
[]
UserAttributeOption
Required
bool
Validation
UserAttributeValidation
Placeholder
string
Enabled
bool
}
// UpdateAttributeDefinitionInput for updating definition
type
UpdateAttributeDefinitionInput
struct
{
Name
*
string
Description
*
string
Type
*
UserAttributeType
Options
*
[]
UserAttributeOption
Required
*
bool
Validation
*
UserAttributeValidation
Placeholder
*
string
Enabled
*
bool
}
// UpdateUserAttributeInput for updating a single attribute value
type
UpdateUserAttributeInput
struct
{
AttributeID
int64
Value
string
}
// UserAttributeDefinitionRepository interface for attribute definition persistence
type
UserAttributeDefinitionRepository
interface
{
Create
(
ctx
context
.
Context
,
def
*
UserAttributeDefinition
)
error
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
UserAttributeDefinition
,
error
)
GetByKey
(
ctx
context
.
Context
,
key
string
)
(
*
UserAttributeDefinition
,
error
)
Update
(
ctx
context
.
Context
,
def
*
UserAttributeDefinition
)
error
Delete
(
ctx
context
.
Context
,
id
int64
)
error
List
(
ctx
context
.
Context
,
enabledOnly
bool
)
([]
UserAttributeDefinition
,
error
)
UpdateDisplayOrders
(
ctx
context
.
Context
,
orders
map
[
int64
]
int
)
error
ExistsByKey
(
ctx
context
.
Context
,
key
string
)
(
bool
,
error
)
}
// UserAttributeValueRepository interface for user attribute value persistence
type
UserAttributeValueRepository
interface
{
GetByUserID
(
ctx
context
.
Context
,
userID
int64
)
([]
UserAttributeValue
,
error
)
GetByUserIDs
(
ctx
context
.
Context
,
userIDs
[]
int64
)
([]
UserAttributeValue
,
error
)
UpsertBatch
(
ctx
context
.
Context
,
userID
int64
,
values
[]
UpdateUserAttributeInput
)
error
DeleteByAttributeID
(
ctx
context
.
Context
,
attributeID
int64
)
error
DeleteByUserID
(
ctx
context
.
Context
,
userID
int64
)
error
}
backend/internal/service/user_attribute_service.go
0 → 100644
View file @
106e59b7
package
service
import
(
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// UserAttributeService handles attribute management
type
UserAttributeService
struct
{
defRepo
UserAttributeDefinitionRepository
valueRepo
UserAttributeValueRepository
}
// NewUserAttributeService creates a new service instance
func
NewUserAttributeService
(
defRepo
UserAttributeDefinitionRepository
,
valueRepo
UserAttributeValueRepository
,
)
*
UserAttributeService
{
return
&
UserAttributeService
{
defRepo
:
defRepo
,
valueRepo
:
valueRepo
,
}
}
// CreateDefinition creates a new attribute definition
func
(
s
*
UserAttributeService
)
CreateDefinition
(
ctx
context
.
Context
,
input
CreateAttributeDefinitionInput
)
(
*
UserAttributeDefinition
,
error
)
{
// Validate type
if
!
isValidAttributeType
(
input
.
Type
)
{
return
nil
,
ErrInvalidAttributeType
}
// Check if key exists
exists
,
err
:=
s
.
defRepo
.
ExistsByKey
(
ctx
,
input
.
Key
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"check key exists: %w"
,
err
)
}
if
exists
{
return
nil
,
ErrAttributeKeyExists
}
def
:=
&
UserAttributeDefinition
{
Key
:
input
.
Key
,
Name
:
input
.
Name
,
Description
:
input
.
Description
,
Type
:
input
.
Type
,
Options
:
input
.
Options
,
Required
:
input
.
Required
,
Validation
:
input
.
Validation
,
Placeholder
:
input
.
Placeholder
,
Enabled
:
input
.
Enabled
,
}
if
err
:=
s
.
defRepo
.
Create
(
ctx
,
def
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"create definition: %w"
,
err
)
}
return
def
,
nil
}
// GetDefinition retrieves a definition by ID
func
(
s
*
UserAttributeService
)
GetDefinition
(
ctx
context
.
Context
,
id
int64
)
(
*
UserAttributeDefinition
,
error
)
{
return
s
.
defRepo
.
GetByID
(
ctx
,
id
)
}
// ListDefinitions lists all definitions
func
(
s
*
UserAttributeService
)
ListDefinitions
(
ctx
context
.
Context
,
enabledOnly
bool
)
([]
UserAttributeDefinition
,
error
)
{
return
s
.
defRepo
.
List
(
ctx
,
enabledOnly
)
}
// UpdateDefinition updates an existing definition
func
(
s
*
UserAttributeService
)
UpdateDefinition
(
ctx
context
.
Context
,
id
int64
,
input
UpdateAttributeDefinitionInput
)
(
*
UserAttributeDefinition
,
error
)
{
def
,
err
:=
s
.
defRepo
.
GetByID
(
ctx
,
id
)
if
err
!=
nil
{
return
nil
,
err
}
if
input
.
Name
!=
nil
{
def
.
Name
=
*
input
.
Name
}
if
input
.
Description
!=
nil
{
def
.
Description
=
*
input
.
Description
}
if
input
.
Type
!=
nil
{
if
!
isValidAttributeType
(
*
input
.
Type
)
{
return
nil
,
ErrInvalidAttributeType
}
def
.
Type
=
*
input
.
Type
}
if
input
.
Options
!=
nil
{
def
.
Options
=
*
input
.
Options
}
if
input
.
Required
!=
nil
{
def
.
Required
=
*
input
.
Required
}
if
input
.
Validation
!=
nil
{
def
.
Validation
=
*
input
.
Validation
}
if
input
.
Placeholder
!=
nil
{
def
.
Placeholder
=
*
input
.
Placeholder
}
if
input
.
Enabled
!=
nil
{
def
.
Enabled
=
*
input
.
Enabled
}
if
err
:=
s
.
defRepo
.
Update
(
ctx
,
def
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"update definition: %w"
,
err
)
}
return
def
,
nil
}
// DeleteDefinition soft-deletes a definition and hard-deletes associated values
func
(
s
*
UserAttributeService
)
DeleteDefinition
(
ctx
context
.
Context
,
id
int64
)
error
{
// Check if definition exists
_
,
err
:=
s
.
defRepo
.
GetByID
(
ctx
,
id
)
if
err
!=
nil
{
return
err
}
// First delete all values (hard delete)
if
err
:=
s
.
valueRepo
.
DeleteByAttributeID
(
ctx
,
id
);
err
!=
nil
{
return
fmt
.
Errorf
(
"delete values: %w"
,
err
)
}
// Then soft-delete the definition
if
err
:=
s
.
defRepo
.
Delete
(
ctx
,
id
);
err
!=
nil
{
return
fmt
.
Errorf
(
"delete definition: %w"
,
err
)
}
return
nil
}
// ReorderDefinitions updates display order for multiple definitions
func
(
s
*
UserAttributeService
)
ReorderDefinitions
(
ctx
context
.
Context
,
orders
map
[
int64
]
int
)
error
{
return
s
.
defRepo
.
UpdateDisplayOrders
(
ctx
,
orders
)
}
// GetUserAttributes retrieves all attribute values for a user
func
(
s
*
UserAttributeService
)
GetUserAttributes
(
ctx
context
.
Context
,
userID
int64
)
([]
UserAttributeValue
,
error
)
{
return
s
.
valueRepo
.
GetByUserID
(
ctx
,
userID
)
}
// GetBatchUserAttributes retrieves attribute values for multiple users
// Returns a map of userID -> map of attributeID -> value
func
(
s
*
UserAttributeService
)
GetBatchUserAttributes
(
ctx
context
.
Context
,
userIDs
[]
int64
)
(
map
[
int64
]
map
[
int64
]
string
,
error
)
{
values
,
err
:=
s
.
valueRepo
.
GetByUserIDs
(
ctx
,
userIDs
)
if
err
!=
nil
{
return
nil
,
err
}
result
:=
make
(
map
[
int64
]
map
[
int64
]
string
)
for
_
,
v
:=
range
values
{
if
result
[
v
.
UserID
]
==
nil
{
result
[
v
.
UserID
]
=
make
(
map
[
int64
]
string
)
}
result
[
v
.
UserID
][
v
.
AttributeID
]
=
v
.
Value
}
return
result
,
nil
}
// UpdateUserAttributes batch updates attribute values for a user
func
(
s
*
UserAttributeService
)
UpdateUserAttributes
(
ctx
context
.
Context
,
userID
int64
,
inputs
[]
UpdateUserAttributeInput
)
error
{
// Validate all values before updating
defs
,
err
:=
s
.
defRepo
.
List
(
ctx
,
true
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"list definitions: %w"
,
err
)
}
defMap
:=
make
(
map
[
int64
]
*
UserAttributeDefinition
,
len
(
defs
))
for
i
:=
range
defs
{
defMap
[
defs
[
i
]
.
ID
]
=
&
defs
[
i
]
}
for
_
,
input
:=
range
inputs
{
def
,
ok
:=
defMap
[
input
.
AttributeID
]
if
!
ok
{
return
ErrAttributeDefinitionNotFound
}
if
err
:=
s
.
validateValue
(
def
,
input
.
Value
);
err
!=
nil
{
return
err
}
}
return
s
.
valueRepo
.
UpsertBatch
(
ctx
,
userID
,
inputs
)
}
// validateValue validates a value against its definition
func
(
s
*
UserAttributeService
)
validateValue
(
def
*
UserAttributeDefinition
,
value
string
)
error
{
// Skip validation for empty non-required fields
if
value
==
""
&&
!
def
.
Required
{
return
nil
}
// Required check
if
def
.
Required
&&
value
==
""
{
return
validationError
(
fmt
.
Sprintf
(
"%s is required"
,
def
.
Name
))
}
v
:=
def
.
Validation
// String length validation
if
v
.
MinLength
!=
nil
&&
len
(
value
)
<
*
v
.
MinLength
{
return
validationError
(
fmt
.
Sprintf
(
"%s must be at least %d characters"
,
def
.
Name
,
*
v
.
MinLength
))
}
if
v
.
MaxLength
!=
nil
&&
len
(
value
)
>
*
v
.
MaxLength
{
return
validationError
(
fmt
.
Sprintf
(
"%s must be at most %d characters"
,
def
.
Name
,
*
v
.
MaxLength
))
}
// Number validation
if
def
.
Type
==
AttributeTypeNumber
&&
value
!=
""
{
num
,
err
:=
strconv
.
Atoi
(
value
)
if
err
!=
nil
{
return
validationError
(
fmt
.
Sprintf
(
"%s must be a number"
,
def
.
Name
))
}
if
v
.
Min
!=
nil
&&
num
<
*
v
.
Min
{
return
validationError
(
fmt
.
Sprintf
(
"%s must be at least %d"
,
def
.
Name
,
*
v
.
Min
))
}
if
v
.
Max
!=
nil
&&
num
>
*
v
.
Max
{
return
validationError
(
fmt
.
Sprintf
(
"%s must be at most %d"
,
def
.
Name
,
*
v
.
Max
))
}
}
// Pattern validation
if
v
.
Pattern
!=
nil
&&
*
v
.
Pattern
!=
""
&&
value
!=
""
{
re
,
err
:=
regexp
.
Compile
(
*
v
.
Pattern
)
if
err
==
nil
&&
!
re
.
MatchString
(
value
)
{
msg
:=
def
.
Name
+
" format is invalid"
if
v
.
Message
!=
nil
&&
*
v
.
Message
!=
""
{
msg
=
*
v
.
Message
}
return
validationError
(
msg
)
}
}
// Select validation
if
def
.
Type
==
AttributeTypeSelect
&&
value
!=
""
{
found
:=
false
for
_
,
opt
:=
range
def
.
Options
{
if
opt
.
Value
==
value
{
found
=
true
break
}
}
if
!
found
{
return
validationError
(
fmt
.
Sprintf
(
"%s: invalid option"
,
def
.
Name
))
}
}
// Multi-select validation (stored as JSON array)
if
def
.
Type
==
AttributeTypeMultiSelect
&&
value
!=
""
{
var
values
[]
string
if
err
:=
json
.
Unmarshal
([]
byte
(
value
),
&
values
);
err
!=
nil
{
// Try comma-separated fallback
values
=
strings
.
Split
(
value
,
","
)
}
for
_
,
val
:=
range
values
{
val
=
strings
.
TrimSpace
(
val
)
found
:=
false
for
_
,
opt
:=
range
def
.
Options
{
if
opt
.
Value
==
val
{
found
=
true
break
}
}
if
!
found
{
return
validationError
(
fmt
.
Sprintf
(
"%s: invalid option %s"
,
def
.
Name
,
val
))
}
}
}
return
nil
}
// validationError creates a validation error with a custom message
func
validationError
(
msg
string
)
error
{
return
infraerrors
.
BadRequest
(
"ATTRIBUTE_VALIDATION_FAILED"
,
msg
)
}
func
isValidAttributeType
(
t
UserAttributeType
)
bool
{
switch
t
{
case
AttributeTypeText
,
AttributeTypeTextarea
,
AttributeTypeNumber
,
AttributeTypeEmail
,
AttributeTypeURL
,
AttributeTypeDate
,
AttributeTypeSelect
,
AttributeTypeMultiSelect
:
return
true
}
return
false
}
backend/internal/service/user_service.go
View file @
106e59b7
...
@@ -14,6 +14,14 @@ var (
...
@@ -14,6 +14,14 @@ var (
ErrInsufficientPerms
=
infraerrors
.
Forbidden
(
"INSUFFICIENT_PERMISSIONS"
,
"insufficient permissions"
)
ErrInsufficientPerms
=
infraerrors
.
Forbidden
(
"INSUFFICIENT_PERMISSIONS"
,
"insufficient permissions"
)
)
)
// UserListFilters contains all filter options for listing users
type
UserListFilters
struct
{
Status
string
// User status filter
Role
string
// User role filter
Search
string
// Search in email, username
Attributes
map
[
int64
]
string
// Custom attribute filters: attributeID -> value
}
type
UserRepository
interface
{
type
UserRepository
interface
{
Create
(
ctx
context
.
Context
,
user
*
User
)
error
Create
(
ctx
context
.
Context
,
user
*
User
)
error
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
User
,
error
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
User
,
error
)
...
@@ -23,7 +31,7 @@ type UserRepository interface {
...
@@ -23,7 +31,7 @@ type UserRepository interface {
Delete
(
ctx
context
.
Context
,
id
int64
)
error
Delete
(
ctx
context
.
Context
,
id
int64
)
error
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
User
,
*
pagination
.
PaginationResult
,
error
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
User
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
status
,
role
,
search
string
)
([]
User
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
UserListFilters
)
([]
User
,
*
pagination
.
PaginationResult
,
error
)
UpdateBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
UpdateBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
DeductBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
DeductBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
...
@@ -36,7 +44,6 @@ type UserRepository interface {
...
@@ -36,7 +44,6 @@ type UserRepository interface {
type
UpdateProfileRequest
struct
{
type
UpdateProfileRequest
struct
{
Email
*
string
`json:"email"`
Email
*
string
`json:"email"`
Username
*
string
`json:"username"`
Username
*
string
`json:"username"`
Wechat
*
string
`json:"wechat"`
Concurrency
*
int
`json:"concurrency"`
Concurrency
*
int
`json:"concurrency"`
}
}
...
@@ -100,10 +107,6 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
...
@@ -100,10 +107,6 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
user
.
Username
=
*
req
.
Username
user
.
Username
=
*
req
.
Username
}
}
if
req
.
Wechat
!=
nil
{
user
.
Wechat
=
*
req
.
Wechat
}
if
req
.
Concurrency
!=
nil
{
if
req
.
Concurrency
!=
nil
{
user
.
Concurrency
=
*
req
.
Concurrency
user
.
Concurrency
=
*
req
.
Concurrency
}
}
...
...
backend/internal/service/wire.go
View file @
106e59b7
...
@@ -125,4 +125,5 @@ var ProviderSet = wire.NewSet(
...
@@ -125,4 +125,5 @@ var ProviderSet = wire.NewSet(
ProvideTimingWheelService
,
ProvideTimingWheelService
,
ProvideDeferredService
,
ProvideDeferredService
,
ProvideAntigravityQuotaRefresher
,
ProvideAntigravityQuotaRefresher
,
NewUserAttributeService
,
)
)
backend/migrations/018_user_attributes.sql
0 → 100644
View file @
106e59b7
-- Add user attribute definitions and values tables for custom user attributes.
-- User Attribute Definitions table (with soft delete support)
CREATE
TABLE
IF
NOT
EXISTS
user_attribute_definitions
(
id
BIGSERIAL
PRIMARY
KEY
,
key
VARCHAR
(
100
)
NOT
NULL
,
name
VARCHAR
(
255
)
NOT
NULL
,
description
TEXT
DEFAULT
''
,
type
VARCHAR
(
20
)
NOT
NULL
,
options
JSONB
DEFAULT
'[]'
::
jsonb
,
required
BOOLEAN
NOT
NULL
DEFAULT
FALSE
,
validation
JSONB
DEFAULT
'{}'
::
jsonb
,
placeholder
VARCHAR
(
255
)
DEFAULT
''
,
display_order
INT
NOT
NULL
DEFAULT
0
,
enabled
BOOLEAN
NOT
NULL
DEFAULT
TRUE
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
deleted_at
TIMESTAMPTZ
);
-- Partial unique index for key (only for non-deleted records)
-- Allows reusing keys after soft delete
CREATE
UNIQUE
INDEX
IF
NOT
EXISTS
idx_user_attribute_definitions_key_unique
ON
user_attribute_definitions
(
key
)
WHERE
deleted_at
IS
NULL
;
CREATE
INDEX
IF
NOT
EXISTS
idx_user_attribute_definitions_enabled
ON
user_attribute_definitions
(
enabled
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_attribute_definitions_display_order
ON
user_attribute_definitions
(
display_order
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_attribute_definitions_deleted_at
ON
user_attribute_definitions
(
deleted_at
);
-- User Attribute Values table (hard delete only, no deleted_at)
CREATE
TABLE
IF
NOT
EXISTS
user_attribute_values
(
id
BIGSERIAL
PRIMARY
KEY
,
user_id
BIGINT
NOT
NULL
REFERENCES
users
(
id
)
ON
DELETE
CASCADE
,
attribute_id
BIGINT
NOT
NULL
REFERENCES
user_attribute_definitions
(
id
)
ON
DELETE
CASCADE
,
value
TEXT
DEFAULT
''
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
UNIQUE
(
user_id
,
attribute_id
)
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_attribute_values_user_id
ON
user_attribute_values
(
user_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_attribute_values_attribute_id
ON
user_attribute_values
(
attribute_id
);
backend/migrations/019_migrate_wechat_to_attributes.sql
0 → 100644
View file @
106e59b7
-- Migration: Move wechat field from users table to user_attribute_values
-- This migration:
-- 1. Creates a "wechat" attribute definition
-- 2. Migrates existing wechat data to user_attribute_values
-- 3. Does NOT drop the wechat column (for rollback safety, can be done in a later migration)
-- +goose Up
-- +goose StatementBegin
-- Step 1: Insert wechat attribute definition if not exists
INSERT
INTO
user_attribute_definitions
(
key
,
name
,
description
,
type
,
options
,
required
,
validation
,
placeholder
,
display_order
,
enabled
,
created_at
,
updated_at
)
SELECT
'wechat'
,
'微信'
,
'用户微信号'
,
'text'
,
'[]'
::
jsonb
,
false
,
'{}'
::
jsonb
,
'请输入微信号'
,
0
,
true
,
NOW
(),
NOW
()
WHERE
NOT
EXISTS
(
SELECT
1
FROM
user_attribute_definitions
WHERE
key
=
'wechat'
AND
deleted_at
IS
NULL
);
-- Step 2: Migrate existing wechat values to user_attribute_values
-- Only migrate non-empty values
INSERT
INTO
user_attribute_values
(
user_id
,
attribute_id
,
value
,
created_at
,
updated_at
)
SELECT
u
.
id
,
(
SELECT
id
FROM
user_attribute_definitions
WHERE
key
=
'wechat'
AND
deleted_at
IS
NULL
LIMIT
1
),
u
.
wechat
,
NOW
(),
NOW
()
FROM
users
u
WHERE
u
.
wechat
IS
NOT
NULL
AND
u
.
wechat
!=
''
AND
u
.
deleted_at
IS
NULL
AND
NOT
EXISTS
(
SELECT
1
FROM
user_attribute_values
uav
WHERE
uav
.
user_id
=
u
.
id
AND
uav
.
attribute_id
=
(
SELECT
id
FROM
user_attribute_definitions
WHERE
key
=
'wechat'
AND
deleted_at
IS
NULL
LIMIT
1
)
);
-- Step 3: Update display_order to ensure wechat appears first
UPDATE
user_attribute_definitions
SET
display_order
=
-
1
WHERE
key
=
'wechat'
AND
deleted_at
IS
NULL
;
-- Reorder all attributes starting from 0
WITH
ordered
AS
(
SELECT
id
,
ROW_NUMBER
()
OVER
(
ORDER
BY
display_order
,
id
)
-
1
as
new_order
FROM
user_attribute_definitions
WHERE
deleted_at
IS
NULL
)
UPDATE
user_attribute_definitions
SET
display_order
=
ordered
.
new_order
FROM
ordered
WHERE
user_attribute_definitions
.
id
=
ordered
.
id
;
-- Step 4: Drop the redundant wechat column from users table
ALTER
TABLE
users
DROP
COLUMN
IF
EXISTS
wechat
;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Restore wechat column
ALTER
TABLE
users
ADD
COLUMN
IF
NOT
EXISTS
wechat
VARCHAR
(
100
)
DEFAULT
''
;
-- Copy attribute values back to users.wechat column
UPDATE
users
u
SET
wechat
=
uav
.
value
FROM
user_attribute_values
uav
JOIN
user_attribute_definitions
uad
ON
uav
.
attribute_id
=
uad
.
id
WHERE
uav
.
user_id
=
u
.
id
AND
uad
.
key
=
'wechat'
AND
uad
.
deleted_at
IS
NULL
;
-- Delete migrated attribute values
DELETE
FROM
user_attribute_values
WHERE
attribute_id
IN
(
SELECT
id
FROM
user_attribute_definitions
WHERE
key
=
'wechat'
AND
deleted_at
IS
NULL
);
-- Soft-delete the wechat attribute definition
UPDATE
user_attribute_definitions
SET
deleted_at
=
NOW
()
WHERE
key
=
'wechat'
AND
deleted_at
IS
NULL
;
-- +goose StatementEnd
Prev
1
2
3
4
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment