deploy(v2): h5 build with base /v2/h5, admin dockerfile & nginx route, shared compose
This commit is contained in:
@@ -28,7 +28,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory('/admin/'),
|
history: createWebHistory('/v2/admin/'),
|
||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { fileURLToPath, URL } from 'node:url'
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/admin/',
|
base: '/v2/admin/',
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
AutoImport({
|
AutoImport({
|
||||||
|
|||||||
@@ -328,6 +328,74 @@ describe('PublicService', () => {
|
|||||||
expect(result.items[0].goodName).toBe(`Pub High ${stamp}`); // 代表行 = 排序第一条
|
expect(result.items[0].goodName).toBe(`Pub High ${stamp}`); // 代表行 = 排序第一条
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('list price is the family price matrix minimum (SQL aggregate)', async () => {
|
||||||
|
// 自包含 fixture:CUSTOM 光板成员(craftLabel/logisticsLabel 是矩阵归因来源)
|
||||||
|
// + 单变体 38 元。SDS 成员无标签时矩阵为空(不解析名称,等整理补标签),
|
||||||
|
// 共享 fixture 族 therefore 无矩阵,无法覆盖该路径。
|
||||||
|
const og = await prisma.originGood.create({
|
||||||
|
data: {
|
||||||
|
source: 'CUSTOM',
|
||||||
|
sdsGoodId: `pub-matrix-${stamp}`,
|
||||||
|
goodName: `Pub Matrix ${stamp}`,
|
||||||
|
craftLabel: '不打印',
|
||||||
|
logisticsLabel: '包邮',
|
||||||
|
goodPrice: 50, // 无矩阵时的回退链接价
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const family = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `Pub Matrix Family ${stamp}`, primaryOriginGoodId: og.id },
|
||||||
|
});
|
||||||
|
await prisma.originGood.update({
|
||||||
|
where: { id: og.id },
|
||||||
|
data: { familyId: family.id },
|
||||||
|
});
|
||||||
|
await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: og.id,
|
||||||
|
sdsVariantId: `pub-matrix-v-${stamp}`,
|
||||||
|
sku: `PM${stamp}`,
|
||||||
|
sizeName: 'S',
|
||||||
|
price: 38,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const good = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: `Pub Matrix Good ${stamp}`,
|
||||||
|
originGoodId: og.id,
|
||||||
|
familyId: family.id,
|
||||||
|
countryId,
|
||||||
|
categoryId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// 重算前:族无矩阵 → 回退链接价
|
||||||
|
const before = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
keyword: `Pub Matrix Good`,
|
||||||
|
});
|
||||||
|
expect(before.items).toHaveLength(1);
|
||||||
|
expect(before.items[0].price).toBe('50');
|
||||||
|
|
||||||
|
await new FamilyRecomputeService(prisma).recomputeFamily(family.id);
|
||||||
|
const after = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
keyword: `Pub Matrix Good`,
|
||||||
|
});
|
||||||
|
expect(after.items).toHaveLength(1);
|
||||||
|
// 重算后:矩阵最低价 38 生效,不再是回退链接价
|
||||||
|
expect(after.items[0].price).toBe('38');
|
||||||
|
} finally {
|
||||||
|
await prisma.good.delete({ where: { id: good.id } });
|
||||||
|
await prisma.originGoodVariant.deleteMany({
|
||||||
|
where: { sdsVariantId: `pub-matrix-v-${stamp}` },
|
||||||
|
});
|
||||||
|
await prisma.productFamily.delete({ where: { id: family.id } });
|
||||||
|
await prisma.originGood.delete({ where: { id: og.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('custom goods (无族) are not visible on public endpoints', async () => {
|
it('custom goods (无族) are not visible on public endpoints', async () => {
|
||||||
const customPublicId = `custom-public-${stamp}`;
|
const customPublicId = `custom-public-${stamp}`;
|
||||||
const origin = await prisma.originGood.create({
|
const origin = await prisma.originGood.create({
|
||||||
|
|||||||
@@ -60,6 +60,24 @@ const PUBLIC_GOOD_INCLUDE = {
|
|||||||
|
|
||||||
type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;
|
type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表/首页卡片的精简 include:重 JSON(变体 design_data、detail、merged 副源、
|
||||||
|
* 整份族矩阵)全部不进列表查询——全量商品拉这些字段实测要 ~2s,而列表 DTO
|
||||||
|
* 一个都不用。详情接口仍走 PUBLIC_GOOD_INCLUDE。
|
||||||
|
*/
|
||||||
|
const PUBLIC_GOOD_LIST_INCLUDE = {
|
||||||
|
country: true,
|
||||||
|
category: true,
|
||||||
|
tag: { include: { tagGroup: true } },
|
||||||
|
position: true,
|
||||||
|
originGood: {
|
||||||
|
select: { sdsGoodId: true, goodImage: true, goodPrice: true },
|
||||||
|
},
|
||||||
|
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||||
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
|
type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PublicService {
|
export class PublicService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -214,10 +232,11 @@ export class PublicService {
|
|||||||
// 再内存分组、分页作用于分组结果 —— 若量级上万需改为物化族表查询。
|
// 再内存分组、分页作用于分组结果 —— 若量级上万需改为物化族表查询。
|
||||||
const rows = await this.prisma.good.findMany({
|
const rows = await this.prisma.good.findMany({
|
||||||
where,
|
where,
|
||||||
include: PUBLIC_GOOD_INCLUDE,
|
include: PUBLIC_GOOD_LIST_INCLUDE,
|
||||||
orderBy,
|
orderBy,
|
||||||
});
|
});
|
||||||
const grouped = new Map<string, PublicGoodRow[]>();
|
const familyMinPrices = await this.loadFamilyMinPrices();
|
||||||
|
const grouped = new Map<string, PublicGoodListRow[]>();
|
||||||
for (const good of rows) {
|
for (const good of rows) {
|
||||||
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
||||||
const bucket = grouped.get(key);
|
const bucket = grouped.get(key);
|
||||||
@@ -228,8 +247,10 @@ export class PublicService {
|
|||||||
const rep = goods[0];
|
const rep = goods[0];
|
||||||
const dto = this.toPublicGood(rep);
|
const dto = this.toPublicGood(rep);
|
||||||
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
|
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
|
||||||
const familyMin = this.familyMinPrice(rep);
|
const familyMin = rep.familyId
|
||||||
if (rep.familyId && familyMin !== null) dto.price = familyMin;
|
? familyMinPrices.get(rep.familyId.toString())
|
||||||
|
: undefined;
|
||||||
|
if (familyMin !== undefined) dto.price = familyMin;
|
||||||
return dto;
|
return dto;
|
||||||
});
|
});
|
||||||
if (query.sort === 'PRICE_ASC' || query.sort === 'PRICE_DESC') {
|
if (query.sort === 'PRICE_ASC' || query.sort === 'PRICE_DESC') {
|
||||||
@@ -249,14 +270,28 @@ export class PublicService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 族物化矩阵的最低价;无族/无矩阵返回 null */
|
/**
|
||||||
private familyMinPrice(good: PublicGoodRow): string | null {
|
* 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include
|
||||||
const matrix = good.originGood.family?.priceMatrix as
|
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
|
||||||
| { rows?: Array<{ price: string }> }
|
* 每族一个数字。非数字/缺失 price 的行跳过,与旧内存版过滤语义一致。
|
||||||
| null
|
*/
|
||||||
| undefined;
|
private async loadFamilyMinPrices(): Promise<Map<string, string>> {
|
||||||
const prices = (matrix?.rows ?? []).map((r) => Number(r.price)).filter((n) => Number.isFinite(n));
|
const rows = await this.prisma.$queryRaw<
|
||||||
return prices.length ? String(Math.min(...prices)) : null;
|
Array<{ family_id: bigint | string; min_price: Prisma.Decimal | null }>
|
||||||
|
>`
|
||||||
|
SELECT f.family_id, MIN((r->>'price')::numeric) AS min_price
|
||||||
|
FROM product_families f
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(f.price_matrix->'rows') AS r
|
||||||
|
WHERE (r->>'price') ~ '^-?[0-9]+(\.[0-9]+)?$'
|
||||||
|
GROUP BY f.family_id
|
||||||
|
`;
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.min_price !== null) {
|
||||||
|
result.set(row.family_id.toString(), String(Number(row.min_price)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -321,7 +356,7 @@ export class PublicService {
|
|||||||
originGood: { delisted: false },
|
originGood: { delisted: false },
|
||||||
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
|
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
|
||||||
},
|
},
|
||||||
include: PUBLIC_GOOD_INCLUDE,
|
include: PUBLIC_GOOD_LIST_INCLUDE,
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ position: { indexVal: 'asc' } },
|
{ position: { indexVal: 'asc' } },
|
||||||
{ goodPriority: 'desc' },
|
{ goodPriority: 'desc' },
|
||||||
@@ -329,6 +364,7 @@ export class PublicService {
|
|||||||
],
|
],
|
||||||
take: query.limit,
|
take: query.limit,
|
||||||
});
|
});
|
||||||
|
const familyMinPrices = await this.loadFamilyMinPrices();
|
||||||
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit
|
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const items: PublicGoodDto[] = [];
|
const items: PublicGoodDto[] = [];
|
||||||
@@ -337,14 +373,17 @@ export class PublicService {
|
|||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
const dto = this.toPublicGood(good);
|
const dto = this.toPublicGood(good);
|
||||||
const familyMin = this.familyMinPrice(good);
|
const familyMin = good.familyId
|
||||||
if (good.familyId && familyMin !== null) dto.price = familyMin;
|
? familyMinPrices.get(good.familyId.toString())
|
||||||
|
: undefined;
|
||||||
|
if (familyMin !== undefined) dto.price = familyMin;
|
||||||
items.push(dto);
|
items.push(dto);
|
||||||
}
|
}
|
||||||
return items.slice(0, query.limit);
|
return items.slice(0, query.limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPublicGood(good: PublicGoodRow): PublicGoodDto {
|
/** 入参用精简行类型:列表(PublicGoodListRow)与详情(PublicGoodRow,字段超集)都能传 */
|
||||||
|
private toPublicGood(good: PublicGoodListRow): PublicGoodDto {
|
||||||
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
||||||
group
|
group
|
||||||
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
|
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# InkReach Admin - Vite build served by nginx
|
# InkReach Admin (v2) - Vite build served by nginx
|
||||||
|
# v2 部署本地化:与现网共用 official.inkreach.cc,SPA 路径挂 /v2/admin/ 下
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
@@ -16,7 +17,7 @@ COPY apps/admin apps/admin
|
|||||||
RUN pnpm --filter @inkreach/admin exec vite build
|
RUN pnpm --filter @inkreach/admin exec vite build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
COPY --from=build /app/apps/admin/dist /usr/share/nginx/html/admin
|
COPY --from=build /app/apps/admin/dist /usr/share/nginx/html/v2/admin
|
||||||
COPY deploy/nginx/admin.conf /etc/nginx/conf.d/default.conf
|
COPY deploy/nginx/admin.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY deploy/h5 /usr/share/nginx/html/h5
|
COPY deploy/h5 /usr/share/nginx/html/v2/h5
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# v2 部署(refactor/v2 分支):与现网 deploy 项目完全隔离(独立库/独立卷/独立网络),
|
||||||
|
# 对外复用 official.inkreach.cc 域名,由现网 nginx 按路径分流:
|
||||||
|
# https://official.inkreach.cc/v2/admin/ -> 本栈 admin(SPA)
|
||||||
|
# https://official.inkreach.cc/v2-api/ -> 本栈 api(去掉前缀后转发)
|
||||||
|
# 本栈两容器挂到外部网络 inkreach-shared,与 deploy-admin-1 互通(别名 v2-api / v2-admin)。
|
||||||
|
name: deploy-v2
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: inkreach
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: inkreach
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U inkreach -d inkreach"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/api.Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3001
|
||||||
|
DATABASE_URL: postgresql://inkreach:${POSTGRES_PASSWORD}@postgres:5432/inkreach
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
CORS_ORIGINS: "*"
|
||||||
|
# 激活 product-family 聚合公开读路径(现网 v1 未设置,保持旧行为,便于对比)
|
||||||
|
PUBLIC_DETAIL_FROM_FAMILY: "true"
|
||||||
|
volumes:
|
||||||
|
- uploads:/app/uploads
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
shared:
|
||||||
|
aliases:
|
||||||
|
- v2-api
|
||||||
|
|
||||||
|
admin:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/admin.Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_BASE: /v2-api/
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
volumes:
|
||||||
|
- ./nginx/admin.v2.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
shared:
|
||||||
|
aliases:
|
||||||
|
- v2-admin
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
shared:
|
||||||
|
external: true
|
||||||
|
name: inkreach-shared
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
uploads:
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
<!DOCTYPE html><html lang=zh-CN><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><title>印美达POD供应链</title><script>var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
|
<!DOCTYPE html><html lang=zh-CN><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><title>印美达POD供应链</title><script>var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
|
||||||
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')</script><link rel=stylesheet href=/h5/static/index.149e085d.css></head><body><noscript><strong>Please enable JavaScript to continue.</strong></noscript><div id=app></div><script src=/h5/static/js/chunk-vendors.6234e216.js></script><script src=/h5/static/js/index.c5073ea1.js></script></body></html>
|
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')</script><link rel=stylesheet href=/v2/h5/static/index.149e085d.css></head><body><noscript><strong>Please enable JavaScript to continue.</strong></noscript><div id=app></div><script src=/v2/h5/static/js/chunk-vendors.6234e216.js></script><script src=/v2/h5/static/js/index.e79d1a25.js></script></body></html>
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
|
|||||||
|
# v2 部署本地化:这个 nginx 容器只负责把 /v2/admin/ 下的 SPA 静态文件吐出去
|
||||||
|
# 对外分流由现网 deploy-admin-1 的 nginx 完成(/v2/admin/ 原样透传、/v2-api/ 转发到 v2 api)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location = /v2 {
|
||||||
|
return 301 /v2/admin/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /v2/admin/ {
|
||||||
|
try_files $uri $uri/ /v2/admin/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# v2 版 H5(构建时 router.base 必须为 /v2/h5/,产物放 /usr/share/nginx/html/v2/h5)
|
||||||
|
location = /v2/h5 {
|
||||||
|
return 301 /v2/h5/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /v2/h5/index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /v2/h5/ {
|
||||||
|
try_files $uri $uri/ /v2/h5/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user