从数据库到 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-api 和 api 中分别维护两份产品表定义,也不要把 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 数据库类型,也没有把 sortOrder、isPublished、创建时间等管理字段暴露给 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 则要求 sortOrder 与 isPublished 明确传入,避免编辑时因缺省值产生歧义。

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,
  };
}

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

  • name、slug 去除两侧空白。
  • 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-page、data-filter-panel、base-badge、base-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 的核心仓库不会因为教程需要,就把"产品"固化成所有项目都必须拥有的默认模块。

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

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


下一篇

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

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

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

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

相关推荐
Lank_M21 分钟前
白底都占八成,PNG 为何只在图表页赢过 JPG
前端
张一凡9322 分钟前
给 Coding Agent 加一道"架构闸门":Jin 是如何做到"先审架构、再写代码、不漂移"的
前端·后端·agent
Linye22 分钟前
AI 写的 HTML 原型全是空壳?聊聊真实 DOM 交互避坑
前端·人工智能
Jvav练习生31 分钟前
AI Agent 总在关键时刻“失忆”翻车?一文讲透上下文工程(附可运行 Python 代码)
后端
ZANE32 分钟前
纯前端实现的 PDF 与图片互相转换工具技术实现
前端
leo在掘金38 分钟前
只挂了11小时,5000台开发机被塞进SSH公钥
后端·架构
xjz184243 分钟前
Rust 运算符优先级:括号不只用来改变顺序
后端
写码跑山的老艾44 分钟前
为什么有了 MySQL,还要 Redis 和 Elasticsearch?从一件商品看数据系统的权衡
后端
对象存储与RustFS1 小时前
Jenkins 的构建产物别堆在控制器磁盘:用 RustFS 当它的 S3 工件仓库
后端·docker·云原生