演示
Skip to content

二次开发文档 ​

本文档面向需要在 LYShop 基础上进行业务扩展、插件开发、定制化改造的开发者,覆盖后端插件体系、驱动接口、数据模型、API 规范、营销管线、前端开发、配置权限、测试部署等全部环节。每个章节均包含架构说明、完整代码示例和最佳实践。


统一库存架构说明 ​

当前最新架构下,商城库存能力不再默认等于 WMS,而是统一收口到 server/core/inventory 抽象层。

核心原则 ​

  • 商品主数据、SKU 主数据由 product 管理
  • 库存交易能力由 inventory 管理
  • wms 只是一个内置库存 provider,不再是唯一库存真源
  • 企业已有 WMS 通过 external_wms provider 对接
  • SKU 是库存占用、扣减、回补、可售库存查询的统一粒度

三种库存模式 ​

  1. local
    • 默认模式
    • 直接使用 product_skus.stock
  2. builtin_wms
    • 启用内置 wms 插件
    • 使用系统内仓储库存表与预占能力
  3. external_wms
    • 对接企业外部 WMS
    • 支持 sync 与 async

推荐依赖关系 ​

二次开发时:

  • 订单、商品、积分商城等业务模块,应依赖 core/inventory
  • 不要直接从业务插件硬连 plugins/wms/service
  • 若新增新的库存来源,应新增一个新的 inventory provider,而不是把判断分散写进业务层

关键目录 ​

text
server/core/inventory/
├── provider.go        # Provider 接口与注册
├── router.go          # Provider 选择与模式判断
├── model.go           # 统一库存共享模型
├── local.go           # 本地库存实现
├── external.go        # 外部WMS provider
├── external_adapter.go # 外部WMS通用企业协议适配层
├── tasks.go           # 异步任务状态迁移
└── worker*.go         # 异步任务执行与轮询

共享模型 ​

  • inventory_reservation
    • 统一预占记录
  • order_inventory_state
    • 统一订单库存状态
  • inventory_integration_tasks
    • external_wms + async 模式下的异步任务表

订单库存状态 ​

统一使用 orders.inventory_status 表达库存结果:

  • none
  • pending
  • reserved
  • confirmed
  • released
  • failed

其中:

  • external_wms + async 模式下,订单在收到最终结果前会先保持 pending
  • 最终状态由异步任务执行结果和回调共同收口

二开建议 ​

  • 新增库存相关业务时,先判断该业务是“商品主数据问题”还是“库存来源问题”
  • 若只是新增商品字段、规格生成、SKU 展示,优先改 product
  • 若涉及库存扣减、释放、可售库存查询,优先接入 inventory
  • 若接第三方仓储系统,优先新增 adapter / provider,不要污染订单和商品服务

目录 ​

  1. 概述与环境准备
  2. 后端插件开发
  3. 后端驱动开发
  4. 数据模型与迁移
  5. API 开发规范
  6. 营销管线扩展
  7. Admin 后台前端开发
  8. PC 商城与 H5 前端开发
  9. 配置中心与权限系统
  10. 测试、调试与部署

一、概述与环境准备 ​

1.1 项目架构总览 ​

LYShop 采用前后端分离架构,后端为 Go 单体服务,前端分为三个独立应用:

lyshop/
├── server/            # Go 后端(Gin + GORM + SQLite/MySQL + Redis)
│   ├── main.go        # 入口,空导入启用的插件
│   ├── core/          # 框架核心
│   │   ├── app/       # 应用初始化与启动
│   │   ├── plugin/    # 插件接口、注册、加载
│   │   ├── driver/    # 驱动接口(存储/支付/短信/物流/AI/OAuth/配送)
│   │   ├── middleware/ # 中间件(CORS/日志/JWT认证)
│   │   ├── marketing/ # 营销价格管线
│   │   ├── response/  # 统一响应格式
│   │   ├── db/        # 数据库初始化
│   │   └── cache/     # Redis 初始化
│   ├── model/         # 核心数据模型
│   ├── plugins/       # 25+ 业务插件
│   ├── api/           # 核心路由(认证、管理员)
│   └── service/       # 核心业务逻辑
├── admin/             # Vue3 管理后台(TailwindCSS + Vite)
├── web/               # Vue3 PC 端商城(UnoCSS + Vite)
├── app/               # uni-app 移动端/小程序(UnoCSS + uview-plus)
└── docs-site/         # VitePress 文档站点

1.2 技术栈 ​

层级技术版本
后端框架Go + GinGo 1.26+
ORMGORMv1.31+
数据库SQLite(默认)/ MySQLSQLite 3+ / MySQL 8.0+
缓存Redis6.0+
认证JWT (HS256)golang-jwt/v5
前端框架Vue 3 (Composition API)3.4+
构建工具Vite5.x
状态管理Pinia2.1+
路由Vue Router4.3+
HTTP 客户端Axios / uni.request1.7+
Admin 样式TailwindCSS3.4+
Web 样式UnoCSS-
App 框架uni-app + uview-plus3.0+
文档VitePress-

1.3 开发环境搭建 ​

环境要求 ​

  • Go 1.21+
  • Node.js 18+
  • SQLite 3+(默认)或 MySQL 8.0+
  • Redis(可选,不配则部分缓存功能不可用)

后端启动 ​

bash
# 1. 进入后端目录
cd server

# 2. 复制配置文件并修改数据库/Redis 连接信息
cp ../config.example.yaml config.yaml

# 3. 安装依赖并启动
go mod tidy
go run main.go -config config.yaml
# 服务默认监听 :8080

说明:

  • database.dsn 默认是 lyshop.db(SQLite)。
  • redis.addr 留空会启用进程内嵌 Redis,适合本机单机调试。

前端启动 ​

bash
# Admin 管理后台(端口 9527)
cd admin && npm install && npm run dev

# PC 商城(端口 9529)
cd web && npm install && npm run dev

# H5 移动端
cd app && npm install && npm run dev:h5

# 微信小程序
cd app && npm run dev:mp-weixin

# 演示模式(使用 Mock 数据,无需后端)
npm run dev:demo

目录约定 ​

开发新功能时,请遵循以下目录约定:

目录说明
server/plugins/<name>/plugin.go插件入口
server/plugins/<name>/plugin.json插件元数据
server/plugins/<name>/api/HTTP 路由处理
server/plugins/<name>/service/业务逻辑
server/plugins/<name>/model/数据模型
admin/src/views/<module>/管理端页面
admin/src/router/index.ts管理端路由
web/src/views/PC 端页面
app/pages/<module>/H5/小程序页面

二、后端插件开发 ​

2.1 插件体系架构 ​

LYShop 的全部业务功能都以插件形式实现。每个插件是一个独立的 Go 包,通过 init() 函数自注册到全局注册表,由框架在启动时统一加载。

插件生命周期:

init() 注册  →  Load() 校验依赖  →  Migrate() 建表  →  RegisterRoutes() 注册路由  →  Install() 初始化

2.2 Plugin 接口 ​

所有插件必须实现 Plugin 接口(定义在 server/core/plugin/plugin.go):

go
// Plugin 是所有插件必须实现的接口
type Plugin interface {
    // Meta 返回插件元数据(从嵌入的 plugin.json 解析)
    Meta() Meta
    // RegisterRoutes 注册前台和管理端 API 路由
    RegisterRoutes(front, admin *gin.RouterGroup)
    // Migrate 执行数据库建表/迁移(必须幂等)
    Migrate(db *gorm.DB) error
    // Install 在 Migrate 和 RegisterRoutes 之后调用一次
    Install() error
    // Uninstall 插件被禁用时调用
    Uninstall() error
}

2.3 Meta 元数据结构 ​

go
type Meta struct {
    Name        string        `json:"name"`         // 唯一标识符,如 "product"
    Title       string        `json:"title"`        // 显示名称,如 "商品插件"
    Version     string        `json:"version"`      // 语义化版本
    Description string        `json:"description"`  // 简述
    Author      string        `json:"author"`       // 作者
    Depends     []string      `json:"depends"`      // 依赖的插件列表
    Menus       []MenuItem    `json:"menus"`         // 管理端菜单
    Permissions []string      `json:"permissions"`   // 声明的权限标识
    ConfigItems []ConfigField `json:"config_items"`  // 配置中心字段声明
}

MenuItem(管理端菜单项):

go
type MenuItem struct {
    Title      string     `json:"title"`               // 菜单标题
    Icon       string     `json:"icon"`                // 图标名称
    Path       string     `json:"path"`                // 路由路径
    Sort       int        `json:"sort"`                // 排序权重(越小越靠前)
    Permission string     `json:"permission,omitempty"` // 所需权限(空 = 无需权限)
    Children   []MenuItem `json:"children,omitempty"`   // 子菜单
}

ConfigField(配置中心字段):

go
type ConfigField struct {
    Key         string `json:"key"`           // 配置键名
    Label       string `json:"label"`         // 显示标签
    Type        string `json:"type"`          // text|password|textarea|number|select|switch
    Placeholder string `json:"placeholder"`   // 输入提示
    Required    bool   `json:"required"`      // 是否必填
    Options     []struct {                    // type=select 时的选项
        Label string `json:"label"`
        Value string `json:"value"`
    } `json:"options,omitempty"`
}

2.4 插件注册与加载 ​

注册机制(server/core/plugin/registry.go):

go
var registry []Plugin

// Register 将插件加入全局注册表,在 init() 中调用
func Register(p Plugin)

// Find 按名称查找插件
func Find(name string) Plugin

// All 返回所有已注册插件的快照
func All() []Plugin

// EnabledMenus 根据启用的插件和权限,返回过滤后的菜单树
func EnabledMenus(enabled []string, perms []string) []MenuItem

加载流程(server/core/plugin/loader.go):

go
func Load(enabled []string, db *gorm.DB, front, admin *gin.RouterGroup) error {
    // 1. 校验所有 enabled 插件已注册(否则报错提示加 blank import)
    // 2. 校验依赖链(Depends 中的插件必须也在 enabled 中)
    // 3. 按 config 中的顺序依次执行:
    //    - p.Migrate(db)         → 建表
    //    - p.RegisterRoutes(...)  → 注册路由
    //    - p.Install()           → 初始化数据
}

启用方式(server/main.go):

go
import (
    // 空导入使插件的 init() 被执行
    _ "github.com/ijry/lyshop/plugins/product"
    _ "github.com/ijry/lyshop/plugins/order"
    // 新增插件在此添加空导入
    _ "github.com/ijry/lyshop/plugins/my_plugin"
)

同时在 config.yaml 的 plugins.enabled 列表中添加插件名称。

2.5 完整示例:创建一个新插件 ​

下面以创建一个「公告管理」插件为例,演示完整的插件开发流程。

步骤 1:创建目录结构 ​

server/plugins/announcement/
├── plugin.go          # 插件入口
├── plugin.json        # 插件元数据
├── api/
│   ├── front.go       # 前台路由
│   └── admin.go       # 管理端路由
├── model/
│   └── announcement.go # 数据模型
└── service/
    └── announcement.go # 业务逻辑

步骤 2:编写 plugin.json ​

json
{
  "name": "announcement",
  "title": "公告插件",
  "version": "1.0.0",
  "description": "站点公告管理,支持前台展示和后台 CRUD",
  "author": "developer",
  "depends": [],
  "menus": [
    {
      "title": "内容管理",
      "icon": "megaphone",
      "path": "/announcement",
      "sort": 50,
      "children": [
        {
          "title": "公告列表",
          "path": "/announcement/list",
          "permission": "announcement:view"
        }
      ]
    }
  ],
  "permissions": [
    "announcement:view",
    "announcement:create",
    "announcement:edit",
    "announcement:delete"
  ]
}

步骤 3:定义数据模型 ​

go
// server/plugins/announcement/model/announcement.go
package model

import "github.com/ijry/lyshop/model"

type Announcement struct {
    model.Base
    Title   string `gorm:"size:255;not null"    json:"title"`
    Content string `gorm:"type:text"            json:"content"`
    Sort    int    `gorm:"not null;default:0"   json:"sort"`
    Status  int8   `gorm:"not null;default:1"   json:"status"` // 1=显示 0=隐藏
}

步骤 4:编写业务逻辑 ​

go
// server/plugins/announcement/service/announcement.go
package service

import (
    "context"

    "github.com/ijry/lyshop/core/db"
    annomodel "github.com/ijry/lyshop/plugins/announcement/model"
)

// ListPublished 查询已发布的公告
func ListPublished(ctx context.Context) ([]annomodel.Announcement, error) {
    var list []annomodel.Announcement
    err := db.DB.WithContext(ctx).
        Where("status = ?", 1).
        Order("sort DESC, id DESC").
        Find(&list).Error
    return list, err
}

// ListAll 查询全部公告(管理端)
func ListAll(ctx context.Context, page, size int) ([]annomodel.Announcement, int64, error) {
    var list []annomodel.Announcement
    var total int64
    q := db.DB.WithContext(ctx).Model(&annomodel.Announcement{})
    q.Count(&total)
    err := q.Order("id DESC").
        Offset((page - 1) * size).Limit(size).
        Find(&list).Error
    return list, total, err
}

// Create 创建公告
func Create(ctx context.Context, a *annomodel.Announcement) error {
    return db.DB.WithContext(ctx).Create(a).Error
}

// Update 更新公告
func Update(ctx context.Context, id uint64, updates map[string]any) error {
    return db.DB.WithContext(ctx).
        Model(&annomodel.Announcement{}).
        Where("id = ?", id).
        Updates(updates).Error
}

// Delete 删除公告
func Delete(ctx context.Context, id uint64) error {
    return db.DB.WithContext(ctx).
        Where("id = ?", id).
        Delete(&annomodel.Announcement{}).Error
}

步骤 5:注册路由 ​

前台路由:

go
// server/plugins/announcement/api/front.go
package api

import (
    "github.com/gin-gonic/gin"
    "github.com/ijry/lyshop/core/response"
    annosvc "github.com/ijry/lyshop/plugins/announcement/service"
)

func RegisterFrontRoutes(g *gin.RouterGroup) {
    g.GET("/announcements", listPublished)
}

func listPublished(c *gin.Context) {
    list, err := annosvc.ListPublished(c.Request.Context())
    if err != nil {
        response.Fail(c, 500, err.Error())
        return
    }
    response.OK(c, list)
}

管理端路由:

go
// server/plugins/announcement/api/admin.go
package api

import (
    "strconv"

    "github.com/gin-gonic/gin"
    "github.com/ijry/lyshop/core/middleware"
    "github.com/ijry/lyshop/core/response"
    annomodel "github.com/ijry/lyshop/plugins/announcement/model"
    annosvc "github.com/ijry/lyshop/plugins/announcement/service"
)

func RegisterAdminRoutes(g *gin.RouterGroup) {
    g.GET("/announcements",
        middleware.RequirePermission("announcement:view"), adminList)
    g.POST("/announcements",
        middleware.RequirePermission("announcement:create"), adminCreate)
    g.PUT("/announcements/:id",
        middleware.RequirePermission("announcement:edit"), adminUpdate)
    g.DELETE("/announcements/:id",
        middleware.RequirePermission("announcement:delete"), adminDelete)
}

func adminList(c *gin.Context) {
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
    list, total, err := annosvc.ListAll(c.Request.Context(), page, size)
    if err != nil {
        response.Fail(c, 500, err.Error())
        return
    }
    response.OK(c, response.PageData{List: list, Total: total, Page: page, Size: size})
}

func adminCreate(c *gin.Context) {
    var anno annomodel.Announcement
    if err := c.ShouldBindJSON(&anno); err != nil {
        response.Fail(c, 400, err.Error())
        return
    }
    if err := annosvc.Create(c.Request.Context(), &anno); err != nil {
        response.Fail(c, 500, err.Error())
        return
    }
    response.OK(c, anno)
}

func adminUpdate(c *gin.Context) {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
    var updates map[string]any
    c.ShouldBindJSON(&updates)
    if err := annosvc.Update(c.Request.Context(), id, updates); err != nil {
        response.Fail(c, 500, err.Error())
        return
    }
    response.OK(c, nil)
}

func adminDelete(c *gin.Context) {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
    if err := annosvc.Delete(c.Request.Context(), id); err != nil {
        response.Fail(c, 500, err.Error())
        return
    }
    response.OK(c, nil)
}

步骤 6:编写插件入口 ​

go
// server/plugins/announcement/plugin.go
package announcement

import (
    _ "embed"
    "encoding/json"

    "github.com/gin-gonic/gin"
    "github.com/ijry/lyshop/core/plugin"
    annoapi "github.com/ijry/lyshop/plugins/announcement/api"
    annomodel "github.com/ijry/lyshop/plugins/announcement/model"
    "gorm.io/gorm"
)

//go:embed plugin.json
var metaJSON []byte

type annoPlugin struct {
    meta plugin.Meta
}

func init() {
    var m plugin.Meta
    if err := json.Unmarshal(metaJSON, &m); err != nil {
        panic("announcement plugin: invalid plugin.json: " + err.Error())
    }
    plugin.Register(&annoPlugin{meta: m})
}

func (p *annoPlugin) Meta() plugin.Meta { return p.meta }

func (p *annoPlugin) RegisterRoutes(front, admin *gin.RouterGroup) {
    annoapi.RegisterFrontRoutes(front)
    annoapi.RegisterAdminRoutes(admin)
}

func (p *annoPlugin) Migrate(db *gorm.DB) error {
    return db.AutoMigrate(&annomodel.Announcement{})
}

func (p *annoPlugin) Install() error   { return nil }
func (p *annoPlugin) Uninstall() error { return nil }

步骤 7:注册并启用 ​

在 server/main.go 添加空导入:

go
_ "github.com/ijry/lyshop/plugins/announcement"

在 config.yaml 添加启用项:

yaml
plugins:
  enabled:
    - product
    - order
    # ...
    - announcement   # 新增

重启后端服务,插件自动建表、注册路由、挂载菜单。

2.6 最佳实践 ​

  • Migrate 必须幂等:使用 db.AutoMigrate(),它只添加不存在的列和索引,不会破坏已有数据
  • 依赖声明要准确:如果你的插件用到了 product 的 model 或 service,务必在 depends 中声明
  • 按 config 顺序加载:plugins.enabled 列表的顺序即加载顺序,被依赖的插件需排在前面
  • 权限粒度明确:permission 字段使用 模块:操作 格式,如 announcement:view、announcement:create
  • 每个插件一个 Go 包:避免跨插件直接引用 model/service,需要时通过接口或事件解耦

三、后端驱动开发 ​

3.1 驱动体系概述 ​

LYShop 的驱动(Driver)系统提供了对外部服务的可插拔抽象。每种驱动类型定义统一接口,不同实现以插件形式注册。系统启动时自动选择配置的驱动。

当前支持 7 类驱动:

驱动类型接口目录用途已有实现
Storagecore/driver/storage/文件上传local, oss, cos, qiniu
Paymentcore/driver/payment/支付网关wechat_pay, alipay
SMScore/driver/sms/短信发送aliyun, tencent
Logisticscore/driver/logistics/物流追踪kuaidi100, kdniao
AIcore/driver/ai/AI 图片生成tongyi, wenxin, dalle
OAuthcore/driver/oauth/第三方登录wechat
Deliverycore/driver/delivery/配送方式express, local

3.2 存储驱动 ​

接口定义(server/core/driver/storage/storage.go):

go
type UploadResult struct {
    Path string `json:"path"` // 存储路径
    URL  string `json:"url"`  // 公开访问 URL
    Size int64  `json:"size"` // 文件大小(字节)
    Mime string `json:"mime"` // MIME 类型
}

type Driver interface {
    Name() string
    Upload(ctx context.Context, file *multipart.FileHeader) (*UploadResult, error)
    Delete(ctx context.Context, path string) error
    GetURL(path string) string
}

注册表特点:

  • 支持名称别名(storage_local 和 local 等价)
  • 支持默认驱动选择(SetDefault / GetByName(""))
  • 通过 storage_router 插件可实现多驱动路由

示例:实现一个自定义存储驱动

go
// server/plugins/storage_minio/driver.go
package storage_minio

import (
    "context"
    "fmt"
    "mime/multipart"
    "path/filepath"
    "time"

    "github.com/ijry/lyshop/core/driver/storage"
    "github.com/minio/minio-go/v7"
    "github.com/minio/minio-go/v7/pkg/credentials"
)

type minioDriver struct {
    client   *minio.Client
    bucket   string
    endpoint string
}

func NewDriver(endpoint, accessKey, secretKey, bucket string) (*minioDriver, error) {
    client, err := minio.New(endpoint, &minio.Options{
        Creds:  credentials.NewStaticV4(accessKey, secretKey, ""),
        Secure: true,
    })
    if err != nil {
        return nil, err
    }
    return &minioDriver{client: client, bucket: bucket, endpoint: endpoint}, nil
}

func (d *minioDriver) Name() string { return "minio" }

func (d *minioDriver) Upload(ctx context.Context, fh *multipart.FileHeader) (*storage.UploadResult, error) {
    file, err := fh.Open()
    if err != nil {
        return nil, err
    }
    defer file.Close()

    key := fmt.Sprintf("uploads/%d%s", time.Now().UnixNano(), filepath.Ext(fh.Filename))
    _, err = d.client.PutObject(ctx, d.bucket, key, file, fh.Size, minio.PutObjectOptions{
        ContentType: fh.Header.Get("Content-Type"),
    })
    if err != nil {
        return nil, err
    }
    return &storage.UploadResult{
        Path: key,
        URL:  d.GetURL(key),
        Size: fh.Size,
        Mime: fh.Header.Get("Content-Type"),
    }, nil
}

func (d *minioDriver) Delete(ctx context.Context, path string) error {
    return d.client.RemoveObject(ctx, d.bucket, path, minio.RemoveObjectOptions{})
}

func (d *minioDriver) GetURL(path string) string {
    return fmt.Sprintf("https://%s/%s/%s", d.endpoint, d.bucket, path)
}

然后在插件的 Install() 中读取配置并注册:

go
func (p *minioPlugin) Install() error {
    // 从 ConfigKV 读取配置
    cfg := readConfig("storage_minio")
    driver, err := NewDriver(cfg["endpoint"], cfg["access_key"], cfg["secret_key"], cfg["bucket"])
    if err != nil {
        return err
    }
    storage.Register(driver)
    return nil
}

3.3 支付驱动 ​

接口定义(server/core/driver/payment/payment.go):

go
type OrderParams struct {
    OrderNo     string // 商户订单号
    Amount      int64  // 金额(分),如 9900 = ¥99.00
    Description string // 订单描述
    NotifyURL   string // 回调通知地址
    OpenID      string // 微信小程序 JSAPI 需要
    ClientIP    string // H5/App 需要
}

type OrderResult struct {
    PrepayID  string            // 预支付 ID
    PayParams map[string]string // 传给前端 SDK 的参数
}

type QueryResult struct {
    OutTradeNo string // 商户订单号
    TradeNo    string // 平台交易号
    Status     string // "paid" | "unpaid" | "refunded"
    Amount     int64  // 金额(分)
}

type RefundParams struct {
    OrderNo     string // 原订单号
    RefundNo    string // 退款单号
    Amount      int64  // 退款金额
    TotalAmount int64  // 原订单总额
    Reason      string // 退款原因
}

type Driver interface {
    Name() string
    CreateOrder(ctx context.Context, p *OrderParams) (*OrderResult, error)
    QueryOrder(ctx context.Context, tradeNo string) (*QueryResult, error)
    Refund(ctx context.Context, p *RefundParams) (*RefundResult, error)
    HandleNotify(ctx context.Context, r *http.Request) (*NotifyResult, error)
}

注册方式:

go
// 在插件的 init() 或 Install() 中
payment.Register(myPaymentDriver)

// 使用时按名称获取
driver, err := payment.Get("wechat_pay")

3.4 短信驱动 ​

接口定义(server/core/driver/sms/sms.go):

go
type Driver interface {
    Name() string
    Send(ctx context.Context, phone, templateCode string, params map[string]string) error
}

短信驱动为单实例注册(最后注册的生效):

go
sms.Register(myDriver)

// 使用
driver, err := sms.Get()
driver.Send(ctx, "13800138000", "SMS_CODE_TPL", map[string]string{"code": "1234"})

3.5 物流驱动 ​

接口定义(server/core/driver/logistics/logistics.go):

go
type QueryReq struct {
    CompanyCode string // 快递公司编码
    TrackingNo  string // 运单号
    Phone       string // 收件人手机尾号(部分平台需要)
}

type TrackNode struct {
    Time       string          // 时间
    Location   string          // 地点
    StatusCode string          // 状态码
    StatusText string          // 状态文本
    RawPayload json.RawMessage // 原始数据
}

type TrackResult struct {
    Provider   string      // 驱动名称
    StatusCode string      // 总状态码
    StatusText string      // 总状态文本
    SignedAt   *time.Time  // 签收时间
    Nodes      []TrackNode // 轨迹节点
}

type Driver interface {
    Name() string
    Query(ctx context.Context, req QueryReq) (*TrackResult, error)
}

物流驱动支持主备切换:

go
// 设置主备驱动
logistics.SetDefaultDrivers("kuaidi100", "kdniao")

// 自动 fallback
driver, name, err := logistics.ResolveByPinnedOrFallback(pinnedDriver)

3.6 自定义驱动开发流程 ​

  1. 定义驱动实现:在 server/plugins/<driver_name>/ 下实现对应 Driver 接口
  2. 编写 plugin.json:声明 config_items 让管理员在配置中心填写密钥等信息
  3. 在 Install() 中注册:读取 ConfigKV,创建驱动实例,调用 driver.Register()
  4. 在 main.go 空导入:添加 _ "github.com/ijry/lyshop/plugins/<driver_name>"
  5. 在 config.yaml 启用:添加到 plugins.enabled 列表

3.7 最佳实践 ​

  • 驱动接口保持精简:方法不超过 5 个,只抽象核心操作
  • 配置从 ConfigKV 读取:不要硬编码密钥或地址,利用配置中心
  • 错误信息要脱敏:返回给前端的错误不应包含第三方 SDK 的敏感信息
  • Router 插件模式:多个同类驱动通过 router 插件统一管理(参考 storage_router、logistics_router)

四、数据模型与迁移 ​

4.1 Base 模型 ​

所有模型嵌入 model.Base(定义在 server/model/base.go),自动提供主键和时间戳:

go
type Base struct {
    ID        uint64    `gorm:"primaryKey;autoIncrement" json:"id"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

4.2 核心模型参考 ​

User(用户):

go
type User struct {
    Base
    MerchantID uint64 `gorm:"not null;default:0;index" json:"merchant_id"` // 多商户预留
    Phone      string `gorm:"size:20;uniqueIndex"      json:"phone"`
    Nickname   string `gorm:"size:64"                  json:"nickname"`
    Avatar     string `gorm:"size:500"                 json:"avatar"`
    Points     int    `gorm:"not null;default:0"       json:"points"`
    Status     int8   `gorm:"not null;default:1"       json:"status"` // 1=活跃 0=禁用
}

Admin(管理员):

go
type Admin struct {
    Base
    Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
    Password string `gorm:"size:255;not null"            json:"-"` // bcrypt 哈希,json 忽略
    RoleID   uint64 `gorm:"not null"                     json:"role_id"`
    Status   int8   `gorm:"not null;default:1"           json:"status"`
}

Role(角色):

go
type Role struct {
    Base
    Name        string          `gorm:"size:64;not null" json:"name"`
    Permissions json.RawMessage `gorm:"type:json"        json:"permissions"` // []string
}

ConfigKV(配置键值对):

go
type ConfigKV struct {
    ID     uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
    Plugin string `gorm:"size:64;not null;uniqueIndex:uk_plugin_key"  json:"plugin"`
    Key    string `gorm:"size:128;not null;uniqueIndex:uk_plugin_key" json:"key"`
    Value  string `gorm:"type:text"                                  json:"value"`
}

func (ConfigKV) TableName() string { return "configs" }

4.3 插件模型示例 ​

以商品模型为例(server/plugins/product/model/product.go):

go
type Product struct {
    model.Base
    MerchantID    uint64          `gorm:"not null;default:0;index"    json:"merchant_id"`
    CategoryID    uint64          `gorm:"not null;index"               json:"category_id"`
    Title         string          `gorm:"size:255;not null"            json:"title"`
    Subtitle      string          `gorm:"size:255"                     json:"subtitle"`
    Cover         string          `gorm:"size:500"                     json:"cover"`
    Price         float64         `gorm:"type:decimal(10,2);not null"  json:"price"`
    OriginPrice   float64         `gorm:"type:decimal(10,2)"           json:"origin_price"`
    Stock         int             `gorm:"not null;default:0"           json:"stock"`
    Sales         int             `gorm:"not null;default:0"           json:"sales"`
    FavoriteCount int             `gorm:"not null;default:0"           json:"favorite_count"`
    Status        int8            `gorm:"not null;default:1"           json:"status"`
    Sort          int             `gorm:"not null;default:0"           json:"sort"`
    Detail        json.RawMessage `gorm:"type:json"                    json:"detail"`
}

4.4 GORM Tag 规范 ​

Tag说明示例
gorm:"primaryKey"主键ID 字段
gorm:"autoIncrement"自增ID 字段
gorm:"size:N"varchar 长度size:255
gorm:"type:xxx"指定列类型type:decimal(10,2)、type:text、type:json
gorm:"not null"非空约束
gorm:"default:N"默认值default:0、default:1
gorm:"uniqueIndex"唯一索引单字段唯一
gorm:"uniqueIndex:name"复合唯一索引uniqueIndex:uk_plugin_key
gorm:"index"普通索引
json:"-"JSON 序列化忽略Password 字段
json:"field_name"JSON 字段名使用 snake_case

4.5 迁移方式 ​

LYShop 使用 GORM 的 AutoMigrate 进行迁移,在插件的 Migrate() 方法中调用:

go
func (p *myPlugin) Migrate(db *gorm.DB) error {
    return db.AutoMigrate(
        &mymodel.ModelA{},
        &mymodel.ModelB{},
    )
}

AutoMigrate 行为:

  • 表不存在 → 创建表
  • 表存在但缺少列 → 添加列
  • 列存在但类型变更 → 尝试修改(不删除列)
  • 索引不存在 → 创建索引
  • 不会删除已有列或数据

4.6 关联关系 ​

一对多:

go
// 一个分类有多个商品
type Category struct {
    model.Base
    Name     string    `gorm:"size:64;not null" json:"name"`
    Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
}

type Product struct {
    model.Base
    CategoryID uint64 `gorm:"not null;index" json:"category_id"`
    // ...
}

// 查询时预加载
db.Preload("Products").Find(&categories)

多对多(使用中间表):

go
// 商品收藏关系
type ProductFavorite struct {
    model.Base
    UserID    uint64 `gorm:"not null;uniqueIndex:uk_user_product" json:"user_id"`
    ProductID uint64 `gorm:"not null;uniqueIndex:uk_user_product" json:"product_id"`
}

4.7 最佳实践 ​

  • 所有模型嵌入 model.Base:保持 ID、时间戳的一致性
  • 字段命名使用 snake_case:与 JSON 输出保持一致
  • 字符串字段指定 size:避免默认 varchar(255) 浪费空间
  • 状态字段使用 int8:1=正常/启用,0=禁用/隐藏,预留扩展空间
  • 金额使用 decimal(10,2):避免浮点精度问题
  • JSON 字段使用 json.RawMessage:灵活存储结构化数据
  • 敏感字段加 json:"-":如密码不输出到 API 响应

五、API 开发规范 ​

5.1 统一响应格式 ​

所有 API 使用统一的 JSON 信封格式(server/core/response/response.go):

go
type R struct {
    Code int    `json:"code"` // 0=成功,非0=业务错误
    Msg  string `json:"msg"`  // 提示信息
    Data any    `json:"data"` // 业务数据
}

响应函数:

go
// 成功响应
response.OK(c, data)
// 输出: {"code": 0, "msg": "success", "data": {...}}

// 业务错误
response.Fail(c, 10001, "商品不存在")
// 输出: {"code": 10001, "msg": "商品不存在", "data": null}

// 用于 AbortWithStatusJSON 的错误
response.Err(401, "请先登录")

分页数据:

go
response.OK(c, response.PageData{
    List:  list,
    Total: total,
    Page:  page,
    Size:  size,
})
// 输出: {"code": 0, "msg": "success", "data": {"list": [...], "total": 100, "page": 1, "size": 20}}

5.2 路由组织 ​

路由分为三个组,在 server/core/app/app.go 中创建:

go
// 前台公开路由(用户端 API)
front := r.Group("/api/v1")

// 管理端公开路由(如登录)
adminPublic := r.Group("/admin/api")

// 管理端认证路由(需要 JWT + admin 角色)
adminAuth := r.Group("/admin/api")
adminAuth.Use(middleware.RequireAdmin)

插件中注册路由:

go
func (p *myPlugin) RegisterRoutes(front, admin *gin.RouterGroup) {
    // front 是 /api/v1 组
    // admin 是 /admin/api 组(已包含 RequireAdmin 中间件)
    myapi.RegisterFrontRoutes(front)
    myapi.RegisterAdminRoutes(admin)
}

5.3 前台路由示例 ​

参考商品插件的前台路由(server/plugins/product/api/front.go):

go
func RegisterFrontRoutes(g *gin.RouterGroup) {
    // 公开路由 — 无需登录
    g.GET("/categories", listCategories)
    g.GET("/products", listProducts)
    g.GET("/products/:id", getProduct)

    // 认证路由 — 需要用户登录
    auth := g.Group("")
    auth.Use(middleware.RequireAuth)
    auth.POST("/products/:id/favorite", favoriteProduct)
    auth.DELETE("/products/:id/favorite", unfavoriteProduct)
    auth.GET("/user/favorites", listUserFavorites)
}

5.4 管理端路由示例 ​

参考商品插件的管理端路由(server/plugins/product/api/admin.go):

go
func RegisterAdminRoutes(g *gin.RouterGroup) {
    // 每个接口单独声明权限
    g.GET("/products",     middleware.RequirePermission("product:view"),   adminListProducts)
    g.POST("/products",    middleware.RequirePermission("product:create"), adminCreateProduct)
    g.PUT("/products/:id", middleware.RequirePermission("product:edit"),   adminUpdateProduct)
    g.DELETE("/products/:id", middleware.RequirePermission("product:delete"), adminDeleteProduct)
}

5.5 认证中间件 ​

JWT 认证定义在 server/core/middleware/auth.go:

go
// JWT Claims 结构
type Claims struct {
    UserID uint64   `json:"user_id"`
    Role   string   `json:"role"`  // "user" | "admin"
    Perms  []string `json:"perms"` // 权限列表
    jwt.RegisteredClaims
}

// 生成 Token
token, err := middleware.GenerateToken(userID, "admin", []string{"product:view", "product:create"})

// 解析 Token
claims, err := middleware.ParseToken(tokenString)

三层认证中间件:

go
// RequireAuth — 校验 JWT,设置 user_id/role/perms 到 Context
middleware.RequireAuth

// RequireAdmin — 在 RequireAuth 基础上检查 role == "admin"
middleware.RequireAdmin

// RequirePermission — 检查具体权限(支持 "*" 通配符)
middleware.RequirePermission("product:view")

在 Handler 中获取用户信息:

go
func myHandler(c *gin.Context) {
    // 获取当前用户 ID
    userID, _ := c.Get("user_id")
    uid := userID.(uint64)

    // 获取角色
    role, _ := c.Get("role")

    // 获取权限列表
    perms, _ := c.Get("perms")
    permList := perms.([]string)
}

5.6 请求参数处理 ​

Query 参数(GET):

go
// 定义查询结构
type ProductListQuery struct {
    Page       int    `form:"page"`
    Size       int    `form:"size"`
    Keyword    string `form:"keyword"`
    CategoryID uint64 `form:"category_id"`
}

func listProducts(c *gin.Context) {
    var q ProductListQuery
    c.ShouldBindQuery(&q)
    // 使用 q.Page, q.Keyword 等
}

Path 参数:

go
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)

JSON Body(POST/PUT):

go
// 绑定到结构体
var req CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
    response.Fail(c, 400, err.Error())
    return
}

// 绑定到 map(灵活更新)
var updates map[string]any
c.ShouldBindJSON(&updates)

文件上传:

go
fh, err := c.FormFile("file")
if err != nil {
    response.Fail(c, 400, "请选择文件")
    return
}
driver, _ := storage.Get()
result, err := driver.Upload(c.Request.Context(), fh)

5.7 错误码约定 ​

范围说明
0成功
400参数校验错误
401未登录 / Token 无效
403无权限
500服务器内部错误
10001-19999业务错误(各插件自定义)

5.8 最佳实践 ​

  • HTTP 状态码始终 200:业务错误通过 code 字段区分,前端统一处理
  • 前台和管理端路由分离:分别注册到 front 和 admin 路由组
  • 权限逐接口声明:每个管理端接口单独使用 RequirePermission
  • Service 层不感知 HTTP:Handler 解析参数、调用 Service、返回响应,Service 只接受纯数据
  • 分页接口返回 PageData:统一 list/total/page/size 结构
  • Context 传递:使用 c.Request.Context() 传递给 Service 和 DB 操作

六、营销管线扩展 ​

6.1 管线架构 ​

LYShop 的营销价格计算使用管线(Pipeline)模式。多个 PriceCalculator 按优先级排序执行,依次对 PriceContext 中的价格进行修改。

商品原价 → 活动折扣 → VIP 折扣 → 满减折扣 → 优惠券折扣 → 积分抵扣 → 最终价格

6.2 PriceCalculator 接口 ​

定义在 server/core/marketing/pipeline.go:

go
type PriceCalculator interface {
    // Name 返回可读标识
    Name() string
    // Priority 控制执行顺序(数值越小越先执行)
    Priority() int
    // Calculate 修改 ctx 中的价格数据
    // 返回 continueNext=false 可中断管线(如排他性活动)
    Calculate(ctx *PriceContext) (continueNext bool, err error)
}

注册方式:

go
// 在 init() 中注册,自动按 Priority 排序
marketing.Register(myCalculator)

管线执行流程(Calculate 函数):

go
func Calculate(ctx *PriceContext) error {
    // 1. 计算商品小计
    ctx.GoodsAmount = sum(item.Price * item.Qty)
    ctx.FinalAmount = ctx.GoodsAmount

    // 2. 按优先级执行各 Calculator
    for _, calc := range calculators {
        cont, err := calc.Calculate(ctx)
        // 每步重新计算最终价格
        ctx.FinalAmount = ctx.GoodsAmount
            - ctx.ActivityDiscount
            - ctx.VipDiscount
            - ctx.FullReduceDiscount
            - ctx.CouponDiscount
            - ctx.PointsDiscount
        if !cont { break }
    }

    // 3. 最终价格不低于 0
    if ctx.FinalAmount < 0 { ctx.FinalAmount = 0 }
}

6.3 PriceContext 数据结构 ​

定义在 server/core/marketing/context.go:

go
type PriceContext struct {
    // === 输入 ===
    UserID          uint64
    Items           []OrderItem            // 订单商品行
    CouponIDs       []uint64               // 用户选择的优惠券
    ActivityID      uint64                 // 指定活动(0=自动检测)
    PointsUse       int                    // 用户选择使用的积分数
    IsVIP           bool                   // 是否 VIP
    VIPLevelID      uint64                 // VIP 等级 ID
    ItemVIPDiscount map[uint64]float64     // sku_id -> VIP 折扣金额

    // === 计算结果(各步骤逐步填充)===
    GoodsAmount        float64             // 商品小计
    ActivityDiscount   float64             // 活动折扣
    VipDiscount        float64             // VIP 折扣
    FullReduceDiscount float64             // 满减折扣
    CouponDiscount     float64             // 优惠券折扣
    PointsDiscount     float64             // 积分抵扣
    FinalAmount        float64             // 最终价格

    AppliedRules []AppliedRule              // 已应用的规则记录
    Commissions  []Commission              // 分销返佣(不影响 FinalAmount)
}

type OrderItem struct {
    ProductID     uint64
    SkuID         uint64
    Title         string
    Price         float64 // 原始单价
    Qty           int
    ActivityPrice float64 // 活动价(0 表示无)
    VipPrice      float64 // VIP 价(0 表示无)
}

type AppliedRule struct {
    Type     string  `json:"type"`     // activity|vip|coupon|full_reduce|points
    Name     string  `json:"name"`     // 可读标签
    Discount float64 `json:"discount"` // 折扣金额(正数)
}

6.4 完整示例:实现满减计算器 ​

以下实现「满 200 减 30」的满减规则:

go
// server/plugins/marketing/calculator/full_reduce.go
package calculator

import (
    "github.com/ijry/lyshop/core/marketing"
)

type fullReduceCalc struct{}

func init() {
    marketing.Register(&fullReduceCalc{})
}

func (c *fullReduceCalc) Name() string     { return "满减折扣" }
func (c *fullReduceCalc) Priority() int    { return 300 } // 在活动(100)、VIP(200) 之后

func (c *fullReduceCalc) Calculate(ctx *marketing.PriceContext) (bool, error) {
    // 计算当前已扣折扣后的实付金额
    currentAmount := ctx.GoodsAmount - ctx.ActivityDiscount - ctx.VipDiscount

    // 示例规则:满 200 减 30,满 500 减 100
    rules := []struct {
        Threshold float64
        Discount  float64
    }{
        {500, 100},
        {200, 30},
    }

    for _, rule := range rules {
        if currentAmount >= rule.Threshold {
            ctx.FullReduceDiscount = rule.Discount
            ctx.AppliedRules = append(ctx.AppliedRules, marketing.AppliedRule{
                Type:     "full_reduce",
                Name:     fmt.Sprintf("满%.0f减%.0f", rule.Threshold, rule.Discount),
                Discount: rule.Discount,
            })
            break // 只命中最高一档
        }
    }

    return true, nil // continueNext=true,允许后续折扣继续
}

6.5 优先级推荐 ​

优先级阶段说明
100活动折扣秒杀、团购等排他活动
200VIP 折扣会员专属价
300满减折扣满减规则
400优惠券折扣用户手动选择使用
500积分抵扣积分兑换
900分销佣金计算返佣(不影响支付价格)

6.6 最佳实践 ​

  • 排他性活动:返回 continueNext=false 可阻止后续折扣(如秒杀不可叠加优惠券)
  • AppliedRules 必须记录:所有折扣都需追加到 AppliedRules,用于订单详情展示
  • 折扣字段互不干扰:每个 Calculator 只修改自己对应的字段(ActivityDiscount / VipDiscount / ...)
  • 管线中不做数据库写操作:管线是纯计算,数据持久化由订单模块负责
  • 在实际项目中应从数据库读取规则:示例中硬编码仅作演示

七、Admin 后台前端开发 ​

7.1 技术架构 ​

项目说明
框架Vue 3 + TypeScript
构建Vite 5
样式TailwindCSS 3.4
状态Pinia
路由Vue Router 4 (hash/history)
图标lucide-vue-next
图表ECharts 5
APIAxios

目录结构:

admin/src/
├── api/              # API 模块
│   ├── request.ts    # Axios 封装
│   ├── auth.ts       # 认证相关 API
│   └── plugins.ts    # 插件相关 API
├── components/       # 通用组件
├── layouts/
│   └── AdminLayout.vue # 管理后台主布局
├── mock/             # Mock 数据
├── router/
│   └── index.ts      # 路由配置
├── stores/
│   └── auth.ts       # 认证状态
├── types/            # TypeScript 类型定义
├── utils/            # 工具函数
│   ├── notify.ts     # 通知处理
│   ├── toast.ts      # Toast 提示
│   └── dialog.ts     # 对话框
└── views/            # 页面组件(按模块分目录)

7.2 路由注册 ​

在 admin/src/router/index.ts 中注册新页面:

typescript
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'

const isMock = import.meta.env.VITE_MOCK === 'true'

const router = createRouter({
  // Mock 模式用 hash,生产用 history + /admin 前缀
  history: isMock ? createWebHashHistory() : createWebHistory('/admin'),
  routes: [
    { path: '/login', component: () => import('@/views/Login.vue') },
    {
      path: '/',
      component: () => import('@/layouts/AdminLayout.vue'),
      meta: { requiresAuth: true },
      children: [
        { path: '', redirect: '/dashboard' },
        { path: 'dashboard', name: '首页', component: () => import('@/views/Dashboard.vue') },

        // 示例:为公告插件添加管理端页面
        { path: 'announcement/list', name: '公告列表',
          component: () => import('@/views/announcement/AnnouncementList.vue') },
        { path: 'announcement/form', name: '新增公告',
          component: () => import('@/views/announcement/AnnouncementForm.vue') },
        { path: 'announcement/form/:id', name: '编辑公告',
          component: () => import('@/views/announcement/AnnouncementForm.vue') },
      ]
    }
  ]
})

// 路由守卫:检查 admin_token
router.beforeEach(to => {
  const token = localStorage.getItem('admin_token')
  if (to.meta.requiresAuth && !token) return '/login'
})

要点:

  • 路由的 name 会显示在顶部导航栏
  • 所有页面放在 AdminLayout 的 children 中
  • 使用动态导入实现代码分割

7.3 API 调用 ​

请求封装(admin/src/api/request.ts):

typescript
const http = axios.create({ baseURL: '/admin/api', timeout: 30000 })

// 请求拦截器:注入 Token
http.interceptors.request.use(config => {
  const token = localStorage.getItem('admin_token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

// 响应拦截器:解包 { code, msg, data }
http.interceptors.response.use(res => {
  const { code, msg, data } = res.data
  if (code !== 0) return Promise.reject(new Error(msg || '请求失败'))
  return data  // 直接返回 data 字段
})

编写 API 模块:

typescript
// admin/src/api/announcement.ts
import { get, post, put, del } from './request'

export interface Announcement {
  id: number
  title: string
  content: string
  sort: number
  status: number
  created_at: string
}

export function listAnnouncements(params: { page: number; size: number }) {
  return get<{ list: Announcement[]; total: number }>('/announcements', params)
}

export function createAnnouncement(data: Partial<Announcement>) {
  return post<Announcement>('/announcements', data)
}

export function updateAnnouncement(id: number, data: Partial<Announcement>) {
  return put(`/announcements/${id}`, data)
}

export function deleteAnnouncement(id: number) {
  return del(`/announcements/${id}`)
}

7.4 列表页开发 ​

以公告列表为例:

vue
<!-- admin/src/views/announcement/AnnouncementList.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listAnnouncements, deleteAnnouncement } from '@/api/announcement'
import type { Announcement } from '@/api/announcement'
import { useRouter } from 'vue-router'
import { showToast } from '@/utils/toast'
import { showDialog } from '@/utils/dialog'

const router = useRouter()
const list = ref<Announcement[]>([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const loading = ref(false)

async function fetchList() {
  loading.value = true
  try {
    const data = await listAnnouncements({ page: page.value, size: size.value })
    list.value = data.list || []
    total.value = data.total
  } finally {
    loading.value = false
  }
}

async function handleDelete(id: number) {
  const ok = await showDialog('确认删除该公告?')
  if (!ok) return
  await deleteAnnouncement(id)
  showToast('删除成功')
  fetchList()
}

onMounted(fetchList)
</script>

<template>
  <div class="p-6">
    <!-- 头部 -->
    <div class="flex items-center justify-between mb-6">
      <h2 class="text-xl font-bold">公告列表</h2>
      <button
        class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
        @click="router.push('/announcement/form')"
      >
        新增公告
      </button>
    </div>

    <!-- 表格 -->
    <div class="bg-white rounded-lg shadow overflow-hidden">
      <table class="w-full text-sm">
        <thead class="bg-gray-50 text-left">
          <tr>
            <th class="px-4 py-3 font-medium">ID</th>
            <th class="px-4 py-3 font-medium">标题</th>
            <th class="px-4 py-3 font-medium">排序</th>
            <th class="px-4 py-3 font-medium">状态</th>
            <th class="px-4 py-3 font-medium">创建时间</th>
            <th class="px-4 py-3 font-medium">操作</th>
          </tr>
        </thead>
        <tbody class="divide-y">
          <tr v-for="item in list" :key="item.id" class="hover:bg-gray-50">
            <td class="px-4 py-3">{{ item.id }}</td>
            <td class="px-4 py-3">{{ item.title }}</td>
            <td class="px-4 py-3">{{ item.sort }}</td>
            <td class="px-4 py-3">
              <span :class="item.status === 1 ? 'text-green-600' : 'text-gray-400'">
                {{ item.status === 1 ? '显示' : '隐藏' }}
              </span>
            </td>
            <td class="px-4 py-3">{{ item.created_at }}</td>
            <td class="px-4 py-3 space-x-2">
              <button
                class="text-blue-600 hover:underline"
                @click="router.push(`/announcement/form/${item.id}`)"
              >
                编辑
              </button>
              <button
                class="text-red-600 hover:underline"
                @click="handleDelete(item.id)"
              >
                删除
              </button>
            </td>
          </tr>
        </tbody>
      </table>
    </div>

    <!-- 分页 -->
    <div class="flex justify-end mt-4 gap-2">
      <button
        class="px-3 py-1 border rounded"
        :disabled="page <= 1"
        @click="page--; fetchList()"
      >上一页</button>
      <span class="px-3 py-1">{{ page }} / {{ Math.ceil(total / size) }}</span>
      <button
        class="px-3 py-1 border rounded"
        :disabled="page * size >= total"
        @click="page++; fetchList()"
      >下一页</button>
    </div>
  </div>
</template>

7.5 表单页开发 ​

vue
<!-- admin/src/views/announcement/AnnouncementForm.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { createAnnouncement, updateAnnouncement } from '@/api/announcement'
import { get } from '@/api/request'
import { showToast } from '@/utils/toast'

const route = useRoute()
const router = useRouter()
const id = route.params.id ? Number(route.params.id) : null
const isEdit = !!id

const form = ref({
  title: '',
  content: '',
  sort: 0,
  status: 1,
})
const submitting = ref(false)

onMounted(async () => {
  if (isEdit) {
    const data = await get(`/announcements/${id}`)
    Object.assign(form.value, data)
  }
})

async function handleSubmit() {
  if (!form.value.title.trim()) {
    showToast('请输入标题')
    return
  }
  submitting.value = true
  try {
    if (isEdit) {
      await updateAnnouncement(id!, form.value)
      showToast('更新成功')
    } else {
      await createAnnouncement(form.value)
      showToast('创建成功')
    }
    router.push('/announcement/list')
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <div class="p-6 max-w-2xl">
    <h2 class="text-xl font-bold mb-6">{{ isEdit ? '编辑公告' : '新增公告' }}</h2>

    <div class="space-y-4">
      <!-- 标题 -->
      <div>
        <label class="block text-sm font-medium mb-1">标题 <span class="text-red-500">*</span></label>
        <input
          v-model="form.title"
          class="w-full border rounded-lg px-3 py-2"
          placeholder="请输入公告标题"
        />
      </div>

      <!-- 内容 -->
      <div>
        <label class="block text-sm font-medium mb-1">内容</label>
        <textarea
          v-model="form.content"
          class="w-full border rounded-lg px-3 py-2"
          rows="6"
          placeholder="请输入公告内容"
        />
      </div>

      <!-- 排序 -->
      <div>
        <label class="block text-sm font-medium mb-1">排序</label>
        <input
          v-model.number="form.sort"
          type="number"
          class="w-full border rounded-lg px-3 py-2"
        />
      </div>

      <!-- 状态 -->
      <div>
        <label class="block text-sm font-medium mb-1">状态</label>
        <select v-model="form.status" class="w-full border rounded-lg px-3 py-2">
          <option :value="1">显示</option>
          <option :value="0">隐藏</option>
        </select>
      </div>

      <!-- 操作按钮 -->
      <div class="flex gap-3 pt-4">
        <button
          class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
          :disabled="submitting"
          @click="handleSubmit"
        >
          {{ isEdit ? '保存' : '创建' }}
        </button>
        <button
          class="px-6 py-2 border rounded-lg hover:bg-gray-50"
          @click="router.back()"
        >
          取消
        </button>
      </div>
    </div>
  </div>
</template>

7.6 认证 Store ​

管理端认证状态(admin/src/stores/auth.ts):

typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  const token = ref(localStorage.getItem('admin_token') || '')
  const isLoggedIn = computed(() => !!token.value)

  // 从 JWT payload 解析权限
  const permissions = computed<string[]>(() => {
    if (!token.value) return []
    try {
      const payload = token.value.split('.')[1]
      // 标准 base64 解码
      const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
      const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
      const data = JSON.parse(atob(padded))
      return data.perms || []
    } catch { return [] }
  })

  function loginAction(newToken: string) {
    token.value = newToken
    localStorage.setItem('admin_token', newToken)
  }

  function hasPermission(perm: string): boolean {
    return permissions.value.includes('*') || permissions.value.includes(perm)
  }

  function logout() {
    token.value = ''
    localStorage.removeItem('admin_token')
  }

  return { token, isLoggedIn, permissions, loginAction, hasPermission, logout }
})

7.7 工具函数 ​

Toast 提示:

typescript
// admin/src/utils/toast.ts
import { ref } from 'vue'

export interface ToastItem {
  id: number
  message: string
  type: 'success' | 'error' | 'info'
}

export const toasts = ref<ToastItem[]>([])
let seq = 0

export function showToast(message: string, type: 'success' | 'error' | 'info' = 'success') {
  const id = ++seq
  toasts.value.push({ id, message, type })
  setTimeout(() => {
    toasts.value = toasts.value.filter(t => t.id !== id)
  }, 3000)
}

对话框:

typescript
// admin/src/utils/dialog.ts
export function showDialog(message: string): Promise<boolean> {
  return Promise.resolve(confirm(message))
}

7.8 最佳实践 ​

  • 页面按模块分目录:views/announcement/、views/product/ 等
  • 路由名称即页面标题:name 字段会显示在 AdminLayout 的顶部栏
  • 使用 TailwindCSS 原子类:保持样式一致性,无需编写 <style> 块
  • API 模块独立封装:每个业务模块一个 API 文件,导出类型和函数
  • 请求错误统一处理:拦截器自动解包响应并抛出错误

八、PC 商城与 H5 前端开发 ​

8.1 Web 端(PC 商城) ​

技术架构 ​

web/src/
├── api/request.ts     # Axios 封装 + Mock 支持
├── components/        # 通用组件
├── mock/              # Mock 数据
├── router/index.ts    # 路由配置
├── stores/            # Pinia 状态管理
│   ├── auth.ts        # 认证
│   ├── cart.ts        # 购物车
│   ├── chat.ts        # 聊天
│   └── site.ts        # 站点配置
├── utils/             # 工具函数
└── views/             # 页面

API 请求封装 ​

Web 端的请求封装(web/src/api/request.ts)支持 Mock 和真实 API 两种模式:

typescript
import axios from 'axios'

const MOCK_ENABLED = import.meta.env.VITE_MOCK === 'true'
const BASE_URL = MOCK_ENABLED ? '' : (import.meta.env.VITE_API_URL || '')

const http = axios.create({ baseURL: BASE_URL, timeout: 30000 })

// Token 注入
http.interceptors.request.use(config => {
  const token = localStorage.getItem('user_token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

// 响应解包
http.interceptors.response.use(
  res => {
    const { code, msg, data } = res.data
    if (code !== 0) return Promise.reject(new Error(msg || '请求失败'))
    return data
  },
  err => Promise.reject(err)
)

// Mock 模式下的请求处理
async function mockRequest<T>(method: string, url: string, params?: any): Promise<T> {
  const { matchMock } = await import('@/mock/index')
  const result = matchMock(method, url, params)
  await new Promise(r => setTimeout(r, 100 + Math.random() * 200))
  if (result.matched) return (result.data ?? null) as T
  console.warn(`[Mock] No data for: ${method} ${url}`)
  return null as T
}

// 导出请求方法
export async function get<T = any>(url: string, params?: any): Promise<T> {
  if (MOCK_ENABLED) return mockRequest<T>('GET', url, params)
  return http.get(url, { params }) as Promise<T>
}

export async function post<T = any>(url: string, data?: any): Promise<T> {
  if (MOCK_ENABLED) return mockRequest<T>('POST', url, data)
  return http.post(url, data) as Promise<T>
}

export async function put<T = any>(url: string, data?: any): Promise<T> {
  if (MOCK_ENABLED) return mockRequest<T>('PUT', url, data)
  return http.put(url, data) as Promise<T>
}

export async function del<T = any>(url: string, data?: any): Promise<T> {
  if (MOCK_ENABLED) return mockRequest<T>('DELETE', url, data)
  return http.delete(url, { data }) as Promise<T>
}

export async function upload<T = any>(url: string, file: File): Promise<T> {
  if (MOCK_ENABLED) return mockRequest<T>('POST', url, { name: file.name, size: file.size })
  const form = new FormData()
  form.append('file', file)
  return http.post(url, form, {
    headers: { 'Content-Type': 'multipart/form-data' },
  }) as Promise<T>
}

Pinia Store 模式 ​

认证 Store:

typescript
// web/src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  const token = ref(localStorage.getItem('user_token') || '')
  const isLoggedIn = computed(() => !!token.value)

  function setToken(t: string) {
    token.value = t
    localStorage.setItem('user_token', t)
  }

  function logout() {
    token.value = ''
    localStorage.removeItem('user_token')
  }

  return { token, isLoggedIn, setToken, logout }
})

购物车 Store:

typescript
// web/src/stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface CartItem {
  sku_id: number
  qty: number
  product: { id: number; title: string; cover: string; price: number }
  sku: { id: number; attrs: string; price: number; stock: number }
}

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const total = computed(() => items.value.reduce((s, i) => s + i.sku.price * i.qty, 0))
  const count = computed(() => items.value.length)

  function setItems(list: CartItem[]) { items.value = list }
  function removeItem(skuId: number) {
    items.value = items.value.filter(i => i.sku_id !== skuId)
  }
  function updateQty(skuId: number, qty: number) {
    const item = items.value.find(i => i.sku_id === skuId)
    if (item) item.qty = qty
  }

  return { items, total, count, setItems, removeItem, updateQty }
})

主题色系统 ​

站点配置 Store(web/src/stores/site.ts)负责动态主题:

typescript
export interface SiteSettings {
  site_name: string
  site_logo: string
  seo_title: string
  seo_keywords: string
  seo_description: string
  icp: string
  // Hero 区域
  hero_badge: string
  hero_title: string
  hero_subtitle: string
  hero_btn_text: string
  hero_btn_link: string
  // 主题色
  color_primary: string       // 主色,如 #dc2626
  color_primary_light: string // 亮主色
  color_primary_dark: string  // 暗主色
  color_bg_page: string       // 页面背景
  color_bg_header: string     // 头部背景
  color_bg_footer: string     // 底部背景
  color_price: string         // 价格颜色
  color_hero_from: string     // Hero 渐变起始
  color_hero_to: string       // Hero 渐变结束
}

主题应用机制:

typescript
function applyTheme(s: SiteSettings) {
  const root = document.documentElement.style
  root.setProperty('--color-primary', s.color_primary)
  root.setProperty('--color-primary-light', s.color_primary_light)
  root.setProperty('--color-primary-dark', s.color_primary_dark)
  root.setProperty('--color-bg-page', s.color_bg_page)
  root.setProperty('--color-bg-header', s.color_bg_header)
  root.setProperty('--color-bg-footer', s.color_bg_footer)
  root.setProperty('--color-price', s.color_price)
  root.setProperty('--color-hero-from', s.color_hero_from)
  root.setProperty('--color-hero-to', s.color_hero_to)

  // 同步 SEO 元信息
  document.title = s.seo_title || s.site_name
}

在组件中使用 CSS 变量:

vue
<template>
  <!-- 使用 CSS 变量 -->
  <button :style="{ background: 'var(--color-primary)' }">购买</button>
  <span :style="{ color: 'var(--color-price)' }">¥{{ price }}</span>

  <!-- 在 UnoCSS 中使用 -->
  <div class="bg-[var(--color-bg-page)]">...</div>
</template>

路由配置 ​

typescript
// web/src/router/index.ts
import { createRouter, createWebHashHistory } from 'vue-router'

const router = createRouter({
  history: createWebHashHistory(),
  scrollBehavior: () => ({ top: 0 }),
  routes: [
    { path: '/', component: () => import('@/views/Home.vue') },
    { path: '/products', component: () => import('@/views/ProductList.vue') },
    { path: '/product/:id', component: () => import('@/views/ProductDetail.vue') },
    { path: '/cart', component: () => import('@/views/Cart.vue') },
    { path: '/orders', component: () => import('@/views/OrderList.vue') },
    { path: '/orders/:id', component: () => import('@/views/OrderDetail.vue') },
    { path: '/login', component: () => import('@/views/Login.vue') },
    { path: '/user', component: () => import('@/views/UserCenter.vue') },
    // 添加新页面...
  ]
})

新增页面示例 ​

在 Web 端添加一个公告展示页面:

vue
<!-- web/src/views/Announcements.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get } from '@/api/request'

interface Announcement {
  id: number
  title: string
  content: string
  created_at: string
}

const list = ref<Announcement[]>([])

onMounted(async () => {
  list.value = await get<Announcement[]>('/api/v1/announcements') || []
})
</script>

<template>
  <div class="max-w-4xl mx-auto py-8 px-4">
    <h1 class="text-2xl font-bold mb-6">公告通知</h1>
    <div class="space-y-4">
      <div
        v-for="item in list"
        :key="item.id"
        class="bg-white rounded-lg shadow p-6"
      >
        <h2 class="text-lg font-semibold mb-2">{{ item.title }}</h2>
        <p class="text-gray-600 text-sm mb-3">{{ item.created_at }}</p>
        <div class="text-gray-700 leading-relaxed">{{ item.content }}</div>
      </div>
      <div v-if="!list.length" class="text-center text-gray-400 py-12">
        暂无公告
      </div>
    </div>
  </div>
</template>

然后在 web/src/router/index.ts 添加路由:

typescript
{ path: '/announcements', component: () => import('@/views/Announcements.vue') },

8.2 App 端(H5/小程序) ​

技术架构 ​

App 端使用 uni-app 框架,同时支持 H5 和微信小程序:

app/
├── composables/       # 组合式函数
│   └── useTheme.ts    # 主题管理
├── components/        # 组件
├── mock/              # Mock 数据 + 行业预设
├── pages/             # 页面(按 pages.json 配置)
├── utils/
│   └── request.ts     # 请求封装(uni.request)
├── pages.json         # 路由配置
├── App.vue            # 根组件
└── main.ts            # 入口

路由配置(pages.json) ​

uni-app 使用 pages.json 代替 Vue Router:

json
{
  "pages": [
    { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页", "navigationStyle": "custom" } },
    { "path": "pages/product/list", "style": { "navigationBarTitleText": "商品" } },
    { "path": "pages/product/detail", "style": { "navigationBarTitleText": "商品详情" } },
    { "path": "pages/cart/index", "style": { "navigationBarTitleText": "购物车" } },
    { "path": "pages/order/list", "style": { "navigationBarTitleText": "订单" } }
  ],
  "tabBar": {
    "color": "#999",
    "selectedColor": "#dc2626",
    "list": [
      { "pagePath": "pages/index/index", "text": "首页" },
      { "pagePath": "pages/product/list", "text": "商品" },
      { "pagePath": "pages/cart/index", "text": "购物车" },
      { "pagePath": "pages/order/list", "text": "订单" },
      { "pagePath": "pages/user/index", "text": "我的" }
    ]
  }
}

添加新页面:

json
// 在 pages 数组中添加
{ "path": "pages/announcement/index", "style": { "navigationBarTitleText": "公告" } }

然后创建对应文件 app/pages/announcement/index.vue。

请求封装 ​

App 端使用 uni.request 封装(app/utils/request.ts):

typescript
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080'

export function get<T>(url: string, params?: any): Promise<T> {
  return new Promise((resolve, reject) => {
    const token = uni.getStorageSync('user_token')
    uni.request({
      url: `${BASE_URL}${url}`,
      method: 'GET',
      data: params,
      header: { Authorization: token ? `Bearer ${token}` : '' },
      success(res: any) {
        const { code, msg, data } = res.data
        if (code !== 0) return reject(new Error(msg))
        resolve(data as T)
      },
      fail: reject,
    })
  })
}

暗黑模式 Composable ​

主题管理(app/composables/useTheme.ts):

typescript
import { ref, computed } from 'vue'

export type ThemeMode = 'light' | 'dark' | 'auto'

const STORAGE_KEY = 'app_theme_mode'
const themeMode = ref<ThemeMode>(uni.getStorageSync(STORAGE_KEY) || 'auto')

function getSystemTheme(): 'light' | 'dark' {
  try {
    const info = uni.getSystemInfoSync()
    if ((info as any).theme === 'dark') return 'dark'
  } catch {}
  if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
    return 'dark'
  }
  return 'light'
}

const effectiveTheme = computed(() => {
  if (themeMode.value === 'auto') return getSystemTheme()
  return themeMode.value
})

function applyTheme() {
  const theme = effectiveTheme.value
  if (typeof document !== 'undefined') {
    document.documentElement.setAttribute('data-up-theme', theme)
  }
}

function setTheme(mode: ThemeMode) {
  themeMode.value = mode
  uni.setStorageSync(STORAGE_KEY, mode)
  applyTheme()
}

function toggleTheme() {
  setTheme(effectiveTheme.value === 'light' ? 'dark' : 'light')
}

function initTheme() {
  applyTheme()
  if (typeof window !== 'undefined') {
    window.matchMedia?.('(prefers-color-scheme: dark)')
      .addEventListener('change', () => { if (themeMode.value === 'auto') applyTheme() })
  }
}

export function useTheme() {
  return { themeMode, effectiveTheme, setTheme, toggleTheme, applyTheme, initTheme }
}

在页面中使用:

vue
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { effectiveTheme, toggleTheme } = useTheme()
</script>

<template>
  <view @tap="toggleTheme">
    当前主题:{{ effectiveTheme }}
  </view>
</template>

8.3 跨端开发注意事项 ​

差异点Web 端App 端
HTTP 客户端Axiosuni.request
路由方式Vue Routerpages.json + uni.navigateTo
存储localStorageuni.getStorageSync
样式框架UnoCSSUnoCSS + uview-plus
条件编译不支持支持 #ifdef H5 / #ifdef MP-WEIXIN
页面导航router.push()uni.navigateTo({ url: '/pages/xxx' })
文件上传FormDatauni.uploadFile

九、配置中心与权限系统 ​

9.1 配置中心 ​

配置存储模型 ​

配置数据存储在 configs 表中,以插件为命名空间:

go
type ConfigKV struct {
    ID     uint64 `gorm:"primaryKey;autoIncrement"`
    Plugin string `gorm:"size:64;not null;uniqueIndex:uk_plugin_key"`  // 插件名
    Key    string `gorm:"size:128;not null;uniqueIndex:uk_plugin_key"` // 配置键
    Value  string `gorm:"type:text"`                                   // 配置值
}

配置声明(plugin.json) ​

插件通过 config_items 声明配置字段,管理端自动渲染配置表单:

json
{
  "name": "sms",
  "config_items": [
    {
      "key": "provider",
      "label": "短信服务商",
      "type": "select",
      "required": true,
      "options": [
        { "label": "阿里云", "value": "aliyun" },
        { "label": "腾讯云", "value": "tencent" }
      ]
    },
    {
      "key": "access_key",
      "label": "AccessKey",
      "type": "text",
      "required": true
    },
    {
      "key": "secret_key",
      "label": "SecretKey",
      "type": "password",
      "required": true
    },
    {
      "key": "sign_name",
      "label": "签名名称",
      "type": "text",
      "required": true,
      "placeholder": "如:LYShop"
    }
  ]
}

支持的字段类型:

type渲染方式适用场景
text单行文本输入AccessKey、域名
password密码输入框SecretKey
textarea多行文本输入证书内容
number数字输入端口号、限额
select下拉选择服务商选择
switch开关功能启用/禁用

配置中心 API ​

GET  /admin/api/config/schemas     → 获取所有插件的配置声明
GET  /admin/api/config/:plugin     → 获取某插件的配置值
PUT  /admin/api/config/:plugin     → 保存某插件的配置值

示例请求:

bash
# 获取短信插件配置
GET /admin/api/config/sms
# 响应: {"code":0, "data": {"provider":"aliyun", "access_key":"xxx", ...}}

# 保存配置
PUT /admin/api/config/sms
Body: {"provider":"aliyun", "access_key":"LTAI...", "secret_key":"xxx", "sign_name":"LYShop"}

在插件中读取配置 ​

go
func readConfig(pluginName string) map[string]string {
    var kvs []model.ConfigKV
    db.DB.Where("plugin = ?", pluginName).Find(&kvs)
    result := make(map[string]string)
    for _, kv := range kvs {
        result[kv.Key] = kv.Value
    }
    return result
}

// 使用
cfg := readConfig("sms")
provider := cfg["provider"]  // "aliyun"
accessKey := cfg["access_key"]

9.2 权限系统(RBAC) ​

架构设计 ​

LYShop 使用基于角色的访问控制(RBAC):

Admin —— belongsTo ——→ Role —— has ——→ []Permission
  • 每个 Admin 关联一个 Role
  • 每个 Role 包含一组权限字符串(json.RawMessage)
  • 权限格式:模块:操作,如 product:view、order:edit
  • "*" 通配符表示超级管理员(拥有所有权限)

权限声明 ​

每个插件在 plugin.json 中声明自己的权限:

json
{
  "permissions": [
    "product:view",
    "product:create",
    "product:edit",
    "product:delete"
  ]
}

框架自动聚合所有启用插件的权限:

go
// 获取所有已声明的权限(用于角色管理界面)
allPerms := plugin.AllPermissions(config.Global.Plugins.Enabled)

权限校验 ​

后端校验(中间件):

go
// 单个接口声明所需权限
g.GET("/products", middleware.RequirePermission("product:view"), handler)

// RequirePermission 的实现
func RequirePermission(perm string) gin.HandlerFunc {
    return func(c *gin.Context) {
        perms := c.GetStringSlice("perms") // 从 JWT 解析
        for _, p := range perms {
            if p == "*" || p == perm {
                c.Next()
                return
            }
        }
        c.AbortWithStatusJSON(200, response.Err(403, "无权限: "+perm))
    }
}

前端校验(Store):

typescript
const authStore = useAuthStore()

// 在页面或组件中检查权限
if (authStore.hasPermission('product:create')) {
  // 显示「新增商品」按钮
}

9.3 菜单系统 ​

管理端菜单由插件动态声明,框架按权限过滤:

go
// 管理端菜单 API(app.go 中注册)
adminAuth.GET("/menus", func(c *gin.Context) {
    perms, _ := c.Get("perms")
    permList, _ := perms.([]string)
    // 只返回当前管理员有权限的菜单项
    menus := plugin.EnabledMenus(config.Global.Plugins.Enabled, permList)
    c.JSON(200, menus)
})

菜单过滤逻辑:

  1. 遍历 plugins.enabled 列表中的每个插件
  2. 获取插件的 Menus 声明
  3. 递归过滤:如果菜单项有 permission 字段且用户无此权限,则隐藏
  4. 如果父菜单的所有子菜单都被过滤,则父菜单也隐藏

9.4 完整的权限配置流程 ​

  1. 插件声明权限:在 plugin.json 的 permissions 字段中列出
  2. 创建角色:在管理端创建角色,勾选所需权限
  3. 分配角色:将角色分配给管理员账户
  4. 后端校验:每个 API 通过 RequirePermission 中间件校验
  5. 前端校验:菜单 API 按权限过滤,页面内按需检查

9.5 最佳实践 ​

  • 权限命名规范:模块:操作,如 product:view、order:create
  • 配置键命名:使用 snake_case,与 Go 和 JSON 风格一致
  • 敏感配置用 password 类型:在前端以掩码显示
  • "*" 权限仅给超级管理员:不要在普通角色中使用通配符
  • 配置变更后需重新初始化驱动:部分驱动在 Install 时读取配置,修改后可能需要重启

十、测试、调试与部署 ​

10.1 Mock 系统 ​

所有前端应用都支持 Mock 模式,通过 VITE_MOCK=true 环境变量启用。

启动命令:

bash
# Web 端演示模式
cd web && npm run dev:demo

# Admin 端演示模式
cd admin && npm run dev:demo

# App H5 演示模式
cd app && npm run dev:h5:demo

Mock 数据组织:

web/src/mock/index.ts      # Web 端 Mock 路由匹配
admin/src/mock/index.ts    # Admin 端 Mock
app/mock/
├── index.ts               # App 端 Mock 路由匹配
├── data/                  # 原始数据(JSON)
└── presets/               # 行业预设(商城/超市/生鲜/珠宝 等 7 个)
    ├── types.ts           # MockPreset 类型定义
    ├── index.ts           # 预设加载器
    ├── mall.ts            # 综合商城预设
    └── ...

App 商品详情演示补全:

  • app/mock/index.ts 继续复用现有 /api/v1/products/:id 和 /api/v1/products/:id/reviews,不新增演示专用接口。
  • 当预设仅提供商品列表基础字段时,Mock 层会自动补齐商品详情所需的轮播图、SKU 规格、详情图文 blocks、销量/库存/收藏数。
  • 对没有真实订单评价沉淀的商品,Mock 层会补齐评价列表、追评和商家回复,确保列表页、推荐位和营销页跳转到详情页时都有完整演示数据。
  • 商品详情轮播图、详情图和评价晒图会优先复用商品自身 cover、images 和 detail.blocks 中已有图片,减少随机图与商品语义不匹配的问题。

Mock 路由匹配原理:

typescript
// mock/index.ts 中的 matchMock 函数
export function matchMock(method: string, url: string, params?: any) {
  // 路由表以 "METHOD /path" 为 key
  const routes: Record<string, Function> = {
    'GET /api/v1/products': (params) => {
      // 返回 Mock 数据
      return { matched: true, data: { list: [...], total: 100 } }
    },
    'GET /api/v1/products/:id': (params) => {
      const id = extractParam(url, '/api/v1/products/:id')
      return { matched: true, data: productDetail }
    },
    // ...
  }
}

添加新接口的 Mock 数据:

在对应的 mock/index.ts 中添加路由匹配规则即可:

typescript
// 为公告接口添加 Mock
'GET /api/v1/announcements': () => ({
  matched: true,
  data: [
    { id: 1, title: '618 大促开启', content: '...', created_at: '2026-06-01' },
    { id: 2, title: '新用户注册送礼', content: '...', created_at: '2026-05-20' },
  ]
}),

10.1.1 PC 装修高级样式结构(PcDecorPage) ​

PC 装修接口路径为(/admin/api/decor/pc、/api/v1/pc/decor),components 字段使用页面对象:

json
{
  "components": {
    "pageStyle": {
      "background": {
        "mode": "solid",
        "solidColor": "#f8fafc",
        "gradient": {
          "angle": 135,
          "stops": [
            { "color": "#f8fafc", "position": 0 },
            { "color": "#eef2ff", "position": 100 }
          ]
        },
        "image": {
          "url": "",
          "size": "cover",
          "customSize": "100% auto",
          "position": "center top",
          "repeat": "no-repeat",
          "attachment": "scroll"
        },
        "overlay": { "enabled": false, "color": "#000000", "opacity": 0.2 }
      },
      "content": { "maxWidth": 1280, "gutterX": 24, "sectionGap": 24 },
      "surface": { "radius": 12, "shadow": "none" }
    },
    "components": [
      {
        "id": "pc_hero",
        "type": "hero",
        "props": {},
        "style": {
          "marginTop": 24,
          "marginBottom": 0,
          "paddingX": 24,
          "paddingY": 0,
          "backgroundColor": "transparent",
          "borderRadius": 12,
          "borderWidth": 0,
          "borderColor": "#e5e7eb",
          "shadow": "none"
        }
      }
    ]
  }
}

说明:

  • pageStyle 管页面级背景、遮罩、内容宽度和默认表面样式。
  • components[].style 是组件级覆盖,优先级高于 pageStyle 的默认值。
  • 背景图支持 URL + 上传配置,字段由 Admin 装修页直接产出。

10.1.2 Mock 同步规则(admin/web/app) ​

PC 装修字段要求三端 Mock 同步:

  • admin/src/mock/index.ts:GET/PUT /admin/api/decor/pc 使用 PcDecorPage 结构。
  • web/src/mock/index.ts:GET /api/v1/pc/decor 返回同结构对象。
  • app/mock/presets/types.ts 与各行业预设:pcDecor 使用 { pageStyle, components }。

若仅改其中一端,会出现「后台预览正常、前台演示异常」或类型检查失败。

10.2 本地调试 ​

后端调试 ​

bash
# 开发模式(debug 级日志)
# config.yaml 中设置 server.mode: debug
go run main.go -config config.yaml

# 只编译不运行(检查编译错误)
go build ./...

# 运行测试
go test ./...

代理配置:

前端开发服务器通过 Vite 代理转发 API 请求到后端:

typescript
// web/vite.config.ts
export default defineConfig({
  server: {
    port: 9529,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
})

// admin/vite.config.ts
export default defineConfig({
  server: {
    port: 9527,
    proxy: {
      '/admin/api': { target: 'http://localhost:8080', changeOrigin: true },
      '/api': { target: 'http://localhost:8080', changeOrigin: true },
    },
  },
})

前端调试 ​

bash
# 真实 API 模式(需要后端运行)
npm run dev

# Mock 模式(无需后端)
npm run dev:demo

联调流程 ​

  1. 先用 Mock 模式开发前端页面,确保 UI 正确
  2. 后端实现 API 后,切换到真实模式验证数据交互
  3. 使用浏览器开发者工具检查请求和响应
  4. 对核心流程(登录、下单、支付)做端到端回归

10.3 Go 测试 ​

go
// server/plugins/announcement/service/announcement_test.go
package service_test

import (
    "context"
    "testing"

    "github.com/stretchr/testify/assert"
    annosvc "github.com/ijry/lyshop/plugins/announcement/service"
    annomodel "github.com/ijry/lyshop/plugins/announcement/model"
)

func TestCreateAndList(t *testing.T) {
    ctx := context.Background()

    // 创建公告
    anno := &annomodel.Announcement{
        Title:   "测试公告",
        Content: "这是一条测试公告",
        Status:  1,
    }
    err := annosvc.Create(ctx, anno)
    assert.NoError(t, err)
    assert.NotZero(t, anno.ID)

    // 查询已发布公告
    list, err := annosvc.ListPublished(ctx)
    assert.NoError(t, err)
    assert.NotEmpty(t, list)
}

10.4 CI/CD ​

项目使用 GitHub Actions 进行 CI(.github/workflows/ci.yml):

yaml
name: CI
on:
  push:
    branches: [master]
  pull_request:
    branches: [master]
jobs:
  backend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.22' }
      - run: go test ./...
      - run: go build ./...

  admin:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: cd admin && npm install && npm run build

10.5 生产构建 ​

bash
# 后端构建
cd server && go build -o lyshop main.go

# Admin 构建(输出到 admin/dist/)
cd admin && npm run build

# PC 商城构建(输出到 web/dist/)
cd web && npm run build

# H5 构建(输出到 app/dist/build/h5/)
cd app && npm run build:h5

# 微信小程序构建(输出到 app/dist/build/mp-weixin/)
cd app && npm run build:mp-weixin

10.6 Docker 部署 ​

项目提供完整的 Docker Compose 配置:

bash
# 一键部署
docker-compose up -d

docker-compose.yml 架构:

┌─────────┐     ┌──────────────┐     ┌──────────┐
│  Nginx  │────▶│  Go Server   │────▶│ Database │
│ (反向代理) │     │  (:8080)     │     │          │
└────┬────┘     └──────┬───────┘     └──────────┘
     │                 │
     │                 └────▶┌──────────┐
     │                       │  Redis   │
     ├──▶ /admin → admin/dist│          │
     ├──▶ /h5   → app/dist  └──────────┘
     └──▶ /     → web/dist

环境配置清单:

配置项说明示例
database.dsn数据库连接串(默认 SQLite,兼容 MySQL)lyshop.db / root:pass@tcp(mysql:3306)/lyshop?...
redis.addrRedis 地址(留空启用内嵌 Redis)"" / redis:6379
jwt.secretJWT 密钥(务必修改)随机字符串
server.mode运行模式release
plugins.enabled启用的插件列表见 config.example.yaml

10.6.1 单机内嵌前端 + GUI 启动器(Windows) ​

适用于个人用户本机运行。流程:

bash
# 在仓库根目录执行
pwsh -File scripts/build-standalone.ps1

输出目录 dist/:

  • lyshop.exe
  • lyshop-gui.exe

内嵌模式下服务端会直接提供前端路由:

  • / → PC 商城
  • /admin/ → 管理后台(接口路径仍是 /admin/api/*)
  • /h5/ → uni-app H5

10.7 Nginx 配置示例 ​

nginx
server {
    listen 80;
    server_name shop.example.com;

    # PC 商城
    location / {
        root /usr/share/nginx/html/web;
        try_files $uri $uri/ /index.html;
    }

    # Admin 后台
    location /admin {
        alias /usr/share/nginx/html/admin;
        try_files $uri $uri/ /admin/index.html;
    }

    # H5 移动端
    location /h5 {
        alias /usr/share/nginx/html/h5;
        try_files $uri $uri/ /h5/index.html;
    }

    # API 反向代理
    location /api {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /admin/api {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # WebSocket
    location /ws {
        proxy_pass http://localhost:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

10.8 最佳实践 ​

  • 开发时先用 Mock 模式:快速迭代 UI,避免依赖后端
  • 为新接口同步添加 Mock 数据:保证演示模式可用
  • 生产环境必须修改 JWT Secret:使用随机字符串
  • 使用 server.mode: release:生产环境关闭 Gin 的 debug 日志
  • WebSocket 需要 Nginx 特殊配置:升级 HTTP 协议为 WebSocket
  • 数据库备份:生产环境定期备份当前使用的数据库(SQLite 文件或 MySQL 数据)

附录:常用开发速查 ​

API 响应格式 ​

json
// 成功
{"code": 0, "msg": "success", "data": {...}}

// 分页
{"code": 0, "msg": "success", "data": {"list": [...], "total": 100, "page": 1, "size": 20}}

// 失败
{"code": 10001, "msg": "商品不存在", "data": null}

新增插件 Checklist ​

  • [ ] 创建 server/plugins/<name>/ 目录
  • [ ] 编写 plugin.json(name, menus, permissions, config_items)
  • [ ] 定义 Model(嵌入 model.Base)
  • [ ] 编写 Service(业务逻辑)
  • [ ] 编写 API(front.go + admin.go)
  • [ ] 编写 plugin.go(init 注册 + Migrate + RegisterRoutes)
  • [ ] 在 main.go 添加空导入
  • [ ] 在 config.yaml 的 plugins.enabled 中启用
  • [ ] 在 admin/src/router/index.ts 添加管理端路由
  • [ ] 在 admin/src/views/<name>/ 创建管理端页面
  • [ ] 为新接口添加 Mock 数据
  • [ ] 更新文档

文件命名约定 ​

位置命名规则示例
Go 包snake_caseannouncement, wechat_pay
Go 文件snake_caseannouncement.go
Vue 组件PascalCaseAnnouncementList.vue
TypeScriptcamelCaserequest.ts, orderStatus.ts
CSS 类kebab-case / TailwindCSStext-red-600
数据库表snake_case (复数)announcements
JSON 字段snake_casecreated_at, category_id

Released under the MIT License.