diff --git a/RULE.MD b/RULE.MD index 7a30851..11580d2 100644 --- a/RULE.MD +++ b/RULE.MD @@ -1,118 +1,65 @@ # SimApi(Cangjie)编码规则 -> 依据 `game-platform-api`(E:\games\platform\game-platform-api)实际项目实践沉淀,所有规则均在 Cangjie 1.1.3 + simapi-cj 下实测通过。与 README.md(框架能力说明)互补:README 讲"能用什么",本文件讲"该怎么写"。 +> 通用编码规则,适用于所有使用 simapi-cj 的应用项目。与 README.md(框架能力说明)互补:README 讲"能用什么",本文件讲"该怎么写"。本文件只记录**规则**,不涉及具体业务实现。 --- ## 1. 接口设计 -### 1.1 动作接口不返回数据 +### 1.1 写操作返回 Unit,查询返回数据 -- **写操作**(增删改、发码、换绑、确认等)一律返回 `Unit`:方法不写 `: Unit` 返回类型,方法体不写 `()` 返回占位。 +- **写操作**(增删改、确认、发码等)一律不声明返回类型(Unit):方法体结尾用**无值 `return`** 强制 Unit(见 §3.2)。 - 只有**查询接口**才声明返回类型并返回数据(`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 + 业务错误码。 +- 不搞 `prepare` / `confirm` 两段式接口;参数与状态校验在方法开头一次性做完,有问题直接 `error` 抛错(§4),由全局异常中间件统一转 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 当前登录用户 +### 1.3 当前登录用户 - 直接使用 `loginInfo.id`(`SimApiBaseController` 提供),**不要重新声明局部变量**(如 `let me = loginInfo.id`)。 -- `@SimApiAuth["user"]` 类型 Token 下,请求体里的 `accountId` 一律忽略,防止越权。 +- Token 类型鉴权下,请求体里的用户标识(id / accountId 等)一律忽略,防止越权。 --- ## 2. 控制器写法 +- 继承 `SimApiBaseController`,类级 `@SimApiAuth["user"]`(或 `@SimApiAuth` 任意登录)。 +- 路由 `@HttpPost["小写路径"]`,以控制器域开头(`account/`、`auth/`、`asset/`…)。 +- 每个接口方法上方写 `/// POST /path 行为说明`(简单接口单行,复杂接口用 `/** ... */`)。 +- 依赖注入一律用**类名构造函数(主构造函数)**:`let` 形参自动成为实例字段,无需手动赋值: + ```cangjie -/** - * 账号与点券控制器(REST 仅供 Web 用户调用)。 - */ -@SimApiAuth["user"] public class AccountController <: SimApiBaseController { - private let _db: DataContext - private let _codes: AuthCodeHelper + public AccountController(let _db: DataContext) {} - 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 + /// POST /account/query 查询账号 + @HttpPost["account/query"] + public func query(): Account { + errorWhenNone(_db.accounts.find(loginInfo.id), code: 1001, message: "账号不存在") } } ``` -- 继承 `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` 断言式校验。 +- 没有依赖 / 没有字段的类,**空的 `public init() {}` 一律不写**。 +- 参数校验放方法开头,用 §4 的 `errorWhen` 断言式校验。 ### 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`。 +- 请求 / 响应 DTO **一律不写构造函数**,类体只有 `public var` 字段,**不使用 `prop`**。 +- 必填字段:带类型默认值(`String = String()`、`Int64 = 0` 等)。 +- 可选字段:标注 `@NotRequired`,带默认值即可(`?String = None` 或普通类型默认值)。 +- 放 `src/controllers/dtos/`,按域分文件,文件名蛇形(`account_dto.cj`、`auth_dto.cj`)。 + +```cangjie +/// Web 注册请求 +public class RegisterRequest { + public var phone: String = String() + public var name: ?String = None + public var avatar: ?String = None + public var code: String = String() +} +``` --- @@ -121,7 +68,7 @@ public class AccountController <: SimApiBaseController { ### 3.1 match 分支 - `case X => {}` **不合法**(`{}` 被解析成 lambda 开头,报 "expected '=>' in lambda expression")→ 必须 `case X => ()`。 -- 空操作 lambda 只能 `{ => () }`,不能 `{ => {} }`(如 TOTP 通过时返回的 complete 回调 `return { => () }`)。 +- 空操作 lambda 只能 `{ => () }`,不能 `{ => {} }`。 ### 3.2 省略返回类型的方法(重点) @@ -132,109 +79,173 @@ public class AccountController <: SimApiBaseController { - **解法**:方法结尾加**无值 `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 +public func doSomething() { + match (kind) { + case A => helper() // String + case B => error(code: 1001, message: "xxx") // Unit } return // 必须有:否则 String 分支 vs Unit 分支类型冲突 } ``` -### 3.3 override / 接口实现必须保留 `: Unit` +### 3.3 接口实现不保留 `: Unit` -`override func onConnect(...): Unit`、`onMessage`、`migrations` 的 `up/down`、`seeds` 的 `run/upsert` 等**必须写 `: Unit`**;省略会被推断成 `Interface` 之类导致与接口签名不匹配。 +- `override` / 接口实现方法(如迁移的 `up` / `down`、seed 的 `run`、事件回调等)一律**不写 `: Unit`**。 +- 声明返回类型的方法(查询接口等)照常写返回类型。 ### 3.4 其它 -- `error()` / `errorWhen()` / `errorWhenNone()` 均返回 `Unit`(`SimApiError`),可用于语句位置。 -- 同一 `match` / `if-else` 内多个分支表达式类型必须一致;`error` 是 Unit 会破坏合并,涉及"分支里既有值又有 error"时用 §3.2 的结尾 `return` 规避。 -- 删掉方法结尾 `()` 后,动作接口的 `data` 是否仍为 `null` 需冒烟确认(`POST` 接口看响应体无 `data` 字段即符合规则)。 +- `error` 系列方法均返回 `Unit`(§4),可用于语句位置。 +- 同一 `match` / `if-else` 内多个分支表达式类型必须一致;涉及"分支既有值又有 error"用 §3.2 的结尾 `return` 规避。 +- 分支必须返回值、但某分支注定 `error` 抛错时,补**死代码 + 注释**保持类型统一: + `JsonObject() // 死代码:error 必抛,仅为保持分支类型统一`。 --- ## 4. 错误处理 - 业务错误 **HTTP 恒 200**,错误信息在响应体 `code` / `message` 字段(SimApiExceptionMiddleware 保证)。 +- 报错统一用 `SimApiError` 文件里的**顶层函数**(`import simcu::simapi.helpers.*` 后直接调用;`SimApiError.error(...)` 旧写法保留兼容,新代码不用): + +| 函数 | 行为 | 默认 code | +|---|---|---| +| `error(code!, message!)` | 直接抛错 | 500 | +| `errorWhen(condition, code!, message!)` | 条件为 true 抛错 | 400 | +| `errorWhenTrue(condition, code!, message!)` | 条件为 true 抛错(别名) | 400 | +| `errorWhenFalse(condition, code!, message!)` | 条件为 false 抛错 | 400 | +| `errorWhenNone(value: ?T, code!, message!)` | None 抛错,否则解包返回 | 404 | + +- `code!` / `message!` 为**强制命名参数**,调用必须带参数名。 - 错误码分段(项目内约定): | code | 含义 | |------|------| -| 1001 | 参数 / 业务校验失败(手机号为空、未注册、越权等) | -| 1002 | 持有者校验失败 / 不支持的操作(护照不支持 drop/open 等) | -| 2001 | 点券余额不足 | -| 2002 | 资产不存在 / 状态不可操作 | -| 2003 | TOTP 验证失败 | -| 2006 | 护照在线(禁止上架等) | -| 400 | 验证码错误 / 过期 | +| 1001 | 参数 / 业务校验失败(为空、未注册、越权等) | +| 1002 | 无权限 / 持有者校验失败 / 不支持的操作 | +| 2001 | 余额不足 | +| 2002 | 资源不存在 / 状态不可操作 | +| 2003 | 校验失败(TOTP 等) | +| 400 | 凭证错误 / 过期 | +| 500 | 服务器内部错误 | - 参数校验写成断言式,避免 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: "账号不存在") +let acc = errorWhenNone(_db.accounts.find(id), code: 1001, message: "账号不存在") ``` --- ## 5. 数据访问(simcu::orm) -- `DataContext` 用 `@DbContext` 宏纯声明 `DbSet`;`main.cj` 中 `addScoped` 注册,连接串走 `ConnectionStrings:Pgsql`(迁移 CLI 兜底 `ORM_CONNECTION_STRING`)。 -- 链式查询: +### 5.1 DataContext 与实体 + +- `DataContext` 用 `@DbContext` 宏**纯声明** `DbSet` 属性(宏自动补继承 / 构造 / 字段注入 / migrations()): ```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() +@DbContext +public class DataContext { + prop accounts: DbSet + prop assets: DbSet +} ``` -- 写操作固定顺序:改实体 → `_db.xxx.update(a)` / `.add(log)` / `.remove(a)` → `_db.saveChanges()` → (如有验证码)`complete()`。 +- 实体类:`@Table["表名"]` 标表名,属性名 camelCase,列名 snake_case 用 `@Column` 显式映射: + +```cangjie +@Table["accounts"] +public class Account { + public var id: String = String() // 主键统一 id(空则 orm 自动生成) + @Required + public var phone: String = String() // 必填 + @Column["otp_secret"] + public var otpSecret: ?String = None // 可选 → NULL + @Column["created_at"] + public var createdAt: DateTime = DateTime.now() +} +``` + +- 常用注解:`@Key`(主键,普通表主键属性统一 `id`)、`@Required`(NOT NULL)、`@Index`(普通索引)、`@Column["列名"]`(列映射)、`@TableIndex["col1,col2", unique, "partial 条件", "索引名"]`(唯一 / 复合 / 条件索引)。 +- 可选字段一律 `?T = None`;时间字段 `DateTime`(`DateTime.now()` 默认),不用 Int64 Unix 秒。 + +### 5.2 查询 + +- 链式:`.filter("属性名", "=", 值)` → `.first()` / `.toList()` / `.count()` / `.orderByDesc("updatedAt")`。 +- 条件列名用**实体属性名**(camelCase),不是数据库列名;枚举字段直接传枚举值比较。 + +### 5.3 写操作 + +- 固定顺序:改实体 → `_db.xxx.update(a)` / `.add(x)` / `.remove(a)` → `_db.saveChanges()`。 +- 幂等:按业务唯一键(如 `orderId`)先 `count()` 去重,命中直接返回;乐观锁字段 `version` 每次变更自增。 --- -## 6. 验证码与缓存 +## 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. 项目结构 +### 6.1 项目结构(文件名一律蛇形 snake_case) ``` 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 注册 + 迁移执行 +├── controllers/ # xxx_controller.cj(一个控制器一个文件) +│ └── dtos/ # xxx_dto.cj(按域分文件,一个文件多 DTO 类) +├── helpers/ # xxx_helper.cj +├── migrations/ # <纯时间戳>.cj(CLI 生成,勿手编)+ migration_registry.cj + snapshot.json +├── models/ # data_context.cj + 实体 xxx.cj(每实体一文件) +└── seeds/ # xxx.cj(@Seed 标注,类名即 seedId) ``` -- `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)。 +- 包名 / 目录名 / 源文件名全部 snake_case;类型(类 / 枚举 / 接口)PascalCase。 +- **Windows 大小写不敏感**:仅大小写变化的重命名(`Game.cj → game.cj`)需两步(先临时名再最终名)。 + +### 6.2 命名 + +- 文件名 / 包名 / 目录名:一律 snake_case。 +- 类型(类 / 枚举 / 接口 / 宏):PascalCase。 +- 字段 / 局部变量 / 函数:camelCase;**私有字段 `_camelCase`**;静态字段 / 常量 camelCase。 +- 方法:camelCase,动词开头;辅助函数动词开头(`maskPhone`、`upsert`)。 + +### 6.3 import 风格 + +- 通配:`import gameplatform.controllers.*`。 +- 同包多符号:`import simcu::simapi.helpers.{error, errorWhen, errorWhenNone}`。 +- 单符号:`import gameplatform.models.Account`。 +- 依赖包前缀 `simcu::xxx`;同项目包直接写包名(`gameplatform.xxx`)。 + +### 6.4 枚举 + +- 成员**大写开头**(`Deduction`、`PhoneChange`、`Passport`…)。 +- 仓颉有**默认 ToString 实现**,不需要自写 `toString()`。 + +### 6.5 数据库命名 + +- 表名:复数小写(`accounts`、`assets`、`server_op_logs`)。 +- 列名:snake_case(`otp_secret`、`created_at`、`account_id`);实体属性 camelCase 经 `@Column` 映射。 +- 主键:统一 `id`(String,空则 orm 自动生成)。 +- 索引:`ix__`;唯一 / 复合 / 条件索引 `ux_
_`。 --- -## 8. 注释与命名 +## 7. 迁移与数据填充(simcu::orm CLI) -- 类注释 `/** ... */`:说明职责、路由表、权限与调用方(REST 还是 WS)。 -- 接口方法注释:单行 `/// POST /path 行为说明`;多行用 `/** ... */`。 -- 控制器 / 模型 / 枚举:PascalCase;字段 camelCase;私有字段 `_camelCase`。 -- 枚举成员大写开头(`Deduction`、`PhoneChange`…),实现 `ToString` 提供成员名字符串(存库 / key 用)。 -- 常量 / 静态字段 camelCase(如 `smsCodeTtl`、`smsCodeCacheKey`)。 +- 迁移文件**必须由 CLI 生成**:`cjpm run -g -- orm add <描述>` → 产出 `src/migrations/<纯时间戳>.cj`(类 `MigrationId<时间戳> <: Migration`,id 纯时间戳;文件头自动标注"由 CLI 生成,勿手编")+ `migration_registry.cj` + `snapshot.json`。 +- `up` / `down` 用 `builder.createTable(...)` / `createIndex(...)`;列类型 `ColumnTypes.TextCol / BigIntCol / DateTimeCol`;返回类型按 §3.3 不写 `: Unit`。 +- 命令行:`orm add` / `orm rm` / `orm update`(应用待执行迁移)/ `orm downgrade [目标]` / `orm list`。 +- 应用侧:`main.cj` `import gameplatform.migrations.*` 触发注册;非 CLI 模式启动前自动 `db.migrate()`(对齐 EF Core `Database.Migrate`)。 + +### 数据填充(seed) + +- `@Seed["说明"]` 标注普通类,**类名即 seedId**(如 `InitGames`),`run(ctx: DataContext)` 内用应用自己的 DataContext 操作 ORM。 +- 语义:不记录运行历史,**幂等由 run 内自行保证**(按主键 `find` 存在即跳过,不覆盖)。 +- 命令行:`cjpm run -g -- orm seed`(列出)/ `seed --all`(全部)/ `seed <类名>`(指定)。 +- 应用侧:`main.cj` `import gameplatform.seeds.*` 触发加载。 + +--- + +## 8. 收尾 + +- 改代码前先读本文件,避免踩 §3 语法坑。 +- 新项目起步:文件蛇形、控制器继承 `SimApiBaseController`、`@SimApiAuth` 鉴权、`errorWhen` 断言、迁移 / seed 全走 CLI。 +- 冒烟验证清单:动作接口响应体无 `data` 字段;`orm list` 无待应用迁移;错误码命中 §4 分段。