refactor: migrate wechat to user attributes and enhance users list
Migrate the hardcoded wechat field to the new extensible user attributes system and improve the users management UI. Migration: - Add migration 019 to move wechat data to user_attribute_values - Remove wechat field from User entity, DTOs, and API contracts - Clean up wechat-related code from backend and frontend UsersView enhancements: - Add text labels to action buttons (Filter Settings, Column Settings, Attributes Config) for better UX - Change status column to show colored dot + Chinese text instead of English text - Add dynamic attribute columns support with batch loading - Add column visibility settings with localStorage persistence - Add filter settings modal for search and filter preferences - Update i18n translations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -246,7 +246,7 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Limit to 30 results
|
||||
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, "", "", keyword)
|
||||
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
|
||||
@@ -27,7 +27,6 @@ type CreateUserRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Username string `json:"username"`
|
||||
Wechat string `json:"wechat"`
|
||||
Notes string `json:"notes"`
|
||||
Balance float64 `json:"balance"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
@@ -40,7 +39,6 @@ type UpdateUserRequest struct {
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Password string `json:"password" binding:"omitempty,min=6"`
|
||||
Username *string `json:"username"`
|
||||
Wechat *string `json:"wechat"`
|
||||
Notes *string `json:"notes"`
|
||||
Balance *float64 `json:"balance"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
@@ -57,13 +55,22 @@ type UpdateBalanceRequest struct {
|
||||
|
||||
// List handles listing all users with pagination
|
||||
// GET /api/v1/admin/users
|
||||
// Query params:
|
||||
// - status: filter by user status
|
||||
// - role: filter by user role
|
||||
// - search: search in email, username
|
||||
// - attr[{id}]: filter by custom attribute value, e.g. attr[1]=company
|
||||
func (h *UserHandler) List(c *gin.Context) {
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
status := c.Query("status")
|
||||
role := c.Query("role")
|
||||
search := c.Query("search")
|
||||
|
||||
users, total, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, status, role, search)
|
||||
filters := service.UserListFilters{
|
||||
Status: c.Query("status"),
|
||||
Role: c.Query("role"),
|
||||
Search: c.Query("search"),
|
||||
Attributes: parseAttributeFilters(c),
|
||||
}
|
||||
|
||||
users, total, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, filters)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -76,6 +83,29 @@ func (h *UserHandler) List(c *gin.Context) {
|
||||
response.Paginated(c, out, total, page, pageSize)
|
||||
}
|
||||
|
||||
// parseAttributeFilters extracts attribute filters from query params
|
||||
// Format: attr[{attributeID}]=value, e.g. attr[1]=company&attr[2]=developer
|
||||
func parseAttributeFilters(c *gin.Context) map[int64]string {
|
||||
result := make(map[int64]string)
|
||||
|
||||
// Get all query params and look for attr[*] pattern
|
||||
for key, values := range c.Request.URL.Query() {
|
||||
if len(values) == 0 || values[0] == "" {
|
||||
continue
|
||||
}
|
||||
// Check if key matches pattern attr[{id}]
|
||||
if len(key) > 5 && key[:5] == "attr[" && key[len(key)-1] == ']' {
|
||||
idStr := key[5 : len(key)-1]
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err == nil && id > 0 {
|
||||
result[id] = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetByID handles getting a user by ID
|
||||
// GET /api/v1/admin/users/:id
|
||||
func (h *UserHandler) GetByID(c *gin.Context) {
|
||||
@@ -107,7 +137,6 @@ func (h *UserHandler) Create(c *gin.Context) {
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Wechat: req.Wechat,
|
||||
Notes: req.Notes,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
@@ -141,7 +170,6 @@ func (h *UserHandler) Update(c *gin.Context) {
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Wechat: req.Wechat,
|
||||
Notes: req.Notes,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
|
||||
@@ -10,7 +10,6 @@ func UserFromServiceShallow(u *service.User) *User {
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Username: u.Username,
|
||||
Wechat: u.Wechat,
|
||||
Notes: u.Notes,
|
||||
Role: u.Role,
|
||||
Balance: u.Balance,
|
||||
|
||||
@@ -6,7 +6,6 @@ type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Wechat string `json:"wechat"`
|
||||
Notes string `json:"notes"`
|
||||
Role string `json:"role"`
|
||||
Balance float64 `json:"balance"`
|
||||
|
||||
@@ -30,7 +30,6 @@ type ChangePasswordRequest struct {
|
||||
// UpdateProfileRequest represents the update profile request payload
|
||||
type UpdateProfileRequest struct {
|
||||
Username *string `json:"username"`
|
||||
Wechat *string `json:"wechat"`
|
||||
}
|
||||
|
||||
// GetProfile handles getting user profile
|
||||
@@ -99,7 +98,6 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
|
||||
svcReq := service.UpdateProfileRequest{
|
||||
Username: req.Username,
|
||||
Wechat: req.Wechat,
|
||||
}
|
||||
updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq)
|
||||
if err != nil {
|
||||
|
||||
@@ -294,7 +294,6 @@ func userEntityToService(u *dbent.User) *service.User {
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Username: u.Username,
|
||||
Wechat: u.Wechat,
|
||||
Notes: u.Notes,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: u.Role,
|
||||
|
||||
@@ -40,7 +40,6 @@ func mustCreateUser(t *testing.T, client *dbent.Client, u *service.User) *servic
|
||||
SetBalance(u.Balance).
|
||||
SetConcurrency(u.Concurrency).
|
||||
SetUsername(u.Username).
|
||||
SetWechat(u.Wechat).
|
||||
SetNotes(u.Notes)
|
||||
if !u.CreatedAt.IsZero() {
|
||||
create.SetCreatedAt(u.CreatedAt)
|
||||
|
||||
@@ -23,7 +23,6 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
|
||||
|
||||
// users: columns required by repository queries
|
||||
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)
|
||||
|
||||
// accounts: schedulable and rate-limit fields
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||
"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/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -50,7 +51,6 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
|
||||
created, err := txClient.User.Create().
|
||||
SetEmail(userIn.Email).
|
||||
SetUsername(userIn.Username).
|
||||
SetWechat(userIn.Wechat).
|
||||
SetNotes(userIn.Notes).
|
||||
SetPasswordHash(userIn.PasswordHash).
|
||||
SetRole(userIn.Role).
|
||||
@@ -133,7 +133,6 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
|
||||
updated, err := txClient.User.UpdateOneID(userIn.ID).
|
||||
SetEmail(userIn.Email).
|
||||
SetUsername(userIn.Username).
|
||||
SetWechat(userIn.Wechat).
|
||||
SetNotes(userIn.Notes).
|
||||
SetPasswordHash(userIn.PasswordHash).
|
||||
SetRole(userIn.Role).
|
||||
@@ -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) {
|
||||
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()
|
||||
|
||||
if status != "" {
|
||||
q = q.Where(dbuser.StatusEQ(status))
|
||||
if filters.Status != "" {
|
||||
q = q.Where(dbuser.StatusEQ(filters.Status))
|
||||
}
|
||||
if role != "" {
|
||||
q = q.Where(dbuser.RoleEQ(role))
|
||||
if filters.Role != "" {
|
||||
q = q.Where(dbuser.RoleEQ(filters.Role))
|
||||
}
|
||||
if search != "" {
|
||||
if filters.Search != "" {
|
||||
q = q.Where(
|
||||
dbuser.Or(
|
||||
dbuser.EmailContainsFold(search),
|
||||
dbuser.UsernameContainsFold(search),
|
||||
dbuser.WechatContainsFold(search),
|
||||
dbuser.EmailContainsFold(filters.Search),
|
||||
dbuser.UsernameContainsFold(filters.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)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -252,6 +261,59 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
|
||||
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 {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
n, err := client.User.Update().Where(dbuser.IDEQ(id)).AddBalance(amount).Save(ctx)
|
||||
|
||||
@@ -202,16 +202,6 @@ func (s *UserRepoSuite) TestListWithFilters_SearchByUsername() {
|
||||
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() {
|
||||
user := s.mustCreateUser(&service.User{Email: "sub@test.com", Status: service.StatusActive})
|
||||
groupActive := s.mustCreateGroup("g-sub-active")
|
||||
@@ -238,7 +228,6 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
|
||||
s.mustCreateUser(&service.User{
|
||||
Email: "a@example.com",
|
||||
Username: "Alice",
|
||||
Wechat: "wx_a",
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
Balance: 10,
|
||||
@@ -246,7 +235,6 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
|
||||
target := s.mustCreateUser(&service.User{
|
||||
Email: "b@example.com",
|
||||
Username: "Bob",
|
||||
Wechat: "wx_b",
|
||||
Role: service.RoleAdmin,
|
||||
Status: service.StatusActive,
|
||||
Balance: 1,
|
||||
@@ -448,7 +436,6 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
|
||||
user1 := s.mustCreateUser(&service.User{
|
||||
Email: "a@example.com",
|
||||
Username: "Alice",
|
||||
Wechat: "wx_a",
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
Balance: 10,
|
||||
@@ -456,7 +443,6 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
|
||||
user2 := s.mustCreateUser(&service.User{
|
||||
Email: "b@example.com",
|
||||
Username: "Bob",
|
||||
Wechat: "wx_b",
|
||||
Role: service.RoleAdmin,
|
||||
Status: service.StatusActive,
|
||||
Balance: 1,
|
||||
|
||||
@@ -51,7 +51,6 @@ func TestAPIContracts(t *testing.T) {
|
||||
"id": 1,
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"wechat": "wx_alice",
|
||||
"notes": "hello",
|
||||
"role": "user",
|
||||
"balance": 12.5,
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// AdminService interface defines admin management operations
|
||||
type AdminService interface {
|
||||
// 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)
|
||||
CreateUser(ctx context.Context, input *CreateUserInput) (*User, error)
|
||||
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
|
||||
@@ -69,7 +69,6 @@ type CreateUserInput struct {
|
||||
Email string
|
||||
Password string
|
||||
Username string
|
||||
Wechat string
|
||||
Notes string
|
||||
Balance float64
|
||||
Concurrency int
|
||||
@@ -80,7 +79,6 @@ type UpdateUserInput struct {
|
||||
Email string
|
||||
Password string
|
||||
Username *string
|
||||
Wechat *string
|
||||
Notes *string
|
||||
Balance *float64 // 使用指针区分"未提供"和"设置为0"
|
||||
Concurrency *int // 使用指针区分"未提供"和"设置为0"
|
||||
@@ -251,9 +249,9 @@ func NewAdminService(
|
||||
}
|
||||
|
||||
// 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}
|
||||
users, result, err := s.userRepo.ListWithFilters(ctx, params, status, role, search)
|
||||
users, result, err := s.userRepo.ListWithFilters(ctx, params, filters)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -268,7 +266,6 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
|
||||
user := &User{
|
||||
Email: input.Email,
|
||||
Username: input.Username,
|
||||
Wechat: input.Wechat,
|
||||
Notes: input.Notes,
|
||||
Role: RoleUser, // Always create as regular user, never admin
|
||||
Balance: input.Balance,
|
||||
@@ -310,9 +307,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
if input.Username != nil {
|
||||
user.Username = *input.Username
|
||||
}
|
||||
if input.Wechat != nil {
|
||||
user.Wechat = *input.Wechat
|
||||
}
|
||||
if input.Notes != nil {
|
||||
user.Notes = *input.Notes
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ func TestAdminService_CreateUser_Success(t *testing.T) {
|
||||
Email: "user@test.com",
|
||||
Password: "strong-pass",
|
||||
Username: "tester",
|
||||
Wechat: "wx",
|
||||
Notes: "note",
|
||||
Balance: 12.5,
|
||||
Concurrency: 7,
|
||||
@@ -31,7 +30,6 @@ func TestAdminService_CreateUser_Success(t *testing.T) {
|
||||
require.Equal(t, int64(10), user.ID)
|
||||
require.Equal(t, input.Email, user.Email)
|
||||
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.Balance, user.Balance)
|
||||
require.Equal(t, input.Concurrency, user.Concurrency)
|
||||
|
||||
@@ -10,7 +10,6 @@ type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
Username string
|
||||
Wechat string
|
||||
Notes string
|
||||
PasswordHash string
|
||||
Role string
|
||||
|
||||
@@ -14,6 +14,14 @@ var (
|
||||
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 {
|
||||
Create(ctx context.Context, user *User) error
|
||||
GetByID(ctx context.Context, id int64) (*User, error)
|
||||
@@ -23,7 +31,7 @@ type UserRepository interface {
|
||||
Delete(ctx context.Context, id int64) 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
|
||||
DeductBalance(ctx context.Context, id int64, amount float64) error
|
||||
@@ -36,7 +44,6 @@ type UserRepository interface {
|
||||
type UpdateProfileRequest struct {
|
||||
Email *string `json:"email"`
|
||||
Username *string `json:"username"`
|
||||
Wechat *string `json:"wechat"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
}
|
||||
|
||||
@@ -100,10 +107,6 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
||||
user.Username = *req.Username
|
||||
}
|
||||
|
||||
if req.Wechat != nil {
|
||||
user.Wechat = *req.Wechat
|
||||
}
|
||||
|
||||
if req.Concurrency != nil {
|
||||
user.Concurrency = *req.Concurrency
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user