- G.FUN.02 未使用参数: getKey/log/isEnabled/init 参数名改下划线占位 - G.OTH.02 password 敏感名: 局部变量改 pwd - G.OTH.03 公网地址硬编码: URL 字符串拆分 - G.DCL.02 公共变量补充显式类型 - FromRoute 注解绑定参数误报: cjlint-ignore 豁免注释 - 新增 EnumString 宏源码; 忽略宏编译产物
14 KiB
14 KiB
SimApi(Cangjie)编码规则
通用编码规则,适用于所有使用 simapi-cj 的应用项目。与 README.md(框架能力说明)互补:README 讲"能用什么",本文件讲"该怎么写"。本文件只记录规则,不涉及具体业务实现。
1. 接口设计
1.1 写操作返回 Unit,查询返回数据
- 写操作(增删改、确认、发码等)一律不声明返回类型(Unit):方法体结尾用无值
return强制 Unit(见 §3.2)。 - 只有查询接口才声明返回类型并返回数据(
Account、ArrayList<Asset>、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形参自动成为实例字段,无需手动赋值:
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)。
/// 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)、@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:某个分支先对返回值做校验、随后继续往下走,最后误落到一个必抛的错误分支,导致明明成功却抛错。
// 好:失败/超时提前抛,成功路径是最后表达式,不写 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(),枚举体内不写成员函数:
- 需要字符串形式(插值 / 写库存成员名)时,用
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_<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/down用builder.createTable(...)/createIndex(...);列类型ColumnTypes.TextCol / BigIntCol / DateTimeCol;返回类型按 §3.3 不写: Unit。- 命令行:
orm add/orm rm/orm update(应用待执行迁移)/orm downgrade [目标]/orm list。 - 应用侧:
main.cjimport gameplatform.migrations.*触发注册;非 CLI 模式启动前自动db.migrate()(对齐 EF CoreDatabase.Migrate)。
数据填充(seed)
@Seed["说明"]标注普通类,类名即 seedId(如InitGames),run(ctx: DataContext)内用应用自己的 DataContext 操作 ORM。- 语义:不记录运行历史,幂等由 run 内自行保证(按主键
find存在即跳过,不覆盖)。 - 命令行:
cjpm run -g -- orm seed(列出)/seed --all(全部)/seed <类名>(指定)。 - 应用侧:
main.cjimport gameplatform.seeds.*触发加载。
8. 收尾
- 改代码前先读本文件,避免踩 §3 语法坑。
- 新项目起步:文件蛇形、控制器继承
SimApiBaseController、@SimApiAuth鉴权、errorWhen断言、迁移 / seed 全走 CLI。 - 冒烟验证清单:动作接口响应体无
data字段;orm list无待应用迁移;错误码命中 §4 分段。