From ff1d613f6f50f639073008b2f87518d7e124fc56 Mon Sep 17 00:00:00 2001 From: xRain Date: Tue, 25 Aug 2026 13:44:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86Rule=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RULE.MD | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 RULE.MD diff --git a/RULE.MD b/RULE.MD new file mode 100644 index 0000000..7a30851 --- /dev/null +++ b/RULE.MD @@ -0,0 +1,240 @@ +# SimApi(Cangjie)编码规则 + +> 依据 `game-platform-api`(E:\games\platform\game-platform-api)实际项目实践沉淀,所有规则均在 Cangjie 1.1.3 + simapi-cj 下实测通过。与 README.md(框架能力说明)互补:README 讲"能用什么",本文件讲"该怎么写"。 + +--- + +## 1. 接口设计 + +### 1.1 动作接口不返回数据 + +- **写操作**(增删改、发码、换绑、确认等)一律返回 `Unit`:方法不写 `: Unit` 返回类型,方法体不写 `()` 返回占位。 +- 只有**查询接口**才声明返回类型并返回数据(`Account`、`ArrayList`、`String` 等)。 +- 需要强制 Unit 推断时(见 §3),在方法结尾写**无值 `return`**: + +```cangjie +@HttpPost["account/update-profile"] +public func updateProfile(@FromBody request: UpdateProfileRequest) { + errorWhenNone(_db.accounts.find(loginInfo.id), code: 1001, message: "账号不存在") + acc.name = request.name + _db.accounts.update(acc) + _db.saveChanges() + return // 结尾无值 return:强制 Unit(最后表达式 saveChanges 是 Int64) +} +``` + +### 1.2 一步到位,不做两段式 + +- 不搞 `prepare` / `confirm` 两段式接口。下单、挂单、购买等**直接一个接口完成**;参数与状态校验在方法开头一次性做完,有问题直接 `error` 抛错,由全局异常中间件统一转 HTTP 200 + 业务错误码。 + +### 1.3 验证码统一入口 + +- 登录 / 注册:`POST /auth/verify-code`(`isRegister` 区分)。 +- 其余**所有**需验证码的场景统一走 `POST /account/verify-code`,`kind` 指定用途,`resourceId` 为业务资源: + +```cangjie +public class VerifyOpCodeRequest { + public var kind: AuthCodeKind = AuthCodeKind.List + @NotRequired + public var resourceId: ?String = None +} +``` + +| kind | resourceId 语义 | +|------|-----------------| +| `Deduction` | 扣费金额字符串 | +| `List` / `Destroy` / `Drop` / `Open` / `Trade` | 资产 id | +| `PhoneChange` | 缺省 → 发到当前绑定号;传入新手机号 → 发码到新号 | +| `Login` / `Register` | 请走 `auth/verify-code`,本接口拒绝 | + +### 1.4 验证码校验与消费分离 + +- **校验接口只校验不消费**(如 `account/verify-old-code`):验证码保留,仅确认"验证码正确"。 +- **真正变更的接口**一次性传全部验证码 + 目标数据,全部通过后改绑并消费(如 `account/change-phone` 需 `oldCode` + `code` + `newPhone`)。 +- **消费时机**:业务落库成功(`saveChanges`)之后调用 `verifyCode` 返回的 complete 回调删除缓存;失败路径(`error` 抛错)不消费。 + +```cangjie +@HttpPost["account/change-phone"] +public func changePhone(@FromBody request: ChangePhoneRequest) { + errorWhen(request.oldCode.isEmpty(), code: 1001, message: "缺少老手机号验证码") + errorWhen(request.code.isEmpty(), code: 1001, message: "缺少新手机号验证码") + let acc = errorWhenNone(_db.accounts.find(loginInfo.id), code: 1001, message: "账号不存在") + let oldComplete = _codes.verifyCode(acc.phone, request.oldCode, AuthCodeKind.PhoneChange, loginInfo.id) + let newComplete = _codes.verifyCode(request.newPhone, request.code, AuthCodeKind.PhoneChange, loginInfo.id) + acc.phone = request.newPhone + _db.accounts.update(acc) + _db.saveChanges() + oldComplete() // 落库成功后才消费 + newComplete() +} +``` + +### 1.5 当前登录用户 + +- 直接使用 `loginInfo.id`(`SimApiBaseController` 提供),**不要重新声明局部变量**(如 `let me = loginInfo.id`)。 +- `@SimApiAuth["user"]` 类型 Token 下,请求体里的 `accountId` 一律忽略,防止越权。 + +--- + +## 2. 控制器写法 + +```cangjie +/** + * 账号与点券控制器(REST 仅供 Web 用户调用)。 + */ +@SimApiAuth["user"] +public class AccountController <: SimApiBaseController { + private let _db: DataContext + private let _codes: AuthCodeHelper + + public init(db: DataContext, codes: AuthCodeHelper) { + this._db = db + this._codes = codes + } + + /// POST /account/update-profile 更新资料 + @HttpPost["account/update-profile"] + public func updateProfile(@FromBody request: UpdateProfileRequest) { + // ... + return + } +} +``` + +- 继承 `SimApiBaseController`,类级 `@SimApiAuth["user"]`(或 `@SimApiAuth` 任意登录)。 +- 路由 `@HttpPost["小写路径"]`,以控制器域开头:`account/`、`auth/`、`asset/`、`trade/`、`fund/`、`game/`。 +- 每个接口方法上方第一行写 `/// POST /path ...` 说明。 +- 依赖全部**构造注入**:`private let _xxx` + `public init(xxx: T) { this._xxx = xxx }`;在 `main.cj` 用 `addScoped` / `addSingleton` 注册。 +- 参数校验放方法开头,用 `errorWhen` / `errorWhenNone` 断言式校验。 + +### DTO + +- 放 `src/controllers/dtos/`,按域分文件(`AccountDto.cj`、`AssetDto.cj`、`AuthDto.cj` …)。 +- 必填字段:`public var phone: String = String()`(或类型默认值)。 +- 可选字段:标注 `@NotRequired`,类型不限,**带默认值即可**——可以是 `?String = None`,也可以是普通类型 `String = String()`、`Int64 = 0` 等。 +- 响应 DTO 提供带参 `init`,字段用 `public var`。 + +--- + +## 3. Cangjie 语法实测(易踩坑) + +### 3.1 match 分支 + +- `case X => {}` **不合法**(`{}` 被解析成 lambda 开头,报 "expected '=>' in lambda expression")→ 必须 `case X => ()`。 +- 空操作 lambda 只能 `{ => () }`,不能 `{ => {} }`(如 TOTP 通过时返回的 complete 回调 `return { => () }`)。 + +### 3.2 省略返回类型的方法(重点) + +省略返回类型时,编译器把**方法体内所有 `return` 表达式 + 最后表达式**合并推断: + +- 含 `error(...)`(返回 `Unit`)分支的 `match` 作为方法最后表达式 → 其他分支(如 `String`)与 `Unit` 无公共子类型,编译失败。 +- 方法内有提前无值 `return`、最后表达式又是非 Unit(如 `saveChanges()` 返回 `Int64`)→ 同样冲突。 +- **解法**:方法结尾加**无值 `return`**,让最后的 `match` / 表达式降级为普通语句,不再参与类型合并。 + +```cangjie +public func verifyCode(@FromBody request: VerifyOpCodeRequest) { + match (request.kind) { + case AuthCodeKind.Deduction => + codes.sendCellCode(cell, AuthCodeKind.Deduction, amount) // String + // ... + case AuthCodeKind.Login | AuthCodeKind.Register => + error(code: 1001, message: "登录/注册验证码请走 auth/verify-code") // Unit + } + return // 必须有:否则 String 分支 vs Unit 分支类型冲突 +} +``` + +### 3.3 override / 接口实现必须保留 `: Unit` + +`override func onConnect(...): Unit`、`onMessage`、`migrations` 的 `up/down`、`seeds` 的 `run/upsert` 等**必须写 `: Unit`**;省略会被推断成 `Interface` 之类导致与接口签名不匹配。 + +### 3.4 其它 + +- `error()` / `errorWhen()` / `errorWhenNone()` 均返回 `Unit`(`SimApiError`),可用于语句位置。 +- 同一 `match` / `if-else` 内多个分支表达式类型必须一致;`error` 是 Unit 会破坏合并,涉及"分支里既有值又有 error"时用 §3.2 的结尾 `return` 规避。 +- 删掉方法结尾 `()` 后,动作接口的 `data` 是否仍为 `null` 需冒烟确认(`POST` 接口看响应体无 `data` 字段即符合规则)。 + +--- + +## 4. 错误处理 + +- 业务错误 **HTTP 恒 200**,错误信息在响应体 `code` / `message` 字段(SimApiExceptionMiddleware 保证)。 +- 错误码分段(项目内约定): + +| code | 含义 | +|------|------| +| 1001 | 参数 / 业务校验失败(手机号为空、未注册、越权等) | +| 1002 | 持有者校验失败 / 不支持的操作(护照不支持 drop/open 等) | +| 2001 | 点券余额不足 | +| 2002 | 资产不存在 / 状态不可操作 | +| 2003 | TOTP 验证失败 | +| 2006 | 护照在线(禁止上架等) | +| 400 | 验证码错误 / 过期 | + +- 参数校验写成断言式,避免 if 包大段逻辑: + +```cangjie +errorWhen(request.phone.isEmpty(), code: 1001, message: "手机号不能为空") +errorWhen(exists > 0, code: 1001, message: "该手机号已注册") +let acc = errorWhenNone(_db.accounts.find(loginInfo.id), code: 1001, message: "账号不存在") +``` + +--- + +## 5. 数据访问(simcu::orm) + +- `DataContext` 用 `@DbContext` 宏纯声明 `DbSet`;`main.cj` 中 `addScoped` 注册,连接串走 `ConnectionStrings:Pgsql`(迁移 CLI 兜底 `ORM_CONNECTION_STRING`)。 +- 链式查询: + +```cangjie +let acc = errorWhenNone(_db.accounts.query() + .filter("phone", "=", request.phone) + .first(), code: 1001, message: "手机号未注册") +let mine = _db.assets.query() + .filter("accountId", "=", loginInfo.id) + .filter("status", "=", Int64(1)) + .orderByDesc("updatedAt") + .toList() +``` + +- 写操作固定顺序:改实体 → `_db.xxx.update(a)` / `.add(log)` / `.remove(a)` → `_db.saveChanges()` → (如有验证码)`complete()`。 + +--- + +## 6. 验证码与缓存 + +- 验证码存 Redis(`SimApiCache`),key 模板 `AuthCode:{CELL}:{ACTION}:{RESOURCE}`(SimApiCache 自动加 `SimApi:Cache:` 前缀),即 `SimApi:Cache:AuthCode:<手机号>:<用途>:<资源id>`。 +- `resource` 约定:登录 / 注册为 `""`;资产操作 / 换绑用**账号 id**;扣费用金额字符串。 +- 验证逻辑(`AuthCodeHelper.verifyCode`): + 1. 账号存在且 `action` 非 `PhoneChange` / `Register` → 优先验证 **TOTP 动态码**,通过则 complete 为空操作; + 2. 否则走**短信验证码**,通过后返回"延迟删除"complete,业务落库成功才消费。 +- 短信验证码 6 位,TTL 120s;开发模式由 `SmsHelper` 打印到服务日志。 + +--- + +## 7. 项目结构 + +``` +src/ +├── main.cj # SimApiExtensions 配置 + DI 注册 + 迁移自动执行 + WS 端口 +├── controllers/ # 业务控制器 + dtos/ +├── helpers/ # AuthCodeHelper / SmsHelper 等业务帮助类 +├── migrations/ # YYYYMMDDHHMMSS_描述.cj + MigrationRegistry(up/down 保留 : Unit) +├── models/ # @DbContext DataContext + 实体(SimApiBaseModel / orm 实体) +├── seeds/ # @Seed 标注 + 顶层 let 注册(run/upsert 保留 : Unit) +└── websocket/ # 每连接 handler(GameHandler <: IWebsocketHandler)+ WsForwarder +``` + +- `main.cj` 启动流程:连接串 → DI 注册(DataContext / SmsHelper / AuthCodeHelper / WsForwarder / WebSocketServer)→ `SimApiExtensions.addSimApi`(开关 + Redis + 请求日志)→ 迁移 CLI / 自动 `migrate()` → WS `addHandler` → `useSimApi` → `run`。 +- **websocket**:每连接一个 handler(握手工厂创建),业务方法 `biz*` 前缀、用 `payload` 参数 + 会话状态 `_gameId` 定位归属;每个请求独立 `DataContext`(长连接不共享,`withCtx` 内创建 scoped)。 +- 迁移 / seeds 注册通过"顶层 let + 仅 import 即触发加载执行"(`import gameplatform.migrations.*` 等,见 main.cj)。 + +--- + +## 8. 注释与命名 + +- 类注释 `/** ... */`:说明职责、路由表、权限与调用方(REST 还是 WS)。 +- 接口方法注释:单行 `/// POST /path 行为说明`;多行用 `/** ... */`。 +- 控制器 / 模型 / 枚举:PascalCase;字段 camelCase;私有字段 `_camelCase`。 +- 枚举成员大写开头(`Deduction`、`PhoneChange`…),实现 `ToString` 提供成员名字符串(存库 / key 用)。 +- 常量 / 静态字段 camelCase(如 `smsCodeTtl`、`smsCodeCacheKey`)。