从数据库到 Vue 页面:用 LY Fullstack 完成第一个真实全栈业务模块

上一篇,我介绍了为什么要做 LY Fullstack。

它不是一套提前塞满商城、内容、订单和工作流的后台系统,而是一块已经完成登录、权限、数据库、请求层、测试和工程规范的全栈底座。

但底座到底是不是底座,不能只看目录是否整齐,也不能只看后台截图是否好看。

真正的检验方式只有一个:

拿到它以后,能不能沿着现有边界,增加一条属于自己的真实业务链路?

这一篇不继续讲抽象架构。

我们假设现在要开发一个企业官网,为它增加最常见的"产品管理"模块:管理员在后台维护产品,C 端官网通过公开 API 读取已经发布的产品。

完整链路如下:

text 复制代码
PostgreSQL 产品表
  → Prisma Client
  → Shared 跨端类型
  → admin-api 管理端 CRUD
  → RBAC 菜单与操作权限
  → Vue 产品管理页面
  → api 面向 C 端公开读取
  → 单元测试与全仓检查

项目地址:github.com/liangy0323/...


先确定这次到底做什么

"产品管理"四个字很容易继续膨胀。

分类、规格、SKU、图片上传、富文本详情、多语言、上下架时间、SEO、库存和询盘关系,全都可以往里面加。如果一开始全部实现,这篇文章很快就会从"第一条业务链路"变成"从零开发一个商城"。

所以先把验收标准收紧。

管理后台需要做到:

  • 分页查询产品。
  • 按产品名称或稳定标识搜索。
  • 按草稿、已发布状态筛选。
  • 新增、编辑和删除产品。
  • 控制产品是否对 C 端公开。

C 端 API 需要做到:

  • 只返回已经发布的产品。
  • 支持分页读取产品列表。
  • 使用稳定的 slug 查询产品详情。
  • 不暴露管理端字段和未发布数据。

这次不做分类、上传和富文本,也不为了"以后可能需要"提前设计十几张表。

第一条链路最重要的不是功能多,而是把每一层放到正确的位置,并且真正跑通。


先让 AI 读规则,再给它业务任务

LY Fullstack 已经在根目录维护了 AGENTS.md,并在 .rules/ 中拆分了数据库、服务端、Vue、TypeScript、错误处理和代码审查规范。

所以我不会只对 AI 说一句:

帮我做一个产品管理。

这种指令缺少业务边界,也没有成功标准。AI 只能自己猜字段、猜目录、猜权限,最后很容易得到一套"能演示,但不属于当前项目"的代码。

我会先把任务写成下面这样:

text 复制代码
请先完整阅读根 AGENTS.md,以及本次任务涉及的 .rules 规范。

为企业官网增加产品管理模块:

1. packages/database 增加 Product 模型和 migration。
2. packages/shared 定义管理端与 C 端需要的安全传输类型。
3. apps/admin-api 增加产品分页、创建、编辑、删除接口。
4. 管理端接口必须接入 AdminJwtGuard、PermissionGuard,并使用:
   business:product:list
   business:product:create
   business:product:update
   business:product:delete
5. apps/admin 增加产品列表、筛选、分页和新增编辑弹窗,复用现有 CRUD 规范。
6. apps/api 增加公开产品列表与详情接口,只允许读取已发布产品。
7. 为 Service 和页面 composable 补充单元测试。
8. 不增加分类、上传、富文本、软删除、多语言和其他需求之外的能力。
9. 完成后执行 pnpm check,并检查内存泄漏与异常容错。

这段任务描述仍然不会替你决定所有实现细节,但它至少把范围、边界、权限和完成标准说清楚了。

AI 可以负责加速实现,但产品边界和验收标准仍然必须由人决定。

为了让文章重点落在全链路上,后面的代码块只展示关键结构,省略了部分 import、字段级 JSDoc 和重复样板。真正写入项目时仍然要遵守仓库规则,并以 pnpm check 全部通过作为完成标准,不能把文章片段直接当成未经检查的最终代码。


第一步:数据库是业务事实的起点

LY Fullstack 的数据库唯一真相源位于:

text 复制代码
packages/database/prisma/schema.prisma

不要在 admin-apiapi 中分别维护两份产品表定义,也不要把 Prisma 类型直接传给浏览器。

这次增加一个最小的 Product 模型:

prisma 复制代码
model Product {
  id          Int      @id @default(autoincrement())
  name        String   @db.VarChar(100)
  slug        String   @unique @db.VarChar(100)
  summary     String?  @db.VarChar(300)
  sortOrder   Int      @default(0) @map("sort_order")
  isPublished Boolean  @default(false) @map("is_published")
  createdAt   DateTime @default(now()) @map("created_at")
  updatedAt   DateTime @updatedAt @map("updated_at")

  @@index([isPublished, sortOrder])
  @@map("products")
}

这里有几个刻意做出的决定。

slug 和数据库主键不是一回事

id 负责数据库内部关联,slug 负责形成稳定、可读的公开地址,例如:

text 复制代码
/products/ly-fullstack

以后即使数据库主键变化,公开 URL 也不需要跟着变化。

草稿默认不能公开

isPublished 默认是 false

新增产品后,管理员必须主动发布,C 端 API 才能读取。安全边界不能建立在"大家记得别展示草稿"这种约定上,而应该直接写进查询条件。

先不增加软删除

软删除并不是给每张表机械增加一个 deletedAt。它会继续影响唯一索引、查询条件、恢复逻辑和数据合规。

当前需求只需要普通删除,就不要提前承担这一整套复杂度。


第二步:创建 migration,而不是只修改 Schema

修改 schema.prisma 只代表代码中的目标结构发生了变化,数据库本身还没有更新。

开发环境应该使用 prisma migrate dev 生成并应用 migration,而不是直接手写生产数据库,也不要用 db push 代替需要进入版本控制的迁移历史。

LY Fullstack 不在 packages/database 中保存任何应用密钥,因此需要显式提供本地数据库连接串。连接串可以从 Setup 生成的 apps/admin-api/.env.development 中取得,但不要把真实密码复制进文章、提交记录或聊天内容。

bash 复制代码
pnpm --filter @repo/database exec prisma migrate dev --name add_product --url "你的本地 DATABASE_URL"

pnpm --filter @repo/database generate

Prisma 7 的 migrate dev 不再自动执行 prisma generate,所以生成 Client 需要显式执行。这个命令只用于开发环境;生产环境应该继续执行仓库已有的 db:migrate,应用已经提交的 migration。具体行为可以查看 Prisma migrate dev 官方文档

完成后,仓库中应该新增类似目录:

text 复制代码
packages/database/prisma/migrations/
└── <时间戳>_add_product/
    └── migration.sql

migration 必须进入 Git。

它不是本地数据库生成出来的临时文件,而是其他开发者、测试环境和生产环境把数据库演进到同一状态的依据。


第三步:跨端类型只定义一次

数据库有了 Product,并不意味着前端可以直接导入 Prisma 生成类型。

Prisma 类型包含服务端数据库结构,浏览器真正需要的是经过选择和映射的 HTTP 契约。这两者的边界必须分开。

packages/shared/src/types/modules/product.ts 中定义管理端和公开 API 需要的类型:

ts 复制代码
import type { PaginationParams } from "./pagination";

/**
 * 产品发布状态筛选值
 */
export type AdminProductStatusFilter = "DRAFT" | "PUBLISHED";

/**
 * 管理端产品列表查询参数
 */
export interface AdminProductQueryParams extends PaginationParams {
  keyword?: string;
  status?: AdminProductStatusFilter;
}

/**
 * 管理端产品列表记录
 */
export interface AdminProductListItem {
  id: number;
  name: string;
  slug: string;
  summary: string | null;
  sortOrder: number;
  isPublished: boolean;
  createdAt: string;
  updatedAt: string;
}

/**
 * 新增产品请求参数
 */
export interface CreateAdminProductParams {
  name: string;
  slug: string;
  summary?: string | null;
  sortOrder?: number;
  isPublished?: boolean;
}

/**
 * 编辑产品请求参数
 */
export interface UpdateAdminProductParams {
  name: string;
  slug: string;
  summary?: string | null;
  sortOrder: number;
  isPublished: boolean;
}

/**
 * C 端产品列表记录
 */
export interface PublicProductListItem {
  name: string;
  slug: string;
  summary: string | null;
}

/**
 * C 端产品列表查询参数
 */
export type PublicProductQueryParams = PaginationParams;

/**
 * C 端产品详情
 */
export type PublicProductDetail = PublicProductListItem;

然后在 packages/shared/src/types/index.ts 聚合导出:

ts 复制代码
export * from "./modules/product";

这里没有直接复用 Product 数据库类型,也没有把 sortOrderisPublished、创建时间等管理字段暴露给 C 端。

类型不是越完整越好。

HTTP 契约应该只包含调用方真正需要知道的内容。


第四步:在 admin-api 建立管理端 CRUD

管理端产品模块放在:

text 复制代码
apps/admin-api/src/modules/product/
├── dto/
│   ├── create-product.dto.ts
│   ├── product-query.dto.ts
│   └── update-product.dto.ts
├── product.controller.ts
├── product.module.ts
├── product.service.test.ts
└── product.service.ts

这套目录没有发明新的组织方式,而是继续复用现有用户、角色、字典和公共配置模块的结构。

DTO 负责拒绝不可信输入

浏览器传来的数据不能因为有 TypeScript 类型就自动变得可信。

TypeScript 只存在于编译阶段,真正进入 HTTP 接口的 JSON 仍然需要运行时校验。

ts 复制代码
import {
  IsBoolean,
  IsInt,
  IsOptional,
  IsString,
  Matches,
  MaxLength,
} from "class-validator";

/**
 * 新增产品 DTO
 */
export class CreateProductDto {
  @IsString()
  @MaxLength(100)
  name!: string;

  @IsString()
  @MaxLength(100)
  @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
  slug!: string;

  @IsOptional()
  @IsString()
  @MaxLength(300)
  summary?: string | null;

  @IsOptional()
  @IsInt()
  sortOrder?: number;

  @IsOptional()
  @IsBoolean()
  isPublished?: boolean;
}

分页 DTO 继续限制页码、每页数量、关键词长度和允许的状态值。更新 DTO 则要求 sortOrderisPublished 明确传入,避免编辑时因缺省值产生歧义。

Controller 只处理 HTTP 边界

ts 复制代码
@Controller("products")
@UseGuards(AdminJwtGuard, PermissionGuard)
export class ProductController {
  constructor(
    @Inject(ProductService) private readonly productService: ProductService,
  ) {}

  @Get()
  @RequirePermissions("business:product:list")
  getProducts(
    @Query(createDtoValidationPipe(ProductQueryDto)) query: ProductQueryDto,
  ): Promise<PaginationResult<AdminProductListItem>> {
    return this.productService.getProducts(query);
  }

  @Post()
  @RequirePermissions("business:product:create")
  createProduct(
    @Body(createDtoValidationPipe(CreateProductDto)) dto: CreateProductDto,
  ): Promise<AdminProductListItem> {
    return this.productService.createProduct(dto);
  }

  @Put(":id")
  @RequirePermissions("business:product:update")
  updateProduct(
    @Param("id", ParseIntPipe) id: number,
    @Body(createDtoValidationPipe(UpdateProductDto)) dto: UpdateProductDto,
  ): Promise<AdminProductListItem> {
    return this.productService.updateProduct(id, dto);
  }

  @Delete(":id")
  @HttpCode(HttpStatus.NO_CONTENT)
  @RequirePermissions("business:product:delete")
  deleteProduct(@Param("id", ParseIntPipe) id: number): Promise<void> {
    return this.productService.deleteProduct(id);
  }
}

Controller 没有写 Prisma 查询,也没有决定产品名称怎样归一化。

它只负责四件事:接收参数、执行校验、声明权限、把调用交给 Service。

Service 承担业务规则

分页查询的核心逻辑与现有 CRUD 模块保持一致:

ts 复制代码
async getProducts(query: ProductQueryDto): Promise<PaginationResult<AdminProductListItem>> {
  const keyword = query.keyword?.trim();
  const where: Prisma.ProductWhereInput = {
    isPublished: query.status ? query.status === 'PUBLISHED' : undefined,
    OR: keyword
      ? [
          { name: { contains: keyword, mode: 'insensitive' } },
          { slug: { contains: keyword, mode: 'insensitive' } },
        ]
      : undefined,
  };

  const [total, records] = await this.prisma.$transaction([
    this.prisma.product.count({ where }),
    this.prisma.product.findMany({
      where,
      select: PRODUCT_SELECT,
      orderBy: [{ sortOrder: 'asc' }, { id: 'desc' }],
      skip: (query.pageNum - 1) * query.pageSize,
      take: query.pageSize,
    }),
  ]);

  return {
    list: records.map((record) => this.mapProduct(record)),
    total,
    pageNum: query.pageNum,
    pageSize: query.pageSize,
  };
}

创建和编辑时还需要完成这些服务端规则:

  • nameslug 去除两侧空白。
  • slug 统一转成小写。
  • 空的 summary 保存为 null
  • 重复 slug 转换为明确的 409 冲突提示。
  • 编辑和删除不存在的产品时返回 404。
  • 数据库时间映射成跨端使用的 ISO 字符串。

前端即使绕过表单校验直接发请求,这些规则仍然成立。

最后创建 ProductModule,并把它加入 apps/admin-api/src/modules/app/app.module.ts。如果忘记注册模块,文件写得再完整,NestJS 也不会加载对应 Controller。


第五步:权限不只是把按钮藏起来

产品接口使用了四个权限码:

text 复制代码
business:product:list
business:product:create
business:product:update
business:product:delete

它们遵循项目已有的三段式格式:

text 复制代码
<模块>:<资源>:<操作>

前端页面是否展示操作按钮,只是用户体验;真正的安全边界仍然是 admin-api 中的 PermissionGuard

即使有人绕过页面,直接请求删除接口,只要当前管理员没有 business:product:delete,服务端就必须返回 403。

先注册静态页面

apps/admin/src/router/modules/business.ts 中增加业务路由:

ts 复制代码
import type { RouteRecordRaw } from "vue-router";

/**
 * 业务管理路由树
 */
export const business: RouteRecordRaw = {
  path: "business",
  name: "business",
  redirect: "/business/product",
  meta: {
    title: "业务管理",
  },
  children: [
    {
      path: "product",
      name: "business-product",
      component: () => import("@/views/business/product/index.vue"),
      meta: {
        title: "产品管理",
        pageBinding: {
          component: "business/product/index",
          permissionPrefix: "business:product",
        },
      },
    },
  ],
};

再把 business 加入 Router 的 ADMIN_PAGE_ROUTES

这里的 pageBinding 很重要。菜单管理页面会从静态路由表派生可绑定页面,并读取 permissionPrefix 生成标准 CRUD 权限,避免再维护一份平行的页面配置。

再通过菜单管理绑定数据库导航

启动项目后,使用超级管理员进入"系统管理 → 菜单管理":

  1. 新建"业务管理"目录。
  2. 在目录下新增"产品管理"菜单。
  3. 绑定刚刚注册的 business-product 页面。
  4. 生成标准操作权限。
  5. 在角色管理中把产品菜单和对应操作权限分配给业务角色。

数据库菜单决定当前管理员能看到什么,Vue Router 决定页面组件如何加载,后端 Guard 决定接口能不能执行。

三者职责不同,不能只完成其中一层。

开发阶段可以先通过菜单管理验证页面与权限。准备部署到新环境前,还应该把稳定的业务菜单同步进项目的 seed 或专门的初始化数据,保证新数据库能够重复得到相同配置。


第六步:Vue 页面继续复用现有 CRUD 范式

前端新增的目录建议保持下面的结构:

text 复制代码
apps/admin/src/
├── api/modules/product/
│   ├── api.ts
│   └── interface.ts
├── views/business/product/
│   ├── components/product-form-dialog/
│   │   ├── composables/use-product-form.ts
│   │   └── index.vue
│   ├── composables/
│   │   ├── use-product-management.test.ts
│   │   └── use-product-management.ts
│   └── index.vue

页面入口只负责结构和交互编排;分页、删除、提交和错误状态继续放在 composable 中。

请求层只描述接口

ts 复制代码
export const API_ADMIN_PRODUCTS = "/products";

export const getAdminProductApi = (id: number): string => `/products/${id}`;
ts 复制代码
export const fetchAdminProducts = (
  params: AdminProductQueryParams,
): Promise<PaginationResult<AdminProductListItem>> =>
  serviceBase.get<
    PaginationResult<AdminProductListItem>,
    AdminProductQueryParams
  >(API_ADMIN_PRODUCTS, params);

export const createAdminProduct = (
  params: CreateAdminProductParams,
): Promise<AdminProductListItem> =>
  serviceBase.post<AdminProductListItem, CreateAdminProductParams>(
    API_ADMIN_PRODUCTS,
    params,
  );

export const updateAdminProduct = (
  id: number,
  params: UpdateAdminProductParams,
): Promise<AdminProductListItem> =>
  serviceBase.put<AdminProductListItem, UpdateAdminProductParams>(
    getAdminProductApi(id),
    params,
  );

export const deleteAdminProduct = (id: number): Promise<void> =>
  serviceBase.delete<void>(getAdminProductApi(id));

完成后,在 apps/admin/src/api/index.ts 中聚合导出产品请求函数。页面只从统一的 @/api 入口调用,避免业务组件依赖请求层的内部目录。

Token 注入、401 处理和通用错误提示已经由 serviceBase 承担。业务 API 不需要再创建一套 Axios 实例,也不要在每个请求函数里重复弹 Toast。

页面模型与默认值留在各自边界

Shared 类型只描述 HTTP 契约,Vue 表单需要的空字符串和筛选组件状态仍然属于 Admin 应用:

ts 复制代码
export type AdminProductFilterModel = AdminProductQueryParams & DataFilterModel;

export interface AdminProductFormModel extends Required<
  Omit<CreateAdminProductParams, "summary">
> {
  summary: string;
}

然后在 apps/admin/src/constants/modules/model.ts 中增加默认值:

ts 复制代码
export const ADMIN_PRODUCT_FILTER_MODEL: AdminProductFilterModel = {
  pageNum: 1,
  pageSize: 20,
  keyword: "",
  status: undefined,
};

export const ADMIN_PRODUCT_FORM_MODEL: AdminProductFormModel = {
  name: "",
  slug: "",
  summary: "",
  sortOrder: 0,
  isPublished: false,
};

这样,接口参数不会被 Vue 表单为了双向绑定而使用的空字符串污染,表单也不需要把服务端 DTO 当成自己的 UI 状态。

列表状态复用 usePagination

ts 复制代码
export const useProductManagement = () => {
  const deletingId = ref<number>();
  const {
    loading,
    filters,
    itemList,
    total,
    setFilters,
    reload,
    handleSearch,
    handleReset,
    handlePageNumChange,
    handlePageSizeChange,
  } = usePagination<AdminProductListItem, AdminProductFilterModel>(
    { defaultFilters: ADMIN_PRODUCT_FILTER_MODEL },
    fetchAdminProducts,
  );

  const handleProductDelete = async (
    product: AdminProductListItem,
  ): Promise<void> => {
    if (deletingId.value) return;

    try {
      await ElMessageBox.confirm(
        `确定删除产品"${product.name}"吗?`,
        "删除产品",
        {
          type: "warning",
          confirmButtonText: "删除",
          cancelButtonText: "取消",
        },
      );
    } catch {
      return;
    }

    deletingId.value = product.id;
    try {
      await deleteAdminProduct(product.id);
      ElMessage.success("产品已删除");
      if (itemList.value.length === 1 && filters.pageNum > 1) {
        await handlePageNumChange(filters.pageNum - 1);
      } else {
        await reload();
      }
    } catch {
      // 请求拦截器已经展示服务端错误。
    } finally {
      deletingId.value = undefined;
    }
  };

  return {
    loading,
    deletingId,
    filters,
    productList: itemList,
    total,
    setFilters,
    reload,
    handleSearch,
    handleReset,
    handlePageNumChange,
    handlePageSizeChange,
    handleProductDelete,
  };
};

现有 usePagination 已经统一处理了:

  • 搜索和重置回到第一页。
  • 空筛选条件不进入 Query String。
  • 快速连续请求时,旧结果不会覆盖新结果。
  • 页面卸载后,过期响应不会继续修改状态。
  • 后端返回空列表时使用安全兜底。

业务模块只需要关心"怎样获取产品",不用重新实现一遍分页状态机。

表单必须防重复提交

产品表单 composable 继续遵循相同模式:

ts 复制代码
const handleSubmit = async (): Promise<void> => {
  if (
    submitting.value ||
    !(await formRef.value?.validate().catch(() => false))
  ) {
    return;
  }

  submitting.value = true;
  try {
    if (operationType.value === "add") {
      await createAdminProduct(createParams());
    } else if (productId.value) {
      await updateAdminProduct(productId.value, createUpdateParams());
    }

    dialogVisible.value = false;
    ElMessage.success(
      operationType.value === "add" ? "产品已创建" : "产品已更新",
    );
    options.onSuccess(operationType.value);
  } catch {
    // 保留用户输入,由请求拦截器展示具体错误。
  } finally {
    submitting.value = false;
  }
};

finally 不是装饰。

如果接口失败后没有恢复 submitting,保存按钮会永远停留在 Loading 状态。用户连续点击时没有提交锁,也可能在数据库中创建重复记录。

页面 UI 则继续复用现有 admin-crud-pagedata-filter-panelbase-badgebase-empty-state 和分页规范,不需要为产品管理重新设计一套后台视觉语言。


第七步:默认 api 提供 C 端读取,但不接管 C 端产品

管理后台可以增删改查之后,还差最后一段:官网怎样读取产品?

这正是默认 apps/api 存在的原因。

LY Fullstack 不知道你的 C 端最终是 Nuxt、Next.js、小程序还是移动端,因此不会预置具体页面;但它可以为真实业务提供边界清晰的服务端入口。

产品公开接口放在:

text 复制代码
apps/api/src/modules/public-product/
├── dto/public-product-query.dto.ts
├── public-product.controller.ts
├── public-product.module.ts
├── public-product.service.test.ts
└── public-product.service.ts

接口只需要两个:

text 复制代码
GET /api/public/products?pageNum=1&pageSize=20
GET /api/public/products/:slug

Service 查询必须把公开条件写死:

ts 复制代码
async getProducts(
  query: PublicProductQueryDto,
): Promise<PaginationResult<PublicProductListItem>> {
  const where: Prisma.ProductWhereInput = { isPublished: true };
  const [total, records] = await this.prisma.$transaction([
    this.prisma.product.count({ where }),
    this.prisma.product.findMany({
      where,
      select: {
        name: true,
        slug: true,
        summary: true,
      },
      orderBy: [{ sortOrder: 'asc' }, { id: 'desc' }],
      skip: (query.pageNum - 1) * query.pageSize,
      take: query.pageSize,
    }),
  ]);

  return {
    list: records,
    total,
    pageNum: query.pageNum,
    pageSize: query.pageSize,
  };
}

async getProduct(slug: string): Promise<PublicProductDetail> {
  const product = await this.prisma.product.findFirst({
    where: {
      slug: slug.trim().toLowerCase(),
      isPublished: true,
    },
    select: {
      name: true,
      slug: true,
      summary: true,
    },
  });

  if (!product) {
    throw new NotFoundException('产品不存在或尚未发布');
  }

  return product;
}

这里没有调用 admin-api,也没有复用管理端 JWT Guard。

两个应用可以共享 PostgreSQL 和 Shared 类型,但认证边界、Controller、Service 与运行进程仍然彼此独立。

这不是重复建设。

管理端写接口和 C 端读接口面对的是不同调用方,也承担不同风险:

边界 管理端产品接口 C 端公开产品接口
调用方 已登录管理员 官网、小程序或其他客户端
权限 JWT + RBAC 当前示例免登录
可见数据 草稿与已发布 仅已发布
返回字段 管理、排序、状态和时间字段 只返回公开展示字段
主要风险 越权修改 草稿泄露与无限制查询

最后把 PublicProductModule 注册到 apps/api/src/modules/app/app.module.ts

到这里,LY Fullstack 仍然没有替你决定官网长什么样,但已经为任何真实客户端提供了稳定、可测试的产品数据入口。


第八步:不要只验证"页面能打开"

一条全栈链路完成后,至少要验证下面这些场景。

数据库

  • migration 可以在空数据库中正常执行。
  • slug 唯一索引确实存在。
  • migration 文件已经提交,没有只改本地数据库。

admin-api

  • 可以按关键词和发布状态分页查询。
  • 重复 slug 返回明确冲突错误。
  • 不存在的产品编辑和删除返回 404。
  • 普通管理员缺少权限时返回 403。
  • 超级管理员仍然可以完成系统恢复和权限配置。

Admin

  • 空列表有空状态,不是空白表格。
  • 请求失败后 Loading 能恢复。
  • 连续点击保存不会重复提交。
  • 删除当前页最后一条数据后,分页不会停在空页。
  • 快速切换筛选时,旧请求不会覆盖新结果。

C 端 API

  • 草稿产品不会出现在列表中。
  • 使用草稿 slug 查询详情返回 404。
  • 响应中没有 isPublished、排序和管理时间等内部字段。
  • pageSize 有明确上限,不能一次读取全部数据。

最后执行:

bash 复制代码
pnpm check

它会依次检查架构边界、TypeScript、ESLint、Prettier、单元测试和生产构建。

"页面看起来正常"只能证明你走通了一个最顺利的路径。

异常、空数据、权限和快速操作也成立,才算真正完成。


回头看,这条链路到底建立了什么

完成产品管理之后,我们增加的不是一张表和一个 Vue 页面,而是一条可以继续复制的业务路径:

text 复制代码
业务需求
  → 收紧范围与验收标准
  → database 定义事实
  → migration 记录结构演进
  → shared 建立安全契约
  → admin-api 负责管理与授权
  → admin 负责操作界面
  → api 负责 C 端公开读取
  → tests 与 pnpm check 验证结果

下一次增加文章、案例、询盘或者公告模块时,字段和业务规则会变化,但代码应该放在哪里、权限怎样落地、C 端怎样读取、完成后怎样检查,已经不需要重新猜一遍。

这才是工程底座真正节省的时间。

它不是替你生成所有未来业务,而是让每一项真实业务都能沿着清晰边界继续生长。


最后

如果你只看概念,Controller、Service、DTO、migration、RBAC 和共享类型很容易变成一堆需要背诵的名词。

但沿着产品管理真正走完一次之后,它们会重新变成很具体的问题:

  • 谁负责拒绝非法输入?
  • 谁负责决定草稿能不能公开?
  • 哪些字段可以交给浏览器?
  • 管理端和 C 端为什么不能共用认证?
  • 新环境怎样得到同样的数据库和权限配置?

全栈能力不是"前端页面也写、后端接口也写"这么简单。

它更重要的部分,是知道数据和职责应该在哪里交汇,又应该在哪里停下。

项目地址:

github.com/liangy0323/...

如果你准备跟着这篇文章实现产品管理,建议先 Fork 项目,在自己的业务分支中完成。LY Fullstack 的核心仓库不会因为教程需要,就把"产品"固化成所有项目都必须拥有的默认模块。

这也是它始终坚持的边界:

通用能力进入底座,具体业务留给真正需要它的项目。


下一篇

产品接口已经接入了 AdminJwtGuardPermissionGuard,菜单、角色与操作权限也真正关联起来。

所以下一篇,我们单独拆开这条权限链路:

权限为什么不能只靠前端隐藏按钮?从登录 Token 到数据库 RBAC,再到后端 Guard,完整看懂一次管理端授权。

这也是很多前端第一次写后台服务时,最容易低估的一部分。

相关推荐
黑马程序员毕设1 小时前
非遗文化展览系统设计与实现
人工智能·spring boot·后端·微信小程序·毕设
hunterandroid1 小时前
自定义 View 绘制与触摸冲突:从滑动卡顿到事件分发闭环
android·前端
预知同行1 小时前
AI Native 应用缓存架构实战:语义缓存、Prompt 缓存与响应缓存的三层设计
后端·架构
vancece1 小时前
JS实现堆 & 215.数组中的第K个最大元素
java·前端·javascript
唐青枫1 小时前
别把错误当异常:Zig error set、error union 与 errdefer 实战
后端
用户298698530141 小时前
Markdown 转 PDF 免费在线攻略:简单几步轻松搞定
人工智能·后端·markdown
newerp1 小时前
语法分析与 AST:Parser 与 go/ast
后端·程序员·go
newerp1 小时前
Go 编译过程全景
后端·程序员·go
SimonKing1 小时前
Apache Fory JSON或许可以替代FastJson
java·后端·程序员