diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 9d7e9d6..01bc017 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -49,6 +49,23 @@ func (h *AuthHandler) Login(c *gin.Context) { }) } +// Register POST /api/v1/public/register +func (h *AuthHandler) Register(c *gin.Context) { + var req service.RegisterInput + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.svc.Register(req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": result}) +} + // Refresh POST /api/v1/auth/refresh func (h *AuthHandler) Refresh(c *gin.Context) { var req struct { diff --git a/backend/internal/model/shop.go b/backend/internal/model/shop.go index 505ad0f..b7c157c 100644 --- a/backend/internal/model/shop.go +++ b/backend/internal/model/shop.go @@ -4,6 +4,7 @@ type Shop struct { Base Name string `gorm:"size:100;not null" json:"name"` Code string `gorm:"size:50;uniqueIndex" json:"code"` + Description string `gorm:"type:text" json:"description"` Address string `gorm:"size:255" json:"address"` Phone string `gorm:"size:30" json:"phone"` BusinessHours string `gorm:"size:100" json:"business_hours"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index ecb6a45..9032f7b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -58,6 +58,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { public.GET("/products/:public_id", publicH.GetProduct) public.GET("/release", publicH.GetRelease) public.POST("/errors", errorReportH.Submit) + public.POST("/register", authH.Register) } // 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 29583f5..0548f38 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -2,9 +2,11 @@ package service import ( "errors" + "fmt" "time" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" @@ -61,6 +63,76 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m return pair, &user, nil } +// RegisterInput 注册新门店所需参数 +type RegisterInput struct { + ShopName string `json:"shop_name" binding:"required"` + Address string `json:"address" binding:"required"` + ManagerName string `json:"manager_name" binding:"required"` + Phone string `json:"phone" binding:"required"` + Description string `json:"description"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required,min=6"` +} + +// RegisterResult 注册成功后返回的数据 +type RegisterResult struct { + ShopCode string `json:"shop_code"` + ShopName string `json:"shop_name"` + Username string `json:"username"` +} + +// Register 自助注册新门店(公开接口,无需认证) +func (s *AuthService) Register(in RegisterInput) (*RegisterResult, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + var result RegisterResult + err = s.db.Transaction(func(tx *gorm.DB) error { + // 先用 UUID 占位创建门店,得到真实 ID + shop := model.Shop{ + Name: in.ShopName, + Code: uuid.New().String(), + Address: in.Address, + Phone: in.Phone, + ManagerName: in.ManagerName, + Description: in.Description, + } + if err := tx.Create(&shop).Error; err != nil { + return err + } + + // 用自增 ID 生成正式门店编码 + shop.Code = fmt.Sprintf("S%06d", shop.ID) + if err := tx.Model(&shop).Update("code", shop.Code).Error; err != nil { + return err + } + + // 创建管理员用户 + user := model.User{ + ShopID: shop.ID, + Username: in.Username, + PasswordHash: string(hash), + RealName: in.ManagerName, + Phone: in.Phone, + Role: "admin", + IsActive: true, + } + if err := tx.Create(&user).Error; err != nil { + return err + } + + result = RegisterResult{ + ShopCode: shop.Code, + ShopName: shop.Name, + Username: user.Username, + } + return nil + }) + return &result, err +} + // HashPassword 生成 bcrypt 哈希 func HashPassword(plain string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index 8895ff8..532c6b2 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `shops` ( `address` VARCHAR(255) DEFAULT NULL, `phone` VARCHAR(30) DEFAULT NULL, `manager_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人', + `description` TEXT DEFAULT NULL COMMENT '店铺简介', `logo_url` VARCHAR(500) DEFAULT '' COMMENT '门店 logo URL', `business_license` VARCHAR(500) DEFAULT NULL COMMENT '营业执照照片URL', `shop_photos` JSON DEFAULT NULL COMMENT '门店照片URL数组', diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 086c2f9..351fe08 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -53,6 +53,7 @@ func SetupTestDB() *gorm.DB { deleted_at DATETIME, name TEXT NOT NULL, code TEXT UNIQUE, + description TEXT, address TEXT, phone TEXT, manager_name TEXT, diff --git a/client/lib/screens/auth/login_screen.dart b/client/lib/screens/auth/login_screen.dart index 9bde887..7a589bc 100644 --- a/client/lib/screens/auth/login_screen.dart +++ b/client/lib/screens/auth/login_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../core/auth/auth_state.dart'; import '../../core/theme/app_theme.dart'; import '../../core/storage/login_history.dart'; @@ -484,6 +485,20 @@ class _LoginScreenState extends ConsumerState { letterSpacing: 2)), ), ), + const SizedBox(height: 12), + TextButton( + onPressed: () => launchUrl( + Uri.parse('https://jiu.51yanmei.com/register/'), + mode: LaunchMode.externalApplication, + ), + child: const Text( + '还没有门店账号?前往官网注册', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + ), ], ), ), diff --git a/web/_includes/nav.njk b/web/_includes/nav.njk index 5102063..4e01709 100644 --- a/web/_includes/nav.njk +++ b/web/_includes/nav.njk @@ -10,6 +10,8 @@ 文档 支持 - + diff --git a/web/register.njk b/web/register.njk new file mode 100644 index 0000000..2efb019 --- /dev/null +++ b/web/register.njk @@ -0,0 +1,280 @@ +--- +layout: base.njk +title: 注册新门店 +description: 填写门店信息,创建岩美酒库管理账户,注册完成后获取门店编码用于登录客户端。 +pageStyle: | + .reg-page { padding: var(--space-16) 0 var(--space-24); } + .reg-wrap { max-width: 640px; margin: 0 auto; padding: 0 var(--space-4); } + .reg-hero { text-align: center; margin-bottom: var(--space-8); } + .reg-hero h1 { font-size: var(--text-3xl); font-weight: 700; margin-bottom: var(--space-2); } + .reg-hero p { color: var(--neutral-500); font-size: var(--text-md); max-width: 480px; margin: 0 auto; } + .reg-card { background: var(--neutral-0); border: 1px solid var(--neutral-200); border-radius: var(--radius-xl); padding: var(--space-8); } + .reg-fieldset { border: none; padding: 0; margin: 0 0 var(--space-8); } + .reg-legend { font-size: var(--text-sm); font-weight: 600; color: var(--neutral-500); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--space-5); padding-bottom: var(--space-3); border-bottom: 1px solid var(--neutral-100); display: block; width: 100%; } + .form-row { margin-bottom: var(--space-5); } + .form-label { display: block; font-size: var(--text-sm); font-weight: 500; color: var(--neutral-700); margin-bottom: var(--space-1-5); } + .form-label .req { color: var(--error-500); margin-left: 2px; } + .form-input, .form-textarea { width: 100%; padding: var(--space-2-5) var(--space-3); border: 1px solid var(--neutral-300); border-radius: var(--radius-md); font-size: var(--text-md); font-family: inherit; background: var(--neutral-0); color: var(--neutral-900); transition: border-color .15s, box-shadow .15s; box-sizing: border-box; } + .form-input:focus, .form-textarea:focus { outline: none; border-color: var(--brand-500); box-shadow: 0 0 0 3px var(--brand-100); } + .form-textarea { resize: vertical; min-height: 80px; } + .form-helper { font-size: var(--text-xs); color: var(--neutral-400); margin-top: var(--space-1); } + .reg-submit { width: 100%; padding: var(--space-3) var(--space-4); font-size: var(--text-md); font-weight: 600; margin-top: var(--space-2); } + .reg-login-hint { text-align: center; margin-top: var(--space-4); font-size: var(--text-sm); color: var(--neutral-500); } + .reg-login-hint a { color: var(--brand-600); } + .reg-error { display: none; background: var(--error-50); border: 1px solid var(--error-200); border-radius: var(--radius-md); padding: var(--space-3) var(--space-4); margin-bottom: var(--space-5); font-size: var(--text-sm); color: var(--error-700); } + .reg-error.visible { display: block; } + .success-panel { display: none; text-align: center; } + .success-panel.visible { display: block; } + .success-icon { color: var(--success-500); margin-bottom: var(--space-4); } + .success-panel h2 { font-size: var(--text-2xl); font-weight: 700; margin-bottom: var(--space-2); } + .success-panel > p { color: var(--neutral-500); margin-bottom: var(--space-6); } + .shop-code-box { background: var(--brand-50); border: 1px solid var(--brand-200); border-radius: var(--radius-lg); padding: var(--space-5) var(--space-6); margin-bottom: var(--space-6); display: flex; align-items: center; justify-content: center; gap: var(--space-3); } + .shop-code-text { font-size: var(--text-3xl); font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--brand-700); letter-spacing: 0.05em; } + .copy-btn { display: inline-flex; align-items: center; gap: var(--space-1-5); padding: var(--space-2) var(--space-3); background: var(--brand-600); color: #fff; border: none; border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; transition: background .15s; } + .copy-btn:hover { background: var(--brand-700); } + .login-hint-card { background: var(--neutral-50); border: 1px solid var(--neutral-200); border-radius: var(--radius-md); padding: var(--space-4) var(--space-5); margin-bottom: var(--space-6); text-align: left; } + .login-hint-card p { font-size: var(--text-sm); font-weight: 600; color: var(--neutral-600); margin-bottom: var(--space-3); } + .login-hint-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); } + .login-hint-table td { padding: var(--space-1-5) var(--space-2); } + .login-hint-table td:first-child { color: var(--neutral-500); width: 80px; } + .login-hint-table td:last-child { font-weight: 500; color: var(--neutral-800); font-family: 'JetBrains Mono', monospace; } + .success-note { font-size: var(--text-xs); color: var(--neutral-400); margin-bottom: var(--space-6); } + .btn-loading { opacity: .7; cursor: not-allowed; } +--- + +
+
+ +
+

注册新门店

+

填写以下信息,创建您的专属酒库管理账户。注册完成后系统自动分配门店编码,用于登录客户端。

+
+ + +
+
+ + +
+ 门店信息 + +
+ + +
您的酒行正式名称,将显示在所有单据和商品标签上
+
+ +
+ + +
实际经营地址,打印标签和单据时展示
+
+ +
+ + +
主要负责人真实姓名,也是首个管理员账号的姓名
+
+ +
+ + +
对外联系电话,打印商品标签时会展示
+
+ +
+ + +
选填,最多 200 字;客户扫商品码时可见
+
+
+ + +
+ 管理员账号 + +
+ + +
登录账号(3–30 位,字母/数字/下划线),创建后不可修改
+
+ +
+ + +
至少 6 位,建议字母数字组合
+
+ +
+ + +
+
+ + + +
+ + +
+
+ +
+

注册成功!

+

您的门店编码已生成,请妥善保存:

+ +
+ + +
+ + + +

如忘记门店编码,登录后可在「系统设置 → 关于」中查看。

+ + 前往登录 + +
+ +
+
+ +