# SimApi(Cangjie)编码规则 > 通用编码规则,适用于所有使用 simapi-cj 的应用项目。与 README.md(框架能力说明)互补:README 讲"能用什么",本文件讲"该怎么写"。本文件只记录**规则**,不涉及具体业务实现。 --- ## 1. 接口设计 ### 1.1 写操作返回 Unit,查询返回数据 - **写操作**(增删改、确认、发码等)一律不声明返回类型(Unit):方法体结尾用**无值 `return`** 强制 Unit(见 §3.2)。 - 只有**查询接口**才声明返回类型并返回数据(`Account`、`ArrayList`、`String` 等)。 ### 1.2 一步到位,不做两段式 - 不搞 `prepare` / `confirm` 两段式接口;参数与状态校验在方法开头一次性做完,有问题直接 `error` 抛错(§4),由全局异常中间件统一转 HTTP 200 + 业务错误码。 ### 1.3 当前登录用户 - 直接使用 `loginInfo.id`(`SimApiBaseController` 提供),**不要重新声明局部变量**(如 `let me = loginInfo.id`)。 - Token 类型鉴权下,请求体里的用户标识(id / accountId 等)一律忽略,防止越权。 --- ## 2. 控制器写法 - 继承 `SimApiBaseController`,类级 `@SimApiAuth["user"]`(或 `@SimApiAuth` 任意登录)。 - 路由 `@HttpPost["小写路径"]`,以控制器域开头(`account/`、`auth/`、`asset/`…)。 - 每个接口方法上方写 `/// POST /path 行为说明`(简单接口单行,复杂接口用 `/** ... */`)。 - 依赖注入一律用**类名构造函数(主构造函数)**:`let` 形参自动成为实例字段,无需手动赋值: ```cangjie public class AccountController <: SimApiBaseController { public AccountController(let _db: DataContext) {} /// POST /account/query 查询账号 @HttpPost["account/query"] public func query(): Account { errorWhenNone(_db.accounts.find(loginInfo.id), code: 1001, message: "账号不存在") } } ``` - ⚠️ `let` 形参已自动生成同名字段,**类体内不要再显式声明同名字段**(`private let _db: DataContext` 会报 "redefinition of declaration")。 - 没有依赖 / 没有字段的类,**空的 `public init() {}` 一律不写**。 - 参数校验放方法开头,用 §4 的 `errorWhen` 断言式校验。 ### DTO - 请求 / 响应 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() } ``` --- ## 3. Cangjie 语法实测(易踩坑) ### 3.1 match 分支 - `case X => {}` **不合法**(`{}` 被解析成 lambda 开头,报 "expected '=>' in lambda expression")→ 必须 `case X => ()`。 - 空操作 lambda 只能 `{ => () }`,不能 `{ => {} }`。 ### 3.2 省略返回类型的方法(重点) 省略返回类型时,编译器把**方法体内所有 `return` 表达式 + 最后表达式**合并推断: - 含 `error(...)`(返回 `Unit`)分支的 `match` 作为方法最后表达式 → 其他分支(如 `String`)与 `Unit` 无公共子类型,编译失败。 - 方法内有提前无值 `return`、最后表达式又是非 Unit(如 `saveChanges()` 返回 `Int64`)→ 同样冲突。 - **解法**:方法结尾加**无值 `return`**,让最后的 `match` / 表达式降级为普通语句,不再参与类型合并。 ```cangjie public func doSomething() { match (kind) { case A => helper() // String case B => error(code: 1001, message: "xxx") // Unit } return // 必须有:否则 String 分支 vs Unit 分支类型冲突 } ``` ### 3.3 接口实现不保留 `: Unit` - `override` / 接口实现方法(如迁移的 `up` / `down`、seed 的 `run`、事件回调等)一律**不写 `: Unit`**。 - 声明返回类型的方法(查询接口等)照常写返回类型。 ### 3.4 其它 - `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 | 无权限 / 持有者校验失败 / 不支持的操作 | | 2001 | 余额不足 | | 2002 | 资源不存在 / 状态不可操作 | | 2003 | 校验失败(TOTP 等) | | 2004 | 资产不在本服务器 | | 2005 | 查询超时 | | 400 | 凭证错误 / 过期 | | 500 | 服务器内部错误 | - 参数校验写成断言式,避免 if 包大段逻辑: ```cangjie errorWhen(request.phone.isEmpty(), code: 1001, message: "手机号不能为空") errorWhen(exists > 0, code: 1001, message: "该手机号已注册") let acc = errorWhenNone(_db.accounts.find(id), code: 1001, message: "账号不存在") ``` --- ## 5. 数据访问(simcu::orm) ### 5.1 DataContext 与实体 - `DataContext` 用 `@DbContext` 宏**纯声明** `DbSet` 属性(宏自动补继承 / 构造 / 字段注入 / migrations()): ```cangjie @DbContext public class DataContext { prop accounts: DbSet prop assets: DbSet } ``` - 实体类:`@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.1 项目结构(文件名一律蛇形 snake_case) ``` src/ ├── 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) ``` - 包名 / 目录名 / 源文件名全部 snake_case;类型(类 / 枚举 / 接口)PascalCase。 - **Windows 大小写不敏感**:仅大小写变化的重命名(`Game.cj → game.cj`)需两步(先临时名再最终名)。 ### 6.2 命名 - 文件名 / 包名 / 目录名:一律 snake_case。 - 类型(类 / 枚举 / 接口 / 宏):PascalCase。 - 字段 / 局部变量 / 函数:camelCase;**私有字段 `_camelCase`**;静态字段 / 常量 camelCase。 - 方法:camelCase,动词开头;辅助函数动词开头(`maskPhone`、`upsert`)。 ### 6.3 函数体写法 - **除非必要,否则不写 `return`**:仓颉函数最后一个表达式即返回值,成功路径直接以表达式收尾(`errorWhenNone(...)`、`match`、普通表达式);只有**提前退出**(中途返回)才写 `return`。 - 这样能避免「最后一步丢了返回值」这类 bug:某个分支先对返回值做校验、随后继续往下走,最后误落到一个必抛的错误分支,导致明明成功却抛错。 ```cangjie // 好:失败/超时提前抛,成功路径是最后表达式,不写 return if (wait.okResult.isEmpty() && wait.firstFail.isEmpty()) { error(code: 2005, message: "超时") } if (!wait.firstFail.isEmpty()) { let root = JsonValue.fromStr(wait.firstFail).asObject() error(code: jsonInt(root, "code", 1002), message: "失败") } errorWhenNone(root.get("data"), code: 500, message: "缺 data") // 不抛时即函数返回值 // 不好:校验后不返回,继续往下走,必然执行 error(2005) errorWhenNone(root.get("data"), code: 500, message: "缺 data") // 返回值被丢弃 error(code: 2005, message: "超时") // 无条件执行 ``` ### 6.4 import 风格 - 通配:`import gameplatform.controllers.*`。 - 同包多符号:`import simcu::simapi.helpers.{error, errorWhen, errorWhenNone}`。 - 单符号:`import gameplatform.models.Account`。 - 依赖包前缀 `simcu::xxx`;同项目包直接写包名(`gameplatform.xxx`)。 ### 6.5 枚举 - 成员**大写开头**(`Deduction`、`PhoneChange`、`Passport`…)。 - **当前编译器(1.1.3)枚举没有默认 ToString 实现**(字符串插值 `"${x}"` 会报 "should implement interface 'ToString'")。 - 需要字符串形式(插值 / 写库存成员名)时,用 `extend` 在**枚举体外**提供 `toString()`,枚举体内不写成员函数: ```cangjie public enum AssetKind { | Passport | Package } // 当前编译器枚举无默认 ToString,字符串插值/写库需要时在此提供;编译器提供默认实现后可删除。 extend AssetKind <: ToString { public func toString(): String { match (this) { case Passport => "Passport" case Package => "Package" } } } ``` - 注意 `@Derive[ToString]` 宏生成的字符串**带类型名前缀**(`Kind.A` 而非 `A`),依赖纯成员名的场景不能用。 - 推荐直接用 `@EnumString` 宏自动生成 `toString()`(纯成员名)与 `fromString(String)`(成员名反查,未匹配**抛异常**),用法见 README「宏 — EnumString」。宏生成的是 extend,枚举体内仍不写成员函数。 ### 6.6 数据库命名 - 表名:复数小写(`accounts`、`assets`、`server_op_logs`)。 - 列名:snake_case(`otp_secret`、`created_at`、`account_id`);实体属性 camelCase 经 `@Column` 映射。 - 主键:统一 `id`(String,空则 orm 自动生成)。 - 索引:`ix__`;唯一 / 复合 / 条件索引 `ux_
_`。 --- ## 7. 迁移与数据填充(simcu::orm CLI) - 迁移文件**必须由 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 分段。