diff --git a/.env.example b/.env.example index cfefec3..630cb70 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ FRONTEND_PORT=15173 # HTTPS_PORT=443 JWT_SECRET=your_very_secure_jwt_secret_here_change_me_in_production DATA_ENCRYPTION_KEY=your_32_plus_char_data_encryption_key_change_me +# 仅用于空数据库的首次启动;已有管理员账号时不会被覆盖。 +INITIAL_ADMIN_USERNAME=skin_nh_admin +INITIAL_ADMIN_PASSWORD=change_me_to_a_secure_password POSTGRES_USER=affiliate POSTGRES_PASSWORD=affiliate_dev_password POSTGRES_DB=affiliate_dash diff --git a/README.md b/README.md index 4d91550..97e15af 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ make dev 启动后: - 前端:`http://localhost:15173` - 后端:`http://localhost:18080` -- 默认管理员:`admin` / `admin123` +- 空数据库首次启动时由 `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` 创建平台管理员 按 `Ctrl+C` 停止所有服务。 @@ -61,6 +61,7 @@ make frontend # 前端 :15173 cp .env.example .env # 编辑 .env,确保以下变量已配置: # JWT_SECRET — 生产环境务必修改 +# INITIAL_ADMIN_USERNAME / INITIAL_ADMIN_PASSWORD — 空数据库首次创建的平台管理员,不会覆盖已有账号 # OPEN_API_KEY — 皮肤源头对接 API Key # OPEN_API_SECRET — 签名密钥 # POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB — PostgreSQL 初始化配置 @@ -103,6 +104,8 @@ make docker-down # 停止并移除 | `FRONTEND_PORT` | 前端开发端口 | `15173` | | `HTTP_PORT` / `HTTPS_PORT` | Caddy 对外 HTTP/HTTPS 端口 | `80` / `443` | | `JWT_SECRET` | JWT 签名密钥 | 开发默认值 | +| `INITIAL_ADMIN_USERNAME` | 空数据库首次创建的平台管理员用户名 | 必填(仅首次) | +| `INITIAL_ADMIN_PASSWORD` | 空数据库首次创建的平台管理员密码 | 必填,至少 8 位(仅首次) | | `DATABASE_URL` | PostgreSQL 连接串 | 本地开发默认值 | | `DOCKER_DATABASE_URL` | Docker 后端容器内 PostgreSQL 连接串,通常无需设置 | Compose 内置默认值 | | `POSTGRES_PORT` | PostgreSQL 映射到宿主机的端口 | `15432` | diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index da21aff..2b643d1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -84,7 +84,7 @@ func main() { cfg.DeliveryLinkTTLMinutes, ) - if err := authSvc.EnsureAdmin(); err != nil { + if err := authSvc.EnsureAdmin(cfg.InitialAdminUsername, cfg.InitialAdminPassword); err != nil { log.Fatalf("ensure admin: %v", err) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e64ee2d..1d14888 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -14,6 +14,9 @@ type Config struct { JWTSecret string DatabaseURL string Mode string // debug / release + // InitialAdminUsername 和 InitialAdminPassword 仅用于空数据库首次创建平台管理员。 + InitialAdminUsername string + InitialAdminPassword string // DataEncryptionKey 用于加密保存 API/回调密钥;为空时由 JWT_SECRET 派生。 DataEncryptionKey string // OpenAPIKey 皮肤源头开放接口标识(Header: X-Api-Key,可公开给对接方) @@ -67,6 +70,8 @@ func Load() *Config { JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"), DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:15432/affiliate_dash?sslmode=disable&TimeZone=Asia/Shanghai"), Mode: mode, + InitialAdminUsername: getEnv("INITIAL_ADMIN_USERNAME", ""), + InitialAdminPassword: getEnv("INITIAL_ADMIN_PASSWORD", ""), DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")), OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"), OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"), diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index acd2cda..f5c444a 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -43,6 +43,17 @@ func (h *UserHandler) List(c *gin.Context) { response.Page(c, list, total, page, size) } +func (h *UserHandler) ListMerchantMembers(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + list, total, err := h.svc.ListMerchantMembers(page, size) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.Page(c, list, total, page, size) +} + type createUserReq struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required,min=6"` @@ -82,6 +93,24 @@ func (h *UserHandler) UpdateStatus(c *gin.Context) { response.OK(c, nil) } +type userRoleReq struct { + Role string `json:"role" binding:"required"` +} + +func (h *UserHandler) UpdateRole(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req userRoleReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:角色必填") + return + } + if err := h.svc.UpdateRole(uint(id), req.Role, middleware.GetUserID(c)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} + func (h *UserHandler) Delete(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 64) if err := h.svc.Delete(uint(id), middleware.GetUserID(c)); err != nil { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index e2e5d26..508eab0 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -193,8 +193,10 @@ func Setup(h *Handlers) *gin.Engine { admin.Use(middleware.RequireRole(model.RoleAdmin)) { admin.GET("/users", h.User.List) + admin.GET("/platform/members", h.User.ListMerchantMembers) admin.POST("/users", h.User.Create) admin.PATCH("/users/:id/status", h.User.UpdateStatus) + admin.PATCH("/users/:id/role", h.User.UpdateRole) admin.DELETE("/users/:id", h.User.Delete) admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) admin.POST("/platform/merchants", h.Merchant.CreateMerchant) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 3104dc6..f10e186 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -86,7 +86,9 @@ func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword s return s.db.Model(&user).Update("password_hash", string(hash)).Error } -func (s *AuthService) EnsureAdmin() error { +// EnsureAdmin creates the first platform administrator for an empty database. +// Existing administrator accounts are never changed by environment variables. +func (s *AuthService) EnsureAdmin(username, password string) error { var count int64 s.db.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&count) if count > 0 { @@ -103,14 +105,21 @@ func (s *AuthService) EnsureAdmin() error { } return nil } - hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) + username = strings.TrimSpace(username) + if len(username) < 3 { + return errors.New("空数据库首次启动必须设置 INITIAL_ADMIN_USERNAME(至少 3 位)") + } + if len(password) < 8 { + return errors.New("空数据库首次启动必须设置 INITIAL_ADMIN_PASSWORD(至少 8 位)") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } admin := &model.User{ - Username: "admin", + Username: username, PasswordHash: string(hash), - Nickname: "管理员", + Nickname: username, Role: model.RoleAdmin, Status: 1, } diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go index dfebf33..c9a98a9 100644 --- a/backend/internal/service/merchant_test.go +++ b/backend/internal/service/merchant_test.go @@ -224,3 +224,57 @@ func TestUserServiceCannotDeleteOrDisableLastAdmin(t *testing.T) { t.Fatal("should not delete the last enabled admin") } } + +func TestUserServicePromotesMerchantAccountToPlatformAdmin(t *testing.T) { + db := newServiceTestDB(t) + var merchant model.Merchant + if err := db.Where("code = ?", model.MerchantCodeSelfOperated).First(&merchant).Error; err != nil { + t.Fatalf("load self-operated merchant: %v", err) + } + svc := NewUserService(db, NewTenantService(db)) + actor, err := svc.Create("existing-admin", "password123", "现有管理员", model.RoleAdmin, 0) + if err != nil { + t.Fatalf("create actor: %v", err) + } + member, err := svc.Create("skin_nh_admin", "password123", "新管理员", model.RoleMerchant, 0) + if err != nil { + t.Fatalf("create merchant user: %v", err) + } + if err := svc.UpdateRole(member.ID, model.RoleAdmin, actor.ID); err != nil { + t.Fatalf("promote account: %v", err) + } + var promoted model.User + if err := db.First(&promoted, member.ID).Error; err != nil || promoted.Role != model.RoleAdmin { + t.Fatalf("account should be admin, user=%+v err=%v", promoted, err) + } + var membership model.MerchantMember + if err := db.Where("merchant_id = ? AND user_id = ?", merchant.ID, member.ID).First(&membership).Error; err != nil { + t.Fatalf("promoted account should join self-operated merchant: %v", err) + } + if membership.Role != model.MemberRoleOwner || !membership.IsDefault { + t.Fatalf("unexpected promoted membership: %+v", membership) + } +} + +func TestEnsureAdminUsesConfiguredCredentialsOnlyForEmptyDatabase(t *testing.T) { + db := newServiceTestDB(t) + tenant := NewTenantService(db) + auth := NewAuthService(db, nil, tenant) + if err := auth.EnsureAdmin("skin_nh_admin", "secure-password"); err != nil { + t.Fatalf("create configured admin: %v", err) + } + var created model.User + if err := db.Where("username = ?", "skin_nh_admin").First(&created).Error; err != nil { + t.Fatalf("configured admin should exist: %v", err) + } + if err := bcrypt.CompareHashAndPassword([]byte(created.PasswordHash), []byte("secure-password")); err != nil { + t.Fatalf("configured password should match: %v", err) + } + if err := auth.EnsureAdmin("replacement-admin", "another-password"); err != nil { + t.Fatalf("existing admin should not be replaced: %v", err) + } + var count int64 + if err := db.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&count).Error; err != nil || count != 1 { + t.Fatalf("existing admin should remain unchanged, count=%d err=%v", count, err) + } +} diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index b435963..74dbae9 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -7,6 +7,7 @@ import ( "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type UserService struct { @@ -58,6 +59,23 @@ func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) { return list, total, err } +// ListMerchantMembers returns employee assignments across merchants for platform operations. +func (s *UserService) ListMerchantMembers(page, size int) ([]model.MerchantMember, int64, error) { + page, size = normalizePage(page, size) + tx := s.db.Model(&model.MerchantMember{}). + Joins("JOIN users ON users.id = merchant_members.user_id AND users.deleted_at IS NULL"). + Where("users.role = ?", model.RoleMerchant) + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + var members []model.MerchantMember + err := tx.Preload("Merchant").Preload("User"). + Order("merchant_members.merchant_id ASC, merchant_members.id ASC"). + Offset((page - 1) * size).Limit(size).Find(&members).Error + return members, total, err +} + func (s *UserService) Create(username, password, nickname, role string, merchantID uint) (*model.User, error) { var count int64 s.db.Model(&model.User{}).Where("username = ?", username).Count(&count) @@ -137,6 +155,58 @@ func (s *UserService) UpdateStatus(id uint, status int) error { }) } +// UpdateRole changes the platform-level account role. Promoted administrators are +// also made owners of the self-operated merchant for a consistent merchant context. +func (s *UserService) UpdateRole(id uint, role string, actorUserID uint) error { + if id == 0 { + return errors.New("用户不存在") + } + if id == actorUserID { + return errors.New("不能修改当前登录账号的系统角色") + } + if role != model.RoleAdmin && role != model.RoleMerchant { + return errors.New("无效的账号角色") + } + return s.db.Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("用户不存在") + } + return err + } + if user.Role == role { + return nil + } + if user.Role == model.RoleAdmin && user.Status == 1 && role != model.RoleAdmin { + var admins int64 + if err := tx.Model(&model.User{}).Where("role = ? AND status = ?", model.RoleAdmin, 1).Count(&admins).Error; err != nil { + return err + } + if admins <= 1 { + return errors.New("至少保留一个启用的平台管理员") + } + } + if err := tx.Model(&user).Update("role", role).Error; err != nil { + return err + } + if role != model.RoleAdmin { + return nil + } + var merchant model.Merchant + if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&merchant).Error; err != nil { + return err + } + member := model.MerchantMember{MerchantID: merchant.ID, UserID: user.ID, Role: model.MemberRoleOwner, Status: user.Status, IsDefault: true} + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "role": model.MemberRoleOwner, "status": user.Status, "is_default": true, + }), + }).Create(&member).Error + }) +} + // Delete removes a platform account and its merchant memberships. It protects the current // account and the final enabled platform administrator so the platform cannot be locked out. func (s *UserService) Delete(id, actorUserID uint) error { diff --git a/docker-compose.yml b/docker-compose.yml index d0e4eeb..7842be8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,8 @@ services: - PORT=8080 - TZ=${TZ:-Asia/Shanghai} - JWT_SECRET=${JWT_SECRET} + - INITIAL_ADMIN_USERNAME=${INITIAL_ADMIN_USERNAME:-} + - INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-} - "DATABASE_URL=${DOCKER_DATABASE_URL:-postgres://${POSTGRES_USER:-affiliate}:${POSTGRES_PASSWORD:-affiliate_dev_password}@postgres:5432/${POSTGRES_DB:-affiliate_dash}?sslmode=disable&TimeZone=${TZ:-Asia/Shanghai}}" - GIN_MODE=${GIN_MODE:-release} - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 688b549..d4dda64 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -53,6 +53,8 @@ export const userApi = { request.post('/users', data).then((r) => r.data.data as User), updateStatus: (id: number, status: number) => request.patch(`/users/${id}/status`, { status }).then((r) => r.data.data), + updateRole: (id: number, role: User['role']) => + request.patch(`/users/${id}/role`, { role }).then((r) => r.data.data), remove: (id: number) => request.delete(`/users/${id}`).then((r) => r.data.data), } @@ -176,6 +178,8 @@ export const deliveryApi = { } export const platformApi = { + members: (params?: Record) => + request.get('/platform/members', { params }).then((r) => r.data.data as PageResult), merchants: (params?: Record) => request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult), createMerchant: (data: { diff --git a/frontend/src/pages/PlatformUsers.tsx b/frontend/src/pages/PlatformUsers.tsx index 69fd283..63b296a 100644 --- a/frontend/src/pages/PlatformUsers.tsx +++ b/frontend/src/pages/PlatformUsers.tsx @@ -1,64 +1,128 @@ -import { useCallback, useEffect, useState } from 'react' -import { Button, Popconfirm, Table, Tag, Typography, message } from 'antd' -import { DeleteOutlined, ReloadOutlined } from '@ant-design/icons' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button, Popconfirm, Space, Table, Tabs, Tag, Typography, message } from 'antd' +import { DeleteOutlined, ReloadOutlined, SafetyCertificateOutlined } from '@ant-design/icons' import type { ColumnsType } from 'antd/es/table' -import { userApi } from '../api' +import { platformApi, userApi } from '../api' import { PageHeader } from '../components/PageHeader' -import type { PageResult, User } from '../types' +import type { MerchantMember, PageResult, User } from '../types' import { formatDateTime } from '../utils/time' import { useAuth } from '../store/auth' +import { roleText } from './merchantCenterUtils' +import { useNavigate } from 'react-router-dom' export default function PlatformUsers() { const { user: currentUser } = useAuth() - const [data, setData] = useState>({ list: [], total: 0, page: 1, size: 20 }) + const navigate = useNavigate() + const [tab, setTab] = useState('admins') + const [admins, setAdmins] = useState>({ list: [], total: 0, page: 1, size: 20 }) + const [members, setMembers] = useState>({ list: [], total: 0, page: 1, size: 20 }) const [loading, setLoading] = useState(false) - const load = useCallback(async (page = data.page, size = data.size) => { + const loadAdmins = useCallback(async (page = admins.page, size = admins.size) => { setLoading(true) try { - setData(await userApi.list({ page, size })) + setAdmins(await userApi.list({ page, size, role: 'admin' })) } catch (e) { message.error(e instanceof Error ? e.message : '加载失败') } finally { setLoading(false) } - }, [data.page, data.size]) + }, [admins.page, admins.size]) + + const loadMembers = useCallback(async (page = members.page, size = members.size) => { + setLoading(true) + try { + setMembers(await platformApi.members({ page, size })) + } catch (e) { + message.error(e instanceof Error ? e.message : '加载失败') + } finally { + setLoading(false) + } + }, [members.page, members.size]) useEffect(() => { - void load() - }, [load]) + if (tab === 'admins') void loadAdmins() + else void loadMembers() + }, [loadAdmins, loadMembers, tab]) const remove = async (record: User) => { try { await userApi.remove(record.id) message.success('账号已删除') - load() + loadAdmins() } catch (e) { message.error(e instanceof Error ? e.message : '删除失败') } } - const columns: ColumnsType = [ - { title: '用户名', dataIndex: 'username', width: 220, render: (value) => {value} }, + const promote = async (record: MerchantMember) => { + try { + await userApi.updateRole(record.user_id, 'admin') + message.success('已设为平台超级管理员;请使用该账号重新登录') + loadMembers() + } catch (e) { + message.error(e instanceof Error ? e.message : '设置失败') + } + } + + const adminColumns: ColumnsType = [ + { title: '用户名', dataIndex: 'username', width: 240, render: (value) => {value} }, { title: '昵称', dataIndex: 'nickname', width: 180, render: (value) => value || '-' }, - { title: '账号类型', dataIndex: 'role', width: 140, render: (value) => {value === 'admin' ? '平台管理员' : '商户账号'} }, { title: '状态', dataIndex: 'status', width: 100, render: (value) => value === 1 ? 启用 : 停用 }, { title: '创建时间', dataIndex: 'created_at', width: 180, render: formatDateTime }, { title: '操作', - width: 120, + width: 130, render: (_, record) => record.id === currentUser?.id ? 当前账号 : ( - remove(record)} okButtonProps={{ danger: true }}> + remove(record)} okButtonProps={{ danger: true }}> ), }, ] + const memberColumns: ColumnsType = [ + { title: '商户', width: 240, render: (_, record) => {record.merchant?.name || `商户#${record.merchant_id}`}{record.merchant?.code} }, + { title: '员工账号', dataIndex: ['user', 'username'], width: 200, render: (_, record) => record.user?.username || `#${record.user_id}` }, + { title: '昵称', dataIndex: ['user', 'nickname'], width: 160, render: (_, record) => record.user?.nickname || '-' }, + { title: '商户角色', dataIndex: 'role', width: 130, render: (role) => {roleText(role)} }, + { title: '成员状态', dataIndex: 'status', width: 110, render: (status) => status === 1 ? 启用 : 停用 }, + { + title: '操作', + width: 230, + render: (_, record) => ( + + {record.user?.role !== 'admin' && record.user_id !== currentUser?.id && ( + promote(record)}> + + + )} + + + ), + }, + ] + + const items = useMemo(() => [ + { + key: 'admins', + label: `平台管理员 (${admins.total})`, + children: loadAdmins(page, size) }} />, + }, + { + key: 'members', + label: `商户员工 (${members.total})`, + children:
loadMembers(page, size) }} />, + }, + ], [adminColumns, admins, loadAdmins, loadMembers, loading, memberColumns, members]) + return (
- } loading={loading} onClick={() => load()}>刷新} /> -
load(page, size) }} /> + } loading={loading} onClick={() => tab === 'admins' ? loadAdmins() : loadMembers()}>刷新} /> + ) }