diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9d606d3..da21aff 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -122,7 +122,7 @@ func main() { addr := ":" + cfg.Port log.Printf("游戏皮肤供货平台 API 启动: http://localhost%s", addr) log.Printf("数据库: PostgreSQL") - log.Printf("默认管理员: admin / admin123") + log.Printf("管理员账号已初始化;请通过账号菜单及时修改初始密码") log.Printf("开放接口鉴权: X-App-Key + X-Timestamp + X-Nonce + X-Sign (HMAC-SHA256)") if cfg.OpenAPIDebug { log.Printf("开放接口调试日志: 开启 (OPEN_API_DEBUG=0 可关闭)") diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index d8cf2df..61231a2 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -43,3 +43,21 @@ func (h *AuthHandler) Profile(c *gin.Context) { } response.OK(c, user) } + +type changePasswordReq struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` +} + +func (h *AuthHandler) ChangePassword(c *gin.Context) { + var req changePasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请填写当前密码和至少 8 位的新密码") + return + } + if err := h.svc.ChangePassword(middleware.GetUserID(c), req.CurrentPassword, req.NewPassword); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index dbb5435..e6c0f9d 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -101,6 +101,16 @@ func (h *MerchantHandler) ListOrders(c *gin.Context) { response.Page(c, list, total, page, size) } +// ListManualOrderProducts returns active products for order creation without granting product-management access. +func (h *MerchantHandler) ListManualOrderProducts(c *gin.Context) { + list, _, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), 1, 100, true) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, list) +} + type merchantManualOrderReq struct { ClientOrderNo string `json:"client_order_no" binding:"required,max=96"` SKU string `json:"sku" binding:"required,max=96"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 67fa17c..be2eb62 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -146,6 +146,7 @@ func Setup(h *Handlers) *gin.Engine { auth.Use(middleware.Tenant(h.Tenant)) { auth.GET("/auth/profile", h.Auth.Profile) + auth.PUT("/auth/password", h.Auth.ChangePassword) auth.GET("/dashboard", h.Dashboard.Dashboard) // 商户后台:商户就是平台下游客户,成员只代表该商户内部员工。 @@ -155,6 +156,7 @@ func Setup(h *Handlers) *gin.Engine { merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.ListProducts) merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionProductsManage), h.Merchant.UpdateProductStatus) merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.ListOrders) + merchant.GET("/orders/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.ListManualOrderProducts) merchant.POST("/orders/manual", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.CreateManualOrder) merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.CreateTestOrder) merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.GetDeliveryLink) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index e1e8c67..3104dc6 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -2,6 +2,7 @@ package service import ( "errors" + "strings" "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/jwt" @@ -54,6 +55,37 @@ func (s *AuthService) GetProfile(userID uint) (*model.User, error) { return &user, nil } +// ChangePassword only changes the current account after its existing password is verified. +func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error { + if userID == 0 { + return errors.New("无效的用户身份") + } + if len(newPassword) < 8 { + return errors.New("新密码至少 8 位") + } + if strings.TrimSpace(newPassword) == "" { + return errors.New("新密码不能为空") + } + var user model.User + if err := s.db.First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("用户不存在") + } + return err + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil { + return errors.New("当前密码错误") + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(newPassword)); err == nil { + return errors.New("新密码不能与当前密码相同") + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + return s.db.Model(&user).Update("password_hash", string(hash)).Error +} + func (s *AuthService) EnsureAdmin() error { var count int64 s.db.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&count) diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go index fde73e2..3c9d716 100644 --- a/backend/internal/service/merchant_test.go +++ b/backend/internal/service/merchant_test.go @@ -6,6 +6,8 @@ import ( "testing" "affiliate_dash/internal/model" + + "golang.org/x/crypto/bcrypt" ) func TestCreateAPIClientEnforcesPerMerchantLimit(t *testing.T) { @@ -174,3 +176,29 @@ func TestMemberCanBeUpdatedOrRemovedWithoutDeletingAccount(t *testing.T) { t.Fatalf("member relationship should be removed, count=%d err=%v", count, err) } } + +func TestAuthServiceChangePasswordVerifiesCurrentPassword(t *testing.T) { + db := newServiceTestDB(t) + tenant := NewTenantService(db) + auth := NewAuthService(db, nil, tenant) + user, err := NewUserService(db, tenant).Create("password-owner", "old-password", "密码管理员", model.RoleAdmin, 0) + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := auth.ChangePassword(user.ID, "wrong-password", "new-password"); err == nil { + t.Fatal("expected current password validation error") + } + if err := auth.ChangePassword(user.ID, "old-password", "new-password"); err != nil { + t.Fatalf("change password: %v", err) + } + var saved model.User + if err := db.First(&saved, user.ID).Error; err != nil { + t.Fatalf("load updated user: %v", err) + } + if err := bcrypt.CompareHashAndPassword([]byte(saved.PasswordHash), []byte("new-password")); err != nil { + t.Fatalf("new password should match: %v", err) + } + if err := bcrypt.CompareHashAndPassword([]byte(saved.PasswordHash), []byte("old-password")); err == nil { + t.Fatal("old password should no longer match") + } +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7e80439..1900f89 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -37,6 +37,8 @@ export const authApi = { const res = await request.get('/auth/profile') return res.data.data as User }, + changePassword: (data: { current_password: string; new_password: string }) => + request.put('/auth/password', data).then((r) => r.data.data), } export const dashboardApi = { @@ -62,6 +64,8 @@ export const merchantApi = { request.patch(`/merchant/products/${id}/status`, { status }).then((r) => r.data.data), orders: (params?: Record) => request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult), + manualOrderProducts: () => + request.get('/merchant/orders/products').then((r) => r.data.data as MerchantProduct[]), createTestOrder: (data: { sku: string buyer_reference?: string diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 1d5373f..844f1d7 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -4,11 +4,15 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom' import { Layout, Dropdown, + Form, + Input, + Modal, Space, Typography, Avatar, Tag, Button, + message, Tooltip, } from 'antd' import { @@ -18,6 +22,7 @@ import { DashboardOutlined, DownOutlined, FileTextOutlined, + KeyOutlined, LogoutOutlined, MenuOutlined, OrderedListOutlined, @@ -27,6 +32,7 @@ import { WalletOutlined, } from '@ant-design/icons' import { useAuth } from '../store/auth' +import { authApi } from '../api' import type { MenuProps } from 'antd' const { Header, Sider, Content } = Layout @@ -207,6 +213,9 @@ export default function MainLayout() { return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true' }) const { user, logout, isAdmin, merchant, merchantPermissions } = useAuth() + const [passwordOpen, setPasswordOpen] = useState(false) + const [passwordSaving, setPasswordSaving] = useState(false) + const [passwordForm] = Form.useForm() const navigate = useNavigate() const location = useLocation() const isDocsPage = location.pathname.startsWith('/open-api') @@ -265,6 +274,25 @@ export default function MainLayout() { navigate(child.path) } + const submitPasswordChange = async () => { + try { + const values = await passwordForm.validateFields() + setPasswordSaving(true) + await authApi.changePassword({ + current_password: values.current_password, + new_password: values.new_password, + }) + setPasswordOpen(false) + passwordForm.resetFields() + logout() + navigate('/login') + } catch (e) { + message.error(e instanceof Error ? e.message : '密码修改失败') + } finally { + setPasswordSaving(false) + } + } + const userMenu: MenuProps['items'] = [ { key: 'user-info', @@ -277,6 +305,12 @@ export default function MainLayout() { ), }, { type: 'divider' }, + { + key: 'change-password', + icon: , + label: '修改密码', + onClick: () => setPasswordOpen(true), + }, { key: 'logout', icon: , @@ -412,6 +446,32 @@ export default function MainLayout() { + { + setPasswordOpen(false) + passwordForm.resetFields() + }} + destroyOnClose + > +
+ + + + + + + ({ validator: (_, value) => !value || getFieldValue('new_password') === value ? Promise.resolve() : Promise.reject(new Error('两次输入的密码不一致')) }), + ]}> + + +
+
) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 1ea1483..58ab176 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Form, Input, Button, Typography, message, Space, Tag } from 'antd' +import { Form, Input, Button, Typography, message, Space } from 'antd' import { UserOutlined, LockOutlined, RightOutlined } from '@ant-design/icons' import { useAuth } from '../store/auth' @@ -23,11 +23,6 @@ export default function Login() { } } - const fillCredentials = (user: string, pass: string) => { - form.setFieldsValue({ username: user, password: pass }) - message.info(`已填入账号: ${user}`) - } - return (
@@ -46,7 +41,6 @@ export default function Login() { form={form} layout="vertical" onFinish={onFinish} - initialValues={{ username: 'admin', password: 'admin123' }} requiredMark={false} > - {/* 快捷测试账号填充 */} -
-
- 快速体验(演示账号列表): -
- - fillCredentials('admin', 'admin123')} - > - 管理员 (admin) - - -
- - 商户账号与成员密钥请联系平台管理员统一配置与创建 + 请使用平台管理员或商户负责人分配的账号登录
diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 078b41e..bc47941 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -294,25 +294,18 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer }) }, [loadWallet, merchantPermissions, walletFilterForm]) - const newManualClientOrderNo = () => { - const suffix = window.crypto?.randomUUID?.().replaceAll('-', '') || `${Date.now()}${Math.random().toString(36).slice(2)}` - return `manual-${suffix.slice(0, 32)}` - } - const openManualOrderCreate = () => { manualOrderForm.resetFields() manualOrderForm.setFieldsValue({ - client_order_no: newManualClientOrderNo(), quantity: 1, }) setManualOrderOpen(true) setManualOrderProductsLoading(true) - merchantApi.products({ page: 1, size: 100 }) + merchantApi.manualOrderProducts() .then((data) => { - const activeProducts = (data.list || []).filter((item) => item.status === 'active') - setManualOrderProducts(activeProducts) - if (activeProducts.length > 0) { - manualOrderForm.setFieldsValue({ sku: activeProducts[0].sku }) + setManualOrderProducts(data || []) + if (data.length > 0) { + manualOrderForm.setFieldsValue({ sku: data[0].sku }) } else { message.warning('没有可用商品') }