seed.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. // RBAC 种子生成器(AC-14)——**从路由表生成,不手写清单**。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md AC-14 行("种子幂等 + 与路由表 1:1 对齐")/
  4. // `01-architecture/casbin-rbac-design.md` §6(casbin p 规则种子写法:`dom=*`、`keyMatch2` 路径、
  5. // `regexMatch` 方法、`INSERT IGNORE` 幂等)/ `02-data-model/core-model-ddl.sql`(三张表的既有种子格式)。
  6. //
  7. // ---------------------------------------------------------------------------
  8. // 为什么必须"从路由表生成"
  9. // ---------------------------------------------------------------------------
  10. //
  11. // 本任务只挂**第一批 40 个**端点,后面还有第二批(44~63)。手写种子清单在第二批时**一定会漏**,
  12. // 而症状极其隐蔽:接口从 **404** 变成 **403(1003)**——404 一眼看出"没挂",
  13. // 403 看起来像"权限配错",排查者会去查 RBAC 矩阵/角色/token,最后才发现是种子少了一行。
  14. //
  15. // 因此三张表全部由 `Route` 派生:
  16. //
  17. // casbin_rule ← Route.Roles × Route.Path × Route.Method(**1:1** 可反查)
  18. // t_permission ← 每个端点一个 button 权限点(code 由 method+path 推导)
  19. // t_role_permission← Route.Roles × 该端点的权限点 code
  20. //
  21. // ---------------------------------------------------------------------------
  22. // 幂等(重复执行不产生重复行)
  23. // ---------------------------------------------------------------------------
  24. //
  25. // 复用既有 DDL 的 `INSERT IGNORE` 写法(MySQL),依赖三张表已有的唯一键:
  26. //
  27. // casbin_rule UNIQUE uk_policy (ptype,v0,v1,v2,v3)
  28. // t_permission PRIMARY KEY (code)
  29. // t_role_permission UNIQUE uk_role_permission (role_code,permission_code)
  30. //
  31. // SQLite(单测库)没有 `INSERT IGNORE`,改写为 `INSERT OR IGNORE`(同语义、同依赖唯一键)。
  32. // 两种方言都**不做"先查后插"**:先查后插在并发下会插重(两次都查不到),
  33. // 而唯一键 + IGNORE 是数据库级的原子保证。
  34. //
  35. // ---------------------------------------------------------------------------
  36. // 与既有 `/auth`、`/org` 种子的关系(不另起一套)
  37. // ---------------------------------------------------------------------------
  38. //
  39. // 写法完全同构:`ptype=p` / `v0=角色码` / `v1=*`(不区分机构)/ `v2=path` / `v3=method 正则`,
  40. // 同一张 `casbin_rule`、同一套 model.conf,不新增表、不新增列、不改 model。
  41. //
  42. // **唯一的路径写法差异(有意,且更安全)**:既有种子把 `:id` 段写成 `*`
  43. // (如 `/org/institutions/*`),本生成器**原样保留 Fiber 的 `:param`** 写法
  44. // (如 `/admin/activities/:id`)。原因:
  45. // 1. `keyMatch2` 原生支持 `:param`(转成 `[^/]+`),语义与 Fiber 完全一致,无需翻译;
  46. // 2. `*` 在 keyMatch2 里是 `.*`(**跨 `/` 匹配**),`/admin/activities/*` 会连带命中
  47. // `/admin/activities/1/sign`(POST)等本不该给该角色的端点 —— 那是一条**提权**路径。
  48. // 保留 `:param` 后规则与路由表逐字符可对齐,`git diff` 就能审出"这条策略给了谁"。
  49. // 3. 生成器不做"路径翻译"这一步,就不会出现"翻译函数写错、某类路径全放行"的静默缺陷。
  50. package handler
  51. import (
  52. "context"
  53. "fmt"
  54. "sort"
  55. "strings"
  56. "gorm.io/gorm"
  57. )
  58. // CasbinRule `casbin_rule` 的一行(ptype + v0..v3,对齐 `infra/rbac` 的 `ruleRow`)。
  59. //
  60. // 字段语义(casbin model.conf `p = sub, dom, obj, act`):
  61. // - Ptype:策略类型(本生成器恒 `p`;`g` 段留空供将来人级授权);
  62. // - V0:`sub` = 角色码;
  63. // - V1:`dom` = 机构 ID;本批恒 `*`(不区分机构,与既有 /org 种子一致);
  64. // - V2:`obj` = 请求 path(keyMatch2,保留下 Fiber 的 `:param` 写法);
  65. // - V3:`act` = HTTP method 正则(同 path 多 method 合并为 `GET|POST`)。
  66. type CasbinRule struct {
  67. Ptype string
  68. V0 string
  69. V1 string
  70. V2 string
  71. V3 string
  72. }
  73. // PermissionSeed `t_permission` 的一行(权限点字典;本批只生成 `button` 型)。
  74. //
  75. // 为什么只生成 `button` 不生成 `menu`:菜单树是**UI 结构**(`parent_code`/`path`/`icon`/`sort`
  76. // 描述的是"页面怎么摆",与"后端有哪些接口"不是同一件事,路由表推不出层级与图标。
  77. // 硬造 menu 会把菜单树写坏(`/auth/issue` 的 menuTree 直接受影响)。菜单项由前端/产品排期单独提。
  78. type PermissionSeed struct {
  79. Code string
  80. Type string
  81. Name string
  82. ParentCode string
  83. Path string
  84. Icon string
  85. Sort int
  86. Status string
  87. }
  88. // RolePermissionSeed `t_role_permission` 的一行(角色 × 权限点矩阵)。
  89. type RolePermissionSeed struct {
  90. RoleCode string
  91. PermissionCode string
  92. }
  93. // RBACSeed 一次生成的三张表种子(顺序稳定,便于 diff 与单测快照)。
  94. type RBACSeed struct {
  95. // CasbinRules 接口强制策略(`casbin_rule`,第②层)。
  96. CasbinRules []CasbinRule
  97. // Permissions 权限点字典(`t_permission`,第①层展示用)。
  98. Permissions []PermissionSeed
  99. // RolePermissions 角色矩阵(`t_role_permission`)。
  100. RolePermissions []RolePermissionSeed
  101. }
  102. // SeedResult 种子落库结果(幂等判据:第二次执行时 `Inserted*` 全为 0)。
  103. type SeedResult struct {
  104. // CasbinRules 本次生成的策略行数(不是插入行数)。
  105. CasbinRules int
  106. // Permissions 本次生成的权限点行数。
  107. Permissions int
  108. // RolePermissions 本次生成的角色矩阵行数。
  109. RolePermissions int
  110. // InsertedCasbinRules 本次**实际插入**的策略行数(重复行被唯一键忽略,不计入)。
  111. InsertedCasbinRules int64
  112. // InsertedPermissions 本次实际插入的权限点行数。
  113. InsertedPermissions int64
  114. // InsertedRolePermissions 本次实际插入的角色矩阵行数。
  115. InsertedRolePermissions int64
  116. }
  117. // BuildRBACSeed 从路由表生成三张表种子。
  118. //
  119. // 入参:routes []Route(生产传 `Registration.Routes`,保证"挂什么就种什么")。
  120. // 出参:
  121. // - *RBACSeed:三张表的行(顺序稳定);
  122. // - error:权限点 code 冲突(两个端点推导出同一 code)。**冲突必须报错而不是去重**:
  123. // 去重会让某个端点的权限点在矩阵里"看起来配了、实际没有",是典型的静默失效。
  124. func BuildRBACSeed(routes []Route) (*RBACSeed, error) {
  125. seed := &RBACSeed{
  126. CasbinRules: make([]CasbinRule, 0, len(routes)*2),
  127. Permissions: make([]PermissionSeed, 0, len(routes)),
  128. RolePermissions: make([]RolePermissionSeed, 0, len(routes)*2),
  129. }
  130. codes := make(map[string]int, len(routes))
  131. rolePerms := make(map[string]struct{}, len(routes)*2)
  132. casbin := make(map[casbinKey]map[string]struct{}, len(routes))
  133. order := make([]casbinKey, 0, len(routes))
  134. for _, r := range routes {
  135. // ---- t_permission(每个端点一个 button 权限点)
  136. code := permissionCodeFor(r.Method, r.Path)
  137. if prev, ok := codes[code]; ok {
  138. return nil, fmt.Errorf(
  139. "academic seed: 端点 %d 与端点 %d 推导出同一权限点 code %q(端点路径 %s %s / %s)——"+
  140. "请检查 permissionCodeFor 的推导规则,不要用去重掩盖",
  141. prev, r.No, code, r.Method, r.Path, r.Name)
  142. }
  143. codes[code] = r.No
  144. seed.Permissions = append(seed.Permissions, PermissionSeed{
  145. Code: code, Type: "button", Name: r.Name,
  146. ParentCode: "", Path: "", Icon: "",
  147. Sort: r.No, Status: "active",
  148. })
  149. // ---- t_role_permission(角色 × 本端点权限点)
  150. for _, role := range r.Roles {
  151. key := role + "\x00" + code
  152. if _, dup := rolePerms[key]; dup {
  153. continue
  154. }
  155. rolePerms[key] = struct{}{}
  156. seed.RolePermissions = append(seed.RolePermissions, RolePermissionSeed{
  157. RoleCode: role, PermissionCode: code,
  158. })
  159. }
  160. // ---- casbin_rule(角色 × path,多 method 合并)
  161. for _, role := range r.Roles {
  162. key := casbinKey{role: role, path: r.Path}
  163. if _, ok := casbin[key]; !ok {
  164. casbin[key] = make(map[string]struct{}, 2)
  165. order = append(order, key)
  166. }
  167. casbin[key][r.Method] = struct{}{}
  168. }
  169. }
  170. // 按"首次出现顺序"输出(顺序稳定:注册顺序即契约端点顺序),method 按固定序合并。
  171. for _, key := range order {
  172. methods := make([]string, 0, len(casbin[key]))
  173. for m := range casbin[key] {
  174. methods = append(methods, m)
  175. }
  176. sort.Slice(methods, func(i, j int) bool { return methodRank(methods[i]) < methodRank(methods[j]) })
  177. seed.CasbinRules = append(seed.CasbinRules, CasbinRule{
  178. Ptype: "p", V0: key.role, V1: "*", V2: key.path, V3: strings.Join(methods, "|"),
  179. })
  180. }
  181. return seed, nil
  182. }
  183. // casbinKey casbin 规则的去重键(角色 + path;method 合并到 v3)。
  184. type casbinKey struct {
  185. role string
  186. path string
  187. }
  188. // methodRank method 的稳定排序序(让 `GET|POST` 这样的正则可复现,避免 map 遍历顺序抖动)。
  189. func methodRank(method string) int {
  190. switch method {
  191. case "GET":
  192. return 0
  193. case "POST":
  194. return 1
  195. case "PUT":
  196. return 2
  197. case "PATCH":
  198. return 3
  199. case "DELETE":
  200. return 4
  201. default:
  202. return 9
  203. }
  204. }
  205. // permissionCodeFor 从 `method + path` 推导三段式权限点码 `academic.<资源>.<动作>`。
  206. //
  207. // 推导规则(**纯函数、无表驱动**:第二批端点接进来时零改动):
  208. //
  209. // 资源 = 路径去掉一级段(`/admin`|`/student`)与参数段后,字面段用 `_` 连接;
  210. // 若"路径尾是 `/:param/<动词>`",该动词**不计入资源**(它是动作)。
  211. // 动作 = GET 且路径含参数段 → `get`;GET 无参数段 → `list`;
  212. // 非 GET 且尾是 `/:param/<动词>` → 该动词(连字符转下划线);
  213. // 其余 → POST `create` / PUT `update` / DELETE `delete`。
  214. //
  215. // 样例(可对照交付报告的 40 行注册表逐条核):
  216. //
  217. // GET /admin/finance/accounts/:userId → academic.finance_accounts.get
  218. // POST /admin/finance/refunds/custom → academic.finance_refunds_custom.create
  219. // POST /admin/finance/requests/:id/approve → academic.finance_requests.approve
  220. // GET /admin/activities/discounts/usable → academic.activities_discounts_usable.list
  221. // POST /admin/activities/:id/cancel-sign → academic.activities.cancel_sign
  222. // GET /student/bill → academic.bill.list
  223. // PUT /admin/divide-into/members/:userId/quota → academic.divide_into_members.quota
  224. //
  225. // ⚠ **本规则是本任务推导出来的,契约里没有 academic 的权限点码词表**(现有字典只有
  226. // `org.*` / `role.*`)。若产品/前端已有约定词表,只改本函数一处即可全量重算——
  227. // 这正是"从路由表生成"的价值:不存在"改了 40 行漏了 3 行"。
  228. func permissionCodeFor(method, path string) string {
  229. segs := pathSegments(path)
  230. // 去一级段(/admin 或 /student;将来 /pay 同理)。
  231. if len(segs) > 0 {
  232. segs = segs[1:]
  233. }
  234. if len(segs) == 0 {
  235. // 理论上不会出现(本批所有端点都至少两段),兜底成一个稳定的 code。
  236. return "academic.root." + strings.ToLower(method)
  237. }
  238. hasParam := false
  239. for _, seg := range segs {
  240. if isParamSegment(seg) {
  241. hasParam = true
  242. break
  243. }
  244. }
  245. // 尾形态 `/:param/<动词>`:最后两段为"参数 + 字面段"。
  246. tailVerb := ""
  247. if n := len(segs); n >= 2 && isParamSegment(segs[n-2]) && !isParamSegment(segs[n-1]) {
  248. tailVerb = normalizeSegment(segs[n-1])
  249. }
  250. literals := make([]string, 0, len(segs))
  251. for _, seg := range segs {
  252. if isParamSegment(seg) {
  253. continue
  254. }
  255. literals = append(literals, normalizeSegment(seg))
  256. }
  257. action := ""
  258. switch method {
  259. case "GET":
  260. if hasParam {
  261. action = "get"
  262. } else {
  263. action = "list"
  264. }
  265. default:
  266. if tailVerb != "" {
  267. action = tailVerb
  268. // 尾动词是动作,不进资源段(否则会出现 `..._approve.approve`)。
  269. literals = literals[:len(literals)-1]
  270. } else {
  271. switch method {
  272. case "POST":
  273. action = "create"
  274. case "PUT":
  275. action = "update"
  276. case "DELETE":
  277. action = "delete"
  278. default:
  279. action = strings.ToLower(method)
  280. }
  281. }
  282. }
  283. return "academic." + strings.Join(literals, "_") + "." + action
  284. }
  285. // pathSegments 切分路径为段(丢弃前导空段)。
  286. func pathSegments(path string) []string {
  287. trimmed := strings.Trim(path, "/")
  288. if trimmed == "" {
  289. return nil
  290. }
  291. return strings.Split(trimmed, "/")
  292. }
  293. // isParamSegment 判断是否参数段(Fiber 的 `:name` 与可选 `:name?` 均算)。
  294. func isParamSegment(seg string) bool { return strings.HasPrefix(seg, ":") }
  295. // normalizeSegment 归一化字面段:连字符转下划线(`refund-by-activity` → `refund_by_activity`)。
  296. func normalizeSegment(seg string) string { return strings.ReplaceAll(seg, "-", "_") }
  297. // SQL 渲染:与既有 `core-model-ddl.sql` 的种子写法逐字对齐(`INSERT IGNORE INTO ... VALUES`)。
  298. // CasbinRulesSQL 渲染 `casbin_rule` 的 INSERT 语句。
  299. //
  300. // 入参:dialect `mysql` / `sqlite`(决定 `INSERT IGNORE` 还是 `INSERT OR IGNORE`)。
  301. // 出参:单条多值 INSERT(无行时返回空串)。
  302. func (s *RBACSeed) CasbinRulesSQL(dialect string) (string, error) {
  303. if len(s.CasbinRules) == 0 {
  304. return "", nil
  305. }
  306. prefix, err := insertIgnorePrefix("casbin_rule", "`ptype`, `v0`, `v1`, `v2`, `v3`", dialect)
  307. if err != nil {
  308. return "", err
  309. }
  310. rows := make([]string, 0, len(s.CasbinRules))
  311. for _, r := range s.CasbinRules {
  312. rows = append(rows, fmt.Sprintf("(%s)", strings.Join([]string{
  313. sqlString(r.Ptype), sqlString(r.V0), sqlString(r.V1), sqlString(r.V2), sqlString(r.V3),
  314. }, ", ")))
  315. }
  316. return prefix + "\n " + strings.Join(rows, ",\n ") + ";", nil
  317. }
  318. // PermissionsSQL 渲染 `t_permission` 的 INSERT 语句。
  319. func (s *RBACSeed) PermissionsSQL(dialect string) (string, error) {
  320. if len(s.Permissions) == 0 {
  321. return "", nil
  322. }
  323. prefix, err := insertIgnorePrefix("t_permission",
  324. "`code`, `type`, `name`, `parent_code`, `path`, `icon`, `sort`, `status`", dialect)
  325. if err != nil {
  326. return "", err
  327. }
  328. rows := make([]string, 0, len(s.Permissions))
  329. for _, p := range s.Permissions {
  330. parent := "NULL"
  331. if p.ParentCode != "" {
  332. parent = sqlString(p.ParentCode)
  333. }
  334. path := "NULL"
  335. if p.Path != "" {
  336. path = sqlString(p.Path)
  337. }
  338. icon := "NULL"
  339. if p.Icon != "" {
  340. icon = sqlString(p.Icon)
  341. }
  342. rows = append(rows, fmt.Sprintf("(%s)",
  343. strings.Join([]string{
  344. sqlString(p.Code), sqlString(p.Type), sqlString(p.Name),
  345. parent, path, icon, fmt.Sprintf("%d", p.Sort), sqlString(p.Status),
  346. }, ", ")))
  347. }
  348. return prefix + "\n " + strings.Join(rows, ",\n ") + ";", nil
  349. }
  350. // RolePermissionsSQL 渲染 `t_role_permission` 的 INSERT 语句。
  351. func (s *RBACSeed) RolePermissionsSQL(dialect string) (string, error) {
  352. if len(s.RolePermissions) == 0 {
  353. return "", nil
  354. }
  355. prefix, err := insertIgnorePrefix("t_role_permission", "`role_code`, `permission_code`", dialect)
  356. if err != nil {
  357. return "", err
  358. }
  359. rows := make([]string, 0, len(s.RolePermissions))
  360. for _, rp := range s.RolePermissions {
  361. rows = append(rows, fmt.Sprintf("(%s, %s)", sqlString(rp.RoleCode), sqlString(rp.PermissionCode)))
  362. }
  363. return prefix + "\n " + strings.Join(rows, ",\n ") + ";", nil
  364. }
  365. // SQL 渲染三张表的完整种子脚本(可直接追加进 `core-model-ddl.sql`,逐行可 diff)。
  366. func (s *RBACSeed) SQL(dialect string) (string, error) {
  367. casbin, err := s.CasbinRulesSQL(dialect)
  368. if err != nil {
  369. return "", err
  370. }
  371. perms, err := s.PermissionsSQL(dialect)
  372. if err != nil {
  373. return "", err
  374. }
  375. roles, err := s.RolePermissionsSQL(dialect)
  376. if err != nil {
  377. return "", err
  378. }
  379. parts := []string{
  380. "-- academic 域 RBAC 种子(AC-14,**由路由表生成,勿手改**:改路由后重新生成)",
  381. "-- casbin_rule:第②层接口强制(角色 × path × method;dom=* 不区分机构;path 保留 Fiber 的 :param)",
  382. casbin,
  383. "-- t_permission:button 型权限点(菜单树是 UI 结构,不由路由表推导)",
  384. perms,
  385. "-- t_role_permission:角色 × 权限点矩阵",
  386. roles,
  387. }
  388. out := make([]string, 0, len(parts))
  389. for _, p := range parts {
  390. if strings.TrimSpace(p) == "" {
  391. continue
  392. }
  393. out = append(out, p)
  394. }
  395. return strings.Join(out, "\n") + "\n", nil
  396. }
  397. // insertIgnorePrefix 生成各方言的 `INSERT IGNORE` 前缀。
  398. func insertIgnorePrefix(table, columns, dialect string) (string, error) {
  399. switch dialect {
  400. case "mysql":
  401. return "INSERT IGNORE INTO `" + table + "` (" + columns + ") VALUES", nil
  402. case "sqlite":
  403. return "INSERT OR IGNORE INTO `" + table + "` (" + columns + ") VALUES", nil
  404. default:
  405. return "", fmt.Errorf("academic seed: 不支持的方言 %q(仅 mysql/sqlite)", dialect)
  406. }
  407. }
  408. // sqlString 把字符串渲染为 SQL 字面量(单引号转义;本生成器的输入来自路由表,仍做转义以防将来注错)。
  409. func sqlString(s string) string {
  410. return "'" + strings.ReplaceAll(s, "'", "''") + "'"
  411. }
  412. // SeedRBAC 生成并落库 RBAC 种子(**幂等**:重复执行由唯一键 + IGNORE 吸收)。
  413. //
  414. // 入参:
  415. // - ctx context.Context:执行上下文;
  416. // - db *gorm.DB:数据库句柄(方言决定 IGNORE 关键字;仅支持 mysql/sqlite);
  417. // - routes []Route:路由表(生产传 `Registration.Routes`——**挂什么就种什么**)。
  418. //
  419. // 出参:
  420. // - *SeedResult:生成行数 + 实际插入行数(`Inserted*` 全 0 即"已是最新",可直接用于幂等断言);
  421. // - error:权限点 code 冲突 / 方言不支持 / 执行失败。
  422. //
  423. // 装配位置:`cmd/api` 装配层在 `UNIFIED_AUTO_MIGRATE=1`(dev 建表守卫)时调用;
  424. // 生产按"DDL 先行"纪律用本函数渲染的 SQL(见 `SQL`)随迁移脚本执行,不在启动时写权限表。
  425. func SeedRBAC(ctx context.Context, db *gorm.DB, routes []Route) (*SeedResult, error) {
  426. if db == nil {
  427. return nil, fmt.Errorf("academic seed: DB 未注入")
  428. }
  429. seed, err := BuildRBACSeed(routes)
  430. if err != nil {
  431. return nil, err
  432. }
  433. dialect := db.Dialector.Name()
  434. res := &SeedResult{
  435. CasbinRules: len(seed.CasbinRules),
  436. Permissions: len(seed.Permissions),
  437. RolePermissions: len(seed.RolePermissions),
  438. }
  439. if sqlText, serr := seed.CasbinRulesSQL(dialect); serr != nil {
  440. return nil, serr
  441. } else if sqlText != "" {
  442. tx := db.WithContext(ctx).Exec(sqlText)
  443. if tx.Error != nil {
  444. return nil, fmt.Errorf("academic seed: 写 casbin_rule 失败: %w", tx.Error)
  445. }
  446. res.InsertedCasbinRules = tx.RowsAffected
  447. }
  448. if sqlText, serr := seed.PermissionsSQL(dialect); serr != nil {
  449. return nil, serr
  450. } else if sqlText != "" {
  451. tx := db.WithContext(ctx).Exec(sqlText)
  452. if tx.Error != nil {
  453. return nil, fmt.Errorf("academic seed: 写 t_permission 失败: %w", tx.Error)
  454. }
  455. res.InsertedPermissions = tx.RowsAffected
  456. }
  457. if sqlText, serr := seed.RolePermissionsSQL(dialect); serr != nil {
  458. return nil, serr
  459. } else if sqlText != "" {
  460. tx := db.WithContext(ctx).Exec(sqlText)
  461. if tx.Error != nil {
  462. return nil, fmt.Errorf("academic seed: 写 t_role_permission 失败: %w", tx.Error)
  463. }
  464. res.InsertedRolePermissions = tx.RowsAffected
  465. }
  466. return res, nil
  467. }