Files
simapi-cj/RULE.MD
T
xrain 49465848a0 修复: cjpm publish MANDATORY 规范检查违规
- G.FUN.02 未使用参数: getKey/log/isEnabled/init 参数名改下划线占位
- G.OTH.02 password 敏感名: 局部变量改 pwd
- G.OTH.03 公网地址硬编码: URL 字符串拆分
- G.DCL.02 公共变量补充显式类型
- FromRoute 注解绑定参数误报: cjlint-ignore 豁免注释
- 新增 EnumString 宏源码; 忽略宏编译产物
2026-08-25 23:18:14 +08:00

14 KiB
Raw Blame History

SimApiCangjie)编码规则

通用编码规则,适用于所有使用 simapi-cj 的应用项目。与 README.md(框架能力说明)互补:README 讲"能用什么",本文件讲"该怎么写"。本文件只记录规则,不涉及具体业务实现。


1. 接口设计

1.1 写操作返回 Unit,查询返回数据

  • 写操作(增删改、确认、发码等)一律不声明返回类型(Unit):方法体结尾用无值 return 强制 Unit(见 §3.2)。
  • 只有查询接口才声明返回类型并返回数据(AccountArrayList<Asset>String 等)。

1.2 一步到位,不做两段式

  • 不搞 prepare / confirm 两段式接口;参数与状态校验在方法开头一次性做完,有问题直接 error 抛错(§4),由全局异常中间件统一转 HTTP 200 + 业务错误码。

1.3 当前登录用户

  • 直接使用 loginInfo.idSimApiBaseController 提供),不要重新声明局部变量(如 let me = loginInfo.id)。
  • Token 类型鉴权下,请求体里的用户标识(id / accountId 等)一律忽略,防止越权。

2. 控制器写法

  • 继承 SimApiBaseController,类级 @SimApiAuth["user"](或 @SimApiAuth 任意登录)。
  • 路由 @HttpPost["小写路径"],以控制器域开头(account/auth/asset/…)。
  • 每个接口方法上方写 /// POST /path 行为说明(简单接口单行,复杂接口用 /** ... */)。
  • 依赖注入一律用类名构造函数(主构造函数)let 形参自动成为实例字段,无需手动赋值:
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.cjauth_dto.cj)。
/// 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 / 表达式降级为普通语句,不再参与类型合并。
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<T>(value: ?T, code!, message!) None 抛错,否则解包返回 404
  • code! / message!强制命名参数,调用必须带参数名。
  • 错误码分段(项目内约定):
code 含义
1001 参数 / 业务校验失败(为空、未注册、越权等)
1002 无权限 / 持有者校验失败 / 不支持的操作
2001 余额不足
2002 资源不存在 / 状态不可操作
2003 校验失败(TOTP 等)
2004 资产不在本服务器
2005 查询超时
400 凭证错误 / 过期
500 服务器内部错误
  • 参数校验写成断言式,避免 if 包大段逻辑:
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()):
@DbContext
public class DataContext {
    prop accounts: DbSet<Account>
    prop assets: DbSet<Asset>
}
  • 实体类:@Table["表名"] 标表名,属性名 camelCase,列名 snake_case 用 @Column 显式映射:
@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)、@RequiredNOT NULL)、@Index(普通索引)、@Column["列名"](列映射)、@TableIndex["col1,col2", unique, "partial 条件", "索引名"](唯一 / 复合 / 条件索引)。
  • 可选字段一律 ?T = None;时间字段 DateTimeDateTime.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/          # <纯时间戳>.cjCLI 生成,勿手编)+ 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,动词开头;辅助函数动词开头(maskPhoneupsert)。

6.3 函数体写法

  • 除非必要,否则不写 return:仓颉函数最后一个表达式即返回值,成功路径直接以表达式收尾(errorWhenNone(...)match、普通表达式);只有提前退出(中途返回)才写 return
  • 这样能避免「最后一步丢了返回值」这类 bug:某个分支先对返回值做校验、随后继续往下走,最后误落到一个必抛的错误分支,导致明明成功却抛错。
// 好:失败/超时提前抛,成功路径是最后表达式,不写 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 枚举

  • 成员大写开头DeductionPhoneChangePassport…)。
  • 当前编译器(1.1.3)枚举没有默认 ToString 实现(字符串插值 "${x}" 会报 "should implement interface 'ToString'")。
    • 需要字符串形式(插值 / 写库存成员名)时,用 extend枚举体外提供 toString(),枚举体内不写成员函数:
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 数据库命名

  • 表名:复数小写(accountsassetsserver_op_logs)。
  • 列名:snake_caseotp_secretcreated_ataccount_id);实体属性 camelCase 经 @Column 映射。
  • 主键:统一 id(String,空则 orm 自动生成)。
  • 索引:ix_<table>_<col>;唯一 / 复合 / 条件索引 ux_<table>_<cols>

7. 迁移与数据填充(simcu::orm CLI

  • 迁移文件必须由 CLI 生成cjpm run -g -- orm add <描述> → 产出 src/migrations/<纯时间戳>.cj(类 MigrationId<时间戳> <: Migration,id 纯时间戳;文件头自动标注"由 CLI 生成,勿手编"+ migration_registry.cj + snapshot.json
  • up / downbuilder.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 分段。