11 KiB
11 KiB
SimApi(Cangjie)编码规则
依据
game-platform-api(E:\games\platform\game-platform-api)实际项目实践沉淀,所有规则均在 Cangjie 1.1.3 + simapi-cj 下实测通过。与 README.md(框架能力说明)互补:README 讲"能用什么",本文件讲"该怎么写"。
1. 接口设计
1.1 动作接口不返回数据
- 写操作(增删改、发码、换绑、确认等)一律返回
Unit:方法不写: Unit返回类型,方法体不写()返回占位。 - 只有查询接口才声明返回类型并返回数据(
Account、ArrayList<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-code(isRegister区分)。 - 其余所有需验证码的场景统一走
POST /account/verify-code,kind指定用途,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-phone需oldCode+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.id(SimApiBaseController提供),不要重新声明局部变量(如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.cj用addScoped/addSingleton注册。 - 参数校验放方法开头,用
errorWhen/errorWhenNone断言式校验。
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。
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(...): Unit、onMessage、migrations 的 up/down、seeds 的 run/upsert 等必须写 : Unit;省略会被推断成 Interface<Any> 之类导致与接口签名不匹配。
3.4 其它
error()/errorWhen()/errorWhenNone()均返回Unit(SimApiError),可用于语句位置。- 同一
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宏纯声明DbSet;main.cj中addScoped<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. 验证码与缓存
- 验证码存 Redis(
SimApiCache),key 模板AuthCode:{CELL}:{ACTION}:{RESOURCE}(SimApiCache 自动加SimApi:Cache:前缀),即SimApi:Cache:AuthCode:<手机号>:<用途>:<资源id>。 resource约定:登录 / 注册为"";资产操作 / 换绑用账号 id;扣费用金额字符串。- 验证逻辑(
AuthCodeHelper.verifyCode):- 账号存在且
action非PhoneChange/Register→ 优先验证 TOTP 动态码,通过则 complete 为空操作; - 否则走短信验证码,通过后返回"延迟删除"complete,业务落库成功才消费。
- 账号存在且
- 短信验证码 6 位,TTL 120s;开发模式由
SmsHelper打印到服务日志。
7. 项目结构
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 注册(DataContext / SmsHelper / AuthCodeHelper / WsForwarder / WebSocketServer)→SimApiExtensions.addSimApi(开关 + Redis + 请求日志)→ 迁移 CLI / 自动migrate()→ WSaddHandler→useSimApi→run。- websocket:每连接一个 handler(握手工厂创建),业务方法
biz*前缀、用payload参数 + 会话状态_gameId定位归属;每个请求独立DataContext(长连接不共享,withCtx内创建 scoped)。 - 迁移 / seeds 注册通过"顶层 let + 仅 import 即触发加载执行"(
import gameplatform.migrations.*等,见 main.cj)。
8. 注释与命名
- 类注释
/** ... */:说明职责、路由表、权限与调用方(REST 还是 WS)。 - 接口方法注释:单行
/// POST /path 行为说明;多行用/** ... */。 - 控制器 / 模型 / 枚举:PascalCase;字段 camelCase;私有字段
_camelCase。 - 枚举成员大写开头(
Deduction、PhoneChange…),实现ToString提供成员名字符串(存库 / key 用)。 - 常量 / 静态字段 camelCase(如
smsCodeTtl、smsCodeCacheKey)。