chore: initialize SaaS order service

This commit is contained in:
2026-09-03 21:36:37 +08:00
commit 02a8975198
12 changed files with 5531 additions and 0 deletions

6
.cool-saas-service.json Normal file
View File

@@ -0,0 +1,6 @@
{
"kind": "cool-saas-microservice",
"formatVersion": 1,
"moduleCode": "order",
"generatedRoot": "src/saas/generated"
}

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
logs/
dist/
dist-*/
coverage/
*.log
*.tsbuildinfo
.env
.env.*
!.env.example
.tmp-*

5
.npmrc Normal file
View File

@@ -0,0 +1,5 @@
@cool-midway:registry=https://npm.blendercg.art/
@g1sir8b:registry=https://npm.blendercg.art/
//npm.blendercg.art/:_authToken=${NODE_AUTH_TOKEN}
always-auth=true
confirmModulesPurge=false

156
README.md Normal file
View File

@@ -0,0 +1,156 @@
# COOL SaaS 模块微服务起始项目
这是一个可复制到 `cool-midway-biz/microservices/<service-name>` 的干净起始项目。
它只包含托管 SaaS 模块微服务运行所需的基础结构,不包含 Demo 商品业务、Pilot、
Examples、测试 Fixture 或任何既有模块的生成契约。
## 初始化
1. 将本目录的项目文件复制到 `cool-midway-biz/microservices/<service-name>`,不要复制模板
仓库自身的 `.git`scaffold 工具也必须移除模板 origin再由业务项目重新初始化。
2. 修改 `package.json` 中的包名,并按需修改
`src/config/app.default.ts` 中的本地默认端口 `7101`
3. 在开发环境的模块管理页输入基础版本(例如 `1.0.0`)并锁定 Candidate平台会分配
Build Revision、`1.0.0-rc.<revision>` 候选版本和同名 release 分支。
4. 安装依赖后,用平台给出的精确 Revision 生成并启动开发实例:
```powershell
pnpm install
pnpm exec cool saas-model generate --module <module-code> --revision <build-revision>
pnpm dev
```
首次生成会初始化 Git如果需要、要求配置 `origin`,并切换到平台指定的
`release/<candidate-version>` 分支。CLI 会在项目根目录写入可提交的
`.cool-saas-candidate.json`其中只有模块、Revision、版本、分支与 checksum不包含
控制面 token、Runtime ticket 或其他 Secret。
`src/config/app.default.ts` 会直接导入 `src/saas/generated`。因此全新模板在首次生成
完成前不能编译,这是为了避免用占位契约冒充平台锁定的 Build Revision。
`start:local` 会显式开启仅限 `local/test` 的 Runtime bootstrap fallback本地控制面
启动命令也必须开启同一个 flag。两端未另设 token 时只使用仓库内公开的开发常量,
生产环境始终拒绝这条路径。
## CLI 生命周期
在微服务根目录执行:
```powershell
pnpm exec cool saas-model generate `
--module <module-code> `
--revision <build-revision>
pnpm build
```
管理端的“生成到本地”仍只负责选择目录并写入生成文件,不会初始化 Git、修改 remote
或切换分支;完整 Candidate 开发流程应使用上面的 CLI generate 命令建立这些 Git
约束。
使用默认输出目录时CLI 会写入 `src/saas/generated/<module-code>`,并自动维护
`src/saas/generated/index.ts` 稳定导出入口。
生成后可随时执行 `pnpm exec cool saas-model verify` 检查当前分支、候选锁、生成文件与
控制面的精确 Candidate 是否一致。`pnpm dev` 会先执行同一校验,再调用
`package.json` 中 `coolSaas.lifecycle.dev` 的非递归命令构建并启动原有开发宿主;因此
配置里不要写回 `pnpm dev`。项目接入自己的 watch/reload 工具后,可将该命令替换为对应
的开发启动命令。
CLI 生成的 `models.ts`、`contracts.ts`、`saas-actions.ts`、`saas-events.ts`、
`module.ts`、`index.ts` 和锁文件属于同一个不可变 Build请整体保留且不要手工修改。
## 固化 Candidate
开发联调完成后,先提交并将当前 release 分支推送到 `origin`,然后执行:
```powershell
pnpm run publish
```
该脚本只调用 `cool saas-model finalize`。CLI 从 `package.json` 的 `coolSaas.lifecycle`
读取并依次执行 `test` 与 `build`(本模板分别为 typecheck 和原有 build确认工作区仍然
干净且当前提交已推送后,创建并推送 `v<candidate-version>` Tag最后向控制面幂等提交
freeze 证据。生命周期子命令不得调用 `pnpm dev`、`pnpm publish` 或再次调用
`cool saas-model dev/finalize`,以免递归。
freeze 成功后,原来的 development 实例会因身份不再匹配而停止参与路由。再次执行
`pnpm dev`CLI 会读取候选的 Frozen Artifact只允许从完全一致且干净的 Git commit
以 `runtimeMode=frozen` 启动。请用这个固化实例完成最终验收;验收通过后才能在管理端
晋升 Stable。`accepted` 状态也可以用同一命令重启精确固化实例,但已晋升、拒绝、替代
或放弃的候选会被拒绝。
控制面地址与凭证继续使用 `COOL_SAAS_CONTROL_PLANE_URL`、`COOL_SAAS_TOKEN`,或 CLI 的
`--endpoint`、`--token` 参数。CLI token 只用于校验/freeze 请求,不会写入候选锁,也
不会传给测试、构建或开发宿主子进程。freeze 调用
`/internal/saas/module_release/freeze`,只发送 Bearer 凭证,不依赖浏览器 Cookie。
## 依赖与发布
`@cool-midway/core`、`@cool-midway/module-runtime` 和 `@cool-midway/rpc` 使用
`https://npm.blendercg.art/` 中已发布的精确版本。安装前通过环境变量提供私服凭证,
不要把 token 写入仓库:
```powershell
$env:NODE_AUTH_TOKEN='<registry-token>'
pnpm install
```
本地环境可以使用 Runtime 提供的开发配置。应用开发者在部署描述中只需要关注:
```ini
PORT=7101
SAAS_CONTROL_PLANE_URL=https://control-plane.example.com
```
`PORT` 是进程在容器内监听的健康检查端口,不是 Action RPC 的入口。同一镜像的所有
副本都可以监听 `7101`。宿主机可分别映射为 `17101:7101`、`17102:7101`,也可以完全
不发布端口;无论选择哪种方式,容器内配置都保持不变。
仓库当前网关与模块宿主默认连接 Redis `6397`。部署环境如果使用其他 Redis 服务端口,
由编排器同时覆盖双方的 `REDIS_PORT`,不能只修改一侧。独立模块宿主默认采用
`RPC_RUNTIME_MODE=all`,即同一进程同时接收 Action 并运行托管 Outbox/Inbox Worker
只有明确拆成 `rpc` 与 `worker` 两类副本时才覆盖该值,不能用兼容旧名称 `server`
冒充完整托管宿主。
生产环境由受信部署编排器在应用配置之外完成以下工作:
- 签发绑定精确 Build/Release 的 bootstrap ticket并注入 `BUILD_SHA`、Redis 和
RPC HMAC 材料Release 身份及摘要由控制面权威响应回填,不要求手工填写。
- 为每个副本分别挂载短期、一次性且绑定模块/Build/Release/制品的
`SAAS_RUNTIME_BOOTSTRAP_TOKEN_FILE`,不能让两个副本共用一个票据。票据签发结果会
同时返回并绑定该副本的 `instanceId`。
- 为每个副本提供独立、可写的 `SAAS_RUNTIME_BOOTSTRAP_STATE_FILE`;其中的实例 UUID、
Runtime Node ID、租约及 fencing 状态由 Runtime 管理,开发者不要手工填写。
- 使用票据签发结果中的 `instanceId` 生成对应的 `SAAS_TENANT_CREDENTIALS_FILE`,再与
票据一起挂载给该副本。源码和 Compose 文件都不能保存 Secret 值。
生产及其他严格环境不接受手工配置 `RPC_INSTANCE_ID`、`SAAS_RUNTIME_NODE_ID` 和
`SAAS_SERVICE_TOKEN_FILE` 来绕过 bootstrap。进程只能通过一次性 ticket 建立新会话,
或恢复 Runtime 已验真且仍有效的独占 state静态三元组仅作为 local/test 的迁移兼容,
不能用于部署。
`bootstrap.js` 会先执行 Runtime pre-bootstrap再校验精确 Release 环境,最后启动
Midway。`GET /health/` 读取 `rpcHostReadiness` 的真实快照bootstrap、Node ID 租约或
RPC 注册任一未就绪时都会返回 HTTP `503`,只有全部就绪才返回 `200`。
仓库根目录的 `docker-compose.microservices.yml` 是同镜像双副本示例。两个副本容器内
均使用 `7101`,并分别映射到宿主机 `17101`、`17102`,每个副本有独立的 bootstrap
ticket、tenant credential Secret 和 Runtime state volume。生产环境若只需容器网络
内的 readiness probe可以用部署 override 清空 `ports`Action RPC 不受影响:
```yaml
services:
demo-stable-a:
ports: []
demo-stable-b:
ports: []
```
Compose 顶层 Secret 的 `${..._FILE}` 是编排器生成的宿主机文件路径,不是应用配置。
这些源文件在 Linux 上必须限制为服务账户私有权限(例如 `0600`),且不能提交到
仓库。生产平台可等价替换为 Kubernetes/Swarm external Secret容器内的固定挂载路径
和 `*_FILE` 环境变量无需改变。
`SAAS_CONTROL_PLANE_URL` 必须是容器自身能够访问的地址;容器中的 `127.0.0.1` 指向
容器本身,不能用来表示宿主机或另一个控制面容器。生产环境必须使用 HTTPS。

36
bootstrap.js vendored Normal file
View File

@@ -0,0 +1,36 @@
'use strict';
const { Bootstrap } = require('@midwayjs/bootstrap');
const { prepareSaasServiceHost } = require('@cool-midway/rpc/saas-host');
const path = require('path');
const {
requireExactReleaseEnvironment,
} = require('./release-environment');
function loadGeneratedSaasModule() {
return require('./dist/saas/generated').GeneratedSaasModule;
}
async function start() {
await prepareSaasServiceHost({
generated: loadGeneratedSaasModule(),
});
requireExactReleaseEnvironment();
return Bootstrap.configure({
baseDir: path.join(__dirname, 'dist'),
imports: [require('./dist/configuration')],
}).run();
}
if (require.main === module) {
start().catch(error => {
process.stderr.write(`${error?.stack || error?.message || error}\n`);
process.exitCode = 1;
});
}
module.exports = {
loadGeneratedSaasModule,
requireExactReleaseEnvironment,
start,
};

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "@tenant/saas-module-service",
"version": "0.1.0",
"private": true,
"description": "COOL SaaS 托管模块微服务",
"main": "dist/configuration.js",
"scripts": {
"dev": "cool saas-model dev",
"typecheck": "tsc --noEmit",
"build": "rimraf dist && mwtsc --cleanOutDir",
"publish": "cool saas-model finalize",
"start": "node bootstrap.js",
"start:local": "cross-env NODE_ENV=local SAAS_RUNTIME_BOOTSTRAP_LOCAL_FALLBACK_ENABLED=true node bootstrap.js"
},
"coolSaas": {
"lifecycle": {
"dev": "pnpm run build && pnpm run start:local",
"test": "pnpm run typecheck",
"build": "pnpm run build"
}
},
"dependencies": {
"@cool-midway/module-runtime": "9.0.2",
"@cool-midway/rpc": "9.0.2",
"@midwayjs/bootstrap": "^3.20.3",
"@midwayjs/core": "^3.20.3",
"@midwayjs/koa": "^3.20.3",
"@midwayjs/validate": "^3.20.3",
"mysql2": "^3.12.0",
"reflect-metadata": "^0.2.2",
"typeorm": "npm:@cool-midway/typeorm@0.3.20"
},
"devDependencies": {
"@cool-midway/core": "8.0.11",
"@types/node": "22.10.7",
"cross-env": "^7.0.3",
"mwtsc": "^1.16.0",
"rimraf": "^6.0.1",
"typescript": "~5.7.3"
}
}

4943
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

195
release-environment.js Normal file
View File

@@ -0,0 +1,195 @@
'use strict';
const path = require('path');
const { statSync } = require('fs');
function requireRuntimeFile(environment, field, minimumSize, maximumSize) {
const file = String(environment[field] || '').trim();
if (!path.isAbsolute(file)) {
throw new Error(`${field} must be absolute`);
}
const stat = statSync(file, { throwIfNoEntry: false });
if (!stat?.isFile() || stat.size < minimumSize || stat.size > maximumSize) {
throw new Error(`${field} is unavailable or invalid`);
}
}
function requireExactReleaseEnvironment(environment = process.env) {
const runtimeEnvironment = String(environment.NODE_ENV || '')
.trim()
.toLowerCase();
const localOrTest = ['local', 'test'].includes(runtimeEnvironment);
const required =
runtimeEnvironment === 'prod' ||
runtimeEnvironment === 'production' ||
environment.RPC_REQUIRE_EXACT_RELEASE === 'true';
if (!required) return;
const fields = [
'RPC_SERVICE_VERSION',
'RPC_MIN_SCHEMA_VERSION',
'RPC_MAX_SCHEMA_VERSION',
'RPC_RELEASE_ID',
'RPC_RELEASE_VERSION',
'RPC_SCHEMA_CHECKSUM',
'RPC_ACTION_MANIFEST_CHECKSUM',
'RPC_RUNTIME_CONTRACT_CHECKSUM',
'RPC_IMPLEMENTATION_MANIFEST_CHECKSUM',
'RPC_ARTIFACT_DIGEST',
'BUILD_SHA',
'SAAS_CONTROL_PLANE_URL',
'SAAS_TENANT_CREDENTIALS_FILE',
];
const missing = fields.filter(
field => !String(environment[field] || '').trim()
);
if (missing.length) {
throw new Error(
`Exact SaaS Release environment is incomplete: ${missing.join(', ')}`
);
}
const bootstrapTicketFile = String(
environment.SAAS_RUNTIME_BOOTSTRAP_TOKEN_FILE || ''
).trim();
const bootstrapStateFile = String(
environment.SAAS_RUNTIME_BOOTSTRAP_STATE_FILE || ''
).trim();
const configuredIdentityReady = [
'RPC_INSTANCE_ID',
'SAAS_RUNTIME_NODE_ID',
'SAAS_SERVICE_TOKEN_FILE',
].every(field => String(environment[field] || '').trim());
if (
String(environment.SAAS_RUNTIME_BOOTSTRAP_TOKEN || '').trim() &&
['prod', 'production'].includes(runtimeEnvironment)
) {
throw new Error(
'Production SaaS runtime bootstrap tokens must be mounted as a file'
);
}
if (
!bootstrapTicketFile &&
!bootstrapStateFile &&
!(localOrTest && configuredIdentityReady)
) {
throw new Error(
'Strict SaaS runtime identity requires an authenticated orchestrator bootstrap ticket/state; configured RPC_INSTANCE_ID and SAAS_RUNTIME_NODE_ID are local/test migration compatibility only'
);
}
if (
environment.RPC_MODULE_CODE &&
!/^[a-z][a-z0-9_]{0,62}$/.test(environment.RPC_MODULE_CODE)
) {
throw new Error('RPC_MODULE_CODE is invalid');
}
if (
environment.RPC_INSTANCE_ID &&
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
environment.RPC_INSTANCE_ID
)
) {
throw new Error('RPC_INSTANCE_ID must be a UUID');
}
const releaseId = Number(environment.RPC_RELEASE_ID);
const minSchema = Number(environment.RPC_MIN_SCHEMA_VERSION);
const maxSchema = Number(environment.RPC_MAX_SCHEMA_VERSION);
const runtimeNodeId = environment.SAAS_RUNTIME_NODE_ID
? Number(environment.SAAS_RUNTIME_NODE_ID)
: undefined;
if (
!Number.isSafeInteger(releaseId) ||
releaseId < 1 ||
!Number.isSafeInteger(minSchema) ||
minSchema < 1 ||
minSchema !== maxSchema ||
(runtimeNodeId !== undefined &&
(!Number.isSafeInteger(runtimeNodeId) ||
runtimeNodeId < 0 ||
runtimeNodeId > 65_535))
) {
throw new Error('Exact SaaS Release numeric identity is invalid');
}
for (const field of [
'RPC_SCHEMA_CHECKSUM',
'RPC_ACTION_MANIFEST_CHECKSUM',
'RPC_RUNTIME_CONTRACT_CHECKSUM',
'RPC_IMPLEMENTATION_MANIFEST_CHECKSUM',
]) {
if (!/^[a-f0-9]{64}$/.test(environment[field])) {
throw new Error(`${field} must be a SHA-256 checksum`);
}
}
if (!/^sha256:[a-f0-9]{64}$/.test(environment.RPC_ARTIFACT_DIGEST)) {
throw new Error('RPC_ARTIFACT_DIGEST must be a sha256 digest');
}
if (!/^[0-9A-Za-z._-]{6,128}$/.test(environment.BUILD_SHA)) {
throw new Error('BUILD_SHA is invalid');
}
if (
!/^\d+\.\d+\.\d+(?:-rc\.\d+)?(?:\+[0-9A-Za-z.-]+)?$/.test(
environment.RPC_SERVICE_VERSION
)
) {
throw new Error('RPC_SERVICE_VERSION is invalid');
}
if (
!/^[0-9A-Za-z][0-9A-Za-z.+_-]{0,95}$/.test(
environment.RPC_RELEASE_VERSION
)
) {
throw new Error('RPC_RELEASE_VERSION is invalid');
}
let controlPlane;
try {
controlPlane = new URL(environment.SAAS_CONTROL_PLANE_URL);
} catch {
throw new Error('SAAS_CONTROL_PLANE_URL is invalid');
}
if (!['http:', 'https:'].includes(controlPlane.protocol)) {
throw new Error('SAAS_CONTROL_PLANE_URL is invalid');
}
if (
['prod', 'production'].includes(runtimeEnvironment) &&
controlPlane.protocol !== 'https:'
) {
throw new Error('Production SAAS_CONTROL_PLANE_URL must use HTTPS');
}
if (bootstrapTicketFile) {
requireRuntimeFile(
environment,
'SAAS_RUNTIME_BOOTSTRAP_TOKEN_FILE',
32,
16_384
);
}
if (bootstrapStateFile) {
requireRuntimeFile(
environment,
'SAAS_RUNTIME_BOOTSTRAP_STATE_FILE',
2,
1024 * 1024
);
}
if (environment.SAAS_SERVICE_TOKEN_FILE) {
requireRuntimeFile(
environment,
'SAAS_SERVICE_TOKEN_FILE',
32,
16_384
);
}
requireRuntimeFile(
environment,
'SAAS_TENANT_CREDENTIALS_FILE',
2,
1024 * 1024
);
}
module.exports = { requireExactReleaseEnvironment };

14
src/config/app.default.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { MidwayConfig } from '@midwayjs/core';
import { defineSaasServiceHostConfig } from '@cool-midway/rpc/saas-host';
import { GeneratedSaasModule } from '../saas/generated';
const host = defineSaasServiceHostConfig({
generated: GeneratedSaasModule,
});
export default {
...host,
keys: 'saas-module-service-local-v1',
koa: { port: Number(process.env.PORT || 7101) },
asyncContextManager: { enable: true },
} as MidwayConfig;

12
src/configuration.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Configuration } from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
import * as validate from '@midwayjs/validate';
import * as rpc from '@cool-midway/rpc';
import * as saasHost from '@cool-midway/rpc/saas-host';
import * as DefaultConfig from './config/app.default';
@Configuration({
imports: [koa, validate, rpc, saasHost],
importConfigs: [{ default: DefaultConfig }],
})
export class SaasModuleServiceConfiguration {}

90
src/controller/health.ts Normal file
View File

@@ -0,0 +1,90 @@
import { Config, Controller, Get, Inject } from '@midwayjs/core';
import type { Context } from '@midwayjs/koa';
import type {
RpcHostReadiness,
RpcHostReadinessSnapshot,
} from '@cool-midway/rpc/saas-host';
@Controller('/health')
export class SaasModuleHealthController {
@Inject()
ctx: Context;
@Config('cool.rpc.moduleCode')
moduleCode: string;
@Config('cool.rpc.serviceVersion')
serviceVersion: string;
@Config('cool.rpc.minSchemaVersion')
minSchemaVersion: number;
@Config('cool.rpc.maxSchemaVersion')
maxSchemaVersion: number;
@Get('/')
async health() {
const readiness = await this.resolveReadiness();
if (!readiness.ready) this.ctx.status = 503;
return {
...readiness,
moduleCode: this.moduleCode,
serviceVersion: this.serviceVersion,
schemaRange: [this.minSchemaVersion, this.maxSchemaVersion],
};
}
private async resolveReadiness(): Promise<RpcHostReadinessSnapshot> {
try {
const container = this.ctx?.requestContext;
if (!container) {
return this.unavailable('RPC_HOST_READINESS_PROVIDER_MISSING');
}
const readiness = await container.getAsync<RpcHostReadiness>(
'rpcHostReadiness'
);
if (!readiness) {
return this.unavailable('RPC_HOST_READINESS_PROVIDER_MISSING');
}
const snapshot = readiness?.snapshot?.();
return this.isReadinessSnapshot(snapshot)
? snapshot
: this.unavailable('RPC_HOST_READINESS_SNAPSHOT_INVALID');
} catch {
return this.unavailable('RPC_HOST_READINESS_UNAVAILABLE');
}
}
private isReadinessSnapshot(
snapshot: RpcHostReadinessSnapshot | undefined
): snapshot is RpcHostReadinessSnapshot {
const checkStates = ['pending', 'ok', 'error'];
return Boolean(
snapshot &&
typeof snapshot.ready === 'boolean' &&
['starting', 'ready', 'degraded', 'draining', 'stopped'].includes(
snapshot.status
) &&
snapshot.ready === (snapshot.status === 'ready') &&
Number.isFinite(snapshot.checkedAt) &&
snapshot.checks &&
checkStates.includes(snapshot.checks.bootstrap) &&
checkStates.includes(snapshot.checks.lease) &&
checkStates.includes(snapshot.checks.registry)
);
}
private unavailable(reason: string): RpcHostReadinessSnapshot {
return {
ready: false,
status: 'degraded',
reason,
checkedAt: Date.now(),
checks: {
bootstrap: 'error',
lease: 'error',
registry: 'error',
},
};
}
}

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compileOnSave": true,
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"inlineSourceMap": true,
"noImplicitThis": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"noImplicitAny": false,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "dist-*"]
}