Files
simapi-cj/RULE.MD
T
2026-08-25 13:44:28 +08:00

11 KiB
Raw Blame History

SimApiCangjie)编码规则

依据 game-platform-apiE:\games\platform\game-platform-api)实际项目实践沉淀,所有规则均在 Cangjie 1.1.3 + simapi-cj 下实测通过。与 README.md(框架能力说明)互补:README 讲"能用什么",本文件讲"该怎么写"。


1. 接口设计

1.1 动作接口不返回数据

  • 写操作(增删改、发码、换绑、确认等)一律返回 Unit:方法不写 : Unit 返回类型,方法体不写 () 返回占位。
  • 只有查询接口才声明返回类型并返回数据(AccountArrayList<Asset>String 等)。
  • 需要强制 Unit 推断时(见 §3),在方法结尾写无值 return
@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-codeisRegister 区分)。
  • 其余所有需验证码的场景统一走 POST /account/verify-codekind 指定用途,resourceId 为业务资源:
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-phoneoldCode + code + newPhone)。
  • 消费时机:业务落库成功(saveChanges)之后调用 verifyCode 返回的 complete 回调删除缓存;失败路径(error 抛错)不消费。
@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.idSimApiBaseController 提供),不要重新声明局部变量(如 let me = loginInfo.id)。
  • @SimApiAuth["user"] 类型 Token 下,请求体里的 accountId 一律忽略,防止越权。

2. 控制器写法

/**
 * 账号与点券控制器(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.cjaddScoped / addSingleton 注册。
  • 参数校验放方法开头,用 errorWhen / errorWhenNone 断言式校验。

DTO

  • src/controllers/dtos/,按域分文件(AccountDto.cjAssetDto.cjAuthDto.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 / 表达式降级为普通语句,不再参与类型合并。
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(...): UnitonMessagemigrationsup/downseedsrun/upsert必须写 : Unit;省略会被推断成 Interface<Any> 之类导致与接口签名不匹配。

3.4 其它

  • error() / errorWhen() / errorWhenNone() 均返回 UnitSimApiError),可用于语句位置。
  • 同一 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 包大段逻辑:
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 宏纯声明 DbSetmain.cjaddScoped<DataContext> 注册,连接串走 ConnectionStrings:Pgsql(迁移 CLI 兜底 ORM_CONNECTION_STRING)。
  • 链式查询:
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. 验证码与缓存

  • 验证码存 RedisSimApiCache),key 模板 AuthCode:{CELL}:{ACTION}:{RESOURCE}SimApiCache 自动加 SimApi:Cache: 前缀),即 SimApi:Cache:AuthCode:<手机号>:<用途>:<资源id>
  • resource 约定:登录 / 注册为 "";资产操作 / 换绑用账号 id;扣费用金额字符串。
  • 验证逻辑(AuthCodeHelper.verifyCode):
    1. 账号存在且 actionPhoneChange / Register → 优先验证 TOTP 动态码,通过则 complete 为空操作;
    2. 否则走短信验证码,通过后返回"延迟删除"complete,业务落库成功才消费。
  • 短信验证码 6 位,TTL 120s;开发模式由 SmsHelper 打印到服务日志。

7. 项目结构

src/
├── main.cj            # SimApiExtensions 配置 + DI 注册 + 迁移自动执行 + WS 端口
├── controllers/       # 业务控制器 + dtos/
├── helpers/           # AuthCodeHelper / SmsHelper 等业务帮助类
├── migrations/        # YYYYMMDDHHMMSS_描述.cj + MigrationRegistryup/down 保留 : Unit
├── models/            # @DbContext DataContext + 实体(SimApiBaseModel / orm 实体)
├── seeds/             # @Seed 标注 + 顶层 let 注册(run/upsert 保留 : Unit
└── websocket/         # 每连接 handlerGameHandler <: IWebsocketHandler+ WsForwarder
  • main.cj 启动流程:连接串 → DI 注册(DataContext / SmsHelper / AuthCodeHelper / WsForwarder / WebSocketServer)→ SimApiExtensions.addSimApi(开关 + Redis + 请求日志)→ 迁移 CLI / 自动 migrate() → WS addHandleruseSimApirun
  • websocket:每连接一个 handler(握手工厂创建),业务方法 biz* 前缀、用 payload 参数 + 会话状态 _gameId 定位归属;每个请求独立 DataContext(长连接不共享,withCtx 内创建 scoped)。
  • 迁移 / seeds 注册通过"顶层 let + 仅 import 即触发加载执行"import gameplatform.migrations.* 等,见 main.cj)。

8. 注释与命名

  • 类注释 /** ... */:说明职责、路由表、权限与调用方(REST 还是 WS)。
  • 接口方法注释:单行 /// POST /path 行为说明;多行用 /** ... */
  • 控制器 / 模型 / 枚举:PascalCase;字段 camelCase;私有字段 _camelCase
  • 枚举成员大写开头(DeductionPhoneChange…),实现 ToString 提供成员名字符串(存库 / key 用)。
  • 常量 / 静态字段 camelCase(如 smsCodeTtlsmsCodeCacheKey)。