上一篇,我介绍了为什么要做 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 权限,避免再维护一份平行的页面配置。
再通过菜单管理绑定数据库导航
启动项目后,使用超级管理员进入"系统管理 → 菜单管理":
- 新建"业务管理"目录。
- 在目录下新增"产品管理"菜单。
- 绑定刚刚注册的
business-product页面。 - 生成标准操作权限。
- 在角色管理中把产品菜单和对应操作权限分配给业务角色。
数据库菜单决定当前管理员能看到什么,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 端为什么不能共用认证?
- 新环境怎样得到同样的数据库和权限配置?
全栈能力不是"前端页面也写、后端接口也写"这么简单。
它更重要的部分,是知道数据和职责应该在哪里交汇,又应该在哪里停下。
项目地址:
如果你准备跟着这篇文章实现产品管理,建议先 Fork 项目,在自己的业务分支中完成。LY Fullstack 的核心仓库不会因为教程需要,就把"产品"固化成所有项目都必须拥有的默认模块。
这也是它始终坚持的边界:
通用能力进入底座,具体业务留给真正需要它的项目。
下一篇
产品接口已经接入了 AdminJwtGuard 和 PermissionGuard,菜单、角色与操作权限也真正关联起来。
所以下一篇,我们单独拆开这条权限链路:
权限为什么不能只靠前端隐藏按钮?从登录 Token 到数据库 RBAC,再到后端 Guard,完整看懂一次管理端授权。
这也是很多前端第一次写后台服务时,最容易低估的一部分。